refactor(tools): unify tool APIs and result handling (#38367)
This commit is contained in:
parent
8cac010bac
commit
79c1544072
133 changed files with 3602 additions and 2770 deletions
|
|
@ -16,7 +16,7 @@ import { location } from "./fixture/location"
|
|||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
|
||||
|
||||
const patchToolNode = makeLocationNode({
|
||||
name: "test/patch-tool-plugin",
|
||||
|
|
@ -96,29 +96,19 @@ const withTool = <A, E, R>(
|
|||
const activeLocation = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location(
|
||||
{ directory: AbsolutePath.make(directory) },
|
||||
{ projectDirectory: AbsolutePath.make(projectDirectory) },
|
||||
),
|
||||
location({ directory: AbsolutePath.make(directory) }, { projectDirectory: AbsolutePath.make(projectDirectory) }),
|
||||
),
|
||||
)
|
||||
return Effect.gen(function* () {
|
||||
return yield* body(yield* ToolRegistry.Service)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
ToolRegistry.node,
|
||||
ToolRegistry.toolsNode,
|
||||
patchToolNode,
|
||||
]),
|
||||
[
|
||||
[FSUtil.node, filesystem],
|
||||
[Location.node, activeLocation],
|
||||
[PermissionV2.node, permission],
|
||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||
],
|
||||
),
|
||||
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, patchToolNode]), [
|
||||
[FSUtil.node, filesystem],
|
||||
[Location.node, activeLocation],
|
||||
[PermissionV2.node, permission],
|
||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||
]),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -162,18 +152,23 @@ describe("PatchTool", () => {
|
|||
withTool(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["patch"])
|
||||
const settled = yield* settleTool(
|
||||
const settled = yield* executeTool(
|
||||
registry,
|
||||
call(
|
||||
"*** Begin Patch\n*** Add File: nested/new.txt\n+created\n*** Update File: update.txt\n@@\n-before\n+after\n*** Delete File: remove.txt\n*** End Patch",
|
||||
),
|
||||
)
|
||||
expect(settled.result).toEqual({
|
||||
type: "text",
|
||||
value: "Success. Updated the following files:\nA nested/new.txt\nM update.txt\nD remove.txt",
|
||||
})
|
||||
if (process.platform === "win32") expect(settled.result.value).not.toContain("\\")
|
||||
expect(settled.output?.structured).toMatchObject({
|
||||
expect(settled.status).toBe("completed")
|
||||
if (settled.status !== "completed") return
|
||||
expect(settled.content).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
text: "Success. Updated the following files:\nA nested/new.txt\nM update.txt\nD remove.txt",
|
||||
},
|
||||
])
|
||||
const modelText = settled.content[0]?.type === "text" ? settled.content[0].text : ""
|
||||
if (process.platform === "win32") expect(modelText).not.toContain("\\")
|
||||
expect(settled.output).toMatchObject({
|
||||
applied: [
|
||||
{ type: "add", resource: "nested/new.txt" },
|
||||
{ type: "update", resource: "update.txt" },
|
||||
|
|
@ -248,9 +243,11 @@ describe("PatchTool", () => {
|
|||
"*** Begin Patch\n*** Add File: created.txt\n+created\n*** Update File: old.txt\n*** Move to: moved.txt\n@@\n-before\n+after\n*** End Patch",
|
||||
),
|
||||
),
|
||||
).toEqual({
|
||||
type: "text",
|
||||
value: "Success. Updated the following files:\nA created.txt\nM moved.txt",
|
||||
).toMatchObject({
|
||||
status: "completed",
|
||||
content: [
|
||||
{ type: "text", text: "Success. Updated the following files:\nA created.txt\nM moved.txt" },
|
||||
],
|
||||
})
|
||||
expect(yield* exists(source)).toBe(false)
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "moved.txt"), "utf8"))).toBe(
|
||||
|
|
@ -278,7 +275,9 @@ describe("PatchTool", () => {
|
|||
return Effect.promise(() =>
|
||||
Promise.all([
|
||||
fs.writeFile(source, "before\n"),
|
||||
fs.mkdir(path.dirname(destination), { recursive: true }).then(() => fs.writeFile(destination, "existing\n")),
|
||||
fs
|
||||
.mkdir(path.dirname(destination), { recursive: true })
|
||||
.then(() => fs.writeFile(destination, "existing\n")),
|
||||
]),
|
||||
).pipe(
|
||||
Effect.andThen(
|
||||
|
|
@ -291,7 +290,7 @@ describe("PatchTool", () => {
|
|||
"*** Begin Patch\n*** Update File: old.txt\n*** Move to: nested/moved.txt\n@@\n-before\n+after\n*** End Patch",
|
||||
),
|
||||
),
|
||||
).toMatchObject({ type: "text" })
|
||||
).toMatchObject({ status: "completed" })
|
||||
expect(yield* exists(source)).toBe(false)
|
||||
expect(yield* Effect.promise(() => fs.readFile(destination, "utf8"))).toBe("after\n")
|
||||
}),
|
||||
|
|
@ -325,19 +324,21 @@ describe("PatchTool", () => {
|
|||
),
|
||||
)
|
||||
|
||||
it.live("includes move file info in structured output", () =>
|
||||
it.live("includes move file info in output and metadata", () =>
|
||||
withTempTool((directory, registry) =>
|
||||
Effect.gen(function* () {
|
||||
const source = path.join(directory, "old", "name.txt")
|
||||
yield* Effect.promise(() => fs.mkdir(path.dirname(source), { recursive: true }))
|
||||
yield* Effect.promise(() => fs.writeFile(source, "old content\n"))
|
||||
const settled = yield* settleTool(
|
||||
const settled = yield* executeTool(
|
||||
registry,
|
||||
call(
|
||||
"*** 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",
|
||||
),
|
||||
)
|
||||
expect(settled.output?.structured).toMatchObject({
|
||||
expect(settled.status).toBe("completed")
|
||||
if (settled.status !== "completed") return
|
||||
expect(settled.output).toMatchObject({
|
||||
applied: [{ type: "update", resource: "renamed/dir/name.txt" }],
|
||||
files: [
|
||||
{
|
||||
|
|
@ -393,7 +394,7 @@ describe("PatchTool", () => {
|
|||
yield* Effect.promise(() => fs.mkdir(path.join(directory, "dir")))
|
||||
expect(
|
||||
yield* executeTool(registry, call("*** Begin Patch\n*** Delete File: dir\n*** End Patch")),
|
||||
).toMatchObject({ type: "error" })
|
||||
).toMatchObject({ status: "error" })
|
||||
expect(yield* exists(path.join(directory, "dir"))).toBe(true)
|
||||
}),
|
||||
),
|
||||
|
|
@ -407,11 +408,9 @@ describe("PatchTool", () => {
|
|||
expect(
|
||||
yield* executeTool(
|
||||
registry,
|
||||
call(
|
||||
"*** Begin Patch\n*** Update File: two-chunks.txt\n@@\n-b\n+B\n\n-d\n+D\n*** End Patch",
|
||||
),
|
||||
call("*** Begin Patch\n*** Update File: two-chunks.txt\n@@\n-b\n+B\n\n-d\n+D\n*** End Patch"),
|
||||
),
|
||||
).toMatchObject({ type: "error" })
|
||||
).toMatchObject({ status: "error" })
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("a\nb\nc\nd\n")
|
||||
}),
|
||||
),
|
||||
|
|
@ -420,7 +419,10 @@ describe("PatchTool", () => {
|
|||
it.live("requires patchText", () =>
|
||||
withTempTool((_directory, registry) =>
|
||||
Effect.gen(function* () {
|
||||
expect(yield* executeTool(registry, call(""))).toEqual({ type: "error", value: "patchText is required" })
|
||||
expect(yield* executeTool(registry, call(""))).toEqual({
|
||||
status: "error",
|
||||
error: { type: "tool.execution", message: "patchText is required" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
@ -429,12 +431,18 @@ describe("PatchTool", () => {
|
|||
withTempTool((_directory, registry) =>
|
||||
Effect.gen(function* () {
|
||||
expect(yield* executeTool(registry, call("invalid patch"))).toEqual({
|
||||
type: "error",
|
||||
value: "patch verification failed: The first line of the patch must be '*** Begin Patch'",
|
||||
status: "error",
|
||||
error: {
|
||||
type: "tool.execution",
|
||||
message: "patch verification failed: The first line of the patch must be '*** Begin Patch'",
|
||||
},
|
||||
})
|
||||
expect(yield* executeTool(registry, call("*** Begin Patch\n*** Add File: foo\n+hello"))).toEqual({
|
||||
type: "error",
|
||||
value: "patch verification failed: The last line of the patch must be '*** End Patch'",
|
||||
status: "error",
|
||||
error: {
|
||||
type: "tool.execution",
|
||||
message: "patch verification failed: The last line of the patch must be '*** End Patch'",
|
||||
},
|
||||
})
|
||||
}),
|
||||
),
|
||||
|
|
@ -444,8 +452,8 @@ describe("PatchTool", () => {
|
|||
withTempTool((_directory, registry) =>
|
||||
Effect.gen(function* () {
|
||||
expect(yield* executeTool(registry, call("*** Begin Patch\n*** End Patch"))).toEqual({
|
||||
type: "error",
|
||||
value: "patch rejected: empty patch",
|
||||
status: "error",
|
||||
error: { type: "tool.execution", message: "patch rejected: empty patch" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
|
|
@ -454,15 +462,13 @@ describe("PatchTool", () => {
|
|||
it.live("rejects an invalid hunk header", () =>
|
||||
withTempTool((_directory, registry) =>
|
||||
Effect.gen(function* () {
|
||||
expect(
|
||||
yield* executeTool(
|
||||
registry,
|
||||
call("*** Begin Patch\n*** Frobnicate File: foo\n*** End Patch"),
|
||||
),
|
||||
).toEqual({
|
||||
type: "error",
|
||||
value:
|
||||
"patch verification failed: Invalid hunk at line 2: '*** Frobnicate File: foo' is not a valid hunk header. Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'",
|
||||
expect(yield* executeTool(registry, call("*** Begin Patch\n*** Frobnicate File: foo\n*** End Patch"))).toEqual({
|
||||
status: "error",
|
||||
error: {
|
||||
type: "tool.execution",
|
||||
message:
|
||||
"patch verification failed: Invalid hunk at line 2: '*** Frobnicate File: foo' is not a valid hunk header. Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'",
|
||||
},
|
||||
})
|
||||
}),
|
||||
),
|
||||
|
|
@ -490,13 +496,13 @@ describe("PatchTool", () => {
|
|||
const bom = "\uFEFF"
|
||||
const target = path.join(directory, "example.cs")
|
||||
yield* Effect.promise(() => fs.writeFile(target, `${bom}using System;\n\nclass Test {}\n`))
|
||||
const settled = yield* settleTool(
|
||||
const settled = yield* executeTool(
|
||||
registry,
|
||||
call(
|
||||
"*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch",
|
||||
),
|
||||
call("*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch"),
|
||||
)
|
||||
const output = Schema.decodeUnknownSync(PatchTool.Output)(settled.output?.structured)
|
||||
expect(settled.status).toBe("completed")
|
||||
if (settled.status !== "completed") return
|
||||
const output = Schema.decodeUnknownSync(PatchTool.Output)(settled.output)
|
||||
expect(output.files[0]?.patch).not.toContain(bom)
|
||||
expect(output.files[0]?.patch).not.toContain("-using System;")
|
||||
expect(output.files[0]?.patch).not.toContain("+using System;")
|
||||
|
|
@ -517,7 +523,10 @@ describe("PatchTool", () => {
|
|||
registry,
|
||||
call("*** Begin Patch\n*** Update File: unchanged.txt\n@@\n-missing\n+changed\n*** End Patch"),
|
||||
),
|
||||
).toMatchObject({ type: "error", value: expect.stringContaining("Failed to find expected lines") })
|
||||
).toMatchObject({
|
||||
status: "error",
|
||||
error: { message: expect.stringContaining("Failed to find expected lines") },
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("line1\nline2\n")
|
||||
}),
|
||||
),
|
||||
|
|
@ -532,10 +541,12 @@ describe("PatchTool", () => {
|
|||
call("*** Begin Patch\n*** Update File: missing.txt\n@@\n-old\n+new\n*** End Patch"),
|
||||
),
|
||||
).toMatchObject({
|
||||
type: "error",
|
||||
value: expect.stringContaining(
|
||||
`patch verification failed: Failed to read file to update ${path.join(directory, "missing.txt")}: `,
|
||||
),
|
||||
status: "error",
|
||||
error: {
|
||||
message: expect.stringContaining(
|
||||
`patch verification failed: Failed to read file to update ${path.join(directory, "missing.txt")}: `,
|
||||
),
|
||||
},
|
||||
})
|
||||
}),
|
||||
),
|
||||
|
|
@ -548,8 +559,11 @@ describe("PatchTool", () => {
|
|||
expect(
|
||||
yield* executeTool(registry, call("*** Begin Patch\n*** Update File: nested\n@@\n-old\n+new\n*** End Patch")),
|
||||
).toEqual({
|
||||
type: "error",
|
||||
value: `patch verification failed: Failed to read file to update ${path.join(directory, "nested")}: path is a directory`,
|
||||
status: "error",
|
||||
error: {
|
||||
type: "tool.execution",
|
||||
message: `patch verification failed: Failed to read file to update ${path.join(directory, "nested")}: path is a directory`,
|
||||
},
|
||||
})
|
||||
}),
|
||||
),
|
||||
|
|
@ -560,7 +574,7 @@ describe("PatchTool", () => {
|
|||
Effect.gen(function* () {
|
||||
expect(
|
||||
yield* executeTool(registry, call("*** Begin Patch\n*** Delete File: missing.txt\n*** End Patch")),
|
||||
).toMatchObject({ type: "error", value: expect.stringContaining("patch verification failed") })
|
||||
).toMatchObject({ status: "error", error: { message: expect.stringContaining("patch verification failed") } })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
@ -580,7 +594,7 @@ describe("PatchTool", () => {
|
|||
registry,
|
||||
call(`*** Begin Patch\n*** Update File: ${target}\n@@\n-before\n+after\n*** End Patch`),
|
||||
),
|
||||
).toMatchObject({ type: "text" })
|
||||
).toMatchObject({ status: "completed" })
|
||||
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
|
||||
expect(readsBeforeEditApproval).toBe(1)
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
|
||||
|
|
@ -614,7 +628,7 @@ describe("PatchTool", () => {
|
|||
registry,
|
||||
call(`*** Begin Patch\n*** Update File: ${target}\n@@\n-before\n+after\n*** End Patch`),
|
||||
),
|
||||
).toMatchObject({ type: "error" })
|
||||
).toMatchObject({ status: "error" })
|
||||
expect(assertions.map((input) => input.action)).toEqual(["external_directory"])
|
||||
expect(readsBeforeEditApproval).toBe(0)
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("before\n")
|
||||
|
|
@ -649,7 +663,7 @@ describe("PatchTool", () => {
|
|||
registry,
|
||||
call("*** Begin Patch\n*** Update File: ../sibling.txt\n@@\n-before\n+after\n*** End Patch"),
|
||||
),
|
||||
).toMatchObject({ type: "text" })
|
||||
).toMatchObject({ status: "completed" })
|
||||
expect(assertions.map((input) => input.action)).toEqual(["edit"])
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
|
||||
}),
|
||||
|
|
@ -680,7 +694,7 @@ describe("PatchTool", () => {
|
|||
registry,
|
||||
call("*** Begin Patch\n*** Update File: link.txt\n@@\n-before\n+after\n*** End Patch"),
|
||||
),
|
||||
).toMatchObject({ type: "text" })
|
||||
).toMatchObject({ status: "completed" })
|
||||
expect(assertions.map((input) => input.action)).toEqual(["edit"])
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
|
||||
}),
|
||||
|
|
@ -711,7 +725,7 @@ describe("PatchTool", () => {
|
|||
registry,
|
||||
call(`*** Begin Patch\n*** Update File: ${relative}\n@@\n-before\n+after\n*** End Patch`),
|
||||
),
|
||||
).toMatchObject({ type: "text" })
|
||||
).toMatchObject({ status: "completed" })
|
||||
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
|
||||
expect(readsBeforeEditApproval).toBe(1)
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
|
||||
|
|
@ -747,7 +761,7 @@ describe("PatchTool", () => {
|
|||
`*** Begin Patch\n*** Update File: ${first}\n@@\n-before\n+after\n*** Update File: ${second}\n@@\n-before\n+after\n*** End Patch`,
|
||||
),
|
||||
),
|
||||
).toMatchObject({ type: "text" })
|
||||
).toMatchObject({ status: "completed" })
|
||||
expect(assertions.map((input) => input.action)).toEqual([
|
||||
"external_directory",
|
||||
"external_directory",
|
||||
|
|
@ -786,8 +800,10 @@ describe("PatchTool", () => {
|
|||
),
|
||||
),
|
||||
).toMatchObject({
|
||||
type: "error",
|
||||
value: expect.stringContaining("patch verification failed: Failed to read file to update"),
|
||||
status: "error",
|
||||
error: {
|
||||
message: expect.stringContaining("patch verification failed: Failed to read file to update"),
|
||||
},
|
||||
})
|
||||
expect(yield* exists(path.join(tmp.path, "created.txt"))).toBe(false)
|
||||
}),
|
||||
|
|
@ -812,7 +828,7 @@ describe("PatchTool", () => {
|
|||
registry,
|
||||
call("*** Begin Patch\n*** Add File: existing.txt\n+replacement\n*** End Patch"),
|
||||
),
|
||||
).toMatchObject({ type: "text" })
|
||||
).toMatchObject({ status: "completed" })
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("replacement\n")
|
||||
}),
|
||||
),
|
||||
|
|
@ -837,7 +853,7 @@ describe("PatchTool", () => {
|
|||
registry,
|
||||
call("*** Begin Patch\n*** Add File: appeared.txt\n+replacement\n*** End Patch"),
|
||||
),
|
||||
).toMatchObject({ type: "text" })
|
||||
).toMatchObject({ status: "completed" })
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("replacement\n")
|
||||
}),
|
||||
)
|
||||
|
|
@ -876,5 +892,4 @@ describe("PatchTool", () => {
|
|||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue