mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 17:08:21 -04:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7832a677fb | |||
| 5e2e0d6965 | |||
| f92d84746b | |||
| f2f5eb6f16 |
@@ -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:*",
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}),
|
||||||
|
)
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}),
|
||||||
|
)
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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")])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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")),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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:*",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user