Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions packages/opencode/test/cli/is-tool-on-path.test.ts
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 ?? "")
Comment on lines +101 to +102
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Hardcoded : separator breaks Windows compatibility.

The implementation in skill-helpers.ts uses process.platform === "win32" ? ";" : ":" for the PATH separator. The test should mirror this to remain cross-platform.

🔧 Proposed fix
+    const sep = process.platform === "win32" ? ";" : ":"
     savedEnv.PATH = process.env.PATH
-    process.env.PATH = path.join(tmp.path, "path-bin") + ":" + (process.env.PATH ?? "")
+    process.env.PATH = path.join(tmp.path, "path-bin") + sep + (process.env.PATH ?? "")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
savedEnv.PATH = process.env.PATH
process.env.PATH = path.join(tmp.path, "path-bin") + ":" + (process.env.PATH ?? "")
const sep = process.platform === "win32" ? ";" : ":"
savedEnv.PATH = process.env.PATH
process.env.PATH = path.join(tmp.path, "path-bin") + sep + (process.env.PATH ?? "")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/opencode/test/cli/is-tool-on-path.test.ts` around lines 101 - 102,
The test hardcodes ":" when setting process.env.PATH which breaks Windows;
change the assignment to use Node's platform-aware delimiter (e.g.,
path.delimiter) or the same conditional used in skill-helpers.ts so that
savedEnv.PATH and the new process.env.PATH assignment use path.delimiter (or
process.platform === "win32" ? ";" : ":") when building path.join(tmp.path,
"path-bin") + delimiter + (process.env.PATH ?? "").


await Instance.provide({
directory: tmp.path,
fn: async () => {
const found = await isToolOnPath("my-path-tool", tmp.path)
expect(found).toBe(true)
},
})
})
})
81 changes: 81 additions & 0 deletions packages/opencode/test/tool/batch.test.ts
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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fail fast instead of conditionally skipping formatter assertions.

These branches silently skip message assertions when formatValidationError is missing, so tests can pass without validating the formatter contract.

🔧 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
Verify each finding against the current code and only fix it if needed.

In `@packages/opencode/test/tool/batch.test.ts` around lines 17 - 23, The test
currently conditionally skips formatter assertions when
toolInfo.formatValidationError is missing; instead, assert that the formatter
exists before using it so the test fails fast. Update the test around the result
handling (the block using result, result.success, and
toolInfo.formatValidationError) to assert toolInfo.formatValidationError is
defined (or throw) when result.success is false, then call
toolInfo.formatValidationError(result.error) and perform the existing
expect(...).toContain checks for "Invalid parameters for tool 'batch'", "Provide
at least one tool call", and "Expected payload format".

})

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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

DISALLOWED guard test never exercises the guard path.

The test currently validates tool metadata only (id, shape) and would still pass if the recursive-call restriction were removed. Please either rename this test to match what it checks, or add an assertion that execute rejects a recursive batch call with the expected message.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/opencode/test/tool/batch.test.ts` around lines 56 - 80, The test
currently only checks metadata but must also assert the DISALLOWED guard; update
the test that references BatchTool to call the execute function and verify it
rejects when given a call with call.tool === "batch" and that the thrown/error
message includes the phrase "not allowed in batch"; if execute requires
Session.updatePart, supply a minimal stub/mock session object or otherwise stub
that dependency so execute can run and throw the DISALLOWED error—target
symbols: BatchTool, BatchTool.init, and the execute function exported from
"../../src/tool/batch".

})
Loading