feat(aircoding): AirCoding V2 baseline — deterministic multi-agent architecture

Forked from OpenCode v1.17.4 with multi-agent system:
- 5 agents: aircoding, scheduler, worker, architect, reviewer
- Deterministic DAG scheduling engine (coordinator_tick)
- Tool whitelists as hard enforcement
- AirCoding validation plugin
- System prompt injection for routing
- V1 requirements: C4 docs, ADR, AGENTS.md, debug-log.md
- Design documents in docs/
This commit is contained in:
airlongdian
2026-06-13 21:41:54 +08:00
commit af3016fe27
5757 changed files with 1170017 additions and 0 deletions

View File

@@ -0,0 +1,484 @@
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
exports[`tool parameters JSON Schema (wire shape) apply_patch 1`] = `
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"patchText": {
"description": "The full patch text that describes all changes to be made",
"type": "string",
},
},
"required": [
"patchText",
],
"type": "object",
}
`;
exports[`tool parameters JSON Schema (wire shape) bash 1`] = `
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"command": {
"description": "The command to execute",
"type": "string",
},
"description": {
"description":
"Clear, concise description of what this command does in 5-10 words. Examples:
Input: ls
Output: Lists files in current directory
Input: git status
Output: Shows working tree status
Input: npm install
Output: Installs package dependencies
Input: mkdir foo
Output: Creates directory 'foo'"
,
"type": "string",
},
"timeout": {
"description": "Optional timeout in milliseconds",
"exclusiveMinimum": 0,
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer",
},
"workdir": {
"description": "The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.",
"type": "string",
},
},
"required": [
"command",
"description",
],
"type": "object",
}
`;
exports[`tool parameters JSON Schema (wire shape) edit 1`] = `
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"filePath": {
"description": "The absolute path to the file to modify",
"type": "string",
},
"newString": {
"description": "The text to replace it with (must be different from oldString)",
"type": "string",
},
"oldString": {
"description": "The text to replace",
"type": "string",
},
"replaceAll": {
"description": "Replace all occurrences of oldString (default false)",
"type": "boolean",
},
},
"required": [
"filePath",
"oldString",
"newString",
],
"type": "object",
}
`;
exports[`tool parameters JSON Schema (wire shape) glob 1`] = `
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"path": {
"description": "The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.",
"type": "string",
},
"pattern": {
"description": "The glob pattern to match files against",
"type": "string",
},
},
"required": [
"pattern",
],
"type": "object",
}
`;
exports[`tool parameters JSON Schema (wire shape) grep 1`] = `
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"include": {
"description": "File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")",
"type": "string",
},
"path": {
"description": "The directory to search in. Defaults to the current working directory.",
"type": "string",
},
"pattern": {
"description": "The regex pattern to search for in file contents",
"type": "string",
},
},
"required": [
"pattern",
],
"type": "object",
}
`;
exports[`tool parameters JSON Schema (wire shape) invalid 1`] = `
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"error": {
"type": "string",
},
"tool": {
"type": "string",
},
},
"required": [
"tool",
"error",
],
"type": "object",
}
`;
exports[`tool parameters JSON Schema (wire shape) lsp 1`] = `
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"character": {
"description": "The character offset (1-based, as shown in editors)",
"maximum": 9007199254740991,
"minimum": 1,
"type": "integer",
},
"filePath": {
"description": "The absolute or relative path to the file",
"type": "string",
},
"line": {
"description": "The line number (1-based, as shown in editors)",
"maximum": 9007199254740991,
"minimum": 1,
"type": "integer",
},
"operation": {
"description": "The LSP operation to perform",
"enum": [
"goToDefinition",
"findReferences",
"hover",
"documentSymbol",
"workspaceSymbol",
"goToImplementation",
"prepareCallHierarchy",
"incomingCalls",
"outgoingCalls",
],
"type": "string",
},
"query": {
"description": "Search query for workspaceSymbol. Empty string requests all symbols.",
"type": "string",
},
},
"required": [
"operation",
"filePath",
"line",
"character",
],
"type": "object",
}
`;
exports[`tool parameters JSON Schema (wire shape) plan 1`] = `
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {},
"type": "object",
}
`;
exports[`tool parameters JSON Schema (wire shape) question 1`] = `
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"questions": {
"description": "Questions to ask",
"items": {
"properties": {
"header": {
"description": "Very short label (max 30 chars)",
"type": "string",
},
"multiple": {
"description": "Allow selecting multiple choices",
"type": "boolean",
},
"options": {
"description": "Available choices",
"items": {
"properties": {
"description": {
"description": "Explanation of choice",
"type": "string",
},
"label": {
"description": "Display text (1-5 words, concise)",
"type": "string",
},
},
"required": [
"label",
"description",
],
"type": "object",
},
"type": "array",
},
"question": {
"description": "Complete question",
"type": "string",
},
},
"required": [
"question",
"header",
"options",
],
"type": "object",
},
"type": "array",
},
},
"required": [
"questions",
],
"type": "object",
}
`;
exports[`tool parameters JSON Schema (wire shape) read 1`] = `
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"filePath": {
"description": "The absolute path to the file or directory to read",
"type": "string",
},
"limit": {
"description": "The maximum number of lines to read (defaults to 2000)",
"maximum": 9007199254740991,
"minimum": 0,
"type": "integer",
},
"offset": {
"description": "The line number to start reading from (1-indexed)",
"maximum": 9007199254740991,
"minimum": 0,
"type": "integer",
},
},
"required": [
"filePath",
],
"type": "object",
}
`;
exports[`tool parameters JSON Schema (wire shape) skill 1`] = `
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"name": {
"description": "The name of the skill from available_skills",
"type": "string",
},
},
"required": [
"name",
],
"type": "object",
}
`;
exports[`tool parameters JSON Schema (wire shape) task 1`] = `
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"background": {
"description": "Run the agent in the background. You will be notified when it completes. DO NOT sleep, poll, or proactively check on its progress",
"type": "boolean",
},
"command": {
"description": "The command that triggered this task",
"type": "string",
},
"description": {
"description": "A short (3-5 words) description of the task",
"type": "string",
},
"prompt": {
"description": "The task for the agent to perform",
"type": "string",
},
"subagent_type": {
"description": "The type of specialized agent to use for this task",
"type": "string",
},
"task_id": {
"description": "This should only be set if you mean to resume a previous task (you can pass a prior task_id and the task will continue the same subagent session as before instead of creating a fresh one)",
"type": "string",
},
},
"required": [
"description",
"prompt",
"subagent_type",
],
"type": "object",
}
`;
exports[`tool parameters JSON Schema (wire shape) todo 1`] = `
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"todos": {
"description": "The updated todo list",
"items": {
"properties": {
"content": {
"description": "Brief description of the task",
"type": "string",
},
"priority": {
"description": "Priority level of the task: high, medium, low",
"type": "string",
},
"status": {
"description": "Current status of the task: pending, in_progress, completed, cancelled",
"type": "string",
},
},
"required": [
"content",
"status",
"priority",
],
"type": "object",
},
"type": "array",
},
},
"required": [
"todos",
],
"type": "object",
}
`;
exports[`tool parameters JSON Schema (wire shape) webfetch 1`] = `
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"format": {
"default": "markdown",
"description": "The format to return the content in (text, markdown, or html). Defaults to markdown.",
"enum": [
"text",
"markdown",
"html",
],
"type": "string",
},
"timeout": {
"description": "Optional timeout in seconds (max 120)",
"type": "number",
},
"url": {
"description": "The URL to fetch content from",
"type": "string",
},
},
"required": [
"url",
],
"type": "object",
}
`;
exports[`tool parameters JSON Schema (wire shape) websearch 1`] = `
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"contextMaxCharacters": {
"description": "Maximum characters for context string optimized for LLMs (default: 10000)",
"type": "number",
},
"livecrawl": {
"description": "Live crawl mode - 'fallback': use live crawling as backup if cached content unavailable, 'preferred': prioritize live crawling (default: 'fallback')",
"enum": [
"fallback",
"preferred",
],
"type": "string",
},
"numResults": {
"description": "Number of search results to return (default: 8)",
"type": "number",
},
"query": {
"description": "Websearch query",
"type": "string",
},
"type": {
"description": "Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search",
"enum": [
"auto",
"fast",
"deep",
],
"type": "string",
},
},
"required": [
"query",
],
"type": "object",
}
`;
exports[`tool parameters JSON Schema (wire shape) write 1`] = `
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"content": {
"description": "The content to write to the file",
"type": "string",
},
"filePath": {
"description": "The absolute path to the file to write (must be absolute, not relative)",
"type": "string",
},
},
"required": [
"content",
"filePath",
],
"type": "object",
}
`;

View File

@@ -0,0 +1,9 @@
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
exports[`tool.ls basic 1`] = `
"packages/opencode/test/fixtures/example/
broken.ts
cli.ts
ink.tsx
"
`;

View File

@@ -0,0 +1,533 @@
import { describe, expect } from "bun:test"
import path from "path"
import * as fs from "fs/promises"
import { Cause, Effect, Exit, Layer } from "effect"
import { ApplyPatchTool } from "../../src/tool/apply_patch"
import { LSP } from "@/lsp/lsp"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Format } from "../../src/format"
import { Agent } from "../../src/agent/agent"
import { EventV2Bridge } from "../../src/event-v2-bridge"
import { Truncate } from "@/tool/truncate"
import { TestInstance } from "../fixture/fixture"
import { SessionID, MessageID } from "../../src/session/schema"
import { testEffect } from "../lib/effect"
const it = testEffect(
Layer.mergeAll(
LSP.defaultLayer,
FSUtil.defaultLayer,
Format.defaultLayer,
EventV2Bridge.defaultLayer,
Truncate.defaultLayer,
Agent.defaultLayer,
),
)
const baseCtx = {
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make("msg_test"),
callID: "",
agent: "build",
abort: AbortSignal.any([]),
messages: [],
metadata: () => Effect.void,
}
type AskInput = {
permission: string
patterns: string[]
always: string[]
metadata: {
diff: string
filepath: string
files: Array<{
filePath: string
relativePath: string
type: "add" | "update" | "delete" | "move"
patch: string
additions: number
deletions: number
movePath?: string
}>
}
}
type ToolCtx = typeof baseCtx & {
ask: (input: AskInput) => Effect.Effect<void>
}
const execute = Effect.fn("ApplyPatchToolTest.execute")(function* (params: { patchText: string }, ctx: ToolCtx) {
const info = yield* ApplyPatchTool
const tool = yield* info.init()
return yield* tool.execute(params, ctx)
})
const makeCtx = () => {
const calls: AskInput[] = []
const ctx: ToolCtx = {
...baseCtx,
ask: (input) =>
Effect.sync(() => {
calls.push(input)
}),
}
return { ctx, calls }
}
const readText = (filepath: string) => Effect.promise(() => fs.readFile(filepath, "utf-8"))
const writeText = (filepath: string, content: string) => Effect.promise(() => fs.writeFile(filepath, content, "utf-8"))
const makeDir = (dir: string) => Effect.promise(() => fs.mkdir(dir, { recursive: true }))
const expectFailure = <A, E, R>(effect: Effect.Effect<A, E, R>, message?: string) =>
Effect.gen(function* () {
const exit = yield* Effect.exit(effect)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit) && message) expect(Cause.pretty(exit.cause)).toContain(message)
})
const expectReadFailure = (filepath: string) => expectFailure(readText(filepath))
describe("tool.apply_patch freeform", () => {
it.live("requires patchText", () =>
Effect.gen(function* () {
const { ctx } = makeCtx()
yield* expectFailure(execute({ patchText: "" }, ctx), "patchText is required")
}),
)
it.live("rejects invalid patch format", () =>
Effect.gen(function* () {
const { ctx } = makeCtx()
yield* expectFailure(execute({ patchText: "invalid patch" }, ctx), "apply_patch verification failed")
}),
)
it.live("rejects empty patch", () =>
Effect.gen(function* () {
const { ctx } = makeCtx()
yield* expectFailure(execute({ patchText: "*** Begin Patch\n*** End Patch" }, ctx), "patch rejected: empty patch")
}),
)
it.instance(
"applies add/update/delete in one patch",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const { ctx, calls } = makeCtx()
const modifyPath = path.join(test.directory, "modify.txt")
const deletePath = path.join(test.directory, "delete.txt")
yield* writeText(modifyPath, "line1\nline2\n")
yield* writeText(deletePath, "obsolete\n")
const patchText =
"*** Begin Patch\n*** Add File: nested/new.txt\n+created\n*** Delete File: delete.txt\n*** Update File: modify.txt\n@@\n-line2\n+changed\n*** End Patch"
const result = yield* execute({ patchText }, ctx)
expect(result.title).toContain("Success. Updated the following files")
expect(result.output).toContain("Success. Updated the following files")
// Strict formatting assertions for slashes
expect(result.output).toMatch(/A nested\/new\.txt/)
expect(result.output).toMatch(/D delete\.txt/)
expect(result.output).toMatch(/M modify\.txt/)
if (process.platform === "win32") {
expect(result.output).not.toContain("\\")
}
expect(result.metadata.diff).toContain("Index:")
expect(calls.length).toBe(1)
// Verify permission metadata includes files array for UI rendering
const permissionCall = calls[0]
expect(permissionCall.metadata.files).toHaveLength(3)
expect(permissionCall.metadata.files.map((f) => f.type).sort()).toEqual(["add", "delete", "update"])
const addFile = permissionCall.metadata.files.find((f) => f.type === "add")
expect(addFile?.relativePath).toBe("nested/new.txt")
expect(addFile?.patch).toContain("+created")
const updateFile = permissionCall.metadata.files.find((f) => f.type === "update")
expect(updateFile?.patch).toContain("-line2")
expect(updateFile?.patch).toContain("+changed")
expect(yield* readText(path.join(test.directory, "nested", "new.txt"))).toBe("created\n")
expect(yield* readText(modifyPath)).toBe("line1\nchanged\n")
yield* expectReadFailure(deletePath)
}),
{ git: true },
)
it.instance(
"permission metadata includes move file info",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const { ctx, calls } = makeCtx()
const original = path.join(test.directory, "old", "name.txt")
yield* makeDir(path.dirname(original))
yield* writeText(original, "old content\n")
const patchText =
"*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-old content\n+new content\n*** End Patch"
yield* execute({ patchText }, ctx)
expect(calls.length).toBe(1)
const permissionCall = calls[0]
expect(permissionCall.metadata.files).toHaveLength(1)
const moveFile = permissionCall.metadata.files[0]
expect(moveFile.type).toBe("move")
expect(moveFile.relativePath).toBe("renamed/dir/name.txt")
expect(moveFile.movePath).toBe(path.join(test.directory, "renamed/dir/name.txt"))
expect(moveFile.patch).toContain("-old content")
expect(moveFile.patch).toContain("+new content")
}),
{ git: true },
)
it.instance("applies multiple hunks to one file", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const { ctx } = makeCtx()
const target = path.join(test.directory, "multi.txt")
yield* writeText(target, "line1\nline2\nline3\nline4\n")
const patchText =
"*** Begin Patch\n*** Update File: multi.txt\n@@\n-line2\n+changed2\n@@\n-line4\n+changed4\n*** End Patch"
yield* execute({ patchText }, ctx)
expect(yield* readText(target)).toBe("line1\nchanged2\nline3\nchanged4\n")
}),
)
it.instance("does not invent a first-line diff for BOM files", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const { ctx, calls } = makeCtx()
const bom = String.fromCharCode(0xfeff)
const target = path.join(test.directory, "example.cs")
yield* writeText(target, `${bom}using System;\n\nclass Test {}\n`)
const patchText =
"*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch"
yield* execute({ patchText }, ctx)
expect(calls.length).toBe(1)
const shown = calls[0].metadata.files[0]?.patch ?? ""
expect(shown).not.toContain(bom)
expect(shown).not.toContain("-using System;")
expect(shown).not.toContain("+using System;")
const content = yield* readText(target)
expect(content.charCodeAt(0)).toBe(0xfeff)
expect(content.slice(1)).toBe("using System;\n\nclass Test {}\nclass Next {}\n")
}),
)
it.instance("inserts lines with insert-only hunk", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const { ctx } = makeCtx()
const target = path.join(test.directory, "insert_only.txt")
yield* writeText(target, "alpha\nomega\n")
const patchText = "*** Begin Patch\n*** Update File: insert_only.txt\n@@\n alpha\n+beta\n omega\n*** End Patch"
yield* execute({ patchText }, ctx)
expect(yield* readText(target)).toBe("alpha\nbeta\nomega\n")
}),
)
it.instance("appends trailing newline on update", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const { ctx } = makeCtx()
const target = path.join(test.directory, "no_newline.txt")
yield* writeText(target, "no newline at end")
const patchText =
"*** Begin Patch\n*** Update File: no_newline.txt\n@@\n-no newline at end\n+first line\n+second line\n*** End Patch"
yield* execute({ patchText }, ctx)
const contents = yield* readText(target)
expect(contents.endsWith("\n")).toBe(true)
expect(contents).toBe("first line\nsecond line\n")
}),
)
it.instance("moves file to a new directory", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const { ctx } = makeCtx()
const original = path.join(test.directory, "old", "name.txt")
yield* makeDir(path.dirname(original))
yield* writeText(original, "old content\n")
const patchText =
"*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-old content\n+new content\n*** End Patch"
yield* execute({ patchText }, ctx)
const moved = path.join(test.directory, "renamed", "dir", "name.txt")
yield* expectReadFailure(original)
expect(yield* readText(moved)).toBe("new content\n")
}),
)
it.instance("moves file overwriting existing destination", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const { ctx } = makeCtx()
const original = path.join(test.directory, "old", "name.txt")
const destination = path.join(test.directory, "renamed", "dir", "name.txt")
yield* makeDir(path.dirname(original))
yield* makeDir(path.dirname(destination))
yield* writeText(original, "from\n")
yield* writeText(destination, "existing\n")
const patchText =
"*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-from\n+new\n*** End Patch"
yield* execute({ patchText }, ctx)
yield* expectReadFailure(original)
expect(yield* readText(destination)).toBe("new\n")
}),
)
it.instance("adds file overwriting existing file", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const { ctx } = makeCtx()
const target = path.join(test.directory, "duplicate.txt")
yield* writeText(target, "old content\n")
const patchText = "*** Begin Patch\n*** Add File: duplicate.txt\n+new content\n*** End Patch"
yield* execute({ patchText }, ctx)
expect(yield* readText(target)).toBe("new content\n")
}),
)
it.instance("rejects update when target file is missing", () =>
Effect.gen(function* () {
const { ctx } = makeCtx()
const patchText = "*** Begin Patch\n*** Update File: missing.txt\n@@\n-nope\n+better\n*** End Patch"
yield* expectFailure(
execute({ patchText }, ctx),
"apply_patch verification failed: Failed to read file to update",
)
}),
)
it.instance("rejects delete when file is missing", () =>
Effect.gen(function* () {
const { ctx } = makeCtx()
const patchText = "*** Begin Patch\n*** Delete File: missing.txt\n*** End Patch"
yield* expectFailure(execute({ patchText }, ctx))
}),
)
it.instance("rejects delete when target is a directory", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const { ctx } = makeCtx()
const dirPath = path.join(test.directory, "dir")
yield* makeDir(dirPath)
const patchText = "*** Begin Patch\n*** Delete File: dir\n*** End Patch"
yield* expectFailure(execute({ patchText }, ctx))
}),
)
it.instance("rejects invalid hunk header", () =>
Effect.gen(function* () {
const { ctx } = makeCtx()
const patchText = "*** Begin Patch\n*** Frobnicate File: foo\n*** End Patch"
yield* expectFailure(execute({ patchText }, ctx), "apply_patch verification failed")
}),
)
it.instance("rejects update with missing context", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const { ctx } = makeCtx()
const target = path.join(test.directory, "modify.txt")
yield* writeText(target, "line1\nline2\n")
const patchText = "*** Begin Patch\n*** Update File: modify.txt\n@@\n-missing\n+changed\n*** End Patch"
yield* expectFailure(execute({ patchText }, ctx), "apply_patch verification failed")
expect(yield* readText(target)).toBe("line1\nline2\n")
}),
)
it.instance("verification failure leaves no side effects", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const { ctx } = makeCtx()
const patchText =
"*** Begin Patch\n*** Add File: created.txt\n+hello\n*** Update File: missing.txt\n@@\n-old\n+new\n*** End Patch"
yield* expectFailure(execute({ patchText }, ctx))
yield* expectReadFailure(path.join(test.directory, "created.txt"))
}),
)
it.instance("supports end of file anchor", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const { ctx } = makeCtx()
const target = path.join(test.directory, "tail.txt")
yield* writeText(target, "alpha\nlast\n")
const patchText = "*** Begin Patch\n*** Update File: tail.txt\n@@\n-last\n+end\n*** End of File\n*** End Patch"
yield* execute({ patchText }, ctx)
expect(yield* readText(target)).toBe("alpha\nend\n")
}),
)
it.instance("rejects missing second chunk context", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const { ctx } = makeCtx()
const target = path.join(test.directory, "two_chunks.txt")
yield* writeText(target, "a\nb\nc\nd\n")
const patchText = "*** Begin Patch\n*** Update File: two_chunks.txt\n@@\n-b\n+B\n\n-d\n+D\n*** End Patch"
yield* expectFailure(execute({ patchText }, ctx))
expect(yield* readText(target)).toBe("a\nb\nc\nd\n")
}),
)
it.instance("disambiguates change context with @@ header", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const { ctx } = makeCtx()
const target = path.join(test.directory, "multi_ctx.txt")
yield* writeText(target, "fn a\nx=10\ny=2\nfn b\nx=10\ny=20\n")
const patchText = "*** Begin Patch\n*** Update File: multi_ctx.txt\n@@ fn b\n-x=10\n+x=11\n*** End Patch"
yield* execute({ patchText }, ctx)
expect(yield* readText(target)).toBe("fn a\nx=10\ny=2\nfn b\nx=11\ny=20\n")
}),
)
it.instance("EOF anchor matches from end of file first", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const { ctx } = makeCtx()
const target = path.join(test.directory, "eof_anchor.txt")
// File has duplicate "marker" lines - one in middle, one at end
yield* writeText(target, "start\nmarker\nmiddle\nmarker\nend\n")
// With EOF anchor, should match the LAST "marker" line, not the first
const patchText =
"*** Begin Patch\n*** Update File: eof_anchor.txt\n@@\n-marker\n-end\n+marker-changed\n+end\n*** End of File\n*** End Patch"
yield* execute({ patchText }, ctx)
// First marker unchanged, second marker changed
expect(yield* readText(target)).toBe("start\nmarker\nmiddle\nmarker-changed\nend\n")
}),
)
it.instance("parses heredoc-wrapped patch", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const { ctx } = makeCtx()
const patchText = `cat <<'EOF'
*** Begin Patch
*** Add File: heredoc_test.txt
+heredoc content
*** End Patch
EOF`
yield* execute({ patchText }, ctx)
expect(yield* readText(path.join(test.directory, "heredoc_test.txt"))).toBe("heredoc content\n")
}),
)
it.instance("parses heredoc-wrapped patch without cat", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const { ctx } = makeCtx()
const patchText = `<<EOF
*** Begin Patch
*** Add File: heredoc_no_cat.txt
+no cat prefix
*** End Patch
EOF`
yield* execute({ patchText }, ctx)
expect(yield* readText(path.join(test.directory, "heredoc_no_cat.txt"))).toBe("no cat prefix\n")
}),
)
it.instance("matches with trailing whitespace differences", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const { ctx } = makeCtx()
const target = path.join(test.directory, "trailing_ws.txt")
// File has trailing spaces on some lines
yield* writeText(target, "line1 \nline2\nline3 \n")
// Patch doesn't have trailing spaces - should still match via rstrip pass
const patchText = "*** Begin Patch\n*** Update File: trailing_ws.txt\n@@\n-line2\n+changed\n*** End Patch"
yield* execute({ patchText }, ctx)
expect(yield* readText(target)).toBe("line1 \nchanged\nline3 \n")
}),
)
it.instance("matches with leading whitespace differences", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const { ctx } = makeCtx()
const target = path.join(test.directory, "leading_ws.txt")
// File has leading spaces
yield* writeText(target, " line1\nline2\n line3\n")
// Patch without leading spaces - should match via trim pass
const patchText = "*** Begin Patch\n*** Update File: leading_ws.txt\n@@\n-line2\n+changed\n*** End Patch"
yield* execute({ patchText }, ctx)
expect(yield* readText(target)).toBe(" line1\nchanged\n line3\n")
}),
)
it.instance("matches with Unicode punctuation differences", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const { ctx } = makeCtx()
const target = path.join(test.directory, "unicode.txt")
// File has fancy Unicode quotes (U+201C, U+201D) and em-dash (U+2014)
const leftQuote = "\u201C"
const rightQuote = "\u201D"
const emDash = "\u2014"
yield* writeText(target, `He said ${leftQuote}hello${rightQuote}\nsome${emDash}dash\nend\n`)
// Patch uses ASCII equivalents - should match via normalized pass
// The replacement uses ASCII quotes from the patch (not preserving Unicode)
const patchText =
'*** Begin Patch\n*** Update File: unicode.txt\n@@\n-He said "hello"\n+He said "hi"\n*** End Patch'
yield* execute({ patchText }, ctx)
// Result has ASCII quotes because that's what the patch specifies
expect(yield* readText(target)).toBe(`He said "hi"\nsome${emDash}dash\nend\n`)
}),
)
})

