Compare commits

...

3 Commits

Author SHA1 Message Date
Aiden Cline 945e73c9b7 fix(core): keep nested MCP code mode tools direct 2026-08-17 03:51:01 +00:00
Luke Parker c400746dd5 fix(app): render code mode executions (#42949) 2026-08-17 13:03:06 +10:00
opencode-agent[bot] e3ce37899d fix(core): run HTTP hooks for session generate (#42965)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-16 21:44:22 -05:00
12 changed files with 247 additions and 48 deletions
+5
View File
@@ -63,6 +63,7 @@ import { AgentPlugin } from "./agent.js"
import { CommandPlugin } from "./command.js"
import { PlanPlugin } from "./plan.js"
import { ModelsDevPlugin } from "./models-dev.js"
import { McpCodeModePlugin } from "./mcp-codemode.js"
import { ProviderPlugins } from "./provider.js"
import { WebSearchPlugins } from "./websearch/index.js"
import { PluginRuntime } from "./runtime.js"
@@ -94,6 +95,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const location = yield* Location.Service
const locationMutation = yield* LocationMutation.Service
const models = yield* ModelsDev.Service
const mcpCodeMode = yield* McpCodeModePlugin.Service
const npm = yield* Npm.Service
const permission = yield* Permission.Service
const runtime = yield* PluginRuntime.Service
@@ -131,6 +133,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
Context.make(Location.Service, location),
Context.make(LocationMutation.Service, locationMutation),
Context.make(ModelsDev.Service, models),
Context.make(McpCodeModePlugin.Service, mcpCodeMode),
Context.make(Npm.Service, npm),
Context.make(Permission.Service, permission),
Context.make(PluginRuntime.Service, runtime),
@@ -175,6 +178,7 @@ export const requirements = LayerNode.group([
Location.node,
LocationMutation.node,
ModelsDev.node,
McpCodeModePlugin.node,
Npm.node,
Permission.node,
PluginRuntime.node,
@@ -202,6 +206,7 @@ const pre = [
SkillPlugin.Plugin,
...SystemPromptPlugin.Plugins,
ModelsDevPlugin,
McpCodeModePlugin.Plugin,
...ProviderPlugins,
...WebSearchPlugins,
PatchTool.Plugin,
+51
View File
@@ -0,0 +1,51 @@
export * as McpCodeModePlugin from "./mcp-codemode.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Scope } from "effect"
const directToolHosts = new Set(["mcp.cloudflare.com"])
type Resolver = (config: typeof ConfigMCP.Server.Type) => boolean | undefined
export interface Interface {
register: (resolver: Resolver) => Effect.Effect<void, never, Scope.Scope>
resolve: (config: typeof ConfigMCP.Server.Type) => boolean | undefined
}
export class Service extends Context.Service<Service, Interface>()("@opencode/McpCodeModePlugin") {}
export const layer = Layer.effect(
Service,
Effect.sync(() => {
let resolvers: Resolver[] = []
return Service.of({
register: Effect.fn("McpCodeModePlugin.register")(function* (resolver) {
resolvers = [...resolvers, resolver]
yield* Effect.addFinalizer(() => Effect.sync(() => (resolvers = resolvers.filter((item) => item !== resolver))))
}),
resolve: (config) =>
resolvers
.toReversed()
.map((resolver) => resolver(config))
.find((value) => value !== undefined),
})
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [] })
export const Plugin = define({
id: "opencode.mcp.codemode-compatibility",
effect: Effect.fn(function* () {
const defaults = yield* Service
yield* defaults.register(codeModeCompatibilityDefault)
}),
})
export function codeModeCompatibilityDefault(config: typeof ConfigMCP.Server.Type) {
if (config.type !== "remote") return
const url = URL.parse(config.url)
if (!url || !directToolHosts.has(url.hostname.toLowerCase())) return
return false
}
@@ -11,6 +11,7 @@ import { SessionContext } from "./context.js"
import { SessionGenerate } from "./generate.js"
import { SessionHistory } from "./history.js"
import { SessionModelHeaders } from "./model-headers.js"
import { SessionModelHttp } from "./model-http.js"
import { SessionPromptCacheKey } from "./prompt-cache-key.js"
import { SessionRunnerModel } from "./runner/model.js"
import { SessionSystemPrompt } from "./system-prompt.js"
@@ -79,6 +80,13 @@ export const layer = Layer.effect(
messages: contextEvent.messages,
tools: hookedTools,
}),
{
http: SessionModelHttp.middleware(hooks, {
sessionID: selection.session.id,
agent: selection.agent.id,
model: model.ref,
}),
},
)
yield* Effect.logInfo("session generation usage diagnostic", { usage: response.usage })
return response.text
+19 -3
View File
@@ -2,12 +2,17 @@ export * as McpTool from "./mcp.js"
import { ToolFailure } from "@opencode-ai/ai"
import { McpEvent } from "@opencode-ai/schema/mcp-event"
import { Document } from "@opencode-ai/schema/config"
import { Context, Effect, Exit, Fiber, type JsonSchema, Layer, Scope, Semaphore, Stream } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "../bus.js"
import { MCP } from "../mcp/index.js"
import { Config } from "../config.js"
import { Permission } from "../permission.js"
import { Plugin } from "../plugin.js"
import { McpCodeModePlugin } from "../plugin/mcp-codemode.js"
import { PluginSupervisor } from "../plugin/supervisor.js"
import { Tool } from "../tool.js"
/**
@@ -27,6 +32,8 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const mcp = yield* MCP.Service
const config = yield* Config.Service
const codemode = yield* McpCodeModePlugin.Service
const tools = yield* Tool.Service
const bus = yield* Bus.Service
const permission = yield* Permission.Service
@@ -39,14 +46,23 @@ export const layer = Layer.effect(
const reconcile = lock.withPermit(
Effect.gen(function* () {
const discovered = yield* mcp.tools()
const servers = new Map(
(yield* config.entries())
.filter((entry): entry is Document => entry.type === "document")
.flatMap((entry) => Object.entries(entry.info.mcp?.servers ?? {})),
)
const next = yield* Scope.fork(scope)
yield* tools
.transform((draft) => {
for (const tool of discovered) {
const schema = (tool.inputSchema ?? {}) as JsonSchema.JsonSchema
const server = servers.get(tool.server)
draft.add({
name: tool.name,
options: { namespace: namespace(tool.server), codemode: tool.codemode !== false },
options: {
namespace: namespace(tool.server),
codemode: (tool.codemode ?? (server ? codemode.resolve(server) : undefined)) !== false,
},
description: tool.description ?? "",
input: {
...schema,
@@ -122,7 +138,7 @@ export const layer = Layer.effect(
)
const initial = yield* reconcile.pipe(Effect.forkScoped)
yield* bus.subscribe(McpEvent.ToolsChanged).pipe(
yield* bus.subscribe([McpEvent.ToolsChanged, Plugin.Event.Updated]).pipe(
Stream.runForEach(() => reconcile),
Effect.forkScoped({ startImmediately: true }),
)
@@ -133,5 +149,5 @@ export const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [Tool.node, MCP.node, Bus.node, Permission.node],
deps: [Tool.node, MCP.node, McpCodeModePlugin.node, Config.node, Bus.node, Permission.node, PluginSupervisor.node],
})
+6
View File
@@ -29,6 +29,8 @@ import { MCP } from "@opencode-ai/core/mcp/index"
import { MCPClient } from "@opencode-ai/core/mcp/client"
import { MCPStdio } from "@opencode-ai/core/mcp/stdio"
import { Permission } from "@opencode-ai/core/permission"
import { McpCodeModePlugin } from "@opencode-ai/core/plugin/mcp-codemode"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { McpTool } from "@opencode-ai/core/tool/mcp"
@@ -336,12 +338,16 @@ const permissions = Layer.mock(Permission.Service, {
}),
})
const events = Layer.mock(Bus.Service, { subscribe: () => Stream.never })
const plugins = Layer.succeed(PluginSupervisor.Service, PluginSupervisor.Service.of({ flush: Effect.void }))
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node]), [
[MCP.node, mcp],
[McpCodeModePlugin.node, McpCodeModePlugin.layer],
[Config.node, Config.testLayer()],
[Permission.node, permissions],
[Bus.node, events],
[Image.node, imagePassthrough],
[PluginSupervisor.node, plugins],
]),
)
@@ -0,0 +1,36 @@
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
import { codeModeCompatibilityDefault, McpCodeModePlugin } from "@opencode-ai/core/plugin/mcp-codemode"
import { describe, expect, it } from "bun:test"
import { Effect } from "effect"
describe("MCP Code Mode compatibility defaults", () => {
it("keeps Cloudflare's Code Mode MCP server direct by default", () => {
const config = new ConfigMCP.Remote({ type: "remote", url: "https://mcp.cloudflare.com/mcp" })
expect(codeModeCompatibilityDefault(config)).toBe(false)
})
it("does not change Cloudflare's product-specific MCP servers", () => {
const config = new ConfigMCP.Remote({ type: "remote", url: "https://docs.mcp.cloudflare.com/mcp" })
expect(codeModeCompatibilityDefault(config)).toBeUndefined()
})
it("does not change local or unrelated remote servers", () => {
const local = new ConfigMCP.Local({ type: "local", command: ["server"] })
const remote = new ConfigMCP.Remote({ type: "remote", url: "https://example.com/mcp" })
expect(codeModeCompatibilityDefault(local)).toBeUndefined()
expect(codeModeCompatibilityDefault(remote)).toBeUndefined()
})
it("retains a registered direct-tool default", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const defaults = yield* McpCodeModePlugin.Service
yield* defaults.register(codeModeCompatibilityDefault)
const config = new ConfigMCP.Remote({ type: "remote", url: "https://mcp.cloudflare.com/mcp" })
expect(defaults.resolve(config)).toBe(false)
}).pipe(Effect.provide(McpCodeModePlugin.layer)),
),
)
})
})
+5 -1
View File
@@ -51,15 +51,17 @@ import { Effect, Layer, Schema, Stream } from "effect"
import { testEffect } from "./lib/effect"
const requests: LLMRequest[] = []
let hasHttpMiddleware = false
let instruction: string | Instructions.Unavailable = "Initial context"
const sessionID = SessionSchema.ID.make("ses_generate_test")
const model = LanguageModel.make({ id: "generate-model", provider: "test", route: OpenAIChat.route })
const client = Layer.mock(LLMClient.Service)({
stream: () => Stream.die(new Error("unused")),
generate: (request) =>
generate: (request, options) =>
Effect.sync(() => {
requests.push(request)
hasHttpMiddleware = typeof options?.http === "function"
const response = LLMResponse.fromEvents([
LLMEvent.stepStart({ index: 0 }),
LLMEvent.textStart({ id: "generate" }),
@@ -221,6 +223,7 @@ const setup = Effect.gen(function* () {
it.effect("generates from fresh settled Session context without durable mutation", () =>
Effect.gen(function* () {
requests.length = 0
hasHttpMiddleware = false
instruction = "Initial context"
const { db, bus, instructions } = yield* setup
yield* InstructionState.prepare(db, bus, instructions, sessionID)
@@ -298,6 +301,7 @@ it.effect("generates from fresh settled Session context without durable mutation
expect(result).toBe("Transient answer")
expect(requests).toHaveLength(1)
expect(hasHttpMiddleware).toBe(true)
expect(requests[0]?.model).toBe(model)
expect(requests[0]?.system[0]?.text).toBe("Hooked system")
expect(requests[0]?.system.map((part) => part.text)).toContain("Initial context")
@@ -547,6 +547,12 @@ export function getToolInfo(
title: i18n.t("ui.tool.shell"),
subtitle: input.command,
}
case "execute":
return {
icon: "console",
title: i18n.t("ui.tool.execute"),
subtitle: input.code,
}
case "edit":
return {
icon: "code-lines",
@@ -1574,6 +1580,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
if (typeof value === "string" && value) return value
return taskId()
})
const toolError = createMemo(() => partError(part(), i18n.t("ui.toolErrorCard.failed")))
const render = createMemo(() => ToolRegistry.render(part().tool) ?? GenericTool)
const controlledOpen = () => (props.onToolOpenChange ? (props.toolOpen ?? props.defaultOpen) : undefined)
@@ -1583,7 +1590,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
<Show when={!hideQuestion()}>
<div data-component="tool-part-wrapper" data-timeline-part-id={part().id}>
<Switch>
<Match when={part().state.status === "error" && (part().state as any).error}>
<Match when={toolError()}>
{(error) => {
const cleaned = error().replace("Error: ", "")
if (part().tool === "question" && cleaned.includes("dismissed this question")) {
@@ -1644,6 +1651,26 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
)
}
function partError(part: ToolPart, fallback: string) {
if (part.state.status === "error") return part.state.error
if (part.tool !== "execute" || !("metadata" in part.state)) return undefined
const calls = part.state.metadata?.toolCalls
const failed =
part.state.metadata?.error === true ||
(Array.isArray(calls) &&
calls.some(
(call) =>
call !== null &&
typeof call === "object" &&
!Array.isArray(call) &&
"status" in call &&
call.status === "error",
))
if (!failed) return undefined
if ("output" in part.state && typeof part.state.output === "string" && part.state.output) return part.state.output
return fallback
}
export function MessageDivider(props: { label: string }) {
return (
<div data-component="compaction-part">
@@ -2104,6 +2131,84 @@ ToolRegistry.register({
ToolRegistry.register({ name: "subagent", render: ToolRegistry.render("task") })
function ConsoleOutput(props: { copy: string; children: JSX.Element }) {
const i18n = useI18n()
const [copied, setCopied] = createSignal(false)
const copy = async () => {
if (!props.copy) return
if (!(await writeClipboard(props.copy))) return
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<div data-component="bash-output" dir="ltr">
<div data-slot="bash-copy">
<TooltipV2 value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")} placement="top">
<IconButtonV2
icon={<IconV2 name={copied() ? "check" : "outline-copy"} size="small" />}
size="normal"
variant="ghost-muted"
onMouseDown={(event) => event.preventDefault()}
onClick={copy}
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
/>
</TooltipV2>
</div>
<div
data-slot="bash-scroll"
data-scrollable
tabIndex={0}
role="region"
aria-label={i18n.t("ui.scrollView.ariaLabel")}
>
<pre data-slot="bash-pre">
<code>{props.children}</code>
</pre>
</div>
</div>
)
}
ToolRegistry.register({
name: "execute",
render(props) {
const i18n = useI18n()
const pending = () => props.status === "pending" || props.status === "streaming" || props.status === "running"
const code = createMemo(() => (typeof props.input.code === "string" ? props.input.code : ""))
const text = createMemo(() => {
const output = stripAnsi(props.output ?? "").replace(/\r\n?/g, "\n")
return `${code()}${output ? "\n\n" + output : ""}`
})
const sawPending = pending()
return (
<BasicTool
{...props}
icon="console"
allowOpenWhilePending
trigger={(open) => (
<div data-slot="basic-tool-tool-info-structured">
<span data-slot="basic-tool-tool-indicator">
<Icon name="console" size="small" />
</span>
<div data-slot="basic-tool-tool-info-main">
<span data-slot="basic-tool-tool-title">
<TextShimmer text={i18n.t("ui.tool.execute")} active={pending()} />
</span>
<Show when={!open() && code()}>
<ShellSubmessage text={code()} animate={sawPending} />
</Show>
</div>
</div>
)}
>
<ConsoleOutput copy={text()}>{text()}</ConsoleOutput>
</BasicTool>
)
},
})
ToolRegistry.register({
name: "shell",
render(props) {
@@ -2116,17 +2221,6 @@ ToolRegistry.register({
const out = stripAnsi(props.output || props.metadata.output || "").replace(/\r\n?/g, "\n")
return `${command()}${out ? "\n\n" + out : ""}`
})
const [copied, setCopied] = createSignal(false)
const handleCopy = async () => {
const content = command()
if (!content) return
if (await writeClipboard(content)) {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
return (
<BasicTool
{...props}
@@ -2145,36 +2239,12 @@ ToolRegistry.register({
</div>
)}
>
<div data-component="bash-output" dir="ltr">
<div data-slot="bash-copy">
<TooltipV2 value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")} placement="top">
<IconButtonV2
icon={<IconV2 name={copied() ? "check" : "outline-copy"} size="small" />}
size="normal"
variant="ghost-muted"
onMouseDown={(e) => e.preventDefault()}
onClick={handleCopy}
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
/>
</TooltipV2>
</div>
<div
data-slot="bash-scroll"
data-scrollable
tabIndex={0}
role="region"
aria-label={i18n.t("ui.scrollView.ariaLabel")}
>
<pre data-slot="bash-pre">
<code>
<span data-slot="bash-prompt" aria-hidden="true">
{"$ "}
</span>
{text()}
</code>
</pre>
</div>
</div>
<ConsoleOutput copy={command()}>
<span data-slot="bash-prompt" aria-hidden="true">
{"$ "}
</span>
{text()}
</ConsoleOutput>
</BasicTool>
)
},
@@ -71,8 +71,9 @@ describe("partDefaultOpen", () => {
).toBe(true)
})
test("preserves shell defaults", () => {
test("applies shell defaults to console tools", () => {
expect(partDefaultOpen(tool("shell", {}), true, false)).toBe(true)
expect(partDefaultOpen(tool("execute", {}), true, false)).toBe(true)
})
})
@@ -23,7 +23,7 @@ function deletionOnly(part: ToolPart) {
export function partDefaultOpen(part: PartType, shell = false, edit = false): boolean | undefined {
if (part.type !== "tool") return undefined
if (part.tool === "bash" || part.tool === "shell") return shell
if (part.tool === "bash" || part.tool === "shell" || part.tool === "execute") return shell
if (part.tool === "edit" || part.tool === "write" || part.tool === "patch" || part.tool === "apply_patch") {
if (!edit) return false
return !deletionOnly(part)
@@ -54,6 +54,7 @@ export function ToolErrorCard(props: ToolErrorCardProps) {
websearch: "ui.tool.websearch",
bash: "ui.tool.shell",
shell: "ui.tool.shell",
execute: "ui.tool.execute",
patch: "ui.tool.patch",
apply_patch: "ui.tool.patch",
question: "ui.tool.questions",
+1
View File
@@ -158,6 +158,7 @@ const source = {
"ui.tool.websearch": "Web Search",
"ui.tool.websearch.provider": "{{provider}} Web Search",
"ui.tool.shell": "Shell",
"ui.tool.execute": "Execute",
"ui.tool.patch": "Patch",
"ui.tool.questions": "Questions",
"ui.tool.questions.numbered": "Questions {{number}}",