Compare commits

..
Author SHA1 Message Date
Kit Langton 3e506cfbde refactor(core): separate configured command invocation 2026-08-28 13:01:59 -04:00
7 changed files with 451 additions and 475 deletions
+107
View File
@@ -0,0 +1,107 @@
export * as CommandInvocation from "./invocation.js"
import type { Plugin } from "@opencode-ai/plugin/effect"
import { Agent } from "@opencode-ai/schema/agent"
import type { ConfigCommand } from "@opencode-ai/schema/config/command"
import { Model } from "@opencode-ai/schema/model"
import { Provider } from "@opencode-ai/schema/provider"
import { AppProcess } from "@opencode-ai/util/process"
import { Effect } from "effect"
import { ChildProcess } from "effect/unstable/process"
import type { Command } from "../command.js"
import { Location } from "../location.js"
import { ShellSelect } from "../shell/select.js"
// Invocation for configured template commands; source loading and registration stay with the caller.
export const make = Effect.fnUntraced(function* (ctx: Pick<Plugin.Context, "agent" | "session">) {
const location = yield* Location.Service
const processes = yield* AppProcess.Service
const shell = yield* ShellSelect.Service
return Effect.fn("CommandInvocation.invoke")(function* (command: ConfigCommand.Info, input: Command.Invocation) {
const agent = command.agent === undefined ? undefined : Agent.ID.make(command.agent)
const commandAgent = yield* Effect.gen(function* () {
if (agent === undefined) return
const session = yield* ctx.session.get({ sessionID: input.sessionID })
if (session.agent !== agent) yield* ctx.session.switchAgent({ sessionID: input.sessionID, agent })
return (yield* ctx.agent.get({ agentID: agent })).data
})
const model =
command.model === undefined
? commandAgent?.model
: {
id: Model.ID.make(command.model.model),
providerID: Provider.ID.make(command.model.providerID),
...(command.model.variant === undefined ? {} : { variant: Model.VariantID.make(command.model.variant) }),
}
if (model !== undefined) yield* ctx.session.switchModel({ sessionID: input.sessionID, model })
yield* ctx.session.prompt({
...input.prompt,
sessionID: input.sessionID,
text: yield* evaluateTemplate(command.template, input.prompt.text, { location, processes, shell }),
delivery: input.delivery,
})
})
})
function evaluateTemplate(
template: string,
input: string,
services: {
readonly location: Location.Info
readonly processes: AppProcess.Interface
readonly shell: ShellSelect.Interface
},
) {
return Effect.gen(function* () {
const args = parseArguments(input)
const placeholders = template.match(placeholderRegex) ?? []
const last = Math.max(0, ...placeholders.map((item) => Number(item.slice(1))))
const expanded = template.replaceAll(placeholderRegex, (_, index) => {
const position = Number(index)
const argIndex = position - 1
if (argIndex >= args.length) return ""
if (position === last) return args.slice(argIndex).join(" ")
return args[argIndex]
})
const withArguments = expanded.replaceAll("$ARGUMENTS", input)
const text =
placeholders.length === 0 && !template.includes("$ARGUMENTS") && input.trim()
? `${withArguments}\n\n${input}`.trim()
: withArguments.trim()
const matches = Array.from(text.matchAll(shellRegex))
if (matches.length === 0) return text
const shell = yield* services.shell.resolve({ priority: "config" })
const outputs = yield* Effect.forEach(
matches,
(match) => {
const source = match[1] ?? ""
return services.processes
.run(
ChildProcess.make(shell, ShellSelect.args(shell, source), {
cwd: services.location.directory,
stdin: "ignore",
}),
{ combineOutput: true },
)
.pipe(
Effect.map((result) => (result.output ?? Buffer.concat([result.stdout, result.stderr])).toString("utf8")),
Effect.mapError(
(error) => new Error(`Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}`),
),
)
},
{ concurrency: 2 },
)
const iterator = outputs[Symbol.iterator]()
return text.replace(shellRegex, () => iterator.next().value ?? "")
})
}
function parseArguments(input: string) {
return (input.match(argsRegex) ?? []).map((arg) => arg.replace(quoteTrimRegex, ""))
}
const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi
const placeholderRegex = /\$(\d+)/g
const quoteTrimRegex = /^["']|["']$/g
const shellRegex = /!`([^`]+)`/g
+3 -106
View File
@@ -1,18 +1,12 @@
export * as ConfigCommandPlugin from "./command.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Agent } from "@opencode-ai/schema/agent"
import { Info, type Entry } from "@opencode-ai/schema/config"
import { ConfigCommand } from "@opencode-ai/schema/config/command"
import { Model } from "@opencode-ai/schema/model"
import { Provider } from "@opencode-ai/schema/provider"
import { AppProcess } from "@opencode-ai/util/process"
import path from "path"
import { Effect, Option, Schema, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { CommandInvocation } from "../../command/invocation.js"
import { Config } from "../../config.js"
import { Location } from "../../location.js"
import { ShellSelect } from "../../shell/select.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { ConfigMarkdown } from "../markdown.js"
@@ -29,9 +23,7 @@ export const Plugin = define({
const commands = yield* loadDirectory(fs, entry.path)
return [{ commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) }]
})
const location = yield* Location.Service
const processes = yield* AppProcess.Service
const shell = yield* ShellSelect.Service
const invoke = yield* CommandInvocation.make(ctx)
const load = Effect.fn("ConfigCommandPlugin.load")(function* () {
return yield* Effect.forEach(yield* config.entries(), loadEntry).pipe(Effect.map((documents) => documents.flat()))
})
@@ -63,38 +55,7 @@ export const Plugin = define({
draft.add({
name,
description: command.description,
execute: (input) =>
Effect.gen(function* () {
const agent = command.agent === undefined ? undefined : Agent.ID.make(command.agent)
const commandAgent = yield* Effect.gen(function* () {
if (agent === undefined) return
const session = yield* ctx.session.get({ sessionID: input.sessionID })
if (session.agent !== agent) yield* ctx.session.switchAgent({ sessionID: input.sessionID, agent })
return (yield* ctx.agent.get({ agentID: agent })).data
})
const model =
command.model === undefined
? commandAgent?.model
: {
id: Model.ID.make(command.model.model),
providerID: Provider.ID.make(command.model.providerID),
...(command.model.variant === undefined
? {}
: { variant: Model.VariantID.make(command.model.variant) }),
}
if (model !== undefined) yield* ctx.session.switchModel({ sessionID: input.sessionID, model })
yield* ctx.session.prompt({
...input.prompt,
sessionID: input.sessionID,
text: yield* evaluateTemplate(command.template, input.prompt.text, {
config,
location,
processes,
shell,
}),
delivery: input.delivery,
})
}).pipe(Effect.asVoid),
execute: (input) => invoke(command, input),
})
}
}
@@ -147,67 +108,3 @@ function decode(directory: string, filepath: string, content: string) {
info,
}
}
function evaluateTemplate(
template: string,
input: string,
services: {
readonly config: Config.Interface
readonly location: Location.Info
readonly processes: AppProcess.Interface
readonly shell: ShellSelect.Interface
},
) {
return Effect.gen(function* () {
const args = parseArguments(input)
const placeholders = template.match(placeholderRegex) ?? []
const last = Math.max(0, ...placeholders.map((item) => Number(item.slice(1))))
const expanded = template.replaceAll(placeholderRegex, (_, index) => {
const position = Number(index)
const argIndex = position - 1
if (argIndex >= args.length) return ""
if (position === last) return args.slice(argIndex).join(" ")
return args[argIndex]
})
const withArguments = expanded.replaceAll("$ARGUMENTS", input)
const text =
placeholders.length === 0 && !template.includes("$ARGUMENTS") && input.trim()
? `${withArguments}\n\n${input}`.trim()
: withArguments.trim()
const matches = Array.from(text.matchAll(shellRegex))
if (matches.length === 0) return text
const shell = yield* services.shell.resolve({ priority: "config" })
const outputs = yield* Effect.forEach(
matches,
(match) => {
const source = match[1] ?? ""
return services.processes
.run(
ChildProcess.make(shell, ShellSelect.args(shell, source), {
cwd: services.location.directory,
stdin: "ignore",
}),
{ combineOutput: true },
)
.pipe(
Effect.map((result) => (result.output ?? Buffer.concat([result.stdout, result.stderr])).toString("utf8")),
Effect.mapError((error) =>
new Error(`Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}`),
),
)
},
{ concurrency: 2 },
)
const iterator = outputs[Symbol.iterator]()
return text.replace(shellRegex, () => iterator.next().value ?? "")
})
}
function parseArguments(input: string) {
return (input.match(argsRegex) ?? []).map((arg) => arg.replace(quoteTrimRegex, ""))
}
const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi
const placeholderRegex = /\$(\d+)/g
const quoteTrimRegex = /^["']|["']$/g
const shellRegex = /!`([^`]+)`/g
+126 -6
View File
@@ -2,14 +2,17 @@ export * as ConfigSkillPlugin from "./skill.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import type { Entry } from "@opencode-ai/schema/config"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import path from "path"
import { Effect, Stream } from "effect"
import { Effect, FiberMap, PubSub, Semaphore, Stream } from "effect"
import { Config } from "../../config.js"
import { Watcher } from "../../filesystem/watcher.js"
import { Location } from "../../location.js"
import { AbsolutePath } from "../../schema.js"
import { Skill } from "../../skill.js"
import { SkillSourceObserver } from "../../skill/source-observer.js"
import { SkillDiscovery } from "../../skill/discovery.js"
import { SkillFile } from "./skill-file.js"
type Source = Skill.DirectorySource | Skill.UrlSource
@@ -17,11 +20,59 @@ export const Plugin = define({
id: "opencode.config.skill",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const discovery = yield* SkillDiscovery.Service
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const location = yield* Location.Service
const loaded: { entries: Entry[] } = {
const watcher = yield* Watcher.Service
const loaded: { entries: Entry[]; skills: Skill.Info[] } = {
entries: yield* config.entries(),
skills: [],
}
const watches = yield* FiberMap.make<string>()
const changes = yield* PubSub.sliding<string>(1)
const lock = Semaphore.makeUnsafe(1)
const watch = Effect.fn("ConfigSkillPlugin.watch")(function* (directory: string, type: Watcher.WatchInput["type"]) {
const target = path.resolve(directory)
const updates = yield* watcher.subscribe({ path: target, type })
yield* FiberMap.run(
watches,
`${type}:${target}`,
updates.pipe(Stream.runForEach((update) => PubSub.publish(changes, update.path).pipe(Effect.asVoid))),
{ onlyIfMissing: true, startImmediately: true },
)
})
function firstMissing(target: string): Effect.Effect<string | undefined> {
const parent = path.dirname(target)
if (parent === target) return Effect.undefined
return fs.isDir(parent).pipe(Effect.flatMap((exists) => (exists ? Effect.succeed(target) : firstMissing(parent))))
}
const watchDirectory: (directory: string) => Effect.Effect<string[]> = Effect.fn(
"ConfigSkillPlugin.watchDirectory",
)(function* (directory: string) {
const target = path.resolve(directory)
const resolved = yield* fs.realPath(directory).pipe(Effect.orElseSucceed(() => undefined))
if (resolved) {
yield* watch(resolved, "directory")
if (resolved !== target) yield* watch(target, "file")
return resolved === target ? [target] : [target, resolved]
}
const missing = yield* firstMissing(target)
if (missing) yield* watch(missing, "file")
if (
yield* fs.realPath(directory).pipe(
Effect.as(true),
Effect.orElseSucceed(() => false),
)
) {
if (missing) yield* FiberMap.remove(watches, `file:${path.resolve(missing)}`)
return yield* watchDirectory(directory)
}
return [target]
})
const sources = () => {
const result: Source[] = []
@@ -56,16 +107,85 @@ export const Plugin = define({
return result
}
const observer = yield* SkillSourceObserver.make({ sources, onChange: ctx.skill.reload })
const load = Effect.fn("ConfigSkillPlugin.load")(function* (source: Source) {
const directories =
source.type === "directory"
? [source.path]
: yield* discovery.pull(source.url).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to load skill source", {
source: Skill.Source.key(source),
cause,
}).pipe(Effect.as([] as AbsolutePath[])),
),
)
const roots = (yield* Effect.forEach(directories, watchDirectory)).flat()
const skills: Skill.Info[] = []
for (const directory of directories) {
const files = yield* fs
.scan("{*.md,**/SKILL.md}", { cwd: directory, absolute: true, include: "file", symlink: true, dot: true })
.pipe(Effect.orElseSucceed(() => [] as string[]))
for (const filepath of files.toSorted()) {
const resolved = yield* fs.realPath(filepath).pipe(Effect.orElseSucceed(() => filepath))
if (!roots.some((root) => FSUtil.contains(root, resolved))) yield* watch(path.dirname(resolved), "directory")
const content = yield* fs.readFileStringSafe(filepath).pipe(Effect.orElseSucceed(() => undefined))
if (!content) continue
const parsed = SkillFile.parse(directory, filepath, content)
if (parsed._tag === "Skipped") {
yield* Effect.logDebug("skill file skipped", {
filepath,
reason: parsed.reason,
...(parsed.reason === "frontmatter" ? { issue: parsed.issue } : {}),
})
continue
}
skills.push(parsed.skill)
}
}
yield* Effect.logDebug("skill source loaded", {
source: Skill.Source.key(source),
type: source.type,
directories,
skills: skills.map((skill) => skill.id),
})
return skills
})
const refresh = Effect.fn("ConfigSkillPlugin.refresh")(function* (file?: string) {
yield* lock.withPermit(
Effect.gen(function* () {
yield* FiberMap.clear(watches)
const skills = new Map<Skill.ID, Skill.Info>()
const current = sources()
for (const source of current) {
for (const skill of yield* load(source)) skills.set(skill.id, skill)
}
loaded.skills = Array.from(skills.values())
if (file) {
yield* Effect.logInfo("skills rescanned", {
file,
sources: current.map(Skill.Source.key),
skills: loaded.skills.map((skill) => skill.id),
})
}
}),
)
})
yield* Stream.fromPubSub(changes).pipe(
Stream.runForEach((file) => refresh(file).pipe(Effect.andThen(ctx.skill.reload()))),
Effect.forkScoped({ startImmediately: true }),
)
yield* refresh()
yield* ctx.skill.transform((draft) => {
for (const skill of observer.list()) draft.add(skill)
for (const skill of loaded.skills) draft.add(skill)
})
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() =>
config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(observer.refresh()),
Effect.andThen(refresh()),
Effect.andThen(ctx.skill.reload()),
),
),
-143
View File
@@ -1,143 +0,0 @@
export * as SkillSourceObserver from "./source-observer.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import path from "path"
import { Effect, FiberMap, PubSub, Semaphore, Stream } from "effect"
import { Watcher } from "../filesystem/watcher.js"
import { AbsolutePath } from "../schema.js"
import { Skill } from "../skill.js"
import { SkillDiscovery } from "./discovery.js"
import { SkillFile } from "../config/plugin/skill-file.js"
type Source = Skill.DirectorySource | Skill.UrlSource
// Sources are read inside each rescan; the caller owns their interpretation and
// publishes domain updates after filesystem-triggered snapshots are committed.
export const make = Effect.fn("SkillSourceObserver.make")(function* (input: {
readonly sources: () => readonly Source[]
readonly onChange: () => Effect.Effect<void>
}) {
const discovery = yield* SkillDiscovery.Service
const fs = yield* FSUtil.Service
const watcher = yield* Watcher.Service
const loaded: { skills: Skill.Info[] } = { skills: [] }
const watches = yield* FiberMap.make<string>()
const changes = yield* PubSub.sliding<string>(1)
const lock = Semaphore.makeUnsafe(1)
const watch = Effect.fn("SkillSourceObserver.watch")(function* (directory: string, type: Watcher.WatchInput["type"]) {
const target = path.resolve(directory)
const updates = yield* watcher.subscribe({ path: target, type })
yield* FiberMap.run(
watches,
`${type}:${target}`,
updates.pipe(Stream.runForEach((update) => PubSub.publish(changes, update.path).pipe(Effect.asVoid))),
{ onlyIfMissing: true, startImmediately: true },
)
})
function firstMissing(target: string): Effect.Effect<string | undefined> {
const parent = path.dirname(target)
if (parent === target) return Effect.undefined
return fs.isDir(parent).pipe(Effect.flatMap((exists) => (exists ? Effect.succeed(target) : firstMissing(parent))))
}
const watchDirectory: (directory: string) => Effect.Effect<string[]> = Effect.fn(
"SkillSourceObserver.watchDirectory",
)(function* (directory: string) {
const target = path.resolve(directory)
const resolved = yield* fs.realPath(directory).pipe(Effect.orElseSucceed(() => undefined))
if (resolved) {
yield* watch(resolved, "directory")
if (resolved !== target) yield* watch(target, "file")
return resolved === target ? [target] : [target, resolved]
}
const missing = yield* firstMissing(target)
if (missing) yield* watch(missing, "file")
if (
yield* fs.realPath(directory).pipe(
Effect.as(true),
Effect.orElseSucceed(() => false),
)
) {
if (missing) yield* FiberMap.remove(watches, `file:${path.resolve(missing)}`)
return yield* watchDirectory(directory)
}
return [target]
})
const load = Effect.fn("SkillSourceObserver.load")(function* (source: Source) {
const directories =
source.type === "directory"
? [source.path]
: yield* discovery.pull(source.url).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to load skill source", {
source: Skill.Source.key(source),
cause,
}).pipe(Effect.as([] as AbsolutePath[])),
),
)
const roots = (yield* Effect.forEach(directories, watchDirectory)).flat()
const skills: Skill.Info[] = []
for (const directory of directories) {
const files = yield* fs
.scan("{*.md,**/SKILL.md}", { cwd: directory, absolute: true, include: "file", symlink: true, dot: true })
.pipe(Effect.orElseSucceed(() => [] as string[]))
for (const filepath of files.toSorted()) {
const resolved = yield* fs.realPath(filepath).pipe(Effect.orElseSucceed(() => filepath))
if (!roots.some((root) => FSUtil.contains(root, resolved))) yield* watch(path.dirname(resolved), "directory")
const content = yield* fs.readFileStringSafe(filepath).pipe(Effect.orElseSucceed(() => undefined))
if (!content) continue
const parsed = SkillFile.parse(directory, filepath, content)
if (parsed._tag === "Skipped") {
yield* Effect.logDebug("skill file skipped", {
filepath,
reason: parsed.reason,
...(parsed.reason === "frontmatter" ? { issue: parsed.issue } : {}),
})
continue
}
skills.push(parsed.skill)
}
}
yield* Effect.logDebug("skill source loaded", {
source: Skill.Source.key(source),
type: source.type,
directories,
skills: skills.map((skill) => skill.id),
})
return skills
})
const refresh = Effect.fn("SkillSourceObserver.refresh")(function* (file?: string) {
yield* lock.withPermit(
Effect.gen(function* () {
yield* FiberMap.clear(watches)
const skills = new Map<Skill.ID, Skill.Info>()
const current = input.sources()
for (const source of current) {
for (const skill of yield* load(source)) skills.set(skill.id, skill)
}
loaded.skills = Array.from(skills.values())
if (file) {
yield* Effect.logInfo("skills rescanned", {
file,
sources: current.map(Skill.Source.key),
skills: loaded.skills.map((skill) => skill.id),
})
}
}),
)
})
yield* Stream.fromPubSub(changes).pipe(
Stream.runForEach((file) => refresh(file).pipe(Effect.andThen(() => input.onChange()))),
Effect.forkScoped({ startImmediately: true }),
)
yield* refresh()
return {
list: (): readonly Skill.Info[] => loaded.skills,
refresh: () => refresh(),
}
})
@@ -0,0 +1,215 @@
import { describe, expect } from "bun:test"
import path from "path"
import { DateTime, Effect, Layer } from "effect"
import { CommandInvocation } from "@opencode-ai/core/command/invocation"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Location } from "@opencode-ai/core/location"
import { ShellSelect } from "@opencode-ai/core/shell/select"
import { Agent } from "@opencode-ai/schema/agent"
import { ConfigCommand } from "@opencode-ai/schema/config/command"
import { Model } from "@opencode-ai/schema/model"
import { Money } from "@opencode-ai/schema/money"
import { Provider } from "@opencode-ai/schema/provider"
import { Session } from "@opencode-ai/schema/session"
import { SessionInbox } from "@opencode-ai/schema/session-inbox"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { AppProcess } from "@opencode-ai/util/process"
import { tempLocationLayer } from "../fixture/location"
import { testEffect } from "../lib/effect"
import { host } from "../plugin/host"
const shell = ShellSelect.Service.of({
resolve: (input) =>
Effect.sync(() => {
expect(input).toEqual({ priority: "config" })
return "sh"
}),
transform: () => Effect.die("unused shell.transform"),
reload: () => Effect.die("unused shell.reload"),
})
const it = testEffect(
Layer.mergeAll(AppNodeBuilder.build(AppProcess.node), tempLocationLayer, Layer.succeed(ShellSelect.Service, shell)),
)
const sessionID = Session.ID.make("ses_command_invocation")
describe("CommandInvocation", () => {
it.effect("expands arguments without changing unconfigured session defaults or prompt attachments", () =>
Effect.gen(function* () {
const prompts: unknown[] = []
const invoke = yield* CommandInvocation.make(promptHost(prompts))
const files = [{ uri: "file:///context.md", name: "context" }]
for (const [template, text, expected] of [
[
"$2 / $1 / $2",
`"alpha beta" 'gamma delta' [Image 3] tail`,
"gamma delta [Image 3] tail / alpha beta / gamma delta [Image 3] tail",
],
["[$1][$3]", "one two", "[one][]"],
["raw [$ARGUMENTS]", `"alpha beta" 'gamma delta'`, `raw ["alpha beta" 'gamma delta']`],
[" Review ", " details ", "Review \n\n details"],
[" Review ", " ", "Review"],
]) {
expect(
yield* invoke(new ConfigCommand.Info({ template }), {
sessionID,
prompt: { text, files },
delivery: "queue",
}),
).toBeUndefined()
expect(prompts.at(-1)).toEqual({ sessionID, text: expected, files, delivery: "queue" })
}
}),
)
it.effect("switches agents before applying command or agent model defaults and admitting the prompt", () =>
Effect.gen(function* () {
const calls: unknown[] = []
const ctx = promptHost(calls)
const location = yield* Location.Service
const reviewer = Agent.ID.make("reviewer")
const agentModel = { id: Model.ID.make("agent-model"), providerID: Provider.ID.make("example") }
const commandModel = {
model: Model.ID.make("command-model"),
providerID: Provider.ID.make("example"),
variant: Model.VariantID.make("careful"),
}
const session = Session.Info.make({
id: sessionID,
projectID: location.project.id,
agent: Agent.ID.make("build"),
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: { directory: location.directory },
})
for (const testCase of [
{
currentAgent: session.agent,
agentModel,
command: new ConfigCommand.Info({ template: "Review", agent: reviewer, model: commandModel }),
expected: [
["session.get", { sessionID }],
["switchAgent", { sessionID, agent: reviewer }],
["agent.get", { agentID: reviewer }],
["switchModel", { sessionID, model: { id: "command-model", providerID: "example", variant: "careful" } }],
],
},
{
currentAgent: reviewer,
agentModel,
command: new ConfigCommand.Info({ template: "Review", agent: reviewer }),
expected: [
["session.get", { sessionID }],
["agent.get", { agentID: reviewer }],
["switchModel", { sessionID, model: agentModel }],
],
},
{
currentAgent: session.agent,
agentModel: undefined,
command: new ConfigCommand.Info({ template: "Review", agent: reviewer }),
expected: [
["session.get", { sessionID }],
["switchAgent", { sessionID, agent: reviewer }],
["agent.get", { agentID: reviewer }],
],
},
{
currentAgent: session.agent,
agentModel,
command: new ConfigCommand.Info({
template: "Review",
model: { model: commandModel.model, providerID: commandModel.providerID },
}),
expected: [["switchModel", { sessionID, model: { id: "command-model", providerID: "example" } }]],
},
]) {
calls.length = 0
const invoke = yield* CommandInvocation.make(
host({
agent: {
...ctx.agent,
get: (input) =>
Effect.sync(() => {
calls.push(["agent.get", input])
return { location, data: { ...Agent.Info.default(reviewer), model: testCase.agentModel } }
}),
},
session: {
...ctx.session,
get: (input) =>
Effect.sync(() => {
calls.push(["session.get", input])
return { ...session, agent: testCase.currentAgent }
}),
switchAgent: (input) => Effect.sync(() => calls.push(["switchAgent", input])),
switchModel: (input) => Effect.sync(() => calls.push(["switchModel", input])),
},
}),
)
yield* invoke(testCase.command, {
sessionID,
prompt: { text: "" },
delivery: "steer",
})
expect(calls).toEqual([...testCase.expected, { sessionID, text: "Review", delivery: "steer" }])
}
}),
)
it.live("interpolates in source order using the location, closed stdin and nonzero-exit output", () =>
Effect.gen(function* () {
const prompts: unknown[] = []
const location = yield* Location.Service
yield* Effect.promise(() => Bun.write(path.join(location.directory, "context.txt"), "context"))
const invoke = yield* CommandInvocation.make(promptHost(prompts))
yield* invoke(
new ConfigCommand.Info({
template:
'first=!`read value || printf closed-; cat context.txt; sleep 0.05; printf "%s" "-stderr" >&2; exit 7`; second=!`printf "%s" "$1"`',
}),
{ sessionID, prompt: { text: "argument" }, delivery: "steer" },
)
expect(prompts).toEqual([{ sessionID, text: "first=closed-context-stderr; second=argument", delivery: "steer" }])
}),
)
it.live("wraps process failures with the shell source and does not admit a prompt", () =>
Effect.gen(function* () {
const prompts: unknown[] = []
const location = yield* Location.Service
const missing = path.join(location.directory, "missing-shell")
const invoke = yield* CommandInvocation.make(promptHost(prompts)).pipe(
Effect.provideService(ShellSelect.Service, { ...shell, resolve: () => Effect.succeed(missing) }),
)
const error = yield* invoke(new ConfigCommand.Info({ template: '!`printf "hello"`' }), {
sessionID,
prompt: { text: "" },
delivery: "steer",
}).pipe(Effect.flip)
expect(error).toBeInstanceOf(Error)
expect(String(error)).toContain('Shell interpolation failed for "printf \\"hello\\"": Command failed:')
expect(String(error)).toContain(missing)
expect(prompts).toEqual([])
}),
)
})
function promptHost(prompts: unknown[]) {
return host({
session: {
prompt: (input) =>
Effect.sync(() => {
prompts.push(input)
return SessionInbox.User.make({
id: SessionMessage.ID.make("msg_command_invocation"),
sessionID: input.sessionID,
timeCreated: DateTime.makeUnsafe(0),
type: "user",
payload: { text: input.text },
delivery: input.delivery ?? "steer",
})
}),
},
})
}
-53
View File
@@ -9,7 +9,6 @@ import {
Directory as ConfigDirectory,
Document,
type Entry,
Event,
Info,
} from "@opencode-ai/schema/config"
import { ConfigSkillPlugin } from "@opencode-ai/core/config/plugin/skill"
@@ -180,58 +179,6 @@ metadata:
})
describe("ConfigSkillPlugin.Plugin", () => {
it.live("reinterprets config entries before refreshing and publishing skills", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir()))
const first = path.join(tmp.path, "first")
const second = path.join(tmp.path, "second")
yield* Effect.promise(async () => {
await fs.mkdir(path.join(first, "review"), { recursive: true })
await fs.mkdir(path.join(second, "deploy"), { recursive: true })
await write(first, "review", "First")
await write(second, "deploy", "Second")
})
yield* Effect.gen(function* () {
const config = yield* Config.Test
const skill = yield* Skill.Service
const bus = yield* Bus.Service
const watcher = yield* Watcher.Test
yield* ConfigSkillPlugin.Plugin.effect(
host({
skill: {
list: () => Effect.die("unused skill.list"),
transform: skill.transform,
reload: skill.reload,
},
event: { subscribe: () => bus.subscribe(Event.Updated) },
}),
).pipe(
Effect.provideService(Global.Service, Global.Service.of({ ...Global.make(), home: tmp.path })),
Effect.provideService(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(tmp.path) })),
),
)
expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("review")])
const updated = yield* Deferred.make<Skill.Info[]>()
yield* bus.subscribe(Skill.Event.Updated).pipe(
Stream.runForEach(() => skill.list().pipe(Effect.flatMap((skills) => Deferred.succeed(updated, skills)))),
Effect.forkScoped({ startImmediately: true }),
)
yield* config.setEntries([new Document({ type: "document", info: decode({ skills: [second] }) })])
yield* bus.publish(Event.Updated, {})
expect(yield* Deferred.await(updated).pipe(Effect.timeout("2 seconds"))).toMatchObject([
{ id: "deploy", description: "Second" },
])
expect(yield* watcher.subscriptions()).toEqual([
{ path: first, type: "directory" },
{ path: second, type: "directory" },
])
}).pipe(Effect.provide(Config.testLayer([new Document({ type: "document", info: decode({ skills: [first] }) })])))
}),
)
it.live("maps config entry types to skill directories", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
@@ -1,167 +0,0 @@
import fs from "fs/promises"
import path from "path"
import { createServer } from "node:http"
import { describe, expect } from "bun:test"
import { NodeHttpServer } from "@effect/platform-node"
import { Deferred, Effect, Layer } from "effect"
import { HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Skill } from "@opencode-ai/core/skill"
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
import { SkillSourceObserver } from "@opencode-ai/core/skill/source-observer"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { tmpdir } from "./fixture/tmpdir"
import { it } from "./lib/effect"
describe("SkillSourceObserver", () => {
it.live("rebuilds watches on every refresh and releases them when the observer scope closes", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir()))
const first = path.join(tmp.path, "first")
const second = path.join(tmp.path, "second")
yield* Effect.promise(async () => {
await fs.mkdir(first)
await fs.mkdir(second)
await fs.writeFile(path.join(first, "review.md"), "# First")
await fs.writeFile(path.join(second, "deploy.md"), "# Second")
})
const started: string[] = []
const stopped: string[] = []
const active = new Set<(update: Watcher.Update) => void>()
const native = Watcher.Native.of({
subscribe: (input) =>
Effect.sync(() => {
started.push(input.target)
active.add(input.publish)
return {
unsubscribe: () => {
stopped.push(input.target)
active.delete(input.publish)
return Promise.resolve()
},
}
}),
})
const current = {
sources: [
Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make(first) }),
Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make(first) }),
],
}
yield* Effect.gen(function* () {
yield* Effect.gen(function* () {
const changed = yield* Deferred.make<readonly Skill.Info[]>()
const observer = yield* SkillSourceObserver.make({
sources: () => {
// Source interpretation still happens after the old watches are released.
expect(active.size).toBe(0)
return current.sources
},
onChange: (): Effect.Effect<void> => Deferred.succeed(changed, observer.list()).pipe(Effect.asVoid),
})
expect(observer.list().map((skill) => skill.id)).toEqual([Skill.ID.make("review")])
expect(started).toEqual([first])
expect(stopped).toEqual([])
expect(active.size).toBe(1)
yield* observer.refresh()
expect(started).toEqual([first, first])
expect(stopped).toEqual([first])
expect(active.size).toBe(1)
current.sources = [Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make(second) })]
yield* observer.refresh()
expect(observer.list().map((skill) => skill.id)).toEqual([Skill.ID.make("deploy")])
expect(started).toEqual([first, first, second])
expect(stopped).toEqual([first, first])
expect(yield* Deferred.isDone(changed)).toBe(false)
const snapshot = observer.list()
const file = path.join(second, "deploy.md")
yield* Effect.promise(() => fs.writeFile(file, "# Updated"))
yield* Effect.sync(() => active.forEach((publish) => publish({ path: file, type: "update" })))
expect(yield* Deferred.await(changed).pipe(Effect.timeout("2 seconds"))).toMatchObject([
{ id: "deploy", content: "# Updated" },
])
expect(observer.list()[0]?.content).toBe("# Updated")
expect(snapshot[0]?.content).toBe("# Second")
expect(started).toEqual([first, first, second, second])
expect(stopped).toEqual([first, first, second])
expect(active.size).toBe(1)
}).pipe(Effect.scoped)
// The Watcher layer remains alive; only the observer's consumers were disposed.
expect(active.size).toBe(0)
expect(stopped).toEqual(started)
}).pipe(
Effect.provide(Watcher.layer().pipe(Layer.provide(Layer.succeed(Watcher.Native, native)))),
Effect.provide(AppNodeBuilder.build(LayerNode.group([FSUtil.node, SkillDiscovery.node]))),
)
expect(stopped).toEqual(started)
}),
)
it.live("pulls URL sources through SkillDiscovery on manual and filesystem refreshes", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir()))
const catalog = { version: "1", content: "# First", requests: [] as string[] }
const server = yield* NodeHttpServer.make(createServer, { host: "127.0.0.1", port: 0 })
const base = new URL("/catalog/", HttpServer.formatAddress(server.address)).href
yield* server.serve(
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
catalog.requests.push(request.url)
if (request.url === "/catalog/index.json") {
return HttpServerResponse.text(
JSON.stringify({ skills: [{ name: "review", version: catalog.version, files: ["SKILL.md"] }] }),
)
}
return HttpServerResponse.text(catalog.content)
}),
)
yield* Effect.gen(function* () {
const changed = yield* Deferred.make<void>()
const observer = yield* SkillSourceObserver.make({
sources: () => [Skill.UrlSource.make({ type: "url", url: base })],
onChange: () => Deferred.succeed(changed, undefined).pipe(Effect.asVoid),
})
expect(observer.list()).toMatchObject([{ id: "review", content: "# First" }])
expect(FSUtil.contains(tmp.path, observer.list()[0].location)).toBe(true)
catalog.version = "2"
catalog.content = "# Second"
yield* observer.refresh()
expect(observer.list()).toMatchObject([{ id: "review", content: "# Second" }])
expect(yield* Deferred.isDone(changed)).toBe(false)
catalog.version = "3"
catalog.content = "# Third"
const watcher = yield* Watcher.Test
yield* watcher.emit({ path: observer.list()[0].location, type: "update" })
yield* Deferred.await(changed).pipe(Effect.timeout("2 seconds"))
expect(observer.list()).toMatchObject([{ id: "review", content: "# Third" }])
expect(catalog.requests).toEqual([
"/catalog/index.json",
"/catalog/review/SKILL.md",
"/catalog/index.json",
"/catalog/review/SKILL.md",
"/catalog/index.json",
"/catalog/review/SKILL.md",
])
}).pipe(
Effect.provide(
Layer.mergeAll(
AppNodeBuilder.build(LayerNode.group([FSUtil.node, SkillDiscovery.node]), [
[Global.node, Global.layerWith({ cache: tmp.path })],
]),
Watcher.testLayer,
),
),
)
}),
)
})