Repository navigation
Description
Description
Plugin tools that call context.metadata() during execution have their custom metadata silently discarded. The completed tool result in the API response (POST /session/:id/message) only contains { truncated, outputPath } regardless of what the tool set.
Cause
fromPlugin in packages/opencode/src/tool/registry.ts (lines 60-82) wraps plugin tool execution. It calls def.execute(), which returns a string, then constructs a hardcoded metadata object:
function fromPlugin(id: string, def: ToolDefinition): Tool.Info {
return {
id,
init: async (initCtx) => ({
parameters: z.object(def.args),
description: def.description,
execute: async (args, ctx) => {
const pluginCtx = {
...ctx,
directory: Instance.directory,
worktree: Instance.worktree,
} as unknown as PluginToolContext;
const result = await def.execute(args as any, pluginCtx);
const out = await Truncate.output(result, {}, initCtx?.agent);
return {
title: "",
output: out.truncated ? out.content : result,
metadata: {
truncated: out.truncated,
outputPath: out.truncated ? out.outputPath : undefined,
},
// ^^^^^^^^ hardcoded — custom metadata from context.metadata() is lost
};
},
}),
};
}Two things go wrong:
-
context.metadata()updates the running-state ToolPart, but when the tool completes,processor.tsreplaces the entirestatewith a newcompletedstate using the metadata from the execute return value — overwriting whatever was set during execution. -
The return metadata is hardcoded to
{ truncated, outputPath }. There's no mechanism to capture whatcontext.metadata()set and merge it into the final result.
Suggested Fix
Intercept context.metadata() calls in the fromPlugin wrapper, accumulate custom metadata, and merge it into the final return value:
execute: async (args, ctx) => {
let customMetadata: Record<string, any> = {};
const pluginCtx = {
...ctx,
directory: Instance.directory,
worktree: Instance.worktree,
metadata(input: { title?: string; metadata?: Record<string, any> }) {
if (input.metadata)
customMetadata = { ...customMetadata, ...input.metadata };
ctx.metadata(input); // still update live TUI state
},
} as unknown as PluginToolContext;
const result = await def.execute(args as any, pluginCtx);
const out = await Truncate.output(result, {}, initCtx?.agent);
return {
title: "",
output: out.truncated ? out.content : result,
metadata: {
...customMetadata,
truncated: out.truncated,
...(out.truncated && { outputPath: out.outputPath }),
},
};
};The original ctx.metadata() is still called so the TUI gets live updates during execution.
Plugins
Custom tools defined in .opencode/tools/ using @opencode-ai/plugin
OpenCode version
1.1.53
Steps to reproduce
- Create a plugin tool in
.opencode/tools/my_tool.ts:
import { tool } from "@opencode-ai/plugin";
export default tool({
description: "A tool that sets custom metadata",
args: {
input: tool.schema.string(),
},
async execute(args, context) {
context.metadata({
metadata: { foo: "bar", custom_field: 123 },
});
return "done";
},
});- Invoke the tool via
POST /session/:id/message - Inspect the response — the tool part's
state.metadatais{ truncated: false }, not{ foo: "bar", custom_field: 123 }
Screenshot and/or share link
N/A
Operating System
Linux (Docker container)
Terminal
N/A (API-only usage via HTTP server mode)
Activity
github-actions commentedon Feb 6, 2026
This issue might be a duplicate of or related to existing issues. Please check:
Feel free to ignore if this doesn't address your specific case.
fix: preserve plugin tool custom metadata from ctx.metadata() (anomal…
gotgenes commentedon Mar 30, 2026
Subscribing — ran into this from the other side. The related issue #18585 covers the TUI rendering gap where
state.titleset bycontext.metadata()is ignored byGenericTool/InlineTool. This issue covers the completion-time metadata being discarded byfromPlugin. Together they mean plugin tool metadata is effectively invisible in the TUI both during execution (title not rendered) and after completion (metadata overwritten).