mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-11 03:59:54 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ed473870ab | |||
| 60a24a760b |
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { type Virtualizer } from "@tanstack/solid-virtual"
|
||||
import { Node, Window } from "happy-dom"
|
||||
import { Window } from "happy-dom"
|
||||
import { mutationNodesContainElement, observeElementOffsetReconnectAware } from "./observe-element-offset"
|
||||
|
||||
test("matches only the scroll element or an ancestor containing it", () => {
|
||||
@@ -18,7 +18,6 @@ test("matches only the scroll element or an ancestor containing it", () => {
|
||||
|
||||
test("reports a divergent native offset once and ignores equal offsets and unrelated mutations", async () => {
|
||||
const targetWindow = new Window()
|
||||
const mutations = controlledMutations(targetWindow)
|
||||
const route = targetWindow.document.createElement("section")
|
||||
const viewport = targetWindow.document.createElement("div")
|
||||
const unrelated = targetWindow.document.createElement("div")
|
||||
@@ -41,24 +40,24 @@ test("reports a divergent native offset once and ignores equal offsets and unrel
|
||||
instance.scrollOffset = offset
|
||||
})
|
||||
|
||||
try {
|
||||
mutations.append(targetWindow.document.body, unrelated)
|
||||
mutations.remove(unrelated)
|
||||
expect(calls).toEqual([])
|
||||
targetWindow.document.body.append(unrelated)
|
||||
unrelated.remove()
|
||||
await frames(2, targetWindow)
|
||||
expect(calls).toEqual([])
|
||||
|
||||
mutations.remove(route)
|
||||
mutations.append(targetWindow.document.body, route)
|
||||
await frames(2, targetWindow)
|
||||
expect(calls).toEqual([[0, false]])
|
||||
route.remove()
|
||||
targetWindow.document.body.append(route)
|
||||
await waitFor(() => calls.length === 1, targetWindow)
|
||||
expect(calls).toEqual([[0, false]])
|
||||
|
||||
mutations.remove(route)
|
||||
mutations.append(targetWindow.document.body, route)
|
||||
await frames(2, targetWindow)
|
||||
expect(calls).toEqual([[0, false]])
|
||||
} finally {
|
||||
cleanup?.()
|
||||
await targetWindow.happyDOM.close()
|
||||
}
|
||||
route.remove()
|
||||
targetWindow.document.body.append(route)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await frames(3, targetWindow)
|
||||
expect(calls).toEqual([[0, false]])
|
||||
|
||||
cleanup?.()
|
||||
await targetWindow.happyDOM.close()
|
||||
})
|
||||
|
||||
test("keeps checking until stale reset-delay callbacks can no longer win", async () => {
|
||||
@@ -205,33 +204,7 @@ async function frames(count: number, targetWindow: FrameWindow = window) {
|
||||
}
|
||||
}
|
||||
|
||||
function controlledMutations(targetWindow: Window) {
|
||||
let emit: (record: MutationRecord) => void = () => {
|
||||
throw new Error("Mutation observer is not active")
|
||||
}
|
||||
class ControlledMutationObserver {
|
||||
constructor(callback: MutationCallback) {
|
||||
emit = (record) => callback([record], this as unknown as MutationObserver)
|
||||
}
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return []
|
||||
}
|
||||
}
|
||||
Object.defineProperty(targetWindow, "MutationObserver", { value: ControlledMutationObserver })
|
||||
const record = (target: Node, addedNodes: Node[], removedNodes: Node[]) =>
|
||||
({ type: "childList", target, addedNodes, removedNodes }) as unknown as MutationRecord
|
||||
return {
|
||||
append(parent: Node, node: Node) {
|
||||
parent.appendChild(node)
|
||||
emit(record(parent, [node], []))
|
||||
},
|
||||
remove(node: Node) {
|
||||
const parent = node.parentNode
|
||||
if (!parent) throw new Error("Mutation target has no parent")
|
||||
parent.removeChild(node)
|
||||
emit(record(parent, [], [node]))
|
||||
},
|
||||
}
|
||||
async function waitFor(condition: () => boolean, targetWindow: FrameWindow = window) {
|
||||
const deadline = targetWindow.performance.now() + 1_000
|
||||
while (!condition() && targetWindow.performance.now() < deadline) await frames(1, targetWindow)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Runtime } from "../../framework/runtime"
|
||||
import { ServerConnection } from "../../services/server-connection"
|
||||
import { Config } from "../../config"
|
||||
import { resolve } from "@opencode-ai/tui/config"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
|
||||
export default Runtime.handler(Commands.commands.mini, (input) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -17,7 +16,6 @@ export default Runtime.handler(Commands.commands.mini, (input) =>
|
||||
mismatch: "replace",
|
||||
})
|
||||
const config = yield* Config.Service
|
||||
const global = yield* Global.Service
|
||||
const resolved = resolve(yield* config.get(), { terminalSuspend: process.platform !== "win32" })
|
||||
const fileSystem = yield* FileSystem.FileSystem
|
||||
const runServicePromise = Effect.runPromiseWith(Context.make(FileSystem.FileSystem, fileSystem))
|
||||
@@ -41,7 +39,6 @@ export default Runtime.handler(Commands.commands.mini, (input) =>
|
||||
config: {
|
||||
update: (update) => runServicePromise(config.update(update)),
|
||||
},
|
||||
paths: { home: global.home, state: global.state, log: global.log },
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { MiniFrontendInput } from "@opencode-ai/tui/mini"
|
||||
import { createModelPreferenceRepository } from "@opencode-ai/tui/model-preference"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import fs from "node:fs"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
@@ -128,9 +129,13 @@ export async function usingInteractiveStdin<T>(
|
||||
export function createMiniHost(input: {
|
||||
terminal: InteractiveStdin
|
||||
directory: string
|
||||
paths: { home: string; state: string; log: string }
|
||||
paths?: { home: string; state: string; log: string }
|
||||
}): MiniHost {
|
||||
const paths = input.paths
|
||||
const paths = input.paths ?? {
|
||||
home: Global.Path.home,
|
||||
state: Global.Path.state,
|
||||
log: Global.Path.log,
|
||||
}
|
||||
const diagnostics = {
|
||||
pid: process.pid,
|
||||
cwd: input.directory,
|
||||
|
||||
@@ -22,7 +22,6 @@ export type MiniCommandInput = {
|
||||
demo?: boolean
|
||||
tuiConfig?: MiniFrontendInput["tuiConfig"]
|
||||
config?: MiniFrontendInput["config"]
|
||||
paths: { home: string; state: string; log: string }
|
||||
}
|
||||
|
||||
type Model = MiniFrontendInput["model"]
|
||||
@@ -105,7 +104,7 @@ export async function runMini(input: MiniCommandInput) {
|
||||
}))
|
||||
const frontend = await frontendTask
|
||||
return frontend.runMiniFrontend({
|
||||
host: createMiniHost({ terminal, directory, paths: input.paths }),
|
||||
host: createMiniHost({ terminal, directory }),
|
||||
sdk,
|
||||
directory,
|
||||
target: resolveTarget,
|
||||
|
||||
@@ -39,8 +39,7 @@ export const run = Effect.fnUntraced(function* (options: Options) {
|
||||
})
|
||||
|
||||
const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
const global = yield* Global.Service
|
||||
if (options.mode === "service") yield* Effect.sync(() => process.chdir(global.home))
|
||||
if (options.mode === "service") yield* Effect.sync(() => process.chdir(Global.Path.home))
|
||||
return yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const serviceOptions = options.mode === "service" ? yield* ServiceConfig.options() : undefined
|
||||
|
||||
@@ -11,9 +11,7 @@ import { createAcpFixture, expectOk, initialize, newSession, selectConfigOption
|
||||
describe("acp lifecycle subprocess", () => {
|
||||
test("stdin EOF exits cleanly", async () => {
|
||||
await using fixture = await createAcpFixture()
|
||||
const acp = fixture.spawn()
|
||||
await initialize(acp)
|
||||
expect(await acp.close()).toBe(0)
|
||||
expect(await fixture.spawn().close()).toBe(0)
|
||||
}, 60_000)
|
||||
|
||||
test("close capability and close request", async () => {
|
||||
|
||||
@@ -41,7 +41,6 @@ export type SessionMessageAgentSelected = {
|
||||
time: { created: number }
|
||||
type: "agent-switched"
|
||||
agent: string
|
||||
previous?: string
|
||||
}
|
||||
|
||||
export type PromptBase64 = string
|
||||
@@ -2536,7 +2535,6 @@ export type SessionImportInput = {
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "agent-switched"
|
||||
readonly agent: string
|
||||
readonly previous?: string
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
@@ -2788,7 +2786,6 @@ export type SessionImportInput = {
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "agent-switched"
|
||||
readonly agent: string
|
||||
readonly previous?: string
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
@@ -3040,7 +3037,6 @@ export type SessionImportInput = {
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "agent-switched"
|
||||
readonly agent: string
|
||||
readonly previous?: string
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
|
||||
@@ -11,7 +11,6 @@ import { ChildProcess } from "effect/unstable/process"
|
||||
import { Config } from "./config"
|
||||
import { Location } from "./location"
|
||||
import { ShellSelect } from "./shell/select"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
|
||||
export const Info = Command.Info
|
||||
export type Info = Command.Info
|
||||
@@ -62,7 +61,6 @@ export const layer = (options?: ShellSelect.Options) =>
|
||||
const processes = yield* AppProcess.Service
|
||||
const config = yield* Config.Service
|
||||
const location = yield* Location.Service
|
||||
const global = yield* Global.Service
|
||||
const state = State.create<Data, Draft>({
|
||||
name: "command",
|
||||
initial: () => ({ commands: new Map() }),
|
||||
@@ -113,7 +111,6 @@ export const layer = (options?: ShellSelect.Options) =>
|
||||
location,
|
||||
processes,
|
||||
shell: options,
|
||||
bin: global.bin,
|
||||
})
|
||||
|
||||
const prompt = (yield* mcp.prompts()).find(
|
||||
@@ -167,7 +164,6 @@ function evaluateTemplate(
|
||||
readonly location: Location.Info
|
||||
readonly processes: AppProcess.Interface
|
||||
readonly shell?: ShellSelect.Options
|
||||
readonly bin: string
|
||||
},
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
@@ -201,16 +197,11 @@ const evaluateShell = Effect.fnUntraced(function* (
|
||||
readonly location: Location.Info
|
||||
readonly processes: AppProcess.Interface
|
||||
readonly shell?: ShellSelect.Options
|
||||
readonly bin: string
|
||||
},
|
||||
) {
|
||||
const matches = Array.from(text.matchAll(shellRegex))
|
||||
if (matches.length === 0) return text
|
||||
const shell = ShellSelect.preferred(
|
||||
Config.latest(yield* services.config.entries(), "shell"),
|
||||
services.shell,
|
||||
services.bin,
|
||||
)
|
||||
const shell = ShellSelect.preferred(Config.latest(yield* services.config.entries(), "shell"), services.shell)
|
||||
const outputs = yield* Effect.forEach(
|
||||
matches,
|
||||
(match) => {
|
||||
@@ -271,7 +262,7 @@ export function configured(options?: ShellSelect.Options) {
|
||||
return makeLocationNode({
|
||||
service: Service,
|
||||
layer: layer(options),
|
||||
deps: [MCP.node, Bus.node, AppProcess.node, Config.node, Location.node, Global.node],
|
||||
deps: [MCP.node, Bus.node, AppProcess.node, Config.node, Location.node],
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
export * as ConfigInstructionPlugin from "./instruction"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { dirname, join } from "path"
|
||||
import { Effect, PubSub, Semaphore, Stream } from "effect"
|
||||
import { Watcher } from "../../filesystem/watcher"
|
||||
import { InstructionDiscovery } from "../../instruction-discovery"
|
||||
import { Instructions } from "../../instructions/index"
|
||||
import { Location } from "../../location"
|
||||
import { AbsolutePath } from "../../schema"
|
||||
|
||||
type Loaded =
|
||||
| { readonly type: "available"; readonly files: InstructionDiscovery.File[] }
|
||||
| { readonly type: "unavailable" }
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.config.instruction",
|
||||
effect: Effect.fn(function* () {
|
||||
const discovery = yield* InstructionDiscovery.Service
|
||||
yield* Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const location = yield* Location.Service
|
||||
const watcher = yield* Watcher.Service
|
||||
const changes = yield* PubSub.sliding<string>(1)
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
const start = yield* fs.resolve(location.directory)
|
||||
const stop = yield* fs.resolve(location.project.directory)
|
||||
const project = discovery.project && FSUtil.contains(stop, start)
|
||||
const globalFile = yield* fs.resolve(join(global.config, "AGENTS.md"))
|
||||
const loaded: { current: Loaded } = { current: { type: "available", files: [] } }
|
||||
|
||||
const publish = (update: Watcher.Update) => PubSub.publish(changes, update.path).pipe(Effect.asVoid)
|
||||
const candidates = [
|
||||
globalFile,
|
||||
...(project ? ancestorDirectories(start, stop).map((directory) => join(directory, "AGENTS.md")) : []),
|
||||
]
|
||||
for (const path of new Set(candidates)) {
|
||||
const updates = yield* watcher.subscribe({ path, type: "file" })
|
||||
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped({ startImmediately: true }))
|
||||
}
|
||||
|
||||
const read = Effect.fn("ConfigInstructionPlugin.read")(function* (path: string) {
|
||||
const content = yield* fs.readFileStringSafe(path)
|
||||
if (content !== undefined) return new InstructionDiscovery.File({ path: AbsolutePath.make(path), content })
|
||||
yield* Effect.logDebug("instruction file skipped", { path, reason: "unavailable" })
|
||||
})
|
||||
|
||||
const globalSource = Effect.fn("ConfigInstructionPlugin.globalSource")(function* () {
|
||||
const file = yield* read(globalFile)
|
||||
return file ? [file] : []
|
||||
})
|
||||
|
||||
const projectSource = Effect.fn("ConfigInstructionPlugin.projectSource")(function* () {
|
||||
if (!project) return []
|
||||
const discovered = new Set(
|
||||
yield* Effect.forEach(yield* fs.up({ targets: ["AGENTS.md"], start, stop }), fs.resolve),
|
||||
)
|
||||
const files = yield* Effect.forEach(discovered, read, { concurrency: "unbounded" })
|
||||
if (files.some((file) => file === undefined)) return Instructions.unavailable
|
||||
return files.filter((file): file is InstructionDiscovery.File => file !== undefined)
|
||||
})
|
||||
|
||||
const isolate = <A, E, R>(source: string, effect: Effect.Effect<A, E, R>) =>
|
||||
effect.pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("failed to load instruction source", { source, cause }).pipe(
|
||||
Effect.as(Instructions.unavailable),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const refresh = Effect.fn("ConfigInstructionPlugin.refresh")(function* (file?: string) {
|
||||
yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const sources = yield* Effect.all({
|
||||
global: isolate("global", globalSource()),
|
||||
project: isolate("project", projectSource()),
|
||||
})
|
||||
loaded.current =
|
||||
Array.isArray(sources.global) && Array.isArray(sources.project)
|
||||
? { type: "available", files: [...sources.global, ...sources.project] }
|
||||
: { type: "unavailable" }
|
||||
if (!file) return
|
||||
yield* Effect.logDebug("instructions rescanned", {
|
||||
file,
|
||||
instructions:
|
||||
loaded.current.type === "available" ? loaded.current.files.map((item) => item.path) : "unavailable",
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
yield* Stream.fromPubSub(changes).pipe(
|
||||
Stream.runForEach((file) => refresh(file).pipe(Effect.andThen(discovery.reload()))),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* refresh()
|
||||
yield* discovery.transform((draft) => {
|
||||
if (loaded.current.type === "unavailable") {
|
||||
draft.unavailable()
|
||||
return
|
||||
}
|
||||
for (const file of loaded.current.files) draft.add(file)
|
||||
})
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("failed to activate instruction source", { cause }).pipe(
|
||||
Effect.andThen(discovery.transform((draft) => draft.unavailable())),
|
||||
Effect.asVoid,
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
function ancestorDirectories(start: string, stop: string): string[] {
|
||||
if (start === stop) return [start]
|
||||
return [start, ...ancestorDirectories(dirname(start), stop)]
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
export * as SkillFile from "./skill-file"
|
||||
|
||||
import path from "path"
|
||||
import { Result, Schema, type SchemaIssue, SchemaParser } from "effect"
|
||||
import { ConfigMarkdown } from "../markdown"
|
||||
import { AbsolutePath } from "../../schema"
|
||||
import { Skill } from "../../skill"
|
||||
|
||||
const Frontmatter = Schema.Struct({
|
||||
name: Schema.String.pipe(Schema.optional),
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
slash: Schema.Boolean.pipe(Schema.optional),
|
||||
metadata: Schema.Unknown.pipe(Schema.optional),
|
||||
})
|
||||
const decodeFrontmatter = SchemaParser.decodeUnknownResult(Frontmatter)
|
||||
|
||||
export type ParseResult =
|
||||
| { readonly _tag: "Parsed"; readonly skill: Skill.Info }
|
||||
| { readonly _tag: "Skipped"; readonly reason: "markdown" }
|
||||
| { readonly _tag: "Skipped"; readonly reason: "frontmatter"; readonly issue: SchemaIssue.Issue }
|
||||
|
||||
const metadataBoolean = (metadata: unknown, key: string) => {
|
||||
if (metadata === undefined || metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) {
|
||||
return undefined
|
||||
}
|
||||
const value = Reflect.get(metadata, key)
|
||||
if (typeof value === "boolean") return value
|
||||
if (typeof value !== "string") return undefined
|
||||
const normalized = value.trim().toLowerCase()
|
||||
if (normalized === "true") return true
|
||||
if (normalized === "false") return false
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function parse(directory: string, filepath: string, content: string): ParseResult {
|
||||
const markdown = ConfigMarkdown.parseOption(content)
|
||||
if (!markdown) return { _tag: "Skipped", reason: "markdown" }
|
||||
const decoded = decodeFrontmatter(markdown.data)
|
||||
if (Result.isFailure(decoded)) return { _tag: "Skipped", reason: "frontmatter", issue: decoded.failure }
|
||||
const frontmatter = decoded.success
|
||||
const id =
|
||||
path.dirname(filepath) === directory ? path.basename(filepath, ".md") : path.basename(path.dirname(filepath))
|
||||
const slash = metadataBoolean(frontmatter.metadata, "opencode/slash") ?? frontmatter.slash
|
||||
const autoinvoke = metadataBoolean(frontmatter.metadata, "opencode/autoinvoke")
|
||||
return {
|
||||
_tag: "Parsed",
|
||||
skill: {
|
||||
id: Skill.ID.make(id),
|
||||
name: Skill.Name.make(frontmatter.name ?? id),
|
||||
...(frontmatter.description === undefined ? {} : { description: frontmatter.description }),
|
||||
...(slash === undefined ? {} : { slash }),
|
||||
...(autoinvoke === undefined ? {} : { autoinvoke }),
|
||||
location: AbsolutePath.make(filepath),
|
||||
content: markdown.content,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,191 +1,64 @@
|
||||
export * as ConfigSkillPlugin from "./skill"
|
||||
|
||||
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, FiberMap, PubSub, Semaphore, Stream } from "effect"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Config } from "../../config"
|
||||
import { Watcher } from "../../filesystem/watcher"
|
||||
import { Location } from "../../location"
|
||||
import { AbsolutePath } from "../../schema"
|
||||
import { Skill } from "../../skill"
|
||||
import { SkillDiscovery } from "../../skill/discovery"
|
||||
import { SkillFile } from "./skill-file"
|
||||
|
||||
type Source = Skill.DirectorySource | Skill.UrlSource
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Location } from "../../location"
|
||||
|
||||
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 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.succeed(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.catch(() => Effect.succeed(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.catch(() => Effect.succeed(false)),
|
||||
)
|
||||
) {
|
||||
if (missing) yield* FiberMap.remove(watches, `file:${path.resolve(missing)}`)
|
||||
return yield* watchDirectory(directory)
|
||||
}
|
||||
return [target]
|
||||
})
|
||||
|
||||
const sources = () => {
|
||||
const result: Source[] = []
|
||||
const add = (source: Source) => {
|
||||
if (result.some((item) => Skill.Source.equals(item, source))) return
|
||||
result.push(source)
|
||||
}
|
||||
const loaded = { entries: yield* config.entries() }
|
||||
yield* ctx.skill.transform((draft) => {
|
||||
const claude = loaded.entries.flatMap((entry) => (entry.type === "claude" ? [entry.path] : []))
|
||||
const agents = loaded.entries.flatMap((entry) => (entry.type === "agents" ? [entry.path] : []))
|
||||
const directories = loaded.entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
|
||||
const items = loaded.entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : []))
|
||||
for (const directory of [...claude, ...agents]) {
|
||||
add(Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }))
|
||||
draft.source(
|
||||
Skill.DirectorySource.make({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.join(directory, "skills")),
|
||||
}),
|
||||
)
|
||||
}
|
||||
for (const directory of directories) {
|
||||
add(Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }))
|
||||
add(Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }))
|
||||
draft.source(
|
||||
Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }),
|
||||
)
|
||||
draft.source(
|
||||
Skill.DirectorySource.make({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.join(directory, "skills")),
|
||||
}),
|
||||
)
|
||||
}
|
||||
for (const item of items) {
|
||||
if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) {
|
||||
add(Skill.UrlSource.make({ type: "url", url: item }))
|
||||
draft.source(Skill.UrlSource.make({ type: "url", url: item }))
|
||||
continue
|
||||
}
|
||||
const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item
|
||||
add(
|
||||
draft.source(
|
||||
Skill.DirectorySource.make({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)),
|
||||
}),
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
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.catch(() => Effect.succeed([] as string[])))
|
||||
for (const filepath of files.toSorted()) {
|
||||
const resolved = yield* fs.realPath(filepath).pipe(Effect.catch(() => Effect.succeed(filepath)))
|
||||
if (!roots.some((root) => FSUtil.contains(root, resolved))) yield* watch(path.dirname(resolved), "directory")
|
||||
const content = yield* fs.readFileStringSafe(filepath).pipe(Effect.catch(() => Effect.succeed(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 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(refresh()),
|
||||
Effect.andThen(ctx.skill.reload()),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
export * as ConfigPluginSource from "./source"
|
||||
|
||||
import { Directory, Document, type Entry } from "@opencode-ai/schema/config"
|
||||
import { ConfigPlugin } from "@opencode-ai/schema/config/plugin"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, Effect, Layer, Option, PubSub, Scope, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { Config } from "../../config"
|
||||
import { Watcher } from "../../filesystem/watcher"
|
||||
import { Location } from "../../location"
|
||||
|
||||
export type Operation =
|
||||
| {
|
||||
readonly type: "add"
|
||||
readonly target: string
|
||||
readonly options: Record<string, unknown>
|
||||
readonly mtime?: number
|
||||
}
|
||||
| {
|
||||
readonly type: "remove"
|
||||
readonly target: string
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly operations: () => Effect.Effect<readonly Operation[], never, Scope.Scope>
|
||||
readonly changes: () => Stream.Stream<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ConfigPluginSource") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const watcher = yield* Watcher.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const configuredChanges = yield* PubSub.unbounded<void>()
|
||||
const watched = new Set<string>()
|
||||
|
||||
// Configured local plugin files can live outside config roots, where the
|
||||
// config change feed cannot see them; watch those entrypoints directly.
|
||||
// Watches start on first sighting and are never torn down individually:
|
||||
// a stale watch after a config edit costs one deduped fs handle and a
|
||||
// no-op activation, and every watch dies with this layer's scope.
|
||||
const watchConfiguredSources = Effect.fn("ConfigPluginSource.watchConfiguredSources")(function* (
|
||||
entries: readonly Entry[],
|
||||
operations: readonly Operation[],
|
||||
) {
|
||||
for (const operation of operations) {
|
||||
if (operation.type !== "add" || !path.isAbsolute(operation.target)) continue
|
||||
if (watched.has(operation.target)) continue
|
||||
// The config change feed already covers {plugin,plugins} directories.
|
||||
if (isPluginSource(entries, operation.target)) continue
|
||||
// Directory targets can't hot-reload (their stat mtime ignores edits
|
||||
// inside), so don't watch what can't trigger anything.
|
||||
if (yield* fs.isDir(operation.target)) continue
|
||||
watched.add(operation.target)
|
||||
const updates = yield* watcher.subscribe({ path: operation.target, type: "file" })
|
||||
yield* updates.pipe(
|
||||
Stream.runForEach(() => PubSub.publish(configuredChanges, undefined)),
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logError("configured plugin watch failed", { target: operation.target, cause }),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
operations: Effect.fn("ConfigPluginSource.operations")(function* () {
|
||||
const entries = yield* config.entries()
|
||||
const operations = yield* scan(fs, location, entries)
|
||||
yield* watchConfiguredSources(entries, operations)
|
||||
return operations
|
||||
}),
|
||||
changes: () =>
|
||||
Stream.merge(
|
||||
config.changes().pipe(
|
||||
Stream.filterEffect((update) =>
|
||||
Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path)),
|
||||
),
|
||||
Stream.map(() => undefined),
|
||||
),
|
||||
Stream.fromPubSub(configuredChanges),
|
||||
),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Config.node, FSUtil.node, Watcher.node, Location.node],
|
||||
})
|
||||
|
||||
export const empty = makeLocationNode({
|
||||
service: Service,
|
||||
layer: Layer.succeed(
|
||||
Service,
|
||||
Service.of({
|
||||
operations: () => Effect.succeed([]),
|
||||
changes: () => Stream.never,
|
||||
}),
|
||||
),
|
||||
deps: [],
|
||||
})
|
||||
|
||||
function parse(input: ConfigPlugin.Plugin): Operation {
|
||||
if (typeof input !== "string") {
|
||||
return { type: "add", target: input.package, options: input.options ?? {} }
|
||||
}
|
||||
if (!input.startsWith("-")) return { type: "add", target: input, options: {} }
|
||||
if (input.length === 1) throw new Error("Plugin remove operation requires a target")
|
||||
return { type: "remove", target: input.slice(1) }
|
||||
}
|
||||
|
||||
const scan = Effect.fn("ConfigPluginSource.scan")(function* (
|
||||
fs: FSUtil.Interface,
|
||||
location: Location.Interface,
|
||||
entries: readonly Entry[],
|
||||
) {
|
||||
const discovered = yield* Effect.forEach(
|
||||
entries.filter((entry): entry is Directory => entry.type === "directory"),
|
||||
(entry) => discoverDirectory(fs, entry.path),
|
||||
).pipe(Effect.map((items) => items.flat()))
|
||||
const configured = entries
|
||||
.filter((entry): entry is Document => entry.type === "document")
|
||||
.flatMap((entry) =>
|
||||
(entry.info.plugins ?? []).map(parse).map((operation) => {
|
||||
if (operation.type === "remove") return operation
|
||||
const directory = entry.path ? path.dirname(entry.path) : location.directory
|
||||
const target = operation.target.startsWith("file://")
|
||||
? fileURLToPath(operation.target)
|
||||
: operation.target.startsWith("./") || operation.target.startsWith("../")
|
||||
? path.resolve(directory, operation.target)
|
||||
: operation.target
|
||||
return { ...operation, target }
|
||||
}),
|
||||
)
|
||||
// Explicit config is applied last so it can remove auto-discovered packages.
|
||||
return yield* Effect.forEach([...discovered, ...configured], (operation) => {
|
||||
if (operation.type === "remove" || !path.isAbsolute(operation.target)) return Effect.succeed(operation)
|
||||
return fs.stat(operation.target).pipe(
|
||||
Effect.map((info) => ({
|
||||
...operation,
|
||||
mtime: Option.getOrElse(info.mtime, () => new Date(0)).getTime(),
|
||||
})),
|
||||
Effect.catch(() => Effect.succeed(operation)),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
function discoverDirectory(fs: FSUtil.Interface, directory: string) {
|
||||
return Effect.gen(function* () {
|
||||
const files = yield* fs
|
||||
.scan("{plugin,plugins}/*.{ts,js}", {
|
||||
cwd: directory,
|
||||
absolute: true,
|
||||
include: "file",
|
||||
dot: true,
|
||||
symlink: true,
|
||||
})
|
||||
.pipe(Effect.orElseSucceed(() => []))
|
||||
return files.sort().map((target): Operation => ({ type: "add", target, options: {} }))
|
||||
})
|
||||
}
|
||||
|
||||
const sourceDirectories = ["plugin", "plugins"] as const
|
||||
|
||||
function isPluginSource(entries: readonly Entry[], file: string) {
|
||||
return entries.some(
|
||||
(entry) =>
|
||||
entry.type === "directory" &&
|
||||
sourceDirectories.some((directory) => FSUtil.contains(path.join(entry.path, directory), file)),
|
||||
)
|
||||
}
|
||||
@@ -40,19 +40,16 @@ const databaseLayer = Layer.effect(
|
||||
)
|
||||
|
||||
export function layer(options: Options = { path: ":memory:" }) {
|
||||
return Layer.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const global = yield* Global.Service
|
||||
const provide = (filename: string) => databaseLayer.pipe(Layer.provide(sqliteLayer({ filename })))
|
||||
const filename = options.path ?? ":memory:"
|
||||
if (filename === ":memory:" || isAbsolute(filename)) return provide(filename)
|
||||
return provide(join(global.data, filename))
|
||||
}),
|
||||
)
|
||||
return Layer.suspend(() => {
|
||||
const provide = (filename: string) => databaseLayer.pipe(Layer.provide(sqliteLayer({ filename })))
|
||||
const filename = options.path ?? ":memory:"
|
||||
if (filename === ":memory:" || isAbsolute(filename)) return provide(filename)
|
||||
return provide(join(Global.Path.data, filename))
|
||||
})
|
||||
}
|
||||
|
||||
export function configured(options?: Options) {
|
||||
return makeGlobalNode({ service: Service, layer: layer(options), deps: [Global.node] })
|
||||
return makeGlobalNode({ service: Service, layer: layer(options), deps: [] })
|
||||
}
|
||||
|
||||
export const node = configured({ path: ":memory:" })
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Effect, Semaphore } from "effect"
|
||||
import type { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
|
||||
import { migrations } from "./migration.gen"
|
||||
import schema from "./schema.gen"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
|
||||
type Database = EffectDrizzleSqlite.EffectSQLiteDatabase
|
||||
type Transaction = Parameters<Parameters<Database["transaction"]>[0]>[0]
|
||||
@@ -14,7 +13,7 @@ const lock = Semaphore.makeUnsafe(1)
|
||||
export type Migration = {
|
||||
id: string
|
||||
foreignKeys?: boolean
|
||||
up: (tx: Transaction) => Effect.Effect<void, unknown, Global.Service>
|
||||
up: (tx: Transaction) => Effect.Effect<void, unknown>
|
||||
}
|
||||
|
||||
export function apply(db: Database) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { readFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { sql } from "drizzle-orm"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
@@ -34,10 +33,7 @@ const wellKnownSourcesKey = "wellknown:sources"
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260805200742_import_legacy_credentials",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
const global = yield* Global.Service
|
||||
return yield* importLegacyCredentials(tx, path.join(global.data, "auth.json"))
|
||||
})
|
||||
return importLegacyCredentials(tx, path.join(Global.Path.data, "auth.json"))
|
||||
},
|
||||
}
|
||||
|
||||
@@ -45,9 +41,9 @@ export default migration
|
||||
|
||||
export function importLegacyCredentials(tx: Parameters<DatabaseMigration.Migration["up"]>[0], filepath: string) {
|
||||
return Effect.gen(function* () {
|
||||
const content = yield* Effect.promise(() => readFile(filepath, "utf8").catch(() => undefined))
|
||||
if (content === undefined) return
|
||||
const input = Option.getOrUndefined(decodeJson(content))
|
||||
const file = Bun.file(filepath)
|
||||
if (!(yield* Effect.promise(() => file.exists()))) return
|
||||
const input = Option.getOrUndefined(decodeJson(yield* Effect.promise(() => file.text())))
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
||||
return yield* Effect.fail(new Error("Legacy credential file must contain an object"))
|
||||
}
|
||||
|
||||
@@ -467,11 +467,10 @@ function updateProgress(progress: Progress) {
|
||||
if (runtimeState.status === "running") runtimeState = { status: "running", progress }
|
||||
}
|
||||
|
||||
export function run(options: Options = {}): Effect.Effect<RunResult, never, Database.Service | Global.Service> {
|
||||
export function run(options: Options = {}): Effect.Effect<RunResult, never, Database.Service> {
|
||||
return lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
const global = yield* Global.Service
|
||||
const state = yield* readState(db)
|
||||
if (state?.phase === "completed") return { status: "completed" as const }
|
||||
if (!(yield* hasLegacySessions(db))) return { status: "completed" as const }
|
||||
@@ -479,7 +478,7 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
|
||||
const now = Date.now()
|
||||
yield* db.run(sql`
|
||||
INSERT OR IGNORE INTO project (id, worktree, time_created, time_updated, sandboxes)
|
||||
VALUES (${Project.ID.global}, ${path.parse(global.data).root}, ${now}, ${now}, '[]')
|
||||
VALUES (${Project.ID.global}, ${path.parse(Global.Path.data).root}, ${now}, ${now}, '[]')
|
||||
`)
|
||||
if (state === undefined)
|
||||
yield* db
|
||||
@@ -493,7 +492,7 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
const sourceTotal = yield* countNextSessions(nextPath(options, global.data))
|
||||
const sourceTotal = yield* countNextSessions(nextPath(options))
|
||||
const legacyTotal = (yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session`))?.value ?? 0
|
||||
const cursor = state?.phase === "sessions" ? state.cursor : undefined
|
||||
const migrated =
|
||||
@@ -503,7 +502,7 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
|
||||
: 0
|
||||
const denominator = sourceTotal + legacyTotal
|
||||
updateProgress({ label: "Migrating sessions", numerator: migrated, denominator })
|
||||
yield* importNextDatabase(db, nextPath(options, global.data), (completed) => {
|
||||
yield* importNextDatabase(db, nextPath(options), (completed) => {
|
||||
updateProgress({ label: "Migrating sessions", numerator: migrated + completed, denominator })
|
||||
})
|
||||
updateProgress({ label: "Migrating sessions", numerator: migrated + sourceTotal, denominator })
|
||||
@@ -622,10 +621,10 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
|
||||
)
|
||||
}
|
||||
|
||||
function nextPath(options: Options, data: string) {
|
||||
function nextPath(options: Options) {
|
||||
if (options.nextDatabasePath) return options.nextDatabasePath
|
||||
if (process.env.OPENCODE_DB === ":memory:") return undefined
|
||||
return path.join(data, "opencode-next.db")
|
||||
return path.join(Global.Path.data, "opencode-next.db")
|
||||
}
|
||||
|
||||
function openNextDatabase(sourcePath: string) {
|
||||
|
||||
@@ -7,7 +7,6 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Config } from "./config"
|
||||
import { Location } from "./location"
|
||||
import { make, type Info } from "./formatter/builtins"
|
||||
@@ -26,7 +25,6 @@ const layer = Layer.effect(
|
||||
const location = yield* Location.Service
|
||||
const npm = yield* Npm.Service
|
||||
const processes = yield* AppProcess.Service
|
||||
const global = yield* Global.Service
|
||||
const commands = new Map<string, string[] | false>()
|
||||
let formatters: Info[] = []
|
||||
|
||||
@@ -44,7 +42,6 @@ const layer = Layer.effect(
|
||||
fs,
|
||||
npm,
|
||||
processes,
|
||||
bin: global.bin,
|
||||
})
|
||||
formatters = builtIns
|
||||
if (configured === true) return
|
||||
@@ -125,5 +122,5 @@ const layer = Layer.effect(
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Config.node, FSUtil.node, Location.node, Npm.node, AppProcess.node, Global.node],
|
||||
deps: [Config.node, FSUtil.node, Location.node, Npm.node, AppProcess.node],
|
||||
})
|
||||
|
||||
@@ -18,10 +18,8 @@ export function make(input: {
|
||||
readonly fs: FSUtil.Interface
|
||||
readonly npm: Npm.Interface
|
||||
readonly processes: AppProcess.Interface
|
||||
readonly bin: string
|
||||
}) {
|
||||
const disabled = false as const
|
||||
const findExecutable = (name: string) => which(name, undefined, input.bin)
|
||||
const findUp = (target: string) => input.fs.findUp(target, input.directory, input.worktree)
|
||||
const readText = (file: string) => input.fs.readFileString(file).pipe(Effect.orElseSucceed(() => ""))
|
||||
const commandOutput = (command: string[]) =>
|
||||
@@ -39,7 +37,7 @@ export function make(input: {
|
||||
name: "gofmt",
|
||||
extensions: [".go"],
|
||||
enabled: Effect.sync(() => {
|
||||
const match = findExecutable("gofmt")
|
||||
const match = which("gofmt")
|
||||
return match ? [match, "-w", "$FILE"] : disabled
|
||||
}),
|
||||
}
|
||||
@@ -48,7 +46,7 @@ export function make(input: {
|
||||
name: "mix",
|
||||
extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"],
|
||||
enabled: Effect.sync(() => {
|
||||
const match = findExecutable("mix")
|
||||
const match = which("mix")
|
||||
return match ? [match, "format", "$FILE"] : disabled
|
||||
}),
|
||||
}
|
||||
@@ -151,7 +149,7 @@ export function make(input: {
|
||||
name: "zig",
|
||||
extensions: [".zig", ".zon"],
|
||||
enabled: Effect.sync(() => {
|
||||
const match = findExecutable("zig")
|
||||
const match = which("zig")
|
||||
return match ? [match, "fmt", "$FILE"] : disabled
|
||||
}),
|
||||
}
|
||||
@@ -161,7 +159,7 @@ export function make(input: {
|
||||
extensions: [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"],
|
||||
enabled: Effect.gen(function* () {
|
||||
if (!(yield* findUp(".clang-format")).length) return disabled
|
||||
const match = findExecutable("clang-format")
|
||||
const match = which("clang-format")
|
||||
return match ? [match, "-i", "$FILE"] : disabled
|
||||
}).pipe(Effect.orElseSucceed(() => disabled)),
|
||||
}
|
||||
@@ -170,7 +168,7 @@ export function make(input: {
|
||||
name: "ktlint",
|
||||
extensions: [".kt", ".kts"],
|
||||
enabled: Effect.sync(() => {
|
||||
const match = findExecutable("ktlint")
|
||||
const match = which("ktlint")
|
||||
return match ? [match, "-F", "$FILE"] : disabled
|
||||
}),
|
||||
}
|
||||
@@ -179,18 +177,17 @@ export function make(input: {
|
||||
name: "ruff",
|
||||
extensions: [".py", ".pyi"],
|
||||
enabled: Effect.gen(function* () {
|
||||
const bin = findExecutable("ruff")
|
||||
if (!bin) return disabled
|
||||
if (!which("ruff")) return disabled
|
||||
for (const config of ["pyproject.toml", "ruff.toml", ".ruff.toml"]) {
|
||||
const found = yield* findUp(config)
|
||||
if (!found.length) continue
|
||||
if (config !== "pyproject.toml" || (yield* readText(found[0])).includes("[tool.ruff]")) {
|
||||
return [bin, "format", "$FILE"]
|
||||
return ["ruff", "format", "$FILE"]
|
||||
}
|
||||
}
|
||||
for (const dependency of ["requirements.txt", "pyproject.toml", "Pipfile"]) {
|
||||
const found = yield* findUp(dependency)
|
||||
if (found.length && (yield* readText(found[0])).includes("ruff")) return [bin, "format", "$FILE"]
|
||||
if (found.length && (yield* readText(found[0])).includes("ruff")) return ["ruff", "format", "$FILE"]
|
||||
}
|
||||
return disabled
|
||||
}).pipe(Effect.orElseSucceed(() => disabled)),
|
||||
@@ -200,7 +197,7 @@ export function make(input: {
|
||||
name: "air",
|
||||
extensions: [".R"],
|
||||
enabled: Effect.gen(function* () {
|
||||
const bin = findExecutable("air")
|
||||
const bin = which("air")
|
||||
if (!bin) return disabled
|
||||
const output = yield* commandOutput([bin, "--help"])
|
||||
if (output._tag === "None" || output.value.exitCode !== 0) return disabled
|
||||
@@ -213,34 +210,34 @@ export function make(input: {
|
||||
name: "uv",
|
||||
extensions: [".py", ".pyi"],
|
||||
enabled: Effect.gen(function* () {
|
||||
const bin = findExecutable("uv")
|
||||
const bin = which("uv")
|
||||
if (!bin) return disabled
|
||||
const output = yield* commandOutput([bin, "format", "--help"])
|
||||
return output._tag === "Some" && output.value.exitCode === 0 ? [bin, "format", "--", "$FILE"] : disabled
|
||||
}),
|
||||
}
|
||||
|
||||
const rubocop = executable("rubocop", [".rb", ".rake", ".gemspec", ".ru"], ["--autocorrect", "$FILE"], findExecutable)
|
||||
const standardrb = executable("standardrb", [".rb", ".rake", ".gemspec", ".ru"], ["--fix", "$FILE"], findExecutable)
|
||||
const htmlbeautifier = executable("htmlbeautifier", [".erb", ".html.erb"], ["$FILE"], findExecutable)
|
||||
const dart = executable("dart", [".dart"], ["format", "$FILE"], findExecutable)
|
||||
const rubocop = executable("rubocop", [".rb", ".rake", ".gemspec", ".ru"], ["--autocorrect", "$FILE"])
|
||||
const standardrb = executable("standardrb", [".rb", ".rake", ".gemspec", ".ru"], ["--fix", "$FILE"])
|
||||
const htmlbeautifier = executable("htmlbeautifier", [".erb", ".html.erb"], ["$FILE"])
|
||||
const dart = executable("dart", [".dart"], ["format", "$FILE"])
|
||||
|
||||
const ocamlformat: Info = {
|
||||
name: "ocamlformat",
|
||||
extensions: [".ml", ".mli"],
|
||||
enabled: Effect.gen(function* () {
|
||||
if (!(yield* findUp(".ocamlformat")).length) return disabled
|
||||
const match = findExecutable("ocamlformat")
|
||||
const match = which("ocamlformat")
|
||||
return match ? [match, "-i", "$FILE"] : disabled
|
||||
}).pipe(Effect.orElseSucceed(() => disabled)),
|
||||
}
|
||||
|
||||
const terraform = executable("terraform", [".tf", ".tfvars"], ["fmt", "$FILE"], findExecutable)
|
||||
const latexindent = executable("latexindent", [".tex"], ["-w", "-s", "$FILE"], findExecutable)
|
||||
const gleam = executable("gleam", [".gleam"], ["format", "$FILE"], findExecutable)
|
||||
const shfmt = executable("shfmt", [".sh", ".bash"], ["-w", "$FILE"], findExecutable)
|
||||
const nixfmt = executable("nixfmt", [".nix"], ["$FILE"], findExecutable)
|
||||
const rustfmt = executable("rustfmt", [".rs"], ["$FILE"], findExecutable)
|
||||
const terraform = executable("terraform", [".tf", ".tfvars"], ["fmt", "$FILE"])
|
||||
const latexindent = executable("latexindent", [".tex"], ["-w", "-s", "$FILE"])
|
||||
const gleam = executable("gleam", [".gleam"], ["format", "$FILE"])
|
||||
const shfmt = executable("shfmt", [".sh", ".bash"], ["-w", "$FILE"])
|
||||
const nixfmt = executable("nixfmt", [".nix"], ["$FILE"])
|
||||
const rustfmt = executable("rustfmt", [".rs"], ["$FILE"])
|
||||
|
||||
const pint: Info = {
|
||||
name: "pint",
|
||||
@@ -256,9 +253,9 @@ export function make(input: {
|
||||
}).pipe(Effect.orElseSucceed(() => disabled)),
|
||||
}
|
||||
|
||||
const ormolu = executable("ormolu", [".hs"], ["-i", "$FILE"], findExecutable)
|
||||
const cljfmt = executable("cljfmt", [".clj", ".cljs", ".cljc", ".edn"], ["fix", "--quiet", "$FILE"], findExecutable)
|
||||
const dfmt = executable("dfmt", [".d"], ["-i", "$FILE"], findExecutable)
|
||||
const ormolu = executable("ormolu", [".hs"], ["-i", "$FILE"])
|
||||
const cljfmt = executable("cljfmt", [".clj", ".cljs", ".cljc", ".edn"], ["fix", "--quiet", "$FILE"])
|
||||
const dfmt = executable("dfmt", [".d"], ["-i", "$FILE"])
|
||||
|
||||
return [
|
||||
gofmt,
|
||||
@@ -290,17 +287,12 @@ export function make(input: {
|
||||
] satisfies Info[]
|
||||
}
|
||||
|
||||
function executable(
|
||||
name: string,
|
||||
extensions: readonly string[],
|
||||
args: string[],
|
||||
findExecutable: (name: string) => string | null,
|
||||
): Info {
|
||||
function executable(name: string, extensions: readonly string[], args: string[]): Info {
|
||||
return {
|
||||
name,
|
||||
extensions,
|
||||
enabled: Effect.sync(() => {
|
||||
const match = findExecutable(name)
|
||||
const match = which(name)
|
||||
return match ? [match, ...args] : false
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -285,8 +285,9 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
|
||||
cacheWrite: undefined,
|
||||
},
|
||||
outputTokens: {
|
||||
...outputUsage(responseBody.usage),
|
||||
total: responseBody.usage?.completion_tokens ?? undefined,
|
||||
text: undefined,
|
||||
reasoning: responseBody.usage?.completion_tokens_details?.reasoning_tokens ?? undefined,
|
||||
},
|
||||
raw: responseBody.usage ?? undefined,
|
||||
},
|
||||
@@ -356,7 +357,6 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
|
||||
cachedTokens: number | undefined
|
||||
}
|
||||
totalTokens: number | undefined
|
||||
rawCompletionTokens: number | undefined
|
||||
} = {
|
||||
completionTokens: undefined,
|
||||
completionTokensDetails: {
|
||||
@@ -369,7 +369,6 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
|
||||
cachedTokens: undefined,
|
||||
},
|
||||
totalTokens: undefined,
|
||||
rawCompletionTokens: undefined,
|
||||
}
|
||||
let isFirstChunk = true
|
||||
const providerOptionsName = this.providerOptionsName
|
||||
@@ -433,11 +432,11 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
|
||||
} = value.usage
|
||||
|
||||
usage.promptTokens = prompt_tokens ?? undefined
|
||||
usage.rawCompletionTokens = completion_tokens ?? undefined
|
||||
const output = outputUsage(value.usage)
|
||||
usage.completionTokens = output.total
|
||||
usage.completionTokensDetails.reasoningTokens = output.reasoning
|
||||
usage.completionTokens = completion_tokens ?? undefined
|
||||
usage.totalTokens = total_tokens ?? undefined
|
||||
if (completion_tokens_details?.reasoning_tokens != null) {
|
||||
usage.completionTokensDetails.reasoningTokens = completion_tokens_details?.reasoning_tokens
|
||||
}
|
||||
if (completion_tokens_details?.accepted_prediction_tokens != null) {
|
||||
usage.completionTokensDetails.acceptedPredictionTokens =
|
||||
completion_tokens_details?.accepted_prediction_tokens
|
||||
@@ -709,7 +708,7 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
|
||||
},
|
||||
raw: {
|
||||
prompt_tokens: usage.promptTokens ?? null,
|
||||
completion_tokens: usage.rawCompletionTokens ?? null,
|
||||
completion_tokens: usage.completionTokens ?? null,
|
||||
total_tokens: usage.totalTokens ?? null,
|
||||
},
|
||||
},
|
||||
@@ -728,7 +727,6 @@ const openaiCompatibleTokenUsageSchema = z
|
||||
.object({
|
||||
prompt_tokens: z.number().nullish(),
|
||||
completion_tokens: z.number().nullish(),
|
||||
reasoning_tokens: z.number().nullish(),
|
||||
total_tokens: z.number().nullish(),
|
||||
prompt_tokens_details: z
|
||||
.object({
|
||||
@@ -745,17 +743,6 @@ const openaiCompatibleTokenUsageSchema = z
|
||||
})
|
||||
.nullish()
|
||||
|
||||
function outputUsage(usage: z.infer<typeof openaiCompatibleTokenUsageSchema>) {
|
||||
const nested = usage?.completion_tokens_details?.reasoning_tokens
|
||||
return {
|
||||
total:
|
||||
usage?.completion_tokens == null
|
||||
? undefined
|
||||
: usage.completion_tokens + (nested == null ? (usage.reasoning_tokens ?? 0) : 0),
|
||||
reasoning: nested ?? usage?.reasoning_tokens ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// limited version of the schema, focussed on what is needed for the implementation
|
||||
// this approach limits breakages when the API changes and increases efficiency
|
||||
const OpenAICompatibleChatResponseSchema = z.object({
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
export * as InstructionDiscovery from "./instruction-discovery"
|
||||
|
||||
import { Context, Effect, Layer, Schema, Types } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Bus } from "./bus"
|
||||
import { Instructions } from "./instructions/index"
|
||||
import { Array, Context, Effect, Layer, Schema } from "effect"
|
||||
import { isAbsolute, join, relative, sep } from "path"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Location } from "./location"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { State } from "./state"
|
||||
import { Instructions } from "./instructions/index"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
|
||||
export class File extends Schema.Class<File>("InstructionDiscovery.File")({
|
||||
class File extends Schema.Class<File>("InstructionDiscovery.File")({
|
||||
path: AbsolutePath,
|
||||
content: Schema.String,
|
||||
}) {}
|
||||
@@ -15,30 +17,7 @@ export class File extends Schema.Class<File>("InstructionDiscovery.File")({
|
||||
const Files = Schema.Array(File)
|
||||
const key = Instructions.Key.make("core/instructions")
|
||||
|
||||
export const Event = {
|
||||
Updated: Bus.ephemeral({ type: "instruction-discovery.updated", schema: {} }),
|
||||
}
|
||||
|
||||
export type Data = {
|
||||
files: Map<AbsolutePath, Types.DeepMutable<File>>
|
||||
available: boolean
|
||||
}
|
||||
|
||||
export type Draft = {
|
||||
list: () => readonly Types.DeepMutable<File>[]
|
||||
// Map insertion order is render order: config adds global then nearest-to-farthest project files;
|
||||
// sibling contributors interleave by transform registration order.
|
||||
add: (file: File) => void
|
||||
update: (path: string, update: (file: Types.DeepMutable<File>) => void) => void
|
||||
remove: (path: string) => void
|
||||
unavailable: () => void
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
// Discovery policy lives here because internal plugins have no per-composition options channel.
|
||||
// Move it into plugin config once plugins can consume their own options.
|
||||
readonly project: boolean
|
||||
readonly list: () => Effect.Effect<File[] | Instructions.Unavailable>
|
||||
export interface Interface {
|
||||
readonly load: () => Effect.Effect<Instructions.List>
|
||||
}
|
||||
|
||||
@@ -53,26 +32,9 @@ export const layer = (options?: Options) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const state = State.create<Data, Draft>({
|
||||
name: "instruction-discovery",
|
||||
initial: () => ({ files: new Map(), available: true }),
|
||||
draft: (draft) => ({
|
||||
list: () => Array.from(draft.files.values()),
|
||||
add: (file) => draft.files.set(file.path, new File(file) as Types.DeepMutable<File>),
|
||||
update: (path, update) => {
|
||||
const current = draft.files.get(AbsolutePath.make(path))
|
||||
if (!current) return
|
||||
update(current)
|
||||
current.path = AbsolutePath.make(path)
|
||||
},
|
||||
remove: (path) => draft.files.delete(AbsolutePath.make(path)),
|
||||
unavailable: () => {
|
||||
draft.available = false
|
||||
},
|
||||
}),
|
||||
finalize: () => bus.publish(Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const location = yield* Location.Service
|
||||
|
||||
const source = (value: ReadonlyArray<File> | Instructions.Unavailable | Instructions.Removed) =>
|
||||
Instructions.make<ReadonlyArray<File>>({
|
||||
@@ -87,22 +49,52 @@ export const layer = (options?: Options) =>
|
||||
},
|
||||
})
|
||||
|
||||
const list = Effect.fn("InstructionDiscovery.list")(function* () {
|
||||
const current = state.get()
|
||||
if (!current.available) return Instructions.unavailable
|
||||
return Array.from(current.files.values())
|
||||
const observe = Effect.fn("InstructionDiscovery.observe")(function* () {
|
||||
const start = yield* fs.resolve(location.directory)
|
||||
const stop = yield* fs.resolve(location.project.directory)
|
||||
const fromProject = relative(stop, start)
|
||||
const insideProject =
|
||||
fromProject === "" ||
|
||||
(fromProject !== ".." && !fromProject.startsWith(`..${sep}`) && !isAbsolute(fromProject))
|
||||
const discovered = new Set(
|
||||
yield* Effect.forEach(
|
||||
options?.project === false || !insideProject
|
||||
? []
|
||||
: yield* fs.up({
|
||||
targets: ["AGENTS.md"],
|
||||
start,
|
||||
stop,
|
||||
}),
|
||||
fs.resolve,
|
||||
),
|
||||
)
|
||||
const paths = Array.dedupe([yield* fs.resolve(join(global.config, "AGENTS.md")), ...discovered])
|
||||
const files = yield* Effect.forEach(
|
||||
paths,
|
||||
(path) =>
|
||||
fs
|
||||
.readFileStringSafe(path)
|
||||
.pipe(
|
||||
Effect.map((content) =>
|
||||
content === undefined ? undefined : new File({ path: AbsolutePath.make(path), content }),
|
||||
),
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
if (files.some((file, index) => file === undefined && discovered.has(paths[index])))
|
||||
return Instructions.unavailable
|
||||
return files.filter((file): file is File => file !== undefined)
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
project: options?.project !== false,
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
list,
|
||||
load: Effect.fn("InstructionDiscovery.load")(function* () {
|
||||
const files = yield* list()
|
||||
if (!Array.isArray(files)) return source(files)
|
||||
return source(files.length === 0 ? Instructions.removed : files)
|
||||
}),
|
||||
load: () =>
|
||||
observe().pipe(
|
||||
Effect.map((files) =>
|
||||
Array.isArray(files) && files.length === 0 ? source(Instructions.removed) : source(files),
|
||||
),
|
||||
Effect.catch(() => Effect.succeed(source(Instructions.unavailable))),
|
||||
Effect.catchDefect(() => Effect.succeed(source(Instructions.unavailable))),
|
||||
),
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -111,7 +103,7 @@ export function configured(options?: Options) {
|
||||
return makeLocationNode({
|
||||
service: Service,
|
||||
layer: layer(options),
|
||||
deps: [Bus.node],
|
||||
deps: [FSUtil.node, Global.node, Location.node],
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Context, Duration, Effect, Layer, Option, Schedule, Schema, Semaphore } from "effect"
|
||||
import path from "path"
|
||||
import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect"
|
||||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { ModelsDev } from "@opencode-ai/schema/models-dev"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { App } from "./app"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Flock } from "@opencode-ai/util/flock"
|
||||
import { Hash } from "@opencode-ai/util/hash"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Bus } from "./bus"
|
||||
@@ -10,7 +13,6 @@ import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { Model } from "./model"
|
||||
import { Provider } from "./provider"
|
||||
import { KV } from "./kv"
|
||||
|
||||
export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"])
|
||||
export type CatalogModelStatus = typeof CatalogModelStatus.Type
|
||||
@@ -535,18 +537,6 @@ export type Options = typeof Options.Type
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ModelsDev") {}
|
||||
|
||||
const CatalogJson = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown))
|
||||
const Cache = Schema.Struct({
|
||||
updatedAt: Schema.Number,
|
||||
body: CatalogJson,
|
||||
})
|
||||
const defaultSource = "https://models.opencode.ai"
|
||||
|
||||
function cacheKey(source: string) {
|
||||
if (source === defaultSource) return "models-dev:catalog"
|
||||
return `models-dev:catalog:${Hash.fast(source)}`
|
||||
}
|
||||
|
||||
export const layer = (options?: Options) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
@@ -554,7 +544,6 @@ export const layer = (options?: Options) =>
|
||||
const fs = yield* FSUtil.Service
|
||||
const bus = yield* Bus.Service
|
||||
const app = yield* App.Metadata
|
||||
const kv = yield* KV.Service
|
||||
const http = HttpClient.filterStatusOk(
|
||||
(yield* HttpClient.HttpClient).pipe(
|
||||
HttpClient.retryTransient({
|
||||
@@ -565,28 +554,21 @@ export const layer = (options?: Options) =>
|
||||
),
|
||||
)
|
||||
|
||||
const source = options?.url || defaultSource
|
||||
const source = options?.url || "https://models.opencode.ai"
|
||||
const fetch = options?.fetch ?? true
|
||||
const userAgent = App.useragent(app)
|
||||
const key = cacheKey(source)
|
||||
const filepath = path.join(
|
||||
Global.Path.cache,
|
||||
source === "https://models.opencode.ai" ? "models.json" : `models-${Hash.fast(source)}.json`,
|
||||
)
|
||||
const ttl = Duration.minutes(5)
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
|
||||
const loadFromCache = Effect.fnUntraced(function* () {
|
||||
const value = yield* kv.get(key)
|
||||
const cached = Schema.decodeUnknownOption(Cache)(value)
|
||||
if (Option.isSome(cached))
|
||||
return {
|
||||
catalog: cached.value.body as Record<string, SourceProvider>,
|
||||
updatedAt: cached.value.updatedAt,
|
||||
}
|
||||
if (value !== undefined) yield* kv.remove(key)
|
||||
})
|
||||
const lockKey = `models-dev:${filepath}`
|
||||
|
||||
const fresh = Effect.fnUntraced(function* () {
|
||||
const cached = yield* loadFromCache()
|
||||
if (!cached) return false
|
||||
return Date.now() - cached.updatedAt < Duration.toMillis(ttl)
|
||||
const stat = yield* fs.stat(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (!stat) return false
|
||||
const mtime = Option.getOrElse(stat.mtime, () => new Date(0)).getTime()
|
||||
return Date.now() - mtime < Duration.toMillis(ttl)
|
||||
})
|
||||
|
||||
const fetchApi = Effect.fn("ModelsDev.fetchApi")(function* () {
|
||||
@@ -598,12 +580,15 @@ export const layer = (options?: Options) =>
|
||||
)
|
||||
})
|
||||
|
||||
const loadFromFile = options?.file
|
||||
? fs.readJson(options.file).pipe(
|
||||
Effect.map((input) => input as Record<string, SourceProvider>),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
)
|
||||
: Effect.succeed(undefined)
|
||||
const loadFromDisk = fs.readJson(options?.file ?? filepath).pipe(
|
||||
Effect.map((input) => input as Record<string, SourceProvider>),
|
||||
Effect.catch((error) => {
|
||||
if (options?.file === undefined && error._tag === "FileSystemError" && error.method === "readJson") {
|
||||
return fs.remove(filepath, { force: true }).pipe(Effect.ignore, Effect.as(undefined))
|
||||
}
|
||||
return Effect.succeed(undefined)
|
||||
}),
|
||||
)
|
||||
|
||||
const loadSnapshot = Effect.sync(() =>
|
||||
typeof OPENCODE_MODELS_DEV === "undefined" ? undefined : OPENCODE_MODELS_DEV,
|
||||
@@ -611,27 +596,33 @@ export const layer = (options?: Options) =>
|
||||
|
||||
const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
|
||||
const text = yield* fetchApi()
|
||||
const catalog = (yield* Schema.decodeUnknownEffect(CatalogJson)(text)) as Record<string, SourceProvider>
|
||||
yield* kv.set(key, { updatedAt: Date.now(), body: text })
|
||||
return catalog
|
||||
const tempfile = `${filepath}.${process.pid}.${Date.now()}.tmp`
|
||||
yield* fs.writeWithDirs(tempfile, text).pipe(
|
||||
Effect.andThen(fs.rename(tempfile, filepath)),
|
||||
Effect.catch((error) =>
|
||||
Effect.gen(function* () {
|
||||
yield* fs.remove(tempfile, { force: true }).pipe(Effect.ignore)
|
||||
return yield* Effect.fail(error)
|
||||
}),
|
||||
),
|
||||
)
|
||||
return text
|
||||
})
|
||||
|
||||
const populate = Effect.gen(function* () {
|
||||
const fromFile = yield* loadFromFile
|
||||
if (fromFile) return normalize(fromFile)
|
||||
const cached = options?.file ? undefined : yield* loadFromCache()
|
||||
if (cached) return normalize(cached.catalog)
|
||||
const fromDisk = yield* loadFromDisk
|
||||
if (fromDisk) return normalize(fromDisk)
|
||||
const bundled = yield* loadSnapshot
|
||||
if (bundled) return normalize(bundled)
|
||||
if (!fetch) return []
|
||||
const catalog = yield* lock.withPermit(
|
||||
// Flock is cross-process: concurrent opencode CLIs can race on this cache file.
|
||||
const text = yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const stored = options?.file ? undefined : yield* loadFromCache()
|
||||
if (stored) return stored.catalog
|
||||
yield* Flock.effect(lockKey)
|
||||
return yield* fetchAndWrite()
|
||||
}),
|
||||
)
|
||||
return normalize(catalog)
|
||||
return normalize(JSON.parse(text) as Record<string, SourceProvider>)
|
||||
}).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie)
|
||||
|
||||
const [cachedGet, invalidate] = yield* Effect.cachedInvalidateWithTTL(populate, Duration.infinity)
|
||||
@@ -639,19 +630,21 @@ export const layer = (options?: Options) =>
|
||||
const get = (): Effect.Effect<readonly Snapshot[]> => cachedGet
|
||||
|
||||
const refresh = Effect.fn("ModelsDev.refresh")(function* (force = false) {
|
||||
yield* lock
|
||||
.withPermit(
|
||||
Effect.gen(function* () {
|
||||
if (!force && (yield* fresh())) return
|
||||
yield* fetchAndWrite()
|
||||
yield* invalidate
|
||||
yield* bus.publish(ModelsDev.Event.Refreshed, {})
|
||||
}),
|
||||
)
|
||||
.pipe(
|
||||
Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause: cause })),
|
||||
Effect.ignore,
|
||||
)
|
||||
if (!force && (yield* fresh())) return
|
||||
yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* Flock.effect(lockKey)
|
||||
// Re-check under the lock: another process may have refreshed between
|
||||
// our outer check and lock acquisition.
|
||||
if (!force && (yield* fresh())) return
|
||||
yield* fetchAndWrite()
|
||||
yield* invalidate
|
||||
yield* bus.publish(ModelsDev.Event.Refreshed, {})
|
||||
}),
|
||||
).pipe(
|
||||
Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause: cause })),
|
||||
Effect.ignore,
|
||||
)
|
||||
})
|
||||
|
||||
if (fetch && !process.argv.includes("--get-yargs-completions")) {
|
||||
@@ -667,7 +660,7 @@ export function configured(options?: Options) {
|
||||
return makeGlobalNode({
|
||||
service: Service,
|
||||
layer: layer(options),
|
||||
deps: [FSUtil.node, Bus.node, App.node, KV.node, httpClient],
|
||||
deps: [FSUtil.node, Bus.node, App.node, httpClient],
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -290,10 +290,8 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
|
||||
transform: (callback) =>
|
||||
skill.transform((draft) => {
|
||||
callback({
|
||||
list: () => mutable(draft.list()),
|
||||
add: (value) => draft.add(Schema.decodeUnknownSync(Skill.Info)(value)),
|
||||
update: draft.update,
|
||||
remove: draft.remove,
|
||||
source: (source) => draft.source(Schema.decodeUnknownSync(Skill.Source)(source)),
|
||||
list: draft.list,
|
||||
})
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
export * as PluginInternal from "./internal"
|
||||
|
||||
import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { Context, Effect, Scope } from "effect"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import { Agent } from "../agent"
|
||||
@@ -12,7 +10,6 @@ import { Config } from "../config"
|
||||
import { Credential } from "../credential"
|
||||
import { ConfigAgentPlugin } from "../config/plugin/agent"
|
||||
import { ConfigCommandPlugin } from "../config/plugin/command"
|
||||
import { ConfigInstructionPlugin } from "../config/plugin/instruction"
|
||||
import { ConfigProviderPlugin } from "../config/plugin/provider"
|
||||
import { ConfigPolicyPlugin } from "../config/plugin/policy"
|
||||
import { ConfigReferencePlugin } from "../config/plugin/reference"
|
||||
@@ -27,7 +24,6 @@ import { FileSystem } from "../filesystem"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Image } from "../image"
|
||||
import { InstructionDiscovery } from "../instruction-discovery"
|
||||
import { Integration } from "../integration"
|
||||
import { KV } from "../kv"
|
||||
import { Location } from "../location"
|
||||
@@ -41,8 +37,6 @@ import { Ripgrep } from "../ripgrep"
|
||||
import { SessionInstructions } from "../session/instructions"
|
||||
import { Shell } from "../shell"
|
||||
import { Skill } from "../skill"
|
||||
import { SkillDiscovery } from "../skill/discovery"
|
||||
import { Watcher } from "../filesystem/watcher"
|
||||
import { PatchTool } from "../tool/plugin/patch"
|
||||
import { EditTool } from "../tool/plugin/edit"
|
||||
import { GlobTool } from "../tool/plugin/glob"
|
||||
@@ -85,7 +79,6 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
const global = yield* Global.Service
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const image = yield* Image.Service
|
||||
const instructionDiscovery = yield* InstructionDiscovery.Service
|
||||
const integration = yield* Integration.Service
|
||||
const kv = yield* KV.Service
|
||||
const location = yield* Location.Service
|
||||
@@ -102,9 +95,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
const instructions = yield* SessionInstructions.Service
|
||||
const shell = yield* Shell.Service
|
||||
const skill = yield* Skill.Service
|
||||
const skillDiscovery = yield* SkillDiscovery.Service
|
||||
const tools = yield* Tool.Service
|
||||
const watcher = yield* Watcher.Service
|
||||
const wellknown = yield* WellKnown.Service
|
||||
return Context.mergeAll(
|
||||
Context.make(Agent.Service, agent),
|
||||
@@ -121,7 +112,6 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
Context.make(Global.Service, global),
|
||||
Context.make(HttpClient.HttpClient, http),
|
||||
Context.make(Image.Service, image),
|
||||
Context.make(InstructionDiscovery.Service, instructionDiscovery),
|
||||
Context.make(Integration.Service, integration),
|
||||
Context.make(KV.Service, kv),
|
||||
Context.make(Location.Service, location),
|
||||
@@ -138,9 +128,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
Context.make(SessionInstructions.Service, instructions),
|
||||
Context.make(Shell.Service, shell),
|
||||
Context.make(Skill.Service, skill),
|
||||
Context.make(SkillDiscovery.Service, skillDiscovery),
|
||||
Context.make(Tool.Service, tools),
|
||||
Context.make(Watcher.Service, watcher),
|
||||
Context.make(WellKnown.Service, wellknown),
|
||||
)
|
||||
})
|
||||
@@ -149,44 +137,6 @@ type ContextServices<A> = A extends Context.Context<infer R> ? R : never
|
||||
|
||||
export type Requirements = ContextServices<Effect.Success<ReturnType<typeof services>>>
|
||||
|
||||
export const requirements = LayerNode.group([
|
||||
Agent.node,
|
||||
Catalog.node,
|
||||
Command.node,
|
||||
Config.node,
|
||||
Credential.node,
|
||||
Bus.node,
|
||||
Environment.node,
|
||||
FileMutation.node,
|
||||
Formatter.node,
|
||||
FileSystem.node,
|
||||
FSUtil.node,
|
||||
Global.node,
|
||||
httpClient,
|
||||
Image.node,
|
||||
InstructionDiscovery.node,
|
||||
Integration.node,
|
||||
KV.node,
|
||||
Location.node,
|
||||
LocationMutation.node,
|
||||
ModelsDev.node,
|
||||
Npm.node,
|
||||
Permission.node,
|
||||
PluginRuntime.node,
|
||||
Form.node,
|
||||
ReadToolFileSystem.node,
|
||||
Reference.node,
|
||||
WebSearch.node,
|
||||
Ripgrep.node,
|
||||
SessionInstructions.node,
|
||||
Shell.node,
|
||||
Skill.node,
|
||||
SkillDiscovery.node,
|
||||
Tool.node,
|
||||
Watcher.node,
|
||||
WellKnown.node,
|
||||
])
|
||||
|
||||
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
|
||||
|
||||
const pre = [
|
||||
@@ -214,7 +164,6 @@ const pre = [
|
||||
] as const satisfies readonly InternalPlugin[]
|
||||
|
||||
const post = [
|
||||
ConfigInstructionPlugin.Plugin,
|
||||
ConfigReferencePlugin.Plugin,
|
||||
ConfigAgentPlugin.Plugin,
|
||||
ConfigCommandPlugin.Plugin,
|
||||
|
||||
@@ -28,23 +28,29 @@ export const Plugin = define({
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const reportContent = yield* reportContentWithDiagnostics(ctx.app)
|
||||
yield* ctx.skill.transform((draft) => {
|
||||
draft.add(
|
||||
Skill.Info.make({
|
||||
id: Skill.ID.make("opencode"),
|
||||
name: Skill.Name.make("OpenCode"),
|
||||
description: OpencodeDescription,
|
||||
location: AbsolutePath.make("/builtin/opencode.md"),
|
||||
content: OpencodeContent,
|
||||
draft.source(
|
||||
Skill.EmbeddedSource.make({
|
||||
type: "embedded",
|
||||
skill: Skill.Info.make({
|
||||
id: Skill.ID.make("opencode"),
|
||||
name: Skill.Name.make("OpenCode"),
|
||||
description: OpencodeDescription,
|
||||
location: AbsolutePath.make("/builtin/opencode.md"),
|
||||
content: OpencodeContent,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
draft.add(
|
||||
Skill.Info.make({
|
||||
id: Skill.ID.make("report"),
|
||||
name: Skill.Name.make("Report"),
|
||||
description: REPORT_DESCRIPTION,
|
||||
slash: true,
|
||||
location: AbsolutePath.make("/builtin/report.md"),
|
||||
content: reportContent,
|
||||
draft.source(
|
||||
Skill.EmbeddedSource.make({
|
||||
type: "embedded",
|
||||
skill: Skill.Info.make({
|
||||
id: Skill.ID.make("report"),
|
||||
name: Skill.Name.make("Report"),
|
||||
description: REPORT_DESCRIPTION,
|
||||
slash: true,
|
||||
location: AbsolutePath.make("/builtin/report.md"),
|
||||
content: reportContent,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,17 +1,48 @@
|
||||
export * as PluginSupervisor from "./supervisor"
|
||||
|
||||
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Event } from "@opencode-ai/schema/config"
|
||||
import { Context, Deferred, Effect, Layer, Schema, Stream } from "effect"
|
||||
import { Directory, Document, Event, type Entry } from "@opencode-ai/schema/config"
|
||||
import { ConfigPlugin } from "@opencode-ai/schema/config/plugin"
|
||||
import { Context, Deferred, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { ConfigPluginSource } from "../config/plugin/source"
|
||||
import { fileURLToPath, pathToFileURL } from "url"
|
||||
import { Agent } from "../agent"
|
||||
import { Catalog } from "../catalog"
|
||||
import { Command } from "../command"
|
||||
import { Config } from "../config"
|
||||
import { Credential } from "../credential"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { Bus } from "../bus"
|
||||
import { Environment } from "../environment"
|
||||
import { FileMutation } from "../file-mutation"
|
||||
import { Formatter } from "../formatter"
|
||||
import { FileSystem } from "../filesystem"
|
||||
import { Watcher } from "../filesystem/watcher"
|
||||
import { Form } from "../form"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Image } from "../image"
|
||||
import { Integration } from "../integration"
|
||||
import { KV } from "../kv"
|
||||
import { Location } from "../location"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { ModelsDev } from "../models-dev"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Permission } from "../permission"
|
||||
import { Plugin } from "../plugin"
|
||||
import { PluginPromise } from "../plugin/promise"
|
||||
import { Reference } from "../reference"
|
||||
import { Ripgrep } from "../ripgrep"
|
||||
import { SessionInstructions } from "../session/instructions"
|
||||
import { Shell } from "../shell"
|
||||
import { Skill } from "../skill"
|
||||
import { ReadToolFileSystem } from "../tool/read-filesystem"
|
||||
import { Tool } from "../tool"
|
||||
import { WebSearch } from "../websearch"
|
||||
import { WellKnown } from "../wellknown"
|
||||
import { PluginInternal } from "./internal"
|
||||
import { PluginRuntime } from "./runtime"
|
||||
import { SdkPlugins } from "./sdk"
|
||||
import { importModule } from "@opencode-ai/util/runtime-import"
|
||||
|
||||
@@ -32,10 +63,65 @@ const PluginModule = Schema.Struct({
|
||||
]),
|
||||
})
|
||||
|
||||
type Operation =
|
||||
| {
|
||||
readonly type: "add"
|
||||
readonly target: string
|
||||
readonly options: Record<string, unknown>
|
||||
readonly mtime?: number
|
||||
}
|
||||
| {
|
||||
readonly type: "remove"
|
||||
readonly target: string
|
||||
}
|
||||
|
||||
function parse(input: ConfigPlugin.Plugin): Operation {
|
||||
if (typeof input !== "string") {
|
||||
return { type: "add", target: input.package, options: input.options ?? {} }
|
||||
}
|
||||
if (!input.startsWith("-")) return { type: "add", target: input, options: {} }
|
||||
if (input.length === 1) throw new Error("Plugin remove operation requires a target")
|
||||
return { type: "remove", target: input.slice(1) }
|
||||
}
|
||||
|
||||
const scan = Effect.fn("PluginSupervisor.scan")(function* (entries: readonly Entry[]) {
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const discovered = yield* Effect.forEach(
|
||||
entries.filter((entry): entry is Directory => entry.type === "directory"),
|
||||
(entry) => discoverDirectory(fs, entry.path),
|
||||
).pipe(Effect.map((items) => items.flat()))
|
||||
const configured = entries
|
||||
.filter((entry): entry is Document => entry.type === "document")
|
||||
.flatMap((entry) =>
|
||||
(entry.info.plugins ?? []).map(parse).map((operation) => {
|
||||
if (operation.type === "remove") return operation
|
||||
const directory = entry.path ? path.dirname(entry.path) : location.directory
|
||||
const target = operation.target.startsWith("file://")
|
||||
? fileURLToPath(operation.target)
|
||||
: operation.target.startsWith("./") || operation.target.startsWith("../")
|
||||
? path.resolve(directory, operation.target)
|
||||
: operation.target
|
||||
return { ...operation, target }
|
||||
}),
|
||||
)
|
||||
// Explicit config is applied last so it can remove auto-discovered packages.
|
||||
return yield* Effect.forEach([...discovered, ...configured], (operation) => {
|
||||
if (operation.type === "remove" || !path.isAbsolute(operation.target)) return Effect.succeed(operation)
|
||||
return fs.stat(operation.target).pipe(
|
||||
Effect.map((info) => ({
|
||||
...operation,
|
||||
mtime: Option.getOrElse(info.mtime, () => new Date(0)).getTime(),
|
||||
})),
|
||||
Effect.catch(() => Effect.succeed(operation)),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
||||
pre: readonly Plugin.Versioned[],
|
||||
post: readonly Plugin.Versioned[],
|
||||
operations: readonly ConfigPluginSource.Operation[],
|
||||
operations: readonly Operation[],
|
||||
) {
|
||||
const matches = (selector: string, target: string) =>
|
||||
selector === "*" || (selector.endsWith(".*") ? target.startsWith(selector.slice(0, -1)) : selector === target)
|
||||
@@ -82,9 +168,7 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
||||
]
|
||||
})
|
||||
|
||||
const load = Effect.fn("PluginSupervisor.load")(function* (
|
||||
operation: Extract<ConfigPluginSource.Operation, { type: "add" }>,
|
||||
) {
|
||||
const load = Effect.fn("PluginSupervisor.load")(function* (operation: Extract<Operation, { type: "add" }>) {
|
||||
const npm = yield* Npm.Service
|
||||
const entrypoint = path.isAbsolute(operation.target)
|
||||
? pathToFileURL(operation.target).href
|
||||
@@ -108,6 +192,31 @@ const load = Effect.fn("PluginSupervisor.load")(function* (
|
||||
} satisfies Plugin.Versioned
|
||||
})
|
||||
|
||||
function discoverDirectory(fs: FSUtil.Interface, directory: string) {
|
||||
return Effect.gen(function* () {
|
||||
const files = yield* fs
|
||||
.scan("{plugin,plugins}/*.{ts,js}", {
|
||||
cwd: directory,
|
||||
absolute: true,
|
||||
include: "file",
|
||||
dot: true,
|
||||
symlink: true,
|
||||
})
|
||||
.pipe(Effect.orElseSucceed(() => []))
|
||||
return files.sort().map((target): Operation => ({ type: "add", target, options: {} }))
|
||||
})
|
||||
}
|
||||
|
||||
const sourceDirectories = ["plugin", "plugins"] as const
|
||||
|
||||
function isPluginSource(entries: readonly Entry[], file: string) {
|
||||
return entries.some(
|
||||
(entry) =>
|
||||
entry.type === "directory" &&
|
||||
sourceDirectories.some((directory) => FSUtil.contains(path.join(entry.path, directory), file)),
|
||||
)
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
/** Wait for the initial plugin generation and startup updates to settle. */
|
||||
readonly flush: Effect.Effect<void>
|
||||
@@ -115,29 +224,72 @@ export interface Interface {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginSupervisor") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* Plugin.Service
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
const sources = yield* ConfigPluginSource.Service
|
||||
const config = yield* Config.Service
|
||||
const bus = yield* Bus.Service
|
||||
const watcher = yield* Watcher.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const ready = { current: yield* Deferred.make<void>() }
|
||||
let observed = 0
|
||||
|
||||
// Configured local plugin files can live outside config roots, where the
|
||||
// config change feed cannot see them; watch those entrypoints directly.
|
||||
// Watches start on first sighting and are never torn down individually:
|
||||
// a stale watch after a config edit costs one deduped fs handle and a
|
||||
// no-op activation, and every watch dies with this layer's scope.
|
||||
const configuredChanges = yield* PubSub.unbounded<void>()
|
||||
const watched = new Set<string>()
|
||||
const watchConfiguredSources = Effect.fn("PluginSupervisor.watchConfiguredSources")(function* (
|
||||
entries: readonly Entry[],
|
||||
operations: readonly Operation[],
|
||||
) {
|
||||
for (const operation of operations) {
|
||||
if (operation.type !== "add" || !path.isAbsolute(operation.target)) continue
|
||||
if (watched.has(operation.target)) continue
|
||||
// The config change feed already covers {plugin,plugins} directories.
|
||||
if (isPluginSource(entries, operation.target)) continue
|
||||
// Directory targets can't hot-reload (their stat mtime ignores edits
|
||||
// inside), so don't watch what can't trigger anything.
|
||||
if (yield* fs.isDir(operation.target)) continue
|
||||
watched.add(operation.target)
|
||||
const updates = yield* watcher.subscribe({ path: operation.target, type: "file" })
|
||||
yield* updates.pipe(
|
||||
Stream.runForEach(() => PubSub.publish(configuredChanges, undefined)),
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logError("configured plugin watch failed", { target: operation.target, cause }),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const activate = Effect.fn("PluginSupervisor.activate")(function* () {
|
||||
// Resolve OpenCode's internal plugins with their privileged Location services.
|
||||
const internal = yield* PluginInternal.list()
|
||||
// Combine internal plugins with host-contributed SDK plugins in boot order.
|
||||
const pre = [...internal.pre.map((plugin) => ({ ...plugin, version: "internal" })), ...sdk.all()]
|
||||
const post = internal.post.map((plugin) => ({ ...plugin, version: "internal" }))
|
||||
const operations = yield* sources.operations()
|
||||
const entries = yield* config.entries()
|
||||
const operations = yield* scan(entries)
|
||||
yield* watchConfiguredSources(entries, operations)
|
||||
// Apply config operations and load enabled package plugins into one ordered generation.
|
||||
const plugins = yield* resolve(pre, post, operations)
|
||||
// Replace the active generation in one scoped, batched activation.
|
||||
yield* registry.activate(plugins)
|
||||
})
|
||||
const updates = Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])).pipe(
|
||||
const updates = Stream.merge(
|
||||
config.changes().pipe(
|
||||
Stream.filterEffect((update) =>
|
||||
Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path)),
|
||||
),
|
||||
Stream.merge(Stream.fromPubSub(configuredChanges)),
|
||||
),
|
||||
bus.subscribe([Event.Updated, SdkPlugins.Updated]),
|
||||
).pipe(
|
||||
// Make accepted work visible to flush before coalescing the burst.
|
||||
Stream.mapEffect(() =>
|
||||
Effect.gen(function* () {
|
||||
@@ -163,13 +315,48 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
const nodeDeps = [
|
||||
Plugin.node,
|
||||
SdkPlugins.node,
|
||||
ConfigPluginSource.node,
|
||||
Bus.node,
|
||||
Npm.node,
|
||||
PluginInternal.requirements,
|
||||
] as const
|
||||
const nodeLayer = layer as Layer.Layer<Service, never, PluginInternal.Requirements>
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: nodeDeps })
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer: nodeLayer,
|
||||
deps: [
|
||||
Plugin.node,
|
||||
SdkPlugins.node,
|
||||
Agent.node,
|
||||
Catalog.node,
|
||||
Command.node,
|
||||
Config.node,
|
||||
Credential.node,
|
||||
Bus.node,
|
||||
Environment.node,
|
||||
FileMutation.node,
|
||||
Formatter.node,
|
||||
FileSystem.node,
|
||||
FSUtil.node,
|
||||
Global.node,
|
||||
httpClient,
|
||||
Image.node,
|
||||
Integration.node,
|
||||
KV.node,
|
||||
Location.node,
|
||||
LocationMutation.node,
|
||||
ModelsDev.node,
|
||||
Npm.node,
|
||||
Permission.node,
|
||||
PluginRuntime.node,
|
||||
Form.node,
|
||||
ReadToolFileSystem.node,
|
||||
Reference.node,
|
||||
Ripgrep.node,
|
||||
SessionInstructions.node,
|
||||
Shell.node,
|
||||
Skill.node,
|
||||
Tool.node,
|
||||
Watcher.node,
|
||||
WebSearch.node,
|
||||
WellKnown.node,
|
||||
],
|
||||
})
|
||||
|
||||
export { layer }
|
||||
|
||||
@@ -9,7 +9,6 @@ import { Bus } from "./bus"
|
||||
import { Location } from "./location"
|
||||
import { PtyID } from "./pty/schema"
|
||||
import { ShellSelect } from "./shell/select"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { lazy } from "./util/lazy"
|
||||
|
||||
const BUFFER_LIMIT = 1024 * 1024 * 2
|
||||
@@ -97,7 +96,6 @@ export const layer = (options?: ShellSelect.Options) =>
|
||||
const bus = yield* Bus.Service
|
||||
const location = yield* Location.Service
|
||||
const config = yield* Config.Service
|
||||
const global = yield* Global.Service
|
||||
const context = yield* Effect.context()
|
||||
const runFork = Effect.runForkWith(context)
|
||||
const sessions = new Map<PtyID, Active>()
|
||||
@@ -167,8 +165,7 @@ export const layer = (options?: ShellSelect.Options) =>
|
||||
|
||||
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
|
||||
const id = PtyID.ascending()
|
||||
const command =
|
||||
input.command || ShellSelect.preferred(Config.latest(yield* config.entries(), "shell"), options, global.bin)
|
||||
const command = input.command || ShellSelect.preferred(Config.latest(yield* config.entries(), "shell"), options)
|
||||
const args = ShellSelect.login(command) ? [...(input.args ?? []), "-l"] : [...(input.args ?? [])]
|
||||
const cwd = input.cwd || location.directory
|
||||
const env = {
|
||||
@@ -318,11 +315,7 @@ export const layer = (options?: ShellSelect.Options) =>
|
||||
)
|
||||
|
||||
export function configured(options?: ShellSelect.Options) {
|
||||
return makeLocationNode({
|
||||
service: Service,
|
||||
layer: layer(options),
|
||||
deps: [Bus.node, Location.node, Config.node, Global.node],
|
||||
})
|
||||
return makeLocationNode({ service: Service, layer: layer(options), deps: [Bus.node, Location.node, Config.node] })
|
||||
}
|
||||
|
||||
export const node = configured()
|
||||
|
||||
@@ -34,8 +34,6 @@ export namespace RipgrepBinary {
|
||||
const fs = yield* FSUtil.Service
|
||||
const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
|
||||
const spawner = yield* ChildProcessSpawner
|
||||
const global = yield* Global.Service
|
||||
const findExecutable = (name: string) => which(name, undefined, global.bin)
|
||||
|
||||
const run = Effect.fnUntraced(function* (command: string, args: string[]) {
|
||||
const handle = yield* spawner.spawn(ChildProcess.make(command, args, { extendEnv: true, stdin: "ignore" }))
|
||||
@@ -55,12 +53,10 @@ export namespace RipgrepBinary {
|
||||
config: (typeof PLATFORM)[keyof typeof PLATFORM],
|
||||
target: string,
|
||||
) {
|
||||
const dir = yield* fs.makeTempDirectoryScoped({ directory: global.bin, prefix: "ripgrep-" })
|
||||
const dir = yield* fs.makeTempDirectoryScoped({ directory: Global.Path.bin, prefix: "ripgrep-" })
|
||||
|
||||
if (config.extension === "zip") {
|
||||
const shell =
|
||||
(yield* Effect.sync(() => findExecutable("powershell.exe") ?? findExecutable("pwsh.exe"))) ??
|
||||
"powershell.exe"
|
||||
const shell = (yield* Effect.sync(() => which("powershell.exe") ?? which("pwsh.exe"))) ?? "powershell.exe"
|
||||
const result = yield* run(shell, [
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
@@ -95,10 +91,10 @@ export namespace RipgrepBinary {
|
||||
return Service.of({
|
||||
filepath: yield* Effect.cached(
|
||||
Effect.gen(function* () {
|
||||
const system = yield* Effect.sync(() => findExecutable(process.platform === "win32" ? "rg.exe" : "rg"))
|
||||
const system = yield* Effect.sync(() => which(process.platform === "win32" ? "rg.exe" : "rg"))
|
||||
if (system && (yield* fs.isFile(system).pipe(Effect.orDie))) return system
|
||||
|
||||
const target = path.join(global.bin, `rg${process.platform === "win32" ? ".exe" : ""}`)
|
||||
const target = path.join(Global.Path.bin, `rg${process.platform === "win32" ? ".exe" : ""}`)
|
||||
if (yield* fs.isFile(target).pipe(Effect.orDie)) return target
|
||||
|
||||
const platformKey = `${process.arch}-${process.platform}` as keyof typeof PLATFORM
|
||||
@@ -107,10 +103,10 @@ export namespace RipgrepBinary {
|
||||
|
||||
const filename = `ripgrep-${VERSION}-${config.platform}.${config.extension}`
|
||||
const url = `https://github.com/BurntSushi/ripgrep/releases/download/${VERSION}/${filename}`
|
||||
const archive = path.join(global.bin, filename)
|
||||
const archive = path.join(Global.Path.bin, filename)
|
||||
|
||||
yield* Effect.logInfo("downloading ripgrep", { url })
|
||||
yield* fs.ensureDir(global.bin).pipe(Effect.orDie)
|
||||
yield* fs.ensureDir(Global.Path.bin).pipe(Effect.orDie)
|
||||
const bytes = yield* HttpClientRequest.get(url).pipe(
|
||||
http.execute,
|
||||
Effect.flatMap((response) => response.arrayBuffer),
|
||||
@@ -131,6 +127,6 @@ export namespace RipgrepBinary {
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [FSUtil.node, Global.node, httpClient, CrossSpawnSpawner.node],
|
||||
deps: [FSUtil.node, httpClient, CrossSpawnSpawner.node],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { SessionEvent } from "./event"
|
||||
import { SessionMessage } from "./message"
|
||||
|
||||
export interface Adapter {
|
||||
readonly getAgent: () => Effect.Effect<SessionMessage.AgentSelected["agent"] | undefined, never, never>
|
||||
readonly getModel: () => Effect.Effect<SessionMessage.ModelSelected["model"] | undefined, never, never>
|
||||
readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined, never, never>
|
||||
readonly getAssistant: (
|
||||
@@ -60,19 +59,15 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
"session.created": () => Effect.void,
|
||||
"session.usage.recorded": () => Effect.void,
|
||||
"session.agent.selected": (event) => {
|
||||
return Effect.gen(function* () {
|
||||
const previous = yield* adapter.getAgent()
|
||||
yield* adapter.appendMessage(
|
||||
SessionMessage.AgentSelected.make({
|
||||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
type: "agent-switched",
|
||||
metadata: event.metadata,
|
||||
agent: event.data.agent,
|
||||
previous,
|
||||
time: { created: event.created },
|
||||
}),
|
||||
)
|
||||
})
|
||||
return adapter.appendMessage(
|
||||
SessionMessage.AgentSelected.make({
|
||||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
type: "agent-switched",
|
||||
metadata: event.metadata,
|
||||
agent: event.data.agent,
|
||||
time: { created: event.created },
|
||||
}),
|
||||
)
|
||||
},
|
||||
"session.model.selected": (event) => {
|
||||
return Effect.gen(function* () {
|
||||
|
||||
@@ -5,7 +5,6 @@ import { DateTime, Effect, Layer, Schema, Stream } from "effect"
|
||||
import { Database } from "../database/database"
|
||||
import { Bus } from "../bus"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Agent } from "../agent"
|
||||
import { Model } from "../model"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionMessage } from "./message"
|
||||
@@ -231,17 +230,6 @@ function run(db: DatabaseService, event: MessageEvent) {
|
||||
}
|
||||
const appendMessage = (message: SessionMessage.Info) => insertMessage(db, event, message)
|
||||
const adapter: SessionMessageUpdater.Adapter = {
|
||||
getAgent() {
|
||||
return db
|
||||
.select({ agent: SessionTable.agent })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.get()
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((row) => (row?.agent ? Agent.ID.make(row.agent) : undefined)),
|
||||
)
|
||||
},
|
||||
getModel() {
|
||||
return db
|
||||
.select({ model: SessionTable.model })
|
||||
@@ -410,15 +398,12 @@ const layer = Layer.effectDiscard(
|
||||
db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie),
|
||||
)
|
||||
yield* bus.project(SessionEvent.AgentSelected, (event) =>
|
||||
Effect.gen(function* () {
|
||||
yield* run(db, event)
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
.set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.created) })
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
db
|
||||
.update(SessionTable)
|
||||
.set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.created) })
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie, Effect.andThen(run(db, event))),
|
||||
)
|
||||
yield* bus.project(SessionEvent.ModelSelected, (event) =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -59,25 +59,6 @@ const attachmentContent = (file: FileAttachment): ContentPart[] => {
|
||||
return []
|
||||
}
|
||||
|
||||
const userAttachmentContent = (files: readonly FileAttachment[]) => {
|
||||
const eligible = files.filter(
|
||||
(file) => imageMimes.has(file.mime) && file.source.type === "inline" && file.mention?.text,
|
||||
)
|
||||
if (eligible.length < 2) return files.flatMap(attachmentContent)
|
||||
|
||||
const seen = new Map<string, string[]>()
|
||||
return files.flatMap((file) => {
|
||||
if (!imageMimes.has(file.mime) || file.source.type !== "inline" || !file.mention?.text)
|
||||
return attachmentContent(file)
|
||||
const metadata = JSON.stringify([file.mime, file.name ?? null, file.description ?? null, file.mention.text])
|
||||
const matches = seen.get(metadata)
|
||||
if (matches?.includes(file.data)) return []
|
||||
if (matches) matches.push(file.data)
|
||||
if (!matches) seen.set(metadata, [file.data])
|
||||
return attachmentContent(file)
|
||||
})
|
||||
}
|
||||
|
||||
const decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
|
||||
|
||||
const providerMetadata = (
|
||||
@@ -205,7 +186,7 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
|
||||
const content = [
|
||||
...(message.skills ?? []).map((skill) => Message.text(skill.text)),
|
||||
...(message.text === "" ? [] : [Message.text(message.text)]),
|
||||
...userAttachmentContent(message.files ?? []),
|
||||
...(message.files ?? []).flatMap(attachmentContent),
|
||||
]
|
||||
if (content.length === 0) return []
|
||||
return [
|
||||
|
||||
@@ -143,9 +143,7 @@ export const layer = (options?: ShellSelect.Options) =>
|
||||
})
|
||||
|
||||
const resolve = () =>
|
||||
config
|
||||
.entries()
|
||||
.pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options, global.bin)))
|
||||
config.entries().pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options)))
|
||||
|
||||
const name = () => resolve().pipe(Effect.map(ShellSelect.name))
|
||||
|
||||
|
||||
@@ -34,19 +34,15 @@ function stat(file: string) {
|
||||
return statSync(file, { throwIfNoEntry: false }) ?? undefined
|
||||
}
|
||||
|
||||
function findExecutable(name: string, bin?: string) {
|
||||
return which(name, undefined, bin)
|
||||
}
|
||||
|
||||
function full(file: string, options?: Options, bin?: string) {
|
||||
function full(file: string, options?: Options) {
|
||||
if (process.platform !== "win32") return file
|
||||
const shell = FSUtil.windowsPath(file)
|
||||
if (path.win32.dirname(shell) !== ".") {
|
||||
if (shell.startsWith("/") && name(shell) === "bash") return gitbash(options, bin) || shell
|
||||
if (shell.startsWith("/") && name(shell) === "bash") return gitbash(options) || shell
|
||||
return shell
|
||||
}
|
||||
if (name(shell) === "bash") return gitbash(options, bin) || findExecutable(shell, bin) || shell
|
||||
return findExecutable(shell, bin) || shell
|
||||
if (name(shell) === "bash") return gitbash(options) || which(shell) || shell
|
||||
return which(shell) || shell
|
||||
}
|
||||
|
||||
function meta(file: string) {
|
||||
@@ -61,26 +57,21 @@ function rooted(file: string) {
|
||||
return path.isAbsolute(FSUtil.windowsPath(file))
|
||||
}
|
||||
|
||||
function resolve(file: string, options?: Options, bin?: string) {
|
||||
const shell = full(file, options, bin)
|
||||
function resolve(file: string, options?: Options) {
|
||||
const shell = full(file, options)
|
||||
if (rooted(shell)) {
|
||||
if (stat(shell)?.isFile()) return shell
|
||||
return
|
||||
}
|
||||
return findExecutable(shell, bin) ?? undefined
|
||||
return which(shell) ?? undefined
|
||||
}
|
||||
|
||||
function win(options?: Options, bin?: string) {
|
||||
function win(options?: Options) {
|
||||
return Array.from(
|
||||
new Set(
|
||||
[
|
||||
findExecutable("pwsh", bin),
|
||||
findExecutable("powershell", bin),
|
||||
gitbash(options, bin),
|
||||
process.env.COMSPEC || "cmd.exe",
|
||||
]
|
||||
[which("pwsh"), which("powershell"), gitbash(options), process.env.COMSPEC || "cmd.exe"]
|
||||
.filter((item): item is string => Boolean(item))
|
||||
.map((file) => full(file, options, bin)),
|
||||
.map((file) => full(file, options)),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -91,27 +82,27 @@ async function unix() {
|
||||
return ["/bin/bash", "/bin/zsh", "/bin/sh"]
|
||||
}
|
||||
|
||||
function select(file: string | undefined, options?: Options, opts?: { acceptable?: boolean }, bin?: string) {
|
||||
function select(file: string | undefined, options?: Options, opts?: { acceptable?: boolean }) {
|
||||
if (file && (!opts?.acceptable || ok(file))) {
|
||||
const shell = resolve(file, options, bin)
|
||||
const shell = resolve(file, options)
|
||||
if (shell) return shell
|
||||
}
|
||||
if (process.platform === "win32") return win(options, bin)[0]
|
||||
return fallback(bin)
|
||||
if (process.platform === "win32") return win(options)[0]
|
||||
return fallback()
|
||||
}
|
||||
|
||||
export function gitbash(options?: Options, bin?: string) {
|
||||
export function gitbash(options?: Options) {
|
||||
if (process.platform !== "win32") return
|
||||
if (options?.gitbash) return options.gitbash
|
||||
const git = findExecutable("git", bin)
|
||||
const git = which("git")
|
||||
if (!git) return
|
||||
const file = path.join(git, "..", "..", "bin", "bash.exe")
|
||||
if (stat(file)?.size) return file
|
||||
}
|
||||
|
||||
function fallback(bin?: string) {
|
||||
function fallback() {
|
||||
if (process.platform === "darwin") return "/bin/zsh"
|
||||
const bash = findExecutable("bash", bin)
|
||||
const bash = which("bash")
|
||||
if (bash) return bash
|
||||
return "/bin/sh"
|
||||
}
|
||||
@@ -129,12 +120,12 @@ export function ps(file: string) {
|
||||
return meta(file)?.ps === true
|
||||
}
|
||||
|
||||
function info(file: string, options?: Options, bin?: string): Item {
|
||||
const item = full(file, options, bin)
|
||||
function info(file: string, options?: Options): Item {
|
||||
const item = full(file, options)
|
||||
const n = name(item)
|
||||
return {
|
||||
path: item,
|
||||
name: resolve(n, options, bin) ? n : item,
|
||||
name: resolve(n, options) ? n : item,
|
||||
acceptable: ok(item),
|
||||
}
|
||||
}
|
||||
@@ -148,36 +139,30 @@ export function args(file: string, command: string) {
|
||||
return ["-c", command]
|
||||
}
|
||||
|
||||
let defaultPreferred: { bin?: string; value: string } | undefined
|
||||
let defaultAcceptable: { bin?: string; value: string } | undefined
|
||||
let defaultPreferred: string | undefined
|
||||
let defaultAcceptable: string | undefined
|
||||
|
||||
export function preferred(configShell?: string, options?: Options, bin?: string) {
|
||||
if (configShell) return select(configShell, options, undefined, bin)
|
||||
if (options?.gitbash) return select(process.env.SHELL, options, undefined, bin)
|
||||
const cached = defaultPreferred
|
||||
if (cached && cached.bin === bin) return cached.value
|
||||
const value = select(process.env.SHELL, undefined, undefined, bin) ?? fallback(bin)
|
||||
defaultPreferred = { bin, value }
|
||||
return value
|
||||
export function preferred(configShell?: string, options?: Options) {
|
||||
if (configShell) return select(configShell, options)
|
||||
if (options?.gitbash) return select(process.env.SHELL, options)
|
||||
defaultPreferred ??= select(process.env.SHELL)
|
||||
return defaultPreferred
|
||||
}
|
||||
preferred.reset = () => {
|
||||
defaultPreferred = undefined
|
||||
}
|
||||
|
||||
export function acceptable(configShell?: string, options?: Options, bin?: string) {
|
||||
if (configShell) return select(configShell, options, { acceptable: true }, bin)
|
||||
if (options?.gitbash) return select(process.env.SHELL, options, { acceptable: true }, bin)
|
||||
const cached = defaultAcceptable
|
||||
if (cached && cached.bin === bin) return cached.value
|
||||
const value = select(process.env.SHELL, undefined, { acceptable: true }, bin) ?? fallback(bin)
|
||||
defaultAcceptable = { bin, value }
|
||||
return value
|
||||
export function acceptable(configShell?: string, options?: Options) {
|
||||
if (configShell) return select(configShell, options, { acceptable: true })
|
||||
if (options?.gitbash) return select(process.env.SHELL, options, { acceptable: true })
|
||||
defaultAcceptable ??= select(process.env.SHELL, undefined, { acceptable: true })
|
||||
return defaultAcceptable
|
||||
}
|
||||
acceptable.reset = () => {
|
||||
defaultAcceptable = undefined
|
||||
}
|
||||
|
||||
export async function list(options?: Options, bin?: string): Promise<Item[]> {
|
||||
const shells = process.platform === "win32" ? win(options, bin) : await unix()
|
||||
return shells.filter((shell) => resolve(shell, options, bin)).map((shell) => info(shell, options, bin))
|
||||
export async function list(options?: Options): Promise<Item[]> {
|
||||
const shells = process.platform === "win32" ? win(options) : await unix()
|
||||
return shells.filter((shell) => resolve(shell, options)).map((shell) => info(shell, options))
|
||||
}
|
||||
|
||||
+192
-23
@@ -2,12 +2,17 @@ export * as Skill from "./skill"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer, Types } from "effect"
|
||||
import { Context, Effect, FiberMap, Layer, PubSub, Schema, Semaphore, Stream, Types } from "effect"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import { Agent } from "./agent"
|
||||
import { ConfigMarkdown } from "./config/markdown"
|
||||
import { Bus } from "./bus"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Permission } from "./permission"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { SkillDiscovery } from "./skill/discovery"
|
||||
import { State } from "./state"
|
||||
import { Watcher } from "./filesystem/watcher"
|
||||
|
||||
export const DirectorySource = Skill.DirectorySource
|
||||
export type DirectorySource = Skill.DirectorySource
|
||||
@@ -52,18 +57,38 @@ export const toModelOutput = (skill: Info, files: ReadonlyArray<string>) => {
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
const Frontmatter = Schema.Struct({
|
||||
name: Schema.String.pipe(Schema.optional),
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
slash: Schema.Boolean.pipe(Schema.optional),
|
||||
metadata: Schema.Unknown.pipe(Schema.optional),
|
||||
})
|
||||
const decodeFrontmatter = Schema.decodeUnknownOption(Frontmatter)
|
||||
|
||||
const metadataBoolean = (metadata: unknown, key: string) => {
|
||||
if (metadata === undefined || metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) {
|
||||
return undefined
|
||||
}
|
||||
const value = (metadata as { readonly [key: string]: unknown })[key]
|
||||
if (typeof value === "boolean") return value
|
||||
if (typeof value !== "string") return undefined
|
||||
const normalized = value.trim().toLowerCase()
|
||||
if (normalized === "true") return true
|
||||
if (normalized === "false") return false
|
||||
return undefined
|
||||
}
|
||||
|
||||
export type Data = {
|
||||
skills: Map<ID, Types.DeepMutable<Info>>
|
||||
sources: Types.DeepMutable<Source>[]
|
||||
}
|
||||
|
||||
export type Draft = {
|
||||
list: () => readonly Types.DeepMutable<Info>[]
|
||||
add: (skill: Info) => void
|
||||
update: (id: string, update: (skill: Types.DeepMutable<Info>) => void) => void
|
||||
remove: (id: string) => void
|
||||
source: (source: Source) => void
|
||||
list: () => readonly Source[]
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
readonly sources: () => Effect.Effect<Source[]>
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
}
|
||||
|
||||
@@ -72,35 +97,179 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Sk
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const discovery = yield* SkillDiscovery.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const bus = yield* Bus.Service
|
||||
const watcher = yield* Watcher.Service
|
||||
const cache = new Map<string, { skills: Info[]; paths: readonly string[] }>()
|
||||
const watches = yield* FiberMap.make<string>()
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
const changes = yield* PubSub.unbounded<string>()
|
||||
|
||||
const invalidate = Effect.fn("Skill.invalidateFromWatcher")(function* (file: string) {
|
||||
const changed = yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const invalidated = Array.from(cache.entries()).filter(([, loaded]) =>
|
||||
loaded.paths.some((item) => FSUtil.overlaps(item, file)),
|
||||
)
|
||||
if (invalidated.length === 0) return false
|
||||
cache.clear()
|
||||
yield* FiberMap.clear(watches)
|
||||
yield* Effect.logInfo("skill cache invalidated", {
|
||||
file,
|
||||
sources: invalidated.map(([key]) => key),
|
||||
skills: invalidated.flatMap(([, loaded]) => loaded.skills.map((skill) => skill.id)),
|
||||
})
|
||||
return true
|
||||
}),
|
||||
)
|
||||
if (!changed) return
|
||||
yield* bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid)
|
||||
})
|
||||
|
||||
yield* Stream.fromPubSub(changes).pipe(Stream.runForEach(invalidate), Effect.forkScoped({ startImmediately: true }))
|
||||
|
||||
const watch = Effect.fn("Skill.watch")(function* (directory: string, type: Watcher.WatchInput["type"]) {
|
||||
const target = path.resolve(directory)
|
||||
const updates = yield* watcher.subscribe(
|
||||
type === "file" ? { path: target, type: "file" } : { path: target, type: "directory" },
|
||||
)
|
||||
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.succeed(undefined)
|
||||
return fs.isDir(parent).pipe(Effect.flatMap((exists) => (exists ? Effect.succeed(target) : firstMissing(parent))))
|
||||
}
|
||||
|
||||
const watchDirectory: (directory: string) => Effect.Effect<string[]> = Effect.fn("Skill.watchDirectory")(function* (
|
||||
directory: string,
|
||||
) {
|
||||
const target = path.resolve(directory)
|
||||
const resolved = yield* fs.realPath(directory).pipe(Effect.catch(() => Effect.succeed(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.catch(() => Effect.succeed(false)),
|
||||
)
|
||||
) {
|
||||
if (missing) yield* FiberMap.remove(watches, `file:${path.resolve(missing)}`)
|
||||
return yield* watchDirectory(directory)
|
||||
}
|
||||
return [target]
|
||||
})
|
||||
|
||||
const state = State.create<Data, Draft>({
|
||||
name: "skill",
|
||||
initial: () => ({ skills: new Map() }),
|
||||
initial: () => ({ sources: [] }),
|
||||
draft: (draft) => ({
|
||||
list: () => Array.from(draft.skills.values()),
|
||||
add: (skill) => {
|
||||
draft.skills.set(skill.id, { ...skill } as Types.DeepMutable<Info>)
|
||||
},
|
||||
update: (id, update) => {
|
||||
const current = draft.skills.get(ID.make(id))
|
||||
if (!current) return
|
||||
update(current)
|
||||
current.id = ID.make(id)
|
||||
},
|
||||
remove: (id) => {
|
||||
draft.skills.delete(ID.make(id))
|
||||
source: (source) => {
|
||||
if (draft.sources.some((item) => Source.equals(item, source))) return
|
||||
draft.sources.push(source as Types.DeepMutable<Source>)
|
||||
},
|
||||
list: () => draft.sources as Source[],
|
||||
}),
|
||||
finalize: () => bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
finalize: () =>
|
||||
lock
|
||||
.withPermit(FiberMap.clear(watches).pipe(Effect.andThen(Effect.sync(() => cache.clear())), Effect.asVoid))
|
||||
.pipe(Effect.andThen(bus.publish(Skill.Event.Updated, {})), Effect.asVoid),
|
||||
})
|
||||
|
||||
const load = Effect.fn("Skill.load")(function* (source: Source) {
|
||||
const skills: Info[] = []
|
||||
if (source.type === "embedded") {
|
||||
yield* Effect.logDebug("skill source loaded", {
|
||||
source: Source.key(source),
|
||||
type: source.type,
|
||||
directories: [],
|
||||
skills: [source.skill.id],
|
||||
})
|
||||
return { skills: [source.skill], paths: [] }
|
||||
}
|
||||
const directories = source.type === "directory" ? [source.path] : yield* discovery.pull(source.url)
|
||||
const roots = (yield* Effect.forEach(directories, watchDirectory)).flat()
|
||||
const paths = [...roots]
|
||||
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.catch(() => Effect.succeed([] as string[])))
|
||||
for (const filepath of files.toSorted()) {
|
||||
const resolved = yield* fs.realPath(filepath).pipe(Effect.catch(() => Effect.succeed(filepath)))
|
||||
if (!roots.some((root) => FSUtil.contains(root, resolved))) {
|
||||
const external = path.dirname(resolved)
|
||||
paths.push(external)
|
||||
yield* watch(external, "directory")
|
||||
}
|
||||
const content = yield* fs.readFileStringSafe(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (!content) continue
|
||||
const markdown = ConfigMarkdown.parseOption(content)
|
||||
if (!markdown) continue
|
||||
const frontmatter = decodeFrontmatter(markdown.data).valueOrUndefined
|
||||
if (!frontmatter) continue
|
||||
const id =
|
||||
path.dirname(filepath) === directory
|
||||
? path.basename(filepath, ".md")
|
||||
: path.basename(path.dirname(filepath))
|
||||
skills.push({
|
||||
id: ID.make(id),
|
||||
name: Name.make(frontmatter.name ?? id),
|
||||
description: frontmatter.description,
|
||||
slash: metadataBoolean(frontmatter.metadata, "opencode/slash") ?? frontmatter.slash,
|
||||
autoinvoke: metadataBoolean(frontmatter.metadata, "opencode/autoinvoke"),
|
||||
location: AbsolutePath.make(filepath),
|
||||
content: markdown.content,
|
||||
})
|
||||
}
|
||||
}
|
||||
yield* Effect.logDebug("skill source loaded", {
|
||||
source: Source.key(source),
|
||||
type: source.type,
|
||||
directories,
|
||||
skills: skills.map((skill) => skill.id),
|
||||
})
|
||||
return { skills, paths }
|
||||
})
|
||||
|
||||
const list = Effect.fn("Skill.list")(function* () {
|
||||
return yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const skills = new Map<ID, Info>()
|
||||
for (const source of state.get().sources) {
|
||||
const key = Source.key(source)
|
||||
const loaded = cache.get(key) ?? (yield* load(source))
|
||||
cache.set(key, loaded)
|
||||
for (const skill of loaded.skills) skills.set(skill.id, skill)
|
||||
}
|
||||
return Array.from(skills.values())
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
list: Effect.fn("Skill.list")(function* () {
|
||||
return Array.from(state.get().skills.values())
|
||||
sources: Effect.fn("Skill.sources")(function* () {
|
||||
return state.get().sources
|
||||
}),
|
||||
list,
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -108,5 +277,5 @@ const layer = Layer.effect(
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Bus.node],
|
||||
deps: [SkillDiscovery.node, FSUtil.node, Bus.node, Watcher.node],
|
||||
})
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import whichPkg from "which"
|
||||
import path from "path"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
|
||||
export function which(cmd: string, env?: NodeJS.ProcessEnv, bin?: string) {
|
||||
export function which(cmd: string, env?: NodeJS.ProcessEnv) {
|
||||
const base = env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path ?? ""
|
||||
const full = base && bin ? base + path.delimiter + bin : base || bin
|
||||
const full = base ? base + path.delimiter + Global.Path.bin : Global.Path.bin
|
||||
const result = whichPkg.sync(cmd, {
|
||||
nothrow: true,
|
||||
path: full,
|
||||
|
||||
@@ -465,7 +465,6 @@ Use native v2 fields.`,
|
||||
},
|
||||
}),
|
||||
)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "reviewer.md"), "Review once"))
|
||||
yield* configTest.emitChange({ type: "create", path: path.join(directory, "reviewer.md") })
|
||||
|
||||
@@ -185,7 +185,6 @@ Review files`,
|
||||
},
|
||||
}),
|
||||
)
|
||||
yield* Effect.yieldNow
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "review.md"), "Review once"))
|
||||
yield* configTest.emitChange({ type: "create", path: path.join(directory, "review.md") })
|
||||
yield* configTest.emitChange({ type: "update", path: path.join(directory, "review.md") })
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
name: first
|
||||
description: First skill
|
||||
---
|
||||
|
||||
# first
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
name: second
|
||||
description: Second skill
|
||||
---
|
||||
|
||||
# second
|
||||
@@ -5,10 +5,8 @@ import { describe, expect } from "bun:test"
|
||||
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { ConfigPluginSource } from "@opencode-ai/core/config/plugin/source"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
@@ -21,19 +19,10 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Effect, Fiber, Logger, Stream } from "effect"
|
||||
import { Database } from "../../src/database/database"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import { tempGlobalLayer } from "../fixture/global"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
|
||||
[Global.node, tempGlobalLayer],
|
||||
]),
|
||||
)
|
||||
const staticIt = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
|
||||
[ConfigPluginSource.node, ConfigPluginSource.empty],
|
||||
[Global.node, tempGlobalLayer],
|
||||
]),
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node])),
|
||||
)
|
||||
|
||||
describe("PluginSupervisor config", () => {
|
||||
@@ -168,29 +157,6 @@ describe("PluginSupervisor config", () => {
|
||||
),
|
||||
)
|
||||
|
||||
staticIt.live("uses only internal and SDK plugins when the static source is wired", () =>
|
||||
Effect.gen(function* () {
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
yield* sdk.register(EffectPlugin.define({ id: "static-sdk", effect: () => Effect.void }))
|
||||
yield* withLocation(
|
||||
{ plugins: ["-*", path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts")] },
|
||||
Effect.gen(function* () {
|
||||
yield* ready()
|
||||
const plugins = yield* Plugin.Service
|
||||
const ids = (yield* plugins.list()).map((plugin) => String(plugin.id))
|
||||
expect(ids).toContain("opencode.agent")
|
||||
expect(ids).toContain("static-sdk")
|
||||
expect(ids).not.toContain("config-promise-plugin")
|
||||
|
||||
const agents = yield* Agent.Service
|
||||
expect(yield* agents.get(Agent.ID.make("directory"))).toBeUndefined()
|
||||
expect(yield* agents.get(Agent.ID.make("configured"))).toBeUndefined()
|
||||
}),
|
||||
true,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("reloads an auto-discovered plugin when its file changes", () =>
|
||||
withLocation(
|
||||
undefined,
|
||||
|
||||
@@ -46,7 +46,9 @@ describe("config plugin reloads", () => {
|
||||
|
||||
expect((yield* agents.get(Agent.ID.make("first")))?.description).toBe("First agent")
|
||||
expect((yield* commands.get("first"))?.description).toBe("First command")
|
||||
expect((yield* skills.list()).some((skill) => skill.id === "first")).toBe(true)
|
||||
expect(
|
||||
(yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/first"),
|
||||
).toBe(true)
|
||||
expect((yield* references.list()).map((reference) => reference.name)).toEqual(["first"])
|
||||
expect(yield* catalog.provider.get(Provider.ID.make("first"))).toBeDefined()
|
||||
|
||||
@@ -67,8 +69,12 @@ describe("config plugin reloads", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
expect((yield* skills.list()).some((skill) => skill.id === "first")).toBe(false)
|
||||
expect((yield* skills.list()).some((skill) => skill.id === "second")).toBe(true)
|
||||
expect(
|
||||
(yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/first"),
|
||||
).toBe(false)
|
||||
expect(
|
||||
(yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/second"),
|
||||
).toBe(true)
|
||||
}).pipe(
|
||||
Effect.provide(Config.testLayer([config("first")])),
|
||||
Effect.provideService(Global.Service, Global.Service.of(Global.make())),
|
||||
@@ -83,7 +89,7 @@ function config(name: string) {
|
||||
info: decode({
|
||||
agents: { [name]: { description: `${title(name)} agent`, mode: "subagent" } },
|
||||
commands: { [name]: { template: `${title(name)} command`, description: `${title(name)} command` } },
|
||||
skills: [path.join(import.meta.dir, "fixture", "skills", `${name}-source`)],
|
||||
skills: [`/skills/${name}`],
|
||||
references: { [name]: `/references/${name}` },
|
||||
providers: { [name]: { models: { chat: { name: `${title(name)} model` } } } },
|
||||
}),
|
||||
|
||||
@@ -1,379 +1,87 @@
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Fiber, Layer, Schema, Stream } from "effect"
|
||||
import { Effect, Layer, Schema, Stream } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import {
|
||||
AgentsDirectory,
|
||||
ClaudeDirectory,
|
||||
Directory as ConfigDirectory,
|
||||
Document,
|
||||
type Entry,
|
||||
Info,
|
||||
} from "@opencode-ai/schema/config"
|
||||
import { AgentsDirectory, ClaudeDirectory, Directory, Document, Info } from "@opencode-ai/schema/config"
|
||||
import { ConfigSkillPlugin } from "@opencode-ai/core/config/plugin/skill"
|
||||
import { SkillFile } from "@opencode-ai/core/config/plugin/skill-file"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
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 { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Skill } from "@opencode-ai/core/skill"
|
||||
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import { location } from "../fixture/location"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { host } from "../plugin/host"
|
||||
|
||||
const urls = new Map<string, AbsolutePath[]>()
|
||||
const failedUrls = new Set<string>()
|
||||
let pulls = 0
|
||||
const discoveryLayer = Layer.succeed(
|
||||
SkillDiscovery.Service,
|
||||
SkillDiscovery.Service.of({
|
||||
pull: (url) => {
|
||||
pulls++
|
||||
if (failedUrls.has(url)) return Effect.die(`failed to pull ${url}`)
|
||||
return Effect.succeed(urls.get(url) ?? [])
|
||||
},
|
||||
}),
|
||||
)
|
||||
const watcherLayer = Watcher.testLayer
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
AppNodeBuilder.build(LayerNode.group([Skill.node, Bus.node, FSUtil.node])),
|
||||
discoveryLayer,
|
||||
watcherLayer,
|
||||
),
|
||||
)
|
||||
const it = testEffect(Layer.empty)
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
function write(directory: string, name: string, description: string) {
|
||||
return fs.writeFile(
|
||||
path.join(directory, name, "SKILL.md"),
|
||||
`---
|
||||
name: ${name}
|
||||
description: ${description}
|
||||
---
|
||||
# ${name}`,
|
||||
)
|
||||
}
|
||||
|
||||
const startEntries = Effect.fnUntraced(function* (entries: Entry[], directory: string, home = directory) {
|
||||
const service = yield* Skill.Service
|
||||
yield* ConfigSkillPlugin.Plugin.effect(
|
||||
host({
|
||||
skill: {
|
||||
list: () => Effect.die("unused skill.list"),
|
||||
transform: service.transform,
|
||||
reload: service.reload,
|
||||
},
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(Config.testLayer(entries)),
|
||||
Effect.provideService(Global.Service, Global.Service.of({ ...Global.make(), home })),
|
||||
Effect.provideService(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
|
||||
)
|
||||
return service
|
||||
})
|
||||
|
||||
const start = (skills: string[], directory: string) =>
|
||||
startEntries(
|
||||
[
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({ skills }),
|
||||
}),
|
||||
],
|
||||
directory,
|
||||
)
|
||||
|
||||
function emitAndWait(update: Watcher.Update) {
|
||||
return Effect.gen(function* () {
|
||||
const watcher = yield* Watcher.Test
|
||||
const bus = yield* Bus.Service
|
||||
const deferred = yield* Deferred.make<void>()
|
||||
const fiber = yield* bus.subscribe(Skill.Event.Updated).pipe(
|
||||
Stream.runForEach(() => Deferred.succeed(deferred, undefined).pipe(Effect.asVoid)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
yield* Effect.yieldNow
|
||||
yield* watcher.emit(update)
|
||||
yield* Deferred.await(deferred).pipe(Effect.timeout("2 seconds"))
|
||||
yield* Fiber.interrupt(fiber)
|
||||
})
|
||||
}
|
||||
|
||||
describe("SkillFile.parse", () => {
|
||||
it.effect("parses root and nested skill ids and metadata flags", () =>
|
||||
Effect.sync(() => {
|
||||
const directory = "/repo/skills"
|
||||
expect(
|
||||
SkillFile.parse(
|
||||
directory,
|
||||
"/repo/skills/manual/SKILL.md",
|
||||
`---
|
||||
name: Manual
|
||||
description: Manual only
|
||||
metadata:
|
||||
opencode/slash: "true"
|
||||
opencode/autoinvoke: false
|
||||
---
|
||||
# manual`,
|
||||
),
|
||||
).toEqual({
|
||||
_tag: "Parsed",
|
||||
skill: {
|
||||
id: Skill.ID.make("manual"),
|
||||
name: Skill.Name.make("Manual"),
|
||||
description: "Manual only",
|
||||
slash: true,
|
||||
autoinvoke: false,
|
||||
location: AbsolutePath.make("/repo/skills/manual/SKILL.md"),
|
||||
content: "# manual",
|
||||
},
|
||||
})
|
||||
expect(SkillFile.parse(directory, "/repo/skills/foo.md", "---\nslash: true\n---\n# foo")).toMatchObject({
|
||||
_tag: "Parsed",
|
||||
skill: { id: Skill.ID.make("foo") },
|
||||
})
|
||||
expect(
|
||||
SkillFile.parse(directory, "/repo/skills/broken.md", "---\ndescription: foo: bar\nmetadata: [\n---\n# broken"),
|
||||
).toEqual({ _tag: "Skipped", reason: "markdown" })
|
||||
expect(SkillFile.parse(directory, "/repo/skills/broken.md", "---\nslash: nope\n---\n# broken")).toMatchObject({
|
||||
_tag: "Skipped",
|
||||
reason: "frontmatter",
|
||||
issue: expect.anything(),
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("ConfigSkillPlugin.Plugin", () => {
|
||||
it.live("maps config entry types to skill directories", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const claude = path.join(tmp.path, "claude")
|
||||
const agents = path.join(tmp.path, "agents")
|
||||
const opencode = path.join(tmp.path, "opencode")
|
||||
const home = path.join(tmp.path, "home")
|
||||
const directory = path.join(tmp.path, "project")
|
||||
const expected = [
|
||||
path.join(claude, "skills"),
|
||||
path.join(agents, "skills"),
|
||||
path.join(opencode, "skill"),
|
||||
path.join(opencode, "skills"),
|
||||
path.join(home, "shared"),
|
||||
path.join(directory, "relative"),
|
||||
]
|
||||
yield* Effect.promise(() => Promise.all(expected.map((item) => fs.mkdir(item, { recursive: true }))))
|
||||
it.effect("registers configured skill directories and URLs", () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = AbsolutePath.make("/repo/packages/app")
|
||||
const sources: Skill.Source[] = []
|
||||
const transform = Effect.fnUntraced(function* (update: (draft: Skill.Draft) => void | Effect.Effect<void>) {
|
||||
const result = update({
|
||||
source: (source) => {
|
||||
sources.push(source)
|
||||
},
|
||||
list: () => sources,
|
||||
})
|
||||
if (Effect.isEffect(result)) yield* result
|
||||
const dispose = Effect.sync(() => {
|
||||
sources.length = 0
|
||||
})
|
||||
yield* Effect.addFinalizer(() => dispose)
|
||||
return { dispose }
|
||||
})
|
||||
|
||||
yield* startEntries(
|
||||
[
|
||||
new ClaudeDirectory({ type: "claude", path: AbsolutePath.make(claude) }),
|
||||
new AgentsDirectory({ type: "agents", path: AbsolutePath.make(agents) }),
|
||||
new ConfigDirectory({ type: "directory", path: AbsolutePath.make(opencode) }),
|
||||
new Document({ type: "document", info: decode({ skills: ["~/shared", "./relative"] }) }),
|
||||
],
|
||||
directory,
|
||||
home,
|
||||
)
|
||||
const watcher = yield* Watcher.Test
|
||||
expect(yield* watcher.subscriptions()).toEqual(expected.map((item) => ({ path: item, type: "directory" })))
|
||||
yield* ConfigSkillPlugin.Plugin.effect(
|
||||
host({
|
||||
skill: { list: () => Effect.die("unused skill.list"), transform, reload: () => Effect.void },
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
).pipe(
|
||||
Effect.provideService(Global.Service, Global.Service.of({ ...Global.make(), home: "/home/test" })),
|
||||
Effect.provideService(Location.Service, Location.Service.of(location({ directory }))),
|
||||
Effect.provide(
|
||||
Config.testLayer([
|
||||
new ClaudeDirectory({ type: "claude", path: AbsolutePath.make("/repo/.claude") }),
|
||||
new AgentsDirectory({ type: "agents", path: AbsolutePath.make("/repo/.agents") }),
|
||||
new Directory({ type: "directory", path: AbsolutePath.make("/repo/.opencode") }),
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
skills: ["./skills", "~/shared-skills", "/opt/skills", "https://example.test/skills/"],
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("loads directory and URL sources with later-source precedence", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
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, "review"), { recursive: true })
|
||||
await write(first, "review", "First")
|
||||
await write(second, "review", "Second")
|
||||
})
|
||||
pulls = 0
|
||||
urls.set("https://example.test/skills/", [AbsolutePath.make(second)])
|
||||
|
||||
const skill = yield* start([first, "https://example.test/skills/"], tmp.path)
|
||||
expect((yield* skill.list()).find((item) => item.id === "review")?.description).toBe("Second")
|
||||
expect(pulls).toBe(1)
|
||||
expect(sources).toEqual([
|
||||
Skill.DirectorySource.make({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.join("/repo/.claude", "skills")),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("keeps directory skills when a URL source fails", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(tmp.path, "review"), { recursive: true })
|
||||
await write(tmp.path, "review", "Available")
|
||||
})
|
||||
const url = "https://unreachable.example.test/skills/"
|
||||
failedUrls.add(url)
|
||||
|
||||
const skill = yield* start([tmp.path, url], tmp.path)
|
||||
expect((yield* skill.list()).find((item) => item.id === "review")?.description).toBe("Available")
|
||||
failedUrls.delete(url)
|
||||
Skill.DirectorySource.make({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.join("/repo/.agents", "skills")),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rescans directory sources when watched files change", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(tmp.path, "deploy"), { recursive: true })
|
||||
await write(tmp.path, "deploy", "Initial")
|
||||
})
|
||||
const skill = yield* start([tmp.path], tmp.path)
|
||||
expect((yield* skill.list()).find((item) => item.id === "deploy")?.description).toBe("Initial")
|
||||
|
||||
const deploy = path.join(tmp.path, "deploy", "SKILL.md")
|
||||
yield* Effect.promise(() => write(tmp.path, "deploy", "Updated"))
|
||||
yield* emitAndWait({ type: "update", path: deploy })
|
||||
expect((yield* skill.list()).find((item) => item.id === "deploy")?.description).toBe("Updated")
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(tmp.path, "review"), { recursive: true })
|
||||
await write(tmp.path, "review", "Review")
|
||||
})
|
||||
yield* emitAndWait({ type: "create", path: path.join(tmp.path, "review", "SKILL.md") })
|
||||
expect((yield* skill.list()).map((item) => item.id)).toEqual([
|
||||
Skill.ID.make("deploy"),
|
||||
Skill.ID.make("review"),
|
||||
])
|
||||
|
||||
yield* Effect.promise(() => fs.rm(path.join(tmp.path, "review"), { recursive: true }))
|
||||
yield* emitAndWait({ type: "delete", path: path.join(tmp.path, "review", "SKILL.md") })
|
||||
expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
|
||||
Skill.DirectorySource.make({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.join("/repo/.opencode", "skill")),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("watches canonical directories behind symlinked skills", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const source = path.join(tmp.path, "source")
|
||||
const target = path.join(tmp.path, "target", "bro")
|
||||
const file = path.join(target, "SKILL.md")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(source, { recursive: true })
|
||||
await fs.mkdir(target, { recursive: true })
|
||||
await fs.writeFile(file, "---\nname: bro\ndescription: Initial\n---\n# bro")
|
||||
await fs.symlink(target, path.join(source, "bro"), process.platform === "win32" ? "junction" : undefined)
|
||||
})
|
||||
|
||||
const skill = yield* start([source], tmp.path)
|
||||
const watcher = yield* Watcher.Test
|
||||
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Initial")
|
||||
expect(yield* watcher.subscriptions()).toContainEqual({ path: target, type: "directory" })
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(file, "---\nname: bro\ndescription: Updated\n---\n# bro"))
|
||||
yield* emitAndWait({ type: "update", path: file })
|
||||
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Updated")
|
||||
Skill.DirectorySource.make({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.join("/repo/.opencode", "skills")),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("reloads symlinked sources when their target changes", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const source = path.join(tmp.path, "source")
|
||||
const first = path.join(tmp.path, "first")
|
||||
const second = path.join(tmp.path, "second")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(first, "bro"), { recursive: true })
|
||||
await fs.mkdir(path.join(second, "bro"), { recursive: true })
|
||||
await write(first, "bro", "First")
|
||||
await write(second, "bro", "Second")
|
||||
await fs.symlink(first, source, process.platform === "win32" ? "junction" : undefined)
|
||||
})
|
||||
|
||||
const skill = yield* start([source], tmp.path)
|
||||
const watcher = yield* Watcher.Test
|
||||
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("First")
|
||||
expect(yield* watcher.subscriptions()).toEqual([
|
||||
{ path: first, type: "directory" },
|
||||
{ path: source, type: "file" },
|
||||
])
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.unlink(source)
|
||||
await fs.symlink(second, source, process.platform === "win32" ? "junction" : undefined)
|
||||
})
|
||||
yield* emitAndWait({ type: "update", path: source })
|
||||
|
||||
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Second")
|
||||
expect(yield* watcher.subscriptions()).toEqual([
|
||||
{ path: first, type: "directory" },
|
||||
{ path: source, type: "file" },
|
||||
{ path: second, type: "directory" },
|
||||
{ path: source, type: "file" },
|
||||
])
|
||||
Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }),
|
||||
Skill.DirectorySource.make({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.join("/home/test", "shared-skills")),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("follows missing source directories as their parents appear", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const source = path.join(tmp.path, "generated", "skills")
|
||||
const skill = yield* start([source], tmp.path)
|
||||
const watcher = yield* Watcher.Test
|
||||
expect(yield* skill.list()).toEqual([])
|
||||
expect(yield* watcher.subscriptions()).toEqual([{ path: path.join(tmp.path, "generated"), type: "file" }])
|
||||
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "generated")))
|
||||
yield* emitAndWait({ type: "create", path: path.join(tmp.path, "generated") })
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(source, "deploy"), { recursive: true })
|
||||
await write(source, "deploy", "Deploy")
|
||||
})
|
||||
yield* emitAndWait({ type: "create", path: source })
|
||||
expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
|
||||
}),
|
||||
),
|
||||
),
|
||||
Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make("/opt/skills") }),
|
||||
Skill.UrlSource.make({ type: "url", url: "https://example.test/skills/" }),
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -11,19 +11,11 @@ import { migrations } from "@opencode-ai/core/database/migration.gen"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import type { SqlClient } from "effect/unstable/sql/SqlClient"
|
||||
import legacyCredentialsMigration from "@opencode-ai/core/database/migration/20260805200742_import_legacy_credentials"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { importLegacyCredentials } from "@opencode-ai/core/database/migration/20260805200742_import_legacy_credentials"
|
||||
|
||||
const run = <A, E>(
|
||||
effect: Effect.Effect<A, E, SqlClient | Global.Service>,
|
||||
global = Global.make({ data: path.join(process.cwd(), ".test-data") }),
|
||||
) =>
|
||||
const run = <A, E>(effect: Effect.Effect<A, E, SqlClient>) =>
|
||||
Effect.runPromise(
|
||||
effect.pipe(
|
||||
Effect.provideService(Global.Service, global),
|
||||
Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })),
|
||||
Effect.scoped,
|
||||
),
|
||||
effect.pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })), Effect.scoped),
|
||||
)
|
||||
|
||||
const makeDb = EffectDrizzleSqlite.makeWithDefaults()
|
||||
@@ -39,7 +31,7 @@ describe("DatabaseMigration", () => {
|
||||
Effect.scoped(Layer.build(layer)),
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(Effect.provideService(Global.Service, Global.make({ data: tmp.path }))),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -135,8 +127,7 @@ describe("DatabaseMigration", () => {
|
||||
VALUES ('existing', 'anthropic', 'Existing', ${JSON.stringify({ type: "key", key: "current-key" })}, ${now}, ${now})
|
||||
`)
|
||||
|
||||
yield* db.run(sql`DELETE FROM migration WHERE id = ${legacyCredentialsMigration.id}`)
|
||||
yield* DatabaseMigration.applyOnly(db, [legacyCredentialsMigration])
|
||||
yield* db.transaction((tx) => importLegacyCredentials(tx, source))
|
||||
|
||||
expect(yield* db.all(sql`SELECT integration_id, label, value FROM credential ORDER BY integration_id`)).toEqual(
|
||||
[
|
||||
@@ -168,28 +159,11 @@ describe("DatabaseMigration", () => {
|
||||
value: JSON.stringify(["https://example.com"]),
|
||||
})
|
||||
}),
|
||||
Global.make({ data: tmp.path }),
|
||||
)
|
||||
|
||||
expect(await Bun.file(source).text()).toBe(content)
|
||||
})
|
||||
|
||||
test("skips legacy credential import when the source file is absent", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* DatabaseMigration.apply(db)
|
||||
yield* db.run(sql`DELETE FROM migration WHERE id = ${legacyCredentialsMigration.id}`)
|
||||
yield* DatabaseMigration.applyOnly(db, [legacyCredentialsMigration])
|
||||
|
||||
expect(yield* db.all(sql`SELECT id FROM credential`)).toEqual([])
|
||||
}),
|
||||
Global.make({ data: tmp.path }),
|
||||
)
|
||||
})
|
||||
|
||||
test("rolls back a failed migration without recording it", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import path from "path"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { tmpdir } from "./tmpdir"
|
||||
|
||||
export const tempGlobalLayer = Layer.unwrap(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.map((tmp) => {
|
||||
const data = path.join(tmp.path, "data")
|
||||
const cache = path.join(tmp.path, "cache")
|
||||
return Global.layerWith({
|
||||
home: path.join(tmp.path, "home"),
|
||||
data,
|
||||
cache,
|
||||
config: path.join(tmp.path, "config"),
|
||||
state: path.join(tmp.path, "state"),
|
||||
tmp: path.join(tmp.path, "tmp"),
|
||||
bin: path.join(cache, "bin"),
|
||||
log: path.join(data, "log"),
|
||||
repos: path.join(data, "repos"),
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -204,7 +204,7 @@ describe("doStream", () => {
|
||||
finishReason: { unified: "tool-calls" },
|
||||
usage: {
|
||||
inputTokens: { total: 19581 },
|
||||
outputTokens: { total: 187, reasoning: 134 },
|
||||
outputTokens: { total: 53 },
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -259,7 +259,7 @@ describe("doStream", () => {
|
||||
finishReason: { unified: "stop" },
|
||||
usage: {
|
||||
inputTokens: { total: 5778 },
|
||||
outputTokens: { total: 154, reasoning: 95 },
|
||||
outputTokens: { total: 59 },
|
||||
},
|
||||
providerMetadata: {
|
||||
copilot: {
|
||||
@@ -391,7 +391,7 @@ describe("doStream", () => {
|
||||
finishReason: { unified: "tool-calls" },
|
||||
usage: {
|
||||
inputTokens: { total: 3767 },
|
||||
outputTokens: { total: 30, reasoning: 11 },
|
||||
outputTokens: { total: 19 },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
|
||||
describe("global paths", () => {
|
||||
test("tmp path is the canonical system temp directory", async () => {
|
||||
expect(Global.Path.tmp).toBe(await fs.realpath(path.join(os.tmpdir(), "opencode")))
|
||||
expect(Global.make().tmp).toBe(Global.Path.tmp)
|
||||
})
|
||||
|
||||
test("tmp path is created on module load", async () => {
|
||||
expect((await fs.stat(Global.Path.tmp)).isDirectory()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,124 +1,49 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Fiber, Layer, Stream } from "effect"
|
||||
import { Effect, Layer } from "effect"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { ConfigInstructionPlugin } from "@opencode-ai/core/config/plugin/instruction"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
|
||||
import { Instructions } from "@opencode-ai/core/instructions"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
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 { tempGlobalLayer } from "./fixture/global"
|
||||
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { readInitial, readUpdate, state } from "./lib/instructions"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { host } from "./plugin/host"
|
||||
import { readInitial, readUpdate, state } from "./lib/instructions"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
|
||||
const instructionLayer = (input: {
|
||||
config?: string
|
||||
config: string
|
||||
locationServiceLayer: Layer.Layer<Location.Service>
|
||||
filesystemLayer?: Layer.Layer<FSUtil.Service>
|
||||
project?: boolean
|
||||
}) => {
|
||||
const watcher = Watcher.testLayer
|
||||
return Layer.mergeAll(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([InstructionDiscovery.node, Bus.node, FSUtil.node, Global.node, Location.node, Watcher.node]),
|
||||
[
|
||||
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: input.project })],
|
||||
[Global.node, input.config ? Global.layerWith({ config: input.config }) : tempGlobalLayer],
|
||||
[Location.node, input.locationServiceLayer],
|
||||
[Watcher.node, watcher],
|
||||
...(input.filesystemLayer ? [[FSUtil.node, input.filesystemLayer] as const] : []),
|
||||
],
|
||||
),
|
||||
watcher,
|
||||
)
|
||||
}
|
||||
|
||||
const start = Effect.fnUntraced(function* () {
|
||||
yield* ConfigInstructionPlugin.Plugin.effect(host())
|
||||
return yield* InstructionDiscovery.Service
|
||||
})
|
||||
|
||||
const file = (path: string, content: string) =>
|
||||
new InstructionDiscovery.File({ path: AbsolutePath.make(path), content })
|
||||
|
||||
function emitAndWait(update: Watcher.Update) {
|
||||
return Effect.gen(function* () {
|
||||
const watcher = yield* Watcher.Test
|
||||
const bus = yield* Bus.Service
|
||||
const updated = yield* Deferred.make<void>()
|
||||
const fiber = yield* bus.subscribe(InstructionDiscovery.Event.Updated).pipe(
|
||||
Stream.runForEach(() => Deferred.succeed(updated, undefined).pipe(Effect.asVoid)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
yield* Effect.yieldNow
|
||||
yield* watcher.emit(update)
|
||||
yield* Deferred.await(updated).pipe(Effect.timeout("2 seconds"))
|
||||
yield* Fiber.interrupt(fiber)
|
||||
})
|
||||
}
|
||||
}) =>
|
||||
AppNodeBuilder.build(InstructionDiscovery.node, [
|
||||
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: input.project })],
|
||||
[Global.node, Global.layerWith({ config: input.config })],
|
||||
[Location.node, input.locationServiceLayer],
|
||||
...(input.filesystemLayer ? [[FSUtil.node, input.filesystemLayer] as const] : []),
|
||||
])
|
||||
|
||||
describe("InstructionDiscovery", () => {
|
||||
it.effect("stores ordered values with last-write-wins precedence", () =>
|
||||
Effect.gen(function* () {
|
||||
const discovery = yield* InstructionDiscovery.Service
|
||||
yield* discovery.transform((draft) => {
|
||||
draft.add(file("/repo/AGENTS.md", "first"))
|
||||
draft.add(file("/repo/packages/AGENTS.md", "package"))
|
||||
draft.add(file("/repo/AGENTS.md", "last"))
|
||||
draft.update("/repo/packages/AGENTS.md", (current) => {
|
||||
current.content = "updated"
|
||||
current.path = AbsolutePath.make("/ignored")
|
||||
})
|
||||
draft.remove("/missing")
|
||||
})
|
||||
|
||||
expect(yield* discovery.list()).toEqual([
|
||||
file("/repo/AGENTS.md", "last"),
|
||||
file("/repo/packages/AGENTS.md", "updated"),
|
||||
])
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(LayerNode.group([InstructionDiscovery.node, Bus.node])))),
|
||||
)
|
||||
|
||||
it.effect("preserves admitted values while the source is unavailable", () =>
|
||||
Effect.gen(function* () {
|
||||
const discovery = yield* InstructionDiscovery.Service
|
||||
yield* discovery.transform((draft) => draft.unavailable())
|
||||
expect(
|
||||
(yield* readUpdate(
|
||||
yield* discovery.load(),
|
||||
state({ "core/instructions": [{ path: "/repo/AGENTS.md", content: "old" }] }),
|
||||
)).changed,
|
||||
).toBe(false)
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(LayerNode.group([InstructionDiscovery.node, Bus.node])))),
|
||||
)
|
||||
})
|
||||
|
||||
describe("ConfigInstructionPlugin.Plugin", () => {
|
||||
it.live("loads global and upward project files and rescans them on change", () =>
|
||||
it.live("loads global and upward project AGENTS.md files as one aggregate context", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) => {
|
||||
const global = path.join(tmp.path, "global")
|
||||
const project = path.join(tmp.path, "project")
|
||||
const directory = path.join(project, "packages", "core")
|
||||
const outside = path.join(tmp.path, "AGENTS.md")
|
||||
const globalFile = path.join(global, "AGENTS.md")
|
||||
const projectFile = path.join(project, "AGENTS.md")
|
||||
const packageFile = path.join(directory, "AGENTS.md")
|
||||
return Effect.gen(function* () {
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const global = path.join(tmp.path, "global")
|
||||
const project = path.join(tmp.path, "project")
|
||||
const directory = path.join(project, "packages", "core")
|
||||
const outside = path.join(tmp.path, "AGENTS.md")
|
||||
const globalFile = path.join(global, "AGENTS.md")
|
||||
const projectFile = path.join(project, "AGENTS.md")
|
||||
const packageFile = path.join(directory, "AGENTS.md")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(global, { recursive: true })
|
||||
await fs.mkdir(directory, { recursive: true })
|
||||
@@ -128,15 +53,25 @@ describe("ConfigInstructionPlugin.Plugin", () => {
|
||||
await fs.writeFile(packageFile, "package")
|
||||
})
|
||||
|
||||
const discovery = yield* start()
|
||||
const watcher = yield* Watcher.Test
|
||||
expect(yield* watcher.subscriptions()).toEqual([
|
||||
{ path: globalFile, type: "file" },
|
||||
{ path: packageFile, type: "file" },
|
||||
{ path: path.join(project, "packages", "AGENTS.md"), type: "file" },
|
||||
{ path: projectFile, type: "file" },
|
||||
])
|
||||
const initialized = yield* readInitial(yield* discovery.load())
|
||||
const load = InstructionDiscovery.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
config: global,
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location(
|
||||
{ directory: AbsolutePath.make(directory) },
|
||||
{ projectDirectory: AbsolutePath.make(project) },
|
||||
),
|
||||
),
|
||||
),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const initialized = yield* readInitial(yield* load)
|
||||
expect(initialized.text).toBe(
|
||||
[
|
||||
`Instructions from: ${globalFile}\nglobal`,
|
||||
@@ -147,14 +82,13 @@ describe("ConfigInstructionPlugin.Plugin", () => {
|
||||
expect(initialized.text).not.toContain("outside")
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(packageFile, "changed"))
|
||||
yield* emitAndWait({ type: "update", path: packageFile })
|
||||
expect((yield* readUpdate(yield* discovery.load(), initialized)).text).toContain(
|
||||
expect((yield* readUpdate(yield* load, initialized)).text).toContain(
|
||||
`Instructions from: ${packageFile}\nchanged`,
|
||||
)
|
||||
|
||||
yield* Effect.promise(() => fs.rm(packageFile))
|
||||
yield* emitAndWait({ type: "delete", path: packageFile })
|
||||
expect((yield* readUpdate(yield* discovery.load(), initialized)).text).toBe(
|
||||
const partial = yield* readUpdate(yield* load, initialized)
|
||||
expect(partial.text).toBe(
|
||||
[
|
||||
"These instructions replace all previously loaded ambient instructions.",
|
||||
`Instructions from: ${globalFile}\nglobal`,
|
||||
@@ -162,30 +96,12 @@ describe("ConfigInstructionPlugin.Plugin", () => {
|
||||
].join("\n\n"),
|
||||
)
|
||||
|
||||
yield* Effect.promise(() => fs.rm(globalFile))
|
||||
yield* emitAndWait({ type: "delete", path: globalFile })
|
||||
yield* Effect.promise(() => fs.rm(projectFile))
|
||||
yield* emitAndWait({ type: "delete", path: projectFile })
|
||||
expect((yield* readUpdate(yield* discovery.load(), initialized)).text).toBe(
|
||||
yield* Effect.promise(() => Promise.all([fs.rm(globalFile), fs.rm(projectFile)]))
|
||||
expect((yield* readUpdate(yield* load, initialized)).text).toBe(
|
||||
"Previously loaded instructions no longer apply.",
|
||||
)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
config: global,
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location(
|
||||
{ directory: AbsolutePath.make(directory) },
|
||||
{ projectDirectory: AbsolutePath.make(project) },
|
||||
),
|
||||
),
|
||||
),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -198,150 +114,115 @@ describe("ConfigInstructionPlugin.Plugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(tmp.path, "AGENTS.md")
|
||||
yield* Effect.promise(() => fs.writeFile(file, ""))
|
||||
const discovery = yield* start()
|
||||
expect((yield* readInitial(yield* discovery.load())).text).toBe(`Instructions from: ${file}\n`)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
config: path.join(tmp.path, "global"),
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(tmp.path) })),
|
||||
),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("discovers a newly created instruction file in an intermediate directory", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) => {
|
||||
const project = path.join(tmp.path, "project")
|
||||
const intermediate = path.join(project, "packages", "AGENTS.md")
|
||||
const directory = path.join(project, "packages", "core")
|
||||
const projectFile = path.join(project, "AGENTS.md")
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.promise(() => fs.mkdir(directory, { recursive: true }))
|
||||
yield* Effect.promise(() => fs.writeFile(projectFile, "project"))
|
||||
const discovery = yield* start()
|
||||
expect((yield* readInitial(yield* discovery.load())).text).toBe(`Instructions from: ${projectFile}\nproject`)
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(intermediate, "intermediate"))
|
||||
yield* emitAndWait({ type: "create", path: intermediate })
|
||||
|
||||
expect((yield* readInitial(yield* discovery.load())).text).toBe(
|
||||
[`Instructions from: ${intermediate}\nintermediate`, `Instructions from: ${projectFile}\nproject`].join(
|
||||
"\n\n",
|
||||
const context = yield* InstructionDiscovery.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
config: path.join(tmp.path, "global"),
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(tmp.path) })),
|
||||
),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
config: path.join(tmp.path, "global"),
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location(
|
||||
{ directory: AbsolutePath.make(directory) },
|
||||
{ projectDirectory: AbsolutePath.make(project) },
|
||||
),
|
||||
),
|
||||
),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
|
||||
expect((yield* readInitial(context)).text).toBe(`Instructions from: ${file}\n`)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("isolates source failure without failing activation", () => {
|
||||
const failingFS = Layer.effect(
|
||||
FSUtil.Service,
|
||||
FSUtil.Service.pipe(
|
||||
Effect.map((fs) =>
|
||||
FSUtil.Service.of({ ...fs, up: () => Effect.fail(new FSUtil.FileSystemError({ method: "up" })) }),
|
||||
),
|
||||
),
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
||||
return Effect.gen(function* () {
|
||||
const discovery = yield* start()
|
||||
expect(
|
||||
(yield* readUpdate(
|
||||
yield* discovery.load(),
|
||||
state({ "core/instructions": [{ path: "/repo/AGENTS.md", content: "old" }] }),
|
||||
)).changed,
|
||||
).toBe(false)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
filesystemLayer: failingFS,
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
|
||||
it.effect("preserves admitted instructions while observation is unavailable", () =>
|
||||
Effect.gen(function* () {
|
||||
const failingFS = Layer.effect(
|
||||
FSUtil.Service,
|
||||
FSUtil.Service.pipe(
|
||||
Effect.map((fs) =>
|
||||
FSUtil.Service.of({ ...fs, up: () => Effect.fail(new FSUtil.FileSystemError({ method: "up" })) }),
|
||||
),
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("marks a discovered file that disappears before read as unavailable", () => {
|
||||
const discovered = AbsolutePath.make("/repo/AGENTS.md")
|
||||
const racingFS = Layer.effect(
|
||||
FSUtil.Service,
|
||||
FSUtil.Service.pipe(
|
||||
Effect.map((fs) =>
|
||||
FSUtil.Service.of({
|
||||
...fs,
|
||||
up: () => Effect.succeed([discovered]),
|
||||
readFileStringSafe: () => Effect.succeed(undefined),
|
||||
),
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
||||
const context = yield* InstructionDiscovery.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
config: "/global",
|
||||
filesystemLayer: failingFS,
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
|
||||
),
|
||||
}),
|
||||
),
|
||||
),
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
||||
return Effect.gen(function* () {
|
||||
const discovery = yield* start()
|
||||
expect(
|
||||
(yield* readUpdate(
|
||||
yield* discovery.load(),
|
||||
state({ "core/instructions": [{ path: discovered, content: "old" }] }),
|
||||
)).changed,
|
||||
).toBe(false)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
filesystemLayer: racingFS,
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
|
||||
),
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
it.effect("canonicalizes boundaries and honors project opt-out", () =>
|
||||
expect(
|
||||
(yield* readUpdate(context, state({ "core/instructions": [{ path: "/repo/AGENTS.md", content: "old" }] })))
|
||||
.changed,
|
||||
).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves admitted instructions when a discovered file disappears before read", () =>
|
||||
Effect.gen(function* () {
|
||||
const observed: { values: { targets: string[]; start: string; stop?: string }[] } = { values: [] }
|
||||
const file = AbsolutePath.make("/repo/AGENTS.md")
|
||||
const racingFS = Layer.effect(
|
||||
FSUtil.Service,
|
||||
FSUtil.Service.pipe(
|
||||
Effect.map((fs) =>
|
||||
FSUtil.Service.of({
|
||||
...fs,
|
||||
up: () => Effect.succeed([file]),
|
||||
readFileStringSafe: () => Effect.succeed(undefined),
|
||||
}),
|
||||
),
|
||||
),
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
||||
const context = yield* InstructionDiscovery.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
config: "/global",
|
||||
filesystemLayer: racingFS,
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
|
||||
),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(
|
||||
(yield* readUpdate(context, state({ "core/instructions": [{ path: file, content: "old" }] }))).changed,
|
||||
).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("canonicalizes upward discovery boundaries", () =>
|
||||
Effect.gen(function* () {
|
||||
let observed: { targets: string[]; start: string; stop?: string } | undefined
|
||||
const observingFS = Layer.effect(
|
||||
FSUtil.Service,
|
||||
FSUtil.Service.pipe(
|
||||
Effect.map((fs) =>
|
||||
FSUtil.Service.of({
|
||||
...fs,
|
||||
up: (options) => Effect.sync(() => (observed.values.push(options), [])),
|
||||
up: (options) =>
|
||||
Effect.sync(() => {
|
||||
observed = options
|
||||
return []
|
||||
}),
|
||||
}),
|
||||
),
|
||||
),
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
||||
|
||||
yield* start().pipe(
|
||||
yield* InstructionDiscovery.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
config: "/global",
|
||||
filesystemLayer: observingFS,
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
@@ -352,11 +233,31 @@ describe("ConfigInstructionPlugin.Plugin", () => {
|
||||
}),
|
||||
),
|
||||
)
|
||||
yield* start().pipe(
|
||||
|
||||
expect(observed).toEqual({
|
||||
targets: ["AGENTS.md"],
|
||||
start: FSUtil.resolve("/repo"),
|
||||
stop: FSUtil.resolve("/repo"),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("honors the project instruction opt-out", () =>
|
||||
Effect.gen(function* () {
|
||||
let scanned = false
|
||||
|
||||
yield* InstructionDiscovery.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
filesystemLayer: observingFS,
|
||||
config: "/global",
|
||||
project: false,
|
||||
filesystemLayer: Layer.effect(
|
||||
FSUtil.Service,
|
||||
FSUtil.Service.pipe(
|
||||
Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })),
|
||||
),
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node))),
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
|
||||
@@ -364,10 +265,25 @@ describe("ConfigInstructionPlugin.Plugin", () => {
|
||||
}),
|
||||
),
|
||||
)
|
||||
yield* start().pipe(
|
||||
|
||||
expect(scanned).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not discover project instructions outside the canonical project root", () =>
|
||||
Effect.gen(function* () {
|
||||
let scanned = false
|
||||
yield* InstructionDiscovery.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
filesystemLayer: observingFS,
|
||||
config: "/global",
|
||||
filesystemLayer: Layer.effect(
|
||||
FSUtil.Service,
|
||||
FSUtil.Service.pipe(
|
||||
Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })),
|
||||
),
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node))),
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
@@ -381,8 +297,7 @@ describe("ConfigInstructionPlugin.Plugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
const repo = path.resolve("/repo")
|
||||
expect(observed.values).toEqual([{ targets: ["AGENTS.md"], start: repo, stop: repo }])
|
||||
expect(scanned).toBe(false)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import os from "os"
|
||||
import { Effect, Layer } from "effect"
|
||||
import * as TestClock from "effect/testing/TestClock"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
@@ -17,7 +16,6 @@ const directory = AbsolutePath.make(FSUtil.resolve("/repo/packages/core"))
|
||||
const projectDirectory = AbsolutePath.make(FSUtil.resolve("/repo"))
|
||||
const timestamp = Date.parse("2026-06-03T12:00:00.000Z")
|
||||
const sessionID = SessionSchema.ID.make("ses_builtin_test")
|
||||
const temporary = os.tmpdir()
|
||||
const localDate = (time: number) => new Date(time).toDateString()
|
||||
const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
@@ -31,7 +29,7 @@ const locationLayer = Layer.succeed(
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(InstructionBuiltIns.node, [
|
||||
[Location.node, locationLayer],
|
||||
[Global.node, Global.layerWith({ config: temporary, tmp: temporary })],
|
||||
[Global.node, Global.layerWith({ config: "/global", tmp: "/temporary" })],
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -51,7 +49,7 @@ describe("InstructionBuiltIns", () => {
|
||||
` Workspace root folder: ${projectDirectory}`,
|
||||
" Is directory a git repo: yes",
|
||||
` Platform: ${process.platform}`,
|
||||
` Use ${temporary} for temporary work outside the workspace; it already exists and is pre-approved for external directory access.`,
|
||||
" Use /temporary for temporary work outside the workspace; it already exists and is pre-approved for external directory access.",
|
||||
"</env>",
|
||||
"",
|
||||
`Today's date: ${localDate(timestamp)}`,
|
||||
|
||||
@@ -9,7 +9,6 @@ import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
@@ -22,7 +21,6 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { toolDefinitions, waitForTool } from "./lib/tool"
|
||||
import { Database } from "../src/database/database"
|
||||
@@ -30,15 +28,9 @@ import { Bus } from "../src/bus"
|
||||
import { Reference } from "../src/reference"
|
||||
import { Tool } from "../src/tool"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node]), [
|
||||
[Global.node, tempGlobalLayer],
|
||||
]),
|
||||
)
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node])))
|
||||
const itWithSdk = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
|
||||
[Global.node, tempGlobalLayer],
|
||||
]),
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node])),
|
||||
)
|
||||
|
||||
describe("LocationServiceMap", () => {
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { describe, expect, beforeEach, afterAll, test } from "bun:test"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Effect, Layer, Ref } from "effect"
|
||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { KV } from "@opencode-ai/core/kv"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { ModelsDev } from "@opencode-ai/core/models-dev"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { it } from "./lib/effect"
|
||||
import { readFile, rm, writeFile, utimes, mkdir } from "fs/promises"
|
||||
import path from "path"
|
||||
|
||||
const cacheKey = "models-dev:catalog"
|
||||
const cacheFile = path.join(Global.Path.cache, "models.json")
|
||||
|
||||
test("normalizes permissive interleaved values to compatibility", () => {
|
||||
expect(Model.compatibility("reasoning_text")).toEqual({ reasoningField: "reasoning_text" })
|
||||
@@ -162,18 +164,7 @@ const makeMockClient = (state: Ref.Ref<MockState>) =>
|
||||
}),
|
||||
)
|
||||
|
||||
interface MockCache {
|
||||
readonly values: Map<string, KV.Value>
|
||||
}
|
||||
|
||||
const makeMockKV = (cache: MockCache) =>
|
||||
Layer.mock(KV.Service, {
|
||||
get: (key) => Effect.sync(() => cache.values.get(key)),
|
||||
set: (key, value) => Effect.sync(() => cache.values.set(key, value)).pipe(Effect.asVoid),
|
||||
remove: (key) => Effect.sync(() => cache.values.delete(key)).pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
const buildLayer = (state: Ref.Ref<MockState>, cache: MockCache, options: ModelsDev.Options = { fetch: false }) =>
|
||||
const buildLayer = (state: Ref.Ref<MockState>, options: ModelsDev.Options = { fetch: false }) =>
|
||||
// Layer.fresh is required because the ModelsDev implementation is a module-level Layer constant,
|
||||
// and Effect.provide uses a process-global MemoMap by default — without fresh,
|
||||
// every test would reuse the cachedInvalidateWithTTL state from the first run.
|
||||
@@ -181,20 +172,31 @@ const buildLayer = (state: Ref.Ref<MockState>, cache: MockCache, options: Models
|
||||
AppNodeBuilder.build(ModelsDev.node, [
|
||||
[ModelsDev.node, ModelsDev.configured(options)],
|
||||
[LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))],
|
||||
[KV.node, makeMockKV(cache)],
|
||||
]),
|
||||
)
|
||||
|
||||
const makeCache = (): MockCache => ({ values: new Map() })
|
||||
const writeCacheText = (text: string, mtimeMs?: number) =>
|
||||
Effect.promise(async () => {
|
||||
await mkdir(Global.Path.cache, { recursive: true })
|
||||
await writeFile(cacheFile, text)
|
||||
if (mtimeMs !== undefined) {
|
||||
const t = mtimeMs / 1000
|
||||
await utimes(cacheFile, t, t)
|
||||
}
|
||||
})
|
||||
|
||||
const writeCacheText = (cache: MockCache, text: string, updatedAt = Date.now()) =>
|
||||
cache.values.set(cacheKey, { updatedAt, body: text })
|
||||
const writeCache = (data: object, mtimeMs?: number) => writeCacheText(JSON.stringify(data), mtimeMs)
|
||||
|
||||
const writeCache = (cache: MockCache, data: object, updatedAt?: number) =>
|
||||
writeCacheText(cache, JSON.stringify(data), updatedAt)
|
||||
const provided = <A, E>(state: Ref.Ref<MockState>, eff: Effect.Effect<A, E, ModelsDev.Service>) =>
|
||||
eff.pipe(Effect.provide(buildLayer(state)))
|
||||
|
||||
const provided = <A, E>(state: Ref.Ref<MockState>, cache: MockCache, eff: Effect.Effect<A, E, ModelsDev.Service>) =>
|
||||
eff.pipe(Effect.provide(buildLayer(state, cache)))
|
||||
beforeEach(async () => {
|
||||
await rm(cacheFile, { force: true })
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(cacheFile, { force: true })
|
||||
})
|
||||
|
||||
const initialState: MockState = {
|
||||
body: JSON.stringify(fixture),
|
||||
@@ -203,14 +205,12 @@ const initialState: MockState = {
|
||||
}
|
||||
|
||||
describe("ModelsDev Service", () => {
|
||||
it.live("get() returns normalized snapshots from KV when a cache entry exists", () =>
|
||||
it.live("get() returns normalized snapshots from disk when cache file exists", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
writeCache(cache, fixture)
|
||||
yield* writeCache(fixture)
|
||||
const state = yield* Ref.make(initialState)
|
||||
const result = yield* provided(
|
||||
state,
|
||||
cache,
|
||||
ModelsDev.Service.use((s) => s.get()),
|
||||
)
|
||||
expect(result).toEqual(fixtureSnapshot)
|
||||
@@ -219,13 +219,11 @@ describe("ModelsDev Service", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("get() returns empty catalog when KV is empty, fetch disabled, and no bundled snapshot is injected", () =>
|
||||
it.live("get() returns empty catalog when disk empty, fetch disabled, and no bundled snapshot is injected", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
const state = yield* Ref.make(initialState)
|
||||
const result = yield* provided(
|
||||
state,
|
||||
cache,
|
||||
ModelsDev.Service.use((s) => s.get()),
|
||||
)
|
||||
expect(result).toEqual([])
|
||||
@@ -234,15 +232,14 @@ describe("ModelsDev Service", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("get() recovers from a corrupted KV entry by fetching a fresh catalog", () =>
|
||||
it.live("get() recovers from a corrupted cache file by fetching a fresh catalog", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
writeCacheText(cache, "{")
|
||||
yield* writeCacheText("{")
|
||||
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
|
||||
const context = yield* Layer.build(buildLayer(state, cache, { fetch: true }))
|
||||
const context = yield* Layer.build(buildLayer(state, { fetch: true }))
|
||||
const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(context))
|
||||
expect(result).toEqual(fixture2Snapshot)
|
||||
expect(cache.values.get(cacheKey)).toMatchObject({ body: JSON.stringify(fixture2) })
|
||||
expect(yield* Effect.promise(() => readFile(cacheFile, "utf8"))).toBe(JSON.stringify(fixture2))
|
||||
const final = yield* Ref.get(state)
|
||||
expect(final.calls.length).toBe(1)
|
||||
}),
|
||||
@@ -250,10 +247,9 @@ describe("ModelsDev Service", () => {
|
||||
|
||||
it.live("uses the default models URL when the configured URL is empty", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
const state = yield* Ref.make(initialState)
|
||||
yield* ModelsDev.Service.use((service) => service.get()).pipe(
|
||||
Effect.provide(buildLayer(state, cache, { url: "", fetch: true })),
|
||||
Effect.provide(buildLayer(state, { url: "", fetch: true })),
|
||||
)
|
||||
expect((yield* Ref.get(state)).calls[0]?.url).toBe("https://models.opencode.ai/api.json")
|
||||
}),
|
||||
@@ -261,31 +257,32 @@ describe("ModelsDev Service", () => {
|
||||
|
||||
it.live("get() is single-flight under concurrent calls", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
yield* writeCache(fixture)
|
||||
const state = yield* Ref.make(initialState)
|
||||
const results = yield* Effect.gen(function* () {
|
||||
const svc = yield* ModelsDev.Service
|
||||
return yield* Effect.all([svc.get(), svc.get(), svc.get(), svc.get(), svc.get()], {
|
||||
concurrency: "unbounded",
|
||||
})
|
||||
}).pipe(Effect.provide(buildLayer(state, cache, { fetch: true })))
|
||||
const results = yield* provided(
|
||||
state,
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* ModelsDev.Service
|
||||
return yield* Effect.all([svc.get(), svc.get(), svc.get(), svc.get(), svc.get()], {
|
||||
concurrency: "unbounded",
|
||||
})
|
||||
}),
|
||||
)
|
||||
for (const result of results) expect(result).toEqual(fixtureSnapshot)
|
||||
expect((yield* Ref.get(state)).calls.length).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("get() caches across calls (later KV writes are ignored until invalidate)", () =>
|
||||
it.live("get() caches across calls (later disk writes are ignored until invalidate)", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
writeCache(cache, fixture)
|
||||
yield* writeCache(fixture)
|
||||
const state = yield* Ref.make(initialState)
|
||||
const first = yield* provided(
|
||||
state,
|
||||
cache,
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* ModelsDev.Service
|
||||
const a = yield* svc.get()
|
||||
writeCache(cache, fixture2)
|
||||
// mutate disk between calls — cache should mask the change
|
||||
yield* writeCache(fixture2)
|
||||
const b = yield* svc.get()
|
||||
return { a, b }
|
||||
}),
|
||||
@@ -297,12 +294,10 @@ describe("ModelsDev Service", () => {
|
||||
|
||||
it.live("refresh(true) fetches via HttpClient and updates the cache", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
writeCache(cache, fixture)
|
||||
yield* writeCache(fixture)
|
||||
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
|
||||
const result = yield* provided(
|
||||
state,
|
||||
cache,
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* ModelsDev.Service
|
||||
const before = yield* svc.get()
|
||||
@@ -313,7 +308,6 @@ describe("ModelsDev Service", () => {
|
||||
)
|
||||
expect(result.before).toEqual(fixtureSnapshot)
|
||||
expect(result.after).toEqual(fixture2Snapshot)
|
||||
expect(cache.values.get(cacheKey)).toMatchObject({ body: JSON.stringify(fixture2) })
|
||||
const final = yield* Ref.get(state)
|
||||
expect(final.calls.length).toBe(1)
|
||||
expect(final.calls[0].url).toContain("/api.json")
|
||||
@@ -321,14 +315,13 @@ describe("ModelsDev Service", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("refresh(false) skips fetch when the KV entry is fresh", () =>
|
||||
it.live("refresh(false) skips fetch when on-disk file is fresh", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
writeCache(cache, fixture, Date.now() - 1000)
|
||||
// Fresh: mtime within the 5-minute TTL.
|
||||
yield* writeCache(fixture, Date.now() - 1000)
|
||||
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
|
||||
yield* provided(
|
||||
state,
|
||||
cache,
|
||||
ModelsDev.Service.use((s) => s.refresh(false)),
|
||||
)
|
||||
const final = yield* Ref.get(state)
|
||||
@@ -336,14 +329,13 @@ describe("ModelsDev Service", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("refresh(false) fetches when the KV entry is stale", () =>
|
||||
it.live("refresh(false) fetches when on-disk file is stale", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
writeCache(cache, fixture, Date.now() - 10 * 60 * 1000)
|
||||
// Stale: mtime 10 minutes ago, beyond the 5-minute TTL.
|
||||
yield* writeCache(fixture, Date.now() - 10 * 60 * 1000)
|
||||
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
|
||||
const after = yield* provided(
|
||||
state,
|
||||
cache,
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* ModelsDev.Service
|
||||
yield* svc.refresh(false)
|
||||
@@ -358,12 +350,10 @@ describe("ModelsDev Service", () => {
|
||||
|
||||
it.live("refresh swallows HTTP errors and leaves cache intact", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
writeCache(cache, fixture)
|
||||
yield* writeCache(fixture)
|
||||
const state = yield* Ref.make({ ...initialState, status: 500, body: "boom" })
|
||||
const result = yield* provided(
|
||||
state,
|
||||
cache,
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* ModelsDev.Service
|
||||
yield* svc.refresh(true)
|
||||
|
||||
@@ -19,11 +19,9 @@ import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||
import { Reference } from "@opencode-ai/core/reference"
|
||||
import { Skill } from "@opencode-ai/core/skill"
|
||||
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { WebSearch } from "@opencode-ai/core/websearch"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
import { tempLocationLayer } from "../fixture/location"
|
||||
|
||||
const npmLayer = Layer.succeed(
|
||||
@@ -55,10 +53,8 @@ export const PluginTestLayer = AppNodeBuilder.build(
|
||||
PluginHooks.node,
|
||||
Reference.node,
|
||||
Skill.node,
|
||||
SkillDiscovery.node,
|
||||
PluginHooks.node,
|
||||
Tool.node,
|
||||
Watcher.node,
|
||||
WebSearch.node,
|
||||
]),
|
||||
[
|
||||
|
||||
@@ -543,10 +543,11 @@ describe("Session.create", () => {
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
const targetDatabase = Database.layer({ path: path.join(tmp.path, "target.sqlite") })
|
||||
const targetLayer = AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node]),
|
||||
[
|
||||
[Database.node, Database.configured({ path: path.join(tmp.path, "target.sqlite") })],
|
||||
[Database.node, targetDatabase],
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
],
|
||||
)
|
||||
@@ -647,7 +648,7 @@ describe("Session.create", () => {
|
||||
it.effect("switches the selected agent through the durable Session event", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const created = yield* session.create({ location, agent: Agent.ID.make("build") })
|
||||
const created = yield* session.create({ location })
|
||||
|
||||
yield* session.switchAgent({ sessionID: created.id, agent: Agent.ID.make("plan") })
|
||||
|
||||
@@ -655,9 +656,6 @@ describe("Session.create", () => {
|
||||
expect(
|
||||
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.drop(1), Stream.take(1), Stream.runCollect)),
|
||||
).toMatchObject([{ type: "session.agent.selected", data: { agent: "plan" } }])
|
||||
expect(yield* session.messages({ sessionID: created.id, order: "asc" })).toMatchObject([
|
||||
{ type: "agent-switched", agent: "plan", previous: "build" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -99,10 +99,7 @@ const builtins = Layer.mock(InstructionBuiltIns.Service, {
|
||||
}),
|
||||
),
|
||||
})
|
||||
const discovery = Layer.mock(InstructionDiscovery.Service, {
|
||||
project: true,
|
||||
load: () => Effect.succeed(Instructions.empty),
|
||||
})
|
||||
const discovery = Layer.mock(InstructionDiscovery.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
const skills = Layer.mock(SkillInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
const references = Layer.mock(ReferenceInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
const mcp = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
|
||||
@@ -358,7 +358,6 @@ describe("SessionProjector", () => {
|
||||
directory: "/project",
|
||||
title: "test",
|
||||
version: "test",
|
||||
agent: "plan",
|
||||
model: previousModel,
|
||||
})
|
||||
.run()
|
||||
@@ -460,10 +459,6 @@ describe("SessionProjector", () => {
|
||||
text: "synthetic context",
|
||||
metadata: { source: "projector-test" },
|
||||
})
|
||||
expect(messages.find((message) => message.type === "agent-switched")).toMatchObject({
|
||||
agent: build,
|
||||
previous: "plan",
|
||||
})
|
||||
expect(messages.find((message) => message.type === "model-switched")).toMatchObject({ previous: previousModel })
|
||||
expect(messages.find((message) => message.type === "shell")).toMatchObject({
|
||||
command: "pwd",
|
||||
|
||||
@@ -373,103 +373,6 @@ Recent work
|
||||
])
|
||||
})
|
||||
|
||||
test("deduplicates provider media while preserving durable attachment references", () => {
|
||||
const data = Base64.make("AAECAw==")
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
SessionMessage.User.make({
|
||||
id: id("user-duplicate-image"),
|
||||
type: "user",
|
||||
text: "[Image 1] [Image 1] [Image 2]",
|
||||
files: [
|
||||
FileAttachment.make({
|
||||
data,
|
||||
mime: "image/png",
|
||||
source: { type: "inline" },
|
||||
name: "image.png",
|
||||
mention: { start: 0, end: 9, text: "[Image 1]" },
|
||||
}),
|
||||
FileAttachment.make({
|
||||
data,
|
||||
mime: "image/png",
|
||||
source: { type: "inline" },
|
||||
name: "image.png",
|
||||
mention: { start: 10, end: 19, text: "[Image 1]" },
|
||||
}),
|
||||
FileAttachment.make({
|
||||
data,
|
||||
mime: "image/png",
|
||||
source: { type: "inline" },
|
||||
name: "image.png",
|
||||
description: "alternate use",
|
||||
mention: { start: 20, end: 29, text: "[Image 2]" },
|
||||
}),
|
||||
],
|
||||
time: { created },
|
||||
}),
|
||||
],
|
||||
model,
|
||||
)
|
||||
|
||||
expect(messages[0]?.content).toEqual([
|
||||
{ type: "text", text: "[Image 1] [Image 1] [Image 2]" },
|
||||
{ type: "media", mediaType: "image/png", data, filename: "image.png" },
|
||||
{
|
||||
type: "media",
|
||||
mediaType: "image/png",
|
||||
data,
|
||||
filename: "image.png",
|
||||
metadata: { description: "alternate use" },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("preserves provider media with distinct labels or URI sources", () => {
|
||||
const data = Base64.make("AAECAw==")
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
SessionMessage.User.make({
|
||||
id: id("user-distinct-images"),
|
||||
type: "user",
|
||||
text: "[Image 1] [Image 2]",
|
||||
files: [
|
||||
FileAttachment.make({
|
||||
data,
|
||||
mime: "image/png",
|
||||
source: { type: "inline" },
|
||||
name: "image.png",
|
||||
mention: { start: 0, end: 9, text: "[Image 1]" },
|
||||
}),
|
||||
FileAttachment.make({
|
||||
data,
|
||||
mime: "image/png",
|
||||
source: { type: "inline" },
|
||||
name: "image.png",
|
||||
mention: { start: 10, end: 19, text: "[Image 2]" },
|
||||
}),
|
||||
FileAttachment.make({
|
||||
data,
|
||||
mime: "image/png",
|
||||
source: { type: "uri", uri: "file:///project/image.png" },
|
||||
name: "image.png",
|
||||
mention: { start: 0, end: 9, text: "[Image 1]" },
|
||||
}),
|
||||
FileAttachment.make({
|
||||
data,
|
||||
mime: "image/png",
|
||||
source: { type: "inline" },
|
||||
name: "image.png",
|
||||
}),
|
||||
],
|
||||
time: { created },
|
||||
}),
|
||||
],
|
||||
model,
|
||||
)
|
||||
|
||||
expect(messages[0]?.content.filter((part) => part.type === "media")).toHaveLength(4)
|
||||
})
|
||||
|
||||
test("replays durable tool media into canonical tool messages without structured base64", () => {
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
|
||||
@@ -83,10 +83,7 @@ const models = Layer.mock(SessionRunnerModel.Service)({
|
||||
),
|
||||
})
|
||||
const systemContext = Layer.mock(InstructionBuiltIns.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
const instructionContext = Layer.mock(InstructionDiscovery.Service, {
|
||||
project: true,
|
||||
load: () => Effect.succeed(Instructions.empty),
|
||||
})
|
||||
const instructionContext = Layer.mock(InstructionDiscovery.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
const skillInstructions = Layer.mock(SkillInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
const referenceInstructions = Layer.mock(ReferenceInstructions.Service, {
|
||||
load: () => Effect.succeed(Instructions.empty),
|
||||
|
||||
@@ -312,10 +312,7 @@ const systemContext = Layer.mock(InstructionBuiltIns.Service, {
|
||||
}),
|
||||
),
|
||||
})
|
||||
const instructionContext = Layer.mock(InstructionDiscovery.Service, {
|
||||
project: true,
|
||||
load: () => Effect.succeed(Instructions.empty),
|
||||
})
|
||||
const instructionContext = Layer.mock(InstructionDiscovery.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
const skillInstructions = Layer.mock(SkillInstructions.Service, {
|
||||
load: (agent) =>
|
||||
Effect.succeed(
|
||||
|
||||
@@ -1,102 +1,438 @@
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Fiber, Stream } from "effect"
|
||||
import { Deferred, Effect, Fiber, Layer, Stream } from "effect"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Skill } from "@opencode-ai/core/skill"
|
||||
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Skill.node, Agent.node, Bus.node])))
|
||||
const urls = new Map<string, AbsolutePath[]>()
|
||||
let pulls = 0
|
||||
const discovery = Layer.succeed(
|
||||
SkillDiscovery.Service,
|
||||
SkillDiscovery.Service.of({
|
||||
pull: (url) => {
|
||||
pulls++
|
||||
return Effect.succeed(urls.get(url) ?? [])
|
||||
},
|
||||
}),
|
||||
)
|
||||
const watcherLayer = Watcher.testLayer
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
AppNodeBuilder.build(LayerNode.group([Skill.node, Agent.node, Bus.node]), [
|
||||
[SkillDiscovery.node, discovery],
|
||||
[Watcher.node, watcherLayer],
|
||||
]),
|
||||
watcherLayer,
|
||||
),
|
||||
)
|
||||
|
||||
const info = (id: string, description: string) =>
|
||||
Skill.Info.make({
|
||||
id: Skill.ID.make(id),
|
||||
name: Skill.Name.make(id),
|
||||
description,
|
||||
location: AbsolutePath.make(`/skills/${id}/SKILL.md`),
|
||||
content: `# ${id}`,
|
||||
function write(directory: string, name: string, description: string) {
|
||||
return fs.writeFile(
|
||||
path.join(directory, name, "SKILL.md"),
|
||||
`---
|
||||
name: ${name}
|
||||
description: ${description}
|
||||
---
|
||||
# ${name}`,
|
||||
)
|
||||
}
|
||||
|
||||
function waitForSkillUpdate() {
|
||||
return Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const deferred = yield* Deferred.make<void>()
|
||||
const fiber = yield* bus.subscribe(Skill.Event.Updated).pipe(
|
||||
Stream.runForEach(() => Deferred.succeed(deferred, undefined).pipe(Effect.asVoid)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
yield* Effect.yieldNow
|
||||
return { deferred, fiber }
|
||||
})
|
||||
}
|
||||
|
||||
function expectSubscription(check: (input: Watcher.WatchInput) => boolean) {
|
||||
return Effect.gen(function* () {
|
||||
const watcher = yield* Watcher.Test
|
||||
expect((yield* watcher.subscriptions()).some(check)).toBe(true)
|
||||
})
|
||||
}
|
||||
|
||||
function emitAndWait(update: Watcher.Update) {
|
||||
return Effect.gen(function* () {
|
||||
const watcher = yield* Watcher.Test
|
||||
yield* Effect.acquireUseRelease(
|
||||
waitForSkillUpdate(),
|
||||
({ deferred }) => watcher.emit(update).pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeout("1 second")),
|
||||
({ fiber }) => Fiber.interrupt(fiber),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
describe("Skill", () => {
|
||||
it.effect("registers values with last-write-wins precedence", () =>
|
||||
it.live("publishes updates when skill sources change", () =>
|
||||
Effect.gen(function* () {
|
||||
const skill = yield* Skill.Service
|
||||
yield* skill.transform((draft) => {
|
||||
draft.add(info("review", "First"))
|
||||
draft.add(info("deploy", "Deploy"))
|
||||
draft.add(info("review", "Second"))
|
||||
expect(draft.list().map((item) => item.id)).toEqual([Skill.ID.make("review"), Skill.ID.make("deploy")])
|
||||
})
|
||||
|
||||
expect(yield* skill.list()).toEqual([info("review", "Second"), info("deploy", "Deploy")])
|
||||
yield* Effect.acquireUseRelease(
|
||||
waitForSkillUpdate(),
|
||||
({ deferred }) =>
|
||||
skill
|
||||
.transform((editor) =>
|
||||
editor.source({ type: "directory", path: AbsolutePath.make("/tmp/opencode-skills") }),
|
||||
)
|
||||
.pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeout("1 second")),
|
||||
({ fiber }) => Fiber.interrupt(fiber),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("updates and removes registered values", () =>
|
||||
Effect.gen(function* () {
|
||||
const skill = yield* Skill.Service
|
||||
yield* skill.transform((draft) => {
|
||||
draft.add(info("review", "Initial"))
|
||||
draft.update("review", (value) => {
|
||||
value.description = "Updated"
|
||||
value.id = Skill.ID.make("ignored")
|
||||
})
|
||||
draft.update("missing", () => Effect.die("unreachable"))
|
||||
draft.add(info("deploy", "Deploy"))
|
||||
draft.remove("deploy")
|
||||
})
|
||||
it.live("registers sources and resolves later source precedence", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
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, "review"), { recursive: true })
|
||||
await write(first, "review", "First")
|
||||
await write(second, "review", "Second")
|
||||
await fs.writeFile(path.join(first, "foo.md"), "---\nslash: true\n---\n# foo")
|
||||
})
|
||||
|
||||
expect(yield* skill.list()).toEqual([info("review", "Updated")])
|
||||
}),
|
||||
)
|
||||
const skill = yield* Skill.Service
|
||||
const watcher = yield* Watcher.Test
|
||||
yield* skill.transform((editor) => {
|
||||
editor.source({ type: "directory", path: AbsolutePath.make(first) })
|
||||
editor.source({ type: "directory", path: AbsolutePath.make(first) })
|
||||
editor.source({ type: "directory", path: AbsolutePath.make(second) })
|
||||
expect(editor.list()).toEqual([
|
||||
{ type: "directory", path: AbsolutePath.make(first) },
|
||||
{ type: "directory", path: AbsolutePath.make(second) },
|
||||
])
|
||||
})
|
||||
|
||||
it.effect("restores earlier values when an updating transform is disposed", () =>
|
||||
Effect.gen(function* () {
|
||||
const skill = yield* Skill.Service
|
||||
const original = info("review", "Initial")
|
||||
yield* skill.transform((draft) => draft.add(original))
|
||||
const updated = yield* skill.transform((draft) =>
|
||||
draft.update("review", (value) => {
|
||||
value.description = "Updated"
|
||||
expect(yield* skill.sources()).toEqual([
|
||||
{ type: "directory", path: AbsolutePath.make(first) },
|
||||
{ type: "directory", path: AbsolutePath.make(second) },
|
||||
])
|
||||
expect(yield* skill.list()).toEqual([
|
||||
Skill.Info.make({
|
||||
id: Skill.ID.make("foo"),
|
||||
name: Skill.Name.make("foo"),
|
||||
slash: true,
|
||||
location: AbsolutePath.make(path.join(first, "foo.md")),
|
||||
content: "# foo",
|
||||
}),
|
||||
{
|
||||
id: Skill.ID.make("review"),
|
||||
name: Skill.Name.make("review"),
|
||||
description: "Second",
|
||||
location: AbsolutePath.make(path.join(second, "review", "SKILL.md")),
|
||||
content: "# review",
|
||||
},
|
||||
])
|
||||
expect(yield* watcher.subscriptions()).toEqual([
|
||||
{ path: first, type: "directory" },
|
||||
{ path: second, type: "directory" },
|
||||
])
|
||||
|
||||
yield* Effect.promise(() => write(second, "review", "Updated Second"))
|
||||
yield* emitAndWait({ type: "update", path: path.join(second, "review", "SKILL.md") })
|
||||
|
||||
expect((yield* skill.list()).find((item) => item.id === "review")?.description).toBe("Updated Second")
|
||||
expect(yield* watcher.subscriptions()).toEqual([
|
||||
{ path: first, type: "directory" },
|
||||
{ path: second, type: "directory" },
|
||||
{ path: first, type: "directory" },
|
||||
{ path: second, type: "directory" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
expect((yield* skill.list())[0]?.description).toBe("Updated")
|
||||
yield* updated.dispose
|
||||
expect((yield* skill.list())[0]?.description).toBe("Initial")
|
||||
expect(original.description).toBe("Initial")
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("publishes updates after committed values are visible", () =>
|
||||
Effect.gen(function* () {
|
||||
const skill = yield* Skill.Service
|
||||
const bus = yield* Bus.Service
|
||||
const updated = yield* Deferred.make<Skill.Info[]>()
|
||||
const fiber = yield* bus.subscribe(Skill.Event.Updated).pipe(
|
||||
Stream.runForEach(() => skill.list().pipe(Effect.flatMap((values) => Deferred.succeed(updated, values)))),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
yield* Effect.yieldNow
|
||||
it.live("loads URL sources and filters skills for agents", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(tmp.path, "deploy"), { recursive: true })
|
||||
await write(tmp.path, "deploy", "Deploy production")
|
||||
})
|
||||
pulls = 0
|
||||
urls.set("https://example.test/skills/", [AbsolutePath.make(tmp.path)])
|
||||
|
||||
yield* skill.transform((draft) => draft.add(info("review", "Visible")))
|
||||
expect(yield* Deferred.await(updated).pipe(Effect.timeout("1 second"))).toEqual([info("review", "Visible")])
|
||||
yield* Fiber.interrupt(fiber)
|
||||
}),
|
||||
)
|
||||
const agents = yield* Agent.Service
|
||||
yield* agents.transform((editor) =>
|
||||
editor.update(Agent.ID.make("reviewer"), (agent) => {
|
||||
agent.permissions.push({ action: "skill", resource: "deploy", effect: "deny" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("filters values by agent permissions", () =>
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* Agent.Service
|
||||
yield* agents.transform((draft) =>
|
||||
draft.update(Agent.ID.make("reviewer"), (agent) => {
|
||||
agent.permissions.push({ action: "skill", resource: "deploy", effect: "deny" })
|
||||
const skill = yield* Skill.Service
|
||||
yield* skill.transform((editor) => editor.source({ type: "url", url: "https://example.test/skills/" }))
|
||||
|
||||
expect((yield* skill.list()).map((item) => item.name)).toEqual([Skill.Name.make("deploy")])
|
||||
expect((yield* skill.list()).map((item) => item.name)).toEqual([Skill.Name.make("deploy")])
|
||||
expect(pulls).toBe(1)
|
||||
expect(Skill.available(yield* skill.list(), (yield* agents.get(Agent.ID.make("reviewer")))!)).toEqual([])
|
||||
}),
|
||||
)
|
||||
const agent = yield* agents.get(Agent.ID.make("reviewer"))
|
||||
expect(Skill.available([info("deploy", "Deploy")], agent!)).toEqual([])
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("parses opencode metadata flags from skill frontmatter", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(tmp.path, "manual"), { recursive: true })
|
||||
await fs.writeFile(
|
||||
path.join(tmp.path, "manual", "SKILL.md"),
|
||||
`---
|
||||
name: manual
|
||||
description: Manual only
|
||||
metadata:
|
||||
opencode/slash: true
|
||||
opencode/autoinvoke: false
|
||||
---
|
||||
# manual`,
|
||||
)
|
||||
})
|
||||
|
||||
const skill = yield* Skill.Service
|
||||
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(tmp.path) }))
|
||||
|
||||
expect(yield* skill.list()).toEqual([
|
||||
{
|
||||
id: Skill.ID.make("manual"),
|
||||
name: Skill.Name.make("manual"),
|
||||
description: "Manual only",
|
||||
slash: true,
|
||||
autoinvoke: false,
|
||||
location: AbsolutePath.make(path.join(tmp.path, "manual", "SKILL.md")),
|
||||
content: "# manual",
|
||||
},
|
||||
])
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("clears cached skills when sources reload", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(tmp.path, "deploy"), { recursive: true })
|
||||
await write(tmp.path, "deploy", "Initial deploy")
|
||||
})
|
||||
|
||||
const skill = yield* Skill.Service
|
||||
const watcher = yield* Watcher.Test
|
||||
const bus = yield* Bus.Service
|
||||
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(tmp.path) }))
|
||||
expect((yield* skill.list()).find((item) => item.id === "deploy")?.description).toBe("Initial deploy")
|
||||
expect(yield* watcher.subscriptions()).toEqual([{ path: tmp.path, type: "directory" }])
|
||||
|
||||
let refreshed: Skill.Info[] = []
|
||||
const unsubscribe = yield* bus.listen((event) => {
|
||||
if (event.type !== Skill.Event.Updated.type) return Effect.void
|
||||
return skill.list().pipe(
|
||||
Effect.tap((items) => Effect.sync(() => (refreshed = items))),
|
||||
Effect.asVoid,
|
||||
)
|
||||
})
|
||||
|
||||
yield* Effect.promise(() => write(tmp.path, "deploy", "Updated deploy"))
|
||||
yield* skill.reload().pipe(Effect.timeout("1 second"))
|
||||
yield* unsubscribe
|
||||
|
||||
expect(refreshed.find((item) => item.id === "deploy")?.description).toBe("Updated deploy")
|
||||
expect(yield* watcher.subscriptions()).toEqual([
|
||||
{ path: tmp.path, type: "directory" },
|
||||
{ path: tmp.path, type: "directory" },
|
||||
])
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("reloads project sources created after their missing parent", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const source = path.join(tmp.path, "generated", "skills")
|
||||
const file = path.join(source, "deploy", "SKILL.md")
|
||||
const skill = yield* Skill.Service
|
||||
const watcher = yield* Watcher.Test
|
||||
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(source) }))
|
||||
expect(yield* skill.list()).toEqual([])
|
||||
expect(yield* watcher.subscriptions()).toEqual([{ path: path.join(tmp.path, "generated"), type: "file" }])
|
||||
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "generated")))
|
||||
yield* emitAndWait({ type: "create", path: path.join(tmp.path, "generated") })
|
||||
expect(yield* skill.list()).toEqual([])
|
||||
expect(yield* watcher.subscriptions()).toEqual([
|
||||
{ path: path.join(tmp.path, "generated"), type: "file" },
|
||||
{ path: source, type: "file" },
|
||||
])
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.dirname(file), { recursive: true })
|
||||
await write(source, "deploy", "Deploy production")
|
||||
})
|
||||
yield* emitAndWait({ type: "create", path: source })
|
||||
|
||||
expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
|
||||
expect(yield* watcher.subscriptions()).toEqual([
|
||||
{ path: path.join(tmp.path, "generated"), type: "file" },
|
||||
{ path: source, type: "file" },
|
||||
{ path: source, type: "directory" },
|
||||
])
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("watches directory sources for added and changed skills", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(tmp.path, "deploy"), { recursive: true })
|
||||
await write(tmp.path, "deploy", "Initial deploy")
|
||||
})
|
||||
|
||||
const skill = yield* Skill.Service
|
||||
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(tmp.path) }))
|
||||
expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
|
||||
yield* expectSubscription((input) => input.type === "directory" && input.path === tmp.path)
|
||||
|
||||
const deploy = path.join(tmp.path, "deploy", "SKILL.md")
|
||||
yield* Effect.promise(() => write(tmp.path, "deploy", "Updated deploy"))
|
||||
yield* emitAndWait({ type: "update", path: deploy })
|
||||
expect((yield* skill.list()).find((item) => item.id === "deploy")?.description).toBe("Updated deploy")
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(tmp.path, "review"), { recursive: true })
|
||||
await write(tmp.path, "review", "Review changes")
|
||||
})
|
||||
const review = path.join(tmp.path, "review", "SKILL.md")
|
||||
yield* emitAndWait({ type: "create", path: review })
|
||||
expect((yield* skill.list()).map((item) => item.id)).toEqual([
|
||||
Skill.ID.make("deploy"),
|
||||
Skill.ID.make("review"),
|
||||
])
|
||||
|
||||
yield* Effect.promise(() => fs.rm(path.join(tmp.path, "review"), { recursive: true }))
|
||||
yield* emitAndWait({ type: "delete", path: review })
|
||||
expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("watches canonical directories behind symlinked skills", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const source = path.join(tmp.path, "source")
|
||||
const target = path.join(tmp.path, "target", "bro")
|
||||
const file = path.join(target, "SKILL.md")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(source, { recursive: true })
|
||||
await fs.mkdir(target, { recursive: true })
|
||||
await fs.writeFile(file, "---\nname: bro\ndescription: Initial\n---\n# bro")
|
||||
await fs.symlink(target, path.join(source, "bro"))
|
||||
})
|
||||
|
||||
const skill = yield* Skill.Service
|
||||
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(source) }))
|
||||
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Initial")
|
||||
yield* expectSubscription((input) => input.type === "directory" && input.path === target)
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(file, "---\nname: bro\ndescription: Updated\n---\n# bro"))
|
||||
yield* emitAndWait({ type: "update", path: file })
|
||||
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Updated")
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("invalidates symlinked sources when their target changes", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const source = path.join(tmp.path, "source")
|
||||
const first = path.join(tmp.path, "first")
|
||||
const second = path.join(tmp.path, "second")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(first, "bro"), { recursive: true })
|
||||
await fs.mkdir(path.join(second, "bro"), { recursive: true })
|
||||
await write(first, "bro", "First")
|
||||
await write(second, "bro", "Second")
|
||||
await fs.symlink(first, source)
|
||||
})
|
||||
|
||||
const skill = yield* Skill.Service
|
||||
const watcher = yield* Watcher.Test
|
||||
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(source) }))
|
||||
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("First")
|
||||
expect(yield* watcher.subscriptions()).toEqual([
|
||||
{ path: first, type: "directory" },
|
||||
{ path: source, type: "file" },
|
||||
])
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.unlink(source)
|
||||
await fs.symlink(second, source)
|
||||
})
|
||||
yield* emitAndWait({ type: "update", path: source })
|
||||
|
||||
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Second")
|
||||
expect(yield* watcher.subscriptions()).toEqual([
|
||||
{ path: first, type: "directory" },
|
||||
{ path: source, type: "file" },
|
||||
{ path: second, type: "directory" },
|
||||
{ path: source, type: "file" },
|
||||
])
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -33,7 +33,6 @@ import { ShellTool } from "@opencode-ai/core/tool/plugin/shell"
|
||||
import { ToolOutput } from "@opencode-ai/core/tool-output"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { toolIdentity, executeTool, toolDefinitions, waitForTool } from "./lib/tool"
|
||||
|
||||
@@ -139,7 +138,6 @@ const layer = AppNodeBuilder.build(
|
||||
[
|
||||
[SessionExecution.node, executionNode],
|
||||
[Permission.node, permission],
|
||||
[Global.node, tempGlobalLayer],
|
||||
],
|
||||
)
|
||||
|
||||
@@ -288,30 +286,27 @@ describe("ShellTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"permissions compound commands separately",
|
||||
() =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
return withSession(tmp.path, (registry) =>
|
||||
executeTool(registry, call({ command: "printf one && printf two" }, "call-compound")),
|
||||
).pipe(
|
||||
Effect.andThen(
|
||||
Effect.sync(() => {
|
||||
expect(assertions).toHaveLength(1)
|
||||
expect(assertions[0]).toMatchObject({
|
||||
resources: ["printf one", "printf two"],
|
||||
save: ["printf *", "printf *"],
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||
),
|
||||
{ timeout: 15_000 },
|
||||
it.live("permissions compound commands separately", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
return withSession(tmp.path, (registry) =>
|
||||
executeTool(registry, call({ command: "printf one && printf two" }, "call-compound")),
|
||||
).pipe(
|
||||
Effect.andThen(
|
||||
Effect.sync(() => {
|
||||
expect(assertions).toHaveLength(1)
|
||||
expect(assertions[0]).toMatchObject({
|
||||
resources: ["printf one", "printf two"],
|
||||
save: ["printf *", "printf *"],
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
|
||||
@@ -81,6 +81,7 @@ describe("SkillTool", () => {
|
||||
Skill.Service.of({
|
||||
transform: (_transform) => Effect.die("unused"),
|
||||
reload: () => Effect.die("unused"),
|
||||
sources: () => Effect.die("unused"),
|
||||
list: () => Effect.succeed(current),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -4,7 +4,6 @@ import path from "path"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
@@ -27,7 +26,6 @@ import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { SubagentTool } from "@opencode-ai/core/tool/plugin/subagent"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { executeTool, toolIdentity, waitForTool } from "./lib/tool"
|
||||
|
||||
@@ -102,10 +100,7 @@ const layer = AppNodeBuilder.build(
|
||||
PluginRuntime.providerNode,
|
||||
LocationServiceMap.node,
|
||||
]),
|
||||
[
|
||||
[SessionExecution.node, executionNode],
|
||||
[Global.node, tempGlobalLayer],
|
||||
],
|
||||
[[SessionExecution.node, executionNode]],
|
||||
)
|
||||
|
||||
const it = testEffect(layer)
|
||||
|
||||
@@ -18,14 +18,9 @@ import { tmpdir } from "./fixture/tmpdir"
|
||||
import path from "path"
|
||||
|
||||
const makeDb = EffectDrizzleSqlite.makeWithDefaults()
|
||||
const run = <A, E>(effect: Effect.Effect<A, E, SqlClient | Scope.Scope | Global.Service>) =>
|
||||
const run = <A, E>(effect: Effect.Effect<A, E, SqlClient | Scope.Scope>) =>
|
||||
Effect.runPromise(
|
||||
Effect.scoped(
|
||||
effect.pipe(
|
||||
Effect.provideService(Global.Service, Global.make({ data: path.join(process.cwd(), ".test-data") })),
|
||||
Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })),
|
||||
),
|
||||
),
|
||||
Effect.scoped(effect.pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })))),
|
||||
)
|
||||
|
||||
const session = (
|
||||
@@ -777,7 +772,7 @@ describe("V1Migration database workflow", () => {
|
||||
`)
|
||||
})
|
||||
|
||||
const database = <A, E>(effect: Effect.Effect<A, E, Database.Service | Global.Service | Scope.Scope>) =>
|
||||
const database = <A, E>(effect: Effect.Effect<A, E, Database.Service | Scope.Scope>) =>
|
||||
run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
@@ -940,7 +935,6 @@ describe("V1Migration database workflow", () => {
|
||||
await database(
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
const global = yield* Global.Service
|
||||
yield* db.run(
|
||||
sql`INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated) VALUES ('ses_orphan', 'missing-project', 'orphan', '/tmp/orphan', 'Orphan', '1', 1, 2)`,
|
||||
)
|
||||
@@ -950,7 +944,7 @@ describe("V1Migration database workflow", () => {
|
||||
project_id: "global",
|
||||
})
|
||||
expect(yield* db.get(sql`SELECT worktree FROM project WHERE id = 'global'`)).toEqual({
|
||||
worktree: path.parse(global.data).root,
|
||||
worktree: path.parse(Global.Path.data).root,
|
||||
})
|
||||
expect(yield* db.get(sql`SELECT value FROM kv WHERE key = 'migration.v1-v2'`)).toEqual({
|
||||
value: '{"phase":"completed"}',
|
||||
|
||||
@@ -44,10 +44,6 @@ export interface MermaidMarkdownRendererOptions {
|
||||
muted?: ColorInput
|
||||
warning?: ColorInput
|
||||
background?: ColorInput
|
||||
request?: ColorInput
|
||||
response?: ColorInput
|
||||
note?: ColorInput
|
||||
noteBackground?: ColorInput
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,12 +131,12 @@ function prepareDiagram(kind: DiagramKind, source: string, options: MermaidMarkd
|
||||
participant: color(colors.primary),
|
||||
lifeline: color(colors.muted),
|
||||
group: color(colors.secondary),
|
||||
request: color(colors.request ?? colors.primary),
|
||||
response: color(colors.response ?? colors.primary),
|
||||
request: color(colors.primary),
|
||||
response: color(colors.primary),
|
||||
fragment: color(colors.secondary),
|
||||
fragmentLabelBg: color(colors.background),
|
||||
note: color(colors.note ?? colors.warning),
|
||||
noteBg: color(colors.noteBackground ?? colors.background),
|
||||
note: color(colors.warning),
|
||||
noteBg: color(colors.background),
|
||||
}),
|
||||
),
|
||||
height: size.height,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { blendColor } from "./core/color/style.js"
|
||||
import { createOpenCodeDiagramPalette } from "./palette.js"
|
||||
|
||||
type Rgb = readonly [number, number, number]
|
||||
@@ -32,15 +31,11 @@ describe("OpenCode diagram palette", () => {
|
||||
}>)("derives a controlled neutral ladder for a $name", ({ text, subdued, secondary, muted }) => {
|
||||
const primary = rgb(text)
|
||||
const info = RGBA.fromInts(40, 120, 220)
|
||||
const success = RGBA.fromInts(80, 180, 120)
|
||||
const warning = RGBA.fromInts(220, 160, 80)
|
||||
const background = RGBA.fromInts(10, 20, 30)
|
||||
const palette = createOpenCodeDiagramPalette({
|
||||
text: primary,
|
||||
subdued: rgb(subdued),
|
||||
info,
|
||||
success,
|
||||
warning,
|
||||
background,
|
||||
})
|
||||
|
||||
@@ -50,9 +45,5 @@ describe("OpenCode diagram palette", () => {
|
||||
expect(palette.muted.equals(rgb(muted))).toBe(true)
|
||||
expect(palette.warning).toBe(info)
|
||||
expect(palette.background).toBe(background)
|
||||
expect(palette.request).toBe(success)
|
||||
expect(palette.response).toBe(warning)
|
||||
expect(palette.note).toBe(primary)
|
||||
expect(palette.noteBackground.equals(blendColor(background, rgb(subdued), 0.25))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,8 +5,6 @@ export interface OpenCodeDiagramPaletteInput {
|
||||
readonly text: RGBA
|
||||
readonly subdued: RGBA
|
||||
readonly info: RGBA
|
||||
readonly success: RGBA
|
||||
readonly warning: RGBA
|
||||
readonly background: RGBA
|
||||
}
|
||||
|
||||
@@ -18,9 +16,5 @@ export function createOpenCodeDiagramPalette(input: OpenCodeDiagramPaletteInput)
|
||||
muted: blendColor(input.text, input.subdued, 0.7),
|
||||
warning: input.info,
|
||||
background: input.background,
|
||||
request: input.success,
|
||||
response: input.warning,
|
||||
note: input.text,
|
||||
noteBackground: blendColor(input.background, input.subdued, 0.25),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,6 @@ export default Plugin.define({
|
||||
text: context.theme.text.default,
|
||||
subdued: context.theme.text.subdued,
|
||||
info: context.theme.text.feedback.info.default,
|
||||
success: context.theme.text.feedback.success.default,
|
||||
warning: context.theme.text.feedback.warning.default,
|
||||
background: context.theme.background.default,
|
||||
}),
|
||||
})),
|
||||
|
||||
@@ -51,15 +51,16 @@ sequenceDiagram
|
||||
`)
|
||||
|
||||
expectDiagram(output).toEqualDiagram(`
|
||||
Browser Server
|
||||
───┬─── ───┬──
|
||||
│ │
|
||||
│ GET / │
|
||||
├─────────────────►
|
||||
│ │
|
||||
│ 401 WWW-Auth │
|
||||
◄─────────────────┤
|
||||
│ │
|
||||
╭─────────╮ ╭────────╮
|
||||
│ Browser │ │ Server │
|
||||
╰────┬────╯ ╰────┬───╯
|
||||
│ │
|
||||
│ GET / │
|
||||
├─────────────────►
|
||||
│ │
|
||||
│ 401 WWW-Auth │
|
||||
◄─────────────────┤
|
||||
│ │
|
||||
`)
|
||||
})
|
||||
|
||||
@@ -139,13 +140,13 @@ sequenceDiagram
|
||||
`)
|
||||
|
||||
const lines = output.split("\n")
|
||||
const browserCenter = lines[0]!.indexOf("w")
|
||||
const serverCenter = lines[0]!.indexOf("v")
|
||||
const browserCenter = lines[1]!.indexOf("w")
|
||||
const serverCenter = lines[1]!.indexOf("v")
|
||||
|
||||
expect(lines[1]?.[browserCenter]).toBe("┬")
|
||||
expect(lines[2]?.[browserCenter]).toBe("│")
|
||||
expect(lines[1]?.[serverCenter]).toBe("┬")
|
||||
expect(lines[2]?.[serverCenter]).toBe("│")
|
||||
expect(lines[2]?.[browserCenter]).toBe("┬")
|
||||
expect(lines[3]?.[browserCenter]).toBe("│")
|
||||
expect(lines[2]?.[serverCenter]).toBe("┬")
|
||||
expect(lines[3]?.[serverCenter]).toBe("│")
|
||||
})
|
||||
|
||||
test("ramps participant frames into neutral lifelines", () => {
|
||||
@@ -276,8 +277,8 @@ sequenceDiagram
|
||||
A->>B: hello`)
|
||||
|
||||
expect(output).not.toContain("<br")
|
||||
expect(output).toContain("First line")
|
||||
expect(output).toContain("Second line")
|
||||
expect(output).toContain("│ First line │")
|
||||
expect(output).toContain("│ Second line │")
|
||||
})
|
||||
|
||||
test("parses Mermaid arrow head variants", () => {
|
||||
@@ -313,27 +314,28 @@ sequenceDiagram
|
||||
`)
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
" A B
|
||||
─┬─ ─┬─
|
||||
│ │
|
||||
│ open solid │
|
||||
├─────────────────>│
|
||||
│ │
|
||||
│ open dashed │
|
||||
│<─────────────────┤
|
||||
│ │
|
||||
│ failed solid │
|
||||
├─────────────────✕│
|
||||
│ │
|
||||
│ failed dashed │
|
||||
│✕─────────────────┤
|
||||
│ │
|
||||
│ async solid │
|
||||
├─────────────────)│
|
||||
│ │
|
||||
│ async dashed │
|
||||
│(─────────────────┤
|
||||
│ │"
|
||||
"╭───╮ ╭───╮
|
||||
│ A │ │ B │
|
||||
╰─┬─╯ ╰─┬─╯
|
||||
│ │
|
||||
│ open solid │
|
||||
├─────────────────>│
|
||||
│ │
|
||||
│ open dashed │
|
||||
│<─────────────────┤
|
||||
│ │
|
||||
│ failed solid │
|
||||
├─────────────────✕│
|
||||
│ │
|
||||
│ failed dashed │
|
||||
│✕─────────────────┤
|
||||
│ │
|
||||
│ async solid │
|
||||
├─────────────────)│
|
||||
│ │
|
||||
│ async dashed │
|
||||
│(─────────────────┤
|
||||
│ │"
|
||||
`)
|
||||
})
|
||||
|
||||
@@ -385,7 +387,7 @@ sequenceDiagram
|
||||
end
|
||||
`)
|
||||
const lines = output.split("\n")
|
||||
const participantCenter = lines.find((line) => line.includes(" A"))!.indexOf("A")
|
||||
const participantCenter = lines.find((line) => line.includes("│ A │"))!.indexOf("A")
|
||||
const fragmentStart = lines.find((line) => line.includes("alt: ok"))!.indexOf("╭")
|
||||
|
||||
expect(fragmentStart).toBeLessThan(participantCenter)
|
||||
@@ -555,7 +557,7 @@ sequenceDiagram
|
||||
const fragmentMessageRow = fragment.split("\n").find((line) => line.includes("this non adjacent message"))!
|
||||
expect(groupMessageRow.trimEnd().endsWith("│")).toBe(true)
|
||||
expect(fragmentMessageRow).toContain("this non adjacent message is deliberately much wider than the frame")
|
||||
expect(fragmentMessageRow.match(/│/g)?.length).toBe(3)
|
||||
expect(fragmentMessageRow.match(/│/g)?.length).toBe(2)
|
||||
})
|
||||
|
||||
test("keeps long notes inside groups and nested fragment frames intact", () => {
|
||||
@@ -598,7 +600,7 @@ sequenceDiagram
|
||||
const groupBorderRight = output.split("\n")[0]!.lastIndexOf("╮")
|
||||
const lines = output.split("\n")
|
||||
const externalLabelRow = lines.findIndex((line) => line.includes("External"))
|
||||
const externalHeaderLeft = lines[externalLabelRow + 1]!.lastIndexOf("─")
|
||||
const externalHeaderLeft = lines[externalLabelRow - 1]!.lastIndexOf("╭")
|
||||
|
||||
expect(externalHeaderLeft).toBeGreaterThan(groupBorderRight)
|
||||
})
|
||||
@@ -653,17 +655,18 @@ sequenceDiagram
|
||||
`)
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
" ╭─ Backend ───────────────────────────────╮
|
||||
Browser │ API Cache DB │
|
||||
───┬─── │ ─┬─ ──┬── ─┬─ │
|
||||
│ │ │ │ │ │
|
||||
│ GET /users/42 │ │ │ │ │
|
||||
├──────────────────► │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ get user:42 │ │ │
|
||||
│ │ ├─────────────────► │ │
|
||||
│ │ │ │ │ │
|
||||
╰─────────────────────────────────────────╯"
|
||||
" ╭─ Backend ──────────────────────────────────╮
|
||||
╭─────────╮ │ ╭─────╮ ╭───────╮ ╭────╮ │
|
||||
│ Browser │ │ │ API │ │ Cache │ │ DB │ │
|
||||
╰────┬────╯ │ ╰──┬──╯ ╰───┬───╯ ╰──┬─╯ │
|
||||
│ │ │ │ │ │
|
||||
│ GET /users/42 │ │ │ │
|
||||
├──────────────────► │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ get user:42 │ │ │
|
||||
│ │ ├─────────────────► │ │
|
||||
│ │ │ │ │ │
|
||||
╰────────────────────────────────────────────╯"
|
||||
`)
|
||||
})
|
||||
|
||||
@@ -703,17 +706,18 @@ sequenceDiagram
|
||||
`)
|
||||
|
||||
expect(output).toMatchInlineSnapshot(`
|
||||
"Service
|
||||
───┬───
|
||||
│
|
||||
├────────────────────╮
|
||||
│ Check Permissions │
|
||||
◄────────────────────╯
|
||||
│"
|
||||
"╭─────────╮
|
||||
│ Service │
|
||||
╰────┬────╯
|
||||
│
|
||||
├────────────────────╮
|
||||
│ Check Permissions │
|
||||
◄────────────────────╯
|
||||
│"
|
||||
`)
|
||||
})
|
||||
|
||||
test("renders note badges in their reserved rows", () => {
|
||||
test("frames notes in their reserved rows", () => {
|
||||
const output = renderSequenceDiagram(`
|
||||
sequenceDiagram
|
||||
Browser->>Server: one
|
||||
@@ -725,9 +729,11 @@ sequenceDiagram
|
||||
const nextMessageRow = lines.findIndex((line) => line.includes("two"))
|
||||
|
||||
expect(noteRow).toBeGreaterThan(0)
|
||||
expect(lines[noteRow - 1]?.trim()).toBe("│ │")
|
||||
expect(lines[noteRow]).toContain(" phase ")
|
||||
expect(lines[noteRow + 1]?.trim()).toBe("│ │")
|
||||
expect(lines[noteRow - 1]).toContain("╭")
|
||||
expect(lines[noteRow - 1]).toContain("╮")
|
||||
expect(lines[noteRow]).toContain("│ phase │")
|
||||
expect(lines[noteRow + 1]).toContain("╰")
|
||||
expect(lines[noteRow + 1]).toContain("╯")
|
||||
expect(nextMessageRow).toBe(noteRow + 2)
|
||||
})
|
||||
|
||||
|
||||
@@ -191,9 +191,29 @@ function renderSelfMessage(
|
||||
}
|
||||
|
||||
function renderNote(grid: SequenceGrid, placement: Extract<SequenceStepPlacement, { type: "note" }>): void {
|
||||
placement.textLines.forEach((line, index) =>
|
||||
setText(grid, placement.textX, placement.textY + index, line, "noteBadge"),
|
||||
)
|
||||
const width = Math.max(...placement.textLines.map(diagramTextWidth))
|
||||
const left = placement.textX
|
||||
const right = left + width - 1
|
||||
const top = placement.textY - 1
|
||||
const bottom = placement.textY + placement.textLines.length
|
||||
|
||||
for (let x = left + 1; x < right; x++) {
|
||||
setCell(grid, x, top, SEQUENCE_BORDER.horizontal, "note")
|
||||
setCell(grid, x, bottom, SEQUENCE_BORDER.horizontal, "note")
|
||||
}
|
||||
for (let y = top + 1; y < bottom; y++) {
|
||||
setCell(grid, left, y, SEQUENCE_BORDER.vertical, "note")
|
||||
setCell(grid, right, y, SEQUENCE_BORDER.vertical, "note")
|
||||
}
|
||||
setCell(grid, left, top, SEQUENCE_BORDER.topLeft, "note")
|
||||
setCell(grid, right, top, SEQUENCE_BORDER.topRight, "note")
|
||||
setCell(grid, left, bottom, SEQUENCE_BORDER.bottomLeft, "note")
|
||||
setCell(grid, right, bottom, SEQUENCE_BORDER.bottomRight, "note")
|
||||
placement.textLines.forEach((line, index) => setText(grid, left, placement.textY + index, line, "noteBadge"))
|
||||
for (let y = placement.textY; y < bottom; y++) {
|
||||
setCell(grid, left, y, SEQUENCE_BORDER.vertical, "note")
|
||||
setCell(grid, right, y, SEQUENCE_BORDER.vertical, "note")
|
||||
}
|
||||
}
|
||||
|
||||
export function drawSequenceDiagramGrid(
|
||||
@@ -216,12 +236,22 @@ export function drawSequenceDiagramGrid(
|
||||
setText(grid, centeredStart(center, line), participantHeaderY + index, line, "participant"),
|
||||
)
|
||||
} else {
|
||||
labelLines.forEach((line, index) =>
|
||||
setText(grid, centeredStart(center, line), participantHeaderTopY + index, line, "participant"),
|
||||
)
|
||||
for (let x = headerLeftX; x <= headerRightX; x++) {
|
||||
setCell(grid, x, participantHeaderTopY, SEQUENCE_BORDER.horizontal, "participant")
|
||||
setCell(grid, x, participantRuleY, SEQUENCE_BORDER.horizontal, "participant")
|
||||
}
|
||||
|
||||
setCell(grid, headerLeftX, participantHeaderTopY, SEQUENCE_BORDER.topLeft, "participant")
|
||||
setCell(grid, headerRightX, participantHeaderTopY, SEQUENCE_BORDER.topRight, "participant")
|
||||
for (let y = participantHeaderY; y < participantRuleY; y++) {
|
||||
setCell(grid, headerLeftX, y, SEQUENCE_BORDER.vertical, "participant")
|
||||
setCell(grid, headerRightX, y, SEQUENCE_BORDER.vertical, "participant")
|
||||
}
|
||||
setCell(grid, headerLeftX, participantRuleY, SEQUENCE_BORDER.bottomLeft, "participant")
|
||||
setCell(grid, headerRightX, participantRuleY, SEQUENCE_BORDER.bottomRight, "participant")
|
||||
labelLines.forEach((line, index) =>
|
||||
setText(grid, centeredStart(center, line), participantHeaderY + index, line, "participant"),
|
||||
)
|
||||
setCell(grid, center, participantRuleY, SEQUENCE_BORDER.topT, "participant")
|
||||
}
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ ${Array.from(
|
||||
expect(explicit.activations).toEqual(shorthand.activations)
|
||||
})
|
||||
|
||||
test("left-aligns message label blocks inside their arrow span", () => {
|
||||
test("centers message label blocks over their arrow span", () => {
|
||||
const plan = createSequencePlacementPlan(
|
||||
parseMermaidSequenceDiagram(`sequenceDiagram
|
||||
participant A
|
||||
@@ -214,6 +214,8 @@ ${Array.from(
|
||||
A->>B: short<br/>a much longer line`),
|
||||
)
|
||||
const message = plan.steps.find((step) => step.type === "message")!
|
||||
expect(message.labelX).toBe(message.leftX + 2)
|
||||
const labelWidth = Math.max(...message.labelLines.map(diagramTextWidth))
|
||||
|
||||
expect(message.labelX * 2 + labelWidth).toBe(message.leftX + message.rightX)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -142,7 +142,7 @@ function messageLabelText(message: SequenceMessage): string {
|
||||
|
||||
function participantHeaderWidth(label: string, compact: boolean): number {
|
||||
const width = labelLinesWidth(mermaidLabelLines(label))
|
||||
return compact ? width : Math.max(3, width)
|
||||
return compact ? width : Math.max(5, width + 4)
|
||||
}
|
||||
|
||||
function fragmentLabelText(fragment: SequenceFragment): string {
|
||||
@@ -247,7 +247,7 @@ function getStepContentBounds(
|
||||
const leftX = Math.min(fromX, toX)
|
||||
const rightX = Math.max(fromX, toX)
|
||||
const labelWidth = messageWidth(step.message)
|
||||
const labelLeftX = leftX + 2
|
||||
const labelLeftX = Math.floor((leftX + rightX - labelWidth) / 2)
|
||||
return { leftX: Math.min(leftX, labelLeftX), rightX: Math.max(rightX, labelLeftX + labelWidth - 1) }
|
||||
}
|
||||
if (step.type !== "note") return undefined
|
||||
@@ -525,8 +525,8 @@ export function createSequencePlacementPlan(
|
||||
...diagram.participants.map((participant) => mermaidLabelLines(participant.label).length),
|
||||
)
|
||||
const participantHeaderTopY = hasGroups ? 1 : 0
|
||||
const participantHeaderY = participantHeaderTopY
|
||||
const participantRuleY = participantHeaderTopY + (compact ? participantLabelHeight - 1 : participantLabelHeight)
|
||||
const participantHeaderY = participantHeaderTopY + (compact ? 0 : 1)
|
||||
const participantRuleY = participantHeaderTopY + (compact ? participantLabelHeight - 1 : participantLabelHeight + 1)
|
||||
const lifelineStartY = participantRuleY + 1
|
||||
const stepStartY = lifelineStartY + 1
|
||||
const width = Math.max(contentBounds.rightX + 1, ...groups.map((group) => group.rightX + 1), fragments.rightX + 1)
|
||||
@@ -650,7 +650,7 @@ export function createSequencePlacementPlan(
|
||||
const inlineLabel = inlineMessageLabel(step.message, labelLines, fromX, toX, compact)
|
||||
const arrowY = inlineLabel ? stepY : stepY + labelLines.length
|
||||
const renderedLabelWidth = inlineLabel ? visualLength(inlineLabel) : labelLinesWidth(labelLines)
|
||||
const labelX = inlineLabel ? Math.floor((leftX + rightX - renderedLabelWidth) / 2) : leftX + 2
|
||||
const labelX = Math.floor((leftX + rightX - renderedLabelWidth) / 2)
|
||||
steps.push({
|
||||
type: "message",
|
||||
message: step.message,
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import type { SkillApi } from "@opencode-ai/client/effect/api"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import type { Effect, Types } from "effect"
|
||||
import type { Effect } from "effect"
|
||||
import type { Transform } from "./registration.js"
|
||||
|
||||
export interface SkillDraft {
|
||||
list(): readonly Types.DeepMutable<Skill.Info>[]
|
||||
add(skill: Skill.Info): void
|
||||
update(id: string, update: (skill: Types.DeepMutable<Skill.Info>) => void): void
|
||||
remove(id: string): void
|
||||
source(source: Skill.Source): void
|
||||
list(): readonly Skill.Source[]
|
||||
}
|
||||
|
||||
export interface SkillDomain extends SkillApi<unknown> {
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import type { SkillApi } from "@opencode-ai/client/promise/api"
|
||||
import type { Skill } from "@opencode-ai/schema/skill"
|
||||
import type { Transform } from "./registration.js"
|
||||
import type { DeepMutable } from "./types.js"
|
||||
|
||||
export interface SkillDraft {
|
||||
list(): readonly DeepMutable<Skill.Info>[]
|
||||
add(skill: Skill.Info): void
|
||||
update(id: string, update: (skill: DeepMutable<Skill.Info>) => void): void
|
||||
remove(id: string): void
|
||||
source(source: Skill.Source): void
|
||||
list(): readonly Skill.Source[]
|
||||
}
|
||||
|
||||
export interface SkillDomain extends SkillApi {
|
||||
|
||||
@@ -12733,9 +12733,6 @@
|
||||
},
|
||||
"agent": {
|
||||
"type": "string"
|
||||
},
|
||||
"previous": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["id", "time", "type", "agent"],
|
||||
|
||||
@@ -42,7 +42,6 @@ export const AgentSelected = Schema.Struct({
|
||||
...Base,
|
||||
type: Schema.tag("agent-switched"),
|
||||
agent: Agent.ID,
|
||||
previous: Agent.ID.pipe(optional),
|
||||
}).annotate({ identifier: "Session.Message.AgentSelected" })
|
||||
|
||||
export interface ModelSelected extends Schema.Schema.Type<typeof ModelSelected> {}
|
||||
|
||||
@@ -46,7 +46,6 @@ import type { ServerOptions } from "./options"
|
||||
import { modalWorkspaceDriver, provider as modalProvider } from "./workspace/modal-workspace"
|
||||
|
||||
const applicationServices = LayerNode.group([
|
||||
Global.node,
|
||||
Database.node,
|
||||
Bus.node,
|
||||
EventLogger.node,
|
||||
|
||||
@@ -72,7 +72,7 @@ import { DialogOpen } from "./component/dialog-open"
|
||||
import { SessionTabs } from "./component/session-tabs"
|
||||
import { sessionTabsFitVertically } from "./ui/layout"
|
||||
import { ThemeErrorToast } from "./component/theme-error-toast"
|
||||
import { createThemeSource, ThemeProvider, useTheme, useThemes } from "./context/theme"
|
||||
import { ThemeProvider, useTheme, useThemes } from "./context/theme"
|
||||
import { Home } from "./routes/home"
|
||||
import { Session } from "./routes/session"
|
||||
import { PromptHistoryProvider } from "./prompt/history"
|
||||
@@ -372,10 +372,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
<DataProvider>
|
||||
<LocationProvider>
|
||||
<SessionTabsProvider>
|
||||
<ThemeProvider
|
||||
mode={mode}
|
||||
source={createThemeSource(global.config)}
|
||||
>
|
||||
<ThemeProvider mode={mode}>
|
||||
<ThemeErrorToast />
|
||||
<LocalProvider>
|
||||
<PromptStashProvider>
|
||||
|
||||
@@ -84,15 +84,6 @@ export const settings: Setting[] = [
|
||||
values: ["none", "auto"],
|
||||
keywords: ["transcript", "messages"],
|
||||
},
|
||||
{
|
||||
title: "Transcript images",
|
||||
category: "Session",
|
||||
path: ["session", "image_preview"],
|
||||
default: false,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
keywords: ["attachments", "images", "tool output"],
|
||||
},
|
||||
{
|
||||
title: "Enabled",
|
||||
category: "Tabs",
|
||||
|
||||
@@ -60,11 +60,6 @@ import { Keymap, type KeymapCommand } from "../../context/keymap"
|
||||
import { abbreviateHome } from "../../runtime"
|
||||
import { PluginSlot } from "../../plugin/render"
|
||||
import type { SessionPending } from "@opencode-ai/schema/session-pending"
|
||||
import {
|
||||
deduplicatePromptImages,
|
||||
preserveMentionlessPromptAttachments,
|
||||
promptAttachmentLabel,
|
||||
} from "../../prompt/attachment"
|
||||
import { DialogImagePreview } from "../dialog-image-preview"
|
||||
|
||||
export type PromptProps = {
|
||||
@@ -336,7 +331,7 @@ export function Prompt(props: PromptProps) {
|
||||
}
|
||||
|
||||
const imageAttachments = createMemo(() =>
|
||||
(deduplicatePromptImages(store.prompt.files) ?? []).filter((file) => file.uri.startsWith("data:image/")),
|
||||
(store.prompt.files ?? []).filter((file) => typeof file.uri === "string" && file.uri.startsWith("data:image/")),
|
||||
)
|
||||
const imagePreviewHeight = createMemo(() => Math.max(4, Math.min(8, Math.floor(dimensions().height / 4))))
|
||||
const imagePreviewWidth = createMemo(() => imagePreviewHeight() * 2)
|
||||
@@ -741,7 +736,6 @@ export function Prompt(props: PromptProps) {
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const newMap = new Map<number, PromptPartRef>()
|
||||
const fileExtmarks = new Map<number, NonNullable<PromptInfo["files"]>[number]>()
|
||||
const files: NonNullable<PromptInfo["files"]> = []
|
||||
const agents: NonNullable<PromptInfo["agents"]> = []
|
||||
const skills: NonNullable<PromptInfo["skills"]> = []
|
||||
@@ -755,8 +749,9 @@ export function Prompt(props: PromptProps) {
|
||||
if (!part?.mention) continue
|
||||
part.mention.start = extmark.start
|
||||
part.mention.end = extmark.end
|
||||
const index = files.length
|
||||
files.push(part)
|
||||
fileExtmarks.set(extmark.id, part)
|
||||
newMap.set(extmark.id, { type: "file", index })
|
||||
continue
|
||||
}
|
||||
if (ref.type === "agent") {
|
||||
@@ -788,19 +783,8 @@ export function Prompt(props: PromptProps) {
|
||||
newMap.set(extmark.id, { type: "pasted", index })
|
||||
}
|
||||
|
||||
const nextFiles = preserveMentionlessPromptAttachments(draft.prompt.files, files)
|
||||
const fileIndices = new Map(nextFiles.map((file, index) => [file, index]))
|
||||
for (const [extmark, file] of fileExtmarks) {
|
||||
const index = fileIndices.get(file)
|
||||
if (index !== undefined) newMap.set(extmark, { type: "file", index })
|
||||
}
|
||||
|
||||
draft.extmarkToPart = newMap
|
||||
if (
|
||||
nextFiles.length !== draft.prompt.files?.length ||
|
||||
nextFiles.some((file, index) => file !== draft.prompt.files?.[index])
|
||||
)
|
||||
draft.prompt.files = nextFiles
|
||||
draft.prompt.files = files
|
||||
draft.prompt.agents = agents
|
||||
draft.prompt.skills = skills
|
||||
draft.prompt.pasted = pasted
|
||||
@@ -1154,6 +1138,7 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
// Capture mode before it gets reset
|
||||
const currentMode = store.mode
|
||||
|
||||
if (store.mode === "shell") {
|
||||
move.startSubmit()
|
||||
void client.api.session.shell({
|
||||
@@ -1391,7 +1376,13 @@ export function Prompt(props: PromptProps) {
|
||||
function pasteAttachment(file: { filename?: string; uri: string }) {
|
||||
const currentOffset = input.cursorOffset
|
||||
const extmarkStart = currentOffset
|
||||
const virtualText = promptAttachmentLabel(store.prompt.files, { uri: file.uri, name: file.filename })
|
||||
const pdf = file.uri.startsWith("data:application/pdf;")
|
||||
const count = pdf
|
||||
? (store.prompt.files?.filter(
|
||||
(attachment) => typeof attachment.uri === "string" && attachment.uri.startsWith("data:application/pdf;"),
|
||||
).length ?? 0)
|
||||
: imageAttachments().length
|
||||
const virtualText = pdf ? `[PDF ${count + 1}]` : `[Image ${count + 1}]`
|
||||
const extmarkEnd = extmarkStart + virtualText.length
|
||||
const textToInsert = virtualText + " "
|
||||
|
||||
|
||||
@@ -131,9 +131,6 @@ export const Info = Schema.Struct({
|
||||
grouping: Schema.optional(Schema.Literals(["auto", "none"])).annotate({
|
||||
description: "Group related transcript items automatically or render each item separately",
|
||||
}),
|
||||
image_preview: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Show user attachment and tool-result images in the session transcript",
|
||||
}),
|
||||
markdown: Schema.optional(Schema.Literals(["source", "rendered"])).annotate({
|
||||
description: "Show Markdown syntax markers or conceal them in rendered transcript content",
|
||||
}),
|
||||
|
||||
@@ -386,8 +386,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
},
|
||||
}))
|
||||
break
|
||||
case "session.agent.selected": {
|
||||
const previous = store.session.info[event.data.sessionID]?.agent
|
||||
case "session.agent.selected":
|
||||
if (store.session.info[event.data.sessionID])
|
||||
setStore("session", "info", event.data.sessionID, "agent", event.data.agent)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
@@ -395,12 +394,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "agent-switched",
|
||||
agent: event.data.agent,
|
||||
previous,
|
||||
time: { created: event.created },
|
||||
})
|
||||
})
|
||||
break
|
||||
}
|
||||
case "session.model.selected":
|
||||
if (store.session.info[event.data.sessionID])
|
||||
setStore("session", "info", event.data.sessionID, "model", event.data.model)
|
||||
|
||||
@@ -28,6 +28,7 @@ import { createEffect, createMemo, onCleanup, onMount, type Accessor, type Paren
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useConfig } from "../config"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { DevTools } from "../devtools"
|
||||
import { configDirectories } from "../util/config-directories"
|
||||
|
||||
@@ -68,15 +69,15 @@ export type ThemeSource = Readonly<{
|
||||
subscribeRefresh?(refresh: () => void): () => void
|
||||
}>
|
||||
|
||||
export const createThemeSource = (config: string): ThemeSource => ({
|
||||
const themeSource: ThemeSource = {
|
||||
async discover() {
|
||||
return discoverThemes(configDirectories(config, process.cwd()))
|
||||
return discoverThemes(configDirectories(Global.Path.config, process.cwd()))
|
||||
},
|
||||
subscribeRefresh(refresh) {
|
||||
process.on("SIGUSR2", refresh)
|
||||
return () => process.off("SIGUSR2", refresh)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export { discoverThemes } from "../theme/discovery"
|
||||
|
||||
@@ -138,11 +139,11 @@ subscribeThemes((themes) => setStore("themes", themes))
|
||||
|
||||
const themeContext = createSimpleContext({
|
||||
name: "Theme",
|
||||
init: (props: { mode: "dark" | "light"; source: ThemeSource }): ThemeContextValue => {
|
||||
init: (props: { mode: "dark" | "light"; source?: ThemeSource }): ThemeContextValue => {
|
||||
const renderer = useRenderer()
|
||||
const configState = useConfig()
|
||||
const config = configState.data
|
||||
const themes = props.source
|
||||
const themes = props.source ?? themeSource
|
||||
const pick = (value: unknown) => {
|
||||
if (value === "dark" || value === "light") return value
|
||||
return
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import type { PromptInput } from "@opencode-ai/schema"
|
||||
|
||||
type PromptFile = PromptInput.FileAttachment
|
||||
type PromptFileIdentity = Pick<PromptFile, "uri" | "name" | "description">
|
||||
type ProjectedFile = Readonly<{
|
||||
data: string
|
||||
mime: string
|
||||
source: { type: string }
|
||||
name?: string
|
||||
description?: string
|
||||
mention?: { text: string }
|
||||
}>
|
||||
|
||||
function attachmentKind(uri: string) {
|
||||
if (uri.startsWith("data:image/")) return "Image"
|
||||
if (uri.startsWith("data:application/pdf;")) return "PDF"
|
||||
return undefined
|
||||
}
|
||||
|
||||
function attachmentMetadata(file: PromptFileIdentity) {
|
||||
return JSON.stringify([file.name ?? null, file.description ?? null])
|
||||
}
|
||||
|
||||
function deduplicateByIdentity<T>(
|
||||
items: readonly T[],
|
||||
identity: (item: T) => { metadata: string; payload: string } | undefined,
|
||||
) {
|
||||
const seen = new Map<string, string[]>()
|
||||
return items.filter((item) => {
|
||||
const key = identity(item)
|
||||
if (!key) return true
|
||||
const matches = seen.get(key.metadata)
|
||||
if (matches?.includes(key.payload)) return false
|
||||
if (matches) matches.push(key.payload)
|
||||
if (!matches) seen.set(key.metadata, [key.payload])
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export function deduplicatePromptImages(files: readonly PromptFile[] | undefined) {
|
||||
if (!files || files.length < 2) return files
|
||||
return deduplicateByIdentity(files, (file) =>
|
||||
file.uri.startsWith("data:image/") && file.mention?.text
|
||||
? {
|
||||
metadata: JSON.stringify([attachmentMetadata(file), file.mention.text]),
|
||||
payload: file.uri,
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
}
|
||||
|
||||
export function preserveMentionlessPromptAttachments(
|
||||
files: readonly PromptFile[] | undefined,
|
||||
mentioned: PromptFile[],
|
||||
) {
|
||||
if (!files) return mentioned
|
||||
const tracked = mentioned.values()
|
||||
return files.flatMap((file) => {
|
||||
if (!file.mention?.text) return [file]
|
||||
const next = tracked.next()
|
||||
return next.done ? [] : [next.value]
|
||||
})
|
||||
}
|
||||
|
||||
export function deduplicateVisibleImages<T extends ProjectedFile>(files: readonly T[]) {
|
||||
return deduplicateByIdentity(files, (file) =>
|
||||
file.mime.startsWith("image/") && file.source.type === "inline" && file.mention?.text
|
||||
? {
|
||||
metadata: JSON.stringify([file.mime, file.name ?? null, file.description ?? null, file.mention.text]),
|
||||
payload: file.data,
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
}
|
||||
|
||||
export function promptAttachmentLabel(files: readonly PromptFile[] | undefined, file: PromptFileIdentity) {
|
||||
const kind = attachmentKind(file.uri)
|
||||
if (!kind) throw new Error(`Unsupported inline attachment: ${file.uri}`)
|
||||
const metadata = attachmentMetadata(file)
|
||||
const existing =
|
||||
kind === "Image"
|
||||
? files?.find(
|
||||
(candidate) =>
|
||||
candidate.uri === file.uri && attachmentMetadata(candidate) === metadata && candidate.mention?.text,
|
||||
)?.mention?.text
|
||||
: undefined
|
||||
if (existing) return existing
|
||||
|
||||
const pattern = new RegExp(`^\\[${kind} (\\d+)\\]$`)
|
||||
const count =
|
||||
files?.reduce((highest, candidate) => {
|
||||
const match = candidate.mention?.text.match(pattern)
|
||||
return match ? Math.max(highest, Number(match[1])) : highest
|
||||
}, 0) ?? 0
|
||||
return `[${kind} ${count + 1}]`
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
createMemo,
|
||||
createSignal,
|
||||
For,
|
||||
Index,
|
||||
Match,
|
||||
on,
|
||||
onCleanup,
|
||||
@@ -13,7 +12,6 @@ import {
|
||||
Show,
|
||||
Switch,
|
||||
useContext,
|
||||
type Accessor,
|
||||
} from "solid-js"
|
||||
import path from "node:path"
|
||||
import { EOL, tmpdir } from "node:os"
|
||||
@@ -26,7 +24,7 @@ import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
|
||||
import { Spinner, SPINNER_FRAMES } from "../../component/spinner"
|
||||
import { PatchDiff } from "../../component/patch-diff"
|
||||
import { createSyntaxStyleMemo, ThemeContextProvider, useTheme, useThemes } from "../../context/theme"
|
||||
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA, MouseEvent } from "@opentui/core"
|
||||
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
|
||||
import { Prompt, type PromptRef } from "../../component/prompt"
|
||||
import type {
|
||||
ModelInfo,
|
||||
@@ -56,7 +54,6 @@ import { openEditor } from "../../editor"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
import { DialogSelect } from "../../ui/dialog-select"
|
||||
import { DialogSessionRename } from "../../component/dialog-session-rename"
|
||||
import { DialogImagePreview } from "../../component/dialog-image-preview"
|
||||
import { DialogMessage } from "./dialog-message"
|
||||
import { DialogFork } from "./dialog-fork"
|
||||
import { DialogTimeline } from "./dialog-timeline"
|
||||
@@ -70,7 +67,6 @@ import stripAnsi from "strip-ansi"
|
||||
import { usePromptRef } from "../../context/prompt"
|
||||
import { sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH } from "../../ui/layout"
|
||||
import { projectedPromptInput } from "../../prompt/codec"
|
||||
import { deduplicateVisibleImages } from "../../prompt/attachment"
|
||||
import { useEpilogue } from "../../context/epilogue"
|
||||
import { normalizePath } from "../../util/path"
|
||||
import { PermissionPrompt } from "./permission"
|
||||
@@ -1596,9 +1592,8 @@ function SessionGroupView(props: {
|
||||
</InlineToolRow>
|
||||
</Show>
|
||||
<Show when={expanded() && grouped().length > 0}>
|
||||
<For each={grouped()}>{(part) => <ToolPart part={part} images={false} />}</For>
|
||||
<For each={grouped()}>{(part) => <ToolPart part={part} />}</For>
|
||||
</Show>
|
||||
<ToolImages parts={grouped()} />
|
||||
<For each={pending()}>{(part) => <ToolPart part={part} />}</For>
|
||||
</Show>
|
||||
</Show>
|
||||
@@ -1652,12 +1647,7 @@ function SessionSwitchMessageV2(props: { message: SessionMessageInfo }) {
|
||||
const ctx = use()
|
||||
const theme = useTheme()
|
||||
const text = () => {
|
||||
if (props.message.type === "agent-switched") {
|
||||
const agent = Locale.titlecase(props.message.agent)
|
||||
if (props.message.previous && props.message.previous !== props.message.agent)
|
||||
return `Switched agent from ${Locale.titlecase(props.message.previous)} to ${agent}`
|
||||
return `Switched agent to ${agent}`
|
||||
}
|
||||
if (props.message.type === "agent-switched") return `Switched agent to ${props.message.agent}`
|
||||
if (props.message.type === "model-switched")
|
||||
return switchLabel(props.message.model, ctx.models(), props.message.previous)
|
||||
return ""
|
||||
@@ -1905,13 +1895,8 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
||||
const ctx = use()
|
||||
const data = useData()
|
||||
const local = useLocal()
|
||||
const files = createMemo(() => deduplicateVisibleImages(props.message.files ?? []))
|
||||
const files = createMemo(() => props.message.files ?? [])
|
||||
const skills = createMemo(() => props.message.skills ?? [])
|
||||
const images = createMemo(() =>
|
||||
files().flatMap((file) =>
|
||||
file.mime.startsWith("image/") ? [{ uri: `data:${file.mime};base64,${file.data}` }] : [],
|
||||
),
|
||||
)
|
||||
const themes = useThemes()
|
||||
const theme = useTheme("elevated")
|
||||
const mode = themes.mode
|
||||
@@ -1933,7 +1918,6 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
||||
borderColor={delivery() ? theme.border.default : color()}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
>
|
||||
<SessionImages images={images()} paddingLeft={2} />
|
||||
<box
|
||||
onMouseOver={() => {
|
||||
setHover(true)
|
||||
@@ -2226,7 +2210,7 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) {
|
||||
|
||||
// Pending messages moved to individual tool pending functions
|
||||
|
||||
function ToolPart(props: { part: SessionMessageAssistantTool; images?: boolean }) {
|
||||
function ToolPart(props: { part: SessionMessageAssistantTool }) {
|
||||
const display = createMemo(() => toolDisplay(props.part.name))
|
||||
|
||||
const toolprops = {
|
||||
@@ -2250,7 +2234,7 @@ function ToolPart(props: { part: SessionMessageAssistantTool; images?: boolean }
|
||||
},
|
||||
}
|
||||
|
||||
const content = (
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={display() === "shell"}>
|
||||
<Shell {...toolprops} />
|
||||
@@ -2296,87 +2280,6 @@ function ToolPart(props: { part: SessionMessageAssistantTool; images?: boolean }
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
return [
|
||||
content,
|
||||
<Show when={props.images !== false}>
|
||||
<ToolImages parts={[props.part]} />
|
||||
</Show>,
|
||||
]
|
||||
}
|
||||
|
||||
function ToolImages(props: { parts: readonly SessionMessageAssistantTool[] }) {
|
||||
const images = createMemo(() => props.parts.flatMap(inlineToolImages))
|
||||
return <SessionImages images={images()} />
|
||||
}
|
||||
|
||||
function SessionImages(props: { images: readonly { uri: string }[]; paddingLeft?: number }) {
|
||||
const ctx = use()
|
||||
const dialog = useDialog()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const images = createMemo(() => (ctx.config.session?.image_preview ? props.images : []))
|
||||
const height = createMemo(() => Math.max(4, Math.min(8, Math.floor(dimensions().height / 4))))
|
||||
const visible = createMemo(() => images().slice(0, 3))
|
||||
|
||||
return (
|
||||
<Show when={visible().length > 0}>
|
||||
<box
|
||||
flexDirection="row"
|
||||
flexShrink={0}
|
||||
paddingTop={1}
|
||||
paddingLeft={props.paddingLeft ?? 3}
|
||||
paddingRight={2}
|
||||
paddingBottom={1}
|
||||
gap={1}
|
||||
>
|
||||
<For each={visible()}>
|
||||
{(image, index) => {
|
||||
const [failed, setFailed] = createSignal(false)
|
||||
return (
|
||||
<box
|
||||
width={height() * 2}
|
||||
height={height()}
|
||||
flexBasis={height() * 2}
|
||||
flexShrink={1}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
onMouseUp={(event: MouseEvent) => {
|
||||
if (event.button !== 0) return
|
||||
event.stopPropagation()
|
||||
dialog.replace(() => <DialogImagePreview images={images()} initial={index()} />)
|
||||
}}
|
||||
>
|
||||
<Show when={!failed()} fallback={<text>No preview</text>}>
|
||||
<image
|
||||
source={image.uri}
|
||||
fit="cover"
|
||||
protocol="auto"
|
||||
width="100%"
|
||||
height="100%"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<Show when={images().length > visible().length}>
|
||||
<box width={8} height={height()} flexShrink={1} alignItems="center" justifyContent="center">
|
||||
<text wrapMode="none" truncate>
|
||||
+{images().length - visible().length} more
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function inlineToolImages(part: SessionMessageAssistantTool) {
|
||||
return toolDisplayContent(part.state).flatMap((content) =>
|
||||
content.type === "file" && content.mime.startsWith("image/") && content.uri.startsWith("data:image/")
|
||||
? [{ uri: content.uri }]
|
||||
: [],
|
||||
)
|
||||
}
|
||||
|
||||
type ToolProps = {
|
||||
@@ -3027,63 +2930,6 @@ function executeCalls(value: unknown): ExecuteCall[] {
|
||||
})
|
||||
}
|
||||
|
||||
export function executeCallSummary(call: ExecuteCall) {
|
||||
const args = primitiveInputSummary(call.input ?? {}).replace(/\s+/g, " ")
|
||||
return `↳ ${call.tool}${call.status === "error" ? " (failed)" : ""}${args ? ` ${args}` : ""}`
|
||||
}
|
||||
|
||||
function ExecuteCallView(props: { call: Accessor<ExecuteCall> }) {
|
||||
const theme = useTheme()
|
||||
const renderer = useRenderer()
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
const [hover, setHover] = createSignal(false)
|
||||
const input = createMemo(() => Object.entries(props.call().input ?? {}))
|
||||
const expandable = createMemo(() => input().length > 0)
|
||||
const title = createMemo(() => `↳ ${props.call().tool}${props.call().status === "error" ? " (failed)" : ""}`)
|
||||
|
||||
return (
|
||||
<box
|
||||
paddingLeft={3 + INLINE_TOOL_ICON_WIDTH}
|
||||
onMouseOver={() => expandable() && setHover(true)}
|
||||
onMouseOut={() => setHover(false)}
|
||||
onMouseUp={() => {
|
||||
if (!expandable() || renderer.getSelection()?.getSelectedText()) return
|
||||
setExpanded((value) => !value)
|
||||
}}
|
||||
>
|
||||
<text
|
||||
wrapMode="none"
|
||||
truncate
|
||||
fg={
|
||||
props.call().status === "error"
|
||||
? theme.text.feedback.error.default
|
||||
: hover()
|
||||
? theme.text.default
|
||||
: theme.text.subdued
|
||||
}
|
||||
>
|
||||
{expanded() ? title() : executeCallSummary(props.call())}
|
||||
</text>
|
||||
<Show when={expanded()}>
|
||||
<box paddingLeft={2}>
|
||||
<For each={input()}>
|
||||
{([key, value]) => (
|
||||
<box flexDirection="row">
|
||||
<text flexShrink={0} fg={theme.text.subdued}>
|
||||
{key}:{" "}
|
||||
</text>
|
||||
<text flexGrow={1} wrapMode="word" fg={theme.text.default}>
|
||||
{typeof value === "string" ? value : JSON.stringify(value, null, 2)}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
// The `execute` tool streams child tool calls through metadata, not a child session like Task.
|
||||
function Execute(props: ToolProps) {
|
||||
const ctx = use()
|
||||
@@ -3094,6 +2940,14 @@ function Execute(props: ToolProps) {
|
||||
const hasRuntimeError = createMemo(() => props.metadata.error === true || props.part.state.status === "error")
|
||||
const outputPreview = createMemo(() => collapseToolOutput(output(), 4, 4 * Math.max(20, ctx.width - 6)).output)
|
||||
const showOutput = createMemo(() => output() && hasRuntimeError())
|
||||
const content = createMemo(() => {
|
||||
const lines = ["execute"]
|
||||
for (const call of calls()) {
|
||||
const args = primitiveInputSummary(call.input ?? {})
|
||||
lines.push(`↳ ${call.tool}${args ? ` ${args}` : ""}${call.status === "error" ? " (failed)" : ""}`)
|
||||
}
|
||||
return lines.join("\n")
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -3105,9 +2959,8 @@ function Execute(props: ToolProps) {
|
||||
complete={true}
|
||||
part={props.part}
|
||||
>
|
||||
execute
|
||||
{content()}
|
||||
</InlineTool>
|
||||
<Index each={calls()}>{(call) => <ExecuteCallView call={call} />}</Index>
|
||||
<Show when={showOutput()}>
|
||||
<box paddingLeft={3}>
|
||||
<For each={outputPreview().split("\n")}>
|
||||
|
||||
@@ -10,7 +10,6 @@ import { ThemeProvider } from "../../../src/context/theme"
|
||||
import { DialogProvider, useDialog } from "../../../src/ui/dialog"
|
||||
import { ToastProvider } from "../../../src/ui/toast"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
import { emptyThemeSource } from "../../fixture/fixture"
|
||||
|
||||
test("searches settings globally and opens the matching setting", async () => {
|
||||
let current: Info = {}
|
||||
@@ -54,7 +53,7 @@ test("searches settings globally and opens the matching setting", async () => {
|
||||
<TestTuiContexts>
|
||||
<ConfigProvider config={resolve(current, { terminalSuspend: true })} service={service}>
|
||||
<Keymap.Provider>
|
||||
<ThemeProvider mode="dark" source={emptyThemeSource}>
|
||||
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
|
||||
<ToastProvider>
|
||||
<DialogProvider>
|
||||
<Fixture />
|
||||
|
||||
@@ -16,7 +16,6 @@ import { ThemeProvider } from "../../../src/context/theme"
|
||||
import { Composer } from "../../../src/routes/session/composer"
|
||||
import { createSessionRows, type SessionRow } from "../../../src/routes/session/rows"
|
||||
import { createApi, createEventStream, createFetch, directory, json, worktree } from "../../fixture/tui-client"
|
||||
import { emptyThemeSource } from "../../fixture/fixture"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
|
||||
@@ -1980,7 +1979,7 @@ test("keeps shell state scoped to location", async () => {
|
||||
return (
|
||||
<RouteProvider initialRoute={{ type: "session", sessionID: "ses_shared" }}>
|
||||
<Keymap.Provider>
|
||||
<ThemeProvider mode="dark" source={emptyThemeSource}>
|
||||
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
|
||||
<Composer sessionID="ses_shared" open={true} defaultTab="shell" />
|
||||
</ThemeProvider>
|
||||
</Keymap.Provider>
|
||||
|
||||
@@ -16,7 +16,6 @@ import { ThemeProvider } from "../../../src/context/theme"
|
||||
import { DialogProvider, useDialog } from "../../../src/ui/dialog"
|
||||
import { ToastProvider } from "../../../src/ui/toast"
|
||||
import { createApi, createEventStream, createFetch, json, type FetchHandler } from "../../fixture/tui-client"
|
||||
import { emptyThemeSource } from "../../fixture/fixture"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
import { tmpdir } from "../../fixture/fixture"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
@@ -227,7 +226,7 @@ async function renderOpen(
|
||||
<DataProvider>
|
||||
<LocationProvider>
|
||||
<SessionTabsProvider>
|
||||
<ThemeProvider mode="dark" source={emptyThemeSource}>
|
||||
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
|
||||
<DialogProvider>
|
||||
<Probe />
|
||||
</DialogProvider>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { expect, test } from "bun:test"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { onCleanup } from "solid-js"
|
||||
import { emptyThemeSource, tmpdir } from "../../fixture/fixture"
|
||||
import { tmpdir } from "../../fixture/fixture"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
import type { TuiKeybind } from "../../../src/config/keybind"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
@@ -58,7 +58,7 @@ async function mountPrompt(input: {
|
||||
>
|
||||
<ConfigProvider config={resolvedConfig}>
|
||||
<Keymap.Provider>
|
||||
<ThemeProvider mode="dark" source={emptyThemeSource}>
|
||||
<ThemeProvider mode="dark">
|
||||
<ToastProvider>
|
||||
<DialogProvider>
|
||||
<Prompt />
|
||||
|
||||
@@ -9,7 +9,7 @@ import { dialogWidth } from "../../../src/ui/dialog"
|
||||
import { dialogSelectContentWidth, type DialogSelectOption } from "../../../src/ui/dialog-select"
|
||||
import { truncateFilePath } from "../../../src/ui/file-path"
|
||||
import { stringWidth } from "../../../src/util/string-width"
|
||||
import { emptyThemeSource, tmpdir } from "../../fixture/fixture"
|
||||
import { tmpdir } from "../../fixture/fixture"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
|
||||
@@ -62,7 +62,7 @@ async function renderSelect(
|
||||
<TestTuiContexts directory={root} paths={{ home: root, state, worktree: root }}>
|
||||
<ConfigProvider config={config}>
|
||||
<Keymap.Provider>
|
||||
<ThemeProvider mode="dark" source={emptyThemeSource}>
|
||||
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
|
||||
<ToastProvider>
|
||||
<DialogProvider>
|
||||
<Select />
|
||||
@@ -138,7 +138,7 @@ async function mountSelect(
|
||||
<TestTuiContexts directory={root} paths={{ home: root, state, worktree: root }}>
|
||||
<ConfigProvider config={config}>
|
||||
<Keymap.Provider>
|
||||
<ThemeProvider mode="dark" source={emptyThemeSource}>
|
||||
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
|
||||
<ToastProvider>
|
||||
<DialogProvider>
|
||||
<Fixture />
|
||||
|
||||
@@ -4,7 +4,6 @@ import { testRender } from "@opentui/solid"
|
||||
import type { JSX } from "solid-js"
|
||||
import { onMount, type ParentProps } from "solid-js"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
import { emptyThemeSource } from "../../fixture/fixture"
|
||||
import { ThemeProvider, useThemes } from "../../../src/context/theme"
|
||||
import type { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { ConfigProvider } from "../../../src/config"
|
||||
@@ -150,7 +149,7 @@ function withTheme(component: () => JSX.Element, onReady = () => {}) {
|
||||
return (
|
||||
<TestTuiContexts>
|
||||
<ConfigProvider config={createTuiResolvedConfig()}>
|
||||
<ThemeProvider mode="dark" source={emptyThemeSource}>
|
||||
<ThemeProvider mode="dark">
|
||||
<Ready onReady={onReady}>{component()}</Ready>
|
||||
</ThemeProvider>
|
||||
</ConfigProvider>
|
||||
|
||||
@@ -12,7 +12,6 @@ import type {
|
||||
Slot,
|
||||
} from "@opencode-ai/plugin/tui/context"
|
||||
import { ThemeProvider, useThemes } from "../../../src/context/theme"
|
||||
import { emptyThemeSource } from "../../fixture/fixture"
|
||||
import { ConfigProvider } from "../../../src/config"
|
||||
import { TuiKeybind } from "../../../src/config/keybind"
|
||||
import { Keymap } from "../../../src/context/keymap"
|
||||
@@ -225,7 +224,7 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?:
|
||||
<ConfigProvider config={config}>
|
||||
<Keymap.Provider>
|
||||
<ToastProvider>
|
||||
<ThemeProvider mode="dark" source={emptyThemeSource}>
|
||||
<ThemeProvider mode="dark">
|
||||
<DialogProvider>
|
||||
<Content />
|
||||
</DialogProvider>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { ThemeProvider } from "../../../src/context/theme"
|
||||
import { Keymap } from "../../../src/context/keymap"
|
||||
import { ConfigProvider } from "../../../src/config"
|
||||
import { ToastProvider } from "../../../src/ui/toast"
|
||||
import { emptyThemeSource, tmpdir } from "../../fixture/fixture"
|
||||
import { tmpdir } from "../../fixture/fixture"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
import { createApi, createEventStream, createFetch } from "../../fixture/tui-client"
|
||||
@@ -72,7 +72,7 @@ async function mountForm(root: string, width = 80) {
|
||||
<ConfigProvider config={config}>
|
||||
<Keymap.Provider>
|
||||
<ClientProvider api={createApi(transport.fetch)}>
|
||||
<ThemeProvider mode="dark" source={emptyThemeSource}>
|
||||
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
|
||||
<ToastProvider>
|
||||
<FormPrompt form={form} />
|
||||
</ToastProvider>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { For } from "solid-js"
|
||||
import { testRender, type JSX } from "@opentui/solid"
|
||||
import {
|
||||
InlineToolRow,
|
||||
executeCallSummary,
|
||||
isBackgroundSubagent,
|
||||
parseApplyPatchFiles,
|
||||
parseDiagnostics,
|
||||
@@ -183,22 +182,6 @@ describe("TUI inline tool wrapping", () => {
|
||||
expect(parseQuestionAnswers({})).toBeUndefined()
|
||||
})
|
||||
|
||||
test("summarizes execute calls on one line", () => {
|
||||
expect(
|
||||
executeCallSummary({
|
||||
tool: "session.prompt",
|
||||
status: "completed",
|
||||
input: { sessionID: "ses_example", notify: true },
|
||||
}),
|
||||
).toBe("↳ session.prompt [sessionID=ses_example, notify=true]")
|
||||
expect(executeCallSummary({ tool: "session.get", status: "error", input: { nested: { hidden: true } } })).toBe(
|
||||
"↳ session.get (failed)",
|
||||
)
|
||||
expect(
|
||||
executeCallSummary({ tool: "session.prompt", status: "completed", input: { text: "first line\nsecond line" } }),
|
||||
).toBe("↳ session.prompt [text=first line second line]")
|
||||
})
|
||||
|
||||
test("ignores diagnostics with malformed nested ranges", () => {
|
||||
expect(
|
||||
parseDiagnostics(
|
||||
|
||||
@@ -24,7 +24,6 @@ test("validates the session tabs setting", () => {
|
||||
expect(() => decode({ tabs: { layout: true } })).toThrow()
|
||||
expect(() => decode({ tabs: { enabled: "on" } })).toThrow()
|
||||
expect(decode({ prompt: { image_preview: true } })).toEqual({ prompt: { image_preview: true } })
|
||||
expect(decode({ session: { image_preview: true } })).toEqual({ session: { image_preview: true } })
|
||||
})
|
||||
|
||||
test("resolves nested config and keybind defaults", () => {
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { mkdtemp, realpath, rm } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import os from "node:os"
|
||||
import type { ThemeSource } from "../../src/context/theme"
|
||||
|
||||
export const emptyThemeSource: ThemeSource = { discover: () => Promise.resolve({}) }
|
||||
|
||||
export async function tmpdir() {
|
||||
const directory = await realpath(await mkdtemp(path.join(os.tmpdir(), "opencode-tui-test-")))
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
deduplicatePromptImages,
|
||||
deduplicateVisibleImages,
|
||||
preserveMentionlessPromptAttachments,
|
||||
promptAttachmentLabel,
|
||||
} from "../../src/prompt/attachment"
|
||||
|
||||
describe("prompt attachments", () => {
|
||||
test("deduplicates identical inline images while preserving other attachments", () => {
|
||||
const files = [
|
||||
{
|
||||
uri: "data:image/png;base64,AAA",
|
||||
name: "first.png",
|
||||
mention: { start: 0, end: 9, text: "[Image 1]" },
|
||||
},
|
||||
{ uri: "file:///same", name: "first.txt" },
|
||||
{ uri: "data:application/pdf;base64,CCC", name: "first.pdf" },
|
||||
{
|
||||
uri: "data:image/png;base64,BBB",
|
||||
name: "second.png",
|
||||
mention: { start: 10, end: 19, text: "[Image 2]" },
|
||||
},
|
||||
{
|
||||
uri: "data:image/png;base64,AAA",
|
||||
name: "first.png",
|
||||
mention: { start: 20, end: 29, text: "[Image 1]" },
|
||||
},
|
||||
{
|
||||
uri: "data:image/png;base64,AAA",
|
||||
name: "first.png",
|
||||
description: "alternate use",
|
||||
mention: { start: 30, end: 39, text: "[Image 1]" },
|
||||
},
|
||||
{ uri: "file:///same", name: "second.txt" },
|
||||
{ uri: "data:application/pdf;base64,CCC", name: "first.pdf" },
|
||||
]
|
||||
|
||||
expect(deduplicatePromptImages(files)).toEqual([
|
||||
files[0],
|
||||
files[1],
|
||||
files[2],
|
||||
files[3],
|
||||
files[5],
|
||||
files[6],
|
||||
files[7],
|
||||
])
|
||||
expect(files).toHaveLength(8)
|
||||
})
|
||||
|
||||
test("reuses labels for identical image data", () => {
|
||||
const first = "data:image/png;base64,AAA"
|
||||
const second = "data:image/png;base64,BBB"
|
||||
const files = [{ uri: first, mention: { start: 0, end: 9, text: "[Image 1]" } }]
|
||||
|
||||
expect(promptAttachmentLabel(files, { uri: first })).toBe("[Image 1]")
|
||||
expect(promptAttachmentLabel([...files, { ...files[0], mention: undefined }], { uri: second })).toBe("[Image 2]")
|
||||
expect(promptAttachmentLabel([{ uri: first }], { uri: first })).toBe("[Image 1]")
|
||||
})
|
||||
|
||||
test("numbers PDFs independently from images", () => {
|
||||
const files = [{ uri: "data:image/png;base64,AAA" }]
|
||||
|
||||
expect(promptAttachmentLabel(files, { uri: "data:application/pdf;base64,BBB" })).toBe("[PDF 1]")
|
||||
})
|
||||
|
||||
test("does not reuse a label when attachment metadata differs", () => {
|
||||
const uri = "data:image/png;base64,AAA"
|
||||
const files = [{ uri, name: "one.png", mention: { start: 0, end: 9, text: "[Image 1]" } }]
|
||||
|
||||
expect(promptAttachmentLabel(files, { uri, name: "two.png" })).toBe("[Image 2]")
|
||||
})
|
||||
|
||||
test("does not reuse numbers after an earlier attachment is removed", () => {
|
||||
const files = [{ uri: "data:image/png;base64,BBB", mention: { start: 0, end: 9, text: "[Image 2]" } }]
|
||||
|
||||
expect(promptAttachmentLabel(files, { uri: "data:image/png;base64,CCC" })).toBe("[Image 3]")
|
||||
})
|
||||
|
||||
test("preserves mentionless attachments when tracked mentions are synchronized", () => {
|
||||
const mentionless = { uri: "data:image/png;base64,AAA" }
|
||||
const emptyMention = {
|
||||
uri: "data:image/png;base64,CCC",
|
||||
mention: { start: 0, end: 0, text: "" },
|
||||
}
|
||||
const mentioned = {
|
||||
uri: "data:image/png;base64,BBB",
|
||||
mention: { start: 0, end: 9, text: "[Image 1]" },
|
||||
}
|
||||
|
||||
const restored = preserveMentionlessPromptAttachments([mentionless, emptyMention, mentioned], [mentioned])
|
||||
expect(restored).toEqual([mentionless, emptyMention, mentioned])
|
||||
expect(restored.indexOf(mentioned)).toBe(2)
|
||||
|
||||
const another = {
|
||||
uri: "data:image/png;base64,DDD",
|
||||
mention: { start: 10, end: 19, text: "[Image 2]" },
|
||||
}
|
||||
expect(preserveMentionlessPromptAttachments([mentioned, mentionless, another], [another, mentioned])).toEqual([
|
||||
another,
|
||||
mentionless,
|
||||
mentioned,
|
||||
])
|
||||
})
|
||||
|
||||
test("deduplicates visible inline image cards without dropping durable references", () => {
|
||||
const file = {
|
||||
data: "AAA",
|
||||
mime: "image/png",
|
||||
source: { type: "inline" },
|
||||
name: "clipboard",
|
||||
mention: { text: "[Image 1]" },
|
||||
}
|
||||
const files = [file, { ...file, mention: { text: "[Image 1]" } }]
|
||||
|
||||
expect(deduplicateVisibleImages(files)).toEqual([file])
|
||||
expect(files).toHaveLength(2)
|
||||
|
||||
const distinct = [
|
||||
{ ...file, mention: { text: "[Image 2]" } },
|
||||
{ ...file, mention: undefined },
|
||||
]
|
||||
expect(deduplicateVisibleImages([file, ...distinct])).toEqual([file, ...distinct])
|
||||
})
|
||||
})
|
||||
@@ -41,21 +41,4 @@ describe("prompt history", () => {
|
||||
const b = entry("describe this", [{ name: "b.png", uri: "data:image/png;base64,BBB" }])
|
||||
expect(isDuplicateEntry(a, b)).toBe(false)
|
||||
})
|
||||
|
||||
test("preserves duplicate attachment mentions for prompt restoration", () => {
|
||||
const value = entry("[Image 1] [Image 1]", [
|
||||
{
|
||||
name: "clipboard",
|
||||
uri: "data:image/png;base64,AAA",
|
||||
mention: { start: 0, end: 9, text: "[Image 1]" },
|
||||
},
|
||||
{
|
||||
name: "clipboard",
|
||||
uri: "data:image/png;base64,AAA",
|
||||
mention: { start: 10, end: 19, text: "[Image 1]" },
|
||||
},
|
||||
])
|
||||
|
||||
expect(parsePromptHistory(JSON.stringify(value))).toEqual([value])
|
||||
})
|
||||
})
|
||||
|
||||
+20
-20
@@ -1,5 +1,5 @@
|
||||
import path from "path"
|
||||
import fs from "fs"
|
||||
import fs from "fs/promises"
|
||||
import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir"
|
||||
import os from "os"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
@@ -13,6 +13,8 @@ const config = path.join(xdgConfig!, app)
|
||||
const state = path.join(xdgState!, app)
|
||||
const tmp = path.join(os.tmpdir(), app)
|
||||
|
||||
await fs.mkdir(tmp, { recursive: true })
|
||||
|
||||
const paths = {
|
||||
get home() {
|
||||
return process.env.OPENCODE_TEST_HOME ?? os.homedir()
|
||||
@@ -24,13 +26,22 @@ const paths = {
|
||||
cache,
|
||||
config,
|
||||
state,
|
||||
tmp,
|
||||
tmp: await fs.realpath(tmp),
|
||||
}
|
||||
|
||||
export const Path = paths
|
||||
|
||||
Flock.setGlobal({ state })
|
||||
|
||||
await Promise.all([
|
||||
fs.mkdir(Path.data, { recursive: true }),
|
||||
fs.mkdir(Path.config, { recursive: true }),
|
||||
fs.mkdir(Path.state, { recursive: true }),
|
||||
fs.mkdir(Path.log, { recursive: true }),
|
||||
fs.mkdir(Path.bin, { recursive: true }),
|
||||
fs.mkdir(Path.repos, { recursive: true }),
|
||||
])
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Global") {}
|
||||
|
||||
export interface Interface {
|
||||
@@ -46,14 +57,13 @@ export interface Interface {
|
||||
}
|
||||
|
||||
export function make(input: Partial<Interface> = {}): Interface {
|
||||
// The acquired service canonicalizes default tmp; use it instead of Path.tmp for path comparisons.
|
||||
return {
|
||||
home: Path.home,
|
||||
data: Path.data,
|
||||
cache: Path.cache,
|
||||
config: Path.config,
|
||||
state: Path.state,
|
||||
tmp: input.tmp ?? Path.tmp,
|
||||
tmp: Path.tmp,
|
||||
bin: Path.bin,
|
||||
log: Path.log,
|
||||
repos: Path.repos,
|
||||
@@ -61,27 +71,17 @@ export function make(input: Partial<Interface> = {}): Interface {
|
||||
}
|
||||
}
|
||||
|
||||
const acquire = (input: Partial<Interface>) =>
|
||||
Effect.gen(function* () {
|
||||
const service = Service.of(make(input))
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all(
|
||||
[service.data, service.config, service.state, service.log, service.bin, service.repos, service.tmp].map(
|
||||
(directory) => fs.promises.mkdir(directory, { recursive: true }),
|
||||
),
|
||||
),
|
||||
)
|
||||
const canonicalTmp = yield* Effect.promise(() => fs.promises.realpath(service.tmp))
|
||||
return Service.of({ ...service, tmp: input.tmp ?? canonicalTmp })
|
||||
})
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.suspend(() => acquire({ config: process.env.OPENCODE_CONFIG_DIR ?? Path.config })),
|
||||
Effect.sync(() => Service.of(make({ config: process.env.OPENCODE_CONFIG_DIR ?? Path.config }))),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [] })
|
||||
|
||||
export const layerWith = (input: Partial<Interface>) => Layer.effect(Service, acquire(input))
|
||||
export const layerWith = (input: Partial<Interface>) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.sync(() => Service.of(make(input))),
|
||||
)
|
||||
|
||||
export * as Global from "./global.js"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Effect, FileSystem, Formatter, Logger, type LogLevel } from "effect"
|
||||
import { Formatter, Logger, type LogLevel } from "effect"
|
||||
import path from "path"
|
||||
import { Global } from "../global.js"
|
||||
import { runID } from "./shared.js"
|
||||
@@ -53,11 +53,7 @@ export function file(local = true, channel = "local") {
|
||||
|
||||
export function fileLogger(target = file(), id: string = runID) {
|
||||
// Do not set batchWindow to 0; it causes high idle CPU usage.
|
||||
return Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
yield* fs.makeDirectory(path.dirname(target), { recursive: true })
|
||||
return yield* Logger.toFile(formatter(id), target, { flag: "a" })
|
||||
})
|
||||
return Logger.toFile(formatter(id), target, { flag: "a" })
|
||||
}
|
||||
|
||||
const stderrLogger = Logger.make((options) => process.stderr.write(formatter().log(options) + "\n"))
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import fs from "fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Global } from "../src/global.js"
|
||||
|
||||
describe("global", () => {
|
||||
test("importing the module does not create directories", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-global-import-"))
|
||||
const directories = ["data", "cache", "config", "state", "tmp"].map((directory) => path.join(root, directory))
|
||||
const module = pathToFileURL(path.join(import.meta.dir, "../src/global.ts")).href
|
||||
const result = Bun.spawnSync({
|
||||
cmd: [process.execPath, "-e", `const { Global } = await import(${JSON.stringify(module)}); void Global.Path.tmp`],
|
||||
env: {
|
||||
...process.env,
|
||||
XDG_DATA_HOME: directories[0],
|
||||
XDG_CACHE_HOME: directories[1],
|
||||
XDG_CONFIG_HOME: directories[2],
|
||||
XDG_STATE_HOME: directories[3],
|
||||
TMPDIR: directories[4],
|
||||
},
|
||||
stderr: "pipe",
|
||||
})
|
||||
|
||||
expect(result.exitCode, result.stderr.toString()).toBe(0)
|
||||
directories.forEach((directory) => expect(fs.existsSync(path.join(directory, "opencode"))).toBe(false))
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test("building layerWith creates service directories and preserves an explicit tmp", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-global-layer-"))
|
||||
const directories = {
|
||||
data: path.join(root, "data"),
|
||||
config: path.join(root, "config"),
|
||||
state: path.join(root, "state"),
|
||||
log: path.join(root, "log"),
|
||||
bin: path.join(root, "bin"),
|
||||
repos: path.join(root, "repos"),
|
||||
tmp: path.join(root, "nested", "..", "tmp"),
|
||||
}
|
||||
|
||||
const context = await Effect.runPromise(Effect.scoped(Layer.build(Global.layerWith(directories))))
|
||||
|
||||
Object.values(directories).forEach((directory) => expect(fs.statSync(directory).isDirectory()).toBe(true))
|
||||
expect(Context.get(context, Global.Service).tmp).toBe(directories.tmp)
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test("building a layer with default tmp creates and canonicalizes it", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-global-layer-"))
|
||||
const directories = ["data", "cache", "config", "state", "tmp"].map((directory) => path.join(root, directory))
|
||||
const module = pathToFileURL(path.join(import.meta.dir, "../src/global.ts")).href
|
||||
const result = Bun.spawnSync({
|
||||
cmd: [
|
||||
process.execPath,
|
||||
"-e",
|
||||
`
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
const { Global } = await import(${JSON.stringify(module)})
|
||||
const context = await Effect.runPromise(Effect.scoped(Layer.build(Global.layerWith({}))))
|
||||
process.stdout.write(Context.get(context, Global.Service).tmp)
|
||||
`,
|
||||
],
|
||||
cwd: path.join(import.meta.dir, ".."),
|
||||
env: {
|
||||
...process.env,
|
||||
XDG_DATA_HOME: directories[0],
|
||||
XDG_CACHE_HOME: directories[1],
|
||||
XDG_CONFIG_HOME: directories[2],
|
||||
XDG_STATE_HOME: directories[3],
|
||||
TMPDIR: directories[4],
|
||||
},
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
|
||||
expect(result.exitCode, result.stderr.toString()).toBe(0)
|
||||
expect(result.stdout.toString()).toBe(fs.realpathSync(path.join(directories[4], "opencode")))
|
||||
const created = [
|
||||
path.join(directories[0], "opencode"),
|
||||
path.join(directories[1], "opencode", "bin"),
|
||||
path.join(directories[2], "opencode"),
|
||||
path.join(directories[3], "opencode"),
|
||||
path.join(directories[0], "opencode", "log"),
|
||||
path.join(directories[0], "opencode", "repos"),
|
||||
path.join(directories[4], "opencode"),
|
||||
]
|
||||
created.forEach((directory) => expect(fs.statSync(directory).isDirectory()).toBe(true))
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
})
|
||||
@@ -12733,9 +12733,6 @@
|
||||
},
|
||||
"agent": {
|
||||
"type": "string"
|
||||
},
|
||||
"previous": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["id", "time", "type", "agent"],
|
||||
|
||||
@@ -12733,9 +12733,6 @@
|
||||
},
|
||||
"agent": {
|
||||
"type": "string"
|
||||
},
|
||||
"previous": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["id", "time", "type", "agent"],
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user