View File

@@ -0,0 +1,578 @@
import { afterEach, describe, expect } from "bun:test"
import path from "path"
import fs from "fs/promises"
import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"
import { EditTool } from "../../src/tool/edit"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { LSP } from "@/lsp/lsp"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Format } from "../../src/format"
import { Agent } from "../../src/agent/agent"
import { EventV2Bridge } from "../../src/event-v2-bridge"
import { Truncate } from "@/tool/truncate"
import { SessionID, MessageID } from "../../src/session/schema"
import * as Tool from "../../src/tool/tool"
import { testEffect } from "../lib/effect"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
const ctx = {
sessionID: SessionID.make("ses_test-edit-session"),
messageID: MessageID.make("msg_test"),
callID: "",
agent: "build",
abort: AbortSignal.any([]),
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
}
afterEach(async () => {
await disposeAllInstances()
})
const layer = Layer.mergeAll(
LSP.defaultLayer,
FSUtil.defaultLayer,
Format.defaultLayer,
EventV2Bridge.defaultLayer,
Truncate.defaultLayer,
Agent.defaultLayer,
)
const it = testEffect(layer)
const init = Effect.fn("EditToolTest.init")(function* () {
const info = yield* EditTool
return yield* info.init()
})
const run = Effect.fn("EditToolTest.run")(function* (
args: Tool.InferParameters<typeof EditTool>,
next: Tool.Context = ctx,
) {
const tool = yield* init()
return yield* tool.execute(args, next)
})
const fail = Effect.fn("EditToolTest.fail")(function* (args: Tool.InferParameters<typeof EditTool>) {
const exit = yield* run(args).pipe(Effect.exit)
if (Exit.isFailure(exit)) {
const err = Cause.squash(exit.cause)
return err instanceof Error ? err : new Error(String(err))
}
throw new Error("expected edit to fail")
})
const put = Effect.fn("EditToolTest.put")(function* (p: string, content: string) {
const fs = yield* FSUtil.Service
yield* fs.writeWithDirs(p, content)
})
const load = Effect.fn("EditToolTest.load")(function* (p: string) {
const fs = yield* FSUtil.Service
return yield* fs.readFileString(p)
})
const loadRaw = Effect.fn("EditToolTest.loadRaw")(function* (p: string) {
return yield* Effect.promise(() => fs.readFile(p, "utf-8"))
})
const makeDirectory = Effect.fn("EditToolTest.makeDirectory")(function* (p: string) {
const fs = yield* FSUtil.Service
yield* fs.makeDirectory(p)
})
const onceBus = Effect.fn("EditToolTest.onceBus")(function* (def: typeof Watcher.Event.Updated) {
const events = yield* EventV2Bridge.Service
const deferred = yield* Deferred.make<void>()
const unsub = yield* events.listen((event) => {
if (event.type === def.type) Deferred.doneUnsafe(deferred, Effect.void)
return Effect.void
})
yield* Effect.addFinalizer(() => unsub)
return deferred
})
describe("tool.edit", () => {
describe("creating new files", () => {
it.instance("creates new file when oldString is empty", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "newfile.txt")
const result = yield* run({ filePath: filepath, oldString: "", newString: "new content" })
expect(result.metadata.diff).toContain("new content")
expect(yield* load(filepath)).toBe("new content")
}),
)
it.instance("rejects empty oldString on existing files and leaves content unchanged", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "existing.cs")
const bom = String.fromCharCode(0xfeff)
const original = `${bom}using System;\n`
yield* put(filepath, original)
expect((yield* fail({ filePath: filepath, oldString: "", newString: "using Up;\n" })).message).toContain(
"oldString cannot be empty",
)
const content = yield* loadRaw(filepath)
expect(content).toBe(original)
}),
)
it.instance("creates new file with nested directories", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "nested", "dir", "file.txt")
yield* run({ filePath: filepath, oldString: "", newString: "nested file" })
expect(yield* load(filepath)).toBe("nested file")
}),
)
it.instance("emits add event for new files", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const updated = yield* onceBus(Watcher.Event.Updated)
yield* run({ filePath: path.join(test.directory, "new.txt"), oldString: "", newString: "content" })
yield* Deferred.await(updated)
}),
)
})
describe("editing existing files", () => {
it.instance("replaces text in existing file", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "existing.txt")
yield* put(filepath, "old content here")
const result = yield* run({ filePath: filepath, oldString: "old content", newString: "new content" })
expect(result.output).toContain("Edit applied successfully")
expect(yield* load(filepath)).toBe("new content here")
}),
)
it.instance("replaces the first visible line in BOM files", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "existing.cs")
const bom = String.fromCharCode(0xfeff)
yield* put(filepath, `${bom}using System;\nclass Test {}\n`)
const result = yield* run({ filePath: filepath, oldString: "using System;", newString: "using Up;" })
expect(result.metadata.diff).toContain("-using System;")
expect(result.metadata.diff).toContain("+using Up;")
expect(result.metadata.diff).not.toContain(bom)
const content = yield* loadRaw(filepath)
expect(content.charCodeAt(0)).toBe(0xfeff)
expect(content.slice(1)).toBe("using Up;\nclass Test {}\n")
}),
)
it.instance("throws error when file does not exist", () =>
Effect.gen(function* () {
const test = yield* TestInstance
expect(
(yield* fail({ filePath: path.join(test.directory, "nonexistent.txt"), oldString: "old", newString: "new" }))
.message,
).toContain("not found")
}),
)
it.instance("throws error when oldString equals newString", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "file.txt")
yield* put(filepath, "content")
expect((yield* fail({ filePath: filepath, oldString: "same", newString: "same" })).message).toContain(
"identical",
)
}),
)
it.instance("throws error when oldString not found in file", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "file.txt")
yield* put(filepath, "actual content")
expect(yield* fail({ filePath: filepath, oldString: "not in file", newString: "replacement" })).toBeInstanceOf(
Error,
)
}),
)
it.instance("rejects loose block-anchor matches and leaves content unchanged", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "file.ts")
const original = [
"function configure() {",
" keepImportantState()",
" removeAllUserData()",
" archiveBackups()",
" auditLog()",
"}",
].join("\n")
yield* put(filepath, original)
expect(
(yield* fail({
filePath: filepath,
oldString: ["function configure() {", " const enabled = true", "}"].join("\n"),
newString: ["function configure() {", " const enabled = false", "}"].join("\n"),
})).message,
).toContain("Could not find oldString")
expect(yield* load(filepath)).toBe(original)
}),
)
it.instance("rejects block-anchor matches with unrelated middle content", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "file.ts")
const original = ["function configure() {", " removeAllUserData()", "}"].join("\n")
yield* put(filepath, original)
expect(
(yield* fail({
filePath: filepath,
oldString: ["function configure() {", " const enabled = true", "}"].join("\n"),
newString: ["function configure() {", " const enabled = false", "}"].join("\n"),
})).message,
).toContain("Could not find oldString")
expect(yield* load(filepath)).toBe(original)
}),
)
it.instance("replaces all occurrences with replaceAll option", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "file.txt")
yield* put(filepath, "foo bar foo baz foo")
yield* run({ filePath: filepath, oldString: "foo", newString: "qux", replaceAll: true })
expect(yield* load(filepath)).toBe("qux bar qux baz qux")
}),
)
it.instance("emits change event for existing files", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "file.txt")
yield* put(filepath, "original")
const updated = yield* onceBus(Watcher.Event.Updated)
yield* run({ filePath: filepath, oldString: "original", newString: "modified" })
yield* Deferred.await(updated)
}),
)
})
describe("edge cases", () => {
it.instance("handles multiline replacements", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "file.txt")
yield* put(filepath, "line1\nline2\nline3")
yield* run({ filePath: filepath, oldString: "line2", newString: "new line 2\nextra line" })
expect(yield* load(filepath)).toBe("line1\nnew line 2\nextra line\nline3")
}),
)
it.instance("handles CRLF line endings", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "file.txt")
yield* put(filepath, "line1\r\nold\r\nline3")
yield* run({ filePath: filepath, oldString: "old", newString: "new" })
expect(yield* load(filepath)).toBe("line1\r\nnew\r\nline3")
}),
)
it.instance("throws error when oldString equals newString", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "file.txt")
yield* put(filepath, "content")
expect((yield* fail({ filePath: filepath, oldString: "", newString: "" })).message).toContain("identical")
}),
)
it.instance("throws error when path is directory", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const dirpath = path.join(test.directory, "adir")
yield* makeDirectory(dirpath)
expect((yield* fail({ filePath: dirpath, oldString: "old", newString: "new" })).message).toContain("directory")
}),
)
it.instance("tracks file diff statistics", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "file.txt")
yield* put(filepath, "line1\nline2\nline3")
const result = yield* run({ filePath: filepath, oldString: "line2", newString: "new line a\nnew line b" })
expect(result.metadata.filediff).toBeDefined()
expect(result.metadata.filediff.file).toBe(filepath)
expect(result.metadata.filediff.additions).toBeGreaterThan(0)
}),
)
})
describe("line endings", () => {
const old = "alpha\nbeta\ngamma"
const next = "alpha\nbeta-updated\ngamma"
const alt = "alpha\nbeta\nomega"
const normalize = (text: string, ending: "\n" | "\r\n") => {
const normalized = text.replaceAll("\r\n", "\n")
if (ending === "\n") return normalized
return normalized.replaceAll("\n", "\r\n")
}
const count = (content: string) => {
const crlf = content.match(/\r\n/g)?.length ?? 0
const lf = content.match(/\n/g)?.length ?? 0
return {
crlf,
lf: lf - crlf,
}
}
const expectLf = (content: string) => {
const counts = count(content)
expect(counts.crlf).toBe(0)
expect(counts.lf).toBeGreaterThan(0)
}
const expectCrlf = (content: string) => {
const counts = count(content)
expect(counts.lf).toBe(0)
expect(counts.crlf).toBeGreaterThan(0)
}
type Input = {
content: string
oldString: string
newString: string
replaceAll?: boolean
}
const apply = Effect.fn("EditToolTest.lineEndings.apply")(function* (input: Input) {
const test = yield* TestInstance
const filePath = path.join(test.directory, "test.txt")
yield* put(filePath, input.content)
yield* run({
filePath,
oldString: input.oldString,
newString: input.newString,
replaceAll: input.replaceAll,
})
return yield* load(filePath)
})
it.instance("preserves LF with LF multi-line strings", () =>
Effect.gen(function* () {
const content = normalize(old + "\n", "\n")
const output = yield* apply({
content,
oldString: normalize(old, "\n"),
newString: normalize(next, "\n"),
})
expect(output).toBe(normalize(next + "\n", "\n"))
expectLf(output)
}),
)
it.instance("preserves CRLF with CRLF multi-line strings", () =>
Effect.gen(function* () {
const content = normalize(old + "\n", "\r\n")
const output = yield* apply({
content,
oldString: normalize(old, "\r\n"),
newString: normalize(next, "\r\n"),
})
expect(output).toBe(normalize(next + "\n", "\r\n"))
expectCrlf(output)
}),
)
it.instance("preserves LF when old/new use CRLF", () =>
Effect.gen(function* () {
const content = normalize(old + "\n", "\n")
const output = yield* apply({
content,
oldString: normalize(old, "\r\n"),
newString: normalize(next, "\r\n"),
})
expect(output).toBe(normalize(next + "\n", "\n"))
expectLf(output)
}),
)
it.instance("preserves CRLF when old/new use LF", () =>
Effect.gen(function* () {
const content = normalize(old + "\n", "\r\n")
const output = yield* apply({
content,
oldString: normalize(old, "\n"),
newString: normalize(next, "\n"),
})
expect(output).toBe(normalize(next + "\n", "\r\n"))
expectCrlf(output)
}),
)
it.instance("preserves LF when newString uses CRLF", () =>
Effect.gen(function* () {
const content = normalize(old + "\n", "\n")
const output = yield* apply({
content,
oldString: normalize(old, "\n"),
newString: normalize(next, "\r\n"),
})
expect(output).toBe(normalize(next + "\n", "\n"))
expectLf(output)
}),
)
it.instance("preserves CRLF when newString uses LF", () =>
Effect.gen(function* () {
const content = normalize(old + "\n", "\r\n")
const output = yield* apply({
content,
oldString: normalize(old, "\r\n"),
newString: normalize(next, "\n"),
})
expect(output).toBe(normalize(next + "\n", "\r\n"))
expectCrlf(output)
}),
)
it.instance("preserves LF with mixed old/new line endings", () =>
Effect.gen(function* () {
const content = normalize(old + "\n", "\n")
const output = yield* apply({
content,
oldString: "alpha\nbeta\r\ngamma",
newString: "alpha\r\nbeta\nomega",
})
expect(output).toBe(normalize(alt + "\n", "\n"))
expectLf(output)
}),
)
it.instance("preserves CRLF with mixed old/new line endings", () =>
Effect.gen(function* () {
const content = normalize(old + "\n", "\r\n")
const output = yield* apply({
content,
oldString: "alpha\r\nbeta\ngamma",
newString: "alpha\nbeta\r\nomega",
})
expect(output).toBe(normalize(alt + "\n", "\r\n"))
expectCrlf(output)
}),
)
it.instance("replaceAll preserves LF for multi-line blocks", () =>
Effect.gen(function* () {
const blockOld = "alpha\nbeta"
const blockNew = "alpha\nbeta-updated"
const content = normalize(blockOld + "\n" + blockOld + "\n", "\n")
const output = yield* apply({
content,
oldString: normalize(blockOld, "\n"),
newString: normalize(blockNew, "\n"),
replaceAll: true,
})
expect(output).toBe(normalize(blockNew + "\n" + blockNew + "\n", "\n"))
expectLf(output)
}),
)
it.instance("replaceAll preserves CRLF for multi-line blocks", () =>
Effect.gen(function* () {
const blockOld = "alpha\nbeta"
const blockNew = "alpha\nbeta-updated"
const content = normalize(blockOld + "\n" + blockOld + "\n", "\r\n")
const output = yield* apply({
content,
oldString: normalize(blockOld, "\r\n"),
newString: normalize(blockNew, "\r\n"),
replaceAll: true,
})
expect(output).toBe(normalize(blockNew + "\n" + blockNew + "\n", "\r\n"))
expectCrlf(output)
}),
)
})
describe("concurrent editing", () => {
it.instance("preserves concurrent edits to different sections of the same file", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "file.txt")
yield* put(filepath, "top = 0\nmiddle = keep\nbottom = 0\n")
const firstAsk = yield* Deferred.make<void>()
let asks = 0
const delayedCtx = {
...ctx,
ask: () =>
Effect.gen(function* () {
asks++
if (asks !== 1) return
yield* Deferred.succeed(firstAsk, undefined)
yield* Effect.sleep("50 millis")
}),
}
const first = yield* run(
{
filePath: filepath,
oldString: "top = 0",
newString: "top = 1",
},
delayedCtx,
).pipe(Effect.forkScoped)
yield* Deferred.await(firstAsk)
yield* Effect.all([
Fiber.join(first),
run(
{
filePath: filepath,
oldString: "bottom = 0",
newString: "bottom = 2",
},
delayedCtx,
),
])
expect(yield* load(filepath)).toBe("top = 1\nmiddle = keep\nbottom = 2\n")
}),
)
})
})

