fix(core): show Code Mode programs the same failure text as direct calls (#60109)

This commit is contained in:
Aiden Cline 2026-10-29 21:38:49 -05:00 • committed by GitHub
parent c45aae77ab
commit cbe52d2da2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 27 additions and 18 deletions

View file

@ -1,5 +1,6 @@
export * as CodeModeTool from "./tool.js"
import { ToolFailure } from "@opencode/ai/effect"
import { CodeMode, Namespace, Tool } from "@opencode/codemode"
import type {
Content,
@ -11,6 +12,7 @@ import type {
Result,
} from "@opencode/schema/tool"
import { Clock, Context, Effect, Ref, Schema, Semaphore } from "effect"
import { toSessionError } from "../session/to-session-error.js"
import { definition, normalizedName } from "../tool/runtime.js"
import { CodeModeCatalog } from "./catalog.js"
import { CodeModeWeb } from "./web.js"
@ -120,7 +122,10 @@ export const create = (
executeTool: (name, tool, input) =>
Effect.gen(function* () {
const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1)
const executed = yield* executeTool(name, tool, input, context)
// The program sees the same failure text the model gets for a direct call.
const executed = yield* executeTool(name, tool, input, context).pipe(
Effect.mapError((error) => new ToolFailure({ message: toSessionError(error).message, error })),
)
const content = resultContent(executed)
const outputFileParts = outputFiles(content)
if (outputFileParts.length > 0)

View file

@ -1619,7 +1619,6 @@ it.live("discovers and reads MCP resources through Code Mode", () =>
'return await tools.opencode.read_mcp_resource({ server: "resources", uri: "docs://gone" })',
)
expect(gone.metadata?.error).toBe(true)
expect(gone.output.output).toContain("Unable to read MCP resource resources:docs://gone")
expect(gone.output.output).toContain("Resource not found")
const missing = yield* run(
'return await tools.opencode.read_mcp_resource({ server: "missing", uri: "docs://readme" })',

View file

@ -144,17 +144,15 @@ it.effect("groups available models by provider with paging", () =>
}),
)
const approvals = { assertions: [] as Permission.AssertInput[], denyEdit: false }
const approvals = { assertions: [] as Permission.AssertInput[], rejectEdit: false }
const worktreeIt = testEffect(
makePluginTestLayer(
permissionLayer({
assert: (input) =>
Effect.sync(() => approvals.assertions.push(input)).pipe(
Effect.andThen(
approvals.denyEdit && input.action === "edit"
? Effect.fail(
new Permission.BlockedError({ rules: [], permission: input.action, resources: input.resources }),
)
approvals.rejectEdit && input.action === "edit"
? Effect.fail(new Permission.CorrectedError({ feedback: "Keep this worktree" }))
: Effect.void,
),
),
@ -193,17 +191,14 @@ worktreeIt.live("creates, lists, and removes worktrees for the plugin's project"
yield* OpenCodeTools.Plugin.effect(yield* PluginHost.make(plugins))
const session = yield* sessions.create({ location: Location.Ref.make({ directory: location.directory }) })
const registry = yield* Tool.Service
const call = (name: string, input: Record<string, unknown>) =>
const program = (code: string) =>
executeTool(registry, {
sessionID: session.id,
...toolIdentity,
call: {
type: "tool-call",
id: `call-${name}-${JSON.stringify(input)}`,
name: "execute",
input: { code: `return await tools.opencode.${name}(${JSON.stringify(input)})` },
},
call: { type: "tool-call", id: `call-${code}`, name: "execute", input: { code } },
})
const call = (name: string, input: Record<string, unknown>) =>
program(`return await tools.opencode.${name}(${JSON.stringify(input)})`)
const run = (name: string, input: Record<string, unknown>) =>
call(name, input).pipe(
Effect.map((result) => JSON.parse(result.content?.[0]?.type === "text" ? result.content[0].text : "")),
@ -234,12 +229,21 @@ worktreeIt.live("creates, lists, and removes worktrees for the plugin's project"
expect(yield* Effect.promise(() => Bun.file(path.join(directory, "draft.txt")).exists())).toBe(true)
yield* Effect.promise(() => fs.rm(path.join(directory, "draft.txt")))
// Removal requires the same approval as editing files in the worktree.
// Removal requires the same approval as editing files in the worktree. Rejection feedback reaches the
// program and the execute result, as it would reach the model from a direct call.
approvals.assertions.length = 0
approvals.denyEdit = true
expect(yield* call("worktree_remove", { directory })).toMatchObject({ metadata: { error: true } })
approvals.rejectEdit = true
expect(yield* call("worktree_remove", { directory })).toMatchObject({
content: [{ type: "text", text: "Keep this worktree" }],
metadata: { error: true },
})
expect(
yield* program(
`try { await tools.opencode.worktree_remove(${JSON.stringify({ directory })}) } catch (error) { return error.message }`,
),
).toMatchObject({ content: [{ type: "text", text: "Keep this worktree" }] })
expect(yield* Effect.promise(() => Bun.file(path.join(directory, ".git")).exists())).toBe(true)
approvals.denyEdit = false
approvals.rejectEdit = false
// Relative paths resolve from the session's directory for both approval and removal.
expect(yield* run("worktree_remove", { directory: path.relative(location.directory, directory) })).toEqual({
@ -252,6 +256,7 @@ worktreeIt.live("creates, lists, and removes worktrees for the plugin's project"
expect(approvals.assertions).toMatchObject([
{ action: "edit", resources: [resource], save: ["*"], sessionID: session.id },
{ action: "edit", resources: [resource], save: ["*"], sessionID: session.id },
{ action: "edit", resources: [resource], save: ["*"], sessionID: session.id },
])
expect((yield* run("worktree_list", {})).worktrees).not.toContainEqual({ directory, strategy: "git" })
}),