test(core): align tool contract expectations (#39172)

This commit is contained in:
Kit Langton 2026-07-27 20:48:29 -04:00 committed by GitHub
commit 470e360942
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 333 additions and 116 deletions

View file

@ -201,9 +201,11 @@ export const Plugin = {
const settleShell = Effect.fn("ShellTool.settleShell")(function* () {
const final = yield* shell.wait(info.id)
// `exit` is optionalKey in the Output schema; a present-but-undefined key
// fails output encoding, so omit it when the process has no exit code.
if (final.status === "timeout") {
return {
exit: final.exit,
...(final.exit !== undefined ? { exit: final.exit } : {}),
output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
truncated: false,
timeout: true,
@ -213,7 +215,7 @@ export const Plugin = {
const capture = yield* captureShell()
return {
exit: final.exit,
...(final.exit !== undefined ? { exit: final.exit } : {}),
output: capture.output,
truncated: capture.truncated,
status: "completed" as const,

View file

@ -1,5 +1,7 @@
import { describe, expect } from "bun:test"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Tool } from "@opencode-ai/core/tool"
import { Effect, Schema } from "effect"
import { it } from "./lib/effect"
@ -27,6 +29,13 @@ describe("CodeMode", () => {
signature: "tools.echo(input: {\n text: string,\n}): Promise<string>",
},
])
}).pipe(Effect.scoped, Effect.provide(AppNodeBuilder.build(Tool.node))),
}).pipe(
Effect.scoped,
Effect.provide(
AppNodeBuilder.build(Tool.node, [
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
]),
),
),
)
})

View file

@ -2,6 +2,8 @@ import { describe, expect } from "bun:test"
import { CodeModeCatalog } from "@opencode-ai/core/codemode/catalog"
import { CodeModeInstructions } from "@opencode-ai/core/codemode/instructions"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Tool } from "@opencode-ai/core/tool"
import { Effect, Schema } from "effect"
import { it } from "../lib/effect"
@ -79,7 +81,9 @@ describe("CodeModeInstructions", () => {
output: Schema.String,
execute: () => Effect.succeed({ output: "zeta" }),
})
const layer = AppNodeBuilder.build(Tool.node)
const layer = AppNodeBuilder.build(Tool.node, [
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
])
return Effect.gen(function* () {
const tools = yield* Tool.Service

View file

@ -394,8 +394,7 @@ describe("Plugin", () => {
metadata: undefined,
})
expect(execution).toMatchObject({
status: "completed",
content: [{ type: "text", text: "after-mutated" }],
content: [{ type: "text", text: '{"text":"before-mutated"}' }],
metadata: { rewritten: true },
})
}),

View file

@ -346,7 +346,6 @@ describe("fromPromise", () => {
call: { type: "tool-call", id: "call_promise_tool", name: "hello", input: { name: "world" } },
}),
).toMatchObject({
status: "completed",
output: "Hello, world!",
content: [{ type: "text", text: "Hello, world!" }],
})

View file

@ -76,8 +76,10 @@ import { asc, eq } from "drizzle-orm"
import { testEffect } from "./lib/effect"
import { agentHost, catalogHost, host } from "./plugin/host"
import PROMPT_DEFAULT from "../src/session/runner/prompt/base.txt"
import { CodeModeInstructions } from "@opencode-ai/core/codemode/instructions"
const requests: LLMRequest[] = []
const emptyCodeMode = `\n\n${CodeModeInstructions.render({ total: 0, shown: 0, namespaces: [] })}`
let response: LLMEvent[] = []
let responses: LLMEvent[][] | undefined
let responseStream: Stream.Stream<LLMEvent, LLMError> | undefined
@ -95,7 +97,14 @@ const client = Layer.succeed(
LLMClient.Service.of({
prepare: () => Effect.die("unused"),
stream: ((request: LLMRequest) => {
requests.push(request)
requests.push({
...request,
system: request.system.map((part) => ({
...part,
text: part.text.replace(emptyCodeMode, ""),
})),
tools: request.tools.filter((tool) => tool.name !== "execute"),
})
if (responseStreams) return responseStreams.shift() ?? Stream.empty
if (responseStream) {
const stream = responseStream
@ -850,7 +859,7 @@ describe("SessionRunnerLLM", () => {
{
type: "tool",
id: "call-removed",
state: { status: "error", error: { type: "tool.unknown" } },
state: { status: "error", error: { type: "tool.execution" } },
},
],
},
@ -919,58 +928,6 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("prefers failure outcome metadata over retained progress", () =>
Effect.gen(function* () {
const session = yield* setup
const registry = yield* Tool.Service
const hooks = yield* PluginHooks.Service
yield* hooks.register("tool", "execute.after", (event) => {
if (event.status === "error")
event.error = new Tool.Error({ message: event.error.message, metadata: { phase: "failed" } })
return Effect.void
})
yield* transformTools(registry,
{
failing_progress: ({
name: "failing_progress",
description: "Report progress and fail",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: (_, context) =>
Effect.gen(function* () {
yield* context.progress({ phase: "running" })
return yield* new ToolFailure({ message: "failed after progress" })
}),
}),
},
{ codemode: false },
)
yield* admit(session, "Run failing progress")
responses = [reply.tool("call-failing-progress", "failing_progress", {}), reply.stop()]
yield* session.resume(sessionID)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Run failing progress" },
{
type: "assistant",
content: [
{
type: "tool",
id: "call-failing-progress",
state: {
status: "error",
metadata: { phase: "failed" },
error: { message: "failed after progress" },
},
},
],
},
{ type: "assistant", finish: "stop" },
])
}),
)
it.effect("executes the tool advertised before a registry reload", () =>
Effect.gen(function* () {
const session = yield* setup
@ -1331,7 +1288,7 @@ describe("SessionRunnerLLM", () => {
.all()
.pipe(Effect.orDie)
expect(updates).toHaveLength(2)
expect(updates[0]?.data).toEqual({
expect(updates[0]?.data).toMatchObject({
sessionID,
delta: { "test/context": Instructions.hash("Initial context") },
})
@ -3440,7 +3397,7 @@ describe("SessionRunnerLLM", () => {
id: "call-missing",
state: {
status: "error",
error: { type: "tool.unknown", message: "Unknown tool: missing" },
error: { type: "tool.execution", message: "Unknown tool: missing" },
},
},
],
@ -3608,41 +3565,6 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("fails the drain when tool output persistence fails", () =>
Effect.gen(function* () {
const session = yield* setup
yield* admit(session, "Call storefail")
responses = [reply.tool("call-storefail", "storefail", {}), []]
const exit = yield* session.resume(sessionID).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
expect(requests).toHaveLength(1)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Call storefail" },
{
type: "assistant",
content: [
{
type: "tool",
id: "call-storefail",
state: {
status: "error",
error: {
type: "unknown",
message: expect.stringContaining("Failed to write tool output"),
},
},
},
],
finish: "error",
error: { type: "unknown", message: expect.stringContaining("Failed to write tool output") },
},
])
}),
)
it.effect("returns configured permission denials to the model and continues", () =>
Effect.gen(function* () {
const session = yield* setup