View File

@@ -0,0 +1,155 @@
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
import { describe, expect } from "bun:test"
import path from "path"
import { Effect } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import type { Tool } from "@/tool/tool"
import { assertExternalDirectoryEffect } from "../../src/tool/external-directory"
import { Filesystem } from "@/util/filesystem"
import { TestInstance, tmpdirScoped } from "../fixture/fixture"
import type { Permission } from "../../src/permission"
import { SessionID, MessageID } from "../../src/session/schema"
import { testEffect } from "../lib/effect"
const it = testEffect(CrossSpawnSpawner.defaultLayer)
const baseCtx: Omit<Tool.Context, "ask"> = {
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make("msg_test"),
callID: "",
agent: "build",
abort: AbortSignal.any([]),
messages: [],
metadata: () => Effect.void,
}
const glob = (p: string) =>
process.platform === "win32" ? Filesystem.normalizePathPattern(p) : p.replaceAll("\\", "/")
function makeCtx() {
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
const ctx: Tool.Context = {
...baseCtx,
ask: (req) =>
Effect.sync(() => {
requests.push(req)
}),
}
return { requests, ctx }
}
describe("tool.assertExternalDirectory", () => {
it.live("no-ops for empty target", () =>
Effect.gen(function* () {
const { requests, ctx } = makeCtx()
yield* assertExternalDirectoryEffect(ctx)
expect(requests.length).toBe(0)
}),
)
it.instance("no-ops for paths inside the instance directory", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const { requests, ctx } = makeCtx()
yield* assertExternalDirectoryEffect(ctx, path.join(test.directory, "file.txt"))
expect(requests.length).toBe(0)
}),
)
it.instance("asks with a single canonical glob", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const { requests, ctx } = makeCtx()
const target = path.join(path.dirname(test.directory), "outside", "file.txt")
const expected = glob(path.join(path.dirname(target), "*"))
yield* assertExternalDirectoryEffect(ctx, target)
const req = requests.find((r) => r.permission === "external_directory")
expect(req).toBeDefined()
expect(req!.patterns).toEqual([expected])
expect(req!.always).toEqual([expected])
}),
)
it.instance("uses target directory when kind=directory", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const { requests, ctx } = makeCtx()
const target = path.join(path.dirname(test.directory), "outside")
const expected = glob(path.join(target, "*"))
yield* assertExternalDirectoryEffect(ctx, target, { kind: "directory" })
const req = requests.find((r) => r.permission === "external_directory")
expect(req).toBeDefined()
expect(req!.patterns).toEqual([expected])
expect(req!.always).toEqual([expected])
}),
)
it.live("skips prompting when bypass=true", () =>
Effect.gen(function* () {
const { requests, ctx } = makeCtx()
yield* assertExternalDirectoryEffect(ctx, "/tmp/outside/file.txt", { bypass: true })
expect(requests.length).toBe(0)
}),
)
if (process.platform === "win32") {
it.instance(
"normalizes Windows path variants to one glob",
() =>
Effect.gen(function* () {
const { requests, ctx } = makeCtx()
const outerTmp = yield* tmpdirScoped()
yield* Effect.promise(() => Bun.write(path.join(outerTmp, "outside.txt"), "x"))
const target = path.join(outerTmp, "outside.txt")
const alt = target
.replace(/^[A-Za-z]:/, "")
.replaceAll("\\", "/")
.toLowerCase()
yield* assertExternalDirectoryEffect(ctx, alt)
const req = requests.find((r) => r.permission === "external_directory")
const expected = glob(path.join(outerTmp, "*"))
expect(req).toBeDefined()
expect(req!.patterns).toEqual([expected])
expect(req!.always).toEqual([expected])
}),
{ git: true },
)
it.instance(
"uses drive root glob for root files",
() =>
Effect.gen(function* () {
const { requests, ctx } = makeCtx()
const tmp = yield* TestInstance
const root = path.parse(tmp.directory).root
const target = path.join(root, "boot.ini")
yield* assertExternalDirectoryEffect(ctx, target)
const req = requests.find((r) => r.permission === "external_directory")
const expected = path.join(root, "*")
expect(req).toBeDefined()
expect(req!.patterns).toEqual([expected])
expect(req!.always).toEqual([expected])
}),
{ git: true },
)
}
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,136 @@
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
import { describe, expect } from "bun:test"
import path from "path"
import { Cause, Effect, Exit, Layer } from "effect"
import { GlobTool } from "../../src/tool/glob"
import { SessionID, MessageID } from "../../src/session/schema"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Global } from "@opencode-ai/core/global"
import { Truncate } from "@/tool/truncate"
import { Agent } from "../../src/agent/agent"
import { TestInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { Config } from "@/config/config"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { Git } from "@/git"
import { Filesystem } from "@/util/filesystem"
import { Permission } from "../../src/permission"
import type * as Tool from "../../src/tool/tool"
const toolLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
Layer.mergeAll(
CrossSpawnSpawner.defaultLayer,
FSUtil.defaultLayer,
Ripgrep.defaultLayer,
Truncate.defaultLayer,
Agent.defaultLayer,
Git.defaultLayer,
)
const it = testEffect(toolLayer())
const full = (p: string) => (process.platform === "win32" ? Filesystem.normalizePath(p) : p)
const ctx = {
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make("msg_test"),
callID: "",
agent: "build",
abort: AbortSignal.any([]),
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
}
const asks = () => {
const items: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
return {
items,
next: {
...ctx,
ask: (req: Omit<PermissionV1.Request, "id" | "sessionID" | "tool">) =>
Effect.sync(() => {
items.push(req)
}),
} satisfies Tool.Context,
}
}
const githubBase = <A, E, R>(url: string, self: Effect.Effect<A, E, R>) =>
Effect.acquireUseRelease(
Effect.sync(() => {
const previous = process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL
process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = url
return previous
}),
() => self,
(previous) =>
Effect.sync(() => {
if (previous) process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = previous
else delete process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL
}),
)
const git = Effect.fn("GlobToolTest.git")(function* (cwd: string, args: string[]) {
return yield* Effect.promise(async () => {
const proc = Bun.spawn(["git", ...args], {
cwd,
stdout: "pipe",
stderr: "pipe",
})
const [stdout, stderr, code] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
])
if (code !== 0) throw new Error(stderr.trim() || stdout.trim() || `git ${args.join(" ")} failed`)
return stdout.trim()
})
})
describe("tool.glob", () => {
it.instance("matches files from a directory path", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() => Bun.write(path.join(test.directory, "a.ts"), "export const a = 1\n"))
yield* Effect.promise(() => Bun.write(path.join(test.directory, "b.txt"), "hello\n"))
const info = yield* GlobTool
const glob = yield* info.init()
const result = yield* glob.execute(
{
pattern: "*.ts",
path: test.directory,
},
ctx,
)
expect(result.metadata.count).toBe(1)
expect(result.output).toContain(path.join(test.directory, "a.ts"))
expect(result.output).not.toContain(path.join(test.directory, "b.txt"))
}),
)
it.instance("rejects exact file paths", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const file = path.join(test.directory, "a.ts")
yield* Effect.promise(() => Bun.write(file, "export const a = 1\n"))
const info = yield* GlobTool
const glob = yield* info.init()
const exit = yield* glob
.execute(
{
pattern: "*.ts",
path: file,
},
ctx,
)
.pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
const err = Cause.squash(exit.cause)
expect(err instanceof Error ? err.message : String(err)).toContain("glob path must be a directory")
}
}),
)
})

View File

@@ -0,0 +1,225 @@
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import os from "os"
import path from "path"
import { Effect, Layer } from "effect"
import { GrepTool } from "../../src/tool/grep"
import { provideInstance, testInstanceStoreLayer, TestInstance, tmpdirScoped } from "../fixture/fixture"
import { SessionID, MessageID } from "../../src/session/schema"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Global } from "@opencode-ai/core/global"
import { Truncate } from "@/tool/truncate"
import { Agent } from "../../src/agent/agent"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { testEffect } from "../lib/effect"
import { Permission } from "../../src/permission"
import type * as Tool from "../../src/tool/tool"
import { Config } from "@/config/config"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { Git } from "@/git"
import { Filesystem } from "@/util/filesystem"
const toolLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
Layer.mergeAll(
CrossSpawnSpawner.defaultLayer,
FSUtil.defaultLayer,
Ripgrep.defaultLayer,
Truncate.defaultLayer,
Agent.defaultLayer,
Git.defaultLayer,
)
const it = testEffect(toolLayer())
const rooted = testEffect(Layer.mergeAll(toolLayer(), testInstanceStoreLayer))
const ctx = {
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make("msg_test"),
callID: "",
agent: "build",
abort: AbortSignal.any([]),
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
}
const root = path.join(__dirname, "../..")
const full = (p: string) => (process.platform === "win32" ? Filesystem.normalizePath(p) : p)
const githubBase = <A, E, R>(url: string, self: Effect.Effect<A, E, R>) =>
Effect.acquireUseRelease(
Effect.sync(() => {
const previous = process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL
process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = url
return previous
}),
() => self,
(previous) =>
Effect.sync(() => {
if (previous) process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = previous
else delete process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL
}),
)
const git = Effect.fn("GrepToolTest.git")(function* (cwd: string, args: string[]) {
return yield* Effect.promise(async () => {
const proc = Bun.spawn(["git", ...args], {
cwd,
stdout: "pipe",
stderr: "pipe",
})
const [stdout, stderr, code] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
])
if (code !== 0) throw new Error(stderr.trim() || stdout.trim() || `git ${args.join(" ")} failed`)
return stdout.trim()
})
})
describe("tool.grep", () => {
rooted.live("basic search", () =>
Effect.gen(function* () {
const info = yield* GrepTool
const grep = yield* info.init()
const result = yield* provideInstance(root)(
grep.execute(
{
pattern: "export",
path: path.join(root, "src/tool"),
include: "*.ts",
},
ctx,
),
)
expect(result.metadata.matches).toBeGreaterThan(0)
expect(result.output).toContain("Found")
}),
)
it.instance("no matches returns correct output", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() => Bun.write(path.join(test.directory, "test.txt"), "hello world"))
const info = yield* GrepTool
const grep = yield* info.init()
const result = yield* grep.execute(
{
pattern: "xyznonexistentpatternxyz123",
path: test.directory,
},
ctx,
)
expect(result.metadata.matches).toBe(0)
expect(result.output).toBe("No files found")
}),
)
it.instance("finds matches in tmp instance", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() => Bun.write(path.join(test.directory, "test.txt"), "line1\nline2\nline3"))
const info = yield* GrepTool
const grep = yield* info.init()
const result = yield* grep.execute(
{
pattern: "line",
path: test.directory,
},
ctx,
)
expect(result.metadata.matches).toBeGreaterThan(0)
}),
)
it.instance("does not report an unknown total when results are truncated", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() =>
Promise.all(
Array.from({ length: 101 }, (_, index) =>
Bun.write(path.join(test.directory, `match-${index}.txt`), "needle"),
),
),
)
const info = yield* GrepTool
const grep = yield* info.init()
const result = yield* grep.execute({ pattern: "needle", path: test.directory, include: "*.txt" }, ctx)
expect(result.output).toContain("(Results truncated. Consider using a more specific path or pattern.)")
expect(result.output).not.toMatch(/showing \d+ of \d+ matches/)
}),
)
it.instance("supports exact file paths", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const file = path.join(test.directory, "test.txt")
yield* Effect.promise(() => Bun.write(file, "line1\nline2\nline3"))
const info = yield* GrepTool
const grep = yield* info.init()
const result = yield* grep.execute(
{
pattern: "line2",
path: file,
},
ctx,
)
expect(result.metadata.matches).toBe(1)
expect(result.output).toContain(file)
expect(result.output).toContain("Line 2: line2")
}),
)
it.instance("does not ask for external_directory when alias path is allowed", () =>
Effect.gen(function* () {
if (process.platform === "win32") return
yield* TestInstance
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "opencode-grep-alias-"))),
(dir) => Effect.promise(() => fs.rm(dir, { recursive: true, force: true })),
)
const real = path.join(tmp, "real")
const alias = path.join(tmp, "alias")
yield* Effect.promise(() => fs.mkdir(real))
yield* Effect.promise(() => fs.symlink(real, alias, "dir"))
yield* Effect.promise(() => Bun.write(path.join(real, "test.txt"), "needle"))
const ruleset = Permission.fromConfig({
grep: "allow",
external_directory: {
[path.join(alias, "*")]: "allow",
},
})
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
const next: Tool.Context = {
...ctx,
ask: (req) =>
Effect.sync(() => {
const needsAsk = req.patterns.some(
(pattern) => Permission.evaluate(req.permission, pattern, ruleset).action !== "allow",
)
if (needsAsk) requests.push(req)
}),
}
const info = yield* GrepTool
const grep = yield* info.init()
const result = yield* grep.execute(
{
pattern: "needle",
path: alias,
include: "*.txt",
},
next,
)
expect(result.metadata.matches).toBe(1)
expect(requests.find((req) => req.permission === "external_directory")).toBeUndefined()
}),
)
})

View File

