Compare commits

..
Author SHA1 Message Date
Kit Langton be954ec0b1 chore(core): remove obsolete changeset 2026-08-28 16:32:46 -04:00
Kit Langton f8ee647d50 Merge branch 'v2' into runtime-columns 2026-08-28 16:28:38 -04:00
Kit Langton 964245bc2a test(core): isolate project adoption fixture (#46009) 2026-08-28 16:18:36 -04:00
Kit Langton aea3e7c1d2 fix(core): refresh plugins in background (#45993) 2026-08-28 19:43:12 +00:00
Kit Langton 3625942952 feat(core): pass session context to MCP tools (#46008) 2026-08-28 15:43:06 -04:00
Kit Langton 0593a6b8eb fix(core): isolate models seed replay (#45686) 2026-08-28 15:26:22 -04:00
Kit Langton 426e5c6389 fix(tui): open recent picker before server reads (#45977)
Open the recent-session and project picker synchronously with selectable cached rows and independent refreshes. Reconcile committed moves and deletions without restoring stale rows, preserve dismissal and selection through delayed reads, and keep filtered selections visible after asynchronous results arrive.
2026-08-28 15:18:24 -04:00
Kit Langton ebdfcf4866 test(tui): remove brittle animation sampling (#46003) 2026-08-28 19:16:47 +00:00
Kit Langton 000d0882c3 docs(core): record adapter fork boundaries (#45691) 2026-08-28 15:14:46 -04:00
Kit Langton 6062e30cb9 test(core): assert typed failures directly (#45690) 2026-08-28 15:14:42 -04:00
Kit Langton 3badee1a3c test(core): simplify config test setup (#45689) 2026-08-28 15:14:39 -04:00
Kit Langton 4a0256d374 test(core): use effect search harness (#45682) 2026-08-28 15:14:36 -04:00
Kit Langton 52ec62bef0 docs: correct config discovery boundary (#45681) 2026-08-28 15:14:32 -04:00
Kit Langton 31af9858fd test(core): assert pending prompt identity (#45680) 2026-08-28 15:14:28 -04:00
Kit Langton 3151660fbb refactor(core): name shell records as commands (#45693) 2026-08-28 15:00:37 -04:00
Kit Langton 0362ef48ff test(core): isolate transport metrics (#45688) 2026-08-28 15:00:33 -04:00
Kit Langton facd7ff452 refactor(core): simplify V1 migration effects (#45685) 2026-08-28 15:00:29 -04:00
Kit Langton 134cdda333 fix(core): normalize SDK file data (#45679) 2026-08-28 14:56:46 -04:00
Kit Langton 5634ef1bb6 refactor(core): simplify manual compaction (#45678) 2026-08-28 14:56:40 -04:00
Kit Langton 2379ab3d51 fix(core): defer memory filesystem observation (#45675) 2026-08-28 14:56:36 -04:00
Kit Langton 5c908ebba5 refactor(core): simplify reference config precedence (#45673) 2026-08-28 14:56:31 -04:00
Kit Langton ba0755d933 refactor(core): reuse platform contract types (#45666) 2026-08-28 14:56:27 -04:00
Kit Langton f7d6b00c1e test(core): use collected arrays directly (#45665) 2026-08-28 14:56:21 -04:00
Kit Langton f24f1e0a29 fix(core): use runtime update columns 2026-08-27 15:29:59 -04:00
103 changed files with 2204 additions and 3160 deletions
+3 -11
View File
@@ -966,7 +966,6 @@
"mime-types": "3.0.2",
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"pacote": "21.5.1",
"resolve.exports": "catalog:",
},
"devDependencies": {
@@ -976,7 +975,6 @@
"@types/node": "catalog:",
"@types/npm-package-arg": "6.1.4",
"@types/npmcli__arborist": "6.3.3",
"@types/pacote": "11.1.8",
"@typescript/native-preview": "catalog:",
},
},
@@ -5814,7 +5812,7 @@
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
@@ -6644,6 +6642,8 @@
"svgo/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
"tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"temp/rimraf": ["rimraf@2.6.3", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA=="],
"tempy/type-fest": ["type-fest@0.16.0", "", {}, "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg=="],
@@ -7450,12 +7450,6 @@
"miniflare/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="],
"minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"openid-client/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
@@ -8162,8 +8156,6 @@
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
"app-builder-lib/hosted-git-info/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"archiver-utils/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
"archiver-utils/glob/minimatch/brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="],
@@ -85,10 +85,10 @@ export default Runtime.handler(Commands, (input) =>
},
packages: {
resolve: (spec, install = true) =>
runPromise(install ? npm.add(spec, { subpaths: ["tui"] }) : npm.resolve(spec, { subpaths: ["tui"] })),
check: (spec) =>
runPromise(
npm.check(spec).pipe(Effect.mapError((error) => (error.cause instanceof Error ? error.cause : error))),
(install ? npm.add(spec, { subpaths: ["tui"] }) : npm.resolve(spec, { subpaths: ["tui"] })).pipe(
Effect.map((result) => result.entrypoint),
),
),
},
environment: requestedServer === undefined ? Env.session() : undefined,
-8
View File
@@ -88,16 +88,8 @@ export type PluginListInput = {
export type PluginListOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<Plugin.Info> }
export type PluginListOperation<E = never> = (input?: PluginListInput) => Effect.Effect<PluginListOutput, E>
export type PluginCheckInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly target: string
}
export type PluginCheckOutput = { readonly location: Location.Info; readonly data: Plugin.PackageStatus }
export type PluginCheckOperation<E = never> = (input: PluginCheckInput) => Effect.Effect<PluginCheckOutput, E>
export interface PluginApi<E = never> {
readonly list: PluginListOperation<E>
readonly check: PluginCheckOperation<E>
}
export type SessionListInput = {
+1 -13
View File
@@ -15,8 +15,6 @@ import type {
AgentGetOutput,
PluginListInput,
PluginListOutput,
PluginCheckInput,
PluginCheckOutput,
SessionListInput,
SessionListOutput,
SessionStatsInput,
@@ -320,17 +318,7 @@ const EndpointPluginList = (raw: RawClient["server.plugin"]) => (input?: PluginL
raw["plugin.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const EndpointPluginCheck = (raw: RawClient["server.plugin"]) => (input: PluginCheckInput) =>
preserveEffect<PluginCheckOutput>()(
raw["plugin.check"]({ query: { location: input["location"] }, payload: { target: input["target"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const adaptGroupPlugin = (raw: RawClient["server.plugin"]) => ({
list: EndpointPluginList(raw),
check: EndpointPluginCheck(raw),
})
const adaptGroupPlugin = (raw: RawClient["server.plugin"]) => ({ list: EndpointPluginList(raw) })
const EndpointSessionList = (raw: RawClient["server.session"]) => (input?: SessionListInput) =>
preserveEffect<SessionListOutput>()(
-1
View File
@@ -32,7 +32,6 @@ export { Location } from "@opencode-ai/schema/location"
export { Model } from "@opencode-ai/schema/model"
export { Permission } from "@opencode-ai/schema/permission"
export { PermissionSaved } from "@opencode-ai/schema/permission-saved"
export { Plugin } from "@opencode-ai/schema/plugin"
export { Project } from "@opencode-ai/schema/project"
export { Worktree } from "@opencode-ai/schema/worktree"
export { Provider } from "@opencode-ai/schema/provider"
@@ -9,8 +9,6 @@ import type {
AgentGetOutput,
PluginListInput,
PluginListOutput,
PluginCheckInput,
PluginCheckOutput,
SessionListInput,
SessionListOutput,
SessionStatsInput,
@@ -464,19 +462,6 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
check: (input: PluginCheckInput, requestOptions?: RequestOptions) =>
request<PluginCheckOutput>(
{
method: "POST",
path: `/api/plugin/check`,
query: { location: input["location"] },
body: { target: input["target"] },
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
},
session: {
list: (input?: SessionListInput, requestOptions?: RequestOptions) =>
+2 -20
View File
@@ -16,8 +16,6 @@ export type PluginSource =
| { type: "local"; path: string }
| { type: "sdk" }
export type PluginPackageStatus = { installed?: string; available?: string; mutable: boolean }
export type SessionForkBoundary = { type: "before"; messageID: string } | { type: "through"; messageID: string }
export type MoneyUSD = number
@@ -443,8 +441,8 @@ export type ProviderRequest = {
export type PermissionRule = { action: string; resource: string; effect: PermissionEffect }
export type PluginInfo =
| { id: string; source: PluginSource; revision?: string; status: "active"; tui: boolean }
| { id?: string; source: PluginSource; revision?: string; status: "failed"; error: string; tui: boolean }
| { id: string; source: PluginSource; status: "active"; tui: boolean }
| { id?: string; source: PluginSource; status: "failed"; error: string; tui: boolean }
export type SessionMessageLocationSwitched = {
id: string
@@ -2340,10 +2338,6 @@ export type AgentNotFoundError = {
export const isAgentNotFoundError = (value: unknown): value is AgentNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "AgentNotFoundError"
export type PluginCheckError = { readonly _tag: "PluginCheckError"; readonly message: string }
export const isPluginCheckError = (value: unknown): value is PluginCheckError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "PluginCheckError"
export type InvalidCursorError = { readonly _tag: "InvalidCursorError"; readonly message: string }
export const isInvalidCursorError = (value: unknown): value is InvalidCursorError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidCursorError"
@@ -2554,18 +2548,6 @@ export type PluginListOutput = {
data: Array<PluginInfo>
}
export type PluginCheckInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly target: { readonly target: string }["target"]
}
export type PluginCheckOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: PluginPackageStatus
}
export type SessionListInput = {
readonly workspace?: {
readonly workspace?: string | undefined
@@ -3,7 +3,6 @@ import { Schema } from "effect"
import { Agent } from "@opencode-ai/schema/agent"
import { Config } from "@opencode-ai/schema/config"
import { Model } from "@opencode-ai/schema/model"
import { Plugin } from "@opencode-ai/schema/plugin"
import { Prompt } from "@opencode-ai/schema/prompt"
import { Session } from "@opencode-ai/schema/session"
import { SessionMessage } from "@opencode-ai/schema/session-message"
@@ -14,7 +13,6 @@ test("effect entrypoint exposes canonical Schema contracts", () => {
expect(Client.Agent).toBe(Agent)
expect(Client.Config).toBe(Config)
expect(Client.Model).toBe(Model)
expect(Client.Plugin).toBe(Plugin)
expect(Client.Session).toBe(Session)
})
-35
View File
@@ -8,7 +8,6 @@ import {
Location,
Model,
OpenCode,
Plugin,
Prompt,
Session,
SessionMessage,
@@ -28,40 +27,6 @@ test("health.get decodes the readiness response", async () => {
expect(result).toEqual({ healthy: true, version: "old", pid: 123 })
})
test("plugin.check decodes package status and its declared error", async () => {
const status = { installed: "1.0.0", available: "1.1.0", mutable: true }
const httpClient = HttpClient.make((request) => {
expect(request.method).toBe("POST")
expect(request.url).toBe("http://localhost:3000/api/plugin/check")
expect(Object.fromEntries(request.urlParams.params)).toEqual({ "location[directory]": "/tmp/project" })
const body =
request.body._tag === "Uint8Array" ? JSON.parse(new TextDecoder().decode(request.body.body)) : undefined
expect(Object.keys(body)).toEqual(["target"])
return Effect.succeed(
HttpClientResponse.fromWeb(
request,
body.target === "missing"
? Response.json({ _tag: "PluginCheckError", message: "Not a configured plugin source" }, { status: 400 })
: Response.json({
location: {
directory: "/tmp/project",
project: { id: "proj_test", directory: "/tmp/project", canonical: "/tmp/project" },
},
data: status,
}),
),
)
})
await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
const location = { directory: "/tmp/project" }
expect((yield* client.plugin.check({ target: "fixture-plugin@^1", location })).data).toEqual(status)
const error = yield* client.plugin.check({ target: "missing", location }).pipe(Effect.flip)
expect(error).toBeInstanceOf(Plugin.CheckError)
expect(error.message).toBe("Not a configured plugin source")
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
})
test("session.get returns the decoded Effect projection", async () => {
const httpClient = HttpClient.make((request) =>
Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session))),
+1 -29
View File
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test"
import { isPluginCheckError, isSessionNotFoundError, isUnauthorizedError, OpenCode } from "../src/promise/index"
import { isSessionNotFoundError, isUnauthorizedError, OpenCode } from "../src/promise/index"
test("exposes every standard HTTP API group", () => {
const client = OpenCode.make({ baseUrl: "http://localhost:3000" })
@@ -55,34 +55,6 @@ test("exposes every standard HTTP API group", () => {
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "timeout", "output", "remove"])
expect(Object.keys(client.project)).toEqual(["list", "update", "current"])
expect(Object.keys(client.worktree)).toEqual(["list", "create", "remove", "refresh"])
expect(Object.keys(client.plugin)).toEqual(["list", "check"])
})
test("plugin.check uses the inspection contract and retains declared errors", async () => {
const status = { installed: "1.0.0", available: "1.1.0", mutable: true }
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
expect(request.method).toBe("POST")
expect(request.url).toBe("http://localhost:3000/api/plugin/check?location%5Bdirectory%5D=%2Ftmp%2Fproject")
const body = await request.json()
expect(Object.keys(body)).toEqual(["target"])
if (body.target === "missing")
return Response.json({ _tag: "PluginCheckError", message: "Not a configured plugin source" }, { status: 400 })
expect(body.target).toBe("fixture-plugin@^1")
return Response.json({ location: { directory: "/tmp/project" }, data: status })
},
})
expect(
(await client.plugin.check({ target: "fixture-plugin@^1", location: { directory: "/tmp/project" } })).data,
).toEqual(status)
const error = await client.plugin
.check({ target: "missing", location: { directory: "/tmp/project" } })
.catch((cause: unknown) => cause)
expect(isPluginCheckError(error)).toBe(true)
if (!isPluginCheckError(error)) throw new Error("Expected PluginCheckError")
expect(error.message).toBe("Not a configured plugin source")
})
test("config.get returns ordered config entries for a location", async () => {
+12 -4
View File
@@ -482,8 +482,7 @@ function toolMessage(input: LLMRequest["messages"][number]) {
const value = part.result.value.filter((item) => {
if (item.type !== "file") return true
if (!item.mime.startsWith("image/") && item.mime !== "application/pdf") return true
const data = /^data:[^;,]+(?:;[^,]*)*;base64,(.*)$/s.exec(item.uri)?.[1] ?? item.uri
media.push({ type: "file", mediaType: item.mime, data, filename: item.name })
media.push({ type: "file", mediaType: item.mime, data: fileData(item.uri), filename: item.name })
return false
})
return toolResultPart({
@@ -507,7 +506,7 @@ function text(part: ContentPart) {
function userPart(part: ContentPart): UserContent {
if (part.type === "text") return [{ type: "text", text: part.text }]
if (part.type === "media")
return [{ type: "file", mediaType: part.mediaType, data: part.data, filename: part.filename }]
return [{ type: "file", mediaType: part.mediaType, data: fileData(part.data), filename: part.filename }]
return []
}
@@ -516,7 +515,7 @@ function assistantPart(part: ContentPart): AssistantContent {
case "text":
return [{ type: "text", text: part.text, providerOptions: metadataProviderOptions(part.providerMetadata) }]
case "media":
return [{ type: "file", mediaType: part.mediaType, data: part.data, filename: part.filename }]
return [{ type: "file", mediaType: part.mediaType, data: fileData(part.data), filename: part.filename }]
case "reasoning":
return [{ type: "reasoning", text: part.text, providerOptions: metadataProviderOptions(part.providerMetadata) }]
case "tool-call":
@@ -535,6 +534,15 @@ function assistantPart(part: ContentPart): AssistantContent {
}
}
function fileData(data: Extract<ContentPart, { type: "media" }>["data"]) {
if (typeof data !== "string") return data
const base64 = /^data:[^;,]+(?:;[^,]*)*;base64,(.*)$/s.exec(data)?.[1]
if (base64 !== undefined) return base64
if (!URL.canParse(data)) return data
const url = new URL(data)
return url.protocol === "http:" || url.protocol === "https:" ? url : data
}
function toolResultPart(part: ContentPart): ToolResultContent[] {
if (part.type !== "tool-result") return []
return [
+1 -3
View File
@@ -20,14 +20,13 @@ export const Plugin = define({
const global = yield* Global.Service
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, ctx.reference.reload())
yield* ctx.reference.transform((draft) => {
const entries = new Map<string, Reference.Source>()
for (const doc of loaded.entries.filter((entry): entry is Document => entry.type === "document")) {
const directory = doc.path ? path.dirname(doc.path) : location.directory
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
if (!validAlias(name)) continue
const description = typeof entry === "string" ? undefined : entry.description
const hidden = typeof entry === "string" ? undefined : entry.hidden
entries.set(
draft.add(
name,
local(entry)
? Reference.LocalSource.make({
@@ -48,7 +47,6 @@ export const Plugin = define({
)
}
}
for (const [name, source] of entries) draft.add(name, source)
})
}),
})
@@ -0,0 +1,35 @@
# Effect Drizzle SQLite Adapter
This subtree is an upstream-derived Drizzle ORM fork adapted to run SQLite query
builders over Effect's generic `SqlClient`. It is maintained source, not
generated output.
## Provenance
The implementation is derived from Drizzle ORM's Effect SQLite driver/session,
SQLite Effect query builders, and shared query-builder utilities. The
corresponding upstream source families are `drizzle-orm/src/effect-sqlite`,
`drizzle-orm/src/sqlite-core`, and `drizzle-orm/src/utils.ts`.
The exact upstream revision originally copied into this repository is unknown.
The currently pinned `drizzle-orm` version is a compatibility dependency, not
copy provenance.
## Local Boundary
The supported local entrypoint is `@opencode-ai/core/database/drizzle`, exposed
as the `EffectDrizzleSqlite` namespace. OpenCode's database service consumes that
facade from `database/database.ts`.
Material local adaptations include:
- a runtime-independent driver over Effect's generic `SqlClient`
- local cache, mapping, and runtime-inspection helpers
- suppressed statement tracing beneath the database operation boundary
- explicit SQLite transactions and savepoints
- native transaction delegation for Durable Object SQLite
- deliberate query-builder variance annotations
Preserve these adaptations when comparing or synchronizing upstream code.
Focused regression coverage is in `test/database-drizzle.test.ts` and
`test/sqlite-workerd.test.ts`.
@@ -36,14 +36,14 @@ export const DefaultServices = Layer.merge(EffectCache.Default, EffectLogger.Def
*
* @example
* ```ts
* import { SqliteClient } from '@effect/sql-sqlite-node';
* import * as SQLiteDrizzle from 'drizzle-orm/effect-sqlite';
* import * as Effect from 'effect/Effect';
* import { SqliteClient } from "@effect/sql-sqlite-node"
* import { EffectDrizzleSqlite } from "@opencode-ai/core/database/drizzle"
* import { Effect } from "effect"
*
* const db = yield* SQLiteDrizzle.make({ relations }).pipe(
* Effect.provide(SQLiteDrizzle.DefaultServices),
* Effect.provide(SqliteClient.layer({ filename: 'sqlite.db' })),
* );
* const db = yield* EffectDrizzleSqlite.make({ relations }).pipe(
* Effect.provide(EffectDrizzleSqlite.DefaultServices),
* Effect.provide(SqliteClient.layer({ filename: "sqlite.db" })),
* )
* ```
*/
export const make = Effect.fn("SQLiteDrizzle.make")(function* <TRelations extends AnyRelations = EmptyRelations>(
@@ -279,7 +279,7 @@ export class SQLiteEffectUpdateBase<
: undefined
on = on(
new Proxy(
this.config.table._.columns,
getTableColumnsRuntime(this.config.table),
new SelectionProxyHandler({ sqlAliasedBehavior: "sql", sqlBehavior: "sql" }),
) as any,
from &&
+129 -132
View File
@@ -481,7 +481,7 @@ export function status(): Effect.Effect<Status, never, Database.Service> {
if (runtimeState.status === "error") return runtimeState
if (state?.phase === "completed") return { status: "completed" as const }
return { status: "required" as const }
}).pipe(Effect.orDie)
})
}
export const layer = Layer.effectDiscard(
@@ -521,76 +521,75 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
const state = yield* readState(db)
if (state?.phase === "completed") return { status: "completed" as const }
if (!(yield* hasLegacySessions(db))) return { status: "completed" as const }
const migrate = Effect.gen(function* () {
const now = Date.now()
yield* db.run(sql`
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}, '[]')
`)
if (state === undefined)
yield* db
.transaction((tx) =>
Effect.gen(function* () {
while (true) {
yield* tx.run(sql`
if (state === undefined)
yield* db
.transaction((tx) =>
Effect.gen(function* () {
while (true) {
yield* tx.run(sql`
DELETE FROM event
WHERE rowid IN (SELECT rowid FROM event LIMIT ${EVENT_DELETE_BATCH_SIZE})
`)
const deleted = (yield* tx.get<{ value: number }>(sql`SELECT changes() AS value`))?.value ?? 0
if (deleted < EVENT_DELETE_BATCH_SIZE) break
yield* Effect.yieldNow
}
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions" } })
.run()
}),
)
.pipe(Effect.orDie)
const sourceTotal = yield* countNextSessions(nextPath(options, global.data))
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 =
cursor !== undefined
? ((yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session WHERE id >= ${cursor}`))
?.value ?? 0)
: 0
const denominator = sourceTotal + legacyTotal
updateProgress({ label: "Migrating sessions", numerator: migrated, denominator })
yield* importNextDatabase(db, nextPath(options, global.data), (completed) => {
updateProgress({ label: "Migrating sessions", numerator: migrated + completed, denominator })
})
updateProgress({ label: "Migrating sessions", numerator: migrated + sourceTotal, denominator })
const projects = new Set(
(yield* db.all<{ id: string }>(sql`SELECT id FROM project`)).map((project) => project.id),
)
while (true) {
const state = yield* readState(db)
const cursorValue = state?.phase === "sessions" ? state.cursor : undefined
const nextID = yield* db.get<{ id: string; project_id: string }>(
cursorValue === undefined
? sql`SELECT id, project_id FROM session ORDER BY id DESC LIMIT 1`
: sql`SELECT id, project_id FROM session WHERE id < ${cursorValue} ORDER BY id DESC LIMIT 1`,
const deleted = (yield* tx.get<{ value: number }>(sql`SELECT changes() AS value`))?.value ?? 0
if (deleted < EVENT_DELETE_BATCH_SIZE) break
yield* Effect.yieldNow
}
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions" } })
.run()
}),
)
if (!nextID) break
yield* db
.transaction((tx) =>
Effect.gen(function* () {
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions", cursor: nextID.id } })
.onConflictDoUpdate({
target: KVTable.key,
set: { value: { phase: "sessions", cursor: nextID.id }, time_updated: Date.now() },
})
.run()
const projectID = projects.has(nextID.project_id) ? nextID.project_id : Project.ID.global
if (projectID !== nextID.project_id)
yield* Effect.logWarning("Reassigned V1 session with missing project", {
sessionID: nextID.id,
projectID: nextID.project_id,
})
yield* tx.run(sql`
.pipe(Effect.orDie)
const sourceTotal = yield* countNextSessions(nextPath(options, global.data))
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 =
cursor !== undefined
? ((yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session WHERE id >= ${cursor}`))
?.value ?? 0)
: 0
const denominator = sourceTotal + legacyTotal
updateProgress({ label: "Migrating sessions", numerator: migrated, denominator })
yield* importNextDatabase(db, nextPath(options, global.data), (completed) => {
updateProgress({ label: "Migrating sessions", numerator: migrated + completed, denominator })
})
updateProgress({ label: "Migrating sessions", numerator: migrated + sourceTotal, denominator })
const projects = new Set(
(yield* db.all<{ id: string }>(sql`SELECT id FROM project`)).map((project) => project.id),
)
while (true) {
const state = yield* readState(db)
const cursorValue = state?.phase === "sessions" ? state.cursor : undefined
const nextID = yield* db.get<{ id: string; project_id: string }>(
cursorValue === undefined
? sql`SELECT id, project_id FROM session ORDER BY id DESC LIMIT 1`
: sql`SELECT id, project_id FROM session WHERE id < ${cursorValue} ORDER BY id DESC LIMIT 1`,
)
if (!nextID) break
yield* db
.transaction((tx) =>
Effect.gen(function* () {
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions", cursor: nextID.id } })
.onConflictDoUpdate({
target: KVTable.key,
set: { value: { phase: "sessions", cursor: nextID.id }, time_updated: Date.now() },
})
.run()
const projectID = projects.has(nextID.project_id) ? nextID.project_id : Project.ID.global
if (projectID !== nextID.project_id)
yield* Effect.logWarning("Reassigned V1 session with missing project", {
sessionID: nextID.id,
projectID: nextID.project_id,
})
yield* tx.run(sql`
INSERT OR IGNORE INTO session_v2 (
id, project_id, workspace_id, parent_id, slug, directory, path, title, version, share_url,
summary_additions, summary_deletions, summary_files, summary_diffs, metadata, cost,
@@ -605,81 +604,79 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
FROM session
WHERE id = ${nextID.id}
`)
const next = yield* tx
.select()
.from(SessionTable)
.where(eq(SessionTable.id, SessionSchema.ID.make(nextID.id)))
.get()
if (!next) return yield* Effect.die(new Error(`Failed to copy V1 session ${nextID.id}`))
const sourceMessages = yield* tx.all<SourceMessage>(
sql`SELECT id, session_id, time_created, time_updated, data FROM message WHERE session_id = ${next.id}`,
)
const sourceParts = yield* tx.all<SourcePart>(
sql`SELECT id, message_id, session_id, time_created, time_updated, data FROM part WHERE session_id = ${next.id}`,
)
const transformed = transformSession({ session: next, messages: sourceMessages, parts: sourceParts })
yield* Effect.forEach(transformed.warnings, (warning) =>
Effect.logWarning("Skipped V1 migration row", warning),
)
yield* tx.delete(SessionMessageTable).where(eq(SessionMessageTable.session_id, next.id)).run()
yield* Effect.forEach(transformed.messages, (message) =>
tx
.insert(SessionMessageTable)
.values({
id: SessionMessage.ID.make(message.id),
session_id: SessionSchema.ID.make(message.session_id),
type: message.type,
seq: message.seq,
time_created: message.time_created,
time_updated: message.time_updated,
data: sql`${JSON.stringify(message.data)}`,
})
.run(),
)
yield* tx
.update(SessionTable)
.set({ ...transformed.session, time_updated: next.time_updated })
.where(eq(SessionTable.id, next.id))
.run()
yield* tx
.insert(EventSequenceTable)
.values({ aggregate_id: next.id, seq: transformed.watermark })
.onConflictDoUpdate({
target: EventSequenceTable.aggregate_id,
set: { seq: transformed.watermark, owner_id: null },
const next = yield* tx
.select()
.from(SessionTable)
.where(eq(SessionTable.id, SessionSchema.ID.make(nextID.id)))
.get()
if (!next) return yield* Effect.die(new Error(`Failed to copy V1 session ${nextID.id}`))
const sourceMessages = yield* tx.all<SourceMessage>(
sql`SELECT id, session_id, time_created, time_updated, data FROM message WHERE session_id = ${next.id}`,
)
const sourceParts = yield* tx.all<SourcePart>(
sql`SELECT id, message_id, session_id, time_created, time_updated, data FROM part WHERE session_id = ${next.id}`,
)
const transformed = transformSession({ session: next, messages: sourceMessages, parts: sourceParts })
yield* Effect.forEach(transformed.warnings, (warning) =>
Effect.logWarning("Skipped V1 migration row", warning),
)
yield* tx.delete(SessionMessageTable).where(eq(SessionMessageTable.session_id, next.id)).run()
yield* Effect.forEach(transformed.messages, (message) =>
tx
.insert(SessionMessageTable)
.values({
id: SessionMessage.ID.make(message.id),
session_id: SessionSchema.ID.make(message.session_id),
type: message.type,
seq: message.seq,
time_created: message.time_created,
time_updated: message.time_updated,
data: sql`${JSON.stringify(message.data)}`,
})
.run()
}),
)
.pipe(Effect.orDie)
if (runtimeState.status === "running")
runtimeState = {
status: "running",
progress: {
label: "Migrating sessions",
numerator: (runtimeState.progress.numerator ?? 0) + 1,
denominator,
},
}
yield* Effect.yieldNow
}
yield* db
.transaction((tx) =>
Effect.gen(function* () {
.run(),
)
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "completed" } })
.update(SessionTable)
.set({ ...transformed.session, time_updated: next.time_updated })
.where(eq(SessionTable.id, next.id))
.run()
yield* tx
.insert(EventSequenceTable)
.values({ aggregate_id: next.id, seq: transformed.watermark })
.onConflictDoUpdate({
target: KVTable.key,
set: { value: { phase: "completed" }, time_updated: Date.now() },
target: EventSequenceTable.aggregate_id,
set: { seq: transformed.watermark, owner_id: null },
})
.run()
}),
)
.pipe(Effect.orDie)
return { status: "completed" as const }
})
return yield* migrate
if (runtimeState.status === "running")
runtimeState = {
status: "running",
progress: {
label: "Migrating sessions",
numerator: (runtimeState.progress.numerator ?? 0) + 1,
denominator,
},
}
yield* Effect.yieldNow
}
yield* db
.transaction((tx) =>
Effect.gen(function* () {
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "completed" } })
.onConflictDoUpdate({
target: KVTable.key,
set: { value: { phase: "completed" }, time_updated: Date.now() },
})
.run()
}),
)
.pipe(Effect.orDie)
return { status: "completed" as const }
}).pipe(Effect.orDie),
)
}
@@ -708,7 +705,7 @@ function countNextSessions(sourcePath: string | undefined) {
if (!isNextDatabase(source)) return 0
return source.query<{ value: number }, []>("SELECT COUNT(*) AS value FROM session").get()?.value ?? 0
}),
).pipe(Effect.orElseSucceed(() => 0))
)
}
function importNextDatabase(
+55 -52
View File
@@ -61,21 +61,23 @@ export const makeMemoryDriver = (): MemoryDriver => {
}
const failed = (value: string, cause: unknown) => new Failed({ path: value, cause })
const overrides: FilesImpl = {
stat: (value) => {
const node = lookup(value)
return node ? Effect.succeed(info(node)) : Effect.fail(new NotFound({ path: value }))
},
read: (value, range) => {
const original = lookup(value)
if (!original) return Effect.fail(new NotFound({ path: value }))
if (original.type === "directory") return Effect.fail(new WrongKind({ path: value, actual: "directory" }))
const resolved = resolveKey(value, true)
const node = resolved === undefined ? undefined : nodes.get(resolved)
if (!node) return Effect.fail(new NotFound({ path: value }))
if (node.type !== "file") return Effect.fail(new WrongKind({ path: value, actual: node.type }))
const bytes = range === undefined ? node.bytes : node.bytes.subarray(range.offset, range.offset + range.length)
return Effect.succeed({ info: info(node), bytes: bytes.slice() })
},
stat: (value) =>
Effect.suspend(() => {
const node = lookup(value)
return node ? Effect.succeed(info(node)) : Effect.fail(new NotFound({ path: value }))
}),
read: (value, range) =>
Effect.gen(function* () {
const original = lookup(value)
if (!original) return yield* new NotFound({ path: value })
if (original.type === "directory") return yield* new WrongKind({ path: value, actual: "directory" })
const resolved = resolveKey(value, true)
const node = resolved === undefined ? undefined : nodes.get(resolved)
if (!node) return yield* new NotFound({ path: value })
if (node.type !== "file") return yield* new WrongKind({ path: value, actual: node.type })
const bytes = range === undefined ? node.bytes : node.bytes.subarray(range.offset, range.offset + range.length)
return { info: info(node), bytes: bytes.slice() }
}),
write: (value, bytes) =>
Effect.try({
try: () => {
@@ -89,17 +91,17 @@ export const makeMemoryDriver = (): MemoryDriver => {
},
catch: (cause) => failed(value, cause),
}),
list: (value) => {
const target = resolveKey(value, true) ?? key(value)
const node = nodes.get(target)
if (!node) return Effect.fail(new NotFound({ path: value }))
if (node.type !== "directory") return Effect.fail(new WrongKind({ path: value, actual: node.type }))
const entries = [...nodes.entries()]
.filter(([entry]) => entry !== target && path.posix.dirname(entry) === target)
.map(([entry, child]) => ({ name: path.posix.basename(entry), type: child.type satisfies FileType }))
.sort((a, b) => a.name.localeCompare(b.name))
return Effect.succeed(entries)
},
list: (value) =>
Effect.gen(function* () {
const target = resolveKey(value, true) ?? key(value)
const node = nodes.get(target)
if (!node) return yield* new NotFound({ path: value })
if (node.type !== "directory") return yield* new WrongKind({ path: value, actual: node.type })
return [...nodes.entries()]
.filter(([entry]) => entry !== target && path.posix.dirname(entry) === target)
.map(([entry, child]) => ({ name: path.posix.basename(entry), type: child.type satisfies FileType }))
.sort((a, b) => a.name.localeCompare(b.name))
}),
remove: (value) =>
Effect.sync(() => {
const target = resolveKey(value, false) ?? key(value)
@@ -107,32 +109,33 @@ export const makeMemoryDriver = (): MemoryDriver => {
if (entry === target || entry.startsWith(`${target}/`)) nodes.delete(entry)
}
}),
move: (from, to) => {
const source = resolveKey(from, false) ?? key(from)
const node = nodes.get(source)
if (!node) return Effect.fail(new NotFound({ path: from }))
return Effect.try({
try: () => {
const requested = resolveKey(to, false) ?? key(to)
const destination =
nodes.get(requested)?.type === "directory"
? path.posix.join(requested, path.posix.basename(source))
: requested
if (node.type === "directory" && destination.startsWith(`${source}/`)) {
throw new Error(`Cannot move a directory into itself: ${from}`)
}
const existing = nodes.get(destination)
if (node.type === "directory" && existing && existing.type !== "directory") {
throw new Error(`Cannot overwrite a non-directory with a directory: ${to}`)
}
requireParent(destination)
const moved = [...nodes.entries()].filter(([entry]) => entry === source || entry.startsWith(`${source}/`))
for (const [entry] of moved) nodes.delete(entry)
for (const [entry, child] of moved) nodes.set(`${destination}${entry.slice(source.length)}`, child)
},
catch: (cause) => failed(from, cause),
})
},
move: (from, to) =>
Effect.gen(function* () {
const source = resolveKey(from, false) ?? key(from)
const node = nodes.get(source)
if (!node) return yield* new NotFound({ path: from })
yield* Effect.try({
try: () => {
const requested = resolveKey(to, false) ?? key(to)
const destination =
nodes.get(requested)?.type === "directory"
? path.posix.join(requested, path.posix.basename(source))
: requested
if (node.type === "directory" && destination.startsWith(`${source}/`)) {
throw new Error(`Cannot move a directory into itself: ${from}`)
}
const existing = nodes.get(destination)
if (node.type === "directory" && existing && existing.type !== "directory") {
throw new Error(`Cannot overwrite a non-directory with a directory: ${to}`)
}
requireParent(destination)
const moved = [...nodes.entries()].filter(([entry]) => entry === source || entry.startsWith(`${source}/`))
for (const [entry] of moved) nodes.delete(entry)
for (const [entry, child] of moved) nodes.set(`${destination}${entry.slice(source.length)}`, child)
},
catch: (cause) => failed(from, cause),
})
}),
mkdir: (value) => Effect.try({ try: () => mkdirSync(value), catch: (cause) => failed(value, cause) }),
}
+36 -3
View File
@@ -1,5 +1,38 @@
This is a temporary package used primarily for GitHub Copilot compatibility.
# GitHub Copilot AI SDK Adapters
These DO NOT apply for openai-compatible providers or majority of providers supporting completions/responses apis. THIS IS ONLY FOR GITHUB COPILOT!!!
This directory contains upstream-derived AI SDK implementations adapted for
GitHub Copilot. It is not a generic OpenAI-compatible provider.
Avoid making edits to these files
## Provenance
- `chat/` is derived from the Vercel AI SDK
`@ai-sdk/openai-compatible` chat implementation.
- `responses/` is derived from the Vercel AI SDK `@ai-sdk/openai` Responses
implementation.
- The exact upstream revisions originally copied into this repository are
unknown. Current dependency versions and the `VERSION` constant in
`copilot-provider.ts` are not copy provenance.
## Ownership
Keep `chat/` and `responses/` structurally close to their upstream modules, but
preserve the intentional Copilot adaptations: the `copilot` options and metadata
namespace, `thinking_budget`, reasoning text and opaque reasoning, stateless
Responses requests with encrypted reasoning, rotating response item IDs, and
explicit function-tool strictness taking precedence over the global fallback.
`copilot-provider.ts` is the local adapter assembly entrypoint used by
`plugin/provider/github-copilot.ts`. `models.ts` is OpenCode-owned catalog
reconciliation, not vendored SDK code. Authentication, request headers, model
routing, and integration lifecycle are also owned by the provider plugin.
When updating the upstream-shaped modules, compare against both source packages
and reapply the documented Copilot adaptations. Focused regression coverage is
in:
- `test/github-copilot/copilot-chat-model.test.ts`
- `test/github-copilot/convert-to-copilot-messages.test.ts`
- `test/github-copilot/openai-responses-language-model.test.ts`
- `test/github-copilot/openai-responses-prepare-tools.test.ts`
- `test/github-copilot/models.test.ts`
- `test/plugin/provider-github-copilot.test.ts`
+2 -7
View File
@@ -3,7 +3,7 @@ import { Effect } from "effect"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { FileSystem } from "../filesystem.js"
import { DecodeError, ResizerUnavailableError, SizeError } from "../image.js"
import { DecodeError, ResizerUnavailableError, SizeError, type Limits } from "../image.js"
const JPEG_QUALITIES = [80, 85, 70, 55, 40]
@@ -33,12 +33,7 @@ export const make = Effect.gen(function* () {
return Effect.fn("Image.Photon.normalize")(function* (
resource: string,
content: FileSystem.Content & { readonly encoding: "base64" },
limits: {
readonly autoResize: boolean
readonly maxWidth: number
readonly maxHeight: number
readonly maxBase64Bytes: number
},
limits: Readonly<Limits>,
) {
const photon = yield* loadPhoton
const decoded = yield* Effect.try({
+7 -1
View File
@@ -26,6 +26,7 @@ import {
} from "@modelcontextprotocol/sdk/types.js"
import { Cause, Effect, Exit, Schema } from "effect"
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
import type { Session } from "@opencode-ai/schema/session"
import { McpStdio } from "./stdio.js"
const DEFAULT_STARTUP_TIMEOUT = 30_000
@@ -156,6 +157,7 @@ export interface Connection {
readonly callTool: (input: {
readonly name: string
readonly args?: Record<string, unknown>
readonly sessionID?: Session.ID
}) => Effect.Effect<CallToolResult, Error>
readonly onClose: (callback: () => void) => void
/** Registers a callback fired when the server emits an MCP logging notification. */
@@ -396,7 +398,11 @@ export const connect = Effect.fnUntraced(function* (
Effect.tryPromise({
try: (signal) =>
client.callTool(
{ name: input.name, arguments: input.args ?? {} },
{
name: input.name,
arguments: input.args ?? {},
...(input.sessionID === undefined ? {} : { _meta: { sessionID: input.sessionID } }),
},
CallToolResultSchema,
// Keep progress tokens available while enforcing a hard wall-clock execution timeout.
{ signal, timeout: executionTimeout, onprogress: () => {} },
+3 -1
View File
@@ -3,6 +3,7 @@ export * as Mcp from "./index.js"
import { Mcp } from "@opencode-ai/schema/mcp"
import { McpEvent } from "@opencode-ai/schema/mcp-event"
import { ephemeral } from "@opencode-ai/schema/event"
import type { Session } from "@opencode-ai/schema/session"
import { createHash } from "node:crypto"
import { isDeepStrictEqual } from "node:util"
import { Cause, Context, Effect, Exit, FiberSet, Latch, Layer, Schema, Scope, Stream, Types } from "effect"
@@ -153,6 +154,7 @@ export interface Interface extends State.Transformable<Draft> {
readonly server: ServerName | string
readonly name: string
readonly args?: Record<string, unknown>
readonly sessionID?: Session.ID
}) => Effect.Effect<ToolResult, NotFoundError | ToolCallError>
readonly instructions: () => Effect.Effect<ServerInstructions[]>
readonly prompts: () => Effect.Effect<Prompt[]>
@@ -762,7 +764,7 @@ export const layer = (options?: Options) =>
message: "MCP server is not connected",
})
const result = yield* target.entry.client
.callTool({ name: input.name, args: input.args })
.callTool({ name: input.name, args: input.args, sessionID: input.sessionID })
.pipe(
Effect.mapError(
(error) => new ToolCallError({ server: target.name, tool: input.name, message: error.message }),
+5 -30
View File
@@ -4,7 +4,7 @@ import os from "node:os"
import path from "node:path"
import { Context, Effect, Layer, Schema } from "effect"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Added, Handoff, ReadLines, Removed, type ReadResult } from "@opencode-ai/schema/persistent-pty"
import { Added, Handoff, PersistentPty, ReadLines, Removed, type ReadResult } from "@opencode-ai/schema/persistent-pty"
import { Session } from "@opencode-ai/schema/session"
import { Bus } from "../bus.js"
import { Pty } from "@opencode-ai/schema/pty"
@@ -26,19 +26,9 @@ export { Handoff } from "@opencode-ai/schema/persistent-pty"
export const Options = Schema.Struct({ handoff: Schema.optional(Handoff) })
export type Options = typeof Options.Type
export type Info = Pty.Info & {
readonly sessionID: Session.ID
readonly foregroundProcess: string | null
readonly size: { readonly cols: number; readonly rows: number }
readonly output: { readonly head: number; readonly tail: number }
}
export type Info = PersistentPty.Info
export type Snapshot = {
readonly info: Info
readonly text: string
readonly checkpoint: Uint8Array
readonly cursor: { readonly x: number; readonly y: number }
}
export type Snapshot = PersistentPty.Snapshot
export type Attachment = {
readonly info: Info
@@ -161,15 +151,7 @@ export const configured = (options: Options = {}) =>
const create = Effect.fn("PersistentPty.create")(function* (
sessionID: Session.ID,
input: {
readonly command?: string
readonly args: readonly string[]
readonly cwd?: string
readonly title: string
readonly env: Readonly<Record<string, string>>
readonly cols?: number
readonly rows?: number
},
input: Parameters<Interface["create"]>[1],
) {
const response = yield* request(
daemon,
@@ -338,14 +320,7 @@ export const configured = (options: Options = {}) =>
const attach = Effect.fn("PersistentPty.attach")(function* (
id: Pty.ID,
input: {
readonly cursor: number
readonly attachmentID: string
readonly role: Role
readonly takeover?: boolean
readonly onEvent: (event: StreamEvent) => void
readonly onEnd: () => void
},
input: Parameters<Interface["attach"]>[1],
) {
yield* get(id)
const attachment = yield* daemon
+3 -9
View File
@@ -1,5 +1,5 @@
export * as Plugin from "./plugin.js"
export { CheckError, Event, ID, Info, PackageStatus, Source } from "@opencode-ai/schema/plugin"
export { Event, ID, Info, Source } from "@opencode-ai/schema/plugin"
import { Plugin } from "@opencode-ai/schema/plugin"
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
@@ -37,7 +37,6 @@ export interface Interface {
export type Versioned = PluginDefinition & {
readonly version: string
readonly revision?: string
readonly source?: Plugin.Source
}
@@ -120,22 +119,18 @@ const layer = Layer.effect(
nextInventory.push(activeInfo(definition))
continue
}
const failed = {
nextInventory.push({
id: definition.id,
source: definition.source ?? { type: "builtin" },
...(definition.revision === undefined ? {} : { revision: definition.revision }),
status: "failed",
error: loaded.error,
tui: definition.tui ?? false,
} satisfies Plugin.Info
nextInventory.push(failed)
})
if (!previous) continue
const restored = yield* load(previous.plugin)
if (restored.scope !== undefined) {
active.set(definition.id, { plugin: previous.plugin, scope: restored.scope })
failed.revision = previous.plugin.revision
if (failed.revision === undefined) delete failed.revision
continue
}
yield* Effect.logError("failed to restore plugin; deactivating", {
@@ -180,7 +175,6 @@ function activeInfo(plugin: Versioned): Plugin.Info {
return {
id: Plugin.ID.make(plugin.id),
source: plugin.source ?? { type: "builtin" },
...(plugin.revision === undefined ? {} : { revision: plugin.revision }),
status: "active",
tui: plugin.tui ?? false,
}
-7
View File
@@ -3,7 +3,6 @@ export * as PluginHost from "./host.js"
import { Plugin } from "@opencode-ai/plugin/effect"
import type { IntegrationMethodRegistration } from "@opencode-ai/plugin/effect/integration"
import { EventManifest } from "@opencode-ai/schema/event-manifest"
import { CheckError } from "@opencode-ai/schema/plugin"
import { ServerConfig } from "@opencode-ai/schema/mcp"
import { App } from "../app.js"
import { Effect, Schema, Stream } from "effect"
@@ -333,12 +332,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
},
plugin: {
list: () => response(plugin.list()),
check: () =>
Effect.fail(
new CheckError({
message: "Plugin package checks are available through an external client or the Plugins dialog.",
}),
),
},
reference: {
list: () => response(reference.list()),
+1 -1
View File
@@ -37,7 +37,7 @@ export const ModelsDevPlugin = define({
})
for (const model of provider.models) {
if (model.status === "deprecated") continue
catalog.model.update(provider.info.id, model.id, (draft) => Object.assign(draft, model))
catalog.model.update(provider.info.id, model.id, (draft) => Object.assign(draft, structuredClone(model)))
}
}
})
+4 -11
View File
@@ -3,7 +3,7 @@ export * as PluginModule from "./module.js"
import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
import { Npm } from "@opencode-ai/util/npm"
import { importModule } from "@opencode-ai/util/runtime-import"
import { Effect, Predicate, Schema } from "effect"
import { Effect, Schema } from "effect"
import path from "path"
import { pathToFileURL } from "url"
import type { ConfigPluginSource } from "../config/plugin/source.js"
@@ -34,16 +34,13 @@ const Definition = Schema.Struct({
]),
})
const revisions = new WeakMap<object, string | undefined>()
export const load = Effect.fn("PluginModule.load")(function* (
operation: Extract<ConfigPluginSource.Operation, { type: "add" }>,
) {
const npm = yield* Npm.Service
const resolved = path.isAbsolute(operation.target)
? { entrypoint: pathToFileURL(operation.target).href, revision: undefined }
: yield* npm.add(operation.target, { subpaths: ["server", ""], refresh: true })
const entrypoint = resolved.entrypoint
const entrypoint = path.isAbsolute(operation.target)
? pathToFileURL(operation.target).href
: (yield* npm.add(operation.target, { subpaths: ["server", ""] })).entrypoint
if (!entrypoint) return yield* Effect.fail(new Error(`Plugin entrypoint not found: ${operation.target}`))
// Bun currently ignores query parameters when caching file:// imports.
const target = typeof Bun !== "undefined" ? operation.target.replaceAll("\\", "/") : entrypoint
@@ -51,16 +48,12 @@ export const load = Effect.fn("PluginModule.load")(function* (
yield* Effect.log({ msg: "loading plugin", id: operation.target, entrypoint: source })
const mod = yield* Effect.promise(() => importModule(source))
const value = (yield* Schema.decodeUnknownEffect(Definition)(mod)).default
const exported = Predicate.hasProperty(mod, "default") && Predicate.isObject(mod.default) ? mod.default : undefined
// Keep the import-time revision on the original export, not copied namespaces or decoded records.
if (exported && !revisions.has(exported)) revisions.set(exported, resolved.revision)
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
return {
id: plugin.id,
tui: plugin.tui,
vcs: plugin.vcs,
version: JSON.stringify(operation),
revision: exported ? revisions.get(exported) : undefined,
source: path.isAbsolute(operation.target)
? { type: "local" as const, path: operation.target }
: { type: "package" as const, package: operation.target },
+6 -4
View File
@@ -76,11 +76,13 @@ to every project for that user. Project configuration can live in any directory
as `opencode.json(c)` or `.opencode/opencode.json(c)`, including nested packages
in a monorepo.
When OpenCode starts, it searches from the current directory up to the project
root. It merges direct `opencode.json(c)` files from root to current directory,
During ordinary project discovery, OpenCode searches the current Location
directory and every ancestor through the filesystem root, including directories
above the detected project or repository root. It merges direct
`opencode.json(c)` files from the farthest ancestor to the current directory,
then does the same for `.opencode/opencode.json(c)` files. This means every
`.opencode` config overrides every direct config. Global configuration has the
lowest precedence.
discovered `.opencode` config overrides every discovered direct config. Global
filesystem configuration has lower precedence than these discovered documents.
Common configuration fields include `model`, `default_agent`, `permissions`,
`agents`, `commands`, `plugins`, `providers`, `mcp`, `skills`, `instructions`,
@@ -1,7 +1,6 @@
export * as PluginSupervisor from "./supervisor-service.js"
import { Context, Effect } from "effect"
import type { Plugin } from "@opencode-ai/schema/plugin"
/**
* Dependency-only supervisor seam. Keep this module free of implementation
@@ -10,7 +9,6 @@ import type { Plugin } from "@opencode-ai/schema/plugin"
export interface Interface {
/** Wait for the initial plugin generation and startup updates to settle. */
readonly flush: Effect.Effect<void>
readonly check: (target: string) => Effect.Effect<Plugin.PackageStatus, Plugin.CheckError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginSupervisor") {}
+16 -23
View File
@@ -78,6 +78,9 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
...post.filter((plugin) => enabled.has(plugin.id)),
],
failures: [...failures.values()],
refreshes: [...packages.entries()].flatMap(([target, plugin]) =>
!path.isAbsolute(target) && enabled.has(plugin.id) ? [target] : [],
),
}
})
@@ -114,6 +117,18 @@ export const layer = Layer.effect(
const resolved = yield* resolve(pre, post, operations)
// Replace the active generation in one scoped, batched activation.
yield* registry.activate(resolved.plugins, resolved.failures)
if (resolved.refreshes.length) {
yield* Effect.forEach(
resolved.refreshes,
(target) =>
npm
.add(target, { subpaths: ["server", ""], refresh: true })
.pipe(
Effect.catchCause((cause) => Effect.logWarning("failed to refresh package plugin", { target, cause })),
),
{ concurrency: "unbounded", discard: true },
).pipe(Effect.forkDetach)
}
})
const updates = Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])).pipe(
// Make accepted work visible to flush before coalescing the burst.
@@ -137,29 +152,7 @@ export const layer = Layer.effect(
),
Effect.forkScoped({ startImmediately: true }),
)
return Service.of({
flush: ready.await,
check: Effect.fn("PluginSupervisor.check")(function* (target: string) {
yield* ready.await
const inventory = yield* registry.list()
if (!inventory.some((plugin) => plugin.source.type === "package" && plugin.source.package === target)) {
return yield* new Plugin.CheckError({
message: `Plugin package is not in the current server inventory: ${target}`,
})
}
if (!(yield* Effect.promise(() => Npm.isInstallablePackage(target)))) {
return yield* new Plugin.CheckError({ message: `Unsupported plugin package source: ${target}` })
}
return yield* npm.check(target).pipe(
Effect.mapError(
(error) =>
new Plugin.CheckError({
message: `Failed to check plugin package ${target}: ${error.cause instanceof Error ? error.cause.message : error.message}`,
}),
),
)
}),
})
return Service.of({ flush: ready.await })
}),
)
+20 -19
View File
@@ -393,26 +393,27 @@ export const layer = Layer.effect(
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
inputID: input.inputID,
})
const resolved = yield* input.resolveModel(input.session).pipe(
Effect.catch((cause) =>
failed({
sessionID: input.session.id,
reason: "manual",
error: toSessionError(cause),
inputID: input.inputID,
}),
),
return yield* input.resolveModel(input.session).pipe(
Effect.matchEffect({
onFailure: (cause) =>
failed({
sessionID: input.session.id,
reason: "manual",
error: toSessionError(cause),
inputID: input.inputID,
}),
onSuccess: (resolved) =>
execute({
session: input.session,
resolved,
prepare: input.prepare,
reason: "manual",
inputID: input.inputID,
started: input.started,
...content,
}),
}),
)
if ("status" in resolved) return resolved
return yield* execute({
session: input.session,
resolved,
prepare: input.prepare,
reason: "manual",
inputID: input.inputID,
started: input.started,
...content,
})
})
return Service.of({
transform: state.transform,
@@ -131,11 +131,7 @@ const layer = Layer.effect(
return (yield* rows(sessionID, false)).map((row) => ({ key: row.key, value: row.value }))
})
const put = Effect.fn("InstructionEntry.put")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly key: Key
readonly value: Schema.Json
}) {
const put = Effect.fn("InstructionEntry.put")(function* (input: Parameters<Interface["put"]>[0]) {
const actualBytes = Buffer.byteLength(JSON.stringify(input.value), "utf8")
if (actualBytes > MaxValueBytes)
yield* new ValueTooLargeError({
@@ -159,10 +155,7 @@ const layer = Layer.effect(
.pipe(Effect.orDie)
})
const remove = Effect.fn("InstructionEntry.remove")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly key: Key
}) {
const remove = Effect.fn("InstructionEntry.remove")(function* (input: Parameters<Interface["remove"]>[0]) {
yield* db
.update(InstructionEntryTable)
.set({ value: null, removed: true, time_updated: Date.now() })
+1 -4
View File
@@ -43,10 +43,7 @@ const layer = Layer.effect(
// are re-discovered and re-injected instead of staying silently lost.
const inFlight = yield* Ref.make<Map<SessionSchema.ID, Set<string>>>(new Map())
const load = Effect.fn("SessionInstructions.load")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly paths: ReadonlyArray<string>
}) {
const load = Effect.fn("SessionInstructions.load")(function* (input: Parameters<Interface["load"]>[0]) {
const claimed = yield* Ref.modify(inFlight, (map) => {
const existing = map.get(input.sessionID) ?? new Set<string>()
const newlyClaimed = input.paths.filter((path) => !existing.has(path))
+2 -7
View File
@@ -15,7 +15,7 @@ import { SessionMessage } from "../message.js"
import { SessionSchema } from "../schema.js"
import { SessionStore } from "../store.js"
import { SessionTitle } from "../title.js"
import { DrainResult, Service, type Continuation } from "./index.js"
import { DrainResult, Service, type Interface } from "./index.js"
import { Snapshot } from "../../snapshot.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { llmClient } from "../../effect/app-node-platform.js"
@@ -44,12 +44,7 @@ const layer = Layer.effect(
// Title generation starts once input is visible and must not delay model execution.
const titles = yield* FiberMap.make<SessionSchema.ID, void, never>()
const drain = Effect.fn("SessionRunner.drain")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly force: boolean
readonly continuation?: Continuation
readonly promotable?: SessionInbox.Promotable
}) {
const drain = Effect.fn("SessionRunner.drain")(function* (input: Parameters<Interface["drain"]>[0]) {
const sessionID = input.sessionID
let force = input.force
let continuing = input.continuation !== undefined
+49 -49
View File
@@ -122,8 +122,8 @@ const layer = () =>
const environments = yield* SessionEnvironment.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
const sessions = new Map<string, Active>()
const exitOrder: string[] = []
const commands = new Map<Shell.ID, Active>()
const exitOrder: Shell.ID[] = []
const outputDir = path.join(global.data, DIRECTORY, location.project.id)
const { mkdir, unlink } = yield* Effect.promise(() => import("fs/promises"))
@@ -132,44 +132,44 @@ const layer = () =>
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
for (const session of sessions.values()) {
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
for (const command of commands.values()) {
if (command.timeoutFiber) yield* Fiber.interrupt(command.timeoutFiber)
// Teardown interrupts pending commands; it is not a terminal command failure.
yield* Deferred.interrupt(session.done)
yield* Deferred.interrupt(command.done)
}
sessions.clear()
commands.clear()
exitOrder.length = 0
}),
)
const require = Effect.fnUntraced(function* (id: Shell.ID) {
const session = sessions.get(id)
if (!session) return yield* new NotFoundError({ id })
return session
const command = commands.get(id)
if (!command) return yield* new NotFoundError({ id })
return command
})
const removeSession = Effect.fnUntraced(function* (id: Shell.ID) {
const session = sessions.get(id)
const removeCommand = Effect.fnUntraced(function* (id: Shell.ID) {
const command = commands.get(id)
const index = exitOrder.indexOf(id)
if (index !== -1) exitOrder.splice(index, 1)
if (!session) return
sessions.delete(id)
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
if (!command) return
commands.delete(id)
if (command.timeoutFiber) yield* Fiber.interrupt(command.timeoutFiber)
// Unblock any wait still pending when the command is removed before it terminated.
yield* Deferred.fail(session.done, new NotFoundError({ id }))
yield* Effect.promise(() => unlink(session.file).catch(() => {}))
yield* Deferred.fail(command.done, new NotFoundError({ id }))
yield* Effect.promise(() => unlink(command.file).catch(() => {}))
yield* bus.publish(Shell.Event.Deleted, { id })
})
const remove = Effect.fn("Shell.remove")(function* (id: Shell.ID) {
yield* require(id)
yield* removeSession(id)
yield* removeCommand(id)
})
const list = Effect.fn("Shell.list")(function* () {
return Array.from(sessions.values())
.filter((session) => session.info.status === "running")
.map((session) => session.info)
return Array.from(commands.values())
.filter((command) => command.info.status === "running")
.map((command) => command.info)
})
const get = Effect.fn("Shell.get")(function* (id: Shell.ID) {
@@ -181,24 +181,24 @@ const layer = () =>
})
const timeout = Effect.fn("Shell.timeout")(function* (id: Shell.ID, duration: number) {
const session = yield* require(id)
if (session.info.status !== "running" || !session.timeout) return session.info
yield* session.timeout(duration)
return session.info
const command = yield* require(id)
if (command.info.status !== "running" || !command.timeout) return command.info
yield* command.timeout(duration)
return command.info
})
const output = Effect.fnUntraced(function* (id: Shell.ID, input?: Shell.OutputInput) {
const session = yield* require(id)
const command = yield* require(id)
const cursor = input?.cursor ?? 0
const limit = input?.limit ?? 65536
if (cursor >= session.size) return { output: "", cursor: session.size, size: session.size, truncated: false }
if (cursor >= command.size) return { output: "", cursor: command.size, size: command.size, truncated: false }
const start = Math.max(0, cursor)
const length = Math.min(limit, session.size - start)
const length = Math.min(limit, command.size - start)
const buffer = Buffer.alloc(length)
const bytesRead = yield* Effect.promise(
() =>
new Promise<number>((resolve) => {
const stream = createReadStream(session.file, { start, end: start + length - 1 })
const stream = createReadStream(command.file, { start, end: start + length - 1 })
let offset = 0
stream.on("data", (chunk: string | Buffer) => {
const bytes = Buffer.from(chunk)
@@ -212,7 +212,7 @@ const layer = () =>
return {
output: buffer.subarray(0, bytesRead).toString("utf8"),
cursor: start + bytesRead,
size: session.size,
size: command.size,
truncated: false,
}
})
@@ -257,7 +257,7 @@ const layer = () =>
// Spawn through the Environment and stream combined output to the file. The handle is scope-bound, so
// the managing fiber keeps its scope open until the command terminates (it awaits `done` at the
// end). `create` returns once `ready` resolves with the registered session.
// end). `create` returns once `ready` resolves with the registered command.
const ready = Deferred.makeUnsafe<Active, AppProcess.AppProcessError>()
runFork(
Effect.scoped(
@@ -275,7 +275,7 @@ const layer = () =>
.pipe(
Effect.mapError((cause) => new AppProcess.AppProcessError({ command: invocation.command, cause })),
)
const session: Active = {
const command: Active = {
info: produce(info, (draft) => {
draft.pid = handle.pid
}),
@@ -283,7 +283,7 @@ const layer = () =>
size: 0,
done: Deferred.makeUnsafe<Info, NotFoundError>(),
}
sessions.set(id, session)
commands.set(id, command)
const stream = createWriteStream(file)
const outputDone = Latch.makeUnsafe()
@@ -291,7 +291,7 @@ const layer = () =>
Stream.runForEach((chunk: Uint8Array) =>
Effect.sync(() => {
stream.write(chunk)
session.size += chunk.length
command.size += chunk.length
}),
),
)
@@ -317,8 +317,8 @@ const layer = () =>
const finish = (status: Info["status"], exit?: number, beforeWait = Effect.void) =>
Effect.gen(function* () {
if (session.info.status !== "running") return
session.info = produce(session.info, (draft) => {
if (command.info.status !== "running") return
command.info = produce(command.info, (draft) => {
draft.status = status
if (exit !== undefined) draft.exit = exit
draft.time.completed = Date.now()
@@ -326,10 +326,10 @@ const layer = () =>
yield* beforeWait
yield* outputDone.await
// Resolve waiters with the terminal Info before any retention eviction, so an evicted
// session still reports success rather than the removal NotFoundError. This runs before
// command still reports success rather than the removal NotFoundError. This runs before
// the timeout-fiber interrupt below, which on the timeout path would otherwise cancel
// this very fiber (finish is invoked by the timeout fiber) before waiters are resolved.
yield* Deferred.succeed(session.done, session.info)
yield* Deferred.succeed(command.done, command.info)
yield* bus.publish(Shell.Event.Exited, {
id,
...(exit !== undefined ? { exit } : {}),
@@ -339,19 +339,19 @@ const layer = () =>
while (exitOrder.length > EXITED_LIMIT) {
const oldest = exitOrder[0]
if (!oldest) break
yield* removeSession(Shell.ID.make(oldest))
yield* removeCommand(oldest)
}
// Cancel a pending timeout once the command exits on its own. Interrupting last avoids
// aborting finish when finish itself runs on the timeout fiber.
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
if (command.timeoutFiber) yield* Fiber.interrupt(command.timeoutFiber)
})
session.timeout = (duration) =>
command.timeout = (duration) =>
Effect.gen(function* () {
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
session.timeoutFiber = undefined
if (duration === 0 || session.info.status !== "running") return
session.timeoutFiber = runFork(
if (command.timeoutFiber) yield* Fiber.interrupt(command.timeoutFiber)
command.timeoutFiber = undefined
if (duration === 0 || command.info.status !== "running") return
command.timeoutFiber = runFork(
Effect.sleep(Duration.millis(duration)).pipe(
Effect.flatMap(() =>
finish("timeout", undefined, handle.kill().pipe(Effect.catch(() => Effect.void))),
@@ -360,7 +360,7 @@ const layer = () =>
)
})
yield* session.timeout(invocation.timeout)
yield* command.timeout(invocation.timeout)
runFork(
handle.exitCode.pipe(
@@ -370,16 +370,16 @@ const layer = () =>
)
yield* bus.publish(Shell.Event.Created, { info })
yield* Deferred.succeed(ready, session)
yield* Deferred.succeed(ready, command)
// Hold the handle's scope open until the command terminates; closing it earlier would
// release (kill) the process before its exit is observed.
yield* Deferred.await(session.done).pipe(Effect.catch(() => Effect.void))
yield* Deferred.await(command.done).pipe(Effect.catch(() => Effect.void))
}),
).pipe(Effect.catchTag("AppProcessError", (error) => Deferred.fail(ready, error))),
)
const session = yield* Deferred.await(ready)
return session.info
const command = yield* Deferred.await(ready)
return command.info
})
return Service.of({ create, list, get, wait, timeout, output, remove })
+1
View File
@@ -72,6 +72,7 @@ export const layer = Layer.effect(
server: tool.server,
name: tool.name,
args: (input ?? {}) as Record<string, unknown>,
sessionID: context.sessionID,
})
.pipe(
Effect.catchTags({
+120 -3
View File
@@ -375,7 +375,100 @@ it.effect("projects replay metadata onto AI SDK prompt parts", () =>
}),
)
it.effect("moves a tool image through the real Mistral provider as a user message", () =>
it.effect("normalizes file data across AI SDK prompt parts", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* aisdk.hook.sdk((event) => {
event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
})
const resolved = yield* aisdk.model(model("opaque-provider"))
const bytes = new Uint8Array([0, 1, 2, 3])
const prepared = yield* compileRequest(
LLM.request({
model: resolved,
messages: [
Message.user([
{ type: "media", mediaType: "image/png", data: bytes, filename: "bytes.png" },
{ type: "media", mediaType: "image/png", data: "AAAA", filename: "base64.png" },
{
type: "media",
mediaType: "image/png",
data: "data:image/png;charset=utf-8;base64,AQID",
filename: "inline.png",
},
{ type: "media", mediaType: "image/png", data: "https://example.com/image.png" },
{ type: "media", mediaType: "image/png", data: "s3://bucket/image.png" },
]),
Message.assistant({
type: "media",
mediaType: "application/pdf",
data: "http://example.com/document.pdf",
filename: "document.pdf",
}),
Message.tool({
id: "call_1",
name: "screenshot",
result: {
type: "content",
value: [{ type: "file", uri: "data:image/png;base64,BAUG", mime: "image/png", name: "tool.png" }],
},
}),
],
}),
)
expect(prepared.body.prompt).toEqual([
{
role: "user",
content: [
{ type: "file", mediaType: "image/png", data: bytes, filename: "bytes.png" },
{ type: "file", mediaType: "image/png", data: "AAAA", filename: "base64.png" },
{ type: "file", mediaType: "image/png", data: "AQID", filename: "inline.png" },
{
type: "file",
mediaType: "image/png",
data: new URL("https://example.com/image.png"),
filename: undefined,
},
{ type: "file", mediaType: "image/png", data: "s3://bucket/image.png", filename: undefined },
],
},
{
role: "assistant",
content: [
{
type: "file",
mediaType: "application/pdf",
data: new URL("http://example.com/document.pdf"),
filename: "document.pdf",
},
],
},
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call_1",
toolName: "screenshot",
output: { type: "text", value: "Media attached in the following user message." },
providerOptions: undefined,
},
],
},
{
role: "user",
content: [
{ type: "text", text: "Attached media from tool result:" },
{ type: "file", mediaType: "image/png", data: "BAUG", filename: "tool.png" },
],
},
])
}),
)
it.effect("normalizes user and tool media through the real Mistral provider", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
let body: { messages?: unknown[] } | undefined
@@ -415,7 +508,14 @@ it.effect("moves a tool image through the real Mistral provider as a user messag
LLM.request({
model: resolved,
messages: [
Message.user("Inspect the screenshot."),
Message.user([
{ type: "text", text: "Inspect the attachments." },
{ type: "media", mediaType: "image/png", data: new Uint8Array([0, 1, 2, 3]) },
{ type: "media", mediaType: "image/png", data: "AQID" },
{ type: "media", mediaType: "image/png", data: "data:image/png;base64,BAUG" },
{ type: "media", mediaType: "image/png", data: "http://example.com/image.png" },
{ type: "media", mediaType: "application/pdf", data: "https://example.com/document.pdf" },
]),
Message.assistant({ type: "tool-call", id: "call_1", name: "screenshot", input: {} }),
Message.tool({
type: "tool-result",
@@ -426,6 +526,12 @@ it.effect("moves a tool image through the real Mistral provider as a user messag
value: [
{ type: "text", text: "Screenshot captured" },
{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png", name: "screen.png" },
{
type: "file",
uri: "https://example.com/tool-document.pdf",
mime: "application/pdf",
name: "tool-document.pdf",
},
],
},
}),
@@ -434,7 +540,17 @@ it.effect("moves a tool image through the real Mistral provider as a user messag
).pipe(Effect.provide(client))
expect(body?.messages).toEqual([
{ role: "user", content: [{ type: "text", text: "Inspect the screenshot." }] },
{
role: "user",
content: [
{ type: "text", text: "Inspect the attachments." },
{ type: "image_url", image_url: "data:image/png;base64,AAECAw==" },
{ type: "image_url", image_url: "data:image/png;base64,AQID" },
{ type: "image_url", image_url: "data:image/png;base64,BAUG" },
{ type: "image_url", image_url: "http://example.com/image.png" },
{ type: "document_url", document_url: "https://example.com/document.pdf" },
],
},
{
role: "assistant",
content: "",
@@ -457,6 +573,7 @@ it.effect("moves a tool image through the real Mistral provider as a user messag
content: [
{ type: "text", text: "Attached media from tool result:" },
{ type: "image_url", image_url: "data:image/png;base64,AAAA" },
{ type: "document_url", document_url: "https://example.com/tool-document.pdf" },
],
},
])
+22 -24
View File
@@ -85,8 +85,8 @@ describe("Bus Session routing", () => {
projectID: Project.ID.global,
})
const done = yield* bus.publish(Done, {})
expect(Array.from(yield* Fiber.join(first))).toEqual([moved, done])
expect(Array.from(yield* Fiber.join(second))).toEqual([moved, after, same, done])
expect(yield* Fiber.join(first)).toEqual([moved, done])
expect(yield* Fiber.join(second)).toEqual([moved, after, same, done])
expect(moved.location).toEqual(a)
}),
)
@@ -130,8 +130,8 @@ describe("Bus Session routing", () => {
)
const after = yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID: child })
const done = yield* bus.publish(Done, {})
expect(Array.from(yield* Fiber.join(first)).map((event) => event.id)).toEqual([eventID, after.id, done.id])
expect(Array.from(yield* Fiber.join(second))).toEqual([done])
expect((yield* Fiber.join(first)).map((event) => event.id)).toEqual([eventID, after.id, done.id])
expect(yield* Fiber.join(second)).toEqual([done])
}),
)
}),
@@ -158,19 +158,17 @@ describe("Bus Session routing", () => {
const explicit = yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID: id }, { location: b })
const done = yield* bus.publish(Done, {})
expect(Array.from(yield* Fiber.join(first))).toEqual([renamed, text, broadcast, done])
expect(Array.from(yield* Fiber.join(second))).toEqual([broadcast, explicit, done])
expect(Array.from(yield* Fiber.join(workspace))).toEqual([broadcast, done])
expect(Array.from(yield* Fiber.join(global))).toEqual([renamed, text, broadcast, explicit, done])
expect(yield* Fiber.join(first)).toEqual([renamed, text, broadcast, done])
expect(yield* Fiber.join(second)).toEqual([broadcast, explicit, done])
expect(yield* Fiber.join(workspace)).toEqual([broadcast, done])
expect(yield* Fiber.join(global)).toEqual([renamed, text, broadcast, explicit, done])
expect(listened).toEqual([renamed, text, broadcast, explicit, done])
expect(renamed).not.toHaveProperty("location")
expect(text).not.toHaveProperty("location")
expect(JSON.parse(JSON.stringify(renamed))).not.toHaveProperty("location")
const history = yield* bus.log({ aggregateID: id }).pipe(Stream.runCollect)
expect(
Array.from(history)
.filter((event): event is Event.Payload => !Bus.isSynced(event))
.every((event) => !event.location),
history.filter((event): event is Event.Payload => !Bus.isSynced(event)).every((event) => !event.location),
).toBe(true)
}),
)
@@ -197,8 +195,8 @@ describe("Bus Session routing", () => {
yield* bus.publish(SessionEvent.Moved, { sessionID: id, location: b, projectID: Project.ID.global })
const expected = yield* bus.publish(SessionEvent.Renamed, { sessionID: id, title: "destination" })
const done = yield* bus.publish(Done, {})
expect(Array.from(yield* Fiber.join(typed))).toEqual([expected])
expect(Array.from(yield* Fiber.join(multiple))).toEqual([expected, done])
expect(yield* Fiber.join(typed)).toEqual([expected])
expect(yield* Fiber.join(multiple)).toEqual([expected, done])
}),
)
@@ -226,8 +224,8 @@ describe("Bus Session routing", () => {
const done = yield* bus.publish(Done, {})
yield* Deferred.succeed(gate, undefined)
expect(Array.from(yield* Fiber.join(first))).toEqual([created, before, moved, done])
expect(Array.from(yield* Fiber.join(second))).toEqual([moved, after, done])
expect(yield* Fiber.join(first)).toEqual([created, before, moved, done])
expect(yield* Fiber.join(second)).toEqual([moved, after, done])
expect(moved).not.toHaveProperty("location")
}),
)
@@ -245,9 +243,9 @@ describe("Bus Session routing", () => {
const database = yield* Database.Service
expect(yield* database.db.select().from(SessionTable).where(eq(SessionTable.id, id)).get()).toBeUndefined()
expect(Array.from(yield* Fiber.join(first))).toEqual([deleted, done])
expect(Array.from(yield* Fiber.join(second))).toEqual([done])
expect(Array.from(yield* Fiber.join(global))).toEqual([deleted, missing, done])
expect(yield* Fiber.join(first)).toEqual([deleted, done])
expect(yield* Fiber.join(second)).toEqual([done])
expect(yield* Fiber.join(global)).toEqual([deleted, missing, done])
}),
)
@@ -266,8 +264,8 @@ describe("Bus Session routing", () => {
])
const done = yield* bus.publish(Done, {})
yield* Deferred.succeed(gate, undefined)
expect(Array.from(yield* Fiber.join(first))).toEqual([events[0], events[1], done])
expect(Array.from(yield* Fiber.join(second))).toEqual([events[1], events[2], events[3], done])
expect(yield* Fiber.join(first)).toEqual([events[0], events[1], done])
expect(yield* Fiber.join(second)).toEqual([events[1], events[2], events[3], done])
}),
)
@@ -295,8 +293,8 @@ describe("Bus Session routing", () => {
const done = yield* bus.publish(Done, {})
expect(Exit.isFailure(single)).toBe(true)
expect(Exit.isFailure(batch)).toBe(true)
expect(Array.from(yield* Fiber.join(first))).toEqual([before, after, done])
expect(Array.from(yield* Fiber.join(second))).toEqual([done])
expect(yield* Fiber.join(first)).toEqual([before, after, done])
expect(yield* Fiber.join(second)).toEqual([done])
}),
)
@@ -327,8 +325,8 @@ describe("Bus Session routing", () => {
{ publish: true },
)
const done = yield* bus.publish(Done, {})
expect(Array.from(yield* Fiber.join(first))).toEqual([done])
const received = Array.from(yield* Fiber.join(second))
expect(yield* Fiber.join(first)).toEqual([done])
const received = yield* Fiber.join(second)
expect(received.map((event) => event.id)).toEqual([after.id, replayID, done.id])
expect(received[1]).not.toHaveProperty("location")
}),
+15 -15
View File
@@ -123,7 +123,7 @@ describe("Bus", () => {
yield* bus.publish(Message, { text: "hello" })
yield* bus.publish(CountMessage, { count: 2 })
const received = Array.from(yield* Fiber.join(fiber)).map((event) =>
const received = (yield* Fiber.join(fiber)).map((event) =>
event.type === "test.message" ? event.data.text : event.data.count,
)
expect(received).toEqual(["hello", 2])
@@ -136,7 +136,7 @@ describe("Bus", () => {
const fiber = yield* bus.subscribe(Message).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
const event = yield* bus.publish(Message, { text: "hello" })
const received = Array.from(yield* Fiber.join(fiber))
const received = yield* Fiber.join(fiber)
expect(received).toEqual([event])
expect(event.type).toBe("test.message")
@@ -212,8 +212,8 @@ describe("Bus", () => {
yield* Effect.yieldNow
const event = yield* bus.publish(Message, { text: "hello" })
expect(Array.from(yield* Fiber.join(typed))).toEqual([event])
expect(Array.from(yield* Fiber.join(wildcard))).toEqual([event])
expect(yield* Fiber.join(typed)).toEqual([event])
expect(yield* Fiber.join(wildcard)).toEqual([event])
}),
)
@@ -602,7 +602,7 @@ describe("Bus", () => {
yield* bus.publish(DurableMessage, durableData(aggregateID, "two"))
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
expect((yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
[1, durableData(aggregateID, "one")],
[2, durableData(aggregateID, "two")],
])
@@ -618,7 +618,7 @@ describe("Bus", () => {
yield* bus.publish(DurableMessage, durableData(aggregateID, "one"))
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
expect((yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
[0, durableData(aggregateID, "zero")],
[1, durableData(aggregateID, "one")],
])
@@ -653,7 +653,7 @@ describe("Bus", () => {
yield* bus.publish(DurableMessage, durableData(aggregateID, "during handoff"))
yield* Deferred.succeed(continueRead, undefined)
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
expect((yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
[0, durableData(aggregateID, "during handoff")],
])
}).pipe(Effect.provide(eventLayer))
@@ -672,7 +672,7 @@ describe("Bus", () => {
yield* bus.publish(DurableMessage, durableData(aggregateID, String(index)))
}
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual(
expect((yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual(
Array.from({ length: count }, (_, index) => [index, durableData(aggregateID, String(index))]),
)
}),
@@ -688,7 +688,7 @@ describe("Bus", () => {
yield* bus.publish(Message, { text: "live only" })
yield* bus.publish(DurableMessage, durableData(aggregateID, "durable"))
expect(Array.from(yield* Fiber.join(fiber)).map((event) => event.type)).toEqual([DurableMessage.type])
expect((yield* Fiber.join(fiber)).map((event) => event.type)).toEqual([DurableMessage.type])
}),
)
@@ -1268,7 +1268,7 @@ describe("Bus", () => {
yield* bus.publish(DurableMessage, durableData(aggregateID, "zero"))
yield* bus.publish(DurableMessage, durableData(aggregateID, "one"))
const items = Array.from(yield* Stream.runCollect(bus.log({ aggregateID })))
const items = yield* Stream.runCollect(bus.log({ aggregateID }))
expect(items.map((item) => (Bus.isSynced(item) ? item.type : item.durable?.seq))).toEqual([
Event.Seq.make(0),
@@ -1284,9 +1284,9 @@ describe("Bus", () => {
const bus = yield* Bus.Service
const aggregateID = Session.ID.create()
const empty = Array.from(yield* Stream.runCollect(bus.log({ aggregateID })))
const empty = yield* Stream.runCollect(bus.log({ aggregateID }))
yield* bus.publish(DurableMessage, durableData(aggregateID, "zero"))
const drained = Array.from(yield* Stream.runCollect(bus.log({ aggregateID, after: 0 })))
const drained = yield* Stream.runCollect(bus.log({ aggregateID, after: 0 }))
expect(empty).toEqual([{ type: "log.synced", aggregateID }])
expect(empty[0]).not.toHaveProperty("seq")
@@ -1306,7 +1306,7 @@ describe("Bus", () => {
yield* bus.publish(DurableMessage, durableData(aggregateID, "one"))
const items = Array.from(yield* Fiber.join(fiber))
const items = yield* Fiber.join(fiber)
expect(items.map((item) => (Bus.isSynced(item) ? item : item.durable?.seq))).toEqual([
Event.Seq.make(0),
{ type: "log.synced", aggregateID, seq: Event.Seq.make(0) },
@@ -1330,7 +1330,7 @@ describe("Bus", () => {
yield* bus.publish(DurableMessage, durableData(aggregateID, "three"))
yield* bus.publish(DurableMessage, durableData(aggregateID, "four"))
const items = Array.from(yield* Stream.runCollect(bus.log({ aggregateID })))
const items = yield* Stream.runCollect(bus.log({ aggregateID }))
expect(items.map((item) => (Bus.isSynced(item) ? item.type : item.durable?.seq))).toEqual([
Event.Seq.make(0),
@@ -1378,7 +1378,7 @@ describe("Bus", () => {
yield* bus.publish(DurableMessage, durableData(aggregateID, "one"))
yield* Deferred.succeed(releaseRead, undefined)
const items = Array.from(yield* Fiber.join(fiber))
const items = yield* Fiber.join(fiber)
expect(items.map((item) => (Bus.isSynced(item) ? item : item.durable?.seq))).toEqual([
Event.Seq.make(0),
{ type: "log.synced", aggregateID, seq: Event.Seq.make(0) },
+4 -16
View File
@@ -330,10 +330,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
)
it.live("loads legacy file-based agents from config directories", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
@@ -407,10 +404,7 @@ Use native v2 fields.`,
for (const testCase of sourceCases()) {
it.effect(`rebuilds agents when a source file is ${testCase.name}`, () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const directory = path.join(tmp.path, testCase.source)
@@ -445,10 +439,7 @@ Use native v2 fields.`,
}
it.effect("coalesces updates inside the debounce window into one rebuild", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const directory = path.join(tmp.path, "agents")
@@ -485,10 +476,7 @@ Use native v2 fields.`,
)
it.effect("ignores updates outside agent source directories", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const directory = path.join(tmp.path, "agents")
+4 -16
View File
@@ -54,10 +54,7 @@ const decode = Schema.decodeUnknownSync(Info)
describe("ConfigCommandPlugin.Plugin", () => {
it.live("loads inline and file-based commands in config order", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
@@ -166,10 +163,7 @@ Review files`,
for (const testCase of sourceCases()) {
it.effect(`rebuilds commands when a source file is ${testCase.name}`, () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const directory = path.join(tmp.path, "commands")
@@ -212,10 +206,7 @@ Review files`,
}
it.effect("coalesces updates inside the debounce window into one rebuild", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const directory = path.join(tmp.path, "commands")
@@ -254,10 +245,7 @@ Review files`,
)
it.effect("ignores updates outside command source directories", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const directory = path.join(tmp.path, "commands")
+255 -335
View File
@@ -1,7 +1,7 @@
import path from "path"
import fs from "fs/promises"
import { describe, expect } from "bun:test"
import { Effect, Fiber, Layer, Logger, PubSub, Schema, Stream } from "effect"
import { describe, expect, test } from "bun:test"
import { Effect, Fiber, Layer, Logger, Schema, Stream } from "effect"
import { FastCheck } from "effect/testing"
import { Config } from "@opencode-ai/core/config"
import { AgentsDirectory, Directory, Document, Event, Info } from "@opencode-ai/schema/config"
@@ -77,10 +77,7 @@ const provider = {
describe("Config", () => {
it.live("excludes home-level claude and agents directories when global is disabled", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) => {
const global = path.join(tmp.path, "global")
const home = path.join(global, "home")
@@ -120,10 +117,7 @@ describe("Config", () => {
)
it.live("excludes global config reached through the project walk when global is disabled", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) => {
// The location sits BENEATH the global config dir, so the upward walk
// reaches the global opencode.json as a direct file.
@@ -156,10 +150,7 @@ describe("Config", () => {
)
it.live("loads explicit file and content overrides in priority order", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) => {
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
@@ -194,10 +185,7 @@ describe("Config", () => {
)
it.live("skips project configuration when project discovery is disabled", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) => {
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
@@ -225,10 +213,7 @@ describe("Config", () => {
)
it.live("reloads external config and publishes directory updates", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const global = path.join(tmp.path, "global")
@@ -261,10 +246,7 @@ describe("Config", () => {
)
it.live("exposes filesystem updates under config roots through changes", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const global = path.join(tmp.path, "global")
@@ -296,10 +278,7 @@ describe("Config", () => {
// watch being torn down, making recreation invisible) only reproduces with
// path-faithful event delivery.
it.live("keeps watching a deleted config file so recreating it reloads", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const global = path.join(tmp.path, "global")
@@ -369,26 +348,24 @@ describe("Config", () => {
}).pipe(Effect.provide(Config.testLayer())),
)
it.effect("returns the latest defined scalar from priority-ordered documents", () =>
Effect.sync(() => {
const entries = [
new Document({
type: "document",
info: new Info({ model: selection("openrouter/openai/gpt-5") }),
}),
new Directory({ type: "directory", path: AbsolutePath.make("/skills") }),
new AgentsDirectory({ type: "agents", path: AbsolutePath.make("/agents") }),
new Document({ type: "document", info: new Info({}) }),
new Document({
type: "document",
info: new Info({ model: selection("openrouter/openai/gpt-5.5") }),
}),
]
test("returns the latest defined scalar from priority-ordered documents", () => {
const entries = [
new Document({
type: "document",
info: new Info({ model: selection("openrouter/openai/gpt-5") }),
}),
new Directory({ type: "directory", path: AbsolutePath.make("/skills") }),
new AgentsDirectory({ type: "agents", path: AbsolutePath.make("/agents") }),
new Document({ type: "document", info: new Info({}) }),
new Document({
type: "document",
info: new Info({ model: selection("openrouter/openai/gpt-5.5") }),
}),
]
expect(Config.latest(entries, "model")).toEqual(selection("openrouter/openai/gpt-5.5"))
expect(Config.latest(entries, "default_agent")).toBeUndefined()
}),
)
expect(Config.latest(entries, "model")).toEqual(selection("openrouter/openai/gpt-5.5"))
expect(Config.latest(entries, "default_agent")).toBeUndefined()
})
it.live("tolerates unavailable authenticated wellknown config and reloads it later", () =>
Effect.acquireUseRelease(
@@ -580,268 +557,241 @@ describe("Config", () => {
).pipe(Effect.provide(Logger.layer([logger])))
})
it.effect("migrates arbitrary v1 configuration into valid v2 configuration", () =>
Effect.sync(() => {
FastCheck.assert(
FastCheck.property(Schema.toArbitrary(ConfigV1.Info)(FastCheck), (info) => {
const parsed = Schema.decodeUnknownSync(ConfigV1.Info)(
Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(
Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(info),
),
)
Schema.decodeUnknownSync(Info)(ConfigMigrateV1.migrate(parsed), { errors: "all" })
}),
{ numRuns: 100 },
)
}),
)
test("migrates arbitrary v1 configuration into valid v2 configuration", () => {
FastCheck.assert(
FastCheck.property(Schema.toArbitrary(ConfigV1.Info)(FastCheck), (info) => {
const parsed = Schema.decodeUnknownSync(ConfigV1.Info)(
Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(
Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(info),
),
)
Schema.decodeUnknownSync(Info)(ConfigMigrateV1.migrate(parsed), { errors: "all" })
}),
{ numRuns: 100 },
)
}, 30_000)
it.effect("migrates the v1 experimental subagent depth", () =>
Effect.sync(() => {
expect(ConfigMigrateV1.migrate({ experimental: { subagent_depth: 2 } }).experimental?.subagent_depth).toBe(2)
}),
)
test("migrates the v1 experimental subagent depth", () => {
expect(ConfigMigrateV1.migrate({ experimental: { subagent_depth: 2 } }).experimental?.subagent_depth).toBe(2)
})
it.effect("migrates the v1 small model to the title agent", () =>
Effect.sync(() => {
expect(
ConfigMigrateV1.migrate({
small_model: "anthropic/claude-haiku-4-5",
agent: { title: { prompt: "Custom title prompt" } },
}).agents?.title,
).toEqual({
model: { providerID: "anthropic", model: "claude-haiku-4-5" },
system: "Custom title prompt",
})
}),
)
test("migrates the v1 small model to the title agent", () => {
expect(
ConfigMigrateV1.migrate({
small_model: "anthropic/claude-haiku-4-5",
agent: { title: { prompt: "Custom title prompt" } },
}).agents?.title,
).toEqual({
model: { providerID: "anthropic", model: "claude-haiku-4-5" },
system: "Custom title prompt",
})
})
it.effect("migrates v1 provider lists to policies", () =>
Effect.sync(() => {
expect(
ConfigMigrateV1.migrate({
enabled_providers: ["anthropic", "openai"],
disabled_providers: ["openai"],
}).experimental?.policies,
).toEqual([
{ action: "provider.use", resource: "*", effect: "deny" },
{ action: "provider.use", resource: "anthropic", effect: "allow" },
{ action: "provider.use", resource: "openai", effect: "allow" },
{ action: "provider.use", resource: "openai", effect: "deny" },
])
expect(ConfigMigrateV1.migrate({ enabled_providers: [] }).experimental?.policies).toEqual([
{ action: "provider.use", resource: "*", effect: "deny" },
])
}),
)
test("migrates v1 provider lists to policies", () => {
expect(
ConfigMigrateV1.migrate({
enabled_providers: ["anthropic", "openai"],
disabled_providers: ["openai"],
}).experimental?.policies,
).toEqual([
{ action: "provider.use", resource: "*", effect: "deny" },
{ action: "provider.use", resource: "anthropic", effect: "allow" },
{ action: "provider.use", resource: "openai", effect: "allow" },
{ action: "provider.use", resource: "openai", effect: "deny" },
])
expect(ConfigMigrateV1.migrate({ enabled_providers: [] }).experimental?.policies).toEqual([
{ action: "provider.use", resource: "*", effect: "deny" },
])
})
it.effect("migrates v1 provider setup options into AISDK settings", () =>
Effect.sync(() => {
const migrated = ConfigMigrateV1.migrate({
provider: {
bedrock: {
npm: "@ai-sdk/amazon-bedrock",
models: { claude: { provider: { npm: "@ai-sdk/anthropic" } } },
options: {
headers: { "x-test": "1" },
body: { trace: true },
region: "us-east-1",
profile: "dev",
},
test("migrates v1 provider setup options into AISDK settings", () => {
const migrated = ConfigMigrateV1.migrate({
provider: {
bedrock: {
npm: "@ai-sdk/amazon-bedrock",
models: { claude: { provider: { npm: "@ai-sdk/anthropic" } } },
options: {
headers: { "x-test": "1" },
body: { trace: true },
region: "us-east-1",
profile: "dev",
},
},
})
},
})
expect(migrated.providers?.bedrock).toMatchObject({
package: Provider.aisdk("@ai-sdk/amazon-bedrock"),
models: { claude: { package: Provider.aisdk("@ai-sdk/anthropic") } },
settings: { region: "us-east-1", profile: "dev" },
headers: { "x-test": "1" },
body: { trace: true },
})
}),
)
expect(migrated.providers?.bedrock).toMatchObject({
package: Provider.aisdk("@ai-sdk/amazon-bedrock"),
models: { claude: { package: Provider.aisdk("@ai-sdk/anthropic") } },
settings: { region: "us-east-1", profile: "dev" },
headers: { "x-test": "1" },
body: { trace: true },
})
})
it.effect("renames old provider IDs while migrating v1 configuration", () =>
Effect.sync(() => {
const migrated = ConfigMigrateV1.migrate({
model: "azure-cognitive-services/deployment",
enabled_providers: ["google-vertex-anthropic"],
disabled_providers: ["azure-cognitive-services"],
agent: {
reviewer: { model: "google-vertex-anthropic/claude-sonnet" },
test("renames old provider IDs while migrating v1 configuration", () => {
const migrated = ConfigMigrateV1.migrate({
model: "azure-cognitive-services/deployment",
enabled_providers: ["google-vertex-anthropic"],
disabled_providers: ["azure-cognitive-services"],
agent: {
reviewer: { model: "google-vertex-anthropic/claude-sonnet" },
},
command: {
review: { template: "Review", model: "azure-cognitive-services/deployment" },
},
provider: {
"azure-cognitive-services": {
npm: "@ai-sdk/azure",
env: ["AZURE_COGNITIVE_SERVICES_RESOURCE_NAME", "AZURE_COGNITIVE_SERVICES_API_KEY"],
models: { deployment: {} },
},
"google-vertex-anthropic": {
npm: "@ai-sdk/google-vertex/anthropic",
options: { project: "test-project", location: "us-central1" },
models: { "claude-sonnet": {} },
},
},
})
expect(migrated.model).toEqual({ providerID: "azure", model: "deployment" })
expect(migrated.agents?.reviewer?.model).toEqual({ providerID: "google-vertex", model: "claude-sonnet" })
expect(migrated.commands?.review?.model).toEqual({ providerID: "azure", model: "deployment" })
expect(migrated.experimental?.policies).toEqual([
{ action: "provider.use", resource: "*", effect: "deny" },
{ action: "provider.use", resource: "google-vertex", effect: "allow" },
{ action: "provider.use", resource: "azure", effect: "deny" },
])
expect(migrated.providers?.azure).toMatchObject({
env: ["AZURE_COGNITIVE_SERVICES_API_KEY"],
package: Provider.aisdk("@ai-sdk/azure"),
models: { deployment: {} },
})
expect(migrated.providers?.["azure-cognitive-services"]).toBeUndefined()
expect(migrated.providers?.["google-vertex"]).toMatchObject({
settings: { project: "test-project", location: "us-central1" },
models: {
"claude-sonnet": { package: Provider.aisdk("@ai-sdk/google-vertex/anthropic") },
},
})
expect(migrated.providers?.["google-vertex"]).not.toHaveProperty("package")
expect(migrated.providers?.["google-vertex-anthropic"]).toBeUndefined()
})
test("preserves the generated base URL for v1 Azure OpenAI-compatible providers", () => {
const migrated = ConfigMigrateV1.migrate({
provider: {
"azure-cognitive-services": {
npm: "@ai-sdk/openai-compatible",
env: ["AZURE_COGNITIVE_SERVICES_RESOURCE_NAME", "AZURE_COGNITIVE_SERVICES_API_KEY"],
},
},
})
expect(migrated.providers?.azure).toMatchObject({
env: ["AZURE_COGNITIVE_SERVICES_API_KEY"],
package: Provider.aisdk("@ai-sdk/openai-compatible"),
settings: {
baseURL: "https://${AZURE_COGNITIVE_SERVICES_RESOURCE_NAME}.cognitiveservices.azure.com/openai",
},
})
})
test("ignores old provider IDs when the current provider ID is configured", () => {
const migrated = ConfigMigrateV1.migrate({
provider: {
azure: { models: { current: {} } },
"azure-cognitive-services": { models: { legacy: {} } },
"google-vertex": { models: { gemini: {} } },
"google-vertex-anthropic": { models: { claude: {} } },
},
})
expect(migrated.providers?.azure?.models).toEqual({ current: expect.anything() })
expect(migrated.providers?.["google-vertex"]?.models).toEqual({ gemini: expect.anything() })
})
test("preserves the built-in package for v1 Vertex Anthropic custom models", () => {
const migrated = ConfigMigrateV1.migrate({
provider: {
"google-vertex-anthropic": {
models: { claude: {} },
},
},
})
expect(migrated.providers?.["google-vertex"]?.package).toBeUndefined()
expect(migrated.providers?.["google-vertex"]?.models?.claude?.package).toBe(
Provider.aisdk("@ai-sdk/google-vertex/anthropic"),
)
})
test("migrates v1 interleaved fields to compatibility", () => {
const migrated = ConfigMigrateV1.migrate({
provider: {
custom: {
models: {
object: { interleaved: { field: "vendor_reasoning" } },
string: { interleaved: "reasoning_text" },
boolean: { interleaved: true },
},
},
},
})
expect(migrated.providers?.custom?.models?.object?.compatibility).toEqual({
reasoningField: "vendor_reasoning",
})
expect(migrated.providers?.custom?.models?.string?.compatibility).toEqual({ reasoningField: "reasoning_text" })
expect(migrated.providers?.custom?.models?.boolean?.compatibility).toBeUndefined()
})
test("migrates v1 command configuration", () => {
expect(
ConfigMigrateV1.migrate({
command: {
review: { template: "Review", model: "azure-cognitive-services/deployment" },
},
provider: {
"azure-cognitive-services": {
npm: "@ai-sdk/azure",
env: ["AZURE_COGNITIVE_SERVICES_RESOURCE_NAME", "AZURE_COGNITIVE_SERVICES_API_KEY"],
models: { deployment: {} },
},
"google-vertex-anthropic": {
npm: "@ai-sdk/google-vertex/anthropic",
options: { project: "test-project", location: "us-central1" },
models: { "claude-sonnet": {} },
review: {
template: "Review changes",
description: "Review code",
agent: "reviewer",
model: "anthropic/claude",
variant: "high",
subtask: true,
},
},
})
}).commands,
).toEqual({
review: {
template: "Review changes",
description: "Review code",
agent: "reviewer",
model: { providerID: "anthropic", model: "claude", variant: "high" },
subtask: true,
},
})
})
expect(migrated.model).toEqual({ providerID: "azure", model: "deployment" })
expect(migrated.agents?.reviewer?.model).toEqual({ providerID: "google-vertex", model: "claude-sonnet" })
expect(migrated.commands?.review?.model).toEqual({ providerID: "azure", model: "deployment" })
expect(migrated.experimental?.policies).toEqual([
{ action: "provider.use", resource: "*", effect: "deny" },
{ action: "provider.use", resource: "google-vertex", effect: "allow" },
{ action: "provider.use", resource: "azure", effect: "deny" },
])
expect(migrated.providers?.azure).toMatchObject({
env: ["AZURE_COGNITIVE_SERVICES_API_KEY"],
package: Provider.aisdk("@ai-sdk/azure"),
models: { deployment: {} },
})
expect(migrated.providers?.["azure-cognitive-services"]).toBeUndefined()
expect(migrated.providers?.["google-vertex"]).toMatchObject({
settings: { project: "test-project", location: "us-central1" },
models: {
"claude-sonnet": { package: Provider.aisdk("@ai-sdk/google-vertex/anthropic") },
test("normalizes renamed permission actions when migrating v1 permissions", () => {
expect(
ConfigMigrateV1.migrate({
permission: {
task: "ask",
bash: { "git status": "allow", "*": "deny" },
write: "deny",
read: "allow",
},
})
expect(migrated.providers?.["google-vertex"]).not.toHaveProperty("package")
expect(migrated.providers?.["google-vertex-anthropic"]).toBeUndefined()
}),
)
it.effect("preserves the generated base URL for v1 Azure OpenAI-compatible providers", () =>
Effect.sync(() => {
const migrated = ConfigMigrateV1.migrate({
provider: {
"azure-cognitive-services": {
npm: "@ai-sdk/openai-compatible",
env: ["AZURE_COGNITIVE_SERVICES_RESOURCE_NAME", "AZURE_COGNITIVE_SERVICES_API_KEY"],
},
},
})
expect(migrated.providers?.azure).toMatchObject({
env: ["AZURE_COGNITIVE_SERVICES_API_KEY"],
package: Provider.aisdk("@ai-sdk/openai-compatible"),
settings: {
baseURL: "https://${AZURE_COGNITIVE_SERVICES_RESOURCE_NAME}.cognitiveservices.azure.com/openai",
},
})
}),
)
it.effect("ignores old provider IDs when the current provider ID is configured", () =>
Effect.sync(() => {
const migrated = ConfigMigrateV1.migrate({
provider: {
azure: { models: { current: {} } },
"azure-cognitive-services": { models: { legacy: {} } },
"google-vertex": { models: { gemini: {} } },
"google-vertex-anthropic": { models: { claude: {} } },
},
})
expect(migrated.providers?.azure?.models).toEqual({ current: expect.anything() })
expect(migrated.providers?.["google-vertex"]?.models).toEqual({ gemini: expect.anything() })
}),
)
it.effect("preserves the built-in package for v1 Vertex Anthropic custom models", () =>
Effect.sync(() => {
const migrated = ConfigMigrateV1.migrate({
provider: {
"google-vertex-anthropic": {
models: { claude: {} },
},
},
})
expect(migrated.providers?.["google-vertex"]?.package).toBeUndefined()
expect(migrated.providers?.["google-vertex"]?.models?.claude?.package).toBe(
Provider.aisdk("@ai-sdk/google-vertex/anthropic"),
)
}),
)
it.effect("migrates v1 interleaved fields to compatibility", () =>
Effect.sync(() => {
const migrated = ConfigMigrateV1.migrate({
provider: {
custom: {
models: {
object: { interleaved: { field: "vendor_reasoning" } },
string: { interleaved: "reasoning_text" },
boolean: { interleaved: true },
},
},
},
})
expect(migrated.providers?.custom?.models?.object?.compatibility).toEqual({
reasoningField: "vendor_reasoning",
})
expect(migrated.providers?.custom?.models?.string?.compatibility).toEqual({ reasoningField: "reasoning_text" })
expect(migrated.providers?.custom?.models?.boolean?.compatibility).toBeUndefined()
}),
)
it.effect("migrates v1 command configuration", () =>
Effect.sync(() => {
expect(
ConfigMigrateV1.migrate({
command: {
review: {
template: "Review changes",
description: "Review code",
agent: "reviewer",
model: "anthropic/claude",
variant: "high",
subtask: true,
},
},
}).commands,
).toEqual({
review: {
template: "Review changes",
description: "Review code",
agent: "reviewer",
model: { providerID: "anthropic", model: "claude", variant: "high" },
subtask: true,
},
})
}),
)
it.effect("normalizes renamed permission actions when migrating v1 permissions", () =>
Effect.sync(() => {
expect(
ConfigMigrateV1.migrate({
permission: {
task: "ask",
bash: { "git status": "allow", "*": "deny" },
write: "deny",
read: "allow",
},
}).permissions,
).toEqual([
{ action: "subagent", resource: "*", effect: "ask" },
{ action: "shell", resource: "git status", effect: "allow" },
{ action: "shell", resource: "*", effect: "deny" },
{ action: "edit", resource: "*", effect: "deny" },
{ action: "read", resource: "*", effect: "allow" },
])
}),
)
}).permissions,
).toEqual([
{ action: "subagent", resource: "*", effect: "ask" },
{ action: "shell", resource: "git status", effect: "allow" },
{ action: "shell", resource: "*", effect: "deny" },
{ action: "edit", resource: "*", effect: "deny" },
{ action: "read", resource: "*", effect: "allow" },
])
})
it.live("returns an empty configuration when directory files do not exist", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const config = yield* Config.Service
@@ -856,10 +806,7 @@ describe("Config", () => {
)
it.live("deduplicates global ecosystem directories found during upward discovery", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const global = path.join(tmp.path, "global")
@@ -888,10 +835,7 @@ describe("Config", () => {
)
it.live("does not watch ecosystem config roots", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
@@ -919,10 +863,7 @@ describe("Config", () => {
)
it.live("loads opencode JSON and JSONC files from lowest to highest priority", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
@@ -1033,10 +974,7 @@ describe("Config", () => {
)
it.live("does not load legacy config.json files", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
@@ -1055,10 +993,7 @@ describe("Config", () => {
)
it.live("accepts $schema metadata without writing it into config files", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const file = path.join(tmp.path, "opencode.json")
@@ -1082,10 +1017,7 @@ describe("Config", () => {
)
it.live("loads supported scalar and resource configuration", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
@@ -1271,10 +1203,7 @@ describe("Config", () => {
)
it.live("migrates the deprecated reference key into references", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
@@ -1307,10 +1236,7 @@ describe("Config", () => {
)
it.live("migrates v1 configuration when a v1-only key is present", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
@@ -1482,10 +1408,7 @@ describe("Config", () => {
)
it.live("ignores an invalid file while loading valid config values", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
@@ -1511,10 +1434,7 @@ describe("Config", () => {
)
it.live("loads global and ancestor configuration across the project boundary", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) => {
const global = path.join(tmp.path, "global")
const root = path.join(tmp.path, "repo")
+91 -11
View File
@@ -2,13 +2,15 @@ import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { describe, expect } from "bun:test"
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
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 { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { Npm } from "@opencode-ai/util/npm"
import { Bus } from "@opencode-ai/core/bus"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
@@ -18,7 +20,7 @@ import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Effect, Fiber, Logger, Stream } from "effect"
import { Effect, Fiber, Layer, Logger, Schedule, Stream } from "effect"
import { Database } from "../../src/database/database"
import { tmpdir } from "../fixture/tmpdir"
import { tempGlobalLayer } from "../fixture/global"
@@ -35,6 +37,40 @@ const staticIt = testEffect(
[Global.node, tempGlobalLayer],
]),
)
const refreshNpm = makeGlobalNode({
service: Npm.Service,
layer: Layer.effect(
Npm.Service,
Effect.gen(function* () {
const global = yield* Global.Service
const directory = path.join(global.tmp, "background-refresh-plugin")
const installed = { directory, entrypoint: pathToFileURL(path.join(directory, "index.js")).href }
return Npm.Service.of({
add: (_pkg, options) =>
options?.refresh
? Effect.gen(function* () {
yield* Effect.promise(() => Bun.write(path.join(directory, "refresh-requested"), ""))
yield* waitForFile(path.join(directory, "refresh-release")).pipe(Effect.orDie)
yield* Effect.promise(() => Bun.write(path.join(directory, "refresh-finished"), ""))
return installed
})
: Effect.succeed(installed),
resolve: () => Effect.succeed(installed),
which: () => Effect.succeed(undefined),
})
}),
),
deps: [Global.node],
})
const refreshIt = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node, Global.node]),
[
[Global.node, tempGlobalLayer],
[Npm.node, refreshNpm],
],
),
)
describe("PluginSupervisor config", () => {
it.live("applies selectors in order", () =>
@@ -51,7 +87,6 @@ describe("PluginSupervisor config", () => {
}),
),
)
it.live("allows the built-in Plan agent to be disabled", () =>
withLocation(
{ agents: { plan: { disabled: true } } },
@@ -270,7 +305,7 @@ 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* sdk.register(define({ id: "static-sdk", effect: () => Effect.void }))
yield* withLocation(
{ plugins: ["-*", path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts")] },
Effect.gen(function* () {
@@ -380,7 +415,7 @@ describe("PluginSupervisor config", () => {
it.live("loads user plugins before internal post plugins", () =>
Effect.gen(function* () {
const sdk = yield* SdkPlugins.Service
yield* sdk.register(EffectPlugin.define({ id: "sdk-order", effect: () => Effect.void }))
yield* sdk.register(define({ id: "sdk-order", effect: () => Effect.void }))
yield* withLocation(
{
plugins: [
@@ -431,8 +466,8 @@ describe("PluginSupervisor config", () => {
it.live("unblocks flush when plugin activation fails", () =>
Effect.gen(function* () {
const sdk = yield* SdkPlugins.Service
yield* sdk.register(EffectPlugin.define({ id: "duplicate-id", effect: () => Effect.void }))
yield* sdk.register(EffectPlugin.define({ id: "duplicate-id", effect: () => Effect.void }))
yield* sdk.register(define({ id: "duplicate-id", effect: () => Effect.void }))
yield* sdk.register(define({ id: "duplicate-id", effect: () => Effect.void }))
yield* withLocation(
undefined,
Effect.gen(function* () {
@@ -441,6 +476,47 @@ describe("PluginSupervisor config", () => {
)
}),
)
refreshIt.live("refreshes active package plugins after setup without blocking flush", () =>
Effect.gen(function* () {
const global = yield* Global.Service
const directory = path.join(global.tmp, "background-refresh-plugin")
const activated = path.join(directory, "activated")
const release = path.join(directory, "release")
const refreshed = path.join(directory, "refresh-requested")
const refreshRelease = path.join(directory, "refresh-release")
const refreshFinished = path.join(directory, "refresh-finished")
yield* Effect.promise(async () => {
await fs.mkdir(directory, { recursive: true })
await fs.writeFile(
path.join(directory, "index.js"),
`export default {
id: "background-refresh-plugin",
async setup() {
await Bun.write(${JSON.stringify(activated)}, "")
while (!(await Bun.file(${JSON.stringify(release)}).exists())) await Bun.sleep(10)
},
}`,
)
})
yield* withLocation(
{ plugins: ["background-refresh-plugin"] },
Effect.gen(function* () {
yield* waitForFile(activated)
yield* Effect.sleep("100 millis")
expect(yield* Effect.promise(() => Bun.file(refreshed).exists())).toBeFalse()
yield* Effect.promise(() => Bun.write(release, ""))
yield* waitForFile(refreshed)
yield* ready().pipe(Effect.timeout("2 seconds"))
yield* Effect.promise(() => Bun.write(refreshRelease, ""))
yield* waitForFile(refreshFinished)
const plugins = yield* Plugin.Service
expect((yield* plugins.list()).map((plugin) => String(plugin.id))).toContain("background-refresh-plugin")
}),
)
}),
)
})
const ready = Effect.fnUntraced(function* () {
@@ -448,16 +524,20 @@ const ready = Effect.fnUntraced(function* () {
yield* supervisor.flush
})
const waitForFile = (file: string) =>
Effect.promise(() => Bun.file(file).exists()).pipe(
Effect.filterOrFail((exists) => exists),
Effect.retry({ times: 200, schedule: Schedule.spaced("10 millis") }),
Effect.timeout("2 seconds"),
)
function withLocation<A, E, R>(
config: unknown,
effect: Effect.Effect<A, E, R>,
fixtures = false,
prepare?: (directory: string) => Promise<void>,
) {
return Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
return Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.tap((tmp) =>
Effect.promise(async () => {
await prepare?.(tmp.path)
+43
View File
@@ -34,6 +34,41 @@ const decode = Schema.decodeUnknownSync(Info)
const document = path.join(import.meta.dir, "opencode.json")
describe("config plugin reloads", () => {
it.effect("preserves reference precedence and insertion order across documents", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const references = yield* Reference.Service
const host = yield* PluginHost.make(plugins)
yield* references.transform((draft) =>
draft.add(
"external",
Reference.LocalSource.make({ type: "local", path: AbsolutePath.make("/references/external") }),
),
)
yield* ConfigReferencePlugin.Plugin.effect(host)
const result = yield* references.list()
expect(result.map((reference) => reference.name)).toEqual(["external", "shared", "first", "second"])
expect(result.find((reference) => reference.name === "shared")?.path).toBe(
AbsolutePath.make(path.resolve("/config/second/shared")),
)
}).pipe(
Effect.provide(
Config.testLayer([
referenceConfig("/config/first/opencode.json", {
shared: "./shared",
first: "./first",
}),
referenceConfig("/config/second/opencode.json", {
shared: "./shared",
second: "./second",
}),
]),
),
Effect.provideService(Global.Service, Global.Service.of(Global.make())),
),
)
it.live("reloads config-backed domains without reloading external plugins", () =>
Effect.gen(function* () {
const agents = yield* Agent.Service
@@ -102,6 +137,14 @@ function config(name: string) {
})
}
function referenceConfig(file: string, references: Record<string, string>) {
return new Document({
type: "document",
path: AbsolutePath.make(file),
info: decode({ references }),
})
}
function title(value: string) {
return value.charAt(0).toUpperCase() + value.slice(1)
}
@@ -15,6 +15,14 @@ const users = sqliteTable("users", {
id: integer().primaryKey({ autoIncrement: true }),
name: text().notNull(),
})
const teams = sqliteTable("teams", {
id: integer().primaryKey(),
name: text().notNull(),
})
const memberships = sqliteTable("memberships", {
user_id: integer().notNull(),
team_id: integer().notNull(),
})
const run = <A, E>(effect: Effect.Effect<A, E, SqlClient>) =>
Effect.runPromise(
@@ -163,3 +171,41 @@ test("supports returning and rejects empty update sets", async () => {
}),
)
})
test("supports function-valued update joins with runtime table columns", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
const query = db
.update(users)
.set({ name: "Grace" })
.from(teams)
.innerJoin(memberships, (update) => eq(update.id, memberships.user_id))
.where(eq(teams.name, "Core"))
expect(query.toSQL()).toEqual({
sql: 'update "users" set "name" = ? from "teams" inner join "memberships" on "users"."id" = "memberships"."user_id" where "teams"."name" = ?',
params: ["Grace", "Core"],
})
}),
)
})
test("supports SQL-valued update joins", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
const query = db
.update(users)
.set({ name: "Lin" })
.from(teams)
.innerJoin(memberships, eq(users.id, memberships.user_id))
.where(eq(teams.name, "Core"))
expect(query.toSQL()).toEqual({
sql: 'update "users" set "name" = ? from "teams" inner join "memberships" on "users"."id" = "memberships"."user_id" where "teams"."name" = ?',
params: ["Lin", "Core"],
})
}),
)
})
@@ -16,18 +16,7 @@ function js(code: string, opts?: ChildProcess.CommandOptions) {
}
function decodeByteStream(stream: Stream.Stream<Uint8Array, PlatformError.PlatformError>) {
return Stream.runCollect(stream).pipe(
Effect.map((chunks) => {
const total = chunks.reduce((acc, x) => acc + x.length, 0)
const out = new Uint8Array(total)
let off = 0
for (const chunk of chunks) {
out.set(chunk, off)
off += chunk.length
}
return new TextDecoder("utf-8").decode(out).trim()
}),
)
return Stream.mkUint8Array(stream).pipe(Effect.map((bytes) => new TextDecoder("utf-8").decode(bytes).trim()))
}
function alive(pid: number) {
+140 -141
View File
@@ -1,6 +1,6 @@
import { describe, expect, spyOn, test } from "bun:test"
import { describe, expect, spyOn } from "bun:test"
import fuzzysort from "fuzzysort"
import { mkdir, mkdtemp, rm } from "node:fs/promises"
import { mkdir } from "node:fs/promises"
import os from "os"
import path from "path"
import { Deferred, Effect, Layer } from "effect"
@@ -14,6 +14,8 @@ import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { Workspace } from "@opencode-ai/core/workspace"
import { location } from "../fixture/location"
import { tmpdir } from "../fixture/tmpdir"
import { it } from "../lib/effect"
const ripgrepStub = (entry: string, onFind: (input: Ripgrep.FindInput) => void) =>
Layer.succeed(
@@ -32,14 +34,13 @@ const ripgrepStub = (entry: string, onFind: (input: Ripgrep.FindInput) => void)
)
describe("FileSystemSearch", () => {
test("honors wildcard directory rules from .gitignore", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-fff-ignore-"))
try {
await mkdir(path.join(directory, "rust/target/debug/deps"), { recursive: true })
await Bun.write(path.join(directory, ".gitignore"), "**/target/\n")
await Bun.write(path.join(directory, "rust/target/debug/deps/ignored.rs"), "ignored")
const git = Bun.spawnSync(["git", "init", "-q"], { cwd: directory })
expect(git.exitCode).toBe(0)
it.live("honors wildcard directory rules from .gitignore", () =>
Effect.gen(function* () {
const directory = (yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-fff-ignore-")))).path
yield* Effect.promise(() => mkdir(path.join(directory, "rust/target/debug/deps"), { recursive: true }))
yield* Effect.promise(() => Bun.write(path.join(directory, ".gitignore"), "**/target/\n"))
yield* Effect.promise(() => Bun.write(path.join(directory, "rust/target/debug/deps/ignored.rs"), "ignored"))
expect(Bun.spawnSync(["git", "init", "-q"], { cwd: directory }).exitCode).toBe(0)
const ref = Location.Ref.make({ directory: AbsolutePath.make(directory) })
const layer = FileSystemSearch.fffLayer.pipe(
@@ -54,26 +55,22 @@ describe("FileSystemSearch", () => {
),
),
)
const entries = await Effect.runPromise(
Effect.gen(function* () {
const search = yield* FileSystemSearch.Service
return yield* search.find({ query: "target" })
}).pipe(Effect.provide(layer), Effect.scoped),
)
yield* Effect.gen(function* () {
const search = yield* FileSystemSearch.Service
const entries = yield* search.find({ query: "target" })
expect(entries.every((entry) => !entry.path.startsWith("rust/target/"))).toBe(true)
}).pipe(Effect.provide(layer))
}),
)
expect(entries.every((entry) => !entry.path.startsWith("rust/target/"))).toBe(true)
} finally {
await rm(directory, { recursive: true, force: true })
}
})
test("selects the ripgrep layer for workspace-backed locations even when vcs would pick fff", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-search-workspace-"))
try {
it.live("selects the ripgrep layer for workspace-backed locations even when vcs would pick fff", () =>
Effect.gen(function* () {
const directory = (yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-search-workspace-"))))
.path
// A local file that only an fff index of the server directory could surface.
// The fff-vs-ripgrep discrimination only bites where Fff.available() is
// true; elsewhere the layer choice already falls back to ripgrep.
await Bun.write(path.join(directory, "server-local.ts"), "server local")
yield* Effect.promise(() => Bun.write(path.join(directory, "server-local.ts"), "server local"))
let observed: Ripgrep.FindInput | undefined
const ref = Location.Ref.make({
directory: AbsolutePath.make(directory),
@@ -92,37 +89,35 @@ describe("FileSystemSearch", () => {
[Ripgrep.node, ripgrepStub("remote.ts", (input) => (observed = input))],
])
await Effect.runPromise(
Effect.gen(function* () {
const search = yield* FileSystemSearch.Service
const entries = yield* search.find({ query: "ts", type: "file" })
expect(observed?.cwd).toBe(directory)
expect(entries.map((entry) => entry.path)).toEqual([RelativePath.make("remote.ts")])
}).pipe(Effect.provide(layer), Effect.scoped),
)
} finally {
await rm(directory, { recursive: true, force: true })
}
})
yield* Effect.gen(function* () {
const search = yield* FileSystemSearch.Service
const entries = yield* search.find({ query: "ts", type: "file" })
expect(observed?.cwd).toBe(directory)
expect(entries.map((entry) => entry.path)).toEqual([RelativePath.make("remote.ts")])
}).pipe(Effect.provide(layer))
}),
)
test("bounds a home scan even when home is detected as a repository", async () => {
let observed: Ripgrep.FindInput | undefined
const home = AbsolutePath.make(os.homedir())
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location({ directory: home }, { vcs: { type: "git", store: AbsolutePath.make(path.join(home, ".git")) } }),
it.live("bounds a home scan even when home is detected as a repository", () =>
Effect.gen(function* () {
let observed: Ripgrep.FindInput | undefined
const home = AbsolutePath.make(os.homedir())
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: home },
{ vcs: { type: "git", store: AbsolutePath.make(path.join(home, ".git")) } },
),
),
),
),
],
[Ripgrep.node, ripgrepStub("src/index.ts", (input) => (observed = input))],
])
await Effect.runPromise(
Effect.gen(function* () {
],
[Ripgrep.node, ripgrepStub("src/index.ts", (input) => (observed = input))],
])
yield* Effect.gen(function* () {
const search = yield* FileSystemSearch.Service
yield* Effect.sleep("10 millis")
expect(observed).toBeUndefined()
@@ -132,52 +127,52 @@ describe("FileSystemSearch", () => {
expect((yield* search.find({ query: "src", type: "directory" }))[0]?.path).toBe(
RelativePath.make(`src${path.sep}`),
)
}).pipe(Effect.provide(layer), Effect.scoped),
)
})
}).pipe(Effect.provide(layer))
}),
)
test("refreshes a stale ripgrep index atomically without blocking search", async () => {
let scans = 0
const started = Effect.runSync(Deferred.make<void>())
const release = Effect.runSync(Deferred.make<void>())
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-atomic")) }),
it.effect("refreshes a stale ripgrep index atomically without blocking search", () =>
Effect.gen(function* () {
let scans = 0
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-atomic")) }),
),
),
),
],
[
Ripgrep.node,
Layer.succeed(
Ripgrep.Service,
Ripgrep.Service.of({
find: (input) =>
Effect.gen(function* () {
scans++
if (scans > 1) {
yield* Deferred.succeed(started, undefined)
yield* Deferred.await(release)
}
const entry = FileSystem.Entry.make({
path: RelativePath.make(scans === 1 ? "src/old.ts" : "src/new.ts"),
type: "file",
})
if (input.onEntry) yield* input.onEntry(entry)
return [entry]
}),
glob: () => Effect.succeed([]),
grep: () => Effect.succeed([]),
}),
),
],
])
],
[
Ripgrep.node,
Layer.succeed(
Ripgrep.Service,
Ripgrep.Service.of({
find: (input) =>
Effect.gen(function* () {
scans++
if (scans > 1) {
yield* Deferred.succeed(started, undefined)
yield* Deferred.await(release)
}
const entry = FileSystem.Entry.make({
path: RelativePath.make(scans === 1 ? "src/old.ts" : "src/new.ts"),
type: "file",
})
if (input.onEntry) yield* input.onEntry(entry)
return [entry]
}),
glob: () => Effect.succeed([]),
grep: () => Effect.succeed([]),
}),
),
],
])
await Effect.runPromise(
Effect.gen(function* () {
yield* Effect.gen(function* () {
const search = yield* FileSystemSearch.Service
yield* search.find({ query: "old", type: "file" })
expect((yield* search.find({ query: "old", type: "file" }))[0]?.path).toBe(RelativePath.make("src/old.ts"))
@@ -196,47 +191,53 @@ describe("FileSystemSearch", () => {
}).pipe(Effect.repeat({ until: (entries) => entries.length > 0 }))
expect(refreshed[0]?.path).toBe(RelativePath.make("src/new.ts"))
expect(scans).toBe(2)
}).pipe(Effect.provide(layer), Effect.provide(TestClock.layer()), Effect.scoped),
)
})
}).pipe(Effect.provide(layer))
}),
)
test("reuses location-owned fuzzy targets across index refreshes", async () => {
let scans = 0
const second = Effect.runSync(Deferred.make<void>())
const prepare = spyOn(fuzzysort, "prepare")
const cleanup = spyOn(fuzzysort, "cleanup")
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-cache")) }),
it.effect("reuses location-owned fuzzy targets across index refreshes", () =>
Effect.gen(function* () {
let scans = 0
const second = yield* Deferred.make<void>()
const prepare = yield* Effect.acquireRelease(
Effect.sync(() => spyOn(fuzzysort, "prepare")),
(value) => Effect.sync(() => value.mockRestore()),
)
const cleanup = yield* Effect.acquireRelease(
Effect.sync(() => spyOn(fuzzysort, "cleanup")),
(value) => Effect.sync(() => value.mockRestore()),
)
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-cache")) }),
),
),
),
],
[
Ripgrep.node,
Layer.succeed(
Ripgrep.Service,
Ripgrep.Service.of({
find: (input) =>
Effect.gen(function* () {
scans++
const entry = FileSystem.Entry.make({ path: RelativePath.make("src/index.ts"), type: "file" })
if (input.onEntry) yield* input.onEntry(entry)
if (scans > 1) yield* Deferred.succeed(second, undefined)
return [entry]
}),
glob: () => Effect.succeed([]),
grep: () => Effect.succeed([]),
}),
),
],
])
],
[
Ripgrep.node,
Layer.succeed(
Ripgrep.Service,
Ripgrep.Service.of({
find: (input) =>
Effect.gen(function* () {
scans++
const entry = FileSystem.Entry.make({ path: RelativePath.make("src/index.ts"), type: "file" })
if (input.onEntry) yield* input.onEntry(entry)
if (scans > 1) yield* Deferred.succeed(second, undefined)
return [entry]
}),
glob: () => Effect.succeed([]),
grep: () => Effect.succeed([]),
}),
),
],
])
await Effect.runPromise(
Effect.gen(function* () {
yield* Effect.gen(function* () {
const search = yield* FileSystemSearch.Service
yield* search.find({ query: "index", type: "file" })
yield* TestClock.adjust("10 seconds")
@@ -246,9 +247,7 @@ describe("FileSystemSearch", () => {
expect(prepare).toHaveBeenCalledTimes(2)
expect(cleanup).toHaveBeenCalledTimes(3)
}).pipe(Effect.provide(layer), Effect.provide(TestClock.layer()), Effect.scoped),
)
prepare.mockRestore()
cleanup.mockRestore()
})
}).pipe(Effect.provide(layer))
}),
)
})
@@ -31,10 +31,7 @@ const it = testEffect(AppNodeBuilder.build(LayerNode.group([FSUtil.node, Bus.nod
const configLayer = Config.testLayer()
const pluginNode = makeLocationNode({
service: PluginSupervisor.Service,
layer: Layer.succeed(
PluginSupervisor.Service,
PluginSupervisor.Service.of({ flush: Effect.void, check: () => Effect.succeed({ mutable: false }) }),
),
layer: Layer.succeed(PluginSupervisor.Service, PluginSupervisor.Service.of({ flush: Effect.void })),
deps: [],
})
@@ -316,7 +313,7 @@ describe("LocationWatcher subscriptions", () => {
Effect.gen(function* () {
const policy = yield* LocationWatcherPolicy.Service
yield* policy.transform((draft) => draft.add([".git"]))
return PluginSupervisor.Service.of({ flush: Effect.void, check: () => Effect.succeed({ mutable: false }) })
return PluginSupervisor.Service.of({ flush: Effect.void })
}),
),
deps: [LocationWatcherPolicy.node],
@@ -12,10 +12,7 @@ export const promptLocationLayer = Layer.effect(
() =>
Layer.merge(
LayerNode.compile(PluginHooks.node),
Layer.succeed(PluginSupervisor.Service, {
flush: Effect.void,
check: () => Effect.succeed({ mutable: false }),
}),
Layer.succeed(PluginSupervisor.Service, { flush: Effect.void }),
) as Layer.Layer<LocationServices>,
),
)
+1 -1
View File
@@ -186,7 +186,7 @@ describe("Integration", () => {
value: Credential.Key.make({ type: "key", key: "secret", configuration: { accountId: "account" } }),
}),
])
expect(Array.from(yield* Fiber.join(created), (event) => ({ type: event.type, data: event.data }))).toEqual([
expect((yield* Fiber.join(created)).map((event) => ({ type: event.type, data: event.data }))).toEqual([
{ type: Credential.Event.Updated.type, data: {} },
{ type: Credential.Event.Switched.type, data: { credentialID: stored[0]?.id, integrationID } },
])
@@ -49,6 +49,29 @@ export const environmentConformance = <E>(
}),
)
check("observes filesystem state when an operation executes", (harness) =>
Effect.gen(function* () {
const target = `${harness.root}/deferred.txt`
const source = `${harness.root}/source.txt`
const destination = `${harness.root}/destination.txt`
const read = harness.files.read(target)
const stat = harness.files.stat(target)
const list = harness.files.list(harness.root)
const move = harness.files.move(source, destination)
yield* harness.files.write(target, bytes("first"))
yield* harness.files.write(source, bytes("moved"))
expect(text((yield* read).bytes)).toBe("first")
expect((yield* stat).size).toBe(5)
expect(yield* list).toContainEqual({ name: "deferred.txt", type: "file" })
yield* move
expect(text((yield* harness.files.read(destination)).bytes)).toBe("moved")
yield* harness.files.write(target, bytes("second"))
expect(text((yield* read).bytes)).toBe("second")
}),
)
check("reports missing paths", (harness) =>
Effect.gen(function* () {
const target = `${harness.root}/missing`
+104
View File
@@ -49,6 +49,7 @@ import { executeTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/t
let assertion: Deferred.Deferred<Permission.AssertInput> | undefined
let decision: Effect.Effect<void, Permission.Error> = Effect.void
let calls = 0
let invocations: Array<Parameters<Mcp.Interface["callTool"]>[0]> = []
type ResourcePage = {
items: Array<{ name: string; uri: string; description?: string; mimeType?: string }>
@@ -83,6 +84,12 @@ function resourceServer(
resourceLists: 0,
templateLists: 0,
toolLists: 0,
toolCalls: [] as Array<{
name: string
arguments: Record<string, unknown> | undefined
sessionID: unknown
progressToken: unknown
}>,
initializations: 0,
urls: [] as string[],
}
@@ -132,6 +139,17 @@ function resourceServer(
}
})
}
if (!input.emptyElicitation && !input.urlElicitation) {
protocol.setRequestHandler(CallToolRequestSchema, (request) => {
state.toolCalls.push({
name: request.params.name,
arguments: request.params.arguments,
sessionID: request.params._meta?.sessionID,
progressToken: request.params._meta?.progressToken,
})
return Promise.resolve({ content: [] })
})
}
if (input.resources !== false) {
protocol.setRequestHandler(ListResourcesRequestSchema, (request) => {
state.resourceLists += 1
@@ -313,6 +331,7 @@ const mcp = Layer.mock(Mcp.Service, {
callTool: (input) =>
Effect.sync(() => {
calls += 1
invocations.push(input)
if (input.name === "fail")
return new Mcp.ToolResult({
server: Mcp.ServerName.make(input.server),
@@ -380,6 +399,43 @@ test("MCP tool names match V1 sanitization", () => {
expect(McpTool.name("context 7", "resolve.library/id")).toBe("context_7_resolve_library_id")
})
test("passes session IDs as MCP request metadata", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const server = yield* resourceServer()
const connection = yield* connect(
"session-metadata",
new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false }),
import.meta.dir,
)
yield* connection.callTool({
name: "echo",
args: { text: "hello" },
sessionID: Session.ID.make("ses_mcp_metadata"),
})
yield* connection.callTool({ name: "echo" })
expect(server.state.toolCalls).toEqual([
{
name: "echo",
arguments: { text: "hello" },
sessionID: "ses_mcp_metadata",
progressToken: expect.any(Number),
},
{
name: "echo",
arguments: {},
sessionID: undefined,
progressToken: expect.any(Number),
},
])
expect(server.state.toolCalls[0]?.progressToken).not.toBe(server.state.toolCalls[1]?.progressToken)
}),
),
)
})
test("preserves output schema validation across paginated tool discovery", async () => {
const server = new Server({ name: "pagination", version: "1.0.0" }, { capabilities: { tools: {} } })
server.setRequestHandler(ListToolsRequestSchema, ({ params }) =>
@@ -1610,6 +1666,54 @@ it.effect("advertises MCP output schemas to Code Mode", () =>
}),
)
it.effect("forwards the invoking session through direct and Code Mode MCP tools", () =>
Effect.gen(function* () {
assertion = yield* Deferred.make<Permission.AssertInput>()
decision = Effect.void
invocations = []
const registry = yield* Tool.Service
const registration = yield* McpTool.Service
yield* registration.flush
const toolSet = yield* registry.snapshot()
expect(toolSet.definitions.find((tool) => tool.name === "direct_lookup")?.inputSchema).not.toHaveProperty(
"properties.sessionID",
)
expect(toolSet.codeModeCatalog?.find((tool) => tool.path === "demo.search")?.signature).not.toContain("sessionID")
const directSessionID = Session.ID.make("ses_mcp_direct")
yield* toolSet.execute({
sessionID: directSessionID,
...toolIdentity,
call: { type: "tool-call", id: "call_mcp_direct", name: "direct_lookup", input: {} },
})
expect(invocations[0]).toEqual({
server: "direct",
name: "lookup",
args: {},
sessionID: directSessionID,
})
const codeModeSessionID = Session.ID.make("ses_mcp_codemode")
yield* toolSet.execute({
sessionID: codeModeSessionID,
...toolIdentity,
call: {
type: "tool-call",
id: "call_mcp_codemode",
name: "execute",
input: { code: "return await tools.demo.search({})" },
},
})
expect(invocations[1]).toEqual({
server: "demo",
name: "search",
args: {},
sessionID: codeModeSessionID,
})
}),
)
it.effect("returns content-only MCP results through Code Mode", () =>
Effect.gen(function* () {
assertion = yield* Deferred.make<Permission.AssertInput>()
+2 -166
View File
@@ -55,62 +55,6 @@ async function createGitFixture(directory: string) {
return { repository, commit }
}
async function createRegistryFixture(directory: string) {
const tarballs = new Map<string, Uint8Array>()
for (const version of ["1.0.0", "1.1.0"]) {
const root = path.join(directory, version)
await fs.mkdir(path.join(root, "package"), { recursive: true })
await writePackage(path.join(root, "package"), {
name: "@fixture/registry-plugin",
version,
exports: "./index.js",
})
await Bun.write(path.join(root, "package", "index.js"), `export const version = "${version}"\n`)
await Bun.$`tar -czf package.tgz package`.cwd(root)
tarballs.set(version, await Bun.file(path.join(root, "package.tgz")).bytes())
}
const state = { latest: "1.0.0", offline: false, requests: 0, tarballRequests: 0 }
const server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch(request) {
state.requests++
if (state.offline) return new Response("offline", { status: 503 })
const url = new URL(request.url)
if (decodeURIComponent(url.pathname) === "/@fixture/registry-plugin")
return Response.json({
name: "@fixture/registry-plugin",
"dist-tags": { latest: state.latest },
versions: Object.fromEntries(
[...tarballs.keys()].map((version) => [
version,
{ name: "@fixture/registry-plugin", version, dist: { tarball: `${url.origin}/${version}.tgz` } },
]),
),
})
state.tarballRequests++
const tarball = tarballs.get(url.pathname.slice(1).replace(".tgz", ""))
return tarball ? new Response(tarball) : new Response("missing", { status: 404 })
},
})
return {
state,
tarball: `${server.url}1.0.0.tgz`,
async configure(cache: string, spec: string) {
const root = path.join(cache, "packages", await Npm.cacheKey(spec))
await fs.mkdir(root, { recursive: true })
await Bun.write(
path.join(root, ".npmrc"),
`@fixture:registry=${server.url}\ncache=${path.join(directory, "npm-cache")}\nfetch-retries=0\naudit=false\n`,
)
return root
},
async [Symbol.asyncDispose]() {
await server.stop(true)
},
}
}
describe("Npm.sanitize", () => {
test("keeps normal scoped package specs unchanged", () => {
expect(Npm.sanitize("@opencode/acme")).toBe("@opencode/acme")
@@ -187,7 +131,6 @@ describe("Npm.add", () => {
expect(entry.directory).toBe(directory)
expect(entry.entrypoint).toEndWith("/index.js")
expect(entry.revision).toBe("1.0.0")
})
test("falls back to the original spec when parsing fails", async () => {
@@ -255,7 +198,6 @@ describe("Npm.add", () => {
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
expect(entries.added.entrypoint).toEndWith("/index.js")
expect(entries.added.revision).toBe(fixture.commit)
expect(entries.cached).toEqual(entries.added)
expect(entries.resolved).toEqual(entries.added)
expect(
@@ -276,7 +218,6 @@ describe("Npm.add", () => {
expect(entry.directory).toEndWith(path.join("node_modules", "fixture-subdirectory-plugin"))
expect(entry.entrypoint).toEndWith("/index.js")
expect(entry.revision).toBe(fixture.commit)
expect(
await fs.stat(path.join(path.dirname(entry.directory), "fixture-subdirectory-dependency", "package.json")),
).toBeTruthy()
@@ -293,29 +234,13 @@ describe("Npm.add", () => {
const first = await Effect.gen(function* () {
const npm = yield* Npm.Service
const mutableEntry = yield* npm.add(mutable, { refresh: true })
const mutableEntry = yield* npm.add(mutable)
const pinnedEntry = yield* npm.add(pinned, { refresh: true })
const nextCommit = yield* Effect.promise(async () => {
yield* Effect.promise(async () => {
await Bun.write(path.join(fixture.repository, "index.js"), 'export default { root: "second" }\n')
await Bun.$`git -C ${fixture.repository} add .`
await Bun.$`git -C ${fixture.repository} -c user.name=fixture -c user.email=fixture@example.com commit -qm second`
return Bun.$`git -C ${fixture.repository} rev-parse HEAD`.text().then((value) => value.trim())
})
const lock = path.join(cache, "packages", yield* Effect.promise(() => Npm.cacheKey(mutable)), "package-lock.json")
const before = yield* Effect.promise(() => Bun.file(lock).bytes())
expect((yield* npm.resolve(mutable)).revision).toBe(fixture.commit)
expect(yield* npm.check(mutable)).toEqual({ installed: fixture.commit, available: nextCommit, mutable: true })
expect(yield* npm.check(pinned)).toEqual({ installed: fixture.commit, available: fixture.commit, mutable: false })
expect(yield* Effect.promise(() => Bun.file(lock).bytes())).toEqual(before)
expect(yield* npm.add(mutable)).toEqual(mutableEntry)
expect(mutableEntry.revision).toBe(fixture.commit)
const uncached = `git+${repository}#HEAD`
expect(yield* npm.check(uncached)).toEqual({ installed: undefined, available: nextCommit, mutable: true })
expect(yield* Effect.promise(() => fs.readdir(path.join(cache, "packages")))).not.toContain(
yield* Effect.promise(() => Npm.cacheKey(uncached)),
)
expect((yield* npm.resolve(uncached)).entrypoint).toBeUndefined()
yield* npm.add(mutable, { refresh: true })
return { mutable: mutableEntry, pinned: pinnedEntry }
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
@@ -335,8 +260,6 @@ describe("Npm.add", () => {
await fs.rename(fixture.repository, `${fixture.repository}-offline`)
const offline = await Effect.gen(function* () {
const npm = yield* Npm.Service
expect((yield* npm.resolve(mutable)).revision).toBe(second.mutable.revision)
expect((yield* npm.check(mutable).pipe(Effect.flip))._tag).toBe("NpmInstallFailedError")
return yield* npm.add(mutable, { refresh: true })
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
expect(await Bun.file(path.join(offline.directory, "index.js")).text()).toContain('root: "second"')
@@ -368,92 +291,5 @@ describe("Npm.resolve", () => {
return yield* npm.resolve(spec, { subpaths: ["tui"] })
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
expect(resolved.entrypoint).toEndWith("/tui.js")
expect(resolved.revision).toBe("1.0.0")
})
})
describe("Npm.check and revision metadata", () => {
test("checks registry tags, ranges, and exact pins without changing or installing packages", async () => {
await using tmp = await tmpdir()
await using registry = await createRegistryFixture(tmp.path)
const cache = path.join(tmp.path, "cache")
const spec = "@fixture/registry-plugin@latest"
const range = "@fixture/registry-plugin@^1.0.0"
const pinned = "@fixture/registry-plugin@1.0.0"
const alias = "alias@npm:@fixture/registry-plugin@latest"
const root = await registry.configure(cache, spec)
const rangeRoot = await registry.configure(cache, range)
const pinnedRoot = await registry.configure(cache, pinned)
await registry.configure(cache, alias)
await Effect.gen(function* () {
const npm = yield* Npm.Service
for (const unsupported of [registry.tarball, alias]) {
const error = yield* npm.check(unsupported).pipe(Effect.flip)
expect(error._tag).toBe("NpmInstallFailedError")
expect(error.cause).toEqual(new Error("Package checks only support registry and Git package specs"))
}
expect(registry.state.requests).toBe(0)
expect(registry.state.tarballRequests).toBe(0)
expect((yield* npm.resolve(spec)).revision).toBeUndefined()
const loaded = yield* npm.add(spec)
expect(loaded.revision).toBe("1.0.0")
const files = [
"package.json",
"package-lock.json",
"node_modules/@fixture/registry-plugin/package.json",
"node_modules/@fixture/registry-plugin/index.js",
]
const before = yield* Effect.promise(() =>
Promise.all(files.map((file) => Bun.file(path.join(root, file)).bytes())),
)
const requests = registry.state.tarballRequests
expect(yield* npm.check(spec)).toEqual({ installed: "1.0.0", available: "1.0.0", mutable: true })
registry.state.latest = "1.1.0"
expect(yield* npm.check(spec)).toEqual({ installed: "1.0.0", available: "1.1.0", mutable: true })
expect(yield* npm.check(range)).toEqual({ installed: undefined, available: "1.1.0", mutable: true })
expect(yield* npm.check(pinned)).toEqual({ installed: undefined, available: "1.0.0", mutable: false })
expect(yield* npm.add(spec)).toEqual(loaded)
expect(yield* npm.resolve(spec)).toEqual(loaded)
expect(
yield* Effect.promise(() => Promise.all(files.map((file) => Bun.file(path.join(root, file)).bytes()))),
).toEqual(before)
expect(registry.state.tarballRequests).toBe(requests)
expect(yield* Effect.promise(() => fs.readdir(rangeRoot))).toEqual([".npmrc"])
expect(yield* Effect.promise(() => fs.readdir(pinnedRoot))).toEqual([".npmrc"])
registry.state.offline = true
expect((yield* npm.check(spec).pipe(Effect.flip))._tag).toBe("NpmInstallFailedError")
expect((yield* npm.resolve(spec)).revision).toBe("1.0.0")
expect(yield* npm.add(spec)).toEqual(loaded)
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
}, 30_000)
test("leaves unknown installed revisions undefined and prefers the wrapper Git lock", async () => {
await using tmp = await tmpdir()
const cache = path.join(tmp.path, "cache")
const commit = "0123456789abcdef0123456789abcdef01234567"
const spec = `git+https://example.com/fixture.git#${commit}`
const root = path.join(cache, "packages", await Npm.cacheKey(spec))
const directory = path.join(root, "node_modules", "fixture")
await fs.mkdir(directory, { recursive: true })
await writePackage(root, { dependencies: { fixture: spec } })
await writePackage(directory, { name: "fixture", exports: "./index.js" })
await Bun.write(path.join(directory, "index.js"), "export default {}\n")
await Effect.gen(function* () {
const npm = yield* Npm.Service
expect((yield* npm.resolve(spec)).revision).toBeUndefined()
expect((yield* npm.add(spec)).revision).toBeUndefined()
const lock = (revision: string) =>
JSON.stringify({
packages: { "node_modules/fixture": { resolved: `git+https://example.com/fixture.git#${revision}` } },
})
yield* Effect.promise(() => Bun.write(path.join(root, "node_modules", ".package-lock.json"), lock(commit)))
expect((yield* npm.resolve(spec)).revision).toBe(commit)
const installed = "f".repeat(40)
yield* Effect.promise(() => Bun.write(path.join(root, "package-lock.json"), lock(installed)))
expect((yield* npm.resolve(spec)).revision).toBe(installed)
yield* Effect.promise(() => fs.rm(directory, { recursive: true }))
expect((yield* npm.resolve(spec)).revision).toBeUndefined()
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
})
})
+12 -16
View File
@@ -450,23 +450,19 @@ describe("Plugin", () => {
}),
})
for (const revision of ["1.0.0", undefined]) {
yield* plugins.activate([{ ...versioned(previous, revision ?? "unknown"), revision }])
yield* plugins.activate([{ ...versioned(replacement, "2"), revision: "2.0.0" }])
yield* plugins.activate([versioned(previous)])
yield* plugins.activate([versioned(replacement, "2")])
expect(yield* plugins.list()).toEqual([
{
id: Plugin.ID.make("managed"),
source: { type: "builtin" },
...(revision === undefined ? {} : { revision }),
status: "failed",
error: expect.stringContaining("replacement failed"),
tui: false,
},
])
if (revision === undefined) expect((yield* plugins.list())[0]).not.toHaveProperty("revision")
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("previous")
}
expect(yield* plugins.list()).toEqual([
{
id: Plugin.ID.make("managed"),
source: { type: "builtin" },
status: "failed",
error: expect.stringContaining("replacement failed"),
tui: false,
},
])
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("previous")
}),
)
-248
View File
@@ -1,248 +0,0 @@
import path from "node:path"
import fs from "node:fs/promises"
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Bus } from "@opencode-ai/core/bus"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginModule } from "@opencode-ai/core/plugin/module"
import { PluginPromise } from "@opencode-ai/core/plugin/promise"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { AbsolutePath } from "@opencode-ai/schema/schema"
import { PackageStatus } from "@opencode-ai/schema/plugin"
import { Global } from "@opencode-ai/util/global"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Npm } from "@opencode-ai/util/npm"
import { tempGlobalLayer } from "../fixture/global"
import { tmpdir } from "../fixture/tmpdir"
import { it, testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const pluginIt = testEffect(PluginTestLayer)
// Bun.build inside bun test panics on Windows; the Node import boundary is platform-independent.
describe.skipIf(process.platform === "win32")("Node plugin revisions", () => {
it.live("keeps the loaded revision across Node CommonJS namespace wrappers", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("plugin-node-revision-")))
const entrypoint = path.join(tmp.path, "plugin.cjs")
const harness = path.join(tmp.path, "harness.ts")
yield* Effect.promise(() =>
fs.symlink(path.join(import.meta.dir, "../../../util/node_modules"), path.join(tmp.path, "node_modules")),
)
yield* Effect.promise(() =>
Bun.write(
harness,
`
import assert from "node:assert/strict"
import { writeFile } from "node:fs/promises"
import { Effect } from "effect"
import { PluginModule } from ${JSON.stringify(path.join(import.meta.dir, "../../src/plugin/module.ts"))}
import { Npm } from ${JSON.stringify(path.join(import.meta.dir, "../../../util/src/npm.ts"))}
const entrypoint = process.argv[2]
const source = (tui) => 'module.exports = { id: "node-revision-plugin", tui: ' + tui + ', effect: () => {} }'
const load = (revision) => Effect.runPromise(PluginModule.load({ type: "add", target: "inspection-plugin@latest", options: {} }).pipe(
Effect.provideService(Npm.Service, {
add: () => Effect.succeed({ directory: "", entrypoint, revision }),
resolve: () => Effect.die("unused resolve"),
check: () => Effect.die("unused check"),
which: () => Effect.die("unused which"),
}),
))
await writeFile(entrypoint, source(false))
const first = await load("1.0.0")
assert.deepEqual({ tui: first.tui, revision: first.revision }, { tui: false, revision: "1.0.0" })
await writeFile(entrypoint, source(true))
const second = await load("2.0.0")
assert.deepEqual({ tui: second.tui, revision: second.revision }, { tui: false, revision: "1.0.0" })
`,
),
)
const result = yield* Effect.promise(() =>
Bun.build({
entrypoints: [harness],
outdir: tmp.path,
naming: "[name].mjs",
target: "node",
conditions: ["node"],
plugins: [
{
name: "externalize-dependencies",
setup(build) {
build.onResolve({ filter: /^[^.#/]/ }, (args) =>
args.path.startsWith("@opencode-ai/") ? undefined : { path: args.path, external: true },
)
},
},
],
}),
)
expect(result.success).toBe(true)
const child = Bun.spawn(["node", path.join(tmp.path, "harness.mjs"), entrypoint], {
cwd: tmp.path,
env: {
PATH: process.env.PATH,
HOME: tmp.path,
XDG_CONFIG_HOME: path.join(tmp.path, "config"),
XDG_DATA_HOME: path.join(tmp.path, "data"),
XDG_CACHE_HOME: path.join(tmp.path, "cache"),
XDG_STATE_HOME: path.join(tmp.path, "state"),
},
stdout: "pipe",
stderr: "pipe",
})
yield* Effect.addFinalizer(() => Effect.sync(() => child.kill()))
expect(
yield* Effect.promise(() =>
Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]),
),
).toEqual([0, expect.any(String), expect.any(String)])
}),
)
})
pluginIt.live("keeps loaded revision tied to cached module code rather than refreshed disk metadata", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("plugin-revision-")))
const entrypoint = path.join(tmp.path, "plugin.ts")
const source = (tui: boolean) =>
`import { Effect } from ${JSON.stringify(import.meta.resolve("effect"))}\nexport default { id: "revision-plugin", tui: ${tui}, effect: () => Effect.void }\n`
yield* Effect.promise(() => Bun.write(entrypoint, source(false)))
const npm = yield* Npm.Service
const plugins = yield* Plugin.Service
const operation = { type: "add" as const, target: "inspection-plugin@latest", options: {} }
const load = (revision: string, options: Record<string, unknown>) =>
PluginModule.load({ ...operation, options }).pipe(
Effect.provideService(Npm.Service, {
...npm,
add: () => Effect.succeed({ directory: tmp.path, entrypoint, revision }),
}),
)
const first = yield* load("1.0.0", {})
yield* plugins.activate([first])
expect(first.version).toBe(JSON.stringify(operation))
expect(first.source).toEqual({ type: "package", package: operation.target })
expect((yield* plugins.list())[0]?.revision).toBe("1.0.0")
yield* Effect.promise(() => Bun.write(entrypoint, source(true)))
const second = yield* load("2.0.0", { changed: true })
expect(second.tui).toBe(false)
expect(second.revision).toBe("1.0.0")
yield* plugins.activate([second])
expect((yield* plugins.list())[0]?.revision).toBe("1.0.0")
yield* plugins.activate([{ ...second, version: "failed", effect: () => Effect.die("setup failed") }])
expect((yield* plugins.list())[0]).toMatchObject({ status: "failed", revision: "1.0.0" })
expect(Plugin.PackageStatus).toBe(PackageStatus)
}),
)
pluginIt.effect("rejects in-process checks in Effect and Promise setup without waiting for activation", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
yield* plugins.activate([
{
id: "effect-check",
version: "1",
effect: (host) =>
host.plugin.check({ target: "inspection-plugin@latest" }).pipe(
Effect.flip,
Effect.tap((error) =>
Effect.sync(() =>
expect(error).toMatchObject({
_tag: "PluginCheckError",
message: expect.stringContaining("external client"),
}),
),
),
Effect.orDie,
Effect.asVoid,
),
},
{
...PluginPromise.fromPromise({
id: "promise-check",
setup: async (host) => {
await expect(host.plugin.check({ target: "inspection-plugin@latest" })).rejects.toThrow("external client")
},
}),
version: "1",
},
])
expect((yield* plugins.list()).map((plugin) => plugin.status)).toEqual(["active", "active"])
}),
)
it.live("checks configured package sources without installing, writing config, or updating plugins", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("plugin-check-")))
const target = "inspection-plugin@latest"
const unsupported = "https://example.com/plugin.tgz"
const config = JSON.stringify({ plugins: ["-*", target, unsupported] })
const configPath = path.join(tmp.path, "opencode.json")
yield* Effect.promise(() => Bun.write(configPath, config))
let adds = 0
let checks = 0
let fail = false
const context = yield* Layer.build(
AppNodeBuilder.build(LayerNode.group([LocationServiceMap.node, Bus.node]), [
[Global.node, tempGlobalLayer],
[
Npm.node,
Layer.succeed(Npm.Service, {
add: () => {
adds++
return Effect.fail(new Npm.InstallFailedError({ dir: tmp.path }))
},
resolve: () => Effect.succeed({ directory: tmp.path }),
which: () => Effect.undefined,
check: () => {
checks++
if (fail)
return Effect.fail(
new Npm.InstallFailedError({ dir: tmp.path, cause: new Error("registry unavailable") }),
)
return Effect.succeed({ available: "2.0.0", mutable: true })
},
}),
],
]),
)
const locations = yield* LocationServiceMap.Service.pipe(Effect.provide(context))
const bus = yield* Bus.Service.pipe(Effect.provide(context))
yield* Effect.gen(function* () {
const supervisor = yield* PluginSupervisor.Service
const plugins = yield* Plugin.Service
yield* supervisor.flush
const inventory = yield* plugins.list()
const initialAdds = adds
let updates = 0
const unsubscribe = yield* bus.listen((event) =>
Effect.sync(() => {
if (event.type === Plugin.Event.Updated.type) updates++
}),
)
yield* Effect.addFinalizer(() => unsubscribe)
expect(yield* supervisor.check(target)).toEqual({ available: "2.0.0", mutable: true })
expect(yield* supervisor.check("not-configured@latest").pipe(Effect.flip)).toMatchObject({
_tag: "PluginCheckError",
message: expect.stringContaining("server inventory"),
})
expect(yield* supervisor.check(unsupported).pipe(Effect.flip)).toMatchObject({
_tag: "PluginCheckError",
message: `Unsupported plugin package source: ${unsupported}`,
})
expect(checks).toBe(1)
fail = true
expect(yield* supervisor.check(target).pipe(Effect.flip)).toMatchObject({
_tag: "PluginCheckError",
message: expect.stringContaining("registry unavailable"),
})
expect(adds).toBe(initialAdds)
expect(yield* plugins.list()).toEqual(inventory)
expect(updates).toBe(0)
expect(yield* Effect.promise(() => Bun.file(configPath).text())).toBe(config)
}).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }))))
}),
)
-1
View File
@@ -38,7 +38,6 @@ const npmLayer = Layer.succeed(
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined }),
which: () => Effect.undefined,
check: () => Effect.succeed({ mutable: false }),
}),
)
-1
View File
@@ -103,7 +103,6 @@ export function host(overrides: Overrides = {}): Plugin.Context {
},
plugin: overrides.plugin ?? {
list: () => Effect.die("unused plugin.list"),
check: () => Effect.die("unused plugin.check"),
},
reference: overrides.reference ?? {
list: () => Effect.die("unused reference.list"),
+62 -1
View File
@@ -1,7 +1,7 @@
import path from "path"
import { describe, expect } from "bun:test"
import { Money } from "@opencode-ai/schema/money"
import { Effect, Layer } from "effect"
import { Effect, Exit, Layer, Scope } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Integration } from "@opencode-ai/core/integration"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -11,6 +11,8 @@ import { Location } from "@opencode-ai/core/location"
import { Model } from "@opencode-ai/core/model"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { ModelsDevPlugin } from "@opencode-ai/core/plugin/models-dev"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -18,6 +20,7 @@ import { withEnv } from "../fixture/env"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
import { catalogHost, host, integrationHost } from "./host"
import { PluginTestLayer } from "./fixture"
const locationLayer = Layer.succeed(
Location.Service,
@@ -27,10 +30,68 @@ const layer = AppNodeBuilder.build(LayerNode.group([Catalog.node, Integration.no
[Location.node, locationLayer],
])
const it = testEffect(layer)
const real = testEffect(PluginTestLayer)
const models = (file: string) =>
AppNodeBuilder.build(ModelsDev.node, [[ModelsDev.node, ModelsDev.configured({ file, fetch: false })]])
describe("ModelsDevPlugin", () => {
real.effect("keeps the retained model seed unchanged across catalog replay", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const plugins = yield* Plugin.Service
const providerID = Provider.ID.make("acme")
const modelID = Model.ID.make("model")
const modelsDev = ModelsDev.Service.of({
get: () =>
Effect.succeed([
{
info: {
id: providerID,
name: "Acme",
activation: "auto",
package: Provider.aisdk("@ai-sdk/openai-compatible"),
},
environment: [],
models: [
{
id: modelID,
modelID,
providerID,
name: "Model",
capabilities: { tools: true, input: [], output: [] },
variants: [],
time: { released: Date.parse("2026-01-01") },
cost: [],
status: "active",
enabled: true,
limit: { context: 128_000, output: 32_000 },
},
],
},
] satisfies readonly ModelsDev.Snapshot[]),
refresh: () => Effect.void,
})
const pluginHost = yield* PluginHost.make(plugins)
yield* ModelsDevPlugin.effect(pluginHost).pipe(Effect.provideService(ModelsDev.Service, modelsDev))
const scope = yield* Scope.make()
yield* catalog
.transform((draft) =>
draft.model.update(providerID, modelID, (model) => {
model.variants ??= []
model.variants.push({ id: Model.VariantID.make("configured") })
}),
)
.pipe(Scope.provide(scope))
expect((yield* catalog.model.get(providerID, modelID))?.variants).toEqual([
{ id: Model.VariantID.make("configured") },
])
yield* Scope.close(scope, Exit.void)
expect((yield* catalog.model.get(providerID, modelID))?.variants).toEqual([])
}),
)
it.effect("projects normalized models.dev snapshots into the catalog", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
+29
View File
@@ -0,0 +1,29 @@
import path from "path"
import { pathToFileURL } from "url"
import { expect, test } from "bun:test"
import { PluginModule } from "@opencode-ai/core/plugin/module"
import { Npm } from "@opencode-ai/util/npm"
import { Effect } from "effect"
test("loads cached plugin packages without requesting a refresh", async () => {
const calls: unknown[] = []
const entrypoint = path.join(import.meta.dir, "fixtures", "config-effect-plugin.ts")
const plugin = await PluginModule.load({ type: "add", target: "fixture-plugin", options: {} }).pipe(
Effect.provideService(
Npm.Service,
Npm.Service.of({
add: (_pkg, options) =>
Effect.sync(() => {
calls.push(options)
return { directory: path.dirname(entrypoint), entrypoint: pathToFileURL(entrypoint).href }
}),
resolve: () => Effect.die(new Error("Unexpected resolve")),
which: () => Effect.die(new Error("Unexpected which")),
}),
),
Effect.runPromise,
)
expect(plugin.id).toBe("config-effect-plugin")
expect(calls).toEqual([{ subpaths: ["server", ""] }])
})
@@ -25,7 +25,6 @@ function npmEntrypoint(entrypoint?: string) {
add: () => Effect.succeed({ directory: "", entrypoint }),
resolve: () => Effect.succeed({ directory: "", entrypoint }),
which: () => Effect.undefined,
check: () => Effect.succeed({ mutable: false }),
})
}
@@ -17,7 +17,6 @@ const npm = Npm.Service.of({
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined }),
which: () => Effect.undefined,
check: () => Effect.succeed({ mutable: false }),
})
const addPlugin = Effect.fn(function* () {
+13 -35
View File
@@ -70,20 +70,12 @@ describe("AppProcess", () => {
"requireSuccess fails on non-zero exit",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const exit = yield* Effect.exit(
svc.run(cmd("-e", "process.exit(1)")).pipe(Effect.flatMap(AppProcess.requireSuccess)),
)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
const reason = exit.cause.reasons[0]
if (reason && reason._tag === "Fail") {
expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError)
expect((reason.error as AppProcess.AppProcessError).exitCode).toBe(1)
expect((reason.error as AppProcess.AppProcessError).message).toContain("Command failed (exit 1)")
} else {
throw new Error("expected fail reason")
}
}
const error = yield* svc
.run(cmd("-e", "process.exit(1)"))
.pipe(Effect.flatMap(AppProcess.requireSuccess), Effect.flip)
expect(error).toBeInstanceOf(AppProcess.AppProcessError)
expect(error.exitCode).toBe(1)
expect(error.message).toContain("Command failed (exit 1)")
}),
)
@@ -105,15 +97,9 @@ describe("AppProcess", () => {
expect(okZero.exitCode).toBe(0)
const okOne = yield* svc.run(cmd("-e", "process.exit(1)")).pipe(Effect.flatMap(requireZeroOrOne))
expect(okOne.exitCode).toBe(1)
const exit = yield* Effect.exit(svc.run(cmd("-e", "process.exit(2)")).pipe(Effect.flatMap(requireZeroOrOne)))
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
const reason = exit.cause.reasons[0]
if (reason && reason._tag === "Fail") {
expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError)
expect((reason.error as AppProcess.AppProcessError).exitCode).toBe(2)
}
}
const error = yield* svc.run(cmd("-e", "process.exit(2)")).pipe(Effect.flatMap(requireZeroOrOne), Effect.flip)
expect(error).toBeInstanceOf(AppProcess.AppProcessError)
expect(error.exitCode).toBe(2)
}),
)
@@ -313,18 +299,10 @@ describe("AppProcess", () => {
.runStream(cmd("-e", "console.log('only'); process.exit(1)"), { okExitCodes: [0, 1] })
.pipe(Stream.runCollect)
expect(Array.from(allowed)).toEqual(["only"])
const exit = yield* Effect.exit(
svc
.runStream(cmd("-e", "console.log('a'); process.exit(2)"), { okExitCodes: [0, 1] })
.pipe(Stream.runCollect),
)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
const reason = exit.cause.reasons[0]
if (reason && reason._tag === "Fail") {
expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError)
}
}
const error = yield* svc
.runStream(cmd("-e", "console.log('a'); process.exit(2)"), { okExitCodes: [0, 1] })
.pipe(Stream.runCollect, Effect.flip)
expect(error).toBeInstanceOf(AppProcess.AppProcessError)
}),
)
@@ -20,6 +20,8 @@ import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { App } from "@opencode-ai/core/app"
import { Agent } from "@opencode-ai/core/agent"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -301,6 +303,54 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
}),
)
it.effect("manual compaction records model resolution failures without calling the model", () =>
Effect.gen(function* () {
requests = []
const compaction = yield* SessionCompaction.Service
const store = yield* SessionStore.Service
const sessionID = Session.ID.make("ses_manual_resolution_failure")
const session = yield* insertSession(sessionID)
const modelRequests = yield* SessionModelRequest.Service
const inputID = SessionMessage.ID.make("msg_manual_resolution_failure")
expect(
yield* compaction.compactManual({
session,
resolveModel: () =>
Effect.fail(
new SessionRunnerModel.ModelUnavailableError({
providerID: Provider.ID.make("test"),
modelID: Model.ID.make("missing"),
}),
),
prepare: modelRequests.prepare,
messages: [
{
id: SessionMessage.ID.create(),
type: "user",
text: "Summarize this conversation.",
time: { created: DateTime.makeUnsafe(0) },
},
],
inputID,
}),
).toEqual({
status: "failed",
error: { type: "provider.no-route", message: "Model unavailable: test/missing" },
})
expect(requests).toHaveLength(0)
expect(yield* store.context(sessionID)).toMatchObject([
{
id: inputID,
type: "compaction",
status: "failed",
reason: "manual",
error: { type: "provider.no-route", message: "Model unavailable: test/missing" },
},
])
}),
)
it.effect("forked session compaction reuses the fork root prompt cache key", () =>
Effect.gen(function* () {
requests = []
+12 -1
View File
@@ -67,6 +67,17 @@ const liveIt = testEffect(
],
),
)
const projectIt = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, Project.node, SessionProjector.node, SessionStore.node, Session.node]),
[
[Bus.node, Bus.configured({ persist: true })],
// Project adoption needs plain-prompt admission, not live plugin/provider startup.
[LocationServiceMap.node, promptLocationLayer],
[SessionExecution.node, SessionExecution.noopLayer],
],
),
)
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
const id = Session.ID.create()
@@ -119,7 +130,7 @@ describe("Session.create", () => {
),
)
liveIt.live("follows the directory's project identity established after creation", () =>
projectIt.live("follows the directory's project identity established after creation", () =>
withTmp((directory) =>
Effect.gen(function* () {
const session = yield* Session.Service
@@ -857,8 +857,7 @@ describe("SessionModelTransport", () => {
test("records metadata-only lifecycle metrics", async () => {
const fixture = automatic()
await run(
fixture.connector,
await Effect.runPromise(
Effect.gen(function* () {
const transport = yield* SessionModelTransport.Service
const executor = transport.bind(session)
@@ -874,7 +873,11 @@ describe("SessionModelTransport", () => {
)
expect(JSON.stringify(lifecycle)).not.toContain("secret-one")
expect(JSON.stringify(lifecycle)).not.toContain("secret-two")
}),
}).pipe(
Effect.provide(SessionModelTransport.makeLayer(fixture.connector)),
Effect.scoped,
Effect.provideService(Metric.MetricRegistry, new Map()),
),
)
})
})
+5 -8
View File
@@ -78,15 +78,11 @@ const locations = Layer.effect(
Layer.mock(Snapshot.Service, {
capture: () =>
ready ? Effect.undefined : Effect.die(new Error("Snapshot used before plugins were ready")),
restore: () =>
ready ? Effect.void : Effect.die(new Error("Snapshot used before plugins were ready")),
restore: () => (ready ? Effect.void : Effect.die(new Error("Snapshot used before plugins were ready"))),
}),
Layer.succeed(
PluginSupervisor.Service,
PluginSupervisor.Service.of({
flush: Effect.sync(() => (ready = true)),
check: () => Effect.succeed({ mutable: false }),
}),
PluginSupervisor.Service.of({ flush: Effect.sync(() => (ready = true)) }),
),
)
}),
@@ -175,8 +171,9 @@ const assistantRow = (id: SessionMessage.ID, seq: number) => {
describe("Session.prompt", () => {
it.effect("exposes the execution registry", () =>
Effect.gen(function* () {
const session = yield* Session.Service
activeSessions.add(sessionID)
expect(Array.from(yield* (yield* Session.Service).active)).toEqual([sessionID])
expect(Array.from(yield* session.active)).toEqual([sessionID])
}).pipe(Effect.ensuring(Effect.sync(() => activeSessions.clear()))),
)
@@ -560,7 +557,7 @@ describe("Session.prompt", () => {
yield* session.resume(sessionID)
expect(yield* session.messages({ sessionID })).toEqual([])
expect(yield* admitted(message.id)).not.toHaveProperty("promotedSeq")
expect((yield* session.inbox(sessionID)).map((item) => item.id)).toEqual([message.id])
expect(executionCalls).toEqual([sessionID])
expect(wakeCalls).toEqual([])
}),
@@ -85,10 +85,7 @@ const referenceInstructions = Layer.mock(ReferenceInstructions.Service, {
})
const mcpInstructions = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const config = Config.testLayer()
const pluginSupervisor = Layer.succeed(
PluginSupervisor.Service,
PluginSupervisor.Service.of({ flush: Effect.void, check: () => Effect.succeed({ mutable: false }) }),
)
const pluginSupervisor = Layer.succeed(PluginSupervisor.Service, PluginSupervisor.Service.of({ flush: Effect.void }))
const promptCatalog = Layer.mock(Catalog.Service, {
provider: {
get: () => Effect.undefined,
@@ -390,7 +390,6 @@ const layer = Layer.unwrap(
PluginSupervisor.Service,
PluginSupervisor.Service.of({
flush: Effect.suspend(() => state.pluginFlushHook),
check: () => Effect.succeed({ mutable: false }),
}),
)
const promptCatalog = Layer.mock(Catalog.Service, {
+1 -1
View File
@@ -38,7 +38,7 @@ const skills = Layer.mergeAll(
get: (id) => Effect.succeed(id === info.id ? info : undefined),
list: () => Effect.succeed([info]),
}),
Layer.succeed(PluginSupervisor.Service, { flush: Effect.void, check: () => Effect.succeed({ mutable: false }) }),
Layer.succeed(PluginSupervisor.Service, { flush: Effect.void }),
)
const locations = Layer.effect(
LocationServiceMap.Service,
+2 -4
View File
@@ -105,11 +105,9 @@ test("foreign typed failures settle as Tool.Error at the untrusted boundary", as
execute: () => new ForeignFailure({ message: "transport died" }) as never,
}
const exit = await Effect.runPromiseExit(execute(lying, {}, context))
expect(exit._tag).toBe("Failure")
const error = exit._tag === "Failure" ? exit.cause.reasons.find((reason) => "error" in reason)?.error : undefined
const error = await Effect.runPromise(execute(lying, {}, context).pipe(Effect.flip))
expect(error).toBeInstanceOf(Tool.Error)
expect((error as Tool.Error).message).toBe("transport died")
expect(error.message).toBe("transport died")
})
test("execute supports callable namespace tools", async () => {
+1 -3
View File
@@ -127,9 +127,7 @@ const shellPluginSupervisor = makeLocationNode({
service: PluginSupervisor.Service,
layer: Layer.effect(
PluginSupervisor.Service,
registerToolPlugin(ShellTool.Plugin).pipe(
Effect.as(PluginSupervisor.Service.of({ flush: Effect.void, check: () => Effect.succeed({ mutable: false }) })),
),
registerToolPlugin(ShellTool.Plugin).pipe(Effect.as(PluginSupervisor.Service.of({ flush: Effect.void }))),
),
deps: [
Config.node,
+1 -3
View File
@@ -100,9 +100,7 @@ const subagentPluginSupervisor = makeLocationNode({
service: PluginSupervisor.Service,
layer: Layer.effect(
PluginSupervisor.Service,
registerToolPlugin(SubagentTool.Plugin).pipe(
Effect.as(PluginSupervisor.Service.of({ flush: Effect.void, check: () => Effect.succeed({ mutable: false }) })),
),
registerToolPlugin(SubagentTool.Plugin).pipe(Effect.as(PluginSupervisor.Service.of({ flush: Effect.void }))),
),
deps: [Agent.node, Config.node, Permission.node, PluginRuntime.node, Tool.node],
})
+2 -2
View File
@@ -171,7 +171,7 @@ describe("Worktree", () => {
{ directory: created.directory, strategy: "git" },
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
)
expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
expect((yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: false })
@@ -550,7 +550,7 @@ describe("Worktree", () => {
{ directory: existing, strategy: "git" },
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
)
expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
expect((yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
yield* Effect.promise(() => $`git worktree remove --force ${target}`.cwd(input.root.path).quiet())
yield* Effect.promise(() => $`git worktree remove --force ${unchanged}`.cwd(input.root.path).quiet())
-1
View File
@@ -289,7 +289,6 @@ export function fromPromise(plugin: Plugin) {
},
plugin: {
list: adaptApiMethod(PluginEndpoints["plugin.list"], host.plugin.list),
check: adaptApiMethod(PluginEndpoints["plugin.check"], host.plugin.check),
},
reference: {
list: adaptApiMethod(ReferenceEndpoints["reference.list"], host.reference.list),
-153
View File
@@ -486,123 +486,6 @@
"summary": "List plugins"
}
},
"/api/plugin/check": {
"post": {
"tags": ["plugin"],
"operationId": "v2.plugin.check",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"location": {
"$ref": "#/components/schemas/Location.InfoEncoded"
},
"data": {
"$ref": "#/components/schemas/Plugin.PackageStatus"
}
},
"required": ["location", "data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "PluginCheckError | InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/PluginCheckErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Inspect installed and available revisions of an active or failed package in the current server inventory without installing it.",
"summary": "Check plugin package",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"target": {
"type": "string"
}
},
"required": ["target"],
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/session": {
"get": {
"tags": ["session"],
@@ -16424,9 +16307,6 @@
"source": {
"$ref": "#/components/schemas/Plugin.Source"
},
"revision": {
"type": "string"
},
"status": {
"type": "string",
"enum": ["active"]
@@ -16447,9 +16327,6 @@
"source": {
"$ref": "#/components/schemas/Plugin.Source"
},
"revision": {
"type": "string"
},
"status": {
"type": "string",
"enum": ["failed"]
@@ -16466,22 +16343,6 @@
}
]
},
"Plugin.PackageStatus": {
"type": "object",
"properties": {
"installed": {
"type": "string"
},
"available": {
"type": "string"
},
"mutable": {
"type": "boolean"
}
},
"required": ["mutable"],
"additionalProperties": false
},
"Plugin.Source": {
"anyOf": [
{
@@ -16536,20 +16397,6 @@
}
]
},
"PluginCheckErrorEncoded": {
"type": "object",
"properties": {
"_tag": {
"type": "string",
"enum": ["PluginCheckError"]
},
"message": {
"type": "string"
}
},
"required": ["_tag", "message"],
"additionalProperties": false
},
"Project": {
"type": "object",
"properties": {
-17
View File
@@ -19,23 +19,6 @@ export const PluginGroup = HttpApiGroup.make("server.plugin")
}),
),
)
.add(
HttpApiEndpoint.post("plugin.check", "/api/plugin/check", {
query: LocationQuery,
payload: Schema.Struct({ target: Schema.String }),
success: Location.response(Plugin.PackageStatus),
error: Plugin.CheckError.annotate({ httpApiStatus: 400 }),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.plugin.check",
summary: "Check plugin package",
description:
"Inspect installed and available revisions of an active or failed package in the current server inventory without installing it.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "plugin",
-15
View File
@@ -19,14 +19,12 @@ export const Info = Schema.Union([
Schema.Struct({
id: ID,
source: Source,
revision: optional(Schema.String),
status: Schema.Literal("active"),
tui: Schema.Boolean,
}),
Schema.Struct({
id: ID.pipe(optional),
source: Source,
revision: optional(Schema.String),
status: Schema.Literal("failed"),
error: Schema.String,
tui: Schema.Boolean,
@@ -34,19 +32,6 @@ export const Info = Schema.Union([
]).annotate({ identifier: "Plugin.Info" })
export type Info = typeof Info.Type
export const PackageStatus = Schema.Struct({
installed: optional(Schema.String),
available: optional(Schema.String),
mutable: Schema.Boolean,
}).annotate({ identifier: "Plugin.PackageStatus" })
export interface PackageStatus extends Schema.Schema.Type<typeof PackageStatus> {}
export class CheckError extends Schema.TaggedError<CheckError>()(
"PluginCheckError",
{ message: Schema.String },
{ identifier: "PluginCheckError" },
) {}
const Added = ephemeral({
type: "plugin.added",
schema: { id: ID },
-32
View File
@@ -1,32 +0,0 @@
import { expect, test } from "bun:test"
import { Schema } from "effect"
import { Plugin } from "../src/plugin.js"
test("plugin inspection omits absent revisions", () => {
expect(
Schema.encodeSync(Plugin.PackageStatus)({ installed: undefined, available: undefined, mutable: true }),
).toEqual({ mutable: true })
for (const status of ["active", "failed"] as const) {
const info = {
id: Plugin.ID.make("example"),
source: { type: "package" as const, package: "example@latest" },
status,
error: "setup failed",
tui: false,
revision: undefined,
}
expect(Schema.encodeSync(Plugin.Info)(info)).not.toHaveProperty("revision")
expect(Schema.encodeSync(Plugin.Info)({ ...info, revision: "1.2.3" })).toHaveProperty("revision", "1.2.3")
}
})
test("plugin inspection contracts have stable identifiers and readable errors", () => {
expect(Plugin.PackageStatus.ast.annotations?.identifier).toBe("Plugin.PackageStatus")
expect(Plugin.CheckError.ast.annotations?.identifier).toBe("PluginCheckError")
const error = new Plugin.CheckError({ message: "Plugin package is not configured: example" })
expect(error.message).toBe("Plugin package is not configured: example")
expect(Schema.encodeSync(Plugin.CheckError)(error)).toEqual({
_tag: "PluginCheckError",
message: error.message,
})
})
+1 -1
View File
@@ -47,7 +47,7 @@ for (const selection of ["explicit", "default"] as const) {
PluginSupervisor.Service,
Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
return { flush: release.open.pipe(Effect.andThen(plugins.flush)), check: plugins.check }
return { flush: release.open.pipe(Effect.andThen(plugins.flush)) }
}),
).pipe(Layer.provide(PluginSupervisor.layer))
const opencode = yield* fixture.sdk.OpenCode.create(
+5 -10
View File
@@ -1,18 +1,13 @@
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor-service"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
import { response } from "../location"
export const PluginHandler = HttpApiBuilder.group(Api, "server.plugin", (handlers) =>
handlers
.handle("plugin.list", () =>
Effect.gen(function* () {
return yield* response(Plugin.Service.use((plugin) => plugin.list()))
}),
)
.handle("plugin.check", ({ payload }) =>
response(PluginSupervisor.Service.use((supervisor) => supervisor.check(payload.target))),
),
handlers.handle("plugin.list", () =>
Effect.gen(function* () {
return yield* response(Plugin.Service.use((plugin) => plugin.list()))
}),
),
)
@@ -50,7 +50,6 @@ test("plugin readiness stays lazy and resolves the supervisor for every executio
() => new ServiceUnavailableError({ message: "initialization timed out", service: "test" }),
)
const layer = Layer.succeed(PluginSupervisor.Service, {
check: () => Effect.succeed({ mutable: false }),
flush: Effect.sync(() => {
flushes++
}),
-76
View File
@@ -1,76 +0,0 @@
import fs from "node:fs/promises"
import path from "node:path"
import { expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Npm } from "@opencode-ai/util/npm"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { ServerFetch } from "../src/fetch"
it.live("plugin check honors Location query, omits unknown revisions, and returns readable 400 errors", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("plugin-check-http-")))
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
yield* Effect.promise(() => Promise.all([fs.mkdir(global), fs.mkdir(project)]))
const target = "inspection-plugin@latest"
const config = JSON.stringify({ plugins: ["-*", target] })
yield* Effect.promise(() => Bun.write(path.join(project, "opencode.json"), config))
let fail = false
let checks = 0
const handler = yield* ServerFetch.make(
{ database: { path: ":memory:" }, config: { directory: global }, fs: { filewatcher: false } },
{
overrides: [
[
Npm.node,
Layer.succeed(Npm.Service, {
add: () => Effect.fail(new Npm.InstallFailedError({ dir: tmp.path })),
resolve: () => Effect.succeed({ directory: tmp.path }),
which: () => Effect.undefined,
check: () => {
checks++
if (fail)
return Effect.fail(
new Npm.InstallFailedError({ dir: tmp.path, cause: new Error("registry unavailable") }),
)
return Effect.succeed({ installed: undefined, available: "2.0.0", mutable: true })
},
}),
],
],
},
)
const request = (directory: string, target: string) =>
Effect.promise(async () => {
const url = new URL("http://opencode.local/api/plugin/check")
url.searchParams.set("location[directory]", directory)
const response = await handler(
new Request(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ target }),
}),
)
const body: unknown = await response.json()
return { status: response.status, body }
})
const result = yield* request(project, target)
expect(result).toMatchObject({
status: 200,
body: { location: { directory: project }, data: { available: "2.0.0", mutable: true } },
})
expect(result).not.toHaveProperty("body.data.installed")
expect(yield* request(global, target)).toMatchObject({
status: 400,
body: { _tag: "PluginCheckError", message: expect.stringContaining("server inventory") },
})
expect(checks).toBe(1)
fail = true
expect(yield* request(project, target)).toMatchObject({
status: 400,
body: { _tag: "PluginCheckError", message: expect.stringContaining("registry unavailable") },
})
expect(yield* Effect.promise(() => Bun.file(path.join(project, "opencode.json")).text())).toBe(config)
}),
)
+15 -10
View File
@@ -68,7 +68,7 @@ import { DialogThemeList } from "./component/dialog-theme-list"
import { DialogHelp } from "./ui/dialog-help"
import { DialogAgent } from "./component/dialog-agent"
import { DialogSessionList } from "./component/dialog-session-list"
import { DialogOpen, DialogOpenKey, loadDialogOpen } from "./component/dialog-open"
import { DialogOpen, DialogOpenKey, moveOpenSession } from "./component/dialog-open"
import { SessionTabs } from "./component/session-tabs"
import { clampSessionTabsWidth, sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH } from "./ui/layout"
import { createPaneResize } from "./ui/pane-resize"
@@ -507,7 +507,7 @@ function App(props: { pair?: DialogPairCredentials }) {
}).catch((error) => console.error("Failed to persist TUI layout", error))
},
})
let openingOpen: Promise<SessionInfo[]> | undefined
const [openSessions, setOpenSessions] = createSignal<SessionInfo[]>([])
// Toast once when an MCP server enters a failed or needs-auth state so the user knows to act,
// without having to open the status panel. Tracking the last alerted status avoids re-toasting
// the same problem on every refresh while still re-alerting if the state changes.
@@ -719,14 +719,12 @@ function App(props: { pair?: DialogPairCredentials }) {
title: "Open session or project",
category: "Session",
slash: { name: "open", aliases: ["projects", "project"] },
run: async () => {
if (dialog.key === DialogOpenKey || openingOpen) return
const previous = dialog.stack.at(-1)
openingOpen = loadDialogOpen(data, client)
const sessions = await openingOpen
openingOpen = undefined
if (dialog.stack.at(-1) !== previous) return
dialog.replace(() => <DialogOpen sessions={sessions} />, undefined, { key: DialogOpenKey, size: "large" })
run: () => {
if (dialog.key === DialogOpenKey) return
dialog.replace(() => <DialogOpen sessions={openSessions()} onLoad={setOpenSessions} />, undefined, {
key: DialogOpenKey,
size: "large",
})
},
},
...Array.from({ length: 9 }, (_, i) => ({
@@ -1213,7 +1211,14 @@ function App(props: { pair?: DialogPairCredentials }) {
})
})
event.on("session.moved", (evt) => {
setOpenSessions((sessions) =>
sessions.map((session) => (session.id !== evt.data.sessionID ? session : moveOpenSession(session, evt))),
)
})
event.on("session.deleted", (evt) => {
setOpenSessions((sessions) => sessions.filter((session) => session.id !== evt.data.sessionID))
if (route.data.type === "session" && route.data.sessionID === evt.data.sessionID) {
const title = active?.id === evt.data.sessionID ? active.title : undefined
route.navigate({ type: "home" })
+73 -17
View File
@@ -1,5 +1,5 @@
import { createMemo, createResource, createSignal } from "solid-js"
import type { SessionInfo } from "@opencode-ai/client"
import { createMemo, createResource, createSignal, onCleanup, Show } from "solid-js"
import type { OpenCodeEvent, SessionInfo } from "@opencode-ai/client"
import { useTerminalDimensions } from "@opentui/solid"
import type { RGBA } from "@opentui/core"
import { dialogWidth, useDialog } from "../ui/dialog"
@@ -25,18 +25,7 @@ export const DialogOpenKey = Symbol("DialogOpen")
type OpenTarget = { type: "session"; sessionID: string } | { type: "project"; directory: string }
export async function loadDialogOpen(data: ReturnType<typeof useData>, client: ReturnType<typeof useClient>) {
const [, sessions] = await Promise.all([
data.project.sync().catch(() => {}),
client.api.session
.list({ limit: 50, order: "desc", parentID: null })
.then((response) => response.data)
.catch(() => [] as SessionInfo[]),
])
return sessions
}
export function DialogOpen(props: { sessions: SessionInfo[] }) {
export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions: SessionInfo[]) => void }) {
const dialog = useDialog()
const route = useRoute()
const data = useData()
@@ -51,6 +40,41 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
const shortcuts = Keymap.useShortcuts()
const [filter, setFilter] = createSignal("")
const [selectionMoved, setSelectionMoved] = createSignal(false)
let closed = false
onCleanup(() => {
closed = true
})
const [recent] = createResource(() => {
// A late read must not overwrite deletion or placement facts observed in flight.
const changed = new Map<string, Extract<OpenCodeEvent, { type: "session.deleted" | "session.moved" }>>()
const unsubscribe = client.event.listen((message) => {
const event = message.details
if (event.type === "session.deleted" || event.type === "session.moved") changed.set(event.data.sessionID, event)
})
onCleanup(unsubscribe)
return client.api.session
.list({ limit: 50, order: "desc", parentID: null })
.then((response) => {
if (!closed)
props.onLoad(
response.data.flatMap((session) => {
const event = changed.get(session.id)
if (!event) return [session]
if (event.type === "session.deleted") return []
return [moveOpenSession(props.sessions.find((entry) => entry.id === session.id) ?? session, event)]
}),
)
return true
})
.catch(() => false)
.finally(unsubscribe)
})
const [projects] = createResource(() =>
data.project.sync().then(
() => true,
() => false,
),
)
const [matched] = createResource(
() => {
@@ -154,12 +178,34 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
preserveSelection={selectionMoved()}
onMove={() => setSelectionMoved(true)}
onFilter={setFilter}
emptyView={
<Show when={!recent.loading && !projects.loading}>
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>No recent sessions or projects</text>
</box>
</Show>
}
footer={
<box>
<Show when={recent.loading || projects.loading}>
<Spinner color={theme.text.subdued}>Refreshing sessions and projects...</Spinner>
</Show>
<Show when={recent() === false || projects() === false}>
<text fg={theme.text.feedback.error.default}>
Could not refresh{" "}
{recent() === false ? (projects() === false ? "sessions and projects" : "sessions") : "projects"}.
</text>
</Show>
</box>
}
noMatchView={
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>
{shortcuts.get("session.list")
? `No matches · search all sessions with ${shortcuts.get("session.list")}`
: "No matches"}
{recent.loading || projects.loading || matched.loading
? "Searching sessions and projects..."
: shortcuts.get("session.list")
? `No matches · search all sessions with ${shortcuts.get("session.list")}`
: "No matches"}
</text>
</box>
}
@@ -177,6 +223,16 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
)
}
export function moveOpenSession(session: SessionInfo, event: Extract<OpenCodeEvent, { type: "session.moved" }>) {
return {
...session,
location: event.data.location,
projectID: event.data.projectID ?? session.projectID,
subpath: event.data.subpath,
time: { ...session.time, updated: Math.max(session.time.updated, event.created) },
}
}
function timeAgo(timestamp: number) {
const minutes = Math.floor((Date.now() - timestamp) / 60_000)
if (minutes < 1) return "now"
-1
View File
@@ -269,7 +269,6 @@ export const Definitions = {
"prompt.autocomplete.complete": keybind("tab", "Complete autocomplete item"),
"permission.prompt.fullscreen": keybind("ctrl+f", "Toggle permission prompt fullscreen"),
"plugins.toggle": keybind("space", "Toggle plugin"),
"plugins.check": keybind("ctrl+r", "Check for plugin updates"),
"dialog.mcp.toggle": keybind("space", "Toggle MCP server"),
"dialog.plugins.install": keybind("shift+i", "Install plugin from plugin dialog"),
@@ -1,15 +1,10 @@
import type { PluginInfo } from "@opencode-ai/client"
import { Plugin } from "@opencode-ai/plugin/tui"
import type { PackageStatus } from "@opencode-ai/schema/plugin"
import type { ScrollBoxRenderable } from "@opentui/core"
import { useTerminalDimensions } from "@opentui/solid"
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, Show } from "solid-js"
import { createEffect, createMemo, createResource, createSignal, onMount, Show } from "solid-js"
import { DialogErrorDetails } from "../../component/dialog-error-details"
import { usePlugin } from "../../plugin/context"
import { localSource } from "../../plugin/discovery"
import { DialogSelect, type DialogSelectOption, type DialogSelectRef } from "../../ui/dialog-select"
import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select"
import { useDialog } from "../../ui/dialog"
import { errorMessage } from "../../util/error"
const id = "opencode.plugins"
@@ -22,8 +17,6 @@ type Entry =
readonly target: string
readonly status: "active" | "inactive" | "failed"
readonly error?: string
readonly revision?: string
readonly builtin?: boolean
}
export function PluginsDialog(props: {
@@ -32,28 +25,18 @@ export function PluginsDialog(props: {
server?: () => readonly PluginInfo[]
}) {
const dialog = useDialog()
const dimensions = useTerminalDimensions()
const [locked, setLocked] = createSignal(false)
const [busy, setBusy] = createSignal(false)
const [list, setList] = createSignal<DialogSelectRef<string>>()
const [filter, setFilter] = createSignal("")
const [detail, setDetail] = createSignal<string>()
const [errorDetail, setErrorDetail] = createSignal(false)
const [errors, setErrors] = createSignal<Record<string, string | undefined>>({})
const [checks, setChecks] = props.context.storage.memory("checks", {
initial: { packages: {} as Record<string, PackageStatus> },
})
const [focused, setFocused] = createSignal<string>()
const [detail, setDetail] = createSignal<Entry>()
const [initial, setInitial] = createSignal<string>()
const [server, { refetch }] = createResource(
() => (props.server ? undefined : props.context.data.location.default()),
const [server] = createResource(
() => (props.server ? undefined : (props.context.location ?? props.context.data.location.default())),
(location) => props.context.client.plugin.list({ location }).then((result) => result.data),
)
onMount(() => dialog.setSize("medium"))
onCleanup(props.context.data.on("plugin.updated", () => void refetch()))
onCleanup(props.context.data.on("server.connected", () => void refetch()))
const entries = createMemo<Entry[]>(() => {
const registered = props.plugins.registered()
const builtins: Entry[] = registered
const builtins: Entry[] = props.plugins
.registered()
.filter((plugin) => plugin.id !== id && plugin.source === "builtin")
.map((plugin) => ({
key: `tui:${plugin.id}`,
@@ -61,7 +44,6 @@ export function PluginsDialog(props: {
id: plugin.id,
target: plugin.id,
status: plugin.active ? ("active" as const) : ("inactive" as const),
builtin: true,
}))
const external: Entry[] = props.plugins
.list()
@@ -73,7 +55,6 @@ export function PluginsDialog(props: {
target: plugin.target,
status: plugin.status,
error: plugin.status === "failed" ? plugin.error : undefined,
revision: registered.find((item) => item.id === plugin.id)?.revision,
}))
const serverEntries: Entry[] = (props.server?.() ?? server() ?? []).map((plugin) => ({
key: `server:${plugin.id ?? source(plugin, props.context)}`,
@@ -90,68 +71,9 @@ export function PluginsDialog(props: {
const first = entries().find((entry) => entry.runtime === "tui")
if (!first) return
setInitial(first.key)
setFocused(first.key)
})
const owner = (entry: Entry | undefined) => {
if (!entry) return undefined
if (entry.runtime === "server")
return entry.plugin.source.type === "package"
? { runtime: "server" as const, target: entry.plugin.source.package }
: undefined
if (entry.builtin || localSource(entry.target, ".")) return undefined
const companion = (props.server?.() ?? server() ?? []).some(
(plugin) => plugin.source.type === "package" && plugin.source.package === entry.target,
)
return { runtime: companion ? "server" : "tui", target: entry.target }
}
const checkKey = (entry: Entry | undefined) => {
const target = owner(entry)
return JSON.stringify([
target?.runtime,
target?.runtime === "server" ? props.context.data.location.default() : undefined,
target?.target,
])
}
const checked = (entry: Entry | undefined): PackageStatus | undefined => checks.packages[checkKey(entry)]
const revision = (entry: Entry) => (entry.runtime === "server" ? entry.plugin.revision : entry.revision)
const available = (entry: Entry) => {
const value = checked(entry)
return value?.mutable && value.available && value.available !== (revision(entry) ?? value.installed)
}
const entryError = (entry: Entry | undefined) => {
const load = pluginError(entry)
const check = errors()[checkKey(entry)]
return load && check ? `Load error:\n${load}\n\nCheck error:\n${check}` : (check ?? load)
}
const check = (entry: Entry | undefined) => {
const target = owner(entry)
if (locked() || !entry || !target) return
const key = checkKey(entry)
setLocked(true)
setBusy(true)
setErrors((items) => ({ ...items, [key]: undefined }))
const task =
target.runtime === "server"
? props.context.client.plugin
.check({ target: target.target, location: props.context.data.location.default() })
.then((result) => result.data)
: props.plugins.check(target.target)
void task
.then((result) =>
setChecks((draft) => {
draft.packages[key] = result
}),
)
.catch((cause) => {
setErrors((items) => ({ ...items, [key]: errorMessage(cause) }))
props.context.ui.toast.show({ variant: "error", message: "Could not check plugin updates; view details." })
})
.finally(() => {
setBusy(false)
setLocked(false)
})
}
const options = createMemo(() =>
entries().map(
(entry): DialogSelectOption<string> => ({
@@ -159,18 +81,11 @@ export function PluginsDialog(props: {
value: entry.key,
category: entry.runtime === "tui" ? "TUI" : "Server",
searchText: entry.runtime === "tui" ? entry.target : source(entry.plugin, props.context),
footer: entryError(entry)
? "failed"
: available(entry)
? "↑ update"
: checked(entry)?.mutable === false
? "pinned"
: status(entry) === "active"
? undefined
: status(entry),
footerColor: entryError(entry)
? props.context.theme.text.feedback.error.default
: props.context.theme.text.subdued,
footer: status(entry) === "active" ? undefined : status(entry),
footerColor:
status(entry) === "failed"
? props.context.theme.text.feedback.error.default
: props.context.theme.text.subdued,
gutter:
status(entry) === "active"
? () => <text fg={props.context.theme.text.feedback.success.default}></text>
@@ -180,10 +95,10 @@ export function PluginsDialog(props: {
}),
),
)
const focusedEntry = createMemo(() => entries().find((entry) => entry.key === list()?.selected?.value))
const focusedEntry = createMemo(() => entries().find((entry) => entry.key === focused()))
const focusedTui = createMemo(() => {
const entry = focusedEntry()
if (entry?.runtime !== "tui" || !entry.id) return undefined
if (entry?.runtime !== "tui" || !entry.id) return
return entry
})
const toggleTitle = createMemo(() => {
@@ -210,18 +125,10 @@ export function PluginsDialog(props: {
.finally(() => setLocked(false))
}
const detailEntry = createMemo(() => entries().find((entry) => entry.key === detail()))
const back = () => {
setDetail()
setErrorDetail(false)
dialog.setSize("medium")
}
let scroll: ScrollBoxRenderable | undefined
return (
<box>
<Show
when={detailEntry()}
when={detail()}
fallback={
<DialogSelect
title="Plugins"
@@ -229,133 +136,45 @@ export function PluginsDialog(props: {
current={initial()}
locked={locked()}
preserveSelection={true}
initialFilter={filter()}
onFilter={setFilter}
ref={setList}
onMove={(option) => setFocused(option.value)}
onSelect={(option) => {
setInitial(option.value)
setDetail(option.value)
const entry = entries().find((entry) => entry.key === option.value)
if (pluginError(entry)) setDetail(entry)
}}
actions={[
...(focusedTui() && !busy()
actions={
focusedTui()
? [
{
title: toggleTitle(),
command: "plugins.toggle",
onTrigger: (option: DialogSelectOption<string>) =>
toggle(entries().find((entry) => entry.key === option.value)),
onTrigger: (option) => toggle(entries().find((entry) => entry.key === option.value)),
},
]
: []),
...(owner(focusedEntry()) && !busy()
? [
{
title: "check",
command: "plugins.check",
onTrigger: (option: DialogSelectOption<string>) =>
check(entries().find((entry) => entry.key === option.value)),
},
]
: []),
]}
footerHints={!busy() && dimensions().width >= 60 ? [{ title: "enter", label: "details" }] : []}
: []
}
footer={
<Show when={busy()}>
<text fg={props.context.theme.text.subdued}>Checking...</text>
<Show when={pluginError(focusedEntry())}>
<text>
<span style={{ fg: props.context.theme.text.default }}>
<b>enter</b>
</span>
<span style={{ fg: props.context.theme.text.subdued }}> view error</span>
</text>
</Show>
}
/>
}
>
{(entry) => (
<Show
when={errorDetail()}
fallback={
<DialogSelect
title={label(entry(), props.context)}
renderFilter={false}
locked={locked()}
titleView={
<box flexGrow={1} flexShrink={1} flexBasis={0} minWidth={0} gap={1}>
<text fg={props.context.theme.text.default} truncate>
<b>{label(entry(), props.context)}</b>
</text>
<scrollbox
ref={(value) => {
scroll = value
}}
width="100%"
height={Math.min(10, Math.max(2, Math.floor((dimensions().height * 3) / 4) - 8))}
scrollbarOptions={{ visible: false }}
>
<text width="100%" fg={props.context.theme.text.subdued} wrapMode="word">
{[
`Runtime ${entry().runtime === "server" ? "Server" : "This terminal"}`,
`Status ${status(entry())}`,
`Source ${pluginSource(entry(), props.context)}`,
`Scope ${owner(entry())?.runtime === "server" || entry().runtime === "server" ? `Server: ${props.context.ui.format.path(props.context.data.location.default().directory)}` : "This terminal"}`,
`Loaded ${revision(entry()) ?? "Unknown"}`,
`Installed ${owner(entry()) ? (checked(entry()) ? (checked(entry())?.installed ?? "Unknown") : "Not checked") : "Not applicable"}`,
`Available ${owner(entry()) ? (checked(entry()) ? (checked(entry())?.available ?? "Unknown") : "Not checked") : "Not applicable"}${checked(entry())?.mutable === false ? " (pinned)" : ""}`,
owner(entry())
? owner(entry())?.runtime === "server"
? "Restart the server, then reopen the TUI to apply updates."
: "Select a newer package spec in cli.json, then reopen the TUI."
: pluginSource(entry(), props.context) === "builtin"
? "Updates with OpenCode itself."
: "Local/SDK source; no package check.",
].join("\n")}
</text>
</scrollbox>
</box>
}
options={[
...(owner(entry()) ? [{ title: "Check for updates", value: "check" }] : []),
...(entryError(entry()) ? [{ title: "View error details", value: "error" }] : []),
{ title: "Back to plugins", value: "back" },
]}
onSelect={(option) => {
if (option.value === "check") return check(entry())
if (option.value === "error") return setErrorDetail(true)
back()
}}
bindings={[
{ bind: "escape", title: "Back", group: "Dialog", run: back },
{ bind: "pageup", title: "Scroll details up", group: "Dialog", run: () => scroll?.scrollBy(-5) },
{ bind: "pagedown", title: "Scroll details down", group: "Dialog", run: () => scroll?.scrollBy(5) },
...(owner(entry())
? [{ id: "plugins.check", title: "Check for updates", group: "Dialog", run: () => check(entry()) }]
: []),
...(entry().runtime === "tui"
? [{ id: "plugins.toggle", title: "Toggle plugin", group: "Dialog", run: () => toggle(entry()) }]
: []),
]}
footer={
<text fg={props.context.theme.text.subdued}>
{busy()
? "Checking..."
: entryError(entry())
? "Check/load failed; view error."
: available(entry())
? "↑ New revision available."
: checked(entry())?.mutable === false
? "Pinned source."
: "PgUp/PgDn scroll details"}
</text>
}
/>
}
>
<DialogErrorDetails
title={`${entry().runtime === "tui" ? "TUI" : "Server"} plugin: ${label(entry(), props.context)}`}
error={entryError(entry()) ?? "Unknown plugin error"}
context={`Status: ${status(entry())}\nRuntime: ${entry().runtime}\nSource: ${pluginSource(entry(), props.context)}`}
onBack={() => {
setErrorDetail(false)
dialog.setSize("medium")
}}
/>
</Show>
<DialogErrorDetails
title={`${entry().runtime === "tui" ? "TUI" : "Server"} plugin: ${label(entry(), props.context)}`}
error={pluginError(entry()) ?? "Unknown plugin error"}
context={`Status: failed\nRuntime: ${entry().runtime}\nSource: ${pluginSource(entry(), props.context)}`}
onBack={() => {
setDetail()
dialog.setSize("medium")
}}
/>
)}
</Show>
</box>
@@ -368,7 +187,7 @@ function label(entry: Entry, context: Plugin.Context) {
}
function pluginSource(entry: Entry, context: Plugin.Context) {
if (entry.runtime === "tui") return entry.builtin ? "builtin" : entry.target
if (entry.runtime === "tui") return entry.target
return source(entry.plugin, context)
}
+6 -40
View File
@@ -1,5 +1,4 @@
import type { PluginInfo } from "@opencode-ai/client"
import type { PackageStatus } from "@opencode-ai/schema/plugin"
import type { Plugin } from "@opencode-ai/plugin/tui"
import { createMarkdownCodeBlockRenderer, type MarkdownCodeBlockRenderer, type MarkdownOptions } from "@opentui/core"
import {
@@ -34,8 +33,7 @@ import { createSourceWatcher } from "./watch"
import { discoverTuiPlugins, freshSpecifier, localSource } from "./discovery"
export interface PackageResolver {
readonly resolve: (spec: string, install?: boolean) => Promise<{ entrypoint?: string; revision?: string }>
readonly check: (spec: string) => Promise<PackageStatus>
readonly resolve: (spec: string, install?: boolean) => Promise<string | undefined>
}
type State =
@@ -47,7 +45,6 @@ type RegisteredPlugin = {
readonly id: string
readonly source: "builtin" | "external"
readonly active: boolean
readonly revision?: string
}
type Value = {
@@ -63,7 +60,6 @@ type Value = {
readonly markdown: () => MarkdownOptions["renderNode"]
readonly activate: (id: string) => Promise<boolean>
readonly deactivate: (id: string) => Promise<boolean>
readonly check: (target: string) => Promise<PackageStatus>
}
type Registration = {
@@ -71,7 +67,6 @@ type Registration = {
source: RegisteredPlugin["source"]
target?: string
version: string
revision?: string
options?: Readonly<Record<string, any>>
active: boolean
routes: Record<string, Page>
@@ -81,14 +76,10 @@ type Registration = {
}
// One entry of the desired plugin generation produced by the resolve phase.
type Desired = Pick<Registration, "plugin" | "source" | "target" | "version" | "revision" | "options"> & {
enabled: boolean
}
type Desired = Pick<Registration, "plugin" | "source" | "target" | "version" | "options"> & { enabled: boolean }
const PluginContext = createContext<Value>()
let sourceVersion = Date.now()
// Re-importing the same specifier can return cached code even if its package changed on disk.
const importedRevisions = new WeakMap<object, string | undefined>()
export function combineMarkdownRenderers(
sources: ReadonlyArray<Readonly<Record<string, MarkdownCodeBlockRenderer>>>,
@@ -343,7 +334,6 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
source: "external",
target,
version: resolved.version,
revision: resolved.revision,
options,
enabled: true,
})
@@ -544,7 +534,6 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
id,
source: plugin.source,
active: plugin.active,
revision: plugin.revision,
})),
route: (id, name) => store.registrations[id]?.routes[name]?.render,
slots: { register: registerSlot, resolved },
@@ -553,13 +542,6 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
// toggle mid-reload cannot mix registrations across generations.
activate: (id) => enqueue(() => activate(id)),
deactivate: (id) => enqueue(() => deactivate(id)),
check: async (target) => {
if (!store.states.some((state) => state.target === target) || localSource(target, directory))
throw new Error(`Not a configured package plugin: ${target}`)
if (serverPlugins().some((plugin) => plugin.source.package === target))
return (await client.api.plugin.check({ target, location: data.location.default() })).data
return props.packages.check(target)
},
}}
>
{props.children}
@@ -598,16 +580,8 @@ async function resolvePlugin(
// Package entrypoints never change within a session, so a loaded previous
// version needs no re-resolution (which could otherwise hit npm).
if (!local && previous && sameOptions(previous.options, options))
return {
status: "unchanged" as const,
plugin: previous.plugin,
version: previous.version,
revision: previous.revision,
}
const resolved = local
? { entrypoint: await resolveLocal(local), revision: undefined }
: await packages.resolve(spec, install)
const entrypoint = resolved.entrypoint
return { status: "unchanged" as const, plugin: previous.plugin, version: previous.version }
const entrypoint = local ? await resolveLocal(local) : await packages.resolve(spec, install)
if (!entrypoint) return { status: "unsupported" as const }
// Content remains stable across the several mtimes one save may expose to
// filesystem watchers, while the generation keeps reverted modules fresh.
@@ -615,7 +589,7 @@ async function resolvePlugin(
while (true) {
const version = generation === undefined ? entrypoint : freshSpecifier(entrypoint, generation)
if (previous && previous.version === version && sameOptions(previous.options, options))
return { status: "unchanged" as const, plugin: previous.plugin, version, revision: previous.revision }
return { status: "unchanged" as const, plugin: previous.plugin, version }
const mod: { readonly default?: unknown } = await import(version)
if (generation !== undefined) {
const observed = await sourceGeneration(entrypoint)
@@ -627,13 +601,7 @@ async function resolvePlugin(
}
}
if (!isPlugin(mod.default)) throw new Error(`Invalid V2 TUI plugin module: ${spec}`)
if (!local && !importedRevisions.has(mod)) importedRevisions.set(mod, resolved.revision)
return {
status: "loaded" as const,
plugin: mod.default,
version,
revision: local ? undefined : importedRevisions.get(mod),
}
return { status: "loaded" as const, plugin: mod.default, version }
}
}
@@ -643,7 +611,6 @@ function toRegistration(item: Desired): Registration {
source: item.source,
target: item.target,
version: item.version,
revision: item.revision,
options: snapshotOptions(item.options),
active: false,
routes: {},
@@ -659,7 +626,6 @@ function toDesired(item: Registration): Desired {
source: item.source,
target: item.target,
version: item.version,
revision: item.revision,
options: item.options,
enabled: item.active,
}
+18 -28
View File
@@ -17,7 +17,6 @@ export interface DialogSelectProps<T> {
title: string
titleView?: JSX.Element
placeholder?: string
initialFilter?: string
footer?: JSX.Element
emptyView?: JSX.Element
noMatchView?: JSX.Element
@@ -94,7 +93,6 @@ export function dialogSelectContentWidth(dialogWidth: number) {
export type DialogSelectRef<T> = {
filter: string
filtered: DialogSelectOption<T>[]
selected: DialogSelectOption<T> | undefined
moveTo(value: T): void
}
@@ -113,7 +111,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
const [store, setStore] = createStore({
selected: 0,
filter: props.initialFilter ?? "",
filter: "",
input: "keyboard" as "keyboard" | "mouse",
})
const [focusedAction, setFocusedAction] = createSignal<number>()
@@ -286,8 +284,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
selection = option
if (!moved) return
if (
(!props.preserveSelection && (props.current === undefined || props.focusCurrent === false)) ||
store.filter.length > 0
!props.preserveSelection &&
(props.current === undefined || props.focusCurrent === false || store.filter.length > 0)
)
return
scrollAfterLayout(false, option.value)
@@ -307,25 +305,21 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
})
createEffect(
on(
[() => store.filter, () => props.current],
([filter, current]) => {
if (filter.length > 0) resetSelection = true
if (filter.length > 0) {
const option = flat()[0]
if (!option) return
moveTo(0, true, false)
scrollAfterLayout(true, option.value)
return
}
if (!current || props.focusCurrent === false) return
const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current))
if (currentIndex < 0) return
moveTo(currentIndex, true)
scrollAfterLayout(true, current)
},
{ defer: Boolean(props.initialFilter) },
),
on([() => store.filter, () => props.current], ([filter, current]) => {
if (filter.length > 0) resetSelection = true
if (filter.length > 0) {
const option = flat()[0]
if (!option) return
moveTo(0, true, false)
scrollAfterLayout(true, option.value)
return
}
if (!current || props.focusCurrent === false) return
const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current))
if (currentIndex < 0) return
moveTo(currentIndex, true)
scrollAfterLayout(true, current)
}),
)
function move(direction: number) {
@@ -532,9 +526,6 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
get filtered() {
return filtered()
},
get selected() {
return selected()
},
moveTo(value) {
const index = flat().findIndex((option) => isDeepEqual(option.value, value))
if (index >= 0) moveTo(index, true)
@@ -635,7 +626,6 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<Show when={props.renderFilter !== false}>
<box paddingTop={1}>
<input
value={props.initialFilter ?? ""}
onInput={(e) => {
if (props.locked) return
batch(() => {
+248 -13
View File
@@ -8,6 +8,241 @@ import path from "node:path"
import { createEventStream, createFetch, directory, json } from "./fixture/tui-client"
import { tmpdir } from "./fixture/fixture"
test.each([100, 44])("Ctrl-O is immediate, dismissible, and prunes cached deletions at width %s", async (width) => {
await using state = await tmpdir()
const setup = await createTestRenderer({ width, height: 30, useThread: false, kittyKeyboard: true })
setup.renderer.start()
const ready = Promise.withResolvers<void>()
const requested = Promise.withResolvers<void>()
const response = Promise.withResolvers<Response>()
const projects = Promise.withResolvers<Response>()
const refresh = Promise.withResolvers<Response>()
const events = createEventStream()
const cachedSession = {
id: "ses_cached",
title: "Cached session",
projectID: "proj_fixture",
location: { directory: "/fixture" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 2 },
}
let requests = 0
const calls = createFetch((url) => {
if (url.pathname === "/api/session") {
requests++
requested.resolve()
if (requests === 1) return response.promise
if (requests === 2 || requests === 4) return refresh.promise.then((response) => response.clone())
if (requests > 4) return new Response("Unavailable", { status: 503 })
return json({ data: [cachedSession], cursor: {} })
}
if (url.pathname === "/api/project") return projects.promise
return undefined
}, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
try {
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({ animations: false }), update: async () => ({}) },
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
args: {},
log: () => {},
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
)
await ready.promise
await setup.waitForFrame((frame) => frame.includes("commands"))
setup.mockInput.pressKey("o", { ctrl: true })
await requested.promise
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain("Search sessions")
expect(setup.captureCharFrame()).toContain("Refreshing")
projects.resolve(
json([
{
id: "proj_fixture",
canonical: "/fixture",
name: "Fixture project",
time: { created: 1, updated: 2 },
sandboxes: [],
},
]),
)
await setup.waitForFrame((frame) => frame.includes("Fixture project"))
setup.mockInput.pressKey("o", { ctrl: true })
expect(requests).toBe(1)
setup.mockInput.pressEscape()
await setup.waitForFrame((frame) => !frame.includes("Fixture project"))
response.resolve(json({ data: [{ ...cachedSession, id: "ses_disposed", title: "Disposed response" }], cursor: {} }))
await setup.renderOnce()
expect(setup.captureCharFrame()).not.toContain("Fixture project")
setup.mockInput.pressKey("o", { ctrl: true })
await setup.waitForFrame((frame) => frame.includes("Refreshing"))
expect(setup.captureCharFrame()).not.toContain("Disposed")
setup.mockInput.pressEscape()
await setup.waitForFrame((frame) => !frame.includes("Fixture project"))
setup.mockInput.pressKey("o", { ctrl: true })
await setup.waitForFrame((frame) => frame.includes("Cached"))
setup.mockInput.pressEscape()
await setup.waitForFrame((frame) => !frame.includes("Fixture project"))
setup.mockInput.pressKey("o", { ctrl: true })
await setup.waitForFrame((frame) => frame.includes("Cached") && frame.includes("Refreshing"))
events.emit({
id: "evt_deleted",
created: 1,
type: "session.deleted",
durable: { aggregateID: "ses_cached", seq: 1, version: 2 },
data: { sessionID: "ses_cached" },
})
await setup.waitForFrame((frame) => !frame.includes("Cached"))
refresh.resolve(json({ data: [cachedSession], cursor: {} }))
await setup.waitForFrame((frame) => frame.includes("Fixture project") && !frame.includes("Refreshing"))
expect(setup.captureCharFrame()).not.toContain("Cached")
setup.mockInput.pressEscape()
await setup.waitForFrame((frame) => !frame.includes("Fixture project"))
setup.mockInput.pressKey("o", { ctrl: true })
await setup.waitForFrame((frame) => frame.includes("Could not refresh sessions"))
expect(setup.captureCharFrame()).not.toContain("Cached")
setup.renderer.destroy()
await task
} finally {
response.resolve(json({ data: [], cursor: {} }))
projects.resolve(json([]))
refresh.resolve(json({ data: [], cursor: {} }))
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await server.stop()
}
})
test.each(["dismissed", "refreshing"])(
"Ctrl-O retains committed movement of a cached-only session while %s",
async (phase) => {
await using state = await tmpdir()
const setup = await createTestRenderer({ width: 100, height: 30, useThread: false, kittyKeyboard: true })
setup.renderer.start()
const ready = Promise.withResolvers<void>()
const refresh = Promise.withResolvers<Response>()
const metadata = Promise.withResolvers<Response>()
const destinationRequested = Promise.withResolvers<void>()
const events = createEventStream()
const cached = {
id: "ses_cached_move",
title: "Cached movement",
projectID: "proj_old",
location: { directory: "/fixture/old" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 2 },
}
let requests = 0
const locations: string[] = []
const calls = createFetch((url) => {
if (url.pathname === "/api/session") {
if (url.searchParams.has("parentID")) {
const parent = url.searchParams.get("parentID")
if (parent && parent !== "null") return json({ data: [], cursor: {} })
}
return requests++ === 0
? json({ data: [cached], cursor: {} })
: refresh.promise.then((response) => response.clone())
}
if (url.pathname === `/api/session/${cached.id}`) return metadata.promise
if (url.pathname === `/api/session/${cached.id}/message`) return json({ data: [], cursor: {} })
if (url.pathname === `/api/session/${cached.id}/inbox` || url.pathname === `/api/session/${cached.id}/permission`)
return json({ data: [] })
if (url.pathname === "/api/project")
return json(
["old", "new"].map((name) => ({
id: `proj_${name}`,
canonical: `/fixture/${name}`,
name: name === "old" ? "Old" : "New",
time: { created: 1, updated: 2 },
sandboxes: [],
})),
)
if (url.pathname === "/api/location") {
const query = url.searchParams.get("location[directory]") ?? ""
locations.push(query)
if (query.includes("/fixture/new")) {
destinationRequested.resolve()
return json({
directory: "/fixture/new",
project: { id: "proj_new", directory: "/fixture/new", canonical: "/fixture/new" },
})
}
}
return undefined
}, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({ animations: false, tabs: { enabled: false } }), update: async () => ({}) },
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
args: {},
log: () => {},
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
)
try {
await ready.promise
await setup.waitForFrame((frame) => frame.includes("commands"))
setup.mockInput.pressKey("o", { ctrl: true })
await setup.waitForFrame((frame) => frame.includes(cached.title) && !frame.includes("Refreshing"))
setup.mockInput.pressEscape()
await setup.waitForFrame((frame) => !frame.includes("Search sessions"))
if (phase === "refreshing") {
setup.mockInput.pressKey("o", { ctrl: true })
await setup.waitForFrame((frame) => frame.includes(cached.title) && frame.includes("Refreshing"))
}
events.emit({
id: "evt_cached_moved",
created: 3,
type: "session.moved",
durable: { aggregateID: cached.id, seq: 1, version: 1 },
data: { sessionID: cached.id, location: { directory: "/fixture/new" }, projectID: "proj_new" },
})
if (phase === "dismissed") {
setup.mockInput.pressKey("o", { ctrl: true })
refresh.resolve(new Response("Unavailable", { status: 503 }))
await setup.waitForFrame((frame) => frame.includes("Could not refresh sessions"))
}
if (phase === "refreshing") {
await setup.waitForFrame((frame) =>
frame.split("\n").some((line) => line.includes(cached.title) && line.includes("New")),
)
refresh.resolve(json({ data: [cached], cursor: {} }))
await setup.waitForFrame((frame) => frame.includes(cached.title) && !frame.includes("Refreshing"))
}
await setup.waitForFrame((frame) =>
frame.split("\n").some((line) => line.includes(cached.title) && line.includes("New")),
)
expect(
setup
.captureCharFrame()
.split("\n")
.find((line) => line.includes(cached.title)),
).toContain("New")
locations.length = 0
setup.mockInput.pressEnter()
await destinationRequested.promise
expect(locations.some((query) => query.includes("/fixture/old"))).toBe(false)
} finally {
refresh.resolve(json({ data: [], cursor: {} }))
metadata.resolve(json({ data: { ...cached, projectID: "proj_new", location: { directory: "/fixture/new" } } }))
setup.renderer.destroy()
await task
await server.stop()
}
},
)
test("SIGHUP clears title and disposes scoped resources once", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
const titles: string[] = []
@@ -32,7 +267,7 @@ test("SIGHUP clears title and disposes scoped resources once", async () => {
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: { resolve: async () => ({}), check: async () => ({ mutable: false }) },
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: {},
log: () => {},
@@ -108,7 +343,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: { resolve: async () => ({}), check: async () => ({ mutable: false }) },
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy" },
log: () => {},
@@ -182,7 +417,7 @@ test("session title generated while an untitled session is loading remains visib
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: { resolve: async () => ({}), check: async () => ({ mutable: false }) },
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy" },
log: () => {},
@@ -274,7 +509,7 @@ test("automatic rename refreshes the displayed title before settling, even witho
}),
update: async () => ({}),
},
packages: { resolve: async () => ({}), check: async () => ({ mutable: false }) },
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: session.id },
log: () => {},
@@ -354,7 +589,7 @@ test("session startup prompt is submitted exactly once", async () => {
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: { resolve: async () => ({}), check: async () => ({ mutable: false }) },
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy", prompt: "RESUME_READY" },
log: () => {},
@@ -435,7 +670,7 @@ test.each([false, true])("uses the resolved launch directory for new prompts (fa
get: async () => ({ animations: false, tabs: { enabled: false }, keybinds: { "session.new": "f6" } }),
update: async () => ({}),
},
packages: { resolve: async () => ({}), check: async () => ({ mutable: false }) },
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
args: {},
log: () => {},
@@ -509,7 +744,7 @@ test("error investigations repeatedly seed editable home drafts without creating
get: async () => ({ animations: false, keybinds: { "mcp.list": "f6" } }),
update: async () => ({}),
},
packages: { resolve: async () => ({}), check: async () => ({ mutable: false }) },
packages: { resolve: async () => undefined },
args: {},
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
log: () => {},
@@ -590,7 +825,7 @@ test("shows jump to latest after scrolling one line above the final message", as
}),
update: async () => ({}),
},
packages: { resolve: async () => ({}), check: async () => ({ mutable: false }) },
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy" },
log: () => {},
@@ -678,7 +913,7 @@ test("new session inherits the active session model", async () => {
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({ animations: false }), update: async () => ({}) },
packages: { resolve: async () => ({}), check: async () => ({ mutable: false }) },
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy" },
log: () => {},
@@ -768,7 +1003,7 @@ test("keeps the prompt display stable while a new location catalog loads", async
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({ animations: false }), update: async () => ({}) },
packages: { resolve: async () => ({}), check: async () => ({ mutable: false }) },
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
args: {},
log: () => {},
@@ -849,7 +1084,7 @@ test("configured app bindings execute settings and permission commands", async (
}),
update: async () => ({}),
},
packages: { resolve: async () => ({}), check: async () => ({ mutable: false }) },
packages: { resolve: async () => undefined },
args: {},
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
log: () => {},
@@ -903,7 +1138,7 @@ test("ctrl+c dismisses autocomplete and shell mode before exiting", async () =>
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({ animations: false }), update: async () => ({}) },
packages: { resolve: async () => ({}), check: async () => ({ mutable: false }) },
packages: { resolve: async () => undefined },
args: {},
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
log: () => {},
@@ -1015,7 +1250,7 @@ test.each(["manual", "select"] as const)(
}),
update: async () => ({}),
},
packages: { resolve: async () => ({}), check: async () => ({ mutable: false }) },
packages: { resolve: async () => undefined },
args: { sessionID: session.id },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
log: () => {},
+255 -16
View File
@@ -1,10 +1,13 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { once } from "node:events"
import { CliRenderEvents, TextAttributes } from "@opentui/core"
import { testRender } from "@opentui/solid"
import { onMount } from "solid-js"
import { DialogOpen, DialogOpenKey, loadDialogOpen } from "../../../src/component/dialog-open"
import { createSignal, onMount } from "solid-js"
import type { SessionInfo } from "@opencode-ai/client"
import { DialogOpen, DialogOpenKey } from "../../../src/component/dialog-open"
import { ConfigProvider } from "../../../src/config"
import { ClientProvider, useClient } from "../../../src/context/client"
import { ClientProvider } from "../../../src/context/client"
import { DataProvider, useData } from "../../../src/context/data"
import { Keymap } from "../../../src/context/keymap"
import { LocationProvider, useLocation } from "../../../src/context/location"
@@ -85,7 +88,7 @@ test("finds and opens an exact session ID outside the recent list", async () =>
expect(fixture.route.data).toEqual({ type: "session", sessionID })
expect(fixture.location.ref).toEqual(remote)
} finally {
fixture.dispose()
await fixture.dispose()
}
})
@@ -131,7 +134,7 @@ test("shows the current project and opens its root", async () => {
}
})
test("waits for sessions before showing the populated picker", async () => {
test("shows projects while sessions refresh and preserves the selected project", async () => {
let resolveSessions!: (response: Response) => void
const sessions = new Promise<Response>((resolve) => (resolveSessions = resolve))
const fixture = await renderOpen((url) => {
@@ -157,8 +160,9 @@ test("waits for sessions before showing the populated picker", async () => {
})
try {
await fixture.app.renderOnce()
expect(fixture.app.captureCharFrame()).not.toContain("Search sessions and projects")
await fixture.app.waitForFrame((frame) => frame.includes("Second project") && frame.includes("Refreshing"))
expect(fixture.app.captureCharFrame()).toContain("Search sessions and projects")
fixture.app.mockInput.pressArrow("down")
resolveSessions(
json({
@@ -177,8 +181,6 @@ test("waits for sessions before showing the populated picker", async () => {
}),
)
await fixture.app.waitForFrame((frame) => frame.includes("Recent session") && frame.includes("Second project"))
fixture.app.mockInput.pressArrow("down")
fixture.app.mockInput.pressArrow("down")
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "home")
@@ -188,6 +190,240 @@ test("waits for sessions before showing the populated picker", async () => {
}
})
test.each([false, true])("keeps a filtered selection visible after refresh with query reset %s", async (reset) => {
const sessions = Promise.withResolvers<Response>()
const fixture = await renderOpen((url) => {
if (url.pathname === "/api/session") return sessions.promise
if (url.pathname === "/api/project")
return json([
{
id: "proj_first",
canonical: "/tmp/opencode/first",
name: "First shared project",
time: { created: 1, updated: 2 },
sandboxes: [],
},
{
id: "proj_second",
canonical: "/tmp/opencode/second",
name: "Second shared project",
time: { created: 1, updated: 1 },
sandboxes: [],
},
])
return undefined
})
const selectedTitle = () =>
fixture.app
.captureSpans()
.lines.flatMap((line) => line.spans)
.filter((span) => span.attributes & TextAttributes.BOLD)
.map((span) => span.text)
.join("")
try {
await fixture.app.waitForFrame((frame) => frame.includes("Second shared project") && frame.includes("Refreshing"))
await fixture.app.mockInput.typeText("shared")
await fixture.app.waitForFrame(() => selectedTitle().includes("First shared project"))
fixture.app.mockInput.pressArrow("down")
await fixture.app.waitForFrame(() => selectedTitle().includes("Second shared project"))
sessions.resolve(
json({
data: Array.from({ length: 12 }, (_, index) => ({
...recentSession,
id: `ses_shared_${index}`,
title: "shared",
time: { created: 1, updated: index + 3 },
})),
cursor: {},
}),
)
await fixture.app.waitForFrame((frame) => frame.includes("Open") && !frame.includes("Refreshing"))
// Selection reveal runs on FRAME; the following paint must show the selected row.
const frame = once(fixture.app.renderer, CliRenderEvents.FRAME)
fixture.app.renderer.requestRender()
await frame
expect(fixture.app.captureCharFrame()).toContain("Second shared project")
expect(selectedTitle()).toContain("Second shared project")
if (reset) {
await fixture.app.mockInput.typeText(" project")
await fixture.app.waitForFrame(() => selectedTitle().includes("First shared project"))
expect(fixture.app.captureCharFrame()).toContain("Second shared project")
expect(selectedTitle()).not.toContain("Second shared project")
}
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "home")
expect(fixture.route.data).toEqual({
type: "home",
location: { directory: `/tmp/opencode/${reset ? "first" : "second"}` },
})
} finally {
sessions.resolve(json({ data: [], cursor: {} }))
await fixture.dispose()
}
})
const recentSession = {
id: "ses_recent",
projectID: "proj_recent",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 2 },
title: "Recent session",
location: { directory: "/fixture" },
}
test("sessions remain selectable while projects are still loading", async () => {
const projects = Promise.withResolvers<Response>()
const fixture = await renderOpen((url) => {
if (url.pathname === "/api/session") return json({ data: [recentSession], cursor: {} })
if (url.pathname === "/api/project") return projects.promise
return undefined
})
try {
await fixture.app.waitForFrame((frame) => frame.includes("Recent session") && frame.includes("Refreshing"))
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "session")
expect(fixture.route.data).toEqual({ type: "session", sessionID: recentSession.id })
} finally {
projects.resolve(json([]))
await fixture.dispose()
}
})
test("shows hydrated sessions immediately without waiting for either read", async () => {
const sessions = Promise.withResolvers<Response>()
const projects = Promise.withResolvers<Response>()
const fixture = await renderOpen(
(url) => {
if (url.pathname === "/api/session") return sessions.promise
if (url.pathname === "/api/project") return projects.promise
return undefined
},
({ data }) => data.session.remember(recentSession),
)
try {
await fixture.app.waitForFrame((frame) => frame.includes("Recent session") && frame.includes("Refreshing"))
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "session")
expect(fixture.route.data).toEqual({ type: "session", sessionID: recentSession.id })
} finally {
sessions.resolve(json({ data: [], cursor: {} }))
projects.resolve(json([]))
await fixture.dispose()
}
})
test("keeps an uncached moved session in the first successful refresh", async () => {
const response = Promise.withResolvers<Response>()
const destination = { directory: "/fixture/destination" }
const fixture = await renderOpen((url) => (url.pathname === "/api/session" ? response.promise : undefined))
try {
await fixture.app.waitForFrame((frame) => frame.includes("Refreshing"))
fixture.emit({
id: "evt_uncached_move",
created: 3,
type: "session.moved",
durable: { aggregateID: recentSession.id, seq: 1, version: 1 },
data: { sessionID: recentSession.id, location: destination, projectID: "proj_destination" },
})
// The following event supplies an ordered-stream receipt barrier without hydrating metadata.
fixture.emit({
id: "evt_move_received",
created: 4,
type: "session.execution.started",
durable: { aggregateID: recentSession.id, seq: 2, version: 1 },
data: { sessionID: recentSession.id },
})
await fixture.app.waitFor(() => fixture.data.session.status(recentSession.id) === "running")
expect(fixture.data.session.get(recentSession.id)).toBeUndefined()
response.resolve(
json({
data: [
{ ...recentSession, location: destination, projectID: "proj_destination", time: { created: 1, updated: 3 } },
],
cursor: {},
}),
)
await fixture.app.waitForFrame((frame) => frame.includes(recentSession.title) && !frame.includes("Refreshing"))
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "session")
expect(fixture.route.data).toEqual({ type: "session", sessionID: recentSession.id })
expect(fixture.location.ref).toEqual(destination)
} finally {
response.resolve(json({ data: [], cursor: {} }))
await fixture.dispose()
}
})
test("keeps the previous recent list usable when reopening fails to refresh", async () => {
let requests = 0
const fixture = await renderOpen((url) => {
if (url.pathname === "/api/session")
return requests++ === 0
? json({ data: [recentSession], cursor: {} })
: new Response("Unavailable", { status: 503 })
return undefined
})
try {
await fixture.app.waitForFrame((frame) => frame.includes("Recent session") && !frame.includes("Refreshing"))
fixture.app.mockInput.pressEscape()
await fixture.app.waitForFrame((frame) => !frame.includes("Recent session"))
fixture.open()
await fixture.app.waitForFrame((frame) => frame.includes("Could not refresh sessions"))
expect(fixture.app.captureCharFrame()).toContain("Recent session")
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "session")
expect(fixture.route.data).toEqual({ type: "session", sessionID: recentSession.id })
} finally {
await fixture.dispose()
}
})
test("shows an initial loading shell instead of reporting an empty list", async () => {
const sessions = Promise.withResolvers<Response>()
const projects = Promise.withResolvers<Response>()
const fixture = await renderOpen((url) => {
if (url.pathname === "/api/session") return sessions.promise
if (url.pathname === "/api/project") return projects.promise
return undefined
})
try {
await fixture.app.waitForFrame(
(frame) => frame.includes("Search sessions and projects") && frame.includes("Refreshing"),
)
expect(fixture.app.captureCharFrame()).not.toContain("No items available")
await fixture.app.mockInput.typeText("missing")
await fixture.app.waitForFrame((frame) => frame.includes("Searching sessions and projects"))
expect(fixture.app.captureCharFrame()).not.toContain("No matches")
sessions.resolve(json({ data: [], cursor: {} }))
projects.resolve(json([]))
await fixture.app.waitForFrame((frame) => frame.includes("No matches") && !frame.includes("Refreshing"))
} finally {
sessions.resolve(json({ data: [], cursor: {} }))
projects.resolve(json([]))
await fixture.dispose()
}
})
test("reports both refresh failures while keeping hydrated sessions usable", async () => {
const fixture = await renderOpen(
(url) => {
if (url.pathname === "/api/session" || url.pathname === "/api/project")
return new Response("Unavailable", { status: 503 })
return undefined
},
({ data }) => data.session.remember(recentSession),
)
try {
await fixture.app.waitForFrame((frame) => frame.includes("Could not refresh sessions and projects"))
expect(fixture.app.captureCharFrame()).toContain("Recent session")
} finally {
await fixture.dispose()
}
})
test("option arrows jump between sections", async () => {
const handler: FetchHandler = (url) => {
if (url.pathname === "/api/session")
@@ -291,20 +527,21 @@ async function renderOpen(
let location!: ReturnType<typeof useLocation>
let data!: ReturnType<typeof useData>
let storage!: ReturnType<typeof useStorage>
let open!: () => void
function Probe() {
const dialog = useDialog()
const client = useClient()
const [sessions, setSessions] = createSignal<SessionInfo[]>([])
route = useRoute()
location = useLocation()
data = useData()
storage = useStorage()
onMount(
() =>
void Promise.all([beforeOpen?.({ data, location }), loadDialogOpen(data, client)]).then(([, sessions]) =>
dialog.replace(() => <DialogOpen sessions={sessions} />, undefined, { key: DialogOpenKey, size: "large" }),
),
)
open = () =>
dialog.replace(() => <DialogOpen sessions={sessions()} onLoad={setSessions} />, undefined, {
key: DialogOpenKey,
size: "large",
})
onMount(() => void Promise.resolve(beforeOpen?.({ data, location })).then(open))
return null
}
@@ -344,6 +581,8 @@ async function renderOpen(
return {
app,
emit: events.emit,
open: () => open(),
get route() {
return route
},
+2 -2
View File
@@ -59,7 +59,7 @@ test.each([70, 120])(
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({ animations: false }), update: async () => ({}) },
packages: { resolve: async () => ({}), check: async () => ({ mutable: false }) },
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
args: { sessionID },
log: () => {},
@@ -184,7 +184,7 @@ test.each(["first", "second"])(
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({ animations: false }), update: async () => ({}) },
packages: { resolve: async () => ({}), check: async () => ({ mutable: false }) },
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
args: { sessionID },
log: () => {},
@@ -32,7 +32,6 @@ for (const orientation of ["horizontal", "vertical"] as const) {
await using temporary = await tmpdir()
const [status, setStatus] = createSignal<SessionTabsStatus>(EMPTY_SESSION_TAB_STATUS)
const [active, setActive] = createSignal("second")
const [animations, setAnimations] = createSignal(false)
const [newTab, setNewTab] = createSignal(false)
const [preview, setPreview] = createSignal(false)
const settings: Info = { tabs: { enabled: true } }
@@ -91,11 +90,7 @@ for (const orientation of ["horizontal", "vertical"] as const) {
<ToastProvider>
<DialogProvider>
<box width="100%" height="100%">
<SessionTabs
controller={controller}
orientation={orientation}
animations={animations()}
/>
<SessionTabs controller={controller} orientation={orientation} animations={false} />
</box>
</DialogProvider>
</ToastProvider>
@@ -141,7 +136,6 @@ for (const orientation of ["horizontal", "vertical"] as const) {
}
for (const attention of ["question", "permission"] as const) {
setAnimations(false)
setActive("second")
setStatus({ ...EMPTY_SESSION_TAB_STATUS, busy: true, attention })
await app.renderOnce()
@@ -171,84 +165,39 @@ for (const orientation of ["horizontal", "vertical"] as const) {
const dim = glow()
expect(dim).toBeGreaterThan(0)
expect(dim).toBeLessThan(full)
setActive("second")
await app.renderOnce()
setAnimations(true)
await app.renderOnce()
expect(app.renderer.root.liveCount).toBe(0)
setActive("first")
await app.renderOnce()
expect(app.renderer.root.liveCount).toBe(0)
await app.waitForFrame(() => glow() > dim && glow() < full)
await app.waitForFrame(() => glow() === dim, { maxPasses: 60 })
setActive("second")
await app.renderOnce()
expect(app.renderer.root.liveCount).toBe(0)
await app.waitForFrame(() => glow() > dim && glow() < full)
await app.waitForFrame(() => glow() === full, { maxPasses: 60 })
setStatus(EMPTY_SESSION_TAB_STATUS)
await app.renderOnce()
expect(app.renderer.root.liveCount).toBeGreaterThan(0)
}
const glyph = "\u2022"
for (const unread of ["activity", "error"] as const) {
setAnimations(false)
setActive("second")
setStatus({ ...EMPTY_SESSION_TAB_STATUS, busy: true })
await app.renderOnce()
setAnimations(true)
setStatus({ ...EMPTY_SESSION_TAB_STATUS, unread })
await app.renderOnce()
const color = () =>
app
.captureSpans()
.lines.flatMap((line) => line.spans)
.find((span) => span.text.trim() === glyph)?.fg
expect(color()?.toInts()).toEqual(
const color = app
.captureSpans()
.lines.flatMap((line) => line.spans)
.find((span) => span.text.trim() === glyph)?.fg
expect(color?.toInts()).toEqual(
(unread === "error" ? theme.text.feedback.error.default : theme.text.status.unread).toInts(),
)
const brightness = () => {
const value = color()
return value ? value.r + value.g + value.b : undefined
}
const initial = brightness()!
await app.mockMouse.click(1, orientation === "vertical" ? 1 : 0)
await app.renderOnce()
expect(active()).toBe("first")
expect(status().unread).toBeUndefined()
expect(app.captureCharFrame()).toContain(`${glyph} First`)
await app.waitForFrame((frame) => frame.includes(`${glyph} First`) && (brightness() ?? -1) > initial)
const peak = brightness()!
await app.waitForFrame((frame) => frame.includes(`${glyph} First`) && (brightness() ?? Infinity) < peak)
await app.waitForFrame((frame) => frame.includes(" First"), { maxPasses: 60 })
expect(app.captureCharFrame()).toContain(" First")
expect(app.captureCharFrame()).not.toContain(`${glyph} First`)
}
setAnimations(false)
setStatus({ ...EMPTY_SESSION_TAB_STATUS, unread: "activity" })
await app.renderOnce()
setAnimations(true)
await app.mockMouse.click(1, orientation === "vertical" ? 1 : 0)
setStatus({ ...EMPTY_SESSION_TAB_STATUS, busy: true })
await app.waitForFrame((frame) => SPINNER_FRAMES.slice(1).some((glyph) => frame.includes(`${glyph} First`)))
setStatus({ ...EMPTY_SESSION_TAB_STATUS, busy: true, attention: "question" })
await app.waitForFrame((frame) => frame.includes("? First"))
await config.update((draft) => {
draft.tabs.indicators = "numbers"
})
await app.waitForFrame((frame) => frame.includes("1 First") && frame.includes("2 Second"))
setStatus({ ...EMPTY_SESSION_TAB_STATUS, busy: true })
await app.renderOnce()
expect(app.captureCharFrame()).toContain("1 First")
await config.update((draft) => {
draft.tabs.indicators = "status"
})
await app.waitForFrame((frame) => SPINNER_FRAMES.some((glyph) => frame.includes(`${glyph} First`)))
await app.waitForFrame((frame) => frame.includes(`${SPINNER_FRAMES[0]} First`))
setAnimations(false)
setStatus(EMPTY_SESSION_TAB_STATUS)
setActive("second")
await app.renderOnce()
@@ -1,47 +0,0 @@
import { Plugin } from "@opencode-ai/plugin/tui"
import { useConfig } from "../../src/config"
import { useLocation } from "../../src/context/location"
import { useRoute } from "../../src/context/route"
import { usePlugin } from "../../src/plugin/context"
export const probe = {
setups: 0,
cleanups: 0,
plugins: (): ReturnType<typeof usePlugin> => {
throw new Error("Plugin probe not mounted")
},
config: (): ReturnType<typeof useConfig> => {
throw new Error("Plugin probe not mounted")
},
location: (): ReturnType<typeof useLocation> => {
throw new Error("Plugin probe not mounted")
},
navigate: (_directory: string): void => {
throw new Error("Plugin probe not mounted")
},
}
export default Plugin.define({
id: "fixture.inspection",
setup(context) {
if (context.options.fail) throw new Error("Fixture setup failed")
probe.setups++
context.ui.slot({
append: "app",
render: () => {
const plugins = usePlugin()
const config = useConfig()
const location = useLocation()
const route = useRoute()
probe.plugins = () => plugins
probe.config = () => config
probe.location = () => location
probe.navigate = (directory) => route.navigate({ type: "home", location: { directory } })
return null
},
})
return () => {
probe.cleanups++
}
},
})
+3 -4
View File
@@ -7,7 +7,6 @@ import path from "node:path"
import { pathToFileURL } from "node:url"
import { createEventStream, createFetch, json } from "./fixture/tui-client"
import { tmpdir } from "./fixture/fixture"
import type { PackageResolver } from "../src/plugin/context"
function lifecyclePluginSource(marker: string, id: string, version: string) {
return `
@@ -58,7 +57,7 @@ async function bootApp(
directory: string,
options?: {
plugins?: unknown[]
resolve?: PackageResolver["resolve"]
resolve?: (spec: string, install?: boolean) => Promise<string | undefined>
},
) {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
@@ -92,7 +91,7 @@ async function bootApp(
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: { resolve: options?.resolve ?? (async () => ({})), check: async () => ({ mutable: false }) },
packages: { resolve: options?.resolve ?? (async () => undefined) },
args: {},
log: () => {},
}).pipe(
@@ -129,7 +128,7 @@ test("loads an advertised package TUI entrypoint only from the local cache", asy
],
resolve: async (spec, install) => {
resolutions.push({ spec, install })
return { entrypoint: pathToFileURL(entrypoint).href, revision: "1.0.0" }
return pathToFileURL(entrypoint).href
},
})
@@ -1,347 +0,0 @@
import { expect, test } from "bun:test"
import type { PluginInfo } from "@opencode-ai/client"
import type { PackageStatus } from "@opencode-ai/schema/plugin"
import { Global } from "@opencode-ai/util/global"
import { InputRenderable } from "@opentui/core"
import { createTestRenderer } from "@opentui/core/testing"
import { Effect, FileSystem } from "effect"
import path from "node:path"
import { pathToFileURL } from "node:url"
import type { Info } from "../src/config"
import type { PackageResolver } from "../src/plugin/context"
import { probe } from "./fixture/plugin-inspection"
import { tmpdir } from "./fixture/fixture"
import { createEventStream, createFetch, json } from "./fixture/tui-client"
const target = "fixture-plugin@latest"
const entrypoint = new URL("./fixture/plugin-inspection.tsx", import.meta.url).href
async function boot(
directory: string,
options: {
width?: number
kittyKeyboard?: boolean
plugins?: Info["plugins"]
server?: () => PluginInfo[]
check?: PackageResolver["check"]
resolve?: PackageResolver["resolve"]
} = {},
) {
const app = await createTestRenderer({
width: options.width ?? 100,
height: 24,
useThread: false,
kittyKeyboard: options.kittyKeyboard ?? true,
})
const location = { directory, project: { id: "proj_fixture", directory, canonical: directory } }
const events = createEventStream()
const requests: { path: string; directory: string | null; target: string }[] = []
const calls = createFetch(async (url, request) => {
if (url.pathname.startsWith("/api/plugin/")) {
const body = await request.json()
requests.push({ path: url.pathname, directory: url.searchParams.get("location[directory]"), target: body.target })
return json({ location, data: { installed: "9.0.0", available: "10.0.0", mutable: true } })
}
if (url.pathname === "/api/plugin") {
return json({ location, data: options.server?.() ?? [] })
}
if (url.pathname === "/api/location")
return json({ ...location, directory: url.searchParams.get("location[directory]") ?? directory })
if (url.pathname === "/api/fs/list") return json({ location, data: [] })
}, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
const config: Info = { animations: false, plugins: options.plugins ?? [target], keybinds: { "plugins.list": "f6" } }
const cwd = process.cwd()
process.chdir(directory)
probe.setups = 0
probe.cleanups = 0
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: {
get: async () => structuredClone(config),
update: async (update) => {
update(config)
return structuredClone(config)
},
},
packages: {
resolve: options.resolve ?? (async () => ({ entrypoint, revision: "1.0.0" })),
check: options.check ?? (async () => ({ installed: "1.0.0", available: "2.0.0", mutable: true })),
},
terminalHandoff: async () => ({ renderer: app.renderer, mode: "dark", complete: () => {} }),
args: {},
log: () => {},
}).pipe(
Effect.provide(
Global.layerWith(
Object.fromEntries(
["home", "config", "data", "cache", "state", "tmp", "bin", "log", "repos"].map((name) => [
name,
path.join(directory, name),
]),
),
),
),
Effect.provide(FileSystem.layerNoop({})),
),
)
app.renderer.start()
await app.waitForFrame((frame) => frame.includes("Ask anything"), { maxPasses: 100 })
return {
...app,
events,
requests,
async [Symbol.asyncDispose]() {
app.renderer.destroy()
await task.finally(async () => {
process.chdir(cwd)
events.disconnect()
await server.stop(true)
})
},
}
}
async function open(app: Awaited<ReturnType<typeof boot>>, search = "fixture.inspection") {
app.mockInput.pressKey("F6")
await app.waitForFrame((frame) => frame.includes("Plugins") && frame.includes("Search"))
await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable)
await app.mockInput.typeText(search)
await app.waitForFrame((frame) => frame.includes(search) && !frame.includes("Search"))
}
test("explicit checks are read-only, retain loaded snapshots, and preserve toggle/filter behavior", async () => {
await using tmp = await tmpdir()
const checked: string[] = []
const resolved: string[] = []
let disk = "1.0.0"
await using app = await boot(tmp.path, {
resolve: async (spec) => {
resolved.push(spec)
return { entrypoint, revision: disk }
},
check: async (spec) => {
checked.push(spec)
return { installed: disk, available: "3.0.0", mutable: true }
},
})
await open(app)
expect(checked).toEqual([])
app.mockInput.pressEnter()
const details = await app.waitForFrame((frame) => frame.includes("Loaded") && frame.includes("Check for updates"))
expect(details).toContain("1.0.0")
expect(details).not.toMatch(/Update package|Reload installed|Enable in this terminal/)
disk = "2.0.0"
app.mockInput.pressKey("r", { ctrl: true })
const result = await app.waitForFrame((frame) => frame.includes("New revision available"))
expect(result).toMatch(/Loaded\s+1\.0\.0/)
expect(result).toMatch(/Installed\s+2\.0\.0/)
expect(checked).toEqual([target])
expect(resolved).toEqual([target])
expect([probe.setups, probe.cleanups]).toEqual([1, 0])
expect(app.requests).toEqual([])
await expect(probe.plugins().check("unconfigured-plugin")).rejects.toThrow("Not a configured package")
await expect(probe.plugins().check("opencode.plugins")).rejects.toThrow("Not a configured package")
app.mockInput.pressEscape()
await app.waitForFrame((frame) => frame.includes("↑ update") && frame.includes("disable"))
expect(app.renderer.currentFocusedEditor?.plainText).toBe("fixture.inspection")
app.mockInput.pressKey(" ")
await app.waitForFrame((frame) => frame.includes("enable"))
expect(
probe
.plugins()
.registered()
.find((item) => item.id === "fixture.inspection")?.revision,
).toBe("1.0.0")
app.mockInput.pressKey(" ")
await app.waitForFrame((frame) => frame.includes("disable"))
await probe.config().update((draft) => {
draft.plugins = [{ package: target, options: { flag: true } }]
})
await app.waitFor(() => probe.setups === 3)
expect(
probe
.plugins()
.registered()
.find((item) => item.id === "fixture.inspection")?.revision,
).toBe("1.0.0")
await probe.config().update((draft) => {
draft.plugins = [{ package: target, options: { fail: true } }]
})
await app.waitFor(() =>
probe
.plugins()
.list()
.some((item) => item.status === "failed"),
)
expect(
probe
.plugins()
.registered()
.find((item) => item.id === "fixture.inspection"),
).toMatchObject({ active: true, revision: "1.0.0" })
})
test.each([40, 100])("pinned checks, busy/error details, and CLI spec guidance at width %s", async (width) => {
await using tmp = await tmpdir()
const pending = Promise.withResolvers<PackageStatus>()
let calls = 0
await using app = await boot(tmp.path, {
width,
check: () => {
calls++
return pending.promise
},
})
await open(app)
app.mockInput.pressKey("r", { ctrl: true })
await app.waitForFrame((frame) => frame.includes("Checking..."))
app.mockInput.pressKey("r", { ctrl: true })
expect(calls).toBe(1)
pending.resolve({ available: "2.0.0", mutable: false })
await app.waitForFrame((frame) => frame.includes("pinned"))
app.mockInput.pressEnter()
const frame = await app.waitForFrame((frame) => frame.includes("Pinned source."))
expect(frame).not.toContain("New revision available")
expect(frame).not.toMatch(/Update package|Reload installed/)
if (width === 100) expect(frame).toMatch(/Installed\s+Unknown/)
app.mockInput.pressKey("\x1b[6~")
await app.waitForFrame((frame) => frame.includes("cli.json"))
expect(app.requests).toEqual([])
})
test.each(["active", "failed", "without-tui"])(
"late %s server inventory uses the default check scope and shares results",
async (kind) => {
await using tmp = await tmpdir()
let server: PluginInfo[] = []
await using app = await boot(tmp.path, {
server: () => server,
check: async () => {
throw new Error("Companion must check on server")
},
})
probe.navigate("/projects/active-session")
await app.waitFor(() => probe.location().current?.directory === "/projects/active-session")
await open(app)
const info = {
id: "fixture.server",
source: { type: "package" as const, package: target },
tui: kind === "active",
revision: "9.0.0",
}
server = [
kind === "failed" ? { ...info, status: "failed", error: "Server fixture failed" } : { ...info, status: "active" },
]
app.events.emit({ id: "evt_fixture", created: Date.now(), type: "plugin.updated", data: {} })
app.mockInput.pressKey("c", { ctrl: true })
await app.mockInput.typeText("fixture.server")
await app.waitForFrame((frame) => frame.includes("fixture.server") && frame.includes("Server"))
app.mockInput.pressKey("c", { ctrl: true })
await app.mockInput.typeText("fixture.inspection")
await app.waitForFrame((frame) => frame.includes("fixture.inspection") && !frame.includes("fixture.server"))
app.mockInput.pressKey("r", { ctrl: true })
await app.waitForFrame((frame) => frame.includes("↑ update"))
expect(app.requests).toEqual([{ path: "/api/plugin/check", directory: tmp.path, target }])
app.mockInput.pressEnter()
const details = await app.waitForFrame((frame) => frame.includes("Loaded") && frame.includes("Installed"))
expect(details).toMatch(/Loaded\s+1\.0\.0/)
expect(details).toMatch(/Installed\s+9\.0\.0/)
app.mockInput.pressEscape()
await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable)
app.mockInput.pressKey("c", { ctrl: true })
await app.mockInput.typeText("fixture.server")
await app.waitForFrame(
(frame) => frame.includes("fixture.server") && frame.includes(kind === "failed" ? "failed" : "↑ update"),
)
app.mockInput.pressEnter()
const shared = await app.waitForFrame((frame) => frame.includes("Installed"))
expect(shared).toMatch(/Installed\s+9\.0\.0/)
expect(app.requests).toHaveLength(1)
},
)
test("failed checks keep existing error investigation accessible", async () => {
await using tmp = await tmpdir()
await using app = await boot(tmp.path, {
check: async () => {
throw new Error("Registry unavailable")
},
})
await probe.config().update((draft) => {
draft.plugins = [{ package: target, options: { fail: true } }]
})
await app.waitFor(() =>
probe
.plugins()
.list()
.some((item) => item.status === "failed"),
)
await open(app)
app.mockInput.pressKey("r", { ctrl: true })
await app.waitForFrame((frame) => frame.includes("failed"))
app.mockInput.pressEnter()
await app.waitForFrame((frame) => frame.includes("View error details"))
app.mockInput.pressArrow("down")
app.mockInput.pressEnter()
const errors = await app.waitForFrame(
(frame) =>
frame.includes("Registry unavailable") &&
frame.includes("Fixture setup failed") &&
frame.includes("i investigate"),
)
expect(errors).toContain("Load error:")
expect(errors).toContain("Check error:")
app.mockInput.pressEscape()
await app.waitForFrame((frame) => frame.includes("View error details"))
})
test.each(["opencode.system", entrypoint])("builtins and local sources have no package check: %s", async (spec) => {
await using tmp = await tmpdir()
await using app = await boot(tmp.path, { plugins: spec.startsWith("opencode.") ? [] : [spec] })
await open(app, spec.startsWith("opencode.") ? "opencode" : "fixture.inspection")
app.mockInput.pressEnter()
const frame = await app.waitForFrame((frame) => frame.includes("Not applicable"))
expect(frame).not.toContain("Check for updates")
expect(frame).not.toMatch(/Update package|Reload installed/)
app.mockInput.pressKey("\x1b[6~")
await app.waitForFrame((frame) =>
frame.includes(spec.startsWith("opencode.") ? "Updates with OpenCode" : "no package check"),
)
expect(app.requests).toEqual([])
})
test.each([false, true])(
"real paging keys expose overflowing package revisions with Kitty %s",
async (kittyKeyboard) => {
await using tmp = await tmpdir()
const source = `git+https://example.com/${"nested-directory/".repeat(16)}plugin.git#main`
const one = "a".repeat(40)
const two = "b".repeat(40)
await Bun.write(path.join(tmp.path, "overflow.ts"), 'export default { id: "fixture.overflow", setup() {} }\n')
await using app = await boot(tmp.path, {
kittyKeyboard,
plugins: [source],
resolve: async () => ({ entrypoint: pathToFileURL(path.join(tmp.path, "overflow.ts")).href, revision: one }),
check: async () => ({ installed: one, available: two, mutable: true }),
})
await open(app, "fixture.overflow")
app.mockInput.pressEnter()
const top = await app.waitForFrame((frame) => frame.includes("Source") && frame.includes("Check for updates"))
expect(top).not.toContain("Available")
app.mockInput.pressKey("r", { ctrl: true })
await app.waitForFrame((frame) => frame.includes("New revision available"))
app.mockInput.pressKey("\x1b[6~")
await app.waitForFrame((frame) => !frame.includes("Runtime"))
app.mockInput.pressKey("\x1b[6~")
const scrolled = await app.waitForFrame((frame) => frame.includes("Installed") && frame.includes("Available"))
expect(scrolled).toContain(one)
expect(scrolled).toContain(two)
app.mockInput.pressKey("\x1b[5~")
app.mockInput.pressKey("\x1b[5~")
await app.waitForFrame((frame) => frame.includes("Runtime") && !frame.includes("Available"))
},
)
+1 -1
View File
@@ -103,7 +103,7 @@ test.each([
}),
update: async () => ({}),
},
packages: { resolve: async () => ({}), check: async () => ({ mutable: false }) },
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy" },
log: () => {},
-2
View File
@@ -53,7 +53,6 @@
"mime-types": "3.0.2",
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"pacote": "21.5.1",
"resolve.exports": "catalog:"
},
"devDependencies": {
@@ -63,7 +62,6 @@
"@types/node": "catalog:",
"@types/npm-package-arg": "6.1.4",
"@types/npmcli__arborist": "6.3.3",
"@types/pacote": "11.1.8",
"@typescript/native-preview": "catalog:"
}
}
+22 -98
View File
@@ -22,7 +22,6 @@ export class InstallFailedError extends Schema.TaggedError<InstallFailedError>()
export interface EntryPoint {
readonly directory: string
readonly entrypoint?: string
readonly revision?: string
}
export interface Interface {
@@ -31,9 +30,6 @@ export interface Interface {
options?: { readonly subpaths?: readonly string[]; readonly refresh?: boolean },
) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
readonly resolve: (pkg: string, options?: { readonly subpaths?: readonly string[] }) => Effect.Effect<EntryPoint>
readonly check: (
pkg: string,
) => Effect.Effect<{ installed?: string; available?: string; mutable: boolean }, InstallFailedError>
readonly which: (pkg: string, bin?: string) => Effect.Effect<string | undefined>
}
@@ -103,11 +99,6 @@ interface ArboristTree {
const PackageJson = Schema.Struct({
dependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)),
version: Schema.optional(Schema.String),
})
const PackageLock = Schema.Struct({
packages: Schema.optional(Schema.Record(Schema.String, Schema.Struct({ resolved: Schema.optional(Schema.String) }))),
})
const layer = Layer.effect(
@@ -122,15 +113,6 @@ const layer = Layer.effect(
Effect.promise(() => cacheKey(pkg)),
(key) => path.join(global.cache, "packages", key),
)
const parse = (pkg: string) =>
Effect.promise(async () => {
const { default: npa } = await import("npm-package-arg")
try {
return npa(pkg)
} catch {
return undefined
}
})
const installedName = Effect.fnUntraced(function* (pkg: string, dir: string, parsedName?: string) {
if (parsedName) return parsedName
const manifest = yield* afs
@@ -142,41 +124,6 @@ const layer = Layer.effect(
}
return pkg
})
const installedRevision = Effect.fnUntraced(function* (root: string, name: string, dir: string, type?: string) {
if (type !== "git") {
const manifest = yield* afs
.readJson(path.join(dir, "package.json"))
.pipe(Effect.flatMap(Schema.decodeUnknownEffect(PackageJson)), Effect.option)
return Option.isSome(manifest) ? manifest.value.version : undefined
}
if (!(yield* afs.existsSafe(dir))) return undefined
// The wrapper lock records the installed Git revision, not the package's own dependency lock.
for (const file of [
path.join(root, "package-lock.json"),
path.join(root, "node_modules", ".package-lock.json"),
]) {
const lock = yield* afs
.readJson(file)
.pipe(Effect.flatMap(Schema.decodeUnknownEffect(PackageLock)), Effect.option)
const revision = gitRevision(
Option.isSome(lock) ? lock.value.packages?.[`node_modules/${name}`]?.resolved : undefined,
)
if (revision) return revision
}
return undefined
})
const entry = Effect.fnUntraced(function* (
root: string,
name: string,
dir: string,
type?: string,
subpaths?: readonly string[],
) {
return {
...resolveEntryPoint(name, dir, subpaths),
revision: yield* installedRevision(root, name, dir, type),
}
})
const refreshed = new Set<string>()
const reify = (input: { dir: string; add?: string[]; update?: boolean }) =>
Effect.gen(function* () {
@@ -219,7 +166,14 @@ const layer = Layer.effect(
pkg: string,
options?: { readonly subpaths?: readonly string[]; readonly refresh?: boolean },
) {
const parsed = yield* parse(pkg)
const { default: npa } = yield* Effect.promise(() => import("npm-package-arg"))
const parsed = (() => {
try {
return npa(pkg)
} catch {
return undefined
}
})()
const parsedName = parsed?.name ?? undefined
const dir = yield* directory(pkg)
const name = yield* installedName(pkg, dir, parsedName)
@@ -235,59 +189,38 @@ const layer = Layer.effect(
}
if (cached) {
return yield* entry(dir, name, path.join(dir, "node_modules", name), parsed?.type, options?.subpaths)
return resolveEntryPoint(name, path.join(dir, "node_modules", name), options?.subpaths)
}
const tree = yield* reify({ dir, add: [pkg] })
if (isMutable(parsed)) refreshed.add(pkg)
const first = tree.edgesOut.values().next().value?.to
if (!first) {
const installed = yield* installedName(pkg, dir, parsedName)
const result = yield* entry(
dir,
installed,
path.join(dir, "node_modules", installed),
parsed?.type,
options?.subpaths,
)
const result = resolveEntryPoint(installed, path.join(dir, "node_modules", installed), options?.subpaths)
if (result.entrypoint) return result
return yield* new InstallFailedError({ add: [pkg], dir })
}
return yield* entry(dir, first.name, first.path, parsed?.type, options?.subpaths)
return resolveEntryPoint(first.name, first.path, options?.subpaths)
}, Effect.scoped)
const resolve = Effect.fn("Npm.resolve")(function* (
pkg: string,
options?: { readonly subpaths?: readonly string[] },
) {
const parsed = yield* parse(pkg)
const { default: npa } = yield* Effect.promise(() => import("npm-package-arg"))
const parsedName = (() => {
try {
return npa(pkg).name ?? undefined
} catch {
return undefined
}
})()
const root = yield* directory(pkg)
const name = yield* installedName(pkg, root, parsed?.name ?? undefined)
const name = yield* installedName(pkg, root, parsedName)
const dir = path.join(root, "node_modules", name)
if (!(yield* afs.existsSafe(dir))) return { directory: dir }
return yield* entry(root, name, dir, parsed?.type, options?.subpaths)
})
const check = Effect.fn("Npm.check")(function* (pkg: string) {
const parsed = yield* parse(pkg)
const root = yield* directory(pkg)
if (
!parsed ||
(parsed.type !== "git" && !(parsed.name !== undefined && ["version", "range", "tag"].includes(parsed.type)))
)
return yield* new InstallFailedError({
dir: root,
cause: new Error("Package checks only support registry and Git package specs"),
})
const name = yield* installedName(pkg, root, parsed.name ?? undefined)
const installed = yield* installedRevision(root, name, path.join(root, "node_modules", name), parsed.type)
const { manifest, resolve } = yield* Effect.promise(() => import("pacote"))
const options = { ...(yield* NpmConfig.load(root)), preferOnline: true, noGitRevCache: true, ignoreScripts: true }
const available = yield* Effect.tryPromise({
try: async () =>
parsed.type === "git" ? gitRevision(await resolve(pkg, options)) : (await manifest(pkg, options)).version,
catch: (cause) => new InstallFailedError({ dir: root, cause }),
})
return { installed, available, mutable: isMutable(parsed) }
return resolveEntryPoint(name, dir, options?.subpaths)
})
const which = Effect.fn("Npm.which")(function* (pkg: string, bin?: string) {
@@ -344,7 +277,6 @@ const layer = Layer.effect(
return Service.of({
add,
resolve,
check,
which,
})
}),
@@ -370,14 +302,6 @@ export async function which(...args: Parameters<Interface["which"]>) {
return runPromise((svc) => svc.which(...args))
}
export async function check(...args: Parameters<Interface["check"]>) {
return runPromise((svc) => svc.check(...args))
}
function gitRevision(resolved: string | undefined) {
return resolved?.match(/#([a-f0-9]{40}|[a-f0-9]{64})(?=::|$)/i)?.[1]
}
function isMutable(parsed: { readonly type: string; readonly gitCommittish?: string | null } | undefined) {
if (!parsed) return false
if (["tag", "range"].includes(parsed.type)) return true
-153
View File
@@ -486,123 +486,6 @@
"summary": "List plugins"
}
},
"/api/plugin/check": {
"post": {
"tags": ["plugin"],
"operationId": "v2.plugin.check",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"location": {
"$ref": "#/components/schemas/Location.InfoEncoded"
},
"data": {
"$ref": "#/components/schemas/Plugin.PackageStatus"
}
},
"required": ["location", "data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "PluginCheckError | InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/PluginCheckErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Inspect installed and available revisions of an active or failed package in the current server inventory without installing it.",
"summary": "Check plugin package",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"target": {
"type": "string"
}
},
"required": ["target"],
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/session": {
"get": {
"tags": ["session"],
@@ -16424,9 +16307,6 @@
"source": {
"$ref": "#/components/schemas/Plugin.Source"
},
"revision": {
"type": "string"
},
"status": {
"type": "string",
"enum": ["active"]
@@ -16447,9 +16327,6 @@
"source": {
"$ref": "#/components/schemas/Plugin.Source"
},
"revision": {
"type": "string"
},
"status": {
"type": "string",
"enum": ["failed"]
@@ -16466,22 +16343,6 @@
}
]
},
"Plugin.PackageStatus": {
"type": "object",
"properties": {
"installed": {
"type": "string"
},
"available": {
"type": "string"
},
"mutable": {
"type": "boolean"
}
},
"required": ["mutable"],
"additionalProperties": false
},
"Plugin.Source": {
"anyOf": [
{
@@ -16536,20 +16397,6 @@
}
]
},
"PluginCheckErrorEncoded": {
"type": "object",
"properties": {
"_tag": {
"type": "string",
"enum": ["PluginCheckError"]
},
"message": {
"type": "string"
}
},
"required": ["_tag", "message"],
"additionalProperties": false
},
"Project": {
"type": "object",
"properties": {
-153
View File
@@ -486,123 +486,6 @@
"summary": "List plugins"
}
},
"/api/plugin/check": {
"post": {
"tags": ["plugin"],
"operationId": "v2.plugin.check",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"location": {
"$ref": "#/components/schemas/Location.InfoEncoded"
},
"data": {
"$ref": "#/components/schemas/Plugin.PackageStatus"
}
},
"required": ["location", "data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "PluginCheckError | InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/PluginCheckErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Inspect installed and available revisions of an active or failed package in the current server inventory without installing it.",
"summary": "Check plugin package",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"target": {
"type": "string"
}
},
"required": ["target"],
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/session": {
"get": {
"tags": ["session"],
@@ -16424,9 +16307,6 @@
"source": {
"$ref": "#/components/schemas/Plugin.Source"
},
"revision": {
"type": "string"
},
"status": {
"type": "string",
"enum": ["active"]
@@ -16447,9 +16327,6 @@
"source": {
"$ref": "#/components/schemas/Plugin.Source"
},
"revision": {
"type": "string"
},
"status": {
"type": "string",
"enum": ["failed"]
@@ -16466,22 +16343,6 @@
}
]
},
"Plugin.PackageStatus": {
"type": "object",
"properties": {
"installed": {
"type": "string"
},
"available": {
"type": "string"
},
"mutable": {
"type": "boolean"
}
},
"required": ["mutable"],
"additionalProperties": false
},
"Plugin.Source": {
"anyOf": [
{
@@ -16536,20 +16397,6 @@
}
]
},
"PluginCheckErrorEncoded": {
"type": "object",
"properties": {
"_tag": {
"type": "string",
"enum": ["PluginCheckError"]
},
"message": {
"type": "string"
}
},
"required": ["_tag", "message"],
"additionalProperties": false
},
"Project": {
"type": "object",
"properties": {
@@ -6,29 +6,6 @@ Plugins configured in `opencode.json(c)` that expose a TUI component are loaded
to build plugins, see [Building plugins](/build/plugins). You do not need to add the same package to `cli.json`. The CLI
gets the active plugin list from the connected OpenCode server, so this also works when the server is remote.
## Inspect plugins
Open **/plugins**, select a row, and press **Enter** to view its runtime, status, source, and scope. **Space** still enables
or disables a TUI plugin in the current terminal.
For package plugins, select **Check for updates**, or press **Ctrl+R** on the selected row. Checks are explicit; OpenCode
does not poll for plugin updates in the background. Details distinguish the **Loaded** revision captured when the code was
loaded, the **Installed** revision currently on disk, and the **Available** revision allowed by the configured package spec.
A small update marker appears after a check finds a different available revision. Unavailable revision metadata is shown
as unknown rather than inferred from the package spec.
Matching server and TUI package rows share a check on the connected server in the default Location used to discover
server plugins. Packages configured only in the CLI are checked on the TUI machine. Checking does not install packages or replace running code.
Exact npm versions and full Git commit hashes remain pinned; change their configured spec to select another revision.
Built-in plugins update with OpenCode, and local files do not have package update checks.
To apply a newer server package revision, use the existing [restart workflow](/plugins), then close and reopen the TUI to
load its companion. The Plugins dialog only inspects and checks revisions; it does not apply updates.
For a package configured only in `cli.json`, select a newer version or Git commit in its package spec, then reopen the TUI.
Reopening the TUI alone does not refresh a cached CLI-only package.
## CLI-only plugins
Use `cli.json` for CLI-only plugins. These plugins run locally in the terminal and remain active when the CLI connects
to a remote server:

Some files were not shown because too many files have changed in this diff Show More