Compare commits

..
Author SHA1 Message Date
Kit Langton b91eb7daf9 refactor(core): use nonempty array guards 2026-08-27 15:45:25 -04:00
5 changed files with 22 additions and 61 deletions
@@ -1,5 +1,6 @@
import { type LLMEvent, type ProviderMetadata, type ToolResultValue } from "@opencode-ai/ai"
import { Clock, Effect, Iterable } from "effect"
import { isArrayNonEmpty, isReadonlyArrayNonEmpty } from "effect/Array"
import { Bus } from "../../bus.js"
import { Model } from "../../model.js"
import { SessionEvent } from "../event.js"
@@ -45,9 +46,6 @@ export interface StepRecord {
/** Derives canonical model content from a provider-hosted tool result. */
type NonEmptyContent = readonly [Tool.Content, ...Tool.Content[]]
const nonEmpty = (content: ReadonlyArray<Tool.Content>): NonEmptyContent | undefined =>
content.length > 0 ? (content as NonEmptyContent) : undefined
const stringify = (value: unknown) => {
if (typeof value === "string") return value
try {
@@ -58,10 +56,7 @@ const stringify = (value: unknown) => {
}
const hostedContent = (result: ToolResultValue): NonEmptyContent => {
if (result.type === "content") {
const content = nonEmpty(result.value)
if (content !== undefined) return content
}
if (result.type === "content" && isReadonlyArrayNonEmpty(result.value)) return result.value
return [{ type: "text", text: stringify(result.value) }]
}
@@ -561,12 +556,12 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
: result.content === undefined
? []
: [...result.content]
if (content.length === 0) return yield* Effect.die(new Error(`Tool execution has no content: ${id}`))
if (!isArrayNonEmpty(content)) return yield* Effect.die(new Error(`Tool execution has no content: ${id}`))
yield* bus.publish(SessionEvent.Tool.Success, {
sessionID: input.sessionID,
assistantMessageID,
id,
content: [content[0], ...content.slice(1)],
content,
...(result.metadata === undefined ? {} : { metadata: result.metadata }),
executed: tool.providerExecuted,
})
+3 -10
View File
@@ -5,6 +5,7 @@ import { Tool } from "@opencode-ai/schema/tool"
import { Skill } from "@opencode-ai/schema/skill"
import { eq } from "drizzle-orm"
import { Context, DateTime, Effect, Layer, Schema } from "effect"
import { map } from "effect/Array"
import path from "path"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { App } from "../app.js"
@@ -304,21 +305,13 @@ function sanitizeToolState(id: string, state: SessionMessage.ToolState): Session
return {
...state,
input: { redacted: `tool-input:${id}` },
content: [
sanitizeToolContent(id, state.content[0]),
...state.content.slice(1).map((item) => sanitizeToolContent(id, item)),
],
content: map(state.content, (item) => sanitizeToolContent(id, item)),
metadata: meta,
}
return {
...state,
input: { redacted: `tool-input:${id}` },
content: state.content
? [
sanitizeToolContent(id, state.content[0]),
...state.content.slice(1).map((item) => sanitizeToolContent(id, item)),
]
: undefined,
content: state.content ? map(state.content, (item) => sanitizeToolContent(id, item)) : undefined,
metadata: meta,
}
}
+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()
```
+3 -4
View File
@@ -1,4 +1,4 @@
import type { NonEmptyReadonlyArray } from "effect/Array"
import { isArrayNonEmpty } from "effect/Array"
import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"
import * as NodePath from "@effect/platform-node/NodePath"
import * as NodeSink from "@effect/platform-node/NodeSink"
@@ -62,10 +62,9 @@ const flatten = (command: ChildProcess.Command) => {
}
walk(command)
if (commands.length === 0) throw new Error("flatten produced empty commands array")
const [head, ...tail] = commands
if (!isArrayNonEmpty(commands)) throw new Error("flatten produced empty commands array")
return {
commands: [head, ...tail] as NonEmptyReadonlyArray<ChildProcess.StandardCommand>,
commands,
opts,
}
}