@@ -0,0 +1,181 @@
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
import { afterEach, describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import path from "path"
import { Agent } from "../../src/agent/agent"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { LSP } from "@/lsp/lsp"
import { Permission } from "../../src/permission"
import { MessageID, SessionID } from "../../src/session/schema"
import { Tool } from "@/tool/tool"
import { Truncate } from "@/tool/truncate"
import { LspTool } from "../../src/tool/lsp"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
afterEach(async () => {
await disposeAllInstances()
})
const ctx = {
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make("msg_test"),
callID: "",
agent: "build",
abort: AbortSignal.any([]),
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
}
const workspaceSymbolQueries: string[] = []
const lsp = Layer.succeed(
LSP.Service,
LSP.Service.of({
init: () => Effect.void,
status: () => Effect.succeed([]),
hasClients: () => Effect.succeed(true),
touchFile: () => Effect.void,
diagnostics: () => Effect.succeed({}),
hover: () => Effect.succeed([]),
definition: () => Effect.succeed([]),
references: () => Effect.succeed([]),
implementation: () => Effect.succeed([]),
documentSymbol: () => Effect.succeed([]),
workspaceSymbol: (query) =>
Effect.sync(() => {
workspaceSymbolQueries.push(query)
return []
}),
prepareCallHierarchy: () => Effect.succeed([]),
incomingCalls: () => Effect.succeed([]),
outgoingCalls: () => Effect.succeed([]),
}),
)
const it = testEffect(
Layer.mergeAll(Agent.defaultLayer, FSUtil.defaultLayer, CrossSpawnSpawner.defaultLayer, Truncate.defaultLayer, lsp),
)
const init = Effect.fn("LspToolTest.init")(function* () {
const info = yield* LspTool
return yield* info.init()
})
const run = Effect.fn("LspToolTest.run")(function* (
args: Tool.InferParameters<typeof LspTool>,
next: Tool.Context = ctx,
) {
const tool = yield* init()
return yield* tool.execute(args, next)
})
const put = Effect.fn("LspToolTest.put")(function* (file: string) {
const fs = yield* FSUtil.Service
yield* fs.writeWithDirs(file, "export const x = 1\n")
})
const asks = () => {
const items: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
return {
items,
next: {
...ctx,
ask: (req: Omit<PermissionV1.Request, "id" | "sessionID" | "tool">) =>
Effect.sync(() => {
items.push(req)
}),
},
}
}
describe("tool.lsp", () => {
describe("permission metadata", () => {
it.instance(
"keeps cursor details for position-based operations",
() =>
Effect.gen(function* () {
const dir = (yield* TestInstance).directory
const file = path.join(dir, "test.ts")
yield* put(file)
const { items, next } = asks()
const result = yield* run({ operation: "goToDefinition", filePath: file, line: 3, character: 7 }, next)
const req = items.find((item) => item.permission === "lsp")
expect(req).toBeDefined()
expect(req!.metadata).toEqual({
operation: "goToDefinition",
filePath: file,
line: 3,
character: 7,
})
expect(result.title).toBe("goToDefinition test.ts:3:7")
}),
{ git: true },
)
it.instance(
"omits cursor details for documentSymbol",
() =>
Effect.gen(function* () {
const dir = (yield* TestInstance).directory
const file = path.join(dir, "test.ts")
yield* put(file)
const { items, next } = asks()
const result = yield* run({ operation: "documentSymbol", filePath: file, line: 3, character: 7 }, next)
const req = items.find((item) => item.permission === "lsp")
expect(req).toBeDefined()
expect(req!.metadata).toEqual({
operation: "documentSymbol",
filePath: file,
})
expect(result.title).toBe("documentSymbol test.ts")
}),
{ git: true },
)
it.instance(
"omits file and cursor details for workspaceSymbol",
() =>
Effect.gen(function* () {
const dir = (yield* TestInstance).directory
workspaceSymbolQueries.length = 0
const file = path.join(dir, "test.ts")
yield* put(file)
const { items, next } = asks()
const result = yield* run({ operation: "workspaceSymbol", filePath: file, line: 3, character: 7 }, next)
const req = items.find((item) => item.permission === "lsp")
expect(req).toBeDefined()
expect(req!.metadata).toEqual({
operation: "workspaceSymbol",
})
expect(result.title).toBe("workspaceSymbol")
}),
{ git: true },
)
it.instance(
"passes workspaceSymbol query to LSP",
() =>
Effect.gen(function* () {
const dir = (yield* TestInstance).directory
workspaceSymbolQueries.length = 0
const file = path.join(dir, "test.ts")
yield* put(file)
yield* run({ operation: "workspaceSymbol", filePath: file, line: 3, character: 7, query: "TestSymbol" })
yield* run({ operation: "workspaceSymbol", filePath: file, line: 3, character: 7 })
expect(workspaceSymbolQueries).toEqual(["TestSymbol", ""])
}),
{ git: true },
)
})
})

View File

@@ -0,0 +1,293 @@
import { describe, expect, test } from "bun:test"
import { Result, Schema } from "effect"
import { ToolJsonSchema } from "../../src/tool/json-schema"
// Each tool exports its parameters schema at module scope so this test can
// import them without running the tool's Effect-based init. The JSON Schema
// snapshot captures what the LLM sees; the parse assertions pin down the
// accepts/rejects contract. `ToolJsonSchema.fromSchema` is the same helper `session/
// prompt.ts` uses to emit tool schemas to the LLM, so the snapshots stay
// provider-compatible while tools use Effect Schema internally.
import { Parameters as ApplyPatch } from "../../src/tool/apply_patch"
import { Parameters as Edit } from "../../src/tool/edit"
import { Parameters as Glob } from "../../src/tool/glob"
import { Parameters as Grep } from "../../src/tool/grep"
import { Parameters as Invalid } from "../../src/tool/invalid"
import { Parameters as Lsp } from "../../src/tool/lsp"
import { Parameters as Plan } from "../../src/tool/plan"
import { Parameters as Question } from "../../src/tool/question"
import { Parameters as Read } from "../../src/tool/read"
import { Parameters as Shell } from "../../src/tool/shell"
import { Parameters as Skill } from "../../src/tool/skill"
import { Parameters as Task } from "../../src/tool/task"
import { Parameters as Todo } from "../../src/tool/todo"
import { Parameters as WebFetch } from "../../src/tool/webfetch"
import { Parameters as WebSearch } from "../../src/tool/websearch"
import { Parameters as Write } from "../../src/tool/write"
const parse = <S extends Schema.Decoder<unknown>>(schema: S, input: unknown): S["Type"] =>
Schema.decodeUnknownSync(schema)(input)
const accepts = (schema: Schema.Decoder<unknown>, input: unknown): boolean =>
Result.isSuccess(Schema.decodeUnknownResult(schema)(input))
const toJsonSchema = ToolJsonSchema.fromSchema
describe("tool parameters", () => {
describe("JSON Schema (wire shape)", () => {
test("apply_patch", () => expect(toJsonSchema(ApplyPatch)).toMatchSnapshot())
test("bash", () => expect(toJsonSchema(Shell)).toMatchSnapshot())
test("edit", () => expect(toJsonSchema(Edit)).toMatchSnapshot())
test("glob", () => expect(toJsonSchema(Glob)).toMatchSnapshot())
test("grep", () => expect(toJsonSchema(Grep)).toMatchSnapshot())
test("invalid", () => expect(toJsonSchema(Invalid)).toMatchSnapshot())
test("lsp", () => expect(toJsonSchema(Lsp)).toMatchSnapshot())
test("plan", () => expect(toJsonSchema(Plan)).toMatchSnapshot())
test("question", () => expect(toJsonSchema(Question)).toMatchSnapshot())
test("read", () => expect(toJsonSchema(Read)).toMatchSnapshot())
test("skill", () => expect(toJsonSchema(Skill)).toMatchSnapshot())
test("task", () => expect(toJsonSchema(Task)).toMatchSnapshot())
test("todo", () => expect(toJsonSchema(Todo)).toMatchSnapshot())
test("webfetch", () => expect(toJsonSchema(WebFetch)).toMatchSnapshot())
test("websearch", () => expect(toJsonSchema(WebSearch)).toMatchSnapshot())
test("write", () => expect(toJsonSchema(Write)).toMatchSnapshot())
test("inlines named child schemas for provider compatibility", () => {
const schema = toJsonSchema(Question)
expect(schema).not.toHaveProperty("$defs")
expect(schema).toMatchObject({
properties: {
questions: { items: { properties: { options: { items: { properties: { label: { type: "string" } } } } } } },
},
})
})
test("preserves required nullable fields", () => {
expect(toJsonSchema(Schema.Struct({ value: Schema.NullOr(Schema.String) }))).toMatchObject({
properties: { value: { anyOf: expect.arrayContaining([{ type: "null" }]) } },
})
})
test("keeps repeated allOf constraints instead of dropping duplicates", () => {
expect(
toJsonSchema(
Schema.Struct({ value: Schema.String.check(Schema.isPattern(/^a/)).check(Schema.isPattern(/z$/)) }),
),
).toMatchObject({ properties: { value: { allOf: [{ pattern: "^a" }, { pattern: "z$" }] } } })
})
test("bounds bare integer fields to safe integer range", () => {
expect(toJsonSchema(Schema.Struct({ value: Schema.Int }))).toMatchObject({
properties: { value: { minimum: Number.MIN_SAFE_INTEGER, maximum: Number.MAX_SAFE_INTEGER } },
})
})
test("does not expose defaulted optional keys as nullable", () => {
expect(toJsonSchema(WebFetch)).toMatchObject({
properties: { format: { type: "string", enum: ["text", "markdown", "html"], default: "markdown" } },
})
expect(toJsonSchema(WebFetch).properties?.format).not.toHaveProperty("anyOf")
})
})
describe("apply_patch", () => {
test("accepts patchText", () => {
expect(parse(ApplyPatch, { patchText: "*** Begin Patch\n*** End Patch" })).toEqual({
patchText: "*** Begin Patch\n*** End Patch",
})
})
test("rejects missing patchText", () => {
expect(accepts(ApplyPatch, {})).toBe(false)
})
test("rejects non-string patchText", () => {
expect(accepts(ApplyPatch, { patchText: 123 })).toBe(false)
})
})
describe("shell", () => {
test("accepts minimum: command + description", () => {
expect(parse(Shell, { command: "ls", description: "list" })).toEqual({ command: "ls", description: "list" })
})
test("accepts optional timeout + workdir", () => {
const parsed = parse(Shell, { command: "ls", description: "list", timeout: 5000, workdir: "/tmp" })
expect(parsed.timeout).toBe(5000)
expect(parsed.workdir).toBe("/tmp")
})
test("rejects missing description", () => {
expect(accepts(Shell, { command: "ls" })).toBe(false)
})
test("rejects missing command", () => {
expect(accepts(Shell, { description: "list" })).toBe(false)
})
})
describe("edit", () => {
test("accepts all four fields", () => {
expect(parse(Edit, { filePath: "/a", oldString: "x", newString: "y", replaceAll: true })).toEqual({
filePath: "/a",
oldString: "x",
newString: "y",
replaceAll: true,
})
})
test("replaceAll is optional", () => {
const parsed = parse(Edit, { filePath: "/a", oldString: "x", newString: "y" })
expect(parsed.replaceAll).toBeUndefined()
})
test("rejects missing filePath", () => {
expect(accepts(Edit, { oldString: "x", newString: "y" })).toBe(false)
})
})
describe("glob", () => {
test("accepts pattern-only", () => {
expect(parse(Glob, { pattern: "**/*.ts" })).toEqual({ pattern: "**/*.ts" })
})
test("accepts optional path", () => {
expect(parse(Glob, { pattern: "**/*.ts", path: "/tmp" }).path).toBe("/tmp")
})
test("rejects missing pattern", () => {
expect(accepts(Glob, {})).toBe(false)
})
})
describe("grep", () => {
test("accepts pattern-only", () => {
expect(parse(Grep, { pattern: "TODO" })).toEqual({ pattern: "TODO" })
})
test("accepts optional path + include", () => {
const parsed = parse(Grep, { pattern: "TODO", path: "/tmp", include: "*.ts" })
expect(parsed.path).toBe("/tmp")
expect(parsed.include).toBe("*.ts")
})
test("rejects missing pattern", () => {
expect(accepts(Grep, {})).toBe(false)
})
})
describe("invalid", () => {
test("accepts tool + error", () => {
expect(parse(Invalid, { tool: "foo", error: "bar" })).toEqual({ tool: "foo", error: "bar" })
})
test("rejects missing fields", () => {
expect(accepts(Invalid, { tool: "foo" })).toBe(false)
expect(accepts(Invalid, { error: "bar" })).toBe(false)
})
})
describe("lsp", () => {
test("accepts all fields", () => {
const parsed = parse(Lsp, { operation: "hover", filePath: "/a.ts", line: 1, character: 1 })
expect(parsed.operation).toBe("hover")
})
test("rejects line < 1", () => {
expect(accepts(Lsp, { operation: "hover", filePath: "/a.ts", line: 0, character: 1 })).toBe(false)
})
test("rejects character < 1", () => {
expect(accepts(Lsp, { operation: "hover", filePath: "/a.ts", line: 1, character: 0 })).toBe(false)
})
test("rejects unknown operation", () => {
expect(accepts(Lsp, { operation: "bogus", filePath: "/a.ts", line: 1, character: 1 })).toBe(false)
})
})
describe("plan", () => {
test("accepts empty object", () => {
expect(parse(Plan, {})).toEqual({})
})
})
describe("question", () => {
test("accepts questions array", () => {
const parsed = parse(Question, {
questions: [
{
question: "pick one",
header: "Header",
custom: false,
options: [{ label: "a", description: "desc" }],
},
],
})
expect(parsed.questions.length).toBe(1)
})
test("rejects missing questions", () => {
expect(accepts(Question, {})).toBe(false)
})
})
describe("read", () => {
test("accepts filePath-only", () => {
expect(parse(Read, { filePath: "/a" }).filePath).toBe("/a")
})
test("accepts optional offset + limit", () => {
const parsed = parse(Read, { filePath: "/a", offset: 10, limit: 100 })
expect(parsed.offset).toBe(10)
expect(parsed.limit).toBe(100)
})
})
describe("skill", () => {
test("accepts name", () => {
expect(parse(Skill, { name: "foo" }).name).toBe("foo")
})
test("rejects missing name", () => {
expect(accepts(Skill, {})).toBe(false)
})
})
describe("task", () => {
test("accepts description + prompt + subagent_type", () => {
const parsed = parse(Task, { description: "d", prompt: "p", subagent_type: "general" })
expect(parsed.subagent_type).toBe("general")
})
test("accepts optional background flag", () => {
const parsed = parse(Task, { description: "d", prompt: "p", subagent_type: "general", background: true })
expect(parsed.background).toBe(true)
})
test("rejects missing prompt", () => {
expect(accepts(Task, { description: "d", subagent_type: "general" })).toBe(false)
})
})
describe("todo", () => {
test("accepts todos array", () => {
const parsed = parse(Todo, {
todos: [{ id: "t1", content: "do x", status: "pending", priority: "medium" }],
})
expect(parsed.todos.length).toBe(1)
})
test("rejects missing todos", () => {
expect(accepts(Todo, {})).toBe(false)
})
})
describe("webfetch", () => {
test("defaults omitted format to markdown", () => {
expect(parse(WebFetch, { url: "https://example.com" })).toEqual({
url: "https://example.com",
format: "markdown",
})
expect(parse(WebFetch, { url: "https://example.com", format: undefined })).toEqual({
url: "https://example.com",
format: "markdown",
})
})
})
describe("websearch", () => {
test("accepts query", () => {
expect(parse(WebSearch, { query: "opencode" }).query).toBe("opencode")
})
})
describe("write", () => {
test("accepts content + filePath", () => {
expect(parse(Write, { content: "hi", filePath: "/a" })).toEqual({ content: "hi", filePath: "/a" })
})
test("rejects missing filePath", () => {
expect(accepts(Write, { content: "hi" })).toBe(false)
})
})
})

View File

@@ -0,0 +1,138 @@
import { describe, expect } from "bun:test"
import { Effect, Fiber, Layer, Queue } from "effect"
import { QuestionTool } from "../../src/tool/question"
import { Question } from "../../src/question"
import { SessionID, MessageID } from "../../src/session/schema"
import { Agent } from "../../src/agent/agent"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Truncate } from "@/tool/truncate"
import { testEffect } from "../lib/effect"
import { EventV2Bridge } from "../../src/event-v2-bridge"
const ctx = {
sessionID: SessionID.make("ses_test-session"),
messageID: MessageID.make("msg_test-message"),
callID: "test-call",
agent: "test-agent",
abort: AbortSignal.any([]),
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
}
const it = testEffect(
Layer.mergeAll(
Question.layer.pipe(Layer.provideMerge(EventV2Bridge.defaultLayer)),
CrossSpawnSpawner.defaultLayer,
Truncate.defaultLayer,
Agent.defaultLayer,
),
)
const pending = Effect.fn("QuestionToolTest.pending")(function* (question: Question.Interface) {
const events = yield* EventV2Bridge.Service
const asked = yield* Queue.unbounded<void>()
const off = yield* events.listen((event) => {
if (event.type === Question.Event.Asked.type) Queue.offerUnsafe(asked, undefined)
return Effect.void
})
yield* Effect.addFinalizer(() => off)
for (;;) {
const items = yield* question.list()
const item = items[0]
if (item) return item
yield* Queue.take(asked).pipe(Effect.timeout("2 seconds"))
}
})
describe("tool.question", () => {
it.instance("should successfully execute with valid question parameters", () =>
Effect.gen(function* () {
const question = yield* Question.Service
const toolInfo = yield* QuestionTool
const tool = yield* toolInfo.init()
const questions = [
{
question: "What is your favorite color?",
header: "Color",
options: [
{ label: "Red", description: "The color of passion" },
{ label: "Blue", description: "The color of sky" },
],
multiple: false,
},
]
const fiber = yield* tool.execute({ questions }, ctx).pipe(Effect.forkScoped)
const item = yield* pending(question)
yield* question.reply({ requestID: item.id, answers: [["Red"]] })
const result = yield* Fiber.join(fiber)
expect(result.title).toBe("Asked 1 question")
}),
)
it.instance("should now pass with a header longer than 12 but less than 30 chars", () =>
Effect.gen(function* () {
const question = yield* Question.Service
const toolInfo = yield* QuestionTool
const tool = yield* toolInfo.init()
const questions = [
{
question: "What is your favorite animal?",
header: "This Header is Over 12",
options: [{ label: "Dog", description: "Man's best friend" }],
},
]
const fiber = yield* tool.execute({ questions }, ctx).pipe(Effect.forkScoped)
const item = yield* pending(question)
yield* question.reply({ requestID: item.id, answers: [["Dog"]] })
const result = yield* Fiber.join(fiber)
expect(result.output).toContain(`"What is your favorite animal?"="Dog"`)
}),
)
// intentionally removed the zod validation due to tool call errors, hoping prompting is gonna be good enough
// test("should throw an Error for header exceeding 30 characters", async () => {
// const tool = await QuestionTool.init()
// const questions = [
// {
// question: "What is your favorite animal?",
// header: "This Header is Definitely More Than Thirty Characters Long",
// options: [{ label: "Dog", description: "Man's best friend" }],
// },
// ]
// try {
// await tool.execute({ questions }, ctx)
// // If it reaches here, the test should fail
// expect(true).toBe(false)
// } catch (e: any) {
// expect(e).toBeInstanceOf(Error)
// expect(e.cause).toBeInstanceOf(z.ZodError)
// }
// })
// test("should throw an Error for label exceeding 30 characters", async () => {
// const tool = await QuestionTool.init()
// const questions = [
// {
// question: "A question with a very long label",
// header: "Long Label",
// options: [
// { label: "This is a very, very, very long label that will exceed the limit", description: "A description" },
// ],
// },
// ]
// try {
// await tool.execute({ questions }, ctx)
// // If it reaches here, the test should fail
// expect(true).toBe(false)
// } catch (e: any) {
// expect(e).toBeInstanceOf(Error)
// expect(e.cause).toBeInstanceOf(z.ZodError)
// }
// })
})

View File

