From cbe52d2da223fb00a077cdef46ac5af2902d117a Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 29 Oct 2026 21:38:49 -0500 Subject: [PATCH] fix(core): show Code Mode programs the same failure text as direct calls (#60109) --- packages/core/src/codemode/tool.ts | 7 ++++- packages/core/test/mcp.test.ts | 1 - packages/core/test/tool-opencode.test.ts | 37 ++++++++++++++---------- 3 files changed, 27 insertions(+), 18 deletions(-) diff --git a/packages/core/src/codemode/tool.ts b/packages/core/src/codemode/tool.ts index d8208aee7ee7..54e9c7b94984 100644 --- a/packages/core/src/codemode/tool.ts +++ b/packages/core/src/codemode/tool.ts @@ -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) diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index dff5b6af96d8..89480a7c6e75 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -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" })', diff --git a/packages/core/test/tool-opencode.test.ts b/packages/core/test/tool-opencode.test.ts index 42737e345765..9eb1618cf5f2 100644 --- a/packages/core/test/tool-opencode.test.ts +++ b/packages/core/test/tool-opencode.test.ts @@ -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) => + 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) => + program(`return await tools.opencode.${name}(${JSON.stringify(input)})`) const run = (name: string, input: Record) => 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" }) }),