Compare commits

..
Author SHA1 Message Date
Kit Langton be8f5a5242 test(core): simplify registry regression fixtures
Reuse small State and Location fixture constructors, use explicit Effect test layers and virtual time for synthetic providers, and ensure cleanup precedes registration disposal. Preserve regression cases, failure causes, and pre-release shutdown assertions.
2026-08-28 14:22:56 -04:00
Kit Langton ce50f77c20 fix(core): own registry notification lifetimes
Run MCP reconciliation and VCS state-driven refreshes in their layer-owned runtimes so shutdown cancels active and queued work. Contain VCS replay failures at the filesystem event boundary, and share one Deferred per reload debounce window.
2026-08-28 14:22:56 -04:00
Kit Langton 4d57b1d0c9 fix(core): preserve observer lifecycle and branch freshness 2026-08-28 14:22:55 -04:00
Kit Langton c036a8b1b6 fix(core): pull pending registry changes on read 2026-08-28 14:22:55 -04:00
104 changed files with 4183 additions and 3996 deletions
+11 -62
View File
@@ -307,7 +307,6 @@ export function createData(config: CreateDataInput) {
// submission order. Each waits for the previous POST to settle, so one
// failure does not block the next.
const sending = new Map<string, Promise<unknown>>()
const messageLoads = new Map<string, Promise<unknown>>()
const compacting = new Map<string, { id: string; observed: Set<string>; request: Promise<SessionInboxCompaction> }>()
onCleanup(() => compacting.clear())
@@ -1530,70 +1529,20 @@ export function createData(config: CreateDataInput) {
loading(sessionID: string) {
return store.session.messageLoading[sessionID] ?? false
},
async loadMore(
sessionID: string,
options?: {
all?: boolean
signal?: AbortSignal
/** Runs synchronously inside the store-publication batch. */
beforePublish?: () => void
},
) {
const signal = options?.signal
if (signal?.aborted) return
while (messageLoads.has(sessionID)) {
const published = await (() => {
const pending = messageLoads.get(sessionID)
if (!signal) return pending
const aborted = Promise.withResolvers<void>()
const cancel = () => aborted.resolve()
signal.addEventListener("abort", cancel, { once: true })
return Promise.race([pending, aborted.promise])
.catch((error) => {
if (!signal.aborted) throw error
})
.finally(() => signal.removeEventListener("abort", cancel))
})()
if ((!options?.all && published) || signal?.aborted) return
}
async loadMore(sessionID: string) {
const cursor = store.session.messageCursor[sessionID]
if (!cursor || signal?.aborted) return
if (!cursor || store.session.messageLoading[sessionID]) return
setStore("session", "messageLoading", sessionID, true)
const request = (async () => {
const fetched: SessionMessageInfo[] = []
let next: string | undefined = cursor
do {
const response = await api().message.list(
{
sessionID,
limit: options?.all ? 200 : messagePageLimit,
cursor: next,
},
{ signal },
)
if (signal?.aborted) return
fetched.push(...response.data)
next = response.cursor.next ?? undefined
if (!options?.all) break
} while (next)
// A jump through history publishes once, not once per page of offscreen messages.
const existing = store.session.message[sessionID] ?? []
const ids = new Set(existing.map((item) => item.id))
const messages = [...fetched.reverse().filter((item) => !ids.has(item.id)), ...existing]
batch(() => {
options?.beforePublish?.()
messageIndex.set(sessionID, new Map(messages.map((item, position) => [item.id, position])))
setStore("session", "message", sessionID, reconcile(messages))
setStore("session", "messageCursor", sessionID, next)
})
return true
})()
.catch((error) => {
if (!signal?.aborted) throw error
})
const response = await api()
.message.list({ sessionID, limit: messagePageLimit, cursor })
.finally(() => setStore("session", "messageLoading", sessionID, false))
track(messageLoads, sessionID, request)
await request
const older = response.data.toReversed()
const existing = store.session.message[sessionID] ?? []
const ids = new Set(existing.map((item) => item.id))
const messages = [...older.filter((item) => !ids.has(item.id)), ...existing]
messageIndex.set(sessionID, new Map(messages.map((item, position) => [item.id, position])))
setStore("session", "message", sessionID, reconcile(messages))
setStore("session", "messageCursor", sessionID, response.cursor.next ?? undefined)
},
invalidate(sessionID: string) {
sync.invalidate(`session.message:${sessionID}`)
-115
View File
@@ -1,5 +1,4 @@
import { expect, test } from "bun:test"
import { getEventListeners } from "node:events"
import { createRoot } from "solid-js"
import { createData, type CreateDataInput } from "../src/solid"
import { OpenCode, type OpenCodeEvent, type Project, type SessionInfo } from "../src/promise"
@@ -415,120 +414,6 @@ test("loads bounded message pages", async () => {
}
})
test.each(["success", "failure", "cancel", "cancel-retry", "cancel-page", "join-cancel", "join-failure"])(
"bulk history (%s)",
async (mode) => {
const messages = [1, 2, 3].map((index) => ({
id: `msg_${index}`,
type: "user",
text: `Message ${index}`,
time: { created: index },
}))
const release = Promise.withResolvers<void>()
const controller = new AbortController()
const requests: URL[] = []
const publications: string[][] = []
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input, init) => {
const url = new URL(input instanceof Request ? input.url : String(input))
requests.push(url)
const cursor = url.searchParams.get("cursor")
if (!cursor) return Response.json({ data: [messages[2]], cursor: { next: "recent" } })
if (cursor === "recent") {
if (mode.startsWith("join")) await release.promise
if (mode === "join-failure") return Response.json({ message: "offline" }, { status: 503 })
return Response.json({ data: [messages[2], messages[1]], cursor: { next: "oldest" } })
}
if (cursor === "oldest") return Response.json({ data: [messages[0]], cursor: { next: "empty" } })
expect(init?.signal).toBe(requests.length === 4 ? controller.signal : undefined)
await release.promise
if (mode === "failure") return Response.json({ message: "offline" }, { status: 503 })
return Response.json({ data: [], cursor: {} })
},
})
const setup = createRoot((dispose) => {
const data = createData({
api: () => api,
directory: "/project",
event: { on: () => () => {}, listen: () => () => {} },
})
return { data, dispose }
})
try {
await setup.data.session.message.sync("ses_refresh")
const newest = setup.data.session.message.get("ses_refresh", "msg_3")
const load = setup.data.session.message.loadMore(
"ses_refresh",
mode.startsWith("join")
? undefined
: {
all: true,
signal: controller.signal,
beforePublish: () => {
publications.push(setup.data.session.message.list("ses_refresh").map((message) => message.id))
expect(setup.data.session.message.get("ses_refresh", "msg_3")).toBe(newest)
},
},
)
const joined = setup.data.session.message.loadMore("ses_refresh", { all: true, signal: controller.signal })
const settled = Promise.allSettled([load, joined])
if (mode.startsWith("join")) {
await wait(() => requests.length === 2)
expect(getEventListeners(controller.signal, "abort")).toHaveLength(1)
controller.abort()
let cancelled = false
void joined.then(() => {
cancelled = true
})
await wait(() => cancelled)
expect(setup.data.session.message.loading("ses_refresh")).toBe(true)
expect(getEventListeners(controller.signal, "abort")).toHaveLength(0)
release.resolve()
expect((await settled).map((result) => result.status)).toEqual(
mode === "join-failure" ? ["rejected", "fulfilled"] : ["fulfilled", "fulfilled"],
)
expect(requests.at(-1)?.searchParams.get("limit")).toBe("20")
expect(requests).toHaveLength(2)
expect(setup.data.session.message.more("ses_refresh")).toBe(true)
expect(setup.data.session.message.list("ses_refresh").map((message) => message.id)).toEqual(
mode === "join-failure" ? ["msg_3"] : ["msg_2", "msg_3"],
)
return
}
await wait(() => requests.length === 4)
expect(setup.data.session.message.loading("ses_refresh")).toBe(true)
expect(setup.data.session.message.list("ses_refresh").map((message) => message.id)).toEqual(["msg_3"])
expect(requests.slice(1).map((url) => url.searchParams.get("limit"))).toEqual(["200", "200", "200"])
if (mode.startsWith("cancel")) controller.abort()
const retry =
mode === "cancel-retry" || mode === "cancel-page"
? setup.data.session.message.loadMore("ses_refresh", mode === "cancel-retry" ? { all: true } : undefined)
: undefined
release.resolve()
expect((await settled).map((result) => result.status)).toEqual(
mode === "failure" ? ["rejected", "rejected"] : ["fulfilled", "fulfilled"],
)
await retry
const success = mode === "success" || mode === "cancel-retry"
expect(setup.data.session.message.loading("ses_refresh")).toBe(false)
expect(setup.data.session.message.more("ses_refresh")).toBe(!success)
expect(setup.data.session.message.list("ses_refresh").map((message) => message.id)).toEqual(
success ? ["msg_1", "msg_2", "msg_3"] : mode === "cancel-page" ? ["msg_2", "msg_3"] : ["msg_3"],
)
expect(setup.data.session.message.get("ses_refresh", "msg_3")).toBe(newest)
expect(requests).toHaveLength(mode === "cancel-retry" ? 7 : mode === "cancel-page" ? 5 : 4)
if (mode === "cancel-page") expect(requests.at(-1)?.searchParams.get("limit")).toBe("20")
expect(publications).toEqual(mode === "success" ? [["msg_3"]] : [])
expect(getEventListeners(controller.signal, "abort")).toHaveLength(0)
} finally {
release.resolve()
setup.dispose()
}
},
)
test("preserves assistant content replacement events across an active message read", async () => {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const release = Promise.withResolvers<void>()
+1 -1
View File
@@ -87,7 +87,7 @@ const layer = Layer.effect(
draft.agents.delete(id)
},
}),
finalize: () => bus.publish(Agent.Event.Updated, {}).pipe(Effect.asVoid),
notify: () => bus.publish(Agent.Event.Updated, {}).pipe(Effect.asVoid),
})
const selectable = (agent: Info | undefined) =>
agent && agent.mode !== "subagent" && !agent.hidden ? agent : undefined
+4 -12
View File
@@ -482,7 +482,8 @@ function toolMessage(input: LLMRequest["messages"][number]) {
const value = part.result.value.filter((item) => {
if (item.type !== "file") return true
if (!item.mime.startsWith("image/") && item.mime !== "application/pdf") return true
media.push({ type: "file", mediaType: item.mime, data: fileData(item.uri), filename: item.name })
const data = /^data:[^;,]+(?:;[^,]*)*;base64,(.*)$/s.exec(item.uri)?.[1] ?? item.uri
media.push({ type: "file", mediaType: item.mime, data, filename: item.name })
return false
})
return toolResultPart({
@@ -506,7 +507,7 @@ function text(part: ContentPart) {
function userPart(part: ContentPart): UserContent {
if (part.type === "text") return [{ type: "text", text: part.text }]
if (part.type === "media")
return [{ type: "file", mediaType: part.mediaType, data: fileData(part.data), filename: part.filename }]
return [{ type: "file", mediaType: part.mediaType, data: part.data, filename: part.filename }]
return []
}
@@ -515,7 +516,7 @@ function assistantPart(part: ContentPart): AssistantContent {
case "text":
return [{ type: "text", text: part.text, providerOptions: metadataProviderOptions(part.providerMetadata) }]
case "media":
return [{ type: "file", mediaType: part.mediaType, data: fileData(part.data), filename: part.filename }]
return [{ type: "file", mediaType: part.mediaType, data: part.data, filename: part.filename }]
case "reasoning":
return [{ type: "reasoning", text: part.text, providerOptions: metadataProviderOptions(part.providerMetadata) }]
case "tool-call":
@@ -534,15 +535,6 @@ function assistantPart(part: ContentPart): AssistantContent {
}
}
function fileData(data: Extract<ContentPart, { type: "media" }>["data"]) {
if (typeof data !== "string") return data
const base64 = /^data:[^;,]+(?:;[^,]*)*;base64,(.*)$/s.exec(data)?.[1]
if (base64 !== undefined) return base64
if (!URL.canParse(data)) return data
const url = new URL(data)
return url.protocol === "http:" || url.protocol === "https:" ? url : data
}
function toolResultPart(part: ContentPart): ToolResultContent[] {
if (part.type !== "tool-result") return []
return [
+154 -148
View File
@@ -294,156 +294,162 @@ export function configured(options?: Options) {
) {
return Effect.gen(function* () {
const durable = definition.durable
if (!durable) return yield* Effect.void
const aggregateID = (event.data as Record<string, unknown>)[durable.aggregate]
if (typeof aggregateID !== "string")
return yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: `Expected string aggregate field ${durable.aggregate}`,
}),
)
if (input && input.aggregateID !== aggregateID) {
yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: `Aggregate mismatch: expected ${input.aggregateID}, got ${aggregateID}`,
}),
)
}
const list = projectors.get(versionedType(definition.type, durable.version)) ?? []
return yield* Effect.uninterruptible(
Effect.gen(function* () {
const committed = yield* db
.transaction(
() =>
Effect.gen(function* () {
const row = yield* db
.select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
.get()
.pipe(Effect.orDie)
const latest = row?.seq ?? -1
const encoded = Schema.encodeUnknownSync(definition.data)(event.data) as Record<string, unknown>
if (input?.strictOwner && row?.ownerID && row.ownerID !== input.ownerID) {
yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: `Replay owner mismatch for aggregate ${aggregateID}: expected ${row.ownerID}, got ${input.ownerID ?? "none"}`,
}),
)
}
if (input && input.seq <= latest) {
if (!persist) return
const stored = yield* db
.select()
.from(EventTable)
.where(and(eq(EventTable.aggregate_id, aggregateID), eq(EventTable.seq, input.seq)))
.get()
.pipe(Effect.orDie)
if (
stored?.id === event.id &&
stored.type === versionedType(definition.type, durable.version) &&
stored.created === (event.created ?? 0) &&
isDeepStrictEqual(stored.data, encoded)
) {
if (input.ownerID && row?.ownerID == null) {
yield* db
.update(EventSequenceTable)
.set({ owner_id: input.ownerID })
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
.run()
.pipe(Effect.orDie)
}
return
}
yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: `Replay diverged at aggregate ${aggregateID} sequence ${input.seq}`,
}),
)
}
if (input && row?.ownerID && row.ownerID !== input.ownerID) {
return
}
const seq = input?.seq ?? latest + 1
if (input && seq !== latest + 1) {
yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: `Sequence mismatch for aggregate ${aggregateID}: expected ${latest + 1}, got ${seq}`,
}),
)
}
if (persist) {
const stored = yield* db
.select({ aggregateID: EventTable.aggregate_id, seq: EventTable.seq })
.from(EventTable)
.where(eq(EventTable.id, event.id))
.get()
.pipe(Effect.orDie)
if (stored)
yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: `Event ${event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`,
}),
)
}
const committed = {
...event,
durable: { aggregateID, seq, version: durable.version },
} as Event.Payload
const route = yield* prepareRoutes([committed])
for (const projector of list) {
yield* projector(committed)
}
if (commit) yield* commit(seq)
yield* db
.insert(EventSequenceTable)
.values([{ aggregate_id: aggregateID, seq, owner_id: input?.ownerID }])
.onConflictDoUpdate({
target: EventSequenceTable.aggregate_id,
set: {
seq: sql`max(${EventSequenceTable.seq}, ${seq})`,
...(input?.ownerID && row?.ownerID == null ? { owner_id: input.ownerID } : {}),
},
})
.run()
.pipe(Effect.orDie)
if (persist)
yield* db
.insert(EventTable)
.values([
{
id: event.id,
aggregate_id: aggregateID,
seq,
created: event.created ?? 0,
type: versionedType(definition.type, durable.version),
data: encoded,
},
])
.run()
.pipe(Effect.orDie)
return { aggregateID, seq, event: committed, route }
}),
{ behavior: "immediate" },
)
.pipe(Effect.orDie)
if (committed) {
committed.route()
yield* Effect.forEach(
pubsub.durable.get(committed.aggregateID) ?? [],
(wake) => PubSub.publish(wake, undefined),
{ discard: true },
if (durable) {
const aggregateID = (event.data as Record<string, unknown>)[durable.aggregate]
if (typeof aggregateID !== "string") {
yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: `Expected string aggregate field ${durable.aggregate}`,
}),
)
} else {
if (input && input.aggregateID !== aggregateID) {
yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: `Aggregate mismatch: expected ${input.aggregateID}, got ${aggregateID}`,
}),
)
}
return committed
}),
)
const list = projectors.get(versionedType(definition.type, durable.version)) ?? []
return yield* Effect.uninterruptible(
Effect.gen(function* () {
const committed = yield* db
.transaction(
() =>
Effect.gen(function* () {
const row = yield* db
.select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
.get()
.pipe(Effect.orDie)
const latest = row?.seq ?? -1
const encoded = Schema.encodeUnknownSync(definition.data)(event.data) as Record<
string,
unknown
>
if (input?.strictOwner && row?.ownerID && row.ownerID !== input.ownerID) {
yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: `Replay owner mismatch for aggregate ${aggregateID}: expected ${row.ownerID}, got ${input.ownerID ?? "none"}`,
}),
)
}
if (input && input.seq <= latest) {
if (!persist) return
const stored = yield* db
.select()
.from(EventTable)
.where(and(eq(EventTable.aggregate_id, aggregateID), eq(EventTable.seq, input.seq)))
.get()
.pipe(Effect.orDie)
if (
stored?.id === event.id &&
stored.type === versionedType(definition.type, durable.version) &&
stored.created === (event.created ?? 0) &&
isDeepStrictEqual(stored.data, encoded)
) {
if (input.ownerID && row?.ownerID == null) {
yield* db
.update(EventSequenceTable)
.set({ owner_id: input.ownerID })
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
.run()
.pipe(Effect.orDie)
}
return
}
yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: `Replay diverged at aggregate ${aggregateID} sequence ${input.seq}`,
}),
)
}
if (input && row?.ownerID && row.ownerID !== input.ownerID) {
return
}
const seq = input?.seq ?? latest + 1
if (input && seq !== latest + 1) {
yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: `Sequence mismatch for aggregate ${aggregateID}: expected ${latest + 1}, got ${seq}`,
}),
)
}
if (persist) {
const stored = yield* db
.select({ aggregateID: EventTable.aggregate_id, seq: EventTable.seq })
.from(EventTable)
.where(eq(EventTable.id, event.id))
.get()
.pipe(Effect.orDie)
if (stored)
yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: `Event ${event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`,
}),
)
}
const committed = {
...event,
durable: { aggregateID, seq, version: durable.version },
} as Event.Payload
const route = yield* prepareRoutes([committed])
for (const projector of list) {
yield* projector(committed)
}
if (commit) yield* commit(seq)
yield* db
.insert(EventSequenceTable)
.values([{ aggregate_id: aggregateID, seq, owner_id: input?.ownerID }])
.onConflictDoUpdate({
target: EventSequenceTable.aggregate_id,
set: {
seq: sql`max(${EventSequenceTable.seq}, ${seq})`,
...(input?.ownerID && row?.ownerID == null ? { owner_id: input.ownerID } : {}),
},
})
.run()
.pipe(Effect.orDie)
if (persist)
yield* db
.insert(EventTable)
.values([
{
id: event.id,
aggregate_id: aggregateID,
seq,
created: event.created ?? 0,
type: versionedType(definition.type, durable.version),
data: encoded,
},
])
.run()
.pipe(Effect.orDie)
return { aggregateID, seq, event: committed, route }
}),
{ behavior: "immediate" },
)
.pipe(Effect.orDie)
if (committed) {
committed.route()
yield* Effect.forEach(
pubsub.durable.get(committed.aggregateID) ?? [],
(wake) => PubSub.publish(wake, undefined),
{ discard: true },
)
}
return committed
}),
)
}
}
})
}
+1 -1
View File
@@ -134,7 +134,7 @@ const layer = Layer.effect(
}
return result
},
finalize: Effect.fn("Catalog.finalize")(function* () {
notify: Effect.fn("Catalog.notify")(function* () {
yield* bus.publish(Catalog.Event.Updated, {})
}),
})
+1 -1
View File
@@ -60,7 +60,7 @@ export const layer = Layer.effect(
draft: (draft) => ({
add: (definition) => draft.set(definition.name, definition),
}),
finalize: () => bus.publish(Command.Event.Updated, {}).pipe(Effect.asVoid),
notify: () => bus.publish(Command.Event.Updated, {}).pipe(Effect.asVoid),
})
const info = (definition: Definition) =>
Info.make({
+150
View File
@@ -0,0 +1,150 @@
export * as ConfigFile from "./file.js"
import { isDeepStrictEqual } from "node:util"
import { isRecord } from "@opencode-ai/ai/utils/record"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Effect, Schema, Semaphore } from "effect"
import {
applyEdits,
createScanner,
findNodeAtLocation,
modify,
parseTree,
type Node,
type ParseError,
} from "jsonc-parser"
export class UpdateError extends Schema.TaggedError<UpdateError>()("ConfigFile.UpdateError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect()),
}) {}
const isJson = Schema.is(Schema.MutableJson)
const isDocument = (value: unknown): value is Schema.MutableJsonObject => isRecord(value) && isJson(value)
const lock = Semaphore.makeUnsafe(1)
/**
* Edits an existing JSON(C) file using raw source values, not resolved Config.Info.
* The synchronous callback mutates a source clone; its return value is ignored.
* Validates JSON only; normalization and substitution remain the reader's job.
* Does not discover files, start watchers, or refresh Config state.
* Read-modify-write calls are serialized within this process.
*/
export const update = Effect.fn("ConfigFile.update")(
function* (
filepath: string,
mutate: (draft: Schema.MutableJsonObject) => void,
): Effect.fn.Return<Schema.JsonObject, UpdateError, FSUtil.Service> {
const fs = yield* FSUtil.Service
const text = yield* fs
.readFileString(filepath)
.pipe(Effect.mapError((cause) => new UpdateError({ message: `Failed to read config: ${filepath}`, cause })))
const errors: ParseError[] = []
const current = parseSource(text, errors)
if (errors.length || !isDocument(current))
return yield* Effect.fail(new UpdateError({ message: `Invalid config file: ${filepath}` }))
const next = yield* Effect.try({
try: () => {
const draft = structuredClone(current)
mutate(draft)
return draft
},
catch: (cause) => new UpdateError({ message: "Config update failed", cause }),
})
if (!isDocument(next))
return yield* Effect.fail(new UpdateError({ message: `Config update must produce a JSON object: ${filepath}` }))
const edits = changes(current, next)
if (!edits.length) return next
const updated = yield* Effect.try({
try: () => edits.reduce(patch, text),
catch: (cause) => new UpdateError({ message: `Failed to patch config: ${filepath}`, cause }),
})
// Duplicate keys can make parse choose the last value while modify edits the first.
const written = parseSource(updated, errors)
if (errors.length || !isDeepStrictEqual(written, next))
return yield* Effect.fail(
new UpdateError({ message: `Config patch does not match the requested update: ${filepath}` }),
)
const temporary = filepath + ".tmp"
yield* fs.writeFileString(temporary, updated.endsWith("\n") ? updated : updated + "\n").pipe(
Effect.andThen(fs.rename(temporary, filepath)),
Effect.mapError((cause) => new UpdateError({ message: `Failed to write config: ${filepath}`, cause })),
)
return next
},
(effect) => lock.withPermit(effect),
)
type Edit = { readonly path: (string | number)[]; readonly value: unknown }
function parseSource(text: string, errors: ParseError[]) {
const root = parseTree(text, errors, { allowTrailingComma: true })
if (!root || errors.length) return undefined
// parse() assigns onto {}, invoking the __proto__ setter instead of retaining
// an own JSON key. Construct object entries from the AST without those setters.
const value = (node: Node): unknown => {
if (node.type === "array") return (node.children ?? []).map(value)
if (node.type === "object")
return Object.fromEntries(
(node.children ?? []).map((property) => {
const child = property.children?.[1]
return [property.children?.[0]?.value, child && value(child)]
}),
)
return node.value
}
return value(root)
}
function patch(text: string, edit: Edit) {
if (edit.value !== undefined)
return applyEdits(
text,
modify(text, edit.path, edit.value, { formattingOptions: { tabSize: 2, insertSpaces: true } }),
)
const tree = parseTree(text)
const node = tree && findNodeAtLocation(tree, edit.path)
if (!node) return text
// jsonc-parser removes adjacent comments along with the separator. Remove only
// the property/element itself and one comma, leaving surrounding comments intact.
const target = node.parent?.type === "property" ? node.parent : node
const siblings = target.parent?.children ?? []
const previous = siblings[siblings.indexOf(target) - 1]
const scanner = createScanner(text, true)
scanner.setPosition(target.offset + target.length)
scanner.scan()
const following = text[scanner.getTokenOffset()] === ","
if (!following && previous) {
scanner.setPosition(previous.offset + previous.length)
scanner.scan()
}
return applyEdits(text, [
{ offset: target.offset, length: target.length, content: "" },
...(following || previous ? [{ offset: scanner.getTokenOffset(), length: 1, content: "" }] : []),
])
}
function changes(before: unknown, after: unknown, path: (string | number)[] = []): Edit[] {
if (isDeepStrictEqual(before, after)) return []
if (Array.isArray(before) && Array.isArray(after)) {
return [
...after.flatMap((value, index) => changes(before[index], value, [...path, index])),
// Remove from the end so earlier deletions cannot shift later paths.
...before
.slice(after.length)
.map((_, index) => ({ path: [...path, after.length + index], value: undefined }))
.toReversed(),
]
}
if (isRecord(before) && isRecord(after)) {
return [...new Set([...Object.keys(before), ...Object.keys(after)])].flatMap((key) => {
if (!Object.hasOwn(after, key)) return [{ path: [...path, key], value: undefined }]
if (!Object.hasOwn(before, key)) return [{ path: [...path, key], value: after[key] }]
return changes(before[key], after[key], [...path, key])
})
}
return [{ path, value: after }]
}
+3 -1
View File
@@ -20,13 +20,14 @@ export const Plugin = define({
const global = yield* Global.Service
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, ctx.reference.reload())
yield* ctx.reference.transform((draft) => {
const entries = new Map<string, Reference.Source>()
for (const doc of loaded.entries.filter((entry): entry is Document => entry.type === "document")) {
const directory = doc.path ? path.dirname(doc.path) : location.directory
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
if (!validAlias(name)) continue
const description = typeof entry === "string" ? undefined : entry.description
const hidden = typeof entry === "string" ? undefined : entry.hidden
draft.add(
entries.set(
name,
local(entry)
? Reference.LocalSource.make({
@@ -47,6 +48,7 @@ export const Plugin = define({
)
}
}
for (const [name, source] of entries) draft.add(name, source)
})
}),
})
@@ -1,35 +0,0 @@
# Effect Drizzle SQLite Adapter
This subtree is an upstream-derived Drizzle ORM fork adapted to run SQLite query
builders over Effect's generic `SqlClient`. It is maintained source, not
generated output.
## Provenance
The implementation is derived from Drizzle ORM's Effect SQLite driver/session,
SQLite Effect query builders, and shared query-builder utilities. The
corresponding upstream source families are `drizzle-orm/src/effect-sqlite`,
`drizzle-orm/src/sqlite-core`, and `drizzle-orm/src/utils.ts`.
The exact upstream revision originally copied into this repository is unknown.
The currently pinned `drizzle-orm` version is a compatibility dependency, not
copy provenance.
## Local Boundary
The supported local entrypoint is `@opencode-ai/core/database/drizzle`, exposed
as the `EffectDrizzleSqlite` namespace. OpenCode's database service consumes that
facade from `database/database.ts`.
Material local adaptations include:
- a runtime-independent driver over Effect's generic `SqlClient`
- local cache, mapping, and runtime-inspection helpers
- suppressed statement tracing beneath the database operation boundary
- explicit SQLite transactions and savepoints
- native transaction delegation for Durable Object SQLite
- deliberate query-builder variance annotations
Preserve these adaptations when comparing or synchronizing upstream code.
Focused regression coverage is in `test/database-drizzle.test.ts` and
`test/sqlite-workerd.test.ts`.
@@ -36,14 +36,14 @@ export const DefaultServices = Layer.merge(EffectCache.Default, EffectLogger.Def
*
* @example
* ```ts
* import { SqliteClient } from "@effect/sql-sqlite-node"
* import { EffectDrizzleSqlite } from "@opencode-ai/core/database/drizzle"
* import { Effect } from "effect"
* import { SqliteClient } from '@effect/sql-sqlite-node';
* import * as SQLiteDrizzle from 'drizzle-orm/effect-sqlite';
* import * as Effect from 'effect/Effect';
*
* const db = yield* EffectDrizzleSqlite.make({ relations }).pipe(
* Effect.provide(EffectDrizzleSqlite.DefaultServices),
* Effect.provide(SqliteClient.layer({ filename: "sqlite.db" })),
* )
* const db = yield* SQLiteDrizzle.make({ relations }).pipe(
* Effect.provide(SQLiteDrizzle.DefaultServices),
* Effect.provide(SqliteClient.layer({ filename: 'sqlite.db' })),
* );
* ```
*/
export const make = Effect.fn("SQLiteDrizzle.make")(function* <TRelations extends AnyRelations = EmptyRelations>(
@@ -279,7 +279,7 @@ export class SQLiteEffectUpdateBase<
: undefined
on = on(
new Proxy(
getTableColumnsRuntime(this.config.table),
this.config.table._.columns,
new SelectionProxyHandler({ sqlAliasedBehavior: "sql", sqlBehavior: "sql" }),
) as any,
from &&
+1 -1
View File
@@ -212,7 +212,7 @@ const nativeLayer = (config: Config) =>
: Layer.effect(
Sqlite.Native,
Effect.die(
"workerd sqlite cannot open a database from a path; use Database.layerFromClient.pipe(Layer.provide(sqliteLayer({ storage })))",
"workerd sqlite cannot open a database from a path; use Database.layerWith(sqliteLayer({ storage }))",
),
)
+161 -151
View File
@@ -275,22 +275,22 @@ export function transformSession(input: TransformInput): TransformResult {
if (paired.has(item.row.id)) return []
const owned = byMessage.get(item.row.id)?.map((part) => part.value) ?? []
if (item.value.role === "user") {
const compaction = owned.find((part): part is SessionV1.CompactionPart => part.type === "compaction")
if (compaction) {
const compaction = owned.find((part) => part.type === "compaction")
if (compaction?.type === "compaction") {
const pairedSummary = messages.find(
(candidate): candidate is (typeof messages)[number] & { value: SessionV1.Assistant } =>
(candidate) =>
candidate.value.role === "assistant" &&
candidate.value.parentID === item.row.id &&
candidate.value.summary === true,
candidate.value.summary,
)
if (!pairedSummary) return []
if (!pairedSummary || pairedSummary.value.role !== "assistant") return []
paired.add(pairedSummary.row.id)
if (pairedSummary.value.error || pairedSummary.value.time.completed === undefined) return []
const summary = pairedSummary
const summaryText = (byMessage.get(summary.row.id) ?? [])
.map((part) => part.value)
.filter((part): part is SessionV1.TextPart => part.type === "text" && part.text.length > 0)
.map((part) => part.text)
.filter((part) => part.type === "text" && part.text.length > 0)
.map((part) => (part.type === "text" ? part.text : ""))
.join("\n\n")
const tailIndex = compaction.tail_start_id
? messages.findIndex((candidate) => candidate.row.id === compaction.tail_start_id)
@@ -313,14 +313,16 @@ export function transformSession(input: TransformInput): TransformResult {
]
}
const subtasks = owned.filter((part) => part.type === "subtask")
const visible = owned.filter((part): part is SessionV1.TextPart => part.type === "text" && !part.ignored)
const files = owned.filter((part): part is SessionV1.FilePart => part.type === "file")
const agents = owned.filter((part): part is SessionV1.AgentPart => part.type === "agent")
const visible = owned.filter((part) => part.type === "text" && !part.ignored)
const files = owned.filter((part) => part.type === "file")
const agents = owned.filter((part) => part.type === "agent")
if (subtasks.length > 0 && visible.length === 0 && files.length === 0 && agents.length === 0) return []
const ordinary = visible.filter((part) => !part.synthetic)
const synthetic = visible.filter((part) => part.synthetic)
const attachments = files.flatMap((part) => migrateFile(part))
const unavailable = files.flatMap((part) => (!part.url.startsWith("data:") ? [unavailableFile(part)] : []))
const ordinary = visible.filter((part) => part.type === "text" && !part.synthetic)
const synthetic = visible.filter((part) => part.type === "text" && part.synthetic)
const attachments = files.flatMap((part) => (part.type === "file" ? migrateFile(part) : []))
const unavailable = files.flatMap((part) =>
part.type === "file" && !part.url.startsWith("data:") ? [unavailableFile(part)] : [],
)
const text = owned
.flatMap((part) => {
if (part.type === "text" && !part.ignored && !part.synthetic) return [part.text]
@@ -328,12 +330,16 @@ export function transformSession(input: TransformInput): TransformResult {
return []
})
.join("\n\n")
const agentAttachments = agents.map((part) => ({
name: part.name,
...(part.source
? { mention: { text: part.source.value, start: part.source.start, end: part.source.end } }
: {}),
}))
const agentAttachments = agents.map((part) =>
part.type === "agent"
? {
name: part.name,
...(part.source
? { mention: { text: part.source.value, start: part.source.start, end: part.source.end } }
: {}),
}
: { name: "" },
)
if (
ordinary.length === 0 &&
unavailable.length === 0 &&
@@ -345,7 +351,7 @@ export function transformSession(input: TransformInput): TransformResult {
row(item.row, {
id: item.row.id,
type: "synthetic",
text: synthetic.map((part) => part.text).join("\n\n"),
text: synthetic.map((part) => (part.type === "text" ? part.text : "")).join("\n\n"),
time: { created: item.row.time_created },
}),
]
@@ -363,7 +369,7 @@ export function transformSession(input: TransformInput): TransformResult {
row(item.row, {
id: syntheticID(item.row.id, used),
type: "synthetic",
text: synthetic.map((part) => part.text).join("\n\n"),
text: synthetic.map((part) => (part.type === "text" ? part.text : "")).join("\n\n"),
time: { created: item.row.time_created },
}),
]
@@ -437,6 +443,7 @@ export function transformSession(input: TransformInput): TransformResult {
})
.map((item, seq) => ({ ...item, seq }))
const assistants = messages
.filter((item) => item.value.role === "assistant")
.map((item) => item.value)
.filter((item): item is SessionV1.Assistant => item.role === "assistant")
const latestUser = messages.findLast((item) => {
@@ -481,7 +488,7 @@ export function status(): Effect.Effect<Status, never, Database.Service> {
if (runtimeState.status === "error") return runtimeState
if (state?.phase === "completed") return { status: "completed" as const }
return { status: "required" as const }
})
}).pipe(Effect.orDie)
}
export const layer = Layer.effectDiscard(
@@ -521,75 +528,76 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
const state = yield* readState(db)
if (state?.phase === "completed") return { status: "completed" as const }
if (!(yield* hasLegacySessions(db))) return { status: "completed" as const }
const now = Date.now()
yield* db.run(sql`
const migrate = Effect.gen(function* () {
const now = Date.now()
yield* db.run(sql`
INSERT OR IGNORE INTO project (id, worktree, time_created, time_updated, sandboxes)
VALUES (${Project.ID.global}, ${path.parse(global.data).root}, ${now}, ${now}, '[]')
`)
if (state === undefined)
yield* db
.transaction((tx) =>
Effect.gen(function* () {
while (true) {
yield* tx.run(sql`
if (state === undefined)
yield* db
.transaction((tx) =>
Effect.gen(function* () {
while (true) {
yield* tx.run(sql`
DELETE FROM event
WHERE rowid IN (SELECT rowid FROM event LIMIT ${EVENT_DELETE_BATCH_SIZE})
`)
const deleted = (yield* tx.get<{ value: number }>(sql`SELECT changes() AS value`))?.value ?? 0
if (deleted < EVENT_DELETE_BATCH_SIZE) break
yield* Effect.yieldNow
}
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions" } })
.run()
}),
)
.pipe(Effect.orDie)
const sourceTotal = yield* countNextSessions(nextPath(options, global.data))
const legacyTotal = (yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session`))?.value ?? 0
const cursor = state?.phase === "sessions" ? state.cursor : undefined
const migrated =
cursor !== undefined
? ((yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session WHERE id >= ${cursor}`))
?.value ?? 0)
: 0
const denominator = sourceTotal + legacyTotal
updateProgress({ label: "Migrating sessions", numerator: migrated, denominator })
yield* importNextDatabase(db, nextPath(options, global.data), (completed) => {
updateProgress({ label: "Migrating sessions", numerator: migrated + completed, denominator })
})
updateProgress({ label: "Migrating sessions", numerator: migrated + sourceTotal, denominator })
const projects = new Set(
(yield* db.all<{ id: string }>(sql`SELECT id FROM project`)).map((project) => project.id),
)
while (true) {
const state = yield* readState(db)
const cursorValue = state?.phase === "sessions" ? state.cursor : undefined
const nextID = yield* db.get<{ id: string; project_id: string }>(
cursorValue === undefined
? sql`SELECT id, project_id FROM session ORDER BY id DESC LIMIT 1`
: sql`SELECT id, project_id FROM session WHERE id < ${cursorValue} ORDER BY id DESC LIMIT 1`,
const deleted = (yield* tx.get<{ value: number }>(sql`SELECT changes() AS value`))?.value ?? 0
if (deleted < EVENT_DELETE_BATCH_SIZE) break
yield* Effect.yieldNow
}
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions" } })
.run()
}),
)
.pipe(Effect.orDie)
const sourceTotal = yield* countNextSessions(nextPath(options, global.data))
const legacyTotal = (yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session`))?.value ?? 0
const cursor = state?.phase === "sessions" ? state.cursor : undefined
const migrated =
cursor !== undefined
? ((yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session WHERE id >= ${cursor}`))
?.value ?? 0)
: 0
const denominator = sourceTotal + legacyTotal
updateProgress({ label: "Migrating sessions", numerator: migrated, denominator })
yield* importNextDatabase(db, nextPath(options, global.data), (completed) => {
updateProgress({ label: "Migrating sessions", numerator: migrated + completed, denominator })
})
updateProgress({ label: "Migrating sessions", numerator: migrated + sourceTotal, denominator })
const projects = new Set(
(yield* db.all<{ id: string }>(sql`SELECT id FROM project`)).map((project) => project.id),
)
if (!nextID) break
yield* db
.transaction((tx) =>
Effect.gen(function* () {
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions", cursor: nextID.id } })
.onConflictDoUpdate({
target: KVTable.key,
set: { value: { phase: "sessions", cursor: nextID.id }, time_updated: Date.now() },
})
.run()
const projectID = projects.has(nextID.project_id) ? nextID.project_id : Project.ID.global
if (projectID !== nextID.project_id)
yield* Effect.logWarning("Reassigned V1 session with missing project", {
sessionID: nextID.id,
projectID: nextID.project_id,
})
yield* tx.run(sql`
while (true) {
const state = yield* readState(db)
const cursorValue = state?.phase === "sessions" ? state.cursor : undefined
const nextID = yield* db.get<{ id: string; project_id: string }>(
cursorValue === undefined
? sql`SELECT id, project_id FROM session ORDER BY id DESC LIMIT 1`
: sql`SELECT id, project_id FROM session WHERE id < ${cursorValue} ORDER BY id DESC LIMIT 1`,
)
if (!nextID) break
yield* db
.transaction((tx) =>
Effect.gen(function* () {
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions", cursor: nextID.id } })
.onConflictDoUpdate({
target: KVTable.key,
set: { value: { phase: "sessions", cursor: nextID.id }, time_updated: Date.now() },
})
.run()
const projectID = projects.has(nextID.project_id) ? nextID.project_id : Project.ID.global
if (projectID !== nextID.project_id)
yield* Effect.logWarning("Reassigned V1 session with missing project", {
sessionID: nextID.id,
projectID: nextID.project_id,
})
yield* tx.run(sql`
INSERT OR IGNORE INTO session_v2 (
id, project_id, workspace_id, parent_id, slug, directory, path, title, version, share_url,
summary_additions, summary_deletions, summary_files, summary_diffs, metadata, cost,
@@ -604,79 +612,81 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
FROM session
WHERE id = ${nextID.id}
`)
const next = yield* tx
.select()
.from(SessionTable)
.where(eq(SessionTable.id, SessionSchema.ID.make(nextID.id)))
.get()
if (!next) return yield* Effect.die(new Error(`Failed to copy V1 session ${nextID.id}`))
const sourceMessages = yield* tx.all<SourceMessage>(
sql`SELECT id, session_id, time_created, time_updated, data FROM message WHERE session_id = ${next.id}`,
)
const sourceParts = yield* tx.all<SourcePart>(
sql`SELECT id, message_id, session_id, time_created, time_updated, data FROM part WHERE session_id = ${next.id}`,
)
const transformed = transformSession({ session: next, messages: sourceMessages, parts: sourceParts })
yield* Effect.forEach(transformed.warnings, (warning) =>
Effect.logWarning("Skipped V1 migration row", warning),
)
yield* tx.delete(SessionMessageTable).where(eq(SessionMessageTable.session_id, next.id)).run()
yield* Effect.forEach(transformed.messages, (message) =>
tx
.insert(SessionMessageTable)
.values({
id: SessionMessage.ID.make(message.id),
session_id: SessionSchema.ID.make(message.session_id),
type: message.type,
seq: message.seq,
time_created: message.time_created,
time_updated: message.time_updated,
data: sql`${JSON.stringify(message.data)}`,
const next = yield* tx
.select()
.from(SessionTable)
.where(eq(SessionTable.id, SessionSchema.ID.make(nextID.id)))
.get()
if (!next) return yield* Effect.die(new Error(`Failed to copy V1 session ${nextID.id}`))
const sourceMessages = yield* tx.all<SourceMessage>(
sql`SELECT id, session_id, time_created, time_updated, data FROM message WHERE session_id = ${next.id}`,
)
const sourceParts = yield* tx.all<SourcePart>(
sql`SELECT id, message_id, session_id, time_created, time_updated, data FROM part WHERE session_id = ${next.id}`,
)
const transformed = transformSession({ session: next, messages: sourceMessages, parts: sourceParts })
yield* Effect.forEach(transformed.warnings, (warning) =>
Effect.logWarning("Skipped V1 migration row", warning),
)
yield* tx.delete(SessionMessageTable).where(eq(SessionMessageTable.session_id, next.id)).run()
yield* Effect.forEach(transformed.messages, (message) =>
tx
.insert(SessionMessageTable)
.values({
id: SessionMessage.ID.make(message.id),
session_id: SessionSchema.ID.make(message.session_id),
type: message.type,
seq: message.seq,
time_created: message.time_created,
time_updated: message.time_updated,
data: sql`${JSON.stringify(message.data)}`,
})
.run(),
)
yield* tx
.update(SessionTable)
.set({ ...transformed.session, time_updated: next.time_updated })
.where(eq(SessionTable.id, next.id))
.run()
yield* tx
.insert(EventSequenceTable)
.values({ aggregate_id: next.id, seq: transformed.watermark })
.onConflictDoUpdate({
target: EventSequenceTable.aggregate_id,
set: { seq: transformed.watermark, owner_id: null },
})
.run(),
)
.run()
}),
)
.pipe(Effect.orDie)
if (runtimeState.status === "running")
runtimeState = {
status: "running",
progress: {
label: "Migrating sessions",
numerator: (runtimeState.progress.numerator ?? 0) + 1,
denominator,
},
}
yield* Effect.yieldNow
}
yield* db
.transaction((tx) =>
Effect.gen(function* () {
yield* tx
.update(SessionTable)
.set({ ...transformed.session, time_updated: next.time_updated })
.where(eq(SessionTable.id, next.id))
.run()
yield* tx
.insert(EventSequenceTable)
.values({ aggregate_id: next.id, seq: transformed.watermark })
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "completed" } })
.onConflictDoUpdate({
target: EventSequenceTable.aggregate_id,
set: { seq: transformed.watermark, owner_id: null },
target: KVTable.key,
set: { value: { phase: "completed" }, time_updated: Date.now() },
})
.run()
}),
)
.pipe(Effect.orDie)
if (runtimeState.status === "running")
runtimeState = {
status: "running",
progress: {
label: "Migrating sessions",
numerator: (runtimeState.progress.numerator ?? 0) + 1,
denominator,
},
}
yield* Effect.yieldNow
}
yield* db
.transaction((tx) =>
Effect.gen(function* () {
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "completed" } })
.onConflictDoUpdate({
target: KVTable.key,
set: { value: { phase: "completed" }, time_updated: Date.now() },
})
.run()
}),
)
.pipe(Effect.orDie)
return { status: "completed" as const }
return { status: "completed" as const }
})
return yield* migrate
}).pipe(Effect.orDie),
)
}
@@ -705,7 +715,7 @@ function countNextSessions(sourcePath: string | undefined) {
if (!isNextDatabase(source)) return 0
return source.query<{ value: number }, []>("SELECT COUNT(*) AS value FROM session").get()?.value ?? 0
}),
)
).pipe(Effect.orElseSucceed(() => 0))
}
function importNextDatabase(
+52 -55
View File
@@ -61,23 +61,21 @@ export const makeMemoryDriver = (): MemoryDriver => {
}
const failed = (value: string, cause: unknown) => new Failed({ path: value, cause })
const overrides: FilesImpl = {
stat: (value) =>
Effect.suspend(() => {
const node = lookup(value)
return node ? Effect.succeed(info(node)) : Effect.fail(new NotFound({ path: value }))
}),
read: (value, range) =>
Effect.gen(function* () {
const original = lookup(value)
if (!original) return yield* new NotFound({ path: value })
if (original.type === "directory") return yield* new WrongKind({ path: value, actual: "directory" })
const resolved = resolveKey(value, true)
const node = resolved === undefined ? undefined : nodes.get(resolved)
if (!node) return yield* new NotFound({ path: value })
if (node.type !== "file") return yield* new WrongKind({ path: value, actual: node.type })
const bytes = range === undefined ? node.bytes : node.bytes.subarray(range.offset, range.offset + range.length)
return { info: info(node), bytes: bytes.slice() }
}),
stat: (value) => {
const node = lookup(value)
return node ? Effect.succeed(info(node)) : Effect.fail(new NotFound({ path: value }))
},
read: (value, range) => {
const original = lookup(value)
if (!original) return Effect.fail(new NotFound({ path: value }))
if (original.type === "directory") return Effect.fail(new WrongKind({ path: value, actual: "directory" }))
const resolved = resolveKey(value, true)
const node = resolved === undefined ? undefined : nodes.get(resolved)
if (!node) return Effect.fail(new NotFound({ path: value }))
if (node.type !== "file") return Effect.fail(new WrongKind({ path: value, actual: node.type }))
const bytes = range === undefined ? node.bytes : node.bytes.subarray(range.offset, range.offset + range.length)
return Effect.succeed({ info: info(node), bytes: bytes.slice() })
},
write: (value, bytes) =>
Effect.try({
try: () => {
@@ -91,17 +89,17 @@ export const makeMemoryDriver = (): MemoryDriver => {
},
catch: (cause) => failed(value, cause),
}),
list: (value) =>
Effect.gen(function* () {
const target = resolveKey(value, true) ?? key(value)
const node = nodes.get(target)
if (!node) return yield* new NotFound({ path: value })
if (node.type !== "directory") return yield* new WrongKind({ path: value, actual: node.type })
return [...nodes.entries()]
.filter(([entry]) => entry !== target && path.posix.dirname(entry) === target)
.map(([entry, child]) => ({ name: path.posix.basename(entry), type: child.type satisfies FileType }))
.sort((a, b) => a.name.localeCompare(b.name))
}),
list: (value) => {
const target = resolveKey(value, true) ?? key(value)
const node = nodes.get(target)
if (!node) return Effect.fail(new NotFound({ path: value }))
if (node.type !== "directory") return Effect.fail(new WrongKind({ path: value, actual: node.type }))
const entries = [...nodes.entries()]
.filter(([entry]) => entry !== target && path.posix.dirname(entry) === target)
.map(([entry, child]) => ({ name: path.posix.basename(entry), type: child.type satisfies FileType }))
.sort((a, b) => a.name.localeCompare(b.name))
return Effect.succeed(entries)
},
remove: (value) =>
Effect.sync(() => {
const target = resolveKey(value, false) ?? key(value)
@@ -109,33 +107,32 @@ export const makeMemoryDriver = (): MemoryDriver => {
if (entry === target || entry.startsWith(`${target}/`)) nodes.delete(entry)
}
}),
move: (from, to) =>
Effect.gen(function* () {
const source = resolveKey(from, false) ?? key(from)
const node = nodes.get(source)
if (!node) return yield* new NotFound({ path: from })
yield* Effect.try({
try: () => {
const requested = resolveKey(to, false) ?? key(to)
const destination =
nodes.get(requested)?.type === "directory"
? path.posix.join(requested, path.posix.basename(source))
: requested
if (node.type === "directory" && destination.startsWith(`${source}/`)) {
throw new Error(`Cannot move a directory into itself: ${from}`)
}
const existing = nodes.get(destination)
if (node.type === "directory" && existing && existing.type !== "directory") {
throw new Error(`Cannot overwrite a non-directory with a directory: ${to}`)
}
requireParent(destination)
const moved = [...nodes.entries()].filter(([entry]) => entry === source || entry.startsWith(`${source}/`))
for (const [entry] of moved) nodes.delete(entry)
for (const [entry, child] of moved) nodes.set(`${destination}${entry.slice(source.length)}`, child)
},
catch: (cause) => failed(from, cause),
})
}),
move: (from, to) => {
const source = resolveKey(from, false) ?? key(from)
const node = nodes.get(source)
if (!node) return Effect.fail(new NotFound({ path: from }))
return Effect.try({
try: () => {
const requested = resolveKey(to, false) ?? key(to)
const destination =
nodes.get(requested)?.type === "directory"
? path.posix.join(requested, path.posix.basename(source))
: requested
if (node.type === "directory" && destination.startsWith(`${source}/`)) {
throw new Error(`Cannot move a directory into itself: ${from}`)
}
const existing = nodes.get(destination)
if (node.type === "directory" && existing && existing.type !== "directory") {
throw new Error(`Cannot overwrite a non-directory with a directory: ${to}`)
}
requireParent(destination)
const moved = [...nodes.entries()].filter(([entry]) => entry === source || entry.startsWith(`${source}/`))
for (const [entry] of moved) nodes.delete(entry)
for (const [entry, child] of moved) nodes.set(`${destination}${entry.slice(source.length)}`, child)
},
catch: (cause) => failed(from, cause),
})
},
mkdir: (value) => Effect.try({ try: () => mkdirSync(value), catch: (cause) => failed(value, cause) }),
}
+4 -2
View File
@@ -62,8 +62,9 @@ export const syncTextBom = Effect.fn("FileMutation.syncTextBom")(function* (
const transactionLocks = KeyedMutex.makeUnsafe<string>()
/**
* Mutation locking is process-local and serializes cooperating OpenCode
* changes; external writes can still race.
* Serialize file changes by absolute target. Conditional writes compare and
* write under the same process-local lock so cooperating OpenCode mutations do
* not overwrite changes made from the same stale content.
*/
const layer = Layer.effect(
Service,
@@ -128,6 +129,7 @@ export const node = makeLocationNode({ service: Service, layer, deps: [Environme
/**
* Deferred until the corresponding integrations exist.
*/
// TODO: Add formatter integration after formatter runtime exists.
// TODO: Publish watcher/file-edit events after watcher integration exists.
// TODO: Add snapshots / undo after snapshot design exists.
// TODO: Notify LSP and collect diagnostics after LSP runtime exists.
@@ -25,19 +25,15 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Lo
const layer = Layer.effect(
Service,
Effect.gen(function* () {
let current: readonly string[] = []
const listeners = new Set<(ignore: readonly string[]) => Effect.Effect<void>>()
const state = State.create<Data, Draft>({
const state: State.Interface<Data, Draft> = State.create<Data, Draft>({
name: "location-watcher-policy",
initial: () => ({ ignore: [] }),
draft: (draft) => ({
add: (ignore) => draft.ignore.push(...ignore),
list: () => draft.ignore,
}),
finalize: (draft) =>
Effect.sync(() => {
current = [...draft.list()]
}).pipe(Effect.andThen(Effect.forEach(listeners, (listener) => listener(current), { discard: true }))),
notify: () => Effect.forEach(listeners, (listener) => listener(state.get().ignore), { discard: true }),
})
const observe = Effect.fn("LocationWatcherPolicy.observe")(function* (
listener: (ignore: readonly string[]) => Effect.Effect<void>,
@@ -56,7 +52,7 @@ const layer = Layer.effect(
return Service.of({
transform: state.transform,
reload: state.reload,
current: () => current,
current: () => state.get().ignore,
observe,
})
}),
+3 -36
View File
@@ -1,38 +1,5 @@
# GitHub Copilot AI SDK Adapters
This is a temporary package used primarily for GitHub Copilot compatibility.
This directory contains upstream-derived AI SDK implementations adapted for
GitHub Copilot. It is not a generic OpenAI-compatible provider.
These DO NOT apply for openai-compatible providers or majority of providers supporting completions/responses apis. THIS IS ONLY FOR GITHUB COPILOT!!!
## Provenance
- `chat/` is derived from the Vercel AI SDK
`@ai-sdk/openai-compatible` chat implementation.
- `responses/` is derived from the Vercel AI SDK `@ai-sdk/openai` Responses
implementation.
- The exact upstream revisions originally copied into this repository are
unknown. Current dependency versions and the `VERSION` constant in
`copilot-provider.ts` are not copy provenance.
## Ownership
Keep `chat/` and `responses/` structurally close to their upstream modules, but
preserve the intentional Copilot adaptations: the `copilot` options and metadata
namespace, `thinking_budget`, reasoning text and opaque reasoning, stateless
Responses requests with encrypted reasoning, rotating response item IDs, and
explicit function-tool strictness taking precedence over the global fallback.
`copilot-provider.ts` is the local adapter assembly entrypoint used by
`plugin/provider/github-copilot.ts`. `models.ts` is OpenCode-owned catalog
reconciliation, not vendored SDK code. Authentication, request headers, model
routing, and integration lifecycle are also owned by the provider plugin.
When updating the upstream-shaped modules, compare against both source packages
and reapply the documented Copilot adaptations. Focused regression coverage is
in:
- `test/github-copilot/copilot-chat-model.test.ts`
- `test/github-copilot/convert-to-copilot-messages.test.ts`
- `test/github-copilot/openai-responses-language-model.test.ts`
- `test/github-copilot/openai-responses-prepare-tools.test.ts`
- `test/github-copilot/models.test.ts`
- `test/plugin/provider-github-copilot.test.ts`
Avoid making edits to these files
+7 -2
View File
@@ -3,7 +3,7 @@ import { Effect } from "effect"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { FileSystem } from "../filesystem.js"
import { DecodeError, ResizerUnavailableError, SizeError, type Limits } from "../image.js"
import { DecodeError, ResizerUnavailableError, SizeError } from "../image.js"
const JPEG_QUALITIES = [80, 85, 70, 55, 40]
@@ -33,7 +33,12 @@ export const make = Effect.gen(function* () {
return Effect.fn("Image.Photon.normalize")(function* (
resource: string,
content: FileSystem.Content & { readonly encoding: "base64" },
limits: Readonly<Limits>,
limits: {
readonly autoResize: boolean
readonly maxWidth: number
readonly maxHeight: number
readonly maxBase64Bytes: number
},
) {
const photon = yield* loadPhoton
const decoded = yield* Effect.try({
+1 -1
View File
@@ -74,7 +74,7 @@ export const layer = (options?: Options) =>
draft.available = false
},
}),
finalize: () => bus.publish(Event.Updated, {}).pipe(Effect.asVoid),
notify: () => bus.publish(Event.Updated, {}).pipe(Effect.asVoid),
})
const source = (value: ReadonlyArray<File> | Instructions.Unavailable | Instructions.Removed) =>
+8 -6
View File
@@ -328,7 +328,7 @@ const layer = Layer.effect(
},
},
}),
finalize: () => bus.publish(Integration.Event.Updated, {}).pipe(Effect.asVoid),
notify: () => bus.publish(Integration.Event.Updated, {}).pipe(Effect.asVoid),
})
const createCredential = Effect.fnUntraced(function* (input: Parameters<Credential.Interface["create"]>[0]) {
@@ -402,11 +402,13 @@ const layer = Layer.effect(
}
yield* Effect.gen(function* () {
const implementation = state
.get()
.integrations.get(attempt.integrationID)
?.implementations.get(attempt.methodID)
const persistence = yield* Effect.sync(() => attempt.label ?? implementation?.label?.(exit.value)).pipe(
const persistence = yield* Effect.sync(() => {
const implementation = state
.get()
.integrations.get(attempt.integrationID)
?.implementations.get(attempt.methodID)
return attempt.label ?? implementation?.label?.(exit.value)
}).pipe(
Effect.flatMap((label) =>
createCredential({
integrationID: attempt.integrationID,
+1 -7
View File
@@ -26,7 +26,6 @@ import {
} from "@modelcontextprotocol/sdk/types.js"
import { Cause, Effect, Exit, Schema } from "effect"
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
import type { Session } from "@opencode-ai/schema/session"
import { McpStdio } from "./stdio.js"
const DEFAULT_STARTUP_TIMEOUT = 30_000
@@ -157,7 +156,6 @@ export interface Connection {
readonly callTool: (input: {
readonly name: string
readonly args?: Record<string, unknown>
readonly sessionID?: Session.ID
}) => Effect.Effect<CallToolResult, Error>
readonly onClose: (callback: () => void) => void
/** Registers a callback fired when the server emits an MCP logging notification. */
@@ -398,11 +396,7 @@ export const connect = Effect.fnUntraced(function* (
Effect.tryPromise({
try: (signal) =>
client.callTool(
{
name: input.name,
arguments: input.args ?? {},
...(input.sessionID === undefined ? {} : { _meta: { sessionID: input.sessionID } }),
},
{ name: input.name, arguments: input.args ?? {} },
CallToolResultSchema,
// Keep progress tokens available while enforcing a hard wall-clock execution timeout.
{ signal, timeout: executionTimeout, onprogress: () => {} },
+26 -8
View File
@@ -3,10 +3,23 @@ export * as Mcp from "./index.js"
import { Mcp } from "@opencode-ai/schema/mcp"
import { McpEvent } from "@opencode-ai/schema/mcp-event"
import { ephemeral } from "@opencode-ai/schema/event"
import type { Session } from "@opencode-ai/schema/session"
import { createHash } from "node:crypto"
import { isDeepStrictEqual } from "node:util"
import { Cause, Context, Effect, Exit, FiberSet, Latch, Layer, Schema, Scope, Stream, Types } from "effect"
import {
Cause,
Context,
Effect,
Exit,
Fiber,
FiberSet,
Latch,
Layer,
Schema,
Scope,
Semaphore,
Stream,
Types,
} from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Credential } from "../credential.js"
import { Bus } from "../bus.js"
@@ -154,7 +167,6 @@ export interface Interface extends State.Transformable<Draft> {
readonly server: ServerName | string
readonly name: string
readonly args?: Record<string, unknown>
readonly sessionID?: Session.ID
}) => Effect.Effect<ToolResult, NotFoundError | ToolCallError>
readonly instructions: () => Effect.Effect<ServerInstructions[]>
readonly prompts: () => Effect.Effect<Prompt[]>
@@ -615,8 +627,9 @@ export const layer = (options?: Options) =>
let applied: Map<ServerName, Mcp.ServerConfig> | undefined
const overrides = new Map<ServerName, Mcp.ServerConfig | false>()
const reconcile = Effect.fnUntraced(function* (next: Draft) {
const servers = new Map(next.list())
const reconcileLock = Semaphore.makeUnsafe(1)
const reconcile = Effect.fnUntraced(function* () {
const servers = state.get().servers
if (!applied && entries.size === 0) {
for (const [name, server] of servers) {
entries.set(name, {
@@ -677,7 +690,7 @@ export const layer = (options?: Options) =>
Stream.runForEach((event) => Effect.sync(() => fork(reconnect(event.data.integrationID)))),
),
)
const state = State.create<Data, Draft>({
const state: State.Interface<Data, Draft> = State.create<Data, Draft>({
name: "mcp",
initial: () => ({
servers: new Map(
@@ -702,7 +715,12 @@ export const layer = (options?: Options) =>
},
remove: (server) => draft.servers.delete(ServerName.make(server)),
}),
finalize: reconcile,
notify: () =>
Effect.gen(function* () {
const exit = yield* Fiber.await(fork(reconcileLock.withPermit(reconcile())))
if (Exit.isFailure(exit) && root.state._tag === "Closed" && Cause.hasInterruptsOnly(exit.cause)) return
yield* exit
}),
})
// Suspend so each await sees current entries; a bare Map iterator is exhausted after one run.
@@ -764,7 +782,7 @@ export const layer = (options?: Options) =>
message: "MCP server is not connected",
})
const result = yield* target.entry.client
.callTool({ name: input.name, args: input.args, sessionID: input.sessionID })
.callTool({ name: input.name, args: input.args })
.pipe(
Effect.mapError(
(error) => new ToolCallError({ server: target.name, tool: input.name, message: error.message }),
+30 -5
View File
@@ -4,7 +4,7 @@ import os from "node:os"
import path from "node:path"
import { Context, Effect, Layer, Schema } from "effect"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Added, Handoff, PersistentPty, ReadLines, Removed, type ReadResult } from "@opencode-ai/schema/persistent-pty"
import { Added, Handoff, ReadLines, Removed, type ReadResult } from "@opencode-ai/schema/persistent-pty"
import { Session } from "@opencode-ai/schema/session"
import { Bus } from "../bus.js"
import { Pty } from "@opencode-ai/schema/pty"
@@ -26,9 +26,19 @@ export { Handoff } from "@opencode-ai/schema/persistent-pty"
export const Options = Schema.Struct({ handoff: Schema.optional(Handoff) })
export type Options = typeof Options.Type
export type Info = PersistentPty.Info
export type Info = Pty.Info & {
readonly sessionID: Session.ID
readonly foregroundProcess: string | null
readonly size: { readonly cols: number; readonly rows: number }
readonly output: { readonly head: number; readonly tail: number }
}
export type Snapshot = PersistentPty.Snapshot
export type Snapshot = {
readonly info: Info
readonly text: string
readonly checkpoint: Uint8Array
readonly cursor: { readonly x: number; readonly y: number }
}
export type Attachment = {
readonly info: Info
@@ -151,7 +161,15 @@ export const configured = (options: Options = {}) =>
const create = Effect.fn("PersistentPty.create")(function* (
sessionID: Session.ID,
input: Parameters<Interface["create"]>[1],
input: {
readonly command?: string
readonly args: readonly string[]
readonly cwd?: string
readonly title: string
readonly env: Readonly<Record<string, string>>
readonly cols?: number
readonly rows?: number
},
) {
const response = yield* request(
daemon,
@@ -320,7 +338,14 @@ export const configured = (options: Options = {}) =>
const attach = Effect.fn("PersistentPty.attach")(function* (
id: Pty.ID,
input: Parameters<Interface["attach"]>[1],
input: {
readonly cursor: number
readonly attachmentID: string
readonly role: Role
readonly takeover?: boolean
readonly onEvent: (event: StreamEvent) => void
readonly onEnd: () => void
},
) {
yield* get(id)
const attachment = yield* daemon
+1 -1
View File
@@ -37,7 +37,7 @@ export const ModelsDevPlugin = define({
})
for (const model of provider.models) {
if (model.status === "deprecated") continue
catalog.model.update(provider.info.id, model.id, (draft) => Object.assign(draft, structuredClone(model)))
catalog.model.update(provider.info.id, model.id, (draft) => Object.assign(draft, model))
}
}
})
+1 -1
View File
@@ -40,7 +40,7 @@ export const load = Effect.fn("PluginModule.load")(function* (
const npm = yield* Npm.Service
const entrypoint = path.isAbsolute(operation.target)
? pathToFileURL(operation.target).href
: (yield* npm.add(operation.target, { subpaths: ["server", ""] })).entrypoint
: (yield* npm.add(operation.target, { subpaths: ["server", ""], refresh: true })).entrypoint
if (!entrypoint) return yield* Effect.fail(new Error(`Plugin entrypoint not found: ${operation.target}`))
// Bun currently ignores query parameters when caching file:// imports.
const target = typeof Bun !== "undefined" ? operation.target.replaceAll("\\", "/") : entrypoint
+4 -6
View File
@@ -76,13 +76,11 @@ to every project for that user. Project configuration can live in any directory
as `opencode.json(c)` or `.opencode/opencode.json(c)`, including nested packages
in a monorepo.
During ordinary project discovery, OpenCode searches the current Location
directory and every ancestor through the filesystem root, including directories
above the detected project or repository root. It merges direct
`opencode.json(c)` files from the farthest ancestor to the current directory,
When OpenCode starts, it searches from the current directory up to the project
root. It merges direct `opencode.json(c)` files from root to current directory,
then does the same for `.opencode/opencode.json(c)` files. This means every
discovered `.opencode` config overrides every discovered direct config. Global
filesystem configuration has lower precedence than these discovered documents.
`.opencode` config overrides every direct config. Global configuration has the
lowest precedence.
Common configuration fields include `model`, `default_agent`, `permissions`,
`agents`, `commands`, `plugins`, `providers`, `mcp`, `skills`, `instructions`,
-16
View File
@@ -78,9 +78,6 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
...post.filter((plugin) => enabled.has(plugin.id)),
],
failures: [...failures.values()],
refreshes: [...packages.entries()].flatMap(([target, plugin]) =>
!path.isAbsolute(target) && enabled.has(plugin.id) ? [target] : [],
),
}
})
@@ -92,7 +89,6 @@ export const layer = Layer.effect(
const instance = yield* InstancePlugins.Service
const sources = yield* ConfigPluginSource.Service
const bus = yield* Bus.Service
const npm = yield* Npm.Service
const ready = yield* Latch.make()
let observed = 0
@@ -117,18 +113,6 @@ export const layer = Layer.effect(
const resolved = yield* resolve(pre, post, operations)
// Replace the active generation in one scoped, batched activation.
yield* registry.activate(resolved.plugins, resolved.failures)
if (resolved.refreshes.length) {
yield* Effect.forEach(
resolved.refreshes,
(target) =>
npm
.add(target, { subpaths: ["server", ""], refresh: true })
.pipe(
Effect.catchCause((cause) => Effect.logWarning("failed to refresh package plugin", { target, cause })),
),
{ concurrency: "unbounded", discard: true },
).pipe(Effect.forkDetach)
}
})
const updates = Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])).pipe(
// Make accepted work visible to flush before coalescing the burst.
+5 -4
View File
@@ -137,10 +137,11 @@ const layer = Layer.effect(
strategy: project.vcs.type === "git" ? "git" : undefined,
})
// A missing directory row means this directory's resolution is a new durable
// fact. The row insert commits atomically with the event, so a crash between
// checks retries on the next resolve instead of stranding the announcement.
// The in-flight set keeps concurrent resolves from publishing the same fact
// twice.
// fact (copy.ts registers copy directories directly; those never strand
// sessions and never announce). The row insert commits atomically with the
// event, so a crash between checks retries on the next resolve instead of
// stranding the announcement. The in-flight set keeps concurrent resolves
// from publishing the same fact twice.
for (const item of directories) {
const key = item.projectID + "\u0000" + item.directory
if (announcing.has(key)) continue
+53 -42
View File
@@ -26,6 +26,7 @@ export type Info = Reference.Info
type Data = {
sources: Map<string, Types.DeepMutable<Source>>
materialized: Map<string, Info>
}
type Draft = {
@@ -47,61 +48,71 @@ const layer = Layer.effect(
const bus = yield* Bus.Service
const cache = yield* RepositoryCache.Service
const scope = yield* Scope.Scope
const materialized = new Map<string, Info>()
const state = State.create<Data, Draft>({
const state: State.Interface<Data, Draft> = State.create<Data, Draft>({
name: "reference",
initial: () => ({ sources: new Map() }),
initial: () => ({ sources: new Map(), materialized: new Map() }),
draft: (draft) => ({
add: (name, source) => draft.sources.set(name, source as Types.DeepMutable<Source>),
remove: (name) => draft.sources.delete(name),
list: () => Array.from(draft.sources.entries()) as [string, Source][],
}),
finalize: (draft) =>
Effect.gen(function* () {
materialized.clear()
for (const [name, source] of draft.list()) {
if (source.type === "local") {
materialized.set(
name,
Info.make({
name,
path: source.path,
...(source.description === undefined ? {} : { description: source.description }),
...(source.hidden === undefined ? {} : { hidden: source.hidden }),
source,
}),
)
continue
}
const repository = Repository.parse(source.repository)
if (!repository || !Repository.isRemote(repository)) continue
if (source.branch) {
try {
Repository.validateBranch(source.branch)
} catch {
continue
}
}
materialized.set(
prepare: (data) => {
for (const [name, source] of data.sources) {
if (source.type === "local") {
data.materialized.set(
name,
Info.make({
name,
path: AbsolutePath.make(Repository.cachePath(global.repos, repository, source.branch)),
path: source.path,
...(source.description === undefined ? {} : { description: source.description }),
...(source.hidden === undefined ? {} : { hidden: source.hidden }),
source,
}),
)
yield* cache.ensure({ reference: repository, branch: source.branch, refresh: true }).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to materialize reference", {
name,
repository: source.repository,
cause,
}),
),
Effect.forkIn(scope),
)
continue
}
const repository = Repository.parse(source.repository)
if (!repository || !Repository.isRemote(repository)) continue
if (source.branch) {
try {
Repository.validateBranch(source.branch)
} catch {
continue
}
}
data.materialized.set(
name,
Info.make({
name,
path: AbsolutePath.make(Repository.cachePath(global.repos, repository, source.branch)),
...(source.description === undefined ? {} : { description: source.description }),
...(source.hidden === undefined ? {} : { hidden: source.hidden }),
source,
}),
)
}
},
notify: () =>
Effect.gen(function* () {
for (const info of state.get().materialized.values()) {
const source = info.source
if (source.type !== "git") continue
yield* cache
.ensure({
reference: Repository.parseRemote(source.repository),
branch: source.branch,
refresh: true,
})
.pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to materialize reference", {
name: info.name,
repository: source.repository,
cause,
}),
),
Effect.forkIn(scope),
)
}
yield* bus.publish(Reference.Event.Updated, {})
}),
@@ -111,7 +122,7 @@ const layer = Layer.effect(
transform: state.transform,
reload: state.reload,
list: Effect.fn("Reference.list")(function* () {
return Array.from(materialized.values())
return Array.from(state.get().materialized.values())
}),
})
}),
+19 -20
View File
@@ -393,27 +393,26 @@ export const layer = Layer.effect(
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
inputID: input.inputID,
})
return yield* input.resolveModel(input.session).pipe(
Effect.matchEffect({
onFailure: (cause) =>
failed({
sessionID: input.session.id,
reason: "manual",
error: toSessionError(cause),
inputID: input.inputID,
}),
onSuccess: (resolved) =>
execute({
session: input.session,
resolved,
prepare: input.prepare,
reason: "manual",
inputID: input.inputID,
started: input.started,
...content,
}),
}),
const resolved = yield* input.resolveModel(input.session).pipe(
Effect.catch((cause) =>
failed({
sessionID: input.session.id,
reason: "manual",
error: toSessionError(cause),
inputID: input.inputID,
}),
),
)
if ("status" in resolved) return resolved
return yield* execute({
session: input.session,
resolved,
prepare: input.prepare,
reason: "manual",
inputID: input.inputID,
started: input.started,
...content,
})
})
return Service.of({
transform: state.transform,
@@ -131,7 +131,11 @@ const layer = Layer.effect(
return (yield* rows(sessionID, false)).map((row) => ({ key: row.key, value: row.value }))
})
const put = Effect.fn("InstructionEntry.put")(function* (input: Parameters<Interface["put"]>[0]) {
const put = Effect.fn("InstructionEntry.put")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly key: Key
readonly value: Schema.Json
}) {
const actualBytes = Buffer.byteLength(JSON.stringify(input.value), "utf8")
if (actualBytes > MaxValueBytes)
yield* new ValueTooLargeError({
@@ -155,7 +159,10 @@ const layer = Layer.effect(
.pipe(Effect.orDie)
})
const remove = Effect.fn("InstructionEntry.remove")(function* (input: Parameters<Interface["remove"]>[0]) {
const remove = Effect.fn("InstructionEntry.remove")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly key: Key
}) {
yield* db
.update(InstructionEntryTable)
.set({ value: null, removed: true, time_updated: Date.now() })
@@ -42,8 +42,8 @@ export const commit = Effect.fn("InstructionState.commit")(function* (
observation: Observation,
) {
if (!observation.initial && Object.keys(observation.delta).length === 0) return
// The rendered text is frozen into the durable event because re-rendering it
// later would require the original Location-scoped instruction sources.
// The rendered text is frozen into the durable event: replaying it later would
// require the Location-scoped registry that produced it.
const text = observation.initial ? "" : yield* renderUpdateText(db, instructions, observation)
yield* bus.publish(
SessionEvent.InstructionsUpdated,
+4 -1
View File
@@ -43,7 +43,10 @@ const layer = Layer.effect(
// are re-discovered and re-injected instead of staying silently lost.
const inFlight = yield* Ref.make<Map<SessionSchema.ID, Set<string>>>(new Map())
const load = Effect.fn("SessionInstructions.load")(function* (input: Parameters<Interface["load"]>[0]) {
const load = Effect.fn("SessionInstructions.load")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly paths: ReadonlyArray<string>
}) {
const claimed = yield* Ref.modify(inFlight, (map) => {
const existing = map.get(input.sessionID) ?? new Set<string>()
const newlyClaimed = input.paths.filter((path) => !existing.has(path))
+7 -2
View File
@@ -15,7 +15,7 @@ import { SessionMessage } from "../message.js"
import { SessionSchema } from "../schema.js"
import { SessionStore } from "../store.js"
import { SessionTitle } from "../title.js"
import { DrainResult, Service, type Interface } from "./index.js"
import { DrainResult, Service, type Continuation } from "./index.js"
import { Snapshot } from "../../snapshot.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { llmClient } from "../../effect/app-node-platform.js"
@@ -44,7 +44,12 @@ const layer = Layer.effect(
// Title generation starts once input is visible and must not delay model execution.
const titles = yield* FiberMap.make<SessionSchema.ID, void, never>()
const drain = Effect.fn("SessionRunner.drain")(function* (input: Parameters<Interface["drain"]>[0]) {
const drain = Effect.fn("SessionRunner.drain")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly force: boolean
readonly continuation?: Continuation
readonly promotable?: SessionInbox.Promotable
}) {
const sessionID = input.sessionID
let force = input.force
let continuing = input.continuation !== undefined
+49 -49
View File
@@ -122,8 +122,8 @@ const layer = () =>
const environments = yield* SessionEnvironment.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
const commands = new Map<Shell.ID, Active>()
const exitOrder: Shell.ID[] = []
const sessions = new Map<string, Active>()
const exitOrder: string[] = []
const outputDir = path.join(global.data, DIRECTORY, location.project.id)
const { mkdir, unlink } = yield* Effect.promise(() => import("fs/promises"))
@@ -132,44 +132,44 @@ const layer = () =>
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
for (const command of commands.values()) {
if (command.timeoutFiber) yield* Fiber.interrupt(command.timeoutFiber)
for (const session of sessions.values()) {
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
// Teardown interrupts pending commands; it is not a terminal command failure.
yield* Deferred.interrupt(command.done)
yield* Deferred.interrupt(session.done)
}
commands.clear()
sessions.clear()
exitOrder.length = 0
}),
)
const require = Effect.fnUntraced(function* (id: Shell.ID) {
const command = commands.get(id)
if (!command) return yield* new NotFoundError({ id })
return command
const session = sessions.get(id)
if (!session) return yield* new NotFoundError({ id })
return session
})
const removeCommand = Effect.fnUntraced(function* (id: Shell.ID) {
const command = commands.get(id)
const removeSession = Effect.fnUntraced(function* (id: Shell.ID) {
const session = sessions.get(id)
const index = exitOrder.indexOf(id)
if (index !== -1) exitOrder.splice(index, 1)
if (!command) return
commands.delete(id)
if (command.timeoutFiber) yield* Fiber.interrupt(command.timeoutFiber)
if (!session) return
sessions.delete(id)
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
// Unblock any wait still pending when the command is removed before it terminated.
yield* Deferred.fail(command.done, new NotFoundError({ id }))
yield* Effect.promise(() => unlink(command.file).catch(() => {}))
yield* Deferred.fail(session.done, new NotFoundError({ id }))
yield* Effect.promise(() => unlink(session.file).catch(() => {}))
yield* bus.publish(Shell.Event.Deleted, { id })
})
const remove = Effect.fn("Shell.remove")(function* (id: Shell.ID) {
yield* require(id)
yield* removeCommand(id)
yield* removeSession(id)
})
const list = Effect.fn("Shell.list")(function* () {
return Array.from(commands.values())
.filter((command) => command.info.status === "running")
.map((command) => command.info)
return Array.from(sessions.values())
.filter((session) => session.info.status === "running")
.map((session) => session.info)
})
const get = Effect.fn("Shell.get")(function* (id: Shell.ID) {
@@ -181,24 +181,24 @@ const layer = () =>
})
const timeout = Effect.fn("Shell.timeout")(function* (id: Shell.ID, duration: number) {
const command = yield* require(id)
if (command.info.status !== "running" || !command.timeout) return command.info
yield* command.timeout(duration)
return command.info
const session = yield* require(id)
if (session.info.status !== "running" || !session.timeout) return session.info
yield* session.timeout(duration)
return session.info
})
const output = Effect.fnUntraced(function* (id: Shell.ID, input?: Shell.OutputInput) {
const command = yield* require(id)
const session = yield* require(id)
const cursor = input?.cursor ?? 0
const limit = input?.limit ?? 65536
if (cursor >= command.size) return { output: "", cursor: command.size, size: command.size, truncated: false }
if (cursor >= session.size) return { output: "", cursor: session.size, size: session.size, truncated: false }
const start = Math.max(0, cursor)
const length = Math.min(limit, command.size - start)
const length = Math.min(limit, session.size - start)
const buffer = Buffer.alloc(length)
const bytesRead = yield* Effect.promise(
() =>
new Promise<number>((resolve) => {
const stream = createReadStream(command.file, { start, end: start + length - 1 })
const stream = createReadStream(session.file, { start, end: start + length - 1 })
let offset = 0
stream.on("data", (chunk: string | Buffer) => {
const bytes = Buffer.from(chunk)
@@ -212,7 +212,7 @@ const layer = () =>
return {
output: buffer.subarray(0, bytesRead).toString("utf8"),
cursor: start + bytesRead,
size: command.size,
size: session.size,
truncated: false,
}
})
@@ -257,7 +257,7 @@ const layer = () =>
// Spawn through the Environment and stream combined output to the file. The handle is scope-bound, so
// the managing fiber keeps its scope open until the command terminates (it awaits `done` at the
// end). `create` returns once `ready` resolves with the registered command.
// end). `create` returns once `ready` resolves with the registered session.
const ready = Deferred.makeUnsafe<Active, AppProcess.AppProcessError>()
runFork(
Effect.scoped(
@@ -275,7 +275,7 @@ const layer = () =>
.pipe(
Effect.mapError((cause) => new AppProcess.AppProcessError({ command: invocation.command, cause })),
)
const command: Active = {
const session: Active = {
info: produce(info, (draft) => {
draft.pid = handle.pid
}),
@@ -283,7 +283,7 @@ const layer = () =>
size: 0,
done: Deferred.makeUnsafe<Info, NotFoundError>(),
}
commands.set(id, command)
sessions.set(id, session)
const stream = createWriteStream(file)
const outputDone = Latch.makeUnsafe()
@@ -291,7 +291,7 @@ const layer = () =>
Stream.runForEach((chunk: Uint8Array) =>
Effect.sync(() => {
stream.write(chunk)
command.size += chunk.length
session.size += chunk.length
}),
),
)
@@ -317,8 +317,8 @@ const layer = () =>
const finish = (status: Info["status"], exit?: number, beforeWait = Effect.void) =>
Effect.gen(function* () {
if (command.info.status !== "running") return
command.info = produce(command.info, (draft) => {
if (session.info.status !== "running") return
session.info = produce(session.info, (draft) => {
draft.status = status
if (exit !== undefined) draft.exit = exit
draft.time.completed = Date.now()
@@ -326,10 +326,10 @@ const layer = () =>
yield* beforeWait
yield* outputDone.await
// Resolve waiters with the terminal Info before any retention eviction, so an evicted
// command still reports success rather than the removal NotFoundError. This runs before
// session still reports success rather than the removal NotFoundError. This runs before
// the timeout-fiber interrupt below, which on the timeout path would otherwise cancel
// this very fiber (finish is invoked by the timeout fiber) before waiters are resolved.
yield* Deferred.succeed(command.done, command.info)
yield* Deferred.succeed(session.done, session.info)
yield* bus.publish(Shell.Event.Exited, {
id,
...(exit !== undefined ? { exit } : {}),
@@ -339,19 +339,19 @@ const layer = () =>
while (exitOrder.length > EXITED_LIMIT) {
const oldest = exitOrder[0]
if (!oldest) break
yield* removeCommand(oldest)
yield* removeSession(Shell.ID.make(oldest))
}
// Cancel a pending timeout once the command exits on its own. Interrupting last avoids
// aborting finish when finish itself runs on the timeout fiber.
if (command.timeoutFiber) yield* Fiber.interrupt(command.timeoutFiber)
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
})
command.timeout = (duration) =>
session.timeout = (duration) =>
Effect.gen(function* () {
if (command.timeoutFiber) yield* Fiber.interrupt(command.timeoutFiber)
command.timeoutFiber = undefined
if (duration === 0 || command.info.status !== "running") return
command.timeoutFiber = runFork(
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
session.timeoutFiber = undefined
if (duration === 0 || session.info.status !== "running") return
session.timeoutFiber = runFork(
Effect.sleep(Duration.millis(duration)).pipe(
Effect.flatMap(() =>
finish("timeout", undefined, handle.kill().pipe(Effect.catch(() => Effect.void))),
@@ -360,7 +360,7 @@ const layer = () =>
)
})
yield* command.timeout(invocation.timeout)
yield* session.timeout(invocation.timeout)
runFork(
handle.exitCode.pipe(
@@ -370,16 +370,16 @@ const layer = () =>
)
yield* bus.publish(Shell.Event.Created, { info })
yield* Deferred.succeed(ready, command)
yield* Deferred.succeed(ready, session)
// Hold the handle's scope open until the command terminates; closing it earlier would
// release (kill) the process before its exit is observed.
yield* Deferred.await(command.done).pipe(Effect.catch(() => Effect.void))
yield* Deferred.await(session.done).pipe(Effect.catch(() => Effect.void))
}),
).pipe(Effect.catchTag("AppProcessError", (error) => Deferred.fail(ready, error))),
)
const command = yield* Deferred.await(ready)
return command.info
const session = yield* Deferred.await(ready)
return session.info
})
return Service.of({ create, list, get, wait, timeout, output, remove })
+1 -1
View File
@@ -109,7 +109,7 @@ const layer = Layer.effect(
draft.skills.delete(ID.make(id))
},
}),
finalize: () => bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid),
notify: () => bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid),
})
return Service.of({
+88 -94
View File
@@ -1,9 +1,9 @@
export * as State from "./state.js"
import { Clock, Context, Deferred, Effect, Scope, Semaphore } from "effect"
import { Clock, Context, Deferred, Effect, Exit, Scope } from "effect"
/**
* A replayable transform applied to a draft during reload.
* A replayable transform applied to a draft while deriving state.
*
* Domain drafts expose readable and writable state while preserving concise
* plugin/config code. Transforms synchronously rebuild derived state.
@@ -16,13 +16,14 @@ export interface Registration {
}
/**
* Registers and applies a scoped transform. Closing the owning Scope removes
* the transform and reloads the materialized state.
* Registers a scoped transform and invalidates the derived state. Closing the
* owning Scope removes the transform. Reads synchronously replay pending changes.
*/
export type Transform<DraftApi> = (
transform: TransformCallback<DraftApi>,
) => Effect.Effect<Registration, never, Scope.Scope>
/** Invalidates the snapshot after captured inputs change and coalesces notifications. */
export type Reload = () => Effect.Effect<void>
export interface Transformable<DraftApi> {
@@ -33,7 +34,7 @@ export interface Transformable<DraftApi> {
type Batch = {
active: boolean
readonly flush: boolean
readonly reloads: Set<Reload>
readonly notifications: Set<Reload>
}
const CurrentBatch = Context.Reference<Batch | undefined>("@opencode/State/CurrentBatch", {
@@ -41,17 +42,24 @@ const CurrentBatch = Context.Reference<Batch | undefined>("@opencode/State/Curre
})
const reloadDebounce = 500
/** flush: false is terminal teardown: states whose transforms are removed stop rebuilding, including pending reloads. */
/** Batches notifications, not read visibility. flush: false is terminal teardown. */
export function batch<A, E, R>(effect: Effect.Effect<A, E, R>, options: { readonly flush?: boolean } = {}) {
return Effect.gen(function* () {
const current = yield* CurrentBatch
if (current?.active && options.flush !== false) return yield* effect
const batch: Batch = { active: true, flush: options.flush !== false, reloads: new Set() }
const exit = yield* effect.pipe(Effect.provideService(CurrentBatch, batch), Effect.exit)
batch.active = false
if (batch.flush) yield* Effect.forEach(batch.reloads, (reload) => reload(), { discard: true })
return yield* exit
})
return Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const current = yield* CurrentBatch
if (current?.active && options.flush !== false) return yield* restore(effect)
const batch: Batch = { active: true, flush: options.flush !== false, notifications: new Set() }
const exit = yield* restore(effect.pipe(Effect.provideService(CurrentBatch, batch))).pipe(Effect.exit)
batch.active = false
const notifications = batch.flush
? yield* Effect.forEach(batch.notifications, (notify) => restore(notify()).pipe(Effect.exit))
: []
// Accepted writes are not rolled back: one failed observer must not hide
// the other states' changes, or replace the batch body's failure.
yield* Exit.asVoidAll([exit, ...notifications])
return yield* exit
}),
)
}
export const inherit = Effect.fnUntraced(function* () {
@@ -65,124 +73,110 @@ export interface Options<State, DraftApi> {
readonly initial: () => State
/** Wraps mutable state in a domain-specific draft API. */
readonly draft: MakeDraft<State, DraftApi>
/** Synchronously completes derived data after ordered transform replay. */
readonly prepare?: (state: State) => void
/**
* Runs after the rebuilt state becomes visible. Update events published here
* act as read barriers: subscribers refetching on the event observe the
* committed state.
* Observes accepted changes outside the read path. Batched writes notify at
* batch completion; reloads debounce notifications. Reads never run this hook.
* Resource reconciliation owns its execution scope and coordination.
*/
readonly finalize?: (draft: DraftApi) => Effect.Effect<void>
readonly notify?: () => Effect.Effect<void>
}
export interface Interface<State, DraftApi> extends Transformable<DraftApi> {
/** Returns the latest accepted state, replaying stale inputs synchronously. */
readonly get: () => State
}
export function create<State, DraftApi>(options: Options<State, DraftApi>): Interface<State, DraftApi> {
let state = options.initial()
let transforms: { run: TransformCallback<DraftApi> }[] = []
let generation = 0
const transforms = new Set<{ run: TransformCallback<DraftApi> }>()
let dirty = false
let requestedAt = 0
let running = false
let closed = false
let waiters: { generation: number; done: Deferred.Deferred<void> }[] = []
const semaphore = Semaphore.makeUnsafe(1)
let pending: Deferred.Deferred<void> | undefined
const commit = Effect.fn("State.commit")(function* (next: State) {
state = next
if (options.finalize) yield* options.finalize(options.draft(next))
})
const materialize = Effect.fnUntraced(function* () {
if (closed) return
const get = () => {
if (!dirty || closed) return state
const next = options.initial()
const api = options.draft(next)
for (const transform of transforms) {
yield* Effect.sync(() => {
transform.run(api)
})
}
yield* commit(next)
transforms.forEach((transform) => transform.run(api))
options.prepare?.(next)
state = next
dirty = false
return state
}
const notify = Effect.fn("State.notify")(function* () {
if (closed) return
get()
if (options.notify) yield* options.notify()
})
const materializeReload = () => semaphore.withPermit(materialize())
const rebuild = (): Effect.Effect<void> =>
const publish = (done: Deferred.Deferred<void>): Effect.Effect<void> =>
Effect.gen(function* () {
const clock = yield* Clock.Clock
const remaining = requestedAt + reloadDebounce - clock.currentTimeMillisUnsafe()
if (remaining > 0) yield* Effect.sleep(remaining)
if (clock.currentTimeMillisUnsafe() < requestedAt + reloadDebounce) return yield* rebuild()
if (clock.currentTimeMillisUnsafe() < requestedAt + reloadDebounce) return yield* publish(done)
const target = generation
const exit = yield* materializeReload().pipe(Effect.exit)
const completed = waiters.filter((waiter) => waiter.generation <= target)
waiters = waiters.filter((waiter) => waiter.generation > target)
yield* Effect.forEach(completed, (waiter) => Deferred.done(waiter.done, exit), {
concurrency: "unbounded",
discard: true,
})
if (generation > target) return yield* rebuild()
running = false
// Release scheduling ownership before observers run: an observer may
// request and await another reload without joining this notification.
pending = undefined
yield* notify().pipe(Deferred.into(done))
})
const reload = Effect.fnUntraced(function* () {
if (closed) return
const done = Deferred.makeUnsafe<void>()
const clock = yield* Clock.Clock
generation++
requestedAt = clock.currentTimeMillisUnsafe()
waiters.push({ generation, done })
if (!running) {
running = true
yield* rebuild().pipe(Effect.forkDetach)
}
yield* Deferred.await(done)
})
const changed = (debounce: boolean) =>
Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
if (closed) return
if (debounce) dirty = true
const batch = yield* CurrentBatch
if (batch?.active) {
if (!batch.flush) {
closed = true
return
}
batch.notifications.add(notify)
return
}
if (!debounce) return yield* restore(notify())
const clock = yield* Clock.Clock
requestedAt = clock.currentTimeMillisUnsafe()
// No yields between choosing the burst's completion and claiming it.
const done = pending ?? Deferred.makeUnsafe<void>()
if (!pending) {
pending = done
yield* publish(done).pipe(Effect.forkDetach)
}
yield* restore(Deferred.await(done))
}),
)
return {
get: () => state,
get,
transform: Effect.fn("State.transform")(function* (update) {
yield* Effect.annotateCurrentSpan("state", options.name ?? "anonymous")
const scope = yield* Scope.Scope
return yield* Effect.uninterruptible(
Effect.gen(function* () {
const transform = { run: update }
let active = true
const dispose = Effect.uninterruptible(
semaphore.withPermit(
Effect.suspend(() => {
if (!active) return Effect.void
active = false
transforms = transforms.filter((item) => item !== transform)
return Effect.gen(function* () {
const batch = yield* CurrentBatch
if (batch?.active) {
// Detached debounced reloads must also stay quiet after teardown.
if (!batch.flush) {
closed = true
return
}
batch.reloads.add(materializeReload)
return
}
yield* materialize()
})
}),
),
)
yield* semaphore.withPermit(
Effect.sync(() => {
transforms = [...transforms, transform]
Effect.suspend(() => {
if (!transforms.delete(transform)) return Effect.void
dirty = true
return changed(false)
}),
)
transforms.add(transform)
dirty = true
yield* Scope.addFinalizer(scope, dispose)
const batch = yield* CurrentBatch
if (batch?.active) batch.reloads.add(materializeReload)
else yield* materializeReload()
yield* changed(false)
return { dispose }
}),
)
}),
reload,
reload: () => changed(true),
}
}
+1 -1
View File
@@ -185,7 +185,7 @@ const layer = Layer.effect(
draft.tools.delete(id)
},
}),
finalize: () =>
notify: () =>
Effect.forEach(
state.get().errors,
({ tool, error }) =>
+3 -3
View File
@@ -7,7 +7,7 @@
- Plugin authors get schema-derived input types at the `ToolDraft.add` boundary through `Tool`.
- The heterogeneous Core registry deliberately erases registered definitions to `Tool.Info`. Use `any` at this internal boundary; do not replace it with `unknown`, JSON-value plumbing, casts, or compiled wrapper types solely to preserve type safety after registration.
- Executors return model content and metadata alongside declared machine output. Shipped built-ins and plugin tools use the same runtime shape after registration.
- `src/tool.ts` stores canonical Location registrations, derives LLM definitions, executes tools, and normalizes model content and images.
- `src/tool.ts` stores canonical Location registrations, derives LLM definitions, executes tools, and applies generic output bounding.
- Built-in tool plugins live in `tool/plugin`.
Do not add a second executable entry type, registry-owned executor, authorization callback, output-path callback, or legacy normalization path.
@@ -53,9 +53,9 @@ Tool filtering is catalog visibility, not execution authorization. A call still
## Output
Built-ins return complete tool responses. `Tool.Snapshot.execute` is the local execution boundary. Generic output bounding is applied by the Session runner after execution.
Built-ins return complete tool responses. `Tool.Snapshot.execute` is the local execution boundary.
Producer capture remains local to producers. Shell stores combined process output in its backing file and returns a bounded tail with the full-output path when truncated.
Producer capture limits remain local to producers. For example, Bash keeps `AppProcess.maxOutputBytes` and accurately reports stdout/stderr capture loss.
## Current Gaps
-1
View File
@@ -72,7 +72,6 @@ export const layer = Layer.effect(
server: tool.server,
name: tool.name,
args: (input ?? {}) as Record<string, unknown>,
sessionID: context.sessionID,
})
.pipe(
Effect.catchTags({
+3 -1
View File
@@ -5,7 +5,9 @@ export abstract class NamedError extends Error {
abstract toObject(): { name: string; data: unknown }
static hasName(error: unknown, name: string): boolean {
return typeof error === "object" && error !== null && "name" in error && error.name === name
return (
typeof error === "object" && error !== null && "name" in error && (error as Record<string, unknown>).name === name
)
}
static create<Name extends string, Fields extends Schema.Struct.Fields>(
+34 -10
View File
@@ -1,7 +1,7 @@
export * as Vcs from "./vcs.js"
import path from "path"
import { Cause, Context, Effect, Layer, Schema, Stream } from "effect"
import { Cause, Context, Effect, Exit, Fiber, FiberSet, Layer, Schema, Semaphore, Stream } from "effect"
import type { VcsDefinition, VcsDraft } from "@opencode-ai/plugin/effect/vcs"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { FileSystem } from "@opencode-ai/schema/filesystem"
@@ -47,8 +47,11 @@ const layer = Layer.effect(
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const bus = yield* Bus.Service
const root = yield* Effect.scope
const fork = yield* FiberSet.makeRuntime<never, void, never>()
const vcs = location.vcs
const current: { info: Info } = { info: { branch: {} } }
const refreshLock = Semaphore.makeUnsafe(1)
const scope = {
directory: location.directory,
worktree: location.project.directory,
@@ -69,7 +72,12 @@ const layer = Layer.effect(
set: (selection) => (draft.selection = selection),
},
}),
finalize: () => refresh(),
notify: () =>
Effect.gen(function* () {
const exit = yield* Fiber.await(fork(refresh()))
if (Exit.isFailure(exit) && root.state._tag === "Closed" && Cause.hasInterruptsOnly(exit.cause)) return
yield* exit
}),
})
const selected = () => {
const value = state.get()
@@ -87,13 +95,23 @@ const layer = Layer.effect(
),
)
const refresh = Effect.fn("Vcs.refresh")(function* () {
const provider = selected()
const next: Info = provider
? yield* protect(provider, "info", provider.info(scope).pipe(Effect.flatMap(decodeInfo)), { branch: {} })
: { branch: {} }
const changed = current.info.branch.current !== next.branch.current
current.info = next
if (changed) yield* bus.publish(VcsEvent.BranchUpdated, { branch: next.branch.current })
const changed = yield* Effect.gen(function* () {
const provider = selected()
const next: Info = provider
? yield* protect(provider, "info", provider.info(scope).pipe(Effect.flatMap(decodeInfo)), { branch: {} })
: { branch: {} }
const changed = current.info.branch.current !== next.branch.current
current.info = next
return changed
}).pipe(refreshLock.withPermit)
if (!changed) return
// Legacy listeners can publish nested updates before streams and SSE receive
// this event. Re-announce the latest branch if publication was overtaken.
while (true) {
const branch = current.info.branch.current
yield* bus.publish(VcsEvent.BranchUpdated, { branch })
if (branch === current.info.branch.current) return
}
})
if (vcs) {
@@ -105,7 +123,13 @@ const layer = Layer.effect(
yield* bus.subscribe(FileSystem.Event.Changed).pipe(
Stream.filter((event) => isBranchMetadata(event.data.file)),
Stream.runForEach((event) =>
refresh().pipe(Effect.withSpan("Vcs.refreshBranch", { attributes: { file: event.data.file } })),
refresh().pipe(
Effect.catchCauseIf(
(cause) => !Cause.hasInterrupts(cause),
(cause) => Effect.logWarning("vcs refresh failed", { file: event.data.file, cause }),
),
Effect.withSpan("Vcs.refreshBranch", { attributes: { file: event.data.file } }),
),
),
Effect.forkScoped({ startImmediately: true }),
)
+1 -1
View File
@@ -88,7 +88,7 @@ const layer = Layer.effect(
set: (selection) => (draft.selection = selection),
},
}),
finalize: () => bus.publish(WebSearch.Event.Updated, {}).pipe(Effect.asVoid),
notify: () => bus.publish(WebSearch.Event.Updated, {}).pipe(Effect.asVoid),
})
const requireProvider = (providers: Map<ID, ProviderImplementation>, providerID: ID) => {
+3 -120
View File
@@ -375,100 +375,7 @@ it.effect("projects replay metadata onto AI SDK prompt parts", () =>
}),
)
it.effect("normalizes file data across AI SDK prompt parts", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* aisdk.hook.sdk((event) => {
event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
})
const resolved = yield* aisdk.model(model("opaque-provider"))
const bytes = new Uint8Array([0, 1, 2, 3])
const prepared = yield* compileRequest(
LLM.request({
model: resolved,
messages: [
Message.user([
{ type: "media", mediaType: "image/png", data: bytes, filename: "bytes.png" },
{ type: "media", mediaType: "image/png", data: "AAAA", filename: "base64.png" },
{
type: "media",
mediaType: "image/png",
data: "data:image/png;charset=utf-8;base64,AQID",
filename: "inline.png",
},
{ type: "media", mediaType: "image/png", data: "https://example.com/image.png" },
{ type: "media", mediaType: "image/png", data: "s3://bucket/image.png" },
]),
Message.assistant({
type: "media",
mediaType: "application/pdf",
data: "http://example.com/document.pdf",
filename: "document.pdf",
}),
Message.tool({
id: "call_1",
name: "screenshot",
result: {
type: "content",
value: [{ type: "file", uri: "data:image/png;base64,BAUG", mime: "image/png", name: "tool.png" }],
},
}),
],
}),
)
expect(prepared.body.prompt).toEqual([
{
role: "user",
content: [
{ type: "file", mediaType: "image/png", data: bytes, filename: "bytes.png" },
{ type: "file", mediaType: "image/png", data: "AAAA", filename: "base64.png" },
{ type: "file", mediaType: "image/png", data: "AQID", filename: "inline.png" },
{
type: "file",
mediaType: "image/png",
data: new URL("https://example.com/image.png"),
filename: undefined,
},
{ type: "file", mediaType: "image/png", data: "s3://bucket/image.png", filename: undefined },
],
},
{
role: "assistant",
content: [
{
type: "file",
mediaType: "application/pdf",
data: new URL("http://example.com/document.pdf"),
filename: "document.pdf",
},
],
},
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call_1",
toolName: "screenshot",
output: { type: "text", value: "Media attached in the following user message." },
providerOptions: undefined,
},
],
},
{
role: "user",
content: [
{ type: "text", text: "Attached media from tool result:" },
{ type: "file", mediaType: "image/png", data: "BAUG", filename: "tool.png" },
],
},
])
}),
)
it.effect("normalizes user and tool media through the real Mistral provider", () =>
it.effect("moves a tool image through the real Mistral provider as a user message", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
let body: { messages?: unknown[] } | undefined
@@ -508,14 +415,7 @@ it.effect("normalizes user and tool media through the real Mistral provider", ()
LLM.request({
model: resolved,
messages: [
Message.user([
{ type: "text", text: "Inspect the attachments." },
{ type: "media", mediaType: "image/png", data: new Uint8Array([0, 1, 2, 3]) },
{ type: "media", mediaType: "image/png", data: "AQID" },
{ type: "media", mediaType: "image/png", data: "data:image/png;base64,BAUG" },
{ type: "media", mediaType: "image/png", data: "http://example.com/image.png" },
{ type: "media", mediaType: "application/pdf", data: "https://example.com/document.pdf" },
]),
Message.user("Inspect the screenshot."),
Message.assistant({ type: "tool-call", id: "call_1", name: "screenshot", input: {} }),
Message.tool({
type: "tool-result",
@@ -526,12 +426,6 @@ it.effect("normalizes user and tool media through the real Mistral provider", ()
value: [
{ type: "text", text: "Screenshot captured" },
{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png", name: "screen.png" },
{
type: "file",
uri: "https://example.com/tool-document.pdf",
mime: "application/pdf",
name: "tool-document.pdf",
},
],
},
}),
@@ -540,17 +434,7 @@ it.effect("normalizes user and tool media through the real Mistral provider", ()
).pipe(Effect.provide(client))
expect(body?.messages).toEqual([
{
role: "user",
content: [
{ type: "text", text: "Inspect the attachments." },
{ type: "image_url", image_url: "data:image/png;base64,AAECAw==" },
{ type: "image_url", image_url: "data:image/png;base64,AQID" },
{ type: "image_url", image_url: "data:image/png;base64,BAUG" },
{ type: "image_url", image_url: "http://example.com/image.png" },
{ type: "document_url", document_url: "https://example.com/document.pdf" },
],
},
{ role: "user", content: [{ type: "text", text: "Inspect the screenshot." }] },
{
role: "assistant",
content: "",
@@ -573,7 +457,6 @@ it.effect("normalizes user and tool media through the real Mistral provider", ()
content: [
{ type: "text", text: "Attached media from tool result:" },
{ type: "image_url", image_url: "data:image/png;base64,AAAA" },
{ type: "document_url", document_url: "https://example.com/tool-document.pdf" },
],
},
])
+24 -22
View File
@@ -85,8 +85,8 @@ describe("Bus Session routing", () => {
projectID: Project.ID.global,
})
const done = yield* bus.publish(Done, {})
expect(yield* Fiber.join(first)).toEqual([moved, done])
expect(yield* Fiber.join(second)).toEqual([moved, after, same, done])
expect(Array.from(yield* Fiber.join(first))).toEqual([moved, done])
expect(Array.from(yield* Fiber.join(second))).toEqual([moved, after, same, done])
expect(moved.location).toEqual(a)
}),
)
@@ -130,8 +130,8 @@ describe("Bus Session routing", () => {
)
const after = yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID: child })
const done = yield* bus.publish(Done, {})
expect((yield* Fiber.join(first)).map((event) => event.id)).toEqual([eventID, after.id, done.id])
expect(yield* Fiber.join(second)).toEqual([done])
expect(Array.from(yield* Fiber.join(first)).map((event) => event.id)).toEqual([eventID, after.id, done.id])
expect(Array.from(yield* Fiber.join(second))).toEqual([done])
}),
)
}),
@@ -158,17 +158,19 @@ describe("Bus Session routing", () => {
const explicit = yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID: id }, { location: b })
const done = yield* bus.publish(Done, {})
expect(yield* Fiber.join(first)).toEqual([renamed, text, broadcast, done])
expect(yield* Fiber.join(second)).toEqual([broadcast, explicit, done])
expect(yield* Fiber.join(workspace)).toEqual([broadcast, done])
expect(yield* Fiber.join(global)).toEqual([renamed, text, broadcast, explicit, done])
expect(Array.from(yield* Fiber.join(first))).toEqual([renamed, text, broadcast, done])
expect(Array.from(yield* Fiber.join(second))).toEqual([broadcast, explicit, done])
expect(Array.from(yield* Fiber.join(workspace))).toEqual([broadcast, done])
expect(Array.from(yield* Fiber.join(global))).toEqual([renamed, text, broadcast, explicit, done])
expect(listened).toEqual([renamed, text, broadcast, explicit, done])
expect(renamed).not.toHaveProperty("location")
expect(text).not.toHaveProperty("location")
expect(JSON.parse(JSON.stringify(renamed))).not.toHaveProperty("location")
const history = yield* bus.log({ aggregateID: id }).pipe(Stream.runCollect)
expect(
history.filter((event): event is Event.Payload => !Bus.isSynced(event)).every((event) => !event.location),
Array.from(history)
.filter((event): event is Event.Payload => !Bus.isSynced(event))
.every((event) => !event.location),
).toBe(true)
}),
)
@@ -195,8 +197,8 @@ describe("Bus Session routing", () => {
yield* bus.publish(SessionEvent.Moved, { sessionID: id, location: b, projectID: Project.ID.global })
const expected = yield* bus.publish(SessionEvent.Renamed, { sessionID: id, title: "destination" })
const done = yield* bus.publish(Done, {})
expect(yield* Fiber.join(typed)).toEqual([expected])
expect(yield* Fiber.join(multiple)).toEqual([expected, done])
expect(Array.from(yield* Fiber.join(typed))).toEqual([expected])
expect(Array.from(yield* Fiber.join(multiple))).toEqual([expected, done])
}),
)
@@ -224,8 +226,8 @@ describe("Bus Session routing", () => {
const done = yield* bus.publish(Done, {})
yield* Deferred.succeed(gate, undefined)
expect(yield* Fiber.join(first)).toEqual([created, before, moved, done])
expect(yield* Fiber.join(second)).toEqual([moved, after, done])
expect(Array.from(yield* Fiber.join(first))).toEqual([created, before, moved, done])
expect(Array.from(yield* Fiber.join(second))).toEqual([moved, after, done])
expect(moved).not.toHaveProperty("location")
}),
)
@@ -243,9 +245,9 @@ describe("Bus Session routing", () => {
const database = yield* Database.Service
expect(yield* database.db.select().from(SessionTable).where(eq(SessionTable.id, id)).get()).toBeUndefined()
expect(yield* Fiber.join(first)).toEqual([deleted, done])
expect(yield* Fiber.join(second)).toEqual([done])
expect(yield* Fiber.join(global)).toEqual([deleted, missing, done])
expect(Array.from(yield* Fiber.join(first))).toEqual([deleted, done])
expect(Array.from(yield* Fiber.join(second))).toEqual([done])
expect(Array.from(yield* Fiber.join(global))).toEqual([deleted, missing, done])
}),
)
@@ -264,8 +266,8 @@ describe("Bus Session routing", () => {
])
const done = yield* bus.publish(Done, {})
yield* Deferred.succeed(gate, undefined)
expect(yield* Fiber.join(first)).toEqual([events[0], events[1], done])
expect(yield* Fiber.join(second)).toEqual([events[1], events[2], events[3], done])
expect(Array.from(yield* Fiber.join(first))).toEqual([events[0], events[1], done])
expect(Array.from(yield* Fiber.join(second))).toEqual([events[1], events[2], events[3], done])
}),
)
@@ -293,8 +295,8 @@ describe("Bus Session routing", () => {
const done = yield* bus.publish(Done, {})
expect(Exit.isFailure(single)).toBe(true)
expect(Exit.isFailure(batch)).toBe(true)
expect(yield* Fiber.join(first)).toEqual([before, after, done])
expect(yield* Fiber.join(second)).toEqual([done])
expect(Array.from(yield* Fiber.join(first))).toEqual([before, after, done])
expect(Array.from(yield* Fiber.join(second))).toEqual([done])
}),
)
@@ -325,8 +327,8 @@ describe("Bus Session routing", () => {
{ publish: true },
)
const done = yield* bus.publish(Done, {})
expect(yield* Fiber.join(first)).toEqual([done])
const received = yield* Fiber.join(second)
expect(Array.from(yield* Fiber.join(first))).toEqual([done])
const received = Array.from(yield* Fiber.join(second))
expect(received.map((event) => event.id)).toEqual([after.id, replayID, done.id])
expect(received[1]).not.toHaveProperty("location")
}),
+15 -15
View File
@@ -123,7 +123,7 @@ describe("Bus", () => {
yield* bus.publish(Message, { text: "hello" })
yield* bus.publish(CountMessage, { count: 2 })
const received = (yield* Fiber.join(fiber)).map((event) =>
const received = Array.from(yield* Fiber.join(fiber)).map((event) =>
event.type === "test.message" ? event.data.text : event.data.count,
)
expect(received).toEqual(["hello", 2])
@@ -136,7 +136,7 @@ describe("Bus", () => {
const fiber = yield* bus.subscribe(Message).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
const event = yield* bus.publish(Message, { text: "hello" })
const received = yield* Fiber.join(fiber)
const received = Array.from(yield* Fiber.join(fiber))
expect(received).toEqual([event])
expect(event.type).toBe("test.message")
@@ -212,8 +212,8 @@ describe("Bus", () => {
yield* Effect.yieldNow
const event = yield* bus.publish(Message, { text: "hello" })
expect(yield* Fiber.join(typed)).toEqual([event])
expect(yield* Fiber.join(wildcard)).toEqual([event])
expect(Array.from(yield* Fiber.join(typed))).toEqual([event])
expect(Array.from(yield* Fiber.join(wildcard))).toEqual([event])
}),
)
@@ -602,7 +602,7 @@ describe("Bus", () => {
yield* bus.publish(DurableMessage, durableData(aggregateID, "two"))
expect((yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
[1, durableData(aggregateID, "one")],
[2, durableData(aggregateID, "two")],
])
@@ -618,7 +618,7 @@ describe("Bus", () => {
yield* bus.publish(DurableMessage, durableData(aggregateID, "one"))
expect((yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
[0, durableData(aggregateID, "zero")],
[1, durableData(aggregateID, "one")],
])
@@ -653,7 +653,7 @@ describe("Bus", () => {
yield* bus.publish(DurableMessage, durableData(aggregateID, "during handoff"))
yield* Deferred.succeed(continueRead, undefined)
expect((yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
[0, durableData(aggregateID, "during handoff")],
])
}).pipe(Effect.provide(eventLayer))
@@ -672,7 +672,7 @@ describe("Bus", () => {
yield* bus.publish(DurableMessage, durableData(aggregateID, String(index)))
}
expect((yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual(
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual(
Array.from({ length: count }, (_, index) => [index, durableData(aggregateID, String(index))]),
)
}),
@@ -688,7 +688,7 @@ describe("Bus", () => {
yield* bus.publish(Message, { text: "live only" })
yield* bus.publish(DurableMessage, durableData(aggregateID, "durable"))
expect((yield* Fiber.join(fiber)).map((event) => event.type)).toEqual([DurableMessage.type])
expect(Array.from(yield* Fiber.join(fiber)).map((event) => event.type)).toEqual([DurableMessage.type])
}),
)
@@ -1268,7 +1268,7 @@ describe("Bus", () => {
yield* bus.publish(DurableMessage, durableData(aggregateID, "zero"))
yield* bus.publish(DurableMessage, durableData(aggregateID, "one"))
const items = yield* Stream.runCollect(bus.log({ aggregateID }))
const items = Array.from(yield* Stream.runCollect(bus.log({ aggregateID })))
expect(items.map((item) => (Bus.isSynced(item) ? item.type : item.durable?.seq))).toEqual([
Event.Seq.make(0),
@@ -1284,9 +1284,9 @@ describe("Bus", () => {
const bus = yield* Bus.Service
const aggregateID = Session.ID.create()
const empty = yield* Stream.runCollect(bus.log({ aggregateID }))
const empty = Array.from(yield* Stream.runCollect(bus.log({ aggregateID })))
yield* bus.publish(DurableMessage, durableData(aggregateID, "zero"))
const drained = yield* Stream.runCollect(bus.log({ aggregateID, after: 0 }))
const drained = Array.from(yield* Stream.runCollect(bus.log({ aggregateID, after: 0 })))
expect(empty).toEqual([{ type: "log.synced", aggregateID }])
expect(empty[0]).not.toHaveProperty("seq")
@@ -1306,7 +1306,7 @@ describe("Bus", () => {
yield* bus.publish(DurableMessage, durableData(aggregateID, "one"))
const items = yield* Fiber.join(fiber)
const items = Array.from(yield* Fiber.join(fiber))
expect(items.map((item) => (Bus.isSynced(item) ? item : item.durable?.seq))).toEqual([
Event.Seq.make(0),
{ type: "log.synced", aggregateID, seq: Event.Seq.make(0) },
@@ -1330,7 +1330,7 @@ describe("Bus", () => {
yield* bus.publish(DurableMessage, durableData(aggregateID, "three"))
yield* bus.publish(DurableMessage, durableData(aggregateID, "four"))
const items = yield* Stream.runCollect(bus.log({ aggregateID }))
const items = Array.from(yield* Stream.runCollect(bus.log({ aggregateID })))
expect(items.map((item) => (Bus.isSynced(item) ? item.type : item.durable?.seq))).toEqual([
Event.Seq.make(0),
@@ -1378,7 +1378,7 @@ describe("Bus", () => {
yield* bus.publish(DurableMessage, durableData(aggregateID, "one"))
yield* Deferred.succeed(releaseRead, undefined)
const items = yield* Fiber.join(fiber)
const items = Array.from(yield* Fiber.join(fiber))
expect(items.map((item) => (Bus.isSynced(item) ? item : item.durable?.seq))).toEqual([
Event.Seq.make(0),
{ type: "log.synced", aggregateID, seq: Event.Seq.make(0) },
+53
View File
@@ -11,6 +11,7 @@ import { Location } from "@opencode-ai/core/location"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { State } from "@opencode-ai/core/state"
import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
@@ -30,6 +31,57 @@ const catalogLayer = AppNodeBuilder.build(
const it = testEffect(catalogLayer)
describe("Catalog", () => {
it.effect("reads available and default models inside a batch before publishing", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const bus = yield* Bus.Service
const observed: string[] = []
const unsubscribe = yield* bus.listen((event) =>
event.type === Catalog.Event.Updated.type
? catalog.model.default().pipe(
Effect.map((model) => {
observed.push(model?.id ?? "none")
}),
)
: Effect.void,
)
yield* Effect.addFinalizer(() => unsubscribe)
const providerID = Provider.ID.make("test")
const old = Model.ID.make("old")
const newest = Model.ID.make("new")
yield* State.batch(
Effect.gen(function* () {
yield* catalog.transform((draft) => {
draft.provider.update(providerID, () => {})
draft.model.update(providerID, old, (model) => {
model.time.released = 1000
})
draft.model.update(providerID, newest, (model) => {
model.time.released = 2000
})
draft.model.default.set(providerID, old)
})
expect((yield* catalog.model.available()).map((model) => model.id)).toEqual([newest, old])
expect((yield* catalog.model.default())?.id).toBe(old)
const overlay = yield* catalog.transform((draft) =>
draft.model.update(providerID, old, (model) => {
model.enabled = false
}),
)
expect((yield* catalog.model.available()).map((model) => model.id)).toEqual([newest])
expect((yield* catalog.model.default())?.id).toBe(newest)
yield* overlay.dispose
expect((yield* catalog.model.default())?.id).toBe(old)
expect(observed).toEqual([])
}),
)
expect(observed).toEqual([old])
}),
)
it.effect("publishes an updated event after catalog changes", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
@@ -291,6 +343,7 @@ describe("Catalog", () => {
configured = false
const reload = yield* catalog.reload().pipe(Effect.forkChild({ startImmediately: true }))
expect((yield* catalog.model.default())?.id).toBe(newest)
yield* TestClock.adjust("500 millis")
yield* Fiber.join(reload)
expect((yield* catalog.model.default())?.id).toBe(newest)
+16 -4
View File
@@ -330,7 +330,10 @@ describe("ConfigAgentPlugin.Plugin", () => {
)
it.live("loads legacy file-based agents from config directories", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
@@ -404,7 +407,10 @@ Use native v2 fields.`,
for (const testCase of sourceCases()) {
it.effect(`rebuilds agents when a source file is ${testCase.name}`, () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const directory = path.join(tmp.path, testCase.source)
@@ -439,7 +445,10 @@ Use native v2 fields.`,
}
it.effect("coalesces updates inside the debounce window into one rebuild", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const directory = path.join(tmp.path, "agents")
@@ -476,7 +485,10 @@ Use native v2 fields.`,
)
it.effect("ignores updates outside agent source directories", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const directory = path.join(tmp.path, "agents")
+16 -4
View File
@@ -54,7 +54,10 @@ const decode = Schema.decodeUnknownSync(Info)
describe("ConfigCommandPlugin.Plugin", () => {
it.live("loads inline and file-based commands in config order", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
@@ -163,7 +166,10 @@ Review files`,
for (const testCase of sourceCases()) {
it.effect(`rebuilds commands when a source file is ${testCase.name}`, () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const directory = path.join(tmp.path, "commands")
@@ -206,7 +212,10 @@ Review files`,
}
it.effect("coalesces updates inside the debounce window into one rebuild", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const directory = path.join(tmp.path, "commands")
@@ -245,7 +254,10 @@ Review files`,
)
it.effect("ignores updates outside command source directories", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const directory = path.join(tmp.path, "commands")
+335 -255
View File
@@ -1,7 +1,7 @@
import path from "path"
import fs from "fs/promises"
import { describe, expect, test } from "bun:test"
import { Effect, Fiber, Layer, Logger, Schema, Stream } from "effect"
import { describe, expect } from "bun:test"
import { Effect, Fiber, Layer, Logger, PubSub, Schema, Stream } from "effect"
import { FastCheck } from "effect/testing"
import { Config } from "@opencode-ai/core/config"
import { AgentsDirectory, Directory, Document, Event, Info } from "@opencode-ai/schema/config"
@@ -77,7 +77,10 @@ const provider = {
describe("Config", () => {
it.live("excludes home-level claude and agents directories when global is disabled", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) => {
const global = path.join(tmp.path, "global")
const home = path.join(global, "home")
@@ -117,7 +120,10 @@ describe("Config", () => {
)
it.live("excludes global config reached through the project walk when global is disabled", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) => {
// The location sits BENEATH the global config dir, so the upward walk
// reaches the global opencode.json as a direct file.
@@ -150,7 +156,10 @@ describe("Config", () => {
)
it.live("loads explicit file and content overrides in priority order", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) => {
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
@@ -185,7 +194,10 @@ describe("Config", () => {
)
it.live("skips project configuration when project discovery is disabled", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) => {
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
@@ -213,7 +225,10 @@ describe("Config", () => {
)
it.live("reloads external config and publishes directory updates", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const global = path.join(tmp.path, "global")
@@ -246,7 +261,10 @@ describe("Config", () => {
)
it.live("exposes filesystem updates under config roots through changes", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const global = path.join(tmp.path, "global")
@@ -278,7 +296,10 @@ describe("Config", () => {
// watch being torn down, making recreation invisible) only reproduces with
// path-faithful event delivery.
it.live("keeps watching a deleted config file so recreating it reloads", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const global = path.join(tmp.path, "global")
@@ -348,24 +369,26 @@ describe("Config", () => {
}).pipe(Effect.provide(Config.testLayer())),
)
test("returns the latest defined scalar from priority-ordered documents", () => {
const entries = [
new Document({
type: "document",
info: new Info({ model: selection("openrouter/openai/gpt-5") }),
}),
new Directory({ type: "directory", path: AbsolutePath.make("/skills") }),
new AgentsDirectory({ type: "agents", path: AbsolutePath.make("/agents") }),
new Document({ type: "document", info: new Info({}) }),
new Document({
type: "document",
info: new Info({ model: selection("openrouter/openai/gpt-5.5") }),
}),
]
it.effect("returns the latest defined scalar from priority-ordered documents", () =>
Effect.sync(() => {
const entries = [
new Document({
type: "document",
info: new Info({ model: selection("openrouter/openai/gpt-5") }),
}),
new Directory({ type: "directory", path: AbsolutePath.make("/skills") }),
new AgentsDirectory({ type: "agents", path: AbsolutePath.make("/agents") }),
new Document({ type: "document", info: new Info({}) }),
new Document({
type: "document",
info: new Info({ model: selection("openrouter/openai/gpt-5.5") }),
}),
]
expect(Config.latest(entries, "model")).toEqual(selection("openrouter/openai/gpt-5.5"))
expect(Config.latest(entries, "default_agent")).toBeUndefined()
})
expect(Config.latest(entries, "model")).toEqual(selection("openrouter/openai/gpt-5.5"))
expect(Config.latest(entries, "default_agent")).toBeUndefined()
}),
)
it.live("tolerates unavailable authenticated wellknown config and reloads it later", () =>
Effect.acquireUseRelease(
@@ -557,241 +580,268 @@ describe("Config", () => {
).pipe(Effect.provide(Logger.layer([logger])))
})
test("migrates arbitrary v1 configuration into valid v2 configuration", () => {
FastCheck.assert(
FastCheck.property(Schema.toArbitrary(ConfigV1.Info)(FastCheck), (info) => {
const parsed = Schema.decodeUnknownSync(ConfigV1.Info)(
Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(
Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(info),
),
)
Schema.decodeUnknownSync(Info)(ConfigMigrateV1.migrate(parsed), { errors: "all" })
}),
{ numRuns: 100 },
)
}, 30_000)
it.effect("migrates arbitrary v1 configuration into valid v2 configuration", () =>
Effect.sync(() => {
FastCheck.assert(
FastCheck.property(Schema.toArbitrary(ConfigV1.Info)(FastCheck), (info) => {
const parsed = Schema.decodeUnknownSync(ConfigV1.Info)(
Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(
Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(info),
),
)
Schema.decodeUnknownSync(Info)(ConfigMigrateV1.migrate(parsed), { errors: "all" })
}),
{ numRuns: 100 },
)
}),
)
test("migrates the v1 experimental subagent depth", () => {
expect(ConfigMigrateV1.migrate({ experimental: { subagent_depth: 2 } }).experimental?.subagent_depth).toBe(2)
})
it.effect("migrates the v1 experimental subagent depth", () =>
Effect.sync(() => {
expect(ConfigMigrateV1.migrate({ experimental: { subagent_depth: 2 } }).experimental?.subagent_depth).toBe(2)
}),
)
test("migrates the v1 small model to the title agent", () => {
expect(
ConfigMigrateV1.migrate({
small_model: "anthropic/claude-haiku-4-5",
agent: { title: { prompt: "Custom title prompt" } },
}).agents?.title,
).toEqual({
model: { providerID: "anthropic", model: "claude-haiku-4-5" },
system: "Custom title prompt",
})
})
it.effect("migrates the v1 small model to the title agent", () =>
Effect.sync(() => {
expect(
ConfigMigrateV1.migrate({
small_model: "anthropic/claude-haiku-4-5",
agent: { title: { prompt: "Custom title prompt" } },
}).agents?.title,
).toEqual({
model: { providerID: "anthropic", model: "claude-haiku-4-5" },
system: "Custom title prompt",
})
}),
)
test("migrates v1 provider lists to policies", () => {
expect(
ConfigMigrateV1.migrate({
enabled_providers: ["anthropic", "openai"],
disabled_providers: ["openai"],
}).experimental?.policies,
).toEqual([
{ action: "provider.use", resource: "*", effect: "deny" },
{ action: "provider.use", resource: "anthropic", effect: "allow" },
{ action: "provider.use", resource: "openai", effect: "allow" },
{ action: "provider.use", resource: "openai", effect: "deny" },
])
expect(ConfigMigrateV1.migrate({ enabled_providers: [] }).experimental?.policies).toEqual([
{ action: "provider.use", resource: "*", effect: "deny" },
])
})
it.effect("migrates v1 provider lists to policies", () =>
Effect.sync(() => {
expect(
ConfigMigrateV1.migrate({
enabled_providers: ["anthropic", "openai"],
disabled_providers: ["openai"],
}).experimental?.policies,
).toEqual([
{ action: "provider.use", resource: "*", effect: "deny" },
{ action: "provider.use", resource: "anthropic", effect: "allow" },
{ action: "provider.use", resource: "openai", effect: "allow" },
{ action: "provider.use", resource: "openai", effect: "deny" },
])
expect(ConfigMigrateV1.migrate({ enabled_providers: [] }).experimental?.policies).toEqual([
{ action: "provider.use", resource: "*", effect: "deny" },
])
}),
)
test("migrates v1 provider setup options into AISDK settings", () => {
const migrated = ConfigMigrateV1.migrate({
provider: {
bedrock: {
npm: "@ai-sdk/amazon-bedrock",
models: { claude: { provider: { npm: "@ai-sdk/anthropic" } } },
options: {
headers: { "x-test": "1" },
body: { trace: true },
region: "us-east-1",
profile: "dev",
it.effect("migrates v1 provider setup options into AISDK settings", () =>
Effect.sync(() => {
const migrated = ConfigMigrateV1.migrate({
provider: {
bedrock: {
npm: "@ai-sdk/amazon-bedrock",
models: { claude: { provider: { npm: "@ai-sdk/anthropic" } } },
options: {
headers: { "x-test": "1" },
body: { trace: true },
region: "us-east-1",
profile: "dev",
},
},
},
},
})
})
expect(migrated.providers?.bedrock).toMatchObject({
package: Provider.aisdk("@ai-sdk/amazon-bedrock"),
models: { claude: { package: Provider.aisdk("@ai-sdk/anthropic") } },
settings: { region: "us-east-1", profile: "dev" },
headers: { "x-test": "1" },
body: { trace: true },
})
})
expect(migrated.providers?.bedrock).toMatchObject({
package: Provider.aisdk("@ai-sdk/amazon-bedrock"),
models: { claude: { package: Provider.aisdk("@ai-sdk/anthropic") } },
settings: { region: "us-east-1", profile: "dev" },
headers: { "x-test": "1" },
body: { trace: true },
})
}),
)
test("renames old provider IDs while migrating v1 configuration", () => {
const migrated = ConfigMigrateV1.migrate({
model: "azure-cognitive-services/deployment",
enabled_providers: ["google-vertex-anthropic"],
disabled_providers: ["azure-cognitive-services"],
agent: {
reviewer: { model: "google-vertex-anthropic/claude-sonnet" },
},
command: {
review: { template: "Review", model: "azure-cognitive-services/deployment" },
},
provider: {
"azure-cognitive-services": {
npm: "@ai-sdk/azure",
env: ["AZURE_COGNITIVE_SERVICES_RESOURCE_NAME", "AZURE_COGNITIVE_SERVICES_API_KEY"],
models: { deployment: {} },
it.effect("renames old provider IDs while migrating v1 configuration", () =>
Effect.sync(() => {
const migrated = ConfigMigrateV1.migrate({
model: "azure-cognitive-services/deployment",
enabled_providers: ["google-vertex-anthropic"],
disabled_providers: ["azure-cognitive-services"],
agent: {
reviewer: { model: "google-vertex-anthropic/claude-sonnet" },
},
"google-vertex-anthropic": {
npm: "@ai-sdk/google-vertex/anthropic",
options: { project: "test-project", location: "us-central1" },
models: { "claude-sonnet": {} },
},
},
})
expect(migrated.model).toEqual({ providerID: "azure", model: "deployment" })
expect(migrated.agents?.reviewer?.model).toEqual({ providerID: "google-vertex", model: "claude-sonnet" })
expect(migrated.commands?.review?.model).toEqual({ providerID: "azure", model: "deployment" })
expect(migrated.experimental?.policies).toEqual([
{ action: "provider.use", resource: "*", effect: "deny" },
{ action: "provider.use", resource: "google-vertex", effect: "allow" },
{ action: "provider.use", resource: "azure", effect: "deny" },
])
expect(migrated.providers?.azure).toMatchObject({
env: ["AZURE_COGNITIVE_SERVICES_API_KEY"],
package: Provider.aisdk("@ai-sdk/azure"),
models: { deployment: {} },
})
expect(migrated.providers?.["azure-cognitive-services"]).toBeUndefined()
expect(migrated.providers?.["google-vertex"]).toMatchObject({
settings: { project: "test-project", location: "us-central1" },
models: {
"claude-sonnet": { package: Provider.aisdk("@ai-sdk/google-vertex/anthropic") },
},
})
expect(migrated.providers?.["google-vertex"]).not.toHaveProperty("package")
expect(migrated.providers?.["google-vertex-anthropic"]).toBeUndefined()
})
test("preserves the generated base URL for v1 Azure OpenAI-compatible providers", () => {
const migrated = ConfigMigrateV1.migrate({
provider: {
"azure-cognitive-services": {
npm: "@ai-sdk/openai-compatible",
env: ["AZURE_COGNITIVE_SERVICES_RESOURCE_NAME", "AZURE_COGNITIVE_SERVICES_API_KEY"],
},
},
})
expect(migrated.providers?.azure).toMatchObject({
env: ["AZURE_COGNITIVE_SERVICES_API_KEY"],
package: Provider.aisdk("@ai-sdk/openai-compatible"),
settings: {
baseURL: "https://${AZURE_COGNITIVE_SERVICES_RESOURCE_NAME}.cognitiveservices.azure.com/openai",
},
})
})
test("ignores old provider IDs when the current provider ID is configured", () => {
const migrated = ConfigMigrateV1.migrate({
provider: {
azure: { models: { current: {} } },
"azure-cognitive-services": { models: { legacy: {} } },
"google-vertex": { models: { gemini: {} } },
"google-vertex-anthropic": { models: { claude: {} } },
},
})
expect(migrated.providers?.azure?.models).toEqual({ current: expect.anything() })
expect(migrated.providers?.["google-vertex"]?.models).toEqual({ gemini: expect.anything() })
})
test("preserves the built-in package for v1 Vertex Anthropic custom models", () => {
const migrated = ConfigMigrateV1.migrate({
provider: {
"google-vertex-anthropic": {
models: { claude: {} },
},
},
})
expect(migrated.providers?.["google-vertex"]?.package).toBeUndefined()
expect(migrated.providers?.["google-vertex"]?.models?.claude?.package).toBe(
Provider.aisdk("@ai-sdk/google-vertex/anthropic"),
)
})
test("migrates v1 interleaved fields to compatibility", () => {
const migrated = ConfigMigrateV1.migrate({
provider: {
custom: {
models: {
object: { interleaved: { field: "vendor_reasoning" } },
string: { interleaved: "reasoning_text" },
boolean: { interleaved: true },
},
},
},
})
expect(migrated.providers?.custom?.models?.object?.compatibility).toEqual({
reasoningField: "vendor_reasoning",
})
expect(migrated.providers?.custom?.models?.string?.compatibility).toEqual({ reasoningField: "reasoning_text" })
expect(migrated.providers?.custom?.models?.boolean?.compatibility).toBeUndefined()
})
test("migrates v1 command configuration", () => {
expect(
ConfigMigrateV1.migrate({
command: {
review: {
template: "Review changes",
description: "Review code",
agent: "reviewer",
model: "anthropic/claude",
variant: "high",
subtask: true,
review: { template: "Review", model: "azure-cognitive-services/deployment" },
},
provider: {
"azure-cognitive-services": {
npm: "@ai-sdk/azure",
env: ["AZURE_COGNITIVE_SERVICES_RESOURCE_NAME", "AZURE_COGNITIVE_SERVICES_API_KEY"],
models: { deployment: {} },
},
"google-vertex-anthropic": {
npm: "@ai-sdk/google-vertex/anthropic",
options: { project: "test-project", location: "us-central1" },
models: { "claude-sonnet": {} },
},
},
}).commands,
).toEqual({
review: {
template: "Review changes",
description: "Review code",
agent: "reviewer",
model: { providerID: "anthropic", model: "claude", variant: "high" },
subtask: true,
},
})
})
})
test("normalizes renamed permission actions when migrating v1 permissions", () => {
expect(
ConfigMigrateV1.migrate({
permission: {
task: "ask",
bash: { "git status": "allow", "*": "deny" },
write: "deny",
read: "allow",
expect(migrated.model).toEqual({ providerID: "azure", model: "deployment" })
expect(migrated.agents?.reviewer?.model).toEqual({ providerID: "google-vertex", model: "claude-sonnet" })
expect(migrated.commands?.review?.model).toEqual({ providerID: "azure", model: "deployment" })
expect(migrated.experimental?.policies).toEqual([
{ action: "provider.use", resource: "*", effect: "deny" },
{ action: "provider.use", resource: "google-vertex", effect: "allow" },
{ action: "provider.use", resource: "azure", effect: "deny" },
])
expect(migrated.providers?.azure).toMatchObject({
env: ["AZURE_COGNITIVE_SERVICES_API_KEY"],
package: Provider.aisdk("@ai-sdk/azure"),
models: { deployment: {} },
})
expect(migrated.providers?.["azure-cognitive-services"]).toBeUndefined()
expect(migrated.providers?.["google-vertex"]).toMatchObject({
settings: { project: "test-project", location: "us-central1" },
models: {
"claude-sonnet": { package: Provider.aisdk("@ai-sdk/google-vertex/anthropic") },
},
}).permissions,
).toEqual([
{ action: "subagent", resource: "*", effect: "ask" },
{ action: "shell", resource: "git status", effect: "allow" },
{ action: "shell", resource: "*", effect: "deny" },
{ action: "edit", resource: "*", effect: "deny" },
{ action: "read", resource: "*", effect: "allow" },
])
})
})
expect(migrated.providers?.["google-vertex"]).not.toHaveProperty("package")
expect(migrated.providers?.["google-vertex-anthropic"]).toBeUndefined()
}),
)
it.effect("preserves the generated base URL for v1 Azure OpenAI-compatible providers", () =>
Effect.sync(() => {
const migrated = ConfigMigrateV1.migrate({
provider: {
"azure-cognitive-services": {
npm: "@ai-sdk/openai-compatible",
env: ["AZURE_COGNITIVE_SERVICES_RESOURCE_NAME", "AZURE_COGNITIVE_SERVICES_API_KEY"],
},
},
})
expect(migrated.providers?.azure).toMatchObject({
env: ["AZURE_COGNITIVE_SERVICES_API_KEY"],
package: Provider.aisdk("@ai-sdk/openai-compatible"),
settings: {
baseURL: "https://${AZURE_COGNITIVE_SERVICES_RESOURCE_NAME}.cognitiveservices.azure.com/openai",
},
})
}),
)
it.effect("ignores old provider IDs when the current provider ID is configured", () =>
Effect.sync(() => {
const migrated = ConfigMigrateV1.migrate({
provider: {
azure: { models: { current: {} } },
"azure-cognitive-services": { models: { legacy: {} } },
"google-vertex": { models: { gemini: {} } },
"google-vertex-anthropic": { models: { claude: {} } },
},
})
expect(migrated.providers?.azure?.models).toEqual({ current: expect.anything() })
expect(migrated.providers?.["google-vertex"]?.models).toEqual({ gemini: expect.anything() })
}),
)
it.effect("preserves the built-in package for v1 Vertex Anthropic custom models", () =>
Effect.sync(() => {
const migrated = ConfigMigrateV1.migrate({
provider: {
"google-vertex-anthropic": {
models: { claude: {} },
},
},
})
expect(migrated.providers?.["google-vertex"]?.package).toBeUndefined()
expect(migrated.providers?.["google-vertex"]?.models?.claude?.package).toBe(
Provider.aisdk("@ai-sdk/google-vertex/anthropic"),
)
}),
)
it.effect("migrates v1 interleaved fields to compatibility", () =>
Effect.sync(() => {
const migrated = ConfigMigrateV1.migrate({
provider: {
custom: {
models: {
object: { interleaved: { field: "vendor_reasoning" } },
string: { interleaved: "reasoning_text" },
boolean: { interleaved: true },
},
},
},
})
expect(migrated.providers?.custom?.models?.object?.compatibility).toEqual({
reasoningField: "vendor_reasoning",
})
expect(migrated.providers?.custom?.models?.string?.compatibility).toEqual({ reasoningField: "reasoning_text" })
expect(migrated.providers?.custom?.models?.boolean?.compatibility).toBeUndefined()
}),
)
it.effect("migrates v1 command configuration", () =>
Effect.sync(() => {
expect(
ConfigMigrateV1.migrate({
command: {
review: {
template: "Review changes",
description: "Review code",
agent: "reviewer",
model: "anthropic/claude",
variant: "high",
subtask: true,
},
},
}).commands,
).toEqual({
review: {
template: "Review changes",
description: "Review code",
agent: "reviewer",
model: { providerID: "anthropic", model: "claude", variant: "high" },
subtask: true,
},
})
}),
)
it.effect("normalizes renamed permission actions when migrating v1 permissions", () =>
Effect.sync(() => {
expect(
ConfigMigrateV1.migrate({
permission: {
task: "ask",
bash: { "git status": "allow", "*": "deny" },
write: "deny",
read: "allow",
},
}).permissions,
).toEqual([
{ action: "subagent", resource: "*", effect: "ask" },
{ action: "shell", resource: "git status", effect: "allow" },
{ action: "shell", resource: "*", effect: "deny" },
{ action: "edit", resource: "*", effect: "deny" },
{ action: "read", resource: "*", effect: "allow" },
])
}),
)
it.live("returns an empty configuration when directory files do not exist", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const config = yield* Config.Service
@@ -806,7 +856,10 @@ describe("Config", () => {
)
it.live("deduplicates global ecosystem directories found during upward discovery", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const global = path.join(tmp.path, "global")
@@ -835,7 +888,10 @@ describe("Config", () => {
)
it.live("does not watch ecosystem config roots", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
@@ -863,7 +919,10 @@ describe("Config", () => {
)
it.live("loads opencode JSON and JSONC files from lowest to highest priority", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
@@ -974,7 +1033,10 @@ describe("Config", () => {
)
it.live("does not load legacy config.json files", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
@@ -993,7 +1055,10 @@ describe("Config", () => {
)
it.live("accepts $schema metadata without writing it into config files", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const file = path.join(tmp.path, "opencode.json")
@@ -1017,7 +1082,10 @@ describe("Config", () => {
)
it.live("loads supported scalar and resource configuration", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
@@ -1203,7 +1271,10 @@ describe("Config", () => {
)
it.live("migrates the deprecated reference key into references", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
@@ -1236,7 +1307,10 @@ describe("Config", () => {
)
it.live("migrates v1 configuration when a v1-only key is present", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
@@ -1408,7 +1482,10 @@ describe("Config", () => {
)
it.live("ignores an invalid file while loading valid config values", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
@@ -1434,7 +1511,10 @@ describe("Config", () => {
)
it.live("loads global and ancestor configuration across the project boundary", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) => {
const global = path.join(tmp.path, "global")
const root = path.join(tmp.path, "repo")
+411
View File
@@ -0,0 +1,411 @@
import path from "path"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { parse } from "jsonc-parser"
import { isRecord } from "@opencode-ai/ai/utils/record"
import { ConfigFile } from "@opencode-ai/core/config/file"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { withTempDir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
// No Config, Location, Watcher, Credential, or WellKnown services are provided.
const it = testEffect(LayerNode.compile(FSUtil.node))
describe("ConfigFile", () => {
it.live("edits the explicit target and preserves comments and unrelated fields", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const global = path.join(tmp.path, "global", "opencode.jsonc")
const target = path.join(tmp.path, "project", "custom.jsonc")
const text = '{\n // Keep this comment.\n "shell": "project",\n "custom": { "value": 1 },\n}\n'
yield* fs.writeWithDirs(global, '{ "shell": "global" }')
yield* fs.writeWithDirs(target, text)
const updated = yield* ConfigFile.update(target, (draft) => {
draft.shell = "updated"
})
expect(updated).toEqual({ shell: "updated", custom: { value: 1 } })
expect(yield* fs.readFileString(target)).toBe(text.replace('"project"', '"updated"'))
expect(yield* fs.readFileString(global)).toBe('{ "shell": "global" }')
}),
),
)
it.live("leaves raw substitutions, model shorthand, and legacy shapes unresolved", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.jsonc")
const text = `{
"model": "{env:OPENCODE_TEST_CONFIG_MODEL}",
"shell": "{file:missing-shell.txt}",
"skills": { "paths": ["./skills"] },
"agent": { "review": { "model": "acme/reasoner" } },
"username": "before"
}
`
yield* fs.writeFileString(target, text)
yield* ConfigFile.update(target, (draft) => {
expect(draft.model).toBe("{env:OPENCODE_TEST_CONFIG_MODEL}")
expect(draft.shell).toBe("{file:missing-shell.txt}")
expect(draft.skills).toEqual({ paths: ["./skills"] })
draft.username = "after"
})
expect(yield* fs.readFileString(target)).toBe(text.replace('"before"', '"after"'))
}),
),
)
it.live("patches nested source fields and deletes legacy keys without migrating them", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.jsonc")
yield* fs.writeFileString(
target,
`{
"agent": {
"review": { "description": "before", "hidden": true },
// Keep the other definition.
"build": { "description": "unchanged" }
},
"snapshot": true
}
`,
)
const updated = yield* ConfigFile.update(target, (draft) => {
const agent: unknown = draft.agent
if (!isRecord(agent) || !isRecord(agent.review)) throw new Error("Missing fixture agent")
agent.review.description = "after"
agent.review.color = "blue"
delete agent.review.hidden
delete draft.snapshot
})
expect(updated).toEqual({
agent: { review: { description: "after", color: "blue" }, build: { description: "unchanged" } },
})
expect(parse(yield* fs.readFileString(target))).toEqual(updated)
expect(yield* fs.readFileString(target)).toContain("// Keep the other definition.")
}),
),
)
it.live("patches array elements without rewriting untouched comments", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.jsonc")
const text = `{
"plugins": [
// Keep the first plugin.
"first",
"second",
// Keep the third plugin.
"third",
"fourth"
]
}
`
yield* fs.writeFileString(target, text)
yield* ConfigFile.update(target, (draft) => {
if (!Array.isArray(draft.plugins)) throw new Error("Missing fixture plugins")
draft.plugins[1] = "updated"
})
expect(yield* fs.readFileString(target)).toBe(text.replace('"second"', '"updated"'))
const shortened = yield* ConfigFile.update(target, (draft) => {
if (!Array.isArray(draft.plugins)) throw new Error("Missing fixture plugins")
draft.plugins.splice(1, 3)
})
expect(shortened.plugins).toEqual(["first"])
expect(parse(yield* fs.readFileString(target))).toEqual(shortened)
const extended = yield* ConfigFile.update(target, (draft) => {
if (!Array.isArray(draft.plugins)) throw new Error("Missing fixture plugins")
draft.plugins.push("added", "last")
})
expect(extended.plugins).toEqual(["first", "added", "last"])
expect(parse(yield* fs.readFileString(target))).toEqual(extended)
expect(yield* fs.readFileString(target)).toContain("// Keep the first plugin.")
}),
),
)
it.live("preserves adjacent comments when deleting properties and array elements", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.jsonc")
yield* fs.writeFileString(
target,
`{
"shell": "remove",
// Keep the model explanation.
"model": "acme/reasoner",
"plugins": ["first", "second", /* Keep the plugin explanation. */ "third"],
"skills": [/* Keep the source explanation. */ "remove",],
}
`,
)
const updated = yield* ConfigFile.update(target, (draft) => {
delete draft.shell
if (!Array.isArray(draft.plugins)) throw new Error("Missing fixture plugins")
draft.plugins.splice(1, 1)
draft.skills = []
})
expect(parse(yield* fs.readFileString(target))).toEqual(updated)
expect(updated).toEqual({ model: "acme/reasoner", plugins: ["first", "third"], skills: [] })
expect(yield* fs.readFileString(target)).toContain("// Keep the model explanation.")
expect(yield* fs.readFileString(target)).toContain("/* Keep the plugin explanation. */")
expect(yield* fs.readFileString(target)).toContain("/* Keep the source explanation. */")
}),
),
)
it.live("deletes own JSON keys that also exist on Object.prototype", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.json")
yield* fs.writeFileString(
target,
'{ "\\u005f_proto__": "remove", "constructor": "remove", "toString": "remove", "shell": "keep" }',
)
const updated = yield* ConfigFile.update(target, (draft) => {
;["__proto__", "constructor", "toString"].forEach((key) => {
delete draft[key]
})
})
expect(updated).toEqual({ shell: "keep" })
expect(yield* fs.readJson(target)).toEqual(updated)
}),
),
)
it.live("preserves and edits object-valued __proto__ source keys", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.json")
yield* fs.writeFileString(target, '{ "__proto__": { "value": "before" }, "shell": "keep" }')
const updated = yield* ConfigFile.update(target, (draft) => {
expect(Object.hasOwn(draft, "__proto__")).toBe(true)
const entry: unknown = draft["__proto__"]
if (!isRecord(entry)) throw new Error("Missing fixture entry")
entry.value = "after"
})
expect(updated).toEqual({ ["__proto__"]: { value: "after" }, shell: "keep" })
expect(yield* fs.readJson(target)).toEqual(updated)
expect(Object.getPrototypeOf(updated)).toBe(Object.prototype)
}),
),
)
it.live("rejects a duplicate-key patch that would not change the effective value", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.json")
const text = '{ "shell": "first", "shell": "second" }'
yield* fs.writeFileString(target, text)
const error = yield* ConfigFile.update(target, (draft) => {
draft.shell = "after"
}).pipe(Effect.flip)
expect(error).toBeInstanceOf(ConfigFile.UpdateError)
expect(error.message).toBe(`Config patch does not match the requested update: ${target}`)
expect(yield* fs.readFileString(target)).toBe(text)
expect(yield* fs.exists(target + ".tmp")).toBe(false)
}),
),
)
it.live("rereads the selected file for consecutive edits without a watcher", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.json")
yield* fs.writeFileString(target, '{ "shell": "first" }')
yield* ConfigFile.update(target, (draft) => {
draft.shell = "second"
})
yield* ConfigFile.update(target, (draft) => {
expect(draft.shell).toBe("second")
draft.username = "added"
})
expect(yield* fs.readJson(target)).toEqual({ shell: "second", username: "added" })
yield* fs.writeFileString(target, '{ "shell": "external", "username": "added" }')
const updated = yield* ConfigFile.update(target, (draft) => {
expect(draft.shell).toBe("external")
draft.snapshots = false
})
expect(yield* fs.readJson(target)).toEqual(updated)
expect(updated).toEqual({ shell: "external", username: "added", snapshots: false })
}),
),
)
it.live("serializes concurrent read-modify-write calls", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.json")
yield* fs.writeFileString(target, '{ "count": 0 }')
const increment = ConfigFile.update(target, (draft) => {
if (typeof draft.count !== "number") throw new Error("Missing fixture count")
draft.count++
})
yield* Effect.all([increment, increment, increment], { concurrency: "unbounded" })
expect(yield* fs.readJson(target)).toEqual({ count: 3 })
}),
),
)
it.live("does not rewrite no-op or structurally equal edits", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.json")
const text = '{\r\n "plugins": ["first"]\r\n}'
yield* fs.writeFileString(target, text)
const before = yield* fs.stat(target)
yield* ConfigFile.update(target, () => {})
yield* ConfigFile.update(target, (draft) => {
draft.plugins = ["first"]
})
expect(yield* fs.readFileString(target)).toBe(text)
expect((yield* fs.stat(target)).ino).toEqual(before.ino)
expect((yield* fs.stat(target)).mtime).toEqual(before.mtime)
expect(yield* fs.exists(target + ".tmp")).toBe(false)
}),
),
)
it.live("leaves the file unchanged when a callback throws and permits a later edit", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.json")
const text = '{ "shell": "before" }'
yield* fs.writeFileString(target, text)
const cause = new Error("Rejected config update")
const error = yield* ConfigFile.update(target, (draft) => {
draft.shell = "discarded"
throw cause
}).pipe(Effect.flip)
expect(error).toBeInstanceOf(ConfigFile.UpdateError)
expect(error.message).toBe("Config update failed")
expect(error.cause).toBe(cause)
expect(yield* fs.readFileString(target)).toBe(text)
expect(yield* fs.exists(target + ".tmp")).toBe(false)
yield* ConfigFile.update(target, (draft) => {
draft.shell = "recovered"
})
expect(yield* fs.readJson(target)).toEqual({ shell: "recovered" })
}),
),
)
it.live("ignores callback return values instead of replacing the document", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.json")
yield* fs.writeFileString(target, "{}")
expect(yield* ConfigFile.update(target, () => new Date(0))).toEqual({})
expect(yield* fs.readFileString(target)).toBe("{}")
const updated = yield* ConfigFile.update(target, (draft) => (draft.shell = "updated"))
expect(updated).toEqual({ shell: "updated" })
expect(yield* fs.readJson(target)).toEqual(updated)
}),
),
)
it.live("rejects non-JSON mutations before writing", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.json")
const text = '{ "shell": "before" }'
yield* fs.writeFileString(target, text)
const error = yield* ConfigFile.update(target, (draft) => {
draft.invalid = Number.NaN
}).pipe(Effect.flip)
expect(error).toBeInstanceOf(ConfigFile.UpdateError)
expect(error.message).toBe(`Config update must produce a JSON object: ${target}`)
expect(yield* fs.readFileString(target)).toBe(text)
expect(yield* fs.exists(target + ".tmp")).toBe(false)
}),
),
)
;["", "{", "[]", "null"].forEach((text) => {
it.live(`rejects invalid or non-object source ${JSON.stringify(text)}`, () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.json")
yield* fs.writeFileString(target, text)
const error = yield* ConfigFile.update(target, () => {
throw new Error("Callback must not run")
}).pipe(Effect.flip)
expect(error).toBeInstanceOf(ConfigFile.UpdateError)
expect(error.message).toBe(`Invalid config file: ${target}`)
expect(yield* fs.readFileString(target)).toBe(text)
expect(yield* fs.exists(target + ".tmp")).toBe(false)
}),
),
)
})
it.live("reports a missing target without creating it", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "missing.json")
const error = yield* ConfigFile.update(target, () => {}).pipe(Effect.flip)
expect(error).toBeInstanceOf(ConfigFile.UpdateError)
expect(error.message).toBe(`Failed to read config: ${target}`)
expect(error.cause).toBeDefined()
expect(yield* fs.exists(target)).toBe(false)
}),
),
)
it.live("reports write failures without replacing the target", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.json")
const text = '{ "shell": "before" }'
yield* fs.writeFileString(target, text)
yield* fs.makeDirectory(target + ".tmp")
const error = yield* ConfigFile.update(target, (draft) => {
draft.shell = "discarded"
}).pipe(Effect.flip)
expect(error).toBeInstanceOf(ConfigFile.UpdateError)
expect(error.message).toBe(`Failed to write config: ${target}`)
expect(error.cause).toBeDefined()
expect(yield* fs.readFileString(target)).toBe(text)
}),
),
)
})
+11 -91
View File
@@ -2,15 +2,13 @@ import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { describe, expect } from "bun:test"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
import { Agent } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { ConfigPluginSource } from "@opencode-ai/core/config/plugin/source"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { Npm } from "@opencode-ai/util/npm"
import { Bus } from "@opencode-ai/core/bus"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
@@ -20,7 +18,7 @@ import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Effect, Fiber, Layer, Logger, Schedule, Stream } from "effect"
import { Effect, Fiber, Logger, Stream } from "effect"
import { Database } from "../../src/database/database"
import { tmpdir } from "../fixture/tmpdir"
import { tempGlobalLayer } from "../fixture/global"
@@ -37,40 +35,6 @@ const staticIt = testEffect(
[Global.node, tempGlobalLayer],
]),
)
const refreshNpm = makeGlobalNode({
service: Npm.Service,
layer: Layer.effect(
Npm.Service,
Effect.gen(function* () {
const global = yield* Global.Service
const directory = path.join(global.tmp, "background-refresh-plugin")
const installed = { directory, entrypoint: pathToFileURL(path.join(directory, "index.js")).href }
return Npm.Service.of({
add: (_pkg, options) =>
options?.refresh
? Effect.gen(function* () {
yield* Effect.promise(() => Bun.write(path.join(directory, "refresh-requested"), ""))
yield* waitForFile(path.join(directory, "refresh-release")).pipe(Effect.orDie)
yield* Effect.promise(() => Bun.write(path.join(directory, "refresh-finished"), ""))
return installed
})
: Effect.succeed(installed),
resolve: () => Effect.succeed(installed),
which: () => Effect.succeed(undefined),
})
}),
),
deps: [Global.node],
})
const refreshIt = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node, Global.node]),
[
[Global.node, tempGlobalLayer],
[Npm.node, refreshNpm],
],
),
)
describe("PluginSupervisor config", () => {
it.live("applies selectors in order", () =>
@@ -87,6 +51,7 @@ describe("PluginSupervisor config", () => {
}),
),
)
it.live("allows the built-in Plan agent to be disabled", () =>
withLocation(
{ agents: { plan: { disabled: true } } },
@@ -305,7 +270,7 @@ describe("PluginSupervisor config", () => {
staticIt.live("uses only internal and SDK plugins when the static source is wired", () =>
Effect.gen(function* () {
const sdk = yield* SdkPlugins.Service
yield* sdk.register(define({ id: "static-sdk", effect: () => Effect.void }))
yield* sdk.register(EffectPlugin.define({ id: "static-sdk", effect: () => Effect.void }))
yield* withLocation(
{ plugins: ["-*", path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts")] },
Effect.gen(function* () {
@@ -415,7 +380,7 @@ describe("PluginSupervisor config", () => {
it.live("loads user plugins before internal post plugins", () =>
Effect.gen(function* () {
const sdk = yield* SdkPlugins.Service
yield* sdk.register(define({ id: "sdk-order", effect: () => Effect.void }))
yield* sdk.register(EffectPlugin.define({ id: "sdk-order", effect: () => Effect.void }))
yield* withLocation(
{
plugins: [
@@ -466,8 +431,8 @@ describe("PluginSupervisor config", () => {
it.live("unblocks flush when plugin activation fails", () =>
Effect.gen(function* () {
const sdk = yield* SdkPlugins.Service
yield* sdk.register(define({ id: "duplicate-id", effect: () => Effect.void }))
yield* sdk.register(define({ id: "duplicate-id", effect: () => Effect.void }))
yield* sdk.register(EffectPlugin.define({ id: "duplicate-id", effect: () => Effect.void }))
yield* sdk.register(EffectPlugin.define({ id: "duplicate-id", effect: () => Effect.void }))
yield* withLocation(
undefined,
Effect.gen(function* () {
@@ -476,47 +441,6 @@ describe("PluginSupervisor config", () => {
)
}),
)
refreshIt.live("refreshes active package plugins after setup without blocking flush", () =>
Effect.gen(function* () {
const global = yield* Global.Service
const directory = path.join(global.tmp, "background-refresh-plugin")
const activated = path.join(directory, "activated")
const release = path.join(directory, "release")
const refreshed = path.join(directory, "refresh-requested")
const refreshRelease = path.join(directory, "refresh-release")
const refreshFinished = path.join(directory, "refresh-finished")
yield* Effect.promise(async () => {
await fs.mkdir(directory, { recursive: true })
await fs.writeFile(
path.join(directory, "index.js"),
`export default {
id: "background-refresh-plugin",
async setup() {
await Bun.write(${JSON.stringify(activated)}, "")
while (!(await Bun.file(${JSON.stringify(release)}).exists())) await Bun.sleep(10)
},
}`,
)
})
yield* withLocation(
{ plugins: ["background-refresh-plugin"] },
Effect.gen(function* () {
yield* waitForFile(activated)
yield* Effect.sleep("100 millis")
expect(yield* Effect.promise(() => Bun.file(refreshed).exists())).toBeFalse()
yield* Effect.promise(() => Bun.write(release, ""))
yield* waitForFile(refreshed)
yield* ready().pipe(Effect.timeout("2 seconds"))
yield* Effect.promise(() => Bun.write(refreshRelease, ""))
yield* waitForFile(refreshFinished)
const plugins = yield* Plugin.Service
expect((yield* plugins.list()).map((plugin) => String(plugin.id))).toContain("background-refresh-plugin")
}),
)
}),
)
})
const ready = Effect.fnUntraced(function* () {
@@ -524,20 +448,16 @@ const ready = Effect.fnUntraced(function* () {
yield* supervisor.flush
})
const waitForFile = (file: string) =>
Effect.promise(() => Bun.file(file).exists()).pipe(
Effect.filterOrFail((exists) => exists),
Effect.retry({ times: 200, schedule: Schedule.spaced("10 millis") }),
Effect.timeout("2 seconds"),
)
function withLocation<A, E, R>(
config: unknown,
effect: Effect.Effect<A, E, R>,
fixtures = false,
prepare?: (directory: string) => Promise<void>,
) {
return Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
return Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.tap((tmp) =>
Effect.promise(async () => {
await prepare?.(tmp.path)
-43
View File
@@ -34,41 +34,6 @@ const decode = Schema.decodeUnknownSync(Info)
const document = path.join(import.meta.dir, "opencode.json")
describe("config plugin reloads", () => {
it.effect("preserves reference precedence and insertion order across documents", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const references = yield* Reference.Service
const host = yield* PluginHost.make(plugins)
yield* references.transform((draft) =>
draft.add(
"external",
Reference.LocalSource.make({ type: "local", path: AbsolutePath.make("/references/external") }),
),
)
yield* ConfigReferencePlugin.Plugin.effect(host)
const result = yield* references.list()
expect(result.map((reference) => reference.name)).toEqual(["external", "shared", "first", "second"])
expect(result.find((reference) => reference.name === "shared")?.path).toBe(
AbsolutePath.make(path.resolve("/config/second/shared")),
)
}).pipe(
Effect.provide(
Config.testLayer([
referenceConfig("/config/first/opencode.json", {
shared: "./shared",
first: "./first",
}),
referenceConfig("/config/second/opencode.json", {
shared: "./shared",
second: "./second",
}),
]),
),
Effect.provideService(Global.Service, Global.Service.of(Global.make())),
),
)
it.live("reloads config-backed domains without reloading external plugins", () =>
Effect.gen(function* () {
const agents = yield* Agent.Service
@@ -137,14 +102,6 @@ function config(name: string) {
})
}
function referenceConfig(file: string, references: Record<string, string>) {
return new Document({
type: "document",
path: AbsolutePath.make(file),
info: decode({ references }),
})
}
function title(value: string) {
return value.charAt(0).toUpperCase() + value.slice(1)
}
@@ -15,14 +15,6 @@ const users = sqliteTable("users", {
id: integer().primaryKey({ autoIncrement: true }),
name: text().notNull(),
})
const teams = sqliteTable("teams", {
id: integer().primaryKey(),
name: text().notNull(),
})
const memberships = sqliteTable("memberships", {
user_id: integer().notNull(),
team_id: integer().notNull(),
})
const run = <A, E>(effect: Effect.Effect<A, E, SqlClient>) =>
Effect.runPromise(
@@ -171,41 +163,3 @@ test("supports returning and rejects empty update sets", async () => {
}),
)
})
test("supports function-valued update joins with runtime table columns", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
const query = db
.update(users)
.set({ name: "Grace" })
.from(teams)
.innerJoin(memberships, (update) => eq(update.id, memberships.user_id))
.where(eq(teams.name, "Core"))
expect(query.toSQL()).toEqual({
sql: 'update "users" set "name" = ? from "teams" inner join "memberships" on "users"."id" = "memberships"."user_id" where "teams"."name" = ?',
params: ["Grace", "Core"],
})
}),
)
})
test("supports SQL-valued update joins", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
const query = db
.update(users)
.set({ name: "Lin" })
.from(teams)
.innerJoin(memberships, eq(users.id, memberships.user_id))
.where(eq(teams.name, "Core"))
expect(query.toSQL()).toEqual({
sql: 'update "users" set "name" = ? from "teams" inner join "memberships" on "users"."id" = "memberships"."user_id" where "teams"."name" = ?',
params: ["Lin", "Core"],
})
}),
)
})
@@ -16,7 +16,18 @@ function js(code: string, opts?: ChildProcess.CommandOptions) {
}
function decodeByteStream(stream: Stream.Stream<Uint8Array, PlatformError.PlatformError>) {
return Stream.mkUint8Array(stream).pipe(Effect.map((bytes) => new TextDecoder("utf-8").decode(bytes).trim()))
return Stream.runCollect(stream).pipe(
Effect.map((chunks) => {
const total = chunks.reduce((acc, x) => acc + x.length, 0)
const out = new Uint8Array(total)
let off = 0
for (const chunk of chunks) {
out.set(chunk, off)
off += chunk.length
}
return new TextDecoder("utf-8").decode(out).trim()
}),
)
}
function alive(pid: number) {
@@ -0,0 +1,127 @@
import { describe, expect } from "bun:test"
import { Effect, Fiber, Scope } from "effect"
import { TestClock } from "effect/testing"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LocationWatcherPolicy } from "@opencode-ai/core/filesystem/location-watcher-policy"
import { State } from "@opencode-ai/core/state"
import { testEffect } from "../lib/effect"
const it = testEffect(AppNodeBuilder.build(LocationWatcherPolicy.node))
describe("LocationWatcherPolicy", () => {
it.effect("reads batched registrations and disposals without notifying observers", () =>
Effect.gen(function* () {
const policy = yield* LocationWatcherPolicy.Service
const observed: string[][] = []
yield* policy.observe((ignore) =>
Effect.sync(() => {
observed.push([...ignore])
}),
)
yield* State.batch(
Effect.gen(function* () {
yield* policy.transform((draft) => draft.add(["base"]))
const overlay = yield* policy.transform((draft) => draft.add(["overlay"]))
const snapshot = policy.current()
expect(snapshot).toEqual(["base", "overlay"])
expect(observed).toEqual([])
yield* overlay.dispose
expect(policy.current()).toEqual(["base"])
expect(snapshot).toEqual(["base", "overlay"])
expect(observed).toEqual([])
}),
)
expect(observed).toEqual([["base"]])
}),
)
it.effect("reads reloaded patterns before debounced observer reconciliation", () =>
Effect.gen(function* () {
const policy = yield* LocationWatcherPolicy.Service
const observed: string[][] = []
let ignore = ["first"]
yield* policy.observe((ignore) =>
Effect.sync(() => {
observed.push([...ignore])
}),
)
yield* policy.transform((draft) => draft.add(ignore))
const snapshot = policy.current()
observed.length = 0
ignore = ["second"]
const reload = yield* policy.reload().pipe(Effect.forkChild({ startImmediately: true }))
expect(policy.current()).toEqual(["second"])
expect(snapshot).toEqual(["first"])
expect(observed).toEqual([])
yield* TestClock.adjust("500 millis")
yield* Fiber.join(reload)
expect(observed).toEqual([["second"]])
}),
)
it.effect("passes the latest policy to later observers after a reentrant registration", () =>
Effect.gen(function* () {
const policy = yield* LocationWatcherPolicy.Service
const scope = yield* Scope.Scope
const observed: string[][] = []
let reentered = false
yield* policy.observe(() =>
Effect.gen(function* () {
if (reentered) return
reentered = true
yield* policy.transform((draft) => draft.add(["inner"])).pipe(Scope.provide(scope))
}),
)
yield* policy.observe((ignore) =>
Effect.sync(() => {
observed.push([...ignore])
}),
)
yield* policy.transform((draft) => draft.add(["outer"]))
expect(policy.current()).toEqual(["outer", "inner"])
expect(observed).toEqual([
["outer", "inner"],
["outer", "inner"],
])
}),
)
it.effect("allows an observer to await a reload and keeps later observers current", () =>
Effect.gen(function* () {
const policy = yield* LocationWatcherPolicy.Service
const observed: string[][] = []
let ignore = ["first"]
let reentered = false
yield* policy.observe(() =>
Effect.gen(function* () {
if (reentered) return
reentered = true
ignore = ["second"]
yield* policy.reload()
}),
)
yield* policy.observe((ignore) =>
Effect.sync(() => {
observed.push([...ignore])
}),
)
const writer = yield* policy
.transform((draft) => draft.add(ignore))
.pipe(Effect.forkChild({ startImmediately: true }))
expect(policy.current()).toEqual(["second"])
expect(observed).toEqual([])
yield* TestClock.adjust("500 millis")
yield* Fiber.join(writer)
expect(observed).toEqual([["second"], ["second"]])
}),
)
})
+141 -140
View File
@@ -1,6 +1,6 @@
import { describe, expect, spyOn } from "bun:test"
import { describe, expect, spyOn, test } from "bun:test"
import fuzzysort from "fuzzysort"
import { mkdir } from "node:fs/promises"
import { mkdir, mkdtemp, rm } from "node:fs/promises"
import os from "os"
import path from "path"
import { Deferred, Effect, Layer } from "effect"
@@ -14,8 +14,6 @@ import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { Workspace } from "@opencode-ai/core/workspace"
import { location } from "../fixture/location"
import { tmpdir } from "../fixture/tmpdir"
import { it } from "../lib/effect"
const ripgrepStub = (entry: string, onFind: (input: Ripgrep.FindInput) => void) =>
Layer.succeed(
@@ -34,13 +32,14 @@ const ripgrepStub = (entry: string, onFind: (input: Ripgrep.FindInput) => void)
)
describe("FileSystemSearch", () => {
it.live("honors wildcard directory rules from .gitignore", () =>
Effect.gen(function* () {
const directory = (yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-fff-ignore-")))).path
yield* Effect.promise(() => mkdir(path.join(directory, "rust/target/debug/deps"), { recursive: true }))
yield* Effect.promise(() => Bun.write(path.join(directory, ".gitignore"), "**/target/\n"))
yield* Effect.promise(() => Bun.write(path.join(directory, "rust/target/debug/deps/ignored.rs"), "ignored"))
expect(Bun.spawnSync(["git", "init", "-q"], { cwd: directory }).exitCode).toBe(0)
test("honors wildcard directory rules from .gitignore", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-fff-ignore-"))
try {
await mkdir(path.join(directory, "rust/target/debug/deps"), { recursive: true })
await Bun.write(path.join(directory, ".gitignore"), "**/target/\n")
await Bun.write(path.join(directory, "rust/target/debug/deps/ignored.rs"), "ignored")
const git = Bun.spawnSync(["git", "init", "-q"], { cwd: directory })
expect(git.exitCode).toBe(0)
const ref = Location.Ref.make({ directory: AbsolutePath.make(directory) })
const layer = FileSystemSearch.fffLayer.pipe(
@@ -55,22 +54,26 @@ describe("FileSystemSearch", () => {
),
),
)
yield* Effect.gen(function* () {
const search = yield* FileSystemSearch.Service
const entries = yield* search.find({ query: "target" })
expect(entries.every((entry) => !entry.path.startsWith("rust/target/"))).toBe(true)
}).pipe(Effect.provide(layer))
}),
)
const entries = await Effect.runPromise(
Effect.gen(function* () {
const search = yield* FileSystemSearch.Service
return yield* search.find({ query: "target" })
}).pipe(Effect.provide(layer), Effect.scoped),
)
it.live("selects the ripgrep layer for workspace-backed locations even when vcs would pick fff", () =>
Effect.gen(function* () {
const directory = (yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-search-workspace-"))))
.path
expect(entries.every((entry) => !entry.path.startsWith("rust/target/"))).toBe(true)
} finally {
await rm(directory, { recursive: true, force: true })
}
})
test("selects the ripgrep layer for workspace-backed locations even when vcs would pick fff", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-search-workspace-"))
try {
// A local file that only an fff index of the server directory could surface.
// The fff-vs-ripgrep discrimination only bites where Fff.available() is
// true; elsewhere the layer choice already falls back to ripgrep.
yield* Effect.promise(() => Bun.write(path.join(directory, "server-local.ts"), "server local"))
await Bun.write(path.join(directory, "server-local.ts"), "server local")
let observed: Ripgrep.FindInput | undefined
const ref = Location.Ref.make({
directory: AbsolutePath.make(directory),
@@ -89,35 +92,37 @@ describe("FileSystemSearch", () => {
[Ripgrep.node, ripgrepStub("remote.ts", (input) => (observed = input))],
])
yield* Effect.gen(function* () {
const search = yield* FileSystemSearch.Service
const entries = yield* search.find({ query: "ts", type: "file" })
expect(observed?.cwd).toBe(directory)
expect(entries.map((entry) => entry.path)).toEqual([RelativePath.make("remote.ts")])
}).pipe(Effect.provide(layer))
}),
)
await Effect.runPromise(
Effect.gen(function* () {
const search = yield* FileSystemSearch.Service
const entries = yield* search.find({ query: "ts", type: "file" })
expect(observed?.cwd).toBe(directory)
expect(entries.map((entry) => entry.path)).toEqual([RelativePath.make("remote.ts")])
}).pipe(Effect.provide(layer), Effect.scoped),
)
} finally {
await rm(directory, { recursive: true, force: true })
}
})
it.live("bounds a home scan even when home is detected as a repository", () =>
Effect.gen(function* () {
let observed: Ripgrep.FindInput | undefined
const home = AbsolutePath.make(os.homedir())
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: home },
{ vcs: { type: "git", store: AbsolutePath.make(path.join(home, ".git")) } },
),
),
test("bounds a home scan even when home is detected as a repository", async () => {
let observed: Ripgrep.FindInput | undefined
const home = AbsolutePath.make(os.homedir())
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location({ directory: home }, { vcs: { type: "git", store: AbsolutePath.make(path.join(home, ".git")) } }),
),
],
[Ripgrep.node, ripgrepStub("src/index.ts", (input) => (observed = input))],
])
yield* Effect.gen(function* () {
),
],
[Ripgrep.node, ripgrepStub("src/index.ts", (input) => (observed = input))],
])
await Effect.runPromise(
Effect.gen(function* () {
const search = yield* FileSystemSearch.Service
yield* Effect.sleep("10 millis")
expect(observed).toBeUndefined()
@@ -127,52 +132,52 @@ describe("FileSystemSearch", () => {
expect((yield* search.find({ query: "src", type: "directory" }))[0]?.path).toBe(
RelativePath.make(`src${path.sep}`),
)
}).pipe(Effect.provide(layer))
}),
)
}).pipe(Effect.provide(layer), Effect.scoped),
)
})
it.effect("refreshes a stale ripgrep index atomically without blocking search", () =>
Effect.gen(function* () {
let scans = 0
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-atomic")) }),
),
test("refreshes a stale ripgrep index atomically without blocking search", async () => {
let scans = 0
const started = Effect.runSync(Deferred.make<void>())
const release = Effect.runSync(Deferred.make<void>())
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-atomic")) }),
),
],
[
Ripgrep.node,
Layer.succeed(
Ripgrep.Service,
Ripgrep.Service.of({
find: (input) =>
Effect.gen(function* () {
scans++
if (scans > 1) {
yield* Deferred.succeed(started, undefined)
yield* Deferred.await(release)
}
const entry = FileSystem.Entry.make({
path: RelativePath.make(scans === 1 ? "src/old.ts" : "src/new.ts"),
type: "file",
})
if (input.onEntry) yield* input.onEntry(entry)
return [entry]
}),
glob: () => Effect.succeed([]),
grep: () => Effect.succeed([]),
}),
),
],
])
),
],
[
Ripgrep.node,
Layer.succeed(
Ripgrep.Service,
Ripgrep.Service.of({
find: (input) =>
Effect.gen(function* () {
scans++
if (scans > 1) {
yield* Deferred.succeed(started, undefined)
yield* Deferred.await(release)
}
const entry = FileSystem.Entry.make({
path: RelativePath.make(scans === 1 ? "src/old.ts" : "src/new.ts"),
type: "file",
})
if (input.onEntry) yield* input.onEntry(entry)
return [entry]
}),
glob: () => Effect.succeed([]),
grep: () => Effect.succeed([]),
}),
),
],
])
yield* Effect.gen(function* () {
await Effect.runPromise(
Effect.gen(function* () {
const search = yield* FileSystemSearch.Service
yield* search.find({ query: "old", type: "file" })
expect((yield* search.find({ query: "old", type: "file" }))[0]?.path).toBe(RelativePath.make("src/old.ts"))
@@ -191,53 +196,47 @@ describe("FileSystemSearch", () => {
}).pipe(Effect.repeat({ until: (entries) => entries.length > 0 }))
expect(refreshed[0]?.path).toBe(RelativePath.make("src/new.ts"))
expect(scans).toBe(2)
}).pipe(Effect.provide(layer))
}),
)
}).pipe(Effect.provide(layer), Effect.provide(TestClock.layer()), Effect.scoped),
)
})
it.effect("reuses location-owned fuzzy targets across index refreshes", () =>
Effect.gen(function* () {
let scans = 0
const second = yield* Deferred.make<void>()
const prepare = yield* Effect.acquireRelease(
Effect.sync(() => spyOn(fuzzysort, "prepare")),
(value) => Effect.sync(() => value.mockRestore()),
)
const cleanup = yield* Effect.acquireRelease(
Effect.sync(() => spyOn(fuzzysort, "cleanup")),
(value) => Effect.sync(() => value.mockRestore()),
)
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-cache")) }),
),
test("reuses location-owned fuzzy targets across index refreshes", async () => {
let scans = 0
const second = Effect.runSync(Deferred.make<void>())
const prepare = spyOn(fuzzysort, "prepare")
const cleanup = spyOn(fuzzysort, "cleanup")
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-cache")) }),
),
],
[
Ripgrep.node,
Layer.succeed(
Ripgrep.Service,
Ripgrep.Service.of({
find: (input) =>
Effect.gen(function* () {
scans++
const entry = FileSystem.Entry.make({ path: RelativePath.make("src/index.ts"), type: "file" })
if (input.onEntry) yield* input.onEntry(entry)
if (scans > 1) yield* Deferred.succeed(second, undefined)
return [entry]
}),
glob: () => Effect.succeed([]),
grep: () => Effect.succeed([]),
}),
),
],
])
),
],
[
Ripgrep.node,
Layer.succeed(
Ripgrep.Service,
Ripgrep.Service.of({
find: (input) =>
Effect.gen(function* () {
scans++
const entry = FileSystem.Entry.make({ path: RelativePath.make("src/index.ts"), type: "file" })
if (input.onEntry) yield* input.onEntry(entry)
if (scans > 1) yield* Deferred.succeed(second, undefined)
return [entry]
}),
glob: () => Effect.succeed([]),
grep: () => Effect.succeed([]),
}),
),
],
])
yield* Effect.gen(function* () {
await Effect.runPromise(
Effect.gen(function* () {
const search = yield* FileSystemSearch.Service
yield* search.find({ query: "index", type: "file" })
yield* TestClock.adjust("10 seconds")
@@ -247,7 +246,9 @@ describe("FileSystemSearch", () => {
expect(prepare).toHaveBeenCalledTimes(2)
expect(cleanup).toHaveBeenCalledTimes(3)
}).pipe(Effect.provide(layer))
}),
)
}).pipe(Effect.provide(layer), Effect.provide(TestClock.layer()), Effect.scoped),
)
prepare.mockRestore()
cleanup.mockRestore()
})
})
+5 -1
View File
@@ -14,6 +14,10 @@ export function location(ref: Location.Ref, input: { projectDirectory?: Absolute
} satisfies Location.Interface
}
export function locationLayer(ref: Location.Ref, input: { projectDirectory?: AbsolutePath; vcs?: Project.Vcs } = {}) {
return Layer.succeed(Location.Service, Location.Service.of(location(ref, input)))
}
export const tempLocationLayer = Layer.unwrap(
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
@@ -21,7 +25,7 @@ export const tempLocationLayer = Layer.unwrap(
).pipe(
Effect.map((tmp) => {
const ref = Location.Ref.make({ directory: AbsolutePath.make(tmp.path) })
return Layer.succeed(Location.Service, Location.Service.of(location(ref)))
return locationLayer(ref)
}),
),
)
@@ -0,0 +1,73 @@
import { describe, expect } from "bun:test"
import { Cause, Effect, Exit, Fiber } from "effect"
import { TestClock } from "effect/testing"
import { Credential } from "@opencode-ai/core/credential"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Integration } from "@opencode-ai/core/integration"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { testEffect } from "./lib/effect"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node])))
describe("Integration replay", () => {
it.effect("fails and closes an OAuth attempt when fresh implementation replay throws", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("replay-test")
const methodID = Integration.MethodID.make("code")
const source = { fail: false, closed: false }
const failure = new Error("integration transform replay failed")
yield* integrations.transform((editor) => {
if (source.fail) throw failure
editor.method.update({
integrationID,
method: { id: methodID, type: "oauth", label: "Fixture" },
authorize: () =>
Effect.addFinalizer(() => Effect.sync(() => (source.closed = true))).pipe(
Effect.as({
mode: "code" as const,
url: "https://example.com/authorize",
instructions: "Enter the fixture code",
callback: () =>
Effect.succeed(
Credential.OAuth.make({
type: "oauth",
methodID,
access: "dummy-access",
refresh: "dummy-refresh",
expires: Number.MAX_SAFE_INTEGER,
}),
),
}),
),
})
})
const attempt = yield* integrations.oauth.connect({ integrationID, methodID, label: "Fixture" })
source.fail = true
const reload = yield* integrations.reload().pipe(Effect.exit, Effect.forkChild({ startImmediately: true }))
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
source.fail = false
yield* TestClock.adjust("500 millis")
yield* Fiber.join(reload)
}),
)
const exit = yield* integrations.oauth
.complete({ integrationID, attemptID: attempt.attemptID, code: "dummy-code" })
.pipe(Effect.exit)
expect(exit).toMatchObject(Exit.die(failure))
expect(Exit.isFailure(exit) && Cause.squash(exit.cause)).toBe(failure)
expect(yield* integrations.oauth.status({ integrationID, attemptID: attempt.attemptID })).toEqual({
status: "failed",
message: failure.message,
time: attempt.time,
})
expect(source.closed).toBe(true)
expect(yield* credentials.list(integrationID)).toEqual([])
}),
)
})
+98 -1
View File
@@ -7,6 +7,7 @@ import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { Integration } from "@opencode-ai/core/integration"
import { State } from "@opencode-ai/core/state"
import { testEffect } from "./lib/effect"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node, Bus.node])))
@@ -186,7 +187,7 @@ describe("Integration", () => {
value: Credential.Key.make({ type: "key", key: "secret", configuration: { accountId: "account" } }),
}),
])
expect((yield* Fiber.join(created)).map((event) => ({ type: event.type, data: event.data }))).toEqual([
expect(Array.from(yield* Fiber.join(created), (event) => ({ type: event.type, data: event.data }))).toEqual([
{ type: Credential.Event.Updated.type, data: {} },
{ type: Credential.Event.Switched.type, data: { credentialID: stored[0]?.id, integrationID } },
])
@@ -262,6 +263,102 @@ describe("Integration", () => {
}),
)
it.effect("resolves stored OAuth with refresh registrations made inside a batch", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("acme")
const method = Integration.OAuthMethod.make({
id: Integration.MethodID.make("browser"),
type: "oauth",
label: "Browser",
})
const expired = Credential.OAuth.make({
type: "oauth",
methodID: method.id,
access: "expired",
refresh: "refresh",
expires: 0,
})
const fresh = Credential.OAuth.make({
...expired,
access: "fresh",
refresh: "fresh-refresh",
expires: (yield* Clock.currentTimeMillis) + Duration.toMillis(Duration.hours(1)),
})
const stored = yield* credentials.create({ integrationID, label: "Personal", value: expired })
const connection = { type: "credential" as const, id: stored.id, label: stored.label }
const calls: string[] = []
const implementation = {
integrationID,
method,
authorize: () => Effect.die("unexpected authorization"),
refresh: (value: Credential.OAuth) =>
Effect.sync(() => {
expect(value).toEqual(expired)
calls.push("original")
return fresh
}),
}
yield* State.batch(
Effect.gen(function* () {
yield* integrations.transform((editor) => editor.method.update(implementation))
expect(yield* integrations.connection.resolve(connection)).toEqual(fresh)
expect((yield* credentials.get(stored.id))?.value).toEqual(fresh)
expect(calls).toEqual(["original"])
expect(yield* integrations.connection.resolve(connection)).toEqual(fresh)
expect(calls).toEqual(["original"])
yield* credentials.update(stored.id, { value: expired })
const overridden = Credential.OAuth.make({ ...fresh, access: "override" })
const override = yield* integrations.transform((editor) =>
editor.method.update({
...implementation,
refresh: (value) =>
Effect.sync(() => {
expect(value).toEqual(expired)
calls.push("override")
return overridden
}),
}),
)
expect(yield* integrations.connection.resolve(connection)).toEqual(overridden)
expect((yield* credentials.get(stored.id))?.value).toEqual(overridden)
yield* override.dispose
yield* credentials.update(stored.id, { value: expired })
expect(yield* integrations.connection.resolve(connection)).toEqual(fresh)
expect(calls).toEqual(["original", "override", "original"])
yield* credentials.update(stored.id, { value: expired })
const removal = yield* integrations.transform((editor) => editor.method.remove(integrationID, method))
expect(yield* integrations.connection.resolve(connection)).toEqual(expired)
expect((yield* credentials.get(stored.id))?.value).toEqual(expired)
expect(calls).toEqual(["original", "override", "original"])
yield* removal.dispose
expect(yield* integrations.connection.resolve(connection)).toEqual(fresh)
yield* credentials.update(stored.id, { value: expired })
yield* integrations.transform((editor) => editor.method.update({ ...implementation, refresh: undefined }))
expect(yield* integrations.connection.resolve(connection)).toEqual(expired)
expect((yield* credentials.get(stored.id))?.value).toEqual(expired)
expect(calls).toEqual(["original", "override", "original", "original"])
const failure = new Error("refresh failed")
yield* integrations.transform((editor) =>
editor.method.update({ ...implementation, refresh: () => Effect.fail(failure) }),
)
expect(yield* integrations.connection.resolve(connection).pipe(Effect.flip)).toEqual(
new Integration.AuthorizationError({ cause: failure }),
)
expect((yield* credentials.get(stored.id))?.value).toEqual(expired)
}),
)
}),
)
it.effect("completes code OAuth once and stores the credential", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
@@ -49,29 +49,6 @@ export const environmentConformance = <E>(
}),
)
check("observes filesystem state when an operation executes", (harness) =>
Effect.gen(function* () {
const target = `${harness.root}/deferred.txt`
const source = `${harness.root}/source.txt`
const destination = `${harness.root}/destination.txt`
const read = harness.files.read(target)
const stat = harness.files.stat(target)
const list = harness.files.list(harness.root)
const move = harness.files.move(source, destination)
yield* harness.files.write(target, bytes("first"))
yield* harness.files.write(source, bytes("moved"))
expect(text((yield* read).bytes)).toBe("first")
expect((yield* stat).size).toBe(5)
expect(yield* list).toContainEqual({ name: "deferred.txt", type: "file" })
yield* move
expect(text((yield* harness.files.read(destination)).bytes)).toBe("moved")
yield* harness.files.write(target, bytes("second"))
expect(text((yield* read).bytes)).toBe("second")
}),
)
check("reports missing paths", (harness) =>
Effect.gen(function* () {
const target = `${harness.root}/missing`
+139 -108
View File
@@ -33,23 +33,37 @@ import { McpStdio } from "@opencode-ai/core/mcp/stdio"
import { Permission } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { State } from "@opencode-ai/core/state"
import { McpTool } from "@opencode-ai/core/tool/mcp"
import { Tool } from "@opencode-ai/core/tool"
import { Deferred, Effect, Exit, Fiber, Layer, PubSub, Ref, Schedule, Schema, Sink, Stream } from "effect"
import {
Context,
Deferred,
Effect,
Exit,
Fiber,
Layer,
PubSub,
Ref,
Schedule,
Schema,
Scope,
Sink,
Stream,
} from "effect"
import { TestClock } from "effect/testing"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { ExitCode, makeHandle, ProcessId } from "effect/unstable/process/ChildProcessSpawner"
import { Image } from "@opencode-ai/core/image"
import { testEffect } from "./lib/effect"
import { imagePassthrough } from "./lib/image"
import { location } from "./fixture/location"
import { location, locationLayer } from "./fixture/location"
import { hostEnvironmentLayer, recordingEnvironmentLayer } from "./fixture/environment"
import { executeTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
let assertion: Deferred.Deferred<Permission.AssertInput> | undefined
let decision: Effect.Effect<void, Permission.Error> = Effect.void
let calls = 0
let invocations: Array<Parameters<Mcp.Interface["callTool"]>[0]> = []
type ResourcePage = {
items: Array<{ name: string; uri: string; description?: string; mimeType?: string }>
@@ -67,7 +81,7 @@ function resourceServer(
listChanged?: boolean
emptyElicitation?: boolean
urlElicitation?: boolean
respond?: (request: Request) => Response | undefined
respond?: (request: Request) => Response | undefined | Promise<Response | undefined>
} = {},
) {
return Effect.acquireRelease(
@@ -84,12 +98,6 @@ function resourceServer(
resourceLists: 0,
templateLists: 0,
toolLists: 0,
toolCalls: [] as Array<{
name: string
arguments: Record<string, unknown> | undefined
sessionID: unknown
progressToken: unknown
}>,
initializations: 0,
urls: [] as string[],
}
@@ -139,17 +147,6 @@ function resourceServer(
}
})
}
if (!input.emptyElicitation && !input.urlElicitation) {
protocol.setRequestHandler(CallToolRequestSchema, (request) => {
state.toolCalls.push({
name: request.params.name,
arguments: request.params.arguments,
sessionID: request.params._meta?.sessionID,
progressToken: request.params._meta?.progressToken,
})
return Promise.resolve({ content: [] })
})
}
if (input.resources !== false) {
protocol.setRequestHandler(ListResourcesRequestSchema, (request) => {
state.resourceLists += 1
@@ -176,7 +173,7 @@ function resourceServer(
if (typeof body === "object" && body !== null && "method" in body && body.method === "initialize") {
state.initializations += 1
}
return input.respond?.(request) ?? transport.handleRequest(request)
return (await input.respond?.(request)) ?? transport.handleRequest(request)
},
})
return {
@@ -331,7 +328,6 @@ const mcp = Layer.mock(Mcp.Service, {
callTool: (input) =>
Effect.sync(() => {
calls += 1
invocations.push(input)
if (input.name === "fail")
return new Mcp.ToolResult({
server: Mcp.ServerName.make(input.server),
@@ -399,43 +395,6 @@ test("MCP tool names match V1 sanitization", () => {
expect(McpTool.name("context 7", "resolve.library/id")).toBe("context_7_resolve_library_id")
})
test("passes session IDs as MCP request metadata", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const server = yield* resourceServer()
const connection = yield* connect(
"session-metadata",
new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false }),
import.meta.dir,
)
yield* connection.callTool({
name: "echo",
args: { text: "hello" },
sessionID: Session.ID.make("ses_mcp_metadata"),
})
yield* connection.callTool({ name: "echo" })
expect(server.state.toolCalls).toEqual([
{
name: "echo",
arguments: { text: "hello" },
sessionID: "ses_mcp_metadata",
progressToken: expect.any(Number),
},
{
name: "echo",
arguments: {},
sessionID: undefined,
progressToken: expect.any(Number),
},
])
expect(server.state.toolCalls[0]?.progressToken).not.toBe(server.state.toolCalls[1]?.progressToken)
}),
),
)
})
test("preserves output schema validation across paginated tool discovery", async () => {
const server = new Server({ name: "pagination", version: "1.0.0" }, { capabilities: { tools: {} } })
server.setRequestHandler(ListToolsRequestSchema, ({ params }) =>
@@ -1411,6 +1370,126 @@ test("reconciles only changed MCP server config", async () => {
)
})
testEffect(Layer.empty).live("serializes MCP config restoration behind an in-flight replacement", () =>
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const accepted = yield* Deferred.make<void>()
const server = yield* resourceServer({
respond: (request) =>
request.method !== "POST"
? undefined
: Effect.runPromise(
Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(release)), Effect.as(undefined)),
),
})
yield* Effect.gen(function* () {
const service = yield* Mcp.Service
expect((yield* service.servers())[0]?.status).toEqual({ status: "disabled" })
const replacing = yield* service
.transform((draft) => draft.update("resources", (config) => (config.disabled = false)))
.pipe(Effect.forkScoped({ startImmediately: true }))
yield* Deferred.await(started)
const restoring = yield* State.batch(
Effect.gen(function* () {
yield* service.transform((draft) => draft.update("resources", (config) => (config.disabled = true)))
yield* Deferred.succeed(accepted, undefined)
}),
).pipe(Effect.forkScoped({ startImmediately: true }))
yield* Deferred.await(accepted)
expect((yield* service.servers())[0]?.status).toEqual({ status: "pending" })
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(replacing)
yield* Fiber.join(restoring)
expect((yield* service.servers())[0]?.status).toEqual({ status: "disabled" })
expect(yield* service.tools()).toEqual([])
expect(server.state.initializations).toBe(1)
}).pipe(
Effect.ensuring(Deferred.succeed(release, undefined)),
Effect.provide(
resourceMcpLayer(new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false, disabled: true })),
),
)
}),
)
const shutdownIt = testEffect(
AppNodeBuilder.build(
LayerNode.group([Bus.node, Integration.node, Credential.node, Form.node, Environment.node, Location.node]),
[
[Location.node, locationLayer({ directory: AbsolutePath.make(import.meta.dir) })],
[Environment.node, hostEnvironmentLayer],
],
),
)
;["active", "queued"].forEach((phase) =>
shutdownIt.effect(`discards ${phase} MCP notifications after its layer closes`, () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const entered = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const root = yield* Scope.make()
yield* Effect.addFinalizer(() =>
Deferred.succeed(release, undefined).pipe(
Effect.andThen(State.batch(Scope.close(root, Exit.void), { flush: false })),
Effect.andThen(TestClock.adjust("500 millis")),
),
)
const context = yield* Layer.buildWithScope(Mcp.layer(), root)
const service = Context.get(context, Mcp.Service)
const observed: string[] = []
let block = false
const unsubscribe = yield* bus.listen((event) =>
Effect.gen(function* () {
if (event.type !== McpEvent.StatusChanged.type) return
observed.push(Schema.decodeUnknownSync(McpEvent.StatusChanged.data)(event.data).server)
if (!block) return
block = false
yield* Deferred.succeed(entered, undefined)
yield* Deferred.await(release)
}),
)
yield* Effect.addFinalizer(() => unsubscribe)
const source = { url: "https://example.com/initial", added: false }
yield* service
.transform((draft) => {
draft.set("fixture", { type: "remote", url: source.url, oauth: false, disabled: true })
if (source.added) draft.set("queued", { type: "local", command: ["unused"], disabled: true })
})
.pipe(Scope.provide(root))
block = true
source.url = "https://example.com/first"
source.added = phase === "active"
const first = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Deferred.await(entered)
source.url = "https://example.com/second"
source.added = true
const second = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
const shutdown = yield* State.batch(Scope.close(root, Exit.void), { flush: false }).pipe(
Effect.forkChild({ startImmediately: true }),
)
yield* TestClock.adjust("1 millis")
expect(shutdown.pollUnsafe()).toBeDefined()
expect(first.pollUnsafe()).toBeDefined()
expect(second.pollUnsafe()).toBeDefined()
expect(yield* Deferred.isDone(release)).toBe(false)
yield* Fiber.join(shutdown)
observed.length = 0
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(first)
yield* Fiber.join(second)
expect(observed).toEqual([])
expect((yield* service.servers()).map((server) => server.name)).toEqual([Mcp.ServerName.make("fixture")])
}),
),
)
test("serializes concurrent MCP lifecycle operations", async () => {
await Effect.runPromise(
Effect.scoped(
@@ -1666,54 +1745,6 @@ it.effect("advertises MCP output schemas to Code Mode", () =>
}),
)
it.effect("forwards the invoking session through direct and Code Mode MCP tools", () =>
Effect.gen(function* () {
assertion = yield* Deferred.make<Permission.AssertInput>()
decision = Effect.void
invocations = []
const registry = yield* Tool.Service
const registration = yield* McpTool.Service
yield* registration.flush
const toolSet = yield* registry.snapshot()
expect(toolSet.definitions.find((tool) => tool.name === "direct_lookup")?.inputSchema).not.toHaveProperty(
"properties.sessionID",
)
expect(toolSet.codeModeCatalog?.find((tool) => tool.path === "demo.search")?.signature).not.toContain("sessionID")
const directSessionID = Session.ID.make("ses_mcp_direct")
yield* toolSet.execute({
sessionID: directSessionID,
...toolIdentity,
call: { type: "tool-call", id: "call_mcp_direct", name: "direct_lookup", input: {} },
})
expect(invocations[0]).toEqual({
server: "direct",
name: "lookup",
args: {},
sessionID: directSessionID,
})
const codeModeSessionID = Session.ID.make("ses_mcp_codemode")
yield* toolSet.execute({
sessionID: codeModeSessionID,
...toolIdentity,
call: {
type: "tool-call",
id: "call_mcp_codemode",
name: "execute",
input: { code: "return await tools.demo.search({})" },
},
})
expect(invocations[1]).toEqual({
server: "demo",
name: "search",
args: {},
sessionID: codeModeSessionID,
})
}),
)
it.effect("returns content-only MCP results through Code Mode", () =>
Effect.gen(function* () {
assertion = yield* Deferred.make<Permission.AssertInput>()
+1 -1
View File
@@ -234,7 +234,7 @@ describe("Npm.add", () => {
const first = await Effect.gen(function* () {
const npm = yield* Npm.Service
const mutableEntry = yield* npm.add(mutable)
const mutableEntry = yield* npm.add(mutable, { refresh: true })
const pinnedEntry = yield* npm.add(pinned, { refresh: true })
yield* Effect.promise(async () => {
await Bun.write(path.join(fixture.repository, "index.js"), 'export default { root: "second" }\n')
+61 -1
View File
@@ -1,10 +1,12 @@
import { describe, expect } from "bun:test"
import { ToolFailure } from "@opencode-ai/ai"
import { Context, Effect, Exit, Fiber, Schema, Stream } from "effect"
import { Clock, Context, Duration, Effect, Exit, Fiber, Schema, Stream } from "effect"
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { Agent } from "@opencode-ai/core/agent"
import { Bus } from "@opencode-ai/core/bus"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
@@ -103,6 +105,64 @@ describe("Plugin", () => {
}),
)
it.live("refreshes its own stored OAuth connection during plugin activation", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("acme")
const methodID = Integration.MethodID.make("browser")
const expired = Credential.OAuth.make({
type: "oauth",
methodID,
access: "expired",
refresh: "refresh",
expires: 0,
})
const fresh = Credential.OAuth.make({
...expired,
access: "fresh",
refresh: "fresh-refresh",
expires: (yield* Clock.currentTimeMillis) + Duration.toMillis(Duration.hours(1)),
})
const stored = yield* credentials.create({ integrationID, label: "Personal", value: expired })
const resolved: (Credential.Value | undefined)[] = []
const refreshed: Credential.OAuth[] = []
yield* plugins.activate([
versioned(
EffectPlugin.define({
id: "oauth-refresh",
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.integration.transform((editor) =>
editor.method.update({
integrationID,
method: { id: methodID, type: "oauth", label: "Browser" },
authorize: () => Effect.die("unexpected authorization"),
refresh: (value) =>
Effect.sync(() => {
refreshed.push(value)
return fresh
}),
}),
)
const connection = yield* ctx.integration.connection.active(integrationID)
if (!connection) return yield* Effect.die("stored connection missing")
resolved.push(yield* ctx.integration.connection.resolve(connection).pipe(Effect.orDie))
}),
}),
),
])
expect(resolved).toEqual([fresh])
expect(refreshed).toEqual([expired])
expect((yield* credentials.get(stored.id))?.value).toEqual(fresh)
expect(yield* plugins.list()).toEqual([
{ id: Plugin.ID.make("oauth-refresh"), source: { type: "builtin" }, status: "active", tui: false },
])
}),
)
it.live("exposes public events through the plugin context", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
+1 -62
View File
@@ -1,7 +1,7 @@
import path from "path"
import { describe, expect } from "bun:test"
import { Money } from "@opencode-ai/schema/money"
import { Effect, Exit, Layer, Scope } from "effect"
import { Effect, Layer } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Integration } from "@opencode-ai/core/integration"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -11,8 +11,6 @@ import { Location } from "@opencode-ai/core/location"
import { Model } from "@opencode-ai/core/model"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { ModelsDevPlugin } from "@opencode-ai/core/plugin/models-dev"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -20,7 +18,6 @@ import { withEnv } from "../fixture/env"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
import { catalogHost, host, integrationHost } from "./host"
import { PluginTestLayer } from "./fixture"
const locationLayer = Layer.succeed(
Location.Service,
@@ -30,68 +27,10 @@ const layer = AppNodeBuilder.build(LayerNode.group([Catalog.node, Integration.no
[Location.node, locationLayer],
])
const it = testEffect(layer)
const real = testEffect(PluginTestLayer)
const models = (file: string) =>
AppNodeBuilder.build(ModelsDev.node, [[ModelsDev.node, ModelsDev.configured({ file, fetch: false })]])
describe("ModelsDevPlugin", () => {
real.effect("keeps the retained model seed unchanged across catalog replay", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const plugins = yield* Plugin.Service
const providerID = Provider.ID.make("acme")
const modelID = Model.ID.make("model")
const modelsDev = ModelsDev.Service.of({
get: () =>
Effect.succeed([
{
info: {
id: providerID,
name: "Acme",
activation: "auto",
package: Provider.aisdk("@ai-sdk/openai-compatible"),
},
environment: [],
models: [
{
id: modelID,
modelID,
providerID,
name: "Model",
capabilities: { tools: true, input: [], output: [] },
variants: [],
time: { released: Date.parse("2026-01-01") },
cost: [],
status: "active",
enabled: true,
limit: { context: 128_000, output: 32_000 },
},
],
},
] satisfies readonly ModelsDev.Snapshot[]),
refresh: () => Effect.void,
})
const pluginHost = yield* PluginHost.make(plugins)
yield* ModelsDevPlugin.effect(pluginHost).pipe(Effect.provideService(ModelsDev.Service, modelsDev))
const scope = yield* Scope.make()
yield* catalog
.transform((draft) =>
draft.model.update(providerID, modelID, (model) => {
model.variants ??= []
model.variants.push({ id: Model.VariantID.make("configured") })
}),
)
.pipe(Scope.provide(scope))
expect((yield* catalog.model.get(providerID, modelID))?.variants).toEqual([
{ id: Model.VariantID.make("configured") },
])
yield* Scope.close(scope, Exit.void)
expect((yield* catalog.model.get(providerID, modelID))?.variants).toEqual([])
}),
)
it.effect("projects normalized models.dev snapshots into the catalog", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
-29
View File
@@ -1,29 +0,0 @@
import path from "path"
import { pathToFileURL } from "url"
import { expect, test } from "bun:test"
import { PluginModule } from "@opencode-ai/core/plugin/module"
import { Npm } from "@opencode-ai/util/npm"
import { Effect } from "effect"
test("loads cached plugin packages without requesting a refresh", async () => {
const calls: unknown[] = []
const entrypoint = path.join(import.meta.dir, "fixtures", "config-effect-plugin.ts")
const plugin = await PluginModule.load({ type: "add", target: "fixture-plugin", options: {} }).pipe(
Effect.provideService(
Npm.Service,
Npm.Service.of({
add: (_pkg, options) =>
Effect.sync(() => {
calls.push(options)
return { directory: path.dirname(entrypoint), entrypoint: pathToFileURL(entrypoint).href }
}),
resolve: () => Effect.die(new Error("Unexpected resolve")),
which: () => Effect.die(new Error("Unexpected which")),
}),
),
Effect.runPromise,
)
expect(plugin.id).toBe("config-effect-plugin")
expect(calls).toEqual([{ subpaths: ["server", ""] }])
})
+55
View File
@@ -3,6 +3,8 @@ import { Message, SystemPart } from "@opencode-ai/ai"
import { DateTime, Effect, Schema } from "effect"
import { Agent } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { Model } from "@opencode-ai/core/model"
import { Location } from "@opencode-ai/core/location"
import { Plugin } from "@opencode-ai/core/plugin"
@@ -110,6 +112,59 @@ describe("fromPromise", () => {
}),
)
it.live("refreshes its own stored OAuth connection during plugin activation", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("acme")
const methodID = Integration.MethodID.make("browser")
const expired = Credential.OAuth.make({
type: "oauth",
methodID,
access: "expired",
refresh: "dummy",
expires: 0,
})
const fresh = Credential.OAuth.make({ ...expired, access: "fresh", expires: Number.MAX_SAFE_INTEGER })
const stored = yield* credentials.create({ integrationID, label: "Fixture", value: expired })
const resolved: string[] = []
const refreshed: string[] = []
const adapted = PluginPromise.fromPromise(
define({
id: "promise-oauth-refresh",
setup: async (ctx) => {
await ctx.integration.transform((editor) =>
editor.method.update({
integrationID,
method: { id: methodID, type: "oauth", label: "Browser" },
authorize: async () => {
throw new Error("unexpected authorization")
},
refresh: async (value) => {
refreshed.push(value.access)
return fresh
},
}),
)
const connection = await ctx.integration.connection.active(integrationID)
if (!connection) throw new Error("stored connection missing")
const value = await ctx.integration.connection.resolve(connection)
resolved.push(value?.type === "oauth" ? value.access : "missing")
},
}),
)
yield* plugins.activate([{ ...adapted, version: "1" }])
expect(resolved).toEqual(["fresh"])
expect(refreshed).toEqual(["expired"])
expect((yield* credentials.get(stored.id))?.value).toEqual(fresh)
expect(yield* plugins.list()).toEqual([
{ id: Plugin.ID.make("promise-oauth-refresh"), source: { type: "builtin" }, status: "active", tui: false },
])
}),
)
it.effect("exposes the host location including workspace and project metadata", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
+35 -13
View File
@@ -70,12 +70,20 @@ describe("AppProcess", () => {
"requireSuccess fails on non-zero exit",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const error = yield* svc
.run(cmd("-e", "process.exit(1)"))
.pipe(Effect.flatMap(AppProcess.requireSuccess), Effect.flip)
expect(error).toBeInstanceOf(AppProcess.AppProcessError)
expect(error.exitCode).toBe(1)
expect(error.message).toContain("Command failed (exit 1)")
const exit = yield* Effect.exit(
svc.run(cmd("-e", "process.exit(1)")).pipe(Effect.flatMap(AppProcess.requireSuccess)),
)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
const reason = exit.cause.reasons[0]
if (reason && reason._tag === "Fail") {
expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError)
expect((reason.error as AppProcess.AppProcessError).exitCode).toBe(1)
expect((reason.error as AppProcess.AppProcessError).message).toContain("Command failed (exit 1)")
} else {
throw new Error("expected fail reason")
}
}
}),
)
@@ -97,9 +105,15 @@ describe("AppProcess", () => {
expect(okZero.exitCode).toBe(0)
const okOne = yield* svc.run(cmd("-e", "process.exit(1)")).pipe(Effect.flatMap(requireZeroOrOne))
expect(okOne.exitCode).toBe(1)
const error = yield* svc.run(cmd("-e", "process.exit(2)")).pipe(Effect.flatMap(requireZeroOrOne), Effect.flip)
expect(error).toBeInstanceOf(AppProcess.AppProcessError)
expect(error.exitCode).toBe(2)
const exit = yield* Effect.exit(svc.run(cmd("-e", "process.exit(2)")).pipe(Effect.flatMap(requireZeroOrOne)))
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
const reason = exit.cause.reasons[0]
if (reason && reason._tag === "Fail") {
expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError)
expect((reason.error as AppProcess.AppProcessError).exitCode).toBe(2)
}
}
}),
)
@@ -299,10 +313,18 @@ describe("AppProcess", () => {
.runStream(cmd("-e", "console.log('only'); process.exit(1)"), { okExitCodes: [0, 1] })
.pipe(Stream.runCollect)
expect(Array.from(allowed)).toEqual(["only"])
const error = yield* svc
.runStream(cmd("-e", "console.log('a'); process.exit(2)"), { okExitCodes: [0, 1] })
.pipe(Stream.runCollect, Effect.flip)
expect(error).toBeInstanceOf(AppProcess.AppProcessError)
const exit = yield* Effect.exit(
svc
.runStream(cmd("-e", "console.log('a'); process.exit(2)"), { okExitCodes: [0, 1] })
.pipe(Stream.runCollect),
)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
const reason = exit.cause.reasons[0]
if (reason && reason._tag === "Fail") {
expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError)
}
}
}),
)
+133 -9
View File
@@ -1,23 +1,147 @@
import { describe, expect } from "bun:test"
import { Effect, Exit, Layer, Scope } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { State } from "@opencode-ai/core/state"
import { Global } from "@opencode-ai/util/global"
import { Reference } from "@opencode-ai/core/reference"
import { Repository } from "@opencode-ai/core/repository"
import { RepositoryCache } from "@opencode-ai/core/repository-cache"
import { it } from "./lib/effect"
import { it, testEffect } from "./lib/effect"
const cache = Layer.mock(RepositoryCache.Service, {
ensure: () => Effect.die("unexpected Git materialization"),
})
const referenceLayer = AppNodeBuilder.build(Reference.node, [[RepositoryCache.node, cache]])
const referenceLayer = AppNodeBuilder.build(LayerNode.group([Reference.node, Bus.node]), [
[RepositoryCache.node, cache],
])
const referenceIt = testEffect(referenceLayer)
describe("Reference", () => {
it.effect("registers normalized sources for the owning scope", () =>
it.effect("prepares batched references before cache work or update events", () => {
const operations: RepositoryCache.EnsureInput[] = []
const cache = Layer.mock(RepositoryCache.Service, {
ensure: (input) =>
Effect.sync(() => {
operations.push(input)
return {
repository: input.reference.label,
host: input.reference.host,
remote: input.reference.remote,
localPath: Repository.cachePath(Global.Path.repos, input.reference, input.branch),
status: "cached",
} satisfies RepositoryCache.Result
}),
})
const referenceLayer = AppNodeBuilder.build(LayerNode.group([Reference.node, Bus.node]), [
[RepositoryCache.node, cache],
])
return Effect.gen(function* () {
const references = yield* Reference.Service
const bus = yield* Bus.Service
const observed: string[][] = []
const unsubscribe = yield* bus.listen((event) =>
event.type === Reference.Event.Updated.type
? references.list().pipe(
Effect.map((infos) => {
observed.push(infos.map((info) => info.name))
}),
)
: Effect.void,
)
yield* Effect.addFinalizer(() => unsubscribe)
yield* State.batch(
Effect.gen(function* () {
yield* references.transform((draft) => {
draft.add("docs", Reference.LocalSource.make({ type: "local", path: AbsolutePath.make("/docs") }))
draft.add(
"sdk",
Reference.GitSource.make({
type: "git",
repository: "owner/repo",
branch: "feature/docs",
description: "SDK documentation",
hidden: true,
}),
)
draft.add("invalid", Reference.GitSource.make({ type: "git", repository: "invalid" }))
draft.add(
"invalid-branch",
Reference.GitSource.make({ type: "git", repository: "owner/repo", branch: "../escape" }),
)
draft.add("file", Reference.GitSource.make({ type: "git", repository: "file:///docs" }))
})
const infos = yield* references.list()
expect(infos.map((info) => info.name)).toEqual(["docs", "sdk"])
expect(infos[1]).toMatchObject({
path: Repository.cachePath(Global.Path.repos, Repository.parseRemote("owner/repo"), "feature/docs"),
description: "SDK documentation",
hidden: true,
})
expect(operations).toEqual([])
expect(observed).toEqual([])
}),
)
expect(observed).toEqual([["docs", "sdk"]])
yield* Effect.yieldNow
expect(
operations.map((input) => ({
repository: input.reference.label,
branch: input.branch,
refresh: input.refresh,
})),
).toEqual([{ repository: "owner/repo", branch: "feature/docs", refresh: true }])
}).pipe(Effect.scoped, Effect.provide(referenceLayer))
})
referenceIt.effect("lets update listeners replace references and refetch the latest projection", () =>
Effect.gen(function* () {
const references = yield* Reference.Service
const scope = yield* Scope.make()
const bus = yield* Bus.Service
const scope = yield* Scope.Scope
const observed: string[][] = []
let reentered = false
const first = yield* bus.listen((event) =>
Effect.gen(function* () {
if (event.type !== Reference.Event.Updated.type || reentered) return
reentered = true
yield* references
.transform((draft) =>
draft.add("docs", Reference.LocalSource.make({ type: "local", path: AbsolutePath.make("/new") })),
)
.pipe(Scope.provide(scope))
}),
)
const second = yield* bus.listen((event) =>
event.type === Reference.Event.Updated.type
? references.list().pipe(
Effect.map((infos) => {
observed.push(infos.map((info) => info.path))
}),
)
: Effect.void,
)
yield* Effect.addFinalizer(() => first.pipe(Effect.andThen(second)))
yield* references.transform((draft) =>
draft.add("docs", Reference.LocalSource.make({ type: "local", path: AbsolutePath.make("/old") })),
)
expect((yield* references.list()).map((info) => info.path)).toEqual([AbsolutePath.make("/new")])
expect(observed).toEqual([["/new"], ["/new"]])
}),
)
referenceIt.effect("registers normalized sources for the owning scope", () =>
Effect.gen(function* () {
const references = yield* Reference.Service
const parent = yield* Effect.scope
const scope = yield* Scope.fork(parent)
const path = AbsolutePath.make("/docs")
const source = Reference.LocalSource.make({
type: "local",
@@ -33,10 +157,10 @@ describe("Reference", () => {
yield* Scope.close(scope, Exit.void)
expect(yield* references.list()).toEqual([])
}).pipe(Effect.provide(referenceLayer)),
}),
)
it.effect("derives Git paths without exposing cache operations", () =>
referenceIt.effect("derives Git paths without exposing cache operations", () =>
Effect.gen(function* () {
const references = yield* Reference.Service
const repository = Repository.parseRemote("owner/repo")
@@ -50,10 +174,10 @@ describe("Reference", () => {
source,
}),
])
}).pipe(Effect.scoped, Effect.provide(referenceLayer)),
}),
)
it.effect("preserves configured Git descriptions", () =>
referenceIt.effect("preserves configured Git descriptions", () =>
Effect.gen(function* () {
const references = yield* Reference.Service
const repository = Repository.parseRemote("owner/repo")
@@ -72,6 +196,6 @@ describe("Reference", () => {
source,
}),
])
}).pipe(Effect.scoped, Effect.provide(referenceLayer)),
}),
)
})
@@ -20,8 +20,6 @@ import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { App } from "@opencode-ai/core/app"
import { Agent } from "@opencode-ai/core/agent"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -303,54 +301,6 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
}),
)
it.effect("manual compaction records model resolution failures without calling the model", () =>
Effect.gen(function* () {
requests = []
const compaction = yield* SessionCompaction.Service
const store = yield* SessionStore.Service
const sessionID = Session.ID.make("ses_manual_resolution_failure")
const session = yield* insertSession(sessionID)
const modelRequests = yield* SessionModelRequest.Service
const inputID = SessionMessage.ID.make("msg_manual_resolution_failure")
expect(
yield* compaction.compactManual({
session,
resolveModel: () =>
Effect.fail(
new SessionRunnerModel.ModelUnavailableError({
providerID: Provider.ID.make("test"),
modelID: Model.ID.make("missing"),
}),
),
prepare: modelRequests.prepare,
messages: [
{
id: SessionMessage.ID.create(),
type: "user",
text: "Summarize this conversation.",
time: { created: DateTime.makeUnsafe(0) },
},
],
inputID,
}),
).toEqual({
status: "failed",
error: { type: "provider.no-route", message: "Model unavailable: test/missing" },
})
expect(requests).toHaveLength(0)
expect(yield* store.context(sessionID)).toMatchObject([
{
id: inputID,
type: "compaction",
status: "failed",
reason: "manual",
error: { type: "provider.no-route", message: "Model unavailable: test/missing" },
},
])
}),
)
it.effect("forked session compaction reuses the fork root prompt cache key", () =>
Effect.gen(function* () {
requests = []
+1 -12
View File
@@ -67,17 +67,6 @@ const liveIt = testEffect(
],
),
)
const projectIt = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, Project.node, SessionProjector.node, SessionStore.node, Session.node]),
[
[Bus.node, Bus.configured({ persist: true })],
// Project adoption needs plain-prompt admission, not live plugin/provider startup.
[LocationServiceMap.node, promptLocationLayer],
[SessionExecution.node, SessionExecution.noopLayer],
],
),
)
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
const id = Session.ID.create()
@@ -130,7 +119,7 @@ describe("Session.create", () => {
),
)
projectIt.live("follows the directory's project identity established after creation", () =>
liveIt.live("follows the directory's project identity established after creation", () =>
withTmp((directory) =>
Effect.gen(function* () {
const session = yield* Session.Service
@@ -857,7 +857,8 @@ describe("SessionModelTransport", () => {
test("records metadata-only lifecycle metrics", async () => {
const fixture = automatic()
await Effect.runPromise(
await run(
fixture.connector,
Effect.gen(function* () {
const transport = yield* SessionModelTransport.Service
const executor = transport.bind(session)
@@ -873,11 +874,7 @@ describe("SessionModelTransport", () => {
)
expect(JSON.stringify(lifecycle)).not.toContain("secret-one")
expect(JSON.stringify(lifecycle)).not.toContain("secret-two")
}).pipe(
Effect.provide(SessionModelTransport.makeLayer(fixture.connector)),
Effect.scoped,
Effect.provideService(Metric.MetricRegistry, new Map()),
),
}),
)
})
})
+236 -42
View File
@@ -60,32 +60,25 @@ const assistantRow = (
return { id, session_id: sessionID, type, seq, time_created: DateTime.toEpochMillis(time.created), data }
}
const seedSession = (overrides?: Partial<typeof SessionTable.$inferInsert>) =>
Effect.gen(function* () {
const db = (yield* Database.Service).db
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
...overrides,
})
.run()
return db
})
describe("SessionProjector", () => {
it.effect("does not settle a pending manual compaction on an auto failure", () =>
Effect.gen(function* () {
const db = yield* seedSession()
const db = (yield* Database.Service).db
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
const bus = yield* Bus.Service
const inputID = SessionMessage.ID.make("msg_manual_compaction")
yield* SessionInbox.admitCompaction(db, bus, { id: inputID, sessionID, delivery: "queue" })
@@ -102,7 +95,22 @@ describe("SessionProjector", () => {
it.effect("loads legacy revert storage into canonical state", () =>
Effect.gen(function* () {
const db = yield* seedSession()
const db = (yield* Database.Service).db
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
const legacy = JSON.stringify({
messageID: "msg_boundary",
snapshot: "tree",
@@ -123,14 +131,28 @@ describe("SessionProjector", () => {
it.effect("projects staged, cleared, and committed reverts", () =>
Effect.gen(function* () {
const db = yield* seedSession({
cost: 1.25,
tokens_input: 10,
tokens_output: 4,
tokens_reasoning: 2,
tokens_cache_read: 3,
tokens_cache_write: 1,
})
const db = (yield* Database.Service).db
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
cost: 1.25,
tokens_input: 10,
tokens_output: 4,
tokens_reasoning: 2,
tokens_cache_read: 3,
tokens_cache_write: 1,
})
.run()
const boundary = SessionMessage.ID.make("msg_boundary")
const earlier = SessionMessage.ID.make("msg_earlier")
yield* db
@@ -205,7 +227,24 @@ describe("SessionProjector", () => {
it.effect("orders projected messages and context by durable aggregate sequence", () =>
Effect.gen(function* () {
yield* seedSession()
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
.pipe(Effect.orDie)
const bus = yield* Bus.Service
yield* bus.publish(SessionEvent.InboxEnqueued, {
@@ -261,7 +300,24 @@ describe("SessionProjector", () => {
it.effect("maps malformed persisted rows consistently while single-message lookup defects", () =>
Effect.gen(function* () {
const db = yield* seedSession()
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
.pipe(Effect.orDie)
const messageID = SessionMessage.ID.make("msg_malformed")
yield* db
.insert(SessionMessageTable)
@@ -288,7 +344,24 @@ describe("SessionProjector", () => {
it.effect("consumes the pending row and projects the message at promotion", () =>
Effect.gen(function* () {
const db = yield* seedSession()
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
.pipe(Effect.orDie)
const bus = yield* Bus.Service
const id = SessionMessage.ID.make("msg_admitted")
const admitted = yield* SessionInbox.admit(db, bus, {
@@ -314,7 +387,26 @@ describe("SessionProjector", () => {
it.effect("projects durable context messages supported by the updater", () =>
Effect.gen(function* () {
const db = yield* seedSession({ agent: "plan", model: previousModel })
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
agent: "plan",
model: previousModel,
})
.run()
.pipe(Effect.orDie)
const bus = yield* Bus.Service
yield* bus.publish(SessionEvent.AgentSelected, {
@@ -440,7 +532,24 @@ describe("SessionProjector", () => {
it.effect("rejects distinct creator events that reuse one projected message ID", () =>
Effect.gen(function* () {
const db = yield* seedSession()
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
.pipe(Effect.orDie)
const bus = yield* Bus.Service
const id = SessionMessage.ID.make("msg_creator_collision")
const { id: _, type, ...data } = encodeMessage({ id, type: "synthetic", text: "existing", time: { created } })
@@ -467,7 +576,24 @@ describe("SessionProjector", () => {
it.effect("projects retry state and clears it at the next step or execution terminal", () =>
Effect.gen(function* () {
const db = yield* seedSession()
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
.pipe(Effect.orDie)
const bus = yield* Bus.Service
const first = SessionMessage.ID.make("msg_retry_first")
const second = SessionMessage.ID.make("msg_retry_second")
@@ -517,7 +643,24 @@ describe("SessionProjector", () => {
it.effect("does not infer restart continuation from lifecycle history", () =>
Effect.gen(function* () {
const db = yield* seedSession()
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
.pipe(Effect.orDie)
const bus = yield* Bus.Service
const suspended = () =>
db
@@ -537,7 +680,24 @@ describe("SessionProjector", () => {
it.effect("updates only the newest incomplete assistant projection", () =>
Effect.gen(function* () {
const db = yield* seedSession()
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionMessageTable)
.values([
@@ -597,7 +757,24 @@ describe("SessionProjector", () => {
it.effect("projects ended and failed step terminal state", () =>
Effect.gen(function* () {
const db = yield* seedSession()
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
.pipe(Effect.orDie)
const endedID = SessionMessage.ID.make("msg_ended")
const failedID = SessionMessage.ID.make("msg_failed")
yield* db
@@ -667,7 +844,24 @@ describe("SessionProjector", () => {
it.effect("does not revive a stale incomplete assistant projection", () =>
Effect.gen(function* () {
const db = yield* seedSession()
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionMessageTable)
.values([
+4 -4
View File
@@ -78,7 +78,8 @@ const locations = Layer.effect(
Layer.mock(Snapshot.Service, {
capture: () =>
ready ? Effect.undefined : Effect.die(new Error("Snapshot used before plugins were ready")),
restore: () => (ready ? Effect.void : Effect.die(new Error("Snapshot used before plugins were ready"))),
restore: () =>
ready ? Effect.void : Effect.die(new Error("Snapshot used before plugins were ready")),
}),
Layer.succeed(
PluginSupervisor.Service,
@@ -171,9 +172,8 @@ const assistantRow = (id: SessionMessage.ID, seq: number) => {
describe("Session.prompt", () => {
it.effect("exposes the execution registry", () =>
Effect.gen(function* () {
const session = yield* Session.Service
activeSessions.add(sessionID)
expect(Array.from(yield* session.active)).toEqual([sessionID])
expect(Array.from(yield* (yield* Session.Service).active)).toEqual([sessionID])
}).pipe(Effect.ensuring(Effect.sync(() => activeSessions.clear()))),
)
@@ -557,7 +557,7 @@ describe("Session.prompt", () => {
yield* session.resume(sessionID)
expect(yield* session.messages({ sessionID })).toEqual([])
expect((yield* session.inbox(sessionID)).map((item) => item.id)).toEqual([message.id])
expect(yield* admitted(message.id)).not.toHaveProperty("promotedSeq")
expect(executionCalls).toEqual([sessionID])
expect(wakeCalls).toEqual([])
}),
+121 -24
View File
@@ -198,20 +198,18 @@ beforeEach(() => {
titleStream = successfulTitle
})
const enableTitleAgent = Effect.gen(function* () {
const agents = yield* Agent.Service
yield* agents.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
})
it.effect("generates a title from the sole user message and renames the session", () =>
Effect.gen(function* () {
yield* enableTitleAgent
requests = []
titleStream = successfulTitle
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
const sessionID = Session.ID.make("ses_title_generate")
yield* insertSession(sessionID)
yield* prompt(sessionID, "Help me debug the failing build")
@@ -242,8 +240,17 @@ it.effect("generates a title from the sole user message and renames the session"
it.effect("uses a small model from the primary provider", () =>
Effect.gen(function* () {
requests = []
titleStream = successfulTitle
selectedSmall = small
yield* enableTitleAgent
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
const sessionID = Session.ID.make("ses_title_small_model")
yield* insertSession(sessionID)
yield* prompt(sessionID, "Use a small model for this title")
@@ -260,12 +267,20 @@ it.effect("uses a small model from the primary provider", () =>
it.effect("falls back to the primary model when the small model fails", () =>
Effect.gen(function* () {
requests = []
titleStream = () =>
requests.length === 1
? Stream.make(LLMEvent.providerError({ message: "Small model unavailable" }))
: successfulTitle()
selectedSmall = lowSmall
yield* enableTitleAgent
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
const sessionID = Session.ID.make("ses_title_small_fallback")
yield* insertSession(
sessionID,
@@ -299,7 +314,16 @@ it.effect("falls back to the primary model when the small model fails", () =>
it.effect("generates from the first user message after later messages exist", () =>
Effect.gen(function* () {
yield* enableTitleAgent
requests = []
titleStream = successfulTitle
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
const sessionID = Session.ID.make("ses_title_second_message")
yield* insertSession(sessionID)
yield* prompt(sessionID, "First message")
@@ -318,7 +342,16 @@ it.effect("generates from the first user message after later messages exist", ()
it.effect("retries a legacy persisted fallback title", () =>
Effect.gen(function* () {
yield* enableTitleAgent
requests = []
titleStream = successfulTitle
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
const sessionID = Session.ID.make("ses_title_legacy")
const created = Date.parse("2026-07-30T18:45:03.662Z")
yield* insertSession(sessionID, "New session - 2026-07-30T18:45:03.662Z", created)
@@ -335,7 +368,16 @@ it.effect("retries a legacy persisted fallback title", () =>
it.effect("generates a title for an explicitly requested child session", () =>
Effect.gen(function* () {
yield* enableTitleAgent
requests = []
titleStream = successfulTitle
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
const sessionID = Session.ID.make("ses_title_child")
const { db } = yield* Database.Service
yield* db
@@ -369,6 +411,8 @@ it.effect("generates a title for an explicitly requested child session", () =>
it.effect("does not generate when the title agent is removed", () =>
Effect.gen(function* () {
requests = []
titleStream = successfulTitle
const sessionID = Session.ID.make("ses_title_no_agent")
yield* insertSession(sessionID)
yield* prompt(sessionID, "Help me debug the failing build")
@@ -385,7 +429,14 @@ it.effect("does not generate when the title agent is removed", () =>
it.effect("regenerates an existing title using the title agent", () =>
Effect.gen(function* () {
yield* enableTitleAgent
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
const sessionID = Session.ID.make("ses_title_regenerate")
yield* insertSession(sessionID, "Original title")
yield* prompt(sessionID, "Investigate the login failure")
@@ -429,7 +480,14 @@ it.effect("regenerates an existing title using the title agent", () =>
it.effect("bounds regeneration context while preserving the original request and recent conversation", () =>
Effect.gen(function* () {
yield* enableTitleAgent
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
const sessionID = Session.ID.make("ses_title_regenerate_bounded")
yield* insertSession(sessionID, "Original title")
yield* prompt(sessionID, `ORIGINAL_GOAL ${"a".repeat(3_000)} OMITTED_ORIGINAL_END`)
@@ -451,7 +509,14 @@ it.effect("bounds regeneration context while preserving the original request and
it.effect("preserves the existing title when regeneration fails", () =>
Effect.gen(function* () {
yield* enableTitleAgent
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
const sessionID = Session.ID.make("ses_title_regenerate_failure")
yield* insertSession(sessionID, "Original title")
yield* prompt(sessionID, "Fail to regenerate this title")
@@ -468,7 +533,15 @@ it.effect("preserves the existing title when regeneration fails", () =>
it.effect("retries after a failed title request", () =>
Effect.gen(function* () {
yield* enableTitleAgent
requests = []
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
const sessionID = Session.ID.make("ses_title_retry")
yield* insertSession(sessionID)
yield* prompt(sessionID, "Retry this title")
@@ -487,7 +560,14 @@ it.effect("retries after a failed title request", () =>
it.effect("does not rename after a failed title stream", () =>
Effect.gen(function* () {
yield* enableTitleAgent
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
const sessionID = Session.ID.make("ses_title_stream_failure")
yield* insertSession(sessionID)
yield* prompt(sessionID, "Fail this title stream")
@@ -509,7 +589,16 @@ it.effect("does not rename after a failed title stream", () =>
it.effect("keeps session context hooks away from title requests", () =>
Effect.gen(function* () {
yield* enableTitleAgent
requests = []
titleStream = successfulTitle
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
// Context hooks shape the agent conversation; title generation is not part of
// it, so it opts out and the transcript passes through unchanged.
const hooks = yield* PluginHooks.Service
@@ -532,7 +621,15 @@ it.effect("keeps session context hooks away from title requests", () =>
it.effect("preserves a manual rename completed while generation is in flight", () =>
Effect.gen(function* () {
yield* enableTitleAgent
requests = []
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
const sessionID = Session.ID.make("ses_title_manual_rename")
yield* insertSession(sessionID)
yield* prompt(sessionID, "Generate this title")
+476 -84
View File
@@ -1,10 +1,18 @@
import { describe, expect } from "bun:test"
import { State } from "@opencode-ai/core/state"
import { Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect"
import { Cause, Deferred, Effect, Exit, Fiber, Scheduler, Scope } from "effect"
import { TestClock } from "effect/testing"
import { testEffect } from "./lib/effect"
import { it } from "./lib/effect"
const it = testEffect(Layer.empty)
function valuesState(
hooks: Pick<State.Options<{ values: string[] }, { add: (item: string) => void }>, "prepare" | "notify"> = {},
) {
return State.create({
initial: () => ({ values: new Array<string>() }),
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
...hooks,
})
}
describe("State", () => {
it.effect("commits a transform atomically when its updater is interrupted", () =>
@@ -12,17 +20,13 @@ describe("State", () => {
const rebuilding = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
let block = true
const state = State.create({
initial: () => ({ values: [] as string[] }),
draft: (draft) => ({ add: (value: string) => draft.values.push(value) }),
finalize: () =>
const state = valuesState({
notify: () =>
block ? Deferred.succeed(rebuilding, undefined).pipe(Effect.andThen(Deferred.await(release))) : Effect.void,
})
const scope = yield* Scope.make()
const fiber = yield* state
.transform((editor) => {
editor.add("registered")
})
.transform((editor) => editor.add("registered"))
.pipe(Scope.provide(scope), Effect.forkChild)
yield* Deferred.await(rebuilding)
const interruption = yield* Fiber.interrupt(fiber).pipe(Effect.forkChild)
@@ -36,20 +40,16 @@ describe("State", () => {
}),
)
it.effect("commits rebuilt state before finalize runs", () =>
it.effect("makes rebuilt state visible before notifying", () =>
Effect.gen(function* () {
const observed: string[][] = []
const state: State.Interface<{ values: string[] }, { add: (item: string) => void }> = State.create({
initial: () => ({ values: [] as string[] }),
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
finalize: () => Effect.sync(() => observed.push([...state.get().values])),
const state: ReturnType<typeof valuesState> = valuesState({
notify: () => Effect.sync(() => observed.push([...state.get().values])),
})
yield* state.transform((draft) => {
draft.add("value")
})
yield* state.transform((draft) => draft.add("value"))
// Update events publish from finalize, so consumers reading on the event
// Update events publish from notify, so consumers reading on the event
// must observe the rebuilt state, not the previous one.
expect(observed).toEqual([["value"]])
}),
@@ -58,14 +58,9 @@ describe("State", () => {
it.effect("runs transforms during every reload", () =>
Effect.gen(function* () {
let value = "first"
const state = State.create({
initial: () => ({ values: [] as string[] }),
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
})
const state = valuesState()
yield* state.transform((editor) => {
editor.add(value)
})
yield* state.transform((editor) => editor.add(value))
expect(state.get().values).toEqual(["first"])
value = "second"
@@ -76,18 +71,327 @@ describe("State", () => {
}),
)
it.effect("reads registrations and disposals inside a batch without publishing", () =>
Effect.gen(function* () {
const observed: string[][] = []
let replays = 0
const state: ReturnType<typeof valuesState> = valuesState({
notify: () => Effect.sync(() => observed.push([...state.get().values])),
})
const scope = yield* Scope.make()
yield* State.batch(
Effect.gen(function* () {
yield* state
.transform((draft) => {
replays++
draft.add("value")
})
.pipe(Scope.provide(scope))
const snapshot = state.get()
expect(snapshot.values).toEqual(["value"])
expect(state.get()).toBe(snapshot)
expect(replays).toBe(1)
expect(observed).toEqual([])
yield* Scope.close(scope, Exit.void)
expect(state.get().values).toEqual([])
expect(snapshot.values).toEqual(["value"])
expect(observed).toEqual([])
}),
)
expect(observed).toEqual([[]])
}),
)
it.effect("reads a requested reload without waiting for its notification debounce", () =>
Effect.gen(function* () {
let value = "first"
let replays = 0
const observed: string[][] = []
const state: ReturnType<typeof valuesState> = valuesState({
notify: () => Effect.sync(() => observed.push([...state.get().values])),
})
yield* state.transform((draft) => {
replays++
draft.add(value)
})
const snapshot = state.get()
observed.length = 0
value = "second"
const reload = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("50 millis")
expect(state.get().values).toEqual(["second"])
expect(snapshot.values).toEqual(["first"])
expect(replays).toBe(2)
expect(observed).toEqual([])
yield* TestClock.adjust("450 millis")
yield* Fiber.join(reload)
expect(observed).toEqual([["second"]])
expect(replays).toBe(2)
}),
)
it.effect("can await reload inside a batch while deferring its notification", () =>
Effect.gen(function* () {
let value = "first"
let notifications = 0
const state = valuesState({ notify: () => Effect.sync(() => notifications++) })
yield* State.batch(
Effect.gen(function* () {
yield* state.transform((draft) => draft.add(value))
expect(state.get().values).toEqual(["first"])
value = "second"
yield* state.reload()
expect(state.get().values).toEqual(["second"])
expect(notifications).toBe(0)
}),
)
expect(notifications).toBe(1)
}),
)
it.effect("prepares derived data during reads without running observers", () =>
Effect.gen(function* () {
let notifications = 0
const state = State.create({
initial: () => ({ values: [] as string[], joined: "" }),
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
prepare: (data) => {
data.joined = data.values.join(",")
},
notify: () => Effect.sync(() => notifications++),
})
yield* State.batch(
Effect.gen(function* () {
yield* state.transform((draft) => draft.add("first"))
yield* state.transform((draft) => draft.add("second"))
expect(state.get().joined).toBe("first,second")
expect(notifications).toBe(0)
}),
)
expect(notifications).toBe(1)
}),
)
it.effect("keeps replay failures observable without replacing the previous snapshot", () =>
Effect.gen(function* () {
let fail = false
const state = valuesState({
prepare: () => {
if (fail) throw new Error("preparation failed")
},
})
yield* state.transform((draft) => draft.add("first"))
const snapshot = state.get()
yield* State.batch(
Effect.gen(function* () {
yield* state.transform((draft) => draft.add("second"))
fail = true
expect(() => state.get()).toThrow("preparation failed")
expect(() => state.get()).toThrow("preparation failed")
expect(snapshot.values).toEqual(["first"])
fail = false
expect(state.get().values).toEqual(["first", "second"])
}),
)
}),
)
it.effect("allows an observer to await a registration on the same state", () =>
Effect.gen(function* () {
const scope = yield* Scope.Scope
let added = false
const observed: string[][] = []
const state: ReturnType<typeof valuesState> = valuesState({
notify: () =>
Effect.gen(function* () {
observed.push([...state.get().values])
if (added) return
added = true
yield* state.transform((draft) => draft.add("second")).pipe(Scope.provide(scope))
}),
})
yield* state.transform((draft) => draft.add("first"))
expect(observed).toEqual([["first"], ["first", "second"]])
expect(state.get().values).toEqual(["first", "second"])
}),
)
it.effect("allows a debounced observer to await another reload", () =>
Effect.gen(function* () {
let value = "first"
let reloadAgain = false
const observed: string[][] = []
const state: ReturnType<typeof valuesState> = valuesState({
notify: () =>
Effect.gen(function* () {
observed.push([...state.get().values])
if (!reloadAgain) return
reloadAgain = false
value = "third"
yield* state.reload()
}),
})
yield* state.transform((draft) => draft.add(value))
observed.length = 0
value = "second"
reloadAgain = true
const reload = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("1 second")
yield* Fiber.join(reload)
expect(observed).toEqual([["second"], ["third"]])
expect(state.get().values).toEqual(["third"])
}),
)
it.effect("keeps reload waiters associated with their own notification results", () =>
Effect.gen(function* () {
const entered = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
let value = "first"
let block = false
const observed: string[][] = []
const state: ReturnType<typeof valuesState> = valuesState({
notify: () =>
Effect.gen(function* () {
observed.push([...state.get().values])
if (!block) return
block = false
yield* Deferred.succeed(entered, undefined)
yield* Deferred.await(release)
return yield* Effect.die(new Error("first notification failed"))
}),
})
yield* state.transform((draft) => draft.add(value))
// Release the detached worker before the earlier registration finalizer runs.
yield* Effect.addFinalizer(() => Deferred.succeed(release, undefined))
observed.length = 0
value = "second"
block = true
const first = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Deferred.await(entered)
value = "third"
const second = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
expect(state.get().values).toEqual(["third"])
yield* TestClock.adjust("500 millis")
yield* Fiber.join(second)
expect(first.pollUnsafe()).toBeUndefined()
expect(observed).toEqual([["second"], ["third"]])
yield* Deferred.succeed(release, undefined)
const exit = yield* Fiber.await(first)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("first notification failed")
}),
)
it.effect("keeps coalesced reload callers independent of cancellation and shares their notification failure", () =>
Effect.gen(function* () {
let fail = false
let notifications = 0
const failure = new Error("notification failed")
const state = State.create({
initial: () => ({}),
draft: (draft) => draft,
notify: () =>
Effect.sync(() => {
notifications++
if (fail) throw failure
}),
})
yield* state.transform(() => {})
notifications = 0
fail = true
const cancelled = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
const first = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
const second = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* Fiber.interrupt(cancelled)
yield* TestClock.adjust("500 millis")
const exits = yield* Fiber.awaitAll([first, second])
fail = false
expect(Exit.hasInterrupts(yield* Fiber.await(cancelled))).toBe(true)
expect(exits.map((exit) => Exit.isFailure(exit) && Cause.squash(exit.cause))).toEqual([failure, failure])
expect(notifications).toBe(1)
const recovered = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Fiber.join(recovered)
expect(notifications).toBe(2)
}),
)
it.effect("continues publishing when a reload caller is cancelled while scheduling its worker", () =>
Effect.gen(function* () {
let value = "first"
let notifications = 0
let interrupted = false
const state = valuesState({ notify: () => Effect.sync(() => notifications++) })
yield* state.transform((draft) => draft.add(value))
notifications = 0
value = "second"
const cancelled = yield* Effect.withFiber((fiber) => {
const base = new Scheduler.MixedScheduler("sync")
const scheduler: Scheduler.Scheduler = {
executionMode: base.executionMode,
// Keep the first scheduled task at the detached worker handoff.
shouldYield: () => false,
makeDispatcher: () => {
const dispatcher = base.makeDispatcher()
return {
scheduleTask: (task, priority) => {
if (!interrupted) {
interrupted = true
fiber.interruptUnsafe()
}
dispatcher.scheduleTask(task, priority)
},
flush: () => dispatcher.flush(),
}
},
}
return state.reload().pipe(Effect.provideService(Scheduler.Scheduler, scheduler))
}).pipe(Effect.forkChild({ startImmediately: true }))
const exit = yield* Fiber.await(cancelled)
expect(interrupted).toBe(true)
expect(Exit.hasInterrupts(exit)).toBe(true)
expect(state.get().values).toEqual(["second"])
yield* TestClock.adjust("500 millis")
expect(notifications).toBe(1)
value = "third"
const reload = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Fiber.join(reload)
expect(state.get().values).toEqual(["third"])
expect(notifications).toBe(2)
}),
)
it.effect("disposes a transform once and rebuilds remaining state", () =>
Effect.gen(function* () {
const state = State.create({
initial: () => ({ values: [] as string[] }),
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
})
yield* state.transform((editor) => {
editor.add("first")
})
const registration = yield* state.transform((editor) => {
editor.add("second")
})
const state = valuesState()
yield* state.transform((editor) => editor.add("first"))
const registration = yield* state.transform((editor) => editor.add("second"))
expect(state.get().values).toEqual(["first", "second"])
yield* registration.dispose
@@ -98,49 +402,137 @@ describe("State", () => {
}),
)
it.effect("batches automatic rebuilds", () =>
it.effect("batches notifications", () =>
Effect.gen(function* () {
let finalized = 0
const first = State.create({
initial: () => ({ values: [] as string[] }),
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
finalize: () => Effect.sync(() => finalized++),
})
const second = State.create({
initial: () => ({ values: [] as string[] }),
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
finalize: () => Effect.sync(() => finalized++),
})
let notifications = 0
const first = valuesState({ notify: () => Effect.sync(() => notifications++) })
const second = valuesState({ notify: () => Effect.sync(() => notifications++) })
yield* State.batch(
Effect.gen(function* () {
yield* first.transform((draft) => {
draft.add("first")
})
yield* first.transform((draft) => {
draft.add("second")
})
yield* second.transform((draft) => {
draft.add("third")
})
expect(finalized).toBe(0)
yield* first.transform((draft) => draft.add("first"))
yield* first.transform((draft) => draft.add("second"))
yield* second.transform((draft) => draft.add("third"))
expect(notifications).toBe(0)
}),
)
expect(first.get().values).toEqual(["first", "second"])
expect(second.get().values).toEqual(["third"])
expect(finalized).toBe(2)
expect(notifications).toBe(2)
}),
)
it.effect("closes a batched observer's owning scope without losing the body's failure", () =>
Effect.gen(function* () {
const entered = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const registrations = yield* Scope.make()
const owner = yield* Scope.make()
yield* Effect.addFinalizer(() =>
Deferred.succeed(release, undefined).pipe(
Effect.andThen(Scope.close(owner, Exit.void)),
Effect.andThen(State.batch(Scope.close(registrations, Exit.void), { flush: false })),
),
)
const state = State.create({
initial: () => ({}),
draft: (draft) => draft,
notify: () => Deferred.succeed(entered, undefined).pipe(Effect.andThen(Deferred.await(release))),
})
const writer = yield* State.batch(
state.transform(() => {}).pipe(Scope.provide(registrations), Effect.andThen(Effect.fail("batch body failed"))),
).pipe(Effect.forkIn(owner, { startImmediately: true }))
yield* Deferred.await(entered)
const shutdown = yield* Scope.close(owner, Exit.void).pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("1 millis")
expect(shutdown.pollUnsafe()).toBeDefined()
expect(yield* Deferred.isDone(release)).toBe(false)
const exit = yield* Fiber.await(writer)
expect(Exit.hasInterrupts(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("batch body failed")
}),
)
it.effect("lets batch observers read the other states' accepted changes", () =>
Effect.gen(function* () {
const observed: string[][] = []
const first = valuesState({
notify: () => Effect.sync(() => observed.push([...second.get().values])),
})
const second = valuesState()
yield* State.batch(
Effect.gen(function* () {
yield* first.transform((draft) => draft.add("first"))
yield* second.transform((draft) => draft.add("second"))
}),
)
expect(observed).toEqual([["second"]])
}),
)
;["replay", "notification"].forEach((failure) =>
it.effect(`notifies the other states when a batch ${failure} fails`, () =>
Effect.gen(function* () {
let fail = true
const observed: string[] = []
const first = State.create({
initial: () => ({}),
draft: (draft) => draft,
notify: () => Effect.sync(() => observed.push("first")),
})
const failing = State.create({
initial: () => ({}),
draft: (draft) => draft,
prepare: () => {
if (fail && failure === "replay") throw new Error("replay failed")
},
notify: () =>
fail ? Effect.die(new Error("notification failed")) : Effect.sync(() => observed.push("failing")),
})
const last = State.create({
initial: () => ({}),
draft: (draft) => draft,
notify: () => Effect.sync(() => observed.push("last")),
})
const exit = yield* State.batch(
Effect.gen(function* () {
yield* first.transform(() => {})
yield* failing.transform(() => {})
yield* last.transform(() => {})
return yield* Effect.die(new Error("batch failed"))
}),
).pipe(Effect.exit)
fail = false
expect(observed).toEqual(["first", "last"])
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
expect(Cause.pretty(exit.cause)).toContain("batch failed")
expect(Cause.pretty(exit.cause)).toContain(`${failure} failed`)
}
const reload = yield* failing.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Fiber.join(reload)
expect(observed).toEqual(["first", "last", "failing"])
}),
),
)
it.effect("discards teardown rebuilds and pending reloads while still running cleanup", () =>
Effect.gen(function* () {
let finalized = 0
let notifications = 0
let prepared = 0
let disposed = 0
const state = State.create({
initial: () => ({ values: [] as string[] }),
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
finalize: () => Effect.sync(() => finalized++),
const state = valuesState({
prepare: () => {
prepared++
},
notify: () => Effect.sync(() => notifications++),
})
const scope = yield* Scope.make()
yield* Scope.addFinalizer(
@@ -148,38 +540,44 @@ describe("State", () => {
Effect.sync(() => disposed++),
)
const registration = yield* state.transform((draft) => draft.add("value")).pipe(Scope.provide(scope))
expect(finalized).toBe(1)
const snapshot = state.get()
expect(notifications).toBe(1)
expect(prepared).toBe(1)
const pending = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("250 millis")
yield* State.batch(Scope.close(scope, Exit.void), { flush: false })
expect(disposed).toBe(1)
expect(finalized).toBe(1)
expect(notifications).toBe(1)
expect(state.get()).toBe(snapshot)
expect(prepared).toBe(1)
yield* TestClock.adjust("500 millis")
yield* Fiber.join(pending)
yield* registration.dispose
yield* state.reload()
expect(finalized).toBe(1)
expect(notifications).toBe(1)
expect(state.get()).toBe(snapshot)
expect(prepared).toBe(1)
}),
)
it.effect("keeps teardown suppression separate from an enclosing live batch", () =>
Effect.gen(function* () {
const finalized: string[] = []
const notifications: string[] = []
const closing = State.create({
initial: () => ({}),
draft: (draft) => draft,
finalize: () => Effect.sync(() => finalized.push("closing")),
notify: () => Effect.sync(() => notifications.push("closing")),
})
const live = State.create({
initial: () => ({}),
draft: (draft) => draft,
finalize: () => Effect.sync(() => finalized.push("live")),
notify: () => Effect.sync(() => notifications.push("live")),
})
const scope = yield* Scope.make()
yield* closing.transform(() => {}).pipe(Scope.provide(scope))
finalized.length = 0
notifications.length = 0
yield* State.batch(
Effect.gen(function* () {
@@ -187,33 +585,27 @@ describe("State", () => {
yield* State.batch(Scope.close(scope, Exit.void), { flush: false })
}),
)
expect(finalized).toEqual(["live"])
expect(notifications).toEqual(["live"])
}),
)
it.effect("debounces reload bursts", () =>
Effect.gen(function* () {
let finalized = 0
const state = State.create({
initial: () => ({ values: [] as string[] }),
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
finalize: () => Effect.sync(() => finalized++),
})
yield* state.transform((draft) => {
draft.add("value")
})
finalized = 0
let notifications = 0
const state = valuesState({ notify: () => Effect.sync(() => notifications++) })
yield* state.transform((draft) => draft.add("value"))
notifications = 0
const first = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("250 millis")
const second = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("499 millis")
expect(finalized).toBe(0)
expect(notifications).toBe(0)
yield* TestClock.adjust("1 millis")
yield* Fiber.join(first)
yield* Fiber.join(second)
expect(finalized).toBe(1)
expect(notifications).toBe(1)
}),
)
})
+4 -2
View File
@@ -105,9 +105,11 @@ test("foreign typed failures settle as Tool.Error at the untrusted boundary", as
execute: () => new ForeignFailure({ message: "transport died" }) as never,
}
const error = await Effect.runPromise(execute(lying, {}, context).pipe(Effect.flip))
const exit = await Effect.runPromiseExit(execute(lying, {}, context))
expect(exit._tag).toBe("Failure")
const error = exit._tag === "Failure" ? exit.cause.reasons.find((reason) => "error" in reason)?.error : undefined
expect(error).toBeInstanceOf(Tool.Error)
expect(error.message).toBe("transport died")
expect((error as Tool.Error).message).toBe("transport died")
})
test("execute supports callable namespace tools", async () => {
+28 -21
View File
@@ -311,7 +311,7 @@ describe("Tool", () => {
}),
)
it.effect("replays empty sources on reload and keeps advertised snapshots", () =>
it.effect("reads refreshed sources before notifications and keeps advertised snapshots", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
let source: Info[] = []
@@ -321,24 +321,24 @@ describe("Tool", () => {
const tool = { ...constant("first"), name: "echo", options: { codemode: false } }
source = [tool]
const first = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Fiber.join(first)
const advertised = yield* service.snapshot()
expect((yield* advertised.execute(call("echo"))).output).toEqual({ text: "first" })
yield* TestClock.adjust("500 millis")
yield* Fiber.join(first)
tool.execute = constant("second").execute
expect((yield* advertised.execute(call("echo"))).output).toEqual({ text: "first" })
const second = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
expect((yield* executeTool(service, call("echo"))).output).toEqual({ text: "second" })
yield* TestClock.adjust("500 millis")
yield* Fiber.join(second)
expect((yield* executeTool(service, call("echo"))).output).toEqual({ text: "second" })
expect((yield* advertised.execute(call("echo"))).output).toEqual({ text: "first" })
source = []
const removed = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
yield* TestClock.adjust("500 millis")
yield* Fiber.join(removed)
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
expect((yield* advertised.execute(call("echo"))).output).toEqual({ text: "first" })
}),
)
@@ -370,7 +370,7 @@ describe("Tool", () => {
}),
)
it.effect("batches tool publication and suppresses terminal teardown replay", () =>
it.effect("batches tool notifications with fresh snapshots and suppresses terminal teardown replay", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
const runs: string[] = []
@@ -386,7 +386,8 @@ describe("Tool", () => {
draft.add({ ...constant("overlay"), name: "echo", options: { codemode: false } })
})
expect(runs).toEqual([])
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["echo", "execute"])
expect(runs).toEqual(["base", "overlay"])
}).pipe(Scope.provide(scope)),
)
@@ -545,23 +546,32 @@ describe("Tool", () => {
}),
)
it.effect("logs invalid tool definitions without dropping healthy tools", () => {
it.effect("compiles healthy tools before notifying invalid definition diagnostics", () => {
const output: unknown[] = []
const logger = Logger.map(Logger.formatStructured, (entry) => {
output.push(entry.message)
})
return Effect.gen(function* () {
const service = yield* Tool.Service
yield* service.transform((draft) => {
draft.add({ ...make(), name: "healthy", options: { codemode: false } })
draft.add({
name: "phone_type",
input: Schema.Struct({}),
execute: () => Effect.succeed({ content: "ok" }),
options: { codemode: false },
} as unknown as Info)
draft.add({ ...make(), name: "codemode" })
})
const snapshot = yield* State.batch(
Effect.gen(function* () {
yield* service.transform((draft) => {
draft.add({ ...make(), name: "healthy", options: { codemode: false } })
draft.add({
name: "phone_type",
input: Schema.Struct({}),
execute: () => Effect.succeed({ content: "ok" }),
options: { codemode: false },
} as unknown as Info)
draft.add({ ...make(), name: "codemode" })
})
const snapshot = yield* service.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["healthy", "execute"])
expect(snapshot.codeModeCatalog?.map((tool) => tool.path)).toEqual(["codemode"])
expect(output).toEqual([])
return snapshot
}),
)
expect(output).toEqual([
[
@@ -573,9 +583,6 @@ describe("Tool", () => {
},
],
])
const snapshot = yield* service.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["healthy", "execute"])
expect(snapshot.codeModeCatalog?.map((tool) => tool.path)).toEqual(["codemode"])
expect((yield* snapshot.execute(call("phone_type")).pipe(Effect.flip)).message).toBe("Unknown tool: phone_type")
}).pipe(Effect.provide(Logger.layer([logger])))
})
+371 -90
View File
@@ -2,35 +2,40 @@ import { $ } from "bun"
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Cause, Effect, Exit, Fiber, Layer, Stream } from "effect"
import { Cause, Context, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, Scope, Stream } from "effect"
import { TestClock } from "effect/testing"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { AppProcess } from "@opencode-ai/util/process"
import { Bus } from "@opencode-ai/core/bus"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { State } from "@opencode-ai/core/state"
import { Vcs } from "@opencode-ai/core/vcs"
import { VcsGitPlugin } from "@opencode-ai/core/plugin/vcs/git"
import type { VcsDefinition, VcsDiffInput } from "@opencode-ai/plugin/effect/vcs"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { VcsEvent } from "@opencode-ai/schema/vcs-event"
import { location } from "./fixture/location"
import { locationLayer } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { it } from "./lib/effect"
import { it, testEffect } from "./lib/effect"
import { host } from "./plugin/host"
const Done = Bus.ephemeral({ type: "test.vcs.done", schema: {} })
const synthetic = testEffect(
LayerNode.compile(LayerNode.group([Vcs.node, Bus.node, Location.node]), [
[Location.node, locationLayer({ directory: AbsolutePath.make(import.meta.dir) })],
]),
)
const provide = (directory: string, input: { git?: boolean } = {}) =>
Effect.provide(
LayerNode.compile(LayerNode.group([Vcs.node, Bus.node, Location.node, AppProcess.node]), [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: AbsolutePath.make(directory) },
input.git ? { vcs: { type: "git", store: AbsolutePath.make(path.join(directory, ".git")) } } : {},
),
),
locationLayer(
{ directory: AbsolutePath.make(directory) },
input.git ? { vcs: { type: "git", store: AbsolutePath.make(path.join(directory, ".git")) } } : {},
),
],
]),
@@ -84,40 +89,36 @@ const provider = (input: Partial<VcsDefinition> = {}) =>
}) satisfies VcsDefinition
describe("Vcs", () => {
it.live("returns empty results outside version control", () =>
withTmp((directory) =>
Effect.gen(function* () {
const vcs = yield* Vcs.Service
expect(yield* vcs.info()).toEqual({ branch: {} })
expect(yield* vcs.branches()).toEqual([])
expect(yield* vcs.status()).toEqual([])
expect(yield* vcs.diff("working")).toEqual([])
expect(yield* vcs.diff("branch")).toEqual([])
}).pipe(provide(directory)),
),
synthetic.effect("returns empty results outside version control", () =>
Effect.gen(function* () {
const vcs = yield* Vcs.Service
expect(yield* vcs.info()).toEqual({ branch: {} })
expect(yield* vcs.branches()).toEqual([])
expect(yield* vcs.status()).toEqual([])
expect(yield* vcs.diff("working")).toEqual([])
expect(yield* vcs.diff("branch")).toEqual([])
}),
)
it.live("serves scoped providers and restores the fallback after disposal", () =>
withTmp((directory) =>
Effect.gen(function* () {
const vcs = yield* Vcs.Service
const registration = yield* vcs.transform((draft) => {
draft.add(provider())
draft.default.set("custom")
})
synthetic.effect("serves scoped providers and restores the fallback after disposal", () =>
Effect.gen(function* () {
const vcs = yield* Vcs.Service
const registration = yield* vcs.transform((draft) => {
draft.add(provider())
draft.default.set("custom")
})
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "main" } })
expect(yield* vcs.branches()).toEqual(["feature", "main"])
expect(yield* vcs.status()).toEqual([{ file: "file.txt", additions: 1, deletions: 0, status: "added" }])
expect(yield* vcs.diff("working")).toEqual([
{ file: "file.txt", patch: "+hello", additions: 1, deletions: 0, status: "added" },
])
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "main" } })
expect(yield* vcs.branches()).toEqual(["feature", "main"])
expect(yield* vcs.status()).toEqual([{ file: "file.txt", additions: 1, deletions: 0, status: "added" }])
expect(yield* vcs.diff("working")).toEqual([
{ file: "file.txt", patch: "+hello", additions: 1, deletions: 0, status: "added" },
])
yield* registration.dispose
expect(yield* vcs.info()).toEqual({ branch: {} })
expect(yield* vcs.status()).toEqual([])
}).pipe(provide(directory)),
),
yield* registration.dispose
expect(yield* vcs.info()).toEqual({ branch: {} })
expect(yield* vcs.status()).toEqual([])
}),
)
it.live("automatically selects a provider matching the resolved repository", () =>
@@ -133,80 +134,360 @@ describe("Vcs", () => {
),
)
it.live("passes location scope and bounded diff options to providers", () =>
withTmp((directory) =>
synthetic.effect("passes location scope and bounded diff options to providers", () =>
Effect.gen(function* () {
const observed: VcsDiffInput[] = []
const vcs = yield* Vcs.Service
const location = yield* Location.Service
yield* vcs.transform((draft) => {
draft.add(
provider({
diff: (input) =>
Effect.sync(() => {
observed.push(input)
return [{ file: "file.txt", patch: "+hello", additions: 1, deletions: 0, status: "added" }]
}),
}),
)
draft.default.set("custom")
})
yield* vcs.diff("branch", { context: 3 })
expect(observed).toEqual([
{
directory: location.directory,
worktree: location.directory,
canonical: location.directory,
mode: "branch",
context: 3,
maxOutputBytes: 10_000_000,
},
])
}),
)
synthetic.effect("validates provider results and bounds oversized patches", () =>
Effect.gen(function* () {
const vcs = yield* Vcs.Service
yield* vcs.transform((draft) => {
draft.add(
provider({
status: () => Effect.succeed([{ file: "file.txt", additions: -1, deletions: 0, status: "added" }]),
diff: () =>
Effect.succeed([
{ file: "file.txt", patch: "x".repeat(10_000_001), additions: 1, deletions: 0, status: "added" },
]),
}),
)
draft.default.set("custom")
})
expect(yield* vcs.status()).toEqual([])
const rows = yield* vcs.diff("working")
expect(rows).toHaveLength(1)
expect(Buffer.byteLength(rows[0].patch)).toBeLessThan(1000)
expect(rows[0].additions).toBe(1)
}),
)
synthetic.effect("preserves provider interruption", () =>
Effect.gen(function* () {
const vcs = yield* Vcs.Service
let interrupt = false
yield* vcs.transform((draft) => {
draft.add(
provider({
info: () =>
interrupt ? Effect.interrupt : Effect.succeed({ branch: { current: "feature", default: "main" } }),
status: () => Effect.never,
}),
)
draft.default.set("custom")
})
const fiber = yield* Effect.forkChild(vcs.status())
yield* Fiber.interrupt(fiber)
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause)).toBeTrue()
interrupt = true
const reload = yield* vcs.reload().pipe(Effect.timeout("1 second"), Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("1 second")
const reloaded = yield* Fiber.await(reload)
expect(Exit.isFailure(reloaded) && Cause.hasInterruptsOnly(reloaded.cause)).toBeTrue()
}),
)
it.live("keeps watching HEAD changes after a transform replay failure", () =>
withGit((directory) =>
Effect.gen(function* () {
const observed: VcsDiffInput[] = []
const vcs = yield* Vcs.Service
yield* vcs.transform((draft) => {
const bus = yield* Bus.Service
const replayed = yield* Deferred.make<void>()
const faulty = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(faulty, Exit.void))
let branch = "initial"
yield* vcs.transform((draft) =>
draft.add(provider({ id: "git", info: () => Effect.sync(() => ({ branch: { current: branch } })) })),
)
const failure = new Error("fixture replay failed")
let replays = 0
const failed = yield* vcs
.transform(() => {
if (++replays === 2) Deferred.doneUnsafe(replayed, Exit.void)
throw failure
})
.pipe(Scope.provide(faulty), Effect.exit)
expect(Exit.isFailure(failed) && Cause.squash(failed.cause)).toBe(failure)
yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, ".git", "HEAD"), event: "change" })
yield* Deferred.await(replayed).pipe(Effect.timeout("1 second"))
yield* Effect.yieldNow
const status = yield* vcs.status().pipe(Effect.exit)
expect(Exit.isFailure(status) && Cause.squash(status.cause)).toBe(failure)
expect((yield* vcs.info()).branch.current).toBe("initial")
branch = "recovered"
yield* Scope.close(faulty, Exit.void)
expect((yield* vcs.info()).branch.current).toBe("recovered")
const updated = yield* bus
.subscribe(VcsEvent.BranchUpdated)
.pipe(Stream.runHead, Effect.timeout("1 second"), Effect.forkScoped({ startImmediately: true }))
branch = "after-recovery"
yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, ".git", "HEAD"), event: "change" })
const event = yield* Fiber.join(updated)
expect((yield* vcs.info()).branch.current).toBe("after-recovery")
expect(Option.getOrUndefined(event)).toMatchObject({ data: { branch: "after-recovery" } })
}),
),
)
it.effect("stops in-flight and queued reloads when its layer closes", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const entered = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const root = yield* Scope.make()
yield* Effect.addFinalizer(() =>
Deferred.succeed(release, undefined).pipe(
Effect.andThen(State.batch(Scope.close(root, Exit.void), { flush: false })),
Effect.andThen(TestClock.adjust("500 millis")),
),
)
const context = yield* Layer.buildWithScope(
LayerNode.compile(Vcs.node, [
[Bus.node, Layer.succeed(Bus.Service, bus)],
[Location.node, locationLayer({ directory: AbsolutePath.make(import.meta.dir) })],
]),
root,
)
const vcs = Context.get(context, Vcs.Service)
const reads: string[] = []
const observed: (string | undefined)[] = []
yield* Effect.acquireRelease(
bus.listen((event) =>
Effect.sync(() => {
if (event.type !== VcsEvent.BranchUpdated.type) return
observed.push(Schema.decodeUnknownSync(VcsEvent.BranchUpdated.data)(event.data).branch)
}),
),
(unsubscribe) => unsubscribe,
)
let branch = "initial"
let block = false
yield* vcs
.transform((draft) => {
draft.add(
provider({
diff: (input) =>
Effect.sync(() => {
observed.push(input)
return [{ file: "file.txt", patch: "+hello", additions: 1, deletions: 0, status: "added" }]
info: () =>
Effect.gen(function* () {
const value = branch
reads.push(value)
if (block) {
block = false
yield* Deferred.succeed(entered, undefined)
yield* Deferred.await(release)
}
return { branch: { current: value } }
}),
}),
)
draft.default.set("custom")
})
.pipe(Scope.provide(root))
observed.length = 0
yield* vcs.diff("branch", { context: 3 })
expect(observed).toEqual([
{
directory,
worktree: directory,
canonical: directory,
mode: "branch",
context: 3,
maxOutputBytes: 10_000_000,
},
])
}).pipe(provide(directory)),
),
block = true
const first = yield* vcs.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Deferred.await(entered).pipe(Effect.timeout("1 second"), TestClock.withLive)
branch = "late"
const second = yield* vcs.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Effect.yieldNow
expect(reads).toEqual(["initial", "initial"])
expect(first.pollUnsafe()).toBeUndefined()
expect(second.pollUnsafe()).toBeUndefined()
const snapshot = yield* vcs.info()
const shutdown = yield* State.batch(Scope.close(root, Exit.void), { flush: false }).pipe(
Effect.forkChild({ startImmediately: true }),
)
yield* TestClock.adjust("1 millis")
expect(shutdown.pollUnsafe()).toBeDefined()
expect(first.pollUnsafe()).toBeDefined()
expect(second.pollUnsafe()).toBeDefined()
expect(yield* Deferred.isDone(release)).toBe(false)
yield* Fiber.join(shutdown)
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(first)
yield* Fiber.join(second)
expect(reads).toEqual(["initial", "initial"])
expect(observed).toEqual([])
expect(yield* vcs.info()).toBe(snapshot)
}).pipe(Effect.provide(LayerNode.compile(Bus.node))),
)
it.live("validates provider results and bounds oversized patches", () =>
withTmp((directory) =>
it.live("serializes filesystem and config refreshes while reading the latest desired provider", () =>
withGit((directory) =>
Effect.gen(function* () {
const vcs = yield* Vcs.Service
yield* vcs.transform((draft) => {
const bus = yield* Bus.Service
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const accepted = yield* Deferred.make<void>()
const reads: string[] = []
yield* vcs.transform((draft) =>
draft.add(
provider({
status: () => Effect.succeed([{ file: "file.txt", additions: -1, deletions: 0, status: "added" }]),
diff: () =>
Effect.succeed([
{ file: "file.txt", patch: "x".repeat(10_000_001), additions: 1, deletions: 0, status: "added" },
]),
id: "git",
info: () =>
Effect.gen(function* () {
reads.push(reads.length === 0 ? "initial" : "filesystem")
if (reads.length === 1) return { branch: { current: "initial" } }
yield* Deferred.succeed(started, undefined)
yield* Deferred.await(release)
return { branch: { current: "filesystem" } }
}),
}),
)
draft.default.set("custom")
})
),
)
const updates = yield* bus
.subscribe(VcsEvent.BranchUpdated)
.pipe(Stream.take(2), Stream.runLast, Effect.forkScoped({ startImmediately: true }))
expect(yield* vcs.status()).toEqual([])
const rows = yield* vcs.diff("working")
expect(rows).toHaveLength(1)
expect(Buffer.byteLength(rows[0].patch)).toBeLessThan(1000)
expect(rows[0].additions).toBe(1)
}).pipe(provide(directory)),
yield* Effect.gen(function* () {
yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, ".git", "HEAD"), event: "change" })
yield* Deferred.await(started)
const configured = yield* State.batch(
Effect.gen(function* () {
yield* vcs.transform((draft) =>
draft.add(
provider({
id: "git",
info: () =>
Effect.sync(() => {
reads.push("config")
return { branch: { current: "config" } }
}),
status: () => Effect.succeed([{ file: "config.txt", additions: 1, deletions: 0, status: "added" }]),
}),
),
)
expect((yield* vcs.status())[0]?.file).toBe("config.txt")
expect(yield* vcs.info()).toEqual({ branch: { current: "initial" } })
yield* Deferred.succeed(accepted, undefined)
}),
).pipe(Effect.forkScoped({ startImmediately: true }))
yield* Deferred.await(accepted)
expect(reads).toEqual(["initial", "filesystem"])
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(configured)
expect(Option.getOrUndefined(yield* Fiber.join(updates))?.data.branch).toBe("config")
expect(yield* vcs.info()).toEqual({ branch: { current: "config" } })
expect(reads).toEqual(["initial", "filesystem", "config"])
}).pipe(Effect.ensuring(Deferred.succeed(release, undefined)))
}),
),
)
it.live("preserves provider interruption", () =>
withTmp((directory) =>
Effect.gen(function* () {
const vcs = yield* Vcs.Service
synthetic.effect("keeps branch streams current when listeners change the selected provider", () =>
Effect.gen(function* () {
const vcs = yield* Vcs.Service
const bus = yield* Bus.Service
const scope = yield* Effect.scope
const updates = yield* bus.subscribe([VcsEvent.BranchUpdated, Done]).pipe(
Stream.takeUntil((event) => event.type === Done.type),
Stream.runCollect,
Effect.forkScoped({ startImmediately: true }),
)
const unsubscribe = yield* bus.listen((event) => {
if (
event.type !== VcsEvent.BranchUpdated.type ||
Schema.decodeUnknownSync(VcsEvent.BranchUpdated.data)(event.data).branch !== "feature"
)
return Effect.void
return vcs
.transform((draft) =>
draft.add(provider({ info: () => Effect.succeed({ branch: { current: "listener" } }) })),
)
.pipe(Scope.provide(scope), Effect.asVoid)
})
yield* Effect.gen(function* () {
yield* vcs.transform((draft) => {
draft.add(provider({ status: () => Effect.never }))
draft.add(provider())
draft.default.set("custom")
})
yield* bus.publish(Done, {})
const events = (yield* Fiber.join(updates)).filter((event) => event.type === VcsEvent.BranchUpdated.type)
expect(yield* vcs.info()).toEqual({ branch: { current: "listener" } })
expect(events.length).toBeGreaterThanOrEqual(2)
expect(events.at(-1)?.data.branch).toBe((yield* vcs.info()).branch.current)
}).pipe(Effect.ensuring(unsubscribe))
}),
)
const fiber = yield* Effect.forkChild(vcs.status())
yield* Fiber.interrupt(fiber)
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause)).toBeTrue()
}).pipe(provide(directory)),
),
synthetic.effect("does not roll back branch streams when an older listener finishes late", () =>
Effect.gen(function* () {
const vcs = yield* Vcs.Service
const bus = yield* Bus.Service
const entered = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const updates = yield* bus.subscribe([VcsEvent.BranchUpdated, Done]).pipe(
Stream.takeUntil((event) => event.type === Done.type),
Stream.runCollect,
Effect.forkScoped({ startImmediately: true }),
)
const unsubscribe = yield* bus.listen((event) =>
event.type === VcsEvent.BranchUpdated.type &&
Schema.decodeUnknownSync(VcsEvent.BranchUpdated.data)(event.data).branch === "older"
? Deferred.succeed(entered, undefined).pipe(Effect.andThen(Deferred.await(release)))
: Effect.void,
)
yield* Effect.gen(function* () {
const older = yield* vcs
.transform((draft) => {
draft.add(provider({ info: () => Effect.succeed({ branch: { current: "older" } }) }))
draft.default.set("custom")
})
.pipe(Effect.forkScoped({ startImmediately: true }))
yield* Deferred.await(entered)
yield* vcs.transform((draft) =>
draft.add(provider({ info: () => Effect.succeed({ branch: { current: "newer" } }) })),
)
expect(older.pollUnsafe()).toBeUndefined()
expect((yield* vcs.info()).branch.current).toBe("newer")
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(older)
yield* bus.publish(Done, {})
const events = (yield* Fiber.join(updates)).filter((event) => event.type === VcsEvent.BranchUpdated.type)
expect(events.length).toBeGreaterThanOrEqual(2)
expect(events.at(-1)?.data.branch).toBe((yield* vcs.info()).branch.current)
}).pipe(Effect.ensuring(Deferred.succeed(release, undefined).pipe(Effect.andThen(unsubscribe))))
}),
)
it.live("lists local branches by recent activity", () =>
+2 -2
View File
@@ -171,7 +171,7 @@ describe("Worktree", () => {
{ directory: created.directory, strategy: "git" },
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
)
expect((yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: false })
@@ -550,7 +550,7 @@ describe("Worktree", () => {
{ directory: existing, strategy: "git" },
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
)
expect((yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
yield* Effect.promise(() => $`git worktree remove --force ${target}`.cwd(input.root.path).quiet())
yield* Effect.promise(() => $`git worktree remove --force ${unchanged}`.cwd(input.root.path).quiet())
+64 -2
View File
@@ -1,14 +1,27 @@
import { describe, expect, test } from "bun:test"
import { Agent } from "@opencode-ai/core/agent"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Vcs } from "@opencode-ai/core/vcs"
import { Credential } from "@opencode-ai/schema/credential"
import { Event } from "@opencode-ai/schema/event"
import { IntegrationID } from "@opencode-ai/schema/integration-id"
import { Deferred, Effect, Exit, Fiber, Option, Schema, Stream } from "effect"
import { it } from "../../core/test/lib/effect"
import { VcsEvent } from "@opencode-ai/schema/vcs-event"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Deferred, Effect, Exit, Fiber, Option, Schema, Scope, Stream } from "effect"
import { locationLayer } from "../../core/test/fixture/location"
import { it, testEffect } from "../../core/test/lib/effect"
import { EventFeed } from "../src/event-feed"
const Internal = Bus.ephemeral({ type: "test.internal", schema: { value: Schema.String } })
const vcsIt = testEffect(
LayerNode.compile(LayerNode.group([Vcs.node, Bus.node]), [
[Location.node, locationLayer({ directory: AbsolutePath.make(import.meta.dir) })],
[Database.node, Database.configured({ path: ":memory:" })],
]),
)
const event = (id: string): Event.Payload<typeof Agent.Event.Updated> => ({
id: Event.ID.make(`evt_${id}`),
@@ -44,6 +57,55 @@ describe("EventFeed", () => {
expect(EventFeed.frame(payload)).toBe(`data: ${JSON.stringify(payload)}\n\n`)
})
vcsIt.effect("delivers the latest VCS branch after an earlier legacy listener reenters", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const vcs = yield* Vcs.Service
const scope = yield* Scope.Scope
const provider = {
id: "fixture",
name: "Fixture",
info: () => Effect.succeed({ branch: { current: "outer" } }),
branches: () => Effect.succeed([]),
status: () => Effect.succeed([]),
diff: () => Effect.succeed([]),
}
const unsubscribe = yield* bus.listen((event) =>
event.type === VcsEvent.BranchUpdated.type &&
Schema.decodeUnknownSync(VcsEvent.BranchUpdated.data)(event.data).branch === "outer"
? vcs
.transform((draft) =>
draft.add({ ...provider, info: () => Effect.succeed({ branch: { current: "inner" } }) }),
)
.pipe(Scope.provide(scope), Effect.asVoid)
: Effect.void,
)
// Unsubscribe before registration teardown can restore "outer" and reenter the listener.
yield* Effect.gen(function* () {
const feed = yield* EventFeed.make(bus.listen, {
encode: (event) => (event.type === VcsEvent.BranchUpdated.type ? (event.data.branch ?? "none") : event.type),
})
const stream = yield* feed.subscribe
const received = yield* stream.pipe(
Stream.takeUntil((frame) => frame === Agent.Event.Updated.type, { excludeLast: true }),
Stream.runLast,
Effect.forkScoped({ startImmediately: true }),
)
yield* vcs.transform((draft) => {
draft.add(provider)
draft.default.set(provider.id)
})
yield* unsubscribe
yield* bus.publish(Agent.Event.Updated, {})
const info = yield* vcs.info()
expect(info.branch.current).toBe("inner")
expect(Option.getOrUndefined(yield* Fiber.join(received))).toBe(info.branch.current)
}).pipe(Effect.ensuring(unsubscribe))
}),
)
it.effect("encodes once and delivers the same frame to every subscriber", () =>
Effect.gen(function* () {
let encodes = 0
+10 -15
View File
@@ -68,7 +68,7 @@ import { DialogThemeList } from "./component/dialog-theme-list"
import { DialogHelp } from "./ui/dialog-help"
import { DialogAgent } from "./component/dialog-agent"
import { DialogSessionList } from "./component/dialog-session-list"
import { DialogOpen, DialogOpenKey, moveOpenSession } from "./component/dialog-open"
import { DialogOpen, DialogOpenKey, loadDialogOpen } from "./component/dialog-open"
import { SessionTabs } from "./component/session-tabs"
import { clampSessionTabsWidth, sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH } from "./ui/layout"
import { createPaneResize } from "./ui/pane-resize"
@@ -507,7 +507,7 @@ function App(props: { pair?: DialogPairCredentials }) {
}).catch((error) => console.error("Failed to persist TUI layout", error))
},
})
const [openSessions, setOpenSessions] = createSignal<SessionInfo[]>([])
let openingOpen: Promise<SessionInfo[]> | undefined
// Toast once when an MCP server enters a failed or needs-auth state so the user knows to act,
// without having to open the status panel. Tracking the last alerted status avoids re-toasting
// the same problem on every refresh while still re-alerting if the state changes.
@@ -719,12 +719,14 @@ function App(props: { pair?: DialogPairCredentials }) {
title: "Open session or project",
category: "Session",
slash: { name: "open", aliases: ["projects", "project"] },
run: () => {
if (dialog.key === DialogOpenKey) return
dialog.replace(() => <DialogOpen sessions={openSessions()} onLoad={setOpenSessions} />, undefined, {
key: DialogOpenKey,
size: "large",
})
run: async () => {
if (dialog.key === DialogOpenKey || openingOpen) return
const previous = dialog.stack.at(-1)
openingOpen = loadDialogOpen(data, client)
const sessions = await openingOpen
openingOpen = undefined
if (dialog.stack.at(-1) !== previous) return
dialog.replace(() => <DialogOpen sessions={sessions} />, undefined, { key: DialogOpenKey, size: "large" })
},
},
...Array.from({ length: 9 }, (_, i) => ({
@@ -1211,14 +1213,7 @@ function App(props: { pair?: DialogPairCredentials }) {
})
})
event.on("session.moved", (evt) => {
setOpenSessions((sessions) =>
sessions.map((session) => (session.id !== evt.data.sessionID ? session : moveOpenSession(session, evt))),
)
})
event.on("session.deleted", (evt) => {
setOpenSessions((sessions) => sessions.filter((session) => session.id !== evt.data.sessionID))
if (route.data.type === "session" && route.data.sessionID === evt.data.sessionID) {
const title = active?.id === evt.data.sessionID ? active.title : undefined
route.navigate({ type: "home" })
+17 -73
View File
@@ -1,5 +1,5 @@
import { createMemo, createResource, createSignal, onCleanup, Show } from "solid-js"
import type { OpenCodeEvent, SessionInfo } from "@opencode-ai/client"
import { createMemo, createResource, createSignal } from "solid-js"
import type { SessionInfo } from "@opencode-ai/client"
import { useTerminalDimensions } from "@opentui/solid"
import type { RGBA } from "@opentui/core"
import { dialogWidth, useDialog } from "../ui/dialog"
@@ -25,7 +25,18 @@ export const DialogOpenKey = Symbol("DialogOpen")
type OpenTarget = { type: "session"; sessionID: string } | { type: "project"; directory: string }
export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions: SessionInfo[]) => void }) {
export async function loadDialogOpen(data: ReturnType<typeof useData>, client: ReturnType<typeof useClient>) {
const [, sessions] = await Promise.all([
data.project.sync().catch(() => {}),
client.api.session
.list({ limit: 50, order: "desc", parentID: null })
.then((response) => response.data)
.catch(() => [] as SessionInfo[]),
])
return sessions
}
export function DialogOpen(props: { sessions: SessionInfo[] }) {
const dialog = useDialog()
const route = useRoute()
const data = useData()
@@ -40,41 +51,6 @@ export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions:
const shortcuts = Keymap.useShortcuts()
const [filter, setFilter] = createSignal("")
const [selectionMoved, setSelectionMoved] = createSignal(false)
let closed = false
onCleanup(() => {
closed = true
})
const [recent] = createResource(() => {
// A late read must not overwrite deletion or placement facts observed in flight.
const changed = new Map<string, Extract<OpenCodeEvent, { type: "session.deleted" | "session.moved" }>>()
const unsubscribe = client.event.listen((message) => {
const event = message.details
if (event.type === "session.deleted" || event.type === "session.moved") changed.set(event.data.sessionID, event)
})
onCleanup(unsubscribe)
return client.api.session
.list({ limit: 50, order: "desc", parentID: null })
.then((response) => {
if (!closed)
props.onLoad(
response.data.flatMap((session) => {
const event = changed.get(session.id)
if (!event) return [session]
if (event.type === "session.deleted") return []
return [moveOpenSession(props.sessions.find((entry) => entry.id === session.id) ?? session, event)]
}),
)
return true
})
.catch(() => false)
.finally(unsubscribe)
})
const [projects] = createResource(() =>
data.project.sync().then(
() => true,
() => false,
),
)
const [matched] = createResource(
() => {
@@ -178,34 +154,12 @@ export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions:
preserveSelection={selectionMoved()}
onMove={() => setSelectionMoved(true)}
onFilter={setFilter}
emptyView={
<Show when={!recent.loading && !projects.loading}>
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>No recent sessions or projects</text>
</box>
</Show>
}
footer={
<box>
<Show when={recent.loading || projects.loading}>
<Spinner color={theme.text.subdued}>Refreshing sessions and projects...</Spinner>
</Show>
<Show when={recent() === false || projects() === false}>
<text fg={theme.text.feedback.error.default}>
Could not refresh{" "}
{recent() === false ? (projects() === false ? "sessions and projects" : "sessions") : "projects"}.
</text>
</Show>
</box>
}
noMatchView={
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>
{recent.loading || projects.loading || matched.loading
? "Searching sessions and projects..."
: shortcuts.get("session.list")
? `No matches · search all sessions with ${shortcuts.get("session.list")}`
: "No matches"}
{shortcuts.get("session.list")
? `No matches · search all sessions with ${shortcuts.get("session.list")}`
: "No matches"}
</text>
</box>
}
@@ -223,16 +177,6 @@ export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions:
)
}
export function moveOpenSession(session: SessionInfo, event: Extract<OpenCodeEvent, { type: "session.moved" }>) {
return {
...session,
location: event.data.location,
projectID: event.data.projectID ?? session.projectID,
subpath: event.data.subpath,
time: { ...session.time, updated: Math.max(session.time.updated, event.created) },
}
}
function timeAgo(timestamp: number) {
const minutes = Math.floor((Date.now() - timestamp) / 60_000)
if (minutes < 1) return "now"
+9 -24
View File
@@ -41,7 +41,6 @@ import { DialogSessionRename } from "./dialog-session-rename"
import { Keymap } from "../context/keymap"
import { registerOpencodeSpinner } from "./register-spinner"
import { SPINNER_FRAMES } from "./spinner-frames"
import "./title-shimmer"
registerOpencodeSpinner()
@@ -97,7 +96,6 @@ export const EMPTY_SESSION_TAB_STATUS: SessionTabsStatus = {
promptPulse: 0,
attention: false,
busy: false,
renaming: false,
}
export type SessionTabsController = Pick<ContextController, "tabs" | "current" | "select" | "close" | "move"> & {
newTab?: () => boolean
@@ -650,7 +648,7 @@ function VerticalSessionTabs(props: {
const restingTitleWidth = () => Math.max(1, width() - numberWidth() - 2)
const hoveredTitleWidth = () => Math.max(1, restingTitleWidth() - 1)
const titleWidth = () => (hovered() === tab.sessionID ? hoveredTitleWidth() : restingTitleWidth())
const title = () => (props.controller ? undefined : session()?.title) ?? tab.title ?? "Untitled session"
const title = () => tab.title ?? "Untitled session"
const scrolling = () => marquee.active() === tab.sessionID
const visibleTitleParts = createMemo(() =>
scrolling()
@@ -909,21 +907,14 @@ function VerticalSessionTabs(props: {
unreadMarker={props.unreadMarker}
attributes={selected() ? TextAttributes.BOLD : undefined}
/>
<title_shimmer
<text
width={titleWidth()}
height={1}
fg={foreground()}
rename={{ pending: status().renaming, title: title() }}
enabled={animations()}
backdrop={pulseBackground()}
wrapMode="none"
selectable={false}
attributes={
(status().renaming && !animations()
? TextAttributes.DIM
: selected()
? TextAttributes.BOLD
: 0) | (tabs.isPreview?.(tab.sessionID) ? TextAttributes.ITALIC : 0) || undefined
(selected() ? TextAttributes.BOLD : 0) |
(tabs.isPreview?.(tab.sessionID) ? TextAttributes.ITALIC : 0) || undefined
}
>
<Show
@@ -936,7 +927,7 @@ function VerticalSessionTabs(props: {
)}
</Index>
</Show>
</title_shimmer>
</text>
<text
position="absolute"
right={1}
@@ -1084,7 +1075,6 @@ function HorizontalSessionTabs(props: {
numbers: boolean
}) {
const tabs = props.controller ?? useSessionTabs()
const data = props.controller ? undefined : useData()
const dimensions = useTerminalDimensions()
const theme = useTheme()
const config = useConfig().data
@@ -1374,7 +1364,7 @@ function HorizontalSessionTabs(props: {
const glowColor = createMemo(() => tint(background(), feedbackColor() ?? unreadColor(), glowLevel()))
const glows = () =>
Boolean(status().attention || (!selected() && !status().busy && status().unread !== undefined))
const title = () => data?.session.get(tab.sessionID)?.title ?? tab.title ?? "Untitled session"
const title = () => tab.title ?? "Untitled session"
const tabNumber = createMemo(() => items().findIndex((item) => item.sessionID === tab.sessionID) + 1)
const numberWidth = () => Math.max(2, String(items().length).length)
// Hovering reveals the close mark, so the title's right bound shifts left of it.
@@ -1502,18 +1492,13 @@ function HorizontalSessionTabs(props: {
unreadMarker={props.unreadMarker}
attributes={bold()}
/>
<title_shimmer
<text
width={availableTitleWidth()}
height={1}
fg={foreground()}
rename={{ pending: status().renaming, title: title() }}
enabled={animations()}
backdrop={background()}
wrapMode="none"
selectable={false}
attributes={
(status().renaming && !animations() ? TextAttributes.DIM : (bold() ?? 0)) |
(tabs.isPreview?.(tab.sessionID) ? TextAttributes.ITALIC : 0) || undefined
(bold() ?? 0) | (tabs.isPreview?.(tab.sessionID) ? TextAttributes.ITALIC : 0) || undefined
}
>
<Show when={scrolling() || glows() || titleFades()} fallback={visibleTitle()}>
@@ -1523,7 +1508,7 @@ function HorizontalSessionTabs(props: {
)}
</Index>
</Show>
</title_shimmer>
</text>
<text
position="absolute"
right={1}
+3 -3
View File
@@ -30,7 +30,7 @@ type TabPulseOptions = RenderableOptions<TabPulseRenderable> & {
}
const clamp = (value: number) => Math.max(0, Math.min(1, value))
export const smootherstep = (value: number) => value * value * value * (value * (value * 6 - 15) + 10)
const smootherstep = (value: number) => value * value * value * (value * (value * 6 - 15) + 10)
const RUN_DURATION = 2_800
const RUN_ATTACK = 450
const RUN_HEAD = 4
@@ -53,11 +53,11 @@ const GLOW_RELEASE_PEAK = 1.25
const GLOW_TAIL = 12
const GLOW_OPACITY = 0.16
const DEFAULT_FOREGROUND = RGBA.defaultForeground()
export const intensityAt = (index: number, front: number, head: number, tail: number) => {
const intensityAt = (index: number, front: number, head: number, tail: number) => {
const distance = front - index
return distance < 0 ? smootherstep(clamp(1 + distance / head)) : smootherstep(clamp(1 - distance / tail))
}
export const coast = (value: number) => {
const coast = (value: number) => {
const ramp = 0.2
if (value < ramp) return (value * value) / (2 * ramp * (1 - ramp))
if (value > 1 - ramp) return 1 - ((1 - value) * (1 - value)) / (2 * ramp * (1 - ramp))
@@ -1,269 +0,0 @@
import {
BoxRenderable,
OptimizedBuffer,
RGBA,
TargetChannel,
TextRenderable,
type RenderContext,
type TextOptions,
} from "@opentui/core"
import { extend } from "@opentui/solid"
import { coast, intensityAt, smootherstep } from "./tab-pulse"
type TitleShimmerOptions = TextOptions & {
rename?: { title: string; pending: boolean }
enabled?: boolean
backdrop?: RGBA
}
const SHIMMER_DURATION = 1200
const SHIMMER_FADE = 240
const ARRIVAL_DURATION = 450
const WIPE_FEATHER = 4
const TRANSPARENT = RGBA.fromValues(0, 0, 0, 0)
// Native text draws wide glyphs as a head followed by flagged continuation cells.
const CONTINUATION = 0xc0000000 | 0
export class TitleShimmerRenderable extends TextRenderable {
private _rename: TitleShimmerOptions["rename"]
private _enabled: boolean
private _backdrop: RGBA
private pendingTitle: string | undefined
private elapsed = 0
private blend = 0
private fresh = true
private arrival: number | undefined
private scratch: OptimizedBuffer | undefined
private previous: OptimizedBuffer | undefined
private mask = new Float32Array(0)
private matrix = new Float32Array(16)
constructor(ctx: RenderContext, options: TitleShimmerOptions) {
super(ctx, options)
this._rename = options.rename
this.pendingTitle = options.rename?.title
this._enabled = options.enabled ?? true
this._backdrop = options.backdrop ?? RGBA.defaultBackground()
this.matrix[15] = 1
this.updateBackdrop()
this.live = this.animating
}
private get animating() {
return this._enabled && (this.shimmering || this.arrival !== undefined || this.blend > 0)
}
private get shimmering() {
return this._rename?.pending && this._rename.title === this.pendingTitle
}
set rename(value: TitleShimmerOptions["rename"]) {
if (value?.title === this._rename?.title && value?.pending === this._rename?.pending) return
if (value?.pending && !this._rename?.pending) {
if (this.pendingTitle !== value.title) this.blend = 0
this.pendingTitle = value.title
if (this.blend === 0) this.elapsed = 0
this.arrival = undefined
this.previous?.destroy()
this.previous = undefined
}
// Only an automatic rename replaces the last painted title with a wipe.
if (value?.title !== this._rename?.title) {
this.arrival = value && this._rename?.pending && this._enabled && this.previous ? 0 : undefined
if (this.arrival === undefined) this.blend = 0
}
this._rename = value
this.changed()
}
set enabled(value: boolean) {
if (value === this._enabled) return
this._enabled = value
if (!value) {
this.arrival = undefined
this.blend = 0
}
this.changed()
}
set backdrop(value: RGBA) {
if (value.equals(this._backdrop)) return
this._backdrop = value
this.updateBackdrop()
this.requestRender()
}
private updateBackdrop() {
this.matrix[3] = this._backdrop.r
this.matrix[7] = this._backdrop.g
this.matrix[11] = this._backdrop.b
}
private changed() {
if (!this.live && this.animating) this.fresh = true
this.live = this.animating
if (!this.animating) {
this.previous?.destroy()
this.previous = undefined
}
this.requestRender()
}
override render(buffer: OptimizedBuffer, deltaTime: number) {
if (!this.visible || this.isDestroyed || !Number.isFinite(this.width) || this.width <= 0 || this.height <= 0) return
if (!this.animating) return super.render(buffer, deltaTime)
// A newly live title must not inherit time spent idle before its fade started.
const delta = this.fresh ? 0 : deltaTime
this.fresh = false
this.elapsed = (this.elapsed + delta) % SHIMMER_DURATION
if (this.arrival !== undefined) {
this.arrival += delta
if (this.arrival >= ARRIVAL_DURATION) {
this.arrival = undefined
this.blend = 0
}
}
this.blend = Math.max(
0,
Math.min(1, this.blend + (this.shimmering || this.arrival !== undefined ? delta : -delta) / SHIMMER_FADE),
)
this.live = this.animating
if (!this.animating) {
this.previous?.destroy()
this.previous = undefined
return super.render(buffer, deltaTime)
}
if (!this.scratch)
this.scratch = OptimizedBuffer.create(this.width, this.height, this._ctx.widthMethod, { respectAlpha: true })
if (this.scratch.width !== this.width || this.scratch.height !== this.height)
this.scratch.resize(this.width, this.height)
// Shade locally, then composite: colorMatrix itself does not respect ancestor scissors.
this.scratch.clear(TRANSPARENT)
// OpenTUI's framebuffer compositor can paint a cut wide glyph. Clip the native text draw first.
const clip = {
left: Math.max(0, -this.screenX),
top: Math.max(0, -this.screenY),
right: Math.min(this.width, buffer.width - this.screenX),
bottom: Math.min(this.height, buffer.height - this.screenY),
}
for (let parent = this.parent; parent; parent = parent.parent) {
if (parent.overflow === "visible" || parent.width <= 0 || parent.height <= 0) continue
const border = parent instanceof BoxRenderable ? parent.border : false
const left = Number(border === true || (Array.isArray(border) && border.includes("left")))
const top = Number(border === true || (Array.isArray(border) && border.includes("top")))
clip.left = Math.max(clip.left, parent.screenX - this.screenX + left)
clip.top = Math.max(clip.top, parent.screenY - this.screenY + top)
clip.right = Math.min(
clip.right,
parent.screenX -
this.screenX +
parent.width -
Number(border === true || (Array.isArray(border) && border.includes("right"))),
)
clip.bottom = Math.min(
clip.bottom,
parent.screenY -
this.screenY +
parent.height -
Number(border === true || (Array.isArray(border) && border.includes("bottom"))),
)
}
this.scratch.pushScissorRect(
clip.left,
clip.top,
Math.max(0, clip.right - clip.left),
Math.max(0, clip.bottom - clip.top),
)
this.scratch.drawTextBuffer(this.textBufferView, 0, 0)
const characters = this.scratch.buffers.char
let end = 0
for (let row = 0; row < this.height; row++) {
let column = this.width
while (
column > 0 &&
(characters[row * this.width + column - 1] === 32 || characters[row * this.width + column - 1] === 0)
)
column--
end = Math.max(end, column)
}
const wipeFront =
this.arrival !== undefined && this.previous
? -WIPE_FEATHER +
coast(this.arrival / ARRIVAL_DURATION) * (Math.max(end, this.previous.width) + WIPE_FEATHER * 2)
: undefined
const cut = Math.max(0, Math.min(this.width, Math.round(wipeFront ?? 0)))
if (wipeFront !== undefined && this.previous) {
this.scratch.clear(TRANSPARENT)
this.scratch.pushScissorRect(0, 0, cut, this.height)
this.scratch.drawTextBuffer(this.textBufferView, 0, 0)
this.scratch.popScissorRect()
// Snapshot slices must also end on whole glyphs; framebuffer clipping alone can split them.
for (let row = 0; row < Math.min(this.height, this.previous.height); row++) {
let left = Math.max(cut, clip.left)
let right = Math.min(this.previous.width, clip.right)
const offset = row * this.previous.width
while (left < right && (this.previous.buffers.char[offset + left] & CONTINUATION) === CONTINUATION) left++
while (
right > left &&
right < this.previous.width &&
(this.previous.buffers.char[offset + right] & CONTINUATION) === CONTINUATION
)
right--
if (right > left) this.scratch.drawFrameBuffer(left, row, this.previous, left, row, right - left, 1)
}
}
this.scratch.clearScissorRects()
if (this.mask.length !== this.width * this.height * 3) this.mask = new Float32Array(this.width * this.height * 3)
if (wipeFront === undefined) {
if (!this.previous)
this.previous = OptimizedBuffer.create(Math.max(1, end), this.height, this._ctx.widthMethod, {
respectAlpha: true,
})
if (this.previous.width !== Math.max(1, end) || this.previous.height !== this.height)
this.previous.resize(Math.max(1, end), this.height)
this.previous.clear(TRANSPARENT)
this.previous.drawFrameBuffer(0, 0, this.scratch)
}
const front = -4 + coast(this.elapsed / SHIMMER_DURATION) * ((this.previous?.width ?? end) + 4 + 18)
const level = smootherstep(this.blend)
let strength = 0
for (let cell = 0; cell < characters.length; cell++) {
const column = cell % this.width
if ((characters[cell] & CONTINUATION) !== CONTINUATION) {
const old = wipeFront === undefined || column >= cut
let visibility = old ? 1 - 0.6 * level * (1 - intensityAt(column, front, 4, 18)) : 1
if (wipeFront !== undefined) {
let width = 1
while (column + width < this.width && (characters[cell + width] & CONTINUATION) === CONTINUATION) width++
const distance = old ? column - wipeFront : wipeFront - (column + width)
visibility *= smootherstep(Math.max(0, Math.min(1, distance / WIPE_FEATHER)))
}
strength = 1 - visibility
}
this.mask[cell * 3] = column
this.mask[cell * 3 + 1] = Math.floor(cell / this.width)
this.mask[cell * 3 + 2] = strength
}
this.scratch.colorMatrix(this.matrix, this.mask, 1, TargetChannel.FG)
buffer.drawFrameBuffer(this.screenX, this.screenY, this.scratch)
this.markClean()
this._ctx.addToHitGrid(this.screenX, this.screenY, this.width, this.height, this.num)
}
override destroy() {
this.previous?.destroy()
this.previous = undefined
this.scratch?.destroy()
this.scratch = undefined
super.destroy()
}
}
extend({ title_shimmer: TitleShimmerRenderable })
declare module "@opentui/solid" {
interface OpenTUIComponents {
title_shimmer: typeof TitleShimmerRenderable
}
}
+1 -23
View File
@@ -1,6 +1,5 @@
import { createData } from "@opencode-ai/client/solid"
import type { Plugin } from "@opencode-ai/plugin/tui"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useClient } from "./client"
@@ -18,27 +17,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
directory: props.directory,
})
data satisfies Plugin.Context["data"]
const [generatingTitles, setGeneratingTitles] = createStore<Record<string, boolean | undefined>>({})
return {
...data,
session: {
...data.session,
title: {
pending: (sessionID: string) => generatingTitles[sessionID] === true,
async generate(sessionID: string) {
if (generatingTitles[sessionID]) return
setGeneratingTitles(sessionID, true)
await client.api.session
.rename({ sessionID, title: "" })
.then(() => {
// The HTTP response can beat the renamed event. Keep pending until the new title is projected locally.
data.session.invalidate(sessionID)
return data.session.sync(sessionID)
})
.finally(() => setGeneratingTitles(sessionID, undefined))
},
},
},
}
return data
},
})
@@ -173,7 +173,6 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
? ("question" as const)
: (false as const),
busy: members.some((id) => data.session.status(id) === "running" || data.session.pending.list(id).length > 0),
renaming: data.session.title.pending(session),
}
}
+19 -49
View File
@@ -7,55 +7,25 @@ export function createHistoryPrepend(input: {
active: (sessionID: string) => boolean
scrollBy: (amount: number) => void
}) {
let pending: { scrollBy: number; continuation?: () => void; after?: () => void } | undefined
let loading = false
return Object.assign(
(scrollBy = 0, continuation?: () => void) => {
const sessionID = input.sessionID()
if (pending) {
if (continuation || (!pending.scrollBy && scrollBy)) {
pending.scrollBy = scrollBy
pending.continuation = continuation
return true
}
return false
}
if (!input.more(sessionID)) return false
const current = { scrollBy, continuation }
pending = current
const before = input.height()
void input.loadMore(sessionID).then(
() =>
input.afterLayout(() => {
if (pending !== current) return
const after = pending.after
pending = undefined
if (!input.active(sessionID)) return
input.scrollBy(input.height() - before + current.scrollBy)
current.continuation?.()
after?.()
}),
() => {
if (pending !== current) return
const after = pending.after
pending = undefined
if (input.active(sessionID)) after?.()
},
)
return true
},
{
cancel() {
if (!pending) return
pending.continuation = undefined
pending.after = undefined
return (scrollBy = 0, continuation?: () => void) => {
const sessionID = input.sessionID()
if (loading || !input.more(sessionID)) return false
loading = true
const before = input.height()
void input.loadMore(sessionID).then(
() =>
input.afterLayout(() => {
loading = false
if (!input.active(sessionID)) return
input.scrollBy(input.height() - before + scrollBy)
continuation?.()
}),
() => {
loading = false
},
after(continuation: () => void) {
if (!pending) return continuation()
// A jump supersedes deferred scrolling, but must wait for anchor compensation.
pending.scrollBy = 0
pending.after = continuation
},
},
)
)
return true
}
}
+13 -78
View File
@@ -1,5 +1,4 @@
import {
batch,
createContext,
createEffect,
createMemo,
@@ -289,7 +288,6 @@ export function Session(props: {
const boundaryIDs = createMemo(() => new Set(boundaries().filter((id) => id !== undefined)))
const [navigationMessage, setNavigationMessage] = createSignal<string>()
const [navigationSlack, setNavigationSlack] = createSignal(0)
const [firstJump, setFirstJump] = createSignal<() => void>()
const [synced, setSynced] = createSignal(false)
const sessionTabs = useSessionTabs()
const terminals = useSessionTerminals()
@@ -302,9 +300,6 @@ export function Session(props: {
const clearMessageNavigation = () => {
ensureAllRowsPending?.splice(0)
prependHistory.cancel()
firstJump()?.()
setFirstJump(undefined)
setNavigationSlack(0)
setNavigationMessage(undefined)
}
@@ -375,8 +370,6 @@ export function Session(props: {
let awayTimer: ReturnType<typeof setTimeout> | undefined
onCleanup(() => {
if (awayTimer) clearTimeout(awayTimer)
prependHistory.cancel()
firstJump()?.()
if (!scroll || scroll.isDestroyed) return
scroll.verticalScrollBar.off("change", updateAwayFromBottom)
saveScrollAnchor()
@@ -459,7 +452,6 @@ export function Session(props: {
}
/** Message navigation needs the full transcript mounted before walking or jumping. */
const ensureAllRows = (continuation: () => void) => {
if (firstJump()) clearMessageNavigation()
if (!ensureAllRowsPending && hidden() === 0 && visibleEnd() === rows.length) return continuation()
if (ensureAllRowsPending) {
ensureAllRowsPending.push(continuation)
@@ -477,13 +469,12 @@ export function Session(props: {
}
function isAwayFromBottom() {
if (revealingOlderRows || revealingNewerRows || ensureAllRowsPending || navigationMessage() || firstJump())
return true
if (revealingOlderRows || revealingNewerRows || ensureAllRowsPending || navigationMessage()) return true
if (visibleEnd() < rows.length) return true
return scroll.scrollTop < Math.max(0, scroll.scrollHeight - scroll.viewport.height)
}
function updateAwayFromBottom() {
const preserveWindow = revealingOlderRows || revealingNewerRows || !!ensureAllRowsPending || !!firstJump()
const preserveWindow = revealingOlderRows || revealingNewerRows || !!ensureAllRowsPending
if (isAwayFromBottom()) setHiddenRows((current) => current ?? hidden())
if (awayTimer) clearTimeout(awayTimer)
awayTimer = setTimeout(() => {
@@ -668,7 +659,6 @@ export function Session(props: {
})
const jumpToBackgroundTool = (target: BackgroundToolTarget, beforeMessageID: string) => {
if (firstJump()) clearMessageNavigation()
const jump = () => {
const index = backgroundToolRowIndex(rows, messages(), target, beforeMessageID)
if (index === -1) {
@@ -769,65 +759,17 @@ export function Session(props: {
group: "Session",
palette: undefined,
run: () => {
if (firstJump()) return
clearMessageNavigation()
const request = new AbortController()
const cancel = () => request.abort()
setFirstJump(() => cancel)
const start = () => {
if (firstJump() !== cancel || scroll.isDestroyed) return
if (revealingOlderRows || revealingNewerRows || ensureAllRowsPending) return afterLayout(start)
const previous = { start: hiddenRows(), end: visibleRowsEnd() }
const restore = () => {
cancel()
batch(() => {
setHiddenRows(previous.start)
setVisibleRowsEnd(previous.end)
})
const first = () => {
if (data.session.message.more(route.sessionID)) {
prependHistory(0, first)
return
}
const commit = () => {
if (firstJump() !== restore || scroll.isDestroyed) return
scroll.stickyScroll = false
batch(() => {
setHiddenRows(0)
setVisibleRowsEnd(TRANSCRIPT_BACKFILL_CHUNK)
setFirstJump(() => cancel)
})
}
// Pin both ends until the head budget commits in the same batch as history.
batch(() => {
setFirstJump(() => restore)
setHiddenRows(hidden())
setVisibleRowsEnd(visibleEnd())
ensureAllRows(() => {
scroll.scrollTo(0)
})
void data.session.message
.loadMore(route.sessionID, {
all: true,
signal: request.signal,
beforePublish: commit,
})
.then(
() => {
commit()
if (firstJump() !== cancel || scroll.isDestroyed) return
if (rows.length <= TRANSCRIPT_BACKFILL_CHUNK) setVisibleRowsEnd(undefined)
scroll.scrollTo(0)
afterLayout(() => {
if (firstJump() !== cancel) return
scroll.scrollTo(0)
setFirstJump(undefined)
updateAwayFromBottom()
})
},
(error) => {
if (firstJump() !== restore || scroll.isDestroyed) return
clearMessageNavigation()
toast.error(error)
updateAwayFromBottom()
},
)
}
prependHistory.after(start)
first()
dialog.clear()
},
},
@@ -859,12 +801,9 @@ export function Session(props: {
slash: { name: "rename", arguments: true as const },
run: (input?: string) => {
if (input === undefined) return DialogSessionRename.show(dialog, route.sessionID, session()?.title)
const title = input.trim()
void (
title
? client.api.session.rename({ sessionID: route.sessionID, title })
: data.session.title.generate(route.sessionID)
).catch((error) => toast.error(error))
void client.api.session
.rename({ sessionID: route.sessionID, title: input.trim() })
.catch((error) => toast.error(error))
},
},
{
@@ -1386,7 +1325,6 @@ export function Session(props: {
flexGrow={1}
scrollAcceleration={scrollAcceleration()}
onMouseScroll={(event) => {
if (firstJump()) clearMessageNavigation()
if (event.scroll?.direction === "up" && revealOlderRows()) return
if (event.scroll?.direction === "down" && revealNewerRows()) return
updateAwayFromBottom()
@@ -1414,10 +1352,7 @@ export function Session(props: {
</scrollbox>
</box>
<box height={1} flexShrink={0} flexDirection="row" justifyContent="flex-end">
<Show when={firstJump()}>
<text fg={theme.text.feedback.info.default}>Loading session history...</text>
</Show>
<Show when={!firstJump() && awayFromBottom()}>
<Show when={awayFromBottom()}>
<box
id="session-jump-to-latest"
paddingLeft={1}
+5 -20
View File
@@ -4,8 +4,6 @@ import { useTheme } from "../../context/theme"
import { useConfig } from "../../config"
import { Slot } from "../../plugin/render"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import { TextAttributes } from "@opentui/core"
import "../../component/title-shimmer"
import { getScrollAcceleration } from "../../util/scroll"
import { SESSION_SIDEBAR_WIDTH } from "../../ui/layout"
@@ -47,24 +45,11 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
>
<box flexShrink={0} gap={1} paddingRight={1}>
<box paddingRight={1}>
<title_shimmer
fg={theme.text.default}
rename={{
pending: data.session.title.pending(props.sessionID),
title: withTimestampedFallback(session()),
}}
enabled={config.animations ?? true}
backdrop={theme.background.default}
attributes={
data.session.title.pending(props.sessionID) && config.animations === false
? TextAttributes.DIM
: TextAttributes.BOLD
}
>
{withTimestampedFallback(session())}
</title_shimmer>
<Show when={session().location.workspaceID}>
<text fg={theme.text.subdued}>{session().location.workspaceID}</text>
<text fg={theme.text.default}>
<b>{withTimestampedFallback(session()!)}</b>
</text>
<Show when={session()!.location.workspaceID}>
<text fg={theme.text.subdued}>{session()!.location.workspaceID}</text>
</Show>
</box>
<Slot path="sidebar.content" input={{ sessionID: props.sessionID }} />
+2 -2
View File
@@ -284,8 +284,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
selection = option
if (!moved) return
if (
!props.preserveSelection &&
(props.current === undefined || props.focusCurrent === false || store.filter.length > 0)
(!props.preserveSelection && (props.current === undefined || props.focusCurrent === false)) ||
store.filter.length > 0
)
return
scrollAfterLayout(false, option.value)
-316
View File
@@ -8,241 +8,6 @@ import path from "node:path"
import { createEventStream, createFetch, directory, json } from "./fixture/tui-client"
import { tmpdir } from "./fixture/fixture"
test.each([100, 44])("Ctrl-O is immediate, dismissible, and prunes cached deletions at width %s", async (width) => {
await using state = await tmpdir()
const setup = await createTestRenderer({ width, height: 30, useThread: false, kittyKeyboard: true })
setup.renderer.start()
const ready = Promise.withResolvers<void>()
const requested = Promise.withResolvers<void>()
const response = Promise.withResolvers<Response>()
const projects = Promise.withResolvers<Response>()
const refresh = Promise.withResolvers<Response>()
const events = createEventStream()
const cachedSession = {
id: "ses_cached",
title: "Cached session",
projectID: "proj_fixture",
location: { directory: "/fixture" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 2 },
}
let requests = 0
const calls = createFetch((url) => {
if (url.pathname === "/api/session") {
requests++
requested.resolve()
if (requests === 1) return response.promise
if (requests === 2 || requests === 4) return refresh.promise.then((response) => response.clone())
if (requests > 4) return new Response("Unavailable", { status: 503 })
return json({ data: [cachedSession], cursor: {} })
}
if (url.pathname === "/api/project") return projects.promise
return undefined
}, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
try {
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({ animations: false }), update: async () => ({}) },
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
args: {},
log: () => {},
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
)
await ready.promise
await setup.waitForFrame((frame) => frame.includes("commands"))
setup.mockInput.pressKey("o", { ctrl: true })
await requested.promise
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain("Search sessions")
expect(setup.captureCharFrame()).toContain("Refreshing")
projects.resolve(
json([
{
id: "proj_fixture",
canonical: "/fixture",
name: "Fixture project",
time: { created: 1, updated: 2 },
sandboxes: [],
},
]),
)
await setup.waitForFrame((frame) => frame.includes("Fixture project"))
setup.mockInput.pressKey("o", { ctrl: true })
expect(requests).toBe(1)
setup.mockInput.pressEscape()
await setup.waitForFrame((frame) => !frame.includes("Fixture project"))
response.resolve(json({ data: [{ ...cachedSession, id: "ses_disposed", title: "Disposed response" }], cursor: {} }))
await setup.renderOnce()
expect(setup.captureCharFrame()).not.toContain("Fixture project")
setup.mockInput.pressKey("o", { ctrl: true })
await setup.waitForFrame((frame) => frame.includes("Refreshing"))
expect(setup.captureCharFrame()).not.toContain("Disposed")
setup.mockInput.pressEscape()
await setup.waitForFrame((frame) => !frame.includes("Fixture project"))
setup.mockInput.pressKey("o", { ctrl: true })
await setup.waitForFrame((frame) => frame.includes("Cached"))
setup.mockInput.pressEscape()
await setup.waitForFrame((frame) => !frame.includes("Fixture project"))
setup.mockInput.pressKey("o", { ctrl: true })
await setup.waitForFrame((frame) => frame.includes("Cached") && frame.includes("Refreshing"))
events.emit({
id: "evt_deleted",
created: 1,
type: "session.deleted",
durable: { aggregateID: "ses_cached", seq: 1, version: 2 },
data: { sessionID: "ses_cached" },
})
await setup.waitForFrame((frame) => !frame.includes("Cached"))
refresh.resolve(json({ data: [cachedSession], cursor: {} }))
await setup.waitForFrame((frame) => frame.includes("Fixture project") && !frame.includes("Refreshing"))
expect(setup.captureCharFrame()).not.toContain("Cached")
setup.mockInput.pressEscape()
await setup.waitForFrame((frame) => !frame.includes("Fixture project"))
setup.mockInput.pressKey("o", { ctrl: true })
await setup.waitForFrame((frame) => frame.includes("Could not refresh sessions"))
expect(setup.captureCharFrame()).not.toContain("Cached")
setup.renderer.destroy()
await task
} finally {
response.resolve(json({ data: [], cursor: {} }))
projects.resolve(json([]))
refresh.resolve(json({ data: [], cursor: {} }))
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await server.stop()
}
})
test.each(["dismissed", "refreshing"])(
"Ctrl-O retains committed movement of a cached-only session while %s",
async (phase) => {
await using state = await tmpdir()
const setup = await createTestRenderer({ width: 100, height: 30, useThread: false, kittyKeyboard: true })
setup.renderer.start()
const ready = Promise.withResolvers<void>()
const refresh = Promise.withResolvers<Response>()
const metadata = Promise.withResolvers<Response>()
const destinationRequested = Promise.withResolvers<void>()
const events = createEventStream()
const cached = {
id: "ses_cached_move",
title: "Cached movement",
projectID: "proj_old",
location: { directory: "/fixture/old" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 2 },
}
let requests = 0
const locations: string[] = []
const calls = createFetch((url) => {
if (url.pathname === "/api/session") {
if (url.searchParams.has("parentID")) {
const parent = url.searchParams.get("parentID")
if (parent && parent !== "null") return json({ data: [], cursor: {} })
}
return requests++ === 0
? json({ data: [cached], cursor: {} })
: refresh.promise.then((response) => response.clone())
}
if (url.pathname === `/api/session/${cached.id}`) return metadata.promise
if (url.pathname === `/api/session/${cached.id}/message`) return json({ data: [], cursor: {} })
if (url.pathname === `/api/session/${cached.id}/inbox` || url.pathname === `/api/session/${cached.id}/permission`)
return json({ data: [] })
if (url.pathname === "/api/project")
return json(
["old", "new"].map((name) => ({
id: `proj_${name}`,
canonical: `/fixture/${name}`,
name: name === "old" ? "Old" : "New",
time: { created: 1, updated: 2 },
sandboxes: [],
})),
)
if (url.pathname === "/api/location") {
const query = url.searchParams.get("location[directory]") ?? ""
locations.push(query)
if (query.includes("/fixture/new")) {
destinationRequested.resolve()
return json({
directory: "/fixture/new",
project: { id: "proj_new", directory: "/fixture/new", canonical: "/fixture/new" },
})
}
}
return undefined
}, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({ animations: false, tabs: { enabled: false } }), update: async () => ({}) },
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
args: {},
log: () => {},
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
)
try {
await ready.promise
await setup.waitForFrame((frame) => frame.includes("commands"))
setup.mockInput.pressKey("o", { ctrl: true })
await setup.waitForFrame((frame) => frame.includes(cached.title) && !frame.includes("Refreshing"))
setup.mockInput.pressEscape()
await setup.waitForFrame((frame) => !frame.includes("Search sessions"))
if (phase === "refreshing") {
setup.mockInput.pressKey("o", { ctrl: true })
await setup.waitForFrame((frame) => frame.includes(cached.title) && frame.includes("Refreshing"))
}
events.emit({
id: "evt_cached_moved",
created: 3,
type: "session.moved",
durable: { aggregateID: cached.id, seq: 1, version: 1 },
data: { sessionID: cached.id, location: { directory: "/fixture/new" }, projectID: "proj_new" },
})
if (phase === "dismissed") {
setup.mockInput.pressKey("o", { ctrl: true })
refresh.resolve(new Response("Unavailable", { status: 503 }))
await setup.waitForFrame((frame) => frame.includes("Could not refresh sessions"))
}
if (phase === "refreshing") {
await setup.waitForFrame((frame) =>
frame.split("\n").some((line) => line.includes(cached.title) && line.includes("New")),
)
refresh.resolve(json({ data: [cached], cursor: {} }))
await setup.waitForFrame((frame) => frame.includes(cached.title) && !frame.includes("Refreshing"))
}
await setup.waitForFrame((frame) =>
frame.split("\n").some((line) => line.includes(cached.title) && line.includes("New")),
)
expect(
setup
.captureCharFrame()
.split("\n")
.find((line) => line.includes(cached.title)),
).toContain("New")
locations.length = 0
setup.mockInput.pressEnter()
await destinationRequested.promise
expect(locations.some((query) => query.includes("/fixture/old"))).toBe(false)
} finally {
refresh.resolve(json({ data: [], cursor: {} }))
metadata.resolve(json({ data: { ...cached, projectID: "proj_new", location: { directory: "/fixture/new" } } }))
setup.renderer.destroy()
await task
await server.stop()
}
},
)
test("SIGHUP clears title and disposes scoped resources once", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
const titles: string[] = []
@@ -458,87 +223,6 @@ test("session title generated while an untitled session is loading remains visib
}
})
test("automatic rename refreshes the displayed title before settling, even without a renamed event", async () => {
await using state = await tmpdir()
const setup = await createTestRenderer({ width: 90, height: 20, useThread: false, kittyKeyboard: true })
setup.renderer.start()
const events = createEventStream()
const response = Promise.withResolvers<Response>()
const bodies: unknown[] = []
const location = { directory, project: { id: "project", directory, canonical: directory } }
const session = {
id: "ses_rename",
title: "Compiler cleanup",
projectID: "project",
location: { directory },
agent: "build",
model: { providerID: "provider", id: "model" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
}
const calls = createFetch(async (url, request) => {
if (url.pathname === "/api/location") return json(location)
if (url.pathname === "/api/agent")
return json({ location, data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }] })
if (url.pathname === "/api/model")
return json({ location, data: [{ id: "model", providerID: "provider", name: "Model", variants: [] }] })
if (url.pathname === "/api/provider") return json({ location, data: [{ id: "provider", name: "Provider" }] })
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
if (url.pathname === "/api/session/ses_rename") return json({ data: session })
if (/^\/api\/session\/ses_rename\/(message|inbox|permission)$/.test(url.pathname))
return json({ data: [], cursor: {} })
if (url.pathname === "/api/session/ses_rename/rename") {
bodies.push(await request.json())
return response.promise
}
return undefined
}, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
try {
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: {
get: async () => ({
tabs: { enabled: true, layout: "vertical" },
session: { sidebar: "hide" },
}),
update: async () => ({}),
},
packages: { resolve: async () => undefined },
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()
+16 -255
View File
@@ -1,13 +1,10 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { once } from "node:events"
import { CliRenderEvents, TextAttributes } from "@opentui/core"
import { testRender } from "@opentui/solid"
import { createSignal, onMount } from "solid-js"
import type { SessionInfo } from "@opencode-ai/client"
import { DialogOpen, DialogOpenKey } from "../../../src/component/dialog-open"
import { onMount } from "solid-js"
import { DialogOpen, DialogOpenKey, loadDialogOpen } from "../../../src/component/dialog-open"
import { ConfigProvider } from "../../../src/config"
import { ClientProvider } from "../../../src/context/client"
import { ClientProvider, useClient } from "../../../src/context/client"
import { DataProvider, useData } from "../../../src/context/data"
import { Keymap } from "../../../src/context/keymap"
import { LocationProvider, useLocation } from "../../../src/context/location"
@@ -88,7 +85,7 @@ test("finds and opens an exact session ID outside the recent list", async () =>
expect(fixture.route.data).toEqual({ type: "session", sessionID })
expect(fixture.location.ref).toEqual(remote)
} finally {
await fixture.dispose()
fixture.dispose()
}
})
@@ -134,7 +131,7 @@ test("shows the current project and opens its root", async () => {
}
})
test("shows projects while sessions refresh and preserves the selected project", async () => {
test("waits for sessions before showing the populated picker", async () => {
let resolveSessions!: (response: Response) => void
const sessions = new Promise<Response>((resolve) => (resolveSessions = resolve))
const fixture = await renderOpen((url) => {
@@ -160,9 +157,8 @@ test("shows projects while sessions refresh and preserves the selected project",
})
try {
await fixture.app.waitForFrame((frame) => frame.includes("Second project") && frame.includes("Refreshing"))
expect(fixture.app.captureCharFrame()).toContain("Search sessions and projects")
fixture.app.mockInput.pressArrow("down")
await fixture.app.renderOnce()
expect(fixture.app.captureCharFrame()).not.toContain("Search sessions and projects")
resolveSessions(
json({
@@ -181,6 +177,8 @@ test("shows projects while sessions refresh and preserves the selected project",
}),
)
await fixture.app.waitForFrame((frame) => frame.includes("Recent session") && frame.includes("Second project"))
fixture.app.mockInput.pressArrow("down")
fixture.app.mockInput.pressArrow("down")
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "home")
@@ -190,240 +188,6 @@ test("shows projects while sessions refresh and preserves the selected project",
}
})
test.each([false, true])("keeps a filtered selection visible after refresh with query reset %s", async (reset) => {
const sessions = Promise.withResolvers<Response>()
const fixture = await renderOpen((url) => {
if (url.pathname === "/api/session") return sessions.promise
if (url.pathname === "/api/project")
return json([
{
id: "proj_first",
canonical: "/tmp/opencode/first",
name: "First shared project",
time: { created: 1, updated: 2 },
sandboxes: [],
},
{
id: "proj_second",
canonical: "/tmp/opencode/second",
name: "Second shared project",
time: { created: 1, updated: 1 },
sandboxes: [],
},
])
return undefined
})
const selectedTitle = () =>
fixture.app
.captureSpans()
.lines.flatMap((line) => line.spans)
.filter((span) => span.attributes & TextAttributes.BOLD)
.map((span) => span.text)
.join("")
try {
await fixture.app.waitForFrame((frame) => frame.includes("Second shared project") && frame.includes("Refreshing"))
await fixture.app.mockInput.typeText("shared")
await fixture.app.waitForFrame(() => selectedTitle().includes("First shared project"))
fixture.app.mockInput.pressArrow("down")
await fixture.app.waitForFrame(() => selectedTitle().includes("Second shared project"))
sessions.resolve(
json({
data: Array.from({ length: 12 }, (_, index) => ({
...recentSession,
id: `ses_shared_${index}`,
title: "shared",
time: { created: 1, updated: index + 3 },
})),
cursor: {},
}),
)
await fixture.app.waitForFrame((frame) => frame.includes("Open") && !frame.includes("Refreshing"))
// Selection reveal runs on FRAME; the following paint must show the selected row.
const frame = once(fixture.app.renderer, CliRenderEvents.FRAME)
fixture.app.renderer.requestRender()
await frame
expect(fixture.app.captureCharFrame()).toContain("Second shared project")
expect(selectedTitle()).toContain("Second shared project")
if (reset) {
await fixture.app.mockInput.typeText(" project")
await fixture.app.waitForFrame(() => selectedTitle().includes("First shared project"))
expect(fixture.app.captureCharFrame()).toContain("Second shared project")
expect(selectedTitle()).not.toContain("Second shared project")
}
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "home")
expect(fixture.route.data).toEqual({
type: "home",
location: { directory: `/tmp/opencode/${reset ? "first" : "second"}` },
})
} finally {
sessions.resolve(json({ data: [], cursor: {} }))
await fixture.dispose()
}
})
const recentSession = {
id: "ses_recent",
projectID: "proj_recent",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 2 },
title: "Recent session",
location: { directory: "/fixture" },
}
test("sessions remain selectable while projects are still loading", async () => {
const projects = Promise.withResolvers<Response>()
const fixture = await renderOpen((url) => {
if (url.pathname === "/api/session") return json({ data: [recentSession], cursor: {} })
if (url.pathname === "/api/project") return projects.promise
return undefined
})
try {
await fixture.app.waitForFrame((frame) => frame.includes("Recent session") && frame.includes("Refreshing"))
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "session")
expect(fixture.route.data).toEqual({ type: "session", sessionID: recentSession.id })
} finally {
projects.resolve(json([]))
await fixture.dispose()
}
})
test("shows hydrated sessions immediately without waiting for either read", async () => {
const sessions = Promise.withResolvers<Response>()
const projects = Promise.withResolvers<Response>()
const fixture = await renderOpen(
(url) => {
if (url.pathname === "/api/session") return sessions.promise
if (url.pathname === "/api/project") return projects.promise
return undefined
},
({ data }) => data.session.remember(recentSession),
)
try {
await fixture.app.waitForFrame((frame) => frame.includes("Recent session") && frame.includes("Refreshing"))
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "session")
expect(fixture.route.data).toEqual({ type: "session", sessionID: recentSession.id })
} finally {
sessions.resolve(json({ data: [], cursor: {} }))
projects.resolve(json([]))
await fixture.dispose()
}
})
test("keeps an uncached moved session in the first successful refresh", async () => {
const response = Promise.withResolvers<Response>()
const destination = { directory: "/fixture/destination" }
const fixture = await renderOpen((url) => (url.pathname === "/api/session" ? response.promise : undefined))
try {
await fixture.app.waitForFrame((frame) => frame.includes("Refreshing"))
fixture.emit({
id: "evt_uncached_move",
created: 3,
type: "session.moved",
durable: { aggregateID: recentSession.id, seq: 1, version: 1 },
data: { sessionID: recentSession.id, location: destination, projectID: "proj_destination" },
})
// The following event supplies an ordered-stream receipt barrier without hydrating metadata.
fixture.emit({
id: "evt_move_received",
created: 4,
type: "session.execution.started",
durable: { aggregateID: recentSession.id, seq: 2, version: 1 },
data: { sessionID: recentSession.id },
})
await fixture.app.waitFor(() => fixture.data.session.status(recentSession.id) === "running")
expect(fixture.data.session.get(recentSession.id)).toBeUndefined()
response.resolve(
json({
data: [
{ ...recentSession, location: destination, projectID: "proj_destination", time: { created: 1, updated: 3 } },
],
cursor: {},
}),
)
await fixture.app.waitForFrame((frame) => frame.includes(recentSession.title) && !frame.includes("Refreshing"))
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "session")
expect(fixture.route.data).toEqual({ type: "session", sessionID: recentSession.id })
expect(fixture.location.ref).toEqual(destination)
} finally {
response.resolve(json({ data: [], cursor: {} }))
await fixture.dispose()
}
})
test("keeps the previous recent list usable when reopening fails to refresh", async () => {
let requests = 0
const fixture = await renderOpen((url) => {
if (url.pathname === "/api/session")
return requests++ === 0
? json({ data: [recentSession], cursor: {} })
: new Response("Unavailable", { status: 503 })
return undefined
})
try {
await fixture.app.waitForFrame((frame) => frame.includes("Recent session") && !frame.includes("Refreshing"))
fixture.app.mockInput.pressEscape()
await fixture.app.waitForFrame((frame) => !frame.includes("Recent session"))
fixture.open()
await fixture.app.waitForFrame((frame) => frame.includes("Could not refresh sessions"))
expect(fixture.app.captureCharFrame()).toContain("Recent session")
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "session")
expect(fixture.route.data).toEqual({ type: "session", sessionID: recentSession.id })
} finally {
await fixture.dispose()
}
})
test("shows an initial loading shell instead of reporting an empty list", async () => {
const sessions = Promise.withResolvers<Response>()
const projects = Promise.withResolvers<Response>()
const fixture = await renderOpen((url) => {
if (url.pathname === "/api/session") return sessions.promise
if (url.pathname === "/api/project") return projects.promise
return undefined
})
try {
await fixture.app.waitForFrame(
(frame) => frame.includes("Search sessions and projects") && frame.includes("Refreshing"),
)
expect(fixture.app.captureCharFrame()).not.toContain("No items available")
await fixture.app.mockInput.typeText("missing")
await fixture.app.waitForFrame((frame) => frame.includes("Searching sessions and projects"))
expect(fixture.app.captureCharFrame()).not.toContain("No matches")
sessions.resolve(json({ data: [], cursor: {} }))
projects.resolve(json([]))
await fixture.app.waitForFrame((frame) => frame.includes("No matches") && !frame.includes("Refreshing"))
} finally {
sessions.resolve(json({ data: [], cursor: {} }))
projects.resolve(json([]))
await fixture.dispose()
}
})
test("reports both refresh failures while keeping hydrated sessions usable", async () => {
const fixture = await renderOpen(
(url) => {
if (url.pathname === "/api/session" || url.pathname === "/api/project")
return new Response("Unavailable", { status: 503 })
return undefined
},
({ data }) => data.session.remember(recentSession),
)
try {
await fixture.app.waitForFrame((frame) => frame.includes("Could not refresh sessions and projects"))
expect(fixture.app.captureCharFrame()).toContain("Recent session")
} finally {
await fixture.dispose()
}
})
test("option arrows jump between sections", async () => {
const handler: FetchHandler = (url) => {
if (url.pathname === "/api/session")
@@ -527,21 +291,20 @@ async function renderOpen(
let location!: ReturnType<typeof useLocation>
let data!: ReturnType<typeof useData>
let storage!: ReturnType<typeof useStorage>
let open!: () => void
function Probe() {
const dialog = useDialog()
const [sessions, setSessions] = createSignal<SessionInfo[]>([])
const client = useClient()
route = useRoute()
location = useLocation()
data = useData()
storage = useStorage()
open = () =>
dialog.replace(() => <DialogOpen sessions={sessions()} onLoad={setSessions} />, undefined, {
key: DialogOpenKey,
size: "large",
})
onMount(() => void Promise.resolve(beforeOpen?.({ data, location })).then(open))
onMount(
() =>
void Promise.all([beforeOpen?.({ data, location }), loadDialogOpen(data, client)]).then(([, sessions]) =>
dialog.replace(() => <DialogOpen sessions={sessions} />, undefined, { key: DialogOpenKey, size: "large" }),
),
)
return null
}
@@ -581,8 +344,6 @@ async function renderOpen(
return {
app,
emit: events.emit,
open: () => open(),
get route() {
return route
},
@@ -101,37 +101,3 @@ test("continues navigation after the prepended page is laid out", async () => {
expect(events).toEqual(["loaded", "layout", "anchored", "continued"])
})
test.each(["success", "failure", "cancel", "takeover"])("settles superseded prepend navigation (%s)", async (mode) => {
const load = Promise.withResolvers<void>()
const layout: (() => void)[] = []
const events: (number | string)[] = []
let height = 100
const prepend = createHistoryPrepend({
sessionID: () => "session-1",
more: () => true,
loadMore: () => load.promise,
height: () => height,
afterLayout: (continuation) => layout.push(continuation),
active: () => true,
scrollBy: (amount) => events.push(amount),
})
prepend(-4, () => events.push("obsolete"))
prepend.cancel()
prepend.after(() => events.push("jump"))
if (mode === "cancel" || mode === "takeover") prepend.cancel()
if (mode === "takeover") expect(prepend(-8)).toBe(true)
if (mode === "failure") load.reject(new Error("offline"))
if (mode !== "failure") {
height = 160
load.resolve()
}
await Promise.resolve()
expect(events).toEqual(mode === "failure" ? ["jump"] : [])
layout.shift()?.()
expect(events).toEqual(
mode === "failure" ? ["jump"] : mode === "success" ? [60, "jump"] : mode === "takeover" ? [52] : [60],
)
prepend.after(() => events.push("idle"))
expect(events.at(-1)).toBe("idle")
})
@@ -32,6 +32,7 @@ for (const orientation of ["horizontal", "vertical"] as const) {
await using temporary = await tmpdir()
const [status, setStatus] = createSignal<SessionTabsStatus>(EMPTY_SESSION_TAB_STATUS)
const [active, setActive] = createSignal("second")
const [animations, setAnimations] = createSignal(false)
const [newTab, setNewTab] = createSignal(false)
const [preview, setPreview] = createSignal(false)
const settings: Info = { tabs: { enabled: true } }
@@ -90,7 +91,11 @@ for (const orientation of ["horizontal", "vertical"] as const) {
<ToastProvider>
<DialogProvider>
<box width="100%" height="100%">
<SessionTabs controller={controller} orientation={orientation} animations={false} />
<SessionTabs
controller={controller}
orientation={orientation}
animations={animations()}
/>
</box>
</DialogProvider>
</ToastProvider>
@@ -136,6 +141,7 @@ for (const orientation of ["horizontal", "vertical"] as const) {
}
for (const attention of ["question", "permission"] as const) {
setAnimations(false)
setActive("second")
setStatus({ ...EMPTY_SESSION_TAB_STATUS, busy: true, attention })
await app.renderOnce()
@@ -165,39 +171,84 @@ for (const orientation of ["horizontal", "vertical"] as const) {
const dim = glow()
expect(dim).toBeGreaterThan(0)
expect(dim).toBeLessThan(full)
setActive("second")
await app.renderOnce()
setAnimations(true)
await app.renderOnce()
expect(app.renderer.root.liveCount).toBe(0)
setActive("first")
await app.renderOnce()
expect(app.renderer.root.liveCount).toBe(0)
await app.waitForFrame(() => glow() > dim && glow() < full)
await app.waitForFrame(() => glow() === dim, { maxPasses: 60 })
setActive("second")
await app.renderOnce()
expect(app.renderer.root.liveCount).toBe(0)
await app.waitForFrame(() => glow() > dim && glow() < full)
await app.waitForFrame(() => glow() === full, { maxPasses: 60 })
setStatus(EMPTY_SESSION_TAB_STATUS)
await app.renderOnce()
expect(app.renderer.root.liveCount).toBeGreaterThan(0)
}
const glyph = "\u2022"
for (const unread of ["activity", "error"] as const) {
setAnimations(false)
setActive("second")
setStatus({ ...EMPTY_SESSION_TAB_STATUS, busy: true })
await app.renderOnce()
setAnimations(true)
setStatus({ ...EMPTY_SESSION_TAB_STATUS, unread })
await app.renderOnce()
const color = app
.captureSpans()
.lines.flatMap((line) => line.spans)
.find((span) => span.text.trim() === glyph)?.fg
expect(color?.toInts()).toEqual(
const color = () =>
app
.captureSpans()
.lines.flatMap((line) => line.spans)
.find((span) => span.text.trim() === glyph)?.fg
expect(color()?.toInts()).toEqual(
(unread === "error" ? theme.text.feedback.error.default : theme.text.status.unread).toInts(),
)
const brightness = () => {
const value = color()
return value ? value.r + value.g + value.b : undefined
}
const initial = brightness()!
await app.mockMouse.click(1, orientation === "vertical" ? 1 : 0)
await app.renderOnce()
expect(active()).toBe("first")
expect(status().unread).toBeUndefined()
expect(app.captureCharFrame()).toContain(" First")
expect(app.captureCharFrame()).not.toContain(`${glyph} First`)
expect(app.captureCharFrame()).toContain(`${glyph} First`)
await app.waitForFrame((frame) => frame.includes(`${glyph} First`) && (brightness() ?? -1) > initial)
const peak = brightness()!
await app.waitForFrame((frame) => frame.includes(`${glyph} First`) && (brightness() ?? Infinity) < peak)
await app.waitForFrame((frame) => frame.includes(" First"), { maxPasses: 60 })
}
setAnimations(false)
setStatus({ ...EMPTY_SESSION_TAB_STATUS, unread: "activity" })
await app.renderOnce()
setAnimations(true)
await app.mockMouse.click(1, orientation === "vertical" ? 1 : 0)
setStatus({ ...EMPTY_SESSION_TAB_STATUS, busy: true })
await app.waitForFrame((frame) => SPINNER_FRAMES.slice(1).some((glyph) => frame.includes(`${glyph} First`)))
setStatus({ ...EMPTY_SESSION_TAB_STATUS, busy: true, attention: "question" })
await app.waitForFrame((frame) => frame.includes("? First"))
await config.update((draft) => {
draft.tabs.indicators = "numbers"
})
await app.waitForFrame((frame) => frame.includes("1 First") && frame.includes("2 Second"))
setStatus({ ...EMPTY_SESSION_TAB_STATUS, busy: true })
await app.renderOnce()
expect(app.captureCharFrame()).toContain("1 First")
await config.update((draft) => {
draft.tabs.indicators = "status"
})
await app.waitForFrame((frame) => frame.includes(`${SPINNER_FRAMES[0]} First`))
await app.waitForFrame((frame) => SPINNER_FRAMES.some((glyph) => frame.includes(`${glyph} First`)))
setAnimations(false)
setStatus(EMPTY_SESSION_TAB_STATUS)
setActive("second")
await app.renderOnce()
@@ -1,145 +0,0 @@
import { expect, test } from "bun:test"
import { BoxRenderable, RGBA, TextAttributes, TextRenderable } from "@opentui/core"
import { createTestRenderer, ManualClock } from "@opentui/core/testing"
import { TitleShimmerRenderable } from "../../src/component/title-shimmer"
test("shimmer fades in from idle and fades out on unchanged completion", async () => {
const clock = new ManualClock()
const app = await createTestRenderer({ width: 24, height: 1, useThread: false, clock })
const title = new TitleShimmerRenderable(app.renderer, {
width: 24,
height: 1,
content: "Compiler cleanup",
fg: "#eeeeee",
backdrop: RGBA.fromHex("#111111"),
rename: { title: "Compiler cleanup", pending: false },
})
app.renderer.root.add(title)
try {
await app.renderOnce()
const frame = app.captureCharFrame()
const colors = app.captureSpans()
clock.advance(2000)
title.rename = { title: "Compiler cleanup", pending: true }
await app.renderOnce()
expect(app.captureSpans()).toEqual(colors)
clock.advance(120)
await app.renderOnce()
const middle = app.captureSpans().lines[0].spans.findLast((span) => span.text.trim())?.fg.r ?? 0
expect(middle).toBeLessThan(colors.lines[0].spans[0].fg.r)
clock.advance(120)
await app.renderOnce()
expect(app.captureSpans().lines[0].spans.findLast((span) => span.text.trim())?.fg.r ?? 0).toBeLessThan(middle)
expect(app.captureSpans()).not.toEqual(colors)
expect(app.captureCharFrame()).toBe(frame)
title.rename = { title: "Compiler cleanup", pending: false }
await app.renderOnce()
expect(app.renderer.root.liveCount).toBe(1)
clock.advance(240)
await app.renderOnce()
expect(app.renderer.root.liveCount).toBe(0)
expect(app.captureSpans()).toEqual(colors)
title.enabled = false
title.rename = { title: "Compiler cleanup", pending: true }
await app.renderOnce()
expect(app.renderer.root.liveCount).toBe(0)
} finally {
app.renderer.destroy()
}
})
test("a feathered wipe keeps the old shimmer moving without dimming the revealed new title", async () => {
const clock = new ManualClock()
const app = await createTestRenderer({ width: 16, height: 1, useThread: false, clock })
const title = new TitleShimmerRenderable(app.renderer, {
width: 16,
height: 1,
content: "ABCDEFGHIJKLMNOP",
fg: "#eeeeee",
attributes: TextAttributes.ITALIC,
backdrop: RGBA.fromHex("#111111"),
rename: { title: "ABCDEFGHIJKLMNOP", pending: true },
})
app.renderer.root.add(title)
try {
await app.renderOnce()
clock.advance(600)
await app.renderOnce()
const colors = app.captureSpans()
title.content = "abcdefghijklmnop"
title.rename = { title: "abcdefghijklmnop", pending: true }
await app.renderOnce()
expect(app.captureCharFrame().trim()).toBe("ABCDEFGHIJKLMNOP")
expect(app.captureSpans()).toEqual(colors)
clock.advance(225)
await app.renderOnce()
expect(app.captureCharFrame().trim()).toBe("abcdefghIJKLMNOP")
const spans = app.captureSpans().lines[0].spans
expect(spans[0].fg.equals(RGBA.fromHex("#eeeeee"))).toBe(true)
expect(spans.some((span) => span.fg.equals(RGBA.fromHex("#111111")))).toBe(true)
expect(spans.at(-1)?.fg.toInts()).not.toEqual(colors.lines[0].spans.at(-1)?.fg.toInts())
expect(spans.every((span) => Boolean(span.attributes & TextAttributes.ITALIC))).toBe(true)
clock.advance(225)
await app.renderOnce()
expect(app.captureCharFrame().trim()).toBe("abcdefghijklmnop")
expect(app.renderer.root.liveCount).toBe(0)
title.rename = { title: "abcdefghijklmnop", pending: false }
title.content = "Manual"
title.rename = { title: "Manual", pending: false }
await app.renderOnce()
expect(app.captureCharFrame().trim()).toBe("Manual")
expect(app.renderer.root.liveCount).toBe(0)
title.rename = { title: "Manual", pending: true }
await app.renderOnce()
title.content = "Next"
title.rename = { title: "Next", pending: false }
title.enabled = false
await app.renderOnce()
expect(app.captureCharFrame().trim()).toBe("Next")
title.enabled = true
expect(app.renderer.root.liveCount).toBe(0)
} finally {
app.renderer.destroy()
}
})
test("native Unicode clipping and shorter replacement leave no split glyphs or old tail", async () => {
const clock = new ManualClock()
const app = await createTestRenderer({ width: 24, height: 3, useThread: false, clock })
const content = "A\u65e5B \u{1f680} cafe\u0301"
const title = new TitleShimmerRenderable(app.renderer, {
width: 20,
height: 1,
content,
fg: "#eeeeee",
wrapMode: "none",
backdrop: RGBA.fromHex("#111111"),
rename: { title: content, pending: true },
})
const plain = new TextRenderable(app.renderer, { width: 20, height: 1, content, wrapMode: "none" })
const shadedBox = new BoxRenderable(app.renderer, { width: 6, height: 1, marginLeft: 2, overflow: "hidden" })
const plainBox = new BoxRenderable(app.renderer, { width: 6, height: 1, marginLeft: 2, overflow: "hidden" })
shadedBox.add(title)
plainBox.add(plain)
app.renderer.root.add(shadedBox)
app.renderer.root.add(plainBox)
app.renderer.root.add(new TextRenderable(app.renderer, { content: "untouched" }))
try {
await app.renderOnce()
expect(app.captureCharFrame().split("\n")[0]).toBe(app.captureCharFrame().split("\n")[1])
title.content = "Short"
title.rename = { title: "Short", pending: false }
clock.advance(200)
await app.renderOnce()
expect(app.captureCharFrame().split("\n")[0].trim()).toBe("Sh B")
expect(app.captureCharFrame().split("\n")[2]).toContain("untouched")
clock.advance(250)
await app.renderOnce()
expect(app.captureCharFrame().split("\n")[0].trim()).toBe("Short")
expect(app.renderer.root.liveCount).toBe(0)
} finally {
app.renderer.destroy()
}
})
-389
View File
@@ -1,389 +0,0 @@
import { expect, test } from "bun:test"
import { type Renderable, ScrollBoxRenderable } from "@opentui/core"
import { createTestRenderer } from "@opentui/core/testing"
import { Effect, FileSystem } from "effect"
import { Global } from "@opencode-ai/util/global"
import { createEventStream, createFetch, directory, json } from "./fixture/tui-client"
import { tmpdir } from "./fixture/fixture"
test.each([
"bottom",
"scrolled",
"cancel",
"scroll-cancel",
"up-cancel",
"mouse-cancel",
"page-cancel",
"failure",
"mixed",
"close",
"resize-cancel",
"settling",
"settling-scrolled",
"settling-reveal",
"prepend",
"prepend-navigation",
"prepend-failure",
])("Home loads a stable, bounded beginning (%s)", async (mode) => {
await using state = await tmpdir()
const setup = await createTestRenderer({ width: 100, height: 30, useThread: false, kittyKeyboard: true })
setup.renderer.start()
const session = {
id: "dummy",
title: "Long history",
projectID: "project",
location: { directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
}
const messages = Array.from({ length: 400 }, (_, index) =>
mode === "mixed" && index % 2
? {
id: `message-${index}`,
type: "assistant",
agent: "build",
model: { providerID: "demo", id: "demo-model" },
content: [{ type: "text", text: `History message ${String(index).padStart(4, "0")}` }],
finish: "stop",
time: { created: index, completed: index + 1 },
}
: {
id: `message-${index}`,
type: "user",
text: `History message ${String(index).padStart(4, "0")}`,
time: { created: index },
},
)
const pages: { end: number; limit: number }[] = []
const release = Promise.withResolvers<void>()
const finish = Promise.withResolvers<void>()
const prior = Promise.withResolvers<void>()
const aborted = Promise.withResolvers<void>()
const events = createEventStream()
const calls = createFetch(async (url, request) => {
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
if (url.pathname === "/api/session/dummy") return json({ data: session })
if (url.pathname === "/api/session/dummy/message") {
const end = Number(url.searchParams.get("cursor") ?? messages.length)
const limit = Number(url.searchParams.get("limit"))
const start = Math.max(0, end - limit)
pages.push({ end, limit })
if (end < messages.length) {
request.signal.addEventListener("abort", () => aborted.resolve(), { once: true })
await (mode.startsWith("prepend") && limit === 20 ? prior.promise : release.promise)
}
if (end === 0) await finish.promise
if (mode === "failure" && end === 0 && pages.filter((page) => page.end === 0).length === 1)
return json({ message: "offline" }, { status: 503 })
if (mode === "prepend-failure" && limit === 200) return json({ message: "offline" }, { status: 503 })
return json({ data: messages.slice(start, end).toReversed(), cursor: end ? { next: String(start) } : {} })
}
if (url.pathname === "/api/session/dummy/inbox") return json({ data: [] })
if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
return undefined
}, events)
const server = Bun.serve({ port: 0, idleTimeout: 0, fetch: (request) => calls.fetch(request) })
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: {
get: async () => ({
animations: false,
tabs: { enabled: false },
keybinds: {
"session.line.up": "f6",
"session.page.down": "f7",
"session.page.up": "f8",
"session.message.previous": "f9",
},
}),
update: async () => ({}),
},
packages: { resolve: async () => undefined },
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))
}
})
-1
View File
@@ -193,7 +193,6 @@ const layer = Layer.effect(
}
const tree = yield* reify({ dir, add: [pkg] })
if (isMutable(parsed)) refreshed.add(pkg)
const first = tree.edgesOut.values().next().value?.to
if (!first) {
const installed = yield* installedName(pkg, dir, parsedName)

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