@@ -0,0 +1,605 @@
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
import { afterEach, describe, expect } from "bun:test"
import { Cause, Effect, Exit, Layer, Stream } from "effect"
import path from "path"
import { Agent } from "../../src/agent/agent"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Global } from "@opencode-ai/core/global"
import { Config } from "@/config/config"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { LSP } from "@/lsp/lsp"
import { Permission } from "../../src/permission"
import { SessionID, MessageID } from "../../src/session/schema"
import { Instruction } from "../../src/session/instruction"
import { ReadTool } from "../../src/tool/read"
import { Truncate } from "@/tool/truncate"
import { Tool } from "@/tool/tool"
import { Filesystem } from "@/util/filesystem"
import {
disposeAllInstances,
provideInstance,
testInstanceStoreLayer,
TestInstance,
tmpdirScoped,
} from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
afterEach(async () => {
await disposeAllInstances()
})
const ctx = {
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make("msg_test"),
callID: "",
agent: "build",
abort: AbortSignal.any([]),
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
}
const readLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
Layer.mergeAll(
Agent.defaultLayer,
FSUtil.defaultLayer,
CrossSpawnSpawner.defaultLayer,
Instruction.defaultLayer,
LSP.defaultLayer,
Ripgrep.defaultLayer,
Truncate.defaultLayer,
)
const it = testEffect(Layer.mergeAll(readLayer(), testInstanceStoreLayer))
const init = Effect.fn("ReadToolTest.init")(function* () {
const info = yield* ReadTool
return yield* info.init()
})
const run = Effect.fn("ReadToolTest.run")(function* (
args: Tool.InferParameters<typeof ReadTool>,
next: Tool.Context = ctx,
) {
const tool = yield* init()
return yield* tool.execute(args, next)
})
const exec = Effect.fn("ReadToolTest.exec")(function* (
dir: string,
args: Tool.InferParameters<typeof ReadTool>,
next: Tool.Context = ctx,
) {
return yield* provideInstance(dir)(run(args, next))
})
const fail = Effect.fn("ReadToolTest.fail")(function* (
dir: string,
args: Tool.InferParameters<typeof ReadTool>,
next: Tool.Context = ctx,
) {
const exit = yield* exec(dir, args, next).pipe(Effect.exit)
if (Exit.isFailure(exit)) {
const err = Cause.squash(exit.cause)
return err instanceof Error ? err : new Error(String(err))
}
throw new Error("expected read to fail")
})
const full = (p: string) => (process.platform === "win32" ? Filesystem.normalizePath(p) : p)
const glob = (p: string) =>
process.platform === "win32" ? Filesystem.normalizePathPattern(p) : p.replaceAll("\\", "/")
const githubBase = <A, E, R>(url: string, self: Effect.Effect<A, E, R>) =>
Effect.acquireUseRelease(
Effect.sync(() => {
const previous = process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL
process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = url
return previous
}),
() => self,
(previous) =>
Effect.sync(() => {
if (previous) process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = previous
else delete process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL
}),
)
const git = Effect.fn("ReadToolTest.git")(function* (cwd: string, args: string[]) {
return yield* Effect.promise(async () => {
const proc = Bun.spawn(["git", ...args], {
cwd,
stdout: "pipe",
stderr: "pipe",
})
const [stdout, stderr, code] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
])
if (code !== 0) throw new Error(stderr.trim() || stdout.trim() || `git ${args.join(" ")} failed`)
return stdout.trim()
})
})
const put = Effect.fn("ReadToolTest.put")(function* (p: string, content: string | Buffer | Uint8Array) {
const fs = yield* FSUtil.Service
yield* fs.writeWithDirs(p, content)
})
const load = Effect.fn("ReadToolTest.load")(function* (p: string) {
const fs = yield* FSUtil.Service
return yield* fs.readFileString(p)
})
const asks = () => {
const items: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
return {
items,
next: {
...ctx,
ask: (req: Omit<PermissionV1.Request, "id" | "sessionID" | "tool">) =>
Effect.sync(() => {
items.push(req)
}),
},
}
}
describe("tool.read external_directory permission", () => {
it.live("allows reading absolute path inside project directory", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
yield* put(path.join(dir, "test.txt"), "hello world")
const result = yield* exec(dir, { filePath: path.join(dir, "test.txt") })
expect(result.output).toContain("hello world")
}),
)
it.live("allows reading file in subdirectory inside project directory", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
yield* put(path.join(dir, "subdir", "test.txt"), "nested content")
const result = yield* exec(dir, { filePath: path.join(dir, "subdir", "test.txt") })
expect(result.output).toContain("nested content")
}),
)
it.live("asks for external_directory permission when reading absolute path outside project", () =>
Effect.gen(function* () {
const outer = yield* tmpdirScoped()
const dir = yield* tmpdirScoped({ git: true })
yield* put(path.join(outer, "secret.txt"), "secret data")
const { items, next } = asks()
yield* exec(dir, { filePath: path.join(outer, "secret.txt") }, next)
const ext = items.find((item) => item.permission === "external_directory")
expect(ext).toBeDefined()
expect(ext!.patterns).toContain(glob(path.join(outer, "*")))
}),
)
if (process.platform === "win32") {
it.live("normalizes read permission paths on Windows", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
yield* put(path.join(dir, "test.txt"), "hello world")
const { items, next } = asks()
const target = path.join(dir, "test.txt")
const alt = target
.replace(/^[A-Za-z]:/, "")
.replaceAll("\\", "/")
.toLowerCase()
yield* exec(dir, { filePath: alt }, next)
const read = items.find((item) => item.permission === "read")
expect(read).toBeDefined()
expect(read!.patterns).toEqual([path.relative(dir, full(target))])
}),
)
}
it.live("uses worktree-relative path for read permission so user rules match like edit/write", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
yield* put(path.join(dir, "src", "secret.ts"), "shh")
const { items, next } = asks()
yield* exec(dir, { filePath: path.join(dir, "src", "secret.ts") }, next)
const read = items.find((item) => item.permission === "read")
expect(read).toBeDefined()
expect(read!.patterns).toEqual([path.join("src", "secret.ts")])
}),
)
it.live("asks for directory-scoped external_directory permission when reading external directory", () =>
Effect.gen(function* () {
const outer = yield* tmpdirScoped()
const dir = yield* tmpdirScoped({ git: true })
yield* put(path.join(outer, "external", "a.txt"), "a")
const { items, next } = asks()
yield* exec(dir, { filePath: path.join(outer, "external") }, next)
const ext = items.find((item) => item.permission === "external_directory")
expect(ext).toBeDefined()
expect(ext!.patterns).toContain(glob(path.join(outer, "external", "*")))
}),
)
it.live("asks for external_directory permission when reading relative path outside project", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const { items, next } = asks()
yield* fail(dir, { filePath: "../outside.txt" }, next)
const ext = items.find((item) => item.permission === "external_directory")
expect(ext).toBeDefined()
}),
)
it.live("does not ask for external_directory permission when reading inside project", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
yield* put(path.join(dir, "internal.txt"), "internal content")
const { items, next } = asks()
yield* exec(dir, { filePath: path.join(dir, "internal.txt") }, next)
const ext = items.find((item) => item.permission === "external_directory")
expect(ext).toBeUndefined()
}),
)
})
describe("tool.read env file permissions", () => {
const cases: [string, boolean][] = [
[".env", true],
[".env.local", true],
[".env.production", true],
[".env.development.local", true],
[".env.example", false],
[".envrc", false],
["environment.ts", false],
]
for (const agentName of ["build", "plan"] as const) {
describe(`agent=${agentName}`, () => {
for (const [filename, shouldAsk] of cases) {
it.live(`${filename} asks=${shouldAsk}`, () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
yield* put(path.join(dir, filename), "content")
const asked = yield* provideInstance(dir)(
Effect.gen(function* () {
const agent = yield* Agent.Service
const info = yield* agent.get(agentName)
let asked = false
const next = {
...ctx,
ask: (req: Omit<PermissionV1.Request, "id" | "sessionID" | "tool">) =>
Effect.sync(() => {
for (const pattern of req.patterns) {
const rule = Permission.evaluate(req.permission, pattern, info.permission)
if (rule.action === "ask" && req.permission === "read") {
asked = true
}
if (rule.action === "deny") {
throw new PermissionV1.DeniedError({ ruleset: info.permission })
}
}
}),
}
yield* run({ filePath: path.join(dir, filename) }, next)
return asked
}),
)
expect(asked).toBe(shouldAsk)
}),
)
}
})
}
})
describe("tool.read truncation", () => {
it.instance("truncates large file by bytes and sets truncated metadata", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const base = yield* load(path.join(FIXTURES_DIR, "models-api.json"))
const target = 60 * 1024
const content = base.length >= target ? base : base.repeat(Math.ceil(target / base.length))
yield* put(path.join(test.directory, "large.json"), content)
const result = yield* run({ filePath: path.join(test.directory, "large.json") })
expect(result.metadata.truncated).toBe(true)
expect(result.output).toContain("Output capped at")
expect(result.output).toContain("Use offset=")
}),
)
it.instance("stops streaming after the byte cap", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "huge.txt")
const content = `${"x".repeat(80)}\n`.repeat(50_000)
yield* put(filepath, content)
const fs = yield* FSUtil.Service
const counter = { bytes: 0 }
const result = yield* run({ filePath: filepath }).pipe(
Effect.provideService(
FSUtil.Service,
FSUtil.Service.of({
...fs,
stream: (file, options) =>
fs.stream(file, options).pipe(
Stream.tap((chunk) =>
Effect.sync(() => {
counter.bytes += chunk.length
}),
),
),
}),
),
)
expect(result.metadata.truncated).toBe(true)
expect(result.output).toContain("Output capped at")
expect(counter.bytes).toBeLessThan(Buffer.byteLength(content, "utf-8") / 2)
}),
)
it.instance("truncates by line count when limit is specified", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
yield* put(path.join(test.directory, "many-lines.txt"), lines)
const result = yield* run({ filePath: path.join(test.directory, "many-lines.txt"), limit: 10 })
expect(result.metadata.truncated).toBe(true)
expect(result.output).toContain("Showing lines 1-10 of 100")
expect(result.output).toContain("Use offset=11")
expect(result.output).toContain("line0")
expect(result.output).toContain("line9")
expect(result.output).not.toContain("line10")
}),
)
it.instance("does not truncate small file", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* put(path.join(test.directory, "small.txt"), "hello world")
const result = yield* run({ filePath: path.join(test.directory, "small.txt") })
expect(result.metadata.truncated).toBe(false)
expect(result.output).toContain("End of file")
expect(result.metadata.display).toMatchObject({
type: "file",
path: path.join(test.directory, "small.txt"),
text: "hello world",
lineStart: 1,
lineEnd: 1,
totalLines: 1,
truncated: false,
})
}),
)
it.live("respects offset parameter", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const lines = Array.from({ length: 20 }, (_, i) => `line${i + 1}`).join("\n")
yield* put(path.join(dir, "offset.txt"), lines)
const result = yield* exec(dir, { filePath: path.join(dir, "offset.txt"), offset: 10, limit: 5 })
expect(result.output).toContain("10: line10")
expect(result.output).toContain("14: line14")
expect(result.output).not.toContain("9: line10")
expect(result.output).not.toContain("15: line15")
expect(result.output).toContain("line10")
expect(result.output).toContain("line14")
expect(result.output).not.toContain("line0")
expect(result.output).not.toContain("line15")
}),
)
it.live("throws when offset is beyond end of file", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const lines = Array.from({ length: 3 }, (_, i) => `line${i + 1}`).join("\n")
yield* put(path.join(dir, "short.txt"), lines)
const err = yield* fail(dir, { filePath: path.join(dir, "short.txt"), offset: 4, limit: 5 })
expect(err.message).toContain("Offset 4 is out of range for this file (3 lines)")
}),
)
it.live("allows reading empty file at default offset", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
yield* put(path.join(dir, "empty.txt"), "")
const result = yield* exec(dir, { filePath: path.join(dir, "empty.txt") })
expect(result.metadata.truncated).toBe(false)
expect(result.output).toContain("End of file - total 0 lines")
}),
)
it.live("throws when offset > 1 for empty file", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
yield* put(path.join(dir, "empty.txt"), "")
const err = yield* fail(dir, { filePath: path.join(dir, "empty.txt"), offset: 2 })
expect(err.message).toContain("Offset 2 is out of range for this file (0 lines)")
}),
)
it.live("does not mark final directory page as truncated", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
yield* Effect.forEach(
Array.from({ length: 10 }, (_, i) => i),
(i) => put(path.join(dir, "dir", `file-${i + 1}.txt`), `line${i}`),
{
concurrency: "unbounded",
},
)
const result = yield* exec(dir, { filePath: path.join(dir, "dir"), offset: 6, limit: 5 })
expect(result.metadata.truncated).toBe(false)
expect(result.output).not.toContain("Showing 5 of 10 entries")
expect(result.metadata.display).toMatchObject({
type: "directory",
path: path.join(dir, "dir"),
entries: ["file-5.txt", "file-6.txt", "file-7.txt", "file-8.txt", "file-9.txt"],
offset: 6,
totalEntries: 10,
truncated: false,
})
}),
)
it.live("truncates long lines", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
yield* put(path.join(dir, "long-line.txt"), "x".repeat(3000))
const result = yield* exec(dir, { filePath: path.join(dir, "long-line.txt") })
expect(result.output).toContain("(line truncated to 2000 chars)")
expect(result.output.length).toBeLessThan(3000)
}),
)
it.live("image files set truncated to false", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const png = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==",
"base64",
)
yield* put(path.join(dir, "image.png"), png)
const result = yield* exec(dir, { filePath: path.join(dir, "image.png") })
expect(result.metadata.truncated).toBe(false)
expect(result.attachments).toBeDefined()
expect(result.attachments?.length).toBe(1)
expect(result.attachments?.[0]).not.toHaveProperty("id")
expect(result.attachments?.[0]).not.toHaveProperty("sessionID")
expect(result.attachments?.[0]).not.toHaveProperty("messageID")
}),
)
it.live("detects attachment media from file contents", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01])
yield* put(path.join(dir, "image.bin"), jpeg)
const result = yield* exec(dir, { filePath: path.join(dir, "image.bin") })
expect(result.output).toBe("Image read successfully")
expect(result.attachments?.[0].mime).toBe("image/jpeg")
expect(result.attachments?.[0].url.startsWith("data:image/jpeg;base64,")).toBe(true)
}),
)
it.live("large image files are properly attached without error", () =>
Effect.gen(function* () {
const result = yield* exec(FIXTURES_DIR, { filePath: path.join(FIXTURES_DIR, "large-image.png") })
expect(result.metadata.truncated).toBe(false)
expect(result.attachments).toBeDefined()
expect(result.attachments?.length).toBe(1)
expect(result.attachments?.[0].type).toBe("file")
expect(result.attachments?.[0]).not.toHaveProperty("id")
expect(result.attachments?.[0]).not.toHaveProperty("sessionID")
expect(result.attachments?.[0]).not.toHaveProperty("messageID")
}),
)
it.live(".fbs files (FlatBuffers schema) are read as text, not images", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const fbs = `namespace MyGame;
table Monster {
pos:Vec3;
name:string;
inventory:[ubyte];
}
root_type Monster;`
yield* put(path.join(dir, "schema.fbs"), fbs)
const result = yield* exec(dir, { filePath: path.join(dir, "schema.fbs") })
expect(result.attachments).toBeUndefined()
expect(result.output).toContain("namespace MyGame")
expect(result.output).toContain("table Monster")
}),
)
it.live("falls through unsupported image mime types to text", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const cases = [
["image.bmp", "BM text content"],
["photo.tiff", "II text content"],
["photo.avif", "avif text content"],
] as const
for (const item of cases) {
yield* put(path.join(dir, item[0]), item[1])
const result = yield* exec(dir, { filePath: path.join(dir, item[0]) })
expect(result.attachments).toBeUndefined()
expect(result.output).toContain(item[1])
}
}),
)
})
describe("tool.read loaded instructions", () => {
it.live("loads AGENTS.md from parent directory and includes in metadata", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
yield* put(path.join(dir, "subdir", "AGENTS.md"), "# Test Instructions\nDo something special.")
yield* put(path.join(dir, "subdir", "nested", "test.txt"), "test content")
const result = yield* exec(dir, { filePath: path.join(dir, "subdir", "nested", "test.txt") })
expect(result.output).toContain("test content")
expect(result.output).toContain("system-reminder")
expect(result.output).toContain("Test Instructions")
expect(result.metadata.loaded).toBeDefined()
expect(result.metadata.loaded).toContain(path.join(dir, "subdir", "AGENTS.md"))
}),
)
})
describe("tool.read binary detection", () => {
it.live("rejects text extension files with null bytes", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const bytes = Buffer.from([0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x00, 0x77, 0x6f, 0x72, 0x6c, 0x64])
yield* put(path.join(dir, "null-byte.txt"), bytes)
const err = yield* fail(dir, { filePath: path.join(dir, "null-byte.txt") })
expect(err.message).toContain("Cannot read binary file")
}),
)
it.live("rejects known binary extensions", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
yield* put(path.join(dir, "module.wasm"), "not really wasm")
const err = yield* fail(dir, { filePath: path.join(dir, "module.wasm") })
expect(err.message).toContain("Cannot read binary file")
}),
)
})

View File

