fix(core): shape compaction and generate requests with built-in context hooks (#48749)

This commit is contained in:
Aiden Cline 2026-09-12 23:10:09 -05:00 • committed by GitHub
parent c5aa7d7e34
commit c43cfccc4e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 75 additions and 17 deletions

View file

@ -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." })

View file

@ -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)
}),
}

View file

@ -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)
}),
}

View file

@ -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)
}),
}

View file

@ -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)
}),
}

View file

@ -66,7 +66,8 @@ export const registerToolPlugin = <R>(
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,

View file

@ -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<string, (event: SessionHooks["context"]) => Effect.Effect<void>>()
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<void>)
return { dispose: Effect.void }
}),
},
}),
),
deps: [
Tool.node,
FileAccess.node,
@ -153,6 +167,34 @@ const withTempTool = <A, E, R>(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()),