refactor(tools): unify tool APIs and result handling (#38367)

This commit is contained in:
Kit Langton 2026-07-23 17:13:31 -04:00 committed by GitHub
commit 79c1544072
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
133 changed files with 3602 additions and 2770 deletions

View file

@ -33,7 +33,7 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions, waitForTool } from "./lib/tool"
import { toolIdentity, executeTool, toolDefinitions, waitForTool } from "./lib/tool"
const sessionID = SessionV2.ID.make("ses_shell_tool_test")
const sessionModel = ModelV2.Ref.make({ id: ModelV2.ID.make("test"), providerID: ProviderV2.ID.make("test") })
@ -204,17 +204,19 @@ describe("ShellTool", () => {
const definitions = yield* toolDefinitions(registry)
const shell = definitions.find((tool) => tool.name === "shell")
expect(shell).toBeDefined()
expect(shell?.outputSchema).not.toHaveProperty("properties.output")
// Code Mode receives the declared output schema, including the command output text.
expect(shell?.outputSchema).toHaveProperty("properties.output")
expect(
(yield* toolDefinitions(registry, [{ action: "shell", resource: "*", effect: "deny" }])).map(
(tool) => tool.name,
),
).not.toContain("shell")
const settled = yield* settleTool(registry, call({ command: helloCommand }))
expect(settled.output?.structured).toMatchObject({ exit: 0, truncated: false })
expect(settled.output?.content[0]).toEqual({ type: "text", text: "hello" })
expect(settled.output?.content[1]).toMatchObject({
const settled = yield* executeTool(registry, call({ command: helloCommand }))
expect(settled.status).toBe("completed")
expect(settled.metadata).toMatchObject({ exit: 0, truncated: false })
expect(settled.content?.[0]).toEqual({ type: "text", text: "hello" })
expect(settled.content?.[1]).toMatchObject({
type: "text",
text: expect.stringContaining("Command exited with code 0."),
})
@ -233,11 +235,11 @@ describe("ShellTool", () => {
reset()
return Effect.promise(() => fs.mkdir(path.join(tmp.path, "src"))).pipe(
Effect.andThen(
withSession(tmp.path, (registry) => settleTool(registry, call({ command: cwdCommand, workdir: "src" }))),
withSession(tmp.path, (registry) => executeTool(registry, call({ command: cwdCommand, workdir: "src" }))),
),
Effect.andThen((settled) =>
Effect.sync(() =>
expect(settled.output?.content[0]).toMatchObject({
expect(settled.content?.[0]).toMatchObject({
type: "text",
text: expect.stringContaining(realpathSync(path.join(tmp.path, "src"))),
}),
@ -256,13 +258,13 @@ describe("ShellTool", () => {
reset()
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const stderr = yield* settleTool(registry, call({ command: stderrCommand }, "call-stderr"))
expect(stderr.output?.structured).toMatchObject({ exit: 0, truncated: false })
expect(stderr.output?.content[0]).toEqual({ type: "text", text: "stderr only" })
const stderr = yield* executeTool(registry, call({ command: stderrCommand }, "call-stderr"))
expect(stderr.metadata).toMatchObject({ exit: 0, truncated: false })
expect(stderr.content?.[0]).toEqual({ type: "text", text: "stderr only" })
const mixed = yield* settleTool(registry, call({ command: mixedOutputCommand }, "call-mixed"))
expect(mixed.output?.structured).toMatchObject({ exit: 0, truncated: false })
const output = mixed.output?.content[0]?.type === "text" ? mixed.output.content[0].text : ""
const mixed = yield* executeTool(registry, call({ command: mixedOutputCommand }, "call-mixed"))
expect(mixed.metadata).toMatchObject({ exit: 0, truncated: false })
const output = mixed.content?.[0]?.type === "text" ? mixed.content[0].text : ""
expect(output).toContain("stdout")
expect(output).toContain("stderr")
}),
@ -352,12 +354,12 @@ describe("ShellTool", () => {
reset()
denyAction = "external_directory"
const target = path.join(outside.path, "secret.txt")
return withSession(active.path, (registry) => settleTool(registry, call({ command: `cat ${target}` }))).pipe(
return withSession(active.path, (registry) => executeTool(registry, call({ command: `cat ${target}` }))).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(assertions.map((item) => item.action)).toEqual(["shell"])
expect(settled.output?.structured).not.toHaveProperty("warnings")
expect(settled.output?.content[1]).toMatchObject({
expect(settled.metadata).not.toHaveProperty("warnings")
expect(settled.content?.[1]).toMatchObject({
type: "text",
text: expect.stringContaining("Warnings:"),
})
@ -378,13 +380,14 @@ describe("ShellTool", () => {
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
settleTool(registry, call({ command: bodyExitCommand }, "call-nonzero")),
executeTool(registry, call({ command: bodyExitCommand }, "call-nonzero")),
).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.output?.structured).toMatchObject({ exit: 7, truncated: false })
expect(settled.output?.content[0]).toEqual({ type: "text", text: "body" })
expect(settled.output?.content[1]).toMatchObject({
expect(settled.status).toBe("completed")
expect(settled.metadata).toMatchObject({ exit: 7, truncated: false })
expect(settled.content?.[0]).toEqual({ type: "text", text: "body" })
expect(settled.content?.[1]).toMatchObject({
type: "text",
text: expect.stringContaining("Command exited with code 7"),
})
@ -403,12 +406,12 @@ describe("ShellTool", () => {
reset()
const bytes = ShellTool.MAX_CAPTURE_BYTES + 1024
return withSession(tmp.path, (registry) =>
settleTool(registry, call({ command: overflowCommand(bytes) }, "call-overflow")),
executeTool(registry, call({ command: overflowCommand(bytes) }, "call-overflow")),
).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.output?.structured).toMatchObject({ exit: 0, truncated: true })
expect(settled.output?.content[0]).toMatchObject({
expect(settled.metadata).toMatchObject({ exit: 0, truncated: true })
expect(settled.content?.[0]).toMatchObject({
type: "text",
text: expect.stringContaining("output truncated; full output saved to:"),
})
@ -421,7 +424,7 @@ describe("ShellTool", () => {
)
it.live(
"reports bounded output progress for a running command",
"reports the shell ID for a running command",
() =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
@ -431,32 +434,21 @@ describe("ShellTool", () => {
const releasePath = path.join(tmp.path, release)
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const observed = yield* Deferred.make<ToolRegistry.Progress>()
yield* settleTool(registry, {
const observed = yield* Deferred.make<string>()
yield* executeTool(registry, {
...call(
{ command: progressOverflowCommand(ShellTool.MAX_CAPTURE_BYTES + 1024, release) },
"call-progress",
),
progress: (update) =>
Effect.gen(function* () {
if (update.structured.truncated !== true) return
const content = update.content[0]
if (content?.type !== "text") return
if (content.text.indexOf("\n\n[output truncated; full output saved to:") !== ShellTool.MAX_CAPTURE_BYTES)
return
yield* Deferred.succeed(observed, update)
if (typeof update.shellID !== "string") return
yield* Deferred.succeed(observed, update.shellID)
yield* Effect.promise(() => fs.writeFile(releasePath, ""))
}),
})
const progress = yield* Deferred.await(observed)
expect(progress.structured).toEqual({ truncated: true })
const content = progress.content[0]
expect(content?.type).toBe("text")
if (content?.type !== "text") return
expect(content.text.indexOf("\n\n[output truncated; full output saved to:")).toBe(
ShellTool.MAX_CAPTURE_BYTES,
)
expect(yield* Deferred.await(observed)).toMatch(/^sh_/)
}).pipe(Effect.ensuring(Effect.promise(() => fs.writeFile(releasePath, "")).pipe(Effect.ignore))),
)
},
@ -466,7 +458,7 @@ describe("ShellTool", () => {
)
it.live(
"does not repeat unchanged shell progress",
"does not repeat shell ID progress",
() =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
@ -475,16 +467,12 @@ describe("ShellTool", () => {
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const updates: ToolRegistry.Progress[] = []
yield* settleTool(registry, {
yield* executeTool(registry, {
...call({ command: steadyProgressCommand }, "call-steady-progress"),
progress: (update) => Effect.sync(() => updates.push(update)),
})
expect(updates).toEqual([
{
structured: { truncated: false },
content: [{ type: "text", text: "steady" }],
},
])
expect(updates).toHaveLength(1)
expect(updates[0]?.shellID).toMatch(/^sh_/)
}),
)
},
@ -493,18 +481,18 @@ describe("ShellTool", () => {
{ timeout: 10_000 },
)
it.live("returns a useful timeout settlement", () =>
it.live("returns a useful timeout outcome", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
settleTool(registry, call({ command: idleCommand, timeout: 50 })),
executeTool(registry, call({ command: idleCommand, timeout: 50 })),
).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.output?.structured).toMatchObject({ timeout: true, truncated: false })
expect(settled.output?.content[1]).toMatchObject({
expect(settled.metadata).toMatchObject({ timeout: true, truncated: false })
expect(settled.content?.[1]).toMatchObject({
type: "text",
text: expect.stringContaining("Command timed out"),
})
@ -529,10 +517,9 @@ describe("ShellTool", () => {
Stream.runHead,
Effect.forkScoped({ startImmediately: true }),
)
const settled = yield* settleTool(registry, call({ command: idleCommand, timeout: 50, background: true }))
const structured = settled.output?.structured as Record<string, unknown> | undefined
const shellID = typeof structured?.shellID === "string" ? structured.shellID : undefined
expect(settled.output?.structured).toMatchObject({ truncated: false })
const settled = yield* executeTool(registry, call({ command: idleCommand, timeout: 50, background: true }))
const shellID = typeof settled.metadata?.shellID === "string" ? settled.metadata.shellID : undefined
expect(settled.metadata).toMatchObject({ truncated: false })
expect(shellID).toStartWith("sh_")
const shell = yield* Shell.Service
@ -562,22 +549,22 @@ describe("ShellTool", () => {
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const shell = yield* Shell.Service
const timed = yield* settleTool(
const timed = yield* executeTool(
registry,
call({ command: idleCommand, background: true }, "call-updated-timeout"),
)
const timedID = (timed.output?.structured as Record<string, unknown> | undefined)?.shellID
const timedID = timed.metadata?.shellID
expect(typeof timedID).toBe("string")
if (typeof timedID !== "string") return
const timedShellID = ShellSchema.ID.make(timedID)
yield* shell.timeout(timedShellID, 50)
expect((yield* shell.wait(timedShellID)).status).toBe("timeout")
const cleared = yield* settleTool(
const cleared = yield* executeTool(
registry,
call({ command: idleCommand, timeout: 50, background: true }, "call-cleared-timeout"),
)
const clearedID = (cleared.output?.structured as Record<string, unknown> | undefined)?.shellID
const clearedID = cleared.metadata?.shellID
expect(typeof clearedID).toBe("string")
if (typeof clearedID !== "string") return
const clearedShellID = ShellSchema.ID.make(clearedID)
@ -601,7 +588,7 @@ describe("ShellTool", () => {
Effect.gen(function* () {
const jobs = yield* Job.Service
const scope = yield* Scope.Scope
const waiting = yield* settleTool(
const waiting = yield* executeTool(
registry,
call({ command: idleCommand, timeout: 50 }, "call-background-signal"),
).pipe(Effect.forkIn(scope, { startImmediately: true }))
@ -616,14 +603,13 @@ describe("ShellTool", () => {
})
expect(yield* backgroundWhenReady()).toMatchObject([{ id: "call-background-signal", type: "shell" }])
const settled = yield* Fiber.join(waiting)
const structured = settled.output?.structured as Record<string, unknown> | undefined
const shellID = typeof structured?.shellID === "string" ? structured.shellID : undefined
expect(settled.output?.structured).toMatchObject({ truncated: false })
expect(settled.output?.content[0]).toEqual({
const shellID = typeof settled.metadata?.shellID === "string" ? settled.metadata.shellID : undefined
expect(settled.metadata).toMatchObject({ truncated: false })
expect(settled.content?.[0]).toEqual({
type: "text",
text: "The command was moved to the background.",
})
expect(settled.output?.content[1]).toMatchObject({
expect(settled.content?.[1]).toMatchObject({
type: "text",
text: expect.stringContaining("DO NOT sleep, poll"),
})