@@ -0,0 +1,497 @@
import { afterEach, describe, expect } from "bun:test"
import path from "path"
import fs from "fs/promises"
import { fileURLToPath, pathToFileURL } from "url"
import { Effect, Layer, Result, Schema } from "effect"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { ToolRegistry } from "@/tool/registry"
import { Tool } from "@/tool/tool"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { TestConfig } from "../fixture/config"
import { Config } from "@/config/config"
import { Plugin } from "@/plugin"
import { Agent } from "@/agent/agent"
import { InstanceState } from "@/effect/instance-state"
import { ToolJsonSchema } from "@/tool/json-schema"
import { MessageID, SessionID } from "@/session/schema"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
const configLayer = TestConfig.layer({
directories: () => InstanceState.directory.pipe(Effect.map((dir) => [path.join(dir, ".opencode")])),
})
// Fake Plugin.Service that returns a single plugin whose `tool` map contains
// one definition with `args: undefined`. Used to exercise the plugin entry
// point of `fromPlugin` for the #27451 / #27630 regression.
const brokenPluginLayer = Layer.succeed(
Plugin.Service,
Plugin.Service.of({
init: () => Effect.void,
trigger: ((_name: unknown, _input: unknown, output: unknown) =>
Effect.succeed(output)) as Plugin.Interface["trigger"],
list: () =>
Effect.succeed([
{
tool: {
broken_plugin_tool: {
description: "plugin tool with missing args",
args: undefined as unknown as Record<string, never>,
execute: async () => "ok",
},
},
},
]),
}),
)
const root = LayerNode.group([ToolRegistry.node, Agent.node])
const replacements = [
LayerNode.replace(Config.node, configLayer),
LayerNode.replace(RuntimeFlags.node, RuntimeFlags.layer()),
]
const it = testEffect(LayerNode.buildLayer(root, { replacements }))
const withBrokenPlugin = testEffect(
LayerNode.buildLayer(root, {
replacements: [...replacements, LayerNode.replace(Plugin.node, brokenPluginLayer)],
}),
)
afterEach(async () => {
await disposeAllInstances()
})
describe("tool.registry", () => {
it.instance("does not expose task_status", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const ids = yield* registry.ids()
expect(ids).not.toContain("task_status")
}),
)
it.instance("hides task background parameter unless experimental background subagents are enabled", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const agent = yield* Agent.Service
const build = yield* agent.get("build")
if (!build) throw new Error("build agent not found")
const task = (yield* registry.tools({
providerID: ProviderV2.ID.opencode,
modelID: ModelV2.ID.make("test"),
agent: build,
})).find((tool) => tool.id === "task")
expect(task?.jsonSchema).toBeDefined()
expect((task?.jsonSchema?.properties as Record<string, unknown> | undefined)?.background).toBeUndefined()
}),
)
it.instance("loads tools from .opencode/tool (singular)", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const opencode = path.join(test.directory, ".opencode")
const tool = path.join(opencode, "tool")
yield* Effect.promise(() => fs.mkdir(tool, { recursive: true }))
yield* Effect.promise(() =>
Bun.write(
path.join(tool, "hello.ts"),
[
"export default {",
" description: 'hello tool',",
" args: {},",
" execute: async () => {",
" return 'hello world'",
" },",
"}",
"",
].join("\n"),
),
)
const registry = yield* ToolRegistry.Service
const ids = yield* registry.ids()
expect(ids).toContain("hello")
}),
)
it.instance("ignores non-tool exports in .opencode/tool files", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const tool = path.join(test.directory, ".opencode", "tool")
yield* Effect.promise(() => fs.mkdir(tool, { recursive: true }))
yield* Effect.promise(() =>
Bun.write(
path.join(tool, "mixed.ts"),
[
"export const helper = 'not a tool'",
"export default {",
" description: 'mixed tool',",
" args: {},",
" execute: async () => 'ok',",
"}",
"",
].join("\n"),
),
)
const registry = yield* ToolRegistry.Service
const ids = yield* registry.ids()
expect(ids).toContain("mixed")
expect(ids).not.toContain("mixed_helper")
}),
)
// Regression for #27451 / #27630: a custom tool that omits `args` must not
// crash registry initialization with
// `Object.entries requires that input parameter not be null or undefined`.
// Pre-1.14.49 the code path was `z.object(def.args)`, and `z.object(undefined)`
// silently produced an empty schema — so the tool registered as no-args.
// Preserve that tolerance.
it.instance("tolerates a custom tool exporting null/undefined args (no-args fallback)", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const tool = path.join(test.directory, ".opencode", "tool")
yield* Effect.promise(() => fs.mkdir(tool, { recursive: true }))
yield* Effect.promise(() =>
Bun.write(
path.join(tool, "noargs.ts"),
[
"export default {",
" description: 'tool with no args',",
" args: undefined,",
" execute: async () => 'ok',",
"}",
"",
].join("\n"),
),
)
const registry = yield* ToolRegistry.Service
const ids = yield* registry.ids()
// Built-in tools must still load — a single malformed custom tool must
// not poison the whole registry.
expect(ids).toContain("read")
const loaded = (yield* registry.all()).find((t) => t.id === "noargs")
if (!loaded) throw new Error("noargs tool was not loaded")
expect(loaded.jsonSchema).toMatchObject({ type: "object", properties: {} })
}),
)
// Same regression, plugin entry point. The original reports (#27451, #27630)
// came in through `plugin.list()` — `oh-my-opencode` was registering a tool
// with `args: undefined` and crashing every message submit. The file-scan
// and plugin-list loops both funnel through `fromPlugin`, but covering both
// entry points means a future refactor that splits them won't silently lose
// protection.
withBrokenPlugin.instance("tolerates a plugin tool registered with null/undefined args", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const ids = yield* registry.ids()
expect(ids).toContain("read")
expect(ids).toContain("broken_plugin_tool")
}),
)
it.instance("loads tools from .opencode/tools (plural)", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const opencode = path.join(test.directory, ".opencode")
const tools = path.join(opencode, "tools")
yield* Effect.promise(() => fs.mkdir(tools, { recursive: true }))
yield* Effect.promise(() =>
Bun.write(
path.join(tools, "hello.ts"),
[
"export default {",
" description: 'hello tool',",
" args: {},",
" execute: async () => {",
" return 'hello world'",
" },",
"}",
"",
].join("\n"),
),
)
const registry = yield* ToolRegistry.Service
const ids = yield* registry.ids()
expect(ids).toContain("hello")
}),
)
it.instance("loads Zod-schema custom tools with JSON Schema and validation", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const customTools = path.join(test.directory, ".opencode", "tools")
const pluginTool = pathToFileURL(path.resolve(import.meta.dir, "../../../plugin/src/tool.ts")).href
yield* Effect.promise(() => fs.mkdir(customTools, { recursive: true }))
yield* Effect.promise(() =>
Bun.write(
path.join(customTools, "sql.ts"),
[
`import { tool } from ${JSON.stringify(pluginTool)}`,
"export default tool({",
" description: 'query database',",
" args: { query: tool.schema.string().describe('SQL query to execute') },",
" execute: async ({ query }) => query,",
"})",
"",
].join("\n"),
),
)
const registry = yield* ToolRegistry.Service
const loaded = (yield* registry.all()).find((tool) => tool.id === "sql")
if (!loaded) throw new Error("custom sql tool was not loaded")
expect(loaded?.jsonSchema).toMatchObject({
type: "object",
properties: {
query: { type: "string", description: "SQL query to execute" },
},
required: ["query"],
})
expect(Result.isSuccess(Schema.decodeUnknownResult(loaded.parameters)({ query: "select 1" }))).toBe(true)
expect(Result.isSuccess(Schema.decodeUnknownResult(loaded.parameters)({}))).toBe(false)
const agents = yield* Agent.Service
const promptTools = yield* registry.tools({
providerID: ProviderV2.ID.opencode,
modelID: ModelV2.ID.make("test"),
agent: yield* agents.defaultInfo(),
})
const promptTool = promptTools.find((tool) => tool.id === "sql")
if (!promptTool) throw new Error("custom sql tool was not returned for prompts")
expect(ToolJsonSchema.fromTool(promptTool)).toMatchObject({
properties: {
query: { type: "string", description: "SQL query to execute" },
},
required: ["query"],
})
}),
)
it.instance(
"preserves Zod arg descriptions from older config-scoped plugin packages",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const opencode = path.join(test.directory, ".opencode")
const customTools = path.join(opencode, "tools")
const plugin = path.join(opencode, "node_modules", "@opencode-ai", "plugin")
yield* Effect.promise(() => fs.mkdir(path.join(plugin, "dist"), { recursive: true }))
yield* Effect.promise(() => fs.mkdir(customTools, { recursive: true }))
yield* Effect.promise(() =>
fs.cp(path.dirname(fileURLToPath(import.meta.resolve("zod"))), path.join(opencode, "node_modules", "zod"), {
dereference: true,
recursive: true,
}),
)
yield* Effect.promise(() =>
Bun.write(
path.join(plugin, "package.json"),
JSON.stringify({ name: "@opencode-ai/plugin", type: "module", exports: { ".": "./dist/index.js" } }),
),
)
yield* Effect.promise(() =>
Bun.write(
path.join(plugin, "dist", "index.js"),
[
"import { z } from 'zod'",
"export function tool(input) {",
" return input",
"}",
"tool.schema = z",
"",
].join("\n"),
),
)
yield* Effect.promise(() =>
Bun.write(
path.join(customTools, "addition.ts"),
[
'import { tool } from "@opencode-ai/plugin"',
"export default tool({",
" description: 'Use this tool to add two numbers and return their sum.',",
" args: {",
" left: tool.schema.number().describe('The first number to add'),",
" right: tool.schema.number().describe('The second number to add'),",
" },",
" execute: async (args) => `${args.left} + ${args.right} = ${args.left + args.right}`,",
"})",
"",
].join("\n"),
),
)
const registry = yield* ToolRegistry.Service
const loaded = (yield* registry.all()).find((tool) => tool.id === "addition")
if (!loaded) throw new Error("custom addition tool was not loaded")
expect(ToolJsonSchema.fromTool(loaded)).toMatchObject({
properties: {
left: { type: "number", description: "The first number to add" },
right: { type: "number", description: "The second number to add" },
},
})
}),
20_000,
)
it.instance("preserves attachments from structured custom tool results", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const customTools = path.join(test.directory, ".opencode", "tools")
const pluginTool = pathToFileURL(path.resolve(import.meta.dir, "../../../plugin/src/tool.ts")).href
yield* Effect.promise(() => fs.mkdir(customTools, { recursive: true }))
yield* Effect.promise(() =>
Bun.write(
path.join(customTools, "image.ts"),
[
`import { tool } from ${JSON.stringify(pluginTool)}`,
"export default tool({",
" description: 'image tool',",
" args: {},",
" execute: async () => ({",
" output: 'here is an image',",
" attachments: [{ type: 'file', mime: 'image/png', filename: 'picture.png', url: 'data:image/png;base64,AAAA' }],",
" }),",
"})",
"",
].join("\n"),
),
)
const registry = yield* ToolRegistry.Service
const loaded = (yield* registry.all()).find((tool) => tool.id === "image")
if (!loaded) throw new Error("custom image tool was not loaded")
const agents = yield* Agent.Service
const result = yield* loaded.execute({}, {
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make("msg_test"),
agent: (yield* agents.defaultInfo()).name,
abort: new AbortController().signal,
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
} satisfies Tool.Context)
expect(result.output).toBe("here is an image")
expect(result.attachments).toEqual([
{ type: "file", mime: "image/png", filename: "picture.png", url: "data:image/png;base64,AAAA" },
])
}),
)
it.instance("loads legacy JSON-schema-shaped custom tools with wire schema", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const tools = path.join(test.directory, ".opencode", "tools")
yield* Effect.promise(() => fs.mkdir(tools, { recursive: true }))
yield* Effect.promise(() =>
Bun.write(
path.join(tools, "legacy.ts"),
[
"export default {",
" description: 'legacy schema tool',",
" args: { text: { type: 'string', description: 'Text to render' } },",
" execute: async ({ text }) => text,",
"}",
"",
].join("\n"),
),
)
const registry = yield* ToolRegistry.Service
const loaded = (yield* registry.all()).find((tool) => tool.id === "legacy")
if (!loaded) throw new Error("legacy custom tool was not loaded")
expect(ToolJsonSchema.fromTool(loaded)).toMatchObject({
type: "object",
properties: {
text: { type: "string", description: "Text to render" },
},
required: ["text"],
})
}),
)
it.instance("loads tools with external dependencies without crashing", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const opencode = path.join(test.directory, ".opencode")
const tools = path.join(opencode, "tools")
yield* Effect.promise(() => fs.mkdir(tools, { recursive: true }))
yield* Effect.promise(() =>
Bun.write(
path.join(opencode, "package.json"),
JSON.stringify({
name: "custom-tools",
dependencies: {
"@opencode-ai/plugin": "^0.0.0",
cowsay: "^1.6.0",
},
}),
),
)
yield* Effect.promise(() =>
Bun.write(
path.join(opencode, "package-lock.json"),
JSON.stringify({
name: "custom-tools",
lockfileVersion: 3,
packages: {
"": {
dependencies: {
"@opencode-ai/plugin": "^0.0.0",
cowsay: "^1.6.0",
},
},
},
}),
),
)
const cowsay = path.join(opencode, "node_modules", "cowsay")
yield* Effect.promise(() => fs.mkdir(cowsay, { recursive: true }))
yield* Effect.promise(() =>
Bun.write(
path.join(cowsay, "package.json"),
JSON.stringify({
name: "cowsay",
type: "module",
exports: "./index.js",
}),
),
)
yield* Effect.promise(() =>
Bun.write(
path.join(cowsay, "index.js"),
["export function say({ text }) {", " return `moo ${text}`", "}", ""].join("\n"),
),
)
yield* Effect.promise(() =>
Bun.write(
path.join(tools, "cowsay.ts"),
[
"import { say } from 'cowsay'",
"export default {",
" description: 'tool that imports cowsay at top level',",
" args: { text: { type: 'string' } },",
" execute: async ({ text }: { text: string }) => {",
" return say({ text })",
" },",
"}",
"",
].join("\n"),
),
)
const registry = yield* ToolRegistry.Service
const ids = yield* registry.ids()
expect(ids).toContain("cowsay")
}),
)
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,136 @@
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { Cause, Effect, Exit, Layer } from "effect"
import { afterEach, describe, expect } from "bun:test"
import path from "path"
import { pathToFileURL } from "url"
import type { Permission } from "../../src/permission"
import type { Tool } from "@/tool/tool"
import { SkillTool } from "../../src/tool/skill"
import { ToolRegistry } from "@/tool/registry"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { SessionID, MessageID } from "../../src/session/schema"
import { testEffect } from "../lib/effect"
const baseCtx: Omit<Tool.Context, "ask"> = {
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make("msg_test"),
callID: "",
agent: "build",
abort: AbortSignal.any([]),
messages: [],
metadata: () => Effect.void,
}
afterEach(async () => {
await disposeAllInstances()
})
const node = CrossSpawnSpawner.defaultLayer
const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node).pipe(Layer.provide(Ripgrep.defaultLayer)))
describe("tool.skill", () => {
it.instance("execute returns skill content block with files", () =>
Effect.gen(function* () {
const dir = (yield* TestInstance).directory
const skill = path.join(dir, ".opencode", "skill", "tool-skill")
yield* Effect.promise(() =>
Bun.write(
path.join(skill, "SKILL.md"),
`---
name: tool-skill
description: Skill for tool tests.
---
# Tool Skill
Use this skill.
`,
),
)
yield* Effect.promise(() => Bun.write(path.join(skill, "scripts", "demo.txt"), "demo"))
const home = process.env.OPENCODE_TEST_HOME
process.env.OPENCODE_TEST_HOME = dir
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
process.env.OPENCODE_TEST_HOME = home
}),
)
const registry = yield* ToolRegistry.Service
const agent = { name: "build", mode: "primary" as const, permission: [], options: {} }
const tool = (yield* registry.tools({
providerID: "opencode" as any,
modelID: "gpt-5" as any,
agent,
})).find((tool) => tool.id === SkillTool.id)
if (!tool) throw new Error("Skill tool not found")
expect(tool.description).not.toContain("tool-skill")
expect(tool.description).not.toContain("Skill for tool tests.")
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
const ctx: Tool.Context = {
...baseCtx,
ask: (req) =>
Effect.sync(() => {
requests.push(req)
}),
}
const result = yield* tool.execute({ name: "tool-skill" }, ctx)
const file = path.resolve(skill, "scripts", "demo.txt")
expect(requests.length).toBe(1)
expect(requests[0].permission).toBe("skill")
expect(requests[0].patterns).toContain("tool-skill")
expect(requests[0].always).toContain("tool-skill")
expect(result.metadata.dir).toBe(skill)
expect(result.output).toContain(`<skill_content name="tool-skill">`)
expect(result.output).toContain(`Base directory for this skill: ${pathToFileURL(skill).href}`)
expect(result.output).toContain(`<file>${file}</file>`)
}),
)
it.instance("execute preserves not found message", () =>
Effect.gen(function* () {
const dir = (yield* TestInstance).directory
const home = process.env.OPENCODE_TEST_HOME
process.env.OPENCODE_TEST_HOME = dir
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
process.env.OPENCODE_TEST_HOME = home
}),
)
const registry = yield* ToolRegistry.Service
const agent = { name: "build", mode: "primary" as const, permission: [], options: {} }
const tool = (yield* registry.tools({
providerID: "opencode" as any,
modelID: "gpt-5" as any,
agent,
})).find((tool) => tool.id === SkillTool.id)
if (!tool) throw new Error("Skill tool not found")
const exit = yield* tool
.execute(
{ name: "missing-skill" },
{
...baseCtx,
ask: () => Effect.void,
},
)
.pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
const error = Cause.squash(exit.cause)
expect(error).toBeInstanceOf(Error)
if (error instanceof Error) expect(error.message).toContain('Skill "missing-skill" not found.')
}
}),
)
})

View File

