Skip to content

Plugin hook \ ool.execute.after\ is declared but never triggered #25918

Closed as not planned

Description

@luismichio

Summary

The tool.execute.after hook is declared in the Hooks interface in @opencode-ai/plugin but is never invoked anywhere in the OpenCode runtime. Any plugin implementing this hook will have its handler registered but silently ignored — the handler is never called.

Details

Hook declaration

The hook is correctly typed in packages/plugin/src/index.ts:

"tool.execute.after"?: (
  input: { tool: string; sessionID: string; callID: string; args: any },
  output: {
    title: string
    output: string
    metadata: any
  },
) => Promise<void>

The plugin.trigger mechanism in packages/opencode/src/plugin/index.ts is also correctly implemented — it passes output by reference, calls all registered handlers, and returns the (possibly mutated) output object:

const trigger = Effect.fn("Plugin.trigger")(function* (name, input, output) {
  if (!name) return output
  const s = yield* InstanceState.get(state)
  for (const hook of s.hooks) {
    const fn = hook[name] as any
    if (!fn) continue
    yield* Effect.promise(async () => fn(input, output))
  }
  return output
})

Missing call site

A search through the entire OpenCode server codebase found zero call sites for plugin.trigger("tool.execute.after", ...):

  • packages/opencode/src/session/processor.ts — handles tool-result stream events and calls completeToolCall(), but no hook trigger
  • packages/opencode/src/session/llm.ts — assembles AI SDK tools and handles streaming, no hook trigger
  • packages/opencode/src/tool/registry.ts — only triggers tool.definition
  • packages/opencode/src/session/session.ts — no plugin references at all
  • packages/opencode/src/agent/agent.ts — only triggers experimental.chat.system.transform
  • All v2 session files — no hook trigger

Where the trigger should logically live

In packages/opencode/src/session/processor.ts, the tool-result event handler at around line 380 receives the completed tool output and calls completeToolCall(). This is the natural location to trigger tool.execute.after:

case "tool-result": {
  // ... EventV2 dual-write ...

  // MISSING: yield* plugin.trigger("tool.execute.after", { ... }, value.output)

  yield* completeToolCall(value.toolCallId, value.output)
  return
}

The value.output object has the shape { title, output, metadata } which matches the hook's declared output parameter exactly.

Impact

Plugins that implement tool.execute.after to intercept or modify tool output (e.g., to inject context, compress output, or add telemetry) have no effect. The hook infrastructure is in place but the wire-up is missing.

Proposed Fix

In packages/opencode/src/session/processor.ts, add the plugin trigger before completeToolCall:

case "tool-result": {
  const toolCall = yield* readToolCall(value.toolCallId)
  // ... existing EventV2 dual-write ...

  // Trigger the after hook so plugins can observe/modify tool output
  const hookInput = {
    tool: value.toolName,
    sessionID: ctx.sessionID,
    callID: value.toolCallId,
    args: toolCall?.part.state.status === "running" ? toolCall.part.state.input : {},
  }
  const hookOutput = yield* plugin.trigger("tool.execute.after", hookInput, {
    title: value.output.title,
    output: value.output.output,
    metadata: value.output.metadata,
  })

  yield* completeToolCall(value.toolCallId, { ...value.output, ...hookOutput })
  return
}

Similarly, tool.execute.before (also declared but never triggered) should be wired up at the tool-call event site.

Also affected

  • tool.execute.before — declared in Hooks but also never triggered in the runtime.

Environment

  • OpenCode version: 1.14.39
  • Found via: source audit of packages/opencode/src/ on commit history at time of investigation

Activity

  1. github-actions commented on May 5, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of (or conflict with) an existing issue. Please check:

    If you believe this is a separate issue, it would help to note which commit or version you tested, and whether the behavior differs for native vs. MCP tools.

  2. luismichio commented on May 5, 2026

    @luismichio
    Author

    Correction after reviewing #21149 and re-auditing prompt.ts

    My initial analysis was wrong. After reviewing the bot's reference to #21149 and doing a targeted search of packages/opencode/src/session/prompt.ts, I can confirm:

    tool.execute.after IS triggered — in prompt.ts, not processor.ts.

    I missed this file in my original audit. Apologies for the incorrect report.

    What actually happens

    There are two distinct code paths in prompt.ts, each with different behaviour:

    Path 1 — Native tools (bash, read, write, etc.) — L421–L455

    const result = yield* item.execute(args, ctx)
    const output = { ...result, attachments: ... }  // assembled {title, output, metadata} shape
    
    yield* plugin.trigger("tool.execute.after", { ... }, output)  // ✅ correct shape
    return output

    The hook receives the correct {title, output, metadata} shape. Mutations to output.output should be reflected in the returned value. However — crucially — the return output at L451 returns the original output object, not the result of plugin.trigger. Since trigger() mutates output in-place and returns the same object reference, this does work correctly for native tools.

    Path 2 — MCP tools — L465–L491

    const result = yield* Effect.promise(() => execute(args, opts))  // raw CallToolResult
    
    yield* plugin.trigger("tool.execute.after", { ... }, result)  // ❌ wrong shape — result.content[] not result.output
    
    const textParts: string[] = []
    for (const contentItem of result.content) {  // assembled AFTER the hook fires
      if (contentItem.type === "text") textParts.push(contentItem.text)
      ...
    }

    This is exactly what #21149 describes: the hook fires with the raw MCP CallToolResult (shape: {content: [{type, text}]}) before text assembly happens. Any mutation to output.output by a plugin is ignored because the text is reconstructed from result.content afterward.

    Conclusion

    • This issue is NOT a duplicate of tool.execute.after hook receives raw MCP result instead of assembled output #21149 — it documents the same underlying split, but from a different angle (output mutation being silently ignored vs. wrong shape).
    • Native tools: hook fires with correct shape, mutations work in-place ✅
    • MCP tools: hook fires with wrong shape (CallToolResult), assembled output ignores any mutations ❌

    The two issues together describe the same root cause: MCP tool output is not assembled before the hook fires. The fix for #21149 (assemble text before triggering the hook for MCP tools) would also resolve the mutation-being-ignored scenario.

    Impact on context-pipe

    Our plugin was attempting to intercept MCP tool results (e.g., pipe_read_file). Since pipe_read_file is an MCP tool, the hook would have received result.content[0].text — not output.output — making the interception incorrect regardless. The plugin has since been updated to a documented placeholder pending the upstream fix.

  3. github-actions commented on Jul 5, 2026

    @github-actions
    Contributor

    To stay organized issues are automatically closed after 60 days of no activity. If the issue is still relevant please open a new one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions