From 8643d53b1f8449485612ce271cf8161850291152 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20J=C3=A4gle?= Date: Wed, 3 Jun 2026 08:52:51 +0200 Subject: [PATCH] fix(opencode-plugin): use SDK types directly and harden ask() against return-type changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin manually duplicated all types from @opencode-ai/plugin instead of importing them, causing types.ts to silently go stale whenever the SDK changed. This caused the Fiber.runLoop crash when SDK 1.15.x reverted ToolContext.ask from Effect.Effect back to Promise. Changes: - Add @opencode-ai/plugin as peerDependency (dev + peer) so SDK types are available at compile time and mismatches are caught by the type checker - Replace types.ts manual copies with re-exports from @opencode-ai/plugin; only BusEvent subtypes (session.compacted / session.idle) remain local because the SDK does not export them individually yet - Add runAsk() helper in plugin.ts that detects at runtime whether ctx.ask() returned an Effect (identified by the stable '~effect/Effect' TypeId property key) or a plain Promise, and dispatches accordingly — a deliberate monkey-patch that insulates the plugin against future SDK flip-flops on this return type - Update wrap() to call runAsk() instead of Effect.runPromise() directly - Update test mock: ask now returns Promise.resolve() to match SDK 1.15.x; comment explains that runAsk() handles both forms --- packages/opencode-plugin/package.json | 5 + packages/opencode-plugin/src/plugin.ts | 44 +++- .../src/tool-handlers/tool-helper.ts | 3 +- packages/opencode-plugin/src/types.ts | 239 +++--------------- .../opencode-plugin/test/e2e/plugin.test.ts | 6 +- pnpm-lock.yaml | 46 ++++ 6 files changed, 128 insertions(+), 215 deletions(-) diff --git a/packages/opencode-plugin/package.json b/packages/opencode-plugin/package.json index 2c5f51e5..969d40ed 100644 --- a/packages/opencode-plugin/package.json +++ b/packages/opencode-plugin/package.json @@ -29,18 +29,23 @@ "devDependencies": { "@codemcp/workflows-core": "workspace:*", "@codemcp/workflows-server": "workspace:*", + "@opencode-ai/plugin": "*", "rimraf": "^6.0.1", "tsup": "^8.0.0", "vitest": "4.0.18" }, "peerDependencies": { "@anthropic-ai/sdk": "*", + "@opencode-ai/plugin": "*", "zod": ">=4.1.8" }, "peerDependenciesMeta": { "@anthropic-ai/sdk": { "optional": true }, + "@opencode-ai/plugin": { + "optional": false + }, "zod": { "optional": false } diff --git a/packages/opencode-plugin/src/plugin.ts b/packages/opencode-plugin/src/plugin.ts index 1a1649d1..50355b81 100644 --- a/packages/opencode-plugin/src/plugin.ts +++ b/packages/opencode-plugin/src/plugin.ts @@ -35,6 +35,46 @@ import { } from './server-context.js'; import { stripWhatsNextReferences } from './utils.js'; +// --------------------------------------------------------------------------- +// Monkey-patch resilience: ToolContext.ask return-type detection +// +// opencode has changed ToolContext.ask's return type between SDK releases: +// • SDK ≤ some pre-April-2026 version → Promise +// • SDK after PR #21986 (Apr 10 2026) → Effect.Effect +// • SDK 1.15.x (current, Jun 2026) → Promise ← reverted again +// +// Rather than chasing each flip, we inspect the actual return value at +// runtime and dispatch accordingly. An Effect object carries the property +// key "~effect/Effect" (its TypeId), which is stable across Effect 3.x and +// 4.x. A plain Promise does not have that key and is always thenable. +// +// This is intentionally a monkey-patch: it compensates for an upstream API +// that has been unstable across SDK versions. If the SDK stabilises on one +// form, this helper can be simplified, but it is cheap enough to keep. +// --------------------------------------------------------------------------- + +const EFFECT_TYPE_ID = '~effect/Effect'; + +/** + * Execute the result of `ToolContext.ask()`, regardless of whether the SDK + * version returns a `Promise` or an `Effect.Effect`. + */ +async function runAsk( + askResult: Promise | Effect.Effect +): Promise { + if ( + askResult !== null && + typeof askResult === 'object' && + EFFECT_TYPE_ID in askResult + ) { + // SDK returned an Effect — bridge it into the async/await world. + await Effect.runPromise(askResult as Effect.Effect); + } else { + // SDK returned a Promise (current behaviour as of SDK 1.15.x). + await (askResult as Promise); + } +} + /** * Buffered instructions from proceed_to_phase or start_development tools. * Consumed (and cleared) by the next chat.message hook invocation. @@ -749,7 +789,7 @@ ACTION REQUIRED: Use proceed_to_phase tool to move to a phase that allows editin ); } - await Effect.runPromise( + await runAsk( ctx.ask({ permission: toolName, patterns: buildPermissionPatterns( @@ -779,7 +819,7 @@ ACTION REQUIRED: Use proceed_to_phase tool to move to a phase that allows editin createProceedToPhaseTool( getServerContext, setBufferedInstructions, - input.client as OpenCodeClient, + input.client as unknown as OpenCodeClient, () => lastKnownModel ) ), diff --git a/packages/opencode-plugin/src/tool-handlers/tool-helper.ts b/packages/opencode-plugin/src/tool-handlers/tool-helper.ts index 93c7c8b6..195ba0ca 100644 --- a/packages/opencode-plugin/src/tool-handlers/tool-helper.ts +++ b/packages/opencode-plugin/src/tool-handlers/tool-helper.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; -import type { ToolDefinition, ToolContext } from '../types.js'; +import type { ToolContext } from '../types.js'; +import type { ToolDefinition } from '@opencode-ai/plugin'; /** * Tool definition helper diff --git a/packages/opencode-plugin/src/types.ts b/packages/opencode-plugin/src/types.ts index 99d00367..a9998131 100644 --- a/packages/opencode-plugin/src/types.ts +++ b/packages/opencode-plugin/src/types.ts @@ -1,229 +1,48 @@ /** * OpenCode Plugin Types * - * Minimal type definitions needed for the plugin. - * Based on @opencode-ai/plugin package types. + * Re-exports the types we need directly from the official @opencode-ai/plugin + * SDK so that any change to the SDK's type signatures (e.g. ToolContext.ask) + * is caught at compile time rather than silently breaking at runtime. + * + * Only types that are NOT exported by the SDK are defined here. */ -import { z } from 'zod'; - -// Simplified types from opencode SDK -export type Part = { - type: 'text' | 'image' | 'file' | 'tool_use' | 'tool_result'; - text?: string; - [key: string]: unknown; -}; - -export type UserMessage = { - id: string; - sessionID: string; - role: 'user'; - [key: string]: unknown; -}; - -export type Message = { - id: string; - sessionID: string; - role: 'user' | 'assistant'; - [key: string]: unknown; -}; +// --------------------------------------------------------------------------- +// Re-export everything from the official SDK +// --------------------------------------------------------------------------- + +export type { + PluginInput, + Plugin, + PluginModule, + Hooks, + ToolDefinition, + ToolContext, +} from '@opencode-ai/plugin'; + +// --------------------------------------------------------------------------- +// BusEvent subtypes +// +// The SDK exports a single opaque `Event` type from @opencode-ai/sdk, but the +// plugin needs to narrow on specific event types (session.compacted, +// session.idle) that are not individually exported. These local types stay +// here until the SDK exposes them directly. +// --------------------------------------------------------------------------- -export type Model = { - providerID: string; - modelID: string; - [key: string]: unknown; -}; - -export type Project = { - id: string; - path: string; - [key: string]: unknown; -}; - -// Plugin input provided by opencode -export type PluginInput = { - client: unknown; // SDK client - project: Project; - directory: string; - worktree: string; - serverUrl: URL; - $: unknown; // BunShell -}; - -// Tool context for custom tools -import type { Effect } from 'effect'; - -export type ToolContext = { - sessionID: string; - messageID: string; - agent: string; - directory: string; - worktree: string; - abort: AbortSignal; - metadata(input: { title?: string; metadata?: Record }): void; - ask(input: { - permission: string; - patterns: string[]; - always: string[]; - metadata: Record; - }): Effect.Effect; -}; - -// Tool definition -export type ToolDefinition = { - description: string; - args: z.ZodRawShape; - execute(args: unknown, context: ToolContext): Promise; -}; - -// Minimal Event types from @opencode-ai/sdk needed for the event hook export type SessionCompactedEvent = { type: 'session.compacted'; properties: { sessionID: string }; }; + export type SessionIdleEvent = { type: 'session.idle'; properties: { sessionID: string }; }; + export type OtherEvent = { type: string; properties: Record; }; -export type BusEvent = SessionCompactedEvent | SessionIdleEvent | OtherEvent; - -// All available hooks -export interface Hooks { - event?: (input: { event: BusEvent }) => Promise; - config?: (input: unknown) => Promise; - tool?: { - [key: string]: ToolDefinition; - }; - auth?: unknown; - - /** - * Called when a new message is received - */ - 'chat.message'?: ( - input: { - sessionID: string; - agent?: string; - model?: { providerID: string; modelID: string }; - messageID?: string; - variant?: string; - }, - output: { message: UserMessage; parts: Part[] } - ) => Promise; - - /** - * Modify parameters sent to LLM - */ - 'chat.params'?: ( - input: { - sessionID: string; - agent: string; - model: Model; - provider: unknown; - message: UserMessage; - }, - output: { - temperature: number; - topP: number; - topK: number; - options: Record; - } - ) => Promise; - - 'chat.headers'?: ( - input: { - sessionID: string; - agent: string; - model: Model; - provider: unknown; - message: UserMessage; - }, - output: { headers: Record } - ) => Promise; - - 'permission.ask'?: ( - input: unknown, - output: { status: 'ask' | 'deny' | 'allow' } - ) => Promise; - - 'command.execute.before'?: ( - input: { command: string; sessionID: string; arguments: string }, - output: { parts: Part[] } - ) => Promise; - - 'tool.execute.before'?: ( - input: { tool: string; sessionID: string; callID: string }, - output: { args: Record } - ) => Promise; - - 'shell.env'?: ( - input: { cwd: string; sessionID?: string; callID?: string }, - output: { env: Record } - ) => Promise; - 'tool.execute.after'?: ( - input: { - tool: string; - sessionID: string; - callID: string; - args: unknown; - }, - output: { - title: string; - output: string; - metadata: unknown; - } - ) => Promise; - - 'experimental.chat.messages.transform'?: ( - input: Record, - output: { - messages: { - info: Message; - parts: Part[]; - }[]; - } - ) => Promise; - - 'experimental.chat.system.transform'?: ( - input: { sessionID?: string; model: Model }, - output: { - system: string[]; - } - ) => Promise; - - /** - * Called before session compaction starts. Allows plugins to customize - * the compaction prompt. - */ - 'experimental.session.compacting'?: ( - input: { sessionID: string }, - output: { context: string[]; prompt?: string } - ) => Promise; - - 'experimental.text.complete'?: ( - input: { sessionID: string; messageID: string; partID: string }, - output: { text: string } - ) => Promise; - - /** - * Modify tool definitions (description and parameters) sent to LLM - */ - 'tool.definition'?: ( - input: { toolID: string }, - output: { description: string; parameters: unknown } - ) => Promise; -} - -// Plugin function signature -export type Plugin = (input: PluginInput) => Promise; - -// Plugin module structure expected by opencode -export type PluginModule = { - id?: string; - server: Plugin; - tui?: never; -}; +export type BusEvent = SessionCompactedEvent | SessionIdleEvent | OtherEvent; diff --git a/packages/opencode-plugin/test/e2e/plugin.test.ts b/packages/opencode-plugin/test/e2e/plugin.test.ts index d0864cb2..42fbd537 100644 --- a/packages/opencode-plugin/test/e2e/plugin.test.ts +++ b/packages/opencode-plugin/test/e2e/plugin.test.ts @@ -6,7 +6,6 @@ */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { Effect } from 'effect'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { tmpdir } from 'node:os'; @@ -64,7 +63,10 @@ function createMockToolContext(overrides: Record = {}) { worktree: '', abort: new AbortController().signal, metadata: vi.fn(), - ask: vi.fn().mockReturnValue(Effect.void), + // Return a plain Promise to match the current SDK (1.15.x). + // The plugin's runAsk() helper handles both Promise and Effect return + // values, so either form works here — but we match the real SDK default. + ask: vi.fn().mockResolvedValue(undefined), ...overrides, }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f06ab44e..5fca3f4e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -193,6 +193,9 @@ importers: '@codemcp/workflows-server': specifier: workspace:* version: link:../mcp-server + '@opencode-ai/plugin': + specifier: '*' + version: 1.3.12(@opentui/core@0.1.94(stage-js@1.0.1)(typescript@5.9.3)(web-tree-sitter@0.25.10))(@opentui/solid@0.1.94(solid-js@1.9.11)(stage-js@1.0.1)(typescript@5.9.3)(web-tree-sitter@0.25.10)) rimraf: specifier: ^6.0.1 version: 6.1.2 @@ -3937,6 +3940,9 @@ packages: resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} engines: {node: '>=18'} + solid-js@1.9.11: + resolution: {integrity: sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q==} + solid-js@1.9.12: resolution: {integrity: sha512-QzKaSJq2/iDrWR1As6MHZQ8fQkdOBf8GReYb7L5iKwMGceg7HxDcaOHk0at66tNgn9U2U7dXo8ZZpLIAmGMzgw==} @@ -5582,6 +5588,14 @@ snapshots: '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': optional: true + '@opencode-ai/plugin@1.3.12(@opentui/core@0.1.94(stage-js@1.0.1)(typescript@5.9.3)(web-tree-sitter@0.25.10))(@opentui/solid@0.1.94(solid-js@1.9.11)(stage-js@1.0.1)(typescript@5.9.3)(web-tree-sitter@0.25.10))': + dependencies: + '@opencode-ai/sdk': 1.3.12 + zod: 4.1.8 + optionalDependencies: + '@opentui/core': 0.1.94(stage-js@1.0.1)(typescript@5.9.3)(web-tree-sitter@0.25.10) + '@opentui/solid': 0.1.94(solid-js@1.9.11)(stage-js@1.0.1)(typescript@5.9.3)(web-tree-sitter@0.25.10) + '@opencode-ai/plugin@1.3.12(@opentui/core@0.1.94(stage-js@1.0.1)(typescript@5.9.3)(web-tree-sitter@0.25.10))(@opentui/solid@0.1.94(solid-js@1.9.12)(stage-js@1.0.1)(typescript@5.9.3)(web-tree-sitter@0.25.10))': dependencies: '@opencode-ai/sdk': 1.3.12 @@ -5633,6 +5647,23 @@ snapshots: - stage-js - typescript + '@opentui/solid@0.1.94(solid-js@1.9.11)(stage-js@1.0.1)(typescript@5.9.3)(web-tree-sitter@0.25.10)': + dependencies: + '@babel/core': 7.28.0 + '@babel/preset-typescript': 7.27.1(@babel/core@7.28.0) + '@opentui/core': 0.1.94(stage-js@1.0.1)(typescript@5.9.3)(web-tree-sitter@0.25.10) + babel-plugin-module-resolver: 5.0.2 + babel-preset-solid: 1.9.10(@babel/core@7.28.0)(solid-js@1.9.11) + entities: 7.0.1 + s-js: 0.4.9 + solid-js: 1.9.11 + transitivePeerDependencies: + - stage-js + - supports-color + - typescript + - web-tree-sitter + optional: true + '@opentui/solid@0.1.94(solid-js@1.9.12)(stage-js@1.0.1)(typescript@5.9.3)(web-tree-sitter@0.25.10)': dependencies: '@babel/core': 7.28.0 @@ -6593,6 +6624,14 @@ snapshots: reselect: 4.1.8 resolve: 1.22.11 + babel-preset-solid@1.9.10(@babel/core@7.28.0)(solid-js@1.9.11): + dependencies: + '@babel/core': 7.28.0 + babel-plugin-jsx-dom-expressions: 0.40.6(@babel/core@7.28.0) + optionalDependencies: + solid-js: 1.9.11 + optional: true + babel-preset-solid@1.9.10(@babel/core@7.28.0)(solid-js@1.9.12): dependencies: '@babel/core': 7.28.0 @@ -8364,6 +8403,13 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 + solid-js@1.9.11: + dependencies: + csstype: 3.2.3 + seroval: 1.5.1 + seroval-plugins: 1.5.1(seroval@1.5.1) + optional: true + solid-js@1.9.12: dependencies: csstype: 3.2.3