@@ -0,0 +1,898 @@
import { afterEach, describe, expect } from "bun:test"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { Database } from "@opencode-ai/core/database/database"
import { Deferred, Effect, Exit, Fiber, Layer } from "effect"
import { Agent } from "../../src/agent/agent"
import { BackgroundJob } from "@/background/job"
import { EventV2Bridge } from "@/event-v2-bridge"
import { Config } from "@/config/config"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { Session } from "@/session/session"
import type { SessionPrompt } from "../../src/session/prompt"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { SessionRunState } from "@/session/run-state"
import { SessionStatus } from "@/session/status"
import { TaskTool, type TaskPromptOps } from "../../src/tool/task"
import { Truncate } from "@/tool/truncate"
import { ToolRegistry } from "@/tool/registry"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { disposeAllInstances } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
afterEach(async () => {
await disposeAllInstances()
})
const ref = {
providerID: ProviderV2.ID.make("test"),
modelID: ModelV2.ID.make("test-model"),
}
const layer = (flags: Partial<RuntimeFlags.Info> = {}) =>
Layer.mergeAll(
Agent.defaultLayer,
BackgroundJob.defaultLayer,
EventV2Bridge.defaultLayer,
Config.defaultLayer,
CrossSpawnSpawner.defaultLayer,
Session.defaultLayer,
SessionRunState.defaultLayer,
SessionStatus.defaultLayer,
Truncate.defaultLayer,
ToolRegistry.defaultLayer,
Database.defaultLayer,
RuntimeFlags.layer(flags),
).pipe(Layer.provide(Ripgrep.defaultLayer))
const it = testEffect(layer())
const background = testEffect(layer({ experimentalBackgroundSubagents: true }))
function defer<T>() {
let resolve!: (value: T | PromiseLike<T>) => void
const promise = new Promise<T>((done) => {
resolve = done
})
return { promise, resolve }
}
const seed = Effect.fn("TaskToolTest.seed")(function* (title = "Pinned") {
const session = yield* Session.Service
const chat = yield* session.create({ title })
const user = yield* session.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: chat.id,
agent: "build",
model: ref,
time: { created: Date.now() },
})
const assistant: SessionV1.Assistant = {
id: MessageID.ascending(),
role: "assistant",
parentID: user.id,
sessionID: chat.id,
mode: "build",
agent: "build",
cost: 0,
path: { cwd: "/tmp", root: "/tmp" },
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
modelID: ref.modelID,
providerID: ref.providerID,
variant: "xhigh",
time: { created: Date.now() },
}
yield* session.updateMessage(assistant)
return { chat, assistant }
})
function stubOps(opts?: { onPrompt?: (input: SessionPrompt.PromptInput) => void; text?: string }): TaskPromptOps {
return {
cancel: () => Effect.void,
resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]),
prompt: (input) =>
Effect.sync(() => {
opts?.onPrompt?.(input)
return reply(input, opts?.text ?? "done")
}),
}
}
function reply(input: SessionPrompt.PromptInput, text: string): SessionV1.WithParts {
const id = MessageID.ascending()
return {
info: {
id,
role: "assistant",
parentID: input.messageID ?? MessageID.ascending(),
sessionID: input.sessionID,
mode: input.agent ?? "general",
agent: input.agent ?? "general",
cost: 0,
path: { cwd: "/tmp", root: "/tmp" },
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
modelID: input.model?.modelID ?? ref.modelID,
providerID: input.model?.providerID ?? ref.providerID,
time: { created: Date.now() },
finish: "stop",
},
parts: [
{
id: PartID.ascending(),
messageID: id,
sessionID: input.sessionID,
type: "text",
text,
},
],
}
}
describe("tool.task", () => {
it.instance(
"description sorts subagents by name and is stable across calls",
() =>
Effect.gen(function* () {
const agent = yield* Agent.Service
const build = yield* agent.get("build")
const registry = yield* ToolRegistry.Service
const get = Effect.fnUntraced(function* () {
const tools = yield* registry.tools({ ...ref, agent: build })
return tools.find((tool) => tool.id === TaskTool.id)?.description ?? ""
})
const first = yield* get()
const second = yield* get()
expect(first).toBe(second)
const alpha = first.indexOf("- alpha: Alpha agent")
const explore = first.indexOf("- explore:")
const general = first.indexOf("- general:")
const zebra = first.indexOf("- zebra: Zebra agent")
expect(alpha).toBeGreaterThan(-1)
expect(explore).toBeGreaterThan(alpha)
expect(general).toBeGreaterThan(explore)
expect(zebra).toBeGreaterThan(general)
}),
{
config: {
agent: {
zebra: {
description: "Zebra agent",
mode: "subagent",
},
alpha: {
description: "Alpha agent",
mode: "subagent",
},
},
},
},
)
it.instance(
"description hides denied subagents for the caller",
() =>
Effect.gen(function* () {
const agent = yield* Agent.Service
const build = yield* agent.get("build")
const registry = yield* ToolRegistry.Service
const description =
(yield* registry.tools({ ...ref, agent: build })).find((tool) => tool.id === TaskTool.id)?.description ?? ""
expect(description).toContain("- alpha: Alpha agent")
expect(description).not.toContain("- zebra: Zebra agent")
}),
{
config: {
permission: {
task: {
"*": "allow",
zebra: "deny",
},
},
agent: {
zebra: {
description: "Zebra agent",
mode: "subagent",
},
alpha: {
description: "Alpha agent",
mode: "subagent",
},
},
},
},
)
it.instance("execute resumes an existing task session from task_id", () =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const { chat, assistant } = yield* seed()
const child = yield* sessions.create({ parentID: chat.id, title: "Existing child" })
const tool = yield* TaskTool
const def = yield* tool.init()
let seen: SessionPrompt.PromptInput | undefined
const promptOps = stubOps({ text: "resumed", onPrompt: (input) => (seen = input) })
const result = yield* def.execute(
{
description: "inspect bug",
prompt: "look into the cache key path",
subagent_type: "general",
task_id: child.id,
},
{
sessionID: chat.id,
messageID: assistant.id,
agent: "build",
abort: new AbortController().signal,
extra: { promptOps },
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
},
)
const kids = yield* sessions.children(chat.id)
expect(kids).toHaveLength(1)
expect(kids[0]?.id).toBe(child.id)
expect(result.metadata.sessionId).toBe(child.id)
expect(result.output).toContain(`<task id="${child.id}" state="completed">`)
expect(seen?.sessionID).toBe(child.id)
expect(seen?.variant).toBe("xhigh")
}),
)
it.instance("execute asks by default and skips checks when bypassed", () =>
Effect.gen(function* () {
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* tool.init()
const calls: unknown[] = []
const promptOps = stubOps()
const exec = (extra?: Record<string, any>) =>
def.execute(
{
description: "inspect bug",
prompt: "look into the cache key path",
subagent_type: "general",
},
{
sessionID: chat.id,
messageID: assistant.id,
agent: "build",
abort: new AbortController().signal,
extra: { promptOps, ...extra },
messages: [],
metadata: () => Effect.void,
ask: (input) =>
Effect.sync(() => {
calls.push(input)
}),
},
)
yield* exec()
yield* exec({ bypassAgentCheck: true })
expect(calls).toHaveLength(1)
expect(calls[0]).toEqual({
permission: "task",
patterns: ["general"],
always: ["*"],
metadata: {
description: "inspect bug",
subagent_type: "general",
},
})
}),
)
it.instance("execute cancels child session when abort signal fires", () =>
Effect.gen(function* () {
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* tool.init()
const ready = defer<SessionPrompt.PromptInput>()
const cancelled = defer<SessionID>()
const abort = new AbortController()
const promptOps: TaskPromptOps = {
cancel: (sessionID) =>
Effect.sync(() => {
cancelled.resolve(sessionID)
}),
resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]),
prompt: (input) =>
Effect.promise(() => {
ready.resolve(input)
return cancelled.promise
}).pipe(Effect.as(reply(input, "cancelled"))),
}
const fiber = yield* def
.execute(
{
description: "inspect bug",
prompt: "look into the cache key path",
subagent_type: "general",
},
{
sessionID: chat.id,
messageID: assistant.id,
agent: "build",
abort: abort.signal,
extra: { promptOps },
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
},
)
.pipe(Effect.forkChild)
const input = yield* Effect.promise(() => ready.promise)
abort.abort()
expect(yield* Effect.promise(() => cancelled.promise)).toBe(input.sessionID)
const exit = yield* Fiber.await(fiber)
expect(Exit.isSuccess(exit)).toBe(true)
}),
)
it.instance("execute creates a child when task_id does not exist", () =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* tool.init()
let seen: SessionPrompt.PromptInput | undefined
const promptOps = stubOps({ text: "created", onPrompt: (input) => (seen = input) })
const result = yield* def.execute(
{
description: "inspect bug",
prompt: "look into the cache key path",
subagent_type: "general",
task_id: "ses_missing",
},
{
sessionID: chat.id,
messageID: assistant.id,
agent: "build",
abort: new AbortController().signal,
extra: { promptOps },
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
},
)
const kids = yield* sessions.children(chat.id)
expect(kids).toHaveLength(1)
expect(kids[0]?.id).toBe(result.metadata.sessionId)
expect(result.metadata.sessionId).not.toBe("ses_missing")
expect(result.output).toContain(`<task id="${result.metadata.sessionId}" state="completed">`)
expect(seen?.sessionID).toBe(result.metadata.sessionId)
}),
)
it.instance(
"execute shapes child permissions for task, todowrite, and primary tools",
() =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* tool.init()
let seen: SessionPrompt.PromptInput | undefined
const promptOps = stubOps({ onPrompt: (input) => (seen = input) })
const result = yield* def.execute(
{
description: "inspect bug",
prompt: "look into the cache key path",
subagent_type: "reviewer",
},
{
sessionID: chat.id,
messageID: assistant.id,
agent: "build",
abort: new AbortController().signal,
extra: { promptOps },
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
},
)
const child = yield* sessions.get(result.metadata.sessionId)
expect(child.parentID).toBe(chat.id)
expect(child.agent).toBe("reviewer")
expect(child.permission).toEqual([
{
permission: "todowrite",
pattern: "*",
action: "deny",
},
{
permission: "bash",
pattern: "*",
action: "deny",
},
{
permission: "read",
pattern: "*",
action: "deny",
},
])
expect(seen?.tools).toBeUndefined()
}),
{
config: {
agent: {
reviewer: {
mode: "subagent",
permission: {
task: "allow",
},
},
},
experimental: {
primary_tools: ["bash", "read"],
},
},
},
)
it.instance("rejects background execution when the experiment is disabled", () =>
Effect.gen(function* () {
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* tool.init()
const exit = yield* def
.execute(
{
description: "inspect bug",
prompt: "look into the cache key path",
subagent_type: "general",
background: true,
},
{
sessionID: chat.id,
messageID: assistant.id,
agent: "build",
abort: new AbortController().signal,
extra: { promptOps: stubOps() },
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
},
)
.pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
}),
)
it.instance("promotes a running foreground task without restarting it", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* tool.init()
const ready = yield* Deferred.make<void>()
const done = yield* Deferred.make<void>()
const injected = yield* Deferred.make<SessionPrompt.PromptInput>()
let runs = 0
const promptOps: TaskPromptOps = {
cancel: () => Effect.void,
resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]),
prompt: (input) => {
if (input.sessionID === chat.id) {
return Deferred.succeed(injected, input).pipe(Effect.as(reply(input, "injected")))
}
return Effect.gen(function* () {
runs += 1
yield* Deferred.succeed(ready, undefined)
yield* Deferred.await(done)
return reply(input, "background done")
})
},
}
const fiber = yield* def
.execute(
{
description: "inspect bug",
prompt: "look into the cache key path",
subagent_type: "general",
},
{
sessionID: chat.id,
messageID: assistant.id,
agent: "build",
abort: new AbortController().signal,
extra: { promptOps },
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
},
)
.pipe(Effect.forkChild)
yield* Deferred.await(ready)
const job = (yield* jobs.list())[0]
expect(job).toBeDefined()
if (!job) throw new Error("task job not found")
expect(job.metadata?.parentSessionId).toBe(chat.id)
yield* jobs.promote(job.id)
const result = yield* Fiber.join(fiber)
expect(result.metadata.background).toBe(true)
expect(result.output).toContain(`state="running"`)
expect((yield* jobs.get(result.metadata.sessionId))?.status).toBe("running")
expect(runs).toBe(1)
yield* Deferred.succeed(done, undefined)
expect((yield* jobs.wait({ id: result.metadata.sessionId })).info?.output).toBe("background done")
expect((yield* Deferred.await(injected)).parts[0]?.type).toBe("text")
expect(runs).toBe(1)
}),
)
background.instance("execute launches background tasks without waiting for completion", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* tool.init()
const result = yield* def.execute(
{
description: "inspect bug",
prompt: "look into the cache key path",
subagent_type: "general",
background: true,
},
{
sessionID: chat.id,
messageID: assistant.id,
agent: "build",
abort: new AbortController().signal,
extra: {
promptOps: {
...stubOps(),
prompt: () => Effect.never,
} satisfies TaskPromptOps,
},
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
},
)
const job = yield* jobs.get(result.metadata.sessionId)
expect(result.metadata.background).toBe(true)
expect(result.output).toContain(`state="running"`)
expect(job?.status).toBe("running")
}),
)
background.instance("background task completion waits for running updates", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* tool.init()
const first = defer<void>()
const second = defer<void>()
const updated = defer<SessionPrompt.PromptInput>()
const injected = defer<SessionPrompt.PromptInput>()
let prompts = 0
const promptOps: TaskPromptOps = {
...stubOps(),
prompt: (input) => {
if (input.sessionID === chat.id) {
injected.resolve(input)
return Effect.succeed(reply(input, "done"))
}
prompts++
if (prompts === 1) return Effect.promise(() => first.promise).pipe(Effect.as(reply(input, "first done")))
updated.resolve(input)
return Effect.promise(() => second.promise).pipe(Effect.as(reply(input, "second done")))
},
}
const context = {
sessionID: chat.id,
messageID: assistant.id,
agent: "build",
abort: new AbortController().signal,
extra: { promptOps },
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
}
const started = yield* def.execute(
{
description: "inspect bug",
prompt: "look into the cache key path",
subagent_type: "general",
background: true,
},
context,
)
const result = yield* def.execute(
{
description: "add investigation scope",
prompt: "also inspect cancellation",
subagent_type: "general",
task_id: started.metadata.sessionId,
},
context,
)
expect(result.metadata.sessionId).toBe(started.metadata.sessionId)
expect(result.metadata.background).toBe(true)
expect(result.output).toContain("Background task updated")
first.resolve()
expect((yield* jobs.get(started.metadata.sessionId))?.status).toBe("running")
expect((yield* Effect.promise(() => updated.promise)).parts).toEqual([
{ type: "text", text: "also inspect cancellation" },
])
second.resolve()
const waited = yield* jobs.wait({ id: started.metadata.sessionId, timeout: 1_000 })
expect(waited.info?.status).toBe("completed")
expect(waited.info?.output).toBe("second done")
const notification = yield* Effect.promise(() => injected.promise)
expect(notification.variant).toBe("xhigh")
expect(notification.parts[0]?.type).toBe("text")
if (notification.parts[0]?.type === "text") expect(notification.parts[0].text).toContain("second done")
}),
)
background.instance("background tasks complete through the background job service", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* tool.init()
const result = yield* def.execute(
{
description: "inspect bug",
prompt: "look into the cache key path",
subagent_type: "general",
background: true,
},
{
sessionID: chat.id,
messageID: assistant.id,
agent: "build",
abort: new AbortController().signal,
extra: { promptOps: stubOps({ text: "background done" }) },
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
},
)
const waited = yield* jobs.wait({ id: result.metadata.sessionId, timeout: 1_000 })
expect(waited.timedOut).toBe(false)
expect(waited.info?.status).toBe("completed")
expect(waited.info?.output).toBe("background done")
}),
)
background.instance("background task completion does not wait for the parent async prompt", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* tool.init()
const result = yield* def.execute(
{
description: "inspect bug",
prompt: "look into the cache key path",
subagent_type: "general",
background: true,
},
{
sessionID: chat.id,
messageID: assistant.id,
agent: "build",
abort: new AbortController().signal,
extra: {
promptOps: {
...stubOps({ text: "background done" }),
prompt: (input) =>
input.sessionID === chat.id ? Effect.never : Effect.succeed(reply(input, "background done")),
} satisfies TaskPromptOps,
},
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
},
)
const waited = yield* jobs.wait({ id: result.metadata.sessionId, timeout: 1_000 })
expect(waited.timedOut).toBe(false)
expect(waited.info?.status).toBe("completed")
}),
)
background.instance("removing the parent session cancels running background tasks", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const sessions = yield* Session.Service
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* tool.init()
const result = yield* def.execute(
{
description: "inspect bug",
prompt: "look into the cache key path",
subagent_type: "general",
background: true,
},
{
sessionID: chat.id,
messageID: assistant.id,
agent: "build",
abort: new AbortController().signal,
extra: {
promptOps: {
...stubOps(),
prompt: () => Effect.never,
} satisfies TaskPromptOps,
},
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
},
)
yield* sessions.remove(chat.id)
const waited = yield* jobs.wait({ id: result.metadata.sessionId, timeout: 1_000 })
expect(waited.timedOut).toBe(false)
expect(waited.info?.status).toBe("cancelled")
}),
)
background.instance("removing the child task session cancels its running background task", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const sessions = yield* Session.Service
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* tool.init()
const result = yield* def.execute(
{
description: "inspect bug",
prompt: "look into the cache key path",
subagent_type: "general",
background: true,
},
{
sessionID: chat.id,
messageID: assistant.id,
agent: "build",
abort: new AbortController().signal,
extra: {
promptOps: {
...stubOps(),
prompt: () => Effect.never,
} satisfies TaskPromptOps,
},
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
},
)
yield* sessions.remove(result.metadata.sessionId)
const waited = yield* jobs.wait({ id: result.metadata.sessionId, timeout: 1_000 })
expect(waited.timedOut).toBe(false)
expect(waited.info?.status).toBe("cancelled")
}),
)
background.instance("cancelling the parent run cancels running background tasks", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const runState = yield* SessionRunState.Service
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* tool.init()
const result = yield* def.execute(
{
description: "inspect bug",
prompt: "look into the cache key path",
subagent_type: "general",
background: true,
},
{
sessionID: chat.id,
messageID: assistant.id,
agent: "build",
abort: new AbortController().signal,
extra: {
promptOps: {
...stubOps(),
prompt: () => Effect.never,
} satisfies TaskPromptOps,
},
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
},
)
yield* runState.cancel(chat.id)
const waited = yield* jobs.wait({ id: result.metadata.sessionId, timeout: 1_000 })
expect(waited.timedOut).toBe(false)
expect(waited.info?.status).toBe("cancelled")
}),
)
it.instance("cancelling a child run cancels its own pre-runner task job", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const runState = yield* SessionRunState.Service
const sessions = yield* Session.Service
const { chat } = yield* seed()
const child = yield* sessions.create({ parentID: chat.id, title: "child" })
yield* jobs.start({
id: child.id,
type: "task",
metadata: { parentSessionId: chat.id, sessionId: child.id },
run: Effect.never,
})
yield* runState.cancel(child.id)
expect((yield* jobs.get(child.id))?.status).toBe("cancelled")
}),
)
it.instance("cancelling a parent run recursively cancels descendant background tasks", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const runState = yield* SessionRunState.Service
const sessions = yield* Session.Service
const { chat } = yield* seed()
const child = yield* sessions.create({ parentID: chat.id, title: "child" })
const grandchild = yield* sessions.create({ parentID: child.id, title: "grandchild" })
yield* jobs.start({
id: child.id,
type: "task",
metadata: { parentSessionId: chat.id, sessionId: child.id },
run: Effect.never,
})
yield* jobs.start({
id: grandchild.id,
type: "task",
metadata: { parentSessionId: child.id, sessionId: grandchild.id },
run: Effect.never,
})
yield* runState.cancel(chat.id)
expect((yield* jobs.get(child.id))?.status).toBe("cancelled")
expect((yield* jobs.get(grandchild.id))?.status).toBe("cancelled")
}),
)
})

View File

@@ -0,0 +1,153 @@
import { describe, expect } from "bun:test"
import { Cause, Effect, Exit, Layer, Schema } from "effect"
import { Agent } from "../../src/agent/agent"
import { MessageID, SessionID } from "../../src/session/schema"
import { Tool } from "@/tool/tool"
import { Truncate } from "@/tool/truncate"
import { testEffect } from "../lib/effect"
const it = testEffect(Layer.mergeAll(Truncate.defaultLayer, Agent.defaultLayer))
const params = Schema.Struct({ input: Schema.String })
function makeCtx(): Tool.Context {
return {
sessionID: SessionID.descending(),
messageID: MessageID.ascending(),
agent: "build",
abort: new AbortController().signal,
messages: [],
metadata() {
return Effect.void
},
ask() {
return Effect.void
},
}
}
function makeTool(id: string, executeFn?: () => void) {
return {
description: "test tool",
parameters: params,
execute() {
executeFn?.()
return Effect.succeed({ title: "test", output: "ok", metadata: {} })
},
}
}
describe("Tool.define", () => {
it.effect("object-defined tool does not mutate the original init object", () =>
Effect.gen(function* () {
const original = makeTool("test")
const originalExecute = original.execute
const info = yield* Tool.define("test-tool", Effect.succeed(original))
yield* info.init()
yield* info.init()
yield* info.init()
expect(original.execute).toBe(originalExecute)
}),
)
it.effect("effect-defined tool returns fresh objects and is unaffected", () =>
Effect.gen(function* () {
const info = yield* Tool.define(
"test-fn-tool",
Effect.succeed(() => Effect.succeed(makeTool("test"))),
)
const first = yield* info.init()
const second = yield* info.init()
expect(first).not.toBe(second)
}),
)
it.effect("object-defined tool returns distinct objects per init() call", () =>
Effect.gen(function* () {
const info = yield* Tool.define("test-copy", Effect.succeed(makeTool("test")))
const first = yield* info.init()
const second = yield* info.init()
expect(first).not.toBe(second)
}),
)
it.effect("execute receives decoded parameters", () =>
Effect.gen(function* () {
const parameters = Schema.Struct({
count: Schema.NumberFromString.pipe(Schema.optional, Schema.withDecodingDefaultType(Effect.succeed(5))),
})
const calls: Array<Schema.Schema.Type<typeof parameters>> = []
const info = yield* Tool.define(
"test-decoded",
Effect.succeed({
description: "test tool",
parameters,
execute(args: Schema.Schema.Type<typeof parameters>) {
calls.push(args)
return Effect.succeed({ title: "test", output: "ok", metadata: { truncated: false } })
},
}),
)
const ctx = makeCtx()
const tool = yield* info.init()
const execute = tool.execute as unknown as (args: unknown, ctx: Tool.Context) => ReturnType<typeof tool.execute>
yield* execute({}, ctx)
yield* execute({ count: "7" }, ctx)
expect(calls).toEqual([{ count: 5 }, { count: 7 }])
}),
)
// Regression for #28438: the wrap is the canonical "untyped → typed" boundary.
// When the LLM emits a tool call with a payload that fails the parameter
// schema, the wrap must surface a typed `Tool.InvalidArgumentsError` whose
// `.message` is the actionable prose the AI SDK feeds back to the model.
it.effect("invalid args surface as Tool.InvalidArgumentsError with friendly message and JSON path", () =>
Effect.gen(function* () {
const parameters = Schema.Struct({
questions: Schema.Array(
Schema.Struct({
question: Schema.String,
options: Schema.Array(Schema.String),
}),
),
})
const info = yield* Tool.define(
"qtest",
Effect.succeed({
description: "test tool",
parameters,
execute() {
return Effect.succeed({ title: "ok", output: "ok", metadata: { truncated: false } })
},
}),
)
const tool = yield* info.init()
const execute = tool.execute as unknown as (args: unknown, ctx: Tool.Context) => ReturnType<typeof tool.execute>
// Missing required `question` field on the first questions[] entry.
const exit = yield* execute({ questions: [{ options: ["a"] }] }, makeCtx()).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (!Exit.isFailure(exit)) return
// The wrap ends with Effect.orDie, so the failure lives in the cause as a
// defect. Recover the typed instance from there.
const die = exit.cause.reasons.find(Cause.isDieReason)
const error = die?.defect
expect(error).toBeInstanceOf(Tool.InvalidArgumentsError)
const args = error as Tool.InvalidArgumentsError
expect(args.tool).toBe("qtest")
expect(args.message).toContain("qtest tool was called with invalid arguments")
expect(args.message).toContain("Please rewrite the input")
expect(args.message).toContain(`["questions"][0]["question"]`)
}),
)
})

View File

