-
Notifications
You must be signed in to change notification settings - Fork 19
test: tools — isToolOnPath discovery and BatchTool validation #412
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,112 @@ | ||
| import { afterEach, describe, test, expect } from "bun:test" | ||
| import { $ } from "bun" | ||
| import fs from "fs/promises" | ||
| import path from "path" | ||
| import { tmpdir } from "../fixture/fixture" | ||
| import { isToolOnPath } from "../../src/cli/cmd/skill-helpers" | ||
| import { Instance } from "../../src/project/instance" | ||
|
|
||
| /** Create a tmpdir with git initialized (signing disabled for CI). */ | ||
| async function tmpdirGit(init?: (dir: string) => Promise<void>) { | ||
| return tmpdir({ | ||
| init: async (dir) => { | ||
| await $`git init`.cwd(dir).quiet() | ||
| await $`git config core.fsmonitor false`.cwd(dir).quiet() | ||
| await $`git config commit.gpgsign false`.cwd(dir).quiet() | ||
| await $`git config user.email "test@opencode.test"`.cwd(dir).quiet() | ||
| await $`git config user.name "Test"`.cwd(dir).quiet() | ||
| await $`git commit --allow-empty -m "root"`.cwd(dir).quiet() | ||
| await init?.(dir) | ||
| }, | ||
| }) | ||
| } | ||
|
|
||
| describe("isToolOnPath", () => { | ||
| const savedEnv: Record<string, string | undefined> = {} | ||
|
|
||
| afterEach(() => { | ||
| for (const [key, val] of Object.entries(savedEnv)) { | ||
| if (val === undefined) delete process.env[key] | ||
| else process.env[key] = val | ||
| } | ||
| Object.keys(savedEnv).forEach((k) => delete savedEnv[k]) | ||
| }) | ||
|
|
||
| test("returns true when tool exists in .opencode/tools/ under cwd", async () => { | ||
| await using tmp = await tmpdirGit(async (dir) => { | ||
| const toolsDir = path.join(dir, ".opencode", "tools") | ||
| await fs.mkdir(toolsDir, { recursive: true }) | ||
| const toolPath = path.join(toolsDir, "my-test-tool") | ||
| await fs.writeFile(toolPath, "#!/bin/sh\necho ok\n") | ||
| await fs.chmod(toolPath, 0o755) | ||
| }) | ||
|
|
||
| await Instance.provide({ | ||
| directory: tmp.path, | ||
| fn: async () => { | ||
| const found = await isToolOnPath("my-test-tool", tmp.path) | ||
| expect(found).toBe(true) | ||
| }, | ||
| }) | ||
| }) | ||
|
|
||
| test("returns false when tool does not exist anywhere", async () => { | ||
| savedEnv.ALTIMATE_BIN_DIR = process.env.ALTIMATE_BIN_DIR | ||
| delete process.env.ALTIMATE_BIN_DIR | ||
|
|
||
| await using tmp = await tmpdirGit() | ||
|
|
||
| await Instance.provide({ | ||
| directory: tmp.path, | ||
| fn: async () => { | ||
| const found = await isToolOnPath("altimate-nonexistent-tool-xyz-99999", tmp.path) | ||
| expect(found).toBe(false) | ||
| }, | ||
| }) | ||
| }) | ||
|
|
||
| test("returns true when tool is found via ALTIMATE_BIN_DIR", async () => { | ||
| await using tmp = await tmpdirGit(async (dir) => { | ||
| const binDir = path.join(dir, "custom-bin") | ||
| await fs.mkdir(binDir, { recursive: true }) | ||
| const toolPath = path.join(binDir, "my-bin-tool") | ||
| await fs.writeFile(toolPath, "#!/bin/sh\necho ok\n") | ||
| await fs.chmod(toolPath, 0o755) | ||
| }) | ||
|
|
||
| savedEnv.ALTIMATE_BIN_DIR = process.env.ALTIMATE_BIN_DIR | ||
| process.env.ALTIMATE_BIN_DIR = path.join(tmp.path, "custom-bin") | ||
|
|
||
| await Instance.provide({ | ||
| directory: tmp.path, | ||
| fn: async () => { | ||
| const found = await isToolOnPath("my-bin-tool", tmp.path) | ||
| expect(found).toBe(true) | ||
| }, | ||
| }) | ||
| }) | ||
|
|
||
| test("returns true when tool is on PATH via prepended directory", async () => { | ||
| await using tmp = await tmpdirGit(async (dir) => { | ||
| const pathDir = path.join(dir, "path-bin") | ||
| await fs.mkdir(pathDir, { recursive: true }) | ||
| const toolPath = path.join(pathDir, "my-path-tool") | ||
| await fs.writeFile(toolPath, "#!/bin/sh\necho ok\n") | ||
| await fs.chmod(toolPath, 0o755) | ||
| }) | ||
|
|
||
| savedEnv.ALTIMATE_BIN_DIR = process.env.ALTIMATE_BIN_DIR | ||
| delete process.env.ALTIMATE_BIN_DIR | ||
|
|
||
| savedEnv.PATH = process.env.PATH | ||
| process.env.PATH = path.join(tmp.path, "path-bin") + ":" + (process.env.PATH ?? "") | ||
|
|
||
| await Instance.provide({ | ||
| directory: tmp.path, | ||
| fn: async () => { | ||
| const found = await isToolOnPath("my-path-tool", tmp.path) | ||
| expect(found).toBe(true) | ||
| }, | ||
| }) | ||
| }) | ||
| }) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| import { describe, test, expect } from "bun:test" | ||
| import z from "zod" | ||
| import { BatchTool } from "../../src/tool/batch" | ||
|
|
||
| // The batch tool uses Tool.define with an async init function. | ||
| // We call init() to get the tool info which includes formatValidationError. | ||
| // The validation schema and formatValidationError are pure — no mocking needed. | ||
|
|
||
| describe("BatchTool: formatValidationError", () => { | ||
| test("produces user-friendly output for empty tool_calls array", async () => { | ||
| const toolInfo = await BatchTool.init() | ||
|
|
||
| // Parse an empty array — should fail the .min(1) constraint | ||
| const result = toolInfo.parameters.safeParse({ tool_calls: [] }) | ||
| expect(result.success).toBe(false) | ||
|
|
||
| if (!result.success && toolInfo.formatValidationError) { | ||
| const msg = toolInfo.formatValidationError(result.error) | ||
| expect(msg).toContain("Invalid parameters for tool 'batch'") | ||
| expect(msg).toContain("Provide at least one tool call") | ||
| // Should include the expected payload format hint | ||
| expect(msg).toContain("Expected payload format") | ||
| } | ||
|
Comment on lines
+17
to
+23
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fail fast instead of conditionally skipping formatter assertions. These branches silently skip message assertions when 🔧 Suggested tightening- if (!result.success && toolInfo.formatValidationError) {
- const msg = toolInfo.formatValidationError(result.error)
+ const formatValidationError = toolInfo.formatValidationError
+ expect(typeof formatValidationError).toBe("function")
+ if (!formatValidationError) {
+ throw new Error("BatchTool.formatValidationError must be defined")
+ }
+ if (!result.success) {
+ const msg = formatValidationError(result.error)
expect(msg).toContain("Invalid parameters for tool 'batch'")
expect(msg).toContain("Provide at least one tool call")
// Should include the expected payload format hint
expect(msg).toContain("Expected payload format")
}Also applies to: 35-40, 49-52 🤖 Prompt for AI Agents |
||
| }) | ||
|
|
||
| test("includes field path for nested validation errors", async () => { | ||
| const toolInfo = await BatchTool.init() | ||
|
|
||
| // Pass a tool_calls entry with an invalid `tool` field (number instead of string) | ||
| const result = toolInfo.parameters.safeParse({ | ||
| tool_calls: [{ tool: 123, parameters: {} }], | ||
| }) | ||
| expect(result.success).toBe(false) | ||
|
|
||
| if (!result.success && toolInfo.formatValidationError) { | ||
| const msg = toolInfo.formatValidationError(result.error) | ||
| // The path should reference the nested field, not just "root" | ||
| expect(msg).toContain("tool_calls.0.tool") | ||
| expect(msg).toContain("Invalid parameters for tool 'batch'") | ||
| } | ||
| }) | ||
|
|
||
| test("validation rejects missing tool_calls field entirely", async () => { | ||
| const toolInfo = await BatchTool.init() | ||
|
|
||
| const result = toolInfo.parameters.safeParse({}) | ||
| expect(result.success).toBe(false) | ||
|
|
||
| if (!result.success && toolInfo.formatValidationError) { | ||
| const msg = toolInfo.formatValidationError(result.error) | ||
| expect(msg).toContain("Invalid parameters for tool 'batch'") | ||
| } | ||
| }) | ||
| }) | ||
|
|
||
| describe("BatchTool: DISALLOWED guard", () => { | ||
| test("batch tool cannot be called recursively — error message is clear", async () => { | ||
| // The DISALLOWED set and the error message are defined in the execute path. | ||
| // We can verify by importing and inspecting the tool's behavior: | ||
| // The execute function checks `if (DISALLOWED.has(call.tool))` and throws | ||
| // with a message mentioning "not allowed in batch". | ||
| // | ||
| // Since execute requires Session.updatePart (heavy dependency), we verify | ||
| // the public contract by importing the source and checking the constants directly | ||
| // plus verifying the error string template. | ||
|
|
||
| // Import the DISALLOWED set via the module | ||
| const batchModule = await import("../../src/tool/batch") | ||
|
|
||
| // The module exports BatchTool but DISALLOWED is module-scoped. | ||
| // We can at minimum verify the tool registers with id "batch" | ||
| expect(batchModule.BatchTool.id).toBe("batch") | ||
|
|
||
| // Verify that the init function returns a valid tool shape | ||
| const toolInfo = await batchModule.BatchTool.init() | ||
| expect(toolInfo.description).toBeTruthy() | ||
| expect(toolInfo.parameters).toBeDefined() | ||
| expect(typeof toolInfo.execute).toBe("function") | ||
| expect(typeof toolInfo.formatValidationError).toBe("function") | ||
| }) | ||
|
Comment on lines
+56
to
+80
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The test currently validates tool metadata only ( 🤖 Prompt for AI Agents |
||
| }) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hardcoded
:separator breaks Windows compatibility.The implementation in
skill-helpers.tsusesprocess.platform === "win32" ? ";" : ":"for the PATH separator. The test should mirror this to remain cross-platform.🔧 Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents