test(httpapi): add route exerciser
This commit is contained in:
@@ -8,7 +8,7 @@ import { Ripgrep } from "../../src/file/ripgrep"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Truncate } from "@/tool/truncate"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
@@ -33,49 +33,47 @@ const ctx = {
|
||||
}
|
||||
|
||||
describe("tool.glob", () => {
|
||||
it.live("matches files from a directory path", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => Bun.write(path.join(dir, "a.ts"), "export const a = 1\n"))
|
||||
yield* Effect.promise(() => Bun.write(path.join(dir, "b.txt"), "hello\n"))
|
||||
const info = yield* GlobTool
|
||||
const glob = yield* info.init()
|
||||
const result = yield* glob.execute(
|
||||
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: dir,
|
||||
path: file,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
expect(result.metadata.count).toBe(1)
|
||||
expect(result.output).toContain(path.join(dir, "a.ts"))
|
||||
expect(result.output).not.toContain(path.join(dir, "b.txt"))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rejects exact file paths", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(dir, "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")
|
||||
}
|
||||
}),
|
||||
),
|
||||
.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")
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { GrepTool } from "../../src/tool/grep"
|
||||
import { provideInstance, provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { provideInstance, TestInstance } from "../fixture/fixture"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Truncate } from "@/tool/truncate"
|
||||
@@ -54,61 +54,58 @@ describe("tool.grep", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("no matches returns correct output", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => Bun.write(path.join(dir, "test.txt"), "hello world"))
|
||||
const info = yield* GrepTool
|
||||
const grep = yield* info.init()
|
||||
const result = yield* grep.execute(
|
||||
{
|
||||
pattern: "xyznonexistentpatternxyz123",
|
||||
path: dir,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
expect(result.metadata.matches).toBe(0)
|
||||
expect(result.output).toBe("No files 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.live("finds matches in tmp instance", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => Bun.write(path.join(dir, "test.txt"), "line1\nline2\nline3"))
|
||||
const info = yield* GrepTool
|
||||
const grep = yield* info.init()
|
||||
const result = yield* grep.execute(
|
||||
{
|
||||
pattern: "line",
|
||||
path: dir,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
expect(result.metadata.matches).toBeGreaterThan(0)
|
||||
}),
|
||||
),
|
||||
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.live("supports exact file paths", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(dir, "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("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")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -6,7 +6,6 @@ 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 { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const ctx = {
|
||||
@@ -34,56 +33,52 @@ const pending = Effect.fn("QuestionToolTest.pending")(function* (question: Quest
|
||||
})
|
||||
|
||||
describe("tool.question", () => {
|
||||
it.live("should successfully execute with valid question parameters", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
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,
|
||||
},
|
||||
]
|
||||
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 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")
|
||||
}),
|
||||
),
|
||||
const result = yield* Fiber.join(fiber)
|
||||
expect(result.title).toBe("Asked 1 question")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("should now pass with a header longer than 12 but less than 30 chars", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
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" }],
|
||||
},
|
||||
]
|
||||
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 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"`)
|
||||
}),
|
||||
),
|
||||
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
|
||||
|
||||
@@ -13,7 +13,7 @@ import { ReadTool } from "../../src/tool/read"
|
||||
import { Truncate } from "@/tool/truncate"
|
||||
import { Tool } from "@/tool/tool"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { disposeAllInstances, provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
|
||||
@@ -255,28 +255,28 @@ describe("tool.read env file permissions", () => {
|
||||
})
|
||||
|
||||
describe("tool.read truncation", () => {
|
||||
it.live("truncates large file by bytes and sets truncated metadata", () =>
|
||||
it.instance("truncates large file by bytes and sets truncated metadata", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped()
|
||||
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(dir, "large.json"), content)
|
||||
yield* put(path.join(test.directory, "large.json"), content)
|
||||
|
||||
const result = yield* exec(dir, { filePath: path.join(dir, "large.json") })
|
||||
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.live("truncates by line count when limit is specified", () =>
|
||||
it.instance("truncates by line count when limit is specified", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped()
|
||||
const test = yield* TestInstance
|
||||
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
|
||||
yield* put(path.join(dir, "many-lines.txt"), lines)
|
||||
yield* put(path.join(test.directory, "many-lines.txt"), lines)
|
||||
|
||||
const result = yield* exec(dir, { filePath: path.join(dir, "many-lines.txt"), limit: 10 })
|
||||
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")
|
||||
@@ -286,12 +286,12 @@ describe("tool.read truncation", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("does not truncate small file", () =>
|
||||
it.instance("does not truncate small file", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped()
|
||||
yield* put(path.join(dir, "small.txt"), "hello world")
|
||||
const test = yield* TestInstance
|
||||
yield* put(path.join(test.directory, "small.txt"), "hello world")
|
||||
|
||||
const result = yield* exec(dir, { filePath: path.join(dir, "small.txt") })
|
||||
const result = yield* run({ filePath: path.join(test.directory, "small.txt") })
|
||||
expect(result.metadata.truncated).toBe(false)
|
||||
expect(result.output).toContain("End of file")
|
||||
}),
|
||||
|
||||
@@ -2,10 +2,9 @@ import { afterEach, describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { ToolRegistry } from "@/tool/registry"
|
||||
import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { TestConfig } from "../fixture/config"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
@@ -57,136 +56,133 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe("tool.registry", () => {
|
||||
it.live("loads tools from .opencode/tool (singular)", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const opencode = path.join(dir, ".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("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.live("loads tools from .opencode/tools (plural)", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const opencode = path.join(dir, ".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 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.live("loads tools with external dependencies without crashing", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const opencode = path.join(dir, ".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",
|
||||
},
|
||||
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")
|
||||
}),
|
||||
),
|
||||
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")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -13,7 +13,7 @@ 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, provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { disposeAllInstances, provideTmpdirInstance, TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const ctx = {
|
||||
@@ -58,66 +58,79 @@ const run = Effect.fn("WriteToolTest.run")(function* (
|
||||
|
||||
describe("tool.write", () => {
|
||||
describe("new file creation", () => {
|
||||
it.live("writes content to new file", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(dir, "newfile.txt")
|
||||
const result = yield* run({ filePath: filepath, content: "Hello, World!" })
|
||||
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)
|
||||
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!")
|
||||
}),
|
||||
),
|
||||
const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
|
||||
expect(content).toBe("Hello, World!")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("creates parent directories if needed", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(dir, "nested", "deep", "file.txt")
|
||||
yield* run({ filePath: filepath, content: "nested content" })
|
||||
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")
|
||||
}),
|
||||
),
|
||||
const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
|
||||
expect(content).toBe("nested content")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("handles relative paths by resolving to instance directory", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* run({ filePath: "relative.txt", content: "relative 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(dir, "relative.txt"), "utf-8"))
|
||||
expect(content).toBe("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.live("overwrites existing file content", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(dir, "existing.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(filepath, "old content", "utf-8"))
|
||||
const result = yield* run({ filePath: filepath, content: "new content" })
|
||||
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)
|
||||
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")
|
||||
}),
|
||||
),
|
||||
const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
|
||||
expect(content).toBe("new content")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("preserves BOM when overwriting existing files", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
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 filepath = path.join(dir, "existing.cs")
|
||||
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"))
|
||||
|
||||
@@ -127,165 +140,138 @@ describe("tool.write", () => {
|
||||
expect(content.charCodeAt(0)).toBe(0xfeff)
|
||||
expect(content.slice(1)).toBe("using Up;\n")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("restores BOM after formatter strips it", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(dir, "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",
|
||||
],
|
||||
},
|
||||
{
|
||||
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.live("returns diff in metadata for existing files", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(dir, "file.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(filepath, "old", "utf-8"))
|
||||
const result = yield* run({ filePath: filepath, content: "new" })
|
||||
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)
|
||||
}),
|
||||
),
|
||||
expect(result.metadata).toHaveProperty("filepath", filepath)
|
||||
expect(result.metadata).toHaveProperty("exists", true)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("file permissions", () => {
|
||||
it.live("sets file permissions when writing sensitive data", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(dir, "sensitive.json")
|
||||
yield* run({ filePath: filepath, content: JSON.stringify({ secret: "data" }) })
|
||||
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)
|
||||
}
|
||||
}),
|
||||
),
|
||||
if (process.platform !== "win32") {
|
||||
const stats = yield* Effect.promise(() => fs.stat(filepath))
|
||||
expect(stats.mode & 0o777).toBe(0o644)
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("content types", () => {
|
||||
it.live("writes JSON content", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(dir, "data.json")
|
||||
const data = { key: "value", nested: { array: [1, 2, 3] } }
|
||||
yield* run({ filePath: filepath, content: JSON.stringify(data, null, 2) })
|
||||
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)
|
||||
}),
|
||||
),
|
||||
const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
|
||||
expect(JSON.parse(content)).toEqual(data)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("writes binary-safe content", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(dir, "binary.bin")
|
||||
const content = "Hello\x00World\x01\x02\x03"
|
||||
yield* run({ filePath: filepath, content })
|
||||
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)
|
||||
}),
|
||||
),
|
||||
const buf = yield* Effect.promise(() => fs.readFile(filepath))
|
||||
expect(buf.toString()).toBe(content)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("writes empty content", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(dir, "empty.txt")
|
||||
yield* run({ filePath: filepath, 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 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)
|
||||
}),
|
||||
),
|
||||
const stats = yield* Effect.promise(() => fs.stat(filepath))
|
||||
expect(stats.size).toBe(0)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("writes multi-line content", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(dir, "multiline.txt")
|
||||
const lines = ["Line 1", "Line 2", "Line 3", ""].join("\n")
|
||||
yield* run({ filePath: filepath, content: lines })
|
||||
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)
|
||||
}),
|
||||
),
|
||||
const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
|
||||
expect(content).toBe(lines)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("handles different line endings", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(dir, "crlf.txt")
|
||||
const content = "Line 1\r\nLine 2\r\nLine 3"
|
||||
yield* run({ filePath: filepath, content })
|
||||
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)
|
||||
}),
|
||||
),
|
||||
const buf = yield* Effect.promise(() => fs.readFile(filepath))
|
||||
expect(buf.toString()).toBe(content)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("error handling", () => {
|
||||
it.live("throws error when OS denies write access", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const readonlyPath = path.join(dir, "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")
|
||||
}),
|
||||
),
|
||||
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.live("returns relative path as title", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(dir, "src", "components", "Button.tsx")
|
||||
yield* Effect.promise(() => fs.mkdir(path.dirname(filepath), { recursive: true }))
|
||||
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"))
|
||||
}),
|
||||
),
|
||||
const result = yield* run({ filePath: filepath, content: "export const Button = () => {}" })
|
||||
expect(result.title).toEndWith(path.join("src", "components", "Button.tsx"))
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user