Compare commits

...
8 Commits
Author SHA1 Message Date
Kit Langton 3369c08ffa docs(plugin): update current API examples 2026-08-27 15:00:32 -04:00
Kit Langton 0c77f6ed5b refactor(core): remove unreachable permission branch (#45617)
Remove the impossible pre-hook deny aggregation branch while preserving configured denials and post-hook denial handling.
2026-08-27 14:48:11 -04:00
Kit Langton 284b222489 refactor(core): reuse read newline locator (#45616)
Reuse the reader newline locator for the terminal tree leaf while preserving accumulated offsets and the whole-tree fallback. Add chunk-boundary coverage.
2026-08-27 14:47:58 -04:00
Kit Langton 705606face refactor(core): reuse formatter executable helper (#45615)
Reuse the existing executable formatter helper for gofmt, mix, zig, and ktlint while preserving arguments, extensions, ordering, and lazy lookup.
2026-08-27 14:47:10 -04:00
Kit Langton b738ef970d test(core): stabilize Windows live I/O fixtures (#45595) 2026-08-27 14:45:07 -04:00
Kit Langton 4112698e72 refactor(core): simplify session runner control flow (#45614) 2026-08-27 14:42:12 -04:00
Aiden Cline a609174969 feat(core): expand tildes in tool path resolution (#45605) 2026-08-27 13:41:39 -05:00
Kit Langton d3694a5383 refactor(core): share read media types (#45597)
Reuse the reader-owned media MIME set in the read tool leaf while preserving both ingestion and unsupported-base64 validation boundaries.
2026-08-27 14:24:41 -04:00
18 changed files with 212 additions and 128 deletions
+9 -32
View File
@@ -35,23 +35,14 @@ export function make(input: {
)
.pipe(Effect.option)
const gofmt: Info = {
name: "gofmt",
extensions: [".go"],
enabled: Effect.sync(() => {
const match = findExecutable("gofmt")
return match ? [match, "-w", "$FILE"] : disabled
}),
}
const gofmt = executable("gofmt", [".go"], ["-w", "$FILE"], findExecutable)
const mix: Info = {
name: "mix",
extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"],
enabled: Effect.sync(() => {
const match = findExecutable("mix")
return match ? [match, "format", "$FILE"] : disabled
}),
}
const mix = executable(
"mix",
[".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"],
["format", "$FILE"],
findExecutable,
)
const prettier: Info = {
name: "prettier",
@@ -147,14 +138,7 @@ export function make(input: {
}).pipe(Effect.orElseSucceed(() => disabled)),
}
const zig: Info = {
name: "zig",
extensions: [".zig", ".zon"],
enabled: Effect.sync(() => {
const match = findExecutable("zig")
return match ? [match, "fmt", "$FILE"] : disabled
}),
}
const zig = executable("zig", [".zig", ".zon"], ["fmt", "$FILE"], findExecutable)
const clang: Info = {
name: "clang-format",
@@ -166,14 +150,7 @@ export function make(input: {
}).pipe(Effect.orElseSucceed(() => disabled)),
}
const ktlint: Info = {
name: "ktlint",
extensions: [".kt", ".kts"],
enabled: Effect.sync(() => {
const match = findExecutable("ktlint")
return match ? [match, "-F", "$FILE"] : disabled
}),
}
const ktlint = executable("ktlint", [".kt", ".kts"], ["-F", "$FILE"], findExecutable)
const ruff: Info = {
name: "ruff",
+20 -7
View File
@@ -4,6 +4,7 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import path from "path"
import { Context, Effect, Layer, Schema } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Location } from "./location.js"
import { Project } from "./project.js"
import { ProjectMarkers } from "./project/markers.js"
@@ -13,9 +14,9 @@ export const Kind = Schema.Literals(["file", "directory"])
export type Kind = typeof Kind.Type
/**
* Mutation paths do not accept project references. Relative paths resolve
* from the active Location. Paths outside it require separate
* `external_directory` approval.
* Mutation paths do not accept project references. A leading `~` expands to
* the home directory; other relative paths resolve from the active Location.
* Paths outside it require separate `external_directory` approval.
*/
export const ResolveInput = Schema.Struct({
path: Schema.String,
@@ -49,13 +50,25 @@ export interface Target {
export interface Interface {
/**
* Resolve a path and derive its permission resources. Relative paths resolve
* from the Location. Paths outside it require separate `external_directory`
* approval. This does not approve the mutation.
* Resolve a path and derive its permission resources. A leading `~` expands
* to the home directory; other relative paths resolve from the Location.
* Paths outside it require separate `external_directory` approval. This does
* not approve the mutation.
*/
readonly resolve: (input: ResolveInput) => Effect.Effect<Target, FSUtil.Error>
}
/** Lexical absolute path, expanding a leading `~` before resolving against `directory`. */
export const resolvePath = (directory: string, input: string, home = Global.Path.home) =>
path.resolve(
directory,
input === "~"
? home
: input.startsWith("~/") || (process.platform === "win32" && input.startsWith("~\\"))
? path.join(home, input.slice(2))
: input,
)
export class Service extends Context.Service<Service, Interface>()("@opencode/LocationMutation") {}
const slash = (value: string) => value.replaceAll("\\", "/")
@@ -68,7 +81,7 @@ const layer = Layer.effect(
const markers = yield* ProjectMarkers.Service
const resolve = Effect.fnUntraced(function* (input: ResolveInput) {
const absolute = path.resolve(location.directory, input.path)
const absolute = resolvePath(location.directory, input.path)
if (FSUtil.contains(location.directory, absolute)) {
return {
absolute,
+1 -1
View File
@@ -170,7 +170,7 @@ const layer = Layer.effect(
if (denied(input, rules)) return { effect: "deny" as const, rules }
const all = [...rules, ...(yield* savedRules())]
const effects = input.resources.map((resource) => evaluate(input.action, resource, all).effect)
const effect: Permission.Effect = effects.includes("deny") ? "deny" : effects.includes("ask") ? "ask" : "allow"
const effect: Permission.Effect = effects.includes("ask") ? "ask" : "allow"
const event = yield* hooks.trigger("permission", "evaluate", {
sessionID: input.sessionID,
agent: input.agent,
+18 -18
View File
@@ -77,29 +77,29 @@ export const layer = Layer.effect(
const releaseOnCommit = (sessionID: SessionSchema.ID) => ({
commit: () => store.release(sessionID),
})
function drain(
const drain = Effect.fnUntraced(function* (
sessionID: SessionSchema.ID,
force: boolean,
continuation?: SessionRunner.Continuation,
promotable: SessionInbox.Promotable = "input",
): Effect.Effect<void, SessionRunner.RunError> {
return Effect.gen(function* () {
const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
const result = yield* SessionRunner.Service.use((runner) =>
runner.drain({ sessionID, force, continuation, promotable }),
).pipe(
Effect.provide(locations.get(session.location)),
Effect.tapCause((cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.void
: Effect.logError("Failed to drain Session", cause).pipe(Effect.annotateLogs({ sessionID })),
),
)
if (result._tag === "Complete") return
return yield* drain(sessionID, false, result.continuation, promotable)
): Effect.fn.Return<void, SessionRunner.RunError> {
const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
const result = yield* SessionRunner.Service.use((runner) =>
runner.drain({ sessionID, force, continuation, promotable }),
).pipe(
Effect.provide(locations.get(session.location)),
Effect.tapCause((cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.void
: Effect.logError("Failed to drain Session", cause).pipe(Effect.annotateLogs({ sessionID })),
),
)
return yield* SessionRunner.DrainResult.$match(result, {
Complete: () => Effect.void,
Moved: (result) => drain(sessionID, false, result.continuation, promotable),
})
}
})
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError, InterruptReason>({
started: (sessionID) =>
reportLifecycle(
+27 -28
View File
@@ -85,14 +85,9 @@ const layer = Layer.effect(
const promotable = input.promotable ?? "input"
if (!force && !continuing) {
const pending = yield* SessionInbox.nextPromotable(db, sessionID, "input")
if (
!pending ||
(pending.delivery === "queue" &&
promotable === "steer" &&
pending.type !== "compaction" &&
pending.type !== "move")
)
return DrainResult.Complete()
if (!pending) return DrainResult.Complete()
const control = pending.type === "compaction" || pending.type === "move"
if (promotable === "steer" && pending.delivery === "queue" && !control) return DrainResult.Complete()
}
yield* plugins.flush
yield* settleStaleToolCalls(sessionID)
@@ -263,29 +258,33 @@ const layer = Layer.effect(
: Effect.succeed(false),
),
})
if (outcome._tag === "Completed") return outcome.needsContinuation
if (outcome._tag === "Retry" || outcome._tag === "Continue") {
yield* retry({ cause: outcome.cause, error: outcome.error, assistantMessageID }).pipe(
Pull.catchDone(() =>
Effect.gen(function* () {
if (outcome._tag === "Retry")
yield* bus.publish(SessionEvent.Step.Failed, { sessionID, assistantMessageID, error: outcome.error })
return yield* outcome.cause
}),
const completed = yield* SessionStep.Outcome.$match(outcome, {
Completed: (outcome) => Effect.succeed(outcome.needsContinuation),
Retry: (outcome) =>
retry({ cause: outcome.cause, error: outcome.error, assistantMessageID }).pipe(
Pull.catchDone(() =>
bus
.publish(SessionEvent.Step.Failed, { sessionID, assistantMessageID, error: outcome.error })
.pipe(Effect.andThen(outcome.cause)),
),
Effect.asVoid,
),
)
if (outcome._tag === "Continue") {
Continue: Effect.fnUntraced(function* (outcome) {
yield* retry({ cause: outcome.cause, error: outcome.error, assistantMessageID }).pipe(
Pull.catchDone(() => outcome.cause),
)
yield* bus.publish(SessionEvent.Synthetic, { sessionID, text: CONTINUE_AFTER_INCOMPLETE_STREAM })
assistantMessageID = SessionMessage.ID.create()
}
continue
}
if (outcome._tag === "Compacted") {
recoverOverflow = false
assistantMessageID = SessionMessage.ID.create()
continue
}
recoverContinuation = false
}),
Compacted: Effect.fnUntraced(function* () {
recoverOverflow = false
assistantMessageID = SessionMessage.ID.create()
}),
RecoverFull: Effect.fnUntraced(function* () {
recoverContinuation = false
}),
})
if (completed !== undefined) return completed
}
})
+12 -13
View File
@@ -36,7 +36,7 @@ export type Outcome = Data.TaggedEnum<{
RecoverFull: {}
Compacted: {}
}>
const Outcome = Data.taggedEnum<Outcome>()
export const Outcome = Data.taggedEnum<Outcome>()
interface Input {
readonly sessionID: SessionSchema.ID
@@ -127,11 +127,11 @@ export const make = Effect.gen(function* () {
Effect.gen(function* () {
const stream = yield* restore(providerStream).pipe(Effect.exit)
const streamFailure = Option.getOrUndefined(Exit.findErrorOption(stream))
const streamInterrupted = stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)
const streamInterrupted = Exit.hasInterrupts(stream)
if (!overflowFailure && publisher.hasStarted()) yield* publisher.streamed()
if (streamInterrupted) yield* interruptTools
const joined = yield* restore(Fiber.awaitAll(toolRuns.map((run) => run.fiber))).pipe(Effect.exit)
if (joined._tag === "Failure") yield* interruptTools
if (Exit.isFailure(joined)) yield* interruptTools
const tools = classifyToolExits(
joined,
toolRuns.map((run) => run.call),
@@ -147,7 +147,7 @@ export const make = Effect.gen(function* () {
if (overflowFailure) yield* publisher.publish(overflowFailure)
const recorded = publisher.record()
const unknownFinish =
stream._tag === "Success" && recorded.finish?.finish === "unknown"
Exit.isSuccess(stream) && recorded.finish?.finish === "unknown"
? new AIError({
reason: new InvalidProviderOutputError({
message: "The provider response ended with an unknown finish reason.",
@@ -191,7 +191,7 @@ export const make = Effect.gen(function* () {
if (interrupted) yield* publisher.failAssistant(STEP_INTERRUPTED)
// All local fibers have joined; only provider-hosted results can still be missing.
if (llmError || (stream._tag === "Success" && !recorded.providerFailed)) {
if (llmError || (Exit.isSuccess(stream) && !recorded.providerFailed)) {
const missing = yield* publisher.failUnsettledTools(RESULT_MISSING, "hosted")
if (missing && !llmError && !recorded.finish) yield* publisher.failAssistant(RESULT_MISSING)
}
@@ -234,10 +234,10 @@ export const make = Effect.gen(function* () {
)
return Outcome.Continue({ cause: llmFailure, error: llmError })
if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
if (Exit.isFailure(stream)) return yield* Effect.failCause(stream.cause)
if (tools.declines.length > 0) return yield* Effect.interrupt
if (tools.interrupted && tools.failure) return yield* Effect.failCause(tools.failure)
if (tools.interrupted && joined._tag === "Failure") return yield* Effect.failCause(joined.cause)
if (tools.interrupted && Exit.isFailure(joined)) return yield* Effect.failCause(joined.cause)
if (record.failure) return yield* new StepFailedError({ error: record.failure })
return Outcome.Completed({
needsContinuation: !input.toolsDisabled && record.needsContinuation,
@@ -265,18 +265,17 @@ const classifyToolExits = (
settled: Exit.Exit<Array<Exit.Exit<void, SessionModelRequest.ExecuteError>>>,
calls: ReadonlyArray<ToolCall>,
) => {
const exits = settled._tag === "Success" ? settled.value : []
const exits = Exit.isSuccess(settled) ? settled.value : []
const declines = exits.flatMap((exit, index) =>
exit._tag === "Failure"
Exit.isFailure(exit)
? exit.cause.reasons.flatMap((reason) =>
Cause.isFailReason(reason) && isDecline(reason.error) ? [{ call: calls[index], reason: reason.error }] : [],
)
: [],
)
const causes =
settled._tag === "Failure"
? [settled.cause]
: exits.flatMap((exit) => (exit._tag === "Failure" ? [exit.cause] : []))
const causes = Exit.isFailure(settled)
? [settled.cause]
: exits.flatMap((exit) => (Exit.isFailure(exit) ? [exit.cause] : []))
const failure = causes
.flatMap((cause) => {
if (Cause.hasInterrupts(cause)) return []
+3 -1
View File
@@ -329,7 +329,9 @@ function expandKnownDirectory(value: string) {
// Unknown shell expressions cannot be resolved safely during permission analysis.
if (value.includes("$") || value.includes("`") || value.startsWith("(")) return
if (value === "~") return os.homedir()
if (value.startsWith("~/") || value.startsWith("~\\")) return path.join(os.homedir(), value.slice(2))
if (value.startsWith("~/") || (process.platform === "win32" && value.startsWith("~\\"))) {
return path.join(os.homedir(), value.slice(2))
}
return value
}
+1 -2
View File
@@ -11,7 +11,6 @@ import { ToolFailure } from "@opencode-ai/ai"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { Bom } from "@opencode-ai/util/bom"
import { Effect, Schema } from "effect"
import path from "path"
import { Environment } from "../../environment/index.js"
import { FileMutation } from "../../file-mutation.js"
import { Formatter } from "../../formatter.js"
@@ -219,7 +218,7 @@ export const Plugin = {
replacements,
} satisfies Output
}).pipe(
fileMutation.withLock([path.resolve(location.directory, input.path)]),
fileMutation.withLock([LocationMutation.resolvePath(location.directory, input.path)]),
Effect.map((output) => ({
output,
content: `Edited ${output.files[0]?.file} (${output.replacements} replacement${output.replacements === 1 ? "" : "s"})`,
+4 -3
View File
@@ -4,7 +4,6 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin
import { ToolFailure } from "@opencode-ai/ai"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { Effect, Result, Schema } from "effect"
import path from "path"
import { Bom } from "@opencode-ai/util/bom"
import { Environment } from "../../environment/index.js"
import { Formatter } from "../../formatter.js"
@@ -87,8 +86,10 @@ export const Plugin = {
const parsed = Patch.parse(input.patchText)
const lockTargets = Result.isSuccess(parsed)
? parsed.success.flatMap((hunk) => [
path.resolve(location.directory, hunk.path),
...(hunk.type === "update" && hunk.movePath ? [path.resolve(location.directory, hunk.movePath)] : []),
LocationMutation.resolvePath(location.directory, hunk.path),
...(hunk.type === "update" && hunk.movePath
? [LocationMutation.resolvePath(location.directory, hunk.movePath)]
: []),
])
: []
const fail = (operation: string, error: unknown) => {
+5 -2
View File
@@ -15,7 +15,6 @@ import { Environment } from "../../environment/index.js"
export const name = "read"
const FILENAME = "AGENTS.md"
const SUPPORTED_MEDIA_MIMES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp", "application/pdf"])
const LocationInput = Schema.Struct({
path: Schema.String.annotate({ description: "File or directory to read" }),
offset: ReadToolFileSystem.PageInput.fields.offset.annotate({
@@ -104,7 +103,11 @@ export const Plugin = {
Effect.catch(() => Effect.void),
Effect.catchDefect(() => Effect.void),
)
if (content.type === "file" && content.encoding === "base64" && !SUPPORTED_MEDIA_MIMES.has(content.mime))
if (
content.type === "file" &&
content.encoding === "base64" &&
!ReadToolFileSystem.MEDIA_MIMES.has(content.mime)
)
return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError({ resource }))
return content
}).pipe(
+3 -7
View File
@@ -18,7 +18,7 @@ const FIRST_CHUNK = 256 * 1024
const MAX_LINE_LENGTH = 2_000
const TREE_BASE = 6
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
const MEDIA_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp", "application/pdf"])
export const MEDIA_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp", "application/pdf"])
export class BinaryFileError extends Schema.TaggedError<BinaryFileError>()("ReadTool.BinaryFileError", {
resource: Schema.String,
@@ -361,12 +361,8 @@ const textOffset = (tree: TextNode, newline: number) => {
if (!child) return tree.summary.bytes
node = child
}
for (const [index, byte] of node.bytes.entries()) {
if (byte !== 10) continue
remaining--
if (remaining === 0) return offset + index + 1
}
return tree.summary.bytes
const end = nthNewline(node.bytes, remaining)
return end === undefined ? tree.summary.bytes : offset + end
}
const nthNewline = (bytes: Uint8Array, count: number) => {
@@ -6,6 +6,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Global } from "@opencode-ai/util/global"
import { tmpdir } from "./fixture/tmpdir"
import { location } from "./fixture/location"
import { it } from "./lib/effect"
@@ -196,4 +197,44 @@ describe("LocationMutation", () => {
path: "README.md",
})
})
test("expands a leading tilde against the home directory", () => {
const home = path.resolve("/Users/aiden")
expect(LocationMutation.resolvePath("/project", "~", home)).toBe(home)
expect(LocationMutation.resolvePath("/project", "~/notes.md", home)).toBe(path.resolve(home, "notes.md"))
expect(LocationMutation.resolvePath("/project", "~draft.md", home)).toBe(path.resolve("/project", "~draft.md"))
expect(LocationMutation.resolvePath("/project", "~\\notes.md", home)).toBe(
process.platform === "win32"
? path.resolve(home, "notes.md")
: path.resolve("/project", "~\\notes.md"),
)
})
it.live("resolves a tilde path as an external home target", () =>
withTmp((directory) =>
Effect.gen(function* () {
const target = yield* (yield* LocationMutation.Service).resolve({ path: "~/notes.md" })
const absolute = path.resolve(Global.Path.home, "notes.md")
expect(target).toMatchObject({
absolute,
resource: absolute.replaceAll("\\", "/"),
})
expect(target.externalDirectory).toMatchObject({
directory: Global.Path.home,
resource: path.join(Global.Path.home, "*").replaceAll("\\", "/"),
})
}).pipe(provide(directory)),
),
)
it.live("treats a tilde path as in-location when the location is home", () =>
Effect.gen(function* () {
const target = yield* (yield* LocationMutation.Service).resolve({ path: "~/notes.md" })
expect(target).toMatchObject({
absolute: path.resolve(Global.Path.home, "notes.md"),
resource: "notes.md",
})
expect(target.externalDirectory).toBeUndefined()
}).pipe(provide(Global.Path.home)),
)
})
+4 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect } from "bun:test"
import { describe, expect, setDefaultTimeout } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
@@ -14,6 +14,9 @@ import { testEffect } from "./lib/effect"
const it = testEffect(Layer.empty)
// Cold Git setup and cloning can exceed Bun's five-second default on Windows.
setDefaultTimeout(15_000)
describe("RepositoryCache", () => {
it.live("replaces a stale cache directory before cloning", () =>
withRemote((fixture) =>
@@ -1,9 +1,10 @@
import { describe, expect } from "bun:test"
import { describe, expect, setDefaultTimeout } from "bun:test"
import path from "path"
import { Deferred, Effect, Fiber, Stream } from "effect"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
@@ -22,6 +23,9 @@ import { tempGlobalLayer } from "./fixture/global"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
// These tests include real Location and plugin startup, not just hook callbacks.
setDefaultTimeout(15_000)
const runtime = PluginRuntime.makeCell()
const it = testEffect(
AppNodeBuilder.build(
@@ -36,6 +40,7 @@ const it = testEffect(
[
[Bus.node, Bus.configured({ persist: true })],
[Global.node, tempGlobalLayer],
[Watcher.node, Watcher.configured({ enabled: false })],
[SessionExecution.node, SessionExecution.noopLayer],
[PluginRuntime.node, PluginRuntime.layerWithCell(runtime)],
],
+5
View File
@@ -137,6 +137,11 @@ describe("ShellParse", () => {
const bash = await Effect.runPromise(ShellParse.scan("cd ~/src", "/bin/bash", "/workspace"))
expect(bash.directories).toEqual([path.join(os.homedir(), "src")])
const backslash = await Effect.runPromise(ShellParse.scan("cd '~\\src'", "/bin/bash", "/workspace"))
expect(backslash.directories).toEqual(
process.platform === "win32" ? [path.join(os.homedir(), "src")] : ["~\\src"],
)
const powershell = await Effect.runPromise(
ShellParse.scan('Set-Location "$PWD/src"; Set-Location $PSHOME', "/usr/local/bin/pwsh", "/workspace"),
)
@@ -257,6 +257,21 @@ describe("ReadToolFileSystem", () => {
}),
)
it.effect("reads after a newline at the first chunk boundary", () =>
Effect.gen(function* () {
const { environment, files, directory } = yield* fixture
const file = path.join(directory, "boundary.txt")
yield* files.writeFileString(file, `${"a".repeat(256 * 1024 - 1)}\nsecond\n`)
const result = yield* ReadToolFileSystem.read(environment, absolute(file), "boundary.txt", {
offset: 2,
limit: 1,
})
expect(result).toMatchObject({ type: "text-page", content: "second", offset: 2, truncated: false })
}),
)
it.effect("preserves the media ingestion limit message", () =>
Effect.gen(function* () {
const { environment, files, directory } = yield* fixture
+21 -7
View File
@@ -5,7 +5,9 @@ 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 only difference from the Effect API is the async boundary: hook callbacks, hook registration, `reload`, and `Registration.dispose` use Promises instead of Effects.
The Promise API uses Promises instead of Effects for setup, runtime hook
callbacks, hook registration, `reload`, and `Registration.dispose`. Transform
draft callbacks remain synchronous.
## Defining A Plugin
@@ -46,12 +48,15 @@ await registration.dispose()
## Transform Hooks
Transform hooks contribute to stateful domains. The draft editor is synchronous; the callback may be `async` when it needs to await other work:
Transform hooks contribute to stateful domains. The draft editor is synchronous,
so load asynchronous data before registering a transform or reloading its domain:
```ts
const description = await loadReviewerDescription()
await ctx.agent.transform((agent) => {
agent.update("reviewer", (item) => {
item.description = "Reviews code for regressions"
item.description = description
item.mode = "subagent"
})
})
@@ -64,8 +69,12 @@ 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
@@ -81,7 +90,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.api.id)
event.language = event.sdk.responses(event.model.modelID)
})
```
@@ -94,14 +103,15 @@ await ctx.session.hook("context", (event) => {
})
```
Promise tools use executable tool values with async executors. Registration
supplies the tool's name and options separately:
Promise tools use complete executable tool values with async executors:
```ts
import { Schema } from "effect"
await ctx.tool.transform((tools) => {
tools.add("echo", {
tools.add({
name: "echo",
options: { codemode: false },
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
@@ -132,6 +142,10 @@ 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()
```
+17 -5
View File
@@ -31,7 +31,9 @@ Registrations are owned by the plugin scope. Closing the scope removes them auto
## Transform Hooks
Transform hooks contribute to stateful domains:
Transform hooks contribute to stateful domains. Their draft callbacks are
synchronous, so load effectful data before registering a transform or reloading
its domain:
```ts
yield *
@@ -52,8 +54,12 @@ 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
@@ -72,10 +78,12 @@ yield *
)
yield *
ctx.aisdk.hook("language", (event) => {
if (event.model.providerID !== "xai") return
event.language = event.sdk.responses(event.model.api.id)
})
ctx.aisdk.hook("language", (event) =>
Effect.sync(() => {
if (event.model.providerID !== "xai") return
event.language = event.sdk.responses(event.model.modelID)
}),
)
```
Hooks run sequentially in registration order. Later hooks observe mutations made by earlier hooks.
@@ -117,6 +125,10 @@ 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()
```