feat(simulation): control arbitrary tool lifecycles (#37816)

This commit is contained in:
Kit Langton 2026-07-19 17:40:30 -04:00 committed by GitHub
commit 6d32bc9cb0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1567 additions and 80 deletions

View file

@ -33,6 +33,35 @@ test("encodes every simulated provider event as OpenAI SSE", async () => {
)
})
test("encodes provider-neutral partial tool input as OpenAI deltas", async () => {
const provider: SimulatedProvider.Interface = {
stream: () =>
Stream.make(
{ type: "toolInputStart", index: 0, id: "call_lookup", name: "lookup" },
{ type: "toolInputDelta", index: 0, text: '{"query":' },
{ type: "toolInputDelta", index: 0, text: '"OpenCode"}' },
{ type: "finish", reason: "tool-calls" },
),
}
const url = new URL(DEFAULT_BASE_URL + PATH)
const request = HttpClientRequest.post(url).pipe(HttpClientRequest.bodyJsonUnsafe({ model: "gpt-5" }))
const matched = SimulationOpenAI.route(provider).match(request, url)
if (!matched) throw new Error("The simulated OpenAI route did not match")
const body = await Effect.runPromise(matched.pipe(Effect.flatMap((response) => response.text)))
expect(body).toBe(
[
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_lookup","function":{"name":"lookup","arguments":""}}]}}]}',
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"query\\":"}}]}}]}',
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\\"OpenCode\\"}"}}]}}]}',
'data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}',
"data: [DONE]",
"",
].join("\n\n"),
)
})
test("rejects malformed intercepted OpenAI JSON as an HTTP client error", async () => {
const provider: SimulatedProvider.Interface = { stream: () => Stream.empty }
const url = new URL(DEFAULT_BASE_URL + PATH)

View file

@ -97,6 +97,96 @@ test("decodes semantic UI snapshots", () => {
expect(() => decode({ format: "opencode-ui-snapshot-v1", nodes })).toThrow()
})
test("decodes the simulated tool lifecycle", () => {
const registration = {
name: "lookup",
description: "Look up a value",
inputSchema: {
type: "object",
properties: { query: { type: "string" } },
required: ["query"],
additionalProperties: false,
},
outputSchema: { type: "object" },
permission: "lookup",
options: { codemode: false },
}
expect(
Backend.decodeRequest({
jsonrpc: "2.0",
id: 1,
method: "tool.attach",
params: { tools: [registration] },
}),
).toMatchObject({ method: "tool.attach", params: { tools: [registration] } })
expect(
Backend.decodeRequest({
jsonrpc: "2.0",
id: 2,
method: "tool.update",
params: {
id: "tool_1",
sequence: 0,
update: { structured: { phase: "searching" }, content: [{ type: "text", text: "Searching" }] },
},
}),
).toMatchObject({ method: "tool.update" })
expect(
Backend.decodeRequest({
jsonrpc: "2.0",
id: 3,
method: "tool.finish",
params: {
id: "tool_1",
output: { structured: { answer: 42 }, content: [{ type: "text", text: "42" }] },
},
}),
).toMatchObject({ method: "tool.finish" })
expect(
Backend.decodeRequest({
jsonrpc: "2.0",
id: 4,
method: "tool.fail",
params: { id: "tool_2", message: "lookup failed" },
}),
).toMatchObject({ method: "tool.fail" })
expect(() =>
Backend.decodeRequest({
jsonrpc: "2.0",
id: 5,
method: "tool.attach",
params: { tools: [registration, registration] },
}),
).toThrow()
for (const invalid of [
{ ...registration, name: "1lookup" },
{ ...registration, options: { namespace: "bad group", codemode: false } },
{ ...registration, name: "execute", options: { codemode: false } },
{ ...registration, options: { namespace: "a".repeat(64), codemode: false } },
])
expect(() =>
Backend.decodeRequest({
jsonrpc: "2.0",
id: 6,
method: "tool.attach",
params: { tools: [invalid] },
}),
).toThrow()
expect(() =>
Backend.decodeRequest({
jsonrpc: "2.0",
id: 7,
method: "tool.attach",
params: {
tools: [
{ ...registration, name: "b_c", options: { namespace: "a", codemode: false } },
{ ...registration, name: "c", options: { namespace: "a.b", codemode: false } },
],
},
}),
).toThrow()
})
const params: Handshake.Params = {
client: { name: "opencode-drive", version: "test" },
expectedRole: "ui",

View file

@ -1,5 +1,24 @@
import { expect, test } from "bun:test"
import { Deferred, Effect, Fiber, Queue, Stream } from "effect"
import { mkdir, mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Config } from "@opencode-ai/core/config"
import { Database } from "@opencode-ai/core/database/database"
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { Plugin } from "@opencode-ai/plugin/v2/effect"
import { Deferred, Effect, Fiber, Layer, Queue, Stream } from "effect"
import type { Scope } from "effect/Scope"
import { SimulatedProvider } from "../src/backend/simulated-provider"
import { availableEndpoint, connect } from "./fixture/websocket"
@ -125,7 +144,7 @@ test("replaces the previous attached controller", async () => {
)
expect(yield* Queue.take(secondMessages)).toMatchObject({ id: 2, result: { ok: true } })
expect(Array.from(yield* Fiber.join(response))).toEqual([{ type: "finish", reason: "stop" }])
}).pipe(Effect.provide(SimulatedProvider.layerDrive({ endpoint })), Effect.scoped),
}).pipe(Effect.provide(providerLayer(endpoint)), Effect.scoped),
)
})
@ -194,6 +213,454 @@ test("fails the provider stream when Drive disconnects the invocation", async ()
)
})
test("controls arbitrary tools through scoped SDK overlays", async () => {
const endpoint = availableEndpoint()
const directory = await mkdtemp(join(tmpdir(), "opencode-simulated-tools-"))
const secondDirectory = join(directory, "second")
await mkdir(secondDirectory)
try {
await Effect.runPromise(
Effect.gen(function* () {
const socket = yield* connect(endpoint)
const messages = yield* messagesFrom(socket)
const plugins = yield* SdkPlugins.Service
let activations = 0
yield* plugins.register(
Plugin.define({
id: "opencode.simulation.test.activation-count",
effect: () => Effect.sync(() => void activations++),
}),
)
const registration = {
name: "lookup",
description: "Look up a value",
inputSchema: {
type: "object",
properties: { query: { type: "string" } },
required: ["query"],
additionalProperties: false,
},
outputSchema: { type: "object" },
options: { codemode: false },
}
const locations = yield* LocationServiceMap.Service
const [primary, secondary] = yield* Effect.all([
Layer.build(locations.get(Location.Ref.make({ directory: AbsolutePath.make(directory) }))),
Layer.build(locations.get(Location.Ref.make({ directory: AbsolutePath.make(secondDirectory) }))),
])
yield* Effect.forEach([primary, secondary], (context) =>
PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(Effect.provide(context)),
)
expect(activations).toBe(2)
yield* Effect.gen(function* () {
socket.send(
JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "tool.attach",
params: { tools: [registration] },
}),
)
expect(yield* Queue.take(messages)).toMatchObject({ id: 1, result: { attached: true } })
const registry = yield* ToolRegistry.Service
const materialized = yield* registry.materialize()
expect(materialized.definitions).toContainEqual(
expect.objectContaining({ name: "lookup", description: "Look up a value" }),
)
const secondaryMaterialized = yield* ToolRegistry.Service.use((secondaryRegistry) =>
secondaryRegistry.materialize(),
).pipe(Effect.provide(secondary))
expect(secondaryMaterialized.definitions).toContainEqual(
expect.objectContaining({ name: "lookup", description: "Look up a value" }),
)
const progress: ToolRegistry.Progress[] = []
const settle = (callID: string, query: string) =>
materialized.settle({
sessionID: SessionV2.ID.make("ses_simulated_tools"),
agent: AgentV2.ID.make("build"),
messageID: SessionMessage.ID.make("msg_simulated_tools"),
progress: (update) => Effect.sync(() => progress.push(update)),
call: {
type: "tool-call",
id: callID,
name: "lookup",
input: { query },
},
})
const successful = yield* settle("call_success", "answer").pipe(Effect.forkScoped)
const successInvocation = yield* takeToolInvocation(messages)
expect(successInvocation.params).toMatchObject({
name: "lookup",
input: { query: "answer" },
context: {
sessionID: "ses_simulated_tools",
agent: "build",
messageID: "msg_simulated_tools",
callID: "call_success",
},
})
const successID = requireString(requireRecord(successInvocation.params).id)
const update = JSON.stringify({
jsonrpc: "2.0",
id: 20,
method: "tool.update",
params: {
id: successID,
sequence: 0,
update: {
structured: { phase: "searching" },
content: [{ type: "text", text: "Searching" }],
},
},
})
socket.send(update)
expect(yield* Queue.take(messages)).toMatchObject({ id: 20, result: { ok: true } })
socket.send(update)
expect(yield* Queue.take(messages)).toMatchObject({ id: 20, result: { ok: true } })
socket.send(
JSON.stringify({
...JSON.parse(update),
id: 21,
}),
)
expect(yield* Queue.take(messages)).toMatchObject({ id: 21, result: { ok: true } })
socket.send(
JSON.stringify({
jsonrpc: "2.0",
id: 20,
method: "tool.update",
params: {
id: successID,
sequence: 0,
update: { structured: { phase: "different" } },
},
}),
)
expect(yield* Queue.take(messages)).toMatchObject({
id: 20,
error: { message: expect.stringContaining("reused with different progress") },
})
socket.send(
JSON.stringify({
jsonrpc: "2.0",
id: 22,
method: "tool.update",
params: {
id: successID,
sequence: 2,
update: { structured: { phase: "skipped" } },
},
}),
)
expect(yield* Queue.take(messages)).toMatchObject({
id: 22,
error: { message: expect.stringContaining("Expected simulated tool update sequence 1") },
})
socket.send(
JSON.stringify({
jsonrpc: "2.0",
id: 3,
method: "tool.finish",
params: {
id: successID,
output: {
structured: { answer: 42 },
content: [{ type: "text", text: "42" }],
},
},
}),
)
expect(yield* Queue.take(messages)).toMatchObject({ id: 3, result: { ok: true } })
socket.send(
JSON.stringify({
jsonrpc: "2.0",
id: 23,
method: "tool.finish",
params: {
id: successID,
output: {
structured: { answer: 42 },
content: [{ type: "text", text: "42" }],
},
},
}),
)
expect(yield* Queue.take(messages)).toMatchObject({ id: 23, result: { ok: true } })
expect(yield* Fiber.join(successful)).toMatchObject({
result: { type: "text", value: "42" },
output: {
structured: { answer: 42 },
content: [{ type: "text", text: "42" }],
},
})
expect(progress).toEqual([
{
structured: { phase: "searching" },
content: [{ type: "text", text: "Searching" }],
},
])
const failed = yield* settle("call_failure", "missing").pipe(Effect.forkScoped)
const failedInvocation = yield* takeToolInvocation(messages)
const failedID = requireString(requireRecord(failedInvocation.params).id)
socket.send(
JSON.stringify({
jsonrpc: "2.0",
id: 4,
method: "tool.fail",
params: { id: failedID, message: "lookup failed" },
}),
)
expect(yield* Queue.take(messages)).toMatchObject({ id: 4, result: { ok: true } })
expect(yield* Fiber.join(failed)).toMatchObject({
result: { type: "error", value: "lookup failed" },
})
const concurrent = [
yield* settle("call_first", "first").pipe(Effect.forkScoped),
yield* settle("call_second", "second").pipe(Effect.forkScoped),
]
const invocations = [yield* takeToolInvocation(messages), yield* takeToolInvocation(messages)]
const byCall = new Map(
invocations.map((invocation) => {
const params = requireRecord(invocation.params)
const context = requireRecord(params.context)
return [requireString(context.callID), requireString(params.id)]
}),
)
for (const [id, callID, value] of [
[5, "call_second", "second result"],
[6, "call_first", "first result"],
] as const) {
socket.send(
JSON.stringify({
jsonrpc: "2.0",
id,
method: "tool.finish",
params: {
id: byCall.get(callID),
output: { structured: value, content: [{ type: "text", text: value }] },
},
}),
)
expect(yield* Queue.take(messages)).toMatchObject({ id, result: { ok: true } })
}
expect((yield* Fiber.join(concurrent[0])).result).toEqual({ type: "text", value: "first result" })
expect((yield* Fiber.join(concurrent[1])).result).toEqual({ type: "text", value: "second result" })
const cancelled = yield* settle("call_cancelled", "slow").pipe(Effect.forkScoped)
const cancelledInvocation = yield* takeToolInvocation(messages)
const cancelledID = requireString(requireRecord(cancelledInvocation.params).id)
yield* Fiber.interrupt(cancelled)
expect(yield* Queue.take(messages)).toMatchObject({
method: "tool.cancel",
params: { id: cancelledID, reason: "interrupted" },
})
socket.send(
JSON.stringify({
jsonrpc: "2.0",
id: 7,
method: "tool.finish",
params: {
id: cancelledID,
output: { structured: null, content: [] },
},
}),
)
expect(yield* Queue.take(messages)).toMatchObject({
id: 7,
error: { message: expect.stringContaining("not found or already finished") },
})
const replayed = yield* settle("call_replayed", "reconnect").pipe(Effect.forkScoped)
const original = yield* takeToolInvocation(messages)
const originalID = requireString(requireRecord(original.params).id)
const replayedProgress = {
structured: { phase: "before-reconnect" },
content: [{ type: "text", text: "Still running" }],
}
socket.send(
JSON.stringify({
jsonrpc: "2.0",
id: 25,
method: "tool.update",
params: { id: originalID, sequence: 0, update: replayedProgress },
}),
)
expect(yield* Queue.take(messages)).toMatchObject({ id: 25, result: { ok: true } })
const replacement = yield* connect(endpoint)
const replacementMessages = yield* messagesFrom(replacement)
replacement.send(
JSON.stringify({
jsonrpc: "2.0",
id: 8,
method: "tool.attach",
params: { tools: [registration] },
}),
)
expect(yield* Queue.take(replacementMessages)).toMatchObject({
id: 8,
error: { message: expect.stringContaining("already attached") },
})
yield* closeSocket(socket)
const disconnected = yield* registry.materialize()
expect(disconnected.definitions).toContainEqual(expect.objectContaining({ name: "lookup" }))
replacement.send(
JSON.stringify({
jsonrpc: "2.0",
id: 90,
method: "tool.attach",
params: { tools: [{ ...registration, name: "replacement" }] },
}),
)
expect(yield* Queue.take(replacementMessages)).toMatchObject({
id: 90,
error: { message: expect.stringContaining("must settle pending invocations") },
})
replacement.send(
JSON.stringify({
jsonrpc: "2.0",
id: 9,
method: "tool.attach",
params: { tools: [registration] },
}),
)
const attached = [
requireRecord(yield* Queue.take(replacementMessages)),
requireRecord(yield* Queue.take(replacementMessages)),
]
expect(attached).toContainEqual(expect.objectContaining({ id: 9, result: { attached: true } }))
expect(attached).toContainEqual(
expect.objectContaining({
method: "tool.invocation",
params: expect.objectContaining({ id: originalID }),
}),
)
replacement.send(
JSON.stringify({
jsonrpc: "2.0",
id: 26,
method: "tool.update",
params: { id: originalID, sequence: 0, update: replayedProgress },
}),
)
expect(yield* Queue.take(replacementMessages)).toMatchObject({ id: 26, result: { ok: true } })
expect(progress.filter((update) => update.structured.phase === "before-reconnect")).toHaveLength(1)
replacement.send(
JSON.stringify({
jsonrpc: "2.0",
id: 10,
method: "tool.finish",
params: {
id: originalID,
output: {
structured: "replayed result",
content: [{ type: "text", text: "replayed result" }],
},
},
}),
)
expect(yield* Queue.take(replacementMessages)).toMatchObject({ id: 10, result: { ok: true } })
expect((yield* Fiber.join(replayed)).result).toEqual({
type: "text",
value: "replayed result",
})
const preserved = yield* settle("call_preserved", "same generation").pipe(Effect.forkScoped)
const preservedInvocation = yield* takeToolInvocation(replacementMessages)
const preservedID = requireString(requireRecord(preservedInvocation.params).id)
replacement.send(
JSON.stringify({
jsonrpc: "2.0",
id: 27,
method: "tool.finish",
params: {
id: preservedID,
output: { structured: "preserved", content: [{ type: "text", text: "preserved" }] },
},
}),
)
expect(yield* Queue.take(replacementMessages)).toMatchObject({ id: 27, result: { ok: true } })
expect((yield* Fiber.join(preserved)).result).toEqual({ type: "text", value: "preserved" })
const namespaced = [
{ ...registration, name: "search", options: { namespace: "github", codemode: false } },
{ ...registration, name: "search", options: { namespace: "web", codemode: false } },
]
replacement.send(
JSON.stringify({
jsonrpc: "2.0",
id: 11,
method: "tool.attach",
params: { tools: namespaced },
}),
)
expect(yield* Queue.take(replacementMessages)).toMatchObject({ id: 11, result: { attached: true } })
const replaced = yield* registry.materialize()
const replacedNames = replaced.definitions.map((definition) => definition.name)
expect(replacedNames).toEqual(expect.arrayContaining(["github_search", "web_search"]))
expect(replacedNames).not.toContain("lookup")
const secondaryReplaced = yield* ToolRegistry.Service.use((secondaryRegistry) =>
secondaryRegistry.materialize(),
).pipe(Effect.provide(secondary))
const secondaryNames = secondaryReplaced.definitions.map((definition) => definition.name)
expect(secondaryNames).toEqual(expect.arrayContaining(["github_search", "web_search"]))
expect(secondaryNames).not.toContain("lookup")
const routed = yield* replaced
.settle({
sessionID: SessionV2.ID.make("ses_simulated_tools"),
agent: AgentV2.ID.make("build"),
messageID: SessionMessage.ID.make("msg_simulated_tools"),
call: {
type: "tool-call",
id: "call_namespaced",
name: "github_search",
input: { query: "routing" },
},
})
.pipe(Effect.forkScoped)
const routedInvocation = yield* takeToolInvocation(replacementMessages)
expect(routedInvocation.params).toMatchObject({ name: "github_search" })
const routedID = requireString(requireRecord(routedInvocation.params).id)
replacement.send(
JSON.stringify({
jsonrpc: "2.0",
id: 12,
method: "tool.finish",
params: {
id: routedID,
output: { structured: "routed", content: [{ type: "text", text: "routed" }] },
},
}),
)
expect(yield* Queue.take(replacementMessages)).toMatchObject({ id: 12, result: { ok: true } })
expect((yield* Fiber.join(routed)).result).toEqual({ type: "text", value: "routed" })
expect(
yield* materialized.settle({
sessionID: SessionV2.ID.make("ses_simulated_tools"),
agent: AgentV2.ID.make("build"),
messageID: SessionMessage.ID.make("msg_simulated_tools"),
call: {
type: "tool-call",
id: "call_stale",
name: "lookup",
input: { query: "stale" },
},
}),
).toMatchObject({
result: { type: "error", value: expect.stringContaining("no longer active") },
})
expect(activations).toBe(2)
}).pipe(Effect.provide(primary))
}).pipe(Effect.provide(toolLifecycleLayer(endpoint)), Effect.scoped),
)
} finally {
await rm(directory, { recursive: true, force: true })
}
}, 15_000)
const request: SimulatedProvider.ProviderRequest = {
url: "https://api.openai.com/v1/chat/completions",
body: { model: "gpt-5", messages: [{ role: "user", content: "Hello" }] },
@ -213,7 +680,26 @@ function runProvider<E>(
const socket = yield* connect(endpoint)
const messages = yield* messagesFrom(socket)
yield* body(provider, socket, messages)
}).pipe(Effect.provide(SimulatedProvider.layerDrive({ endpoint })), Effect.scoped),
}).pipe(Effect.provide(providerLayer(endpoint)), Effect.scoped),
)
}
const providerLayer = (endpoint: string) =>
SimulatedProvider.layerDrive({ endpoint }).pipe(
Layer.provide(
Layer.succeed(SdkPlugins.Service, SdkPlugins.Service.of({ register: () => Effect.void, all: () => [] })),
),
)
const toolLifecycleLayer = (endpoint: string) => {
const provider = makeGlobalNode({
service: SimulatedProvider.Service,
layer: SimulatedProvider.layerDrive({ endpoint }),
deps: [SdkPlugins.node],
})
return AppNodeBuilder.build(
LayerNode.group([Database.node, EventV2.node, SdkPlugins.node, LocationServiceMap.node, provider]),
[[Config.node, Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))]],
)
}
@ -227,6 +713,19 @@ function messagesFrom(socket: WebSocket) {
})
}
function closeSocket(socket: WebSocket) {
return Effect.callback<void>((resume) => {
if (socket.readyState === WebSocket.CLOSED) {
resume(Effect.void)
return Effect.void
}
const closed = () => resume(Effect.void)
socket.addEventListener("close", closed, { once: true })
socket.close()
return Effect.sync(() => socket.removeEventListener("close", closed))
})
}
function attach(socket: WebSocket, messages: Queue.Queue<unknown>) {
return Effect.gen(function* () {
socket.send(JSON.stringify({ jsonrpc: "2.0", id: 1, method: "llm.attach" }))
@ -244,6 +743,21 @@ function takeInvocation(messages: Queue.Queue<unknown>) {
)
}
function takeToolInvocation(messages: Queue.Queue<unknown>) {
return Queue.take(messages).pipe(
Effect.map((message) => {
const opened = requireRecord(message)
if (opened.method !== "tool.invocation") throw new Error("Expected a tool.invocation notification")
return opened
}),
)
}
function requireString(value: unknown) {
if (typeof value !== "string") throw new Error("Expected a string")
return value
}
function requireRecord(value: unknown): Record<string, unknown> {
if (!isRecord(value)) throw new Error("Expected an object")
return value