mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-27 20:16:17 +00:00
Compare commits
8
Commits
beta
...
plugin-api-docs
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3369c08ffa | ||
|
|
0c77f6ed5b | ||
|
|
284b222489 | ||
|
|
705606face | ||
|
|
b738ef970d | ||
|
|
4112698e72 | ||
|
|
a609174969 | ||
|
|
d3694a5383 |
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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) => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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)],
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
```
|
||||
|
||||
@@ -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()
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user