Skip to content

Commit 419fec1

Browse files
committed
fix(opencode-plugin): use SDK types directly and harden ask() against return-type changes
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<void> back to Promise<void>. 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
1 parent 2874dde commit 419fec1

6 files changed

Lines changed: 128 additions & 215 deletions

File tree

packages/opencode-plugin/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,18 +29,23 @@
2929
"devDependencies": {
3030
"@codemcp/workflows-core": "workspace:*",
3131
"@codemcp/workflows-server": "workspace:*",
32+
"@opencode-ai/plugin": "*",
3233
"rimraf": "^6.0.1",
3334
"tsup": "^8.0.0",
3435
"vitest": "4.0.18"
3536
},
3637
"peerDependencies": {
3738
"@anthropic-ai/sdk": "*",
39+
"@opencode-ai/plugin": "*",
3840
"zod": ">=4.1.8"
3941
},
4042
"peerDependenciesMeta": {
4143
"@anthropic-ai/sdk": {
4244
"optional": true
4345
},
46+
"@opencode-ai/plugin": {
47+
"optional": false
48+
},
4449
"zod": {
4550
"optional": false
4651
}

packages/opencode-plugin/src/plugin.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,46 @@ import {
3535
} from './server-context.js';
3636
import { stripWhatsNextReferences } from './utils.js';
3737

38+
// ---------------------------------------------------------------------------
39+
// Monkey-patch resilience: ToolContext.ask return-type detection
40+
//
41+
// opencode has changed ToolContext.ask's return type between SDK releases:
42+
// • SDK ≤ some pre-April-2026 version → Promise<void>
43+
// • SDK after PR #21986 (Apr 10 2026) → Effect.Effect<void>
44+
// • SDK 1.15.x (current, Jun 2026) → Promise<void> ← reverted again
45+
//
46+
// Rather than chasing each flip, we inspect the actual return value at
47+
// runtime and dispatch accordingly. An Effect object carries the property
48+
// key "~effect/Effect" (its TypeId), which is stable across Effect 3.x and
49+
// 4.x. A plain Promise does not have that key and is always thenable.
50+
//
51+
// This is intentionally a monkey-patch: it compensates for an upstream API
52+
// that has been unstable across SDK versions. If the SDK stabilises on one
53+
// form, this helper can be simplified, but it is cheap enough to keep.
54+
// ---------------------------------------------------------------------------
55+
56+
const EFFECT_TYPE_ID = '~effect/Effect';
57+
58+
/**
59+
* Execute the result of `ToolContext.ask()`, regardless of whether the SDK
60+
* version returns a `Promise<void>` or an `Effect.Effect<void>`.
61+
*/
62+
async function runAsk(
63+
askResult: Promise<void> | Effect.Effect<void>
64+
): Promise<void> {
65+
if (
66+
askResult !== null &&
67+
typeof askResult === 'object' &&
68+
EFFECT_TYPE_ID in askResult
69+
) {
70+
// SDK returned an Effect — bridge it into the async/await world.
71+
await Effect.runPromise(askResult as Effect.Effect<void>);
72+
} else {
73+
// SDK returned a Promise (current behaviour as of SDK 1.15.x).
74+
await (askResult as Promise<void>);
75+
}
76+
}
77+
3878
/**
3979
* Buffered instructions from proceed_to_phase or start_development tools.
4080
* 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
749789
);
750790
}
751791

752-
await Effect.runPromise(
792+
await runAsk(
753793
ctx.ask({
754794
permission: toolName,
755795
patterns: buildPermissionPatterns(
@@ -779,7 +819,7 @@ ACTION REQUIRED: Use proceed_to_phase tool to move to a phase that allows editin
779819
createProceedToPhaseTool(
780820
getServerContext,
781821
setBufferedInstructions,
782-
input.client as OpenCodeClient,
822+
input.client as unknown as OpenCodeClient,
783823
() => lastKnownModel
784824
)
785825
),

packages/opencode-plugin/src/tool-handlers/tool-helper.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { z } from 'zod';
2-
import type { ToolDefinition, ToolContext } from '../types.js';
2+
import type { ToolContext } from '../types.js';
3+
import type { ToolDefinition } from '@opencode-ai/plugin';
34

45
/**
56
* Tool definition helper
Lines changed: 29 additions & 210 deletions
Original file line numberDiff line numberDiff line change
@@ -1,229 +1,48 @@
11
/**
22
* OpenCode Plugin Types
33
*
4-
* Minimal type definitions needed for the plugin.
5-
* Based on @opencode-ai/plugin package types.
4+
* Re-exports the types we need directly from the official @opencode-ai/plugin
5+
* SDK so that any change to the SDK's type signatures (e.g. ToolContext.ask)
6+
* is caught at compile time rather than silently breaking at runtime.
7+
*
8+
* Only types that are NOT exported by the SDK are defined here.
69
*/
710

8-
import { z } from 'zod';
9-
10-
// Simplified types from opencode SDK
11-
export type Part = {
12-
type: 'text' | 'image' | 'file' | 'tool_use' | 'tool_result';
13-
text?: string;
14-
[key: string]: unknown;
15-
};
16-
17-
export type UserMessage = {
18-
id: string;
19-
sessionID: string;
20-
role: 'user';
21-
[key: string]: unknown;
22-
};
23-
24-
export type Message = {
25-
id: string;
26-
sessionID: string;
27-
role: 'user' | 'assistant';
28-
[key: string]: unknown;
29-
};
11+
// ---------------------------------------------------------------------------
12+
// Re-export everything from the official SDK
13+
// ---------------------------------------------------------------------------
14+
15+
export type {
16+
PluginInput,
17+
Plugin,
18+
PluginModule,
19+
Hooks,
20+
ToolDefinition,
21+
ToolContext,
22+
} from '@opencode-ai/plugin';
23+
24+
// ---------------------------------------------------------------------------
25+
// BusEvent subtypes
26+
//
27+
// The SDK exports a single opaque `Event` type from @opencode-ai/sdk, but the
28+
// plugin needs to narrow on specific event types (session.compacted,
29+
// session.idle) that are not individually exported. These local types stay
30+
// here until the SDK exposes them directly.
31+
// ---------------------------------------------------------------------------
3032

31-
export type Model = {
32-
providerID: string;
33-
modelID: string;
34-
[key: string]: unknown;
35-
};
36-
37-
export type Project = {
38-
id: string;
39-
path: string;
40-
[key: string]: unknown;
41-
};
42-
43-
// Plugin input provided by opencode
44-
export type PluginInput = {
45-
client: unknown; // SDK client
46-
project: Project;
47-
directory: string;
48-
worktree: string;
49-
serverUrl: URL;
50-
$: unknown; // BunShell
51-
};
52-
53-
// Tool context for custom tools
54-
import type { Effect } from 'effect';
55-
56-
export type ToolContext = {
57-
sessionID: string;
58-
messageID: string;
59-
agent: string;
60-
directory: string;
61-
worktree: string;
62-
abort: AbortSignal;
63-
metadata(input: { title?: string; metadata?: Record<string, unknown> }): void;
64-
ask(input: {
65-
permission: string;
66-
patterns: string[];
67-
always: string[];
68-
metadata: Record<string, unknown>;
69-
}): Effect.Effect<void>;
70-
};
71-
72-
// Tool definition
73-
export type ToolDefinition = {
74-
description: string;
75-
args: z.ZodRawShape;
76-
execute(args: unknown, context: ToolContext): Promise<string>;
77-
};
78-
79-
// Minimal Event types from @opencode-ai/sdk needed for the event hook
8033
export type SessionCompactedEvent = {
8134
type: 'session.compacted';
8235
properties: { sessionID: string };
8336
};
37+
8438
export type SessionIdleEvent = {
8539
type: 'session.idle';
8640
properties: { sessionID: string };
8741
};
42+
8843
export type OtherEvent = {
8944
type: string;
9045
properties: Record<string, unknown>;
9146
};
92-
export type BusEvent = SessionCompactedEvent | SessionIdleEvent | OtherEvent;
93-
94-
// All available hooks
95-
export interface Hooks {
96-
event?: (input: { event: BusEvent }) => Promise<void>;
97-
config?: (input: unknown) => Promise<void>;
98-
tool?: {
99-
[key: string]: ToolDefinition;
100-
};
101-
auth?: unknown;
102-
103-
/**
104-
* Called when a new message is received
105-
*/
106-
'chat.message'?: (
107-
input: {
108-
sessionID: string;
109-
agent?: string;
110-
model?: { providerID: string; modelID: string };
111-
messageID?: string;
112-
variant?: string;
113-
},
114-
output: { message: UserMessage; parts: Part[] }
115-
) => Promise<void>;
116-
117-
/**
118-
* Modify parameters sent to LLM
119-
*/
120-
'chat.params'?: (
121-
input: {
122-
sessionID: string;
123-
agent: string;
124-
model: Model;
125-
provider: unknown;
126-
message: UserMessage;
127-
},
128-
output: {
129-
temperature: number;
130-
topP: number;
131-
topK: number;
132-
options: Record<string, unknown>;
133-
}
134-
) => Promise<void>;
135-
136-
'chat.headers'?: (
137-
input: {
138-
sessionID: string;
139-
agent: string;
140-
model: Model;
141-
provider: unknown;
142-
message: UserMessage;
143-
},
144-
output: { headers: Record<string, string> }
145-
) => Promise<void>;
146-
147-
'permission.ask'?: (
148-
input: unknown,
149-
output: { status: 'ask' | 'deny' | 'allow' }
150-
) => Promise<void>;
151-
152-
'command.execute.before'?: (
153-
input: { command: string; sessionID: string; arguments: string },
154-
output: { parts: Part[] }
155-
) => Promise<void>;
156-
157-
'tool.execute.before'?: (
158-
input: { tool: string; sessionID: string; callID: string },
159-
output: { args: Record<string, unknown> }
160-
) => Promise<void>;
161-
162-
'shell.env'?: (
163-
input: { cwd: string; sessionID?: string; callID?: string },
164-
output: { env: Record<string, string> }
165-
) => Promise<void>;
16647

167-
'tool.execute.after'?: (
168-
input: {
169-
tool: string;
170-
sessionID: string;
171-
callID: string;
172-
args: unknown;
173-
},
174-
output: {
175-
title: string;
176-
output: string;
177-
metadata: unknown;
178-
}
179-
) => Promise<void>;
180-
181-
'experimental.chat.messages.transform'?: (
182-
input: Record<string, never>,
183-
output: {
184-
messages: {
185-
info: Message;
186-
parts: Part[];
187-
}[];
188-
}
189-
) => Promise<void>;
190-
191-
'experimental.chat.system.transform'?: (
192-
input: { sessionID?: string; model: Model },
193-
output: {
194-
system: string[];
195-
}
196-
) => Promise<void>;
197-
198-
/**
199-
* Called before session compaction starts. Allows plugins to customize
200-
* the compaction prompt.
201-
*/
202-
'experimental.session.compacting'?: (
203-
input: { sessionID: string },
204-
output: { context: string[]; prompt?: string }
205-
) => Promise<void>;
206-
207-
'experimental.text.complete'?: (
208-
input: { sessionID: string; messageID: string; partID: string },
209-
output: { text: string }
210-
) => Promise<void>;
211-
212-
/**
213-
* Modify tool definitions (description and parameters) sent to LLM
214-
*/
215-
'tool.definition'?: (
216-
input: { toolID: string },
217-
output: { description: string; parameters: unknown }
218-
) => Promise<void>;
219-
}
220-
221-
// Plugin function signature
222-
export type Plugin = (input: PluginInput) => Promise<Hooks>;
223-
224-
// Plugin module structure expected by opencode
225-
export type PluginModule = {
226-
id?: string;
227-
server: Plugin;
228-
tui?: never;
229-
};
48+
export type BusEvent = SessionCompactedEvent | SessionIdleEvent | OtherEvent;

packages/opencode-plugin/test/e2e/plugin.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
*/
77

88
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
9-
import { Effect } from 'effect';
109
import * as fs from 'node:fs';
1110
import * as path from 'node:path';
1211
import { tmpdir } from 'node:os';
@@ -64,7 +63,10 @@ function createMockToolContext(overrides: Record<string, unknown> = {}) {
6463
worktree: '',
6564
abort: new AbortController().signal,
6665
metadata: vi.fn(),
67-
ask: vi.fn().mockReturnValue(Effect.void),
66+
// Return a plain Promise to match the current SDK (1.15.x).
67+
// The plugin's runAsk() helper handles both Promise and Effect return
68+
// values, so either form works here — but we match the real SDK default.
69+
ask: vi.fn().mockResolvedValue(undefined),
6870
...overrides,
6971
};
7072
}

0 commit comments

Comments
 (0)