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
|
|
@ -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"),
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue