chore: initialize qiming workspace repository
This commit is contained in:
@@ -0,0 +1,499 @@
|
||||
// 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",
|
||||
"type": "number",
|
||||
},
|
||||
"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) codesearch 1`] = `
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"properties": {
|
||||
"query": {
|
||||
"description": "Search query to find relevant context for APIs, Libraries, and SDKs. For example, 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware', 'Next js partial prerendering configuration'",
|
||||
"type": "string",
|
||||
},
|
||||
"tokensNum": {
|
||||
"default": 5000,
|
||||
"description": "Number of tokens to return (1000-50000). Default is 5000 tokens. Adjust this value based on how much context you need - use lower values for focused queries and higher values for comprehensive documentation.",
|
||||
"maximum": 50000,
|
||||
"minimum": 1000,
|
||||
"type": "number",
|
||||
},
|
||||
},
|
||||
"required": [
|
||||
"query",
|
||||
],
|
||||
"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",
|
||||
},
|
||||
},
|
||||
"ref": "QuestionOption",
|
||||
"required": [
|
||||
"label",
|
||||
"description",
|
||||
],
|
||||
"type": "object",
|
||||
},
|
||||
"type": "array",
|
||||
},
|
||||
"question": {
|
||||
"description": "Complete question",
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
"ref": "QuestionPrompt",
|
||||
"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)",
|
||||
"type": "number",
|
||||
},
|
||||
"offset": {
|
||||
"description": "The line number to start reading from (1-indexed)",
|
||||
"type": "number",
|
||||
},
|
||||
},
|
||||
"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": {
|
||||
"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",
|
||||
}
|
||||
`;
|
||||
@@ -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
|
||||
"
|
||||
`;
|
||||
614
qimingcode/packages/opencode/test/tool/apply_patch.test.ts
Normal file
614
qimingcode/packages/opencode/test/tool/apply_patch.test.ts
Normal file
@@ -0,0 +1,614 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import * as fs from "fs/promises"
|
||||
import { Effect, ManagedRuntime, Layer } from "effect"
|
||||
import { ApplyPatchTool } from "../../src/tool/apply_patch"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { LSP } from "../../src/lsp"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Format } from "../../src/format"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { Truncate } from "../../src/tool"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
|
||||
const runtime = ManagedRuntime.make(
|
||||
Layer.mergeAll(
|
||||
LSP.defaultLayer,
|
||||
AppFileSystem.defaultLayer,
|
||||
Format.defaultLayer,
|
||||
Bus.layer,
|
||||
Truncate.defaultLayer,
|
||||
Agent.defaultLayer,
|
||||
),
|
||||
)
|
||||
|
||||
const baseCtx = {
|
||||
sessionID: SessionID.make("ses_test"),
|
||||
messageID: MessageID.make(""),
|
||||
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 = async (params: { patchText: string }, ctx: ToolCtx) => {
|
||||
const info = await runtime.runPromise(ApplyPatchTool)
|
||||
const tool = await runtime.runPromise(info.init())
|
||||
return Effect.runPromise(tool.execute(params, ctx))
|
||||
}
|
||||
|
||||
const makeCtx = () => {
|
||||
const calls: AskInput[] = []
|
||||
const ctx: ToolCtx = {
|
||||
...baseCtx,
|
||||
ask: (input) =>
|
||||
Effect.sync(() => {
|
||||
calls.push(input)
|
||||
}),
|
||||
}
|
||||
|
||||
return { ctx, calls }
|
||||
}
|
||||
|
||||
describe("tool.apply_patch freeform", () => {
|
||||
test("requires patchText", async () => {
|
||||
const { ctx } = makeCtx()
|
||||
await expect(execute({ patchText: "" }, ctx)).rejects.toThrow("patchText is required")
|
||||
})
|
||||
|
||||
test("rejects invalid patch format", async () => {
|
||||
const { ctx } = makeCtx()
|
||||
await expect(execute({ patchText: "invalid patch" }, ctx)).rejects.toThrow("apply_patch verification failed")
|
||||
})
|
||||
|
||||
test("rejects empty patch", async () => {
|
||||
const { ctx } = makeCtx()
|
||||
const emptyPatch = "*** Begin Patch\n*** End Patch"
|
||||
await expect(execute({ patchText: emptyPatch }, ctx)).rejects.toThrow("patch rejected: empty patch")
|
||||
})
|
||||
|
||||
test("applies add/update/delete in one patch", async () => {
|
||||
await using fixture = await tmpdir({ git: true })
|
||||
const { ctx, calls } = makeCtx()
|
||||
|
||||
await Instance.provide({
|
||||
directory: fixture.path,
|
||||
fn: async () => {
|
||||
const modifyPath = path.join(fixture.path, "modify.txt")
|
||||
const deletePath = path.join(fixture.path, "delete.txt")
|
||||
await fs.writeFile(modifyPath, "line1\nline2\n", "utf-8")
|
||||
await fs.writeFile(deletePath, "obsolete\n", "utf-8")
|
||||
|
||||
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 = await 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).toBeDefined()
|
||||
expect(addFile!.relativePath).toBe("nested/new.txt")
|
||||
expect(addFile!.patch).toContain("+created")
|
||||
|
||||
const updateFile = permissionCall.metadata.files.find((f) => f.type === "update")
|
||||
expect(updateFile).toBeDefined()
|
||||
expect(updateFile!.patch).toContain("-line2")
|
||||
expect(updateFile!.patch).toContain("+changed")
|
||||
|
||||
const added = await fs.readFile(path.join(fixture.path, "nested", "new.txt"), "utf-8")
|
||||
expect(added).toBe("created\n")
|
||||
expect(await fs.readFile(modifyPath, "utf-8")).toBe("line1\nchanged\n")
|
||||
await expect(fs.readFile(deletePath, "utf-8")).rejects.toThrow()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("permission metadata includes move file info", async () => {
|
||||
await using fixture = await tmpdir({ git: true })
|
||||
const { ctx, calls } = makeCtx()
|
||||
|
||||
await Instance.provide({
|
||||
directory: fixture.path,
|
||||
fn: async () => {
|
||||
const original = path.join(fixture.path, "old", "name.txt")
|
||||
await fs.mkdir(path.dirname(original), { recursive: true })
|
||||
await fs.writeFile(original, "old content\n", "utf-8")
|
||||
|
||||
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"
|
||||
|
||||
await 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(fixture.path, "renamed/dir/name.txt"))
|
||||
expect(moveFile.patch).toContain("-old content")
|
||||
expect(moveFile.patch).toContain("+new content")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("applies multiple hunks to one file", async () => {
|
||||
await using fixture = await tmpdir()
|
||||
const { ctx } = makeCtx()
|
||||
|
||||
await Instance.provide({
|
||||
directory: fixture.path,
|
||||
fn: async () => {
|
||||
const target = path.join(fixture.path, "multi.txt")
|
||||
await fs.writeFile(target, "line1\nline2\nline3\nline4\n", "utf-8")
|
||||
|
||||
const patchText =
|
||||
"*** Begin Patch\n*** Update File: multi.txt\n@@\n-line2\n+changed2\n@@\n-line4\n+changed4\n*** End Patch"
|
||||
|
||||
await execute({ patchText }, ctx)
|
||||
|
||||
expect(await fs.readFile(target, "utf-8")).toBe("line1\nchanged2\nline3\nchanged4\n")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("does not invent a first-line diff for BOM files", async () => {
|
||||
await using fixture = await tmpdir()
|
||||
const { ctx, calls } = makeCtx()
|
||||
|
||||
await Instance.provide({
|
||||
directory: fixture.path,
|
||||
fn: async () => {
|
||||
const bom = String.fromCharCode(0xfeff)
|
||||
const target = path.join(fixture.path, "example.cs")
|
||||
await fs.writeFile(target, `${bom}using System;\n\nclass Test {}\n`, "utf-8")
|
||||
|
||||
const patchText =
|
||||
"*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch"
|
||||
|
||||
await 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 = await fs.readFile(target, "utf-8")
|
||||
expect(content.charCodeAt(0)).toBe(0xfeff)
|
||||
expect(content.slice(1)).toBe("using System;\n\nclass Test {}\nclass Next {}\n")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("inserts lines with insert-only hunk", async () => {
|
||||
await using fixture = await tmpdir()
|
||||
const { ctx } = makeCtx()
|
||||
|
||||
await Instance.provide({
|
||||
directory: fixture.path,
|
||||
fn: async () => {
|
||||
const target = path.join(fixture.path, "insert_only.txt")
|
||||
await fs.writeFile(target, "alpha\nomega\n", "utf-8")
|
||||
|
||||
const patchText = "*** Begin Patch\n*** Update File: insert_only.txt\n@@\n alpha\n+beta\n omega\n*** End Patch"
|
||||
|
||||
await execute({ patchText }, ctx)
|
||||
|
||||
expect(await fs.readFile(target, "utf-8")).toBe("alpha\nbeta\nomega\n")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("appends trailing newline on update", async () => {
|
||||
await using fixture = await tmpdir()
|
||||
const { ctx } = makeCtx()
|
||||
|
||||
await Instance.provide({
|
||||
directory: fixture.path,
|
||||
fn: async () => {
|
||||
const target = path.join(fixture.path, "no_newline.txt")
|
||||
await fs.writeFile(target, "no newline at end", "utf-8")
|
||||
|
||||
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"
|
||||
|
||||
await execute({ patchText }, ctx)
|
||||
|
||||
const contents = await fs.readFile(target, "utf-8")
|
||||
expect(contents.endsWith("\n")).toBe(true)
|
||||
expect(contents).toBe("first line\nsecond line\n")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("moves file to a new directory", async () => {
|
||||
await using fixture = await tmpdir()
|
||||
const { ctx } = makeCtx()
|
||||
|
||||
await Instance.provide({
|
||||
directory: fixture.path,
|
||||
fn: async () => {
|
||||
const original = path.join(fixture.path, "old", "name.txt")
|
||||
await fs.mkdir(path.dirname(original), { recursive: true })
|
||||
await fs.writeFile(original, "old content\n", "utf-8")
|
||||
|
||||
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"
|
||||
|
||||
await execute({ patchText }, ctx)
|
||||
|
||||
const moved = path.join(fixture.path, "renamed", "dir", "name.txt")
|
||||
await expect(fs.readFile(original, "utf-8")).rejects.toThrow()
|
||||
expect(await fs.readFile(moved, "utf-8")).toBe("new content\n")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("moves file overwriting existing destination", async () => {
|
||||
await using fixture = await tmpdir()
|
||||
const { ctx } = makeCtx()
|
||||
|
||||
await Instance.provide({
|
||||
directory: fixture.path,
|
||||
fn: async () => {
|
||||
const original = path.join(fixture.path, "old", "name.txt")
|
||||
const destination = path.join(fixture.path, "renamed", "dir", "name.txt")
|
||||
await fs.mkdir(path.dirname(original), { recursive: true })
|
||||
await fs.mkdir(path.dirname(destination), { recursive: true })
|
||||
await fs.writeFile(original, "from\n", "utf-8")
|
||||
await fs.writeFile(destination, "existing\n", "utf-8")
|
||||
|
||||
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"
|
||||
|
||||
await execute({ patchText }, ctx)
|
||||
|
||||
await expect(fs.readFile(original, "utf-8")).rejects.toThrow()
|
||||
expect(await fs.readFile(destination, "utf-8")).toBe("new\n")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("adds file overwriting existing file", async () => {
|
||||
await using fixture = await tmpdir()
|
||||
const { ctx } = makeCtx()
|
||||
|
||||
await Instance.provide({
|
||||
directory: fixture.path,
|
||||
fn: async () => {
|
||||
const target = path.join(fixture.path, "duplicate.txt")
|
||||
await fs.writeFile(target, "old content\n", "utf-8")
|
||||
|
||||
const patchText = "*** Begin Patch\n*** Add File: duplicate.txt\n+new content\n*** End Patch"
|
||||
|
||||
await execute({ patchText }, ctx)
|
||||
expect(await fs.readFile(target, "utf-8")).toBe("new content\n")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects update when target file is missing", async () => {
|
||||
await using fixture = await tmpdir()
|
||||
const { ctx } = makeCtx()
|
||||
|
||||
await Instance.provide({
|
||||
directory: fixture.path,
|
||||
fn: async () => {
|
||||
const patchText = "*** Begin Patch\n*** Update File: missing.txt\n@@\n-nope\n+better\n*** End Patch"
|
||||
|
||||
await expect(execute({ patchText }, ctx)).rejects.toThrow(
|
||||
"apply_patch verification failed: Failed to read file to update",
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects delete when file is missing", async () => {
|
||||
await using fixture = await tmpdir()
|
||||
const { ctx } = makeCtx()
|
||||
|
||||
await Instance.provide({
|
||||
directory: fixture.path,
|
||||
fn: async () => {
|
||||
const patchText = "*** Begin Patch\n*** Delete File: missing.txt\n*** End Patch"
|
||||
|
||||
await expect(execute({ patchText }, ctx)).rejects.toThrow()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects delete when target is a directory", async () => {
|
||||
await using fixture = await tmpdir()
|
||||
const { ctx } = makeCtx()
|
||||
|
||||
await Instance.provide({
|
||||
directory: fixture.path,
|
||||
fn: async () => {
|
||||
const dirPath = path.join(fixture.path, "dir")
|
||||
await fs.mkdir(dirPath)
|
||||
|
||||
const patchText = "*** Begin Patch\n*** Delete File: dir\n*** End Patch"
|
||||
|
||||
await expect(execute({ patchText }, ctx)).rejects.toThrow()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects invalid hunk header", async () => {
|
||||
await using fixture = await tmpdir()
|
||||
const { ctx } = makeCtx()
|
||||
|
||||
await Instance.provide({
|
||||
directory: fixture.path,
|
||||
fn: async () => {
|
||||
const patchText = "*** Begin Patch\n*** Frobnicate File: foo\n*** End Patch"
|
||||
|
||||
await expect(execute({ patchText }, ctx)).rejects.toThrow("apply_patch verification failed")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects update with missing context", async () => {
|
||||
await using fixture = await tmpdir()
|
||||
const { ctx } = makeCtx()
|
||||
|
||||
await Instance.provide({
|
||||
directory: fixture.path,
|
||||
fn: async () => {
|
||||
const target = path.join(fixture.path, "modify.txt")
|
||||
await fs.writeFile(target, "line1\nline2\n", "utf-8")
|
||||
|
||||
const patchText = "*** Begin Patch\n*** Update File: modify.txt\n@@\n-missing\n+changed\n*** End Patch"
|
||||
|
||||
await expect(execute({ patchText }, ctx)).rejects.toThrow("apply_patch verification failed")
|
||||
expect(await fs.readFile(target, "utf-8")).toBe("line1\nline2\n")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("verification failure leaves no side effects", async () => {
|
||||
await using fixture = await tmpdir()
|
||||
const { ctx } = makeCtx()
|
||||
|
||||
await Instance.provide({
|
||||
directory: fixture.path,
|
||||
fn: async () => {
|
||||
const patchText =
|
||||
"*** Begin Patch\n*** Add File: created.txt\n+hello\n*** Update File: missing.txt\n@@\n-old\n+new\n*** End Patch"
|
||||
|
||||
await expect(execute({ patchText }, ctx)).rejects.toThrow()
|
||||
|
||||
const createdPath = path.join(fixture.path, "created.txt")
|
||||
await expect(fs.readFile(createdPath, "utf-8")).rejects.toThrow()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("supports end of file anchor", async () => {
|
||||
await using fixture = await tmpdir()
|
||||
const { ctx } = makeCtx()
|
||||
|
||||
await Instance.provide({
|
||||
directory: fixture.path,
|
||||
fn: async () => {
|
||||
const target = path.join(fixture.path, "tail.txt")
|
||||
await fs.writeFile(target, "alpha\nlast\n", "utf-8")
|
||||
|
||||
const patchText = "*** Begin Patch\n*** Update File: tail.txt\n@@\n-last\n+end\n*** End of File\n*** End Patch"
|
||||
|
||||
await execute({ patchText }, ctx)
|
||||
expect(await fs.readFile(target, "utf-8")).toBe("alpha\nend\n")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects missing second chunk context", async () => {
|
||||
await using fixture = await tmpdir()
|
||||
const { ctx } = makeCtx()
|
||||
|
||||
await Instance.provide({
|
||||
directory: fixture.path,
|
||||
fn: async () => {
|
||||
const target = path.join(fixture.path, "two_chunks.txt")
|
||||
await fs.writeFile(target, "a\nb\nc\nd\n", "utf-8")
|
||||
|
||||
const patchText = "*** Begin Patch\n*** Update File: two_chunks.txt\n@@\n-b\n+B\n\n-d\n+D\n*** End Patch"
|
||||
|
||||
await expect(execute({ patchText }, ctx)).rejects.toThrow()
|
||||
expect(await fs.readFile(target, "utf-8")).toBe("a\nb\nc\nd\n")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("disambiguates change context with @@ header", async () => {
|
||||
await using fixture = await tmpdir()
|
||||
const { ctx } = makeCtx()
|
||||
|
||||
await Instance.provide({
|
||||
directory: fixture.path,
|
||||
fn: async () => {
|
||||
const target = path.join(fixture.path, "multi_ctx.txt")
|
||||
await fs.writeFile(target, "fn a\nx=10\ny=2\nfn b\nx=10\ny=20\n", "utf-8")
|
||||
|
||||
const patchText = "*** Begin Patch\n*** Update File: multi_ctx.txt\n@@ fn b\n-x=10\n+x=11\n*** End Patch"
|
||||
|
||||
await execute({ patchText }, ctx)
|
||||
expect(await fs.readFile(target, "utf-8")).toBe("fn a\nx=10\ny=2\nfn b\nx=11\ny=20\n")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("EOF anchor matches from end of file first", async () => {
|
||||
await using fixture = await tmpdir()
|
||||
const { ctx } = makeCtx()
|
||||
|
||||
await Instance.provide({
|
||||
directory: fixture.path,
|
||||
fn: async () => {
|
||||
const target = path.join(fixture.path, "eof_anchor.txt")
|
||||
// File has duplicate "marker" lines - one in middle, one at end
|
||||
await fs.writeFile(target, "start\nmarker\nmiddle\nmarker\nend\n", "utf-8")
|
||||
|
||||
// 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"
|
||||
|
||||
await execute({ patchText }, ctx)
|
||||
// First marker unchanged, second marker changed
|
||||
expect(await fs.readFile(target, "utf-8")).toBe("start\nmarker\nmiddle\nmarker-changed\nend\n")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("parses heredoc-wrapped patch", async () => {
|
||||
await using fixture = await tmpdir()
|
||||
const { ctx } = makeCtx()
|
||||
|
||||
await Instance.provide({
|
||||
directory: fixture.path,
|
||||
fn: async () => {
|
||||
const patchText = `cat <<'EOF'
|
||||
*** Begin Patch
|
||||
*** Add File: heredoc_test.txt
|
||||
+heredoc content
|
||||
*** End Patch
|
||||
EOF`
|
||||
|
||||
await execute({ patchText }, ctx)
|
||||
const content = await fs.readFile(path.join(fixture.path, "heredoc_test.txt"), "utf-8")
|
||||
expect(content).toBe("heredoc content\n")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("parses heredoc-wrapped patch without cat", async () => {
|
||||
await using fixture = await tmpdir()
|
||||
const { ctx } = makeCtx()
|
||||
|
||||
await Instance.provide({
|
||||
directory: fixture.path,
|
||||
fn: async () => {
|
||||
const patchText = `<<EOF
|
||||
*** Begin Patch
|
||||
*** Add File: heredoc_no_cat.txt
|
||||
+no cat prefix
|
||||
*** End Patch
|
||||
EOF`
|
||||
|
||||
await execute({ patchText }, ctx)
|
||||
const content = await fs.readFile(path.join(fixture.path, "heredoc_no_cat.txt"), "utf-8")
|
||||
expect(content).toBe("no cat prefix\n")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("matches with trailing whitespace differences", async () => {
|
||||
await using fixture = await tmpdir()
|
||||
const { ctx } = makeCtx()
|
||||
|
||||
await Instance.provide({
|
||||
directory: fixture.path,
|
||||
fn: async () => {
|
||||
const target = path.join(fixture.path, "trailing_ws.txt")
|
||||
// File has trailing spaces on some lines
|
||||
await fs.writeFile(target, "line1 \nline2\nline3 \n", "utf-8")
|
||||
|
||||
// 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"
|
||||
|
||||
await execute({ patchText }, ctx)
|
||||
expect(await fs.readFile(target, "utf-8")).toBe("line1 \nchanged\nline3 \n")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("matches with leading whitespace differences", async () => {
|
||||
await using fixture = await tmpdir()
|
||||
const { ctx } = makeCtx()
|
||||
|
||||
await Instance.provide({
|
||||
directory: fixture.path,
|
||||
fn: async () => {
|
||||
const target = path.join(fixture.path, "leading_ws.txt")
|
||||
// File has leading spaces
|
||||
await fs.writeFile(target, " line1\nline2\n line3\n", "utf-8")
|
||||
|
||||
// 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"
|
||||
|
||||
await execute({ patchText }, ctx)
|
||||
expect(await fs.readFile(target, "utf-8")).toBe(" line1\nchanged\n line3\n")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("matches with Unicode punctuation differences", async () => {
|
||||
await using fixture = await tmpdir()
|
||||
const { ctx } = makeCtx()
|
||||
|
||||
await Instance.provide({
|
||||
directory: fixture.path,
|
||||
fn: async () => {
|
||||
const target = path.join(fixture.path, "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"
|
||||
await fs.writeFile(target, `He said ${leftQuote}hello${rightQuote}\nsome${emDash}dash\nend\n`, "utf-8")
|
||||
|
||||
// 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'
|
||||
|
||||
await execute({ patchText }, ctx)
|
||||
// Result has ASCII quotes because that's what the patch specifies
|
||||
expect(await fs.readFile(target, "utf-8")).toBe(`He said "hi"\nsome${emDash}dash\nend\n`)
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
1224
qimingcode/packages/opencode/test/tool/bash.test.ts
Normal file
1224
qimingcode/packages/opencode/test/tool/bash.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
754
qimingcode/packages/opencode/test/tool/edit.test.ts
Normal file
754
qimingcode/packages/opencode/test/tool/edit.test.ts
Normal file
@@ -0,0 +1,754 @@
|
||||
import { afterAll, afterEach, describe, test, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { Effect, Layer, ManagedRuntime } from "effect"
|
||||
import { EditTool } from "../../src/tool/edit"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { LSP } from "../../src/lsp"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Format } from "../../src/format"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { BusEvent } from "../../src/bus/bus-event"
|
||||
import { Truncate } from "../../src/tool"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
|
||||
const ctx = {
|
||||
sessionID: SessionID.make("ses_test-edit-session"),
|
||||
messageID: MessageID.make(""),
|
||||
callID: "",
|
||||
agent: "build",
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => Effect.void,
|
||||
ask: () => Effect.void,
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
const runtime = ManagedRuntime.make(
|
||||
Layer.mergeAll(
|
||||
LSP.defaultLayer,
|
||||
AppFileSystem.defaultLayer,
|
||||
Format.defaultLayer,
|
||||
Bus.layer,
|
||||
Truncate.defaultLayer,
|
||||
Agent.defaultLayer,
|
||||
),
|
||||
)
|
||||
|
||||
afterAll(async () => {
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
const resolve = () =>
|
||||
runtime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const info = yield* EditTool
|
||||
return yield* info.init()
|
||||
}),
|
||||
)
|
||||
|
||||
const subscribeBus = <D extends BusEvent.Definition>(def: D, callback: () => unknown) =>
|
||||
runtime.runPromise(Bus.Service.use((bus) => bus.subscribeCallback(def, callback)))
|
||||
|
||||
async function onceBus<D extends BusEvent.Definition>(def: D) {
|
||||
const result = Promise.withResolvers<void>()
|
||||
const unsub = await subscribeBus(def, () => {
|
||||
unsub()
|
||||
result.resolve()
|
||||
})
|
||||
return {
|
||||
wait: result.promise,
|
||||
unsub,
|
||||
}
|
||||
}
|
||||
|
||||
describe("tool.edit", () => {
|
||||
describe("creating new files", () => {
|
||||
test("creates new file when oldString is empty", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "newfile.txt")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const edit = await resolve()
|
||||
const result = await Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "",
|
||||
newString: "new content",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
)
|
||||
|
||||
expect(result.metadata.diff).toContain("new content")
|
||||
|
||||
const content = await fs.readFile(filepath, "utf-8")
|
||||
expect(content).toBe("new content")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("preserves BOM when oldString is empty on existing files", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "existing.cs")
|
||||
const bom = String.fromCharCode(0xfeff)
|
||||
await fs.writeFile(filepath, `${bom}using System;\n`, "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const edit = await resolve()
|
||||
const result = await Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "",
|
||||
newString: "using Up;\n",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
)
|
||||
|
||||
expect(result.metadata.diff).toContain("-using System;")
|
||||
expect(result.metadata.diff).toContain("+using Up;")
|
||||
|
||||
const content = await fs.readFile(filepath, "utf-8")
|
||||
expect(content.charCodeAt(0)).toBe(0xfeff)
|
||||
expect(content.slice(1)).toBe("using Up;\n")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("creates new file with nested directories", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "nested", "dir", "file.txt")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const edit = await resolve()
|
||||
await Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "",
|
||||
newString: "nested file",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
)
|
||||
|
||||
const content = await fs.readFile(filepath, "utf-8")
|
||||
expect(content).toBe("nested file")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("emits add event for new files", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "new.txt")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const { FileWatcher } = await import("../../src/file/watcher")
|
||||
|
||||
const updated = await onceBus(FileWatcher.Event.Updated)
|
||||
|
||||
try {
|
||||
const edit = await resolve()
|
||||
await Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "",
|
||||
newString: "content",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
)
|
||||
|
||||
await updated.wait
|
||||
} finally {
|
||||
updated.unsub()
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("editing existing files", () => {
|
||||
test("replaces text in existing file", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "existing.txt")
|
||||
await fs.writeFile(filepath, "old content here", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const edit = await resolve()
|
||||
const result = await Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "old content",
|
||||
newString: "new content",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
)
|
||||
|
||||
expect(result.output).toContain("Edit applied successfully")
|
||||
|
||||
const content = await fs.readFile(filepath, "utf-8")
|
||||
expect(content).toBe("new content here")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("replaces the first visible line in BOM files", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "existing.cs")
|
||||
const bom = String.fromCharCode(0xfeff)
|
||||
await fs.writeFile(filepath, `${bom}using System;\nclass Test {}\n`, "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const edit = await resolve()
|
||||
const result = await Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "using System;",
|
||||
newString: "using Up;",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
)
|
||||
|
||||
expect(result.metadata.diff).toContain("-using System;")
|
||||
expect(result.metadata.diff).toContain("+using Up;")
|
||||
expect(result.metadata.diff).not.toContain(bom)
|
||||
|
||||
const content = await fs.readFile(filepath, "utf-8")
|
||||
expect(content.charCodeAt(0)).toBe(0xfeff)
|
||||
expect(content.slice(1)).toBe("using Up;\nclass Test {}\n")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("throws error when file does not exist", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "nonexistent.txt")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const edit = await resolve()
|
||||
await expect(
|
||||
Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "old",
|
||||
newString: "new",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
),
|
||||
).rejects.toThrow("not found")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("throws error when oldString equals newString", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "content", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const edit = await resolve()
|
||||
await expect(
|
||||
Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "same",
|
||||
newString: "same",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
),
|
||||
).rejects.toThrow("identical")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("throws error when oldString not found in file", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "actual content", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const edit = await resolve()
|
||||
await expect(
|
||||
Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "not in file",
|
||||
newString: "replacement",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
),
|
||||
).rejects.toThrow()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("replaces all occurrences with replaceAll option", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "foo bar foo baz foo", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const edit = await resolve()
|
||||
await Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "foo",
|
||||
newString: "qux",
|
||||
replaceAll: true,
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
)
|
||||
|
||||
const content = await fs.readFile(filepath, "utf-8")
|
||||
expect(content).toBe("qux bar qux baz qux")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("emits change event for existing files", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "original", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const { FileWatcher } = await import("../../src/file/watcher")
|
||||
|
||||
const updated = await onceBus(FileWatcher.Event.Updated)
|
||||
|
||||
try {
|
||||
const edit = await resolve()
|
||||
await Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "original",
|
||||
newString: "modified",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
)
|
||||
|
||||
await updated.wait
|
||||
} finally {
|
||||
updated.unsub()
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("edge cases", () => {
|
||||
test("handles multiline replacements", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const edit = await resolve()
|
||||
await Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "line2",
|
||||
newString: "new line 2\nextra line",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
)
|
||||
|
||||
const content = await fs.readFile(filepath, "utf-8")
|
||||
expect(content).toBe("line1\nnew line 2\nextra line\nline3")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("handles CRLF line endings", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "line1\r\nold\r\nline3", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const edit = await resolve()
|
||||
await Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "old",
|
||||
newString: "new",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
)
|
||||
|
||||
const content = await fs.readFile(filepath, "utf-8")
|
||||
expect(content).toBe("line1\r\nnew\r\nline3")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("throws error when oldString equals newString", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "content", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const edit = await resolve()
|
||||
await expect(
|
||||
Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "",
|
||||
newString: "",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
),
|
||||
).rejects.toThrow("identical")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("throws error when path is directory", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const dirpath = path.join(tmp.path, "adir")
|
||||
await fs.mkdir(dirpath)
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const edit = await resolve()
|
||||
await expect(
|
||||
Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: dirpath,
|
||||
oldString: "old",
|
||||
newString: "new",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
),
|
||||
).rejects.toThrow("directory")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("tracks file diff statistics", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const edit = await resolve()
|
||||
const result = await Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "line2",
|
||||
newString: "new line a\nnew line b",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
)
|
||||
|
||||
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 = async (input: Input) => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "test.txt"), input.content)
|
||||
},
|
||||
})
|
||||
|
||||
return await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const edit = await resolve()
|
||||
const filePath = path.join(tmp.path, "test.txt")
|
||||
await Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath,
|
||||
oldString: input.oldString,
|
||||
newString: input.newString,
|
||||
replaceAll: input.replaceAll,
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
)
|
||||
return await Bun.file(filePath).text()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
test("preserves LF with LF multi-line strings", async () => {
|
||||
const content = normalize(old + "\n", "\n")
|
||||
const output = await apply({
|
||||
content,
|
||||
oldString: normalize(old, "\n"),
|
||||
newString: normalize(next, "\n"),
|
||||
})
|
||||
expect(output).toBe(normalize(next + "\n", "\n"))
|
||||
expectLf(output)
|
||||
})
|
||||
|
||||
test("preserves CRLF with CRLF multi-line strings", async () => {
|
||||
const content = normalize(old + "\n", "\r\n")
|
||||
const output = await apply({
|
||||
content,
|
||||
oldString: normalize(old, "\r\n"),
|
||||
newString: normalize(next, "\r\n"),
|
||||
})
|
||||
expect(output).toBe(normalize(next + "\n", "\r\n"))
|
||||
expectCrlf(output)
|
||||
})
|
||||
|
||||
test("preserves LF when old/new use CRLF", async () => {
|
||||
const content = normalize(old + "\n", "\n")
|
||||
const output = await apply({
|
||||
content,
|
||||
oldString: normalize(old, "\r\n"),
|
||||
newString: normalize(next, "\r\n"),
|
||||
})
|
||||
expect(output).toBe(normalize(next + "\n", "\n"))
|
||||
expectLf(output)
|
||||
})
|
||||
|
||||
test("preserves CRLF when old/new use LF", async () => {
|
||||
const content = normalize(old + "\n", "\r\n")
|
||||
const output = await apply({
|
||||
content,
|
||||
oldString: normalize(old, "\n"),
|
||||
newString: normalize(next, "\n"),
|
||||
})
|
||||
expect(output).toBe(normalize(next + "\n", "\r\n"))
|
||||
expectCrlf(output)
|
||||
})
|
||||
|
||||
test("preserves LF when newString uses CRLF", async () => {
|
||||
const content = normalize(old + "\n", "\n")
|
||||
const output = await apply({
|
||||
content,
|
||||
oldString: normalize(old, "\n"),
|
||||
newString: normalize(next, "\r\n"),
|
||||
})
|
||||
expect(output).toBe(normalize(next + "\n", "\n"))
|
||||
expectLf(output)
|
||||
})
|
||||
|
||||
test("preserves CRLF when newString uses LF", async () => {
|
||||
const content = normalize(old + "\n", "\r\n")
|
||||
const output = await apply({
|
||||
content,
|
||||
oldString: normalize(old, "\r\n"),
|
||||
newString: normalize(next, "\n"),
|
||||
})
|
||||
expect(output).toBe(normalize(next + "\n", "\r\n"))
|
||||
expectCrlf(output)
|
||||
})
|
||||
|
||||
test("preserves LF with mixed old/new line endings", async () => {
|
||||
const content = normalize(old + "\n", "\n")
|
||||
const output = await apply({
|
||||
content,
|
||||
oldString: "alpha\nbeta\r\ngamma",
|
||||
newString: "alpha\r\nbeta\nomega",
|
||||
})
|
||||
expect(output).toBe(normalize(alt + "\n", "\n"))
|
||||
expectLf(output)
|
||||
})
|
||||
|
||||
test("preserves CRLF with mixed old/new line endings", async () => {
|
||||
const content = normalize(old + "\n", "\r\n")
|
||||
const output = await apply({
|
||||
content,
|
||||
oldString: "alpha\r\nbeta\ngamma",
|
||||
newString: "alpha\nbeta\r\nomega",
|
||||
})
|
||||
expect(output).toBe(normalize(alt + "\n", "\r\n"))
|
||||
expectCrlf(output)
|
||||
})
|
||||
|
||||
test("replaceAll preserves LF for multi-line blocks", async () => {
|
||||
const blockOld = "alpha\nbeta"
|
||||
const blockNew = "alpha\nbeta-updated"
|
||||
const content = normalize(blockOld + "\n" + blockOld + "\n", "\n")
|
||||
const output = await apply({
|
||||
content,
|
||||
oldString: normalize(blockOld, "\n"),
|
||||
newString: normalize(blockNew, "\n"),
|
||||
replaceAll: true,
|
||||
})
|
||||
expect(output).toBe(normalize(blockNew + "\n" + blockNew + "\n", "\n"))
|
||||
expectLf(output)
|
||||
})
|
||||
|
||||
test("replaceAll preserves CRLF for multi-line blocks", async () => {
|
||||
const blockOld = "alpha\nbeta"
|
||||
const blockNew = "alpha\nbeta-updated"
|
||||
const content = normalize(blockOld + "\n" + blockOld + "\n", "\r\n")
|
||||
const output = await 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", () => {
|
||||
test("preserves concurrent edits to different sections of the same file", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "top = 0\nmiddle = keep\nbottom = 0\n", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const edit = await resolve()
|
||||
let asks = 0
|
||||
const firstAsk = Promise.withResolvers<void>()
|
||||
const delayedCtx = {
|
||||
...ctx,
|
||||
ask: () =>
|
||||
Effect.gen(function* () {
|
||||
asks++
|
||||
if (asks !== 1) return
|
||||
firstAsk.resolve()
|
||||
yield* Effect.promise(() => Bun.sleep(50))
|
||||
}),
|
||||
}
|
||||
|
||||
const promise1 = Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "top = 0",
|
||||
newString: "top = 1",
|
||||
},
|
||||
delayedCtx,
|
||||
),
|
||||
)
|
||||
|
||||
await firstAsk.promise
|
||||
|
||||
const promise2 = Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "bottom = 0",
|
||||
newString: "bottom = 2",
|
||||
},
|
||||
delayedCtx,
|
||||
),
|
||||
)
|
||||
|
||||
const results = await Promise.allSettled([promise1, promise2])
|
||||
expect(results[0]?.status).toBe("fulfilled")
|
||||
expect(results[1]?.status).toBe("fulfilled")
|
||||
expect(await fs.readFile(filepath, "utf-8")).toBe("top = 1\nmiddle = keep\nbottom = 2\n")
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,169 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import type { Tool } from "../../src/tool"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { assertExternalDirectory } from "../../src/tool/external-directory"
|
||||
import { Filesystem } from "../../src/util"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import type { Permission } from "../../src/permission"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
|
||||
const baseCtx: Omit<Tool.Context, "ask"> = {
|
||||
sessionID: SessionID.make("ses_test"),
|
||||
messageID: MessageID.make(""),
|
||||
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<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const ctx: Tool.Context = {
|
||||
...baseCtx,
|
||||
ask: (req) =>
|
||||
Effect.sync(() => {
|
||||
requests.push(req)
|
||||
}),
|
||||
}
|
||||
return { requests, ctx }
|
||||
}
|
||||
|
||||
describe("tool.assertExternalDirectory", () => {
|
||||
test("no-ops for empty target", async () => {
|
||||
const { requests, ctx } = makeCtx()
|
||||
|
||||
await Instance.provide({
|
||||
directory: "/tmp",
|
||||
fn: async () => {
|
||||
await assertExternalDirectory(ctx)
|
||||
},
|
||||
})
|
||||
|
||||
expect(requests.length).toBe(0)
|
||||
})
|
||||
|
||||
test("no-ops for paths inside Instance.directory", async () => {
|
||||
const { requests, ctx } = makeCtx()
|
||||
|
||||
await Instance.provide({
|
||||
directory: "/tmp/project",
|
||||
fn: async () => {
|
||||
await assertExternalDirectory(ctx, path.join("/tmp/project", "file.txt"))
|
||||
},
|
||||
})
|
||||
|
||||
expect(requests.length).toBe(0)
|
||||
})
|
||||
|
||||
test("asks with a single canonical glob", async () => {
|
||||
const { requests, ctx } = makeCtx()
|
||||
|
||||
const directory = "/tmp/project"
|
||||
const target = "/tmp/outside/file.txt"
|
||||
const expected = glob(path.join(path.dirname(target), "*"))
|
||||
|
||||
await Instance.provide({
|
||||
directory,
|
||||
fn: async () => {
|
||||
await assertExternalDirectory(ctx, target)
|
||||
},
|
||||
})
|
||||
|
||||
const req = requests.find((r) => r.permission === "external_directory")
|
||||
expect(req).toBeDefined()
|
||||
expect(req!.patterns).toEqual([expected])
|
||||
expect(req!.always).toEqual([expected])
|
||||
})
|
||||
|
||||
test("uses target directory when kind=directory", async () => {
|
||||
const { requests, ctx } = makeCtx()
|
||||
|
||||
const directory = "/tmp/project"
|
||||
const target = "/tmp/outside"
|
||||
const expected = glob(path.join(target, "*"))
|
||||
|
||||
await Instance.provide({
|
||||
directory,
|
||||
fn: async () => {
|
||||
await assertExternalDirectory(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])
|
||||
})
|
||||
|
||||
test("skips prompting when bypass=true", async () => {
|
||||
const { requests, ctx } = makeCtx()
|
||||
|
||||
await Instance.provide({
|
||||
directory: "/tmp/project",
|
||||
fn: async () => {
|
||||
await assertExternalDirectory(ctx, "/tmp/outside/file.txt", { bypass: true })
|
||||
},
|
||||
})
|
||||
|
||||
expect(requests.length).toBe(0)
|
||||
})
|
||||
|
||||
if (process.platform === "win32") {
|
||||
test("normalizes Windows path variants to one glob", async () => {
|
||||
const { requests, ctx } = makeCtx()
|
||||
|
||||
await using outerTmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "outside.txt"), "x")
|
||||
},
|
||||
})
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
const target = path.join(outerTmp.path, "outside.txt")
|
||||
const alt = target
|
||||
.replace(/^[A-Za-z]:/, "")
|
||||
.replaceAll("\\", "/")
|
||||
.toLowerCase()
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await assertExternalDirectory(ctx, alt)
|
||||
},
|
||||
})
|
||||
|
||||
const req = requests.find((r) => r.permission === "external_directory")
|
||||
const expected = glob(path.join(outerTmp.path, "*"))
|
||||
expect(req).toBeDefined()
|
||||
expect(req!.patterns).toEqual([expected])
|
||||
expect(req!.always).toEqual([expected])
|
||||
})
|
||||
|
||||
test("uses drive root glob for root files", async () => {
|
||||
const { requests, ctx } = makeCtx()
|
||||
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const root = path.parse(tmp.path).root
|
||||
const target = path.join(root, "boot.ini")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await assertExternalDirectory(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])
|
||||
})
|
||||
}
|
||||
})
|
||||
BIN
qimingcode/packages/opencode/test/tool/fixtures/large-image.png
Normal file
BIN
qimingcode/packages/opencode/test/tool/fixtures/large-image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 MiB |
65179
qimingcode/packages/opencode/test/tool/fixtures/models-api.json
Normal file
65179
qimingcode/packages/opencode/test/tool/fixtures/models-api.json
Normal file
File diff suppressed because it is too large
Load Diff
81
qimingcode/packages/opencode/test/tool/glob.test.ts
Normal file
81
qimingcode/packages/opencode/test/tool/glob.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
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 "../../src/file/ripgrep"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Truncate } from "../../src/tool"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
AppFileSystem.defaultLayer,
|
||||
Ripgrep.defaultLayer,
|
||||
Truncate.defaultLayer,
|
||||
Agent.defaultLayer,
|
||||
),
|
||||
)
|
||||
|
||||
const ctx = {
|
||||
sessionID: SessionID.make("ses_test"),
|
||||
messageID: MessageID.make(""),
|
||||
callID: "",
|
||||
agent: "build",
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => Effect.void,
|
||||
ask: () => Effect.void,
|
||||
}
|
||||
|
||||
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(
|
||||
{
|
||||
pattern: "*.ts",
|
||||
path: dir,
|
||||
},
|
||||
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")
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
114
qimingcode/packages/opencode/test/tool/grep.test.ts
Normal file
114
qimingcode/packages/opencode/test/tool/grep.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
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 { SessionID, MessageID } from "../../src/session/schema"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Truncate } from "../../src/tool"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { Ripgrep } from "../../src/file/ripgrep"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
AppFileSystem.defaultLayer,
|
||||
Ripgrep.defaultLayer,
|
||||
Truncate.defaultLayer,
|
||||
Agent.defaultLayer,
|
||||
),
|
||||
)
|
||||
|
||||
const ctx = {
|
||||
sessionID: SessionID.make("ses_test"),
|
||||
messageID: MessageID.make(""),
|
||||
callID: "",
|
||||
agent: "build",
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => Effect.void,
|
||||
ask: () => Effect.void,
|
||||
}
|
||||
|
||||
const root = path.join(__dirname, "../..")
|
||||
|
||||
describe("tool.grep", () => {
|
||||
it.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.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.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.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")
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
186
qimingcode/packages/opencode/test/tool/lsp.test.ts
Normal file
186
qimingcode/packages/opencode/test/tool/lsp.test.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
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 { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { LSP } from "../../src/lsp"
|
||||
import { Permission } from "../../src/permission"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { MessageID, SessionID } from "../../src/session/schema"
|
||||
import { Tool, Truncate } from "../../src/tool"
|
||||
import { LspTool } from "../../src/tool/lsp"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
const ctx = {
|
||||
sessionID: SessionID.make("ses_test"),
|
||||
messageID: MessageID.make(""),
|
||||
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,
|
||||
AppFileSystem.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* AppFileSystem.Service
|
||||
yield* fs.writeWithDirs(file, "export const x = 1\n")
|
||||
})
|
||||
|
||||
const asks = () => {
|
||||
const items: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
return {
|
||||
items,
|
||||
next: {
|
||||
...ctx,
|
||||
ask: (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) =>
|
||||
Effect.sync(() => {
|
||||
items.push(req)
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("tool.lsp", () => {
|
||||
describe("permission metadata", () => {
|
||||
it.live("keeps cursor details for position-based operations", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
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.live("omits cursor details for documentSymbol", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
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.live("omits file and cursor details for workspaceSymbol", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
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.live("passes workspaceSymbol query to LSP", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
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 },
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
260
qimingcode/packages/opencode/test/tool/parameters.test.ts
Normal file
260
qimingcode/packages/opencode/test/tool/parameters.test.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Result, Schema } from "effect"
|
||||
import { toJsonSchema } from "../../src/util/effect-zod"
|
||||
|
||||
// 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. `toJsonSchema` is the same helper `session/
|
||||
// prompt.ts` uses to emit tool schemas to the LLM, so the snapshots stay
|
||||
// byte-identical regardless of whether a tool has migrated from zod to Schema.
|
||||
|
||||
import { Parameters as ApplyPatch } from "../../src/tool/apply_patch"
|
||||
import { Parameters as Bash } from "../../src/tool/bash"
|
||||
import { Parameters as CodeSearch } from "../../src/tool/codesearch"
|
||||
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 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))
|
||||
|
||||
describe("tool parameters", () => {
|
||||
describe("JSON Schema (wire shape)", () => {
|
||||
test("apply_patch", () => expect(toJsonSchema(ApplyPatch)).toMatchSnapshot())
|
||||
test("bash", () => expect(toJsonSchema(Bash)).toMatchSnapshot())
|
||||
test("codesearch", () => expect(toJsonSchema(CodeSearch)).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())
|
||||
})
|
||||
|
||||
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("bash", () => {
|
||||
test("accepts minimum: command + description", () => {
|
||||
expect(parse(Bash, { command: "ls", description: "list" })).toEqual({ command: "ls", description: "list" })
|
||||
})
|
||||
test("accepts optional timeout + workdir", () => {
|
||||
const parsed = parse(Bash, { command: "ls", description: "list", timeout: 5000, workdir: "/tmp" })
|
||||
expect(parsed.timeout).toBe(5000)
|
||||
expect(parsed.workdir).toBe("/tmp")
|
||||
})
|
||||
test("rejects missing description (required by zod)", () => {
|
||||
expect(accepts(Bash, { command: "ls" })).toBe(false)
|
||||
})
|
||||
test("rejects missing command", () => {
|
||||
expect(accepts(Bash, { description: "list" })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("codesearch", () => {
|
||||
test("accepts query; tokensNum defaults to 5000", () => {
|
||||
expect(parse(CodeSearch, { query: "hooks" })).toEqual({ query: "hooks", tokensNum: 5000 })
|
||||
})
|
||||
test("accepts override tokensNum", () => {
|
||||
expect(parse(CodeSearch, { query: "hooks", tokensNum: 10000 }).tokensNum).toBe(10000)
|
||||
})
|
||||
test("rejects tokensNum under 1000", () => {
|
||||
expect(accepts(CodeSearch, { query: "x", tokensNum: 500 })).toBe(false)
|
||||
})
|
||||
test("rejects tokensNum over 50000", () => {
|
||||
expect(accepts(CodeSearch, { query: "x", tokensNum: 60000 })).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("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("accepts url-only", () => {
|
||||
expect(parse(WebFetch, { url: "https://example.com" }).url).toBe("https://example.com")
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
129
qimingcode/packages/opencode/test/tool/question.test.ts
Normal file
129
qimingcode/packages/opencode/test/tool/question.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Fiber, Layer } 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 "../../src/tool"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const ctx = {
|
||||
sessionID: SessionID.make("ses_test-session"),
|
||||
messageID: MessageID.make("test-message"),
|
||||
callID: "test-call",
|
||||
agent: "test-agent",
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => Effect.void,
|
||||
ask: () => Effect.void,
|
||||
}
|
||||
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(Question.defaultLayer, CrossSpawnSpawner.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer),
|
||||
)
|
||||
|
||||
const pending = Effect.fn("QuestionToolTest.pending")(function* (question: Question.Interface) {
|
||||
for (;;) {
|
||||
const items = yield* question.list()
|
||||
const item = items[0]
|
||||
if (item) return item
|
||||
yield* Effect.sleep("10 millis")
|
||||
}
|
||||
})
|
||||
|
||||
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,
|
||||
},
|
||||
]
|
||||
|
||||
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.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" }],
|
||||
},
|
||||
]
|
||||
|
||||
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)
|
||||
// }
|
||||
// })
|
||||
})
|
||||
483
qimingcode/packages/opencode/test/tool/read.test.ts
Normal file
483
qimingcode/packages/opencode/test/tool/read.test.ts
Normal file
@@ -0,0 +1,483 @@
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { Cause, Effect, Exit, Layer } from "effect"
|
||||
import path from "path"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { LSP } from "../../src/lsp"
|
||||
import { Permission } from "../../src/permission"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
import { Instruction } from "../../src/session/instruction"
|
||||
import { ReadTool } from "../../src/tool/read"
|
||||
import { Truncate } from "../../src/tool"
|
||||
import { Tool } from "../../src/tool"
|
||||
import { Filesystem } from "../../src/util"
|
||||
import { provideInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
|
||||
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
const ctx = {
|
||||
sessionID: SessionID.make("ses_test"),
|
||||
messageID: MessageID.make(""),
|
||||
callID: "",
|
||||
agent: "build",
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => Effect.void,
|
||||
ask: () => Effect.void,
|
||||
}
|
||||
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
Agent.defaultLayer,
|
||||
AppFileSystem.defaultLayer,
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
Instruction.defaultLayer,
|
||||
LSP.defaultLayer,
|
||||
Truncate.defaultLayer,
|
||||
),
|
||||
)
|
||||
|
||||
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 put = Effect.fn("ReadToolTest.put")(function* (p: string, content: string | Buffer | Uint8Array) {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
yield* fs.writeWithDirs(p, content)
|
||||
})
|
||||
const load = Effect.fn("ReadToolTest.load")(function* (p: string) {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
return yield* fs.readFileString(p)
|
||||
})
|
||||
const asks = () => {
|
||||
const items: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
return {
|
||||
items,
|
||||
next: {
|
||||
...ctx,
|
||||
ask: (req: Omit<Permission.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([full(target)])
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
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<Permission.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 Permission.DeniedError({ ruleset: info.permission })
|
||||
}
|
||||
}
|
||||
}),
|
||||
}
|
||||
|
||||
yield* run({ filePath: path.join(dir, filename) }, next)
|
||||
return asked
|
||||
}),
|
||||
)
|
||||
|
||||
expect(asked).toBe(shouldAsk)
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe("tool.read truncation", () => {
|
||||
it.live("truncates large file by bytes and sets truncated metadata", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped()
|
||||
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)
|
||||
|
||||
const result = yield* exec(dir, { filePath: path.join(dir, "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", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped()
|
||||
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
|
||||
yield* put(path.join(dir, "many-lines.txt"), lines)
|
||||
|
||||
const result = yield* exec(dir, { filePath: path.join(dir, "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.live("does not truncate small file", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped()
|
||||
yield* put(path.join(dir, "small.txt"), "hello world")
|
||||
|
||||
const result = yield* exec(dir, { filePath: path.join(dir, "small.txt") })
|
||||
expect(result.metadata.truncated).toBe(false)
|
||||
expect(result.output).toContain("End of file")
|
||||
}),
|
||||
)
|
||||
|
||||
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")
|
||||
}),
|
||||
)
|
||||
|
||||
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")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
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")
|
||||
}),
|
||||
)
|
||||
})
|
||||
152
qimingcode/packages/opencode/test/tool/registry.test.ts
Normal file
152
qimingcode/packages/opencode/test/tool/registry.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
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 "../../src/tool"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const node = CrossSpawnSpawner.defaultLayer
|
||||
|
||||
const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node))
|
||||
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
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.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.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",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
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")
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
96
qimingcode/packages/opencode/test/tool/skill.test.ts
Normal file
96
qimingcode/packages/opencode/test/tool/skill.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Effect, 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 "../../src/tool"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { SkillTool } from "../../src/tool/skill"
|
||||
import { ToolRegistry } from "../../src/tool"
|
||||
import { provideTmpdirInstance } 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(""),
|
||||
callID: "",
|
||||
agent: "build",
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => Effect.void,
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
const node = CrossSpawnSpawner.defaultLayer
|
||||
|
||||
const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node))
|
||||
|
||||
describe("tool.skill", () => {
|
||||
it.live("execute returns skill content block with files", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
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")
|
||||
|
||||
const requests: Array<Omit<Permission.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>`)
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
})
|
||||
387
qimingcode/packages/opencode/test/tool/task.test.ts
Normal file
387
qimingcode/packages/opencode/test/tool/task.test.ts
Normal file
@@ -0,0 +1,387 @@
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { Config } from "../../src/config"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Session } from "../../src/session"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import type { SessionPrompt } from "../../src/session/prompt"
|
||||
import { MessageID, PartID } from "../../src/session/schema"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { TaskTool, type TaskPromptOps } from "../../src/tool/task"
|
||||
import { Truncate } from "../../src/tool"
|
||||
import { ToolRegistry } from "../../src/tool"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
const ref = {
|
||||
providerID: ProviderID.make("test"),
|
||||
modelID: ModelID.make("test-model"),
|
||||
}
|
||||
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
Agent.defaultLayer,
|
||||
Config.defaultLayer,
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
Session.defaultLayer,
|
||||
Truncate.defaultLayer,
|
||||
ToolRegistry.defaultLayer,
|
||||
),
|
||||
)
|
||||
|
||||
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: MessageV2.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,
|
||||
time: { created: Date.now() },
|
||||
}
|
||||
yield* session.updateMessage(assistant)
|
||||
return { chat, assistant }
|
||||
})
|
||||
|
||||
function stubOps(opts?: { onPrompt?: (input: SessionPrompt.PromptInput) => void; text?: string }): TaskPromptOps {
|
||||
return {
|
||||
cancel() {},
|
||||
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): MessageV2.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.live("description sorts subagents by name and is stable across calls", () =>
|
||||
provideTmpdirInstance(
|
||||
() =>
|
||||
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.live("description hides denied subagents for the caller", () =>
|
||||
provideTmpdirInstance(
|
||||
() =>
|
||||
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.live("execute resumes an existing task session from task_id", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
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}`)
|
||||
expect(seen?.sessionID).toBe(child.id)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("execute asks by default and skips checks when bypassed", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
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.live("execute creates a child when task_id does not exist", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
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}`)
|
||||
expect(seen?.sessionID).toBe(result.metadata.sessionId)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("execute shapes child permissions for task, todowrite, and primary tools", () =>
|
||||
provideTmpdirInstance(
|
||||
() =>
|
||||
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.permission).toEqual([
|
||||
{
|
||||
permission: "todowrite",
|
||||
pattern: "*",
|
||||
action: "deny",
|
||||
},
|
||||
{
|
||||
permission: "bash",
|
||||
pattern: "*",
|
||||
action: "allow",
|
||||
},
|
||||
{
|
||||
permission: "read",
|
||||
pattern: "*",
|
||||
action: "allow",
|
||||
},
|
||||
])
|
||||
expect(seen?.tools).toEqual({
|
||||
todowrite: false,
|
||||
bash: false,
|
||||
read: false,
|
||||
})
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
agent: {
|
||||
reviewer: {
|
||||
mode: "subagent",
|
||||
permission: {
|
||||
task: "allow",
|
||||
},
|
||||
},
|
||||
},
|
||||
experimental: {
|
||||
primary_tools: ["bash", "read"],
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
})
|
||||
99
qimingcode/packages/opencode/test/tool/tool-define.test.ts
Normal file
99
qimingcode/packages/opencode/test/tool/tool-define.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { Effect, Layer, ManagedRuntime, Schema } from "effect"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { MessageID, SessionID } from "../../src/session/schema"
|
||||
import { Tool } from "../../src/tool"
|
||||
import { Truncate } from "../../src/tool"
|
||||
|
||||
const runtime = ManagedRuntime.make(Layer.mergeAll(Truncate.defaultLayer, Agent.defaultLayer))
|
||||
|
||||
const params = Schema.Struct({ input: Schema.String })
|
||||
|
||||
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", () => {
|
||||
test("object-defined tool does not mutate the original init object", async () => {
|
||||
const original = makeTool("test")
|
||||
const originalExecute = original.execute
|
||||
|
||||
const info = await runtime.runPromise(Tool.define("test-tool", Effect.succeed(original)))
|
||||
|
||||
await Effect.runPromise(info.init())
|
||||
await Effect.runPromise(info.init())
|
||||
await Effect.runPromise(info.init())
|
||||
|
||||
expect(original.execute).toBe(originalExecute)
|
||||
})
|
||||
|
||||
test("effect-defined tool returns fresh objects and is unaffected", async () => {
|
||||
const info = await runtime.runPromise(
|
||||
Tool.define(
|
||||
"test-fn-tool",
|
||||
Effect.succeed(() => Effect.succeed(makeTool("test"))),
|
||||
),
|
||||
)
|
||||
|
||||
const first = await Effect.runPromise(info.init())
|
||||
const second = await Effect.runPromise(info.init())
|
||||
|
||||
expect(first).not.toBe(second)
|
||||
})
|
||||
|
||||
test("object-defined tool returns distinct objects per init() call", async () => {
|
||||
const info = await runtime.runPromise(Tool.define("test-copy", Effect.succeed(makeTool("test"))))
|
||||
|
||||
const first = await Effect.runPromise(info.init())
|
||||
const second = await Effect.runPromise(info.init())
|
||||
|
||||
expect(first).not.toBe(second)
|
||||
})
|
||||
|
||||
test("execute receives decoded parameters", async () => {
|
||||
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 = await runtime.runPromise(
|
||||
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: Tool.Context = {
|
||||
sessionID: SessionID.descending(),
|
||||
messageID: MessageID.ascending(),
|
||||
agent: "build",
|
||||
abort: new AbortController().signal,
|
||||
messages: [],
|
||||
metadata() {
|
||||
return Effect.void
|
||||
},
|
||||
ask() {
|
||||
return Effect.void
|
||||
},
|
||||
}
|
||||
const tool = await Effect.runPromise(info.init())
|
||||
const execute = tool.execute as unknown as (args: unknown, ctx: Tool.Context) => ReturnType<typeof tool.execute>
|
||||
|
||||
await Effect.runPromise(execute({}, ctx))
|
||||
await Effect.runPromise(execute({ count: "7" }, ctx))
|
||||
|
||||
expect(calls).toEqual([{ count: 5 }, { count: 7 }])
|
||||
})
|
||||
})
|
||||
260
qimingcode/packages/opencode/test/tool/truncation.test.ts
Normal file
260
qimingcode/packages/opencode/test/tool/truncation.test.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Effect, FileSystem, Layer } from "effect"
|
||||
import { Truncate } from "../../src/tool"
|
||||
import { Config } from "../../src/config"
|
||||
import { Identifier } from "../../src/id/id"
|
||||
import { Process } from "../../src/util"
|
||||
import { Filesystem } from "../../src/util"
|
||||
import path from "path"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { writeFileStringScoped } from "../lib/filesystem"
|
||||
|
||||
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))
|
||||
|
||||
const configuredLayer = (cfg: Config.Info) =>
|
||||
Layer.mergeAll(
|
||||
Truncate.defaultLayer,
|
||||
NodeFileSystem.layer,
|
||||
Layer.mock(Config.Service)({ get: () => Effect.succeed(cfg) }),
|
||||
)
|
||||
const configuredIt = (cfg: Config.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 content = yield* Effect.promise(() => Filesystem.readText(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 content = yield* Effect.promise(() => Filesystem.readText(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 written = yield* Effect.promise(() => Filesystem.readText(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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
103
qimingcode/packages/opencode/test/tool/webfetch.test.ts
Normal file
103
qimingcode/packages/opencode/test/tool/webfetch.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { Truncate } from "../../src/tool"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WebFetchTool } from "../../src/tool/webfetch"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
|
||||
const projectRoot = path.join(import.meta.dir, "../..")
|
||||
|
||||
const ctx = {
|
||||
sessionID: SessionID.make("ses_test"),
|
||||
messageID: MessageID.make("message"),
|
||||
callID: "",
|
||||
agent: "build",
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => Effect.void,
|
||||
ask: () => Effect.void,
|
||||
}
|
||||
|
||||
async function withFetch(fetch: (req: Request) => Response | Promise<Response>, fn: (url: URL) => Promise<void>) {
|
||||
using server = Bun.serve({ port: 0, fetch })
|
||||
await fn(server.url)
|
||||
}
|
||||
|
||||
function exec(args: { url: string; format: "text" | "markdown" | "html" }) {
|
||||
return WebFetchTool.pipe(
|
||||
Effect.flatMap((info) => info.init()),
|
||||
Effect.flatMap((tool) => tool.execute(args, ctx)),
|
||||
Effect.provide(Layer.mergeAll(FetchHttpClient.layer, Truncate.defaultLayer, Agent.defaultLayer)),
|
||||
Effect.runPromise,
|
||||
)
|
||||
}
|
||||
|
||||
describe("tool.webfetch", () => {
|
||||
test("returns image responses as file attachments", async () => {
|
||||
const bytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10])
|
||||
await withFetch(
|
||||
() => new Response(bytes, { status: 200, headers: { "content-type": "IMAGE/PNG; charset=binary" } }),
|
||||
async (url) => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const result = await 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")
|
||||
},
|
||||
})
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test("keeps svg as text output", async () => {
|
||||
const svg = '<svg xmlns="http://www.w3.org/2000/svg"><text>hello</text></svg>'
|
||||
await withFetch(
|
||||
() =>
|
||||
new Response(svg, {
|
||||
status: 200,
|
||||
headers: { "content-type": "image/svg+xml; charset=UTF-8" },
|
||||
}),
|
||||
async (url) => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const result = await exec({ url: new URL("/image.svg", url).toString(), format: "html" })
|
||||
expect(result.output).toContain("<svg")
|
||||
expect(result.attachments).toBeUndefined()
|
||||
},
|
||||
})
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test("keeps text responses as text output", async () => {
|
||||
await withFetch(
|
||||
() =>
|
||||
new Response("hello from webfetch", {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/plain; charset=utf-8" },
|
||||
}),
|
||||
async (url) => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const result = await exec({ url: new URL("/file.txt", url).toString(), format: "text" })
|
||||
expect(result.output).toBe("hello from webfetch")
|
||||
expect(result.attachments).toBeUndefined()
|
||||
},
|
||||
})
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
291
qimingcode/packages/opencode/test/tool/write.test.ts
Normal file
291
qimingcode/packages/opencode/test/tool/write.test.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
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 { Instance } from "../../src/project/instance"
|
||||
import { LSP } from "../../src/lsp"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { Format } from "../../src/format"
|
||||
import { Truncate } from "../../src/tool"
|
||||
import { Tool } from "../../src/tool"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const ctx = {
|
||||
sessionID: SessionID.make("ses_test-write-session"),
|
||||
messageID: MessageID.make(""),
|
||||
callID: "",
|
||||
agent: "build",
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => Effect.void,
|
||||
ask: () => Effect.void,
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
LSP.defaultLayer,
|
||||
AppFileSystem.defaultLayer,
|
||||
Bus.layer,
|
||||
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.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!" })
|
||||
|
||||
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.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" })
|
||||
|
||||
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" })
|
||||
|
||||
const content = yield* Effect.promise(() => fs.readFile(path.join(dir, "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" })
|
||||
|
||||
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.live("preserves BOM when overwriting existing files", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(dir, "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.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",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
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" })
|
||||
|
||||
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" }) })
|
||||
|
||||
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) })
|
||||
|
||||
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 })
|
||||
|
||||
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: "" })
|
||||
|
||||
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.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 })
|
||||
|
||||
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 })
|
||||
|
||||
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")
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
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 }))
|
||||
|
||||
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