From e27c7d68f63f77bacd4a1addb5b579aee9fb4346 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20J=C3=A4gle?= Date: Thu, 7 May 2026 18:09:38 +0200 Subject: [PATCH 1/2] fix: restore correct agent context after implicit compaction in proceed_to_phase ## High level changes - `plugin.ts`: Added `lastKnownAgent` variable that caches the last enabled agent name seen in `chat.message` - `plugin.ts`: Post-compaction `promptAsync` now passes `agent: lastKnownAgent` in the request body so the continue message runs under the original workflow agent - `plugin.ts`: Added `postCompactionAutoResume` flag set on `session.compacted` to bypass the agent filter for OpenCode's own auto-continue message - `plugin.ts`: `chat.message` hook checks `bypassAgentFilter` before the `isAgentEnabled` guard so phase instructions are injected instead of a suppression/no-workflow notice ## Motivation After `proceed_to_phase` triggers implicit compaction, OpenCode resumes the session using its default agent (e.g. `build`) rather than the original workflow agent. This caused two problems: 1. OpenCode's built-in auto-continue `chat.message` arrived with agent `build`, hit the `WORKFLOW_AGENTS` filter, and injected "No Active Workflow Detected" instead of phase instructions. 2. The plugin's own follow-up `promptAsync` (sent on `session.idle`) also omitted the agent field, so it too ran under the wrong agent, losing system prompt and tool access. ## Details - `lastKnownAgent` is only updated when `isAgentEnabled` is true, ensuring a subagent name (e.g. `general`, `explore`) never overwrites the cached workflow agent. - `postCompactionAutoResume` is set to `true` on `session.compacted` and consumed (set back to `false`) by the very next `chat.message` invocation. This is a one-shot bypass: it covers exactly OpenCode's implicit auto-continue turn and nothing more. - The `promptAsync` body now spreads `{ agent: lastKnownAgent }` when a cached agent is available. `PromptInput.agent` is an optional string field in the OpenCode API, so this is a safe additive change with no effect when `lastKnownAgent` is `null`. --- packages/opencode-plugin/src/plugin.ts | 43 ++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/packages/opencode-plugin/src/plugin.ts b/packages/opencode-plugin/src/plugin.ts index 3d3bad82..1a1649d1 100644 --- a/packages/opencode-plugin/src/plugin.ts +++ b/packages/opencode-plugin/src/plugin.ts @@ -172,10 +172,20 @@ export const WorkflowsPlugin: Plugin = async ( // duplicate synthetic part for that specific message. let postCompactionMessagePending = false; + // Set to true right after session.compacted fires so that the very next + // chat.message (OpenCode's own auto-continue) bypasses the agent filter + // and injects proper phase instructions instead of a suppression notice. + let postCompactionAutoResume = false; + // Last-known model from chat.message hook. Cached so proceed_to_phase can // pass providerID + modelID to the summarize API (which requires them). let lastKnownModel: { providerID: string; modelID: string } | null = null; + // Last-known agent from chat.message hook. Used when sending the + // post-compaction phase-aware continue message so it runs under the + // correct agent (e.g. 'workflow') rather than OpenCode's default. + let lastKnownAgent: string | null = null; + /** * Set buffered instructions from a tool result. * The next chat.message hook will use these instead of calling WhatsNextHandler. @@ -305,6 +315,13 @@ export const WorkflowsPlugin: Plugin = async ( lastKnownModel = hookInput.model; } + // Cache the agent for use by the post-compaction continue message. + // Only cache when the agent is enabled (i.e. a primary workflow agent), + // so we don't accidentally cache a subagent name. + if (hookInput.agent && isAgentEnabled(hookInput.agent)) { + lastKnownAgent = hookInput.agent; + } + // If this message was the post-compaction instructions prompt sent by Hook 4, // skip synthetic part injection — the message body IS the instructions. if (postCompactionMessagePending) { @@ -315,6 +332,19 @@ export const WorkflowsPlugin: Plugin = async ( return; } + // After compaction, OpenCode sends an auto-continue message which may arrive + // as a non-workflow agent (e.g. 'build'). In that case we still want to inject + // the phase instructions rather than the suppression/no-workflow notice, so we + // consume the flag and fall through to normal phase-instruction injection below. + const bypassAgentFilter = postCompactionAutoResume; + if (bypassAgentFilter) { + postCompactionAutoResume = false; + logger.debug( + 'chat.message: bypassing agent filter for post-compaction auto-resume', + { agent: hookInput.agent } + ); + } + // If WORKFLOW_AGENTS is set and this agent is not in the allowlist, inject a // suppression instruction as a synthetic part so the LLM knows not to call the // workflow tools (which would only throw errors for non-enabled agents). @@ -322,7 +352,7 @@ export const WorkflowsPlugin: Plugin = async ( // 1. chat.message already has hookInput.agent directly — no stale-state risk. // 2. chat.message fires reliably for every user turn; transform may fire for // intermediate tool-loop LLM calls without a preceding chat.message. - if (!isAgentEnabled(hookInput.agent)) { + if (!bypassAgentFilter && !isAgentEnabled(hookInput.agent)) { logger.debug( 'chat.message: Agent not enabled — injecting tool suppression', { @@ -560,6 +590,11 @@ ACTION REQUIRED: Use proceed_to_phase tool to move to a phase that allows editin if (event.type === 'session.compacted') { postCompactionSession = event.properties.sessionID as string; + // Set flag so the next chat.message (OpenCode's own auto-continue, + // which may fire as a non-workflow agent like 'build') bypasses the + // agent filter and injects proper phase instructions instead of a + // suppression/no-workflow notice. + postCompactionAutoResume = true; logger.info('session.compacted: pending phase-aware continue', { sessionID: postCompactionSession, }); @@ -614,7 +649,10 @@ ACTION REQUIRED: Use proceed_to_phase tool to move to a phase that allows editin session: { promptAsync(params: { path: { id: string }; - body: { parts: Array<{ type: string; text: string }> }; + body: { + parts: Array<{ type: string; text: string }>; + agent?: string; + }; }): Promise; }; }; @@ -622,6 +660,7 @@ ACTION REQUIRED: Use proceed_to_phase tool to move to a phase that allows editin path: { id: sessionID }, body: { parts: [{ type: 'text', text: promptText }], + ...(lastKnownAgent ? { agent: lastKnownAgent } : {}), }, }); logger.info('session.idle: phase-aware continue sent (async)', { From ccbdae1ea16aeb909c84673017f46d5414445b99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20J=C3=A4gle?= Date: Thu, 7 May 2026 18:39:21 +0200 Subject: [PATCH 2/2] fix: add tool.definition hook to bridge zod registry descriptions to host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## High level changes - `packages/opencode-plugin/src/plugin.ts`: Added `'tool.definition'` hook that dynamically imports `'zod'` at hook-call time and registers each tool parameter description into the host's (OpenCode's) `globalRegistry`, making descriptions visible to `z.toJSONSchema()` ## Motivation The previous peerDependency fix (moving `zod` from `dependencies` to `peerDependencies`) was insufficient: OpenCode is distributed as a compiled Bun binary with zod bundled internally, so module hoisting / peerDep resolution does not apply. As a result, the plugin and OpenCode continued to use separate zod instances with separate `globalRegistry` singletons, meaning `.describe()` annotations on tool parameters never reached the LLM's JSON Schema. ## Details - The `tool.definition` hook fires after OpenCode wraps plugin args with `z.object(def.args)` (its own zod) but before `z.toJSONSchema(item.parameters)` is called in `prompt.ts` - `output.parameters._zod.def.shape` contains the original plugin field schemas; their `.description` getter reads from the plugin's `globalRegistry` and works cross-instance - Dynamic `import('zod')` at runtime: when the plugin has no local `node_modules/zod` (peerDependency, correctly installed), ESM resolution walks up to the host's (OpenCode's) zod module, which is already in the module cache — returning the same `globalRegistry` singleton that `z.toJSONSchema()` will read from - Errors are silently swallowed — descriptions are a nice-to-have for LLM guidance, not critical for tool execution - All 64 existing tests pass --- ...-opencode-plugin-parameter-descriptions.md | 28 ++++++++++++ packages/opencode-plugin/src/plugin.ts | 45 +++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/.vibe/development-plan-fix-opencode-plugin-parameter-descriptions.md b/.vibe/development-plan-fix-opencode-plugin-parameter-descriptions.md index 74eb86de..4a37cc9f 100644 --- a/.vibe/development-plan-fix-opencode-plugin-parameter-descriptions.md +++ b/.vibe/development-plan-fix-opencode-plugin-parameter-descriptions.md @@ -55,6 +55,34 @@ Rationale: - No code changes needed in tool handlers — `.describe()` calls stay identical - Verified: Option A produces correct JSON Schema with all descriptions +**Why Option A alone is insufficient (post-implementation learning):** +- OpenCode is distributed as a **compiled Bun binary** — zod is bundled inside the binary +- `peerDependencies` hoisting is irrelevant when host's zod is in a compiled bundle +- Even with peerDeps, in monorepo dev setup another package pulls zod 4.3.6 into plugin/node_modules +- Confirmed: `import('zod')` from plugin context resolves to plugin's 4.3.6, not OC's 4.1.8 + +**Final implementation: Option A + Option C (tool.definition hook with dynamic import)** + +The `tool.definition` hook bridges registries: +1. Receives `output.parameters` (ZodObject created by host's zod.object(def.args)) +2. Reads field descriptions via `.description` getter (works cross-instance from plugin's registry) +3. Dynamically imports `'zod'` — in production (no local node_modules/zod), this resolves to host's zod +4. Registers each field schema's description into host's `globalRegistry` +5. When host calls `z.toJSONSchema(output.parameters)`, it now finds all descriptions + +**Why dynamic import works in production:** +- When installed as a package (peerDep), plugin has no local `node_modules/zod` +- ESM dynamic `import('zod')` resolves up the file tree to host's (OpenCode's) zod +- Module cache returns the same singleton — same `globalRegistry` — descriptions found ✅ + +**Registry bridge research (exhaustive):** +- `bag` property on schema: NOT where descriptions are stored +- `_zod.parent` trick: loses type info (toJSONSchema treats schema as ref to parent type) +- `ZodRegistry.prototype.add` patch: works but requires having a ZodRegistry instance +- Cross-instance `$ZodRegistry.prototype`: different class instances per module, patching one doesn't affect the other +- `toJSONSchema({ metadata: pluginRegistry })`: works but can't change OC's hardcoded call +- Dynamic import approach: cleanest workable solution for production + ## Implementation Plan (Code Phase Tasks) 1. **`opencode-plugin/package.json`**: Move `zod` from `dependencies` to `peerDependencies` with version `">=4.1.8"` diff --git a/packages/opencode-plugin/src/plugin.ts b/packages/opencode-plugin/src/plugin.ts index 1a1649d1..456087a4 100644 --- a/packages/opencode-plugin/src/plugin.ts +++ b/packages/opencode-plugin/src/plugin.ts @@ -797,6 +797,51 @@ ACTION REQUIRED: Use proceed_to_phase tool to move to a phase that allows editin ), }; })(), + + /** + * Bridge Zod .describe() descriptions from plugin's registry into host's registry. + * + * Problem: this plugin uses a different zod instance than OpenCode (host). In Zod v4, + * .describe() stores descriptions in a module-level globalRegistry singleton. When + * OpenCode calls z.toJSONSchema(parameters), it reads from its own registry which has + * no entries for plugin schemas — so all parameter descriptions are missing from the + * JSON Schema sent to the LLM. + * + * Solution: dynamically import 'zod' at hook call time. When the plugin is installed + * without its own node_modules/zod (zod is a peerDependency), this import resolves to + * the host's (OpenCode's) zod module — the same instance used when creating + * output.parameters. We then register each field schema's description into the host's + * globalRegistry, making them visible to the subsequent z.toJSONSchema() call. + */ + 'tool.definition': async ( + _input: { toolID: string }, + output: { description: string; parameters: unknown } + ): Promise => { + try { + const parameters = output.parameters as { + _zod?: { def?: { shape?: Record } }; + }; + const shape = parameters?._zod?.def?.shape; + if (!shape) return; + + // Dynamically import zod — in production (installed without local node_modules/zod), + // this resolves to the host's zod instance, sharing the same globalRegistry. + const { globalRegistry: hostRegistry } = await import('zod'); + + for (const [_key, fieldSchema] of Object.entries(shape)) { + const desc = fieldSchema?.description; + if (desc && typeof desc === 'string') { + ( + hostRegistry as { + add(schema: unknown, meta: { description: string }): void; + } + ).add(fieldSchema, { description: desc }); + } + } + } catch { + // Silently ignore — descriptions are a nice-to-have, not critical + } + }, }; };