feat(opencode): cut plugin system and remote behavior feeds; add SessionContext assembly and Runner
- delete plugin machinery (loader, install, meta, hooks) and all trigger sites - fold first-party provider auth into static registry (provider/hooks.ts) - remove remote instruction URL fetch, skills remote puller, TUI plugin host - add session/context.ts: single provenance-tagged context assembly point - add session/runner.ts: admit/context/stream/tools loop with compaction policy - relocate audit artifacts to audit/ (FORK-AUDIT, instruction docs as evidence)
This commit is contained in:
@@ -3,7 +3,6 @@ import { CodeModeTool, describeCatalog } from "@/tool/code-mode"
|
||||
import { McpCatalog } from "@/mcp/catalog"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { MCP } from "@/mcp"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { Session } from "@/session/session"
|
||||
import { Tool } from "@/tool/tool"
|
||||
import * as Truncate from "@/tool/truncate"
|
||||
@@ -139,10 +138,6 @@ async function buildTool() {
|
||||
}
|
||||
|
||||
const layer = Layer.mergeAll(
|
||||
Layer.mock(Plugin.Service, {
|
||||
trigger: ((_name: unknown, _input: unknown, output: unknown) =>
|
||||
Effect.succeed(output)) as Plugin.Interface["trigger"],
|
||||
}),
|
||||
Layer.mock(Truncate.Service, {
|
||||
output: (text: string) => Effect.succeed({ content: text, truncated: false as const }),
|
||||
}),
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { PermissionV1 } from "@opencode-ai/core/v1/permission"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { MCP } from "@/mcp"
|
||||
import { Permission } from "@/permission"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { Session } from "@/session/session"
|
||||
import { Tool } from "@/tool/tool"
|
||||
import * as Truncate from "@/tool/truncate"
|
||||
@@ -41,12 +40,8 @@ function harness(input: {
|
||||
mcpTools: Record<string, MCP.McpTool>
|
||||
servers: string[]
|
||||
permission?: PermissionV1.Rule[]
|
||||
trigger?: Plugin.Interface["trigger"]
|
||||
}) {
|
||||
return Layer.mergeAll(
|
||||
Layer.mock(Plugin.Service, {
|
||||
trigger: input.trigger ?? (((_name, _input, output) => Effect.succeed(output)) as Plugin.Interface["trigger"]),
|
||||
}),
|
||||
Layer.mock(Truncate.Service, {
|
||||
output: (text: string) => Effect.succeed({ content: text, truncated: false as const }),
|
||||
}),
|
||||
@@ -71,13 +66,12 @@ function build(
|
||||
mcpTools: Record<string, MCP.McpTool>,
|
||||
servers?: string[],
|
||||
permission?: PermissionV1.Rule[],
|
||||
trigger?: Plugin.Interface["trigger"],
|
||||
) {
|
||||
const names = serverNames(mcpTools, servers)
|
||||
return Effect.runPromise(
|
||||
CodeModeTool.pipe(
|
||||
Effect.flatMap(Tool.init),
|
||||
Effect.provide(harness({ mcpTools, servers: names, permission, trigger })),
|
||||
Effect.provide(harness({ mcpTools, servers: names, permission })),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -391,77 +385,6 @@ describe("code mode execute", () => {
|
||||
expect(output.metadata.toolCalls).toEqual([{ tool: "a.tool", status: "error" }])
|
||||
})
|
||||
|
||||
test("child calls fire plugin tool.execute hooks with the MCP key and synthetic parent/N call ids", async () => {
|
||||
const events: { name: string; input: any; output: any }[] = []
|
||||
const trigger = ((name: unknown, input: unknown, output: unknown) =>
|
||||
Effect.sync(() => {
|
||||
events.push({ name: name as string, input, output })
|
||||
return output
|
||||
})) as Plugin.Interface["trigger"]
|
||||
const tool = await build(
|
||||
{
|
||||
a_tool: mcpTool("a", () => ({ content: [{ type: "text", text: "one" }] })),
|
||||
b_tool: mcpTool("b", () => ({ content: [{ type: "text", text: "two" }] })),
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
trigger,
|
||||
)
|
||||
|
||||
const out = await Effect.runPromise(
|
||||
tool.execute({ code: "await tools.a.tool({ x: 1 }); await tools.b.tool({}); return 'done'" }, ctx),
|
||||
)
|
||||
|
||||
expect(out.output).toBe("done")
|
||||
expect(events.map((e) => [e.name, e.input.tool, e.input.callID])).toEqual([
|
||||
["tool.execute.before", "a_tool", "call_code_mode/1"],
|
||||
["tool.execute.after", "a_tool", "call_code_mode/1"],
|
||||
["tool.execute.before", "b_tool", "call_code_mode/2"],
|
||||
["tool.execute.after", "b_tool", "call_code_mode/2"],
|
||||
])
|
||||
const [before, after] = events
|
||||
expect(before!.input.sessionID).toBe(ctx.sessionID)
|
||||
expect(before!.output).toEqual({ args: { x: 1 } })
|
||||
expect(after!.input.args).toEqual({ x: 1 })
|
||||
expect(after!.output).toEqual({ content: [{ type: "text", text: "one" }] })
|
||||
})
|
||||
|
||||
test("a failing before hook fails only that child call as a catchable in-program error", async () => {
|
||||
const trigger = ((name: unknown, input: any, output: unknown) => {
|
||||
if (name === "tool.execute.before" && input.tool === "a_tool") return Effect.die(new Error("hook exploded"))
|
||||
return Effect.succeed(output)
|
||||
}) as Plugin.Interface["trigger"]
|
||||
const called: string[] = []
|
||||
const record = (name: string) => () => {
|
||||
called.push(name)
|
||||
return { content: [{ type: "text", text: "ok" }] }
|
||||
}
|
||||
const tool = await build(
|
||||
{ a_tool: mcpTool("a", record("a")), b_tool: mcpTool("b", record("b")) },
|
||||
undefined,
|
||||
undefined,
|
||||
trigger,
|
||||
)
|
||||
|
||||
const out = await Effect.runPromise(
|
||||
tool.execute(
|
||||
{
|
||||
code: `
|
||||
let caught
|
||||
try { await tools.a.tool({}) } catch (e) { caught = e.message }
|
||||
const r = await tools.b.tool({})
|
||||
return caught + " / " + r
|
||||
`,
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
)
|
||||
|
||||
expect(out.metadata.error).toBeUndefined()
|
||||
expect(out.output).toBe("hook exploded / ok")
|
||||
expect(called).toEqual(["b"])
|
||||
})
|
||||
|
||||
test("streams live per-call metadata as a call starts and finishes", async () => {
|
||||
const snapshots: Array<{ toolCalls: { tool: string; status: string; input?: Record<string, unknown> }[] }> = []
|
||||
const recordingCtx: Tool.Context = {
|
||||
|
||||
@@ -10,7 +10,6 @@ import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { TestConfig } from "../fixture/config"
|
||||
import { Config } from "@/config/config"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
|
||||
@@ -26,30 +25,6 @@ const configLayer = TestConfig.layer({
|
||||
directories: () => InstanceState.directory.pipe(Effect.map((dir) => [path.join(dir, ".opencode")])),
|
||||
})
|
||||
|
||||
// Fake Plugin.Service that returns a single plugin whose `tool` map contains
|
||||
// one definition with `args: undefined`. Used to exercise the plugin entry
|
||||
// point of `fromPlugin` for the #27451 / #27630 regression.
|
||||
const brokenPluginLayer = Layer.succeed(
|
||||
Plugin.Service,
|
||||
Plugin.Service.of({
|
||||
init: () => Effect.void,
|
||||
trigger: ((_name: unknown, _input: unknown, output: unknown) =>
|
||||
Effect.succeed(output)) as Plugin.Interface["trigger"],
|
||||
list: () =>
|
||||
Effect.succeed([
|
||||
{
|
||||
tool: {
|
||||
broken_plugin_tool: {
|
||||
description: "plugin tool with missing args",
|
||||
args: undefined as unknown as Record<string, never>,
|
||||
execute: async () => "ok",
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
}),
|
||||
)
|
||||
|
||||
const root = LayerNode.group([ToolRegistry.node, Agent.node])
|
||||
const replacements = [
|
||||
[Config.node, configLayer],
|
||||
@@ -93,8 +68,6 @@ const withEmptyCodeMode = testEffect(
|
||||
],
|
||||
]),
|
||||
)
|
||||
const withBrokenPlugin = testEffect(LayerNode.compile(root, [...replacements, [Plugin.node, brokenPluginLayer]]))
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
})
|
||||
@@ -257,21 +230,6 @@ describe("tool.registry", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
// Same regression, plugin entry point. The original reports (#27451, #27630)
|
||||
// came in through `plugin.list()` — `oh-my-opencode` was registering a tool
|
||||
// with `args: undefined` and crashing every message submit. The file-scan
|
||||
// and plugin-list loops both funnel through `fromPlugin`, but covering both
|
||||
// entry points means a future refactor that splits them won't silently lose
|
||||
// protection.
|
||||
withBrokenPlugin.instance("tolerates a plugin tool registered with null/undefined args", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const ids = yield* registry.ids()
|
||||
expect(ids).toContain("read")
|
||||
expect(ids).toContain("broken_plugin_tool")
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("loads tools from .opencode/tools (plural)", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
|
||||
@@ -16,7 +16,6 @@ import { Truncate } from "@/tool/truncate"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Plugin } from "../../src/plugin"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { Tool } from "@/tool/tool"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
@@ -27,7 +26,6 @@ const shellLayer = Layer.mergeAll(
|
||||
LayerNode.group([
|
||||
CrossSpawnSpawner.node,
|
||||
FSUtil.node,
|
||||
Plugin.node,
|
||||
Truncate.node,
|
||||
Config.node,
|
||||
Agent.node,
|
||||
|
||||
Reference in New Issue
Block a user