Compare commits

...

4 Commits

Author SHA1 Message Date
Kit Langton 7832a677fb feat(plugin): support dynamic Effect tools 2026-07-15 18:47:10 -04:00
Kit Langton 5e2e0d6965 feat(simulation): add endpoint handshake protocol (#37157) 2026-07-15 17:46:28 -04:00
opencode-agent[bot] f92d84746b refactor(plugin): scope context hook to session (#37175)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-07-15 16:32:00 -04:00
Dax Raad f2f5eb6f16 feat(plugin): restore ai request hook 2026-07-15 15:58:14 -04:00
27 changed files with 657 additions and 22 deletions
+1
View File
@@ -720,6 +720,7 @@
"version": "1.17.20", "version": "1.17.20",
"dependencies": { "dependencies": {
"@ai-sdk/provider": "3.0.8", "@ai-sdk/provider": "3.0.8",
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/client": "workspace:*", "@opencode-ai/client": "workspace:*",
"@opencode-ai/schema": "workspace:*", "@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
+2
View File
@@ -1,6 +1,7 @@
export * as PluginHooks from "./hooks" export * as PluginHooks from "./hooks"
import type { AISDKHooks } from "@opencode-ai/plugin/v2/effect/aisdk" import type { AISDKHooks } from "@opencode-ai/plugin/v2/effect/aisdk"
import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session"
import type { ToolHooks } from "@opencode-ai/plugin/v2/effect/tool" import type { ToolHooks } from "@opencode-ai/plugin/v2/effect/tool"
import { Context, Effect, Layer, Scope } from "effect" import { Context, Effect, Layer, Scope } from "effect"
import { makeLocationNode } from "../effect/app-node" import { makeLocationNode } from "../effect/app-node"
@@ -8,6 +9,7 @@ import { State } from "../state"
export interface Domains { export interface Domains {
readonly aisdk: AISDKHooks readonly aisdk: AISDKHooks
readonly session: SessionHooks
readonly tool: ToolHooks readonly tool: ToolHooks
} }
+8
View File
@@ -317,6 +317,13 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
add: (name, tool, options) => { add: (name, tool, options) => {
registrations.push({ name, tool, ...(options ? { options } : {}) }) registrations.push({ name, tool, ...(options ? { options } : {}) })
}, },
addDynamic: (name, config, options) => {
registrations.push({
name,
tool: Tool.make(config),
...(options ? { options } : {}),
})
},
}), }),
) )
yield* Effect.forEach( yield* Effect.forEach(
@@ -367,6 +374,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
}, },
}, },
session: { session: {
hook: (name, callback) => hooks.register("session", name, callback),
create: (input) => create: (input) =>
runtime.session.create({ runtime.session.create({
id: input?.id, id: input?.id,
+2
View File
@@ -162,6 +162,8 @@ export function fromPromise(plugin: Plugin) {
register(host.tool.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))), register(host.tool.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
}, },
session: { session: {
hook: (name, callback) =>
register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
create: (input) => create: (input) =>
run( run(
host.session.create( host.session.create(
+33 -3
View File
@@ -10,6 +10,7 @@ import {
isContextOverflowFailure, isContextOverflowFailure,
type ProviderErrorEvent, type ProviderErrorEvent,
} from "@opencode-ai/ai" } from "@opencode-ai/ai"
import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session"
import { SessionError } from "@opencode-ai/schema/session-error" import { SessionError } from "@opencode-ai/schema/session-error"
import { Money } from "@opencode-ai/schema/money" import { Money } from "@opencode-ai/schema/money"
import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect" import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
@@ -51,6 +52,7 @@ import { AgentNotFoundError, StepFailedError } from "../error"
import { toSessionError } from "../to-session-error" import { toSessionError } from "../to-session-error"
import { SessionRunnerRetry } from "./retry" import { SessionRunnerRetry } from "./retry"
import { PluginSupervisor } from "../../plugin/supervisor" import { PluginSupervisor } from "../../plugin/supervisor"
import { PluginHooks } from "../../plugin/hooks"
import { SessionModelHeaders } from "../model-headers" import { SessionModelHeaders } from "../model-headers"
type StepTokens = { type StepTokens = {
@@ -89,6 +91,7 @@ const layer = Layer.effect(
const llm = yield* LLMClient.Service const llm = yield* LLMClient.Service
const agents = yield* AgentV2.Service const agents = yield* AgentV2.Service
const tools = yield* ToolRegistry.Service const tools = yield* ToolRegistry.Service
const hooks = yield* PluginHooks.Service
const models = yield* SessionRunnerModel.Service const models = yield* SessionRunnerModel.Service
const store = yield* SessionStore.Service const store = yield* SessionStore.Service
const location = yield* Location.Service const location = yield* Location.Service
@@ -212,6 +215,30 @@ const layer = Layer.effect(
tools: toolMaterialization?.definitions ?? [], tools: toolMaterialization?.definitions ?? [],
toolChoice: isLastStep ? "none" : undefined, toolChoice: isLastStep ? "none" : undefined,
}) })
const availableTools = new Map(request.tools.map((tool) => [tool.name, tool]))
const contextEvent: SessionHooks["context"] = {
sessionID: session.id,
agent: agent.id,
model: resolved.ref,
system: [...request.system],
messages: [...request.messages],
tools: Object.fromEntries(
request.tools.map((tool) => [tool.name, { description: tool.description, input: { ...tool.inputSchema } }]),
),
}
// Plugins may reshape the draft but cannot advertise tools excluded by
// permissions, registration state, or the selected agent's step limit.
yield* hooks.trigger("session", "context", contextEvent)
const hookedRequest = LLM.updateRequest(request, {
system: contextEvent.system,
messages: contextEvent.messages,
tools: Object.entries(contextEvent.tools).flatMap(([name, tool]) => {
const registered = availableTools.get(name)
if (!registered) return []
return [{ ...registered, description: tool.description, inputSchema: tool.input }]
}),
})
const advertisedTools = new Set(hookedRequest.tools.map((tool) => tool.name))
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>() const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>()
const ownedToolFibers: Array<Fiber.Fiber<void, ToolOutputStore.Error>> = [] const ownedToolFibers: Array<Fiber.Fiber<void, ToolOutputStore.Error>> = []
let needsContinuation = false let needsContinuation = false
@@ -232,7 +259,7 @@ const layer = Layer.effect(
const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect) const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect)
const publish = (event: LLMEvent, error?: SessionError.Error) => serialized(publisher.publish(event, error)) const publish = (event: LLMEvent, error?: SessionError.Error) => serialized(publisher.publish(event, error))
let overflowFailure: ProviderErrorEvent | undefined let overflowFailure: ProviderErrorEvent | undefined
const providerStream = llm.stream(request).pipe( const providerStream = llm.stream(hookedRequest).pipe(
Stream.runForEach((event) => Stream.runForEach((event) =>
Effect.gen(function* () { Effect.gen(function* () {
if (overflowFailure || publisher.hasProviderError()) return if (overflowFailure || publisher.hasProviderError()) return
@@ -244,11 +271,13 @@ const layer = Layer.effect(
} }
yield* publish(event) yield* publish(event)
if (event.type !== "tool-call" || event.providerExecuted) return if (event.type !== "tool-call" || event.providerExecuted) return
if (!toolMaterialization) { if (!toolMaterialization || (availableTools.has(event.name) && !advertisedTools.has(event.name))) {
yield* serialized( yield* serialized(
publisher.failUnsettledTools({ publisher.failUnsettledTools({
type: "tool.execution", type: "tool.execution",
message: "Tools are disabled after the maximum agent steps", message: toolMaterialization
? `Tool is not available for this request: ${event.name}`
: "Tools are disabled after the maximum agent steps",
}), }),
) )
return return
@@ -585,6 +614,7 @@ export const node = makeLocationNode({
llmClient, llmClient,
AgentV2.node, AgentV2.node,
ToolRegistry.node, ToolRegistry.node,
PluginHooks.node,
SessionRunnerModel.node, SessionRunnerModel.node,
SessionStore.node, SessionStore.node,
Location.node, Location.node,
+13
View File
@@ -195,6 +195,19 @@ export const Plugin = {
), ),
) )
.pipe(Effect.orDie) .pipe(Effect.orDie)
yield* ctx.session.hook("context", (event) =>
Effect.sync(() => {
const usePatch =
event.model.providerID.toLowerCase() === "openai" || event.model.id.toLowerCase().includes("gpt")
if (usePatch) {
delete event.tools.edit
delete event.tools.write
return
}
delete event.tools.patch
}),
)
}), }),
} }
+27
View File
@@ -215,5 +215,32 @@ export const Plugin = {
), ),
) )
.pipe(Effect.orDie) .pipe(Effect.orDie)
yield* ctx.session.hook("context", (event) =>
Effect.gen(function* () {
const tool = event.tools[name]
if (!tool) return
const selected = yield* agents.resolve(event.agent)
if (!selected) return
const available = (yield* agents.list())
.filter(
(agent) =>
agent.mode !== "primary" &&
!agent.hidden &&
PermissionV2.evaluate(name, agent.id, selected.permissions).effect !== "deny",
)
.toSorted((a, b) => a.id.localeCompare(b.id))
if (available.length === 0) return
tool.description = [
tool.description,
"",
"Available subagents:",
...available.map(
(agent) =>
`- ${agent.id}: ${agent.description ?? "This subagent should only be called when explicitly requested."}`,
),
].join("\n")
}),
)
}), }),
} }
+6
View File
@@ -44,6 +44,9 @@ export const registerToolPlugin = <R>(plugin: {
Effect.gen(function* () { Effect.gen(function* () {
const tools = yield* Tools.Service const tools = yield* Tools.Service
const context = host({ const context = host({
session: {
hook: () => Effect.succeed({ dispose: Effect.void }),
},
tool: { tool: {
transform: (callback) => transform: (callback) =>
Effect.gen(function* () { Effect.gen(function* () {
@@ -56,6 +59,9 @@ export const registerToolPlugin = <R>(plugin: {
add: (name, tool, options) => { add: (name, tool, options) => {
registrations.push({ name, tool, ...(options ? { options } : {}) }) registrations.push({ name, tool, ...(options ? { options } : {}) })
}, },
addDynamic: (name, config, options) => {
registrations.push({ name, tool: Tool.make(config), ...(options ? { options } : {}) })
},
}) })
yield* Effect.forEach( yield* Effect.forEach(
registrations, registrations,
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect } from "bun:test"
import { Message, SystemPart } from "@opencode-ai/ai"
import { Agent } from "@opencode-ai/schema/agent"
import { Model } from "@opencode-ai/schema/model"
import { Provider } from "@opencode-ai/schema/provider"
import { Session } from "@opencode-ai/schema/session"
import { Effect, Layer } from "effect"
import { PluginHooks } from "../src/plugin/hooks"
import { testEffect } from "./lib/effect"
const layer = PluginHooks.node.implementation as Layer.Layer<PluginHooks.Service>
const it = testEffect(layer)
describe("PluginHooks", () => {
it.effect("registers scoped session hooks and triggers them sequentially", () =>
Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
const seen: string[] = []
yield* hooks.register("session", "context", (event) =>
Effect.sync(() => {
seen.push("first")
event.system.push(SystemPart.make("second"))
}),
)
yield* hooks.register("session", "context", (event) =>
Effect.sync(() => {
seen.push(event.system[1]?.text ?? "missing")
event.messages = [Message.user("changed")]
}),
)
const event = {
sessionID: Session.ID.make("ses_hooks"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
system: [SystemPart.make("first")],
messages: [Message.user("original")],
tools: {},
}
expect(yield* hooks.trigger("session", "context", event)).toBe(event)
expect(seen).toEqual(["first", "second"])
expect(event.messages).toEqual([Message.user("changed")])
}),
)
})
+36
View File
@@ -274,6 +274,42 @@ describe("PluginV2", () => {
}), }),
) )
it.effect("registers dynamic Effect tools through the host context", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const registry = yield* ToolRegistry.Service
const plugin = EffectPlugin.define({
id: "dynamic-tool-plugin",
effect: (ctx) =>
ctx.tool
.transform((draft) =>
draft.addDynamic(
"dynamic_tool",
{
description: "Dynamic plugin tool",
jsonSchema: {
type: "object",
properties: { value: { type: "string" } },
required: ["value"],
additionalProperties: false,
},
execute: (input) =>
Effect.succeed({
structured: input,
content: [{ type: "text", text: "dynamic output" }],
}),
},
{ codemode: false },
),
)
.pipe(Effect.orDie),
})
yield* plugins.activate([versioned(plugin)])
expect((yield* registry.materialize()).definitions.map((tool) => tool.name)).toContain("dynamic_tool")
}),
)
it.effect("groups tool names and routes codemode registrations through execute", () => it.effect("groups tool names and routes codemode registrations through execute", () =>
Effect.gen(function* () { Effect.gen(function* () {
const plugins = yield* PluginV2.Service const plugins = yield* PluginV2.Service
+10 -7
View File
@@ -8,7 +8,9 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
import type { IntegrationEnvMethod, IntegrationKeyMethod, IntegrationOAuthMethod } from "@opencode-ai/sdk/v2/types" import type { IntegrationEnvMethod, IntegrationKeyMethod, IntegrationOAuthMethod } from "@opencode-ai/sdk/v2/types"
import { Effect, Stream } from "effect" import { Effect, Stream } from "effect"
type Overrides = Partial<Omit<PluginContext, "options">> type Overrides = Partial<Omit<PluginContext, "options" | "session">> & {
readonly session?: Partial<PluginContext["session"]>
}
export function host(overrides: Overrides = {}): PluginContext { export function host(overrides: Overrides = {}): PluginContext {
return { return {
@@ -77,12 +79,13 @@ export function host(overrides: Overrides = {}): PluginContext {
transform: () => Effect.die("unused tool.transform"), transform: () => Effect.die("unused tool.transform"),
hook: () => Effect.die("unused tool.hook"), hook: () => Effect.die("unused tool.hook"),
}, },
session: overrides.session ?? { session: {
create: () => Effect.die("unused session.create"), hook: overrides.session?.hook ?? (() => Effect.die("unused session.hook")),
get: () => Effect.die("unused session.get"), create: overrides.session?.create ?? (() => Effect.die("unused session.create")),
prompt: () => Effect.die("unused session.prompt"), get: overrides.session?.get ?? (() => Effect.die("unused session.get")),
command: () => Effect.die("unused session.command"), prompt: overrides.session?.prompt ?? (() => Effect.die("unused session.prompt")),
interrupt: () => Effect.die("unused session.interrupt"), command: overrides.session?.command ?? (() => Effect.die("unused session.command")),
interrupt: overrides.session?.interrupt ?? (() => Effect.die("unused session.interrupt")),
}, },
} }
} }
+37
View File
@@ -1,13 +1,18 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Message, SystemPart } from "@opencode-ai/ai"
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent" import { AgentV2 } from "@opencode-ai/core/agent"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginHost } from "@opencode-ai/core/plugin/host" import { PluginHost } from "@opencode-ai/core/plugin/host"
import { PluginPromise } from "@opencode-ai/core/plugin/promise" import { PluginPromise } from "@opencode-ai/core/plugin/promise"
import { SessionV2 } from "@opencode-ai/core/session" import { SessionV2 } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionMessage } from "@opencode-ai/core/session/message"
import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { Plugin } from "@opencode-ai/plugin/v2" import { Plugin } from "@opencode-ai/plugin/v2"
import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session"
import { Model } from "@opencode-ai/schema/model"
import { Provider } from "@opencode-ai/schema/provider"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture" import { PluginTestLayer } from "./fixture"
@@ -72,6 +77,38 @@ describe("fromPromise", () => {
}), }),
) )
it.effect("forwards session context hooks", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const hooks = yield* PluginHooks.Service
const host = yield* PluginHost.make(plugin)
yield* PluginPromise.fromPromise(
Plugin.define({
id: "promise-session-context",
setup: async (ctx) => {
await ctx.session.hook("context", (event) => {
event.system.push(SystemPart.make("Promise hook"))
delete event.tools.echo
})
},
}),
).effect(host)
const event: SessionHooks["context"] = {
sessionID: SessionV2.ID.make("ses_promise_session_context"),
agent: AgentV2.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
system: [SystemPart.make("Initial")],
messages: [Message.user("Hello")],
tools: { echo: { description: "Echo", input: { type: "object" } } },
}
yield* hooks.trigger("session", "context", event)
expect(event.system.map((part) => part.text)).toEqual(["Initial", "Promise hook"])
expect(event.tools).toEqual({})
}),
)
it.effect("disposes a hook registration on request", () => it.effect("disposes a hook registration on request", () =>
Effect.gen(function* () { Effect.gen(function* () {
const agents = yield* AgentV2.Service const agents = yield* AgentV2.Service
+30
View File
@@ -3,7 +3,9 @@ import {
LLMClient, LLMClient,
LLMError, LLMError,
LLMEvent, LLMEvent,
Message,
Model, Model,
SystemPart,
ToolFailure, ToolFailure,
TransportReason, TransportReason,
InvalidProviderOutputReason, InvalidProviderOutputReason,
@@ -42,6 +44,7 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { SessionRunnerSystemPrompt } from "@opencode-ai/core/session/runner/system-prompt" import { SessionRunnerSystemPrompt } from "@opencode-ai/core/session/runner/system-prompt"
import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor" import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { QuestionTool } from "@opencode-ai/core/tool/question" import { QuestionTool } from "@opencode-ai/core/tool/question"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { AgentV2 } from "@opencode-ai/core/agent" import { AgentV2 } from "@opencode-ai/core/agent"
@@ -397,6 +400,7 @@ const it = testEffect(
AgentV2.node, AgentV2.node,
ToolRegistry.node, ToolRegistry.node,
ToolRegistry.toolsNode, ToolRegistry.toolsNode,
PluginHooks.node,
echoNode, echoNode,
SessionRunnerModel.node, SessionRunnerModel.node,
InstructionBuiltIns.node, InstructionBuiltIns.node,
@@ -773,6 +777,32 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) =>
}) })
describe("SessionRunnerLLM", () => { describe("SessionRunnerLLM", () => {
it.effect("applies session context hooks without exposing unavailable tools", () =>
Effect.gen(function* () {
const session = yield* setup
const hooks = yield* PluginHooks.Service
yield* hooks.register("session", "context", (event) =>
Effect.sync(() => {
event.system = [SystemPart.make("Hooked system")]
event.messages = [Message.user("Hooked message")]
delete event.tools.echo
event.tools.unregistered = { description: "Unavailable", input: { type: "object" } }
}),
)
yield* admit(session, "Original message")
responses = [reply.tool("call-removed", "echo", { text: "blocked" })]
yield* session.resume(sessionID)
expect(requests).toHaveLength(1)
expect(requests[0]?.system.map((part) => part.text)).toEqual(["Hooked system"])
expect(requests[0]?.messages).toEqual([Message.user("Hooked message")])
expect(requests[0]?.tools.map((tool) => tool.name)).not.toContain("echo")
expect(requests[0]?.tools.map((tool) => tool.name)).not.toContain("unregistered")
expect(executions).toEqual([])
}),
)
it.effect("advertises and executes a location registered tool", () => it.effect("advertises and executes a location registered tool", () =>
Effect.gen(function* () { Effect.gen(function* () {
const session = yield* setup const session = yield* setup
@@ -17,6 +17,7 @@ Implementation checklist:
- [x] Add OpenTUI UI state extraction for screen, focus, elements, and generated actions. - [x] Add OpenTUI UI state extraction for screen, focus, elements, and generated actions.
- [x] Add OpenTUI UI action execution for typing, keys, enter, arrows, focus, and click. - [x] Add OpenTUI UI action execution for typing, keys, enter, arrows, focus, and click.
- [x] Add reusable JSON-RPC WebSocket server at the manifest's UI endpoint. - [x] Add reusable JSON-RPC WebSocket server at the manifest's UI endpoint.
- [x] Add `simulation.handshake` protocol, role, identity, version, and capability negotiation to both control endpoints.
- [x] Expose `ui.state`, `ui.action`, `ui.render`. - [x] Expose `ui.state`, `ui.action`, `ui.render`.
- [x] Expose `trace.list`, `trace.clear`, `trace.export`. - [x] Expose `trace.list`, `trace.clear`, `trace.export`.
- [x] Wire visible V1/full-TUI renderer path through the same action protocol. - [x] Wire visible V1/full-TUI renderer path through the same action protocol.
@@ -69,6 +69,7 @@ This is important because the frontend has direct access to the renderer, screen
Protocol: Protocol:
- JSON-RPC 2.0 over WebSocket. - JSON-RPC 2.0 over WebSocket.
- Clients negotiate protocol version, endpoint role, and capabilities with `simulation.handshake` before using endpoint methods.
- Loopback only. - Loopback only.
- `OPENCODE_DRIVE` names a manifest in the opencode-drive registry, or is `1` for the unnamed default endpoints. - `OPENCODE_DRIVE` names a manifest in the opencode-drive registry, or is `1` for the unnamed default endpoints.
- The manifest supplies exact loopback `ui` and `backend` WebSocket endpoints. - The manifest supplies exact loopback `ui` and `backend` WebSocket endpoints.
@@ -77,6 +78,42 @@ Protocol:
The app should not send JSON-RPC requests back to the driver in the first milestone. The driver sends requests; the app responds and emits notifications/events as useful. The app should not send JSON-RPC requests back to the driver in the first milestone. The driver sends requests; the app responds and emits notifications/events as useful.
The canonical handshake request is:
```ts
{
jsonrpc: "2.0"
id: string | number | null
method: "simulation.handshake"
params: {
client: {
name: string
version: string
}
expectedRole: "ui" | "backend"
offeredVersions: Array<number>
requiredCapabilities: Array<string>
optionalCapabilities: Array<string>
}
}
```
The response result is:
```ts
{
protocolVersion: 1
role: "ui" | "backend"
server: {
name: string
version: string
}
capabilities: Array<string>
}
```
Capabilities are open strings. Each endpoint advertises only methods and notifications it actually implements. A role mismatch, no supported offered protocol version, or a missing required capability fails the request; unsupported optional capabilities do not.
Initial method groups: Initial method groups:
- `ui.state`: return screen, elements, focus, and generated possible actions. - `ui.state`: return screen, elements, focus, and generated possible actions.
+1
View File
@@ -25,6 +25,7 @@
], ],
"dependencies": { "dependencies": {
"@ai-sdk/provider": "3.0.8", "@ai-sdk/provider": "3.0.8",
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/client": "workspace:*", "@opencode-ai/client": "workspace:*",
"@opencode-ai/schema": "workspace:*", "@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
+7 -5
View File
@@ -80,14 +80,16 @@ yield *
Hooks run sequentially in registration order. Later hooks observe mutations made by earlier hooks. Hooks run sequentially in registration order. Later hooks observe mutations made by earlier hooks.
Session request context is mutable immediately before provider dispatch: Session context is mutable immediately before provider dispatch:
```ts ```ts
yield * yield *
ctx.session.hook("request", (event) => { ctx.session.hook("context", (event) =>
event.tools.read.description = "Read a file using narrow line ranges." Effect.sync(() => {
delete event.tools.write event.tools.read.description = "Read a file using narrow line ranges."
}) delete event.tools.write
}),
)
``` ```
## Reloading A Domain ## Reloading A Domain
+22 -1
View File
@@ -1,3 +1,24 @@
import type { SessionApi } from "@opencode-ai/client/effect/api" import type { SessionApi } from "@opencode-ai/client/effect/api"
import type { Message, SystemPart } from "@opencode-ai/ai"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model"
import type { Session } from "@opencode-ai/schema/session"
import type { JsonSchema } from "effect"
import type { Hooks } from "./registration.js"
export type SessionDomain = Pick<SessionApi<unknown>, "create" | "get" | "prompt" | "command" | "interrupt"> export interface SessionContext {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
system: Array<SystemPart>
messages: Array<Message>
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
}
export interface SessionHooks {
readonly context: SessionContext
}
export type SessionDomain = Pick<SessionApi<unknown>, "create" | "get" | "prompt" | "command" | "interrupt"> & {
readonly hook: Hooks<SessionHooks>
}
+3 -1
View File
@@ -103,7 +103,7 @@ export type DynamicOutput = {
* time (MCP servers, plugin manifests). Input is passed through as `unknown`; * time (MCP servers, plugin manifests). Input is passed through as `unknown`;
* `execute` returns the already-projected structured value and model content. * `execute` returns the already-projected structured value and model content.
*/ */
type DynamicConfig = { export type DynamicConfig = {
readonly description: string readonly description: string
readonly jsonSchema: JsonSchema.JsonSchema readonly jsonSchema: JsonSchema.JsonSchema
readonly outputSchema?: JsonSchema.JsonSchema readonly outputSchema?: JsonSchema.JsonSchema
@@ -284,6 +284,8 @@ export interface RegisterOptions {
export interface ToolDraft { export interface ToolDraft {
add(name: string, tool: AnyTool, options?: RegisterOptions): void add(name: string, tool: AnyTool, options?: RegisterOptions): void
/** Registers a dynamic Effect tool without importing the host's Tool module. */
addDynamic(name: string, config: DynamicConfig, options?: RegisterOptions): void
} }
export interface ToolHooks { export interface ToolHooks {
+2 -2
View File
@@ -85,10 +85,10 @@ await ctx.aisdk.hook("language", (event) => {
}) })
``` ```
Session request context is mutable immediately before provider dispatch: Session context is mutable immediately before provider dispatch:
```ts ```ts
await ctx.session.hook("request", (event) => { await ctx.session.hook("context", (event) => {
event.tools.read.description = "Read a file using narrow line ranges." event.tools.read.description = "Read a file using narrow line ranges."
delete event.tools.write delete event.tools.write
}) })
+22 -1
View File
@@ -1,3 +1,24 @@
import type { SessionApi } from "@opencode-ai/client/promise/api" import type { SessionApi } from "@opencode-ai/client/promise/api"
import type { Message, SystemPart } from "@opencode-ai/ai"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model"
import type { Session } from "@opencode-ai/schema/session"
import type { JsonSchema } from "effect"
import type { Hooks } from "./registration.js"
export type SessionDomain = Pick<SessionApi, "create" | "get" | "prompt" | "command" | "interrupt"> export interface SessionContext {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
system: Array<SystemPart>
messages: Array<Message>
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
}
export interface SessionHooks {
readonly context: SessionContext
}
export type SessionDomain = Pick<SessionApi, "create" | "get" | "prompt" | "command" | "interrupt"> & {
readonly hook: Hooks<SessionHooks>
}
@@ -1,3 +1,4 @@
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { Cause, Context, Effect, Fiber, FiberSet, Layer, PubSub, Queue, Ref, Schema, Semaphore, Stream } from "effect" import { Cause, Context, Effect, Fiber, FiberSet, Layer, PubSub, Queue, Ref, Schema, Semaphore, Stream } from "effect"
import { SimulationControlServer } from "../control-server" import { SimulationControlServer } from "../control-server"
import { SimulationProtocol } from "../protocol" import { SimulationProtocol } from "../protocol"
@@ -211,6 +212,15 @@ function handle(
request: SimulationProtocol.Backend.Request, request: SimulationProtocol.Backend.Request,
) { ) {
switch (request.method) { switch (request.method) {
case "simulation.handshake":
return SimulationProtocol.Handshake.dispatch(
{
role: "backend",
server: { name: "opencode", version: InstallationVersion },
capabilities: SimulationProtocol.Backend.Capabilities,
},
request.params,
)
case "llm.attach": case "llm.attach":
return controllerLock.withPermit( return controllerLock.withPermit(
Effect.gen(function* () { Effect.gen(function* () {
@@ -1,3 +1,4 @@
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { Effect } from "effect" import { Effect } from "effect"
import { SimulationControlServer } from "../control-server" import { SimulationControlServer } from "../control-server"
import { SimulationProtocol } from "../protocol" import { SimulationProtocol } from "../protocol"
@@ -6,6 +7,15 @@ import { SimulationRenderer } from "./renderer"
function handle(harness: Harness, request: SimulationProtocol.Frontend.Request) { function handle(harness: Harness, request: SimulationProtocol.Frontend.Request) {
switch (request.method) { switch (request.method) {
case "simulation.handshake":
return SimulationProtocol.Handshake.dispatch(
{
role: "ui",
server: { name: "opencode", version: InstallationVersion },
capabilities: SimulationProtocol.Frontend.Capabilities,
},
request.params,
)
case "ui.capture": case "ui.capture":
return SimulationActions.capture(harness) return SimulationActions.capture(harness)
case "ui.screenshot": case "ui.screenshot":
+140
View File
@@ -48,7 +48,136 @@ export namespace JsonRpc {
} }
} }
export namespace Handshake {
export const ProtocolVersion = Schema.Literal(1)
export type ProtocolVersion = Schema.Schema.Type<typeof ProtocolVersion>
export const Capability = Schema.NonEmptyString
export type Capability = Schema.Schema.Type<typeof Capability>
export const EndpointRole = Schema.Literals(["ui", "backend"])
export type EndpointRole = Schema.Schema.Type<typeof EndpointRole>
export const Identity = Schema.Struct({
name: Schema.NonEmptyString,
version: Schema.NonEmptyString,
})
export interface Identity extends Schema.Schema.Type<typeof Identity> {}
export const Params = Schema.Struct({
client: Identity,
expectedRole: EndpointRole,
offeredVersions: Schema.Array(
Schema.Int.check(Schema.isGreaterThan(0)),
).check(Schema.isMinLength(1), Schema.isUnique()),
requiredCapabilities: Schema.Array(Capability).check(Schema.isUnique()),
optionalCapabilities: Schema.Array(Capability).check(Schema.isUnique()),
})
export interface Params extends Schema.Schema.Type<typeof Params> {}
export const Response = Schema.Struct({
protocolVersion: ProtocolVersion,
role: EndpointRole,
server: Identity,
capabilities: Schema.Array(Capability),
})
export interface Response extends Schema.Schema.Type<typeof Response> {}
export const Request = Schema.Struct({
...JsonRpc.RequestFields,
method: Schema.Literal("simulation.handshake"),
params: Params,
})
export interface Request extends Schema.Schema.Type<typeof Request> {}
export interface DispatchAction {
readonly role: EndpointRole
readonly server: Identity
readonly capabilities: ReadonlyArray<Capability>
}
export class RoleMismatchError extends Schema.TaggedErrorClass<RoleMismatchError>()(
"SimulationHandshake.RoleMismatchError",
{
expected: EndpointRole,
actual: EndpointRole,
message: Schema.String,
},
) {}
export class UnsupportedProtocolError extends Schema.TaggedErrorClass<UnsupportedProtocolError>()(
"SimulationHandshake.UnsupportedProtocolError",
{
offered: Schema.Array(Schema.Number),
supported: Schema.Array(ProtocolVersion),
message: Schema.String,
},
) {}
export class MissingCapabilityError extends Schema.TaggedErrorClass<MissingCapabilityError>()(
"SimulationHandshake.MissingCapabilityError",
{
missing: Schema.Array(Capability),
message: Schema.String,
},
) {}
export function dispatch(action: DispatchAction, params: Params) {
return Effect.gen(function* () {
if (params.expectedRole !== action.role) {
return yield* Effect.fail(
new RoleMismatchError({
expected: params.expectedRole,
actual: action.role,
message: `Expected simulation endpoint role ${params.expectedRole}, received ${action.role}`,
}),
)
}
if (!params.offeredVersions.includes(1)) {
return yield* Effect.fail(
new UnsupportedProtocolError({
offered: params.offeredVersions,
supported: [1],
message: "No mutually supported simulation protocol version",
}),
)
}
const installed = new Set(action.capabilities)
const missing = params.requiredCapabilities.filter((capability) => !installed.has(capability))
if (missing.length > 0) {
return yield* Effect.fail(
new MissingCapabilityError({
missing,
message: `Simulation endpoint is missing required capabilities: ${missing.join(", ")}`,
}),
)
}
return {
protocolVersion: 1,
role: action.role,
server: action.server,
capabilities: Array.from(installed),
} satisfies Response
})
}
}
export namespace Frontend { export namespace Frontend {
export const Capabilities = [
"ui.type",
"ui.press",
"ui.enter",
"ui.arrow",
"ui.focus",
"ui.click",
"ui.resize",
"ui.matches",
"ui.screenshot",
"ui.state",
"ui.capture",
"ui.recording.finish",
] as const satisfies ReadonlyArray<Handshake.Capability>
export const KeyModifiers = Schema.Struct({ export const KeyModifiers = Schema.Struct({
ctrl: Schema.optional(Schema.Boolean), ctrl: Schema.optional(Schema.Boolean),
shift: Schema.optional(Schema.Boolean), shift: Schema.optional(Schema.Boolean),
@@ -149,6 +278,7 @@ export namespace Frontend {
export interface ResizeParams extends Schema.Schema.Type<typeof ResizeParams> {} export interface ResizeParams extends Schema.Schema.Type<typeof ResizeParams> {}
export const Request = Schema.Union([ export const Request = Schema.Union([
Handshake.Request,
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.type"), params: TypeParams }), Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.type"), params: TypeParams }),
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.press"), params: PressParams }), Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.press"), params: PressParams }),
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.arrow"), params: ArrowParams }), Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.arrow"), params: ArrowParams }),
@@ -173,6 +303,15 @@ export namespace Frontend {
} }
export namespace Backend { export namespace Backend {
export const Capabilities = [
"llm.attach",
"llm.chunk",
"llm.finish",
"llm.disconnect",
"llm.pending",
"llm.request",
] as const satisfies ReadonlyArray<Handshake.Capability>
export const Item = Schema.Union([ export const Item = Schema.Union([
Schema.Struct({ type: Schema.Literal("textDelta"), text: Schema.String }), Schema.Struct({ type: Schema.Literal("textDelta"), text: Schema.String }),
Schema.Struct({ type: Schema.Literal("reasoningDelta"), text: Schema.String }), Schema.Struct({ type: Schema.Literal("reasoningDelta"), text: Schema.String }),
@@ -203,6 +342,7 @@ export namespace Backend {
export interface DisconnectParams extends Schema.Schema.Type<typeof DisconnectParams> {} export interface DisconnectParams extends Schema.Schema.Type<typeof DisconnectParams> {}
export const Request = Schema.Union([ export const Request = Schema.Union([
Handshake.Request,
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("llm.chunk"), params: ChunkParams }), Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("llm.chunk"), params: ChunkParams }),
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("llm.finish"), params: FinishParams }), Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("llm.finish"), params: FinishParams }),
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("llm.disconnect"), params: DisconnectParams }), Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("llm.disconnect"), params: DisconnectParams }),
@@ -19,6 +19,30 @@ test("scopes the frontend control server and reports malformed JSON", async () =
Queue.offerUnsafe(messages, JSON.parse(String(event.data))) Queue.offerUnsafe(messages, JSON.parse(String(event.data)))
}) })
socket.send(
JSON.stringify({
jsonrpc: "2.0",
id: 0,
method: "simulation.handshake",
params: {
client: { name: "test", version: "test" },
expectedRole: "ui",
offeredVersions: [1],
requiredCapabilities: ["ui.state"],
optionalCapabilities: [],
},
}),
)
expect(yield* Queue.take(messages)).toMatchObject({
id: 0,
result: {
protocolVersion: 1,
role: "ui",
server: { name: "opencode", version: expect.any(String) },
capabilities: expect.arrayContaining(["ui.state", "ui.capture"]),
},
})
socket.send(JSON.stringify({ jsonrpc: "2.0", id: 1, method: "ui.state" })) socket.send(JSON.stringify({ jsonrpc: "2.0", id: 1, method: "ui.state" }))
expect(yield* Queue.take(messages)).toMatchObject({ expect(yield* Queue.take(messages)).toMatchObject({
id: 1, id: 1,
+104 -2
View File
@@ -1,5 +1,6 @@
import { expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { Frontend } from "../src/protocol" import { Effect } from "effect"
import { Backend, Frontend, Handshake } from "../src/protocol"
test("decodes ui.matches text params", () => { test("decodes ui.matches text params", () => {
expect( expect(
@@ -19,3 +20,104 @@ test("decodes ui.matches text params", () => {
}), }),
).toThrow() ).toThrow()
}) })
const params: Handshake.Params = {
client: { name: "opencode-drive", version: "test" },
expectedRole: "ui",
offeredVersions: [1],
requiredCapabilities: ["ui.state"],
optionalCapabilities: ["ui.capture", "future.capability"],
}
const ui: Handshake.DispatchAction = {
role: "ui",
server: { name: "opencode", version: "test" },
capabilities: Frontend.Capabilities,
}
describe("simulation.handshake", () => {
test("decodes through both endpoint request protocols", () => {
const request = {
jsonrpc: "2.0" as const,
id: 1,
method: "simulation.handshake" as const,
params,
}
expect(Frontend.decodeRequest(request)).toEqual(request)
expect(Backend.decodeRequest({ ...request, params: { ...params, expectedRole: "backend" } })).toMatchObject({
method: "simulation.handshake",
params: { expectedRole: "backend" },
})
})
test("rejects invalid version and capability declarations", () => {
const request = {
jsonrpc: "2.0" as const,
id: 1,
method: "simulation.handshake" as const,
params,
}
expect(() =>
Frontend.decodeRequest({
...request,
params: { ...params, offeredVersions: [] },
}),
).toThrow()
expect(() =>
Frontend.decodeRequest({
...request,
params: { ...params, requiredCapabilities: ["ui.state", "ui.state"] },
}),
).toThrow()
expect(() =>
Frontend.decodeRequest({
...request,
params: { ...params, optionalCapabilities: [""] },
}),
).toThrow()
})
test("selects the protocol and advertises only installed capabilities", async () => {
await expect(Effect.runPromise(Handshake.dispatch(ui, params))).resolves.toEqual({
protocolVersion: 1,
role: "ui",
server: { name: "opencode", version: "test" },
capabilities: [...Frontend.Capabilities],
})
})
test("rejects a role mismatch", async () => {
await expect(
Effect.runPromise(Handshake.dispatch(ui, { ...params, expectedRole: "backend" })),
).rejects.toMatchObject({ _tag: "SimulationHandshake.RoleMismatchError", expected: "backend", actual: "ui" })
})
test("rejects unsupported protocol versions", async () => {
await expect(Effect.runPromise(Handshake.dispatch(ui, { ...params, offeredVersions: [2] }))).rejects.toMatchObject({
_tag: "SimulationHandshake.UnsupportedProtocolError",
offered: [2],
supported: [1],
})
})
test("rejects a missing required capability but ignores missing optional capabilities", async () => {
await expect(
Effect.runPromise(
Handshake.dispatch(ui, {
...params,
requiredCapabilities: ["ui.state", "ui.future"],
}),
),
).rejects.toMatchObject({ _tag: "SimulationHandshake.MissingCapabilityError", missing: ["ui.future"] })
await expect(
Effect.runPromise(
Handshake.dispatch(ui, {
...params,
requiredCapabilities: [],
optionalCapabilities: ["ui.future"],
}),
),
).resolves.toMatchObject({ capabilities: Frontend.Capabilities })
})
})
@@ -7,6 +7,30 @@ import { availableEndpoint, connect } from "./fixture/websocket"
test("streams a Drive-controlled provider response and removes the finished invocation", async () => { test("streams a Drive-controlled provider response and removes the finished invocation", async () => {
await runProvider((provider, socket, messages) => await runProvider((provider, socket, messages) =>
Effect.gen(function* () { Effect.gen(function* () {
socket.send(
JSON.stringify({
jsonrpc: "2.0",
id: 0,
method: "simulation.handshake",
params: {
client: { name: "test", version: "test" },
expectedRole: "backend",
offeredVersions: [1],
requiredCapabilities: ["llm.attach", "llm.request"],
optionalCapabilities: [],
},
}),
)
expect(yield* Queue.take(messages)).toMatchObject({
id: 0,
result: {
protocolVersion: 1,
role: "backend",
server: { name: "opencode", version: expect.any(String) },
capabilities: expect.arrayContaining(["llm.attach", "llm.request"]),
},
})
socket.send("{") socket.send("{")
expect(yield* Queue.take(messages)).toMatchObject({ id: null, error: { code: -32000 } }) expect(yield* Queue.take(messages)).toMatchObject({ id: null, error: { code: -32000 } })
yield* attach(socket, messages) yield* attach(socket, messages)