@@ -0,0 +1,266 @@
import { describe, test, expect } from "bun:test"
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
import { NodeFileSystem } from "@effect/platform-node"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Effect, FileSystem, Layer } from "effect"
import { Truncate } from "@/tool/truncate"
import { Config } from "@/config/config"
import { Identifier } from "../../src/id/id"
import { Process } from "@/util/process"
import path from "path"
import { testEffect } from "../lib/effect"
import { writeFileStringScoped } from "../lib/filesystem"
import { TestConfig } from "../fixture/config"
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
const ROOT = path.resolve(import.meta.dir, "..", "..")
const it = testEffect(Layer.mergeAll(Truncate.defaultLayer, NodeFileSystem.layer, FSUtil.defaultLayer))
const configuredLayer = (cfg: ConfigV1.Info) =>
Layer.mergeAll(
Truncate.defaultLayer,
NodeFileSystem.layer,
FSUtil.defaultLayer,
TestConfig.layer({ get: () => Effect.succeed(cfg) }),
)
const configuredIt = (cfg: ConfigV1.Info) => testEffect(configuredLayer(cfg))
describe("Truncate", () => {
describe("output", () => {
it.live("truncates large json file by bytes", () =>
Effect.gen(function* () {
const svc = yield* Truncate.Service
const fsys = yield* FSUtil.Service
const content = yield* fsys.readFileString(path.join(FIXTURES_DIR, "models-api.json"))
const result = yield* svc.output(content)
expect(result.truncated).toBe(true)
expect(result.content).toContain("truncated...")
if (result.truncated) expect(result.outputPath).toBeDefined()
}),
)
it.live("returns content unchanged when under limits", () =>
Effect.gen(function* () {
const svc = yield* Truncate.Service
const content = "line1\nline2\nline3"
const result = yield* svc.output(content)
expect(result.truncated).toBe(false)
expect(result.content).toBe(content)
}),
)
it.live("truncates by line count", () =>
Effect.gen(function* () {
const svc = yield* Truncate.Service
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
const result = yield* svc.output(lines, { maxLines: 10 })
expect(result.truncated).toBe(true)
expect(result.content).toContain("...90 lines truncated...")
}),
)
it.live("truncates by byte count", () =>
Effect.gen(function* () {
const svc = yield* Truncate.Service
const content = "a".repeat(1000)
const result = yield* svc.output(content, { maxBytes: 100 })
expect(result.truncated).toBe(true)
expect(result.content).toContain("truncated...")
}),
)
it.live("truncates from head by default", () =>
Effect.gen(function* () {
const svc = yield* Truncate.Service
const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n")
const result = yield* svc.output(lines, { maxLines: 3 })
expect(result.truncated).toBe(true)
expect(result.content).toContain("line0")
expect(result.content).toContain("line1")
expect(result.content).toContain("line2")
expect(result.content).not.toContain("line9")
}),
)
it.live("truncates from tail when direction is tail", () =>
Effect.gen(function* () {
const svc = yield* Truncate.Service
const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n")
const result = yield* svc.output(lines, { maxLines: 3, direction: "tail" })
expect(result.truncated).toBe(true)
expect(result.content).toContain("line7")
expect(result.content).toContain("line8")
expect(result.content).toContain("line9")
expect(result.content).not.toContain("line0")
}),
)
test("uses default MAX_LINES and MAX_BYTES", () => {
expect(Truncate.MAX_LINES).toBe(2000)
expect(Truncate.MAX_BYTES).toBe(50 * 1024)
})
it.live("limits() falls back to MAX_LINES/MAX_BYTES when Config is not provided", () =>
Effect.gen(function* () {
const svc = yield* Truncate.Service
const resolved = yield* svc.limits()
expect(resolved.maxLines).toBe(Truncate.MAX_LINES)
expect(resolved.maxBytes).toBe(Truncate.MAX_BYTES)
}),
)
describe("with tool_output config", () => {
const limitsIt = configuredIt({ tool_output: { max_lines: 123, max_bytes: 456 } })
limitsIt.live("limits() reflects config overrides", () =>
Effect.gen(function* () {
const resolved = yield* (yield* Truncate.Service).limits()
expect(resolved.maxLines).toBe(123)
expect(resolved.maxBytes).toBe(456)
}),
)
// Huge byte budget isolates line truncation. 100 lines against max_lines: 10
// proves the configured line limit is what `output()` enforces.
const lineIt = configuredIt({ tool_output: { max_lines: 10, max_bytes: 1024 * 1024 } })
lineIt.live("output() truncates to configured max_lines", () =>
Effect.gen(function* () {
const content = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
const result = yield* (yield* Truncate.Service).output(content)
expect(result.truncated).toBe(true)
expect(result.content).toContain("...90 lines truncated...")
}),
)
// Huge line budget isolates byte truncation.
const byteIt = configuredIt({ tool_output: { max_lines: 1_000_000, max_bytes: 100 } })
byteIt.live("output() truncates to configured max_bytes", () =>
Effect.gen(function* () {
const content = "a".repeat(1000)
const result = yield* (yield* Truncate.Service).output(content)
expect(result.truncated).toBe(true)
expect(result.content).toContain("bytes truncated...")
}),
)
const overrideIt = configuredIt({ tool_output: { max_lines: 10, max_bytes: 100 } })
overrideIt.live("per-call options still override config", () =>
Effect.gen(function* () {
const content = Array.from({ length: 50 }, (_, i) => `line${i}`).join("\n")
const result = yield* (yield* Truncate.Service).output(content, {
maxLines: 1000,
maxBytes: 1024 * 1024,
})
expect(result.truncated).toBe(false)
}),
)
})
it.live("large single-line file truncates with byte message", () =>
Effect.gen(function* () {
const svc = yield* Truncate.Service
const fsys = yield* FSUtil.Service
const content = yield* fsys.readFileString(path.join(FIXTURES_DIR, "models-api.json"))
const result = yield* svc.output(content)
expect(result.truncated).toBe(true)
expect(result.content).toContain("bytes truncated...")
expect(Buffer.byteLength(content, "utf-8")).toBeGreaterThan(Truncate.MAX_BYTES)
}),
)
it.live("writes full output to file when truncated", () =>
Effect.gen(function* () {
const svc = yield* Truncate.Service
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
const result = yield* svc.output(lines, { maxLines: 10 })
expect(result.truncated).toBe(true)
expect(result.content).toContain("The tool call succeeded but the output was truncated")
expect(result.content).toContain("Grep")
if (!result.truncated) throw new Error("expected truncated")
expect(result.outputPath).toBeDefined()
expect(result.outputPath).toContain("tool_")
const fsys = yield* FSUtil.Service
const written = yield* fsys.readFileString(result.outputPath!)
expect(written).toBe(lines)
}),
)
it.live("suggests Task tool when agent has task permission", () =>
Effect.gen(function* () {
const svc = yield* Truncate.Service
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
const agent = { permission: [{ permission: "task", pattern: "*", action: "allow" as const }] }
const result = yield* svc.output(lines, { maxLines: 10 }, agent as any)
expect(result.truncated).toBe(true)
expect(result.content).toContain("Grep")
expect(result.content).toContain("Task tool")
}),
)
it.live("omits Task tool hint when agent lacks task permission", () =>
Effect.gen(function* () {
const svc = yield* Truncate.Service
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
const agent = { permission: [{ permission: "task", pattern: "*", action: "deny" as const }] }
const result = yield* svc.output(lines, { maxLines: 10 }, agent as any)
expect(result.truncated).toBe(true)
expect(result.content).toContain("Grep")
expect(result.content).not.toContain("Task tool")
}),
)
it.live("does not write file when not truncated", () =>
Effect.gen(function* () {
const svc = yield* Truncate.Service
const content = "short content"
const result = yield* svc.output(content)
expect(result.truncated).toBe(false)
if (result.truncated) throw new Error("expected not truncated")
expect("outputPath" in result).toBe(false)
}),
)
test("loads truncate effect in a fresh process", async () => {
const out = await Process.run([process.execPath, "run", path.join(ROOT, "src", "tool", "truncate.ts")], {
cwd: ROOT,
})
expect(out.code).toBe(0)
}, 20000)
})
describe("cleanup", () => {
const DAY_MS = 24 * 60 * 60 * 1000
it.live("deletes files older than 7 days and preserves recent files", () =>
Effect.gen(function* () {
const svc = yield* Truncate.Service
const fs = yield* FileSystem.FileSystem
yield* fs.makeDirectory(Truncate.DIR, { recursive: true })
const old = path.join(Truncate.DIR, Identifier.create("tool", "ascending", Date.now() - 10 * DAY_MS))
const recent = path.join(Truncate.DIR, Identifier.create("tool", "ascending", Date.now() - 3 * DAY_MS))
yield* writeFileStringScoped(old, "old content")
yield* writeFileStringScoped(recent, "recent content")
yield* svc.cleanup()
expect(yield* fs.exists(old)).toBe(false)
expect(yield* fs.exists(recent)).toBe(true)
}),
)
})
})

View File

@@ -0,0 +1,113 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { Agent } from "../../src/agent/agent"
import { Truncate } from "@/tool/truncate"
import { WebFetchTool } from "../../src/tool/webfetch"
import { SessionID, MessageID } from "../../src/session/schema"
import { Tool } from "@/tool/tool"
import { testEffect } from "../lib/effect"
const it = testEffect(Layer.mergeAll(FetchHttpClient.layer, Truncate.defaultLayer, Agent.defaultLayer))
const ctx = {
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make("msg_message"),
callID: "",
agent: "build",
abort: AbortSignal.any([]),
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
}
const withFetch = <A, E, R>(
fetch: (req: Request) => Response | Promise<Response>,
fn: (url: URL) => Effect.Effect<A, E, R>,
) =>
Effect.acquireUseRelease(
Effect.sync(() => Bun.serve({ port: 0, fetch })),
(server) => fn(server.url),
(server) => Effect.sync(() => server.stop(true)),
)
const exec = Effect.fn("WebFetchToolTest.exec")(function* (args: Tool.InferParameters<typeof WebFetchTool>) {
const info = yield* WebFetchTool
const tool = yield* info.init()
return yield* tool.execute(args, ctx)
})
describe("tool.webfetch", () => {
it.instance("returns image responses as file attachments", () =>
Effect.gen(function* () {
const bytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10])
yield* withFetch(
() => new Response(bytes, { status: 200, headers: { "content-type": "IMAGE/PNG; charset=binary" } }),
(url) =>
Effect.gen(function* () {
const result = yield* exec({ url: new URL("/image.png", url).toString(), format: "markdown" })
expect(result.output).toBe("Image fetched successfully")
expect(result.attachments).toBeDefined()
expect(result.attachments?.length).toBe(1)
expect(result.attachments?.[0].type).toBe("file")
expect(result.attachments?.[0].mime).toBe("image/png")
expect(result.attachments?.[0].url.startsWith("data:image/png;base64,")).toBe(true)
expect(result.attachments?.[0]).not.toHaveProperty("id")
expect(result.attachments?.[0]).not.toHaveProperty("sessionID")
expect(result.attachments?.[0]).not.toHaveProperty("messageID")
}),
)
}),
)
it.instance("keeps svg as text output", () =>
withFetch(
() =>
new Response('<svg xmlns="http://www.w3.org/2000/svg"><text>hello</text></svg>', {
status: 200,
headers: { "content-type": "image/svg+xml; charset=UTF-8" },
}),
(url) =>
Effect.gen(function* () {
const result = yield* exec({ url: new URL("/image.svg", url).toString(), format: "html" })
expect(result.output).toContain("<svg")
expect(result.attachments).toBeUndefined()
}),
),
)
it.instance("keeps text responses as text output", () =>
withFetch(
() =>
new Response("hello from webfetch", {
status: 200,
headers: { "content-type": "text/plain; charset=utf-8" },
}),
(url) =>
Effect.gen(function* () {
const result = yield* exec({ url: new URL("/file.txt", url).toString(), format: "text" })
expect(result.output).toBe("hello from webfetch")
expect(result.attachments).toBeUndefined()
}),
),
)
it.instance("extracts text from html without scripts or styles", () =>
withFetch(
() =>
new Response(
"<html><head><style>.hidden{}</style><script>alert('x')</script></head><body>Hello <b>world</b></body></html>",
{
status: 200,
headers: { "content-type": "text/html; charset=utf-8" },
},
),
(url) =>
Effect.gen(function* () {
const result = yield* exec({ url: new URL("/page.html", url).toString(), format: "text" })
expect(result.output).toBe("Hello world")
expect(result.attachments).toBeUndefined()
}),
),
)
})

View File

@@ -0,0 +1,99 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { parseResponse } from "../../src/tool/mcp-websearch"
import { selectWebSearchProvider, webSearchModelName, webSearchProviderLabel } from "../../src/tool/websearch"
import { webSearchEnabled } from "../../src/tool/registry"
import { it } from "../lib/effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
const SESSION_ID = "ses_0196aabbccddeeff001122334455"
describe("websearch provider", () => {
test("selects a stable provider per session", () => {
expect(selectWebSearchProvider(SESSION_ID)).toBe(selectWebSearchProvider(SESSION_ID))
})
test("supports an operational override", () => {
const original = process.env.OPENCODE_WEBSEARCH_PROVIDER
try {
process.env.OPENCODE_WEBSEARCH_PROVIDER = "parallel"
expect(selectWebSearchProvider(SESSION_ID)).toBe("parallel")
process.env.OPENCODE_WEBSEARCH_PROVIDER = "exa"
expect(selectWebSearchProvider(SESSION_ID)).toBe("exa")
} finally {
if (original === undefined) delete process.env.OPENCODE_WEBSEARCH_PROVIDER
else process.env.OPENCODE_WEBSEARCH_PROVIDER = original
}
})
test("routes to Exa when the Exa flag is enabled", () => {
expect(selectWebSearchProvider(SESSION_ID, { exa: true, parallel: false })).toBe("exa")
})
test("routes to Parallel when the Parallel flag is enabled", () => {
expect(selectWebSearchProvider(SESSION_ID, { exa: false, parallel: true })).toBe("parallel")
})
test("is only enabled for opencode or explicit websearch provider flags", () => {
expect(webSearchEnabled(ProviderV2.ID.opencode, { exa: false, parallel: false })).toBe(true)
expect(webSearchEnabled(ProviderV2.ID.openai, { exa: false, parallel: false })).toBe(false)
expect(webSearchEnabled(ProviderV2.ID.openai, { exa: true, parallel: false })).toBe(true)
expect(webSearchEnabled(ProviderV2.ID.openai, { exa: false, parallel: true })).toBe(true)
})
test("uses branded labels", () => {
expect(webSearchProviderLabel("parallel")).toBe("Parallel Web Search")
expect(webSearchProviderLabel("exa")).toBe("Exa Web Search")
expect(webSearchProviderLabel(undefined)).toBe("Web Search")
})
test("uses the provider API model id for Parallel analytics", () => {
expect(
webSearchModelName({
model: {
id: "claude-opus-4-7",
api: { id: "claude-opus-4.7" },
},
}),
).toBe("claude-opus-4.7")
})
})
describe("websearch MCP response parser", () => {
const payload = JSON.stringify({
jsonrpc: "2.0",
id: 1,
result: {
content: [
{
type: "text",
text: "search results",
},
],
},
})
it.effect("parses plain JSON-RPC responses", () =>
Effect.gen(function* () {
const result = yield* parseResponse(payload)
expect(result).toBe("search results")
}),
)
it.effect("parses SSE JSON-RPC responses", () =>
Effect.gen(function* () {
const result = yield* parseResponse(`event: message\ndata: ${payload}\n\n`)
expect(result).toBe("search results")
}),
)
it.effect("ignores non-JSON SSE data frames", () =>
Effect.gen(function* () {
const result = yield* parseResponse(`data: [DONE]\ndata: ${payload}\n\n`)
expect(result).toBe("search results")
}),
)
})

View File

@@ -0,0 +1,276 @@
import { afterEach, describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import path from "path"
import fs from "fs/promises"
import { WriteTool } from "../../src/tool/write"
import { LSP } from "@/lsp/lsp"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { EventV2Bridge } from "../../src/event-v2-bridge"
import { Format } from "../../src/format"
import { Truncate } from "@/tool/truncate"
import { Tool } from "@/tool/tool"
import { Agent } from "../../src/agent/agent"
import { SessionID, MessageID } from "../../src/session/schema"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const ctx = {
sessionID: SessionID.make("ses_test-write-session"),
messageID: MessageID.make("msg_test"),
callID: "",
agent: "build",
abort: AbortSignal.any([]),
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
}
afterEach(async () => {
await disposeAllInstances()
})
const it = testEffect(
Layer.mergeAll(
LSP.defaultLayer,
FSUtil.defaultLayer,
EventV2Bridge.defaultLayer,
Format.defaultLayer,
CrossSpawnSpawner.defaultLayer,
Truncate.defaultLayer,
Agent.defaultLayer,
),
)
const init = Effect.fn("WriteToolTest.init")(function* () {
const info = yield* WriteTool
return yield* info.init()
})
const run = Effect.fn("WriteToolTest.run")(function* (
args: Tool.InferParameters<typeof WriteTool>,
next: Tool.Context = ctx,
) {
const tool = yield* init()
return yield* tool.execute(args, next)
})
describe("tool.write", () => {
describe("new file creation", () => {
it.instance("writes content to new file", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "newfile.txt")
const result = yield* run({ filePath: filepath, content: "Hello, World!" })
expect(result.output).toContain("Wrote file successfully")
expect(result.metadata.exists).toBe(false)
const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
expect(content).toBe("Hello, World!")
}),
)
it.instance("creates parent directories if needed", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "nested", "deep", "file.txt")
yield* run({ filePath: filepath, content: "nested content" })
const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
expect(content).toBe("nested content")
}),
)
it.instance("handles relative paths by resolving to instance directory", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* run({ filePath: "relative.txt", content: "relative content" })
const content = yield* Effect.promise(() => fs.readFile(path.join(test.directory, "relative.txt"), "utf-8"))
expect(content).toBe("relative content")
}),
)
})
describe("existing file overwrite", () => {
it.instance("overwrites existing file content", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "existing.txt")
yield* Effect.promise(() => fs.writeFile(filepath, "old content", "utf-8"))
const result = yield* run({ filePath: filepath, content: "new content" })
expect(result.output).toContain("Wrote file successfully")
expect(result.metadata.exists).toBe(true)
const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
expect(content).toBe("new content")
}),
)
it.instance("preserves BOM when overwriting existing files", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "existing.cs")
const bom = String.fromCharCode(0xfeff)
yield* Effect.promise(() => fs.writeFile(filepath, `${bom}using System;\n`, "utf-8"))
yield* run({ filePath: filepath, content: "using Up;\n" })
const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
expect(content.charCodeAt(0)).toBe(0xfeff)
expect(content.slice(1)).toBe("using Up;\n")
}),
)
it.instance(
"restores BOM after formatter strips it",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "formatted.cs")
const bom = String.fromCharCode(0xfeff)
yield* Effect.promise(() => fs.writeFile(filepath, `${bom}using System;\n`, "utf-8"))
yield* run({ filePath: filepath, content: "using Up;\n" })
const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
expect(content.charCodeAt(0)).toBe(0xfeff)
expect(content.slice(1)).toBe("using Up;\n")
}),
{
config: {
formatter: {
stripbom: {
extensions: [".cs"],
command: [
"node",
"-e",
"const fs = require('fs'); const file = process.argv[1]; let text = fs.readFileSync(file, 'utf8'); if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); fs.writeFileSync(file, text, 'utf8')",
"$FILE",
],
},
},
},
},
)
it.instance("returns diff in metadata for existing files", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "file.txt")
yield* Effect.promise(() => fs.writeFile(filepath, "old", "utf-8"))
const result = yield* run({ filePath: filepath, content: "new" })
expect(result.metadata).toHaveProperty("filepath", filepath)
expect(result.metadata).toHaveProperty("exists", true)
}),
)
})
describe("file permissions", () => {
it.instance("sets file permissions when writing sensitive data", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "sensitive.json")
yield* run({ filePath: filepath, content: JSON.stringify({ secret: "data" }) })
if (process.platform !== "win32") {
const stats = yield* Effect.promise(() => fs.stat(filepath))
expect(stats.mode & 0o777).toBe(0o644)
}
}),
)
})
describe("content types", () => {
it.instance("writes JSON content", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "data.json")
const data = { key: "value", nested: { array: [1, 2, 3] } }
yield* run({ filePath: filepath, content: JSON.stringify(data, null, 2) })
const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
expect(JSON.parse(content)).toEqual(data)
}),
)
it.instance("writes binary-safe content", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "binary.bin")
const content = "Hello\x00World\x01\x02\x03"
yield* run({ filePath: filepath, content })
const buf = yield* Effect.promise(() => fs.readFile(filepath))
expect(buf.toString()).toBe(content)
}),
)
it.instance("writes empty content", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "empty.txt")
yield* run({ filePath: filepath, content: "" })
const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
expect(content).toBe("")
const stats = yield* Effect.promise(() => fs.stat(filepath))
expect(stats.size).toBe(0)
}),
)
it.instance("writes multi-line content", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "multiline.txt")
const lines = ["Line 1", "Line 2", "Line 3", ""].join("\n")
yield* run({ filePath: filepath, content: lines })
const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
expect(content).toBe(lines)
}),
)
it.instance("handles different line endings", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "crlf.txt")
const content = "Line 1\r\nLine 2\r\nLine 3"
yield* run({ filePath: filepath, content })
const buf = yield* Effect.promise(() => fs.readFile(filepath))
expect(buf.toString()).toBe(content)
}),
)
})
describe("error handling", () => {
it.instance("throws error when OS denies write access", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const readonlyPath = path.join(test.directory, "readonly.txt")
yield* Effect.promise(() => fs.writeFile(readonlyPath, "test", "utf-8"))
yield* Effect.promise(() => fs.chmod(readonlyPath, 0o444))
const exit = yield* run({ filePath: readonlyPath, content: "new content" }).pipe(Effect.exit)
expect(exit._tag).toBe("Failure")
}),
)
})
describe("title generation", () => {
it.instance("returns relative path as title", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "src", "components", "Button.tsx")
yield* Effect.promise(() => fs.mkdir(path.dirname(filepath), { recursive: true }))
const result = yield* run({ filePath: filepath, content: "export const Button = () => {}" })
expect(result.title).toEndWith(path.join("src", "components", "Button.tsx"))
}),
)
})
})