Compare commits

..
Author SHA1 Message Date
Kit Langton be8f5a5242 test(core): simplify registry regression fixtures
Reuse small State and Location fixture constructors, use explicit Effect test layers and virtual time for synthetic providers, and ensure cleanup precedes registration disposal. Preserve regression cases, failure causes, and pre-release shutdown assertions.
2026-08-28 14:22:56 -04:00
Kit Langton ce50f77c20 fix(core): own registry notification lifetimes
Run MCP reconciliation and VCS state-driven refreshes in their layer-owned runtimes so shutdown cancels active and queued work. Contain VCS replay failures at the filesystem event boundary, and share one Deferred per reload debounce window.
2026-08-28 14:22:56 -04:00
Kit Langton 4d57b1d0c9 fix(core): preserve observer lifecycle and branch freshness 2026-08-28 14:22:55 -04:00
Kit Langton c036a8b1b6 fix(core): pull pending registry changes on read 2026-08-28 14:22:55 -04:00
104 changed files with 3255 additions and 4024 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
+11 -62
View File
@@ -307,7 +307,6 @@ export function createData(config: CreateDataInput) {
// submission order. Each waits for the previous POST to settle, so one
// failure does not block the next.
const sending = new Map<string, Promise<unknown>>()
const messageLoads = new Map<string, Promise<unknown>>()
const compacting = new Map<string, { id: string; observed: Set<string>; request: Promise<SessionInboxCompaction> }>()
onCleanup(() => compacting.clear())
@@ -1530,70 +1529,20 @@ export function createData(config: CreateDataInput) {
loading(sessionID: string) {
return store.session.messageLoading[sessionID] ?? false
},
async loadMore(
sessionID: string,
options?: {
all?: boolean
signal?: AbortSignal
/** Runs synchronously inside the store-publication batch. */
beforePublish?: () => void
},
) {
const signal = options?.signal
if (signal?.aborted) return
while (messageLoads.has(sessionID)) {
const published = await (() => {
const pending = messageLoads.get(sessionID)
if (!signal) return pending
const aborted = Promise.withResolvers<void>()
const cancel = () => aborted.resolve()
signal.addEventListener("abort", cancel, { once: true })
return Promise.race([pending, aborted.promise])
.catch((error) => {
if (!signal.aborted) throw error
})
.finally(() => signal.removeEventListener("abort", cancel))
})()
if ((!options?.all && published) || signal?.aborted) return
}
async loadMore(sessionID: string) {
const cursor = store.session.messageCursor[sessionID]
if (!cursor || signal?.aborted) return
if (!cursor || store.session.messageLoading[sessionID]) return
setStore("session", "messageLoading", sessionID, true)
const request = (async () => {
const fetched: SessionMessageInfo[] = []
let next: string | undefined = cursor
do {
const response = await api().message.list(
{
sessionID,
limit: options?.all ? 200 : messagePageLimit,
cursor: next,
},
{ signal },
)
if (signal?.aborted) return
fetched.push(...response.data)
next = response.cursor.next ?? undefined
if (!options?.all) break
} while (next)
// A jump through history publishes once, not once per page of offscreen messages.
const existing = store.session.message[sessionID] ?? []
const ids = new Set(existing.map((item) => item.id))
const messages = [...fetched.reverse().filter((item) => !ids.has(item.id)), ...existing]
batch(() => {
options?.beforePublish?.()
messageIndex.set(sessionID, new Map(messages.map((item, position) => [item.id, position])))
setStore("session", "message", sessionID, reconcile(messages))
setStore("session", "messageCursor", sessionID, next)
})
return true
})()
.catch((error) => {
if (!signal?.aborted) throw error
})
const response = await api()
.message.list({ sessionID, limit: messagePageLimit, cursor })
.finally(() => setStore("session", "messageLoading", sessionID, false))
track(messageLoads, sessionID, request)
await request
const older = response.data.toReversed()
const existing = store.session.message[sessionID] ?? []
const ids = new Set(existing.map((item) => item.id))
const messages = [...older.filter((item) => !ids.has(item.id)), ...existing]
messageIndex.set(sessionID, new Map(messages.map((item, position) => [item.id, position])))
setStore("session", "message", sessionID, reconcile(messages))
setStore("session", "messageCursor", sessionID, response.cursor.next ?? undefined)
},
invalidate(sessionID: string) {
sync.invalidate(`session.message:${sessionID}`)
@@ -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 () => {
-115
View File
@@ -1,5 +1,4 @@
import { expect, test } from "bun:test"
import { getEventListeners } from "node:events"
import { createRoot } from "solid-js"
import { createData, type CreateDataInput } from "../src/solid"
import { OpenCode, type OpenCodeEvent, type Project, type SessionInfo } from "../src/promise"
@@ -415,120 +414,6 @@ test("loads bounded message pages", async () => {
}
})
test.each(["success", "failure", "cancel", "cancel-retry", "cancel-page", "join-cancel", "join-failure"])(
"bulk history (%s)",
async (mode) => {
const messages = [1, 2, 3].map((index) => ({
id: `msg_${index}`,
type: "user",
text: `Message ${index}`,
time: { created: index },
}))
const release = Promise.withResolvers<void>()
const controller = new AbortController()
const requests: URL[] = []
const publications: string[][] = []
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input, init) => {
const url = new URL(input instanceof Request ? input.url : String(input))
requests.push(url)
const cursor = url.searchParams.get("cursor")
if (!cursor) return Response.json({ data: [messages[2]], cursor: { next: "recent" } })
if (cursor === "recent") {
if (mode.startsWith("join")) await release.promise
if (mode === "join-failure") return Response.json({ message: "offline" }, { status: 503 })
return Response.json({ data: [messages[2], messages[1]], cursor: { next: "oldest" } })
}
if (cursor === "oldest") return Response.json({ data: [messages[0]], cursor: { next: "empty" } })
expect(init?.signal).toBe(requests.length === 4 ? controller.signal : undefined)
await release.promise
if (mode === "failure") return Response.json({ message: "offline" }, { status: 503 })
return Response.json({ data: [], cursor: {} })
},
})
const setup = createRoot((dispose) => {
const data = createData({
api: () => api,
directory: "/project",
event: { on: () => () => {}, listen: () => () => {} },
})
return { data, dispose }
})
try {
await setup.data.session.message.sync("ses_refresh")
const newest = setup.data.session.message.get("ses_refresh", "msg_3")
const load = setup.data.session.message.loadMore(
"ses_refresh",
mode.startsWith("join")
? undefined
: {
all: true,
signal: controller.signal,
beforePublish: () => {
publications.push(setup.data.session.message.list("ses_refresh").map((message) => message.id))
expect(setup.data.session.message.get("ses_refresh", "msg_3")).toBe(newest)
},
},
)
const joined = setup.data.session.message.loadMore("ses_refresh", { all: true, signal: controller.signal })
const settled = Promise.allSettled([load, joined])
if (mode.startsWith("join")) {
await wait(() => requests.length === 2)
expect(getEventListeners(controller.signal, "abort")).toHaveLength(1)
controller.abort()
let cancelled = false
void joined.then(() => {
cancelled = true
})
await wait(() => cancelled)
expect(setup.data.session.message.loading("ses_refresh")).toBe(true)
expect(getEventListeners(controller.signal, "abort")).toHaveLength(0)
release.resolve()
expect((await settled).map((result) => result.status)).toEqual(
mode === "join-failure" ? ["rejected", "fulfilled"] : ["fulfilled", "fulfilled"],
)
expect(requests.at(-1)?.searchParams.get("limit")).toBe("20")
expect(requests).toHaveLength(2)
expect(setup.data.session.message.more("ses_refresh")).toBe(true)
expect(setup.data.session.message.list("ses_refresh").map((message) => message.id)).toEqual(
mode === "join-failure" ? ["msg_3"] : ["msg_2", "msg_3"],
)
return
}
await wait(() => requests.length === 4)
expect(setup.data.session.message.loading("ses_refresh")).toBe(true)
expect(setup.data.session.message.list("ses_refresh").map((message) => message.id)).toEqual(["msg_3"])
expect(requests.slice(1).map((url) => url.searchParams.get("limit"))).toEqual(["200", "200", "200"])
if (mode.startsWith("cancel")) controller.abort()
const retry =
mode === "cancel-retry" || mode === "cancel-page"
? setup.data.session.message.loadMore("ses_refresh", mode === "cancel-retry" ? { all: true } : undefined)
: undefined
release.resolve()
expect((await settled).map((result) => result.status)).toEqual(
mode === "failure" ? ["rejected", "rejected"] : ["fulfilled", "fulfilled"],
)
await retry
const success = mode === "success" || mode === "cancel-retry"
expect(setup.data.session.message.loading("ses_refresh")).toBe(false)
expect(setup.data.session.message.more("ses_refresh")).toBe(!success)
expect(setup.data.session.message.list("ses_refresh").map((message) => message.id)).toEqual(
success ? ["msg_1", "msg_2", "msg_3"] : mode === "cancel-page" ? ["msg_2", "msg_3"] : ["msg_3"],
)
expect(setup.data.session.message.get("ses_refresh", "msg_3")).toBe(newest)
expect(requests).toHaveLength(mode === "cancel-retry" ? 7 : mode === "cancel-page" ? 5 : 4)
if (mode === "cancel-page") expect(requests.at(-1)?.searchParams.get("limit")).toBe("20")
expect(publications).toEqual(mode === "success" ? [["msg_3"]] : [])
expect(getEventListeners(controller.signal, "abort")).toHaveLength(0)
} finally {
release.resolve()
setup.dispose()
}
},
)
test("preserves assistant content replacement events across an active message read", async () => {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const release = Promise.withResolvers<void>()
+1 -1
View File
@@ -87,7 +87,7 @@ const layer = Layer.effect(
draft.agents.delete(id)
},
}),
finalize: () => bus.publish(Agent.Event.Updated, {}).pipe(Effect.asVoid),
notify: () => bus.publish(Agent.Event.Updated, {}).pipe(Effect.asVoid),
})
const selectable = (agent: Info | undefined) =>
agent && agent.mode !== "subagent" && !agent.hidden ? agent : undefined
+154 -148
View File
@@ -294,156 +294,162 @@ export function configured(options?: Options) {
) {
return Effect.gen(function* () {
const durable = definition.durable
if (!durable) return yield* Effect.void
const aggregateID = (event.data as Record<string, unknown>)[durable.aggregate]
if (typeof aggregateID !== "string")
return yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: `Expected string aggregate field ${durable.aggregate}`,
}),
)
if (input && input.aggregateID !== aggregateID) {
yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: `Aggregate mismatch: expected ${input.aggregateID}, got ${aggregateID}`,
}),
)
}
const list = projectors.get(versionedType(definition.type, durable.version)) ?? []
return yield* Effect.uninterruptible(
Effect.gen(function* () {
const committed = yield* db
.transaction(
() =>
Effect.gen(function* () {
const row = yield* db
.select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
.get()
.pipe(Effect.orDie)
const latest = row?.seq ?? -1
const encoded = Schema.encodeUnknownSync(definition.data)(event.data) as Record<string, unknown>
if (input?.strictOwner && row?.ownerID && row.ownerID !== input.ownerID) {
yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: `Replay owner mismatch for aggregate ${aggregateID}: expected ${row.ownerID}, got ${input.ownerID ?? "none"}`,
}),
)
}
if (input && input.seq <= latest) {
if (!persist) return
const stored = yield* db
.select()
.from(EventTable)
.where(and(eq(EventTable.aggregate_id, aggregateID), eq(EventTable.seq, input.seq)))
.get()
.pipe(Effect.orDie)
if (
stored?.id === event.id &&
stored.type === versionedType(definition.type, durable.version) &&
stored.created === (event.created ?? 0) &&
isDeepStrictEqual(stored.data, encoded)
) {
if (input.ownerID && row?.ownerID == null) {
yield* db
.update(EventSequenceTable)
.set({ owner_id: input.ownerID })
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
.run()
.pipe(Effect.orDie)
}
return
}
yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: `Replay diverged at aggregate ${aggregateID} sequence ${input.seq}`,
}),
)
}
if (input && row?.ownerID && row.ownerID !== input.ownerID) {
return
}
const seq = input?.seq ?? latest + 1
if (input && seq !== latest + 1) {
yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: `Sequence mismatch for aggregate ${aggregateID}: expected ${latest + 1}, got ${seq}`,
}),
)
}
if (persist) {
const stored = yield* db
.select({ aggregateID: EventTable.aggregate_id, seq: EventTable.seq })
.from(EventTable)
.where(eq(EventTable.id, event.id))
.get()
.pipe(Effect.orDie)
if (stored)
yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: `Event ${event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`,
}),
)
}
const committed = {
...event,
durable: { aggregateID, seq, version: durable.version },
} as Event.Payload
const route = yield* prepareRoutes([committed])
for (const projector of list) {
yield* projector(committed)
}
if (commit) yield* commit(seq)
yield* db
.insert(EventSequenceTable)
.values([{ aggregate_id: aggregateID, seq, owner_id: input?.ownerID }])
.onConflictDoUpdate({
target: EventSequenceTable.aggregate_id,
set: {
seq: sql`max(${EventSequenceTable.seq}, ${seq})`,
...(input?.ownerID && row?.ownerID == null ? { owner_id: input.ownerID } : {}),
},
})
.run()
.pipe(Effect.orDie)
if (persist)
yield* db
.insert(EventTable)
.values([
{
id: event.id,
aggregate_id: aggregateID,
seq,
created: event.created ?? 0,
type: versionedType(definition.type, durable.version),
data: encoded,
},
])
.run()
.pipe(Effect.orDie)
return { aggregateID, seq, event: committed, route }
}),
{ behavior: "immediate" },
)
.pipe(Effect.orDie)
if (committed) {
committed.route()
yield* Effect.forEach(
pubsub.durable.get(committed.aggregateID) ?? [],
(wake) => PubSub.publish(wake, undefined),
{ discard: true },
if (durable) {
const aggregateID = (event.data as Record<string, unknown>)[durable.aggregate]
if (typeof aggregateID !== "string") {
yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: `Expected string aggregate field ${durable.aggregate}`,
}),
)
} else {
if (input && input.aggregateID !== aggregateID) {
yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: `Aggregate mismatch: expected ${input.aggregateID}, got ${aggregateID}`,
}),
)
}
return committed
}),
)
const list = projectors.get(versionedType(definition.type, durable.version)) ?? []
return yield* Effect.uninterruptible(
Effect.gen(function* () {
const committed = yield* db
.transaction(
() =>
Effect.gen(function* () {
const row = yield* db
.select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
.get()
.pipe(Effect.orDie)
const latest = row?.seq ?? -1
const encoded = Schema.encodeUnknownSync(definition.data)(event.data) as Record<
string,
unknown
>
if (input?.strictOwner && row?.ownerID && row.ownerID !== input.ownerID) {
yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: `Replay owner mismatch for aggregate ${aggregateID}: expected ${row.ownerID}, got ${input.ownerID ?? "none"}`,
}),
)
}
if (input && input.seq <= latest) {
if (!persist) return
const stored = yield* db
.select()
.from(EventTable)
.where(and(eq(EventTable.aggregate_id, aggregateID), eq(EventTable.seq, input.seq)))
.get()
.pipe(Effect.orDie)
if (
stored?.id === event.id &&
stored.type === versionedType(definition.type, durable.version) &&
stored.created === (event.created ?? 0) &&
isDeepStrictEqual(stored.data, encoded)
) {
if (input.ownerID && row?.ownerID == null) {
yield* db
.update(EventSequenceTable)
.set({ owner_id: input.ownerID })
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
.run()
.pipe(Effect.orDie)
}
return
}
yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: `Replay diverged at aggregate ${aggregateID} sequence ${input.seq}`,
}),
)
}
if (input && row?.ownerID && row.ownerID !== input.ownerID) {
return
}
const seq = input?.seq ?? latest + 1
if (input && seq !== latest + 1) {
yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: `Sequence mismatch for aggregate ${aggregateID}: expected ${latest + 1}, got ${seq}`,
}),
)
}
if (persist) {
const stored = yield* db
.select({ aggregateID: EventTable.aggregate_id, seq: EventTable.seq })
.from(EventTable)
.where(eq(EventTable.id, event.id))
.get()
.pipe(Effect.orDie)
if (stored)
yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: `Event ${event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`,
}),
)
}
const committed = {
...event,
durable: { aggregateID, seq, version: durable.version },
} as Event.Payload
const route = yield* prepareRoutes([committed])
for (const projector of list) {
yield* projector(committed)
}
if (commit) yield* commit(seq)
yield* db
.insert(EventSequenceTable)
.values([{ aggregate_id: aggregateID, seq, owner_id: input?.ownerID }])
.onConflictDoUpdate({
target: EventSequenceTable.aggregate_id,
set: {
seq: sql`max(${EventSequenceTable.seq}, ${seq})`,
...(input?.ownerID && row?.ownerID == null ? { owner_id: input.ownerID } : {}),
},
})
.run()
.pipe(Effect.orDie)
if (persist)
yield* db
.insert(EventTable)
.values([
{
id: event.id,
aggregate_id: aggregateID,
seq,
created: event.created ?? 0,
type: versionedType(definition.type, durable.version),
data: encoded,
},
])
.run()
.pipe(Effect.orDie)
return { aggregateID, seq, event: committed, route }
}),
{ behavior: "immediate" },
)
.pipe(Effect.orDie)
if (committed) {
committed.route()
yield* Effect.forEach(
pubsub.durable.get(committed.aggregateID) ?? [],
(wake) => PubSub.publish(wake, undefined),
{ discard: true },
)
}
return committed
}),
)
}
}
})
}
+1 -1
View File
@@ -134,7 +134,7 @@ const layer = Layer.effect(
}
return result
},
finalize: Effect.fn("Catalog.finalize")(function* () {
notify: Effect.fn("Catalog.notify")(function* () {
yield* bus.publish(Catalog.Event.Updated, {})
}),
})
+1 -1
View File
@@ -60,7 +60,7 @@ export const layer = Layer.effect(
draft: (draft) => ({
add: (definition) => draft.set(definition.name, definition),
}),
finalize: () => bus.publish(Command.Event.Updated, {}).pipe(Effect.asVoid),
notify: () => bus.publish(Command.Event.Updated, {}).pipe(Effect.asVoid),
})
const info = (definition: Definition) =>
Info.make({
+150
View File
@@ -0,0 +1,150 @@
export * as ConfigFile from "./file.js"
import { isDeepStrictEqual } from "node:util"
import { isRecord } from "@opencode-ai/ai/utils/record"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Effect, Schema, Semaphore } from "effect"
import {
applyEdits,
createScanner,
findNodeAtLocation,
modify,
parseTree,
type Node,
type ParseError,
} from "jsonc-parser"
export class UpdateError extends Schema.TaggedError<UpdateError>()("ConfigFile.UpdateError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect()),
}) {}
const isJson = Schema.is(Schema.MutableJson)
const isDocument = (value: unknown): value is Schema.MutableJsonObject => isRecord(value) && isJson(value)
const lock = Semaphore.makeUnsafe(1)
/**
* Edits an existing JSON(C) file using raw source values, not resolved Config.Info.
* The synchronous callback mutates a source clone; its return value is ignored.
* Validates JSON only; normalization and substitution remain the reader's job.
* Does not discover files, start watchers, or refresh Config state.
* Read-modify-write calls are serialized within this process.
*/
export const update = Effect.fn("ConfigFile.update")(
function* (
filepath: string,
mutate: (draft: Schema.MutableJsonObject) => void,
): Effect.fn.Return<Schema.JsonObject, UpdateError, FSUtil.Service> {
const fs = yield* FSUtil.Service
const text = yield* fs
.readFileString(filepath)
.pipe(Effect.mapError((cause) => new UpdateError({ message: `Failed to read config: ${filepath}`, cause })))
const errors: ParseError[] = []
const current = parseSource(text, errors)
if (errors.length || !isDocument(current))
return yield* Effect.fail(new UpdateError({ message: `Invalid config file: ${filepath}` }))
const next = yield* Effect.try({
try: () => {
const draft = structuredClone(current)
mutate(draft)
return draft
},
catch: (cause) => new UpdateError({ message: "Config update failed", cause }),
})
if (!isDocument(next))
return yield* Effect.fail(new UpdateError({ message: `Config update must produce a JSON object: ${filepath}` }))
const edits = changes(current, next)
if (!edits.length) return next
const updated = yield* Effect.try({
try: () => edits.reduce(patch, text),
catch: (cause) => new UpdateError({ message: `Failed to patch config: ${filepath}`, cause }),
})
// Duplicate keys can make parse choose the last value while modify edits the first.
const written = parseSource(updated, errors)
if (errors.length || !isDeepStrictEqual(written, next))
return yield* Effect.fail(
new UpdateError({ message: `Config patch does not match the requested update: ${filepath}` }),
)
const temporary = filepath + ".tmp"
yield* fs.writeFileString(temporary, updated.endsWith("\n") ? updated : updated + "\n").pipe(
Effect.andThen(fs.rename(temporary, filepath)),
Effect.mapError((cause) => new UpdateError({ message: `Failed to write config: ${filepath}`, cause })),
)
return next
},
(effect) => lock.withPermit(effect),
)
type Edit = { readonly path: (string | number)[]; readonly value: unknown }
function parseSource(text: string, errors: ParseError[]) {
const root = parseTree(text, errors, { allowTrailingComma: true })
if (!root || errors.length) return undefined
// parse() assigns onto {}, invoking the __proto__ setter instead of retaining
// an own JSON key. Construct object entries from the AST without those setters.
const value = (node: Node): unknown => {
if (node.type === "array") return (node.children ?? []).map(value)
if (node.type === "object")
return Object.fromEntries(
(node.children ?? []).map((property) => {
const child = property.children?.[1]
return [property.children?.[0]?.value, child && value(child)]
}),
)
return node.value
}
return value(root)
}
function patch(text: string, edit: Edit) {
if (edit.value !== undefined)
return applyEdits(
text,
modify(text, edit.path, edit.value, { formattingOptions: { tabSize: 2, insertSpaces: true } }),
)
const tree = parseTree(text)
const node = tree && findNodeAtLocation(tree, edit.path)
if (!node) return text
// jsonc-parser removes adjacent comments along with the separator. Remove only
// the property/element itself and one comma, leaving surrounding comments intact.
const target = node.parent?.type === "property" ? node.parent : node
const siblings = target.parent?.children ?? []
const previous = siblings[siblings.indexOf(target) - 1]
const scanner = createScanner(text, true)
scanner.setPosition(target.offset + target.length)
scanner.scan()
const following = text[scanner.getTokenOffset()] === ","
if (!following && previous) {
scanner.setPosition(previous.offset + previous.length)
scanner.scan()
}
return applyEdits(text, [
{ offset: target.offset, length: target.length, content: "" },
...(following || previous ? [{ offset: scanner.getTokenOffset(), length: 1, content: "" }] : []),
])
}
function changes(before: unknown, after: unknown, path: (string | number)[] = []): Edit[] {
if (isDeepStrictEqual(before, after)) return []
if (Array.isArray(before) && Array.isArray(after)) {
return [
...after.flatMap((value, index) => changes(before[index], value, [...path, index])),
// Remove from the end so earlier deletions cannot shift later paths.
...before
.slice(after.length)
.map((_, index) => ({ path: [...path, after.length + index], value: undefined }))
.toReversed(),
]
}
if (isRecord(before) && isRecord(after)) {
return [...new Set([...Object.keys(before), ...Object.keys(after)])].flatMap((key) => {
if (!Object.hasOwn(after, key)) return [{ path: [...path, key], value: undefined }]
if (!Object.hasOwn(before, key)) return [{ path: [...path, key], value: after[key] }]
return changes(before[key], after[key], [...path, key])
})
}
return [{ path, value: after }]
}
+1 -1
View File
@@ -212,7 +212,7 @@ const nativeLayer = (config: Config) =>
: Layer.effect(
Sqlite.Native,
Effect.die(
"workerd sqlite cannot open a database from a path; use Database.layerFromClient.pipe(Layer.provide(sqliteLayer({ storage })))",
"workerd sqlite cannot open a database from a path; use Database.layerWith(sqliteLayer({ storage }))",
),
)
+29 -22
View File
@@ -275,22 +275,22 @@ export function transformSession(input: TransformInput): TransformResult {
if (paired.has(item.row.id)) return []
const owned = byMessage.get(item.row.id)?.map((part) => part.value) ?? []
if (item.value.role === "user") {
const compaction = owned.find((part): part is SessionV1.CompactionPart => part.type === "compaction")
if (compaction) {
const compaction = owned.find((part) => part.type === "compaction")
if (compaction?.type === "compaction") {
const pairedSummary = messages.find(
(candidate): candidate is (typeof messages)[number] & { value: SessionV1.Assistant } =>
(candidate) =>
candidate.value.role === "assistant" &&
candidate.value.parentID === item.row.id &&
candidate.value.summary === true,
candidate.value.summary,
)
if (!pairedSummary) return []
if (!pairedSummary || pairedSummary.value.role !== "assistant") return []
paired.add(pairedSummary.row.id)
if (pairedSummary.value.error || pairedSummary.value.time.completed === undefined) return []
const summary = pairedSummary
const summaryText = (byMessage.get(summary.row.id) ?? [])
.map((part) => part.value)
.filter((part): part is SessionV1.TextPart => part.type === "text" && part.text.length > 0)
.map((part) => part.text)
.filter((part) => part.type === "text" && part.text.length > 0)
.map((part) => (part.type === "text" ? part.text : ""))
.join("\n\n")
const tailIndex = compaction.tail_start_id
? messages.findIndex((candidate) => candidate.row.id === compaction.tail_start_id)
@@ -313,14 +313,16 @@ export function transformSession(input: TransformInput): TransformResult {
]
}
const subtasks = owned.filter((part) => part.type === "subtask")
const visible = owned.filter((part): part is SessionV1.TextPart => part.type === "text" && !part.ignored)
const files = owned.filter((part): part is SessionV1.FilePart => part.type === "file")
const agents = owned.filter((part): part is SessionV1.AgentPart => part.type === "agent")
const visible = owned.filter((part) => part.type === "text" && !part.ignored)
const files = owned.filter((part) => part.type === "file")
const agents = owned.filter((part) => part.type === "agent")
if (subtasks.length > 0 && visible.length === 0 && files.length === 0 && agents.length === 0) return []
const ordinary = visible.filter((part) => !part.synthetic)
const synthetic = visible.filter((part) => part.synthetic)
const attachments = files.flatMap((part) => migrateFile(part))
const unavailable = files.flatMap((part) => (!part.url.startsWith("data:") ? [unavailableFile(part)] : []))
const ordinary = visible.filter((part) => part.type === "text" && !part.synthetic)
const synthetic = visible.filter((part) => part.type === "text" && part.synthetic)
const attachments = files.flatMap((part) => (part.type === "file" ? migrateFile(part) : []))
const unavailable = files.flatMap((part) =>
part.type === "file" && !part.url.startsWith("data:") ? [unavailableFile(part)] : [],
)
const text = owned
.flatMap((part) => {
if (part.type === "text" && !part.ignored && !part.synthetic) return [part.text]
@@ -328,12 +330,16 @@ export function transformSession(input: TransformInput): TransformResult {
return []
})
.join("\n\n")
const agentAttachments = agents.map((part) => ({
name: part.name,
...(part.source
? { mention: { text: part.source.value, start: part.source.start, end: part.source.end } }
: {}),
}))
const agentAttachments = agents.map((part) =>
part.type === "agent"
? {
name: part.name,
...(part.source
? { mention: { text: part.source.value, start: part.source.start, end: part.source.end } }
: {}),
}
: { name: "" },
)
if (
ordinary.length === 0 &&
unavailable.length === 0 &&
@@ -345,7 +351,7 @@ export function transformSession(input: TransformInput): TransformResult {
row(item.row, {
id: item.row.id,
type: "synthetic",
text: synthetic.map((part) => part.text).join("\n\n"),
text: synthetic.map((part) => (part.type === "text" ? part.text : "")).join("\n\n"),
time: { created: item.row.time_created },
}),
]
@@ -363,7 +369,7 @@ export function transformSession(input: TransformInput): TransformResult {
row(item.row, {
id: syntheticID(item.row.id, used),
type: "synthetic",
text: synthetic.map((part) => part.text).join("\n\n"),
text: synthetic.map((part) => (part.type === "text" ? part.text : "")).join("\n\n"),
time: { created: item.row.time_created },
}),
]
@@ -437,6 +443,7 @@ export function transformSession(input: TransformInput): TransformResult {
})
.map((item, seq) => ({ ...item, seq }))
const assistants = messages
.filter((item) => item.value.role === "assistant")
.map((item) => item.value)
.filter((item): item is SessionV1.Assistant => item.role === "assistant")
const latestUser = messages.findLast((item) => {
+4 -2
View File
@@ -62,8 +62,9 @@ export const syncTextBom = Effect.fn("FileMutation.syncTextBom")(function* (
const transactionLocks = KeyedMutex.makeUnsafe<string>()
/**
* Mutation locking is process-local and serializes cooperating OpenCode
* changes; external writes can still race.
* Serialize file changes by absolute target. Conditional writes compare and
* write under the same process-local lock so cooperating OpenCode mutations do
* not overwrite changes made from the same stale content.
*/
const layer = Layer.effect(
Service,
@@ -128,6 +129,7 @@ export const node = makeLocationNode({ service: Service, layer, deps: [Environme
/**
* Deferred until the corresponding integrations exist.
*/
// TODO: Add formatter integration after formatter runtime exists.
// TODO: Publish watcher/file-edit events after watcher integration exists.
// TODO: Add snapshots / undo after snapshot design exists.
// TODO: Notify LSP and collect diagnostics after LSP runtime exists.
@@ -25,19 +25,15 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Lo
const layer = Layer.effect(
Service,
Effect.gen(function* () {
let current: readonly string[] = []
const listeners = new Set<(ignore: readonly string[]) => Effect.Effect<void>>()
const state = State.create<Data, Draft>({
const state: State.Interface<Data, Draft> = State.create<Data, Draft>({
name: "location-watcher-policy",
initial: () => ({ ignore: [] }),
draft: (draft) => ({
add: (ignore) => draft.ignore.push(...ignore),
list: () => draft.ignore,
}),
finalize: (draft) =>
Effect.sync(() => {
current = [...draft.list()]
}).pipe(Effect.andThen(Effect.forEach(listeners, (listener) => listener(current), { discard: true }))),
notify: () => Effect.forEach(listeners, (listener) => listener(state.get().ignore), { discard: true }),
})
const observe = Effect.fn("LocationWatcherPolicy.observe")(function* (
listener: (ignore: readonly string[]) => Effect.Effect<void>,
@@ -56,7 +52,7 @@ const layer = Layer.effect(
return Service.of({
transform: state.transform,
reload: state.reload,
current: () => current,
current: () => state.get().ignore,
observe,
})
}),
+1 -1
View File
@@ -74,7 +74,7 @@ export const layer = (options?: Options) =>
draft.available = false
},
}),
finalize: () => bus.publish(Event.Updated, {}).pipe(Effect.asVoid),
notify: () => bus.publish(Event.Updated, {}).pipe(Effect.asVoid),
})
const source = (value: ReadonlyArray<File> | Instructions.Unavailable | Instructions.Removed) =>
+8 -6
View File
@@ -328,7 +328,7 @@ const layer = Layer.effect(
},
},
}),
finalize: () => bus.publish(Integration.Event.Updated, {}).pipe(Effect.asVoid),
notify: () => bus.publish(Integration.Event.Updated, {}).pipe(Effect.asVoid),
})
const createCredential = Effect.fnUntraced(function* (input: Parameters<Credential.Interface["create"]>[0]) {
@@ -402,11 +402,13 @@ const layer = Layer.effect(
}
yield* Effect.gen(function* () {
const implementation = state
.get()
.integrations.get(attempt.integrationID)
?.implementations.get(attempt.methodID)
const persistence = yield* Effect.sync(() => attempt.label ?? implementation?.label?.(exit.value)).pipe(
const persistence = yield* Effect.sync(() => {
const implementation = state
.get()
.integrations.get(attempt.integrationID)
?.implementations.get(attempt.methodID)
return attempt.label ?? implementation?.label?.(exit.value)
}).pipe(
Effect.flatMap((label) =>
createCredential({
integrationID: attempt.integrationID,
+25 -5
View File
@@ -5,7 +5,21 @@ import { McpEvent } from "@opencode-ai/schema/mcp-event"
import { ephemeral } from "@opencode-ai/schema/event"
import { createHash } from "node:crypto"
import { isDeepStrictEqual } from "node:util"
import { Cause, Context, Effect, Exit, FiberSet, Latch, Layer, Schema, Scope, Stream, Types } from "effect"
import {
Cause,
Context,
Effect,
Exit,
Fiber,
FiberSet,
Latch,
Layer,
Schema,
Scope,
Semaphore,
Stream,
Types,
} from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Credential } from "../credential.js"
import { Bus } from "../bus.js"
@@ -613,8 +627,9 @@ export const layer = (options?: Options) =>
let applied: Map<ServerName, Mcp.ServerConfig> | undefined
const overrides = new Map<ServerName, Mcp.ServerConfig | false>()
const reconcile = Effect.fnUntraced(function* (next: Draft) {
const servers = new Map(next.list())
const reconcileLock = Semaphore.makeUnsafe(1)
const reconcile = Effect.fnUntraced(function* () {
const servers = state.get().servers
if (!applied && entries.size === 0) {
for (const [name, server] of servers) {
entries.set(name, {
@@ -675,7 +690,7 @@ export const layer = (options?: Options) =>
Stream.runForEach((event) => Effect.sync(() => fork(reconnect(event.data.integrationID)))),
),
)
const state = State.create<Data, Draft>({
const state: State.Interface<Data, Draft> = State.create<Data, Draft>({
name: "mcp",
initial: () => ({
servers: new Map(
@@ -700,7 +715,12 @@ export const layer = (options?: Options) =>
},
remove: (server) => draft.servers.delete(ServerName.make(server)),
}),
finalize: reconcile,
notify: () =>
Effect.gen(function* () {
const exit = yield* Fiber.await(fork(reconcileLock.withPermit(reconcile())))
if (Exit.isFailure(exit) && root.state._tag === "Closed" && Cause.hasInterruptsOnly(exit.cause)) return
yield* exit
}),
})
// Suspend so each await sees current entries; a bare Map iterator is exhausted after one run.
+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()),
+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", ""], refresh: true })).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 },
@@ -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") {}
+1 -24
View File
@@ -89,7 +89,6 @@ export const layer = Layer.effect(
const instance = yield* InstancePlugins.Service
const sources = yield* ConfigPluginSource.Service
const bus = yield* Bus.Service
const npm = yield* Npm.Service
const ready = yield* Latch.make()
let observed = 0
@@ -137,29 +136,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 })
}),
)
+5 -4
View File
@@ -137,10 +137,11 @@ const layer = Layer.effect(
strategy: project.vcs.type === "git" ? "git" : undefined,
})
// A missing directory row means this directory's resolution is a new durable
// fact. The row insert commits atomically with the event, so a crash between
// checks retries on the next resolve instead of stranding the announcement.
// The in-flight set keeps concurrent resolves from publishing the same fact
// twice.
// fact (copy.ts registers copy directories directly; those never strand
// sessions and never announce). The row insert commits atomically with the
// event, so a crash between checks retries on the next resolve instead of
// stranding the announcement. The in-flight set keeps concurrent resolves
// from publishing the same fact twice.
for (const item of directories) {
const key = item.projectID + "\u0000" + item.directory
if (announcing.has(key)) continue
+53 -42
View File
@@ -26,6 +26,7 @@ export type Info = Reference.Info
type Data = {
sources: Map<string, Types.DeepMutable<Source>>
materialized: Map<string, Info>
}
type Draft = {
@@ -47,61 +48,71 @@ const layer = Layer.effect(
const bus = yield* Bus.Service
const cache = yield* RepositoryCache.Service
const scope = yield* Scope.Scope
const materialized = new Map<string, Info>()
const state = State.create<Data, Draft>({
const state: State.Interface<Data, Draft> = State.create<Data, Draft>({
name: "reference",
initial: () => ({ sources: new Map() }),
initial: () => ({ sources: new Map(), materialized: new Map() }),
draft: (draft) => ({
add: (name, source) => draft.sources.set(name, source as Types.DeepMutable<Source>),
remove: (name) => draft.sources.delete(name),
list: () => Array.from(draft.sources.entries()) as [string, Source][],
}),
finalize: (draft) =>
Effect.gen(function* () {
materialized.clear()
for (const [name, source] of draft.list()) {
if (source.type === "local") {
materialized.set(
name,
Info.make({
name,
path: source.path,
...(source.description === undefined ? {} : { description: source.description }),
...(source.hidden === undefined ? {} : { hidden: source.hidden }),
source,
}),
)
continue
}
const repository = Repository.parse(source.repository)
if (!repository || !Repository.isRemote(repository)) continue
if (source.branch) {
try {
Repository.validateBranch(source.branch)
} catch {
continue
}
}
materialized.set(
prepare: (data) => {
for (const [name, source] of data.sources) {
if (source.type === "local") {
data.materialized.set(
name,
Info.make({
name,
path: AbsolutePath.make(Repository.cachePath(global.repos, repository, source.branch)),
path: source.path,
...(source.description === undefined ? {} : { description: source.description }),
...(source.hidden === undefined ? {} : { hidden: source.hidden }),
source,
}),
)
yield* cache.ensure({ reference: repository, branch: source.branch, refresh: true }).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to materialize reference", {
name,
repository: source.repository,
cause,
}),
),
Effect.forkIn(scope),
)
continue
}
const repository = Repository.parse(source.repository)
if (!repository || !Repository.isRemote(repository)) continue
if (source.branch) {
try {
Repository.validateBranch(source.branch)
} catch {
continue
}
}
data.materialized.set(
name,
Info.make({
name,
path: AbsolutePath.make(Repository.cachePath(global.repos, repository, source.branch)),
...(source.description === undefined ? {} : { description: source.description }),
...(source.hidden === undefined ? {} : { hidden: source.hidden }),
source,
}),
)
}
},
notify: () =>
Effect.gen(function* () {
for (const info of state.get().materialized.values()) {
const source = info.source
if (source.type !== "git") continue
yield* cache
.ensure({
reference: Repository.parseRemote(source.repository),
branch: source.branch,
refresh: true,
})
.pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to materialize reference", {
name: info.name,
repository: source.repository,
cause,
}),
),
Effect.forkIn(scope),
)
}
yield* bus.publish(Reference.Event.Updated, {})
}),
@@ -111,7 +122,7 @@ const layer = Layer.effect(
transform: state.transform,
reload: state.reload,
list: Effect.fn("Reference.list")(function* () {
return Array.from(materialized.values())
return Array.from(state.get().materialized.values())
}),
})
}),
@@ -42,8 +42,8 @@ export const commit = Effect.fn("InstructionState.commit")(function* (
observation: Observation,
) {
if (!observation.initial && Object.keys(observation.delta).length === 0) return
// The rendered text is frozen into the durable event because re-rendering it
// later would require the original Location-scoped instruction sources.
// The rendered text is frozen into the durable event: replaying it later would
// require the Location-scoped registry that produced it.
const text = observation.initial ? "" : yield* renderUpdateText(db, instructions, observation)
yield* bus.publish(
SessionEvent.InstructionsUpdated,
+1 -1
View File
@@ -109,7 +109,7 @@ const layer = Layer.effect(
draft.skills.delete(ID.make(id))
},
}),
finalize: () => bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid),
notify: () => bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid),
})
return Service.of({
+88 -94
View File
@@ -1,9 +1,9 @@
export * as State from "./state.js"
import { Clock, Context, Deferred, Effect, Scope, Semaphore } from "effect"
import { Clock, Context, Deferred, Effect, Exit, Scope } from "effect"
/**
* A replayable transform applied to a draft during reload.
* A replayable transform applied to a draft while deriving state.
*
* Domain drafts expose readable and writable state while preserving concise
* plugin/config code. Transforms synchronously rebuild derived state.
@@ -16,13 +16,14 @@ export interface Registration {
}
/**
* Registers and applies a scoped transform. Closing the owning Scope removes
* the transform and reloads the materialized state.
* Registers a scoped transform and invalidates the derived state. Closing the
* owning Scope removes the transform. Reads synchronously replay pending changes.
*/
export type Transform<DraftApi> = (
transform: TransformCallback<DraftApi>,
) => Effect.Effect<Registration, never, Scope.Scope>
/** Invalidates the snapshot after captured inputs change and coalesces notifications. */
export type Reload = () => Effect.Effect<void>
export interface Transformable<DraftApi> {
@@ -33,7 +34,7 @@ export interface Transformable<DraftApi> {
type Batch = {
active: boolean
readonly flush: boolean
readonly reloads: Set<Reload>
readonly notifications: Set<Reload>
}
const CurrentBatch = Context.Reference<Batch | undefined>("@opencode/State/CurrentBatch", {
@@ -41,17 +42,24 @@ const CurrentBatch = Context.Reference<Batch | undefined>("@opencode/State/Curre
})
const reloadDebounce = 500
/** flush: false is terminal teardown: states whose transforms are removed stop rebuilding, including pending reloads. */
/** Batches notifications, not read visibility. flush: false is terminal teardown. */
export function batch<A, E, R>(effect: Effect.Effect<A, E, R>, options: { readonly flush?: boolean } = {}) {
return Effect.gen(function* () {
const current = yield* CurrentBatch
if (current?.active && options.flush !== false) return yield* effect
const batch: Batch = { active: true, flush: options.flush !== false, reloads: new Set() }
const exit = yield* effect.pipe(Effect.provideService(CurrentBatch, batch), Effect.exit)
batch.active = false
if (batch.flush) yield* Effect.forEach(batch.reloads, (reload) => reload(), { discard: true })
return yield* exit
})
return Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const current = yield* CurrentBatch
if (current?.active && options.flush !== false) return yield* restore(effect)
const batch: Batch = { active: true, flush: options.flush !== false, notifications: new Set() }
const exit = yield* restore(effect.pipe(Effect.provideService(CurrentBatch, batch))).pipe(Effect.exit)
batch.active = false
const notifications = batch.flush
? yield* Effect.forEach(batch.notifications, (notify) => restore(notify()).pipe(Effect.exit))
: []
// Accepted writes are not rolled back: one failed observer must not hide
// the other states' changes, or replace the batch body's failure.
yield* Exit.asVoidAll([exit, ...notifications])
return yield* exit
}),
)
}
export const inherit = Effect.fnUntraced(function* () {
@@ -65,124 +73,110 @@ export interface Options<State, DraftApi> {
readonly initial: () => State
/** Wraps mutable state in a domain-specific draft API. */
readonly draft: MakeDraft<State, DraftApi>
/** Synchronously completes derived data after ordered transform replay. */
readonly prepare?: (state: State) => void
/**
* Runs after the rebuilt state becomes visible. Update events published here
* act as read barriers: subscribers refetching on the event observe the
* committed state.
* Observes accepted changes outside the read path. Batched writes notify at
* batch completion; reloads debounce notifications. Reads never run this hook.
* Resource reconciliation owns its execution scope and coordination.
*/
readonly finalize?: (draft: DraftApi) => Effect.Effect<void>
readonly notify?: () => Effect.Effect<void>
}
export interface Interface<State, DraftApi> extends Transformable<DraftApi> {
/** Returns the latest accepted state, replaying stale inputs synchronously. */
readonly get: () => State
}
export function create<State, DraftApi>(options: Options<State, DraftApi>): Interface<State, DraftApi> {
let state = options.initial()
let transforms: { run: TransformCallback<DraftApi> }[] = []
let generation = 0
const transforms = new Set<{ run: TransformCallback<DraftApi> }>()
let dirty = false
let requestedAt = 0
let running = false
let closed = false
let waiters: { generation: number; done: Deferred.Deferred<void> }[] = []
const semaphore = Semaphore.makeUnsafe(1)
let pending: Deferred.Deferred<void> | undefined
const commit = Effect.fn("State.commit")(function* (next: State) {
state = next
if (options.finalize) yield* options.finalize(options.draft(next))
})
const materialize = Effect.fnUntraced(function* () {
if (closed) return
const get = () => {
if (!dirty || closed) return state
const next = options.initial()
const api = options.draft(next)
for (const transform of transforms) {
yield* Effect.sync(() => {
transform.run(api)
})
}
yield* commit(next)
transforms.forEach((transform) => transform.run(api))
options.prepare?.(next)
state = next
dirty = false
return state
}
const notify = Effect.fn("State.notify")(function* () {
if (closed) return
get()
if (options.notify) yield* options.notify()
})
const materializeReload = () => semaphore.withPermit(materialize())
const rebuild = (): Effect.Effect<void> =>
const publish = (done: Deferred.Deferred<void>): Effect.Effect<void> =>
Effect.gen(function* () {
const clock = yield* Clock.Clock
const remaining = requestedAt + reloadDebounce - clock.currentTimeMillisUnsafe()
if (remaining > 0) yield* Effect.sleep(remaining)
if (clock.currentTimeMillisUnsafe() < requestedAt + reloadDebounce) return yield* rebuild()
if (clock.currentTimeMillisUnsafe() < requestedAt + reloadDebounce) return yield* publish(done)
const target = generation
const exit = yield* materializeReload().pipe(Effect.exit)
const completed = waiters.filter((waiter) => waiter.generation <= target)
waiters = waiters.filter((waiter) => waiter.generation > target)
yield* Effect.forEach(completed, (waiter) => Deferred.done(waiter.done, exit), {
concurrency: "unbounded",
discard: true,
})
if (generation > target) return yield* rebuild()
running = false
// Release scheduling ownership before observers run: an observer may
// request and await another reload without joining this notification.
pending = undefined
yield* notify().pipe(Deferred.into(done))
})
const reload = Effect.fnUntraced(function* () {
if (closed) return
const done = Deferred.makeUnsafe<void>()
const clock = yield* Clock.Clock
generation++
requestedAt = clock.currentTimeMillisUnsafe()
waiters.push({ generation, done })
if (!running) {
running = true
yield* rebuild().pipe(Effect.forkDetach)
}
yield* Deferred.await(done)
})
const changed = (debounce: boolean) =>
Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
if (closed) return
if (debounce) dirty = true
const batch = yield* CurrentBatch
if (batch?.active) {
if (!batch.flush) {
closed = true
return
}
batch.notifications.add(notify)
return
}
if (!debounce) return yield* restore(notify())
const clock = yield* Clock.Clock
requestedAt = clock.currentTimeMillisUnsafe()
// No yields between choosing the burst's completion and claiming it.
const done = pending ?? Deferred.makeUnsafe<void>()
if (!pending) {
pending = done
yield* publish(done).pipe(Effect.forkDetach)
}
yield* restore(Deferred.await(done))
}),
)
return {
get: () => state,
get,
transform: Effect.fn("State.transform")(function* (update) {
yield* Effect.annotateCurrentSpan("state", options.name ?? "anonymous")
const scope = yield* Scope.Scope
return yield* Effect.uninterruptible(
Effect.gen(function* () {
const transform = { run: update }
let active = true
const dispose = Effect.uninterruptible(
semaphore.withPermit(
Effect.suspend(() => {
if (!active) return Effect.void
active = false
transforms = transforms.filter((item) => item !== transform)
return Effect.gen(function* () {
const batch = yield* CurrentBatch
if (batch?.active) {
// Detached debounced reloads must also stay quiet after teardown.
if (!batch.flush) {
closed = true
return
}
batch.reloads.add(materializeReload)
return
}
yield* materialize()
})
}),
),
)
yield* semaphore.withPermit(
Effect.sync(() => {
transforms = [...transforms, transform]
Effect.suspend(() => {
if (!transforms.delete(transform)) return Effect.void
dirty = true
return changed(false)
}),
)
transforms.add(transform)
dirty = true
yield* Scope.addFinalizer(scope, dispose)
const batch = yield* CurrentBatch
if (batch?.active) batch.reloads.add(materializeReload)
else yield* materializeReload()
yield* changed(false)
return { dispose }
}),
)
}),
reload,
reload: () => changed(true),
}
}
+1 -1
View File
@@ -185,7 +185,7 @@ const layer = Layer.effect(
draft.tools.delete(id)
},
}),
finalize: () =>
notify: () =>
Effect.forEach(
state.get().errors,
({ tool, error }) =>
+3 -3
View File
@@ -7,7 +7,7 @@
- Plugin authors get schema-derived input types at the `ToolDraft.add` boundary through `Tool`.
- The heterogeneous Core registry deliberately erases registered definitions to `Tool.Info`. Use `any` at this internal boundary; do not replace it with `unknown`, JSON-value plumbing, casts, or compiled wrapper types solely to preserve type safety after registration.
- Executors return model content and metadata alongside declared machine output. Shipped built-ins and plugin tools use the same runtime shape after registration.
- `src/tool.ts` stores canonical Location registrations, derives LLM definitions, executes tools, and normalizes model content and images.
- `src/tool.ts` stores canonical Location registrations, derives LLM definitions, executes tools, and applies generic output bounding.
- Built-in tool plugins live in `tool/plugin`.
Do not add a second executable entry type, registry-owned executor, authorization callback, output-path callback, or legacy normalization path.
@@ -53,9 +53,9 @@ Tool filtering is catalog visibility, not execution authorization. A call still
## Output
Built-ins return complete tool responses. `Tool.Snapshot.execute` is the local execution boundary. Generic output bounding is applied by the Session runner after execution.
Built-ins return complete tool responses. `Tool.Snapshot.execute` is the local execution boundary.
Producer capture remains local to producers. Shell stores combined process output in its backing file and returns a bounded tail with the full-output path when truncated.
Producer capture limits remain local to producers. For example, Bash keeps `AppProcess.maxOutputBytes` and accurately reports stdout/stderr capture loss.
## Current Gaps
+3 -1
View File
@@ -5,7 +5,9 @@ export abstract class NamedError extends Error {
abstract toObject(): { name: string; data: unknown }
static hasName(error: unknown, name: string): boolean {
return typeof error === "object" && error !== null && "name" in error && error.name === name
return (
typeof error === "object" && error !== null && "name" in error && (error as Record<string, unknown>).name === name
)
}
static create<Name extends string, Fields extends Schema.Struct.Fields>(
+34 -10
View File
@@ -1,7 +1,7 @@
export * as Vcs from "./vcs.js"
import path from "path"
import { Cause, Context, Effect, Layer, Schema, Stream } from "effect"
import { Cause, Context, Effect, Exit, Fiber, FiberSet, Layer, Schema, Semaphore, Stream } from "effect"
import type { VcsDefinition, VcsDraft } from "@opencode-ai/plugin/effect/vcs"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { FileSystem } from "@opencode-ai/schema/filesystem"
@@ -47,8 +47,11 @@ const layer = Layer.effect(
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const bus = yield* Bus.Service
const root = yield* Effect.scope
const fork = yield* FiberSet.makeRuntime<never, void, never>()
const vcs = location.vcs
const current: { info: Info } = { info: { branch: {} } }
const refreshLock = Semaphore.makeUnsafe(1)
const scope = {
directory: location.directory,
worktree: location.project.directory,
@@ -69,7 +72,12 @@ const layer = Layer.effect(
set: (selection) => (draft.selection = selection),
},
}),
finalize: () => refresh(),
notify: () =>
Effect.gen(function* () {
const exit = yield* Fiber.await(fork(refresh()))
if (Exit.isFailure(exit) && root.state._tag === "Closed" && Cause.hasInterruptsOnly(exit.cause)) return
yield* exit
}),
})
const selected = () => {
const value = state.get()
@@ -87,13 +95,23 @@ const layer = Layer.effect(
),
)
const refresh = Effect.fn("Vcs.refresh")(function* () {
const provider = selected()
const next: Info = provider
? yield* protect(provider, "info", provider.info(scope).pipe(Effect.flatMap(decodeInfo)), { branch: {} })
: { branch: {} }
const changed = current.info.branch.current !== next.branch.current
current.info = next
if (changed) yield* bus.publish(VcsEvent.BranchUpdated, { branch: next.branch.current })
const changed = yield* Effect.gen(function* () {
const provider = selected()
const next: Info = provider
? yield* protect(provider, "info", provider.info(scope).pipe(Effect.flatMap(decodeInfo)), { branch: {} })
: { branch: {} }
const changed = current.info.branch.current !== next.branch.current
current.info = next
return changed
}).pipe(refreshLock.withPermit)
if (!changed) return
// Legacy listeners can publish nested updates before streams and SSE receive
// this event. Re-announce the latest branch if publication was overtaken.
while (true) {
const branch = current.info.branch.current
yield* bus.publish(VcsEvent.BranchUpdated, { branch })
if (branch === current.info.branch.current) return
}
})
if (vcs) {
@@ -105,7 +123,13 @@ const layer = Layer.effect(
yield* bus.subscribe(FileSystem.Event.Changed).pipe(
Stream.filter((event) => isBranchMetadata(event.data.file)),
Stream.runForEach((event) =>
refresh().pipe(Effect.withSpan("Vcs.refreshBranch", { attributes: { file: event.data.file } })),
refresh().pipe(
Effect.catchCauseIf(
(cause) => !Cause.hasInterrupts(cause),
(cause) => Effect.logWarning("vcs refresh failed", { file: event.data.file, cause }),
),
Effect.withSpan("Vcs.refreshBranch", { attributes: { file: event.data.file } }),
),
),
Effect.forkScoped({ startImmediately: true }),
)
+1 -1
View File
@@ -88,7 +88,7 @@ const layer = Layer.effect(
set: (selection) => (draft.selection = selection),
},
}),
finalize: () => bus.publish(WebSearch.Event.Updated, {}).pipe(Effect.asVoid),
notify: () => bus.publish(WebSearch.Event.Updated, {}).pipe(Effect.asVoid),
})
const requireProvider = (providers: Map<ID, ProviderImplementation>, providerID: ID) => {
+53
View File
@@ -11,6 +11,7 @@ import { Location } from "@opencode-ai/core/location"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { State } from "@opencode-ai/core/state"
import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
@@ -30,6 +31,57 @@ const catalogLayer = AppNodeBuilder.build(
const it = testEffect(catalogLayer)
describe("Catalog", () => {
it.effect("reads available and default models inside a batch before publishing", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const bus = yield* Bus.Service
const observed: string[] = []
const unsubscribe = yield* bus.listen((event) =>
event.type === Catalog.Event.Updated.type
? catalog.model.default().pipe(
Effect.map((model) => {
observed.push(model?.id ?? "none")
}),
)
: Effect.void,
)
yield* Effect.addFinalizer(() => unsubscribe)
const providerID = Provider.ID.make("test")
const old = Model.ID.make("old")
const newest = Model.ID.make("new")
yield* State.batch(
Effect.gen(function* () {
yield* catalog.transform((draft) => {
draft.provider.update(providerID, () => {})
draft.model.update(providerID, old, (model) => {
model.time.released = 1000
})
draft.model.update(providerID, newest, (model) => {
model.time.released = 2000
})
draft.model.default.set(providerID, old)
})
expect((yield* catalog.model.available()).map((model) => model.id)).toEqual([newest, old])
expect((yield* catalog.model.default())?.id).toBe(old)
const overlay = yield* catalog.transform((draft) =>
draft.model.update(providerID, old, (model) => {
model.enabled = false
}),
)
expect((yield* catalog.model.available()).map((model) => model.id)).toEqual([newest])
expect((yield* catalog.model.default())?.id).toBe(newest)
yield* overlay.dispose
expect((yield* catalog.model.default())?.id).toBe(old)
expect(observed).toEqual([])
}),
)
expect(observed).toEqual([old])
}),
)
it.effect("publishes an updated event after catalog changes", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
@@ -291,6 +343,7 @@ describe("Catalog", () => {
configured = false
const reload = yield* catalog.reload().pipe(Effect.forkChild({ startImmediately: true }))
expect((yield* catalog.model.default())?.id).toBe(newest)
yield* TestClock.adjust("500 millis")
yield* Fiber.join(reload)
expect((yield* catalog.model.default())?.id).toBe(newest)
+411
View File
@@ -0,0 +1,411 @@
import path from "path"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { parse } from "jsonc-parser"
import { isRecord } from "@opencode-ai/ai/utils/record"
import { ConfigFile } from "@opencode-ai/core/config/file"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { withTempDir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
// No Config, Location, Watcher, Credential, or WellKnown services are provided.
const it = testEffect(LayerNode.compile(FSUtil.node))
describe("ConfigFile", () => {
it.live("edits the explicit target and preserves comments and unrelated fields", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const global = path.join(tmp.path, "global", "opencode.jsonc")
const target = path.join(tmp.path, "project", "custom.jsonc")
const text = '{\n // Keep this comment.\n "shell": "project",\n "custom": { "value": 1 },\n}\n'
yield* fs.writeWithDirs(global, '{ "shell": "global" }')
yield* fs.writeWithDirs(target, text)
const updated = yield* ConfigFile.update(target, (draft) => {
draft.shell = "updated"
})
expect(updated).toEqual({ shell: "updated", custom: { value: 1 } })
expect(yield* fs.readFileString(target)).toBe(text.replace('"project"', '"updated"'))
expect(yield* fs.readFileString(global)).toBe('{ "shell": "global" }')
}),
),
)
it.live("leaves raw substitutions, model shorthand, and legacy shapes unresolved", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.jsonc")
const text = `{
"model": "{env:OPENCODE_TEST_CONFIG_MODEL}",
"shell": "{file:missing-shell.txt}",
"skills": { "paths": ["./skills"] },
"agent": { "review": { "model": "acme/reasoner" } },
"username": "before"
}
`
yield* fs.writeFileString(target, text)
yield* ConfigFile.update(target, (draft) => {
expect(draft.model).toBe("{env:OPENCODE_TEST_CONFIG_MODEL}")
expect(draft.shell).toBe("{file:missing-shell.txt}")
expect(draft.skills).toEqual({ paths: ["./skills"] })
draft.username = "after"
})
expect(yield* fs.readFileString(target)).toBe(text.replace('"before"', '"after"'))
}),
),
)
it.live("patches nested source fields and deletes legacy keys without migrating them", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.jsonc")
yield* fs.writeFileString(
target,
`{
"agent": {
"review": { "description": "before", "hidden": true },
// Keep the other definition.
"build": { "description": "unchanged" }
},
"snapshot": true
}
`,
)
const updated = yield* ConfigFile.update(target, (draft) => {
const agent: unknown = draft.agent
if (!isRecord(agent) || !isRecord(agent.review)) throw new Error("Missing fixture agent")
agent.review.description = "after"
agent.review.color = "blue"
delete agent.review.hidden
delete draft.snapshot
})
expect(updated).toEqual({
agent: { review: { description: "after", color: "blue" }, build: { description: "unchanged" } },
})
expect(parse(yield* fs.readFileString(target))).toEqual(updated)
expect(yield* fs.readFileString(target)).toContain("// Keep the other definition.")
}),
),
)
it.live("patches array elements without rewriting untouched comments", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.jsonc")
const text = `{
"plugins": [
// Keep the first plugin.
"first",
"second",
// Keep the third plugin.
"third",
"fourth"
]
}
`
yield* fs.writeFileString(target, text)
yield* ConfigFile.update(target, (draft) => {
if (!Array.isArray(draft.plugins)) throw new Error("Missing fixture plugins")
draft.plugins[1] = "updated"
})
expect(yield* fs.readFileString(target)).toBe(text.replace('"second"', '"updated"'))
const shortened = yield* ConfigFile.update(target, (draft) => {
if (!Array.isArray(draft.plugins)) throw new Error("Missing fixture plugins")
draft.plugins.splice(1, 3)
})
expect(shortened.plugins).toEqual(["first"])
expect(parse(yield* fs.readFileString(target))).toEqual(shortened)
const extended = yield* ConfigFile.update(target, (draft) => {
if (!Array.isArray(draft.plugins)) throw new Error("Missing fixture plugins")
draft.plugins.push("added", "last")
})
expect(extended.plugins).toEqual(["first", "added", "last"])
expect(parse(yield* fs.readFileString(target))).toEqual(extended)
expect(yield* fs.readFileString(target)).toContain("// Keep the first plugin.")
}),
),
)
it.live("preserves adjacent comments when deleting properties and array elements", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.jsonc")
yield* fs.writeFileString(
target,
`{
"shell": "remove",
// Keep the model explanation.
"model": "acme/reasoner",
"plugins": ["first", "second", /* Keep the plugin explanation. */ "third"],
"skills": [/* Keep the source explanation. */ "remove",],
}
`,
)
const updated = yield* ConfigFile.update(target, (draft) => {
delete draft.shell
if (!Array.isArray(draft.plugins)) throw new Error("Missing fixture plugins")
draft.plugins.splice(1, 1)
draft.skills = []
})
expect(parse(yield* fs.readFileString(target))).toEqual(updated)
expect(updated).toEqual({ model: "acme/reasoner", plugins: ["first", "third"], skills: [] })
expect(yield* fs.readFileString(target)).toContain("// Keep the model explanation.")
expect(yield* fs.readFileString(target)).toContain("/* Keep the plugin explanation. */")
expect(yield* fs.readFileString(target)).toContain("/* Keep the source explanation. */")
}),
),
)
it.live("deletes own JSON keys that also exist on Object.prototype", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.json")
yield* fs.writeFileString(
target,
'{ "\\u005f_proto__": "remove", "constructor": "remove", "toString": "remove", "shell": "keep" }',
)
const updated = yield* ConfigFile.update(target, (draft) => {
;["__proto__", "constructor", "toString"].forEach((key) => {
delete draft[key]
})
})
expect(updated).toEqual({ shell: "keep" })
expect(yield* fs.readJson(target)).toEqual(updated)
}),
),
)
it.live("preserves and edits object-valued __proto__ source keys", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.json")
yield* fs.writeFileString(target, '{ "__proto__": { "value": "before" }, "shell": "keep" }')
const updated = yield* ConfigFile.update(target, (draft) => {
expect(Object.hasOwn(draft, "__proto__")).toBe(true)
const entry: unknown = draft["__proto__"]
if (!isRecord(entry)) throw new Error("Missing fixture entry")
entry.value = "after"
})
expect(updated).toEqual({ ["__proto__"]: { value: "after" }, shell: "keep" })
expect(yield* fs.readJson(target)).toEqual(updated)
expect(Object.getPrototypeOf(updated)).toBe(Object.prototype)
}),
),
)
it.live("rejects a duplicate-key patch that would not change the effective value", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.json")
const text = '{ "shell": "first", "shell": "second" }'
yield* fs.writeFileString(target, text)
const error = yield* ConfigFile.update(target, (draft) => {
draft.shell = "after"
}).pipe(Effect.flip)
expect(error).toBeInstanceOf(ConfigFile.UpdateError)
expect(error.message).toBe(`Config patch does not match the requested update: ${target}`)
expect(yield* fs.readFileString(target)).toBe(text)
expect(yield* fs.exists(target + ".tmp")).toBe(false)
}),
),
)
it.live("rereads the selected file for consecutive edits without a watcher", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.json")
yield* fs.writeFileString(target, '{ "shell": "first" }')
yield* ConfigFile.update(target, (draft) => {
draft.shell = "second"
})
yield* ConfigFile.update(target, (draft) => {
expect(draft.shell).toBe("second")
draft.username = "added"
})
expect(yield* fs.readJson(target)).toEqual({ shell: "second", username: "added" })
yield* fs.writeFileString(target, '{ "shell": "external", "username": "added" }')
const updated = yield* ConfigFile.update(target, (draft) => {
expect(draft.shell).toBe("external")
draft.snapshots = false
})
expect(yield* fs.readJson(target)).toEqual(updated)
expect(updated).toEqual({ shell: "external", username: "added", snapshots: false })
}),
),
)
it.live("serializes concurrent read-modify-write calls", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.json")
yield* fs.writeFileString(target, '{ "count": 0 }')
const increment = ConfigFile.update(target, (draft) => {
if (typeof draft.count !== "number") throw new Error("Missing fixture count")
draft.count++
})
yield* Effect.all([increment, increment, increment], { concurrency: "unbounded" })
expect(yield* fs.readJson(target)).toEqual({ count: 3 })
}),
),
)
it.live("does not rewrite no-op or structurally equal edits", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.json")
const text = '{\r\n "plugins": ["first"]\r\n}'
yield* fs.writeFileString(target, text)
const before = yield* fs.stat(target)
yield* ConfigFile.update(target, () => {})
yield* ConfigFile.update(target, (draft) => {
draft.plugins = ["first"]
})
expect(yield* fs.readFileString(target)).toBe(text)
expect((yield* fs.stat(target)).ino).toEqual(before.ino)
expect((yield* fs.stat(target)).mtime).toEqual(before.mtime)
expect(yield* fs.exists(target + ".tmp")).toBe(false)
}),
),
)
it.live("leaves the file unchanged when a callback throws and permits a later edit", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.json")
const text = '{ "shell": "before" }'
yield* fs.writeFileString(target, text)
const cause = new Error("Rejected config update")
const error = yield* ConfigFile.update(target, (draft) => {
draft.shell = "discarded"
throw cause
}).pipe(Effect.flip)
expect(error).toBeInstanceOf(ConfigFile.UpdateError)
expect(error.message).toBe("Config update failed")
expect(error.cause).toBe(cause)
expect(yield* fs.readFileString(target)).toBe(text)
expect(yield* fs.exists(target + ".tmp")).toBe(false)
yield* ConfigFile.update(target, (draft) => {
draft.shell = "recovered"
})
expect(yield* fs.readJson(target)).toEqual({ shell: "recovered" })
}),
),
)
it.live("ignores callback return values instead of replacing the document", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.json")
yield* fs.writeFileString(target, "{}")
expect(yield* ConfigFile.update(target, () => new Date(0))).toEqual({})
expect(yield* fs.readFileString(target)).toBe("{}")
const updated = yield* ConfigFile.update(target, (draft) => (draft.shell = "updated"))
expect(updated).toEqual({ shell: "updated" })
expect(yield* fs.readJson(target)).toEqual(updated)
}),
),
)
it.live("rejects non-JSON mutations before writing", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.json")
const text = '{ "shell": "before" }'
yield* fs.writeFileString(target, text)
const error = yield* ConfigFile.update(target, (draft) => {
draft.invalid = Number.NaN
}).pipe(Effect.flip)
expect(error).toBeInstanceOf(ConfigFile.UpdateError)
expect(error.message).toBe(`Config update must produce a JSON object: ${target}`)
expect(yield* fs.readFileString(target)).toBe(text)
expect(yield* fs.exists(target + ".tmp")).toBe(false)
}),
),
)
;["", "{", "[]", "null"].forEach((text) => {
it.live(`rejects invalid or non-object source ${JSON.stringify(text)}`, () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.json")
yield* fs.writeFileString(target, text)
const error = yield* ConfigFile.update(target, () => {
throw new Error("Callback must not run")
}).pipe(Effect.flip)
expect(error).toBeInstanceOf(ConfigFile.UpdateError)
expect(error.message).toBe(`Invalid config file: ${target}`)
expect(yield* fs.readFileString(target)).toBe(text)
expect(yield* fs.exists(target + ".tmp")).toBe(false)
}),
),
)
})
it.live("reports a missing target without creating it", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "missing.json")
const error = yield* ConfigFile.update(target, () => {}).pipe(Effect.flip)
expect(error).toBeInstanceOf(ConfigFile.UpdateError)
expect(error.message).toBe(`Failed to read config: ${target}`)
expect(error.cause).toBeDefined()
expect(yield* fs.exists(target)).toBe(false)
}),
),
)
it.live("reports write failures without replacing the target", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.json")
const text = '{ "shell": "before" }'
yield* fs.writeFileString(target, text)
yield* fs.makeDirectory(target + ".tmp")
const error = yield* ConfigFile.update(target, (draft) => {
draft.shell = "discarded"
}).pipe(Effect.flip)
expect(error).toBeInstanceOf(ConfigFile.UpdateError)
expect(error.message).toBe(`Failed to write config: ${target}`)
expect(error.cause).toBeDefined()
expect(yield* fs.readFileString(target)).toBe(text)
}),
),
)
})
@@ -0,0 +1,127 @@
import { describe, expect } from "bun:test"
import { Effect, Fiber, Scope } from "effect"
import { TestClock } from "effect/testing"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LocationWatcherPolicy } from "@opencode-ai/core/filesystem/location-watcher-policy"
import { State } from "@opencode-ai/core/state"
import { testEffect } from "../lib/effect"
const it = testEffect(AppNodeBuilder.build(LocationWatcherPolicy.node))
describe("LocationWatcherPolicy", () => {
it.effect("reads batched registrations and disposals without notifying observers", () =>
Effect.gen(function* () {
const policy = yield* LocationWatcherPolicy.Service
const observed: string[][] = []
yield* policy.observe((ignore) =>
Effect.sync(() => {
observed.push([...ignore])
}),
)
yield* State.batch(
Effect.gen(function* () {
yield* policy.transform((draft) => draft.add(["base"]))
const overlay = yield* policy.transform((draft) => draft.add(["overlay"]))
const snapshot = policy.current()
expect(snapshot).toEqual(["base", "overlay"])
expect(observed).toEqual([])
yield* overlay.dispose
expect(policy.current()).toEqual(["base"])
expect(snapshot).toEqual(["base", "overlay"])
expect(observed).toEqual([])
}),
)
expect(observed).toEqual([["base"]])
}),
)
it.effect("reads reloaded patterns before debounced observer reconciliation", () =>
Effect.gen(function* () {
const policy = yield* LocationWatcherPolicy.Service
const observed: string[][] = []
let ignore = ["first"]
yield* policy.observe((ignore) =>
Effect.sync(() => {
observed.push([...ignore])
}),
)
yield* policy.transform((draft) => draft.add(ignore))
const snapshot = policy.current()
observed.length = 0
ignore = ["second"]
const reload = yield* policy.reload().pipe(Effect.forkChild({ startImmediately: true }))
expect(policy.current()).toEqual(["second"])
expect(snapshot).toEqual(["first"])
expect(observed).toEqual([])
yield* TestClock.adjust("500 millis")
yield* Fiber.join(reload)
expect(observed).toEqual([["second"]])
}),
)
it.effect("passes the latest policy to later observers after a reentrant registration", () =>
Effect.gen(function* () {
const policy = yield* LocationWatcherPolicy.Service
const scope = yield* Scope.Scope
const observed: string[][] = []
let reentered = false
yield* policy.observe(() =>
Effect.gen(function* () {
if (reentered) return
reentered = true
yield* policy.transform((draft) => draft.add(["inner"])).pipe(Scope.provide(scope))
}),
)
yield* policy.observe((ignore) =>
Effect.sync(() => {
observed.push([...ignore])
}),
)
yield* policy.transform((draft) => draft.add(["outer"]))
expect(policy.current()).toEqual(["outer", "inner"])
expect(observed).toEqual([
["outer", "inner"],
["outer", "inner"],
])
}),
)
it.effect("allows an observer to await a reload and keeps later observers current", () =>
Effect.gen(function* () {
const policy = yield* LocationWatcherPolicy.Service
const observed: string[][] = []
let ignore = ["first"]
let reentered = false
yield* policy.observe(() =>
Effect.gen(function* () {
if (reentered) return
reentered = true
ignore = ["second"]
yield* policy.reload()
}),
)
yield* policy.observe((ignore) =>
Effect.sync(() => {
observed.push([...ignore])
}),
)
const writer = yield* policy
.transform((draft) => draft.add(ignore))
.pipe(Effect.forkChild({ startImmediately: true }))
expect(policy.current()).toEqual(["second"])
expect(observed).toEqual([])
yield* TestClock.adjust("500 millis")
yield* Fiber.join(writer)
expect(observed).toEqual([["second"], ["second"]])
}),
)
})
@@ -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],
+5 -1
View File
@@ -14,6 +14,10 @@ export function location(ref: Location.Ref, input: { projectDirectory?: Absolute
} satisfies Location.Interface
}
export function locationLayer(ref: Location.Ref, input: { projectDirectory?: AbsolutePath; vcs?: Project.Vcs } = {}) {
return Layer.succeed(Location.Service, Location.Service.of(location(ref, input)))
}
export const tempLocationLayer = Layer.unwrap(
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
@@ -21,7 +25,7 @@ export const tempLocationLayer = Layer.unwrap(
).pipe(
Effect.map((tmp) => {
const ref = Location.Ref.make({ directory: AbsolutePath.make(tmp.path) })
return Layer.succeed(Location.Service, Location.Service.of(location(ref)))
return locationLayer(ref)
}),
),
)
@@ -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>,
),
)
@@ -0,0 +1,73 @@
import { describe, expect } from "bun:test"
import { Cause, Effect, Exit, Fiber } from "effect"
import { TestClock } from "effect/testing"
import { Credential } from "@opencode-ai/core/credential"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Integration } from "@opencode-ai/core/integration"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { testEffect } from "./lib/effect"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node])))
describe("Integration replay", () => {
it.effect("fails and closes an OAuth attempt when fresh implementation replay throws", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("replay-test")
const methodID = Integration.MethodID.make("code")
const source = { fail: false, closed: false }
const failure = new Error("integration transform replay failed")
yield* integrations.transform((editor) => {
if (source.fail) throw failure
editor.method.update({
integrationID,
method: { id: methodID, type: "oauth", label: "Fixture" },
authorize: () =>
Effect.addFinalizer(() => Effect.sync(() => (source.closed = true))).pipe(
Effect.as({
mode: "code" as const,
url: "https://example.com/authorize",
instructions: "Enter the fixture code",
callback: () =>
Effect.succeed(
Credential.OAuth.make({
type: "oauth",
methodID,
access: "dummy-access",
refresh: "dummy-refresh",
expires: Number.MAX_SAFE_INTEGER,
}),
),
}),
),
})
})
const attempt = yield* integrations.oauth.connect({ integrationID, methodID, label: "Fixture" })
source.fail = true
const reload = yield* integrations.reload().pipe(Effect.exit, Effect.forkChild({ startImmediately: true }))
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
source.fail = false
yield* TestClock.adjust("500 millis")
yield* Fiber.join(reload)
}),
)
const exit = yield* integrations.oauth
.complete({ integrationID, attemptID: attempt.attemptID, code: "dummy-code" })
.pipe(Effect.exit)
expect(exit).toMatchObject(Exit.die(failure))
expect(Exit.isFailure(exit) && Cause.squash(exit.cause)).toBe(failure)
expect(yield* integrations.oauth.status({ integrationID, attemptID: attempt.attemptID })).toEqual({
status: "failed",
message: failure.message,
time: attempt.time,
})
expect(source.closed).toBe(true)
expect(yield* credentials.list(integrationID)).toEqual([])
}),
)
})
+97
View File
@@ -7,6 +7,7 @@ import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { Integration } from "@opencode-ai/core/integration"
import { State } from "@opencode-ai/core/state"
import { testEffect } from "./lib/effect"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node, Bus.node])))
@@ -262,6 +263,102 @@ describe("Integration", () => {
}),
)
it.effect("resolves stored OAuth with refresh registrations made inside a batch", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("acme")
const method = Integration.OAuthMethod.make({
id: Integration.MethodID.make("browser"),
type: "oauth",
label: "Browser",
})
const expired = Credential.OAuth.make({
type: "oauth",
methodID: method.id,
access: "expired",
refresh: "refresh",
expires: 0,
})
const fresh = Credential.OAuth.make({
...expired,
access: "fresh",
refresh: "fresh-refresh",
expires: (yield* Clock.currentTimeMillis) + Duration.toMillis(Duration.hours(1)),
})
const stored = yield* credentials.create({ integrationID, label: "Personal", value: expired })
const connection = { type: "credential" as const, id: stored.id, label: stored.label }
const calls: string[] = []
const implementation = {
integrationID,
method,
authorize: () => Effect.die("unexpected authorization"),
refresh: (value: Credential.OAuth) =>
Effect.sync(() => {
expect(value).toEqual(expired)
calls.push("original")
return fresh
}),
}
yield* State.batch(
Effect.gen(function* () {
yield* integrations.transform((editor) => editor.method.update(implementation))
expect(yield* integrations.connection.resolve(connection)).toEqual(fresh)
expect((yield* credentials.get(stored.id))?.value).toEqual(fresh)
expect(calls).toEqual(["original"])
expect(yield* integrations.connection.resolve(connection)).toEqual(fresh)
expect(calls).toEqual(["original"])
yield* credentials.update(stored.id, { value: expired })
const overridden = Credential.OAuth.make({ ...fresh, access: "override" })
const override = yield* integrations.transform((editor) =>
editor.method.update({
...implementation,
refresh: (value) =>
Effect.sync(() => {
expect(value).toEqual(expired)
calls.push("override")
return overridden
}),
}),
)
expect(yield* integrations.connection.resolve(connection)).toEqual(overridden)
expect((yield* credentials.get(stored.id))?.value).toEqual(overridden)
yield* override.dispose
yield* credentials.update(stored.id, { value: expired })
expect(yield* integrations.connection.resolve(connection)).toEqual(fresh)
expect(calls).toEqual(["original", "override", "original"])
yield* credentials.update(stored.id, { value: expired })
const removal = yield* integrations.transform((editor) => editor.method.remove(integrationID, method))
expect(yield* integrations.connection.resolve(connection)).toEqual(expired)
expect((yield* credentials.get(stored.id))?.value).toEqual(expired)
expect(calls).toEqual(["original", "override", "original"])
yield* removal.dispose
expect(yield* integrations.connection.resolve(connection)).toEqual(fresh)
yield* credentials.update(stored.id, { value: expired })
yield* integrations.transform((editor) => editor.method.update({ ...implementation, refresh: undefined }))
expect(yield* integrations.connection.resolve(connection)).toEqual(expired)
expect((yield* credentials.get(stored.id))?.value).toEqual(expired)
expect(calls).toEqual(["original", "override", "original", "original"])
const failure = new Error("refresh failed")
yield* integrations.transform((editor) =>
editor.method.update({ ...implementation, refresh: () => Effect.fail(failure) }),
)
expect(yield* integrations.connection.resolve(connection).pipe(Effect.flip)).toEqual(
new Integration.AuthorizationError({ cause: failure }),
)
expect((yield* credentials.get(stored.id))?.value).toEqual(expired)
}),
)
}),
)
it.effect("completes code OAuth once and stores the credential", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
+139 -4
View File
@@ -33,16 +33,31 @@ import { McpStdio } from "@opencode-ai/core/mcp/stdio"
import { Permission } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { State } from "@opencode-ai/core/state"
import { McpTool } from "@opencode-ai/core/tool/mcp"
import { Tool } from "@opencode-ai/core/tool"
import { Deferred, Effect, Exit, Fiber, Layer, PubSub, Ref, Schedule, Schema, Sink, Stream } from "effect"
import {
Context,
Deferred,
Effect,
Exit,
Fiber,
Layer,
PubSub,
Ref,
Schedule,
Schema,
Scope,
Sink,
Stream,
} from "effect"
import { TestClock } from "effect/testing"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { ExitCode, makeHandle, ProcessId } from "effect/unstable/process/ChildProcessSpawner"
import { Image } from "@opencode-ai/core/image"
import { testEffect } from "./lib/effect"
import { imagePassthrough } from "./lib/image"
import { location } from "./fixture/location"
import { location, locationLayer } from "./fixture/location"
import { hostEnvironmentLayer, recordingEnvironmentLayer } from "./fixture/environment"
import { executeTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
@@ -66,7 +81,7 @@ function resourceServer(
listChanged?: boolean
emptyElicitation?: boolean
urlElicitation?: boolean
respond?: (request: Request) => Response | undefined
respond?: (request: Request) => Response | undefined | Promise<Response | undefined>
} = {},
) {
return Effect.acquireRelease(
@@ -158,7 +173,7 @@ function resourceServer(
if (typeof body === "object" && body !== null && "method" in body && body.method === "initialize") {
state.initializations += 1
}
return input.respond?.(request) ?? transport.handleRequest(request)
return (await input.respond?.(request)) ?? transport.handleRequest(request)
},
})
return {
@@ -1355,6 +1370,126 @@ test("reconciles only changed MCP server config", async () => {
)
})
testEffect(Layer.empty).live("serializes MCP config restoration behind an in-flight replacement", () =>
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const accepted = yield* Deferred.make<void>()
const server = yield* resourceServer({
respond: (request) =>
request.method !== "POST"
? undefined
: Effect.runPromise(
Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(release)), Effect.as(undefined)),
),
})
yield* Effect.gen(function* () {
const service = yield* Mcp.Service
expect((yield* service.servers())[0]?.status).toEqual({ status: "disabled" })
const replacing = yield* service
.transform((draft) => draft.update("resources", (config) => (config.disabled = false)))
.pipe(Effect.forkScoped({ startImmediately: true }))
yield* Deferred.await(started)
const restoring = yield* State.batch(
Effect.gen(function* () {
yield* service.transform((draft) => draft.update("resources", (config) => (config.disabled = true)))
yield* Deferred.succeed(accepted, undefined)
}),
).pipe(Effect.forkScoped({ startImmediately: true }))
yield* Deferred.await(accepted)
expect((yield* service.servers())[0]?.status).toEqual({ status: "pending" })
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(replacing)
yield* Fiber.join(restoring)
expect((yield* service.servers())[0]?.status).toEqual({ status: "disabled" })
expect(yield* service.tools()).toEqual([])
expect(server.state.initializations).toBe(1)
}).pipe(
Effect.ensuring(Deferred.succeed(release, undefined)),
Effect.provide(
resourceMcpLayer(new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false, disabled: true })),
),
)
}),
)
const shutdownIt = testEffect(
AppNodeBuilder.build(
LayerNode.group([Bus.node, Integration.node, Credential.node, Form.node, Environment.node, Location.node]),
[
[Location.node, locationLayer({ directory: AbsolutePath.make(import.meta.dir) })],
[Environment.node, hostEnvironmentLayer],
],
),
)
;["active", "queued"].forEach((phase) =>
shutdownIt.effect(`discards ${phase} MCP notifications after its layer closes`, () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const entered = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const root = yield* Scope.make()
yield* Effect.addFinalizer(() =>
Deferred.succeed(release, undefined).pipe(
Effect.andThen(State.batch(Scope.close(root, Exit.void), { flush: false })),
Effect.andThen(TestClock.adjust("500 millis")),
),
)
const context = yield* Layer.buildWithScope(Mcp.layer(), root)
const service = Context.get(context, Mcp.Service)
const observed: string[] = []
let block = false
const unsubscribe = yield* bus.listen((event) =>
Effect.gen(function* () {
if (event.type !== McpEvent.StatusChanged.type) return
observed.push(Schema.decodeUnknownSync(McpEvent.StatusChanged.data)(event.data).server)
if (!block) return
block = false
yield* Deferred.succeed(entered, undefined)
yield* Deferred.await(release)
}),
)
yield* Effect.addFinalizer(() => unsubscribe)
const source = { url: "https://example.com/initial", added: false }
yield* service
.transform((draft) => {
draft.set("fixture", { type: "remote", url: source.url, oauth: false, disabled: true })
if (source.added) draft.set("queued", { type: "local", command: ["unused"], disabled: true })
})
.pipe(Scope.provide(root))
block = true
source.url = "https://example.com/first"
source.added = phase === "active"
const first = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Deferred.await(entered)
source.url = "https://example.com/second"
source.added = true
const second = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
const shutdown = yield* State.batch(Scope.close(root, Exit.void), { flush: false }).pipe(
Effect.forkChild({ startImmediately: true }),
)
yield* TestClock.adjust("1 millis")
expect(shutdown.pollUnsafe()).toBeDefined()
expect(first.pollUnsafe()).toBeDefined()
expect(second.pollUnsafe()).toBeDefined()
expect(yield* Deferred.isDone(release)).toBe(false)
yield* Fiber.join(shutdown)
observed.length = 0
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(first)
yield* Fiber.join(second)
expect(observed).toEqual([])
expect((yield* service.servers()).map((server) => server.name)).toEqual([Mcp.ServerName.make("fixture")])
}),
),
)
test("serializes concurrent MCP lifecycle operations", async () => {
await Effect.runPromise(
Effect.scoped(
+1 -165
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()
@@ -295,27 +236,11 @@ describe("Npm.add", () => {
const npm = yield* Npm.Service
const mutableEntry = yield* npm.add(mutable, { refresh: true })
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)
})
})
+73 -17
View File
@@ -1,10 +1,12 @@
import { describe, expect } from "bun:test"
import { ToolFailure } from "@opencode-ai/ai"
import { Context, Effect, Exit, Fiber, Schema, Stream } from "effect"
import { Clock, Context, Duration, Effect, Exit, Fiber, Schema, Stream } from "effect"
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { Agent } from "@opencode-ai/core/agent"
import { Bus } from "@opencode-ai/core/bus"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
@@ -103,6 +105,64 @@ describe("Plugin", () => {
}),
)
it.live("refreshes its own stored OAuth connection during plugin activation", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("acme")
const methodID = Integration.MethodID.make("browser")
const expired = Credential.OAuth.make({
type: "oauth",
methodID,
access: "expired",
refresh: "refresh",
expires: 0,
})
const fresh = Credential.OAuth.make({
...expired,
access: "fresh",
refresh: "fresh-refresh",
expires: (yield* Clock.currentTimeMillis) + Duration.toMillis(Duration.hours(1)),
})
const stored = yield* credentials.create({ integrationID, label: "Personal", value: expired })
const resolved: (Credential.Value | undefined)[] = []
const refreshed: Credential.OAuth[] = []
yield* plugins.activate([
versioned(
EffectPlugin.define({
id: "oauth-refresh",
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.integration.transform((editor) =>
editor.method.update({
integrationID,
method: { id: methodID, type: "oauth", label: "Browser" },
authorize: () => Effect.die("unexpected authorization"),
refresh: (value) =>
Effect.sync(() => {
refreshed.push(value)
return fresh
}),
}),
)
const connection = yield* ctx.integration.connection.active(integrationID)
if (!connection) return yield* Effect.die("stored connection missing")
resolved.push(yield* ctx.integration.connection.resolve(connection).pipe(Effect.orDie))
}),
}),
),
])
expect(resolved).toEqual([fresh])
expect(refreshed).toEqual([expired])
expect((yield* credentials.get(stored.id))?.value).toEqual(fresh)
expect(yield* plugins.list()).toEqual([
{ id: Plugin.ID.make("oauth-refresh"), source: { type: "builtin" }, status: "active", tui: false },
])
}),
)
it.live("exposes public events through the plugin context", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
@@ -450,23 +510,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"),
+55
View File
@@ -3,6 +3,8 @@ import { Message, SystemPart } from "@opencode-ai/ai"
import { DateTime, Effect, Schema } from "effect"
import { Agent } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { Model } from "@opencode-ai/core/model"
import { Location } from "@opencode-ai/core/location"
import { Plugin } from "@opencode-ai/core/plugin"
@@ -110,6 +112,59 @@ describe("fromPromise", () => {
}),
)
it.live("refreshes its own stored OAuth connection during plugin activation", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("acme")
const methodID = Integration.MethodID.make("browser")
const expired = Credential.OAuth.make({
type: "oauth",
methodID,
access: "expired",
refresh: "dummy",
expires: 0,
})
const fresh = Credential.OAuth.make({ ...expired, access: "fresh", expires: Number.MAX_SAFE_INTEGER })
const stored = yield* credentials.create({ integrationID, label: "Fixture", value: expired })
const resolved: string[] = []
const refreshed: string[] = []
const adapted = PluginPromise.fromPromise(
define({
id: "promise-oauth-refresh",
setup: async (ctx) => {
await ctx.integration.transform((editor) =>
editor.method.update({
integrationID,
method: { id: methodID, type: "oauth", label: "Browser" },
authorize: async () => {
throw new Error("unexpected authorization")
},
refresh: async (value) => {
refreshed.push(value.access)
return fresh
},
}),
)
const connection = await ctx.integration.connection.active(integrationID)
if (!connection) throw new Error("stored connection missing")
const value = await ctx.integration.connection.resolve(connection)
resolved.push(value?.type === "oauth" ? value.access : "missing")
},
}),
)
yield* plugins.activate([{ ...adapted, version: "1" }])
expect(resolved).toEqual(["fresh"])
expect(refreshed).toEqual(["expired"])
expect((yield* credentials.get(stored.id))?.value).toEqual(fresh)
expect(yield* plugins.list()).toEqual([
{ id: Plugin.ID.make("promise-oauth-refresh"), source: { type: "builtin" }, status: "active", tui: false },
])
}),
)
it.effect("exposes the host location including workspace and project metadata", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
@@ -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* () {
+133 -9
View File
@@ -1,23 +1,147 @@
import { describe, expect } from "bun:test"
import { Effect, Exit, Layer, Scope } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { State } from "@opencode-ai/core/state"
import { Global } from "@opencode-ai/util/global"
import { Reference } from "@opencode-ai/core/reference"
import { Repository } from "@opencode-ai/core/repository"
import { RepositoryCache } from "@opencode-ai/core/repository-cache"
import { it } from "./lib/effect"
import { it, testEffect } from "./lib/effect"
const cache = Layer.mock(RepositoryCache.Service, {
ensure: () => Effect.die("unexpected Git materialization"),
})
const referenceLayer = AppNodeBuilder.build(Reference.node, [[RepositoryCache.node, cache]])
const referenceLayer = AppNodeBuilder.build(LayerNode.group([Reference.node, Bus.node]), [
[RepositoryCache.node, cache],
])
const referenceIt = testEffect(referenceLayer)
describe("Reference", () => {
it.effect("registers normalized sources for the owning scope", () =>
it.effect("prepares batched references before cache work or update events", () => {
const operations: RepositoryCache.EnsureInput[] = []
const cache = Layer.mock(RepositoryCache.Service, {
ensure: (input) =>
Effect.sync(() => {
operations.push(input)
return {
repository: input.reference.label,
host: input.reference.host,
remote: input.reference.remote,
localPath: Repository.cachePath(Global.Path.repos, input.reference, input.branch),
status: "cached",
} satisfies RepositoryCache.Result
}),
})
const referenceLayer = AppNodeBuilder.build(LayerNode.group([Reference.node, Bus.node]), [
[RepositoryCache.node, cache],
])
return Effect.gen(function* () {
const references = yield* Reference.Service
const bus = yield* Bus.Service
const observed: string[][] = []
const unsubscribe = yield* bus.listen((event) =>
event.type === Reference.Event.Updated.type
? references.list().pipe(
Effect.map((infos) => {
observed.push(infos.map((info) => info.name))
}),
)
: Effect.void,
)
yield* Effect.addFinalizer(() => unsubscribe)
yield* State.batch(
Effect.gen(function* () {
yield* references.transform((draft) => {
draft.add("docs", Reference.LocalSource.make({ type: "local", path: AbsolutePath.make("/docs") }))
draft.add(
"sdk",
Reference.GitSource.make({
type: "git",
repository: "owner/repo",
branch: "feature/docs",
description: "SDK documentation",
hidden: true,
}),
)
draft.add("invalid", Reference.GitSource.make({ type: "git", repository: "invalid" }))
draft.add(
"invalid-branch",
Reference.GitSource.make({ type: "git", repository: "owner/repo", branch: "../escape" }),
)
draft.add("file", Reference.GitSource.make({ type: "git", repository: "file:///docs" }))
})
const infos = yield* references.list()
expect(infos.map((info) => info.name)).toEqual(["docs", "sdk"])
expect(infos[1]).toMatchObject({
path: Repository.cachePath(Global.Path.repos, Repository.parseRemote("owner/repo"), "feature/docs"),
description: "SDK documentation",
hidden: true,
})
expect(operations).toEqual([])
expect(observed).toEqual([])
}),
)
expect(observed).toEqual([["docs", "sdk"]])
yield* Effect.yieldNow
expect(
operations.map((input) => ({
repository: input.reference.label,
branch: input.branch,
refresh: input.refresh,
})),
).toEqual([{ repository: "owner/repo", branch: "feature/docs", refresh: true }])
}).pipe(Effect.scoped, Effect.provide(referenceLayer))
})
referenceIt.effect("lets update listeners replace references and refetch the latest projection", () =>
Effect.gen(function* () {
const references = yield* Reference.Service
const scope = yield* Scope.make()
const bus = yield* Bus.Service
const scope = yield* Scope.Scope
const observed: string[][] = []
let reentered = false
const first = yield* bus.listen((event) =>
Effect.gen(function* () {
if (event.type !== Reference.Event.Updated.type || reentered) return
reentered = true
yield* references
.transform((draft) =>
draft.add("docs", Reference.LocalSource.make({ type: "local", path: AbsolutePath.make("/new") })),
)
.pipe(Scope.provide(scope))
}),
)
const second = yield* bus.listen((event) =>
event.type === Reference.Event.Updated.type
? references.list().pipe(
Effect.map((infos) => {
observed.push(infos.map((info) => info.path))
}),
)
: Effect.void,
)
yield* Effect.addFinalizer(() => first.pipe(Effect.andThen(second)))
yield* references.transform((draft) =>
draft.add("docs", Reference.LocalSource.make({ type: "local", path: AbsolutePath.make("/old") })),
)
expect((yield* references.list()).map((info) => info.path)).toEqual([AbsolutePath.make("/new")])
expect(observed).toEqual([["/new"], ["/new"]])
}),
)
referenceIt.effect("registers normalized sources for the owning scope", () =>
Effect.gen(function* () {
const references = yield* Reference.Service
const parent = yield* Effect.scope
const scope = yield* Scope.fork(parent)
const path = AbsolutePath.make("/docs")
const source = Reference.LocalSource.make({
type: "local",
@@ -33,10 +157,10 @@ describe("Reference", () => {
yield* Scope.close(scope, Exit.void)
expect(yield* references.list()).toEqual([])
}).pipe(Effect.provide(referenceLayer)),
}),
)
it.effect("derives Git paths without exposing cache operations", () =>
referenceIt.effect("derives Git paths without exposing cache operations", () =>
Effect.gen(function* () {
const references = yield* Reference.Service
const repository = Repository.parseRemote("owner/repo")
@@ -50,10 +174,10 @@ describe("Reference", () => {
source,
}),
])
}).pipe(Effect.scoped, Effect.provide(referenceLayer)),
}),
)
it.effect("preserves configured Git descriptions", () =>
referenceIt.effect("preserves configured Git descriptions", () =>
Effect.gen(function* () {
const references = yield* Reference.Service
const repository = Repository.parseRemote("owner/repo")
@@ -72,6 +196,6 @@ describe("Reference", () => {
source,
}),
])
}).pipe(Effect.scoped, Effect.provide(referenceLayer)),
}),
)
})
+236 -42
View File
@@ -60,32 +60,25 @@ const assistantRow = (
return { id, session_id: sessionID, type, seq, time_created: DateTime.toEpochMillis(time.created), data }
}
const seedSession = (overrides?: Partial<typeof SessionTable.$inferInsert>) =>
Effect.gen(function* () {
const db = (yield* Database.Service).db
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
...overrides,
})
.run()
return db
})
describe("SessionProjector", () => {
it.effect("does not settle a pending manual compaction on an auto failure", () =>
Effect.gen(function* () {
const db = yield* seedSession()
const db = (yield* Database.Service).db
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
const bus = yield* Bus.Service
const inputID = SessionMessage.ID.make("msg_manual_compaction")
yield* SessionInbox.admitCompaction(db, bus, { id: inputID, sessionID, delivery: "queue" })
@@ -102,7 +95,22 @@ describe("SessionProjector", () => {
it.effect("loads legacy revert storage into canonical state", () =>
Effect.gen(function* () {
const db = yield* seedSession()
const db = (yield* Database.Service).db
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
const legacy = JSON.stringify({
messageID: "msg_boundary",
snapshot: "tree",
@@ -123,14 +131,28 @@ describe("SessionProjector", () => {
it.effect("projects staged, cleared, and committed reverts", () =>
Effect.gen(function* () {
const db = yield* seedSession({
cost: 1.25,
tokens_input: 10,
tokens_output: 4,
tokens_reasoning: 2,
tokens_cache_read: 3,
tokens_cache_write: 1,
})
const db = (yield* Database.Service).db
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
cost: 1.25,
tokens_input: 10,
tokens_output: 4,
tokens_reasoning: 2,
tokens_cache_read: 3,
tokens_cache_write: 1,
})
.run()
const boundary = SessionMessage.ID.make("msg_boundary")
const earlier = SessionMessage.ID.make("msg_earlier")
yield* db
@@ -205,7 +227,24 @@ describe("SessionProjector", () => {
it.effect("orders projected messages and context by durable aggregate sequence", () =>
Effect.gen(function* () {
yield* seedSession()
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
.pipe(Effect.orDie)
const bus = yield* Bus.Service
yield* bus.publish(SessionEvent.InboxEnqueued, {
@@ -261,7 +300,24 @@ describe("SessionProjector", () => {
it.effect("maps malformed persisted rows consistently while single-message lookup defects", () =>
Effect.gen(function* () {
const db = yield* seedSession()
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
.pipe(Effect.orDie)
const messageID = SessionMessage.ID.make("msg_malformed")
yield* db
.insert(SessionMessageTable)
@@ -288,7 +344,24 @@ describe("SessionProjector", () => {
it.effect("consumes the pending row and projects the message at promotion", () =>
Effect.gen(function* () {
const db = yield* seedSession()
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
.pipe(Effect.orDie)
const bus = yield* Bus.Service
const id = SessionMessage.ID.make("msg_admitted")
const admitted = yield* SessionInbox.admit(db, bus, {
@@ -314,7 +387,26 @@ describe("SessionProjector", () => {
it.effect("projects durable context messages supported by the updater", () =>
Effect.gen(function* () {
const db = yield* seedSession({ agent: "plan", model: previousModel })
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
agent: "plan",
model: previousModel,
})
.run()
.pipe(Effect.orDie)
const bus = yield* Bus.Service
yield* bus.publish(SessionEvent.AgentSelected, {
@@ -440,7 +532,24 @@ describe("SessionProjector", () => {
it.effect("rejects distinct creator events that reuse one projected message ID", () =>
Effect.gen(function* () {
const db = yield* seedSession()
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
.pipe(Effect.orDie)
const bus = yield* Bus.Service
const id = SessionMessage.ID.make("msg_creator_collision")
const { id: _, type, ...data } = encodeMessage({ id, type: "synthetic", text: "existing", time: { created } })
@@ -467,7 +576,24 @@ describe("SessionProjector", () => {
it.effect("projects retry state and clears it at the next step or execution terminal", () =>
Effect.gen(function* () {
const db = yield* seedSession()
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
.pipe(Effect.orDie)
const bus = yield* Bus.Service
const first = SessionMessage.ID.make("msg_retry_first")
const second = SessionMessage.ID.make("msg_retry_second")
@@ -517,7 +643,24 @@ describe("SessionProjector", () => {
it.effect("does not infer restart continuation from lifecycle history", () =>
Effect.gen(function* () {
const db = yield* seedSession()
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
.pipe(Effect.orDie)
const bus = yield* Bus.Service
const suspended = () =>
db
@@ -537,7 +680,24 @@ describe("SessionProjector", () => {
it.effect("updates only the newest incomplete assistant projection", () =>
Effect.gen(function* () {
const db = yield* seedSession()
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionMessageTable)
.values([
@@ -597,7 +757,24 @@ describe("SessionProjector", () => {
it.effect("projects ended and failed step terminal state", () =>
Effect.gen(function* () {
const db = yield* seedSession()
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
.pipe(Effect.orDie)
const endedID = SessionMessage.ID.make("msg_ended")
const failedID = SessionMessage.ID.make("msg_failed")
yield* db
@@ -667,7 +844,24 @@ describe("SessionProjector", () => {
it.effect("does not revive a stale incomplete assistant projection", () =>
Effect.gen(function* () {
const db = yield* seedSession()
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionMessageTable)
.values([
+1 -4
View File
@@ -83,10 +83,7 @@ const locations = Layer.effect(
}),
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)) }),
),
)
}),
@@ -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,
+121 -24
View File
@@ -198,20 +198,18 @@ beforeEach(() => {
titleStream = successfulTitle
})
const enableTitleAgent = Effect.gen(function* () {
const agents = yield* Agent.Service
yield* agents.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
})
it.effect("generates a title from the sole user message and renames the session", () =>
Effect.gen(function* () {
yield* enableTitleAgent
requests = []
titleStream = successfulTitle
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
const sessionID = Session.ID.make("ses_title_generate")
yield* insertSession(sessionID)
yield* prompt(sessionID, "Help me debug the failing build")
@@ -242,8 +240,17 @@ it.effect("generates a title from the sole user message and renames the session"
it.effect("uses a small model from the primary provider", () =>
Effect.gen(function* () {
requests = []
titleStream = successfulTitle
selectedSmall = small
yield* enableTitleAgent
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
const sessionID = Session.ID.make("ses_title_small_model")
yield* insertSession(sessionID)
yield* prompt(sessionID, "Use a small model for this title")
@@ -260,12 +267,20 @@ it.effect("uses a small model from the primary provider", () =>
it.effect("falls back to the primary model when the small model fails", () =>
Effect.gen(function* () {
requests = []
titleStream = () =>
requests.length === 1
? Stream.make(LLMEvent.providerError({ message: "Small model unavailable" }))
: successfulTitle()
selectedSmall = lowSmall
yield* enableTitleAgent
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
const sessionID = Session.ID.make("ses_title_small_fallback")
yield* insertSession(
sessionID,
@@ -299,7 +314,16 @@ it.effect("falls back to the primary model when the small model fails", () =>
it.effect("generates from the first user message after later messages exist", () =>
Effect.gen(function* () {
yield* enableTitleAgent
requests = []
titleStream = successfulTitle
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
const sessionID = Session.ID.make("ses_title_second_message")
yield* insertSession(sessionID)
yield* prompt(sessionID, "First message")
@@ -318,7 +342,16 @@ it.effect("generates from the first user message after later messages exist", ()
it.effect("retries a legacy persisted fallback title", () =>
Effect.gen(function* () {
yield* enableTitleAgent
requests = []
titleStream = successfulTitle
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
const sessionID = Session.ID.make("ses_title_legacy")
const created = Date.parse("2026-07-30T18:45:03.662Z")
yield* insertSession(sessionID, "New session - 2026-07-30T18:45:03.662Z", created)
@@ -335,7 +368,16 @@ it.effect("retries a legacy persisted fallback title", () =>
it.effect("generates a title for an explicitly requested child session", () =>
Effect.gen(function* () {
yield* enableTitleAgent
requests = []
titleStream = successfulTitle
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
const sessionID = Session.ID.make("ses_title_child")
const { db } = yield* Database.Service
yield* db
@@ -369,6 +411,8 @@ it.effect("generates a title for an explicitly requested child session", () =>
it.effect("does not generate when the title agent is removed", () =>
Effect.gen(function* () {
requests = []
titleStream = successfulTitle
const sessionID = Session.ID.make("ses_title_no_agent")
yield* insertSession(sessionID)
yield* prompt(sessionID, "Help me debug the failing build")
@@ -385,7 +429,14 @@ it.effect("does not generate when the title agent is removed", () =>
it.effect("regenerates an existing title using the title agent", () =>
Effect.gen(function* () {
yield* enableTitleAgent
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
const sessionID = Session.ID.make("ses_title_regenerate")
yield* insertSession(sessionID, "Original title")
yield* prompt(sessionID, "Investigate the login failure")
@@ -429,7 +480,14 @@ it.effect("regenerates an existing title using the title agent", () =>
it.effect("bounds regeneration context while preserving the original request and recent conversation", () =>
Effect.gen(function* () {
yield* enableTitleAgent
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
const sessionID = Session.ID.make("ses_title_regenerate_bounded")
yield* insertSession(sessionID, "Original title")
yield* prompt(sessionID, `ORIGINAL_GOAL ${"a".repeat(3_000)} OMITTED_ORIGINAL_END`)
@@ -451,7 +509,14 @@ it.effect("bounds regeneration context while preserving the original request and
it.effect("preserves the existing title when regeneration fails", () =>
Effect.gen(function* () {
yield* enableTitleAgent
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
const sessionID = Session.ID.make("ses_title_regenerate_failure")
yield* insertSession(sessionID, "Original title")
yield* prompt(sessionID, "Fail to regenerate this title")
@@ -468,7 +533,15 @@ it.effect("preserves the existing title when regeneration fails", () =>
it.effect("retries after a failed title request", () =>
Effect.gen(function* () {
yield* enableTitleAgent
requests = []
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
const sessionID = Session.ID.make("ses_title_retry")
yield* insertSession(sessionID)
yield* prompt(sessionID, "Retry this title")
@@ -487,7 +560,14 @@ it.effect("retries after a failed title request", () =>
it.effect("does not rename after a failed title stream", () =>
Effect.gen(function* () {
yield* enableTitleAgent
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
const sessionID = Session.ID.make("ses_title_stream_failure")
yield* insertSession(sessionID)
yield* prompt(sessionID, "Fail this title stream")
@@ -509,7 +589,16 @@ it.effect("does not rename after a failed title stream", () =>
it.effect("keeps session context hooks away from title requests", () =>
Effect.gen(function* () {
yield* enableTitleAgent
requests = []
titleStream = successfulTitle
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
// Context hooks shape the agent conversation; title generation is not part of
// it, so it opts out and the transcript passes through unchanged.
const hooks = yield* PluginHooks.Service
@@ -532,7 +621,15 @@ it.effect("keeps session context hooks away from title requests", () =>
it.effect("preserves a manual rename completed while generation is in flight", () =>
Effect.gen(function* () {
yield* enableTitleAgent
requests = []
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
const sessionID = Session.ID.make("ses_title_manual_rename")
yield* insertSession(sessionID)
yield* prompt(sessionID, "Generate this title")
+476 -84
View File
@@ -1,10 +1,18 @@
import { describe, expect } from "bun:test"
import { State } from "@opencode-ai/core/state"
import { Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect"
import { Cause, Deferred, Effect, Exit, Fiber, Scheduler, Scope } from "effect"
import { TestClock } from "effect/testing"
import { testEffect } from "./lib/effect"
import { it } from "./lib/effect"
const it = testEffect(Layer.empty)
function valuesState(
hooks: Pick<State.Options<{ values: string[] }, { add: (item: string) => void }>, "prepare" | "notify"> = {},
) {
return State.create({
initial: () => ({ values: new Array<string>() }),
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
...hooks,
})
}
describe("State", () => {
it.effect("commits a transform atomically when its updater is interrupted", () =>
@@ -12,17 +20,13 @@ describe("State", () => {
const rebuilding = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
let block = true
const state = State.create({
initial: () => ({ values: [] as string[] }),
draft: (draft) => ({ add: (value: string) => draft.values.push(value) }),
finalize: () =>
const state = valuesState({
notify: () =>
block ? Deferred.succeed(rebuilding, undefined).pipe(Effect.andThen(Deferred.await(release))) : Effect.void,
})
const scope = yield* Scope.make()
const fiber = yield* state
.transform((editor) => {
editor.add("registered")
})
.transform((editor) => editor.add("registered"))
.pipe(Scope.provide(scope), Effect.forkChild)
yield* Deferred.await(rebuilding)
const interruption = yield* Fiber.interrupt(fiber).pipe(Effect.forkChild)
@@ -36,20 +40,16 @@ describe("State", () => {
}),
)
it.effect("commits rebuilt state before finalize runs", () =>
it.effect("makes rebuilt state visible before notifying", () =>
Effect.gen(function* () {
const observed: string[][] = []
const state: State.Interface<{ values: string[] }, { add: (item: string) => void }> = State.create({
initial: () => ({ values: [] as string[] }),
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
finalize: () => Effect.sync(() => observed.push([...state.get().values])),
const state: ReturnType<typeof valuesState> = valuesState({
notify: () => Effect.sync(() => observed.push([...state.get().values])),
})
yield* state.transform((draft) => {
draft.add("value")
})
yield* state.transform((draft) => draft.add("value"))
// Update events publish from finalize, so consumers reading on the event
// Update events publish from notify, so consumers reading on the event
// must observe the rebuilt state, not the previous one.
expect(observed).toEqual([["value"]])
}),
@@ -58,14 +58,9 @@ describe("State", () => {
it.effect("runs transforms during every reload", () =>
Effect.gen(function* () {
let value = "first"
const state = State.create({
initial: () => ({ values: [] as string[] }),
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
})
const state = valuesState()
yield* state.transform((editor) => {
editor.add(value)
})
yield* state.transform((editor) => editor.add(value))
expect(state.get().values).toEqual(["first"])
value = "second"
@@ -76,18 +71,327 @@ describe("State", () => {
}),
)
it.effect("reads registrations and disposals inside a batch without publishing", () =>
Effect.gen(function* () {
const observed: string[][] = []
let replays = 0
const state: ReturnType<typeof valuesState> = valuesState({
notify: () => Effect.sync(() => observed.push([...state.get().values])),
})
const scope = yield* Scope.make()
yield* State.batch(
Effect.gen(function* () {
yield* state
.transform((draft) => {
replays++
draft.add("value")
})
.pipe(Scope.provide(scope))
const snapshot = state.get()
expect(snapshot.values).toEqual(["value"])
expect(state.get()).toBe(snapshot)
expect(replays).toBe(1)
expect(observed).toEqual([])
yield* Scope.close(scope, Exit.void)
expect(state.get().values).toEqual([])
expect(snapshot.values).toEqual(["value"])
expect(observed).toEqual([])
}),
)
expect(observed).toEqual([[]])
}),
)
it.effect("reads a requested reload without waiting for its notification debounce", () =>
Effect.gen(function* () {
let value = "first"
let replays = 0
const observed: string[][] = []
const state: ReturnType<typeof valuesState> = valuesState({
notify: () => Effect.sync(() => observed.push([...state.get().values])),
})
yield* state.transform((draft) => {
replays++
draft.add(value)
})
const snapshot = state.get()
observed.length = 0
value = "second"
const reload = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("50 millis")
expect(state.get().values).toEqual(["second"])
expect(snapshot.values).toEqual(["first"])
expect(replays).toBe(2)
expect(observed).toEqual([])
yield* TestClock.adjust("450 millis")
yield* Fiber.join(reload)
expect(observed).toEqual([["second"]])
expect(replays).toBe(2)
}),
)
it.effect("can await reload inside a batch while deferring its notification", () =>
Effect.gen(function* () {
let value = "first"
let notifications = 0
const state = valuesState({ notify: () => Effect.sync(() => notifications++) })
yield* State.batch(
Effect.gen(function* () {
yield* state.transform((draft) => draft.add(value))
expect(state.get().values).toEqual(["first"])
value = "second"
yield* state.reload()
expect(state.get().values).toEqual(["second"])
expect(notifications).toBe(0)
}),
)
expect(notifications).toBe(1)
}),
)
it.effect("prepares derived data during reads without running observers", () =>
Effect.gen(function* () {
let notifications = 0
const state = State.create({
initial: () => ({ values: [] as string[], joined: "" }),
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
prepare: (data) => {
data.joined = data.values.join(",")
},
notify: () => Effect.sync(() => notifications++),
})
yield* State.batch(
Effect.gen(function* () {
yield* state.transform((draft) => draft.add("first"))
yield* state.transform((draft) => draft.add("second"))
expect(state.get().joined).toBe("first,second")
expect(notifications).toBe(0)
}),
)
expect(notifications).toBe(1)
}),
)
it.effect("keeps replay failures observable without replacing the previous snapshot", () =>
Effect.gen(function* () {
let fail = false
const state = valuesState({
prepare: () => {
if (fail) throw new Error("preparation failed")
},
})
yield* state.transform((draft) => draft.add("first"))
const snapshot = state.get()
yield* State.batch(
Effect.gen(function* () {
yield* state.transform((draft) => draft.add("second"))
fail = true
expect(() => state.get()).toThrow("preparation failed")
expect(() => state.get()).toThrow("preparation failed")
expect(snapshot.values).toEqual(["first"])
fail = false
expect(state.get().values).toEqual(["first", "second"])
}),
)
}),
)
it.effect("allows an observer to await a registration on the same state", () =>
Effect.gen(function* () {
const scope = yield* Scope.Scope
let added = false
const observed: string[][] = []
const state: ReturnType<typeof valuesState> = valuesState({
notify: () =>
Effect.gen(function* () {
observed.push([...state.get().values])
if (added) return
added = true
yield* state.transform((draft) => draft.add("second")).pipe(Scope.provide(scope))
}),
})
yield* state.transform((draft) => draft.add("first"))
expect(observed).toEqual([["first"], ["first", "second"]])
expect(state.get().values).toEqual(["first", "second"])
}),
)
it.effect("allows a debounced observer to await another reload", () =>
Effect.gen(function* () {
let value = "first"
let reloadAgain = false
const observed: string[][] = []
const state: ReturnType<typeof valuesState> = valuesState({
notify: () =>
Effect.gen(function* () {
observed.push([...state.get().values])
if (!reloadAgain) return
reloadAgain = false
value = "third"
yield* state.reload()
}),
})
yield* state.transform((draft) => draft.add(value))
observed.length = 0
value = "second"
reloadAgain = true
const reload = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("1 second")
yield* Fiber.join(reload)
expect(observed).toEqual([["second"], ["third"]])
expect(state.get().values).toEqual(["third"])
}),
)
it.effect("keeps reload waiters associated with their own notification results", () =>
Effect.gen(function* () {
const entered = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
let value = "first"
let block = false
const observed: string[][] = []
const state: ReturnType<typeof valuesState> = valuesState({
notify: () =>
Effect.gen(function* () {
observed.push([...state.get().values])
if (!block) return
block = false
yield* Deferred.succeed(entered, undefined)
yield* Deferred.await(release)
return yield* Effect.die(new Error("first notification failed"))
}),
})
yield* state.transform((draft) => draft.add(value))
// Release the detached worker before the earlier registration finalizer runs.
yield* Effect.addFinalizer(() => Deferred.succeed(release, undefined))
observed.length = 0
value = "second"
block = true
const first = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Deferred.await(entered)
value = "third"
const second = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
expect(state.get().values).toEqual(["third"])
yield* TestClock.adjust("500 millis")
yield* Fiber.join(second)
expect(first.pollUnsafe()).toBeUndefined()
expect(observed).toEqual([["second"], ["third"]])
yield* Deferred.succeed(release, undefined)
const exit = yield* Fiber.await(first)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("first notification failed")
}),
)
it.effect("keeps coalesced reload callers independent of cancellation and shares their notification failure", () =>
Effect.gen(function* () {
let fail = false
let notifications = 0
const failure = new Error("notification failed")
const state = State.create({
initial: () => ({}),
draft: (draft) => draft,
notify: () =>
Effect.sync(() => {
notifications++
if (fail) throw failure
}),
})
yield* state.transform(() => {})
notifications = 0
fail = true
const cancelled = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
const first = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
const second = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* Fiber.interrupt(cancelled)
yield* TestClock.adjust("500 millis")
const exits = yield* Fiber.awaitAll([first, second])
fail = false
expect(Exit.hasInterrupts(yield* Fiber.await(cancelled))).toBe(true)
expect(exits.map((exit) => Exit.isFailure(exit) && Cause.squash(exit.cause))).toEqual([failure, failure])
expect(notifications).toBe(1)
const recovered = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Fiber.join(recovered)
expect(notifications).toBe(2)
}),
)
it.effect("continues publishing when a reload caller is cancelled while scheduling its worker", () =>
Effect.gen(function* () {
let value = "first"
let notifications = 0
let interrupted = false
const state = valuesState({ notify: () => Effect.sync(() => notifications++) })
yield* state.transform((draft) => draft.add(value))
notifications = 0
value = "second"
const cancelled = yield* Effect.withFiber((fiber) => {
const base = new Scheduler.MixedScheduler("sync")
const scheduler: Scheduler.Scheduler = {
executionMode: base.executionMode,
// Keep the first scheduled task at the detached worker handoff.
shouldYield: () => false,
makeDispatcher: () => {
const dispatcher = base.makeDispatcher()
return {
scheduleTask: (task, priority) => {
if (!interrupted) {
interrupted = true
fiber.interruptUnsafe()
}
dispatcher.scheduleTask(task, priority)
},
flush: () => dispatcher.flush(),
}
},
}
return state.reload().pipe(Effect.provideService(Scheduler.Scheduler, scheduler))
}).pipe(Effect.forkChild({ startImmediately: true }))
const exit = yield* Fiber.await(cancelled)
expect(interrupted).toBe(true)
expect(Exit.hasInterrupts(exit)).toBe(true)
expect(state.get().values).toEqual(["second"])
yield* TestClock.adjust("500 millis")
expect(notifications).toBe(1)
value = "third"
const reload = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Fiber.join(reload)
expect(state.get().values).toEqual(["third"])
expect(notifications).toBe(2)
}),
)
it.effect("disposes a transform once and rebuilds remaining state", () =>
Effect.gen(function* () {
const state = State.create({
initial: () => ({ values: [] as string[] }),
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
})
yield* state.transform((editor) => {
editor.add("first")
})
const registration = yield* state.transform((editor) => {
editor.add("second")
})
const state = valuesState()
yield* state.transform((editor) => editor.add("first"))
const registration = yield* state.transform((editor) => editor.add("second"))
expect(state.get().values).toEqual(["first", "second"])
yield* registration.dispose
@@ -98,49 +402,137 @@ describe("State", () => {
}),
)
it.effect("batches automatic rebuilds", () =>
it.effect("batches notifications", () =>
Effect.gen(function* () {
let finalized = 0
const first = State.create({
initial: () => ({ values: [] as string[] }),
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
finalize: () => Effect.sync(() => finalized++),
})
const second = State.create({
initial: () => ({ values: [] as string[] }),
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
finalize: () => Effect.sync(() => finalized++),
})
let notifications = 0
const first = valuesState({ notify: () => Effect.sync(() => notifications++) })
const second = valuesState({ notify: () => Effect.sync(() => notifications++) })
yield* State.batch(
Effect.gen(function* () {
yield* first.transform((draft) => {
draft.add("first")
})
yield* first.transform((draft) => {
draft.add("second")
})
yield* second.transform((draft) => {
draft.add("third")
})
expect(finalized).toBe(0)
yield* first.transform((draft) => draft.add("first"))
yield* first.transform((draft) => draft.add("second"))
yield* second.transform((draft) => draft.add("third"))
expect(notifications).toBe(0)
}),
)
expect(first.get().values).toEqual(["first", "second"])
expect(second.get().values).toEqual(["third"])
expect(finalized).toBe(2)
expect(notifications).toBe(2)
}),
)
it.effect("closes a batched observer's owning scope without losing the body's failure", () =>
Effect.gen(function* () {
const entered = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const registrations = yield* Scope.make()
const owner = yield* Scope.make()
yield* Effect.addFinalizer(() =>
Deferred.succeed(release, undefined).pipe(
Effect.andThen(Scope.close(owner, Exit.void)),
Effect.andThen(State.batch(Scope.close(registrations, Exit.void), { flush: false })),
),
)
const state = State.create({
initial: () => ({}),
draft: (draft) => draft,
notify: () => Deferred.succeed(entered, undefined).pipe(Effect.andThen(Deferred.await(release))),
})
const writer = yield* State.batch(
state.transform(() => {}).pipe(Scope.provide(registrations), Effect.andThen(Effect.fail("batch body failed"))),
).pipe(Effect.forkIn(owner, { startImmediately: true }))
yield* Deferred.await(entered)
const shutdown = yield* Scope.close(owner, Exit.void).pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("1 millis")
expect(shutdown.pollUnsafe()).toBeDefined()
expect(yield* Deferred.isDone(release)).toBe(false)
const exit = yield* Fiber.await(writer)
expect(Exit.hasInterrupts(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("batch body failed")
}),
)
it.effect("lets batch observers read the other states' accepted changes", () =>
Effect.gen(function* () {
const observed: string[][] = []
const first = valuesState({
notify: () => Effect.sync(() => observed.push([...second.get().values])),
})
const second = valuesState()
yield* State.batch(
Effect.gen(function* () {
yield* first.transform((draft) => draft.add("first"))
yield* second.transform((draft) => draft.add("second"))
}),
)
expect(observed).toEqual([["second"]])
}),
)
;["replay", "notification"].forEach((failure) =>
it.effect(`notifies the other states when a batch ${failure} fails`, () =>
Effect.gen(function* () {
let fail = true
const observed: string[] = []
const first = State.create({
initial: () => ({}),
draft: (draft) => draft,
notify: () => Effect.sync(() => observed.push("first")),
})
const failing = State.create({
initial: () => ({}),
draft: (draft) => draft,
prepare: () => {
if (fail && failure === "replay") throw new Error("replay failed")
},
notify: () =>
fail ? Effect.die(new Error("notification failed")) : Effect.sync(() => observed.push("failing")),
})
const last = State.create({
initial: () => ({}),
draft: (draft) => draft,
notify: () => Effect.sync(() => observed.push("last")),
})
const exit = yield* State.batch(
Effect.gen(function* () {
yield* first.transform(() => {})
yield* failing.transform(() => {})
yield* last.transform(() => {})
return yield* Effect.die(new Error("batch failed"))
}),
).pipe(Effect.exit)
fail = false
expect(observed).toEqual(["first", "last"])
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
expect(Cause.pretty(exit.cause)).toContain("batch failed")
expect(Cause.pretty(exit.cause)).toContain(`${failure} failed`)
}
const reload = yield* failing.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Fiber.join(reload)
expect(observed).toEqual(["first", "last", "failing"])
}),
),
)
it.effect("discards teardown rebuilds and pending reloads while still running cleanup", () =>
Effect.gen(function* () {
let finalized = 0
let notifications = 0
let prepared = 0
let disposed = 0
const state = State.create({
initial: () => ({ values: [] as string[] }),
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
finalize: () => Effect.sync(() => finalized++),
const state = valuesState({
prepare: () => {
prepared++
},
notify: () => Effect.sync(() => notifications++),
})
const scope = yield* Scope.make()
yield* Scope.addFinalizer(
@@ -148,38 +540,44 @@ describe("State", () => {
Effect.sync(() => disposed++),
)
const registration = yield* state.transform((draft) => draft.add("value")).pipe(Scope.provide(scope))
expect(finalized).toBe(1)
const snapshot = state.get()
expect(notifications).toBe(1)
expect(prepared).toBe(1)
const pending = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("250 millis")
yield* State.batch(Scope.close(scope, Exit.void), { flush: false })
expect(disposed).toBe(1)
expect(finalized).toBe(1)
expect(notifications).toBe(1)
expect(state.get()).toBe(snapshot)
expect(prepared).toBe(1)
yield* TestClock.adjust("500 millis")
yield* Fiber.join(pending)
yield* registration.dispose
yield* state.reload()
expect(finalized).toBe(1)
expect(notifications).toBe(1)
expect(state.get()).toBe(snapshot)
expect(prepared).toBe(1)
}),
)
it.effect("keeps teardown suppression separate from an enclosing live batch", () =>
Effect.gen(function* () {
const finalized: string[] = []
const notifications: string[] = []
const closing = State.create({
initial: () => ({}),
draft: (draft) => draft,
finalize: () => Effect.sync(() => finalized.push("closing")),
notify: () => Effect.sync(() => notifications.push("closing")),
})
const live = State.create({
initial: () => ({}),
draft: (draft) => draft,
finalize: () => Effect.sync(() => finalized.push("live")),
notify: () => Effect.sync(() => notifications.push("live")),
})
const scope = yield* Scope.make()
yield* closing.transform(() => {}).pipe(Scope.provide(scope))
finalized.length = 0
notifications.length = 0
yield* State.batch(
Effect.gen(function* () {
@@ -187,33 +585,27 @@ describe("State", () => {
yield* State.batch(Scope.close(scope, Exit.void), { flush: false })
}),
)
expect(finalized).toEqual(["live"])
expect(notifications).toEqual(["live"])
}),
)
it.effect("debounces reload bursts", () =>
Effect.gen(function* () {
let finalized = 0
const state = State.create({
initial: () => ({ values: [] as string[] }),
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
finalize: () => Effect.sync(() => finalized++),
})
yield* state.transform((draft) => {
draft.add("value")
})
finalized = 0
let notifications = 0
const state = valuesState({ notify: () => Effect.sync(() => notifications++) })
yield* state.transform((draft) => draft.add("value"))
notifications = 0
const first = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("250 millis")
const second = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("499 millis")
expect(finalized).toBe(0)
expect(notifications).toBe(0)
yield* TestClock.adjust("1 millis")
yield* Fiber.join(first)
yield* Fiber.join(second)
expect(finalized).toBe(1)
expect(notifications).toBe(1)
}),
)
})
+28 -21
View File
@@ -311,7 +311,7 @@ describe("Tool", () => {
}),
)
it.effect("replays empty sources on reload and keeps advertised snapshots", () =>
it.effect("reads refreshed sources before notifications and keeps advertised snapshots", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
let source: Info[] = []
@@ -321,24 +321,24 @@ describe("Tool", () => {
const tool = { ...constant("first"), name: "echo", options: { codemode: false } }
source = [tool]
const first = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Fiber.join(first)
const advertised = yield* service.snapshot()
expect((yield* advertised.execute(call("echo"))).output).toEqual({ text: "first" })
yield* TestClock.adjust("500 millis")
yield* Fiber.join(first)
tool.execute = constant("second").execute
expect((yield* advertised.execute(call("echo"))).output).toEqual({ text: "first" })
const second = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
expect((yield* executeTool(service, call("echo"))).output).toEqual({ text: "second" })
yield* TestClock.adjust("500 millis")
yield* Fiber.join(second)
expect((yield* executeTool(service, call("echo"))).output).toEqual({ text: "second" })
expect((yield* advertised.execute(call("echo"))).output).toEqual({ text: "first" })
source = []
const removed = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
yield* TestClock.adjust("500 millis")
yield* Fiber.join(removed)
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
expect((yield* advertised.execute(call("echo"))).output).toEqual({ text: "first" })
}),
)
@@ -370,7 +370,7 @@ describe("Tool", () => {
}),
)
it.effect("batches tool publication and suppresses terminal teardown replay", () =>
it.effect("batches tool notifications with fresh snapshots and suppresses terminal teardown replay", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
const runs: string[] = []
@@ -386,7 +386,8 @@ describe("Tool", () => {
draft.add({ ...constant("overlay"), name: "echo", options: { codemode: false } })
})
expect(runs).toEqual([])
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["echo", "execute"])
expect(runs).toEqual(["base", "overlay"])
}).pipe(Scope.provide(scope)),
)
@@ -545,23 +546,32 @@ describe("Tool", () => {
}),
)
it.effect("logs invalid tool definitions without dropping healthy tools", () => {
it.effect("compiles healthy tools before notifying invalid definition diagnostics", () => {
const output: unknown[] = []
const logger = Logger.map(Logger.formatStructured, (entry) => {
output.push(entry.message)
})
return Effect.gen(function* () {
const service = yield* Tool.Service
yield* service.transform((draft) => {
draft.add({ ...make(), name: "healthy", options: { codemode: false } })
draft.add({
name: "phone_type",
input: Schema.Struct({}),
execute: () => Effect.succeed({ content: "ok" }),
options: { codemode: false },
} as unknown as Info)
draft.add({ ...make(), name: "codemode" })
})
const snapshot = yield* State.batch(
Effect.gen(function* () {
yield* service.transform((draft) => {
draft.add({ ...make(), name: "healthy", options: { codemode: false } })
draft.add({
name: "phone_type",
input: Schema.Struct({}),
execute: () => Effect.succeed({ content: "ok" }),
options: { codemode: false },
} as unknown as Info)
draft.add({ ...make(), name: "codemode" })
})
const snapshot = yield* service.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["healthy", "execute"])
expect(snapshot.codeModeCatalog?.map((tool) => tool.path)).toEqual(["codemode"])
expect(output).toEqual([])
return snapshot
}),
)
expect(output).toEqual([
[
@@ -573,9 +583,6 @@ describe("Tool", () => {
},
],
])
const snapshot = yield* service.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["healthy", "execute"])
expect(snapshot.codeModeCatalog?.map((tool) => tool.path)).toEqual(["codemode"])
expect((yield* snapshot.execute(call("phone_type")).pipe(Effect.flip)).message).toBe("Unknown tool: phone_type")
}).pipe(Effect.provide(Logger.layer([logger])))
})
+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],
})
+371 -90
View File
@@ -2,35 +2,40 @@ import { $ } from "bun"
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Cause, Effect, Exit, Fiber, Layer, Stream } from "effect"
import { Cause, Context, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, Scope, Stream } from "effect"
import { TestClock } from "effect/testing"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { AppProcess } from "@opencode-ai/util/process"
import { Bus } from "@opencode-ai/core/bus"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { State } from "@opencode-ai/core/state"
import { Vcs } from "@opencode-ai/core/vcs"
import { VcsGitPlugin } from "@opencode-ai/core/plugin/vcs/git"
import type { VcsDefinition, VcsDiffInput } from "@opencode-ai/plugin/effect/vcs"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { VcsEvent } from "@opencode-ai/schema/vcs-event"
import { location } from "./fixture/location"
import { locationLayer } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { it } from "./lib/effect"
import { it, testEffect } from "./lib/effect"
import { host } from "./plugin/host"
const Done = Bus.ephemeral({ type: "test.vcs.done", schema: {} })
const synthetic = testEffect(
LayerNode.compile(LayerNode.group([Vcs.node, Bus.node, Location.node]), [
[Location.node, locationLayer({ directory: AbsolutePath.make(import.meta.dir) })],
]),
)
const provide = (directory: string, input: { git?: boolean } = {}) =>
Effect.provide(
LayerNode.compile(LayerNode.group([Vcs.node, Bus.node, Location.node, AppProcess.node]), [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: AbsolutePath.make(directory) },
input.git ? { vcs: { type: "git", store: AbsolutePath.make(path.join(directory, ".git")) } } : {},
),
),
locationLayer(
{ directory: AbsolutePath.make(directory) },
input.git ? { vcs: { type: "git", store: AbsolutePath.make(path.join(directory, ".git")) } } : {},
),
],
]),
@@ -84,40 +89,36 @@ const provider = (input: Partial<VcsDefinition> = {}) =>
}) satisfies VcsDefinition
describe("Vcs", () => {
it.live("returns empty results outside version control", () =>
withTmp((directory) =>
Effect.gen(function* () {
const vcs = yield* Vcs.Service
expect(yield* vcs.info()).toEqual({ branch: {} })
expect(yield* vcs.branches()).toEqual([])
expect(yield* vcs.status()).toEqual([])
expect(yield* vcs.diff("working")).toEqual([])
expect(yield* vcs.diff("branch")).toEqual([])
}).pipe(provide(directory)),
),
synthetic.effect("returns empty results outside version control", () =>
Effect.gen(function* () {
const vcs = yield* Vcs.Service
expect(yield* vcs.info()).toEqual({ branch: {} })
expect(yield* vcs.branches()).toEqual([])
expect(yield* vcs.status()).toEqual([])
expect(yield* vcs.diff("working")).toEqual([])
expect(yield* vcs.diff("branch")).toEqual([])
}),
)
it.live("serves scoped providers and restores the fallback after disposal", () =>
withTmp((directory) =>
Effect.gen(function* () {
const vcs = yield* Vcs.Service
const registration = yield* vcs.transform((draft) => {
draft.add(provider())
draft.default.set("custom")
})
synthetic.effect("serves scoped providers and restores the fallback after disposal", () =>
Effect.gen(function* () {
const vcs = yield* Vcs.Service
const registration = yield* vcs.transform((draft) => {
draft.add(provider())
draft.default.set("custom")
})
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "main" } })
expect(yield* vcs.branches()).toEqual(["feature", "main"])
expect(yield* vcs.status()).toEqual([{ file: "file.txt", additions: 1, deletions: 0, status: "added" }])
expect(yield* vcs.diff("working")).toEqual([
{ file: "file.txt", patch: "+hello", additions: 1, deletions: 0, status: "added" },
])
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "main" } })
expect(yield* vcs.branches()).toEqual(["feature", "main"])
expect(yield* vcs.status()).toEqual([{ file: "file.txt", additions: 1, deletions: 0, status: "added" }])
expect(yield* vcs.diff("working")).toEqual([
{ file: "file.txt", patch: "+hello", additions: 1, deletions: 0, status: "added" },
])
yield* registration.dispose
expect(yield* vcs.info()).toEqual({ branch: {} })
expect(yield* vcs.status()).toEqual([])
}).pipe(provide(directory)),
),
yield* registration.dispose
expect(yield* vcs.info()).toEqual({ branch: {} })
expect(yield* vcs.status()).toEqual([])
}),
)
it.live("automatically selects a provider matching the resolved repository", () =>
@@ -133,80 +134,360 @@ describe("Vcs", () => {
),
)
it.live("passes location scope and bounded diff options to providers", () =>
withTmp((directory) =>
synthetic.effect("passes location scope and bounded diff options to providers", () =>
Effect.gen(function* () {
const observed: VcsDiffInput[] = []
const vcs = yield* Vcs.Service
const location = yield* Location.Service
yield* vcs.transform((draft) => {
draft.add(
provider({
diff: (input) =>
Effect.sync(() => {
observed.push(input)
return [{ file: "file.txt", patch: "+hello", additions: 1, deletions: 0, status: "added" }]
}),
}),
)
draft.default.set("custom")
})
yield* vcs.diff("branch", { context: 3 })
expect(observed).toEqual([
{
directory: location.directory,
worktree: location.directory,
canonical: location.directory,
mode: "branch",
context: 3,
maxOutputBytes: 10_000_000,
},
])
}),
)
synthetic.effect("validates provider results and bounds oversized patches", () =>
Effect.gen(function* () {
const vcs = yield* Vcs.Service
yield* vcs.transform((draft) => {
draft.add(
provider({
status: () => Effect.succeed([{ file: "file.txt", additions: -1, deletions: 0, status: "added" }]),
diff: () =>
Effect.succeed([
{ file: "file.txt", patch: "x".repeat(10_000_001), additions: 1, deletions: 0, status: "added" },
]),
}),
)
draft.default.set("custom")
})
expect(yield* vcs.status()).toEqual([])
const rows = yield* vcs.diff("working")
expect(rows).toHaveLength(1)
expect(Buffer.byteLength(rows[0].patch)).toBeLessThan(1000)
expect(rows[0].additions).toBe(1)
}),
)
synthetic.effect("preserves provider interruption", () =>
Effect.gen(function* () {
const vcs = yield* Vcs.Service
let interrupt = false
yield* vcs.transform((draft) => {
draft.add(
provider({
info: () =>
interrupt ? Effect.interrupt : Effect.succeed({ branch: { current: "feature", default: "main" } }),
status: () => Effect.never,
}),
)
draft.default.set("custom")
})
const fiber = yield* Effect.forkChild(vcs.status())
yield* Fiber.interrupt(fiber)
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause)).toBeTrue()
interrupt = true
const reload = yield* vcs.reload().pipe(Effect.timeout("1 second"), Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("1 second")
const reloaded = yield* Fiber.await(reload)
expect(Exit.isFailure(reloaded) && Cause.hasInterruptsOnly(reloaded.cause)).toBeTrue()
}),
)
it.live("keeps watching HEAD changes after a transform replay failure", () =>
withGit((directory) =>
Effect.gen(function* () {
const observed: VcsDiffInput[] = []
const vcs = yield* Vcs.Service
yield* vcs.transform((draft) => {
const bus = yield* Bus.Service
const replayed = yield* Deferred.make<void>()
const faulty = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(faulty, Exit.void))
let branch = "initial"
yield* vcs.transform((draft) =>
draft.add(provider({ id: "git", info: () => Effect.sync(() => ({ branch: { current: branch } })) })),
)
const failure = new Error("fixture replay failed")
let replays = 0
const failed = yield* vcs
.transform(() => {
if (++replays === 2) Deferred.doneUnsafe(replayed, Exit.void)
throw failure
})
.pipe(Scope.provide(faulty), Effect.exit)
expect(Exit.isFailure(failed) && Cause.squash(failed.cause)).toBe(failure)
yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, ".git", "HEAD"), event: "change" })
yield* Deferred.await(replayed).pipe(Effect.timeout("1 second"))
yield* Effect.yieldNow
const status = yield* vcs.status().pipe(Effect.exit)
expect(Exit.isFailure(status) && Cause.squash(status.cause)).toBe(failure)
expect((yield* vcs.info()).branch.current).toBe("initial")
branch = "recovered"
yield* Scope.close(faulty, Exit.void)
expect((yield* vcs.info()).branch.current).toBe("recovered")
const updated = yield* bus
.subscribe(VcsEvent.BranchUpdated)
.pipe(Stream.runHead, Effect.timeout("1 second"), Effect.forkScoped({ startImmediately: true }))
branch = "after-recovery"
yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, ".git", "HEAD"), event: "change" })
const event = yield* Fiber.join(updated)
expect((yield* vcs.info()).branch.current).toBe("after-recovery")
expect(Option.getOrUndefined(event)).toMatchObject({ data: { branch: "after-recovery" } })
}),
),
)
it.effect("stops in-flight and queued reloads when its layer closes", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const entered = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const root = yield* Scope.make()
yield* Effect.addFinalizer(() =>
Deferred.succeed(release, undefined).pipe(
Effect.andThen(State.batch(Scope.close(root, Exit.void), { flush: false })),
Effect.andThen(TestClock.adjust("500 millis")),
),
)
const context = yield* Layer.buildWithScope(
LayerNode.compile(Vcs.node, [
[Bus.node, Layer.succeed(Bus.Service, bus)],
[Location.node, locationLayer({ directory: AbsolutePath.make(import.meta.dir) })],
]),
root,
)
const vcs = Context.get(context, Vcs.Service)
const reads: string[] = []
const observed: (string | undefined)[] = []
yield* Effect.acquireRelease(
bus.listen((event) =>
Effect.sync(() => {
if (event.type !== VcsEvent.BranchUpdated.type) return
observed.push(Schema.decodeUnknownSync(VcsEvent.BranchUpdated.data)(event.data).branch)
}),
),
(unsubscribe) => unsubscribe,
)
let branch = "initial"
let block = false
yield* vcs
.transform((draft) => {
draft.add(
provider({
diff: (input) =>
Effect.sync(() => {
observed.push(input)
return [{ file: "file.txt", patch: "+hello", additions: 1, deletions: 0, status: "added" }]
info: () =>
Effect.gen(function* () {
const value = branch
reads.push(value)
if (block) {
block = false
yield* Deferred.succeed(entered, undefined)
yield* Deferred.await(release)
}
return { branch: { current: value } }
}),
}),
)
draft.default.set("custom")
})
.pipe(Scope.provide(root))
observed.length = 0
yield* vcs.diff("branch", { context: 3 })
expect(observed).toEqual([
{
directory,
worktree: directory,
canonical: directory,
mode: "branch",
context: 3,
maxOutputBytes: 10_000_000,
},
])
}).pipe(provide(directory)),
),
block = true
const first = yield* vcs.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Deferred.await(entered).pipe(Effect.timeout("1 second"), TestClock.withLive)
branch = "late"
const second = yield* vcs.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Effect.yieldNow
expect(reads).toEqual(["initial", "initial"])
expect(first.pollUnsafe()).toBeUndefined()
expect(second.pollUnsafe()).toBeUndefined()
const snapshot = yield* vcs.info()
const shutdown = yield* State.batch(Scope.close(root, Exit.void), { flush: false }).pipe(
Effect.forkChild({ startImmediately: true }),
)
yield* TestClock.adjust("1 millis")
expect(shutdown.pollUnsafe()).toBeDefined()
expect(first.pollUnsafe()).toBeDefined()
expect(second.pollUnsafe()).toBeDefined()
expect(yield* Deferred.isDone(release)).toBe(false)
yield* Fiber.join(shutdown)
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(first)
yield* Fiber.join(second)
expect(reads).toEqual(["initial", "initial"])
expect(observed).toEqual([])
expect(yield* vcs.info()).toBe(snapshot)
}).pipe(Effect.provide(LayerNode.compile(Bus.node))),
)
it.live("validates provider results and bounds oversized patches", () =>
withTmp((directory) =>
it.live("serializes filesystem and config refreshes while reading the latest desired provider", () =>
withGit((directory) =>
Effect.gen(function* () {
const vcs = yield* Vcs.Service
yield* vcs.transform((draft) => {
const bus = yield* Bus.Service
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const accepted = yield* Deferred.make<void>()
const reads: string[] = []
yield* vcs.transform((draft) =>
draft.add(
provider({
status: () => Effect.succeed([{ file: "file.txt", additions: -1, deletions: 0, status: "added" }]),
diff: () =>
Effect.succeed([
{ file: "file.txt", patch: "x".repeat(10_000_001), additions: 1, deletions: 0, status: "added" },
]),
id: "git",
info: () =>
Effect.gen(function* () {
reads.push(reads.length === 0 ? "initial" : "filesystem")
if (reads.length === 1) return { branch: { current: "initial" } }
yield* Deferred.succeed(started, undefined)
yield* Deferred.await(release)
return { branch: { current: "filesystem" } }
}),
}),
)
draft.default.set("custom")
})
),
)
const updates = yield* bus
.subscribe(VcsEvent.BranchUpdated)
.pipe(Stream.take(2), Stream.runLast, Effect.forkScoped({ startImmediately: true }))
expect(yield* vcs.status()).toEqual([])
const rows = yield* vcs.diff("working")
expect(rows).toHaveLength(1)
expect(Buffer.byteLength(rows[0].patch)).toBeLessThan(1000)
expect(rows[0].additions).toBe(1)
}).pipe(provide(directory)),
yield* Effect.gen(function* () {
yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, ".git", "HEAD"), event: "change" })
yield* Deferred.await(started)
const configured = yield* State.batch(
Effect.gen(function* () {
yield* vcs.transform((draft) =>
draft.add(
provider({
id: "git",
info: () =>
Effect.sync(() => {
reads.push("config")
return { branch: { current: "config" } }
}),
status: () => Effect.succeed([{ file: "config.txt", additions: 1, deletions: 0, status: "added" }]),
}),
),
)
expect((yield* vcs.status())[0]?.file).toBe("config.txt")
expect(yield* vcs.info()).toEqual({ branch: { current: "initial" } })
yield* Deferred.succeed(accepted, undefined)
}),
).pipe(Effect.forkScoped({ startImmediately: true }))
yield* Deferred.await(accepted)
expect(reads).toEqual(["initial", "filesystem"])
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(configured)
expect(Option.getOrUndefined(yield* Fiber.join(updates))?.data.branch).toBe("config")
expect(yield* vcs.info()).toEqual({ branch: { current: "config" } })
expect(reads).toEqual(["initial", "filesystem", "config"])
}).pipe(Effect.ensuring(Deferred.succeed(release, undefined)))
}),
),
)
it.live("preserves provider interruption", () =>
withTmp((directory) =>
Effect.gen(function* () {
const vcs = yield* Vcs.Service
synthetic.effect("keeps branch streams current when listeners change the selected provider", () =>
Effect.gen(function* () {
const vcs = yield* Vcs.Service
const bus = yield* Bus.Service
const scope = yield* Effect.scope
const updates = yield* bus.subscribe([VcsEvent.BranchUpdated, Done]).pipe(
Stream.takeUntil((event) => event.type === Done.type),
Stream.runCollect,
Effect.forkScoped({ startImmediately: true }),
)
const unsubscribe = yield* bus.listen((event) => {
if (
event.type !== VcsEvent.BranchUpdated.type ||
Schema.decodeUnknownSync(VcsEvent.BranchUpdated.data)(event.data).branch !== "feature"
)
return Effect.void
return vcs
.transform((draft) =>
draft.add(provider({ info: () => Effect.succeed({ branch: { current: "listener" } }) })),
)
.pipe(Scope.provide(scope), Effect.asVoid)
})
yield* Effect.gen(function* () {
yield* vcs.transform((draft) => {
draft.add(provider({ status: () => Effect.never }))
draft.add(provider())
draft.default.set("custom")
})
yield* bus.publish(Done, {})
const events = (yield* Fiber.join(updates)).filter((event) => event.type === VcsEvent.BranchUpdated.type)
expect(yield* vcs.info()).toEqual({ branch: { current: "listener" } })
expect(events.length).toBeGreaterThanOrEqual(2)
expect(events.at(-1)?.data.branch).toBe((yield* vcs.info()).branch.current)
}).pipe(Effect.ensuring(unsubscribe))
}),
)
const fiber = yield* Effect.forkChild(vcs.status())
yield* Fiber.interrupt(fiber)
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause)).toBeTrue()
}).pipe(provide(directory)),
),
synthetic.effect("does not roll back branch streams when an older listener finishes late", () =>
Effect.gen(function* () {
const vcs = yield* Vcs.Service
const bus = yield* Bus.Service
const entered = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const updates = yield* bus.subscribe([VcsEvent.BranchUpdated, Done]).pipe(
Stream.takeUntil((event) => event.type === Done.type),
Stream.runCollect,
Effect.forkScoped({ startImmediately: true }),
)
const unsubscribe = yield* bus.listen((event) =>
event.type === VcsEvent.BranchUpdated.type &&
Schema.decodeUnknownSync(VcsEvent.BranchUpdated.data)(event.data).branch === "older"
? Deferred.succeed(entered, undefined).pipe(Effect.andThen(Deferred.await(release)))
: Effect.void,
)
yield* Effect.gen(function* () {
const older = yield* vcs
.transform((draft) => {
draft.add(provider({ info: () => Effect.succeed({ branch: { current: "older" } }) }))
draft.default.set("custom")
})
.pipe(Effect.forkScoped({ startImmediately: true }))
yield* Deferred.await(entered)
yield* vcs.transform((draft) =>
draft.add(provider({ info: () => Effect.succeed({ branch: { current: "newer" } }) })),
)
expect(older.pollUnsafe()).toBeUndefined()
expect((yield* vcs.info()).branch.current).toBe("newer")
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(older)
yield* bus.publish(Done, {})
const events = (yield* Fiber.join(updates)).filter((event) => event.type === VcsEvent.BranchUpdated.type)
expect(events.length).toBeGreaterThanOrEqual(2)
expect(events.at(-1)?.data.branch).toBe((yield* vcs.info()).branch.current)
}).pipe(Effect.ensuring(Deferred.succeed(release, undefined).pipe(Effect.andThen(unsubscribe))))
}),
)
it.live("lists local branches by recent activity", () =>
-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()))
}),
),
)
+64 -2
View File
@@ -1,14 +1,27 @@
import { describe, expect, test } from "bun:test"
import { Agent } from "@opencode-ai/core/agent"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Vcs } from "@opencode-ai/core/vcs"
import { Credential } from "@opencode-ai/schema/credential"
import { Event } from "@opencode-ai/schema/event"
import { IntegrationID } from "@opencode-ai/schema/integration-id"
import { Deferred, Effect, Exit, Fiber, Option, Schema, Stream } from "effect"
import { it } from "../../core/test/lib/effect"
import { VcsEvent } from "@opencode-ai/schema/vcs-event"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Deferred, Effect, Exit, Fiber, Option, Schema, Scope, Stream } from "effect"
import { locationLayer } from "../../core/test/fixture/location"
import { it, testEffect } from "../../core/test/lib/effect"
import { EventFeed } from "../src/event-feed"
const Internal = Bus.ephemeral({ type: "test.internal", schema: { value: Schema.String } })
const vcsIt = testEffect(
LayerNode.compile(LayerNode.group([Vcs.node, Bus.node]), [
[Location.node, locationLayer({ directory: AbsolutePath.make(import.meta.dir) })],
[Database.node, Database.configured({ path: ":memory:" })],
]),
)
const event = (id: string): Event.Payload<typeof Agent.Event.Updated> => ({
id: Event.ID.make(`evt_${id}`),
@@ -44,6 +57,55 @@ describe("EventFeed", () => {
expect(EventFeed.frame(payload)).toBe(`data: ${JSON.stringify(payload)}\n\n`)
})
vcsIt.effect("delivers the latest VCS branch after an earlier legacy listener reenters", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const vcs = yield* Vcs.Service
const scope = yield* Scope.Scope
const provider = {
id: "fixture",
name: "Fixture",
info: () => Effect.succeed({ branch: { current: "outer" } }),
branches: () => Effect.succeed([]),
status: () => Effect.succeed([]),
diff: () => Effect.succeed([]),
}
const unsubscribe = yield* bus.listen((event) =>
event.type === VcsEvent.BranchUpdated.type &&
Schema.decodeUnknownSync(VcsEvent.BranchUpdated.data)(event.data).branch === "outer"
? vcs
.transform((draft) =>
draft.add({ ...provider, info: () => Effect.succeed({ branch: { current: "inner" } }) }),
)
.pipe(Scope.provide(scope), Effect.asVoid)
: Effect.void,
)
// Unsubscribe before registration teardown can restore "outer" and reenter the listener.
yield* Effect.gen(function* () {
const feed = yield* EventFeed.make(bus.listen, {
encode: (event) => (event.type === VcsEvent.BranchUpdated.type ? (event.data.branch ?? "none") : event.type),
})
const stream = yield* feed.subscribe
const received = yield* stream.pipe(
Stream.takeUntil((frame) => frame === Agent.Event.Updated.type, { excludeLast: true }),
Stream.runLast,
Effect.forkScoped({ startImmediately: true }),
)
yield* vcs.transform((draft) => {
draft.add(provider)
draft.default.set(provider.id)
})
yield* unsubscribe
yield* bus.publish(Agent.Event.Updated, {})
const info = yield* vcs.info()
expect(info.branch.current).toBe("inner")
expect(Option.getOrUndefined(yield* Fiber.join(received))).toBe(info.branch.current)
}).pipe(Effect.ensuring(unsubscribe))
}),
)
it.effect("encodes once and delivers the same frame to every subscriber", () =>
Effect.gen(function* () {
let encodes = 0
@@ -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)
}),
)
+9 -24
View File
@@ -41,7 +41,6 @@ import { DialogSessionRename } from "./dialog-session-rename"
import { Keymap } from "../context/keymap"
import { registerOpencodeSpinner } from "./register-spinner"
import { SPINNER_FRAMES } from "./spinner-frames"
import "./title-shimmer"
registerOpencodeSpinner()
@@ -97,7 +96,6 @@ export const EMPTY_SESSION_TAB_STATUS: SessionTabsStatus = {
promptPulse: 0,
attention: false,
busy: false,
renaming: false,
}
export type SessionTabsController = Pick<ContextController, "tabs" | "current" | "select" | "close" | "move"> & {
newTab?: () => boolean
@@ -650,7 +648,7 @@ function VerticalSessionTabs(props: {
const restingTitleWidth = () => Math.max(1, width() - numberWidth() - 2)
const hoveredTitleWidth = () => Math.max(1, restingTitleWidth() - 1)
const titleWidth = () => (hovered() === tab.sessionID ? hoveredTitleWidth() : restingTitleWidth())
const title = () => (props.controller ? undefined : session()?.title) ?? tab.title ?? "Untitled session"
const title = () => tab.title ?? "Untitled session"
const scrolling = () => marquee.active() === tab.sessionID
const visibleTitleParts = createMemo(() =>
scrolling()
@@ -909,21 +907,14 @@ function VerticalSessionTabs(props: {
unreadMarker={props.unreadMarker}
attributes={selected() ? TextAttributes.BOLD : undefined}
/>
<title_shimmer
<text
width={titleWidth()}
height={1}
fg={foreground()}
rename={{ pending: status().renaming, title: title() }}
enabled={animations()}
backdrop={pulseBackground()}
wrapMode="none"
selectable={false}
attributes={
(status().renaming && !animations()
? TextAttributes.DIM
: selected()
? TextAttributes.BOLD
: 0) | (tabs.isPreview?.(tab.sessionID) ? TextAttributes.ITALIC : 0) || undefined
(selected() ? TextAttributes.BOLD : 0) |
(tabs.isPreview?.(tab.sessionID) ? TextAttributes.ITALIC : 0) || undefined
}
>
<Show
@@ -936,7 +927,7 @@ function VerticalSessionTabs(props: {
)}
</Index>
</Show>
</title_shimmer>
</text>
<text
position="absolute"
right={1}
@@ -1084,7 +1075,6 @@ function HorizontalSessionTabs(props: {
numbers: boolean
}) {
const tabs = props.controller ?? useSessionTabs()
const data = props.controller ? undefined : useData()
const dimensions = useTerminalDimensions()
const theme = useTheme()
const config = useConfig().data
@@ -1374,7 +1364,7 @@ function HorizontalSessionTabs(props: {
const glowColor = createMemo(() => tint(background(), feedbackColor() ?? unreadColor(), glowLevel()))
const glows = () =>
Boolean(status().attention || (!selected() && !status().busy && status().unread !== undefined))
const title = () => data?.session.get(tab.sessionID)?.title ?? tab.title ?? "Untitled session"
const title = () => tab.title ?? "Untitled session"
const tabNumber = createMemo(() => items().findIndex((item) => item.sessionID === tab.sessionID) + 1)
const numberWidth = () => Math.max(2, String(items().length).length)
// Hovering reveals the close mark, so the title's right bound shifts left of it.
@@ -1502,18 +1492,13 @@ function HorizontalSessionTabs(props: {
unreadMarker={props.unreadMarker}
attributes={bold()}
/>
<title_shimmer
<text
width={availableTitleWidth()}
height={1}
fg={foreground()}
rename={{ pending: status().renaming, title: title() }}
enabled={animations()}
backdrop={background()}
wrapMode="none"
selectable={false}
attributes={
(status().renaming && !animations() ? TextAttributes.DIM : (bold() ?? 0)) |
(tabs.isPreview?.(tab.sessionID) ? TextAttributes.ITALIC : 0) || undefined
(bold() ?? 0) | (tabs.isPreview?.(tab.sessionID) ? TextAttributes.ITALIC : 0) || undefined
}
>
<Show when={scrolling() || glows() || titleFades()} fallback={visibleTitle()}>
@@ -1523,7 +1508,7 @@ function HorizontalSessionTabs(props: {
)}
</Index>
</Show>
</title_shimmer>
</text>
<text
position="absolute"
right={1}
+3 -3
View File
@@ -30,7 +30,7 @@ type TabPulseOptions = RenderableOptions<TabPulseRenderable> & {
}
const clamp = (value: number) => Math.max(0, Math.min(1, value))
export const smootherstep = (value: number) => value * value * value * (value * (value * 6 - 15) + 10)
const smootherstep = (value: number) => value * value * value * (value * (value * 6 - 15) + 10)
const RUN_DURATION = 2_800
const RUN_ATTACK = 450
const RUN_HEAD = 4
@@ -53,11 +53,11 @@ const GLOW_RELEASE_PEAK = 1.25
const GLOW_TAIL = 12
const GLOW_OPACITY = 0.16
const DEFAULT_FOREGROUND = RGBA.defaultForeground()
export const intensityAt = (index: number, front: number, head: number, tail: number) => {
const intensityAt = (index: number, front: number, head: number, tail: number) => {
const distance = front - index
return distance < 0 ? smootherstep(clamp(1 + distance / head)) : smootherstep(clamp(1 - distance / tail))
}
export const coast = (value: number) => {
const coast = (value: number) => {
const ramp = 0.2
if (value < ramp) return (value * value) / (2 * ramp * (1 - ramp))
if (value > 1 - ramp) return 1 - ((1 - value) * (1 - value)) / (2 * ramp * (1 - ramp))
@@ -1,269 +0,0 @@
import {
BoxRenderable,
OptimizedBuffer,
RGBA,
TargetChannel,
TextRenderable,
type RenderContext,
type TextOptions,
} from "@opentui/core"
import { extend } from "@opentui/solid"
import { coast, intensityAt, smootherstep } from "./tab-pulse"
type TitleShimmerOptions = TextOptions & {
rename?: { title: string; pending: boolean }
enabled?: boolean
backdrop?: RGBA
}
const SHIMMER_DURATION = 1200
const SHIMMER_FADE = 240
const ARRIVAL_DURATION = 450
const WIPE_FEATHER = 4
const TRANSPARENT = RGBA.fromValues(0, 0, 0, 0)
// Native text draws wide glyphs as a head followed by flagged continuation cells.
const CONTINUATION = 0xc0000000 | 0
export class TitleShimmerRenderable extends TextRenderable {
private _rename: TitleShimmerOptions["rename"]
private _enabled: boolean
private _backdrop: RGBA
private pendingTitle: string | undefined
private elapsed = 0
private blend = 0
private fresh = true
private arrival: number | undefined
private scratch: OptimizedBuffer | undefined
private previous: OptimizedBuffer | undefined
private mask = new Float32Array(0)
private matrix = new Float32Array(16)
constructor(ctx: RenderContext, options: TitleShimmerOptions) {
super(ctx, options)
this._rename = options.rename
this.pendingTitle = options.rename?.title
this._enabled = options.enabled ?? true
this._backdrop = options.backdrop ?? RGBA.defaultBackground()
this.matrix[15] = 1
this.updateBackdrop()
this.live = this.animating
}
private get animating() {
return this._enabled && (this.shimmering || this.arrival !== undefined || this.blend > 0)
}
private get shimmering() {
return this._rename?.pending && this._rename.title === this.pendingTitle
}
set rename(value: TitleShimmerOptions["rename"]) {
if (value?.title === this._rename?.title && value?.pending === this._rename?.pending) return
if (value?.pending && !this._rename?.pending) {
if (this.pendingTitle !== value.title) this.blend = 0
this.pendingTitle = value.title
if (this.blend === 0) this.elapsed = 0
this.arrival = undefined
this.previous?.destroy()
this.previous = undefined
}
// Only an automatic rename replaces the last painted title with a wipe.
if (value?.title !== this._rename?.title) {
this.arrival = value && this._rename?.pending && this._enabled && this.previous ? 0 : undefined
if (this.arrival === undefined) this.blend = 0
}
this._rename = value
this.changed()
}
set enabled(value: boolean) {
if (value === this._enabled) return
this._enabled = value
if (!value) {
this.arrival = undefined
this.blend = 0
}
this.changed()
}
set backdrop(value: RGBA) {
if (value.equals(this._backdrop)) return
this._backdrop = value
this.updateBackdrop()
this.requestRender()
}
private updateBackdrop() {
this.matrix[3] = this._backdrop.r
this.matrix[7] = this._backdrop.g
this.matrix[11] = this._backdrop.b
}
private changed() {
if (!this.live && this.animating) this.fresh = true
this.live = this.animating
if (!this.animating) {
this.previous?.destroy()
this.previous = undefined
}
this.requestRender()
}
override render(buffer: OptimizedBuffer, deltaTime: number) {
if (!this.visible || this.isDestroyed || !Number.isFinite(this.width) || this.width <= 0 || this.height <= 0) return
if (!this.animating) return super.render(buffer, deltaTime)
// A newly live title must not inherit time spent idle before its fade started.
const delta = this.fresh ? 0 : deltaTime
this.fresh = false
this.elapsed = (this.elapsed + delta) % SHIMMER_DURATION
if (this.arrival !== undefined) {
this.arrival += delta
if (this.arrival >= ARRIVAL_DURATION) {
this.arrival = undefined
this.blend = 0
}
}
this.blend = Math.max(
0,
Math.min(1, this.blend + (this.shimmering || this.arrival !== undefined ? delta : -delta) / SHIMMER_FADE),
)
this.live = this.animating
if (!this.animating) {
this.previous?.destroy()
this.previous = undefined
return super.render(buffer, deltaTime)
}
if (!this.scratch)
this.scratch = OptimizedBuffer.create(this.width, this.height, this._ctx.widthMethod, { respectAlpha: true })
if (this.scratch.width !== this.width || this.scratch.height !== this.height)
this.scratch.resize(this.width, this.height)
// Shade locally, then composite: colorMatrix itself does not respect ancestor scissors.
this.scratch.clear(TRANSPARENT)
// OpenTUI's framebuffer compositor can paint a cut wide glyph. Clip the native text draw first.
const clip = {
left: Math.max(0, -this.screenX),
top: Math.max(0, -this.screenY),
right: Math.min(this.width, buffer.width - this.screenX),
bottom: Math.min(this.height, buffer.height - this.screenY),
}
for (let parent = this.parent; parent; parent = parent.parent) {
if (parent.overflow === "visible" || parent.width <= 0 || parent.height <= 0) continue
const border = parent instanceof BoxRenderable ? parent.border : false
const left = Number(border === true || (Array.isArray(border) && border.includes("left")))
const top = Number(border === true || (Array.isArray(border) && border.includes("top")))
clip.left = Math.max(clip.left, parent.screenX - this.screenX + left)
clip.top = Math.max(clip.top, parent.screenY - this.screenY + top)
clip.right = Math.min(
clip.right,
parent.screenX -
this.screenX +
parent.width -
Number(border === true || (Array.isArray(border) && border.includes("right"))),
)
clip.bottom = Math.min(
clip.bottom,
parent.screenY -
this.screenY +
parent.height -
Number(border === true || (Array.isArray(border) && border.includes("bottom"))),
)
}
this.scratch.pushScissorRect(
clip.left,
clip.top,
Math.max(0, clip.right - clip.left),
Math.max(0, clip.bottom - clip.top),
)
this.scratch.drawTextBuffer(this.textBufferView, 0, 0)
const characters = this.scratch.buffers.char
let end = 0
for (let row = 0; row < this.height; row++) {
let column = this.width
while (
column > 0 &&
(characters[row * this.width + column - 1] === 32 || characters[row * this.width + column - 1] === 0)
)
column--
end = Math.max(end, column)
}
const wipeFront =
this.arrival !== undefined && this.previous
? -WIPE_FEATHER +
coast(this.arrival / ARRIVAL_DURATION) * (Math.max(end, this.previous.width) + WIPE_FEATHER * 2)
: undefined
const cut = Math.max(0, Math.min(this.width, Math.round(wipeFront ?? 0)))
if (wipeFront !== undefined && this.previous) {
this.scratch.clear(TRANSPARENT)
this.scratch.pushScissorRect(0, 0, cut, this.height)
this.scratch.drawTextBuffer(this.textBufferView, 0, 0)
this.scratch.popScissorRect()
// Snapshot slices must also end on whole glyphs; framebuffer clipping alone can split them.
for (let row = 0; row < Math.min(this.height, this.previous.height); row++) {
let left = Math.max(cut, clip.left)
let right = Math.min(this.previous.width, clip.right)
const offset = row * this.previous.width
while (left < right && (this.previous.buffers.char[offset + left] & CONTINUATION) === CONTINUATION) left++
while (
right > left &&
right < this.previous.width &&
(this.previous.buffers.char[offset + right] & CONTINUATION) === CONTINUATION
)
right--
if (right > left) this.scratch.drawFrameBuffer(left, row, this.previous, left, row, right - left, 1)
}
}
this.scratch.clearScissorRects()
if (this.mask.length !== this.width * this.height * 3) this.mask = new Float32Array(this.width * this.height * 3)
if (wipeFront === undefined) {
if (!this.previous)
this.previous = OptimizedBuffer.create(Math.max(1, end), this.height, this._ctx.widthMethod, {
respectAlpha: true,
})
if (this.previous.width !== Math.max(1, end) || this.previous.height !== this.height)
this.previous.resize(Math.max(1, end), this.height)
this.previous.clear(TRANSPARENT)
this.previous.drawFrameBuffer(0, 0, this.scratch)
}
const front = -4 + coast(this.elapsed / SHIMMER_DURATION) * ((this.previous?.width ?? end) + 4 + 18)
const level = smootherstep(this.blend)
let strength = 0
for (let cell = 0; cell < characters.length; cell++) {
const column = cell % this.width
if ((characters[cell] & CONTINUATION) !== CONTINUATION) {
const old = wipeFront === undefined || column >= cut
let visibility = old ? 1 - 0.6 * level * (1 - intensityAt(column, front, 4, 18)) : 1
if (wipeFront !== undefined) {
let width = 1
while (column + width < this.width && (characters[cell + width] & CONTINUATION) === CONTINUATION) width++
const distance = old ? column - wipeFront : wipeFront - (column + width)
visibility *= smootherstep(Math.max(0, Math.min(1, distance / WIPE_FEATHER)))
}
strength = 1 - visibility
}
this.mask[cell * 3] = column
this.mask[cell * 3 + 1] = Math.floor(cell / this.width)
this.mask[cell * 3 + 2] = strength
}
this.scratch.colorMatrix(this.matrix, this.mask, 1, TargetChannel.FG)
buffer.drawFrameBuffer(this.screenX, this.screenY, this.scratch)
this.markClean()
this._ctx.addToHitGrid(this.screenX, this.screenY, this.width, this.height, this.num)
}
override destroy() {
this.previous?.destroy()
this.previous = undefined
this.scratch?.destroy()
this.scratch = undefined
super.destroy()
}
}
extend({ title_shimmer: TitleShimmerRenderable })
declare module "@opentui/solid" {
interface OpenTUIComponents {
title_shimmer: typeof TitleShimmerRenderable
}
}
-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 -23
View File
@@ -1,6 +1,5 @@
import { createData } from "@opencode-ai/client/solid"
import type { Plugin } from "@opencode-ai/plugin/tui"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useClient } from "./client"
@@ -18,27 +17,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
directory: props.directory,
})
data satisfies Plugin.Context["data"]
const [generatingTitles, setGeneratingTitles] = createStore<Record<string, boolean | undefined>>({})
return {
...data,
session: {
...data.session,
title: {
pending: (sessionID: string) => generatingTitles[sessionID] === true,
async generate(sessionID: string) {
if (generatingTitles[sessionID]) return
setGeneratingTitles(sessionID, true)
await client.api.session
.rename({ sessionID, title: "" })
.then(() => {
// The HTTP response can beat the renamed event. Keep pending until the new title is projected locally.
data.session.invalidate(sessionID)
return data.session.sync(sessionID)
})
.finally(() => setGeneratingTitles(sessionID, undefined))
},
},
},
}
return data
},
})
@@ -173,7 +173,6 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
? ("question" as const)
: (false as const),
busy: members.some((id) => data.session.status(id) === "running" || data.session.pending.list(id).length > 0),
renaming: data.session.title.pending(session),
}
}
@@ -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,
}
+19 -49
View File
@@ -7,55 +7,25 @@ export function createHistoryPrepend(input: {
active: (sessionID: string) => boolean
scrollBy: (amount: number) => void
}) {
let pending: { scrollBy: number; continuation?: () => void; after?: () => void } | undefined
let loading = false
return Object.assign(
(scrollBy = 0, continuation?: () => void) => {
const sessionID = input.sessionID()
if (pending) {
if (continuation || (!pending.scrollBy && scrollBy)) {
pending.scrollBy = scrollBy
pending.continuation = continuation
return true
}
return false
}
if (!input.more(sessionID)) return false
const current = { scrollBy, continuation }
pending = current
const before = input.height()
void input.loadMore(sessionID).then(
() =>
input.afterLayout(() => {
if (pending !== current) return
const after = pending.after
pending = undefined
if (!input.active(sessionID)) return
input.scrollBy(input.height() - before + current.scrollBy)
current.continuation?.()
after?.()
}),
() => {
if (pending !== current) return
const after = pending.after
pending = undefined
if (input.active(sessionID)) after?.()
},
)
return true
},
{
cancel() {
if (!pending) return
pending.continuation = undefined
pending.after = undefined
return (scrollBy = 0, continuation?: () => void) => {
const sessionID = input.sessionID()
if (loading || !input.more(sessionID)) return false
loading = true
const before = input.height()
void input.loadMore(sessionID).then(
() =>
input.afterLayout(() => {
loading = false
if (!input.active(sessionID)) return
input.scrollBy(input.height() - before + scrollBy)
continuation?.()
}),
() => {
loading = false
},
after(continuation: () => void) {
if (!pending) return continuation()
// A jump supersedes deferred scrolling, but must wait for anchor compensation.
pending.scrollBy = 0
pending.after = continuation
},
},
)
)
return true
}
}
+13 -78
View File
@@ -1,5 +1,4 @@
import {
batch,
createContext,
createEffect,
createMemo,
@@ -289,7 +288,6 @@ export function Session(props: {
const boundaryIDs = createMemo(() => new Set(boundaries().filter((id) => id !== undefined)))
const [navigationMessage, setNavigationMessage] = createSignal<string>()
const [navigationSlack, setNavigationSlack] = createSignal(0)
const [firstJump, setFirstJump] = createSignal<() => void>()
const [synced, setSynced] = createSignal(false)
const sessionTabs = useSessionTabs()
const terminals = useSessionTerminals()
@@ -302,9 +300,6 @@ export function Session(props: {
const clearMessageNavigation = () => {
ensureAllRowsPending?.splice(0)
prependHistory.cancel()
firstJump()?.()
setFirstJump(undefined)
setNavigationSlack(0)
setNavigationMessage(undefined)
}
@@ -375,8 +370,6 @@ export function Session(props: {
let awayTimer: ReturnType<typeof setTimeout> | undefined
onCleanup(() => {
if (awayTimer) clearTimeout(awayTimer)
prependHistory.cancel()
firstJump()?.()
if (!scroll || scroll.isDestroyed) return
scroll.verticalScrollBar.off("change", updateAwayFromBottom)
saveScrollAnchor()
@@ -459,7 +452,6 @@ export function Session(props: {
}
/** Message navigation needs the full transcript mounted before walking or jumping. */
const ensureAllRows = (continuation: () => void) => {
if (firstJump()) clearMessageNavigation()
if (!ensureAllRowsPending && hidden() === 0 && visibleEnd() === rows.length) return continuation()
if (ensureAllRowsPending) {
ensureAllRowsPending.push(continuation)
@@ -477,13 +469,12 @@ export function Session(props: {
}
function isAwayFromBottom() {
if (revealingOlderRows || revealingNewerRows || ensureAllRowsPending || navigationMessage() || firstJump())
return true
if (revealingOlderRows || revealingNewerRows || ensureAllRowsPending || navigationMessage()) return true
if (visibleEnd() < rows.length) return true
return scroll.scrollTop < Math.max(0, scroll.scrollHeight - scroll.viewport.height)
}
function updateAwayFromBottom() {
const preserveWindow = revealingOlderRows || revealingNewerRows || !!ensureAllRowsPending || !!firstJump()
const preserveWindow = revealingOlderRows || revealingNewerRows || !!ensureAllRowsPending
if (isAwayFromBottom()) setHiddenRows((current) => current ?? hidden())
if (awayTimer) clearTimeout(awayTimer)
awayTimer = setTimeout(() => {
@@ -668,7 +659,6 @@ export function Session(props: {
})
const jumpToBackgroundTool = (target: BackgroundToolTarget, beforeMessageID: string) => {
if (firstJump()) clearMessageNavigation()
const jump = () => {
const index = backgroundToolRowIndex(rows, messages(), target, beforeMessageID)
if (index === -1) {
@@ -769,65 +759,17 @@ export function Session(props: {
group: "Session",
palette: undefined,
run: () => {
if (firstJump()) return
clearMessageNavigation()
const request = new AbortController()
const cancel = () => request.abort()
setFirstJump(() => cancel)
const start = () => {
if (firstJump() !== cancel || scroll.isDestroyed) return
if (revealingOlderRows || revealingNewerRows || ensureAllRowsPending) return afterLayout(start)
const previous = { start: hiddenRows(), end: visibleRowsEnd() }
const restore = () => {
cancel()
batch(() => {
setHiddenRows(previous.start)
setVisibleRowsEnd(previous.end)
})
const first = () => {
if (data.session.message.more(route.sessionID)) {
prependHistory(0, first)
return
}
const commit = () => {
if (firstJump() !== restore || scroll.isDestroyed) return
scroll.stickyScroll = false
batch(() => {
setHiddenRows(0)
setVisibleRowsEnd(TRANSCRIPT_BACKFILL_CHUNK)
setFirstJump(() => cancel)
})
}
// Pin both ends until the head budget commits in the same batch as history.
batch(() => {
setFirstJump(() => restore)
setHiddenRows(hidden())
setVisibleRowsEnd(visibleEnd())
ensureAllRows(() => {
scroll.scrollTo(0)
})
void data.session.message
.loadMore(route.sessionID, {
all: true,
signal: request.signal,
beforePublish: commit,
})
.then(
() => {
commit()
if (firstJump() !== cancel || scroll.isDestroyed) return
if (rows.length <= TRANSCRIPT_BACKFILL_CHUNK) setVisibleRowsEnd(undefined)
scroll.scrollTo(0)
afterLayout(() => {
if (firstJump() !== cancel) return
scroll.scrollTo(0)
setFirstJump(undefined)
updateAwayFromBottom()
})
},
(error) => {
if (firstJump() !== restore || scroll.isDestroyed) return
clearMessageNavigation()
toast.error(error)
updateAwayFromBottom()
},
)
}
prependHistory.after(start)
first()
dialog.clear()
},
},
@@ -859,12 +801,9 @@ export function Session(props: {
slash: { name: "rename", arguments: true as const },
run: (input?: string) => {
if (input === undefined) return DialogSessionRename.show(dialog, route.sessionID, session()?.title)
const title = input.trim()
void (
title
? client.api.session.rename({ sessionID: route.sessionID, title })
: data.session.title.generate(route.sessionID)
).catch((error) => toast.error(error))
void client.api.session
.rename({ sessionID: route.sessionID, title: input.trim() })
.catch((error) => toast.error(error))
},
},
{
@@ -1386,7 +1325,6 @@ export function Session(props: {
flexGrow={1}
scrollAcceleration={scrollAcceleration()}
onMouseScroll={(event) => {
if (firstJump()) clearMessageNavigation()
if (event.scroll?.direction === "up" && revealOlderRows()) return
if (event.scroll?.direction === "down" && revealNewerRows()) return
updateAwayFromBottom()
@@ -1414,10 +1352,7 @@ export function Session(props: {
</scrollbox>
</box>
<box height={1} flexShrink={0} flexDirection="row" justifyContent="flex-end">
<Show when={firstJump()}>
<text fg={theme.text.feedback.info.default}>Loading session history...</text>
</Show>
<Show when={!firstJump() && awayFromBottom()}>
<Show when={awayFromBottom()}>
<box
id="session-jump-to-latest"
paddingLeft={1}
+5 -20
View File
@@ -4,8 +4,6 @@ import { useTheme } from "../../context/theme"
import { useConfig } from "../../config"
import { Slot } from "../../plugin/render"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import { TextAttributes } from "@opentui/core"
import "../../component/title-shimmer"
import { getScrollAcceleration } from "../../util/scroll"
import { SESSION_SIDEBAR_WIDTH } from "../../ui/layout"
@@ -47,24 +45,11 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
>
<box flexShrink={0} gap={1} paddingRight={1}>
<box paddingRight={1}>
<title_shimmer
fg={theme.text.default}
rename={{
pending: data.session.title.pending(props.sessionID),
title: withTimestampedFallback(session()),
}}
enabled={config.animations ?? true}
backdrop={theme.background.default}
attributes={
data.session.title.pending(props.sessionID) && config.animations === false
? TextAttributes.DIM
: TextAttributes.BOLD
}
>
{withTimestampedFallback(session())}
</title_shimmer>
<Show when={session().location.workspaceID}>
<text fg={theme.text.subdued}>{session().location.workspaceID}</text>
<text fg={theme.text.default}>
<b>{withTimestampedFallback(session()!)}</b>
</text>
<Show when={session()!.location.workspaceID}>
<text fg={theme.text.subdued}>{session()!.location.workspaceID}</text>
</Show>
</box>
<Slot path="sidebar.content" input={{ sessionID: props.sessionID }} />
+16 -26
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>()
@@ -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(() => {
+12 -93
View File
@@ -32,7 +32,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 +108,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 +182,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: () => {},
@@ -223,87 +223,6 @@ test("session title generated while an untitled session is loading remains visib
}
})
test("automatic rename refreshes the displayed title before settling, even without a renamed event", async () => {
await using state = await tmpdir()
const setup = await createTestRenderer({ width: 90, height: 20, useThread: false, kittyKeyboard: true })
setup.renderer.start()
const events = createEventStream()
const response = Promise.withResolvers<Response>()
const bodies: unknown[] = []
const location = { directory, project: { id: "project", directory, canonical: directory } }
const session = {
id: "ses_rename",
title: "Compiler cleanup",
projectID: "project",
location: { directory },
agent: "build",
model: { providerID: "provider", id: "model" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
}
const calls = createFetch(async (url, request) => {
if (url.pathname === "/api/location") return json(location)
if (url.pathname === "/api/agent")
return json({ location, data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }] })
if (url.pathname === "/api/model")
return json({ location, data: [{ id: "model", providerID: "provider", name: "Model", variants: [] }] })
if (url.pathname === "/api/provider") return json({ location, data: [{ id: "provider", name: "Provider" }] })
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
if (url.pathname === "/api/session/ses_rename") return json({ data: session })
if (/^\/api\/session\/ses_rename\/(message|inbox|permission)$/.test(url.pathname))
return json({ data: [], cursor: {} })
if (url.pathname === "/api/session/ses_rename/rename") {
bodies.push(await request.json())
return response.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 () => ({
tabs: { enabled: true, layout: "vertical" },
session: { sidebar: "hide" },
}),
update: async () => ({}),
},
packages: { resolve: async () => ({}), check: async () => ({ mutable: false }) },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: session.id },
log: () => {},
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
)
await setup.waitForFrame((frame) => frame.includes(session.title) && frame.includes("Build · Model Provider"))
await setup.mockInput.typeText("/rename")
setup.mockInput.pressEscape()
setup.mockInput.pressEnter()
await setup.waitFor(() => bodies.length === 1)
await setup.renderOnce()
expect(bodies[0]).toEqual({ title: "" })
expect(setup.captureCharFrame()).toContain("Compiler cleanup")
session.title = "Simplify compiler parsing"
response.resolve(new Response(null, { status: 204 }))
await setup.waitForFrame((frame) => frame.includes(session.title), { maxPasses: 60 })
expect(setup.captureCharFrame()).not.toContain("Compiler cleanup")
setup.renderer.destroy()
await task
} finally {
response.resolve(new Response(null, { status: 204 }))
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await server.stop()
}
})
test("session startup prompt is submitted exactly once", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
const events = createEventStream()
@@ -354,7 +273,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 +354,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 +428,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 +509,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 +597,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 +687,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 +768,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 +822,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 +934,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: () => {},
@@ -101,37 +101,3 @@ test("continues navigation after the prepended page is laid out", async () => {
expect(events).toEqual(["loaded", "layout", "anchored", "continued"])
})
test.each(["success", "failure", "cancel", "takeover"])("settles superseded prepend navigation (%s)", async (mode) => {
const load = Promise.withResolvers<void>()
const layout: (() => void)[] = []
const events: (number | string)[] = []
let height = 100
const prepend = createHistoryPrepend({
sessionID: () => "session-1",
more: () => true,
loadMore: () => load.promise,
height: () => height,
afterLayout: (continuation) => layout.push(continuation),
active: () => true,
scrollBy: (amount) => events.push(amount),
})
prepend(-4, () => events.push("obsolete"))
prepend.cancel()
prepend.after(() => events.push("jump"))
if (mode === "cancel" || mode === "takeover") prepend.cancel()
if (mode === "takeover") expect(prepend(-8)).toBe(true)
if (mode === "failure") load.reject(new Error("offline"))
if (mode !== "failure") {
height = 160
load.resolve()
}
await Promise.resolve()
expect(events).toEqual(mode === "failure" ? ["jump"] : [])
layout.shift()?.()
expect(events).toEqual(
mode === "failure" ? ["jump"] : mode === "success" ? [60, "jump"] : mode === "takeover" ? [52] : [60],
)
prepend.after(() => events.push("idle"))
expect(events.at(-1)).toBe("idle")
})
+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: () => {},
@@ -1,145 +0,0 @@
import { expect, test } from "bun:test"
import { BoxRenderable, RGBA, TextAttributes, TextRenderable } from "@opentui/core"
import { createTestRenderer, ManualClock } from "@opentui/core/testing"
import { TitleShimmerRenderable } from "../../src/component/title-shimmer"
test("shimmer fades in from idle and fades out on unchanged completion", async () => {
const clock = new ManualClock()
const app = await createTestRenderer({ width: 24, height: 1, useThread: false, clock })
const title = new TitleShimmerRenderable(app.renderer, {
width: 24,
height: 1,
content: "Compiler cleanup",
fg: "#eeeeee",
backdrop: RGBA.fromHex("#111111"),
rename: { title: "Compiler cleanup", pending: false },
})
app.renderer.root.add(title)
try {
await app.renderOnce()
const frame = app.captureCharFrame()
const colors = app.captureSpans()
clock.advance(2000)
title.rename = { title: "Compiler cleanup", pending: true }
await app.renderOnce()
expect(app.captureSpans()).toEqual(colors)
clock.advance(120)
await app.renderOnce()
const middle = app.captureSpans().lines[0].spans.findLast((span) => span.text.trim())?.fg.r ?? 0
expect(middle).toBeLessThan(colors.lines[0].spans[0].fg.r)
clock.advance(120)
await app.renderOnce()
expect(app.captureSpans().lines[0].spans.findLast((span) => span.text.trim())?.fg.r ?? 0).toBeLessThan(middle)
expect(app.captureSpans()).not.toEqual(colors)
expect(app.captureCharFrame()).toBe(frame)
title.rename = { title: "Compiler cleanup", pending: false }
await app.renderOnce()
expect(app.renderer.root.liveCount).toBe(1)
clock.advance(240)
await app.renderOnce()
expect(app.renderer.root.liveCount).toBe(0)
expect(app.captureSpans()).toEqual(colors)
title.enabled = false
title.rename = { title: "Compiler cleanup", pending: true }
await app.renderOnce()
expect(app.renderer.root.liveCount).toBe(0)
} finally {
app.renderer.destroy()
}
})
test("a feathered wipe keeps the old shimmer moving without dimming the revealed new title", async () => {
const clock = new ManualClock()
const app = await createTestRenderer({ width: 16, height: 1, useThread: false, clock })
const title = new TitleShimmerRenderable(app.renderer, {
width: 16,
height: 1,
content: "ABCDEFGHIJKLMNOP",
fg: "#eeeeee",
attributes: TextAttributes.ITALIC,
backdrop: RGBA.fromHex("#111111"),
rename: { title: "ABCDEFGHIJKLMNOP", pending: true },
})
app.renderer.root.add(title)
try {
await app.renderOnce()
clock.advance(600)
await app.renderOnce()
const colors = app.captureSpans()
title.content = "abcdefghijklmnop"
title.rename = { title: "abcdefghijklmnop", pending: true }
await app.renderOnce()
expect(app.captureCharFrame().trim()).toBe("ABCDEFGHIJKLMNOP")
expect(app.captureSpans()).toEqual(colors)
clock.advance(225)
await app.renderOnce()
expect(app.captureCharFrame().trim()).toBe("abcdefghIJKLMNOP")
const spans = app.captureSpans().lines[0].spans
expect(spans[0].fg.equals(RGBA.fromHex("#eeeeee"))).toBe(true)
expect(spans.some((span) => span.fg.equals(RGBA.fromHex("#111111")))).toBe(true)
expect(spans.at(-1)?.fg.toInts()).not.toEqual(colors.lines[0].spans.at(-1)?.fg.toInts())
expect(spans.every((span) => Boolean(span.attributes & TextAttributes.ITALIC))).toBe(true)
clock.advance(225)
await app.renderOnce()
expect(app.captureCharFrame().trim()).toBe("abcdefghijklmnop")
expect(app.renderer.root.liveCount).toBe(0)
title.rename = { title: "abcdefghijklmnop", pending: false }
title.content = "Manual"
title.rename = { title: "Manual", pending: false }
await app.renderOnce()
expect(app.captureCharFrame().trim()).toBe("Manual")
expect(app.renderer.root.liveCount).toBe(0)
title.rename = { title: "Manual", pending: true }
await app.renderOnce()
title.content = "Next"
title.rename = { title: "Next", pending: false }
title.enabled = false
await app.renderOnce()
expect(app.captureCharFrame().trim()).toBe("Next")
title.enabled = true
expect(app.renderer.root.liveCount).toBe(0)
} finally {
app.renderer.destroy()
}
})
test("native Unicode clipping and shorter replacement leave no split glyphs or old tail", async () => {
const clock = new ManualClock()
const app = await createTestRenderer({ width: 24, height: 3, useThread: false, clock })
const content = "A\u65e5B \u{1f680} cafe\u0301"
const title = new TitleShimmerRenderable(app.renderer, {
width: 20,
height: 1,
content,
fg: "#eeeeee",
wrapMode: "none",
backdrop: RGBA.fromHex("#111111"),
rename: { title: content, pending: true },
})
const plain = new TextRenderable(app.renderer, { width: 20, height: 1, content, wrapMode: "none" })
const shadedBox = new BoxRenderable(app.renderer, { width: 6, height: 1, marginLeft: 2, overflow: "hidden" })
const plainBox = new BoxRenderable(app.renderer, { width: 6, height: 1, marginLeft: 2, overflow: "hidden" })
shadedBox.add(title)
plainBox.add(plain)
app.renderer.root.add(shadedBox)
app.renderer.root.add(plainBox)
app.renderer.root.add(new TextRenderable(app.renderer, { content: "untouched" }))
try {
await app.renderOnce()
expect(app.captureCharFrame().split("\n")[0]).toBe(app.captureCharFrame().split("\n")[1])
title.content = "Short"
title.rename = { title: "Short", pending: false }
clock.advance(200)
await app.renderOnce()
expect(app.captureCharFrame().split("\n")[0].trim()).toBe("Sh B")
expect(app.captureCharFrame().split("\n")[2]).toContain("untouched")
clock.advance(250)
await app.renderOnce()
expect(app.captureCharFrame().split("\n")[0].trim()).toBe("Short")
expect(app.renderer.root.liveCount).toBe(0)
} finally {
app.renderer.destroy()
}
})
@@ -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"))
},
)
-389
View File
@@ -1,389 +0,0 @@
import { expect, test } from "bun:test"
import { type Renderable, ScrollBoxRenderable } from "@opentui/core"
import { createTestRenderer } from "@opentui/core/testing"
import { Effect, FileSystem } from "effect"
import { Global } from "@opencode-ai/util/global"
import { createEventStream, createFetch, directory, json } from "./fixture/tui-client"
import { tmpdir } from "./fixture/fixture"
test.each([
"bottom",
"scrolled",
"cancel",
"scroll-cancel",
"up-cancel",
"mouse-cancel",
"page-cancel",
"failure",
"mixed",
"close",
"resize-cancel",
"settling",
"settling-scrolled",
"settling-reveal",
"prepend",
"prepend-navigation",
"prepend-failure",
])("Home loads a stable, bounded beginning (%s)", async (mode) => {
await using state = await tmpdir()
const setup = await createTestRenderer({ width: 100, height: 30, useThread: false, kittyKeyboard: true })
setup.renderer.start()
const session = {
id: "dummy",
title: "Long history",
projectID: "project",
location: { directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
}
const messages = Array.from({ length: 400 }, (_, index) =>
mode === "mixed" && index % 2
? {
id: `message-${index}`,
type: "assistant",
agent: "build",
model: { providerID: "demo", id: "demo-model" },
content: [{ type: "text", text: `History message ${String(index).padStart(4, "0")}` }],
finish: "stop",
time: { created: index, completed: index + 1 },
}
: {
id: `message-${index}`,
type: "user",
text: `History message ${String(index).padStart(4, "0")}`,
time: { created: index },
},
)
const pages: { end: number; limit: number }[] = []
const release = Promise.withResolvers<void>()
const finish = Promise.withResolvers<void>()
const prior = Promise.withResolvers<void>()
const aborted = Promise.withResolvers<void>()
const events = createEventStream()
const calls = createFetch(async (url, request) => {
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
if (url.pathname === "/api/session/dummy") return json({ data: session })
if (url.pathname === "/api/session/dummy/message") {
const end = Number(url.searchParams.get("cursor") ?? messages.length)
const limit = Number(url.searchParams.get("limit"))
const start = Math.max(0, end - limit)
pages.push({ end, limit })
if (end < messages.length) {
request.signal.addEventListener("abort", () => aborted.resolve(), { once: true })
await (mode.startsWith("prepend") && limit === 20 ? prior.promise : release.promise)
}
if (end === 0) await finish.promise
if (mode === "failure" && end === 0 && pages.filter((page) => page.end === 0).length === 1)
return json({ message: "offline" }, { status: 503 })
if (mode === "prepend-failure" && limit === 200) return json({ message: "offline" }, { status: 503 })
return json({ data: messages.slice(start, end).toReversed(), cursor: end ? { next: String(start) } : {} })
}
if (url.pathname === "/api/session/dummy/inbox") return json({ data: [] })
if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
return undefined
}, events)
const server = Bun.serve({ port: 0, idleTimeout: 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 },
keybinds: {
"session.line.up": "f6",
"session.page.down": "f7",
"session.page.up": "f8",
"session.message.previous": "f9",
},
}),
update: async () => ({}),
},
packages: { resolve: async () => ({}), check: async () => ({ mutable: false }) },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy" },
log: () => {},
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
)
try {
await setup.waitForFrame((frame) => frame.includes("History message 0399"))
const findScrollBox = (root: Renderable): ScrollBoxRenderable | undefined =>
root instanceof ScrollBoxRenderable && root.getRenderable("message-399")
? root
: root.getChildren().map(findScrollBox).find(Boolean)
const scroll = findScrollBox(setup.renderer.root)
if (!scroll) throw new Error("session transcript scrollbox was not found")
const mounted = () => scroll.getChildren().filter((child) => child.id?.startsWith("message-"))
const maximum = () => Math.max(0, scroll.scrollHeight - scroll.viewport.height)
if (mode === "scrolled" || mode === "cancel" || mode === "settling-scrolled") {
setup.mockInput.pressKey("F6")
await setup.waitForFrame((frame) => frame.includes("Jump to latest"))
}
if (mode === "page-cancel" || mode.startsWith("prepend")) {
scroll.scrollTo(0)
await setup.waitForFrame((frame) => frame.includes("History message 0380"))
}
await setup.waitForVisualIdle()
const visible = () =>
JSON.stringify(
setup
.captureCharFrame()
.split("\n")
.flatMap((line, y) => {
const message = line.match(/History message (\d{4})/)
// Markdown fills asynchronously; stable user rows are the viewport anchors.
if (mode === "mixed" && message && messages[Number(message[1])]?.type === "assistant") return []
return message ? [{ line: message[0], x: message.index, y }] : []
}),
)
const before = visible()
const frames = [before]
const mountCounts: number[] = []
let navigated = false
const capture = () => {
if (visible() !== frames.at(-1)) frames.push(visible())
if (mode !== "settling-reveal" || scroll.getRenderable("message-0")) mountCounts.push(mounted().length)
if (
mode.startsWith("settling") &&
!navigated &&
setup.captureCharFrame().includes("History message 0000") &&
setup.captureCharFrame().includes("Loading session history")
) {
navigated = true
setup.mockInput.pressKey("F7")
}
}
setup.renderer.on("frame", capture)
if (mode.startsWith("prepend")) {
setup.mockInput.pressKey(mode === "prepend-navigation" ? "F9" : "F8")
await setup.waitFor(() => pages.length === 2)
setup.mockInput.pressKey("HOME")
await setup.waitForFrame((frame) => frame.includes("Loading session history"))
prior.resolve()
await setup.waitFor(() => pages.some((page) => page.end === 360 && page.limit === 200))
await setup.waitForVisualIdle()
expect(visible()).toBe(before)
expect(setup.captureCharFrame()).toContain("Loading session history")
expect(pages).toEqual([
{ end: 400, limit: 20 },
{ end: 380, limit: 20 },
{ end: 360, limit: 200 },
])
release.resolve()
finish.resolve()
if (mode === "prepend-failure") {
await setup.waitForFrame((frame) => !frame.includes("Loading session history"))
expect(visible()).toBe(before)
expect(mounted()).toHaveLength(40)
return
}
await setup.waitForFrame(
(frame) => frame.includes("History message 0000") && !frame.includes("Loading session history"),
)
expect(mounted()).toHaveLength(60)
expect(pages).toHaveLength(5)
return
}
setup.mockInput.pressKey("HOME")
await setup.waitForFrame((frame) => frame.includes("Loading session history..."))
setup.mockInput.pressKey("HOME")
await setup.waitForVisualIdle()
expect(pages).toEqual([
{ end: 400, limit: 20 },
{ end: 380, limit: 200 },
])
expect(frames).toEqual([before])
if (mode === "close" || mode === "resize-cancel") {
if (mode === "close") setup.renderer.destroy()
if (mode === "resize-cancel") {
setup.resize(60, 22)
await setup.waitForFrame(
(frame) => frame.includes("History message 0399") && !frame.includes("Loading session history"),
)
}
await aborted.promise
release.resolve()
finish.resolve()
if (mode === "close") await task
if (mode === "resize-cancel") await setup.waitForVisualIdle({ quietFrames: 4 })
expect(pages).toHaveLength(2)
return
}
if (mode === "mixed") {
release.resolve()
finish.resolve()
await setup.waitForFrame(
(frame) => frame.includes("History message 0000") && !frame.includes("Loading session history"),
)
await setup.waitForVisualIdle()
expect(frames).toEqual([before, visible()])
expect(setup.captureCharFrame()).toContain("History message 0001")
expect(mounted().map((child) => child.id)).toEqual(messages.slice(0, 40).map((message) => message.id))
expect(scroll.scrollTop).toBe(0)
expect(Math.max(...mountCounts)).toBeLessThanOrEqual(60)
return
}
if (mode.startsWith("settling")) {
release.resolve()
finish.resolve()
await setup.waitFor(() => navigated)
await setup.waitForVisualIdle()
expect(mounted().map((child) => child.id)).toEqual(messages.slice(0, 60).map((message) => message.id))
expect(scroll.scrollTop).toBeGreaterThan(0)
setup.mockInput.pressKey("END")
await setup.waitForFrame((frame) => frame.includes("History message 0399") && !frame.includes("Jump to latest"))
await setup.waitFor(() => scroll.scrollTop === maximum())
if (mode === "settling-reveal") {
scroll.scrollTo(0)
await setup.waitForFrame((frame) => frame.includes("History message 0360"))
setup.mockInput.pressKey("F8")
}
navigated = false
setup.mockInput.pressKey("HOME")
await setup.waitFor(() => navigated)
await setup.waitForVisualIdle()
expect(mounted().map((child) => child.id)).toEqual(messages.slice(0, 60).map((message) => message.id))
expect(scroll.scrollTop).toBeGreaterThan(0)
expect(scroll.scrollTop).toBeLessThanOrEqual(scroll.height)
expect(Math.max(...mountCounts)).toBeLessThanOrEqual(60)
expect(pages).toHaveLength(4)
return
}
if (mode === "page-cancel") {
setup.mockInput.pressKey("F8")
await setup.waitForFrame((frame) => !frame.includes("Loading session history"))
release.resolve()
finish.resolve()
await setup.waitFor(() => Boolean(scroll.getRenderable("message-360")))
await setup.waitForVisualIdle()
expect(setup.captureCharFrame()).toContain("History message 0379")
expect(mounted()).toHaveLength(40)
expect(pages).toEqual([
{ end: 400, limit: 20 },
{ end: 380, limit: 200 },
{ end: 380, limit: 20 },
])
return
}
if (mode === "failure") {
release.resolve()
finish.resolve()
await setup.waitForFrame((frame) => !frame.includes("Loading session history"))
events.emit({
id: "evt_live",
created: 400,
type: "session.inbox.enqueued",
durable: { aggregateID: "dummy", seq: 1, version: 1 },
data: {
sessionID: "dummy",
inboxID: "message-live",
item: { type: "user", payload: { text: "Live message after failure" }, delivery: "steer" },
},
})
await setup.waitForFrame((frame) => frame.includes("Live message after failure"))
expect(mounted()).toHaveLength(21)
setup.mockInput.pressKey("HOME")
await setup.waitForFrame(
(frame) => frame.includes("History message 0000") && !frame.includes("Loading session history"),
)
expect(mounted()).toHaveLength(60)
return
}
if (mode === "up-cancel" || mode === "mouse-cancel") {
if (mode === "up-cancel") setup.mockInput.pressKey("F6")
if (mode === "mouse-cancel") await setup.mockMouse.scroll(scroll.viewport.x + 2, scroll.viewport.y + 2, "up")
await setup.waitForFrame(
(frame) => frame.includes("Jump to latest") && !frame.includes("Loading session history"),
)
await setup.waitForVisualIdle()
const cancelled = visible()
release.resolve()
finish.resolve()
await setup.waitForVisualIdle({ quietFrames: 4 })
expect(visible()).toBe(cancelled)
expect(mounted()).toHaveLength(20)
expect(pages).toHaveLength(2)
return
}
if (mode.endsWith("cancel")) {
setup.mockInput.pressKey(mode === "cancel" ? "END" : "F7")
await setup.waitForFrame(
(frame) =>
frame.includes("History message 0399") &&
!frame.includes("Loading session history") &&
!frame.includes("Jump to latest"),
)
await setup.waitFor(() => scroll.scrollTop === maximum())
expect(scroll.scrollTop).toBe(maximum())
release.resolve()
finish.resolve()
await setup.waitForVisualIdle({ quietFrames: 4 })
expect(scroll.scrollTop).toBe(maximum())
expect(mounted()).toHaveLength(20)
expect(pages).toHaveLength(2)
expect(setup.captureCharFrame()).not.toContain("History message 0000")
setup.renderer.off("frame", capture)
setup.mockInput.pressKey("HOME")
}
if (!mode.endsWith("cancel")) {
release.resolve()
await setup.waitFor(() => pages.some((page) => page.end === 0))
await setup.waitForVisualIdle()
expect(frames).toEqual([before])
finish.resolve()
}
await setup.waitForFrame(
(frame) => frame.includes("History message 0000") && !frame.includes("Loading session history"),
)
await setup.waitForVisualIdle()
if (!mode.endsWith("cancel")) expect(frames).toEqual([before, visible()])
setup.renderer.off("frame", capture)
expect(Math.max(...mountCounts)).toBeLessThanOrEqual(60)
expect(mounted().map((child) => child.id)).toEqual(messages.slice(0, 60).map((message) => message.id))
expect(scroll.scrollTop).toBe(0)
expect(pages).toEqual([
{ end: 400, limit: 20 },
...(mode.endsWith("cancel") ? [{ end: 380, limit: 200 }] : []),
{ end: 380, limit: 200 },
{ end: 180, limit: 200 },
{ end: 0, limit: 200 },
])
// Forward paging must reveal the cached middle, not stop at the bounded head.
scroll.scrollTo(scroll.scrollHeight)
await setup.waitForFrame((frame) => frame.includes("History message 0059"))
setup.mockInput.pressKey("F7")
await setup.waitForFrame((frame) => frame.includes("History message 0060"))
expect(mounted().map((child) => child.id)).toEqual(messages.slice(0, 120).map((message) => message.id))
setup.mockInput.pressKey("F8")
await setup.waitForFrame(
(frame) => frame.includes("History message 0059") && !frame.includes("History message 0060"),
)
setup.mockInput.pressKey("END")
await setup.waitForFrame((frame) => frame.includes("History message 0399") && !frame.includes("Jump to latest"))
await setup.waitFor(() => scroll.scrollTop === maximum())
expect(scroll.scrollTop).toBe(maximum())
setup.mockInput.pressKey("HOME")
await setup.waitForFrame(
(frame) => frame.includes("History message 0000") && !frame.includes("Loading session history"),
)
setup.mockInput.pressKey("HOME")
await setup.waitForVisualIdle()
expect(scroll.scrollTop).toBe(0)
expect(mounted()).toHaveLength(60)
expect(pages).toHaveLength(mode.endsWith("cancel") ? 5 : 4)
} finally {
prior.resolve()
release.resolve()
finish.resolve()
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await task.finally(() => server.stop(true))
}
})
-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:"
}
}
+21 -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,37 @@ 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] })
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 +276,6 @@ const layer = Layer.effect(
return Service.of({
add,
resolve,
check,
which,
})
}),
@@ -370,14 +301,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

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