diff --git a/packages/core/src/tool/plugin/opencode.ts b/packages/core/src/tool/plugin/opencode.ts index 352a54e3a55d..729bf173624e 100644 --- a/packages/core/src/tool/plugin/opencode.ts +++ b/packages/core/src/tool/plugin/opencode.ts @@ -2,6 +2,7 @@ export * as OpenCodeTools from "./opencode.js" import { SystemPart, ToolFailure } from "@opencode/ai" import type { Context } from "@opencode/plugin/effect/plugin" +import type { SessionHooks } from "@opencode/plugin/effect/session" import { AbsolutePath } from "@opencode/schema/schema" import { Session } from "@opencode/schema/session" import { Effect, Schema } from "effect" @@ -25,15 +26,17 @@ const MoveOutput = Schema.Struct({ sessionID: Session.ID, directory: AbsolutePat export const Plugin = { id: "opencode.tools", effect: Effect.fn("OpenCodeTools.Plugin")(function* (ctx: Context) { - yield* ctx.session.hook("context", (event) => + const hook = (event: SessionHooks["context"]) => Effect.sync(() => { event.system.push( SystemPart.make( "When you create a worktree outside the current working directory and intend to use it as your primary working directory, consider using `execute` to call `tools.opencode.session_move` and make the worktree the session's working directory.", ), ) - }), - ) + }) + yield* ctx.session.hook("context", hook) + yield* ctx.session.hook("compaction", hook) + yield* ctx.session.hook("generate", hook) yield* ctx.tool .transform((draft) => { draft.namespace({ name: "opencode", description: "OpenCode session and runtime tools." }) diff --git a/packages/core/src/tool/plugin/patch.ts b/packages/core/src/tool/plugin/patch.ts index 98fca2f7f419..30b3ea7d9c72 100644 --- a/packages/core/src/tool/plugin/patch.ts +++ b/packages/core/src/tool/plugin/patch.ts @@ -1,6 +1,7 @@ export * as PatchTool from "./patch.js" import type { Context } from "@opencode/plugin/effect/plugin" +import type { SessionHooks } from "@opencode/plugin/effect/session" import { ToolFailure } from "@opencode/ai" import { FileDiff } from "@opencode/schema/file-diff" import { Effect, Result, Schema } from "effect" @@ -292,7 +293,7 @@ export const Plugin = { ) .pipe(Effect.orDie) - yield* ctx.session.hook("context", (event) => + const hook = (event: SessionHooks["context"]) => Effect.sync(() => { const usePatch = event.model.id.includes("gpt-") && !event.model.id.includes("oss") && !event.model.id.includes("gpt-4") @@ -302,8 +303,10 @@ export const Plugin = { return } delete event.tools.patch - }), - ) + }) + yield* ctx.session.hook("context", hook) + yield* ctx.session.hook("compaction", hook) + yield* ctx.session.hook("generate", hook) }), } diff --git a/packages/core/src/tool/plugin/shell.ts b/packages/core/src/tool/plugin/shell.ts index 948ffb13d8b2..a5abb1d7cd4b 100644 --- a/packages/core/src/tool/plugin/shell.ts +++ b/packages/core/src/tool/plugin/shell.ts @@ -2,6 +2,7 @@ export * as ShellTool from "./shell.js" import { ToolFailure } from "@opencode/ai" import type { Context } from "@opencode/plugin/effect/plugin" +import type { SessionHooks } from "@opencode/plugin/effect/session" import type { ShellCreateBefore } from "@opencode/plugin/effect/shell" import type { Tool } from "@opencode/schema/tool" import { Deferred, Effect, Schema, Scope } from "effect" @@ -271,12 +272,14 @@ export const Plugin = { ) .pipe(Effect.orDie) - yield* ctx.session.hook("context", (event) => + const hook = (event: SessionHooks["context"]) => Effect.gen(function* () { const tool = event.tools[name] if (!tool) return tool.description = description(ShellSelect.name(yield* compatibleShell)) - }), - ) + }) + yield* ctx.session.hook("context", hook) + yield* ctx.session.hook("compaction", hook) + yield* ctx.session.hook("generate", hook) }), } diff --git a/packages/core/src/tool/plugin/subagent.ts b/packages/core/src/tool/plugin/subagent.ts index 2ccb2827c56c..7885e3b4e422 100644 --- a/packages/core/src/tool/plugin/subagent.ts +++ b/packages/core/src/tool/plugin/subagent.ts @@ -2,6 +2,7 @@ export * as SubagentTool from "./subagent.js" import { ToolFailure } from "@opencode/ai" import type { Context } from "@opencode/plugin/effect/plugin" +import type { SessionHooks } from "@opencode/plugin/effect/session" import { Effect, Schema } from "effect" import { Agent } from "../../agent.js" import { Config } from "../../config.js" @@ -234,7 +235,7 @@ export const Plugin = { ) .pipe(Effect.orDie) - yield* ctx.session.hook("context", (event) => + const hook = (event: SessionHooks["context"]) => Effect.gen(function* () { const tool = event.tools[name] if (!tool) return @@ -258,7 +259,9 @@ export const Plugin = { `- ${agent.id}: ${agent.description ?? "This subagent should only be called when explicitly requested."}`, ), ].join("\n") - }), - ) + }) + yield* ctx.session.hook("context", hook) + yield* ctx.session.hook("compaction", hook) + yield* ctx.session.hook("generate", hook) }), } diff --git a/packages/core/src/tool/plugin/websearch.ts b/packages/core/src/tool/plugin/websearch.ts index a8cb48455a65..0338dfdb601e 100644 --- a/packages/core/src/tool/plugin/websearch.ts +++ b/packages/core/src/tool/plugin/websearch.ts @@ -1,6 +1,7 @@ export * as WebSearchTool from "./websearch.js" import type { Context } from "@opencode/plugin/effect/plugin" +import type { SessionHooks } from "@opencode/plugin/effect/session" import { ToolFailure } from "@opencode/ai" import { Effect, Schema, Semaphore } from "effect" import { HttpClientError } from "effect/unstable/http" @@ -180,14 +181,16 @@ export const Plugin = { ) .pipe(Effect.orDie) - yield* ctx.session.hook("context", (event) => + const hook = (event: SessionHooks["context"]) => Effect.gen(function* () { const disabled = yield* websearch.default().pipe( Effect.as(false), Effect.catchTag("WebSearch.Disabled", () => Effect.succeed(true)), ) if (disabled) delete event.tools[name] - }), - ) + }) + yield* ctx.session.hook("context", hook) + yield* ctx.session.hook("compaction", hook) + yield* ctx.session.hook("generate", hook) }), } diff --git a/packages/core/test/lib/tool.ts b/packages/core/test/lib/tool.ts index 6275ffbfcea8..ae3fdaeeea38 100644 --- a/packages/core/test/lib/tool.ts +++ b/packages/core/test/lib/tool.ts @@ -66,7 +66,8 @@ export const registerToolPlugin = ( const context = host({ ...overrides, session: { - hook: () => Effect.succeed({ dispose: Effect.void }), + ...overrides.session, + hook: overrides.session?.hook ?? (() => Effect.succeed({ dispose: Effect.void })), }, tool: { transform: tools.transform, diff --git a/packages/core/test/tool-patch.test.ts b/packages/core/test/tool-patch.test.ts index 4fdfb487f58f..8a392721ef70 100644 --- a/packages/core/test/tool-patch.test.ts +++ b/packages/core/test/tool-patch.test.ts @@ -9,11 +9,14 @@ import { Formatter } from "@opencode/core/formatter" import { FileMutation } from "@opencode/core/file-mutation" import { Location } from "@opencode/core/location" import { FileAccess } from "@opencode/core/file-access" +import { Model } from "@opencode/core/model" import { Permission } from "@opencode/core/permission" +import { Provider } from "@opencode/core/provider" import { AbsolutePath } from "@opencode/core/schema" import { Session } from "@opencode/core/session" import { Tool } from "@opencode/core/tool" import { PatchTool } from "@opencode/core/tool/plugin/patch" +import type { SessionHooks } from "@opencode/plugin/effect/session" import { transformEnvironmentFiles } from "./fixture/environment" import { location } from "./fixture/location" import { tmpdir } from "./fixture/tmpdir" @@ -22,9 +25,20 @@ import { testEffect } from "./lib/effect" import { permissionLayer } from "./lib/permission" import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool" +const sessionHooks = new Map Effect.Effect>() const patchToolNode = makeLocationNode({ name: "test/patch-tool-plugin", - layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)), + layer: Layer.effectDiscard( + registerToolPlugin(PatchTool.Plugin, { + session: { + hook: (name, callback) => + Effect.sync(() => { + sessionHooks.set(name, callback as (event: SessionHooks["context"]) => Effect.Effect) + return { dispose: Effect.void } + }), + }, + }), + ), deps: [ Tool.node, FileAccess.node, @@ -153,6 +167,34 @@ const withTempTool = (body: (directory: string, registry: Tool.Interfac ) describe("PatchTool", () => { + it.live("selects the same edit tools for compaction and generate requests as the agent loop", () => + withTempTool(() => + Effect.gen(function* () { + const event = (id: string): SessionHooks["context"] => ({ + sessionID, + agent: toolIdentity.agent, + model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make(id) }), + system: [], + messages: [], + tools: Object.fromEntries( + ["patch", "edit", "write", "read"].map((name) => [name, { description: name, input: { type: "object" } }]), + ), + options: {}, + }) + for (const name of ["context", "compaction", "generate"]) { + const hook = sessionHooks.get(name) + expect(hook).toBeDefined() + const claude = event("claude-sonnet-4") + yield* hook!(claude) + expect(Object.keys(claude.tools)).toEqual(["edit", "write", "read"]) + const gpt = event("gpt-5") + yield* hook!(gpt) + expect(Object.keys(gpt.tools)).toEqual(["patch", "read"]) + } + }), + ), + ) + it.live("registers and sequentially applies add, update, and delete hunks", () => Effect.acquireUseRelease( Effect.promise(() => tmpdir()),