Files
kilocode/packages/opencode/test/plugin/loader-shared.test.ts
T
+25 ef6b152ff8 OpenCode v1.16.2 (#12088)
* feat(worktree): add managed workspace cloning (#30117)

* test(tui): skip crashing keymap textarea renderer

* fix(core): allow skipping migration execution

* fix(opencode): remove automatic full session diffs (#30127)

* chore: generate

* refactor(worktree): move project out of repository

* zen: deepseek flash

* fix(tui): remount session view on session switch (#30129)

Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>

* go: minimax m3

* refactor(opencode): inline local provider helpers (#30169)

* refactor(opencode): simplify provider setup flow (#30173)

* fix(app): show project sessions before path sync resolves (#30167)

Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>

* fix(core): preserve session metadata migration identity (#30176)

* refactor(session): align namespace imports and inline trivial helpers (#30180)

* opencode(run): add queued prompt management (#30103)

Direct run mode previously made submitted follow-up prompts irrevocable while a response was still running. Let users edit or remove queued prompts before dispatch without interrupting the active turn.

* chore: generate

* fix(acp): honor session/cancel by aborting the running turn (#30145)

Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>

* fix(tui): prevent prompt corruption when pasting near wide characters (#29710)

Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>

* fix(opencode): avoid nullable webfetch format schema (#30215)

* chore: generate

* fix(core): contain lsp warmup defects (#30226)

* add run --replay mode (#30239)

* chore: generate

* chore: update nix node_modules hashes

* fix(stats): restore leaderboard spacing

* fix(stats): center top models dot grid

* fix(stats): stabilize top models hover

* fix(stats): align big-pickle provider resolution (#30274)

* feat(app): v2 desktop UI improvements (#29689)

Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>

* chore: generate

* fix(tui): clarify inline subagent rows (#30051)

* fix(tui): handle events across workspaces (#30281)

* feat(core): update Copilot for token-based billing (#30181)

* fix(tui): keep background marker with subagent label (#30271)

* fix(tui): keep retry attempt before message (#30275)

* chore: generate

* fix(opencode): enforce storage path invariants (#29666)

* chore: generate

* feat(core): add location-based permission service (#30287)

* chore: generate

* fix(tui): preserve live parts during session hydration (#30300)

* fix(app): restore deferred MCP status updates (#30220)

* fix: export v2 stylesheets and declare core node types (#30312)

* chore: update nix node_modules hashes

* fix(app): avoid suspending on pending child path (#30314)

* fix(opencode): remove sunsetted gpt-5.2 and gpt-5.3-codex from allowed models for codex subscriptions (#30316)

* chore: generate

* feat(core): expose session location

* chore: generate

* fix(opencode): preserve websocket api errors (#30321)

* refactor(core): simplify session pagination

* feat(core): add location filesystem contract

* feat(core): add dummy location filesystem layer

* chore: generate

* feat(opencode): add filesystem read and list routes

* chore: generate

* infra: stats

* sync

* feat(app): inset new layout session panels (#30342)

* fix(app): tab title truncation and close button positioning (#30349)

* tui: show model context in run footer (#30380)

* tui: revert OpenTUI upgrade to 0.2.16 (#30383)

* chore: update nix node_modules hashes

* feat(core): add managed repository cache (#30408)

* chore: generate

* chore: generate

* sync

* feat(stats): add cache ratio section

* feat(core): add flagged project references (#30414)

* chore: generate

* feat(core): support named migrations (#30418)

* fix(stats): clean retired provider rows during sync (#30420)

* fix(stats): mention opencode go in top models copy

* feat(core): expose project reference filesystem access (#30423)

* chore: generate

* sync

* fix(tui): scope diff viewer to session directory (#30426)

* test: widen provider header timeout margin (#30427)

* fix(plugin): restore private git install fallback (#30430)

* fix(stats): remove leaderboard nav link

* chore(opencode): remove scout agent (#30435)

* chore: generate

* feat(stats): improve cache ratio chart

* chore: generate

* fix(effect-drizzle-sqlite): preserve transaction begin errors (#30448)

* chore: bump effect beta to 74 (#30449)

* Revert "tui: revert OpenTUI upgrade to 0.2.16 (#30383)" (#30452)

* chore: update nix node_modules hashes

* refactor(opencode): improve startup time by 38% (#30453)

Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box>

* chore: generate

* fix(opencode): patch empty Gemini replay messages (#30463)

* chore: generate

* refactor(core): consolidate filesystem services (#30447)

* chore: generate

* run: enable interactive replay by default (#30465)

* chore: update nix node_modules hashes

* refactor(opencode): remove JSON storage migration (#30461)

* chore: generate

* chore: update nix node_modules hashes

* fix(tui): stop idle background task spinner (#30484)

* refactor(core): move v1 schemas into core (#30473)

* chore: generate

* fix: task id passed to background job for continuation (#30485)

* chore: generate

* feat(core): project copying and tracking directories (#30139)

* chore: generate

* fix(opencode): preserve signed thinking during anthropic reorder (#30182)

* Revert "fix(opencode): preserve signed thinking during anthropic reorder" (#30502)

* fix: rm tool reorder logic from old bug (#30483)

* chore: generate

* feat(app): polish home projects list UI (#30436)

* feat(app): polish select-v2 component (#30446)

Co-authored-by: Brendan Allan <git@brendonovich.dev>

* fix(github): enforce existing git author identity (#30507)

* feat(app): new update button  (#30460)

Co-authored-by: Brendan Allan <git@brendonovich.dev>

* fix(opencode): fallback to sh for curl upgrade (#30499)

Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>

* fix(ui): render whole-file patches as complete diffs (#30516)

* chore: generate

* feat(app): add servers tab to settings dialog (#29675)

* refactor(core): consolidate pty service (#30537)

* chore: generate

* tui: truncate sidebar file paths (#30531)

* chore: update nix node_modules hashes

* feat(stats): add geo breakdown (#30456)

* chore: generate

* chore: update nix node_modules hashes

* fix(acp): classify apply_patch as edit (#30564)

* fix(acp): classify task as think (#30565)

* fix(acp): include external directory permission context (#30567)

* fix(acp): clean read tool display content (#30569)

* fix(tui): route question responses by session directory (#30578)

* fix(stats): serve stats og image from banner

* docs(go): add Qwen3.7 Plus model (#30594)

* fix(openai): preserve websocket idle state (#30586)

* refactor(core): remove ai sdk option fields (#30581)

* chore: generate

* test(core): cover v1 provider option lowering (#30599)

* chore: generate

* refactor(core): nest model api id (#30603)

* fix(core): expose azure openai xhigh efforts (#30620)

* feat(core): add skill registry and file agent loading (#30617)

* chore: generate

* chore: update nix node_modules hashes

* fix(stats): count all go usage

* chore: remove zed extension and automation (#30628)

* fix(opencode): preserve variant for delegated tasks (#30630)

* zen: update nvidia tos

* fix(opencode): route SAP AI Core reasoning variants through modelParams (#30482)

* chore: generate

* fix(app): hide unavailable titlebar update (#30642)

* feat(app): v2 thinking level selector (#30646)

* fix(app,ui): session review reactivity and VCS query cache (#30660)

* feat(core): add embedded v2 session runtime and tool foundation (#30632)

* chore: generate

* chore: update nix node_modules hashes

* docs: correct compaction prune default (#30670)

* fix(opencode): avoid shell cancel race (#30641)

* feat: bump bedrock and add proper mantle support for openai models through aws bedrock (#30464)

* test: wait for shell truncation readiness (#30679)

* chore: update nix node_modules hashes

* refactor(opencode): clean up task tool prompts (#30687)

* feat(core): add command registry (#30624)

* chore: generate

* fix(acp): replay loaded session transcript (#30645)

Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>

* fix(core): reset pre-launch session projections (#30728)

* feat(tui): improve experimental session switcher (#30738)

* fix(opencode): respect disabled auto compaction on overflow (#30749)

* zen: nemotron 3 ultra

* fix(enterprise): install hono standard validator peer (#30740)

Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>

* fix build

* chore: update nix node_modules hashes

* make scripts executable

* fix(tui): show toast when variant_list keybind used with no variants (#30724)

* fix(opencode): `ACP.loadSession` should replay all messages (#30761)

Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>

* fix(opencode): attribute task child agent on creation (#30786)

* fix(tui): add Vue syntax highlighting (#30802)

* fix: bump @openrouter/ai-sdk-provider to 2.9.0 (#30800)

* feat(core): moving sessions (#30640)

* chore: generate

* tweak: background agent prompting to avoid polling issues (#30790)

* upgrade opentui to 0.3.2 (#30748)

* chore: update nix node_modules hashes

* feat(desktop): surface local server startup failures (#30822)

* ci: publish

* refactor(core): make v2 session inputs event sourced (#30785)

* chore: generate

* fix(llm): normalize OpenAI function tool schemas

* chore: generate

* feat(stats): refresh stats routes and homepage (#30419)

* fix(stats): sort metric charts by top usage

* feat(core): add public native API (#30828)

* chore: generate

* feat(app): color themes (#30824)

Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>

* chore: generate

* sync release versions for v1.16.0

* feat(core): attach global native tools (#30832)

* chore: generate

* feat(core): add Snowflake Cortex provider (#29901)

Co-authored-by: Cortex Code <noreply@snowflake.com>

* chore: generate

* feat(core): persist v2 session context epochs (#30789)

* chore: generate

* feat(tui): allow backgrounding synchronous subagents (#30488)

* fix(app): improve tab handling (#30669)

* chore: generate

* fix(tui): prioritize models slash autocomplete (#30848)

* fix(tui): route permission replies to session directory (#30851)

Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>

* fix(cli): harden daemon lifecycle (#30844)

* chore: generate

* feat(app): improve desktop multi-server support (#30678)

Co-authored-by: Brendan Allan <git@brendonovich.dev>

* chore: generate

* fix(app): handle tab overflow and scrolling in titlebar (#30886)

* fix(app): tab overflow (#30894)

* tui: guard path formatting inputs (#30469)

Fixes #27726, #25216, #24856, #24294, #17071, #29164, #24837, #16865, #14279, #29895

* opencode/run: refresh themes after terminal reloads (#30917)

* chore: generate

* fix(tui): fall back to local cwd when editor spawns in attach mode (#30583)

* docs: update Go Qwen tiered pricing (#30936)

* chore: generate

* feat(tui): add diff hunk navigation (#30935)

* chore: rm fuzzy search on references (#30931)

* fix: use mapError instead of orDie for context snapshot decoding (#30905)

Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>

* fix(core): recover corrupted models cache (#30947)

* chore: bun install (#30968)

* fix(opencode): resolve Bedrock hang by using node build conditions (#30873)

* fix(workflows): retry nix-hashes compute-hash on transient failure (#30743)

* fix(stats): scroll model charts to latest on mobile

* fix(opencode): prevent destructive edit matches (#30932)

* chore: generate

* fix(core): respect v2 default agents (#30969)

* chore: generate

* test(opencode): remove disposal event wait race (#30971)

* test(opencode): remove shell timeout output race (#30974)

* fix(opencode): gate reasoning summaries by provider (#30973)

* feat(core): admit v2 skill guidance (#30843)

* fix(workflows): serialize desktop release uploads (#30978)

* fix(stats): add mobile chart end spacing

* release: v1.16.2

* refactor: kilo compat for v1.16.2

* fix(opencode): address v1.16.2 merge regressions

* chore: update kilo-vscode visual regression baselines

* fix(opencode): restore Kilo behavior after v1.16.2 merge

* fix(opencode): retry Windows migration cleanup

* test(opencode): restore clone and macOS watcher coverage

* fix(opencode): address second-pass review for #12099

Preserve imported usage and retry partial JSON migrations. Refresh active dependency patches, remove the obsolete GCP patch, and regenerate Kilo HttpApi branding.

---------

Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Michael Hart <mhart@cloudflare.com>
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
Co-authored-by: smagnuso <smagnuso@gmail.com>
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
Co-authored-by: Orca丶 <93272799+dauphinYan@users.noreply.github.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: James Long <longster@gmail.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box>
Co-authored-by: Ulises Jeremias <ulisescf.24@gmail.com>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: Jérôme Benoit <jerome.benoit@sap.com>
Co-authored-by: Ariane Emory <97994360+ariane-emory@users.noreply.github.com>
Co-authored-by: LIU Xinyu <contact@lxy.cc>
Co-authored-by: Colin McDonnell <colinmcd94@gmail.com>
Co-authored-by: Sebastian <hasta84@gmail.com>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Kamesh Sampath <kamesh.sampath@hotmail.com>
Co-authored-by: Cortex Code <noreply@snowflake.com>
Co-authored-by: pcadena-lila <pcadena@lila.ai>
Co-authored-by: weiconghe <46336277+weiconghe@users.noreply.github.com>
Co-authored-by: alberto <914199+alblez@users.noreply.github.com>
Co-authored-by: kilo-maintainer[bot] <kilo-maintainer[bot]@users.noreply.github.com>
2026-07-13 18:00:35 +02:00

1304 lines
38 KiB
TypeScript

import { afterEach, describe, expect, spyOn } from "bun:test"
import { Effect, Layer } from "effect"
import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { disposeAllInstances, provideInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const { Plugin } = await import("../../src/plugin/index")
const { PluginLoader } = await import("../../src/plugin/loader")
const { readPackageThemes } = await import("../../src/plugin/shared")
const { EventV2Bridge } = await import("../../src/event-v2-bridge")
const { Npm } = await import("@opencode-ai/core/npm")
const { TestConfig } = await import("../fixture/config")
const { RuntimeFlags } = await import("../../src/effect/runtime-flags")
afterEach(async () => {
await disposeAllInstances()
})
const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, FSUtil.defaultLayer, testInstanceStoreLayer))
function withTmp<T, A, E, R>(
init: (dir: string) => Promise<T>,
body: (tmp: { path: string; extra: T }) => Effect.Effect<A, E, R>,
) {
return Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const extra = yield* Effect.promise(() => init(dir))
return yield* body({ path: dir, extra })
})
}
function load(dir: string, flags?: Parameters<typeof RuntimeFlags.layer>[0]) {
const source = path.join(dir, "opencode.json")
return Effect.gen(function* () {
const config = yield* Effect.promise(
() => Bun.file(source).json() as Promise<{ plugin?: Array<string | [string, Record<string, unknown>]> }>,
)
const plugins = config.plugin ?? []
return yield* Effect.gen(function* () {
const plugin = yield* Plugin.Service
yield* plugin.list()
}).pipe(
Effect.provide(
Plugin.layer.pipe(
Layer.provide(EventV2Bridge.defaultLayer),
Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true, ...flags })),
Layer.provide(
TestConfig.layer({
get: () =>
Effect.succeed({
plugin: plugins,
plugin_origins: plugins.map((plugin) => ({ spec: plugin, source, scope: "local" as const })),
}),
directories: () => Effect.succeed([dir]),
}),
),
),
),
provideInstance(dir),
)
})
}
describe("plugin.loader.shared", () => {
it.live("loads a file:// plugin function export", () =>
withTmp(
async (dir) => {
const file = path.join(dir, "plugin.ts")
const mark = path.join(dir, "called.txt")
await Bun.write(
file,
[
"export default async () => {",
` await Bun.write(${JSON.stringify(mark)}, "called")`,
" return {}",
"}",
"",
].join("\n"),
)
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
)
return { mark }
},
(tmp) =>
Effect.gen(function* () {
yield* load(tmp.path)
expect(yield* Effect.promise(() => fs.readFile(tmp.extra.mark, "utf8"))).toBe("called")
}),
),
)
it.live("deduplicates same function exported as default and named", () =>
withTmp(
async (dir) => {
const file = path.join(dir, "plugin.ts")
const mark = path.join(dir, "count.txt")
await Bun.write(mark, "")
await Bun.write(
file,
[
"const run = async () => {",
` const text = await Bun.file(${JSON.stringify(mark)}).text().catch(() => "")`,
` await Bun.write(${JSON.stringify(mark)}, text + "1")`,
" return {}",
"}",
"export default run",
"export const named = run",
"",
].join("\n"),
)
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
)
return { mark }
},
(tmp) =>
Effect.gen(function* () {
yield* load(tmp.path)
expect(yield* Effect.promise(() => fs.readFile(tmp.extra.mark, "utf8"))).toBe("1")
}),
),
)
it.live("uses only default v1 server plugin when present", () =>
withTmp(
async (dir) => {
const file = path.join(dir, "plugin.ts")
const mark = path.join(dir, "count.txt")
await Bun.write(
file,
[
"export default {",
' id: "demo.v1-default",',
" server: async () => {",
` await Bun.write(${JSON.stringify(mark)}, "default")`,
" return {}",
" },",
"}",
"export const named = async () => {",
` await Bun.write(${JSON.stringify(mark)}, "named")`,
" return {}",
"}",
"",
].join("\n"),
)
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
)
return { mark }
},
(tmp) =>
Effect.gen(function* () {
yield* load(tmp.path)
expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("default")
}),
),
)
it.live("rejects v1 file server plugin without id", () =>
withTmp(
async (dir) => {
const file = path.join(dir, "plugin.ts")
const mark = path.join(dir, "called.txt")
await Bun.write(
file,
[
"export default {",
" server: async () => {",
` await Bun.write(${JSON.stringify(mark)}, "called")`,
" return {}",
" },",
"}",
"",
].join("\n"),
)
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
)
return { mark }
},
(tmp) =>
Effect.gen(function* () {
yield* load(tmp.path)
const called = yield* Effect.promise(() =>
Bun.file(tmp.extra.mark)
.text()
.then(() => true)
.catch(() => false),
)
expect(called).toBe(false)
}),
),
)
it.live("rejects v1 plugin that exports server and tui together", () =>
withTmp(
async (dir) => {
const file = path.join(dir, "plugin.ts")
const mark = path.join(dir, "called.txt")
await Bun.write(
file,
[
"export default {",
' id: "demo.mixed",',
" server: async () => {",
` await Bun.write(${JSON.stringify(mark)}, "server")`,
" return {}",
" },",
" tui: async () => {},",
"}",
"",
].join("\n"),
)
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
)
return { mark }
},
(tmp) =>
Effect.gen(function* () {
yield* load(tmp.path)
const called = yield* Effect.promise(() =>
Bun.file(tmp.extra.mark)
.text()
.then(() => true)
.catch(() => false),
)
expect(called).toBe(false)
}),
),
)
it.live("resolves npm plugin specs with explicit and default versions", () =>
withTmp(
async (dir) => {
const acme = path.join(dir, "node_modules", "acme-plugin")
const scope = path.join(dir, "node_modules", "scope-plugin")
await fs.mkdir(acme, { recursive: true })
await fs.mkdir(scope, { recursive: true })
await Bun.write(
path.join(acme, "package.json"),
JSON.stringify({ name: "acme-plugin", type: "module", main: "./index.js" }, null, 2),
)
await Bun.write(path.join(acme, "index.js"), "export default { server: async () => ({}) }\n")
await Bun.write(
path.join(scope, "package.json"),
JSON.stringify({ name: "scope-plugin", type: "module", main: "./index.js" }, null, 2),
)
await Bun.write(path.join(scope, "index.js"), "export default { server: async () => ({}) }\n")
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({ plugin: ["acme-plugin", "scope-plugin@2.3.4"] }, null, 2),
)
return { acme, scope }
},
(tmp) =>
Effect.gen(function* () {
const add = spyOn(Npm, "add").mockImplementation(async (pkg) => {
if (pkg === "acme-plugin") return { directory: tmp.extra.acme, entrypoint: undefined }
return { directory: tmp.extra.scope, entrypoint: undefined }
})
try {
yield* load(tmp.path)
expect(add.mock.calls).toContainEqual(["acme-plugin@latest"])
expect(add.mock.calls).toContainEqual(["scope-plugin@2.3.4"])
} finally {
add.mockRestore()
}
}),
),
)
it.live("loads npm server plugin from package ./server export", () =>
withTmp(
async (dir) => {
const mod = path.join(dir, "mods", "acme-plugin")
const mark = path.join(dir, "server-called.txt")
await fs.mkdir(mod, { recursive: true })
await Bun.write(
path.join(mod, "package.json"),
JSON.stringify(
{
name: "acme-plugin",
type: "module",
exports: {
".": "./index.js",
"./server": "./server.js",
"./tui": "./tui.js",
},
},
null,
2,
),
)
await Bun.write(path.join(mod, "index.js"), 'import "./main-throws.js"\nexport default {}\n')
await Bun.write(path.join(mod, "main-throws.js"), 'throw new Error("main loaded")\n')
await Bun.write(
path.join(mod, "server.js"),
[
"export default {",
" server: async () => {",
` await Bun.write(${JSON.stringify(mark)}, "called")`,
" return {}",
" },",
"}",
"",
].join("\n"),
)
await Bun.write(path.join(mod, "tui.js"), "export default {}\n")
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["acme-plugin@1.0.0"] }, null, 2))
return {
mod,
mark,
}
},
(tmp) =>
Effect.gen(function* () {
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
try {
yield* load(tmp.path)
expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("called")
} finally {
install.mockRestore()
}
}),
),
)
it.live("loads npm server plugin from package server export without leading dot", () =>
withTmp(
async (dir) => {
const mod = path.join(dir, "mods", "acme-plugin")
const dist = path.join(mod, "dist")
const mark = path.join(dir, "server-called.txt")
await fs.mkdir(dist, { recursive: true })
await Bun.write(
path.join(mod, "package.json"),
JSON.stringify(
{
name: "acme-plugin",
type: "module",
exports: {
".": "./index.js",
"./server": "dist/server.js",
},
},
null,
2,
),
)
await Bun.write(path.join(mod, "index.js"), 'import "./main-throws.js"\nexport default {}\n')
await Bun.write(path.join(mod, "main-throws.js"), 'throw new Error("main loaded")\n')
await Bun.write(
path.join(dist, "server.js"),
[
"export default {",
" server: async () => {",
` await Bun.write(${JSON.stringify(mark)}, "called")`,
" return {}",
" },",
"}",
"",
].join("\n"),
)
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["acme-plugin@1.0.0"] }, null, 2))
return {
mod,
mark,
}
},
(tmp) =>
Effect.gen(function* () {
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
try {
yield* load(tmp.path)
expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("called")
} finally {
install.mockRestore()
}
}),
),
)
it.live("loads npm server plugin from package main without leading dot", () =>
withTmp(
async (dir) => {
const mod = path.join(dir, "mods", "acme-plugin")
const dist = path.join(mod, "dist")
const mark = path.join(dir, "main-called.txt")
await fs.mkdir(dist, { recursive: true })
await Bun.write(
path.join(mod, "package.json"),
JSON.stringify(
{
name: "acme-plugin",
type: "module",
main: "dist/index.js",
},
null,
2,
),
)
await Bun.write(
path.join(dist, "index.js"),
[
"export default {",
" server: async () => {",
` await Bun.write(${JSON.stringify(mark)}, "called")`,
" return {}",
" },",
"}",
"",
].join("\n"),
)
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["acme-plugin@1.0.0"] }, null, 2))
return {
mod,
mark,
}
},
(tmp) =>
Effect.gen(function* () {
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
try {
yield* load(tmp.path)
expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("called")
} finally {
install.mockRestore()
}
}),
),
)
it.live("does not use npm package exports dot for server entry", () =>
withTmp(
async (dir) => {
const mod = path.join(dir, "mods", "acme-plugin")
const mark = path.join(dir, "dot-server.txt")
await fs.mkdir(mod, { recursive: true })
await Bun.write(
path.join(mod, "package.json"),
JSON.stringify({
name: "acme-plugin",
type: "module",
exports: { ".": "./index.js" },
}),
)
await Bun.write(
path.join(mod, "index.js"),
[
"export default {",
' id: "demo.dot.server",',
" server: async () => {",
` await Bun.write(${JSON.stringify(mark)}, "called")`,
" return {}",
" },",
"}",
"",
].join("\n"),
)
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["acme-plugin@1.0.0"] }, null, 2))
return { mod, mark }
},
(tmp) =>
Effect.gen(function* () {
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
try {
yield* load(tmp.path)
const called = yield* Effect.promise(() =>
Bun.file(tmp.extra.mark)
.text()
.then(() => true)
.catch(() => false),
)
expect(called).toBe(false)
} finally {
install.mockRestore()
}
}),
),
)
it.live("rejects npm server export that resolves outside plugin directory", () =>
withTmp(
async (dir) => {
const mod = path.join(dir, "mods", "acme-plugin")
const outside = path.join(dir, "outside")
const mark = path.join(dir, "outside-server.txt")
await fs.mkdir(mod, { recursive: true })
await fs.mkdir(outside, { recursive: true })
await Bun.write(
path.join(mod, "package.json"),
JSON.stringify(
{
name: "acme-plugin",
type: "module",
exports: {
".": "./index.js",
"./server": "./escape/server.js",
},
},
null,
2,
),
)
await Bun.write(path.join(mod, "index.js"), "export default {}\n")
await Bun.write(
path.join(outside, "server.js"),
[
"export default {",
" server: async () => {",
` await Bun.write(${JSON.stringify(mark)}, "outside")`,
" return {}",
" },",
"}",
"",
].join("\n"),
)
await fs.symlink(outside, path.join(mod, "escape"), process.platform === "win32" ? "junction" : "dir")
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["acme-plugin"] }, null, 2))
return {
mod,
mark,
}
},
(tmp) =>
Effect.gen(function* () {
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
try {
yield* load(tmp.path)
const called = yield* Effect.promise(() =>
Bun.file(tmp.extra.mark)
.text()
.then(() => true)
.catch(() => false),
)
expect(called).toBe(false)
} finally {
install.mockRestore()
}
}),
),
)
it.live("skips legacy codex and copilot auth plugin specs", () =>
withTmp(
async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify(
{
plugin: ["opencode-openai-codex-auth@1.0.0", "opencode-copilot-auth@1.0.0", "regular-plugin@1.0.0"],
},
null,
2,
),
)
},
(_tmp) =>
Effect.gen(function* () {
const install = spyOn(Npm, "add").mockResolvedValue({ directory: "", entrypoint: undefined })
try {
yield* load(_tmp.path)
const pkgs = install.mock.calls.map((call) => call[0])
expect(pkgs).toContain("regular-plugin@1.0.0")
expect(pkgs).not.toContain("opencode-openai-codex-auth@1.0.0")
expect(pkgs).not.toContain("opencode-copilot-auth@1.0.0")
} finally {
install.mockRestore()
}
}),
),
)
it.live("skips broken plugin when install fails", () =>
withTmp(
async (dir) => {
const ok = path.join(dir, "ok.ts")
const mark = path.join(dir, "ok.txt")
await Bun.write(
ok,
[
"export default {",
' id: "demo.ok",',
" server: async () => {",
` await Bun.write(${JSON.stringify(mark)}, "ok")`,
" return {}",
" },",
"}",
"",
].join("\n"),
)
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({ plugin: ["broken-plugin@9.9.9", pathToFileURL(ok).href] }, null, 2),
)
return { mark }
},
(tmp) =>
Effect.gen(function* () {
const install = spyOn(Npm, "add").mockRejectedValue(new Error("boom"))
try {
yield* load(tmp.path)
expect(install).toHaveBeenCalledWith("broken-plugin@9.9.9")
expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("ok")
} finally {
install.mockRestore()
}
}),
),
)
it.live("continues loading plugins when plugin init throws", () =>
withTmp(
async (dir) => {
const file = pathToFileURL(path.join(dir, "throws.ts")).href
const ok = pathToFileURL(path.join(dir, "ok.ts")).href
const mark = path.join(dir, "ok.txt")
await Bun.write(
path.join(dir, "throws.ts"),
[
"export default {",
' id: "demo.throws",',
" server: async () => {",
' throw new Error("explode")',
" },",
"}",
"",
].join("\n"),
)
await Bun.write(
path.join(dir, "ok.ts"),
[
"export default {",
' id: "demo.ok",',
" server: async () => {",
` await Bun.write(${JSON.stringify(mark)}, "ok")`,
" return {}",
" },",
"}",
"",
].join("\n"),
)
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file, ok] }, null, 2))
return { mark }
},
(tmp) =>
Effect.gen(function* () {
yield* load(tmp.path)
expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("ok")
}),
),
)
it.live("continues loading plugins when plugin module has invalid export", () =>
withTmp(
async (dir) => {
const file = pathToFileURL(path.join(dir, "invalid.ts")).href
const ok = pathToFileURL(path.join(dir, "ok.ts")).href
const mark = path.join(dir, "ok.txt")
await Bun.write(
path.join(dir, "invalid.ts"),
["export default {", ' id: "demo.invalid",', " nope: true,", "}", ""].join("\n"),
)
await Bun.write(
path.join(dir, "ok.ts"),
[
"export default {",
' id: "demo.ok",',
" server: async () => {",
` await Bun.write(${JSON.stringify(mark)}, "ok")`,
" return {}",
" },",
"}",
"",
].join("\n"),
)
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file, ok] }, null, 2))
return { mark }
},
(tmp) =>
Effect.gen(function* () {
yield* load(tmp.path)
expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("ok")
}),
),
)
it.live("continues loading plugins when plugin import fails", () =>
withTmp(
async (dir) => {
const missing = pathToFileURL(path.join(dir, "missing-plugin.ts")).href
const ok = pathToFileURL(path.join(dir, "ok.ts")).href
const mark = path.join(dir, "ok.txt")
await Bun.write(
path.join(dir, "ok.ts"),
[
"export default {",
' id: "demo.ok",',
" server: async () => {",
` await Bun.write(${JSON.stringify(mark)}, "ok")`,
" return {}",
" },",
"}",
"",
].join("\n"),
)
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [missing, ok] }, null, 2))
return { mark }
},
(tmp) =>
Effect.gen(function* () {
yield* load(tmp.path)
expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("ok")
}),
),
)
it.live("loads object plugin via plugin.server", () =>
withTmp(
async (dir) => {
const file = path.join(dir, "object-plugin.ts")
const mark = path.join(dir, "object-called.txt")
await Bun.write(
file,
[
"const plugin = {",
' id: "demo.object",',
" server: async () => {",
` await Bun.write(${JSON.stringify(mark)}, "called")`,
" return {}",
" },",
"}",
"export default plugin",
"",
].join("\n"),
)
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
)
return { mark }
},
(tmp) =>
Effect.gen(function* () {
yield* load(tmp.path)
expect(yield* Effect.promise(() => fs.readFile(tmp.extra.mark, "utf8"))).toBe("called")
}),
),
)
it.live("passes tuple plugin options into server plugin", () =>
withTmp(
async (dir) => {
const file = path.join(dir, "options-plugin.ts")
const mark = path.join(dir, "options.json")
await Bun.write(
file,
[
"const plugin = {",
' id: "demo.options",',
" server: async (_input, options) => {",
` await Bun.write(${JSON.stringify(mark)}, JSON.stringify(options ?? null))`,
" return {}",
" },",
"}",
"export default plugin",
"",
].join("\n"),
)
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({ plugin: [[pathToFileURL(file).href, { source: "tuple", enabled: true }]] }, null, 2),
)
return { mark }
},
(tmp) =>
Effect.gen(function* () {
yield* load(tmp.path)
expect(
(yield* (yield* FSUtil.Service).readJson(tmp.extra.mark)) as { source: string; enabled: boolean },
).toEqual({
source: "tuple",
enabled: true,
})
}),
),
)
it.live("initializes server plugins in config order", () =>
withTmp(
async (dir) => {
const a = path.join(dir, "a-plugin.ts")
const b = path.join(dir, "b-plugin.ts")
const marker = path.join(dir, "server-order.txt")
const aSpec = pathToFileURL(a).href
const bSpec = pathToFileURL(b).href
await Bun.write(
a,
`import fs from "fs/promises"
export default {
id: "demo.order.a",
server: async () => {
await fs.appendFile(${JSON.stringify(marker)}, "a-start\\n")
await Bun.sleep(25)
await fs.appendFile(${JSON.stringify(marker)}, "a-end\\n")
return {}
},
}
`,
)
await Bun.write(
b,
`import fs from "fs/promises"
export default {
id: "demo.order.b",
server: async () => {
await fs.appendFile(${JSON.stringify(marker)}, "b\\n")
return {}
},
}
`,
)
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [aSpec, bSpec] }, null, 2))
return { marker }
},
(tmp) =>
Effect.gen(function* () {
yield* load(tmp.path)
const lines = (yield* Effect.promise(() => fs.readFile(tmp.extra.marker, "utf8"))).trim().split("\n")
expect(lines).toEqual(["a-start", "a-end", "b"])
}),
),
)
it.live("skips external plugins in pure mode", () =>
withTmp(
async (dir) => {
const file = path.join(dir, "plugin.ts")
const mark = path.join(dir, "called.txt")
await Bun.write(
file,
[
"export default {",
' id: "demo.pure",',
" server: async () => {",
` await Bun.write(${JSON.stringify(mark)}, "called")`,
" return {}",
" },",
"}",
"",
].join("\n"),
)
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
)
return { mark }
},
(tmp) =>
Effect.gen(function* () {
yield* load(tmp.path, { pure: true })
const called = yield* Effect.promise(() =>
fs
.readFile(tmp.extra.mark, "utf8")
.then(() => true)
.catch(() => false),
)
expect(called).toBe(false)
}),
),
)
it.live("reads oc-themes from package manifest", () =>
withTmp(
async (dir) => {
const mod = path.join(dir, "mod")
await fs.mkdir(path.join(mod, "themes"), { recursive: true })
await Bun.write(
path.join(mod, "package.json"),
JSON.stringify(
{
name: "acme-plugin",
version: "1.0.0",
"oc-themes": ["themes/one.json", "./themes/one.json", "themes/two.json"],
},
null,
2,
),
)
return { mod }
},
(tmp) =>
Effect.gen(function* () {
const file = path.join(tmp.extra.mod, "package.json")
const fsys = yield* FSUtil.Service
const json = (yield* fsys.readJson(file)) as Record<string, unknown>
const list = readPackageThemes("acme-plugin", {
dir: tmp.extra.mod,
pkg: file,
json,
})
expect(list).toEqual([
FSUtil.resolve(path.join(tmp.extra.mod, "themes", "one.json")),
FSUtil.resolve(path.join(tmp.extra.mod, "themes", "two.json")),
])
}),
),
)
it.live("handles no-entrypoint tui packages via missing callback", () =>
withTmp(
async (dir) => {
const mod = path.join(dir, "mods", "acme-plugin")
await fs.mkdir(path.join(mod, "themes"), { recursive: true })
await Bun.write(
path.join(mod, "package.json"),
JSON.stringify(
{
name: "acme-plugin",
version: "1.0.0",
"oc-themes": ["themes/night.json"],
},
null,
2,
),
)
await Bun.write(path.join(mod, "themes", "night.json"), "{}\n")
return { mod }
},
(tmp) =>
Effect.gen(function* () {
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
const missing: string[] = []
try {
const loaded = yield* Effect.promise(() =>
PluginLoader.loadExternal({
items: [
{
spec: "acme-plugin@1.0.0",
scope: "local" as const,
source: tmp.path,
},
],
kind: "tui",
missing: async (item) => {
if (!item.pkg) return
const themes = readPackageThemes(item.spec, item.pkg)
if (!themes.length) return
return {
spec: item.spec,
target: item.target,
themes,
}
},
report: {
missing(_candidate, _retry, message) {
missing.push(message)
},
},
}),
)
expect(loaded).toEqual([
{
spec: "acme-plugin@1.0.0",
target: tmp.extra.mod,
themes: [FSUtil.resolve(path.join(tmp.extra.mod, "themes", "night.json"))],
},
])
expect(missing).toHaveLength(0)
} finally {
install.mockRestore()
}
}),
),
)
it.live("passes package metadata for entrypoint tui plugins", () =>
withTmp(
async (dir) => {
const mod = path.join(dir, "mods", "acme-plugin")
await fs.mkdir(path.join(mod, "themes"), { recursive: true })
await Bun.write(
path.join(mod, "package.json"),
JSON.stringify(
{
name: "acme-plugin",
version: "1.0.0",
exports: {
"./tui": "./tui.js",
},
"oc-themes": ["themes/night.json"],
},
null,
2,
),
)
await Bun.write(path.join(mod, "tui.js"), 'export default { id: "demo", tui: async () => {} }\n')
await Bun.write(path.join(mod, "themes", "night.json"), "{}\n")
return { mod }
},
(tmp) =>
Effect.gen(function* () {
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
try {
const loaded = yield* Effect.promise(() =>
PluginLoader.loadExternal({
items: [
{
spec: "acme-plugin@1.0.0",
scope: "local" as const,
source: tmp.path,
},
],
kind: "tui",
finish: async (item) => {
if (!item.pkg) return
return {
spec: item.spec,
themes: readPackageThemes(item.spec, item.pkg),
}
},
}),
)
expect(loaded).toEqual([
{
spec: "acme-plugin@1.0.0",
themes: [FSUtil.resolve(path.join(tmp.extra.mod, "themes", "night.json"))],
},
])
} finally {
install.mockRestore()
}
}),
),
)
it.live("rejects oc-themes path traversal", () =>
withTmp(
async (dir) => {
const mod = path.join(dir, "mod")
await fs.mkdir(mod, { recursive: true })
const file = path.join(mod, "package.json")
await Bun.write(file, JSON.stringify({ name: "acme", "oc-themes": ["../escape.json"] }, null, 2))
return { mod, file }
},
(tmp) =>
Effect.gen(function* () {
const fsys = yield* FSUtil.Service
const json = (yield* fsys.readJson(tmp.extra.file)) as Record<string, unknown>
expect(() =>
readPackageThemes("acme", {
dir: tmp.extra.mod,
pkg: tmp.extra.file,
json,
}),
).toThrow("outside plugin directory")
}),
),
)
it.live("retries failed file plugins once after wait and keeps order", () =>
withTmp(
async (dir) => {
const a = path.join(dir, "a")
const b = path.join(dir, "b")
const aSpec = pathToFileURL(a).href
const bSpec = pathToFileURL(b).href
await fs.mkdir(a, { recursive: true })
await fs.mkdir(b, { recursive: true })
return { a, b, aSpec, bSpec }
},
(tmp) =>
Effect.gen(function* () {
let wait = 0
const calls: Array<[string, boolean]> = []
const loaded = yield* Effect.promise(() =>
PluginLoader.loadExternal({
items: [tmp.extra.aSpec, tmp.extra.bSpec].map((spec) => ({
spec,
scope: "local" as const,
source: tmp.path,
})),
kind: "tui",
wait: async () => {
wait += 1
await Bun.write(path.join(tmp.extra.a, "index.ts"), "export default {}\n")
await Bun.write(path.join(tmp.extra.b, "index.ts"), "export default {}\n")
},
report: {
start(candidate, retry) {
calls.push([candidate.plan.spec, retry])
},
},
}),
)
expect(wait).toBe(1)
expect(calls).toEqual([
[tmp.extra.aSpec, false],
[tmp.extra.bSpec, false],
[tmp.extra.aSpec, true],
[tmp.extra.bSpec, true],
])
expect(loaded.map((item) => item.spec)).toEqual([tmp.extra.aSpec, tmp.extra.bSpec])
}),
),
)
it.live("does not retry permanent file plugin entry errors", () =>
withTmp(
async (dir) => {
const mod = path.join(dir, "bad-entry")
const spec = pathToFileURL(mod).href
await fs.mkdir(mod, { recursive: true })
await Bun.write(
path.join(mod, "package.json"),
JSON.stringify({ exports: { "./tui": "../outside.js" } }, null, 2),
)
return { spec }
},
(tmp) =>
Effect.gen(function* () {
let wait = 0
const errors: Array<[string, boolean]> = []
const loaded = yield* Effect.promise(() =>
PluginLoader.loadExternal({
items: [
{
spec: tmp.extra.spec,
scope: "local" as const,
source: tmp.path,
},
],
kind: "tui",
wait: async () => {
wait += 1
},
report: {
error(_candidate, retry, stage) {
errors.push([stage, retry])
},
},
}),
)
expect(loaded).toEqual([])
expect(wait).toBe(0)
expect(errors).toEqual([["entry", false]])
}),
),
)
it.live("does not retry file plugins when finish returns undefined", () =>
withTmp(
async (dir) => {
const file = path.join(dir, "plugin.ts")
const spec = pathToFileURL(file).href
await Bun.write(file, "export default {}\n")
return { spec }
},
(tmp) =>
Effect.gen(function* () {
let wait = 0
let count = 0
const loaded = yield* Effect.promise(() =>
PluginLoader.loadExternal({
items: [
{
spec: tmp.extra.spec,
scope: "local" as const,
source: tmp.path,
},
],
kind: "tui",
wait: async () => {
wait += 1
},
finish: async () => {
count += 1
},
}),
)
expect(wait).toBe(0)
expect(count).toBe(1)
expect(loaded).toEqual([])
}),
),
)
it.live("does not wait or retry npm plugin failures", () =>
Effect.gen(function* () {
const install = spyOn(Npm, "add").mockRejectedValue(new Error("boom"))
let wait = 0
const errors: Array<[string, boolean]> = []
try {
const loaded = yield* Effect.promise(() =>
PluginLoader.loadExternal({
items: [
{
spec: "acme-plugin@1.0.0",
scope: "local" as const,
source: "test",
},
],
kind: "tui",
wait: async () => {
wait += 1
},
report: {
error(_candidate, retry, stage) {
errors.push([stage, retry])
},
},
}),
)
expect(loaded).toEqual([])
expect(wait).toBe(0)
expect(errors).toEqual([["install", false]])
} finally {
install.mockRestore()
}
}),
)
})