Compare commits

..
Author SHA1 Message Date
Kit Langton e9140c8593 test(core): create registry archives with portable paths 2026-08-28 16:22:46 -04:00
Kit Langton 52e3a3f3c7 test(tui): integrate v2 plugin resolver fixtures 2026-08-28 14:55:43 -04:00
Kit Langton 0bb6cf37be fix(tui): animate automatic session renames (#45957) 2026-08-28 18:43:53 +00:00
Kit Langton da57b27277 refactor(core): flatten durable commit validation (#45662) 2026-08-28 14:39:43 -04:00
Kit Langton 42a3fec594 docs(core): clarify runtime ownership (#45671) 2026-08-28 14:39:28 -04:00
Kit Langton d15034264b test(core): reuse session projection fixtures (#45661) 2026-08-28 14:37:33 -04:00
Kit Langton 1ce3c7e580 fix(tui): stop flashing when jumping to session start
Buffer bulk history privately and publish once with a bounded head window. Preserve pending anchor compensation while ensuring newer navigation cancels obsolete Home work. Add atomic-loading and real-App navigation regressions.
2026-08-28 14:35:31 -04:00
Kit Langton 07f27c4eca refactor(core): simplify migration narrowing (#45677) 2026-08-28 14:25:34 -04:00
Kit Langton 196893cfeb refactor(core): remove unused config editor (#45991) 2026-08-28 14:21:21 -04:00
Kit Langton 2d7abb8437 test(tui): adapt compact fixtures to plugin inspection 2026-08-28 14:09:40 -04:00
Kit Langton 1914657dd7 fix(cli): preserve plugin check failure details 2026-08-28 14:01:09 -04:00
Kit Langton 74f83cb226 test(tui): verify real paging input in plugin details 2026-08-28 14:01:09 -04:00
Kit Langton ee62824e4f feat(plugins): inspect revisions and check updates 2026-08-28 14:01:09 -04:00
104 changed files with 4040 additions and 3271 deletions
+11 -3
View File
@@ -966,6 +966,7 @@
"mime-types": "3.0.2",
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"pacote": "21.5.1",
"resolve.exports": "catalog:",
},
"devDependencies": {
@@ -975,6 +976,7 @@
"@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:",
},
},
@@ -5812,7 +5814,7 @@
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
@@ -6642,8 +6644,6 @@
"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,6 +7450,12 @@
"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=="],
@@ -8156,6 +8162,8 @@
"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(
(install ? npm.add(spec, { subpaths: ["tui"] }) : npm.resolve(spec, { subpaths: ["tui"] })).pipe(
Effect.map((result) => result.entrypoint),
),
npm.check(spec).pipe(Effect.mapError((error) => (error.cause instanceof Error ? error.cause : error))),
),
},
environment: requestedServer === undefined ? Env.session() : undefined,
+8
View File
@@ -88,8 +88,16 @@ 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 = {
+13 -1
View File
@@ -15,6 +15,8 @@ import type {
AgentGetOutput,
PluginListInput,
PluginListOutput,
PluginCheckInput,
PluginCheckOutput,
SessionListInput,
SessionListOutput,
SessionStatsInput,
@@ -318,7 +320,17 @@ const EndpointPluginList = (raw: RawClient["server.plugin"]) => (input?: PluginL
raw["plugin.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroupPlugin = (raw: RawClient["server.plugin"]) => ({ list: EndpointPluginList(raw) })
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 EndpointSessionList = (raw: RawClient["server.session"]) => (input?: SessionListInput) =>
preserveEffect<SessionListOutput>()(
+1
View File
@@ -32,6 +32,7 @@ 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,6 +9,8 @@ import type {
AgentGetOutput,
PluginListInput,
PluginListOutput,
PluginCheckInput,
PluginCheckOutput,
SessionListInput,
SessionListOutput,
SessionStatsInput,
@@ -462,6 +464,19 @@ 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) =>
+20 -2
View File
@@ -16,6 +16,8 @@ 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
@@ -441,8 +443,8 @@ export type ProviderRequest = {
export type PermissionRule = { action: string; resource: string; effect: PermissionEffect }
export type PluginInfo =
| { id: string; source: PluginSource; status: "active"; tui: boolean }
| { id?: string; source: PluginSource; status: "failed"; error: string; tui: boolean }
| { id: string; source: PluginSource; revision?: string; status: "active"; tui: boolean }
| { id?: string; source: PluginSource; revision?: string; status: "failed"; error: string; tui: boolean }
export type SessionMessageLocationSwitched = {
id: string
@@ -2338,6 +2340,10 @@ 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"
@@ -2548,6 +2554,18 @@ 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
+62 -11
View File
@@ -307,6 +307,7 @@ 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())
@@ -1529,20 +1530,70 @@ export function createData(config: CreateDataInput) {
loading(sessionID: string) {
return store.session.messageLoading[sessionID] ?? false
},
async loadMore(sessionID: string) {
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
}
const cursor = store.session.messageCursor[sessionID]
if (!cursor || store.session.messageLoading[sessionID]) return
if (!cursor || signal?.aborted) return
setStore("session", "messageLoading", sessionID, true)
const response = await api()
.message.list({ sessionID, limit: messagePageLimit, cursor })
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
})
.finally(() => setStore("session", "messageLoading", sessionID, false))
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)
track(messageLoads, sessionID, request)
await request
},
invalidate(sessionID: string) {
sync.invalidate(`session.message:${sessionID}`)
@@ -3,6 +3,7 @@ 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"
@@ -13,6 +14,7 @@ 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,6 +8,7 @@ import {
Location,
Model,
OpenCode,
Plugin,
Prompt,
Session,
SessionMessage,
@@ -27,6 +28,40 @@ 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))),
+29 -1
View File
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test"
import { isSessionNotFoundError, isUnauthorizedError, OpenCode } from "../src/promise/index"
import { isPluginCheckError, isSessionNotFoundError, isUnauthorizedError, OpenCode } from "../src/promise/index"
test("exposes every standard HTTP API group", () => {
const client = OpenCode.make({ baseUrl: "http://localhost:3000" })
@@ -55,6 +55,34 @@ 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,4 +1,5 @@
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"
@@ -414,6 +415,120 @@ 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)
},
}),
notify: () => bus.publish(Agent.Event.Updated, {}).pipe(Effect.asVoid),
finalize: () => bus.publish(Agent.Event.Updated, {}).pipe(Effect.asVoid),
})
const selectable = (agent: Info | undefined) =>
agent && agent.mode !== "subagent" && !agent.hidden ? agent : undefined
+147 -153
View File
@@ -294,162 +294,156 @@ export function configured(options?: Options) {
) {
return Effect.gen(function* () {
const durable = definition.durable
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}`,
}),
)
}
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)
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
.insert(EventTable)
.values([
{
id: event.id,
aggregate_id: aggregateID,
seq,
created: event.created ?? 0,
type: versionedType(definition.type, durable.version),
data: encoded,
},
])
.update(EventSequenceTable)
.set({ owner_id: input.ownerID })
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
.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
}),
)
}
}
}
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
},
notify: Effect.fn("Catalog.notify")(function* () {
finalize: Effect.fn("Catalog.finalize")(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),
}),
notify: () => bus.publish(Command.Event.Updated, {}).pipe(Effect.asVoid),
finalize: () => bus.publish(Command.Event.Updated, {}).pipe(Effect.asVoid),
})
const info = (definition: Definition) =>
Info.make({
-150
View File
@@ -1,150 +0,0 @@
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.layerWith(sqliteLayer({ storage }))",
"workerd sqlite cannot open a database from a path; use Database.layerFromClient.pipe(Layer.provide(sqliteLayer({ storage })))",
),
)
+22 -29
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.type === "compaction")
if (compaction?.type === "compaction") {
const compaction = owned.find((part): part is SessionV1.CompactionPart => part.type === "compaction")
if (compaction) {
const pairedSummary = messages.find(
(candidate) =>
(candidate): candidate is (typeof messages)[number] & { value: SessionV1.Assistant } =>
candidate.value.role === "assistant" &&
candidate.value.parentID === item.row.id &&
candidate.value.summary,
candidate.value.summary === true,
)
if (!pairedSummary || pairedSummary.value.role !== "assistant") return []
if (!pairedSummary) 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.type === "text" && part.text.length > 0)
.map((part) => (part.type === "text" ? part.text : ""))
.filter((part): part is SessionV1.TextPart => part.type === "text" && part.text.length > 0)
.map((part) => part.text)
.join("\n\n")
const tailIndex = compaction.tail_start_id
? messages.findIndex((candidate) => candidate.row.id === compaction.tail_start_id)
@@ -313,16 +313,14 @@ export function transformSession(input: TransformInput): TransformResult {
]
}
const subtasks = owned.filter((part) => part.type === "subtask")
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")
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")
if (subtasks.length > 0 && visible.length === 0 && files.length === 0 && agents.length === 0) return []
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 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 text = owned
.flatMap((part) => {
if (part.type === "text" && !part.ignored && !part.synthetic) return [part.text]
@@ -330,16 +328,12 @@ export function transformSession(input: TransformInput): TransformResult {
return []
})
.join("\n\n")
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: "" },
)
const agentAttachments = agents.map((part) => ({
name: part.name,
...(part.source
? { mention: { text: part.source.value, start: part.source.start, end: part.source.end } }
: {}),
}))
if (
ordinary.length === 0 &&
unavailable.length === 0 &&
@@ -351,7 +345,7 @@ export function transformSession(input: TransformInput): TransformResult {
row(item.row, {
id: item.row.id,
type: "synthetic",
text: synthetic.map((part) => (part.type === "text" ? part.text : "")).join("\n\n"),
text: synthetic.map((part) => part.text).join("\n\n"),
time: { created: item.row.time_created },
}),
]
@@ -369,7 +363,7 @@ export function transformSession(input: TransformInput): TransformResult {
row(item.row, {
id: syntheticID(item.row.id, used),
type: "synthetic",
text: synthetic.map((part) => (part.type === "text" ? part.text : "")).join("\n\n"),
text: synthetic.map((part) => part.text).join("\n\n"),
time: { created: item.row.time_created },
}),
]
@@ -443,7 +437,6 @@ 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) => {
+2 -4
View File
@@ -62,9 +62,8 @@ export const syncTextBom = Effect.fn("FileMutation.syncTextBom")(function* (
const transactionLocks = KeyedMutex.makeUnsafe<string>()
/**
* 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.
* Mutation locking is process-local and serializes cooperating OpenCode
* changes; external writes can still race.
*/
const layer = Layer.effect(
Service,
@@ -129,7 +128,6 @@ 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,15 +25,19 @@ 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.Interface<Data, Draft> = State.create<Data, Draft>({
const state = State.create<Data, Draft>({
name: "location-watcher-policy",
initial: () => ({ ignore: [] }),
draft: (draft) => ({
add: (ignore) => draft.ignore.push(...ignore),
list: () => draft.ignore,
}),
notify: () => Effect.forEach(listeners, (listener) => listener(state.get().ignore), { discard: true }),
finalize: (draft) =>
Effect.sync(() => {
current = [...draft.list()]
}).pipe(Effect.andThen(Effect.forEach(listeners, (listener) => listener(current), { discard: true }))),
})
const observe = Effect.fn("LocationWatcherPolicy.observe")(function* (
listener: (ignore: readonly string[]) => Effect.Effect<void>,
@@ -52,7 +56,7 @@ const layer = Layer.effect(
return Service.of({
transform: state.transform,
reload: state.reload,
current: () => state.get().ignore,
current: () => current,
observe,
})
}),
+1 -1
View File
@@ -74,7 +74,7 @@ export const layer = (options?: Options) =>
draft.available = false
},
}),
notify: () => bus.publish(Event.Updated, {}).pipe(Effect.asVoid),
finalize: () => bus.publish(Event.Updated, {}).pipe(Effect.asVoid),
})
const source = (value: ReadonlyArray<File> | Instructions.Unavailable | Instructions.Removed) =>
+6 -8
View File
@@ -328,7 +328,7 @@ const layer = Layer.effect(
},
},
}),
notify: () => bus.publish(Integration.Event.Updated, {}).pipe(Effect.asVoid),
finalize: () => bus.publish(Integration.Event.Updated, {}).pipe(Effect.asVoid),
})
const createCredential = Effect.fnUntraced(function* (input: Parameters<Credential.Interface["create"]>[0]) {
@@ -402,13 +402,11 @@ const layer = Layer.effect(
}
yield* Effect.gen(function* () {
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(
const implementation = state
.get()
.integrations.get(attempt.integrationID)
?.implementations.get(attempt.methodID)
const persistence = yield* Effect.sync(() => attempt.label ?? implementation?.label?.(exit.value)).pipe(
Effect.flatMap((label) =>
createCredential({
integrationID: attempt.integrationID,
+5 -25
View File
@@ -5,21 +5,7 @@ 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,
Fiber,
FiberSet,
Latch,
Layer,
Schema,
Scope,
Semaphore,
Stream,
Types,
} from "effect"
import { Cause, Context, Effect, Exit, FiberSet, Latch, Layer, Schema, Scope, Stream, Types } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Credential } from "../credential.js"
import { Bus } from "../bus.js"
@@ -627,9 +613,8 @@ export const layer = (options?: Options) =>
let applied: Map<ServerName, Mcp.ServerConfig> | undefined
const overrides = new Map<ServerName, Mcp.ServerConfig | false>()
const reconcileLock = Semaphore.makeUnsafe(1)
const reconcile = Effect.fnUntraced(function* () {
const servers = state.get().servers
const reconcile = Effect.fnUntraced(function* (next: Draft) {
const servers = new Map(next.list())
if (!applied && entries.size === 0) {
for (const [name, server] of servers) {
entries.set(name, {
@@ -690,7 +675,7 @@ export const layer = (options?: Options) =>
Stream.runForEach((event) => Effect.sync(() => fork(reconnect(event.data.integrationID)))),
),
)
const state: State.Interface<Data, Draft> = State.create<Data, Draft>({
const state = State.create<Data, Draft>({
name: "mcp",
initial: () => ({
servers: new Map(
@@ -715,12 +700,7 @@ export const layer = (options?: Options) =>
},
remove: (server) => draft.servers.delete(ServerName.make(server)),
}),
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
}),
finalize: reconcile,
})
// Suspend so each await sees current entries; a bare Map iterator is exhausted after one run.
+9 -3
View File
@@ -1,5 +1,5 @@
export * as Plugin from "./plugin.js"
export { Event, ID, Info, Source } from "@opencode-ai/schema/plugin"
export { CheckError, Event, ID, Info, PackageStatus, 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,6 +37,7 @@ export interface Interface {
export type Versioned = PluginDefinition & {
readonly version: string
readonly revision?: string
readonly source?: Plugin.Source
}
@@ -119,18 +120,22 @@ const layer = Layer.effect(
nextInventory.push(activeInfo(definition))
continue
}
nextInventory.push({
const failed = {
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", {
@@ -175,6 +180,7 @@ 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,6 +3,7 @@ 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"
@@ -332,6 +333,12 @@ 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()),
+11 -4
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, Schema } from "effect"
import { Effect, Predicate, Schema } from "effect"
import path from "path"
import { pathToFileURL } from "url"
import type { ConfigPluginSource } from "../config/plugin/source.js"
@@ -34,13 +34,16 @@ 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 entrypoint = path.isAbsolute(operation.target)
? pathToFileURL(operation.target).href
: (yield* npm.add(operation.target, { subpaths: ["server", ""], refresh: true })).entrypoint
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
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
@@ -48,12 +51,16 @@ 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,6 +1,7 @@
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
@@ -9,6 +10,7 @@ import { Context, Effect } from "effect"
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") {}
+24 -1
View File
@@ -89,6 +89,7 @@ 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
@@ -136,7 +137,29 @@ export const layer = Layer.effect(
),
Effect.forkScoped({ startImmediately: true }),
)
return Service.of({ flush: ready.await })
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}`,
}),
),
)
}),
})
}),
)
+4 -5
View File
@@ -137,11 +137,10 @@ 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 (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.
// 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.
for (const item of directories) {
const key = item.projectID + "\u0000" + item.directory
if (announcing.has(key)) continue
+42 -53
View File
@@ -26,7 +26,6 @@ export type Info = Reference.Info
type Data = {
sources: Map<string, Types.DeepMutable<Source>>
materialized: Map<string, Info>
}
type Draft = {
@@ -48,71 +47,61 @@ const layer = Layer.effect(
const bus = yield* Bus.Service
const cache = yield* RepositoryCache.Service
const scope = yield* Scope.Scope
const state: State.Interface<Data, Draft> = State.create<Data, Draft>({
const materialized = new Map<string, Info>()
const state = State.create<Data, Draft>({
name: "reference",
initial: () => ({ sources: new Map(), materialized: new Map() }),
initial: () => ({ sources: 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][],
}),
prepare: (data) => {
for (const [name, source] of data.sources) {
if (source.type === "local") {
data.materialized.set(
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(
name,
Info.make({
name,
path: source.path,
path: AbsolutePath.make(Repository.cachePath(global.repos, repository, source.branch)),
...(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
}
}
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* 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),
)
}
yield* bus.publish(Reference.Event.Updated, {})
}),
@@ -122,7 +111,7 @@ const layer = Layer.effect(
transform: state.transform,
reload: state.reload,
list: Effect.fn("Reference.list")(function* () {
return Array.from(state.get().materialized.values())
return Array.from(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: replaying it later would
// require the Location-scoped registry that produced it.
// The rendered text is frozen into the durable event because re-rendering it
// later would require the original Location-scoped instruction sources.
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))
},
}),
notify: () => bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid),
finalize: () => bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid),
})
return Service.of({
+95 -89
View File
@@ -1,9 +1,9 @@
export * as State from "./state.js"
import { Clock, Context, Deferred, Effect, Exit, Scope } from "effect"
import { Clock, Context, Deferred, Effect, Scope, Semaphore } from "effect"
/**
* A replayable transform applied to a draft while deriving state.
* A replayable transform applied to a draft during reload.
*
* Domain drafts expose readable and writable state while preserving concise
* plugin/config code. Transforms synchronously rebuild derived state.
@@ -16,14 +16,13 @@ export interface Registration {
}
/**
* Registers a scoped transform and invalidates the derived state. Closing the
* owning Scope removes the transform. Reads synchronously replay pending changes.
* Registers and applies a scoped transform. Closing the owning Scope removes
* the transform and reloads the materialized state.
*/
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> {
@@ -34,7 +33,7 @@ export interface Transformable<DraftApi> {
type Batch = {
active: boolean
readonly flush: boolean
readonly notifications: Set<Reload>
readonly reloads: Set<Reload>
}
const CurrentBatch = Context.Reference<Batch | undefined>("@opencode/State/CurrentBatch", {
@@ -42,24 +41,17 @@ const CurrentBatch = Context.Reference<Batch | undefined>("@opencode/State/Curre
})
const reloadDebounce = 500
/** Batches notifications, not read visibility. flush: false is terminal teardown. */
/** flush: false is terminal teardown: states whose transforms are removed stop rebuilding, including pending reloads. */
export function batch<A, E, R>(effect: Effect.Effect<A, E, R>, options: { readonly flush?: boolean } = {}) {
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
}),
)
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
})
}
export const inherit = Effect.fnUntraced(function* () {
@@ -73,110 +65,124 @@ 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
/**
* 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.
* Runs after the rebuilt state becomes visible. Update events published here
* act as read barriers: subscribers refetching on the event observe the
* committed state.
*/
readonly notify?: () => Effect.Effect<void>
readonly finalize?: (draft: DraftApi) => 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()
const transforms = new Set<{ run: TransformCallback<DraftApi> }>()
let dirty = false
let transforms: { run: TransformCallback<DraftApi> }[] = []
let generation = 0
let requestedAt = 0
let running = false
let closed = false
let pending: Deferred.Deferred<void> | undefined
let waiters: { generation: number; done: Deferred.Deferred<void> }[] = []
const semaphore = Semaphore.makeUnsafe(1)
const get = () => {
if (!dirty || closed) return state
const next = options.initial()
const api = options.draft(next)
transforms.forEach((transform) => transform.run(api))
options.prepare?.(next)
const commit = Effect.fn("State.commit")(function* (next: State) {
state = next
dirty = false
return state
}
const notify = Effect.fn("State.notify")(function* () {
if (closed) return
get()
if (options.notify) yield* options.notify()
if (options.finalize) yield* options.finalize(options.draft(next))
})
const publish = (done: Deferred.Deferred<void>): Effect.Effect<void> =>
const materialize = Effect.fnUntraced(function* () {
if (closed) return
const next = options.initial()
const api = options.draft(next)
for (const transform of transforms) {
yield* Effect.sync(() => {
transform.run(api)
})
}
yield* commit(next)
})
const materializeReload = () => semaphore.withPermit(materialize())
const rebuild = (): 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* publish(done)
if (clock.currentTimeMillisUnsafe() < requestedAt + reloadDebounce) return yield* rebuild()
// 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 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
})
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))
}),
)
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)
})
return {
get,
get: () => state,
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(
Effect.suspend(() => {
if (!transforms.delete(transform)) return Effect.void
dirty = true
return changed(false)
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]
}),
)
transforms.add(transform)
dirty = true
yield* Scope.addFinalizer(scope, dispose)
yield* changed(false)
const batch = yield* CurrentBatch
if (batch?.active) batch.reloads.add(materializeReload)
else yield* materializeReload()
return { dispose }
}),
)
}),
reload: () => changed(true),
reload,
}
}
+1 -1
View File
@@ -185,7 +185,7 @@ const layer = Layer.effect(
draft.tools.delete(id)
},
}),
notify: () =>
finalize: () =>
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 applies generic output bounding.
- `src/tool.ts` stores canonical Location registrations, derives LLM definitions, executes tools, and normalizes model content and images.
- 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.
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.
Producer capture limits remain local to producers. For example, Bash keeps `AppProcess.maxOutputBytes` and accurately reports stdout/stderr capture loss.
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.
## Current Gaps
+1 -3
View File
@@ -5,9 +5,7 @@ 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 as Record<string, unknown>).name === name
)
return typeof error === "object" && error !== null && "name" in error && error.name === name
}
static create<Name extends string, Fields extends Schema.Struct.Fields>(
+10 -34
View File
@@ -1,7 +1,7 @@
export * as Vcs from "./vcs.js"
import path from "path"
import { Cause, Context, Effect, Exit, Fiber, FiberSet, Layer, Schema, Semaphore, Stream } from "effect"
import { Cause, Context, Effect, Layer, Schema, 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,11 +47,8 @@ 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,
@@ -72,12 +69,7 @@ const layer = Layer.effect(
set: (selection) => (draft.selection = selection),
},
}),
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
}),
finalize: () => refresh(),
})
const selected = () => {
const value = state.get()
@@ -95,23 +87,13 @@ const layer = Layer.effect(
),
)
const refresh = Effect.fn("Vcs.refresh")(function* () {
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
}
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 })
})
if (vcs) {
@@ -123,13 +105,7 @@ const layer = Layer.effect(
yield* bus.subscribe(FileSystem.Event.Changed).pipe(
Stream.filter((event) => isBranchMetadata(event.data.file)),
Stream.runForEach((event) =>
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 } }),
),
refresh().pipe(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),
},
}),
notify: () => bus.publish(WebSearch.Event.Updated, {}).pipe(Effect.asVoid),
finalize: () => bus.publish(WebSearch.Event.Updated, {}).pipe(Effect.asVoid),
})
const requireProvider = (providers: Map<ID, ProviderImplementation>, providerID: ID) => {
-53
View File
@@ -11,7 +11,6 @@ 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"
@@ -31,57 +30,6 @@ 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
@@ -343,7 +291,6 @@ 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
@@ -1,411 +0,0 @@
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)
}),
),
)
})
@@ -1,127 +0,0 @@
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,7 +31,10 @@ 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 })),
layer: Layer.succeed(
PluginSupervisor.Service,
PluginSupervisor.Service.of({ flush: Effect.void, check: () => Effect.succeed({ mutable: false }) }),
),
deps: [],
})
@@ -313,7 +316,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 })
return PluginSupervisor.Service.of({ flush: Effect.void, check: () => Effect.succeed({ mutable: false }) })
}),
),
deps: [LocationWatcherPolicy.node],
+1 -5
View File
@@ -14,10 +14,6 @@ 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()),
@@ -25,7 +21,7 @@ export const tempLocationLayer = Layer.unwrap(
).pipe(
Effect.map((tmp) => {
const ref = Location.Ref.make({ directory: AbsolutePath.make(tmp.path) })
return locationLayer(ref)
return Layer.succeed(Location.Service, Location.Service.of(location(ref)))
}),
),
)
@@ -12,7 +12,10 @@ export const promptLocationLayer = Layer.effect(
() =>
Layer.merge(
LayerNode.compile(PluginHooks.node),
Layer.succeed(PluginSupervisor.Service, { flush: Effect.void }),
Layer.succeed(PluginSupervisor.Service, {
flush: Effect.void,
check: () => Effect.succeed({ mutable: false }),
}),
) as Layer.Layer<LocationServices>,
),
)
@@ -1,73 +0,0 @@
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,7 +7,6 @@ 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])))
@@ -263,102 +262,6 @@ 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
+4 -139
View File
@@ -33,31 +33,16 @@ 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 {
Context,
Deferred,
Effect,
Exit,
Fiber,
Layer,
PubSub,
Ref,
Schedule,
Schema,
Scope,
Sink,
Stream,
} from "effect"
import { Deferred, Effect, Exit, Fiber, Layer, PubSub, Ref, Schedule, Schema, 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, locationLayer } from "./fixture/location"
import { location } from "./fixture/location"
import { hostEnvironmentLayer, recordingEnvironmentLayer } from "./fixture/environment"
import { executeTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
@@ -81,7 +66,7 @@ function resourceServer(
listChanged?: boolean
emptyElicitation?: boolean
urlElicitation?: boolean
respond?: (request: Request) => Response | undefined | Promise<Response | undefined>
respond?: (request: Request) => Response | undefined
} = {},
) {
return Effect.acquireRelease(
@@ -173,7 +158,7 @@ function resourceServer(
if (typeof body === "object" && body !== null && "method" in body && body.method === "initialize") {
state.initializations += 1
}
return (await input.respond?.(request)) ?? transport.handleRequest(request)
return input.respond?.(request) ?? transport.handleRequest(request)
},
})
return {
@@ -1370,126 +1355,6 @@ 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(
+165 -1
View File
@@ -55,6 +55,62 @@ 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")
@@ -131,6 +187,7 @@ 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 () => {
@@ -198,6 +255,7 @@ 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(
@@ -218,6 +276,7 @@ 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()
@@ -236,11 +295,27 @@ describe("Npm.add", () => {
const npm = yield* Npm.Service
const mutableEntry = yield* npm.add(mutable, { refresh: true })
const pinnedEntry = yield* npm.add(pinned, { refresh: true })
yield* Effect.promise(async () => {
const nextCommit = 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)
@@ -260,6 +335,8 @@ 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"')
@@ -291,5 +368,92 @@ 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)
})
})
+17 -73
View File
@@ -1,12 +1,10 @@
import { describe, expect } from "bun:test"
import { ToolFailure } from "@opencode-ai/ai"
import { Clock, Context, Duration, Effect, Exit, Fiber, Schema, Stream } from "effect"
import { Context, 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"
@@ -105,64 +103,6 @@ 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
@@ -510,19 +450,23 @@ describe("Plugin", () => {
}),
})
yield* plugins.activate([versioned(previous)])
yield* plugins.activate([versioned(replacement, "2")])
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" }])
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")
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")
}
}),
)
+248
View File
@@ -0,0 +1,248 @@
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,6 +38,7 @@ 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,6 +103,7 @@ 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,8 +3,6 @@ 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"
@@ -112,59 +110,6 @@ 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,6 +25,7 @@ function npmEntrypoint(entrypoint?: string) {
add: () => Effect.succeed({ directory: "", entrypoint }),
resolve: () => Effect.succeed({ directory: "", entrypoint }),
which: () => Effect.undefined,
check: () => Effect.succeed({ mutable: false }),
})
}
@@ -17,6 +17,7 @@ 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* () {
+9 -133
View File
@@ -1,147 +1,23 @@
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, testEffect } from "./lib/effect"
import { it } from "./lib/effect"
const cache = Layer.mock(RepositoryCache.Service, {
ensure: () => Effect.die("unexpected Git materialization"),
})
const referenceLayer = AppNodeBuilder.build(LayerNode.group([Reference.node, Bus.node]), [
[RepositoryCache.node, cache],
])
const referenceIt = testEffect(referenceLayer)
const referenceLayer = AppNodeBuilder.build(Reference.node, [[RepositoryCache.node, cache]])
describe("Reference", () => {
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", () =>
it.effect("registers normalized sources for the owning scope", () =>
Effect.gen(function* () {
const references = yield* Reference.Service
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 scope = yield* Scope.make()
const path = AbsolutePath.make("/docs")
const source = Reference.LocalSource.make({
type: "local",
@@ -157,10 +33,10 @@ describe("Reference", () => {
yield* Scope.close(scope, Exit.void)
expect(yield* references.list()).toEqual([])
}),
}).pipe(Effect.provide(referenceLayer)),
)
referenceIt.effect("derives Git paths without exposing cache operations", () =>
it.effect("derives Git paths without exposing cache operations", () =>
Effect.gen(function* () {
const references = yield* Reference.Service
const repository = Repository.parseRemote("owner/repo")
@@ -174,10 +50,10 @@ describe("Reference", () => {
source,
}),
])
}),
}).pipe(Effect.scoped, Effect.provide(referenceLayer)),
)
referenceIt.effect("preserves configured Git descriptions", () =>
it.effect("preserves configured Git descriptions", () =>
Effect.gen(function* () {
const references = yield* Reference.Service
const repository = Repository.parseRemote("owner/repo")
@@ -196,6 +72,6 @@ describe("Reference", () => {
source,
}),
])
}),
}).pipe(Effect.scoped, Effect.provide(referenceLayer)),
)
})
+42 -236
View File
@@ -60,25 +60,32 @@ 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* 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 db = yield* seedSession()
const bus = yield* Bus.Service
const inputID = SessionMessage.ID.make("msg_manual_compaction")
yield* SessionInbox.admitCompaction(db, bus, { id: inputID, sessionID, delivery: "queue" })
@@ -95,22 +102,7 @@ describe("SessionProjector", () => {
it.effect("loads legacy revert storage into canonical state", () =>
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",
})
.run()
const db = yield* seedSession()
const legacy = JSON.stringify({
messageID: "msg_boundary",
snapshot: "tree",
@@ -131,28 +123,14 @@ describe("SessionProjector", () => {
it.effect("projects staged, cleared, and committed reverts", () =>
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",
cost: 1.25,
tokens_input: 10,
tokens_output: 4,
tokens_reasoning: 2,
tokens_cache_read: 3,
tokens_cache_write: 1,
})
.run()
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 boundary = SessionMessage.ID.make("msg_boundary")
const earlier = SessionMessage.ID.make("msg_earlier")
yield* db
@@ -227,24 +205,7 @@ describe("SessionProjector", () => {
it.effect("orders projected messages and context by durable aggregate sequence", () =>
Effect.gen(function* () {
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* seedSession()
const bus = yield* Bus.Service
yield* bus.publish(SessionEvent.InboxEnqueued, {
@@ -300,24 +261,7 @@ describe("SessionProjector", () => {
it.effect("maps malformed persisted rows consistently while single-message lookup defects", () =>
Effect.gen(function* () {
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 db = yield* seedSession()
const messageID = SessionMessage.ID.make("msg_malformed")
yield* db
.insert(SessionMessageTable)
@@ -344,24 +288,7 @@ describe("SessionProjector", () => {
it.effect("consumes the pending row and projects the message at promotion", () =>
Effect.gen(function* () {
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 db = yield* seedSession()
const bus = yield* Bus.Service
const id = SessionMessage.ID.make("msg_admitted")
const admitted = yield* SessionInbox.admit(db, bus, {
@@ -387,26 +314,7 @@ describe("SessionProjector", () => {
it.effect("projects durable context messages supported by the updater", () =>
Effect.gen(function* () {
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 db = yield* seedSession({ agent: "plan", model: previousModel })
const bus = yield* Bus.Service
yield* bus.publish(SessionEvent.AgentSelected, {
@@ -532,24 +440,7 @@ describe("SessionProjector", () => {
it.effect("rejects distinct creator events that reuse one projected message ID", () =>
Effect.gen(function* () {
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 db = yield* seedSession()
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 } })
@@ -576,24 +467,7 @@ describe("SessionProjector", () => {
it.effect("projects retry state and clears it at the next step or execution terminal", () =>
Effect.gen(function* () {
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 db = yield* seedSession()
const bus = yield* Bus.Service
const first = SessionMessage.ID.make("msg_retry_first")
const second = SessionMessage.ID.make("msg_retry_second")
@@ -643,24 +517,7 @@ describe("SessionProjector", () => {
it.effect("does not infer restart continuation from lifecycle history", () =>
Effect.gen(function* () {
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 db = yield* seedSession()
const bus = yield* Bus.Service
const suspended = () =>
db
@@ -680,24 +537,7 @@ describe("SessionProjector", () => {
it.effect("updates only the newest incomplete assistant projection", () =>
Effect.gen(function* () {
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 db = yield* seedSession()
yield* db
.insert(SessionMessageTable)
.values([
@@ -757,24 +597,7 @@ describe("SessionProjector", () => {
it.effect("projects ended and failed step terminal state", () =>
Effect.gen(function* () {
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 db = yield* seedSession()
const endedID = SessionMessage.ID.make("msg_ended")
const failedID = SessionMessage.ID.make("msg_failed")
yield* db
@@ -844,24 +667,7 @@ describe("SessionProjector", () => {
it.effect("does not revive a stale incomplete assistant projection", () =>
Effect.gen(function* () {
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 db = yield* seedSession()
yield* db
.insert(SessionMessageTable)
.values([
+4 -1
View File
@@ -83,7 +83,10 @@ const locations = Layer.effect(
}),
Layer.succeed(
PluginSupervisor.Service,
PluginSupervisor.Service.of({ flush: Effect.sync(() => (ready = true)) }),
PluginSupervisor.Service.of({
flush: Effect.sync(() => (ready = true)),
check: () => Effect.succeed({ mutable: false }),
}),
),
)
}),
@@ -85,7 +85,10 @@ 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 }))
const pluginSupervisor = Layer.succeed(
PluginSupervisor.Service,
PluginSupervisor.Service.of({ flush: Effect.void, check: () => Effect.succeed({ mutable: false }) }),
)
const promptCatalog = Layer.mock(Catalog.Service, {
provider: {
get: () => Effect.undefined,
@@ -390,6 +390,7 @@ 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 }),
Layer.succeed(PluginSupervisor.Service, { flush: Effect.void, check: () => Effect.succeed({ mutable: false }) }),
)
const locations = Layer.effect(
LocationServiceMap.Service,
+24 -121
View File
@@ -198,18 +198,20 @@ 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* () {
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."
})
})
yield* enableTitleAgent
const sessionID = Session.ID.make("ses_title_generate")
yield* insertSession(sessionID)
yield* prompt(sessionID, "Help me debug the failing build")
@@ -240,17 +242,8 @@ 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
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."
})
})
yield* enableTitleAgent
const sessionID = Session.ID.make("ses_title_small_model")
yield* insertSession(sessionID)
yield* prompt(sessionID, "Use a small model for this title")
@@ -267,20 +260,12 @@ 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
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."
})
})
yield* enableTitleAgent
const sessionID = Session.ID.make("ses_title_small_fallback")
yield* insertSession(
sessionID,
@@ -314,16 +299,7 @@ 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* () {
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."
})
})
yield* enableTitleAgent
const sessionID = Session.ID.make("ses_title_second_message")
yield* insertSession(sessionID)
yield* prompt(sessionID, "First message")
@@ -342,16 +318,7 @@ it.effect("generates from the first user message after later messages exist", ()
it.effect("retries a legacy persisted fallback title", () =>
Effect.gen(function* () {
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."
})
})
yield* enableTitleAgent
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)
@@ -368,16 +335,7 @@ it.effect("retries a legacy persisted fallback title", () =>
it.effect("generates a title for an explicitly requested child session", () =>
Effect.gen(function* () {
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."
})
})
yield* enableTitleAgent
const sessionID = Session.ID.make("ses_title_child")
const { db } = yield* Database.Service
yield* db
@@ -411,8 +369,6 @@ 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")
@@ -429,14 +385,7 @@ it.effect("does not generate when the title agent is removed", () =>
it.effect("regenerates an existing title using the title agent", () =>
Effect.gen(function* () {
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."
})
})
yield* enableTitleAgent
const sessionID = Session.ID.make("ses_title_regenerate")
yield* insertSession(sessionID, "Original title")
yield* prompt(sessionID, "Investigate the login failure")
@@ -480,14 +429,7 @@ 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* () {
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."
})
})
yield* enableTitleAgent
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`)
@@ -509,14 +451,7 @@ it.effect("bounds regeneration context while preserving the original request and
it.effect("preserves the existing title when regeneration fails", () =>
Effect.gen(function* () {
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."
})
})
yield* enableTitleAgent
const sessionID = Session.ID.make("ses_title_regenerate_failure")
yield* insertSession(sessionID, "Original title")
yield* prompt(sessionID, "Fail to regenerate this title")
@@ -533,15 +468,7 @@ it.effect("preserves the existing title when regeneration fails", () =>
it.effect("retries after a failed title request", () =>
Effect.gen(function* () {
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."
})
})
yield* enableTitleAgent
const sessionID = Session.ID.make("ses_title_retry")
yield* insertSession(sessionID)
yield* prompt(sessionID, "Retry this title")
@@ -560,14 +487,7 @@ it.effect("retries after a failed title request", () =>
it.effect("does not rename after a failed title stream", () =>
Effect.gen(function* () {
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."
})
})
yield* enableTitleAgent
const sessionID = Session.ID.make("ses_title_stream_failure")
yield* insertSession(sessionID)
yield* prompt(sessionID, "Fail this title stream")
@@ -589,16 +509,7 @@ it.effect("does not rename after a failed title stream", () =>
it.effect("keeps session context hooks away from title requests", () =>
Effect.gen(function* () {
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."
})
})
yield* enableTitleAgent
// 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
@@ -621,15 +532,7 @@ 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* () {
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."
})
})
yield* enableTitleAgent
const sessionID = Session.ID.make("ses_title_manual_rename")
yield* insertSession(sessionID)
yield* prompt(sessionID, "Generate this title")
+84 -476
View File
@@ -1,18 +1,10 @@
import { describe, expect } from "bun:test"
import { State } from "@opencode-ai/core/state"
import { Cause, Deferred, Effect, Exit, Fiber, Scheduler, Scope } from "effect"
import { Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect"
import { TestClock } from "effect/testing"
import { it } from "./lib/effect"
import { testEffect } from "./lib/effect"
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,
})
}
const it = testEffect(Layer.empty)
describe("State", () => {
it.effect("commits a transform atomically when its updater is interrupted", () =>
@@ -20,13 +12,17 @@ describe("State", () => {
const rebuilding = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
let block = true
const state = valuesState({
notify: () =>
const state = State.create({
initial: () => ({ values: [] as string[] }),
draft: (draft) => ({ add: (value: string) => draft.values.push(value) }),
finalize: () =>
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)
@@ -40,16 +36,20 @@ describe("State", () => {
}),
)
it.effect("makes rebuilt state visible before notifying", () =>
it.effect("commits rebuilt state before finalize runs", () =>
Effect.gen(function* () {
const observed: string[][] = []
const state: ReturnType<typeof valuesState> = valuesState({
notify: () => Effect.sync(() => observed.push([...state.get().values])),
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])),
})
yield* state.transform((draft) => draft.add("value"))
yield* state.transform((draft) => {
draft.add("value")
})
// Update events publish from notify, so consumers reading on the event
// Update events publish from finalize, so consumers reading on the event
// must observe the rebuilt state, not the previous one.
expect(observed).toEqual([["value"]])
}),
@@ -58,9 +58,14 @@ describe("State", () => {
it.effect("runs transforms during every reload", () =>
Effect.gen(function* () {
let value = "first"
const state = valuesState()
const state = State.create({
initial: () => ({ values: [] as string[] }),
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
})
yield* state.transform((editor) => editor.add(value))
yield* state.transform((editor) => {
editor.add(value)
})
expect(state.get().values).toEqual(["first"])
value = "second"
@@ -71,327 +76,18 @@ 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 = valuesState()
yield* state.transform((editor) => editor.add("first"))
const registration = yield* state.transform((editor) => editor.add("second"))
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")
})
expect(state.get().values).toEqual(["first", "second"])
yield* registration.dispose
@@ -402,137 +98,49 @@ describe("State", () => {
}),
)
it.effect("batches notifications", () =>
it.effect("batches automatic rebuilds", () =>
Effect.gen(function* () {
let notifications = 0
const first = valuesState({ notify: () => Effect.sync(() => notifications++) })
const second = valuesState({ notify: () => Effect.sync(() => notifications++) })
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++),
})
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(notifications).toBe(0)
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)
}),
)
expect(first.get().values).toEqual(["first", "second"])
expect(second.get().values).toEqual(["third"])
expect(notifications).toBe(2)
expect(finalized).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 notifications = 0
let prepared = 0
let finalized = 0
let disposed = 0
const state = valuesState({
prepare: () => {
prepared++
},
notify: () => Effect.sync(() => notifications++),
const state = State.create({
initial: () => ({ values: [] as string[] }),
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
finalize: () => Effect.sync(() => finalized++),
})
const scope = yield* Scope.make()
yield* Scope.addFinalizer(
@@ -540,44 +148,38 @@ describe("State", () => {
Effect.sync(() => disposed++),
)
const registration = yield* state.transform((draft) => draft.add("value")).pipe(Scope.provide(scope))
const snapshot = state.get()
expect(notifications).toBe(1)
expect(prepared).toBe(1)
expect(finalized).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(notifications).toBe(1)
expect(state.get()).toBe(snapshot)
expect(prepared).toBe(1)
expect(finalized).toBe(1)
yield* TestClock.adjust("500 millis")
yield* Fiber.join(pending)
yield* registration.dispose
yield* state.reload()
expect(notifications).toBe(1)
expect(state.get()).toBe(snapshot)
expect(prepared).toBe(1)
expect(finalized).toBe(1)
}),
)
it.effect("keeps teardown suppression separate from an enclosing live batch", () =>
Effect.gen(function* () {
const notifications: string[] = []
const finalized: string[] = []
const closing = State.create({
initial: () => ({}),
draft: (draft) => draft,
notify: () => Effect.sync(() => notifications.push("closing")),
finalize: () => Effect.sync(() => finalized.push("closing")),
})
const live = State.create({
initial: () => ({}),
draft: (draft) => draft,
notify: () => Effect.sync(() => notifications.push("live")),
finalize: () => Effect.sync(() => finalized.push("live")),
})
const scope = yield* Scope.make()
yield* closing.transform(() => {}).pipe(Scope.provide(scope))
notifications.length = 0
finalized.length = 0
yield* State.batch(
Effect.gen(function* () {
@@ -585,27 +187,33 @@ describe("State", () => {
yield* State.batch(Scope.close(scope, Exit.void), { flush: false })
}),
)
expect(notifications).toEqual(["live"])
expect(finalized).toEqual(["live"])
}),
)
it.effect("debounces reload bursts", () =>
Effect.gen(function* () {
let notifications = 0
const state = valuesState({ notify: () => Effect.sync(() => notifications++) })
yield* state.transform((draft) => draft.add("value"))
notifications = 0
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
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(notifications).toBe(0)
expect(finalized).toBe(0)
yield* TestClock.adjust("1 millis")
yield* Fiber.join(first)
yield* Fiber.join(second)
expect(notifications).toBe(1)
expect(finalized).toBe(1)
}),
)
})
+21 -28
View File
@@ -311,7 +311,7 @@ describe("Tool", () => {
}),
)
it.effect("reads refreshed sources before notifications and keeps advertised snapshots", () =>
it.effect("replays empty sources on reload 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 }))
const advertised = yield* service.snapshot()
expect((yield* advertised.execute(call("echo"))).output).toEqual({ text: "first" })
yield* TestClock.adjust("500 millis")
yield* Fiber.join(first)
const advertised = yield* service.snapshot()
expect((yield* advertised.execute(call("echo"))).output).toEqual({ text: "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 notifications with fresh snapshots and suppresses terminal teardown replay", () =>
it.effect("batches tool publication and suppresses terminal teardown replay", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
const runs: string[] = []
@@ -386,8 +386,7 @@ describe("Tool", () => {
draft.add({ ...constant("overlay"), name: "echo", options: { codemode: false } })
})
expect(runs).toEqual([])
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["echo", "execute"])
expect(runs).toEqual(["base", "overlay"])
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
}).pipe(Scope.provide(scope)),
)
@@ -546,32 +545,23 @@ describe("Tool", () => {
}),
)
it.effect("compiles healthy tools before notifying invalid definition diagnostics", () => {
it.effect("logs invalid tool definitions without dropping healthy tools", () => {
const output: unknown[] = []
const logger = Logger.map(Logger.formatStructured, (entry) => {
output.push(entry.message)
})
return Effect.gen(function* () {
const service = yield* Tool.Service
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
}),
)
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" })
})
expect(output).toEqual([
[
@@ -583,6 +573,9 @@ 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])))
})
+3 -1
View File
@@ -127,7 +127,9 @@ const shellPluginSupervisor = makeLocationNode({
service: PluginSupervisor.Service,
layer: Layer.effect(
PluginSupervisor.Service,
registerToolPlugin(ShellTool.Plugin).pipe(Effect.as(PluginSupervisor.Service.of({ flush: Effect.void }))),
registerToolPlugin(ShellTool.Plugin).pipe(
Effect.as(PluginSupervisor.Service.of({ flush: Effect.void, check: () => Effect.succeed({ mutable: false }) })),
),
),
deps: [
Config.node,
+3 -1
View File
@@ -100,7 +100,9 @@ const subagentPluginSupervisor = makeLocationNode({
service: PluginSupervisor.Service,
layer: Layer.effect(
PluginSupervisor.Service,
registerToolPlugin(SubagentTool.Plugin).pipe(Effect.as(PluginSupervisor.Service.of({ flush: Effect.void }))),
registerToolPlugin(SubagentTool.Plugin).pipe(
Effect.as(PluginSupervisor.Service.of({ flush: Effect.void, check: () => Effect.succeed({ mutable: false }) })),
),
),
deps: [Agent.node, Config.node, Permission.node, PluginRuntime.node, Tool.node],
})
+106 -387
View File
@@ -2,40 +2,35 @@ import { $ } from "bun"
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Cause, Context, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, Scope, Stream } from "effect"
import { TestClock } from "effect/testing"
import { Cause, Effect, Exit, Fiber, Layer, Stream } from "effect"
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 { locationLayer } from "./fixture/location"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { it, testEffect } from "./lib/effect"
import { it } 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,
locationLayer(
{ directory: AbsolutePath.make(directory) },
input.git ? { vcs: { type: "git", store: AbsolutePath.make(path.join(directory, ".git")) } } : {},
Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: AbsolutePath.make(directory) },
input.git ? { vcs: { type: "git", store: AbsolutePath.make(path.join(directory, ".git")) } } : {},
),
),
),
],
]),
@@ -89,36 +84,40 @@ const provider = (input: Partial<VcsDefinition> = {}) =>
}) satisfies VcsDefinition
describe("Vcs", () => {
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("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("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")
})
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")
})
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([])
}),
yield* registration.dispose
expect(yield* vcs.info()).toEqual({ branch: {} })
expect(yield* vcs.status()).toEqual([])
}).pipe(provide(directory)),
),
)
it.live("automatically selects a provider matching the resolved repository", () =>
@@ -134,360 +133,80 @@ describe("Vcs", () => {
),
)
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) =>
it.live("passes location scope and bounded diff options to providers", () =>
withTmp((directory) =>
Effect.gen(function* () {
const observed: VcsDiffInput[] = []
const vcs = yield* Vcs.Service
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({
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
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("serializes filesystem and config refreshes while reading the latest desired provider", () =>
withGit((directory) =>
Effect.gen(function* () {
const vcs = yield* Vcs.Service
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({
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" } }
}),
}),
),
)
const updates = yield* bus
.subscribe(VcsEvent.BranchUpdated)
.pipe(Stream.take(2), Stream.runLast, Effect.forkScoped({ startImmediately: true }))
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)))
}),
),
)
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())
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* 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))
}),
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)),
),
)
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,
)
it.live("validates provider results and bounds oversized patches", () =>
withTmp((directory) =>
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")
})
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")
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* 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("preserves provider interruption", () =>
withTmp((directory) =>
Effect.gen(function* () {
const vcs = yield* Vcs.Service
yield* vcs.transform((draft) => {
draft.add(provider({ 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()
}).pipe(provide(directory)),
),
)
it.live("lists local branches by recent activity", () =>
+1
View File
@@ -289,6 +289,7 @@ 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,6 +486,123 @@
"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"],
@@ -16307,6 +16424,9 @@
"source": {
"$ref": "#/components/schemas/Plugin.Source"
},
"revision": {
"type": "string"
},
"status": {
"type": "string",
"enum": ["active"]
@@ -16327,6 +16447,9 @@
"source": {
"$ref": "#/components/schemas/Plugin.Source"
},
"revision": {
"type": "string"
},
"status": {
"type": "string",
"enum": ["failed"]
@@ -16343,6 +16466,22 @@
}
]
},
"Plugin.PackageStatus": {
"type": "object",
"properties": {
"installed": {
"type": "string"
},
"available": {
"type": "string"
},
"mutable": {
"type": "boolean"
}
},
"required": ["mutable"],
"additionalProperties": false
},
"Plugin.Source": {
"anyOf": [
{
@@ -16397,6 +16536,20 @@
}
]
},
"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,6 +19,23 @@ 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,12 +19,14 @@ 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,
@@ -32,6 +34,19 @@ 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
@@ -0,0 +1,32 @@
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)) }
return { flush: release.open.pipe(Effect.andThen(plugins.flush)), check: plugins.check }
}),
).pipe(Layer.provide(PluginSupervisor.layer))
const opencode = yield* fixture.sdk.OpenCode.create(
+10 -5
View File
@@ -1,13 +1,18 @@
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()))
}),
),
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))),
),
)
+2 -64
View File
@@ -1,27 +1,14 @@
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 { 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 { Deferred, Effect, Exit, Fiber, Option, Schema, Stream } from "effect"
import { it } 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}`),
@@ -57,55 +44,6 @@ 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,6 +50,7 @@ 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
@@ -0,0 +1,76 @@
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)
}),
)
+24 -9
View File
@@ -41,6 +41,7 @@ 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()
@@ -96,6 +97,7 @@ 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
@@ -648,7 +650,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 = () => tab.title ?? "Untitled session"
const title = () => (props.controller ? undefined : session()?.title) ?? tab.title ?? "Untitled session"
const scrolling = () => marquee.active() === tab.sessionID
const visibleTitleParts = createMemo(() =>
scrolling()
@@ -907,14 +909,21 @@ function VerticalSessionTabs(props: {
unreadMarker={props.unreadMarker}
attributes={selected() ? TextAttributes.BOLD : undefined}
/>
<text
<title_shimmer
width={titleWidth()}
height={1}
fg={foreground()}
rename={{ pending: status().renaming, title: title() }}
enabled={animations()}
backdrop={pulseBackground()}
wrapMode="none"
selectable={false}
attributes={
(selected() ? TextAttributes.BOLD : 0) |
(tabs.isPreview?.(tab.sessionID) ? TextAttributes.ITALIC : 0) || undefined
(status().renaming && !animations()
? TextAttributes.DIM
: selected()
? TextAttributes.BOLD
: 0) | (tabs.isPreview?.(tab.sessionID) ? TextAttributes.ITALIC : 0) || undefined
}
>
<Show
@@ -927,7 +936,7 @@ function VerticalSessionTabs(props: {
)}
</Index>
</Show>
</text>
</title_shimmer>
<text
position="absolute"
right={1}
@@ -1075,6 +1084,7 @@ 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
@@ -1364,7 +1374,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 = () => tab.title ?? "Untitled session"
const title = () => data?.session.get(tab.sessionID)?.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.
@@ -1492,13 +1502,18 @@ function HorizontalSessionTabs(props: {
unreadMarker={props.unreadMarker}
attributes={bold()}
/>
<text
<title_shimmer
width={availableTitleWidth()}
height={1}
fg={foreground()}
rename={{ pending: status().renaming, title: title() }}
enabled={animations()}
backdrop={background()}
wrapMode="none"
selectable={false}
attributes={
(bold() ?? 0) | (tabs.isPreview?.(tab.sessionID) ? TextAttributes.ITALIC : 0) || undefined
(status().renaming && !animations() ? TextAttributes.DIM : (bold() ?? 0)) |
(tabs.isPreview?.(tab.sessionID) ? TextAttributes.ITALIC : 0) || undefined
}
>
<Show when={scrolling() || glows() || titleFades()} fallback={visibleTitle()}>
@@ -1508,7 +1523,7 @@ function HorizontalSessionTabs(props: {
)}
</Index>
</Show>
</text>
</title_shimmer>
<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))
const smootherstep = (value: number) => value * value * value * (value * (value * 6 - 15) + 10)
export 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()
const intensityAt = (index: number, front: number, head: number, tail: number) => {
export 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))
}
const coast = (value: number) => {
export 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))
@@ -0,0 +1,269 @@
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,6 +269,7 @@ 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"),
+23 -1
View File
@@ -1,5 +1,6 @@
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"
@@ -17,6 +18,27 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
directory: props.directory,
})
data satisfies Plugin.Context["data"]
return 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))
},
},
},
}
},
})
@@ -173,6 +173,7 @@ 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,10 +1,15 @@
import type { PluginInfo } from "@opencode-ai/client"
import { Plugin } from "@opencode-ai/plugin/tui"
import { createEffect, createMemo, createResource, createSignal, onMount, Show } from "solid-js"
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 { DialogErrorDetails } from "../../component/dialog-error-details"
import { usePlugin } from "../../plugin/context"
import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select"
import { localSource } from "../../plugin/discovery"
import { DialogSelect, type DialogSelectOption, type DialogSelectRef } from "../../ui/dialog-select"
import { useDialog } from "../../ui/dialog"
import { errorMessage } from "../../util/error"
const id = "opencode.plugins"
@@ -17,6 +22,8 @@ type Entry =
readonly target: string
readonly status: "active" | "inactive" | "failed"
readonly error?: string
readonly revision?: string
readonly builtin?: boolean
}
export function PluginsDialog(props: {
@@ -25,18 +32,28 @@ export function PluginsDialog(props: {
server?: () => readonly PluginInfo[]
}) {
const dialog = useDialog()
const dimensions = useTerminalDimensions()
const [locked, setLocked] = createSignal(false)
const [focused, setFocused] = createSignal<string>()
const [detail, setDetail] = createSignal<Entry>()
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 [initial, setInitial] = createSignal<string>()
const [server] = createResource(
() => (props.server ? undefined : (props.context.location ?? props.context.data.location.default())),
const [server, { refetch }] = createResource(
() => (props.server ? undefined : 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 builtins: Entry[] = props.plugins
.registered()
const registered = props.plugins.registered()
const builtins: Entry[] = registered
.filter((plugin) => plugin.id !== id && plugin.source === "builtin")
.map((plugin) => ({
key: `tui:${plugin.id}`,
@@ -44,6 +61,7 @@ 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()
@@ -55,6 +73,7 @@ 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)}`,
@@ -71,9 +90,68 @@ 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> => ({
@@ -81,11 +159,18 @@ 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: status(entry) === "active" ? undefined : status(entry),
footerColor:
status(entry) === "failed"
? props.context.theme.text.feedback.error.default
: props.context.theme.text.subdued,
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,
gutter:
status(entry) === "active"
? () => <text fg={props.context.theme.text.feedback.success.default}></text>
@@ -95,10 +180,10 @@ export function PluginsDialog(props: {
}),
),
)
const focusedEntry = createMemo(() => entries().find((entry) => entry.key === focused()))
const focusedEntry = createMemo(() => entries().find((entry) => entry.key === list()?.selected?.value))
const focusedTui = createMemo(() => {
const entry = focusedEntry()
if (entry?.runtime !== "tui" || !entry.id) return
if (entry?.runtime !== "tui" || !entry.id) return undefined
return entry
})
const toggleTitle = createMemo(() => {
@@ -125,10 +210,18 @@ 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={detail()}
when={detailEntry()}
fallback={
<DialogSelect
title="Plugins"
@@ -136,45 +229,133 @@ export function PluginsDialog(props: {
current={initial()}
locked={locked()}
preserveSelection={true}
onMove={(option) => setFocused(option.value)}
initialFilter={filter()}
onFilter={setFilter}
ref={setList}
onSelect={(option) => {
const entry = entries().find((entry) => entry.key === option.value)
if (pluginError(entry)) setDetail(entry)
setInitial(option.value)
setDetail(option.value)
}}
actions={
focusedTui()
actions={[
...(focusedTui() && !busy()
? [
{
title: toggleTitle(),
command: "plugins.toggle",
onTrigger: (option) => toggle(entries().find((entry) => entry.key === option.value)),
onTrigger: (option: DialogSelectOption<string>) =>
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={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 when={busy()}>
<text fg={props.context.theme.text.subdued}>Checking...</text>
</Show>
}
/>
}
>
{(entry) => (
<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
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>
)}
</Show>
</box>
@@ -187,7 +368,7 @@ function label(entry: Entry, context: Plugin.Context) {
}
function pluginSource(entry: Entry, context: Plugin.Context) {
if (entry.runtime === "tui") return entry.target
if (entry.runtime === "tui") return entry.builtin ? "builtin" : entry.target
return source(entry.plugin, context)
}
+40 -6
View File
@@ -1,4 +1,5 @@
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 {
@@ -33,7 +34,8 @@ import { createSourceWatcher } from "./watch"
import { discoverTuiPlugins, freshSpecifier, localSource } from "./discovery"
export interface PackageResolver {
readonly resolve: (spec: string, install?: boolean) => Promise<string | undefined>
readonly resolve: (spec: string, install?: boolean) => Promise<{ entrypoint?: string; revision?: string }>
readonly check: (spec: string) => Promise<PackageStatus>
}
type State =
@@ -45,6 +47,7 @@ type RegisteredPlugin = {
readonly id: string
readonly source: "builtin" | "external"
readonly active: boolean
readonly revision?: string
}
type Value = {
@@ -60,6 +63,7 @@ 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 = {
@@ -67,6 +71,7 @@ type Registration = {
source: RegisteredPlugin["source"]
target?: string
version: string
revision?: string
options?: Readonly<Record<string, any>>
active: boolean
routes: Record<string, Page>
@@ -76,10 +81,14 @@ type Registration = {
}
// One entry of the desired plugin generation produced by the resolve phase.
type Desired = Pick<Registration, "plugin" | "source" | "target" | "version" | "options"> & { enabled: boolean }
type Desired = Pick<Registration, "plugin" | "source" | "target" | "version" | "revision" | "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>>>,
@@ -334,6 +343,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
source: "external",
target,
version: resolved.version,
revision: resolved.revision,
options,
enabled: true,
})
@@ -534,6 +544,7 @@ 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 },
@@ -542,6 +553,13 @@ 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}
@@ -580,8 +598,16 @@ 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 }
const entrypoint = local ? await resolveLocal(local) : await packages.resolve(spec, install)
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
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.
@@ -589,7 +615,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 }
return { status: "unchanged" as const, plugin: previous.plugin, version, revision: previous.revision }
const mod: { readonly default?: unknown } = await import(version)
if (generation !== undefined) {
const observed = await sourceGeneration(entrypoint)
@@ -601,7 +627,13 @@ async function resolvePlugin(
}
}
if (!isPlugin(mod.default)) throw new Error(`Invalid V2 TUI plugin module: ${spec}`)
return { status: "loaded" as const, plugin: mod.default, version }
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),
}
}
}
@@ -611,6 +643,7 @@ function toRegistration(item: Desired): Registration {
source: item.source,
target: item.target,
version: item.version,
revision: item.revision,
options: snapshotOptions(item.options),
active: false,
routes: {},
@@ -626,6 +659,7 @@ function toDesired(item: Registration): Desired {
source: item.source,
target: item.target,
version: item.version,
revision: item.revision,
options: item.options,
enabled: item.active,
}
+49 -19
View File
@@ -7,25 +7,55 @@ export function createHistoryPrepend(input: {
active: (sessionID: string) => boolean
scrollBy: (amount: number) => void
}) {
let loading = false
let pending: { scrollBy: number; continuation?: () => void; after?: () => void } | 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
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 true
}
after(continuation: () => void) {
if (!pending) return continuation()
// A jump supersedes deferred scrolling, but must wait for anchor compensation.
pending.scrollBy = 0
pending.after = continuation
},
},
)
}
+78 -13
View File
@@ -1,4 +1,5 @@
import {
batch,
createContext,
createEffect,
createMemo,
@@ -288,6 +289,7 @@ 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()
@@ -300,6 +302,9 @@ export function Session(props: {
const clearMessageNavigation = () => {
ensureAllRowsPending?.splice(0)
prependHistory.cancel()
firstJump()?.()
setFirstJump(undefined)
setNavigationSlack(0)
setNavigationMessage(undefined)
}
@@ -370,6 +375,8 @@ 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()
@@ -452,6 +459,7 @@ 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)
@@ -469,12 +477,13 @@ export function Session(props: {
}
function isAwayFromBottom() {
if (revealingOlderRows || revealingNewerRows || ensureAllRowsPending || navigationMessage()) return true
if (revealingOlderRows || revealingNewerRows || ensureAllRowsPending || navigationMessage() || firstJump())
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
const preserveWindow = revealingOlderRows || revealingNewerRows || !!ensureAllRowsPending || !!firstJump()
if (isAwayFromBottom()) setHiddenRows((current) => current ?? hidden())
if (awayTimer) clearTimeout(awayTimer)
awayTimer = setTimeout(() => {
@@ -659,6 +668,7 @@ 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) {
@@ -759,17 +769,65 @@ export function Session(props: {
group: "Session",
palette: undefined,
run: () => {
if (firstJump()) return
clearMessageNavigation()
const first = () => {
if (data.session.message.more(route.sessionID)) {
prependHistory(0, first)
return
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)
})
}
ensureAllRows(() => {
scroll.scrollTo(0)
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())
})
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()
},
)
}
first()
prependHistory.after(start)
dialog.clear()
},
},
@@ -801,9 +859,12 @@ 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)
void client.api.session
.rename({ sessionID: route.sessionID, title: input.trim() })
.catch((error) => toast.error(error))
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))
},
},
{
@@ -1325,6 +1386,7 @@ 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()
@@ -1352,7 +1414,10 @@ export function Session(props: {
</scrollbox>
</box>
<box height={1} flexShrink={0} flexDirection="row" justifyContent="flex-end">
<Show when={awayFromBottom()}>
<Show when={firstJump()}>
<text fg={theme.text.feedback.info.default}>Loading session history...</text>
</Show>
<Show when={!firstJump() && awayFromBottom()}>
<box
id="session-jump-to-latest"
paddingLeft={1}
+20 -5
View File
@@ -4,6 +4,8 @@ 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"
@@ -45,11 +47,24 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
>
<box flexShrink={0} gap={1} paddingRight={1}>
<box paddingRight={1}>
<text fg={theme.text.default}>
<b>{withTimestampedFallback(session()!)}</b>
</text>
<Show when={session()!.location.workspaceID}>
<text fg={theme.text.subdued}>{session()!.location.workspaceID}</text>
<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>
</Show>
</box>
<Slot path="sidebar.content" input={{ sessionID: props.sessionID }} />
+26 -16
View File
@@ -17,6 +17,7 @@ export interface DialogSelectProps<T> {
title: string
titleView?: JSX.Element
placeholder?: string
initialFilter?: string
footer?: JSX.Element
emptyView?: JSX.Element
noMatchView?: JSX.Element
@@ -93,6 +94,7 @@ export function dialogSelectContentWidth(dialogWidth: number) {
export type DialogSelectRef<T> = {
filter: string
filtered: DialogSelectOption<T>[]
selected: DialogSelectOption<T> | undefined
moveTo(value: T): void
}
@@ -111,7 +113,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
const [store, setStore] = createStore({
selected: 0,
filter: "",
filter: props.initialFilter ?? "",
input: "keyboard" as "keyboard" | "mouse",
})
const [focusedAction, setFocusedAction] = createSignal<number>()
@@ -305,21 +307,25 @@ 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)
}),
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) },
),
)
function move(direction: number) {
@@ -526,6 +532,9 @@ 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)
@@ -626,6 +635,7 @@ 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(() => {
+93 -12
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 () => undefined },
packages: { resolve: async () => ({}), check: async () => ({ mutable: false }) },
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 () => undefined },
packages: { resolve: async () => ({}), check: async () => ({ mutable: false }) },
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 () => undefined },
packages: { resolve: async () => ({}), check: async () => ({ mutable: false }) },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy" },
log: () => {},
@@ -223,6 +223,87 @@ 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()
@@ -273,7 +354,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 () => undefined },
packages: { resolve: async () => ({}), check: async () => ({ mutable: false }) },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy", prompt: "RESUME_READY" },
log: () => {},
@@ -354,7 +435,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 () => undefined },
packages: { resolve: async () => ({}), check: async () => ({ mutable: false }) },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
args: {},
log: () => {},
@@ -428,7 +509,7 @@ test("error investigations repeatedly seed editable home drafts without creating
get: async () => ({ animations: false, keybinds: { "mcp.list": "f6" } }),
update: async () => ({}),
},
packages: { resolve: async () => undefined },
packages: { resolve: async () => ({}), check: async () => ({ mutable: false }) },
args: {},
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
log: () => {},
@@ -509,7 +590,7 @@ test("shows jump to latest after scrolling one line above the final message", as
}),
update: async () => ({}),
},
packages: { resolve: async () => undefined },
packages: { resolve: async () => ({}), check: async () => ({ mutable: false }) },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy" },
log: () => {},
@@ -597,7 +678,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 () => undefined },
packages: { resolve: async () => ({}), check: async () => ({ mutable: false }) },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy" },
log: () => {},
@@ -687,7 +768,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 () => undefined },
packages: { resolve: async () => ({}), check: async () => ({ mutable: false }) },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
args: {},
log: () => {},
@@ -768,7 +849,7 @@ test("configured app bindings execute settings and permission commands", async (
}),
update: async () => ({}),
},
packages: { resolve: async () => undefined },
packages: { resolve: async () => ({}), check: async () => ({ mutable: false }) },
args: {},
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
log: () => {},
@@ -822,7 +903,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 () => undefined },
packages: { resolve: async () => ({}), check: async () => ({ mutable: false }) },
args: {},
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
log: () => {},
@@ -934,7 +1015,7 @@ test.each(["manual", "select"] as const)(
}),
update: async () => ({}),
},
packages: { resolve: async () => undefined },
packages: { resolve: async () => ({}), check: async () => ({ mutable: false }) },
args: { sessionID: session.id },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
log: () => {},
@@ -101,3 +101,37 @@ 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 () => undefined },
packages: { resolve: async () => ({}), check: async () => ({ mutable: false }) },
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 () => undefined },
packages: { resolve: async () => ({}), check: async () => ({ mutable: false }) },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
args: { sessionID },
log: () => {},
@@ -0,0 +1,145 @@
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()
}
})
@@ -0,0 +1,47 @@
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++
}
},
})
+4 -3
View File
@@ -7,6 +7,7 @@ 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 `
@@ -57,7 +58,7 @@ async function bootApp(
directory: string,
options?: {
plugins?: unknown[]
resolve?: (spec: string, install?: boolean) => Promise<string | undefined>
resolve?: PackageResolver["resolve"]
},
) {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
@@ -91,7 +92,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 () => undefined) },
packages: { resolve: options?.resolve ?? (async () => ({})), check: async () => ({ mutable: false }) },
args: {},
log: () => {},
}).pipe(
@@ -128,7 +129,7 @@ test("loads an advertised package TUI entrypoint only from the local cache", asy
],
resolve: async (spec, install) => {
resolutions.push({ spec, install })
return pathToFileURL(entrypoint).href
return { entrypoint: pathToFileURL(entrypoint).href, revision: "1.0.0" }
},
})
@@ -0,0 +1,347 @@
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
@@ -0,0 +1,389 @@
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,6 +53,7 @@
"mime-types": "3.0.2",
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"pacote": "21.5.1",
"resolve.exports": "catalog:"
},
"devDependencies": {
@@ -62,6 +63,7 @@
"@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:"
}
}
+98 -21
View File
@@ -22,6 +22,7 @@ export class InstallFailedError extends Schema.TaggedError<InstallFailedError>()
export interface EntryPoint {
readonly directory: string
readonly entrypoint?: string
readonly revision?: string
}
export interface Interface {
@@ -30,6 +31,9 @@ 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>
}
@@ -99,6 +103,11 @@ 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(
@@ -113,6 +122,15 @@ 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
@@ -124,6 +142,41 @@ 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* () {
@@ -166,14 +219,7 @@ const layer = Layer.effect(
pkg: string,
options?: { readonly subpaths?: readonly string[]; readonly refresh?: boolean },
) {
const { default: npa } = yield* Effect.promise(() => import("npm-package-arg"))
const parsed = (() => {
try {
return npa(pkg)
} catch {
return undefined
}
})()
const parsed = yield* parse(pkg)
const parsedName = parsed?.name ?? undefined
const dir = yield* directory(pkg)
const name = yield* installedName(pkg, dir, parsedName)
@@ -189,37 +235,59 @@ const layer = Layer.effect(
}
if (cached) {
return resolveEntryPoint(name, path.join(dir, "node_modules", name), options?.subpaths)
return yield* entry(dir, name, path.join(dir, "node_modules", name), parsed?.type, 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 = resolveEntryPoint(installed, path.join(dir, "node_modules", installed), options?.subpaths)
const result = yield* entry(
dir,
installed,
path.join(dir, "node_modules", installed),
parsed?.type,
options?.subpaths,
)
if (result.entrypoint) return result
return yield* new InstallFailedError({ add: [pkg], dir })
}
return resolveEntryPoint(first.name, first.path, options?.subpaths)
return yield* entry(dir, first.name, first.path, parsed?.type, options?.subpaths)
}, Effect.scoped)
const resolve = Effect.fn("Npm.resolve")(function* (
pkg: string,
options?: { readonly subpaths?: readonly string[] },
) {
const { default: npa } = yield* Effect.promise(() => import("npm-package-arg"))
const parsedName = (() => {
try {
return npa(pkg).name ?? undefined
} catch {
return undefined
}
})()
const parsed = yield* parse(pkg)
const root = yield* directory(pkg)
const name = yield* installedName(pkg, root, parsedName)
const name = yield* installedName(pkg, root, parsed?.name ?? undefined)
const dir = path.join(root, "node_modules", name)
if (!(yield* afs.existsSafe(dir))) return { directory: dir }
return resolveEntryPoint(name, dir, options?.subpaths)
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) }
})
const which = Effect.fn("Npm.which")(function* (pkg: string, bin?: string) {
@@ -276,6 +344,7 @@ const layer = Layer.effect(
return Service.of({
add,
resolve,
check,
which,
})
}),
@@ -301,6 +370,14 @@ 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