Compare commits

..
Author SHA1 Message Date
Kit Langton 2e8c67f2be test(core): construct valid tool contexts 2026-08-27 15:18:55 -04:00
4 changed files with 40 additions and 61 deletions
+2 -2
View File
@@ -5,7 +5,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { Location } from "@opencode-ai/core/location"
import { Pty } from "@opencode-ai/core/pty"
import type { PtyID } from "@opencode-ai/core/pty/schema"
import { PtyID } from "@opencode-ai/core/pty/schema"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { ShellSelect } from "@opencode-ai/core/shell/select"
import { location } from "../fixture/location"
@@ -88,7 +88,7 @@ describe("pty", () => {
it.live("returns typed not found errors for missing sessions", () =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const id = "pty_missing" as PtyID
const id = PtyID.make("pty_missing")
for (const result of [
yield* pty.get(id).pipe(Effect.asVoid, Effect.exit),
+26 -21
View File
@@ -1,10 +1,21 @@
import { expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { z } from "zod"
import { Agent } from "@opencode-ai/schema/agent"
import { Session } from "@opencode-ai/schema/session"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import type { Info } from "@opencode-ai/schema/tool"
import { Tool } from "../src/tool"
import { definition, execute } from "../src/tool/runtime"
const context = {
sessionID: Session.ID.make("ses_tool_schema"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_tool_schema"),
id: Tool.CallID.make("call_tool_schema"),
progress: () => Effect.void,
} satisfies Tool.Context
test("tools are structural values", async () => {
const config = {
name: "foreign",
@@ -136,7 +147,7 @@ test("portable schemas validate and describe typed tools", async () => {
inputSchema: { type: "object", properties: { count: { type: "string" } } },
outputSchema: { type: "string" },
})
const result = await Effect.runPromise(execute(tool, { count: "41" }, {} as Tool.Context))
const result = await Effect.runPromise(execute(tool, { count: "41" }, context))
expect(result.output).toBe("42")
})
@@ -166,10 +177,10 @@ test("Zod schemas validate, transform, and describe typed tools", async () => {
additionalProperties: false,
},
})
expect(await Effect.runPromise(execute(tool, { count: "41" }, {} as Tool.Context))).toMatchObject({
expect(await Effect.runPromise(execute(tool, { count: "41" }, context))).toMatchObject({
output: { count: 42 },
})
expect(await Effect.runPromise(Effect.flip(execute(tool, { count: 41 }, {} as Tool.Context)))).toEqual(
expect(await Effect.runPromise(Effect.flip(execute(tool, { count: 41 }, context)))).toEqual(
new Tool.Error({
message:
'Invalid arguments for tool "zod":\n- count: Invalid input: expected string, received number\n\nArguments provided:\n{\n "count": 41\n}\n\nUpdate the arguments and call the tool again.',
@@ -205,7 +216,7 @@ test("portable schema failures become tool failures", async () => {
execute: () => Effect.succeed({ content: "unused" }),
},
1,
{} as Tool.Context,
context,
),
),
)
@@ -228,9 +239,7 @@ test("Effect schema failures use normalized input issues", async () => {
execute: () => Effect.succeed({ content: "unused" }),
}
expect(
await Effect.runPromise(Effect.flip(execute(tool, { value: 1, nested: { count: 0 } }, {} as Tool.Context))),
).toEqual(
expect(await Effect.runPromise(Effect.flip(execute(tool, { value: 1, nested: { count: 0 } }, context)))).toEqual(
new Tool.Error({
message:
'Invalid arguments for tool "effect":\n- value: Expected string\n- nested.count: Expected a value greater than or equal to 1\n\nArguments provided:\n{\n "value": 1,\n "nested": {\n "count": 0\n }\n}\n\nUpdate the arguments and call the tool again.',
@@ -259,7 +268,7 @@ test("input error prompts limit normalized issues", async () => {
execute: () => Effect.succeed({ content: "unused" }),
}
expect(await Effect.runPromise(Effect.flip(execute(tool, {}, {} as Tool.Context)))).toEqual(
expect(await Effect.runPromise(Effect.flip(execute(tool, {}, context)))).toEqual(
new Tool.Error({
message:
'Invalid arguments for tool "limited":\n- root: issue 1\n- root: issue 2\n- root: issue 3\n- root: issue 4\n- root: issue 5\n- ...and 1 more issue\n\nArguments provided:\n{}\n\nUpdate the arguments and call the tool again.',
@@ -278,7 +287,7 @@ test("canonical results carry metadata with typed output", async () => {
execute: ({ value }) => Effect.succeed({ output: { value, internal: true }, metadata: { value }, content: value }),
}
expect(await Effect.runPromise(tool.execute({ value: "out" }, {} as Tool.Context))).toEqual({
expect(await Effect.runPromise(tool.execute({ value: "out" }, context))).toEqual({
output: { value: "out", internal: true },
metadata: { value: "out" },
content: "out",
@@ -312,33 +321,29 @@ test("raw JSON schemas validate and decode tool input", async () => {
description: "Raw tool",
inputSchema: input,
})
expect(await Effect.runPromise(execute(tool, { value: "ok", extra: true }, {} as Tool.Context))).toEqual({
expect(await Effect.runPromise(execute(tool, { value: "ok", extra: true }, context))).toEqual({
output: undefined,
content: [{ type: "text", text: '{"value":"ok"}' }],
})
expect(await Effect.runPromise(Effect.flip(execute(tool, { value: 1 }, {} as Tool.Context)))).toEqual(
expect(await Effect.runPromise(Effect.flip(execute(tool, { value: 1 }, context)))).toEqual(
new Tool.Error({
message:
'Invalid arguments for tool "raw":\n- value: Expected string\n\nArguments provided:\n{\n "value": 1\n}\n\nUpdate the arguments and call the tool again.',
}),
)
expect(await Effect.runPromise(Effect.flip(execute(tool, {}, {} as Tool.Context)))).toEqual(
expect(await Effect.runPromise(Effect.flip(execute(tool, {}, context)))).toEqual(
new Tool.Error({
message:
'Invalid arguments for tool "raw":\n- value: Missing key\n\nArguments provided:\n{}\n\nUpdate the arguments and call the tool again.',
}),
)
expect(
await Effect.runPromise(Effect.flip(execute(tool, { value: "ok", nested: { count: 0 } }, {} as Tool.Context))),
).toEqual(
expect(await Effect.runPromise(Effect.flip(execute(tool, { value: "ok", nested: { count: 0 } }, context)))).toEqual(
new Tool.Error({
message:
'Invalid arguments for tool "raw":\n- nested.count: Expected a value greater than or equal to 1\n\nArguments provided:\n{\n "value": "ok",\n "nested": {\n "count": 0\n }\n}\n\nUpdate the arguments and call the tool again.',
}),
)
expect(
await Effect.runPromise(Effect.flip(execute(tool, { value: 1, nested: { count: 0 } }, {} as Tool.Context))),
).toEqual(
expect(await Effect.runPromise(Effect.flip(execute(tool, { value: 1, nested: { count: 0 } }, context)))).toEqual(
new Tool.Error({
message:
'Invalid arguments for tool "raw":\n- value: Expected string\n- nested.count: Expected a value greater than or equal to 1\n\nArguments provided:\n{\n "value": 1,\n "nested": {\n "count": 0\n }\n}\n\nUpdate the arguments and call the tool again.',
@@ -359,10 +364,10 @@ test("raw JSON schemas resolve draft-07 definitions", async () => {
execute: (input) => Effect.succeed({ content: JSON.stringify(input) }),
}
expect(await Effect.runPromise(execute(tool, { value: "ok" }, {} as Tool.Context))).toMatchObject({
expect(await Effect.runPromise(execute(tool, { value: "ok" }, context))).toMatchObject({
content: [{ type: "text", text: '{"value":"ok"}' }],
})
expect(await Effect.runPromise(Effect.flip(execute(tool, { value: 1 }, {} as Tool.Context)))).toEqual(
expect(await Effect.runPromise(Effect.flip(execute(tool, { value: 1 }, context)))).toEqual(
new Tool.Error({
message:
'Invalid arguments for tool "draft-07":\n- value: Expected value\n\nArguments provided:\n{\n "value": 1\n}\n\nUpdate the arguments and call the tool again.',
@@ -381,7 +386,7 @@ test("raw JSON schemas pass input through when they cannot be imported", async (
execute: (input) => Effect.succeed({ content: JSON.stringify(input) }),
}
expect(await Effect.runPromise(execute(tool, { value: 1, extra: true }, {} as Tool.Context))).toMatchObject({
expect(await Effect.runPromise(execute(tool, { value: 1, extra: true }, context))).toMatchObject({
content: [{ type: "text", text: '{"value":1,"extra":true}' }],
})
})
+7 -21
View File
@@ -5,9 +5,7 @@ The Promise plugin API at `@opencode-ai/plugin` is the async/await equivalent of
- `hook` installs behavior at an OpenCode extension point.
- `reload` reruns every transform hook for a stateful domain.
The Promise API uses Promises instead of Effects for setup, runtime hook
callbacks, hook registration, `reload`, and `Registration.dispose`. Transform
draft callbacks remain synchronous.
The only difference from the Effect API is the async boundary: hook callbacks, hook registration, `reload`, and `Registration.dispose` use Promises instead of Effects.
## Defining A Plugin
@@ -48,15 +46,12 @@ await registration.dispose()
## Transform Hooks
Transform hooks contribute to stateful domains. The draft editor is synchronous,
so load asynchronous data before registering a transform or reloading its domain:
Transform hooks contribute to stateful domains. The draft editor is synchronous; the callback may be `async` when it needs to await other work:
```ts
const description = await loadReviewerDescription()
await ctx.agent.transform((agent) => {
agent.update("reviewer", (item) => {
item.description = description
item.description = "Reviews code for regressions"
item.mode = "subagent"
})
})
@@ -69,12 +64,8 @@ ctx.agent.transform
ctx.catalog.transform
ctx.command.transform
ctx.integration.transform
ctx.mcp.transform
ctx.reference.transform
ctx.skill.transform
ctx.tool.transform
ctx.vcs.transform
ctx.websearch.transform
```
## Runtime Hooks
@@ -90,7 +81,7 @@ await ctx.aisdk.hook("sdk", async (event) => {
await ctx.aisdk.hook("language", (event) => {
if (event.model.providerID !== "xai") return
event.language = event.sdk.responses(event.model.modelID)
event.language = event.sdk.responses(event.model.api.id)
})
```
@@ -103,15 +94,14 @@ await ctx.session.hook("context", (event) => {
})
```
Promise tools use complete executable tool values with async executors:
Promise tools use executable tool values with async executors. Registration
supplies the tool's name and options separately:
```ts
import { Schema } from "effect"
await ctx.tool.transform((tools) => {
tools.add({
name: "echo",
options: { codemode: false },
tools.add("echo", {
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
@@ -142,10 +132,6 @@ ctx.agent.reload()
ctx.catalog.reload()
ctx.command.reload()
ctx.integration.reload()
ctx.mcp.reload()
ctx.reference.reload()
ctx.skill.reload()
ctx.tool.reload()
ctx.vcs.reload()
ctx.websearch.reload()
```
+5 -17
View File
@@ -31,9 +31,7 @@ Registrations are owned by the plugin scope. Closing the scope removes them auto
## Transform Hooks
Transform hooks contribute to stateful domains. Their draft callbacks are
synchronous, so load effectful data before registering a transform or reloading
its domain:
Transform hooks contribute to stateful domains:
```ts
yield *
@@ -54,12 +52,8 @@ ctx.agent.transform
ctx.catalog.transform
ctx.command.transform
ctx.integration.transform
ctx.mcp.transform
ctx.reference.transform
ctx.skill.transform
ctx.tool.transform
ctx.vcs.transform
ctx.websearch.transform
```
## Runtime Hooks
@@ -78,12 +72,10 @@ yield *
)
yield *
ctx.aisdk.hook("language", (event) =>
Effect.sync(() => {
if (event.model.providerID !== "xai") return
event.language = event.sdk.responses(event.model.modelID)
}),
)
ctx.aisdk.hook("language", (event) => {
if (event.model.providerID !== "xai") return
event.language = event.sdk.responses(event.model.api.id)
})
```
Hooks run sequentially in registration order. Later hooks observe mutations made by earlier hooks.
@@ -125,10 +117,6 @@ ctx.agent.reload()
ctx.catalog.reload()
ctx.command.reload()
ctx.integration.reload()
ctx.mcp.reload()
ctx.reference.reload()
ctx.skill.reload()
ctx.tool.reload()
ctx.vcs.reload()
ctx.websearch.reload()
```