refactor(core): unify v2 tool architecture (#31168)

This commit is contained in:
Kit Langton 2026-06-06 20:49:12 -04:00 committed by GitHub
commit 660a00d317
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 2297 additions and 2041 deletions

View file

@ -13,11 +13,11 @@ import { AppProcess } from "@opencode-ai/core/process"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { BashTool } from "@opencode-ai/core/tool/bash"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
const sessionID = SessionV2.ID.make("ses_bash_tool_test")
const assertions: PermissionV2.AssertInput[] = []
@ -27,7 +27,6 @@ const runs: Array<{
readonly shell?: string | boolean
readonly options?: AppProcess.RunOptions
}> = []
const truncations: ToolOutputStore.TruncateInput[] = []
let denyAction: string | undefined
let result: AppProcess.RunResult = {
command: "mock",
@ -39,8 +38,6 @@ let result: AppProcess.RunResult = {
}
let runFailure: AppProcess.AppProcessError | undefined
let afterPermission = (_input: PermissionV2.AssertInput): Effect.Effect<void> => Effect.void
let truncate = (input: ToolOutputStore.TruncateInput): Effect.Effect<ToolOutputStore.TruncateResult> =>
Effect.succeed({ content: input.content, truncated: false })
const permission = Layer.succeed(
PermissionV2.Service,
@ -70,16 +67,6 @@ const appProcess = Layer.succeed(
}),
} as unknown as AppProcess.Interface),
)
const resources = Layer.succeed(
ToolOutputStore.Service,
ToolOutputStore.Service.of({
limits: () => Effect.die("unused"),
write: () => Effect.die("unused"),
truncate: (input) => Effect.sync(() => truncations.push(input)).pipe(Effect.andThen(truncate(input))),
bound: (input) => Effect.succeed({ output: input.output, outputPaths: [] }),
cleanup: () => Effect.die("unused"),
}),
)
const config = Layer.succeed(
Config.Service,
Config.Service.of({
@ -90,7 +77,6 @@ const config = Layer.succeed(
const reset = () => {
assertions.length = 0
runs.length = 0
truncations.length = 0
denyAction = undefined
runFailure = undefined
afterPermission = () => Effect.void
@ -102,7 +88,6 @@ const reset = () => {
stdoutTruncated: false,
stderrTruncated: false,
}
truncate = (input) => Effect.succeed({ content: input.content, truncated: false })
}
const withTool = <A, E, R>(
@ -123,7 +108,6 @@ const withTool = <A, E, R>(
Layer.provide(mutation),
Layer.provide(filesystem),
Layer.provide(processLayer),
Layer.provide(resources),
Layer.provide(config),
)
return Effect.gen(function* () {
@ -133,6 +117,7 @@ const withTool = <A, E, R>(
const call = (input: typeof BashTool.Parameters.Type, id = "call-bash") => ({
sessionID,
...toolIdentity,
call: { type: "tool-call" as const, id, name: "bash", input },
})
@ -146,11 +131,13 @@ describe("BashTool", () => {
reset()
return withTool(tmp.path, (registry) =>
Effect.gen(function* () {
const definitions = yield* registry.definitions()
const definitions = yield* toolDefinitions(registry)
expect(definitions.map((tool) => tool.name)).toEqual(["bash"])
expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.background")
expect(yield* registry.definitions([{ action: "bash", resource: "*", effect: "deny" }])).toEqual([])
expect(yield* registry.settle(call({ command: "pwd", description: "Print working directory" }))).toEqual({
expect(yield* toolDefinitions(registry, [{ action: "bash", resource: "*", effect: "deny" }])).toEqual([])
expect(
yield* settleTool(registry, call({ command: "pwd", description: "Print working directory" })),
).toEqual({
result: { type: "text", value: "hello\n\n\nCommand exited with code 0." },
output: {
structured: {
@ -168,7 +155,7 @@ describe("BashTool", () => {
maxOutputBytes: BashTool.MAX_CAPTURE_BYTES,
maxErrorBytes: BashTool.MAX_CAPTURE_BYTES,
})
expect(assertions).toEqual([{ sessionID, action: "bash", resources: ["pwd"], save: ["pwd"] }])
expect(assertions).toMatchObject([{ sessionID, action: "bash", resources: ["pwd"], save: ["pwd"] }])
}),
)
},
@ -182,7 +169,9 @@ describe("BashTool", () => {
(tmp) => {
reset()
return Effect.promise(() => fs.mkdir(path.join(tmp.path, "src"))).pipe(
Effect.andThen(withTool(tmp.path, (registry) => registry.execute(call({ command: "pwd", workdir: "src" })))),
Effect.andThen(
withTool(tmp.path, (registry) => executeTool(registry, call({ command: "pwd", workdir: "src" }))),
),
Effect.andThen(
Effect.sync(() => expect(runs).toMatchObject([{ cwd: realpathSync(path.join(tmp.path, "src")) }])),
),
@ -206,7 +195,9 @@ describe("BashTool", () => {
}).pipe(Effect.orDie)
: Effect.void
return Effect.promise(() => fs.mkdir(workdir)).pipe(
Effect.andThen(withTool(tmp.path, (registry) => registry.execute(call({ command: "pwd", workdir: "src" })))),
Effect.andThen(
withTool(tmp.path, (registry) => executeTool(registry, call({ command: "pwd", workdir: "src" }))),
),
Effect.andThen(
Effect.sync(() => {
expect(runs).toEqual([])
@ -227,7 +218,7 @@ describe("BashTool", () => {
reset()
return withTool(
tmp.path,
(registry) => registry.settle(call({ command: "printf core-bash" })),
(registry) => settleTool(registry, call({ command: "printf core-bash" })),
AppProcess.defaultLayer,
).pipe(
Effect.andThen((settled) =>
@ -254,7 +245,7 @@ describe("BashTool", () => {
([active, outside]) => {
reset()
return withTool(active.path, (registry) =>
registry.execute(call({ command: "pwd", workdir: outside.path })),
executeTool(registry, call({ command: "pwd", workdir: outside.path })),
).pipe(
Effect.andThen(
Effect.sync(() => {
@ -281,13 +272,15 @@ describe("BashTool", () => {
Effect.gen(function* () {
reset()
denyAction = "external_directory"
yield* withTool(active.path, (registry) => registry.execute(call({ command: "pwd", workdir: outside.path })))
yield* withTool(active.path, (registry) =>
executeTool(registry, call({ command: "pwd", workdir: outside.path })),
)
expect(assertions.map((item) => item.action)).toEqual(["external_directory"])
expect(runs).toEqual([])
reset()
denyAction = "bash"
yield* withTool(active.path, (registry) => registry.execute(call({ command: "pwd" })))
yield* withTool(active.path, (registry) => executeTool(registry, call({ command: "pwd" })))
expect(assertions.map((item) => item.action)).toEqual(["bash"])
expect(runs).toEqual([])
}),
@ -305,7 +298,7 @@ describe("BashTool", () => {
reset()
denyAction = "external_directory"
const target = path.join(outside.path, "secret.txt")
return withTool(active.path, (registry) => registry.settle(call({ command: `cat ${target}` }))).pipe(
return withTool(active.path, (registry) => settleTool(registry, call({ command: `cat ${target}` }))).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(assertions.map((item) => item.action)).toEqual(["bash"])
@ -327,19 +320,13 @@ describe("BashTool", () => {
),
)
it.live("keeps non-zero exits useful and exposes managed overflow by path", () =>
it.live("keeps non-zero exits useful", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
result = { ...result, exitCode: 7, stdout: Buffer.from("HEAD full output TAIL") }
truncate = (input) =>
Effect.succeed({
content: "HEAD\n\n... output truncated; full content saved to /tmp/tool-output/tool_opaque ...\n\nTAIL",
truncated: true,
outputPath: "/tmp/tool-output/tool_opaque",
})
return withTool(tmp.path, (registry) => registry.settle(call({ command: "false" }, "call-overflow"))).pipe(
return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "false" }, "call-overflow"))).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.result).toMatchObject({
@ -350,13 +337,9 @@ describe("BashTool", () => {
command: "false",
cwd: realpathSync(tmp.path),
exitCode: 7,
truncated: true,
outputPath: "/tmp/tool-output/tool_opaque",
output: "HEAD full output TAIL",
truncated: false,
})
expect(settled.outputPaths).toEqual(["/tmp/tool-output/tool_opaque"])
expect(truncations).toMatchObject([
{ sessionID, toolCallID: "call-overflow", content: "HEAD full output TAIL" },
])
}),
),
)
@ -371,7 +354,7 @@ describe("BashTool", () => {
(tmp) => {
reset()
result = { ...result, stdoutTruncated: true }
return withTool(tmp.path, (registry) => registry.settle(call({ command: "verbose" }))).pipe(
return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "verbose" }))).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.output?.structured).toMatchObject({ truncated: true, stdoutTruncated: true })
@ -394,7 +377,7 @@ describe("BashTool", () => {
(tmp) => {
reset()
runFailure = new AppProcess.AppProcessError({ command: "sleep", cause: new Error("Timed out") })
return withTool(tmp.path, (registry) => registry.settle(call({ command: "sleep 60", timeout: 10 }))).pipe(
return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "sleep 60", timeout: 10 }))).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.result).toMatchObject({