diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index 6e781d74b..0d31b5d7c 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -36,6 +36,8 @@ export type Payload = { export type Projector = (event: Payload) => Effect.Effect type AnyProjector = (event: Payload) => Effect.Effect +export type Listener = (event: Payload) => Effect.Effect +export type Unsubscribe = Effect.Effect export type SerializedEvent = { readonly id: ID @@ -114,6 +116,7 @@ export interface Interface { ) => Effect.Effect> readonly subscribe: (definition: D) => Stream.Stream> readonly all: () => Stream.Stream + readonly listen: (listener: Listener) => Effect.Effect readonly project: (definition: D, projector: Projector) => Effect.Effect readonly replay: ( event: SerializedEvent, @@ -135,6 +138,7 @@ export const layer = Layer.effect( const all = yield* PubSub.unbounded() const typed = new Map>() const projectors = new Map() + const listeners = new Array() const { db } = yield* Database.Service const getOrCreate = (definition: Definition) => @@ -247,6 +251,9 @@ export const layer = Layer.effect( data, } as Payload yield* commitSyncEvent(event as Payload) + for (const listener of listeners) { + yield* listener(event as Payload) + } const pubsub = typed.get(event.type) if (pubsub) yield* PubSub.publish(pubsub, event as Payload) yield* PubSub.publish(all, event as Payload) @@ -270,6 +277,9 @@ export const layer = Layer.effect( } as Payload yield* commitSyncEvent(payload, { seq: event.seq, aggregateID: event.aggregateID, ownerID: options?.ownerID }) if (options?.publish) { + for (const listener of listeners) { + yield* listener(payload) + } const pubsub = typed.get(payload.type) if (pubsub) yield* PubSub.publish(pubsub, payload) yield* PubSub.publish(all, payload) @@ -336,6 +346,15 @@ export const layer = Layer.effect( const streamAll = (): Stream.Stream => Stream.fromPubSub(all) + const listen = (listener: Listener): Effect.Effect => + Effect.sync(() => { + listeners.push(listener) + return Effect.sync(() => { + const index = listeners.indexOf(listener) + if (index >= 0) listeners.splice(index, 1) + }) + }) + const project = (definition: D, projector: Projector): Effect.Effect => Effect.sync(() => { const list = projectors.get(definition.type) ?? [] @@ -343,7 +362,7 @@ export const layer = Layer.effect( projectors.set(definition.type, list) }) - return Service.of({ publish, subscribe, all: streamAll, project, replay, replayAll, remove, claim }) + return Service.of({ publish, subscribe, all: streamAll, listen, project, replay, replayAll, remove, claim }) }), ) diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index f715e8c24..3dc268385 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -78,12 +78,19 @@ export class NotFoundError extends Schema.TaggedErrorClass()("Ses sessionID: SessionSchema.ID, }) {} +export class OperationUnavailableError extends Schema.TaggedErrorClass()( + "Session.OperationUnavailableError", + { + operation: Schema.Literals(["prompt", "compact", "wait"]), + }, +) {} + export class MessageDecodeError extends Schema.TaggedErrorClass()("Session.MessageDecodeError", { sessionID: SessionSchema.ID, messageID: SessionMessage.ID, }) {} -export type Error = NotFoundError | MessageDecodeError +export type Error = NotFoundError | MessageDecodeError | OperationUnavailableError export interface Interface { readonly list: (input?: ListInput) => Effect.Effect @@ -99,8 +106,10 @@ export interface Interface { time: number direction: "previous" | "next" } - }) => Effect.Effect - readonly context: (sessionID: SessionSchema.ID) => Effect.Effect + }) => Effect.Effect + readonly context: ( + sessionID: SessionSchema.ID, + ) => Effect.Effect readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect readonly switchModel: (input: { sessionID: SessionSchema.ID; model: ModelV2.Ref }) => Effect.Effect readonly prompt: (input: { @@ -109,7 +118,7 @@ export interface Interface { prompt: Prompt delivery?: SessionSchema.Delivery resume?: boolean - }) => Effect.Effect + }) => Effect.Effect readonly shell: (input: { id?: EventV2.ID sessionID: SessionSchema.ID @@ -124,8 +133,8 @@ export interface Interface { delivery?: SessionSchema.Delivery resume?: boolean }) => Effect.Effect - readonly compact: (input: CompactInput) => Effect.Effect - readonly wait: (id: SessionSchema.ID) => Effect.Effect + readonly compact: (input: CompactInput) => Effect.Effect + readonly wait: (id: SessionSchema.ID) => Effect.Effect readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect } @@ -227,24 +236,91 @@ export const layer = Layer.effect( ) return (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row)) }), - messages: Effect.fn("V2Session.messages")(function* () { - return yield* Effect.die(new Error("Session.messages is not implemented")) + messages: Effect.fn("V2Session.messages")(function* (input) { + yield* result.get(input.sessionID) + const direction = input.cursor?.direction ?? "next" + const requestedOrder = input.order ?? "desc" + const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder + const boundary = input.cursor + ? order === "asc" + ? or( + gt(SessionMessageTable.time_created, input.cursor.time), + and( + eq(SessionMessageTable.time_created, input.cursor.time), + gt(SessionMessageTable.id, input.cursor.id), + ), + ) + : or( + lt(SessionMessageTable.time_created, input.cursor.time), + and( + eq(SessionMessageTable.time_created, input.cursor.time), + lt(SessionMessageTable.id, input.cursor.id), + ), + ) + : undefined + const where = boundary + ? and(eq(SessionMessageTable.session_id, input.sessionID), boundary) + : eq(SessionMessageTable.session_id, input.sessionID) + const query = db + .select() + .from(SessionMessageTable) + .where(where) + .orderBy( + order === "asc" ? asc(SessionMessageTable.time_created) : desc(SessionMessageTable.time_created), + order === "asc" ? asc(SessionMessageTable.id) : desc(SessionMessageTable.id), + ) + const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe( + Effect.orDie, + ) + return yield* Effect.forEach(direction === "previous" ? rows.toReversed() : rows, decode) }), - context: Effect.fn("V2Session.context")(function* () { - return yield* Effect.die(new Error("Session.context is not implemented")) + context: Effect.fn("V2Session.context")(function* (sessionID) { + yield* result.get(sessionID) + const compaction = yield* db + .select() + .from(SessionMessageTable) + .where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction"))) + .orderBy(desc(SessionMessageTable.time_created), desc(SessionMessageTable.id)) + .limit(1) + .get() + .pipe(Effect.orDie) + const rows = yield* db + .select() + .from(SessionMessageTable) + .where( + and( + eq(SessionMessageTable.session_id, sessionID), + compaction + ? or( + gt(SessionMessageTable.time_created, compaction.time_created), + and( + eq(SessionMessageTable.time_created, compaction.time_created), + gte(SessionMessageTable.id, compaction.id), + ), + ) + : undefined, + ), + ) + .orderBy(asc(SessionMessageTable.time_created), asc(SessionMessageTable.id)) + .all() + .pipe(Effect.orDie) + return yield* Effect.forEach(rows, decode) }), - prompt: Effect.fn("V2Session.prompt")(function* () { - return yield* Effect.die(new Error("Session.prompt is not implemented")) + prompt: Effect.fn("V2Session.prompt")(function* (input) { + yield* result.get(input.sessionID) + return yield* Effect.fail(new OperationUnavailableError({ operation: "prompt" })) }), shell: Effect.fn("V2Session.shell")(function* () {}), skill: Effect.fn("V2Session.skill")(function* () {}), switchAgent: Effect.fn("V2Session.switchAgent")(function* () {}), switchModel: Effect.fn("V2Session.switchModel")(function* () {}), - compact: Effect.fn("V2Session.compact")(function* () { - return yield* Effect.die(new Error("Session.compact is not implemented")) + compact: Effect.fn("V2Session.compact")(function* (input) { + yield* result.get(input.sessionID) + return yield* new OperationUnavailableError({ operation: "compact" }) }), - wait: Effect.fn("V2Session.wait")(function* () { - return yield* Effect.die(new Error("Session.wait is not implemented")) + wait: Effect.fn("V2Session.wait")(function* (sessionID) { + yield* result.get(sessionID) + return yield* new OperationUnavailableError({ operation: "wait" }) }), resume: Effect.fn("V2Session.resume")(function* () {}), move: Effect.fn("V2Session.move")(function* () {}), diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index d58d41301..82e22b20c 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -27,7 +27,7 @@ type Usage = { } } -function usage(part: typeof SessionLegacy.Event.PartUpdated.Type["data"]["part"] | unknown): Usage | undefined { +function usage(part: (typeof SessionLegacy.Event.PartUpdated.Type)["data"]["part"] | unknown): Usage | undefined { if (typeof part !== "object" || part === null) return undefined const value = part as Record if (value.type !== "step-finish") return undefined @@ -39,7 +39,7 @@ function sessionRow(info: SessionLegacy.SessionInfo): typeof SessionTable.$infer return { id: info.id, project_id: info.projectID, - workspace_id: info.workspaceID, + workspace_id: info.workspaceID ?? null, parent_id: info.parentID, slug: info.slug, directory: info.directory, @@ -68,19 +68,23 @@ function sessionRow(info: SessionLegacy.SessionInfo): typeof SessionTable.$infer } } -function messageData(info: typeof SessionLegacy.Event.MessageUpdated.Type["data"]["info"]): typeof MessageTable.$inferInsert.data { +function messageData( + info: (typeof SessionLegacy.Event.MessageUpdated.Type)["data"]["info"], +): typeof MessageTable.$inferInsert.data { const { id: _, sessionID: __, ...rest } = info return rest as DeepMutable } -function partData(part: typeof SessionLegacy.Event.PartUpdated.Type["data"]["part"]): typeof PartTable.$inferInsert.data { +function partData( + part: (typeof SessionLegacy.Event.PartUpdated.Type)["data"]["part"], +): typeof PartTable.$inferInsert.data { const { id: _, messageID: __, sessionID: ___, ...rest } = part return rest as DeepMutable } function applyUsage( db: DatabaseService, - sessionID: typeof SessionLegacy.Event.MessageUpdated.Type["data"]["sessionID"], + sessionID: (typeof SessionLegacy.Event.MessageUpdated.Type)["data"]["sessionID"], value: Usage, sign = 1, ) { @@ -108,7 +112,9 @@ function run(db: DatabaseService, event: SessionEvent.Event) { const rows = yield* db .select() .from(SessionMessageTable) - .where(and(eq(SessionMessageTable.session_id, event.data.sessionID), eq(SessionMessageTable.type, "assistant"))) + .where( + and(eq(SessionMessageTable.session_id, event.data.sessionID), eq(SessionMessageTable.type, "assistant")), + ) .all() .pipe(Effect.orDie) return rows @@ -123,7 +129,9 @@ function run(db: DatabaseService, event: SessionEvent.Event) { const rows = yield* db .select() .from(SessionMessageTable) - .where(and(eq(SessionMessageTable.session_id, event.data.sessionID), eq(SessionMessageTable.type, "compaction"))) + .where( + and(eq(SessionMessageTable.session_id, event.data.sessionID), eq(SessionMessageTable.type, "compaction")), + ) .all() .pipe(Effect.orDie) return rows diff --git a/packages/core/src/workspace.ts b/packages/core/src/workspace.ts index a532ba525..30d33abbe 100644 --- a/packages/core/src/workspace.ts +++ b/packages/core/src/workspace.ts @@ -4,7 +4,7 @@ import { Schema } from "effect" import { withStatics } from "./schema" import { Identifier } from "./util/identifier" -export const ID = Schema.String.pipe( +export const ID = Schema.String.check(Schema.isStartsWith("wrk")).pipe( Schema.brand("WorkspaceV2.ID"), withStatics((schema) => ({ ascending: (id?: string) => { diff --git a/packages/core/test/event.test.ts b/packages/core/test/event.test.ts index 1229d3e8f..063235c6b 100644 --- a/packages/core/test/event.test.ts +++ b/packages/core/test/event.test.ts @@ -180,6 +180,29 @@ describe("EventV2", () => { }), ) + it.effect("runs listeners inline after projectors", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const received = new Array() + yield* events.project(SyncMessage, () => + Effect.sync(() => { + received.push("projector") + }), + ) + const unsubscribe = yield* events.listen(() => + Effect.sync(() => { + received.push("listener") + }), + ) + + yield* events.publish(SyncMessage, { id: "one", text: "hello" }) + yield* unsubscribe + yield* events.publish(SyncMessage, { id: "one", text: "after unsubscribe" }) + + expect(received).toEqual(["projector", "listener", "projector"]) + }), + ) + it.effect("inserts sync event rows on publish", () => Effect.gen(function* () { const events = yield* EventV2.Service diff --git a/packages/opencode/src/bus/bus-event.ts b/packages/opencode/src/bus/bus-event.ts deleted file mode 100644 index 5a9e52ef0..000000000 --- a/packages/opencode/src/bus/bus-event.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { Schema } from "effect" -import { EventV2 } from "@opencode-ai/core/event" - -export type Definition = { - type: Type - properties: Properties -} - -const registry = new Map() - -export function define( - type: Type, - properties: Properties, -): Definition { - const result = { type, properties } - registry.set(type, result) - return result -} - -export function effectPayloads() { - return [ - ...registry - .entries() - .map(([type, def]) => - Schema.Struct({ - id: Schema.String, - type: Schema.Literal(type), - properties: def.properties, - }).annotate({ identifier: `Event.${type}` }), - ) - .toArray(), - ...EventV2.registry - .values() - .map((definition) => - Schema.Struct({ - id: Schema.String, - type: Schema.Literal(definition.type), - properties: definition.data, - }).annotate({ identifier: `Event.${definition.type}` }), - ) - .toArray(), - ] -} - -export * as BusEvent from "./bus-event" diff --git a/packages/opencode/src/bus/index.ts b/packages/opencode/src/bus/index.ts deleted file mode 100644 index 920cccd64..000000000 --- a/packages/opencode/src/bus/index.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { Effect, Exit, Layer, PubSub, Scope, Context, Stream, Schema } from "effect" -import { EffectBridge } from "@/effect/bridge" -import * as Log from "@opencode-ai/core/util/log" -import { BusEvent } from "./bus-event" -import { EventV2 } from "@opencode-ai/core/event" -import { GlobalBus } from "./global" -import { InstanceState } from "@/effect/instance-state" -import { makeRuntime } from "@/effect/run-service" -import { serviceUse } from "@opencode-ai/core/effect/service-use" -import { Identifier } from "@/id/id" -import type { InstanceContext } from "@/project/instance-context" -import { InstanceRef } from "@/effect/instance-ref" - -const log = Log.create({ service: "bus" }) - -type BusDefinition = BusEvent.Definition | EventV2.Definition -type BusSchema = D extends { data: infer S extends Schema.Top } - ? S - : D extends { properties: infer S extends Schema.Top } - ? S - : never -type BusProperties = Schema.Schema.Type> - -export const InstanceDisposed = BusEvent.define( - "server.instance.disposed", - Schema.Struct({ - directory: Schema.String, - }), -) - -type Payload = { - id: string - type: D["type"] - properties: BusProperties -} - -type State = { - wildcard: PubSub.PubSub - typed: Map> -} - -export interface Interface { - readonly publish: ( - def: D, - properties: BusProperties, - options?: { id?: string }, - ) => Effect.Effect - // subscribe / subscribeAll are eager: the underlying PubSub subscription is - // acquired in the caller's Scope at `yield*` time. Any publish after the - // yield is delivered, even if stream consumption starts later. The previous - // Stream-returning shape acquired the subscription lazily on first pull, - // opening a race window during which publishes were lost — see - // test/bus/bus-effect.test.ts RACE tests. - readonly subscribe: ( - def: D, - ) => Effect.Effect>, never, Scope.Scope> - readonly subscribeAll: () => Effect.Effect, never, Scope.Scope> - readonly subscribeCallback: ( - def: D, - callback: (event: Payload) => unknown, - ) => Effect.Effect<() => void> - readonly subscribeAllCallback: (callback: (event: any) => unknown) => Effect.Effect<() => void> -} - -export class Service extends Context.Service()("@opencode/Bus") {} - -export const use = serviceUse(Service) - -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const state = yield* InstanceState.make( - Effect.fn("Bus.state")(function* (ctx) { - const wildcard = yield* PubSub.unbounded() - const typed = new Map>() - - yield* Effect.addFinalizer(() => - Effect.gen(function* () { - // Publish InstanceDisposed before shutting down so subscribers see it - yield* PubSub.publish(wildcard, { - type: InstanceDisposed.type, - id: createID(), - properties: { directory: ctx.directory }, - }) - yield* PubSub.shutdown(wildcard) - for (const ps of typed.values()) { - yield* PubSub.shutdown(ps) - } - }), - ) - - return { wildcard, typed } - }), - ) - - function getOrCreate(state: State, def: D) { - return Effect.gen(function* () { - let ps = state.typed.get(def.type) - if (!ps) { - ps = yield* PubSub.unbounded() - state.typed.set(def.type, ps) - } - return ps as unknown as PubSub.PubSub> - }) - } - - function publish(def: D, properties: BusProperties, options?: { id?: string }) { - return Effect.gen(function* () { - const s = yield* InstanceState.get(state) - const payload: Payload = { id: options?.id ?? createID(), type: def.type, properties } - log.info("publishing", { type: def.type }) - - const ps = s.typed.get(def.type) - if (ps) yield* PubSub.publish(ps, payload) - yield* PubSub.publish(s.wildcard, payload) - - const dir = yield* InstanceState.directory - const context = yield* InstanceState.context - const workspace = yield* InstanceState.workspaceID - - GlobalBus.emit("event", { - directory: dir, - project: context.project.id, - workspace, - payload, - }) - }) - } - - const subscribe = ( - def: D, - ): Effect.Effect>, never, Scope.Scope> => - Effect.gen(function* () { - log.info("subscribing", { type: def.type }) - const s = yield* InstanceState.get(state) - const ps = yield* getOrCreate(s, def) - const subscription = yield* PubSub.subscribe(ps) - yield* Effect.addFinalizer(() => Effect.sync(() => log.info("unsubscribing", { type: def.type }))) - return Stream.fromSubscription(subscription) - }) - - const subscribeAll = (): Effect.Effect, never, Scope.Scope> => - Effect.gen(function* () { - log.info("subscribing", { type: "*" }) - const s = yield* InstanceState.get(state) - const subscription = yield* PubSub.subscribe(s.wildcard) - yield* Effect.addFinalizer(() => Effect.sync(() => log.info("unsubscribing", { type: "*" }))) - return Stream.fromSubscription(subscription) - }) - - function on(pubsub: PubSub.PubSub, type: string, callback: (event: T) => unknown) { - return Effect.gen(function* () { - log.info("subscribing", { type }) - const bridge = yield* EffectBridge.make() - const scope = yield* Scope.make() - const subscription = yield* Scope.provide(scope)(PubSub.subscribe(pubsub)) - - yield* Scope.provide(scope)( - Stream.fromSubscription(subscription).pipe( - Stream.runForEach((msg) => - Effect.tryPromise({ - try: () => Promise.resolve().then(() => callback(msg)), - catch: (cause) => { - log.error("subscriber failed", { type, cause }) - }, - }).pipe(Effect.ignore), - ), - Effect.forkScoped, - ), - ) - - return () => { - log.info("unsubscribing", { type }) - bridge.fork(Scope.close(scope, Exit.void)) - } - }) - } - - const subscribeCallback = Effect.fn("Bus.subscribeCallback")(function* ( - def: D, - callback: (event: Payload) => unknown, - ) { - const s = yield* InstanceState.get(state) - const ps = yield* getOrCreate(s, def) - return yield* on(ps, def.type, callback) - }) - - const subscribeAllCallback = Effect.fn("Bus.subscribeAllCallback")(function* (callback: (event: any) => unknown) { - const s = yield* InstanceState.get(state) - return yield* on(s.wildcard, "*", callback) - }) - - return Service.of({ publish, subscribe, subscribeAll, subscribeCallback, subscribeAllCallback }) - }), -) - -export const defaultLayer = layer - -const { runPromise, runSync } = makeRuntime(Service, layer) - -// runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe, -// Scope.make, Effect.forkScoped) is entirely synchronous. If any step becomes async, this will throw. -export function createID() { - return Identifier.create("evt", "ascending") -} - -export async function publish( - ctx: InstanceContext, - def: D, - properties: BusProperties, - options?: { id?: string }, -) { - return runPromise((svc) => svc.publish(def, properties, options).pipe(Effect.provideService(InstanceRef, ctx))) -} - -export function subscribe(def: D, callback: (event: Payload) => unknown) { - return runSync((svc) => svc.subscribeCallback(def, callback)) -} - -export function subscribeAll(callback: (event: any) => unknown) { - return runSync((svc) => svc.subscribeAllCallback(callback)) -} - -export * as Bus from "." diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index 13c7dd30b..e12604c3a 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -27,8 +27,9 @@ import { Session } from "@/session/session" import type { SessionID } from "../../session/schema" import { MessageID, PartID } from "../../session/schema" import { Provider } from "@/provider/provider" -import { Bus } from "../../bus" import { MessageV2 } from "../../session/message-v2" +import { EventV2Bridge } from "@/event-v2-bridge" +import { EventV2 } from "@opencode-ai/core/event" import { SessionPrompt } from "@/session/prompt" import { Git } from "@/git" import { setTimeout as sleep } from "node:timers/promises" @@ -436,7 +437,7 @@ export const GithubRunCommand = effectCmd({ const sessionSvc = yield* Session.Service const sessionShare = yield* SessionShare.Service const sessionPrompt = yield* SessionPrompt.Service - const busSvc = yield* Bus.Service + const events = yield* EventV2Bridge.Service const runLocalEffect = (effect: Effect.Effect) => Effect.runPromise(effect.pipe(Effect.provideService(InstanceRef, ctx))) yield* Effect.promise(async () => { @@ -898,10 +899,12 @@ export const GithubRunCommand = effectCmd({ let text = "" await runLocalEffect( - busSvc.subscribeCallback(MessageV2.Event.PartUpdated, (evt) => { - if (evt.properties.part.sessionID !== session.id) return + events.listen((evt) => { + if (evt.type !== MessageV2.Event.PartUpdated.type) return Effect.void + const data = evt.data as EventV2.Data + if (data.part.sessionID !== session.id) return Effect.void //if (evt.properties.part.messageID === messageID) return - const part = evt.properties.part + const part = data.part if (part.type === "tool" && part.state.status === "completed") { const [tool, color] = TOOL[part.tool] ?? [part.tool, UI.Style.TEXT_INFO_BOLD] @@ -921,9 +924,10 @@ export const GithubRunCommand = effectCmd({ UI.println(UI.markdown(text)) UI.empty() text = "" - return + return Effect.void } } + return Effect.void }), ) } diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index f7ea030aa..75e41b3c8 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -17,7 +17,8 @@ import path from "path" import { Global } from "@opencode-ai/core/global" import { modify, applyEdits } from "jsonc-parser" import { Filesystem } from "@/util/filesystem" -import { Bus } from "../../bus" +import { EventV2Bridge } from "@/event-v2-bridge" +import { EventV2 } from "@opencode-ai/core/event" import { Effect } from "effect" function getAuthStatusIcon(status: MCP.AuthStatus): string { @@ -256,13 +257,17 @@ export const McpAuthCommand = effectCmd({ spinner.start("Starting OAuth flow...") // Subscribe to browser open failure events to show URL for manual opening - const unsubscribe = Bus.subscribe(MCP.BrowserOpenFailed, (evt) => { - if (evt.properties.mcpName === serverName) { + const events = yield* EventV2Bridge.Service + const unsubscribe = yield* events.listen((event) => { + if (event.type !== MCP.BrowserOpenFailed.type) return Effect.void + const data = event.data as EventV2.Data + if (data.mcpName === serverName) { spinner.stop("Could not open browser automatically") prompts.log.warn("Please open this URL in your browser to authenticate:") - prompts.log.info(evt.properties.url) + prompts.log.info(data.url) spinner.start("Waiting for authorization...") } + return Effect.void }) yield* MCP.Service.use((mcp) => mcp.authenticate(serverName)).pipe( @@ -300,7 +305,7 @@ export const McpAuthCommand = effectCmd({ prompts.log.error(error instanceof Error ? error.message : String(error)) }), ), - Effect.ensuring(Effect.sync(() => unsubscribe())), + Effect.ensuring(unsubscribe), ) prompts.outro("Done") diff --git a/packages/opencode/src/cli/cmd/tui/context/editor-zed.ts b/packages/opencode/src/cli/cmd/tui/context/editor-zed.ts index e17d520a0..0b8b58fc7 100644 --- a/packages/opencode/src/cli/cmd/tui/context/editor-zed.ts +++ b/packages/opencode/src/cli/cmd/tui/context/editor-zed.ts @@ -1,10 +1,7 @@ +import { Database } from "bun:sqlite" import os from "node:os" import path from "node:path" -import { Effect, Option, Schema } from "effect" -import { sql } from "drizzle-orm" -import { Sqlite } from "@opencode-ai/core/database/sqlite" -import { layer as sqliteLayer } from "@opencode-ai/core/database/sqlite.bun" -import { makeRuntime } from "@opencode-ai/core/effect/runtime" +import { Option, Schema } from "effect" import { Filesystem } from "@/util/filesystem" import type { EditorSelection } from "./editor" @@ -91,10 +88,12 @@ export async function resolveZedSelection(dbPath: string, cwd = process.cwd()): } function queryZedActiveEditor(dbPath: string, cwd: string) { + let db: Database | undefined try { - const raw = zedDatabase(dbPath).runSync((db) => - Effect.succeed( - db.all(sql.raw(`select + db = new Database(dbPath, { readonly: true }) + const raw = db + .query( + `select i.kind as item_kind, e.item_id as editor_id, i.workspace_id as workspace_id, @@ -106,9 +105,9 @@ function queryZedActiveEditor(dbPath: string, cwd: string) { join workspaces w on w.workspace_id = i.workspace_id left join editors e on e.item_id = i.item_id and e.workspace_id = i.workspace_id where i.active = 1 and p.active = 1 - order by w.timestamp desc`)), - ), - ) + order by w.timestamp desc`, + ) + .all() const rows = raw.flatMap((row) => { const parsed = decodeZedEditorRow(row) @@ -127,22 +126,24 @@ function queryZedActiveEditor(dbPath: string, cwd: string) { return { type: "row" as const, row } } catch { return { type: "unavailable" as const } + } finally { + db?.close() } } function queryZedEditorSelections(dbPath: string, row: ZedActiveEditorRow) { + let db: Database | undefined try { - const raw = zedDatabase(dbPath).runSync((db) => - Effect.succeed( - db.all>( - sql`select + db = new Database(dbPath, { readonly: true }) + const raw = db + .query( + `select start as selection_start, end as selection_end from editor_selections - where editor_id = ${row.editor_id} and workspace_id = ${row.workspace_id}`, - ), - ), - ) + where editor_id = $editorID and workspace_id = $workspaceID`, + ) + .all({ $editorID: row.editor_id, $workspaceID: row.workspace_id }) const selections = raw.flatMap((selection) => { const parsed = decodeZedSelectionRow(selection) @@ -153,33 +154,33 @@ function queryZedEditorSelections(dbPath: string, row: ZedActiveEditorRow) { return { type: "selections" as const, selections } } catch { return { type: "unavailable" as const } + } finally { + db?.close() } } function queryZedEditorContents(dbPath: string, row: ZedActiveEditorRow) { + let db: Database | undefined try { + db = new Database(dbPath, { readonly: true }) const parsed = decodeZedEditorContents( - zedDatabase(dbPath).runSync((db) => - Effect.succeed( - db.get( - sql`select contents + db + .query( + `select contents from editors - where item_id = ${row.editor_id} and workspace_id = ${row.workspace_id}`, - ), - ), - ), + where item_id = $editorID and workspace_id = $workspaceID`, + ) + .get({ $editorID: row.editor_id, $workspaceID: row.workspace_id }), ) if (Option.isNone(parsed)) return { type: "unavailable" as const } return { type: "contents" as const, contents: parsed.value.contents } } catch { return { type: "unavailable" as const } + } finally { + db?.close() } } -function zedDatabase(dbPath: string) { - return makeRuntime(Sqlite.Drizzle, sqliteLayer({ filename: dbPath, readonly: true, create: false })) -} - function isZedActiveEditorRow(row: ZedEditorRow): row is ZedActiveEditorRow { return row.item_kind === "Editor" && row.editor_id != null } diff --git a/packages/opencode/src/cli/cmd/tui/event.ts b/packages/opencode/src/cli/cmd/tui/event.ts index bebb1fc6a..73412b877 100644 --- a/packages/opencode/src/cli/cmd/tui/event.ts +++ b/packages/opencode/src/cli/cmd/tui/event.ts @@ -1,15 +1,15 @@ -import { BusEvent } from "@/bus/bus-event" import { SessionID } from "@/session/schema" import { PositiveInt } from "@opencode-ai/core/schema" +import { EventV2 } from "@opencode-ai/core/event" import { Effect, Schema } from "effect" const DEFAULT_TOAST_DURATION = 5000 export const TuiEvent = { - PromptAppend: BusEvent.define("tui.prompt.append", Schema.Struct({ text: Schema.String })), - CommandExecute: BusEvent.define( - "tui.command.execute", - Schema.Struct({ + PromptAppend: EventV2.define({ type: "tui.prompt.append", schema: { text: Schema.String } }), + CommandExecute: EventV2.define({ + type: "tui.command.execute", + schema: { command: Schema.Union([ Schema.Literals([ "session.list", @@ -31,23 +31,23 @@ export const TuiEvent = { ]), Schema.String, ]), - }), - ), - ToastShow: BusEvent.define( - "tui.toast.show", - Schema.Struct({ + }, + }), + ToastShow: EventV2.define({ + type: "tui.toast.show", + schema: { title: Schema.optional(Schema.String), message: Schema.String, variant: Schema.Literals(["info", "success", "warning", "error"]), duration: PositiveInt.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_TOAST_DURATION))).annotate({ description: "Duration in milliseconds", }), - }), - ), - SessionSelect: BusEvent.define( - "tui.session.select", - Schema.Struct({ + }, + }), + SessionSelect: EventV2.define({ + type: "tui.session.select", + schema: { sessionID: SessionID.annotate({ description: "Session ID to navigate to" }), - }), - ), + }, + }), } diff --git a/packages/opencode/src/cli/cmd/tui/ui/toast.tsx b/packages/opencode/src/cli/cmd/tui/ui/toast.tsx index d15fb3920..381ecca7e 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/toast.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/toast.tsx @@ -7,10 +7,10 @@ import { TextAttributes } from "@opentui/core" import { Schema } from "effect" import { TuiEvent } from "../event" -type ToastInput = Schema.Codec.Encoded -export type ToastOptions = Schema.Schema.Type +type ToastInput = Schema.Codec.Encoded +export type ToastOptions = Schema.Schema.Type -const decodeToastOptions = Schema.decodeUnknownSync(TuiEvent.ToastShow.properties) +const decodeToastOptions = Schema.decodeUnknownSync(TuiEvent.ToastShow.data) export function Toast() { const toast = useToast() diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 96e171733..6ef2ab780 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -1,4 +1,3 @@ -import { BusEvent } from "@/bus/bus-event" import { InstanceState } from "@/effect/instance-state" import { EffectBridge } from "@/effect/bridge" import type { InstanceContext } from "@/project/instance-context" @@ -7,6 +6,7 @@ import { Effect, Layer, Context, Schema } from "effect" import { Config } from "@/config/config" import { MCP } from "../mcp" import { Skill } from "../skill" +import { EventV2 } from "@opencode-ai/core/event" import PROMPT_INITIALIZE from "./template/initialize.txt" import PROMPT_REVIEW from "./template/review.txt" @@ -15,15 +15,15 @@ type State = { } export const Event = { - Executed: BusEvent.define( - "command.executed", - Schema.Struct({ + Executed: EventV2.define({ + type: "command.executed", + schema: { name: Schema.String, sessionID: SessionID, arguments: Schema.String, messageID: MessageID, - }), - ), + }, + }), } export const Info = Schema.Struct({ diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index 4139096d0..d3147ec99 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -6,7 +6,6 @@ import { asc } from "drizzle-orm" import { eq } from "drizzle-orm" import { inArray } from "drizzle-orm" import { Project } from "@/project/project" -import { BusEvent } from "@/bus/bus-event" import { GlobalBus } from "@/bus/global" import { Auth } from "@/auth" import { EventV2 } from "@opencode-ai/core/event" @@ -47,19 +46,19 @@ export const ConnectionStatus = Schema.Struct({ export type ConnectionStatus = Schema.Schema.Type export const Event = { - Ready: BusEvent.define( - "workspace.ready", - Schema.Struct({ + Ready: EventV2.define({ + type: "workspace.ready", + schema: { name: Schema.String, - }), - ), - Failed: BusEvent.define( - "workspace.failed", - Schema.Struct({ + }, + }), + Failed: EventV2.define({ + type: "workspace.failed", + schema: { message: Schema.String, - }), - ), - Status: BusEvent.define("workspace.status", ConnectionStatus), + }, + }), + Status: EventV2.define({ type: "workspace.status", schema: ConnectionStatus.fields }), } function fromRow(row: typeof WorkspaceTable.$inferSelect): Info { @@ -340,14 +339,12 @@ export const layer = Layer.effect( .pipe(Effect.orDie)).map((row) => row.id) const state = sessionIDs.length ? Object.fromEntries( - ( - yield* db - .select() - .from(EventSequenceTable) - .where(inArray(EventSequenceTable.aggregate_id, sessionIDs)) - .all() - .pipe(Effect.orDie) - ).map((row) => [row.aggregate_id, row.seq]), + (yield* db + .select() + .from(EventSequenceTable) + .where(inArray(EventSequenceTable.aggregate_id, sessionIDs)) + .all() + .pipe(Effect.orDie)).map((row) => [row.aggregate_id, row.seq]), ) : {} @@ -800,6 +797,8 @@ export const layer = Layer.effect( }) } + yield* session.setWorkspace({ sessionID: input.sessionID, workspaceID: input.workspaceID }) + log.info("session warp complete", { workspaceID: input.workspaceID, sessionID: input.sessionID, diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index f02943c15..5434bb713 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -4,7 +4,6 @@ import * as Observability from "@opencode-ai/core/effect/observability" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Database } from "@opencode-ai/core/database/database" -import { Bus } from "@/bus" import { Auth } from "@/auth" import { Account } from "@/account/account" import { Config } from "@/config/config" @@ -56,12 +55,12 @@ import { Npm } from "@opencode-ai/core/npm" import { memoMap } from "@opencode-ai/core/effect/memo-map" import { BackgroundJob } from "@/background/job" import { RuntimeFlags } from "@/effect/runtime-flags" +import { EventV2Bridge } from "@/event-v2-bridge" export const AppLayer = Layer.mergeAll( Npm.defaultLayer, AppFileSystem.defaultLayer, Database.defaultLayer, - Bus.defaultLayer, Auth.defaultLayer, Account.defaultLayer, Config.defaultLayer, @@ -85,6 +84,7 @@ export const AppLayer = Layer.mergeAll( SessionStatus.defaultLayer, BackgroundJob.defaultLayer, RuntimeFlags.defaultLayer, + EventV2Bridge.defaultLayer, SessionRunState.defaultLayer, SessionProcessor.defaultLayer, SessionCompaction.defaultLayer, diff --git a/packages/opencode/src/effect/bootstrap-runtime.ts b/packages/opencode/src/effect/bootstrap-runtime.ts index 7f1853852..da3c10ff9 100644 --- a/packages/opencode/src/effect/bootstrap-runtime.ts +++ b/packages/opencode/src/effect/bootstrap-runtime.ts @@ -8,7 +8,6 @@ import { ShareNext } from "@/share/share-next" import { File } from "@/file" import { Vcs } from "@/project/vcs" import { Snapshot } from "@/snapshot" -import { Bus } from "@/bus" import { Config } from "@/config/config" import * as Observability from "@opencode-ai/core/effect/observability" import { memoMap } from "@opencode-ai/core/effect/memo-map" @@ -23,7 +22,6 @@ export const BootstrapLayer = Layer.mergeAll( FileWatcher.defaultLayer, Vcs.defaultLayer, Snapshot.defaultLayer, - Bus.defaultLayer, ).pipe(Layer.provide(Observability.layer)) export const BootstrapRuntime = ManagedRuntime.make(BootstrapLayer, { memoMap }) diff --git a/packages/opencode/src/event-v2-bridge.ts b/packages/opencode/src/event-v2-bridge.ts index 6e84ce82a..673bf1f15 100644 --- a/packages/opencode/src/event-v2-bridge.ts +++ b/packages/opencode/src/event-v2-bridge.ts @@ -1,15 +1,13 @@ -// Temporary V2 bridge: core events are the publish path, but the rest of -// opencode and the HTTP event stream still expect legacy bus payloads. -// This layer goes away once consumers subscribe to core EventV2 directly. -import { Bus as ProjectBus } from "@/bus" -import { GlobalBus } from "@/bus/global" +// Opencode publish boundary for core events. Attach routed instance location +// so direct EventV2 consumers can isolate directory/workspace streams. import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" -import { InstanceStore } from "@/project/instance-store" +import { GlobalBus } from "@/bus/global" import { EventV2 } from "@opencode-ai/core/event" +import { AbsolutePath } from "@opencode-ai/core/schema" import "@opencode-ai/core/account" import "@opencode-ai/core/catalog" import "@opencode-ai/core/session/event" -import { Context, Effect, Layer, Option, Stream } from "effect" +import { Context, Effect, Layer } from "effect" export class Service extends Context.Service()("@opencode/EventV2Bridge") {} @@ -17,54 +15,40 @@ export const layer = Layer.effect( Service, Effect.gen(function* () { const events = yield* EventV2.Service - const bus = yield* ProjectBus.Service - const publishGlobal = (event: EventV2.Payload) => - Effect.sync(() => { - GlobalBus.emit("event", { - workspace: event.location?.workspaceID, - payload: { - id: event.id, - type: event.type, - properties: event.data, + const publish: EventV2.Interface["publish"] = (definition, data, options) => + Effect.gen(function* () { + if (options?.location) return yield* events.publish(definition, data, options) + const ctx = yield* InstanceRef + if (!ctx) return yield* events.publish(definition, data, options) + const workspaceID = yield* WorkspaceRef + return yield* events.publish(definition, data, { + ...options, + location: { + directory: AbsolutePath.make(ctx.directory), + ...(workspaceID ? { workspaceID } : {}), }, }) }) - const provideEventLocation = (event: EventV2.Payload, effect: Effect.Effect) => + const unsubscribe = yield* events.listen((event) => Effect.gen(function* () { const ctx = yield* InstanceRef - if (ctx) return yield* effect - const store = Option.getOrUndefined(yield* Effect.serviceOption(InstanceStore.Service)) - if (!event.location?.directory || !store) return yield* publishGlobal(event) - return yield* store.load({ directory: event.location.directory }).pipe( - Effect.flatMap((ctx) => { - const withInstance = effect.pipe(Effect.provideService(InstanceRef, ctx)) - if (!event.location?.workspaceID) return withInstance - return withInstance.pipe(Effect.provideService(WorkspaceRef, event.location.workspaceID)) - }), - ) - }) - - yield* events.all().pipe( - Stream.runForEach((event) => { - const definition = EventV2.registry.get(event.type) - if (!definition) return Effect.void - return provideEventLocation( - event, - bus.publish({ type: definition.type, properties: definition.data }, event.data, { id: event.id }), - ) + const workspaceID = (yield* WorkspaceRef) ?? event.location?.workspaceID + GlobalBus.emit("event", { + directory: event.location?.directory ?? ctx?.directory, + project: ctx?.project.id, + workspace: workspaceID, + payload: { id: event.id, type: event.type, properties: event.data }, + }) }), - Effect.forkScoped, ) + yield* Effect.addFinalizer(() => unsubscribe) - return Service.of(events) + return Service.of({ ...events, publish }) }), ) -export const defaultLayer = layer.pipe( - Layer.provide(EventV2.defaultLayer), - Layer.provide(ProjectBus.defaultLayer), -) +export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer)) export * as EventV2Bridge from "./event-v2-bridge" diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 0992289fe..c4aa2e1cf 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -1,4 +1,4 @@ -import { BusEvent } from "@/bus/bus-event" +import { EventV2 } from "@opencode-ai/core/event" import { serviceUse } from "@opencode-ai/core/effect/service-use" import { InstanceState } from "@/effect/instance-state" @@ -62,12 +62,12 @@ export const Content = Schema.Struct({ export type Content = DeepMutable> export const Event = { - Edited: BusEvent.define( - "file.edited", - Schema.Struct({ + Edited: EventV2.define({ + type: "file.edited", + schema: { file: Schema.String, - }), - ), + }, + }), } const log = Log.create({ service: "file" }) diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index 91ad9ff4d..eeb3e5f5f 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -4,8 +4,8 @@ import { createWrapper } from "@parcel/watcher/wrapper" import type ParcelWatcher from "@parcel/watcher" import { readdir, realpath } from "fs/promises" import path from "path" -import { Bus } from "@/bus" -import { BusEvent } from "@/bus/bus-event" +import { EventV2 } from "@opencode-ai/core/event" +import { EventV2Bridge } from "@/event-v2-bridge" import { EffectBridge } from "@/effect/bridge" import { InstanceState } from "@/effect/instance-state" import { Flag } from "@opencode-ai/core/flag/flag" @@ -22,13 +22,13 @@ const log = Log.create({ service: "file.watcher" }) const SUBSCRIBE_TIMEOUT_MS = 10_000 export const Event = { - Updated: BusEvent.define( - "file.watcher.updated", - Schema.Struct({ + Updated: EventV2.define({ + type: "file.watcher.updated", + schema: { file: Schema.String, event: Schema.Literals(["add", "change", "unlink"]), - }), - ), + }, + }), } const watcher = lazy((): typeof import("@parcel/watcher") | undefined => { @@ -69,6 +69,7 @@ export const layer = Layer.effect( Effect.gen(function* () { const config = yield* Config.Service const git = yield* Git.Service + const events = yield* EventV2Bridge.Service const state = yield* InstanceState.make( Effect.fn("FileWatcher.state")( @@ -98,9 +99,9 @@ export const layer = Layer.effect( const cb: ParcelWatcher.SubscribeCallback = bridge.bind((err, evts) => { // if (err) return for (const evt of evts) { - if (evt.type === "create") void Bus.publish(ctx, Event.Updated, { file: evt.path, event: "add" }) - if (evt.type === "update") void Bus.publish(ctx, Event.Updated, { file: evt.path, event: "change" }) - if (evt.type === "delete") void Bus.publish(ctx, Event.Updated, { file: evt.path, event: "unlink" }) + if (evt.type === "create") bridge.fork(events.publish(Event.Updated, { file: evt.path, event: "add" })) + if (evt.type === "update") bridge.fork(events.publish(Event.Updated, { file: evt.path, event: "change" })) + if (evt.type === "delete") bridge.fork(events.publish(Event.Updated, { file: evt.path, event: "unlink" })) } }) @@ -162,6 +163,10 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(Git.defaultLayer)) +export const defaultLayer = layer.pipe( + Layer.provide(Config.defaultLayer), + Layer.provide(Git.defaultLayer), + Layer.provide(EventV2Bridge.defaultLayer), +) export * as FileWatcher from "./watcher" diff --git a/packages/opencode/src/ide/index.ts b/packages/opencode/src/ide/index.ts index a31c5bd05..4df2ce8c2 100644 --- a/packages/opencode/src/ide/index.ts +++ b/packages/opencode/src/ide/index.ts @@ -1,4 +1,4 @@ -import { BusEvent } from "@/bus/bus-event" +import { EventV2 } from "@opencode-ai/core/event" import { Schema } from "effect" import { NamedError } from "@opencode-ai/core/util/error" import * as Log from "@opencode-ai/core/util/log" @@ -15,12 +15,12 @@ const SUPPORTED_IDES = [ const log = Log.create({ service: "ide" }) export const Event = { - Installed: BusEvent.define( - "ide.installed", - Schema.Struct({ + Installed: EventV2.define({ + type: "ide.installed", + schema: { ide: Schema.String, - }), - ), + }, + }), } export const AlreadyInstalledError = NamedError.create("AlreadyInstalledError", {}) diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index 1f8dc116b..4367a2679 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -6,7 +6,7 @@ import { errorMessage } from "@/util/error" import { ChildProcess } from "effect/unstable/process" import { AppProcess } from "@opencode-ai/core/process" import path from "path" -import { BusEvent } from "@/bus/bus-event" +import { EventV2 } from "@opencode-ai/core/event" import * as Log from "@opencode-ai/core/util/log" import { makeRuntime } from "@opencode-ai/core/effect/runtime" import semver from "semver" @@ -20,18 +20,18 @@ export type Method = "curl" | "npm" | "yarn" | "pnpm" | "bun" | "brew" | "scoop" export type ReleaseType = "patch" | "minor" | "major" export const Event = { - Updated: BusEvent.define( - "installation.updated", - Schema.Struct({ + Updated: EventV2.define({ + type: "installation.updated", + schema: { version: Schema.String, - }), - ), - UpdateAvailable: BusEvent.define( - "installation.update-available", - Schema.Struct({ + }, + }), + UpdateAvailable: EventV2.define({ + type: "installation.update-available", + schema: { version: Schema.String, - }), - ), + }, + }), } export function getReleaseType(current: string, latest: string): ReleaseType { diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index 205cba6f2..25da0b10c 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -1,5 +1,3 @@ -import { BusEvent } from "@/bus/bus-event" -import { Bus } from "@/bus" import path from "path" import { pathToFileURL, fileURLToPath } from "url" import { createMessageConnection, StreamMessageReader, StreamMessageWriter } from "vscode-jsonrpc/node" @@ -11,8 +9,6 @@ import { Effect, Schema } from "effect" import type * as LSPServer from "./server" import { withTimeout } from "../util/timeout" import { Filesystem } from "@/util/filesystem" -import { InstanceRef } from "@/effect/instance-ref" -import { makeRuntime } from "@/effect/run-service" import type { InstanceContext } from "@/project/instance-context" const DIAGNOSTICS_DEBOUNCE_MS = 150 @@ -28,8 +24,6 @@ const FILE_CHANGE_CHANGED = 2 const TEXT_DOCUMENT_SYNC_INCREMENTAL = 2 const log = Log.create({ service: "lsp.client" }) -const busRuntime = makeRuntime(Bus.Service, Bus.layer) - export type Info = NonNullable>> export type Diagnostic = VSCodeDiagnostic @@ -39,16 +33,6 @@ export class InitializeError extends Schema.TaggedErrorClass()( cause: Schema.optional(Schema.Defect), }) {} -export const Event = { - Diagnostics: BusEvent.define( - "lsp.client.diagnostics", - Schema.Struct({ - serverID: Schema.String, - path: Schema.String, - }), - ), -} - type DocumentDiagnosticReport = { items?: Diagnostic[] relatedDocuments?: Record @@ -169,15 +153,12 @@ export async function create(input: { const published = new Map() const diagnosticRegistrations = new Map() const registrationListeners = new Set<() => void>() + const diagnosticListeners = new Set<(input: { path: string; serverID: string }) => void>() const mergedDiagnostics = (filePath: string) => dedupeDiagnostics([...(pushDiagnostics.get(filePath) ?? []), ...(pullDiagnostics.get(filePath) ?? [])]) const updatePushDiagnostics = (filePath: string, next: Diagnostic[]) => { pushDiagnostics.set(filePath, next) - void busRuntime.runPromise((svc) => - svc - .publish(Event.Diagnostics, { path: filePath, serverID: input.serverID }) - .pipe(Effect.provideService(InstanceRef, instance)), - ) + for (const listener of diagnosticListeners) listener({ path: filePath, serverID: input.serverID }) } const updatePullDiagnostics = (filePath: string, next: Diagnostic[]) => { pullDiagnostics.set(filePath, next) @@ -525,14 +506,12 @@ export async function create(input: { } timeoutTimer = setTimeout(() => finish(false), request.timeout) - unsub = busRuntime.runSync((svc) => - svc - .subscribeCallback(Event.Diagnostics, (event) => { - if (event.properties.path !== request.path || event.properties.serverID !== input.serverID) return - schedule() - }) - .pipe(Effect.provideService(InstanceRef, instance)), - ) + const listener = (event: { path: string; serverID: string }) => { + if (event.path !== request.path || event.serverID !== input.serverID) return + schedule() + } + diagnosticListeners.add(listener) + unsub = () => diagnosticListeners.delete(listener) schedule() }) } diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts index 3117b834c..a0fcfb3fc 100644 --- a/packages/opencode/src/lsp/lsp.ts +++ b/packages/opencode/src/lsp/lsp.ts @@ -1,5 +1,5 @@ -import { BusEvent } from "@/bus/bus-event" -import { Bus } from "@/bus" +import { EventV2Bridge } from "@/event-v2-bridge" +import { EventV2 } from "@opencode-ai/core/event" import * as Log from "@opencode-ai/core/util/log" import * as LSPClient from "./client" import path from "path" @@ -17,7 +17,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags" const log = Log.create({ service: "lsp" }) export const Event = { - Updated: BusEvent.define("lsp.updated", Schema.Struct({})), + Updated: EventV2.define({ type: "lsp.updated", schema: {} }), } const Position = Schema.Struct({ @@ -144,6 +144,7 @@ export const layer = Layer.effect( Effect.gen(function* () { const config = yield* Config.Service const flags = yield* RuntimeFlags.Service + const events = yield* EventV2Bridge.Service const state = yield* InstanceState.make( Effect.fn("LSP.state")(function* (ctx) { @@ -212,9 +213,10 @@ export const layer = Layer.effect( const ctx = yield* InstanceState.context if (!containsPath(file, ctx)) return [] as LSPClient.Info[] const s = yield* InstanceState.get(state) - return yield* Effect.promise(async () => { + const clients = yield* Effect.promise(async () => { const extension = path.parse(file).ext || file const result: LSPClient.Info[] = [] + let updated = 0 async function schedule(server: LSPServer.Info, root: string, key: string) { const handle = await server @@ -291,11 +293,15 @@ export const layer = Layer.effect( if (!client) continue result.push(client) - await Bus.publish(ctx, Event.Updated, {}) + updated++ } - return result + return { result, updated } }) + yield* Effect.forEach(Array.from({ length: clients.updated }), () => events.publish(Event.Updated, {}), { + discard: true, + }) + return clients.result }) const run = Effect.fnUntraced(function* (file: string, fn: (client: LSPClient.Info) => Promise) { @@ -500,7 +506,11 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer)) +export const defaultLayer = layer.pipe( + Layer.provide(Config.defaultLayer), + Layer.provide(RuntimeFlags.defaultLayer), + Layer.provide(EventV2Bridge.defaultLayer), +) export * as Diagnostic from "./diagnostic" diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index efd72c0c1..5de38a8c6 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -22,8 +22,8 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem" import { McpOAuthProvider, OAUTH_CALLBACK_PATH } from "./oauth-provider" import { McpOAuthCallback } from "./oauth-callback" import { McpAuth } from "./auth" -import { BusEvent } from "../bus/bus-event" -import { Bus } from "@/bus" +import { EventV2Bridge } from "@/event-v2-bridge" +import { EventV2 } from "@opencode-ai/core/event" import { TuiEvent } from "@/cli/cmd/tui/event" import open from "open" import { Effect, Exit, Layer, Option, Context, Schema, Stream } from "effect" @@ -48,20 +48,20 @@ export const Resource = Schema.Struct({ }).annotate({ identifier: "McpResource" }) export type Resource = Schema.Schema.Type -export const ToolsChanged = BusEvent.define( - "mcp.tools.changed", - Schema.Struct({ +export const ToolsChanged = EventV2.define({ + type: "mcp.tools.changed", + schema: { server: Schema.String, - }), -) + }, +}) -export const BrowserOpenFailed = BusEvent.define( - "mcp.browser.open.failed", - Schema.Struct({ +export const BrowserOpenFailed = EventV2.define({ + type: "mcp.browser.open.failed", + schema: { mcpName: Schema.String, url: Schema.String, - }), -) + }, +}) export const Failed = NamedError.create("MCPFailed", { name: Schema.String, @@ -277,7 +277,7 @@ export const layer = Layer.effect( Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner const auth = yield* McpAuth.Service - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service type Transport = StdioClientTransport | StreamableHTTPClientTransport | SSEClientTransport @@ -372,7 +372,7 @@ export const layer = Layer.effect( status: "needs_client_registration" as const, error: "Server does not support dynamic client registration. Please provide clientId in config.", } - return bus + return events .publish(TuiEvent.ToastShow, { title: "MCP Authentication Required", message: `Server "${key}" requires a pre-registered client ID. Add clientId to your config.`, @@ -383,7 +383,7 @@ export const layer = Layer.effect( } else { pendingOAuthTransports.set(key, transport) lastStatus = { status: "needs_auth" as const } - return bus + return events .publish(TuiEvent.ToastShow, { title: "MCP Authentication Required", message: `Server "${key}" requires authentication. Run: opencode mcp auth ${key}`, @@ -515,7 +515,7 @@ export const layer = Layer.effect( if (s.clients[name] !== client || s.status[name]?.status !== "connected") return s.defs[name] = listed - await bridge.promise(bus.publish(ToolsChanged, { server: name }).pipe(Effect.ignore)) + await bridge.promise(events.publish(ToolsChanged, { server: name }).pipe(Effect.ignore)) }) } @@ -870,7 +870,7 @@ export const layer = Layer.effect( ), Effect.catch(() => { log.warn("failed to open browser, user must open URL manually", { mcpName }) - return bus.publish(BrowserOpenFailed, { mcpName, url: result.authorizationUrl }).pipe(Effect.ignore) + return events.publish(BrowserOpenFailed, { mcpName, url: result.authorizationUrl }).pipe(Effect.ignore) }), ) @@ -962,7 +962,7 @@ export type AuthStatus = "authenticated" | "expired" | "not_authenticated" export const defaultLayer = layer.pipe( Layer.provide(McpAuth.layer), - Layer.provide(Bus.layer), + Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(Config.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index ec1c24d4f..35546db84 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -1,5 +1,3 @@ -import { Bus } from "@/bus" -import { BusEvent } from "@/bus/bus-event" import { ConfigPermission } from "@/config/permission" import { InstanceState } from "@/effect/instance-state" import { ProjectV2 } from "@opencode-ai/core/project" @@ -13,6 +11,8 @@ import { Deferred, Effect, Layer, Schema, Context } from "effect" import os from "os" import { PermissionV2 } from "@opencode-ai/core/permission" import { PermissionID } from "./schema" +import { EventV2Bridge } from "@/event-v2-bridge" +import { EventV2 } from "@opencode-ai/core/event" const log = Log.create({ service: "permission" }) @@ -67,15 +67,15 @@ export const Approval = Schema.Struct({ export type Approval = Schema.Schema.Type export const Event = { - Asked: BusEvent.define("permission.asked", Request), - Replied: BusEvent.define( - "permission.replied", - Schema.Struct({ + Asked: EventV2.define({ type: "permission.asked", schema: Request.fields }), + Replied: EventV2.define({ + type: "permission.replied", + schema: { sessionID: SessionID, requestID: PermissionID, reply: Reply, - }), - ), + }, + }), } export class RejectedError extends Schema.TaggedErrorClass()("PermissionRejectedError", {}) { @@ -144,7 +144,7 @@ export class Service extends Context.Service()("@opencode/Pe export const layer = Layer.effect( Service, Effect.gen(function* () { - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service const { db } = yield* Database.Service const state = yield* InstanceState.make( Effect.fn("Permission.state")(function* (ctx) { @@ -200,7 +200,7 @@ export const layer = Layer.effect( const deferred = yield* Deferred.make() pending.set(id, { info, deferred }) - yield* bus.publish(Event.Asked, info) + yield* events.publish(Event.Asked, info) return yield* Effect.ensuring( Deferred.await(deferred), Effect.sync(() => { @@ -215,7 +215,7 @@ export const layer = Layer.effect( if (!existing) return yield* new NotFoundError({ requestID: input.requestID }) pending.delete(input.requestID) - yield* bus.publish(Event.Replied, { + yield* events.publish(Event.Replied, { sessionID: existing.info.sessionID, requestID: existing.info.id, reply: input.reply, @@ -230,7 +230,7 @@ export const layer = Layer.effect( for (const [id, item] of pending.entries()) { if (item.info.sessionID !== existing.info.sessionID) continue pending.delete(id) - yield* bus.publish(Event.Replied, { + yield* events.publish(Event.Replied, { sessionID: item.info.sessionID, requestID: item.info.id, reply: "reject", @@ -258,7 +258,7 @@ export const layer = Layer.effect( ) if (!ok) continue pending.delete(id) - yield* bus.publish(Event.Replied, { + yield* events.publish(Event.Replied, { sessionID: item.info.sessionID, requestID: item.info.id, reply: "always", @@ -306,6 +306,6 @@ export function disabled(tools: string[], ruleset: Ruleset): Set { return PermissionV2.disabled(tools, ruleset) } -export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer), Layer.provide(Bus.layer)) +export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer), Layer.provide(EventV2Bridge.defaultLayer)) export * as Permission from "." diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index e59fefe08..33682a6f0 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -6,7 +6,6 @@ import type { WorkspaceAdapter as PluginWorkspaceAdapter, } from "@opencode-ai/plugin" import { Config } from "@/config/config" -import { Bus } from "../bus" import * as Log from "@opencode-ai/core/util/log" import { createOpencodeClient } from "@opencode-ai/sdk" import { ServerAuth } from "@/server/auth" @@ -29,6 +28,7 @@ import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } fro import { registerAdapter } from "@/control-plane/adapters" import type { WorkspaceAdapter } from "@/control-plane/types" import { RuntimeFlags } from "@/effect/runtime-flags" +import { EventV2Bridge } from "@/event-v2-bridge" const log = Log.create({ service: "plugin" }) @@ -112,7 +112,7 @@ async function applyPlugin(load: PluginLoader.Loaded, input: PluginInput, hooks: export const layer = Layer.effect( Service, Effect.gen(function* () { - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service const config = yield* Config.Service const flags = yield* RuntimeFlags.Service @@ -122,7 +122,7 @@ export const layer = Layer.effect( const bridge = yield* EffectBridge.make() function publishPluginError(message: string) { - bridge.fork(bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })) + bridge.fork(events.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })) } const { Server } = yield* Effect.promise(() => import("../server/server")) @@ -224,7 +224,7 @@ export const layer = Layer.effect( }).pipe( Effect.catch(() => { // TODO: make proper events for this - // bus.publish(Session.Event.Error, { + // events.publish(Session.Event.Error, { // error: new NamedError.Unknown({ // message: `Failed to load plugin ${load.spec}: ${message}`, // }).toObject(), @@ -244,17 +244,15 @@ export const layer = Layer.effect( }).pipe(Effect.ignore) } - // Subscribe to bus events, fiber interrupted when scope closes - yield* (yield* bus.subscribeAll()).pipe( - Stream.runForEach((input) => - Effect.sync(() => { - for (const hook of hooks) { - void hook["event"]?.({ event: input as any }) - } - }), - ), - Effect.forkScoped, - ) + const unsubscribe = yield* events.listen((event) => { + if (event.location?.directory !== ctx.directory) return Effect.void + return Effect.sync(() => { + for (const hook of hooks) { + void hook["event"]?.({ event: { id: event.id, type: event.type, properties: event.data } as any }) + } + }) + }) + yield* Effect.addFinalizer(() => unsubscribe) return { hooks } }), @@ -289,7 +287,7 @@ export const layer = Layer.effect( ) export const defaultLayer = layer.pipe( - Layer.provide(Bus.layer), + Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(Config.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer), ) diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index a7e67d45e..e6c5d698a 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -5,7 +5,6 @@ import { File } from "../file" import { Snapshot } from "../snapshot" import * as Project from "./project" import * as Vcs from "./vcs" -import { Bus } from "../bus" import { InstanceState } from "@/effect/instance-state" import { FileWatcher } from "@/file/watcher" import { ShareNext } from "@/share/share-next" @@ -57,7 +56,6 @@ export const layer = Layer.effect( export const defaultLayer: Layer.Layer = layer.pipe( Layer.provide([ - Bus.layer, Config.defaultLayer, File.defaultLayer, FileWatcher.defaultLayer, diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 8eaf4d372..d84e21dad 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -5,10 +5,8 @@ import { PermissionTable, SessionTable } from "@opencode-ai/core/session/sql" import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql" import * as Log from "@opencode-ai/core/util/log" import { Flag } from "@opencode-ai/core/flag/flag" -import { BusEvent } from "@/bus/bus-event" import { GlobalBus } from "@/bus/global" import { which } from "../util/which" -import { Bus } from "@/bus" import { Command } from "@/command" import { InstanceState } from "@/effect/instance-state" import { Effect, Layer, Scope, Context, Stream, Types, Schema } from "effect" @@ -20,6 +18,8 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { AbsolutePath, NonNegativeInt, optionalOmitUndefined } from "@opencode-ai/core/schema" import { serviceUse } from "@opencode-ai/core/effect/service-use" import { RuntimeFlags } from "@/effect/runtime-flags" +import { EventV2Bridge } from "@/event-v2-bridge" +import { EventV2 } from "@opencode-ai/core/event" const log = Log.create({ service: "project" }) @@ -56,7 +56,7 @@ export const Info = Schema.Struct({ export type Info = Types.DeepMutable> export const Event = { - Updated: BusEvent.define("project.updated", Info), + Updated: EventV2.define({ type: "project.updated", schema: Info.fields }), } type Row = typeof ProjectTable.$inferSelect @@ -143,7 +143,7 @@ export const layer = Layer.effect( const proc = yield* AppProcess.Service const spawner = yield* ChildProcessSpawner.ChildProcessSpawner const projectV2 = yield* ProjectV2.Service - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service const flags = yield* RuntimeFlags.Service const { db } = yield* Database.Service @@ -398,12 +398,12 @@ export const layer = Layer.effect( const initState = yield* InstanceState.make( Effect.fn("Project.initState")(function* (ctx) { - yield* (yield* bus.subscribe(Command.Event.Executed)).pipe( - Stream.runForEach((payload) => - payload.properties.name === Command.Default.INIT ? setInitialized(ctx.project.id) : Effect.void, - ), - Effect.forkScoped, - ) + const unsubscribe = yield* events.listen((event) => { + if (event.type !== Command.Event.Executed.type || event.location?.directory !== ctx.directory) return Effect.void + const data = event.data as EventV2.Data + return data.name === Command.Default.INIT ? setInitialized(ctx.project.id) : Effect.void + }) + yield* Effect.addFinalizer(() => unsubscribe) }), ) @@ -474,7 +474,7 @@ export const layer = Layer.effect( ) export const defaultLayer = layer.pipe( - Layer.provide(Bus.defaultLayer), + Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(ProjectV2.defaultLayer), Layer.provide(AppProcess.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer), diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index d2b5729dd..69e3ca9aa 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -1,11 +1,11 @@ import { Effect, Layer, Context, Schema, Stream, Scope } from "effect" import { formatPatch, structuredPatch } from "diff" -import { Bus } from "@/bus" -import { BusEvent } from "@/bus/bus-event" import { InstanceState } from "@/effect/instance-state" import { FileWatcher } from "@/file/watcher" import { Git } from "@/git" import * as Log from "@opencode-ai/core/util/log" +import { EventV2Bridge } from "@/event-v2-bridge" +import { EventV2 } from "@opencode-ai/core/event" const log = Log.create({ service: "vcs" }) const PATCH_CONTEXT_LINES = 2_147_483_647 @@ -239,12 +239,12 @@ export const Mode = Schema.Literals(["git", "branch"]) export type Mode = Schema.Schema.Type export const Event = { - BranchUpdated: BusEvent.define( - "vcs.branch.updated", - Schema.Struct({ + BranchUpdated: EventV2.define({ + type: "vcs.branch.updated", + schema: { branch: Schema.optional(Schema.String), - }), - ), + }, + }), } export const Info = Schema.Struct({ @@ -305,11 +305,11 @@ interface State { export class Service extends Context.Service()("@opencode/Vcs") {} -export const layer: Layer.Layer = Layer.effect( +export const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { const git = yield* Git.Service - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service const scope = yield* Scope.Scope const state = yield* InstanceState.make( @@ -327,20 +327,20 @@ export const layer: Layer.Layer = Lay const value = { current, root } log.info("initialized", { branch: value.current, default_branch: value.root?.name }) - yield* (yield* bus.subscribe(FileWatcher.Event.Updated)).pipe( - Stream.filter((evt) => evt.properties.file.endsWith("HEAD")), - Stream.runForEach((_evt) => - Effect.gen(function* () { - const next = yield* get() - if (next !== value.current) { - log.info("branch changed", { from: value.current, to: next }) - value.current = next - yield* bus.publish(Event.BranchUpdated, { branch: next }) - } - }), - ), - Effect.forkScoped, - ) + const unsubscribe = yield* events.listen((event) => { + if (event.type !== FileWatcher.Event.Updated.type || event.location?.directory !== ctx.directory) return Effect.void + const data = event.data as EventV2.Data + if (!data.file.endsWith("HEAD")) return Effect.void + return Effect.gen(function* () { + const next = yield* get() + if (next !== value.current) { + log.info("branch changed", { from: value.current, to: next }) + value.current = next + yield* events.publish(Event.BranchUpdated, { branch: next }) + } + }) + }) + yield* Effect.addFinalizer(() => unsubscribe) return value }), @@ -429,6 +429,9 @@ export const layer: Layer.Layer = Lay }), ) -export const defaultLayer = layer.pipe(Layer.provide(Git.defaultLayer), Layer.provide(Bus.layer)) +export const defaultLayer = layer.pipe( + Layer.provide(Git.defaultLayer), + Layer.provide(EventV2Bridge.defaultLayer), +) export * as Vcs from "./vcs" diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index b5eab9ce3..453726b96 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -1,5 +1,5 @@ -import { BusEvent } from "@/bus/bus-event" -import { Bus } from "@/bus" +import { EventV2Bridge } from "@/event-v2-bridge" +import { EventV2 } from "@opencode-ai/core/event" import { Config } from "@/config/config" import { InstanceState } from "@/effect/instance-state" import { EffectBridge } from "@/effect/bridge" @@ -92,10 +92,10 @@ export class NotFoundError extends Schema.TaggedErrorClass()("Pty }) {} export const Event = { - Created: BusEvent.define("pty.created", Schema.Struct({ info: Info })), - Updated: BusEvent.define("pty.updated", Schema.Struct({ info: Info })), - Exited: BusEvent.define("pty.exited", Schema.Struct({ id: PtyID, exitCode: NonNegativeInt })), - Deleted: BusEvent.define("pty.deleted", Schema.Struct({ id: PtyID })), + Created: EventV2.define({ type: "pty.created", schema: { info: Info } }), + Updated: EventV2.define({ type: "pty.updated", schema: { info: Info } }), + Exited: EventV2.define({ type: "pty.exited", schema: { id: PtyID, exitCode: NonNegativeInt } }), + Deleted: EventV2.define({ type: "pty.deleted", schema: { id: PtyID } }), } export interface Interface { @@ -122,7 +122,7 @@ export const layer = Layer.effect( Service, Effect.gen(function* () { const config = yield* Config.Service - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service const plugin = yield* Plugin.Service function teardown(session: Active) { @@ -169,7 +169,7 @@ export const layer = Layer.effect( s.sessions.delete(id) log.info("removing session", { id }) teardown(session) - yield* bus.publish(Event.Deleted, { id: session.info.id }) + yield* events.publish(Event.Deleted, { id: session.info.id }) }) const list = Effect.fn("Pty.list")(function* () { @@ -265,10 +265,10 @@ export const layer = Layer.effect( if (session.info.status === "exited") return log.info("session exited", { id, exitCode }) session.info.status = "exited" - bridge.fork(bus.publish(Event.Exited, { id, exitCode })) + bridge.fork(events.publish(Event.Exited, { id, exitCode })) bridge.fork(remove(id)) }) - yield* bus.publish(Event.Created, { info }) + yield* events.publish(Event.Created, { info }) return info }) @@ -280,7 +280,7 @@ export const layer = Layer.effect( if (input.size) { session.process.resize(input.size.cols, input.size.rows) } - yield* bus.publish(Event.Updated, { info: session.info }) + yield* events.publish(Event.Updated, { info: session.info }) return session.info }) @@ -365,7 +365,7 @@ export const layer = Layer.effect( ) export const defaultLayer = layer.pipe( - Layer.provide(Bus.layer), + Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(Plugin.defaultLayer), Layer.provide(Config.defaultLayer), ) diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts index e03af848b..051ae7afd 100644 --- a/packages/opencode/src/question/index.ts +++ b/packages/opencode/src/question/index.ts @@ -1,10 +1,10 @@ import { Deferred, Effect, Layer, Schema, Context } from "effect" -import { Bus } from "@/bus" -import { BusEvent } from "@/bus/bus-event" import { InstanceState } from "@/effect/instance-state" import { SessionID, MessageID } from "@/session/schema" import * as Log from "@opencode-ai/core/util/log" import { QuestionID } from "./schema" +import { EventV2Bridge } from "@/event-v2-bridge" +import { EventV2 } from "@opencode-ai/core/event" const log = Log.create({ service: "question" }) @@ -87,9 +87,9 @@ const Rejected = Schema.Struct({ }).annotate({ identifier: "QuestionRejected" }) export const Event = { - Asked: BusEvent.define("question.asked", Request), - Replied: BusEvent.define("question.replied", Replied), - Rejected: BusEvent.define("question.rejected", Rejected), + Asked: EventV2.define({ type: "question.asked", schema: Request.fields }), + Replied: EventV2.define({ type: "question.replied", schema: Replied.fields }), + Rejected: EventV2.define({ type: "question.rejected", schema: Rejected.fields }), } export class RejectedError extends Schema.TaggedErrorClass()("QuestionRejectedError", {}) { @@ -132,7 +132,7 @@ export class Service extends Context.Service()("@opencode/Qu export const layer = Layer.effect( Service, Effect.gen(function* () { - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service const state = yield* InstanceState.make( Effect.fn("Question.state")(function* () { const state = { @@ -169,7 +169,7 @@ export const layer = Layer.effect( tool: input.tool, } pending.set(id, { info, deferred }) - yield* bus.publish(Event.Asked, info) + yield* events.publish(Event.Asked, info) return yield* Effect.ensuring( Deferred.await(deferred), @@ -191,7 +191,7 @@ export const layer = Layer.effect( } pending.delete(input.requestID) log.info("replied", { requestID: input.requestID, answers: input.answers }) - yield* bus.publish(Event.Replied, { + yield* events.publish(Event.Replied, { sessionID: existing.info.sessionID, requestID: existing.info.id, answers: input.answers.map((a) => [...a]), @@ -208,7 +208,7 @@ export const layer = Layer.effect( } pending.delete(requestID) log.info("rejected", { requestID }) - yield* bus.publish(Event.Rejected, { + yield* events.publish(Event.Rejected, { sessionID: existing.info.sessionID, requestID: existing.info.id, }) @@ -224,6 +224,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(Bus.layer)) +export const defaultLayer = layer.pipe(Layer.provide(EventV2Bridge.defaultLayer)) export * as Question from "." diff --git a/packages/opencode/src/server/event.ts b/packages/opencode/src/server/event.ts index d5f10f47d..f7b657da3 100644 --- a/packages/opencode/src/server/event.ts +++ b/packages/opencode/src/server/event.ts @@ -1,7 +1,6 @@ -import { BusEvent } from "@/bus/bus-event" -import { Schema } from "effect" +import { EventV2 } from "@opencode-ai/core/event" export const Event = { - Connected: BusEvent.define("server.connected", Schema.Struct({})), - Disposed: BusEvent.define("global.disposed", Schema.Struct({})), + Connected: EventV2.define({ type: "server.connected", schema: {} }), + Disposed: EventV2.define({ type: "global.disposed", schema: {} }), } diff --git a/packages/opencode/src/server/routes/instance/httpapi/api.ts b/packages/opencode/src/server/routes/instance/httpapi/api.ts index 59392a03b..0f0b695ea 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/api.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/api.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" import { HttpApi } from "effect/unstable/httpapi" -import { BusEvent } from "@/bus/bus-event" +import { EventV2 } from "@opencode-ai/core/event" import { ConfigApi } from "./groups/config" import { ControlApi } from "./groups/control" import { EventApi } from "./groups/event" @@ -22,8 +22,18 @@ import { V2Api } from "./groups/v2" import { Authorization } from "./middleware/authorization" import { SchemaErrorMiddleware } from "./middleware/schema-error" -// SSE event schemas built from the BusEvent and EventV2 registries. -const EventSchema = Schema.Union(BusEvent.effectPayloads()).annotate({ identifier: "Event" }) +const EventSchema = Schema.Union( + EventV2.registry + .values() + .map((definition) => + Schema.Struct({ + id: Schema.String, + type: Schema.Literal(definition.type), + properties: definition.data, + }).annotate({ identifier: `Event.${definition.type}` }), + ) + .toArray(), +).annotate({ identifier: "Event" }) export const RootHttpApi = HttpApi.make("opencode-root") .addHttpApi(ControlApi) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts index c18b71a7d..b7a50962d 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts @@ -1,5 +1,5 @@ import { Config } from "@/config/config" -import { BusEvent } from "@/bus/bus-event" +import { EventV2 } from "@opencode-ai/core/event" import "@/server/event" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" @@ -14,7 +14,14 @@ const GlobalEventSchema = Schema.Struct({ directory: Schema.String, project: Schema.optional(Schema.String), workspace: Schema.optional(Schema.String), - payload: Schema.Union(BusEvent.effectPayloads()), + payload: Schema.Union( + EventV2.registry + .values() + .map((definition) => + Schema.Struct({ id: Schema.String, type: Schema.Literal(definition.type), properties: definition.data }), + ) + .toArray(), + ), }).annotate({ identifier: "GlobalEvent" }) export const GlobalUpgradeInput = Schema.Struct({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts index 3cf3de5b8..692461d68 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts @@ -12,19 +12,19 @@ const root = "/tui" export const CommandPayload = Schema.Struct({ command: Schema.String }) const EventTuiPromptAppend = Schema.Struct({ type: Schema.Literal(TuiEvent.PromptAppend.type), - properties: TuiEvent.PromptAppend.properties, + properties: TuiEvent.PromptAppend.data, }).annotate({ identifier: "EventTuiPromptAppend" }) const EventTuiCommandExecute = Schema.Struct({ type: Schema.Literal(TuiEvent.CommandExecute.type), - properties: TuiEvent.CommandExecute.properties, + properties: TuiEvent.CommandExecute.data, }).annotate({ identifier: "EventTuiCommandExecute" }) const EventTuiToastShow = Schema.Struct({ type: Schema.Literal(TuiEvent.ToastShow.type), - properties: TuiEvent.ToastShow.properties, + properties: TuiEvent.ToastShow.data, }).annotate({ identifier: "EventTuiToastShow" }) const EventTuiSessionSelect = Schema.Struct({ type: Schema.Literal(TuiEvent.SessionSelect.type), - properties: TuiEvent.SessionSelect.properties, + properties: TuiEvent.SessionSelect.data, }).annotate({ identifier: "EventTuiSessionSelect" }) export const TuiPublishPayload = Schema.Union([ EventTuiPromptAppend, @@ -55,7 +55,7 @@ export const TuiApi = HttpApi.make("tui") .add( HttpApiEndpoint.post("appendPrompt", TuiPaths.appendPrompt, { query: WorkspaceRoutingQuery, - payload: TuiEvent.PromptAppend.properties, + payload: TuiEvent.PromptAppend.data, success: described(Schema.Boolean, "Prompt processed successfully"), error: HttpApiError.BadRequest, }).annotateMerge( @@ -139,7 +139,7 @@ export const TuiApi = HttpApi.make("tui") ), HttpApiEndpoint.post("showToast", TuiPaths.showToast, { query: WorkspaceRoutingQuery, - payload: TuiEvent.ToastShow.properties, + payload: TuiEvent.ToastShow.data, success: described(Schema.Boolean, "Toast notification shown successfully"), }).annotateMerge( OpenApi.annotations({ @@ -162,7 +162,7 @@ export const TuiApi = HttpApi.make("tui") ), HttpApiEndpoint.post("selectSession", TuiPaths.selectSession, { query: WorkspaceRoutingQuery, - payload: TuiEvent.SessionSelect.properties, + payload: TuiEvent.SessionSelect.data, success: described(Schema.Boolean, "Session selected successfully"), error: [HttpApiError.BadRequest, ApiNotFoundError], }).annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts index a1244adda..be2fdb5ba 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts @@ -2,7 +2,7 @@ import { SessionID } from "@/session/schema" import { SessionMessage } from "@opencode-ai/core/session/message" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { InvalidCursorError, SessionNotFoundError } from "../../errors" +import { InvalidCursorError, SessionNotFoundError, UnknownError } from "../../errors" import { V2Authorization } from "../../middleware/authorization" import { WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing" @@ -36,7 +36,7 @@ export const MessageGroup = HttpApiGroup.make("v2.message") next: Schema.String.pipe(Schema.optional), }), }).annotate({ identifier: "V2SessionMessagesResponse" }), - error: [InvalidCursorError, SessionNotFoundError], + error: [InvalidCursorError, SessionNotFoundError, UnknownError], }).annotateMerge( OpenApi.annotations({ identifier: "v2.session.messages", diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts index 328603166..da228445c 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts @@ -1,12 +1,15 @@ import { SessionID } from "@/session/schema" import { SessionMessage } from "@opencode-ai/core/session/message" +import { Prompt } from "@opencode-ai/core/session/prompt" import { SessionV2 } from "@opencode-ai/core/session" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { InvalidCursorError, InvalidRequestError, + ServiceUnavailableError, SessionNotFoundError, + UnknownError, } from "../../errors" import { V2Authorization } from "../../middleware/authorization" import { WorkspaceRoutingQuery, WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing" @@ -55,12 +58,30 @@ export const SessionGroup = HttpApiGroup.make("v2.session") }), ), ) + .add( + HttpApiEndpoint.post("prompt", "/api/session/:sessionID/prompt", { + params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, + payload: Schema.Struct({ + prompt: Prompt, + delivery: SessionV2.Delivery.pipe(Schema.optional), + }), + success: SessionMessage.Message, + error: [SessionNotFoundError, ServiceUnavailableError], + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.prompt", + summary: "Send v2 message", + description: "Create a v2 session message and queue it for the agent loop.", + }), + ), + ) .add( HttpApiEndpoint.post("compact", "/api/session/:sessionID/compact", { params: { sessionID: SessionID }, query: WorkspaceRoutingQuery, success: HttpApiSchema.NoContent, - error: [SessionNotFoundError], + error: [SessionNotFoundError, ServiceUnavailableError], }).annotateMerge( OpenApi.annotations({ identifier: "v2.session.compact", @@ -74,7 +95,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session") params: { sessionID: SessionID }, query: WorkspaceRoutingQuery, success: HttpApiSchema.NoContent, - error: [SessionNotFoundError], + error: [SessionNotFoundError, ServiceUnavailableError], }).annotateMerge( OpenApi.annotations({ identifier: "v2.session.wait", @@ -88,7 +109,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session") params: { sessionID: SessionID }, query: WorkspaceRoutingQuery, success: Schema.Array(SessionMessage.Message), - error: [SessionNotFoundError], + error: [SessionNotFoundError, UnknownError], }).annotateMerge( OpenApi.annotations({ identifier: "v2.session.context", diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/event.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/event.ts index e770a7cfb..edf50927a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/event.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/event.ts @@ -1,6 +1,9 @@ -import { Bus } from "@/bus" +import { EventV2Bridge } from "@/event-v2-bridge" +import { InstanceState } from "@/effect/instance-state" +import { GlobalBus } from "@/bus/global" +import { EventV2 } from "@opencode-ai/core/event" import * as Log from "@opencode-ai/core/util/log" -import { Effect } from "effect" +import { Effect, Queue } from "effect" import * as Stream from "effect/Stream" import { HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" @@ -18,24 +21,51 @@ function eventData(data: unknown): Sse.Event { } } -function eventResponse(bus: Bus.Interface) { +function eventID() { + return EventV2.ID.create() +} + +function eventResponse(events: EventV2.Interface) { return Effect.gen(function* () { - // Subscribe eagerly: the bus subscription is acquired in the request scope - // at this yield, so any publish from now on is queued for the body-pump - // fiber to drain — closing the race where Stream.concat(server.connected, - // lazy-subscribe) used to drop publishes in the prefix-consume window. - const events = (yield* bus.subscribeAll()).pipe( - Stream.takeUntil((event) => event.type === Bus.InstanceDisposed.type), + const instance = yield* InstanceState.context + const workspaceID = yield* InstanceState.workspaceID + // Listener registration is eager, so events published after this point cannot + // be lost while the HTTP body fiber is starting or emitting server.connected. + const queue = yield* Queue.unbounded() + const unsubscribe = yield* events.listen((event) => Effect.sync(() => Queue.offerUnsafe(queue, event))) + yield* Effect.addFinalizer(() => unsubscribe) + const stream = Stream.fromQueue(queue).pipe( + Stream.filter( + (event) => + event.location?.directory === instance.directory && + (event.location.workspaceID === undefined || event.location.workspaceID === workspaceID), + ), + Stream.map((event) => ({ id: event.id, type: event.type, properties: event.data })), ) + const disposed = Stream.callback<{ id: string; type: string; properties: unknown }>((queue) => { + const listener = (event: { directory?: string; payload: { id?: string; type?: string; properties?: unknown } }) => { + if (event.directory !== instance.directory || event.payload.type !== "server.instance.disposed") return + Queue.offerUnsafe(queue, { + id: event.payload.id ?? eventID(), + type: "server.instance.disposed", + properties: event.payload.properties ?? {}, + }) + } + return Effect.acquireRelease( + Effect.sync(() => GlobalBus.on("event", listener)), + () => Effect.sync(() => GlobalBus.off("event", listener)), + ) + }) + const output = stream.pipe(Stream.merge(disposed, { haltStrategy: "left" }), Stream.takeUntil((event) => event.type === "server.instance.disposed")) const heartbeat = Stream.tick("10 seconds").pipe( Stream.drop(1), - Stream.map(() => ({ id: Bus.createID(), type: "server.heartbeat", properties: {} })), + Stream.map(() => ({ id: eventID(), type: "server.heartbeat", properties: {} })), ) log.info("event connected") return HttpServerResponse.stream( - Stream.make({ id: Bus.createID(), type: "server.connected", properties: {} }).pipe( - Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))), + Stream.make({ id: eventID(), type: "server.connected", properties: {} }).pipe( + Stream.concat(output.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))), Stream.map(eventData), Stream.pipeThroughChannel(Sse.encode()), Stream.encodeText, @@ -55,11 +85,11 @@ function eventResponse(bus: Bus.Interface) { export const eventHandlers = HttpApiBuilder.group(EventApi, "event", (handlers) => Effect.gen(function* () { - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service return handlers.handleRaw( "subscribe", Effect.fn("EventHttpApi.subscribe")(function* () { - return yield* eventResponse(bus) + return yield* eventResponse(events) }), ) }), diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts index 4061bec29..e995c2160 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts @@ -29,6 +29,7 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper const project = yield* Project.Service const registry = yield* ToolRegistry.Service const worktreeSvc = yield* Worktree.Service + const sessions = yield* Session.Service const getConsole = Effect.fn("ExperimentalHttpApi.console")(function* () { const [state, groups] = yield* Effect.all( @@ -127,21 +128,19 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper const session = Effect.fn("ExperimentalHttpApi.session")(function* (ctx: { query: typeof SessionListQuery.Type }) { const limit = ctx.query.limit ?? 100 - const sessions = Array.from( - Session.listGlobal({ - directory: ctx.query.directory, - roots: ctx.query.roots, - start: ctx.query.start, - cursor: ctx.query.cursor, - search: ctx.query.search, - limit: limit + 1, - archived: ctx.query.archived, - }), - ) - const list = sessions.length > limit ? sessions.slice(0, limit) : sessions + const all = yield* sessions.listGlobal({ + directory: ctx.query.directory, + roots: ctx.query.roots, + start: ctx.query.start, + cursor: ctx.query.cursor, + search: ctx.query.search, + limit: limit + 1, + archived: ctx.query.archived, + }) + const list = all.length > limit ? all.slice(0, limit) : all return HttpServerResponse.jsonUnsafe(list, { headers: - sessions.length > limit && list.length > 0 + all.length > limit && list.length > 0 ? { "x-next-cursor": String(list[list.length - 1].time.updated) } : undefined, }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts index f80869b64..a63a9a958 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts @@ -1,7 +1,7 @@ import { Config } from "@/config/config" import { GlobalBus, type GlobalEvent as GlobalBusEvent } from "@/bus/global" import { EffectBridge } from "@/effect/bridge" -import { Bus } from "@/bus" +import { EventV2 } from "@opencode-ai/core/event" import { Installation } from "@/installation" import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle" import { InstallationVersion } from "@opencode-ai/core/installation/version" @@ -44,11 +44,11 @@ function eventResponse() { }) const heartbeat = Stream.tick("10 seconds").pipe( Stream.drop(1), - Stream.map(() => ({ payload: { id: Bus.createID(), type: "server.heartbeat", properties: {} } })), + Stream.map(() => ({ payload: { id: EventV2.ID.create(), type: "server.heartbeat", properties: {} } })), ) return HttpServerResponse.stream( - Stream.make({ payload: { id: Bus.createID(), type: "server.connected", properties: {} } }).pipe( + Stream.make({ payload: { id: EventV2.ID.create(), type: "server.connected", properties: {} } }).pipe( Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))), Stream.map(eventData), Stream.pipeThroughChannel(Sse.encode()), diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index c89d8bedb..4bd2afa96 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -1,6 +1,6 @@ import { Agent } from "@/agent/agent" import { SessionLegacy } from "@opencode-ai/core/session/legacy" -import { Bus } from "@/bus" +import { EventV2Bridge } from "@/event-v2-bridge" import { Command } from "@/command" import { Permission } from "@/permission" import { PermissionID } from "@/permission/schema" @@ -57,7 +57,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", const statusSvc = yield* SessionStatus.Service const todoSvc = yield* Todo.Service const summary = yield* SessionSummary.Service - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service const scope = yield* Scope.Scope const list = Effect.fn("SessionHttpApi.list")(function* (ctx: { query: typeof ListQuery.Type }) { @@ -311,7 +311,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", yield* Effect.logError("prompt_async failed").pipe( Effect.annotateLogs({ sessionID: ctx.params.sessionID, cause }), ) - yield* bus.publish(Session.Event.Error, { + yield* events.publish(Session.Event.Error, { sessionID: ctx.params.sessionID, error: new NamedError.Unknown({ message: Cause.pretty(cause) }).toObject(), }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts index 0ecebf451..31ecd5eff 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts @@ -1,4 +1,4 @@ -import { Bus } from "@/bus" +import { EventV2Bridge } from "@/event-v2-bridge" import { TuiEvent } from "@/cli/cmd/tui/event" import { Session } from "@/session/session" import { Effect } from "effect" @@ -26,15 +26,15 @@ const commandAliases = { export const tuiHandlers = HttpApiBuilder.group(InstanceHttpApi, "tui", (handlers) => Effect.gen(function* () { - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service const session = yield* Session.Service - const publishCommand = (command: typeof TuiEvent.CommandExecute.properties.Type.command | undefined) => - bus.publish(TuiEvent.CommandExecute, { command } as typeof TuiEvent.CommandExecute.properties.Type) + const publishCommand = (command: typeof TuiEvent.CommandExecute.data.Type.command | undefined) => + events.publish(TuiEvent.CommandExecute, { command } as typeof TuiEvent.CommandExecute.data.Type) const appendPrompt = Effect.fn("TuiHttpApi.appendPrompt")(function* (ctx: { - payload: typeof TuiEvent.PromptAppend.properties.Type + payload: typeof TuiEvent.PromptAppend.data.Type }) { - yield* bus.publish(TuiEvent.PromptAppend, ctx.payload) + yield* events.publish(TuiEvent.PromptAppend, ctx.payload) return true }) @@ -77,29 +77,29 @@ export const tuiHandlers = HttpApiBuilder.group(InstanceHttpApi, "tui", (handler }) const showToast = Effect.fn("TuiHttpApi.showToast")(function* (ctx: { - payload: typeof TuiEvent.ToastShow.properties.Type + payload: typeof TuiEvent.ToastShow.data.Type }) { - yield* bus.publish(TuiEvent.ToastShow, ctx.payload) + yield* events.publish(TuiEvent.ToastShow, ctx.payload) return true }) const publish = Effect.fn("TuiHttpApi.publish")(function* (ctx: { payload: typeof TuiPublishPayload.Type }) { if (ctx.payload.type === TuiEvent.PromptAppend.type) - yield* bus.publish(TuiEvent.PromptAppend, ctx.payload.properties) + yield* events.publish(TuiEvent.PromptAppend, ctx.payload.properties) if (ctx.payload.type === TuiEvent.CommandExecute.type) - yield* bus.publish(TuiEvent.CommandExecute, ctx.payload.properties) - if (ctx.payload.type === TuiEvent.ToastShow.type) yield* bus.publish(TuiEvent.ToastShow, ctx.payload.properties) + yield* events.publish(TuiEvent.CommandExecute, ctx.payload.properties) + if (ctx.payload.type === TuiEvent.ToastShow.type) yield* events.publish(TuiEvent.ToastShow, ctx.payload.properties) if (ctx.payload.type === TuiEvent.SessionSelect.type) - yield* bus.publish(TuiEvent.SessionSelect, ctx.payload.properties) + yield* events.publish(TuiEvent.SessionSelect, ctx.payload.properties) return true }) const selectSession = Effect.fn("TuiHttpApi.selectSession")(function* (ctx: { - payload: typeof TuiEvent.SessionSelect.properties.Type + payload: typeof TuiEvent.SessionSelect.data.Type }) { if (!ctx.payload.sessionID.startsWith("ses")) return yield* new HttpApiError.BadRequest({}) yield* SessionError.mapStorageNotFound(session.get(ctx.payload.sessionID)) - yield* bus.publish(TuiEvent.SessionSelect, ctx.payload) + yield* events.publish(TuiEvent.SessionSelect, ctx.payload) return true }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts index d97a32e89..c9cfe33bc 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts @@ -4,7 +4,7 @@ import { Effect, Schema } from "effect" import * as DateTime from "effect/DateTime" import { HttpApiBuilder } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../../api" -import { InvalidCursorError, SessionNotFoundError } from "../../errors" +import { InvalidCursorError, SessionNotFoundError, UnknownError } from "../../errors" const DefaultMessagesLimit = 50 @@ -58,6 +58,20 @@ export const messageHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.message }), ), ), + Effect.catchTag("Session.MessageDecodeError", (error) => { + const ref = `err_${crypto.randomUUID().slice(0, 8)}` + return Effect.logError("failed to decode v2 session message").pipe( + Effect.annotateLogs({ ref, sessionID: error.sessionID, messageID: error.messageID }), + Effect.andThen( + Effect.fail( + new UnknownError({ + message: "Unexpected server error. Check server logs for details.", + ref, + }), + ), + ), + ) + }), ) const first = messages[0] const last = messages.at(-1) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts index c472eabda..f6f126335 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts @@ -4,7 +4,13 @@ import { AbsolutePath } from "@opencode-ai/core/schema" import { DateTime, Effect, Option, Schema } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../../api" -import { InvalidCursorError, InvalidRequestError, SessionNotFoundError } from "../../errors" +import { + InvalidCursorError, + InvalidRequestError, + ServiceUnavailableError, + SessionNotFoundError, + UnknownError, +} from "../../errors" const DefaultSessionsLimit = 50 @@ -135,6 +141,35 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session } }), ) + .handle( + "prompt", + Effect.fn(function* (ctx) { + return yield* session + .prompt({ + sessionID: ctx.params.sessionID, + prompt: ctx.payload.prompt, + delivery: ctx.payload.delivery ?? SessionV2.DefaultDelivery, + }) + .pipe( + Effect.catchTag("Session.NotFoundError", (error) => + Effect.fail( + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ), + Effect.catchTag("Session.OperationUnavailableError", (error) => + Effect.fail( + new ServiceUnavailableError({ + message: `V2 session ${error.operation} is not available yet`, + service: `v2.session.${error.operation}`, + }), + ), + ), + ) + }), + ) .handle( "compact", Effect.fn(function* (ctx) { @@ -147,6 +182,14 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session }), ), ), + Effect.catchTag("Session.OperationUnavailableError", (error) => + Effect.fail( + new ServiceUnavailableError({ + message: `V2 session ${error.operation} is not available yet`, + service: `v2.session.${error.operation}`, + }), + ), + ), ) return HttpApiSchema.NoContent.make() }), @@ -163,6 +206,14 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session }), ), ), + Effect.catchTag("Session.OperationUnavailableError", (error) => + Effect.fail( + new ServiceUnavailableError({ + message: `V2 session ${error.operation} is not available yet`, + service: `v2.session.${error.operation}`, + }), + ), + ), ) return HttpApiSchema.NoContent.make() }), @@ -179,6 +230,20 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session }), ), ), + Effect.catchTag("Session.MessageDecodeError", (error) => { + const ref = `err_${crypto.randomUUID().slice(0, 8)}` + return Effect.logError("failed to decode v2 session message").pipe( + Effect.annotateLogs({ ref, sessionID: error.sessionID, messageID: error.messageID }), + Effect.andThen( + Effect.fail( + new UnknownError({ + message: "Unexpected server error. Check server logs for details.", + ref, + }), + ), + ), + ) + }), ) }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/fence.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/fence.ts index f3bfe0668..c5cbc7b82 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/fence.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/fence.ts @@ -1,20 +1,25 @@ import { Flag } from "@opencode-ai/core/flag/flag" +import { Database } from "@opencode-ai/core/database/database" import { Effect } from "effect" import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import * as Fence from "@/server/shared/fence" const ignoredMethods = new Set(["GET", "HEAD", "OPTIONS"]) -export const fenceLayer = HttpRouter.middleware<{ handles: unknown }>()((effect) => +export const fenceLayer = HttpRouter.middleware<{ requires: Database.Service; handles: unknown }>()( Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest - if (!Flag.OPENCODE_WORKSPACE_ID || ignoredMethods.has(request.method)) return yield* effect + const { db } = yield* Database.Service + return (effect) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest + if (!Flag.OPENCODE_WORKSPACE_ID || ignoredMethods.has(request.method)) return yield* effect - const previous = Fence.load() - const response = yield* effect - const current = Fence.diff(previous, Fence.load()) - if (Object.keys(current).length === 0) return response + const previous = yield* Fence.load(db) + const response = yield* effect + const current = Fence.diff(previous, yield* Fence.load(db)) + if (Object.keys(current).length === 0) return response - return HttpServerResponse.setHeader(response, Fence.HEADER, JSON.stringify(current)) + return HttpServerResponse.setHeader(response, Fence.HEADER, JSON.stringify(current)) + }) }), ).layer diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 94990fdba..43a5f2af4 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -13,7 +13,6 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Account } from "@/account/account" import { Agent } from "@/agent/agent" import { Auth } from "@/auth" -import { Bus } from "@/bus" import { Config } from "@/config/config" import { Command } from "@/command" import * as Observability from "@opencode-ai/core/effect/observability" @@ -189,7 +188,7 @@ export function createRoutes( errorLayer, compressionLayer, corsVaryFix, - fenceLayer, + fenceLayer.pipe(Layer.provide(Database.defaultLayer)), cors(corsOptions), Database.defaultLayer, Account.defaultLayer, @@ -231,7 +230,6 @@ export function createRoutes( Vcs.defaultLayer, Workspace.defaultLayer, Worktree.appLayer, - Bus.defaultLayer, AppFileSystem.defaultLayer, FetchHttpClient.layer, HttpServer.layerServices, diff --git a/packages/opencode/src/server/shared/fence.ts b/packages/opencode/src/server/shared/fence.ts index a3e86b165..d01f15d21 100644 --- a/packages/opencode/src/server/shared/fence.ts +++ b/packages/opencode/src/server/shared/fence.ts @@ -5,24 +5,21 @@ import { Workspace } from "@/control-plane/workspace" import type { WorkspaceV2 } from "@opencode-ai/core/workspace" import * as Log from "@opencode-ai/core/util/log" import { Effect } from "effect" -import { makeRuntime } from "@/effect/run-service" export const HEADER = "x-opencode-sync" export type State = Record const log = Log.create({ service: "fence" }) -const runtime = makeRuntime(Database.Service, Database.defaultLayer) -export function load(ids?: string[]) { - return runtime.runSync(({ db }) => - Effect.gen(function* () { - const rows = yield* (ids?.length +export function load(db: Database.Interface["db"], ids?: string[]) { + return Effect.gen(function* () { + const rows = yield* ( + ids?.length ? db.select().from(EventSequenceTable).where(inArray(EventSequenceTable.aggregate_id, ids)).all() : db.select().from(EventSequenceTable).all() - ).pipe(Effect.orDie) + ).pipe(Effect.orDie) - return Object.fromEntries(rows.map((row) => [row.aggregate_id, row.seq])) - }), - ) + return Object.fromEntries(rows.map((row) => [row.aggregate_id, row.seq])) + }) } export function diff(prev: State, next: State) { diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index e2d486949..c20cf7e07 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -1,6 +1,4 @@ -import { BusEvent } from "@/bus/bus-event" import { SessionLegacy } from "@opencode-ai/core/session/legacy" -import { Bus } from "@/bus" import * as Session from "./session" import { SessionID, MessageID, PartID } from "./schema" import { Provider } from "@/provider/provider" @@ -22,16 +20,17 @@ import { RuntimeFlags } from "@/effect/runtime-flags" import { EventV2Bridge } from "@/event-v2-bridge" import { SessionEvent } from "@opencode-ai/core/session/event" import { ProviderV2 } from "@opencode-ai/core/provider" +import { EventV2 } from "@opencode-ai/core/event" const log = Log.create({ service: "session.compaction" }) export const Event = { - Compacted: BusEvent.define( - "session.compacted", - Schema.Struct({ + Compacted: EventV2.define({ + type: "session.compacted", + schema: { sessionID: SessionID, - }), - ), + }, + }), } export const PRUNE_MINIMUM = 20_000 @@ -214,7 +213,6 @@ export const use = serviceUse(Service) export const layer = Layer.effect( Service, Effect.gen(function* () { - const bus = yield* Bus.Service const config = yield* Config.Service const session = yield* Session.Service const agents = yield* Agent.Service @@ -578,7 +576,7 @@ export const layer = Layer.effect( include: selected.tail_start_id, }) } - yield* bus.publish(Event.Compacted, { sessionID: input.sessionID }) + yield* events.publish(Event.Compacted, { sessionID: input.sessionID }) } return result }) @@ -631,7 +629,6 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(SessionProcessor.defaultLayer), Layer.provide(Agent.defaultLayer), Layer.provide(Plugin.defaultLayer), - Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer), Layer.provide(EventV2Bridge.defaultLayer), diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index da3301e23..0ea62147c 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -16,7 +16,8 @@ import type { MessageV2 } from "./message-v2" import { Plugin } from "@/plugin" import { Permission } from "@/permission" import { PermissionID } from "@/permission/schema" -import { Bus } from "@/bus" +import { EventV2Bridge } from "@/event-v2-bridge" +import { EventV2 } from "@opencode-ai/core/event" import { Wildcard } from "@/util/wildcard" import { SessionID } from "@/session/schema" import { Auth } from "@/auth" @@ -66,6 +67,7 @@ const live: Layer.Layer< | Provider.Service | Plugin.Service | Permission.Service + | EventV2Bridge.Service | LLMClientService | RuntimeFlags.Service > = Layer.effect( @@ -76,6 +78,7 @@ const live: Layer.Layer< const provider = yield* Provider.Service const plugin = yield* Plugin.Service const perm = yield* Permission.Service + const events = yield* EventV2Bridge.Service const llmClient = yield* LLMClient.Service const flags = yield* RuntimeFlags.Service @@ -163,11 +166,15 @@ const live: Layer.Layer< } const id = PermissionID.ascending() - let unsub: (() => void) | undefined + let unsub: EventV2.Unsubscribe | undefined try { - unsub = Bus.subscribe(Permission.Event.Replied, (evt) => { - if (evt.properties.requestID === id) void evt.properties.reply - }) + unsub = await bridge.promise(events.listen((event) => { + if (event.type !== Permission.Event.Replied.type) return Effect.void + const data = event.data as EventV2.Data + if (data.requestID !== id) return Effect.void + void data.reply + return Effect.void + })) const toolPatterns = approvalTools.map((t: { name: string; args: string }) => { try { const parsed = JSON.parse(t.args) as Record @@ -195,7 +202,7 @@ const live: Layer.Layer< } catch { return { approved: false } } finally { - unsub?.() + if (unsub) await bridge.promise(unsub) } }) } @@ -371,7 +378,7 @@ const live: Layer.Layer< }), ) -export const layer = live.pipe(Layer.provide(Permission.defaultLayer)) +export const layer = live.pipe(Layer.provide(Permission.defaultLayer), Layer.provide(EventV2Bridge.defaultLayer)) export const defaultLayer = Layer.suspend(() => layer.pipe( diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 98ca7699f..b5811264d 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -1,4 +1,4 @@ -import { BusEvent } from "@/bus/bus-event" +import { EventV2 } from "@opencode-ai/core/event" import { SessionID, MessageID, PartID } from "./schema" import { SessionLegacy } from "@opencode-ai/core/session/legacy" import { ProviderV2 } from "@opencode-ai/core/provider" @@ -56,20 +56,20 @@ function truncateToolOutput(text: string, maxChars?: number) { } export const Event = { - Updated: BusEvent.define("message.updated", SessionLegacy.Event.MessageUpdated.data), - Removed: BusEvent.define("message.removed", SessionLegacy.Event.MessageRemoved.data), - PartUpdated: BusEvent.define("message.part.updated", SessionLegacy.Event.PartUpdated.data), - PartDelta: BusEvent.define( - "message.part.delta", - Schema.Struct({ + Updated: SessionLegacy.Event.MessageUpdated, + Removed: SessionLegacy.Event.MessageRemoved, + PartUpdated: SessionLegacy.Event.PartUpdated, + PartDelta: EventV2.define({ + type: "message.part.delta", + schema: { sessionID: SessionID, messageID: MessageID, partID: PartID, field: Schema.String, delta: Schema.String, - }), - ), - PartRemoved: BusEvent.define("message.part.removed", SessionLegacy.Event.PartRemoved.data), + }, + }), + PartRemoved: SessionLegacy.Event.PartRemoved, } const Cursor = Schema.Struct({ diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 06b888579..f124f7eea 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -3,7 +3,6 @@ import { SessionLegacy } from "@opencode-ai/core/session/legacy" import { Cause, Deferred, Effect, Exit, Layer, Context, Scope, Schema } from "effect" import * as Stream from "effect/Stream" import { Agent } from "@/agent/agent" -import { Bus } from "@/bus" import { Config } from "@/config/config" import { Permission } from "@/permission" import { Plugin } from "@/plugin" @@ -91,7 +90,6 @@ export const layer = Layer.effect( Effect.gen(function* () { const session = yield* Session.Service const config = yield* Config.Service - const bus = yield* Bus.Service const snapshot = yield* Snapshot.Service const agents = yield* Agent.Service const llm = yield* LLM.Service @@ -758,7 +756,7 @@ export const layer = Layer.effect( const error = parse(e) if (SessionLegacy.ContextOverflowError.isInstance(error)) { ctx.needsCompaction = true - yield* bus.publish(Session.Event.Error, { sessionID: ctx.sessionID, error }) + yield* events.publish(Session.Event.Error, { sessionID: ctx.sessionID, error }) return } if (!ctx.assistantMessage.summary) { @@ -775,7 +773,7 @@ export const layer = Layer.effect( } } ctx.assistantMessage.error = error - yield* bus.publish(Session.Event.Error, { + yield* events.publish(Session.Event.Error, { sessionID: ctx.assistantMessage.sessionID, error: ctx.assistantMessage.error, }) @@ -878,7 +876,6 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(SessionSummary.defaultLayer), Layer.provide(SessionStatus.defaultLayer), Layer.provide(Image.defaultLayer), - Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer), Layer.provide(Database.defaultLayer), diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 52ea1d06f..f51635bf8 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -12,7 +12,6 @@ import { Provider } from "@/provider/provider" import { type Tool as AITool, tool, jsonSchema } from "ai" import type { JSONSchema7 } from "@ai-sdk/provider" import { SessionCompaction } from "./compaction" -import { Bus } from "../bus" import { SystemPrompt } from "./system" import { Instruction } from "./instruction" import { Plugin } from "../plugin" @@ -102,7 +101,6 @@ export class Service extends Context.Service()("@opencode/Se export const layer = Layer.effect( Service, Effect.gen(function* () { - const bus = yield* Bus.Service const status = yield* SessionStatus.Service const sessions = yield* Session.Service const agents = yield* Agent.Service @@ -366,7 +364,7 @@ export const layer = Layer.effect( const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name) const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" const error = new NamedError.Unknown({ message: `Agent not found: "${task.agent}".${hint}` }) - yield* bus.publish(Session.Event.Error, { sessionID, error: error.toObject() }) + yield* events.publish(Session.Event.Error, { sessionID, error: error.toObject() }) throw error } @@ -511,7 +509,7 @@ export const layer = Layer.effect( const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name) const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" const error = new NamedError.Unknown({ message: `Agent not found: "${input.agent}".${hint}` }) - yield* bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() }) + yield* events.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() }) throw error } const model = input.model ?? agent.model ?? (yield* currentModel(input.sessionID)) @@ -665,7 +663,7 @@ export const layer = Layer.effect( const err = Cause.squash(exit.cause) if (Provider.ModelNotFoundError.isInstance(err)) { const hint = err.suggestions?.length ? ` Did you mean: ${err.suggestions.join(", ")}?` : "" - yield* bus.publish(Session.Event.Error, { + yield* events.publish(Session.Event.Error, { sessionID, error: new NamedError.Unknown({ message: `Model not found: ${err.providerID}/${err.modelID}.${hint}`, @@ -703,7 +701,7 @@ export const layer = Layer.effect( const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name) const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" const error = new NamedError.Unknown({ message: `Agent not found: "${agentName}".${hint}` }) - yield* bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() }) + yield* events.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() }) throw error } @@ -963,7 +961,7 @@ export const layer = Layer.effect( const error = Cause.squash(exit.cause) log.error("failed to read file", { error }) const message = error instanceof Error ? error.message : String(error) - yield* bus.publish(Session.Event.Error, { + yield* events.publish(Session.Event.Error, { sessionID: input.sessionID, error: new NamedError.Unknown({ message }).toObject(), }) @@ -985,7 +983,7 @@ export const layer = Layer.effect( const error = Cause.squash(exit.cause) log.error("failed to read directory", { error }) const message = error instanceof Error ? error.message : String(error) - yield* bus.publish(Session.Event.Error, { + yield* events.publish(Session.Event.Error, { sessionID: input.sessionID, error: new NamedError.Unknown({ message }).toObject(), }) @@ -1340,7 +1338,7 @@ export const layer = Layer.effect( const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name) const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" const error = new NamedError.Unknown({ message: `Agent not found: "${lastUser.agent}".${hint}` }) - yield* bus.publish(Session.Event.Error, { sessionID, error: error.toObject() }) + yield* events.publish(Session.Event.Error, { sessionID, error: error.toObject() }) throw error } const maxSteps = agent.steps ?? Infinity @@ -1524,7 +1522,7 @@ export const layer = Layer.effect( const available = (yield* commands.list()).map((c) => c.name) const hint = available.length ? ` Available commands: ${available.join(", ")}` : "" const error = new NamedError.Unknown({ message: `Command not found: "${input.command}".${hint}` }) - yield* bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() }) + yield* events.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() }) throw error } const agentName = cmd.agent ?? input.agent @@ -1585,7 +1583,7 @@ export const layer = Layer.effect( const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name) const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" const error = new NamedError.Unknown({ message: `Agent not found: "${agentName}".${hint}` }) - yield* bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() }) + yield* events.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() }) throw error } @@ -1625,7 +1623,7 @@ export const layer = Layer.effect( parts, variant: input.variant, }) - yield* bus.publish(Command.Event.Executed, { + yield* events.publish(Command.Event.Executed, { name: input.command, sessionID: input.sessionID, arguments: input.arguments, @@ -1673,7 +1671,6 @@ export const defaultLayer = Layer.suspend(() => SystemPrompt.defaultLayer, LLM.defaultLayer, Reference.defaultLayer, - Bus.layer, CrossSpawnSpawner.defaultLayer, RuntimeFlags.defaultLayer, EventV2Bridge.defaultLayer, diff --git a/packages/opencode/src/session/revert.ts b/packages/opencode/src/session/revert.ts index 21096063e..f33330704 100644 --- a/packages/opencode/src/session/revert.ts +++ b/packages/opencode/src/session/revert.ts @@ -1,6 +1,6 @@ import { Effect, Layer, Context, Schema } from "effect" import { SessionLegacy } from "@opencode-ai/core/session/legacy" -import { Bus } from "../bus" +import { EventV2Bridge } from "@/event-v2-bridge" import { Snapshot } from "../snapshot" import { Storage } from "@/storage/storage" import * as Log from "@opencode-ai/core/util/log" @@ -33,7 +33,7 @@ export const layer = Layer.effect( const sessions = yield* Session.Service const snap = yield* Snapshot.Service const storage = yield* Storage.Service - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service const summary = yield* SessionSummary.Service const state = yield* SessionRunState.Service @@ -76,7 +76,7 @@ export const layer = Layer.effect( const range = all.filter((msg) => msg.info.id >= rev.messageID) const diffs = yield* summary.computeDiff({ messages: range }) yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore) - yield* bus.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs }) + yield* events.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs }) yield* sessions.setRevert({ sessionID: input.sessionID, revert: rev, @@ -145,7 +145,7 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(Session.defaultLayer), Layer.provide(Snapshot.defaultLayer), Layer.provide(Storage.defaultLayer), - Layer.provide(Bus.layer), + Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(SessionSummary.defaultLayer), ), ) diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index fa9436432..6da07f9b2 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -3,14 +3,13 @@ import { SessionLegacy } from "@opencode-ai/core/session/legacy" import { serviceUse } from "@opencode-ai/core/effect/service-use" import path from "path" import { BackgroundJob } from "@/background/job" -import { BusEvent } from "@/bus/bus-event" -import { Bus } from "@/bus" import { Decimal } from "decimal.js" import type { ProviderMetadata, Usage } from "@opencode-ai/llm" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { Database } from "@opencode-ai/core/database/database" import { makeRuntime } from "@opencode-ai/core/effect/runtime" import { EventV2Bridge } from "@/event-v2-bridge" +import { EventV2 } from "@opencode-ai/core/event" import { SessionV2 } from "@opencode-ai/core/session" import { NotFoundError } from "@/storage/storage" @@ -299,6 +298,16 @@ export type ListInput = { limit?: number } +export type GlobalListInput = { + directory?: string + roots?: boolean + start?: number + cursor?: number + search?: string + limit?: number + archived?: boolean +} + const CreatedEventSchema = Schema.Struct({ sessionID: SessionID, info: Info, @@ -342,25 +351,25 @@ const UpdatedEventSchema = Schema.Struct({ }) export const Event = { - Created: BusEvent.define("session.created", SessionLegacy.Event.Created.data), - Updated: BusEvent.define("session.updated", SessionLegacy.Event.Updated.data), - Deleted: BusEvent.define("session.deleted", SessionLegacy.Event.Deleted.data), - Diff: BusEvent.define( - "session.diff", - Schema.Struct({ + Created: SessionLegacy.Event.Created, + Updated: SessionLegacy.Event.Updated, + Deleted: SessionLegacy.Event.Deleted, + Diff: EventV2.define({ + type: "session.diff", + schema: { sessionID: SessionID, diff: Schema.Array(Snapshot.FileDiff), - }), - ), - Error: BusEvent.define( - "session.error", - Schema.Struct({ + }, + }), + Error: EventV2.define({ + type: "session.error", + schema: { sessionID: Schema.optional(SessionID), // Reuses SessionLegacy.Assistant.fields.error (already Schema.optional) so - // the derived zod keeps the same discriminated-union shape on the bus. + // the derived schema keeps the same discriminated-union shape on the event stream. error: SessionLegacy.Assistant.fields.error, - }), - ), + }, + }), } export function plan(input: { slug: string; time: { created: number } }, instance: InstanceContext) { @@ -445,6 +454,7 @@ export type NotFound = NotFoundError export interface Interface { readonly list: (input?: ListInput) => Effect.Effect + readonly listGlobal: (input?: GlobalListInput) => Effect.Effect readonly create: (input?: { parentID?: SessionID title?: string @@ -469,7 +479,10 @@ export interface Interface { readonly setShare: (input: { sessionID: SessionID; share: Info["share"] }) => Effect.Effect readonly setWorkspace: (input: { sessionID: SessionID; workspaceID: Info["workspaceID"] }) => Effect.Effect readonly diff: (sessionID: SessionID) => Effect.Effect - readonly messages: (input: { sessionID: SessionID; limit?: number }) => Effect.Effect + readonly messages: (input: { + sessionID: SessionID + limit?: number + }) => Effect.Effect readonly children: (parentID: SessionID) => Effect.Effect readonly remove: (sessionID: SessionID) => Effect.Effect readonly updateMessage: (msg: T) => Effect.Effect @@ -510,14 +523,17 @@ export type Patch = Omit, "time" | "share" | "summary" | "revert" export const layer: Layer.Layer< Service, never, - BackgroundJob.Service | Bus.Service | Storage.Service | RuntimeFlags.Service | Database.Service | EventV2Bridge.Service + | BackgroundJob.Service + | Storage.Service + | RuntimeFlags.Service + | Database.Service + | EventV2Bridge.Service > = Layer.effect( Service, Effect.gen(function* () { const { db } = yield* Database.Service const database = yield* Database.Service const background = yield* BackgroundJob.Service - const bus = yield* Bus.Service const events = yield* EventV2Bridge.Service const storage = yield* Storage.Service const flags = yield* RuntimeFlags.Service @@ -570,13 +586,11 @@ export const layer: Layer.Layer< } log.info("created", result) - yield* events.publish(SessionLegacy.Event.Created, { sessionID: result.id, info: result }, { location: eventLocation(result) }) - - if (!flags.experimentalWorkspaces) { - // This only exist for backwards compatibility. We should not be - // manually publishing this event; it is a sync event now - yield* bus.publish(Event.Updated, { sessionID: result.id, info: result }) - } + yield* events.publish( + SessionLegacy.Event.Created, + { sessionID: result.id, info: result }, + { location: eventLocation(result) }, + ) return result }) @@ -589,9 +603,52 @@ export const layer: Layer.Layer< const list = Effect.fn("Session.list")(function* (input?: ListInput) { const ctx = yield* InstanceState.context - return Array.from( - listByProject({ projectID: ctx.project.id, experimentalWorkspaces: flags.experimentalWorkspaces, ...input }), - ) + return yield* listByProject(db, { + projectID: ctx.project.id, + experimentalWorkspaces: flags.experimentalWorkspaces, + ...input, + }) + }) + + const listGlobal = Effect.fn("Session.listGlobal")(function* (input?: GlobalListInput) { + const conditions: SQL[] = [] + if (input?.directory) conditions.push(eq(SessionTable.directory, input.directory)) + if (input?.roots) conditions.push(isNull(SessionTable.parent_id)) + if (input?.start) conditions.push(gte(SessionTable.time_updated, input.start)) + if (input?.cursor) conditions.push(lt(SessionTable.time_updated, input.cursor)) + if (input?.search) conditions.push(like(SessionTable.title, `%${input.search}%`)) + if (!input?.archived) conditions.push(isNull(SessionTable.time_archived)) + + const query = + conditions.length > 0 + ? db + .select() + .from(SessionTable) + .where(and(...conditions)) + : db.select().from(SessionTable) + const rows = yield* query + .orderBy(desc(SessionTable.time_updated), desc(SessionTable.id)) + .limit(input?.limit ?? 100) + .all() + .pipe(Effect.orDie) + const ids = [...new Set(rows.map((row) => row.project_id))] + const projects = new Map() + if (ids.length > 0) { + const items = yield* db + .select({ id: ProjectTable.id, name: ProjectTable.name, worktree: ProjectTable.worktree }) + .from(ProjectTable) + .where(inArray(ProjectTable.id, ids)) + .all() + .pipe(Effect.orDie) + for (const item of items) { + projects.set(item.id, { + id: item.id, + name: item.name ?? undefined, + worktree: item.worktree, + }) + } + } + return rows.map((row) => ({ ...fromRow(row), project: projects.get(row.project_id) ?? null })) }) const children = Effect.fn("Session.children")(function* (parentID: SessionID) { @@ -620,7 +677,11 @@ export const layer: Layer.Layer< yield* remove(child.id) } - yield* events.publish(SessionLegacy.Event.Deleted, { sessionID, info: session }, { location: eventLocation(session) }) + yield* events.publish( + SessionLegacy.Event.Deleted, + { sessionID, info: session }, + { location: eventLocation(session) }, + ) yield* events.remove(sessionID) } catch (e) { log.error(e) @@ -630,11 +691,7 @@ export const layer: Layer.Layer< const updateMessage = (msg: T): Effect.Effect => Effect.gen(function* () { const location = yield* locationForSession(msg.sessionID) - yield* events.publish( - SessionLegacy.Event.MessageUpdated, - { sessionID: msg.sessionID, info: msg }, - { location }, - ) + yield* events.publish(SessionLegacy.Event.MessageUpdated, { sessionID: msg.sessionID, info: msg }, { location }) return msg }).pipe(Effect.withSpan("Session.updateMessage")) @@ -780,9 +837,11 @@ export const layer: Layer.Layer< revert: Info["revert"] summary: Info["summary"] }) { - yield* patch(input.sessionID, { summary: input.summary, time: { updated: Date.now() }, revert: input.revert }).pipe( - Effect.orDie, - ) + yield* patch(input.sessionID, { + summary: input.summary, + time: { updated: Date.now() }, + revert: input.revert, + }).pipe(Effect.orDie) }) const clearRevert = Effect.fn("Session.clearRevert")(function* (sessionID: SessionID) { @@ -804,7 +863,9 @@ export const layer: Layer.Layer< sessionID: SessionID workspaceID: Info["workspaceID"] }) { - yield* patch(input.sessionID, { workspaceID: input.workspaceID, time: { updated: Date.now() } }).pipe(Effect.orDie) + yield* patch(input.sessionID, { workspaceID: input.workspaceID, time: { updated: Date.now() } }).pipe( + Effect.orDie, + ) }) const diff = Effect.fn("Session.diff")(function* (sessionID: SessionID) { @@ -879,7 +940,7 @@ export const layer: Layer.Layer< field: string delta: string }) { - yield* bus.publish(MessageV2.Event.PartDelta, input) + yield* events.publish(MessageV2.Event.PartDelta, input) }) /** Finds the first message matching the predicate, searching newest-first. */ @@ -903,6 +964,7 @@ export const layer: Layer.Layer< return Service.of({ list, + listGlobal, create, fork, touch, @@ -932,7 +994,6 @@ export const layer: Layer.Layer< export const defaultLayer = layer.pipe( Layer.provide(BackgroundJob.defaultLayer), - Layer.provide(Bus.layer), Layer.provide(Storage.defaultLayer), Layer.provide(Database.defaultLayer), Layer.provide(EventV2Bridge.defaultLayer), @@ -957,7 +1018,8 @@ const cancelBackgroundJobs = Effect.fn("Session.cancelBackgroundJobs")(function* ) }) -function* listByProject( +function listByProject( + db: Database.Interface["db"], input: ListInput & { projectID: ProjectV2.ID experimentalWorkspaces: boolean @@ -995,19 +1057,17 @@ function* listByProject( const limit = input.limit ?? 100 - const rows = runtime.runSync(({ db }) => - db - .select() - .from(SessionTable) - .where(and(...conditions)) - .orderBy(desc(SessionTable.time_updated)) - .limit(limit) - .all() - .pipe(Effect.orDie), - ) - for (const row of rows) { - yield fromRow(row) - } + return db + .select() + .from(SessionTable) + .where(and(...conditions)) + .orderBy(desc(SessionTable.time_updated)) + .limit(limit) + .all() + .pipe( + Effect.orDie, + Effect.map((rows) => rows.map(fromRow)), + ) } export function* listGlobal(input?: { diff --git a/packages/opencode/src/session/status.ts b/packages/opencode/src/session/status.ts index 089559e2c..a7a6c5f87 100644 --- a/packages/opencode/src/session/status.ts +++ b/packages/opencode/src/session/status.ts @@ -1,9 +1,9 @@ -import { BusEvent } from "@/bus/bus-event" -import { Bus } from "@/bus" import { InstanceState } from "@/effect/instance-state" import { SessionID } from "./schema" import { NonNegativeInt } from "@opencode-ai/core/schema" import { Effect, Layer, Context, Schema } from "effect" +import { EventV2Bridge } from "@/event-v2-bridge" +import { EventV2 } from "@opencode-ai/core/event" export const Info = Schema.Union([ Schema.Struct({ @@ -32,20 +32,20 @@ export const Info = Schema.Union([ export type Info = Schema.Schema.Type export const Event = { - Status: BusEvent.define( - "session.status", - Schema.Struct({ + Status: EventV2.define({ + type: "session.status", + schema: { sessionID: SessionID, status: Info, - }), - ), + }, + }), // deprecated - Idle: BusEvent.define( - "session.idle", - Schema.Struct({ + Idle: EventV2.define({ + type: "session.idle", + schema: { sessionID: SessionID, - }), - ), + }, + }), } export interface Interface { @@ -59,7 +59,7 @@ export class Service extends Context.Service()("@opencode/Se export const layer = Layer.effect( Service, Effect.gen(function* () { - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service const state = yield* InstanceState.make( Effect.fn("SessionStatus.state")(() => Effect.succeed(new Map())), @@ -76,9 +76,9 @@ export const layer = Layer.effect( const set = Effect.fn("SessionStatus.set")(function* (sessionID: SessionID, status: Info) { const data = yield* InstanceState.get(state) - yield* bus.publish(Event.Status, { sessionID, status }) + yield* events.publish(Event.Status, { sessionID, status }) if (status.type === "idle") { - yield* bus.publish(Event.Idle, { sessionID }) + yield* events.publish(Event.Idle, { sessionID }) data.delete(sessionID) return } @@ -89,6 +89,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(Bus.layer)) +export const defaultLayer = layer.pipe(Layer.provide(EventV2Bridge.defaultLayer)) export * as SessionStatus from "./status" diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index 7f4d93112..0b0d46526 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -1,6 +1,6 @@ import { Effect, Layer, Context, Schema } from "effect" import { SessionLegacy } from "@opencode-ai/core/session/legacy" -import { Bus } from "@/bus" +import { EventV2Bridge } from "@/event-v2-bridge" import { Snapshot } from "@/snapshot" import { Storage } from "@/storage/storage" import * as Session from "./session" @@ -77,7 +77,7 @@ export const layer = Layer.effect( const sessions = yield* Session.Service const snapshot = yield* Snapshot.Service const storage = yield* Storage.Service - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service const computeDiff = Effect.fn("SessionSummary.computeDiff")(function* (input: { messages: SessionLegacy.WithParts[] }) { let from: string | undefined @@ -116,7 +116,7 @@ export const layer = Layer.effect( }, }) yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore) - yield* bus.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs }) + yield* events.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs }) const messages = all.filter( (m) => m.info.id === input.messageID || (m.info.role === "assistant" && m.info.parentID === input.messageID), @@ -152,7 +152,7 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(Session.defaultLayer), Layer.provide(Snapshot.defaultLayer), Layer.provide(Storage.defaultLayer), - Layer.provide(Bus.layer), + Layer.provide(EventV2Bridge.defaultLayer), ), ) diff --git a/packages/opencode/src/session/todo.ts b/packages/opencode/src/session/todo.ts index f3e2bed17..37598f9d5 100644 --- a/packages/opencode/src/session/todo.ts +++ b/packages/opencode/src/session/todo.ts @@ -1,11 +1,11 @@ -import { BusEvent } from "@/bus/bus-event" -import { Bus } from "@/bus" import { SessionID } from "./schema" import { Effect, Layer, Context, Schema } from "effect" import { Database } from "@opencode-ai/core/database/database" import { eq } from "drizzle-orm" import { asc } from "drizzle-orm" import { TodoTable } from "@opencode-ai/core/session/sql" +import { EventV2Bridge } from "@/event-v2-bridge" +import { EventV2 } from "@opencode-ai/core/event" export const Info = Schema.Struct({ content: Schema.String.annotate({ description: "Brief description of the task" }), @@ -17,13 +17,13 @@ export const Info = Schema.Struct({ export type Info = Schema.Schema.Type export const Event = { - Updated: BusEvent.define( - "todo.updated", - Schema.Struct({ + Updated: EventV2.define({ + type: "todo.updated", + schema: { sessionID: SessionID, todos: Schema.Array(Info), - }), - ), + }, + }), } export interface Interface { @@ -36,7 +36,7 @@ export class Service extends Context.Service()("@opencode/Se export const layer = Layer.effect( Service, Effect.gen(function* () { - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service const { db } = yield* Database.Service const update = Effect.fn("Todo.update")(function* (input: { sessionID: SessionID; todos: Info[] }) { @@ -60,7 +60,7 @@ export const layer = Layer.effect( }), ) .pipe(Effect.orDie) - yield* bus.publish(Event.Updated, input) + yield* events.publish(Event.Updated, input) }) const get = Effect.fn("Todo.get")(function* (sessionID: SessionID) { @@ -82,6 +82,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Database.defaultLayer)) +export const defaultLayer = layer.pipe(Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(Database.defaultLayer)) export * as Todo from "./todo" diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index 6b17f80dc..bf0ae3b84 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -3,7 +3,7 @@ import { serviceUse } from "@opencode-ai/core/effect/service-use" import { Effect, Exit, Layer, Option, Schema, Scope, Context, Stream } from "effect" import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { Account } from "@/account/account" -import { Bus } from "@/bus" +import { EventV2Bridge } from "@/event-v2-bridge" import { InstanceState } from "@/effect/instance-state" import { Provider } from "@/provider/provider" @@ -16,6 +16,7 @@ import { Config } from "@/config/config" import * as Log from "@opencode-ai/core/util/log" import { SessionShareTable } from "@opencode-ai/core/share/sql" import { ProviderV2 } from "@opencode-ai/core/provider" +import { EventV2 } from "@opencode-ai/core/event" const log = Log.create({ service: "share-next" }) const disabled = process.env["OPENCODE_DISABLE_SHARE"] === "true" || process.env["OPENCODE_DISABLE_SHARE"] === "1" @@ -111,7 +112,7 @@ export const layer = Layer.effect( Service, Effect.gen(function* () { const account = yield* Account.Service - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service const cfg = yield* Config.Service const { db } = yield* Database.Service const http = yield* HttpClient.HttpClient @@ -165,49 +166,39 @@ export const layer = Layer.effect( if (disabled) return cache - const watch = ( + const watch = ( def: D, - fn: (evt: { properties: any }) => Effect.Effect, + fn: (data: EventV2.Data) => Effect.Effect, ) => - bus.subscribe(def as never).pipe( - Effect.flatMap((stream) => - stream.pipe( - Stream.runForEach((evt) => - fn(evt).pipe( - Effect.catchCause((cause) => - Effect.sync(() => { - log.error("share subscriber failed", { type: def.type, cause }) - }), - ), - ), - ), - Effect.forkScoped, - ), - ), - ) + events.listen((event) => { + if (event.type !== def.type || event.location?.directory !== _ctx.directory) return Effect.void + return fn(event.data as EventV2.Data).pipe( + Effect.catchCause((cause) => Effect.sync(() => log.error("share subscriber failed", { type: def.type, cause }))), + ) + }) - yield* watch(Session.Event.Updated, (evt) => + yield* watch(Session.Event.Updated, (data) => Effect.gen(function* () { - const info = evt.properties.info - yield* sync(info.id, [{ type: "session", data: info }]) + const info = data.info + yield* sync(info.id, [{ type: "session", data: structuredClone(info) as SDK.Session }]) }), ) - yield* watch(MessageV2.Event.Updated, (evt) => + yield* watch(MessageV2.Event.Updated, (data) => Effect.gen(function* () { - const info = evt.properties.info - yield* sync(info.sessionID, [{ type: "message", data: info }]) + const info = data.info + yield* sync(info.sessionID, [{ type: "message", data: structuredClone(info) as SDK.Message }]) if (info.role !== "user") return const model = yield* provider.getModel(info.model.providerID, info.model.modelID) yield* sync(info.sessionID, [{ type: "model", data: [model] }]) }), ) - yield* watch(MessageV2.Event.PartUpdated, (evt) => - sync(evt.properties.part.sessionID, [{ type: "part", data: evt.properties.part }]), + yield* watch(MessageV2.Event.PartUpdated, (data) => + sync(data.part.sessionID, [{ type: "part", data: structuredClone(data.part) as SDK.Part }]), ) - yield* watch(Session.Event.Diff, (evt) => - sync(evt.properties.sessionID, [{ type: "session_diff", data: evt.properties.diff }]), + yield* watch(Session.Event.Diff, (data) => + sync(data.sessionID, [{ type: "session_diff", data: structuredClone(data.diff) as SDK.SnapshotFileDiff[] }]), ) - yield* watch(Session.Event.Deleted, (evt) => remove(evt.properties.sessionID)) + yield* watch(Session.Event.Deleted, (data) => remove(data.sessionID)) return cache }), @@ -373,7 +364,7 @@ export const layer = Layer.effect( ) export const defaultLayer = layer.pipe( - Layer.provide(Bus.layer), + Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(Account.defaultLayer), Layer.provide(Config.defaultLayer), Layer.provide(Database.defaultLayer), diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index c1c6d0d6f..1d21774ee 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -3,7 +3,7 @@ import { pathToFileURL } from "url" import { Effect, Layer, Context, Schema } from "effect" import { NamedError } from "@opencode-ai/core/util/error" import type { Agent } from "@/agent/agent" -import { Bus } from "@/bus" +import { EventV2Bridge } from "@/event-v2-bridge" import { InstanceState } from "@/effect/instance-state" import { Global } from "@opencode-ai/core/global" import { Permission } from "@/permission" @@ -101,7 +101,7 @@ export interface Interface { readonly available: (agent?: Agent.Info) => Effect.Effect } -const add = Effect.fnUntraced(function* (state: State, match: string, bus: Bus.Interface) { +const add = Effect.fnUntraced(function* (state: State, match: string, events: EventV2Bridge.Service["Service"]) { const md = yield* Effect.tryPromise({ try: () => ConfigMarkdown.parse(match), catch: (err) => err, @@ -112,7 +112,7 @@ const add = Effect.fnUntraced(function* (state: State, match: string, bus: Bus.I ? err.data.message : `Failed to parse skill ${match}` const { Session } = yield* Effect.promise(() => import("@/session/session")) - yield* bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) + yield* events.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) log.error("failed to load skill", { skill: match, err }) return undefined }), @@ -232,8 +232,8 @@ const discoverSkills = Effect.fnUntraced(function* ( } }) -const loadSkills = Effect.fnUntraced(function* (state: State, discovered: DiscoveryState, bus: Bus.Interface) { - yield* Effect.forEach(discovered.matches, (match) => add(state, match, bus), { +const loadSkills = Effect.fnUntraced(function* (state: State, discovered: DiscoveryState, events: EventV2Bridge.Service["Service"]) { + yield* Effect.forEach(discovered.matches, (match) => add(state, match, events), { concurrency: "unbounded", discard: true, }) @@ -248,7 +248,7 @@ export const layer = Layer.effect( Effect.gen(function* () { const discovery = yield* Discovery.Service const config = yield* Config.Service - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service const fsys = yield* AppFileSystem.Service const global = yield* Global.Service const flags = yield* RuntimeFlags.Service @@ -277,7 +277,7 @@ export const layer = Layer.effect( location: "", content: CUSTOMIZE_OPENCODE_SKILL_BODY, } - yield* loadSkills(s, yield* InstanceState.get(discovered), bus) + yield* loadSkills(s, yield* InstanceState.get(discovered), events) return s }), ) @@ -317,7 +317,7 @@ export const layer = Layer.effect( export const defaultLayer = layer.pipe( Layer.provide(Discovery.defaultLayer), Layer.provide(Config.defaultLayer), - Layer.provide(Bus.layer), + Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Global.layer), Layer.provide(RuntimeFlags.defaultLayer), diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index 84e84cc39..356d09f65 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -1,7 +1,7 @@ import * as path from "path" import { Effect, Schema } from "effect" import * as Tool from "./tool" -import { Bus } from "../bus" +import { EventV2Bridge } from "@/event-v2-bridge" import { FileWatcher } from "../file/watcher" import { InstanceState } from "@/effect/instance-state" import { Patch } from "../patch" @@ -25,7 +25,7 @@ export const ApplyPatchTool = Tool.define( const lsp = yield* LSP.Service const afs = yield* AppFileSystem.Service const format = yield* Format.Service - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service const run = Effect.fn("ApplyPatchTool.execute")(function* ( params: Schema.Schema.Type, @@ -253,13 +253,13 @@ export const ApplyPatchTool = Tool.define( if (yield* format.file(edited)) { yield* Bom.syncFile(afs, edited, change.bom) } - yield* bus.publish(File.Event.Edited, { file: edited }) + yield* events.publish(File.Event.Edited, { file: edited }) } } // Publish file change events for (const update of updates) { - yield* bus.publish(FileWatcher.Event.Updated, update) + yield* events.publish(FileWatcher.Event.Updated, update) } // Notify LSP of file changes and collect diagnostics diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index ea3aac348..79df2fa1b 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -11,7 +11,7 @@ import { createTwoFilesPatch, diffLines } from "diff" import DESCRIPTION from "./edit.txt" import { File } from "../file" import { FileWatcher } from "../file/watcher" -import { Bus } from "../bus" +import { EventV2Bridge } from "@/event-v2-bridge" import { Format } from "../format" import { InstanceState } from "@/effect/instance-state" import { Snapshot } from "@/snapshot" @@ -61,7 +61,7 @@ export const EditTool = Tool.define( const lsp = yield* LSP.Service const afs = yield* AppFileSystem.Service const format = yield* Format.Service - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service return { description: DESCRIPTION, @@ -108,8 +108,8 @@ export const EditTool = Tool.define( if (yield* format.file(filePath)) { contentNew = yield* Bom.syncFile(afs, filePath, desiredBom) } - yield* bus.publish(File.Event.Edited, { file: filePath }) - yield* bus.publish(FileWatcher.Event.Updated, { + yield* events.publish(File.Event.Edited, { file: filePath }) + yield* events.publish(FileWatcher.Event.Updated, { file: filePath, event: existed ? "change" : "add", }) @@ -152,8 +152,8 @@ export const EditTool = Tool.define( if (yield* format.file(filePath)) { contentNew = yield* Bom.syncFile(afs, filePath, desiredBom) } - yield* bus.publish(File.Event.Edited, { file: filePath }) - yield* bus.publish(FileWatcher.Event.Updated, { + yield* events.publish(File.Event.Edited, { file: filePath }) + yield* events.publish(FileWatcher.Event.Updated, { file: filePath, event: "change", }) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index ed11ac750..aae98a8c7 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -46,7 +46,7 @@ import { Todo } from "../session/todo" import { LSP } from "@/lsp/lsp" import { Instruction } from "../session/instruction" import { AppFileSystem } from "@opencode-ai/core/filesystem" -import { Bus } from "../bus" +import { EventV2Bridge } from "@/event-v2-bridge" import { Agent } from "../agent/agent" import { Git } from "@/git" import { Skill } from "../skill" @@ -99,7 +99,7 @@ export const layer: Layer.Layer< | LSP.Service | Instruction.Service | AppFileSystem.Service - | Bus.Service + | EventV2Bridge.Service | HttpClient.HttpClient | ChildProcessSpawner | Ripgrep.Service @@ -389,7 +389,7 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(LSP.defaultLayer), Layer.provide(Instruction.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), - Layer.provide(Bus.layer), + Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(FetchHttpClient.layer), Layer.provide(Format.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer), diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index c2be73ab1..40de52279 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -5,7 +5,7 @@ import * as Tool from "./tool" import { LSP } from "@/lsp/lsp" import { createTwoFilesPatch } from "diff" import DESCRIPTION from "./write.txt" -import { Bus } from "../bus" +import { EventV2Bridge } from "@/event-v2-bridge" import { File } from "../file" import { FileWatcher } from "../file/watcher" import { Format } from "../format" @@ -29,7 +29,7 @@ export const WriteTool = Tool.define( Effect.gen(function* () { const lsp = yield* LSP.Service const fs = yield* AppFileSystem.Service - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service const format = yield* Format.Service return { @@ -65,8 +65,8 @@ export const WriteTool = Tool.define( if (yield* format.file(filepath)) { yield* Bom.syncFile(fs, filepath, desiredBom) } - yield* bus.publish(File.Event.Edited, { file: filepath }) - yield* bus.publish(FileWatcher.Event.Updated, { + yield* events.publish(File.Event.Edited, { file: filepath }) + yield* events.publish(FileWatcher.Event.Updated, { file: filepath, event: exists ? "change" : "add", }) diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index f17644702..e02e7a3bd 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -9,7 +9,7 @@ import type { ProjectV2 } from "@opencode-ai/core/project" import * as Log from "@opencode-ai/core/util/log" import { Slug } from "@opencode-ai/core/util/slug" import { errorMessage } from "../util/error" -import { BusEvent } from "@/bus/bus-event" +import { EventV2 } from "@opencode-ai/core/event" import { GlobalBus } from "@/bus/global" import { Git } from "@/git" import { Effect, Layer, Path, Schema, Scope, Context } from "effect" @@ -22,19 +22,19 @@ import { InstanceState } from "@/effect/instance-state" const log = Log.create({ service: "worktree" }) export const Event = { - Ready: BusEvent.define( - "worktree.ready", - Schema.Struct({ + Ready: EventV2.define({ + type: "worktree.ready", + schema: { name: Schema.String, branch: Schema.optional(Schema.String), - }), - ), - Failed: BusEvent.define( - "worktree.failed", - Schema.Struct({ + }, + }), + Failed: EventV2.define({ + type: "worktree.failed", + schema: { message: Schema.String, - }), - ), + }, + }), } export const Info = Schema.Struct({ diff --git a/packages/opencode/test/agent/plugin-agent-regression.test.ts b/packages/opencode/test/agent/plugin-agent-regression.test.ts index d79e01c78..60604e811 100644 --- a/packages/opencode/test/agent/plugin-agent-regression.test.ts +++ b/packages/opencode/test/agent/plugin-agent-regression.test.ts @@ -5,7 +5,7 @@ import { FetchHttpClient } from "effect/unstable/http" import path from "path" import { pathToFileURL } from "url" import { Agent } from "../../src/agent/agent" -import { Bus } from "../../src/bus" +import { EventV2Bridge } from "../../src/event-v2-bridge" import { Config } from "../../src/config/config" import { Env } from "../../src/env" import { RuntimeFlags } from "../../src/effect/runtime-flags" @@ -33,7 +33,7 @@ const configLayer = Config.layer.pipe( Layer.provide(FetchHttpClient.layer), ) const pluginLayer = Plugin.layer.pipe( - Layer.provide(Bus.layer), + Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(configLayer), Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true })), ) diff --git a/packages/opencode/test/auth/auth.test.ts b/packages/opencode/test/auth/auth.test.ts index 55e950aab..58ce6ea71 100644 --- a/packages/opencode/test/auth/auth.test.ts +++ b/packages/opencode/test/auth/auth.test.ts @@ -2,7 +2,6 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { Auth } from "../../src/auth" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" const node = CrossSpawnSpawner.defaultLayer @@ -10,77 +9,69 @@ const node = CrossSpawnSpawner.defaultLayer const it = testEffect(Layer.mergeAll(Auth.defaultLayer, node)) describe("Auth", () => { - it.live("set normalizes trailing slashes in keys", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const auth = yield* Auth.Service - yield* auth.set("https://example.com/", { - type: "wellknown", - key: "TOKEN", - token: "abc", - }) - const data = yield* auth.all() - expect(data["https://example.com"]).toBeDefined() - expect(data["https://example.com/"]).toBeUndefined() - }), - ), + it.instance("set normalizes trailing slashes in keys", () => + Effect.gen(function* () { + const auth = yield* Auth.Service + yield* auth.set("https://example.com/", { + type: "wellknown", + key: "TOKEN", + token: "abc", + }) + const data = yield* auth.all() + expect(data["https://example.com"]).toBeDefined() + expect(data["https://example.com/"]).toBeUndefined() + }), ) - it.live("set cleans up pre-existing trailing-slash entry", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const auth = yield* Auth.Service - yield* auth.set("https://example.com/", { - type: "wellknown", - key: "TOKEN", - token: "old", - }) - yield* auth.set("https://example.com", { - type: "wellknown", - key: "TOKEN", - token: "new", - }) - const data = yield* auth.all() - const keys = Object.keys(data).filter((key) => key.includes("example.com")) - expect(keys).toEqual(["https://example.com"]) - const entry = data["https://example.com"]! - expect(entry.type).toBe("wellknown") - if (entry.type === "wellknown") expect(entry.token).toBe("new") - }), - ), + it.instance("set cleans up pre-existing trailing-slash entry", () => + Effect.gen(function* () { + const auth = yield* Auth.Service + yield* auth.set("https://example.com/", { + type: "wellknown", + key: "TOKEN", + token: "old", + }) + yield* auth.set("https://example.com", { + type: "wellknown", + key: "TOKEN", + token: "new", + }) + const data = yield* auth.all() + const keys = Object.keys(data).filter((key) => key.includes("example.com")) + expect(keys).toEqual(["https://example.com"]) + const entry = data["https://example.com"]! + expect(entry.type).toBe("wellknown") + if (entry.type === "wellknown") expect(entry.token).toBe("new") + }), ) - it.live("remove deletes both trailing-slash and normalized keys", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const auth = yield* Auth.Service - yield* auth.set("https://example.com", { - type: "wellknown", - key: "TOKEN", - token: "abc", - }) - yield* auth.remove("https://example.com/") - const data = yield* auth.all() - expect(data["https://example.com"]).toBeUndefined() - expect(data["https://example.com/"]).toBeUndefined() - }), - ), + it.instance("remove deletes both trailing-slash and normalized keys", () => + Effect.gen(function* () { + const auth = yield* Auth.Service + yield* auth.set("https://example.com", { + type: "wellknown", + key: "TOKEN", + token: "abc", + }) + yield* auth.remove("https://example.com/") + const data = yield* auth.all() + expect(data["https://example.com"]).toBeUndefined() + expect(data["https://example.com/"]).toBeUndefined() + }), ) - it.live("set and remove are no-ops on keys without trailing slashes", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const auth = yield* Auth.Service - yield* auth.set("anthropic", { - type: "api", - key: "sk-test", - }) - const data = yield* auth.all() - expect(data["anthropic"]).toBeDefined() - yield* auth.remove("anthropic") - const after = yield* auth.all() - expect(after["anthropic"]).toBeUndefined() - }), - ), + it.instance("set and remove are no-ops on keys without trailing slashes", () => + Effect.gen(function* () { + const auth = yield* Auth.Service + yield* auth.set("anthropic", { + type: "api", + key: "sk-test", + }) + const data = yield* auth.all() + expect(data["anthropic"]).toBeDefined() + yield* auth.remove("anthropic") + const after = yield* auth.all() + expect(after["anthropic"]).toBeUndefined() + }), ) }) diff --git a/packages/opencode/test/bus/bus-effect.test.ts b/packages/opencode/test/bus/bus-effect.test.ts deleted file mode 100644 index dfe653dd1..000000000 --- a/packages/opencode/test/bus/bus-effect.test.ts +++ /dev/null @@ -1,288 +0,0 @@ -import { describe, expect } from "bun:test" -import { Deferred, Effect, Fiber, Latch, Layer, Schema, Stream } from "effect" -import { Bus } from "../../src/bus" -import { BusEvent } from "../../src/bus/bus-event" -import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture" -import { testEffect } from "../lib/effect" - -const TestEvent = { - Ping: BusEvent.define("test.effect.ping", Schema.Struct({ value: Schema.Number })), - Pong: BusEvent.define("test.effect.pong", Schema.Struct({ message: Schema.String })), - Warmup: BusEvent.define("test.effect.warmup", Schema.Struct({})), -} - -const node = CrossSpawnSpawner.defaultLayer - -const live = Layer.mergeAll(Bus.layer, node) - -const it = testEffect(live) - -// Publishes warmup events until the latch opens, proving the forked subscriber -// fiber has actually wired up its PubSub subscription. -const awaitSubscriberReady = Effect.fn("test.awaitSubscriberReady")(function* ( - ready: Latch.Latch, - warmup: Effect.Effect, -) { - const pump = yield* Effect.forkScoped( - Effect.gen(function* () { - while (true) { - yield* warmup - yield* Effect.sleep("5 millis") - } - }), - ) - yield* ready.await.pipe(Effect.timeout("2 seconds")) - yield* Fiber.interrupt(pump) -}) - -describe("Bus (Effect-native)", () => { - it.instance("publish + subscribe stream delivers events", () => - Effect.gen(function* () { - const bus = yield* Bus.Service - const received: number[] = [] - const done = yield* Deferred.make() - const ready = yield* Latch.make() - - yield* Stream.runForEach(yield* bus.subscribe(TestEvent.Ping), (evt) => - Effect.gen(function* () { - if (evt.properties.value < 0) { - yield* ready.open - return - } - received.push(evt.properties.value) - if (received.length === 2) Deferred.doneUnsafe(done, Effect.void) - }), - ).pipe(Effect.forkScoped) - - yield* awaitSubscriberReady(ready, bus.publish(TestEvent.Ping, { value: -1 })) - yield* bus.publish(TestEvent.Ping, { value: 1 }) - yield* bus.publish(TestEvent.Ping, { value: 2 }) - yield* Deferred.await(done) - - expect(received).toEqual([1, 2]) - }), - ) - - it.instance("subscribe filters by event type", () => - Effect.gen(function* () { - const bus = yield* Bus.Service - const pings: number[] = [] - const done = yield* Deferred.make() - const ready = yield* Latch.make() - - yield* Stream.runForEach(yield* bus.subscribe(TestEvent.Ping), (evt) => - Effect.gen(function* () { - if (evt.properties.value < 0) { - yield* ready.open - return - } - pings.push(evt.properties.value) - Deferred.doneUnsafe(done, Effect.void) - }), - ).pipe(Effect.forkScoped) - - yield* awaitSubscriberReady(ready, bus.publish(TestEvent.Ping, { value: -1 })) - yield* bus.publish(TestEvent.Pong, { message: "ignored" }) - yield* bus.publish(TestEvent.Ping, { value: 42 }) - yield* Deferred.await(done) - - expect(pings).toEqual([42]) - }), - ) - - it.instance("subscribeAll receives all types", () => - Effect.gen(function* () { - const bus = yield* Bus.Service - const types: string[] = [] - const done = yield* Deferred.make() - const ready = yield* Latch.make() - - yield* Stream.runForEach(yield* bus.subscribeAll(), (evt) => - Effect.gen(function* () { - if (evt.type === TestEvent.Warmup.type) { - yield* ready.open - return - } - types.push(evt.type) - if (types.length === 2) Deferred.doneUnsafe(done, Effect.void) - }), - ).pipe(Effect.forkScoped) - - yield* awaitSubscriberReady(ready, bus.publish(TestEvent.Warmup, {})) - yield* bus.publish(TestEvent.Ping, { value: 1 }) - yield* bus.publish(TestEvent.Pong, { message: "hi" }) - yield* Deferred.await(done) - - expect(types).toContain("test.effect.ping") - expect(types).toContain("test.effect.pong") - }), - ) - - it.instance("multiple subscribers each receive the event", () => - Effect.gen(function* () { - const bus = yield* Bus.Service - const a: number[] = [] - const b: number[] = [] - const doneA = yield* Deferred.make() - const doneB = yield* Deferred.make() - const readyA = yield* Latch.make() - const readyB = yield* Latch.make() - - yield* Stream.runForEach(yield* bus.subscribe(TestEvent.Ping), (evt) => - Effect.gen(function* () { - if (evt.properties.value < 0) { - yield* readyA.open - return - } - a.push(evt.properties.value) - Deferred.doneUnsafe(doneA, Effect.void) - }), - ).pipe(Effect.forkScoped) - - yield* Stream.runForEach(yield* bus.subscribe(TestEvent.Ping), (evt) => - Effect.gen(function* () { - if (evt.properties.value < 0) { - yield* readyB.open - return - } - b.push(evt.properties.value) - Deferred.doneUnsafe(doneB, Effect.void) - }), - ).pipe(Effect.forkScoped) - - yield* awaitSubscriberReady(readyA, bus.publish(TestEvent.Ping, { value: -1 })) - yield* awaitSubscriberReady(readyB, bus.publish(TestEvent.Ping, { value: -1 })) - yield* bus.publish(TestEvent.Ping, { value: 99 }) - yield* Deferred.await(doneA) - yield* Deferred.await(doneB) - - expect(a).toEqual([99]) - expect(b).toEqual([99]) - }), - ) - - // RACE 1: eager subscription means publishing immediately after yield* - // bus.subscribe is delivered. Regression for the old lazy `Stream.unwrap` - // shape where PubSub.subscribe ran on first pull and missed any publish - // in the hand-off window. - it.instance("eager subscribe: publish after yield* is delivered without consumer-activation race", () => - Effect.gen(function* () { - const bus = yield* Bus.Service - const stream = yield* bus.subscribe(TestEvent.Ping) - - // Hand-off window: subscription is alive (we yielded). Publish goes - // straight into the subscription queue, even with no consumer running. - yield* bus.publish(TestEvent.Ping, { value: 99 }) - - const collected = yield* stream.pipe( - Stream.take(1), - Stream.runCollect, - Effect.timeout("400 millis"), - Effect.option, - ) - - expect(collected._tag).toBe("Some") - if (collected._tag === "Some") { - const arr = Array.from(collected.value) - expect(arr[0].properties.value).toBe(99) - } - }), - ) - - // RACE 2: same property for subscribeAll. - it.instance("eager subscribeAll: publish after yield* is delivered", () => - Effect.gen(function* () { - const bus = yield* Bus.Service - const stream = yield* bus.subscribeAll() - - yield* bus.publish(TestEvent.Ping, { value: 42 }) - - const collected = yield* stream.pipe( - Stream.take(1), - Stream.runCollect, - Effect.timeout("400 millis"), - Effect.option, - ) - - expect(collected._tag).toBe("Some") - if (collected._tag === "Some") { - const arr = Array.from(collected.value) - expect(arr[0].type).toBe(TestEvent.Ping.type) - } - }), - ) - - // RACE 3: the /event-handler shape exactly. With eager subscription, the - // bus subscription is alive before Stream.concat ever starts. Publishes - // during the prefix consumption window are queued and delivered. - it.instance("eager subscribe: Stream.concat(initial, subscribe) delivers publish during prefix", () => - Effect.gen(function* () { - const bus = yield* Bus.Service - const sawInitial = yield* Deferred.make() - const sawPublish = yield* Deferred.make() - - type Frame = { marker?: "initial"; value?: number } - const subscriptionStream = yield* bus.subscribe(TestEvent.Ping) - const handlerStream: Stream.Stream = Stream.make({ marker: "initial" } as Frame).pipe( - Stream.concat(subscriptionStream.pipe(Stream.map((evt): Frame => ({ value: evt.properties.value })))), - ) - - yield* Stream.runForEach(handlerStream, (frame) => - Effect.gen(function* () { - if (frame.marker === "initial") { - Deferred.doneUnsafe(sawInitial, Effect.void) - return - } - if (frame.value !== undefined) Deferred.doneUnsafe(sawPublish, Effect.succeed(frame.value)) - }), - ).pipe(Effect.forkScoped) - - yield* Deferred.await(sawInitial).pipe(Effect.timeout("1 second")) - - yield* bus.publish(TestEvent.Ping, { value: 7 }) - - const got = yield* Deferred.await(sawPublish).pipe(Effect.timeout("1 second"), Effect.option) - expect(got._tag).toBe("Some") - if (got._tag === "Some") expect(got.value).toBe(7) - }), - ) - - it.live("subscribeAll stream sees InstanceDisposed on disposal", () => - Effect.gen(function* () { - const dir = yield* tmpdirScoped() - const types: string[] = [] - const seen = yield* Deferred.make() - const disposed = yield* Deferred.make() - const ready = yield* Latch.make() - - // Set up subscriber inside the instance - yield* Effect.gen(function* () { - const bus = yield* Bus.Service - - yield* Stream.runForEach(yield* bus.subscribeAll(), (evt) => - Effect.gen(function* () { - if (evt.type === TestEvent.Warmup.type) { - yield* ready.open - return - } - types.push(evt.type) - if (evt.type === TestEvent.Ping.type) Deferred.doneUnsafe(seen, Effect.void) - if (evt.type === Bus.InstanceDisposed.type) Deferred.doneUnsafe(disposed, Effect.void) - }), - ).pipe(Effect.forkScoped) - - yield* awaitSubscriberReady(ready, bus.publish(TestEvent.Warmup, {})) - yield* bus.publish(TestEvent.Ping, { value: 1 }) - yield* Deferred.await(seen) - }).pipe(provideInstance(dir)) - - // Dispose from OUTSIDE the instance scope - yield* Effect.promise(disposeAllInstances) - yield* Deferred.await(disposed).pipe(Effect.timeout("2 seconds")) - - expect(types).toContain("test.effect.ping") - expect(types).toContain(Bus.InstanceDisposed.type) - }), - ) -}) diff --git a/packages/opencode/test/bus/bus-integration.test.ts b/packages/opencode/test/bus/bus-integration.test.ts deleted file mode 100644 index 645a94fb3..000000000 --- a/packages/opencode/test/bus/bus-integration.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { afterEach, describe, expect } from "bun:test" -import { Deferred, Effect, Layer, Schema } from "effect" -import { Bus } from "../../src/bus" -import { BusEvent } from "../../src/bus/bus-event" -import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture" -import { testEffect } from "../lib/effect" - -const TestEvent = BusEvent.define("test.integration", Schema.Struct({ value: Schema.Number })) -const it = testEffect(Layer.mergeAll(Bus.layer, CrossSpawnSpawner.defaultLayer)) - -describe("Bus integration: acquireRelease subscriber pattern", () => { - afterEach(() => disposeAllInstances()) - - it.instance("subscriber via callback facade receives events and cleans up on unsub", () => - Effect.gen(function* () { - const bus = yield* Bus.Service - const received: number[] = [] - const receivedTwo = yield* Deferred.make() - - const unsub = yield* bus.subscribeCallback(TestEvent, (evt) => { - received.push(evt.properties.value) - if (received.length === 2) Deferred.doneUnsafe(receivedTwo, Effect.void) - }) - yield* bus.publish(TestEvent, { value: 1 }) - yield* bus.publish(TestEvent, { value: 2 }) - yield* Deferred.await(receivedTwo).pipe(Effect.timeout("2 seconds")) - - expect(received).toEqual([1, 2]) - - yield* Effect.sync(unsub) - yield* bus.publish(TestEvent, { value: 3 }) - yield* Effect.sleep("10 millis") - - expect(received).toEqual([1, 2]) - }), - ) - - it.instance("subscribeAll receives events from multiple types", () => - Effect.gen(function* () { - const bus = yield* Bus.Service - const received: Array<{ type: string; value?: number }> = [] - const OtherEvent = BusEvent.define("test.other", Schema.Struct({ value: Schema.Number })) - const receivedTwo = yield* Deferred.make() - - yield* bus.subscribeAllCallback((evt) => { - received.push({ type: evt.type, value: evt.properties.value }) - if (received.length === 2) Deferred.doneUnsafe(receivedTwo, Effect.void) - }) - yield* bus.publish(TestEvent, { value: 10 }) - yield* bus.publish(OtherEvent, { value: 20 }) - yield* Deferred.await(receivedTwo).pipe(Effect.timeout("2 seconds")) - - expect(received).toEqual([ - { type: "test.integration", value: 10 }, - { type: "test.other", value: 20 }, - ]) - }), - ) - - it.live("subscriber cleanup on instance disposal interrupts the stream", () => - Effect.gen(function* () { - const dir = yield* tmpdirScoped() - const received: number[] = [] - const seen = yield* Deferred.make() - const disposed = yield* Deferred.make() - - yield* Effect.gen(function* () { - const bus = yield* Bus.Service - yield* bus.subscribeAllCallback((evt) => { - if (evt.type === Bus.InstanceDisposed.type) { - Deferred.doneUnsafe(disposed, Effect.void) - return - } - received.push(evt.properties.value) - Deferred.doneUnsafe(seen, Effect.void) - }) - yield* bus.publish(TestEvent, { value: 1 }) - yield* Deferred.await(seen).pipe(Effect.timeout("2 seconds")) - }).pipe(provideInstance(dir)) - - yield* Effect.promise(() => disposeAllInstances()) - yield* Deferred.await(disposed).pipe(Effect.timeout("2 seconds")) - - expect(received).toEqual([1]) - }), - ) -}) diff --git a/packages/opencode/test/bus/bus.test.ts b/packages/opencode/test/bus/bus.test.ts deleted file mode 100644 index 084498616..000000000 --- a/packages/opencode/test/bus/bus.test.ts +++ /dev/null @@ -1,240 +0,0 @@ -import { afterEach, describe, expect } from "bun:test" -import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { Deferred, Effect, Layer, Schema } from "effect" -import { Bus } from "../../src/bus" -import { BusEvent } from "../../src/bus/bus-event" -import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture" -import { testEffect } from "../lib/effect" - -const TestEvent = { - Ping: BusEvent.define("test.ping", Schema.Struct({ value: Schema.Number })), - Pong: BusEvent.define("test.pong", Schema.Struct({ message: Schema.String })), -} - -const it = testEffect(Layer.mergeAll(Bus.layer, CrossSpawnSpawner.defaultLayer)) - -describe("Bus", () => { - afterEach(() => disposeAllInstances()) - - describe("publish + subscribe", () => { - it.instance("subscriber is live immediately after subscribe returns", () => - Effect.gen(function* () { - const bus = yield* Bus.Service - const received: number[] = [] - const done = yield* Deferred.make() - - yield* bus.subscribeCallback(TestEvent.Ping, (evt) => { - received.push(evt.properties.value) - Deferred.doneUnsafe(done, Effect.void) - }) - yield* bus.publish(TestEvent.Ping, { value: 42 }) - yield* Deferred.await(done).pipe(Effect.timeout("2 seconds")) - - expect(received).toEqual([42]) - }), - ) - - it.instance("subscriber receives matching events", () => - Effect.gen(function* () { - const bus = yield* Bus.Service - const received: number[] = [] - const done = yield* Deferred.make() - - yield* bus.subscribeCallback(TestEvent.Ping, (evt) => { - received.push(evt.properties.value) - if (received.length === 2) Deferred.doneUnsafe(done, Effect.void) - }) - yield* bus.publish(TestEvent.Ping, { value: 42 }) - yield* bus.publish(TestEvent.Ping, { value: 99 }) - yield* Deferred.await(done).pipe(Effect.timeout("2 seconds")) - - expect(received).toEqual([42, 99]) - }), - ) - - it.instance("subscriber does not receive events of other types", () => - Effect.gen(function* () { - const bus = yield* Bus.Service - const pings: number[] = [] - const done = yield* Deferred.make() - - yield* bus.subscribeCallback(TestEvent.Ping, (evt) => { - pings.push(evt.properties.value) - Deferred.doneUnsafe(done, Effect.void) - }) - yield* bus.publish(TestEvent.Pong, { message: "hello" }) - yield* bus.publish(TestEvent.Ping, { value: 1 }) - yield* Deferred.await(done).pipe(Effect.timeout("2 seconds")) - - expect(pings).toEqual([1]) - }), - ) - - it.instance("publish with no subscribers does not throw", () => - Effect.gen(function* () { - const bus = yield* Bus.Service - yield* bus.publish(TestEvent.Ping, { value: 1 }) - }), - ) - }) - - describe("unsubscribe", () => { - it.instance("unsubscribe stops delivery", () => - Effect.gen(function* () { - const bus = yield* Bus.Service - const received: number[] = [] - const first = yield* Deferred.make() - - const unsub = yield* bus.subscribeCallback(TestEvent.Ping, (evt) => { - received.push(evt.properties.value) - if (evt.properties.value === 1) Deferred.doneUnsafe(first, Effect.void) - }) - yield* bus.publish(TestEvent.Ping, { value: 1 }) - yield* Deferred.await(first).pipe(Effect.timeout("2 seconds")) - yield* Effect.sync(unsub) - yield* bus.publish(TestEvent.Ping, { value: 2 }) - yield* Effect.sleep("10 millis") - - expect(received).toEqual([1]) - }), - ) - }) - - describe("subscribeAll", () => { - it.instance("subscribeAll is live immediately after subscribe returns", () => - Effect.gen(function* () { - const bus = yield* Bus.Service - const received: string[] = [] - const done = yield* Deferred.make() - - yield* bus.subscribeAllCallback((evt) => { - received.push(evt.type) - Deferred.doneUnsafe(done, Effect.void) - }) - yield* bus.publish(TestEvent.Ping, { value: 1 }) - yield* Deferred.await(done).pipe(Effect.timeout("2 seconds")) - - expect(received).toEqual(["test.ping"]) - }), - ) - - it.instance("receives all event types", () => - Effect.gen(function* () { - const bus = yield* Bus.Service - const received: string[] = [] - const done = yield* Deferred.make() - - yield* bus.subscribeAllCallback((evt) => { - received.push(evt.type) - if (received.length === 2) Deferred.doneUnsafe(done, Effect.void) - }) - yield* bus.publish(TestEvent.Ping, { value: 1 }) - yield* bus.publish(TestEvent.Pong, { message: "hi" }) - yield* Deferred.await(done).pipe(Effect.timeout("2 seconds")) - - expect(received).toContain("test.ping") - expect(received).toContain("test.pong") - }), - ) - }) - - describe("multiple subscribers", () => { - it.instance("all subscribers for same event type are called", () => - Effect.gen(function* () { - const bus = yield* Bus.Service - const a: number[] = [] - const b: number[] = [] - const doneA = yield* Deferred.make() - const doneB = yield* Deferred.make() - - yield* bus.subscribeCallback(TestEvent.Ping, (evt) => { - a.push(evt.properties.value) - Deferred.doneUnsafe(doneA, Effect.void) - }) - yield* bus.subscribeCallback(TestEvent.Ping, (evt) => { - b.push(evt.properties.value) - Deferred.doneUnsafe(doneB, Effect.void) - }) - yield* bus.publish(TestEvent.Ping, { value: 7 }) - yield* Deferred.await(doneA).pipe(Effect.timeout("2 seconds")) - yield* Deferred.await(doneB).pipe(Effect.timeout("2 seconds")) - - expect(a).toEqual([7]) - expect(b).toEqual([7]) - }), - ) - }) - - describe("instance isolation", () => { - it.live("events in one directory do not reach subscribers in another", () => - Effect.gen(function* () { - const tmpA = yield* tmpdirScoped() - const tmpB = yield* tmpdirScoped() - const receivedA: number[] = [] - const receivedB: number[] = [] - const doneA = yield* Deferred.make() - const doneB = yield* Deferred.make() - - yield* Effect.gen(function* () { - const bus = yield* Bus.Service - yield* bus.subscribeCallback(TestEvent.Ping, (evt) => { - receivedA.push(evt.properties.value) - Deferred.doneUnsafe(doneA, Effect.void) - }) - }).pipe(provideInstance(tmpA)) - - yield* Effect.gen(function* () { - const bus = yield* Bus.Service - yield* bus.subscribeCallback(TestEvent.Ping, (evt) => { - receivedB.push(evt.properties.value) - Deferred.doneUnsafe(doneB, Effect.void) - }) - }).pipe(provideInstance(tmpB)) - - yield* Effect.gen(function* () { - const bus = yield* Bus.Service - yield* bus.publish(TestEvent.Ping, { value: 1 }) - }).pipe(provideInstance(tmpA)) - - yield* Effect.gen(function* () { - const bus = yield* Bus.Service - yield* bus.publish(TestEvent.Ping, { value: 2 }) - }).pipe(provideInstance(tmpB)) - - yield* Deferred.await(doneA).pipe(Effect.timeout("2 seconds")) - yield* Deferred.await(doneB).pipe(Effect.timeout("2 seconds")) - - expect(receivedA).toEqual([1]) - expect(receivedB).toEqual([2]) - }), - ) - }) - - describe("instance disposal", () => { - it.live("InstanceDisposed is delivered to wildcard subscribers before stream ends", () => - Effect.gen(function* () { - const tmp = yield* tmpdirScoped() - const received: string[] = [] - const seen = yield* Deferred.make() - const disposed = yield* Deferred.make() - - yield* Effect.gen(function* () { - const bus = yield* Bus.Service - yield* bus.subscribeAllCallback((evt) => { - received.push(evt.type) - if (evt.type === TestEvent.Ping.type) Deferred.doneUnsafe(seen, Effect.void) - if (evt.type === Bus.InstanceDisposed.type) Deferred.doneUnsafe(disposed, Effect.void) - }) - yield* bus.publish(TestEvent.Ping, { value: 1 }) - yield* Deferred.await(seen).pipe(Effect.timeout("2 seconds")) - }).pipe(provideInstance(tmp)) - - yield* Effect.promise(disposeAllInstances) - yield* Deferred.await(disposed).pipe(Effect.timeout("2 seconds")) - - expect(received).toContain("test.ping") - expect(received).toContain(Bus.InstanceDisposed.type) - }), - ) - }) -}) diff --git a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap index 22ad59aaa..14882e264 100644 --- a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap +++ b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap @@ -398,7 +398,6 @@ database tools Commands: opencode db [query] open an interactive sqlite3 shell or run a query [default] opencode db path print the database path - opencode db migrate migrate JSON data to SQLite (merges with existing data) Positionals: query SQL query to execute [string] diff --git a/packages/opencode/test/control-plane/workspace.test.ts b/packages/opencode/test/control-plane/workspace.test.ts index 5eb97c8da..b8928f87a 100644 --- a/packages/opencode/test/control-plane/workspace.test.ts +++ b/packages/opencode/test/control-plane/workspace.test.ts @@ -318,7 +318,12 @@ function insertProject(id: ProjectV2.ID, worktree: string) { function attachSessionToWorkspace(sessionID: SessionID, workspaceID: WorkspaceV2.ID) { return Database.Service.use(({ db }) => - db.update(SessionTable).set({ workspace_id: workspaceID }).where(eq(SessionTable.id, sessionID)).run().pipe(Effect.orDie), + db + .update(SessionTable) + .set({ workspace_id: workspaceID }) + .where(eq(SessionTable.id, sessionID)) + .run() + .pipe(Effect.orDie), ) } @@ -329,7 +334,10 @@ function sessionSequence(sessionID: SessionID) { .from(EventSequenceTable) .where(eq(EventSequenceTable.aggregate_id, sessionID)) .get() - .pipe(Effect.orDie, Effect.map((row) => row?.seq)), + .pipe( + Effect.orDie, + Effect.map((row) => row?.seq), + ), ) } @@ -340,7 +348,10 @@ function sessionSequenceOwner(sessionID: SessionID) { .from(EventSequenceTable) .where(eq(EventSequenceTable.aggregate_id, sessionID)) .get() - .pipe(Effect.orDie, Effect.map((row) => row?.ownerID)), + .pipe( + Effect.orDie, + Effect.map((row) => row?.ownerID), + ), ) } @@ -842,14 +853,12 @@ describe("workspace CRUD", () => { const { db } = yield* Database.Service expect( - ( - yield* db - .select({ workspaceID: SessionTable.workspace_id }) - .from(SessionTable) - .where(eq(SessionTable.id, session.id)) - .get() - .pipe(Effect.orDie) - )?.workspaceID, + (yield* db + .select({ workspaceID: SessionTable.workspace_id }) + .from(SessionTable) + .where(eq(SessionTable.id, session.id)) + .get() + .pipe(Effect.orDie))?.workspaceID, ).toBe(target.id) expect(yield* sessionSequenceOwner(session.id)).toBe(target.id) }) @@ -911,14 +920,12 @@ describe("workspace CRUD", () => { const { db } = yield* Database.Service expect( - ( - yield* db - .select({ workspaceID: SessionTable.workspace_id }) - .from(SessionTable) - .where(eq(SessionTable.id, session.id)) - .get() - .pipe(Effect.orDie) - )?.workspaceID, + (yield* db + .select({ workspaceID: SessionTable.workspace_id }) + .from(SessionTable) + .where(eq(SessionTable.id, session.id)) + .get() + .pipe(Effect.orDie))?.workspaceID, ).toBeNull() expect(yield* sessionSequenceOwner(session.id)).toBe(instance.project.id) }) @@ -955,14 +962,12 @@ describe("workspace CRUD", () => { const { db } = yield* Database.Service expect( - ( - yield* db - .select({ workspaceID: SessionTable.workspace_id }) - .from(SessionTable) - .where(eq(SessionTable.id, session.id)) - .get() - .pipe(Effect.orDie) - )?.workspaceID, + (yield* db + .select({ workspaceID: SessionTable.workspace_id }) + .from(SessionTable) + .where(eq(SessionTable.id, session.id)) + .get() + .pipe(Effect.orDie))?.workspaceID, ).toBeNull() expect(yield* sessionSequenceOwner(session.id)).toBe(projectID) expect(yield* sessionSequenceOwner(session.id)).not.toBe(workspaceProjectID) @@ -973,6 +978,7 @@ describe("workspace CRUD", () => { it.live("sessionWarp syncs previous remote history, replays it, steals, and claims the sequence", () => { const calls: FetchCall[] = [] let historySessionID: SessionID | undefined + let historySession: SessionNs.Info | undefined let historyNextSeq = 0 return Effect.gen(function* () { yield* HttpServer.serveEffect()( @@ -994,7 +1000,7 @@ describe("workspace CRUD", () => { aggregate_id: historySessionID!, seq: historyNextSeq, type: "session.updated.1", - data: { sessionID: historySessionID!, info: { title: "from source history" } }, + data: { sessionID: historySessionID!, info: historySession! }, }, ]) } @@ -1025,6 +1031,7 @@ describe("workspace CRUD", () => { const session = yield* sessionSvc.create({}) yield* attachSessionToWorkspace(session.id, previous.id) historySessionID = session.id + historySession = { ...session, workspaceID: previous.id, title: "from source history" } historyNextSeq = ((yield* sessionSequence(session.id)) ?? -1) + 1 yield* workspace.sessionWarp({ workspaceID: target.id, sessionID: session.id, copyChanges: true }) @@ -1339,6 +1346,7 @@ describe("workspace sync state", () => { it.live("sync history sends the local sequence fence and replays returned events in workspace context", () => { const historyBodies: unknown[] = [] let historySessionID: SessionID | undefined + let historySession: SessionNs.Info | undefined let historyNextSeq = 0 return Effect.gen(function* () { yield* HttpServer.serveEffect()( @@ -1356,7 +1364,7 @@ describe("workspace sync state", () => { aggregate_id: historySessionID!, seq: historyNextSeq, type: "session.updated.1", - data: { sessionID: historySessionID!, info: { title: "from history" } }, + data: { sessionID: historySessionID!, info: historySession! }, }, ]), ) @@ -1380,6 +1388,7 @@ describe("workspace sync state", () => { const session = yield* sessionSvc.create({ title: "before history" }) yield* attachSessionToWorkspace(session.id, info.id) historySessionID = session.id + historySession = { ...session, workspaceID: info.id, title: "from history" } historyNextSeq = ((yield* sessionSequence(session.id)) ?? -1) + 1 yield* workspace.startWorkspaceSyncing(instance.project.id) @@ -1394,8 +1403,9 @@ describe("workspace sync state", () => { captured.events.some( (event) => event.workspace === info.id && - event.payload.type === "sync" && - event.payload.syncEvent.seq === historyNextSeq, + event.payload.type === "session.updated" && + event.payload.properties.sessionID === session.id && + event.payload.properties.info.title === "from history", ), ).toBe(true) yield* workspace.remove(info.id) @@ -1482,6 +1492,7 @@ describe("workspace sync state", () => { it.live("SSE sync events are replayed and forwarded", () => { let sseSessionID: SessionID | undefined + let sseSession: SessionNs.Info | undefined let sseNextSeq = 0 return Effect.gen(function* () { yield* HttpServer.serveEffect()( @@ -1502,7 +1513,7 @@ describe("workspace sync state", () => { aggregateID: sseSessionID!, seq: sseNextSeq, type: "session.updated.1", - data: { sessionID: sseSessionID!, info: { title: "from sse" } }, + data: { sessionID: sseSessionID!, info: sseSession! }, }, }, }, @@ -1530,6 +1541,7 @@ describe("workspace sync state", () => { const session = yield* sessionSvc.create({ title: "before sse" }) yield* attachSessionToWorkspace(session.id, info.id) sseSessionID = session.id + sseSession = { ...session, workspaceID: info.id, title: "from sse" } sseNextSeq = ((yield* sessionSequence(session.id)) ?? -1) + 1 yield* workspace.startWorkspaceSyncing(instance.project.id) @@ -1578,7 +1590,9 @@ describe("workspace waitForSync", () => { const { db } = yield* Database.Service yield* db.insert(EventSequenceTable).values({ aggregate_id: sessionID, seq: 4 }).run().pipe(Effect.orDie) - expect(yield* workspace.waitForSync(WorkspaceV2.ID.ascending("wrk_wait_done"), { [sessionID]: 4 })).toBeUndefined() + expect( + yield* workspace.waitForSync(WorkspaceV2.ID.ascending("wrk_wait_done"), { [sessionID]: 4 }), + ).toBeUndefined() expect( yield* workspace.waitForSync(WorkspaceV2.ID.ascending("wrk_wait_done_2"), { [sessionID]: 3 }), ).toBeUndefined() diff --git a/packages/opencode/test/file/watcher.test.ts b/packages/opencode/test/file/watcher.test.ts index c205da486..3137f6c7d 100644 --- a/packages/opencode/test/file/watcher.test.ts +++ b/packages/opencode/test/file/watcher.test.ts @@ -9,6 +9,7 @@ import { GlobalBus, type GlobalEvent } from "../../src/bus/global" import { Config } from "@/config/config" import { FileWatcher } from "../../src/file/watcher" import { Git } from "../../src/git" +import { EventV2Bridge } from "../../src/event-v2-bridge" // Native @parcel/watcher bindings aren't reliably available in CI (missing on Linux, flaky on Windows) const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip @@ -27,6 +28,7 @@ const watcherConfigLayer = ConfigProvider.layer( const watcherLayer = FileWatcher.layer.pipe( Layer.provide(Config.defaultLayer), Layer.provide(Git.defaultLayer), + Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(watcherConfigLayer), ) diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index d354cd2e5..41a095312 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -1,9 +1,8 @@ import { $ } from "bun" -import * as Observability from "@opencode-ai/core/effect/observability" import * as fs from "fs/promises" import os from "os" import path from "path" -import { Effect, Context, Layer, ManagedRuntime } from "effect" +import { Effect, Context, Layer } from "effect" import type * as PlatformError from "effect/PlatformError" import type * as Scope from "effect/Scope" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" @@ -18,35 +17,31 @@ import { TestLLMServer } from "../lib/llm-server" const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) export const testInstanceStoreLayer = InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap)) -const testInstanceRuntime = ManagedRuntime.make(testInstanceStoreLayer.pipe(Layer.provideMerge(Observability.layer))) - -const runTestInstanceStore = (fn: (store: InstanceStore.Interface) => Effect.Effect) => - testInstanceRuntime.runPromise(InstanceStore.Service.use(fn)) export async function provideTestInstance(input: { directory: string init?: Effect.Effect fn: (ctx: InstanceContext) => R }) { - const ctx = await runTestInstanceStore((store) => store.load({ directory: input.directory })) + const ctx = await InstanceRuntime.load({ directory: input.directory }) try { - if (input.init) await testInstanceRuntime.runPromise(input.init.pipe(Effect.provideService(InstanceRef, ctx))) + if (input.init) await Effect.runPromise(input.init.pipe(Effect.provideService(InstanceRef, ctx))) return await input.fn(ctx) } finally { - await runTestInstanceStore((store) => store.dispose(ctx)) + await InstanceRuntime.disposeInstance(ctx) } } export async function withTestInstance(input: { directory: string; fn: (ctx: InstanceContext) => R }) { - return input.fn(await runTestInstanceStore((store) => store.load({ directory: input.directory }))) + return input.fn(await InstanceRuntime.load({ directory: input.directory })) } export async function reloadTestInstance(input: { directory: string }) { - return runTestInstanceStore((store) => store.reload(input)) + return InstanceRuntime.reloadInstance(input) } export async function disposeAllInstances() { - await Promise.all([InstanceRuntime.disposeAllInstances(), runTestInstanceStore((store) => store.disposeAll())]) + await InstanceRuntime.disposeAllInstances() } // Strip null bytes from paths (defensive fix for CI environment issues) @@ -119,9 +114,10 @@ export async function tmpdir(options?: TmpDirOptions) { } /** Effectful scoped tmpdir. Cleaned up when the scope closes. Make sure these stay in sync */ -export function tmpdirScoped(options?: { +export function tmpdirScoped(options?: { git?: boolean config?: Partial | (() => Partial) + init?: (directory: string) => Effect.Effect }) { return Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner @@ -158,6 +154,8 @@ export function tmpdirScoped(options?: { ) } + if (options?.init) yield* options.init(dir) + return dir }) } @@ -183,21 +181,8 @@ export function provideTmpdirInstance( ) { return Effect.gen(function* () { const path = yield* tmpdirScoped(options) - let provided = false - - yield* Effect.addFinalizer(() => - provided - ? Effect.promise(() => - runTestInstanceStore((store) => - store.load({ directory: path }).pipe(Effect.flatMap((ctx) => store.dispose(ctx))), - ), - ).pipe(Effect.ignore) - : Effect.void, - ) - - provided = true return yield* self(path).pipe(provideInstance(path)) - }) + }).pipe(Effect.provide(testInstanceStoreLayer)) } export class TestInstance extends Context.Service()("@test/Instance") {} @@ -209,7 +194,11 @@ export const requireInstance = Effect.gen(function* () { }) export const withTmpdirInstance = - (options?: { git?: boolean; config?: Partial | (() => Partial) }) => + (options?: { + git?: boolean + config?: Partial | (() => Partial) + init?: (directory: string) => Effect.Effect + }) => (self: Effect.Effect) => Effect.gen(function* () { const directory = yield* tmpdirScoped(options) @@ -222,7 +211,7 @@ export function provideTmpdirServer( ): Effect.Effect< A, E | PlatformError.PlatformError, - R | TestLLMServer | ChildProcessSpawner.ChildProcessSpawner | Scope.Scope | InstanceStore.Service + R | TestLLMServer | ChildProcessSpawner.ChildProcessSpawner | Scope.Scope > { return Effect.gen(function* () { const llm = yield* TestLLMServer diff --git a/packages/opencode/test/format/format.test.ts b/packages/opencode/test/format/format.test.ts index 41468c4d0..96b5ad5ae 100644 --- a/packages/opencode/test/format/format.test.ts +++ b/packages/opencode/test/format/format.test.ts @@ -1,7 +1,7 @@ import { NodeFileSystem } from "@effect/platform-node" import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" -import { provideTmpdirInstance } from "../fixture/fixture" +import { provideTmpdirInstance, testInstanceStoreLayer, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Format } from "../../src/format" @@ -10,141 +10,102 @@ import * as Formatter from "../../src/format/formatter" const it = testEffect(Layer.mergeAll(Format.defaultLayer, CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer)) describe("Format", () => { - it.live("status() returns empty list when no formatters are configured", () => - provideTmpdirInstance(() => + it.instance("status() returns empty list when no formatters are configured", () => + Format.Service.use((fmt) => + Effect.gen(function* () { + expect(yield* fmt.status()).toEqual([]) + }), + ), + ) + + it.instance( + "status() returns built-in formatters when formatter is true", + () => Format.Service.use((fmt) => Effect.gen(function* () { - expect(yield* fmt.status()).toEqual([]) + const statuses = yield* fmt.status() + const gofmt = statuses.find((item) => item.name === "gofmt") + expect(gofmt).toBeDefined() + expect(gofmt!.extensions).toContain(".go") }), ), - ), + { config: { formatter: true } }, ) - it.live("status() returns built-in formatters when formatter is true", () => - provideTmpdirInstance( - () => - Format.Service.use((fmt) => - Effect.gen(function* () { - const statuses = yield* fmt.status() - const gofmt = statuses.find((item) => item.name === "gofmt") - expect(gofmt).toBeDefined() - expect(gofmt!.extensions).toContain(".go") - }), - ), - { - config: { - formatter: true, - }, - }, - ), - ) - - it.live("status() keeps built-in formatters when config object is provided", () => - provideTmpdirInstance( - () => - Format.Service.use((fmt) => - Effect.gen(function* () { - const statuses = yield* fmt.status() - const gofmt = statuses.find((item) => item.name === "gofmt") - const mix = statuses.find((item) => item.name === "mix") - expect(gofmt).toBeDefined() - expect(gofmt!.extensions).toContain(".go") - expect(mix).toBeDefined() - }), - ), - { - config: { - formatter: { - gofmt: {}, - }, - }, - }, - ), - ) - - it.live("status() excludes formatters marked as disabled in config", () => - provideTmpdirInstance( - () => - Format.Service.use((fmt) => - Effect.gen(function* () { - const statuses = yield* fmt.status() - const gofmt = statuses.find((item) => item.name === "gofmt") - const mix = statuses.find((item) => item.name === "mix") - expect(gofmt).toBeUndefined() - expect(mix).toBeDefined() - }), - ), - { - config: { - formatter: { - gofmt: { disabled: true }, - }, - }, - }, - ), - ) - - it.live("status() excludes uv when ruff is disabled", () => - provideTmpdirInstance( - () => - Format.Service.use((fmt) => - Effect.gen(function* () { - const statuses = yield* fmt.status() - expect(statuses.find((item) => item.name === "ruff")).toBeUndefined() - expect(statuses.find((item) => item.name === "uv")).toBeUndefined() - }), - ), - { - config: { - formatter: { - ruff: { disabled: true }, - }, - }, - }, - ), - ) - - it.live("status() excludes ruff when uv is disabled", () => - provideTmpdirInstance( - () => - Format.Service.use((fmt) => - Effect.gen(function* () { - const statuses = yield* fmt.status() - expect(statuses.find((item) => item.name === "ruff")).toBeUndefined() - expect(statuses.find((item) => item.name === "uv")).toBeUndefined() - }), - ), - { - config: { - formatter: { - uv: { disabled: true }, - }, - }, - }, - ), - ) - - it.live("service initializes without error", () => provideTmpdirInstance(() => Format.Service.use(() => Effect.void))) - - it.live("file() returns false when no formatter runs", () => - provideTmpdirInstance( - (dir) => + it.instance( + "status() keeps built-in formatters when config object is provided", + () => + Format.Service.use((fmt) => Effect.gen(function* () { - const file = `${dir}/test.txt` - yield* Effect.promise(() => Bun.write(file, "x")) - - const formatted = yield* Format.use.file(file) - expect(formatted).toBe(false) + const statuses = yield* fmt.status() + const gofmt = statuses.find((item) => item.name === "gofmt") + const mix = statuses.find((item) => item.name === "mix") + expect(gofmt).toBeDefined() + expect(gofmt!.extensions).toContain(".go") + expect(mix).toBeDefined() }), - { - config: { - formatter: false, - }, - }, - ), + ), + { config: { formatter: { gofmt: {} } } }, ) - it.live("status() initializes formatter state per directory", () => + it.instance( + "status() excludes formatters marked as disabled in config", + () => + Format.Service.use((fmt) => + Effect.gen(function* () { + const statuses = yield* fmt.status() + const gofmt = statuses.find((item) => item.name === "gofmt") + const mix = statuses.find((item) => item.name === "mix") + expect(gofmt).toBeUndefined() + expect(mix).toBeDefined() + }), + ), + { config: { formatter: { gofmt: { disabled: true } } } }, + ) + + it.instance( + "status() excludes uv when ruff is disabled", + () => + Format.Service.use((fmt) => + Effect.gen(function* () { + const statuses = yield* fmt.status() + expect(statuses.find((item) => item.name === "ruff")).toBeUndefined() + expect(statuses.find((item) => item.name === "uv")).toBeUndefined() + }), + ), + { config: { formatter: { ruff: { disabled: true } } } }, + ) + + it.instance( + "status() excludes ruff when uv is disabled", + () => + Format.Service.use((fmt) => + Effect.gen(function* () { + const statuses = yield* fmt.status() + expect(statuses.find((item) => item.name === "ruff")).toBeUndefined() + expect(statuses.find((item) => item.name === "uv")).toBeUndefined() + }), + ), + { config: { formatter: { uv: { disabled: true } } } }, + ) + + it.instance("service initializes without error", () => Format.Service.use(() => Effect.void)) + + it.instance( + "file() returns false when no formatter runs", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const file = `${test.directory}/test.txt` + yield* Effect.promise(() => Bun.write(file, "x")) + + const formatted = yield* Format.use.file(file) + expect(formatted).toBe(false) + }), + { config: { formatter: false } }, + ) + + testEffect(Layer.mergeAll(Format.defaultLayer, CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer, testInstanceStoreLayer)).live("status() initializes formatter state per directory", () => Effect.gen(function* () { const a = yield* provideTmpdirInstance(() => Format.use.status(), { config: { formatter: false }, @@ -160,113 +121,106 @@ describe("Format", () => { }), ) - it.live("runs enabled checks for matching formatters in parallel", () => - provideTmpdirInstance( - (path) => - Effect.gen(function* () { - const file = `${path}/test.parallel` - yield* Effect.promise(() => Bun.write(file, "x")) + it.instance( + "runs enabled checks for matching formatters in parallel", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const file = `${test.directory}/test.parallel` + yield* Effect.promise(() => Bun.write(file, "x")) - const one = { - extensions: Formatter.gofmt.extensions, - enabled: Formatter.gofmt.enabled, - } - const two = { - extensions: Formatter.mix.extensions, - enabled: Formatter.mix.enabled, - } + const one = { + extensions: Formatter.gofmt.extensions, + enabled: Formatter.gofmt.enabled, + } + const two = { + extensions: Formatter.mix.extensions, + enabled: Formatter.mix.enabled, + } - let active = 0 - let max = 0 + let active = 0 + let max = 0 - yield* Effect.acquireUseRelease( - Effect.sync(() => { - Formatter.gofmt.extensions = [".parallel"] - Formatter.mix.extensions = [".parallel"] - Formatter.gofmt.enabled = async () => { - active++ - max = Math.max(max, active) - await Promise.resolve() - active-- - return ["sh", "-c", "true"] - } - Formatter.mix.enabled = async () => { - active++ - max = Math.max(max, active) - await Promise.resolve() - active-- - return ["sh", "-c", "true"] - } - }), - () => - Format.Service.use((fmt) => - Effect.gen(function* () { - yield* fmt.init() - yield* fmt.file(file) - }), - ), - () => - Effect.sync(() => { - Formatter.gofmt.extensions = one.extensions - Formatter.gofmt.enabled = one.enabled - Formatter.mix.extensions = two.extensions - Formatter.mix.enabled = two.enabled + yield* Effect.acquireUseRelease( + Effect.sync(() => { + Formatter.gofmt.extensions = [".parallel"] + Formatter.mix.extensions = [".parallel"] + Formatter.gofmt.enabled = async () => { + active++ + max = Math.max(max, active) + await Promise.resolve() + active-- + return ["sh", "-c", "true"] + } + Formatter.mix.enabled = async () => { + active++ + max = Math.max(max, active) + await Promise.resolve() + active-- + return ["sh", "-c", "true"] + } + }), + () => + Format.Service.use((fmt) => + Effect.gen(function* () { + yield* fmt.init() + yield* fmt.file(file) }), - ) + ), + () => + Effect.sync(() => { + Formatter.gofmt.extensions = one.extensions + Formatter.gofmt.enabled = one.enabled + Formatter.mix.extensions = two.extensions + Formatter.mix.enabled = two.enabled + }), + ) - expect(max).toBe(2) - }), - { - config: { - formatter: { - gofmt: {}, - mix: {}, - }, - }, - }, - ), + expect(max).toBe(2) + }), + { config: { formatter: { gofmt: {}, mix: {} } } }, ) - it.live("runs matching formatters sequentially for the same file", () => - provideTmpdirInstance( - (path) => - Effect.gen(function* () { - const file = `${path}/test.seq` - yield* Effect.promise(() => Bun.write(file, "x")) + it.instance( + "runs matching formatters sequentially for the same file", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const file = `${test.directory}/test.seq` + yield* Effect.promise(() => Bun.write(file, "x")) - yield* Format.Service.use((fmt) => - Effect.gen(function* () { - yield* fmt.init() - expect(yield* fmt.file(file)).toBe(true) - }), - ) + yield* Format.Service.use((fmt) => + Effect.gen(function* () { + yield* fmt.init() + expect(yield* fmt.file(file)).toBe(true) + }), + ) - expect(yield* Effect.promise(() => Bun.file(file).text())).toBe("xAB") - }), - { - config: { - formatter: { - first: { - command: [ - "node", - "-e", - "const fs = require('fs'); const file = process.argv[1]; fs.writeFileSync(file, fs.readFileSync(file, 'utf8') + 'A')", - "$FILE", - ], - extensions: [".seq"], - }, - second: { - command: [ - "node", - "-e", - "const fs = require('fs'); const file = process.argv[1]; fs.writeFileSync(file, fs.readFileSync(file, 'utf8') + 'B')", - "$FILE", - ], - extensions: [".seq"], - }, + expect(yield* Effect.promise(() => Bun.file(file).text())).toBe("xAB") + }), + { + config: { + formatter: { + first: { + command: [ + "node", + "-e", + "const fs = require('fs'); const file = process.argv[1]; fs.writeFileSync(file, fs.readFileSync(file, 'utf8') + 'A')", + "$FILE", + ], + extensions: [".seq"], + }, + second: { + command: [ + "node", + "-e", + "const fs = require('fs'); const file = process.argv[1]; fs.writeFileSync(file, fs.readFileSync(file, 'utf8') + 'B')", + "$FILE", + ], + extensions: [".seq"], }, }, }, - ), + }, ) }) diff --git a/packages/opencode/test/lib/effect.ts b/packages/opencode/test/lib/effect.ts index 237146c28..952cc6b62 100644 --- a/packages/opencode/test/lib/effect.ts +++ b/packages/opencode/test/lib/effect.ts @@ -5,20 +5,26 @@ import * as TestClock from "effect/testing/TestClock" import * as TestConsole from "effect/testing/TestConsole" import { memoMap } from "@opencode-ai/core/effect/memo-map" import type { Config } from "@/config/config" -import { TestInstance, testInstanceStoreLayer, withTmpdirInstance } from "../fixture/fixture" +import { TestInstance, withTmpdirInstance } from "../fixture/fixture" import { InstanceStore } from "@/project/instance-store" type Body = Effect.Effect | (() => Effect.Effect) -type InstanceOptions = { git?: boolean; config?: Partial | (() => Partial) } - -function isInstanceOptions(options: InstanceOptions | number | TestOptions | undefined): options is InstanceOptions { - return !!options && typeof options === "object" && ("git" in options || "config" in options) +type InstanceOptions = { + git?: boolean + config?: Partial | (() => Partial) + init?: (directory: string) => Effect.Effect } -function instanceArgs( - options?: InstanceOptions | number | TestOptions, +function isInstanceOptions( + options: InstanceOptions | number | TestOptions | undefined, +): options is InstanceOptions { + return !!options && typeof options === "object" && ("git" in options || "config" in options || "init" in options) +} + +function instanceArgs( + options?: InstanceOptions | number | TestOptions, testOptions?: number | TestOptions, -): { instanceOptions: InstanceOptions | undefined; testOptions: number | TestOptions | undefined } { +): { instanceOptions: InstanceOptions | undefined; testOptions: number | TestOptions | undefined } { if (typeof options === "number") return { instanceOptions: undefined, testOptions: options } if (isInstanceOptions(options)) return { instanceOptions: options, testOptions } return { instanceOptions: undefined, testOptions: options } @@ -76,10 +82,10 @@ const make = (testLayer: Layer.Layer, liveLayer: Layer.Layer, live.skip = (name: string, value: Body, opts?: number | TestOptions) => test.skip(name, () => run(value, liveLayer), opts) - const instance = ( + const instance = ( name: string, - value: Body, - options?: InstanceOptions | number | TestOptions, + value: Body, + options?: InstanceOptions | number | TestOptions, opts?: number | TestOptions, ) => { const args = instanceArgs(options, opts) @@ -90,10 +96,10 @@ const make = (testLayer: Layer.Layer, liveLayer: Layer.Layer, ) } - instance.only = ( + instance.only = ( name: string, - value: Body, - options?: InstanceOptions | number | TestOptions, + value: Body, + options?: InstanceOptions | number | TestOptions, opts?: number | TestOptions, ) => { const args = instanceArgs(options, opts) @@ -104,10 +110,10 @@ const make = (testLayer: Layer.Layer, liveLayer: Layer.Layer, ) } - instance.skip = ( + instance.skip = ( name: string, - value: Body, - options?: InstanceOptions | number | TestOptions, + value: Body, + options?: InstanceOptions | number | TestOptions, opts?: number | TestOptions, ) => { const args = instanceArgs(options, opts) @@ -122,22 +128,22 @@ const make = (testLayer: Layer.Layer, liveLayer: Layer.Layer, } // Test environment with TestClock and TestConsole -const testEnv = Layer.mergeAll(TestConsole.layer, TestClock.layer(), testInstanceStoreLayer) +const testEnv = Layer.mergeAll(TestConsole.layer, TestClock.layer()) // Live environment - uses real clock, but keeps TestConsole for output capture -const liveEnv = Layer.mergeAll(TestConsole.layer, testInstanceStoreLayer) +const liveEnv = TestConsole.layer -export const it = make(testEnv, liveEnv) +export const it = make(testEnv, liveEnv) export const testEffect = (layer: Layer.Layer) => - make(Layer.provideMerge(layer, testEnv), Layer.provideMerge(layer, liveEnv)) + make(Layer.provideMerge(layer, testEnv), Layer.provideMerge(layer, liveEnv)) // Variant of `testEffect` that builds the test layer through the shared // process-wide memoMap so services like Bus/Session resolve to the same // instances Server.Default uses. Use when a test needs pub/sub identity with // an in-process HTTP server — most tests should stick with `testEffect`. export const testEffectShared = (layer: Layer.Layer) => - make(Layer.provideMerge(layer, testEnv), Layer.provideMerge(layer, liveEnv), sharedRun) + make(Layer.provideMerge(layer, testEnv), Layer.provideMerge(layer, liveEnv), sharedRun) export const awaitWithTimeout = ( self: Effect.Effect, diff --git a/packages/opencode/test/lsp/index.test.ts b/packages/opencode/test/lsp/index.test.ts index 78543c458..b99131e26 100644 --- a/packages/opencode/test/lsp/index.test.ts +++ b/packages/opencode/test/lsp/index.test.ts @@ -1,36 +1,44 @@ import { describe, expect, spyOn } from "bun:test" import path from "path" import { Deferred, Effect, Layer } from "effect" -import { Bus } from "@/bus" +import { EventV2Bridge } from "@/event-v2-bridge" import { Config } from "@/config/config" import { RuntimeFlags } from "@/effect/runtime-flags" import { LSP } from "@/lsp/lsp" import * as LSPServer from "@/lsp/server" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { provideTmpdirInstance } from "../fixture/fixture" +import { TestInstance } from "../fixture/fixture" import { awaitWithTimeout, testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(LSP.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const lspLayer = (flags: Parameters[0] = {}) => + LSP.layer.pipe( + Layer.provide(Config.defaultLayer), + Layer.provide(RuntimeFlags.layer(flags)), + Layer.provideMerge(EventV2Bridge.defaultLayer), + ) + +const it = testEffect(Layer.mergeAll(lspLayer(), CrossSpawnSpawner.defaultLayer)) const experimentalTyIt = testEffect( Layer.mergeAll( - LSP.layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(RuntimeFlags.layer({ experimentalLspTy: true }))), + lspLayer({ experimentalLspTy: true }), CrossSpawnSpawner.defaultLayer, ), ) const fakeServerPath = path.join(__dirname, "../fixture/lsp/fake-lsp-server.js") const disabledDownloadIt = testEffect( Layer.mergeAll( - LSP.layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(RuntimeFlags.layer({ disableLspDownload: true }))), + lspLayer({ disableLspDownload: true }), CrossSpawnSpawner.defaultLayer, ), ) describe("lsp.spawn", () => { - it.live("does not spawn builtin LSP for files outside instance", () => - provideTmpdirInstance( - (dir) => - LSP.Service.use((lsp) => + it.instance( + "does not spawn builtin LSP for files outside instance", + () => + LSP.Service.use((lsp) => Effect.gen(function* () { + const dir = (yield* TestInstance).directory const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined) try { @@ -46,14 +54,13 @@ describe("lsp.spawn", () => { } }), ), - { config: { lsp: true } }, - ), + { config: { lsp: true } }, ) - it.live("does not spawn builtin LSP for files inside instance when LSP is unset", () => - provideTmpdirInstance((dir) => + it.instance("does not spawn builtin LSP for files inside instance when LSP is unset", () => LSP.Service.use((lsp) => Effect.gen(function* () { + const dir = (yield* TestInstance).directory const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined) try { @@ -68,14 +75,14 @@ describe("lsp.spawn", () => { } }), ), - ), ) - it.live("would spawn builtin LSP for files inside instance when lsp is true", () => - provideTmpdirInstance( - (dir) => - LSP.Service.use((lsp) => + it.instance( + "would spawn builtin LSP for files inside instance when lsp is true", + () => + LSP.Service.use((lsp) => Effect.gen(function* () { + const dir = (yield* TestInstance).directory const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined) try { @@ -90,44 +97,46 @@ describe("lsp.spawn", () => { } }), ), - { config: { lsp: true } }, - ), + { config: { lsp: true } }, ) - it.live("publishes lsp.updated after custom LSP initialization", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { + it.instance( + "publishes lsp.updated after custom LSP initialization", + () => + Effect.gen(function* () { + const dir = (yield* TestInstance).directory const lsp = yield* LSP.Service const updated = yield* Deferred.make() - const unsubscribe = Bus.subscribe(LSP.Event.Updated, () => - Effect.runSync(Deferred.succeed(updated, undefined)), - ) - yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)) + const events = yield* EventV2Bridge.Service + const unsubscribe = yield* events.listen((event) => { + if (event.type === LSP.Event.Updated.type) Deferred.doneUnsafe(updated, Effect.void) + return Effect.void + }) + yield* Effect.addFinalizer(() => unsubscribe) const file = path.join(dir, "sample.repro") yield* Effect.promise(() => Bun.write(file, "sample\n")) yield* lsp.touchFile(file) yield* awaitWithTimeout(Deferred.await(updated), "lsp.updated event was not published") }), - { - config: { - lsp: { - fake: { - command: [process.execPath, fakeServerPath], - extensions: [".repro"], - }, + { + config: { + lsp: { + fake: { + command: [process.execPath, fakeServerPath], + extensions: [".repro"], }, }, }, - ), + }, ) - it.live("would spawn builtin LSP for files inside instance when config object is provided", () => - provideTmpdirInstance( - (dir) => - LSP.Service.use((lsp) => + it.instance( + "would spawn builtin LSP for files inside instance when config object is provided", + () => + LSP.Service.use((lsp) => Effect.gen(function* () { + const dir = (yield* TestInstance).directory const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined) try { @@ -142,21 +151,21 @@ describe("lsp.spawn", () => { } }), ), - { - config: { - lsp: { - eslint: { disabled: true }, - }, + { + config: { + lsp: { + eslint: { disabled: true }, }, }, - ), + }, ) - it.live("uses pyright instead of ty by default", () => - provideTmpdirInstance( - (dir) => - LSP.Service.use((lsp) => + it.instance( + "uses pyright instead of ty by default", + () => + LSP.Service.use((lsp) => Effect.gen(function* () { + const dir = (yield* TestInstance).directory const ty = spyOn(LSPServer.Ty, "spawn").mockResolvedValue(undefined) const pyright = spyOn(LSPServer.Pyright, "spawn").mockResolvedValue(undefined) @@ -174,15 +183,15 @@ describe("lsp.spawn", () => { } }), ), - { config: { lsp: true } }, - ), + { config: { lsp: true } }, ) - experimentalTyIt.live("uses ty instead of pyright when experimentalLspTy is enabled", () => - provideTmpdirInstance( - (dir) => - LSP.Service.use((lsp) => + experimentalTyIt.instance( + "uses ty instead of pyright when experimentalLspTy is enabled", + () => + LSP.Service.use((lsp) => Effect.gen(function* () { + const dir = (yield* TestInstance).directory const ty = spyOn(LSPServer.Ty, "spawn").mockResolvedValue(undefined) const pyright = spyOn(LSPServer.Pyright, "spawn").mockResolvedValue(undefined) @@ -200,15 +209,15 @@ describe("lsp.spawn", () => { } }), ), - { config: { lsp: true } }, - ), + { config: { lsp: true } }, ) - disabledDownloadIt.live("passes disableLspDownload to builtin LSP spawn", () => - provideTmpdirInstance( - (dir) => - LSP.Service.use((lsp) => + disabledDownloadIt.instance( + "passes disableLspDownload to builtin LSP spawn", + () => + LSP.Service.use((lsp) => Effect.gen(function* () { + const dir = (yield* TestInstance).directory const pyright = spyOn(LSPServer.Pyright, "spawn").mockResolvedValue(undefined) try { @@ -224,7 +233,6 @@ describe("lsp.spawn", () => { } }), ), - { config: { lsp: true } }, - ), + { config: { lsp: true } }, ) }) diff --git a/packages/opencode/test/lsp/lifecycle.test.ts b/packages/opencode/test/lsp/lifecycle.test.ts index 11b191f00..f6db6c087 100644 --- a/packages/opencode/test/lsp/lifecycle.test.ts +++ b/packages/opencode/test/lsp/lifecycle.test.ts @@ -4,7 +4,7 @@ import { Effect, Layer } from "effect" import { LSP } from "@/lsp/lsp" import * as LSPServer from "@/lsp/server" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { provideTmpdirInstance } from "../fixture/fixture" +import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" const it = testEffect(Layer.mergeAll(LSP.defaultLayer, CrossSpawnSpawner.defaultLayer)) @@ -20,137 +20,113 @@ describe("LSP service lifecycle", () => { spawnSpy.mockRestore() }) - it.live("init() completes without error", () => provideTmpdirInstance(() => LSP.Service.use((lsp) => lsp.init()))) + it.instance("init() completes without error", () => LSP.Service.use((lsp) => lsp.init())) - it.live("status() returns empty array initially", () => - provideTmpdirInstance(() => - LSP.Service.use((lsp) => - Effect.gen(function* () { - const result = yield* lsp.status() - expect(Array.isArray(result)).toBe(true) - expect(result.length).toBe(0) - }), - ), + it.instance("status() returns empty array initially", () => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const result = yield* lsp.status() + expect(Array.isArray(result)).toBe(true) + expect(result.length).toBe(0) + }), ), ) - it.live("diagnostics() returns empty object initially", () => - provideTmpdirInstance(() => - LSP.Service.use((lsp) => - Effect.gen(function* () { - const result = yield* lsp.diagnostics() - expect(typeof result).toBe("object") - expect(Object.keys(result).length).toBe(0) - }), - ), + it.instance("diagnostics() returns empty object initially", () => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const result = yield* lsp.diagnostics() + expect(typeof result).toBe("object") + expect(Object.keys(result).length).toBe(0) + }), ), ) - it.live("hasClients() returns false for .ts files in instance when LSP is unset", () => - provideTmpdirInstance((dir) => + it.instance("hasClients() returns false for .ts files in instance when LSP is unset", () => LSP.Service.use((lsp) => Effect.gen(function* () { - const result = yield* lsp.hasClients(path.join(dir, "test.ts")) + const result = yield* lsp.hasClients(path.join((yield* TestInstance).directory, "test.ts")) expect(result).toBe(false) }), ), - ), ) - it.live("hasClients() returns true for .ts files in instance when lsp is true", () => - provideTmpdirInstance( - (dir) => - LSP.Service.use((lsp) => - Effect.gen(function* () { - const result = yield* lsp.hasClients(path.join(dir, "test.ts")) - expect(result).toBe(true) - }), - ), - { config: { lsp: true } }, - ), - ) - - it.live("hasClients() keeps built-in LSPs when config object is provided", () => - provideTmpdirInstance( - (dir) => - LSP.Service.use((lsp) => - Effect.gen(function* () { - const result = yield* lsp.hasClients(path.join(dir, "test.ts")) - expect(result).toBe(true) - }), - ), - { - config: { - lsp: { - eslint: { disabled: true }, - }, - }, - }, - ), - ) - - it.live("hasClients() returns false for files outside instance", () => - provideTmpdirInstance((dir) => + it.instance( + "hasClients() returns true for .ts files in instance when lsp is true", + () => LSP.Service.use((lsp) => Effect.gen(function* () { - const result = yield* lsp.hasClients(path.join(dir, "..", "outside.ts")) - expect(typeof result).toBe("boolean") + const result = yield* lsp.hasClients(path.join((yield* TestInstance).directory, "test.ts")) + expect(result).toBe(true) }), ), + { config: { lsp: true } }, + ) + + it.instance( + "hasClients() keeps built-in LSPs when config object is provided", + () => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const result = yield* lsp.hasClients(path.join((yield* TestInstance).directory, "test.ts")) + expect(result).toBe(true) + }), + ), + { config: { lsp: { eslint: { disabled: true } } } }, + ) + + it.instance("hasClients() returns false for files outside instance", () => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const result = yield* lsp.hasClients(path.join((yield* TestInstance).directory, "..", "outside.ts")) + expect(typeof result).toBe("boolean") + }), ), ) - it.live("workspaceSymbol() returns empty array with no clients", () => - provideTmpdirInstance(() => - LSP.Service.use((lsp) => - Effect.gen(function* () { - const result = yield* lsp.workspaceSymbol("test") - expect(Array.isArray(result)).toBe(true) - expect(result.length).toBe(0) - }), - ), + it.instance("workspaceSymbol() returns empty array with no clients", () => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const result = yield* lsp.workspaceSymbol("test") + expect(Array.isArray(result)).toBe(true) + expect(result.length).toBe(0) + }), ), ) - it.live("definition() returns empty array for unknown file", () => - provideTmpdirInstance((dir) => - LSP.Service.use((lsp) => - Effect.gen(function* () { - const result = yield* lsp.definition({ - file: path.join(dir, "nonexistent.ts"), - line: 0, - character: 0, - }) - expect(Array.isArray(result)).toBe(true) - }), - ), + it.instance("definition() returns empty array for unknown file", () => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const result = yield* lsp.definition({ + file: path.join((yield* TestInstance).directory, "nonexistent.ts"), + line: 0, + character: 0, + }) + expect(Array.isArray(result)).toBe(true) + }), ), ) - it.live("references() returns empty array for unknown file", () => - provideTmpdirInstance((dir) => - LSP.Service.use((lsp) => - Effect.gen(function* () { - const result = yield* lsp.references({ - file: path.join(dir, "nonexistent.ts"), - line: 0, - character: 0, - }) - expect(Array.isArray(result)).toBe(true) - }), - ), + it.instance("references() returns empty array for unknown file", () => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const result = yield* lsp.references({ + file: path.join((yield* TestInstance).directory, "nonexistent.ts"), + line: 0, + character: 0, + }) + expect(Array.isArray(result)).toBe(true) + }), ), ) - it.live("multiple init() calls are idempotent", () => - provideTmpdirInstance(() => - LSP.Service.use((lsp) => - Effect.gen(function* () { - yield* lsp.init() - yield* lsp.init() - yield* lsp.init() - }), - ), + it.instance("multiple init() calls are idempotent", () => + LSP.Service.use((lsp) => + Effect.gen(function* () { + yield* lsp.init() + yield* lsp.init() + yield* lsp.init() + }), ), ) }) diff --git a/packages/opencode/test/mcp/oauth-auto-connect.test.ts b/packages/opencode/test/mcp/oauth-auto-connect.test.ts index 17bdba690..542069fc6 100644 --- a/packages/opencode/test/mcp/oauth-auto-connect.test.ts +++ b/packages/opencode/test/mcp/oauth-auto-connect.test.ts @@ -112,7 +112,7 @@ beforeEach(() => { // Import modules after mocking const { MCP } = await import("../../src/mcp/index") -const { Bus } = await import("../../src/bus") +const { EventV2Bridge } = await import("../../src/event-v2-bridge") const { Config } = await import("../../src/config/config") const { McpAuth } = await import("../../src/mcp/auth") const { McpOAuthProvider } = await import("../../src/mcp/oauth-provider") @@ -123,7 +123,7 @@ const mcpTest = testEffect( Layer.mergeAll( MCP.layer.pipe( Layer.provide(McpAuth.defaultLayer), - Layer.provideMerge(Bus.layer), + Layer.provideMerge(EventV2Bridge.defaultLayer), Layer.provide(Config.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), diff --git a/packages/opencode/test/mcp/oauth-browser.test.ts b/packages/opencode/test/mcp/oauth-browser.test.ts index 16d6a2d46..92f473b11 100644 --- a/packages/opencode/test/mcp/oauth-browser.test.ts +++ b/packages/opencode/test/mcp/oauth-browser.test.ts @@ -106,7 +106,7 @@ beforeEach(() => { // Import modules after mocking const { MCP } = await import("../../src/mcp/index") -const { Bus } = await import("../../src/bus") +const { EventV2Bridge } = await import("../../src/event-v2-bridge") const { Config } = await import("../../src/config/config") const { McpAuth } = await import("../../src/mcp/auth") const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback") @@ -115,7 +115,7 @@ const { CrossSpawnSpawner } = await import("@opencode-ai/core/cross-spawn-spawne const mcpTest = testEffect( MCP.layer.pipe( Layer.provide(McpAuth.defaultLayer), - Layer.provideMerge(Bus.layer), + Layer.provideMerge(EventV2Bridge.defaultLayer), Layer.provide(Config.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), @@ -142,12 +142,14 @@ const trackBrowserOpen = Effect.gen(function* () { }) const trackBrowserOpenFailed = Effect.gen(function* () { - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service const event = yield* Deferred.make<{ mcpName: string; url: string }>() - const unsubscribe = yield* bus.subscribeCallback(MCP.BrowserOpenFailed, (evt) => { - Effect.runSync(Deferred.succeed(event, evt.properties).pipe(Effect.ignore)) + const unsubscribe = yield* events.listen((evt) => { + if (evt.type === MCP.BrowserOpenFailed.type) + Deferred.doneUnsafe(event, Effect.succeed(evt.data as { mcpName: string; url: string })) + return Effect.void }) - yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)) + yield* Effect.addFinalizer(() => unsubscribe) return event }) diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index de791653c..b05da50cb 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -1,7 +1,7 @@ import { test, expect } from "bun:test" import os from "os" import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect" -import { Bus } from "../../src/bus" +import { EventV2Bridge } from "../../src/event-v2-bridge" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Database } from "@opencode-ai/core/database/database" import { Permission } from "../../src/permission" @@ -12,11 +12,11 @@ import { TestInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { MessageID, SessionID } from "../../src/session/schema" -const bus = Bus.layer +const events = EventV2Bridge.defaultLayer const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) const env = Layer.mergeAll( - Permission.layer.pipe(Layer.provide(Database.defaultLayer), Layer.provide(bus)), - bus, + Permission.layer.pipe(Layer.provide(Database.defaultLayer), Layer.provide(events)), + events, CrossSpawnSpawner.defaultLayer, InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap)), ) @@ -654,12 +654,13 @@ it.instance( "ask - publishes asked event", () => Effect.gen(function* () { - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service const seen = yield* Deferred.make() - const unsub = yield* bus.subscribeCallback(Permission.Event.Asked, (event) => { - Deferred.doneUnsafe(seen, Effect.succeed(event.properties)) + const unsub = yield* events.listen((event) => { + if (event.type === Permission.Event.Asked.type) Deferred.doneUnsafe(seen, Effect.succeed(event.data as Permission.Request)) + return Effect.void }) - yield* Effect.addFinalizer(() => Effect.sync(unsub)) + yield* Effect.addFinalizer(() => unsub) const fiber = yield* ask({ sessionID: SessionID.make("session_test"), @@ -914,7 +915,7 @@ it.instance( "reply - publishes replied event", () => Effect.gen(function* () { - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service const seen = yield* Deferred.make<{ sessionID: SessionID; requestID: PermissionID; reply: Permission.Reply }>() const fiber = yield* ask({ @@ -929,10 +930,12 @@ it.instance( yield* waitForPending(1) - const unsub = yield* bus.subscribeCallback(Permission.Event.Replied, (event) => { - Deferred.doneUnsafe(seen, Effect.succeed(event.properties)) + const unsub = yield* events.listen((event) => { + if (event.type === Permission.Event.Replied.type) + Deferred.doneUnsafe(seen, Effect.succeed(event.data as { sessionID: SessionID; requestID: PermissionID; reply: Permission.Reply })) + return Effect.void }) - yield* Effect.addFinalizer(() => Effect.sync(unsub)) + yield* Effect.addFinalizer(() => unsub) yield* reply({ requestID: PermissionID.make("per_test7"), reply: "once" }) yield* Fiber.join(fiber) diff --git a/packages/opencode/test/plugin/auth-override.test.ts b/packages/opencode/test/plugin/auth-override.test.ts index 7daba16c2..58ffe1977 100644 --- a/packages/opencode/test/plugin/auth-override.test.ts +++ b/packages/opencode/test/plugin/auth-override.test.ts @@ -9,7 +9,7 @@ import { ProviderAuth } from "@/provider/auth" import { Plugin } from "@/plugin" import { RuntimeFlags } from "@/effect/runtime-flags" import { Auth } from "@/auth" -import { Bus } from "@/bus" +import { EventV2Bridge } from "@/event-v2-bridge" import { TestConfig } from "../fixture/config" import { testEffect } from "../lib/effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" @@ -22,7 +22,7 @@ function layer(directory: string, plugins: string[]) { Layer.provide(Auth.defaultLayer), Layer.provide( Plugin.layer.pipe( - Layer.provide(Bus.layer), + Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(RuntimeFlags.layer()), Layer.provide( TestConfig.layer({ diff --git a/packages/opencode/test/plugin/loader-shared.test.ts b/packages/opencode/test/plugin/loader-shared.test.ts index ad03d229f..316fb2bc4 100644 --- a/packages/opencode/test/plugin/loader-shared.test.ts +++ b/packages/opencode/test/plugin/loader-shared.test.ts @@ -5,13 +5,13 @@ import path from "path" import { pathToFileURL } from "url" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { AppFileSystem } from "@opencode-ai/core/filesystem" -import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" const { Plugin } = await import("../../src/plugin/index") const { PluginLoader } = await import("../../src/plugin/loader") const { readPackageThemes } = await import("../../src/plugin/shared") -const { Bus } = await import("../../src/bus") +const { EventV2Bridge } = await import("../../src/event-v2-bridge") const { Npm } = await import("@opencode-ai/core/npm") const { TestConfig } = await import("../fixture/config") const { RuntimeFlags } = await import("../../src/effect/runtime-flags") @@ -20,7 +20,7 @@ afterEach(async () => { await disposeAllInstances() }) -const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, AppFileSystem.defaultLayer)) +const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, AppFileSystem.defaultLayer, testInstanceStoreLayer)) function withTmp( init: (dir: string) => Promise, @@ -46,7 +46,7 @@ function load(dir: string, flags?: Parameters[0]) { }).pipe( Effect.provide( Plugin.layer.pipe( - Layer.provide(Bus.layer), + Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true, ...flags })), Layer.provide( TestConfig.layer({ diff --git a/packages/opencode/test/plugin/trigger.test.ts b/packages/opencode/test/plugin/trigger.test.ts index 6714d7247..a3ed8334a 100644 --- a/packages/opencode/test/plugin/trigger.test.ts +++ b/packages/opencode/test/plugin/trigger.test.ts @@ -6,13 +6,13 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import path from "path" import { pathToFileURL } from "url" -import { Bus } from "../../src/bus" +import { EventV2Bridge } from "../../src/event-v2-bridge" import { Config } from "../../src/config/config" import { Env } from "../../src/env" import { RuntimeFlags } from "../../src/effect/runtime-flags" import { Plugin } from "../../src/plugin/index" -import { provideTmpdirInstance } from "../fixture/fixture" +import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { AccountTest } from "../fake/account" import { AuthTest } from "../fake/auth" @@ -31,7 +31,7 @@ const configLayer = Config.layer.pipe( const it = testEffect( Layer.mergeAll( Plugin.layer.pipe( - Layer.provide(Bus.layer), + Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(configLayer), Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true })), ), @@ -41,31 +41,30 @@ const it = testEffect( const systemHook = "experimental.chat.system.transform" function withProject(source: string, self: Effect.Effect) { - return provideTmpdirInstance((dir) => - Effect.gen(function* () { - const file = path.join(dir, "plugin.ts") - yield* Effect.all( - [ - Effect.promise(() => Bun.write(file, source)), - Effect.promise(() => - Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify( - { - $schema: "https://opencode.ai/config.json", - plugin: [pathToFileURL(file).href], - }, - null, - 2, - ), + return Effect.gen(function* () { + const test = yield* TestInstance + const file = path.join(test.directory, "plugin.ts") + yield* Effect.all( + [ + Effect.promise(() => Bun.write(file, source)), + Effect.promise(() => + Bun.write( + path.join(test.directory, "opencode.json"), + JSON.stringify( + { + $schema: "https://opencode.ai/config.json", + plugin: [pathToFileURL(file).href], + }, + null, + 2, ), ), - ], - { discard: true, concurrency: 2 }, - ) - return yield* self - }), - ) + ), + ], + { discard: true, concurrency: 2 }, + ) + return yield* self + }) } const triggerSystemTransform = Effect.fn("PluginTriggerTest.triggerSystemTransform")(function* () { @@ -85,7 +84,7 @@ const triggerSystemTransform = Effect.fn("PluginTriggerTest.triggerSystemTransfo }) describe("plugin.trigger", () => { - it.live("runs synchronous hooks without crashing", () => + it.instance("runs synchronous hooks without crashing", () => withProject( [ "export default async () => ({", @@ -101,7 +100,7 @@ describe("plugin.trigger", () => { ), ) - it.live("awaits asynchronous hooks", () => + it.instance("awaits asynchronous hooks", () => withProject( [ "export default async () => ({", diff --git a/packages/opencode/test/plugin/workspace-adapter.test.ts b/packages/opencode/test/plugin/workspace-adapter.test.ts index 216e2e5a6..2953aaa5f 100644 --- a/packages/opencode/test/plugin/workspace-adapter.test.ts +++ b/packages/opencode/test/plugin/workspace-adapter.test.ts @@ -8,7 +8,7 @@ import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import path from "path" import { pathToFileURL } from "url" import { Auth } from "../../src/auth" -import { Bus } from "../../src/bus" +import { EventV2Bridge } from "../../src/event-v2-bridge" import { Config } from "../../src/config/config" import { Env } from "../../src/env" import { RuntimeFlags } from "../../src/effect/runtime-flags" @@ -21,12 +21,11 @@ import { Vcs } from "../../src/project/vcs" import { InstanceState } from "../../src/effect/instance-state" import { Session } from "../../src/session/session" import { SessionPrompt } from "../../src/session/prompt" -import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" +import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { AccountTest } from "../fake/account" import { AuthTest } from "../fake/auth" import { NpmTest } from "../fake/npm" -import { EventV2Bridge } from "../../src/event-v2-bridge" const configLayer = Config.layer.pipe( Layer.provide(EffectFlock.defaultLayer), @@ -38,7 +37,7 @@ const configLayer = Config.layer.pipe( Layer.provide(FetchHttpClient.layer), ) const pluginLayer = Plugin.layer.pipe( - Layer.provide(Bus.layer), + Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(configLayer), Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true })), ) @@ -63,9 +62,9 @@ afterEach(async () => { }) describe("plugin.workspace", () => { - it.live("plugin can install a workspace adapter", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { + it.instance("plugin can install a workspace adapter", () => + Effect.gen(function* () { + const dir = (yield* TestInstance).directory const type = `plug-${Math.random().toString(36).slice(2)}` const file = path.join(dir, "plugin.ts") const mark = path.join(dir, "created.json") @@ -133,7 +132,6 @@ describe("plugin.workspace", () => { directory: space, extra: { key: "value" }, }) - }), - ), + }), ) }) diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index dffc7f169..1e9567c59 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -73,6 +73,8 @@ delete process.env["CEREBRAS_API_KEY"] delete process.env["SAMBANOVA_API_KEY"] delete process.env["OPENCODE_SERVER_PASSWORD"] delete process.env["OPENCODE_SERVER_USERNAME"] +delete process.env["OPENCODE_EXPERIMENTAL"] +delete process.env["OPENCODE_ENABLE_EXPERIMENTAL_MODELS"] delete process.env["OTEL_EXPORTER_OTLP_ENDPOINT"] delete process.env["OTEL_EXPORTER_OTLP_HEADERS"] delete process.env["OTEL_RESOURCE_ATTRIBUTES"] diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index 08923a12d..bbad81a71 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -1,5 +1,5 @@ import { describe, expect } from "bun:test" -import { Bus } from "@/bus" +import { EventV2Bridge } from "@/event-v2-bridge" import { Project } from "@/project/project" import * as Log from "@opencode-ai/core/util/log" import { $ } from "bun" @@ -75,7 +75,7 @@ function projectLayerWithFailure(failArg: string) { Layer.provide(AppProcess.layer.pipe(Layer.provide(mockGitFailure(failArg)))), Layer.provide(mockGitFailure(failArg)), Layer.provide(ProjectV2.defaultLayer), - Layer.provide(Bus.defaultLayer), + Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer), Layer.provide(Database.defaultLayer), @@ -85,7 +85,7 @@ function projectLayerWithFailure(failArg: string) { function projectLayerWithRuntimeFlags(flags: Parameters[0]) { return Project.layer.pipe( - Layer.provide(Bus.defaultLayer), + Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(ProjectV2.defaultLayer), Layer.provide(AppProcess.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts index b1d637302..be8d22331 100644 --- a/packages/opencode/test/project/vcs.test.ts +++ b/packages/opencode/test/project/vcs.test.ts @@ -5,8 +5,8 @@ import { Deferred, Effect, Layer } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import fs from "fs/promises" import path from "path" -import { disposeAllInstances, provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture" -import { Bus } from "../../src/bus" +import { disposeAllInstances, provideInstance, testInstanceStoreLayer, TestInstance, tmpdirScoped } from "../fixture/fixture" +import { EventV2Bridge } from "../../src/event-v2-bridge" import { FileWatcher } from "../../src/file/watcher" import { Git } from "../../src/git" import { Vcs } from "@/project/vcs" @@ -19,11 +19,12 @@ import { testEffect } from "../lib/effect" const weird = process.platform === "win32" ? "space file.txt" : "tab\tfile.txt" const layer = Layer.mergeAll( - Vcs.layer.pipe(Layer.provideMerge(Git.defaultLayer), Layer.provideMerge(Bus.layer)), + Vcs.layer.pipe(Layer.provideMerge(Git.defaultLayer), Layer.provideMerge(EventV2Bridge.defaultLayer)), CrossSpawnSpawner.defaultLayer, AppFileSystem.defaultLayer, ) const it = testEffect(layer) +const worktreeIt = testEffect(Layer.mergeAll(layer, testInstanceStoreLayer)) const git = Effect.fn("VcsTest.git")(function* (cwd: string, args: string[]) { const result = yield* Git.Service.use((git) => git.run(args, { cwd })) @@ -47,13 +48,15 @@ const init = Effect.fn("VcsTest.init")(function* () { }) const nextBranchUpdate = Effect.fn("VcsTest.nextBranchUpdate")(function* () { - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service const updated = yield* Deferred.make() - const off = yield* bus.subscribeCallback(Vcs.Event.BranchUpdated, (evt) => { - Effect.runSync(Deferred.succeed(updated, evt.properties.branch)) + const off = yield* events.listen((event) => { + if (event.type === Vcs.Event.BranchUpdated.type) + Deferred.doneUnsafe(updated, Effect.succeed((event.data as typeof Vcs.Event.BranchUpdated.data.Type).branch)) + return Effect.void }) - yield* Effect.addFinalizer(() => Effect.sync(off)) + yield* Effect.addFinalizer(() => off) return updated }) @@ -62,9 +65,9 @@ const publishHeadChangeUntil = Effect.fn("VcsTest.publishHeadChangeUntil")(funct pending: Deferred.Deferred, head: string, ) { - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service for (let i = 0; i < 50; i++) { - yield* bus.publish(FileWatcher.Event.Updated, { file: head, event: "change" }) + yield* events.publish(FileWatcher.Event.Updated, { file: head, event: "change" }) if (yield* Deferred.isDone(pending)) return yield* Effect.sleep("10 millis") } @@ -183,7 +186,7 @@ describe("Vcs diff", () => { { git: true }, ) - it.live("detects current branch from the active worktree", () => + worktreeIt.live("detects current branch from the active worktree", () => Effect.gen(function* () { const tmp = yield* tmpdirScoped({ git: true }) const wt = yield* tmpdirScoped() diff --git a/packages/opencode/test/project/worktree-remove.test.ts b/packages/opencode/test/project/worktree-remove.test.ts index fa70ecb89..230ade33a 100644 --- a/packages/opencode/test/project/worktree-remove.test.ts +++ b/packages/opencode/test/project/worktree-remove.test.ts @@ -5,17 +5,18 @@ import path from "path" import { Effect, Layer } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Worktree } from "../../src/worktree" -import { provideTmpdirInstance } from "../fixture/fixture" +import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" const it = testEffect(Layer.mergeAll(Worktree.defaultLayer, CrossSpawnSpawner.defaultLayer)) -const wintest = process.platform === "win32" ? it.live : it.live.skip +const wintest = process.platform === "win32" ? it.instance : it.instance.skip describe("Worktree.remove", () => { - it.live("continues when git remove exits non-zero after detaching", () => - provideTmpdirInstance( - (root) => - Effect.gen(function* () { + it.instance( + "continues when git remove exits non-zero after detaching", + () => + Effect.gen(function* () { + const root = (yield* TestInstance).directory const svc = yield* Worktree.Service const name = `remove-regression-${Date.now().toString(36)}` const branch = `opencode/${name}` @@ -79,15 +80,15 @@ describe("Worktree.remove", () => { $`git show-ref --verify --quiet refs/heads/${branch}`.cwd(root).quiet().nothrow(), ) expect(ref.exitCode).not.toBe(0) - }), - { git: true }, - ), + }), + { git: true }, ) - wintest("stops fsmonitor before removing a worktree", () => - provideTmpdirInstance( - (root) => - Effect.gen(function* () { + wintest( + "stops fsmonitor before removing a worktree", + () => + Effect.gen(function* () { + const root = (yield* TestInstance).directory const svc = yield* Worktree.Service const name = `remove-fsmonitor-${Date.now().toString(36)}` const branch = `opencode/${name}` @@ -119,8 +120,7 @@ describe("Worktree.remove", () => { $`git show-ref --verify --quiet refs/heads/${branch}`.cwd(root).quiet().nothrow(), ) expect(ref.exitCode).not.toBe(0) - }), - { git: true }, - ), + }), + { git: true }, ) }) diff --git a/packages/opencode/test/pty/pty-output-isolation.test.ts b/packages/opencode/test/pty/pty-output-isolation.test.ts index 0fa710f02..20975d986 100644 --- a/packages/opencode/test/pty/pty-output-isolation.test.ts +++ b/packages/opencode/test/pty/pty-output-isolation.test.ts @@ -1,5 +1,5 @@ import { describe, expect } from "bun:test" -import { Bus } from "../../src/bus" +import { EventV2Bridge } from "../../src/event-v2-bridge" import { Config } from "../../src/config/config" import { Plugin } from "../../src/plugin" import { Pty } from "../../src/pty" @@ -10,7 +10,7 @@ type Socket = Parameters[1] const it = testEffect( Pty.layer.pipe( - Layer.provideMerge(Bus.layer), + Layer.provideMerge(EventV2Bridge.defaultLayer), Layer.provideMerge(Config.defaultLayer), Layer.provideMerge(Plugin.defaultLayer), ), diff --git a/packages/opencode/test/pty/pty-session.test.ts b/packages/opencode/test/pty/pty-session.test.ts index 9fda48cc9..74c9f70ec 100644 --- a/packages/opencode/test/pty/pty-session.test.ts +++ b/packages/opencode/test/pty/pty-session.test.ts @@ -1,5 +1,5 @@ import { describe, expect } from "bun:test" -import { Bus } from "../../src/bus" +import { EventV2Bridge } from "../../src/event-v2-bridge" import { Config } from "../../src/config/config" import { Plugin } from "../../src/plugin" import { Pty } from "../../src/pty" @@ -11,7 +11,7 @@ type PtyEvent = { type: "created" | "exited" | "deleted"; id: PtyID } const it = testEffect( Pty.layer.pipe( - Layer.provideMerge(Bus.layer), + Layer.provideMerge(EventV2Bridge.defaultLayer), Layer.provideMerge(Config.defaultLayer), Layer.provideMerge(Plugin.defaultLayer), ), @@ -19,27 +19,19 @@ const it = testEffect( const ptyTest = process.platform === "win32" ? it.instance.skip : it.instance const subscribePtyEvents = Effect.fn("PtySessionTest.subscribePtyEvents")(function* () { - const bus = yield* Bus.Service + const source = yield* EventV2Bridge.Service const events = yield* Queue.unbounded() - const subscribe = (effect: Effect.Effect<() => void, never, A>) => - Effect.acquireRelease(effect, (off) => Effect.sync(off)) - - yield* subscribe( - bus.subscribeCallback(Pty.Event.Created, (evt) => { - Queue.offerUnsafe(events, { type: "created", id: evt.properties.info.id }) - }), - ) - yield* subscribe( - bus.subscribeCallback(Pty.Event.Exited, (evt) => { - Queue.offerUnsafe(events, { type: "exited", id: evt.properties.id }) - }), - ) - yield* subscribe( - bus.subscribeCallback(Pty.Event.Deleted, (evt) => { - Queue.offerUnsafe(events, { type: "deleted", id: evt.properties.id }) - }), - ) + const unsubscribe = yield* source.listen((event) => { + if (event.type === Pty.Event.Created.type) + Queue.offerUnsafe(events, { type: "created", id: (event.data as typeof Pty.Event.Created.data.Type).info.id }) + if (event.type === Pty.Event.Exited.type) + Queue.offerUnsafe(events, { type: "exited", id: (event.data as typeof Pty.Event.Exited.data.Type).id }) + if (event.type === Pty.Event.Deleted.type) + Queue.offerUnsafe(events, { type: "deleted", id: (event.data as typeof Pty.Event.Deleted.data.Type).id }) + return Effect.void + }) + yield* Effect.addFinalizer(() => unsubscribe) return events }) diff --git a/packages/opencode/test/question/question.test.ts b/packages/opencode/test/question/question.test.ts index 5f6f87972..47c71e9e0 100644 --- a/packages/opencode/test/question/question.test.ts +++ b/packages/opencode/test/question/question.test.ts @@ -2,16 +2,19 @@ import { afterEach, expect } from "bun:test" import { Cause, Effect, Exit, Fiber, Layer, Queue } from "effect" import { Question } from "../../src/question" import { InstanceRef } from "../../src/effect/instance-ref" -import { InstanceRuntime } from "../../src/project/instance-runtime" +import { InstanceStore } from "../../src/project/instance-store" import { QuestionID } from "../../src/question/schema" -import { disposeAllInstances, provideInstance, reloadTestInstance, tmpdirScoped } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixture/fixture" import { SessionID } from "../../src/session/schema" import { testEffect } from "../lib/effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { Bus } from "../../src/bus" +import { EventV2Bridge } from "../../src/event-v2-bridge" const it = testEffect( - Layer.mergeAll(Question.layer.pipe(Layer.provideMerge(Bus.layer)), CrossSpawnSpawner.defaultLayer), + Layer.mergeAll(Question.layer.pipe(Layer.provideMerge(EventV2Bridge.defaultLayer)), CrossSpawnSpawner.defaultLayer), +) +const lifecycle = testEffect( + Layer.mergeAll(Question.layer.pipe(Layer.provideMerge(EventV2Bridge.defaultLayer)), CrossSpawnSpawner.defaultLayer, testInstanceStoreLayer), ) const askEffect = Effect.fn("QuestionTest.ask")(function* (input: { @@ -49,10 +52,13 @@ const rejectAll = Effect.gen(function* () { const waitForPending = Effect.fn("QuestionTest.waitForPending")(function* (count: number) { const question = yield* Question.Service - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service const asked = yield* Queue.unbounded() - const off = yield* bus.subscribeCallback(Question.Event.Asked, () => Queue.offerUnsafe(asked, undefined)) - yield* Effect.addFinalizer(() => Effect.sync(off)) + const off = yield* events.listen((event) => { + if (event.type === Question.Event.Asked.type) Queue.offerUnsafe(asked, undefined) + return Effect.void + }) + yield* Effect.addFinalizer(() => off) for (;;) { const pending = yield* question.list() @@ -361,7 +367,7 @@ it.instance( { git: true }, ) -it.live("questions stay isolated by directory", () => +lifecycle.live("questions stay isolated by directory", () => Effect.gen(function* () { const one = yield* tmpdirScoped({ git: true }) const two = yield* tmpdirScoped({ git: true }) @@ -404,7 +410,7 @@ it.live("questions stay isolated by directory", () => }), ) -it.live("pending question rejects on instance dispose", () => +lifecycle.live("pending question rejects on instance dispose", () => Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) const fiber = yield* askEffect({ @@ -423,7 +429,7 @@ it.live("pending question rejects on instance dispose", () => return yield* InstanceRef }).pipe(provideInstance(dir)) if (!ctx) return yield* Effect.die(new Error("missing test instance")) - yield* Effect.promise(() => InstanceRuntime.disposeInstance(ctx)) + yield* InstanceStore.Service.use((store) => store.dispose(ctx)) const exit = yield* Fiber.await(fiber) expect(Exit.isFailure(exit)).toBe(true) @@ -431,7 +437,7 @@ it.live("pending question rejects on instance dispose", () => }), ) -it.live("pending question rejects on instance reload", () => +lifecycle.live("pending question rejects on instance reload", () => Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) const fiber = yield* askEffect({ @@ -446,7 +452,7 @@ it.live("pending question rejects on instance reload", () => }).pipe(provideInstance(dir), Effect.forkScoped) expect(yield* waitForPending(1).pipe(provideInstance(dir))).toHaveLength(1) - yield* Effect.promise(() => reloadTestInstance({ directory: dir })) + yield* InstanceStore.Service.use((store) => store.reload({ directory: dir })) const exit = yield* Fiber.await(fiber) expect(Exit.isFailure(exit)).toBe(true) diff --git a/packages/opencode/test/server/global-session-list.test.ts b/packages/opencode/test/server/global-session-list.test.ts index df49ae084..278e36a96 100644 --- a/packages/opencode/test/server/global-session-list.test.ts +++ b/packages/opencode/test/server/global-session-list.test.ts @@ -27,7 +27,7 @@ describe("session.listGlobal", () => { const firstSession = yield* withSession({ title: "first-session" }) const secondSession = yield* withSession({ title: "second-session" }).pipe(provideInstance(second)) - const sessions = yield* Effect.sync(() => [...SessionNs.listGlobal({ limit: 200 })]) + const sessions = yield* SessionNs.Service.use((session) => session.listGlobal({ limit: 200 })) const ids = sessions.map((session) => session.id) expect(ids).toContain(firstSession.id) @@ -56,12 +56,14 @@ describe("session.listGlobal", () => { yield* SessionNs.Service.use((session) => session.setArchived({ sessionID: archived.id, time: Date.now() })) - const sessions = yield* Effect.sync(() => [...SessionNs.listGlobal({ limit: 200 })]) + const sessions = yield* SessionNs.Service.use((session) => session.listGlobal({ limit: 200 })) const ids = sessions.map((session) => session.id) expect(ids).not.toContain(archived.id) - const allSessions = yield* Effect.sync(() => [...SessionNs.listGlobal({ limit: 200, archived: true })]) + const allSessions = yield* SessionNs.Service.use((session) => + session.listGlobal({ limit: 200, archived: true }), + ) const allIds = allSessions.map((session) => session.id) expect(allIds).toContain(archived.id) @@ -86,13 +88,15 @@ describe("session.listGlobal", () => { ) const second = yield* withSession({ title: "page-two" }) - const page = yield* Effect.sync(() => [...SessionNs.listGlobal({ directory: test.directory, limit: 1 })]) + const page = yield* SessionNs.Service.use((session) => + session.listGlobal({ directory: test.directory, limit: 1 }), + ) expect(page.length).toBe(1) expect(page[0].id).toBe(second.id) - const next = yield* Effect.sync(() => [ - ...SessionNs.listGlobal({ directory: test.directory, limit: 10, cursor: page[0].time.updated }), - ]) + const next = yield* SessionNs.Service.use((session) => + session.listGlobal({ directory: test.directory, limit: 10, cursor: page[0].time.updated }), + ) const ids = next.map((session) => session.id) expect(ids).toContain(first.id) diff --git a/packages/opencode/test/server/httpapi-event.test.ts b/packages/opencode/test/server/httpapi-event.test.ts index 44d421ea0..14e246c2b 100644 --- a/packages/opencode/test/server/httpapi-event.test.ts +++ b/packages/opencode/test/server/httpapi-event.test.ts @@ -1,13 +1,11 @@ import { afterEach, describe, expect } from "bun:test" -import { Effect, Schema } from "effect" +import { Effect, Layer, Queue, Schema, Stream } from "effect" import * as Log from "@opencode-ai/core/util/log" -import { Bus } from "../../src/bus" -import { Event as ServerEvent } from "../../src/server/event" -import { Server } from "../../src/server/server" import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, TestInstance } from "../fixture/fixture" -import { testEffectShared } from "../lib/effect" +import { testEffect } from "../lib/effect" +import { httpApiLayer, requestInDirectory } from "./httpapi-layer" void Log.init({ print: false }) @@ -17,28 +15,25 @@ const EventData = Schema.Struct({ properties: Schema.Record(Schema.String, Schema.Any), }) -const readEvent = (reader: ReadableStreamDefaultReader) => +const readEvent = (reader: Queue.Dequeue) => Effect.gen(function* () { - const result = yield* Effect.promise(() => reader.read()).pipe( + const value = yield* Queue.take(reader).pipe( Effect.timeoutOrElse({ duration: "5 seconds", orElse: () => Effect.fail(new Error("timed out waiting for event")), }), ) - if (result.done || !result.value) return yield* Effect.fail(new Error("event stream closed")) - return Schema.decodeUnknownSync(EventData)( - JSON.parse(new TextDecoder().decode(result.value).replace(/^data: /, "")), - ) + return Schema.decodeUnknownSync(EventData)(JSON.parse(new TextDecoder().decode(value).replace(/^data: /, ""))) }) const openEventStream = (directory: string) => Effect.gen(function* () { - const response = yield* Effect.promise(async () => - Server.Default().app.request(EventPaths.event, { headers: { "x-opencode-directory": directory } }), + const response = yield* requestInDirectory(EventPaths.event, directory) + const reader = yield* Queue.unbounded() + yield* response.stream.pipe( + Stream.runForEach((value) => Queue.offer(reader, value)), + Effect.forkScoped, ) - if (!response.body) return yield* Effect.die("missing SSE response body") - const reader = response.body.getReader() - yield* Effect.addFinalizer(() => Effect.promise(() => reader.cancel().catch(() => undefined))) return { response, reader } }) @@ -47,7 +42,7 @@ afterEach(async () => { await resetDatabase() }) -const it = testEffectShared(Bus.defaultLayer) +const it = testEffect(httpApiLayer) describe("event HttpApi", () => { it.instance( @@ -58,10 +53,10 @@ describe("event HttpApi", () => { const { response, reader } = yield* openEventStream(directory) expect(response.status).toBe(200) - expect(response.headers.get("content-type")).toContain("text/event-stream") - expect(response.headers.get("cache-control")).toBe("no-cache, no-transform") - expect(response.headers.get("x-accel-buffering")).toBe("no") - expect(response.headers.get("x-content-type-options")).toBe("nosniff") + expect(response.headers["content-type"]).toContain("text/event-stream") + expect(response.headers["cache-control"]).toBe("no-cache, no-transform") + expect(response.headers["x-accel-buffering"]).toBe("no") + expect(response.headers["x-content-type-options"]).toBe("nosniff") expect(yield* readEvent(reader)).toMatchObject({ type: "server.connected", properties: {} }) }), { git: true, config: { formatter: false, lsp: false } }, @@ -76,8 +71,8 @@ describe("event HttpApi", () => { expect(yield* readEvent(reader)).toMatchObject({ type: "server.connected", properties: {} }) // If no second event arrives within 250ms, the stream is still open. - const status = yield* Effect.promise(() => reader.read()).pipe( - Effect.map((result) => (result.done ? ("closed" as const) : ("event" as const))), + const status = yield* Queue.take(reader).pipe( + Effect.as("event" as const), Effect.timeoutOrElse({ duration: "250 millis", orElse: () => Effect.succeed("open" as const) }), ) expect(status).toBe("open") @@ -86,16 +81,18 @@ describe("event HttpApi", () => { ) it.instance( - "delivers instance bus events after the initial event", + "delivers instance events after the initial event", () => Effect.gen(function* () { const { directory } = yield* TestInstance const { reader } = yield* openEventStream(directory) expect(yield* readEvent(reader)).toMatchObject({ type: "server.connected", properties: {} }) - yield* Bus.use.publish(ServerEvent.Connected, {}) - expect(yield* readEvent(reader)).toMatchObject({ type: "server.connected", properties: {} }) + const created = yield* requestInDirectory("/session", directory, { method: "POST" }) + expect(created.status).toBe(200) + expect(yield* readEvent(reader)).toMatchObject({ type: "session.created" }) }), { git: true, config: { formatter: false, lsp: false } }, ) + }) diff --git a/packages/opencode/test/server/httpapi-experimental.test.ts b/packages/opencode/test/server/httpapi-experimental.test.ts index 36da905c5..694956f46 100644 --- a/packages/opencode/test/server/httpapi-experimental.test.ts +++ b/packages/opencode/test/server/httpapi-experimental.test.ts @@ -1,8 +1,8 @@ import { afterEach, describe, expect } from "bun:test" import { Deferred, Effect, Fiber, Layer } from "effect" +import { HttpClient, HttpClientResponse } from "effect/unstable/http" import { eq } from "drizzle-orm" import { GlobalBus, type GlobalEvent } from "@/bus/global" -import { Server } from "../../src/server/server" import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental" import { Session } from "@/session/session" import { SessionTable } from "@opencode-ai/core/session/sql" @@ -14,30 +14,23 @@ import { Worktree } from "../../src/worktree" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" +import { httpApiLayer, requestInDirectory } from "./httpapi-layer" void Log.init({ print: false }) -const it = testEffect(Layer.mergeAll(Session.defaultLayer, Database.defaultLayer)) +const it = testEffect(Layer.mergeAll(Session.defaultLayer, Database.defaultLayer, httpApiLayer)) const testWorktreeMutations = process.platform === "win32" ? it.instance.skip : it.instance -function app() { - return Server.Default().app -} - function request(path: string, directory: string, init: RequestInit = {}) { - return Effect.promise(() => { - const headers = new Headers(init.headers) - headers.set("x-opencode-directory", directory) - return Promise.resolve(app().request(path, { ...init, headers })) - }) + return requestInDirectory(path, directory, init) } function createSession(input?: Session.CreateInput) { return Session.use.create(input) } -function json(response: Response) { - return Effect.promise(() => response.json() as Promise) +function json(response: HttpClientResponse.HttpClientResponse) { + return response.json.pipe(Effect.map((value) => value as T)) } function waitReady(input: { directory?: string; name?: string }) { @@ -83,7 +76,11 @@ function insertAccount() { }), (id) => Database.Service.use(({ db }) => - db.delete(AccountTable).where(eq(AccountTable.id, AccountV2.ID.make(id))).run().pipe(Effect.orDie), + db + .delete(AccountTable) + .where(eq(AccountTable.id, AccountV2.ID.make(id))) + .run() + .pipe(Effect.orDie), ), ) } @@ -91,11 +88,19 @@ function insertAccount() { function setSessionUpdated(session: Session.Info, updated: number) { return Effect.gen(function* () { const { db } = yield* Database.Service - yield* db.update(SessionTable).set({ time_updated: updated }).where(eq(SessionTable.id, session.id)).run().pipe(Effect.orDie) + yield* db + .update(SessionTable) + .set({ time_updated: updated }) + .where(eq(SessionTable.id, session.id)) + .run() + .pipe(Effect.orDie) }) } -function withCreatedWorktree(directory: string, use: (info: Worktree.Info) => Effect.Effect) { +function withCreatedWorktree( + directory: string, + use: (info: Worktree.Info) => Effect.Effect, +) { const name = "api-test" const headers = { "content-type": "application/json" } return Effect.acquireUseRelease( @@ -244,7 +249,7 @@ describe("experimental HttpApi", () => { tmp.directory, ) expect(page.status).toBe(200) - expect(page.headers.get("x-next-cursor")).toBeTruthy() + expect(page.headers["x-next-cursor"]).toBeTruthy() const body = yield* json(page) expect(body.map((session) => session.id)).toEqual([second.id]) diff --git a/packages/opencode/test/server/httpapi-layer.ts b/packages/opencode/test/server/httpapi-layer.ts new file mode 100644 index 000000000..a780391ae --- /dev/null +++ b/packages/opencode/test/server/httpapi-layer.ts @@ -0,0 +1,33 @@ +import { NodeHttpServer, NodeServices } from "@effect/platform-node" +import { Config, Layer } from "effect" +import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http" +import { layerWebSocketConstructorGlobal } from "effect/unstable/socket/Socket" +import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" + +const servedRoutes: Layer.Layer = HttpRouter.serve( + HttpApiApp.routes, + { + disableListenLog: true, + disableLogger: true, + }, +) + +export const httpApiLayer = servedRoutes.pipe( + Layer.provide(layerWebSocketConstructorGlobal), + Layer.provideMerge(NodeHttpServer.layerTest), + Layer.provideMerge(NodeServices.layer), +) + +export function request(path: string, init?: RequestInit) { + const url = new URL(path, "http://localhost") + return HttpClientRequest.fromWeb(new Request(url, init)).pipe( + HttpClientRequest.setUrl(url.pathname), + HttpClient.execute, + ) +} + +export function requestInDirectory(path: string, directory: string, init: RequestInit = {}) { + const headers = new Headers(init.headers) + headers.set("x-opencode-directory", directory) + return request(path, { ...init, headers }) +} diff --git a/packages/opencode/test/server/httpapi-provider.test.ts b/packages/opencode/test/server/httpapi-provider.test.ts index 25181f3b2..52b508705 100644 --- a/packages/opencode/test/server/httpapi-provider.test.ts +++ b/packages/opencode/test/server/httpapi-provider.test.ts @@ -2,12 +2,12 @@ import { describe, expect } from "bun:test" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Effect, Layer } from "effect" import path from "path" -import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" import { TestInstance } from "../fixture/fixture" import { markPluginDependenciesReady } from "../fixture/plugin" import { testEffect } from "../lib/effect" +import { httpApiLayer, request } from "./httpapi-layer" void Log.init({ print: false }) @@ -18,16 +18,12 @@ const testStateLayer = Layer.effectDiscard( ), ) -const it = testEffect(Layer.mergeAll(testStateLayer, AppFileSystem.defaultLayer)) +const it = testEffect(Layer.mergeAll(testStateLayer, AppFileSystem.defaultLayer, httpApiLayer)) const projectOptions = { config: { formatter: false, lsp: false } } const providerID = "test-oauth-parity" const oauthURL = "https://example.com/oauth" const oauthInstructions = "Finish OAuth" -function app() { - return Server.Default().app -} - function providerListHasFetch(list: unknown) { if (!Array.isArray(list)) return false return list.some((item: unknown) => { @@ -77,41 +73,34 @@ function hasProviderMutationMarker(input: unknown, key: "all" | "providers", id: } function requestAuthorize(input: { - app: ReturnType providerID: string method: number headers: HeadersInit inputs?: Record }) { - return Effect.promise(async () => { - const response = await input.app.request(`/provider/${input.providerID}/oauth/authorize`, { + return Effect.gen(function* () { + const response = yield* request(`/provider/${input.providerID}/oauth/authorize`, { method: "POST", headers: input.headers, body: JSON.stringify({ method: input.method, ...(input.inputs ? { inputs: input.inputs } : {}) }), }) return { status: response.status, - body: await response.text(), + body: yield* response.text, } }) } -function requestCallback(input: { - app: ReturnType - providerID: string - method: number - headers: HeadersInit - code?: string -}) { - return Effect.promise(async () => { - const response = await input.app.request(`/provider/${input.providerID}/oauth/callback`, { +function requestCallback(input: { providerID: string; method: number; headers: HeadersInit; code?: string }) { + return Effect.gen(function* () { + const response = yield* request(`/provider/${input.providerID}/oauth/callback`, { method: "POST", headers: input.headers, body: JSON.stringify({ method: input.method, ...(input.code ? { code: input.code } : {}) }), }) return { status: response.status, - body: await response.text(), + body: yield* response.text, } }) } @@ -277,15 +266,13 @@ describe("provider HttpApi", () => { it.instance.skip( "returns public v2 provider not found errors", Effect.gen(function* () { - const instance = yield* TestInstance - const response = yield* Effect.promise(() => - Promise.resolve( - app().request("/api/provider/missing", { headers: { "x-opencode-directory": instance.directory } }), - ), - ) + const directory = (yield* TestInstance).directory + const response = yield* request("/api/provider/missing", { + headers: { "x-opencode-directory": directory }, + }) expect(response.status).toBe(404) - expect(yield* Effect.promise(() => response.json())).toEqual({ + expect(yield* response.json).toEqual({ _tag: "ProviderNotFoundError", providerID: "missing", message: "Provider not found: missing", @@ -297,13 +284,9 @@ describe("provider HttpApi", () => { it.instance( "serves OAuth authorize response shapes", Effect.gen(function* () { - const instance = yield* TestInstance - yield* writeProviderAuthPlugin(instance.directory) - const headers = { "x-opencode-directory": instance.directory, "content-type": "application/json" } - const server = app() - + const directory = (yield* TestInstance).directory + const headers = { "x-opencode-directory": directory, "content-type": "application/json" } const api = yield* requestAuthorize({ - app: server, providerID, method: 0, headers, @@ -315,7 +298,6 @@ describe("provider HttpApi", () => { expect(api).toEqual({ status: 200, body: "null" }) const oauth = yield* requestAuthorize({ - app: server, providerID, method: 1, headers, @@ -326,21 +308,19 @@ describe("provider HttpApi", () => { instructions: oauthInstructions, }) }), - projectOptions, + { ...projectOptions, init: writeProviderAuthPlugin }, 30000, ) it.instance( "returns declared provider auth validation errors", Effect.gen(function* () { - const instance = yield* TestInstance - yield* writeProviderAuthValidationPlugin(instance.directory) + const directory = (yield* TestInstance).directory const response = yield* requestAuthorize({ - app: app(), providerID: "test-oauth-validation", method: 0, inputs: { token: "nope" }, - headers: { "x-opencode-directory": instance.directory, "content-type": "application/json" }, + headers: { "x-opencode-directory": directory, "content-type": "application/json" }, }) expect(response.status).toBe(400) @@ -349,19 +329,18 @@ describe("provider HttpApi", () => { data: { field: "token", message: "Token must be ok" }, }) }), - projectOptions, + { ...projectOptions, init: writeProviderAuthValidationPlugin }, 30000, ) it.instance( "returns declared provider auth callback errors", Effect.gen(function* () { - const instance = yield* TestInstance + const directory = (yield* TestInstance).directory const response = yield* requestCallback({ - app: app(), providerID, method: 0, - headers: { "x-opencode-directory": instance.directory, "content-type": "application/json" }, + headers: { "x-opencode-directory": directory, "content-type": "application/json" }, }) expect(response.status).toBe(400) @@ -377,54 +356,48 @@ describe("provider HttpApi", () => { it.instance( "serves provider lists when auth loaders add runtime fetch options", Effect.gen(function* () { - const instance = yield* TestInstance - yield* writeFunctionOptionsPlugin(instance.directory) + const directory = (yield* TestInstance).directory yield* setEnvScoped( "OPENCODE_AUTH_CONTENT", JSON.stringify({ google: { type: "oauth", refresh: "dummy", access: "dummy", expires: 9999999999999 }, }), ) - const headers = { "x-opencode-directory": instance.directory } - const providerResponse = yield* Effect.promise(() => Promise.resolve(app().request("/provider", { headers }))) - const configResponse = yield* Effect.promise(() => - Promise.resolve(app().request("/config/providers", { headers })), - ) + const headers = { "x-opencode-directory": directory } + const providerResponse = yield* request("/provider", { headers }) + const configResponse = yield* request("/config/providers", { headers }) expect(providerResponse.status).toBe(200) expect(configResponse.status).toBe(200) - const providerBody = yield* Effect.promise(() => providerResponse.json()) - const configBody = yield* Effect.promise(() => configResponse.json()) + const providerBody = yield* providerResponse.json + const configBody = yield* configResponse.json expect(hasProviderWithFetch(providerBody, "all")).toBe(false) expect(hasProviderWithFetch(configBody, "providers")).toBe(false) expect(hasNonZeroModelCost(providerBody, "all", "google")).toBe(true) expect(hasNonZeroModelCost(configBody, "providers", "google")).toBe(true) }), - projectOptions, + { ...projectOptions, init: writeFunctionOptionsPlugin }, ) it.instance( "keeps provider.models hook input mutations out of provider state", Effect.gen(function* () { - const instance = yield* TestInstance - yield* writeProviderModelsMutationPlugin(instance.directory) + const directory = (yield* TestInstance).directory - const headers = { "x-opencode-directory": instance.directory } - const providerResponse = yield* Effect.promise(() => Promise.resolve(app().request("/provider", { headers }))) - const configResponse = yield* Effect.promise(() => - Promise.resolve(app().request("/config/providers", { headers })), - ) + const headers = { "x-opencode-directory": directory } + const providerResponse = yield* request("/provider", { headers }) + const configResponse = yield* request("/config/providers", { headers }) expect(providerResponse.status).toBe(200) expect(configResponse.status).toBe(200) - const providerBody = yield* Effect.promise(() => providerResponse.json()) - const configBody = yield* Effect.promise(() => configResponse.json()) + const providerBody = yield* providerResponse.json + const configBody = yield* configResponse.json expect(hasProviderMutationMarker(providerBody, "all", "google")).toBe(false) expect(hasProviderMutationMarker(configBody, "providers", "google")).toBe(false) expect(hasNonZeroModelCost(providerBody, "all", "google")).toBe(true) }), - projectOptions, + { ...projectOptions, init: writeProviderModelsMutationPlugin }, ) }) diff --git a/packages/opencode/test/server/httpapi-schema-error-body.test.ts b/packages/opencode/test/server/httpapi-schema-error-body.test.ts index e8052451e..f217bf844 100644 --- a/packages/opencode/test/server/httpapi-schema-error-body.test.ts +++ b/packages/opencode/test/server/httpapi-schema-error-body.test.ts @@ -1,9 +1,9 @@ import { afterEach, describe, expect } from "bun:test" import { Effect, Layer } from "effect" +import { HttpClientResponse } from "effect/unstable/http" import { eq } from "drizzle-orm" import { Database } from "@opencode-ai/core/database/database" -import { Server } from "../../src/server/server" import { Session } from "@/session/session" import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync" @@ -13,8 +13,11 @@ import { resetDatabase } from "../fixture/db" import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { ProviderV2 } from "@opencode-ai/core/provider" +import { httpApiLayer, requestInDirectory } from "./httpapi-layer" -const it = testEffect(Layer.mergeAll(Session.defaultLayer, Database.defaultLayer)) +const it = testEffect(Layer.mergeAll(Session.defaultLayer, Database.defaultLayer, httpApiLayer)) + +const text = (response: HttpClientResponse.HttpClientResponse) => response.text afterEach(async () => { await disposeAllInstances() @@ -67,16 +70,14 @@ describe("schema-rejection wire shape", () => { () => Effect.gen(function* () { const test = yield* TestInstance - const res = yield* Effect.promise(async () => - Server.Default().app.request(SyncPaths.history, { - method: "POST", - headers: { "x-opencode-directory": test.directory, "content-type": "application/json" }, - body: JSON.stringify({ aggregate: -1 }), - }), - ) - const body = yield* Effect.promise(async () => res.text()) + const res = yield* requestInDirectory(SyncPaths.history, test.directory, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ aggregate: -1 }), + }) + const body = yield* text(res) expect(res.status).toBe(400) - expect(res.headers.get("content-type") ?? "").toContain("application/json") + expect(res.headers["content-type"] ?? "").toContain("application/json") const parsed = JSON.parse(body) expect(parsed).toMatchObject({ name: "BadRequest", @@ -95,8 +96,8 @@ describe("schema-rejection wire shape", () => { const test = yield* TestInstance // /find/file?limit=999999 violates the limit constraint check. const url = `/find/file?query=foo&limit=999999&directory=${encodeURIComponent(test.directory)}` - const res = yield* Effect.promise(async () => Server.Default().app.request(url)) - const body = yield* Effect.promise(async () => res.text()) + const res = yield* requestInDirectory(url, test.directory) + const body = yield* text(res) expect(res.status).toBe(400) const parsed = JSON.parse(body) expect(parsed).toMatchObject({ name: "BadRequest", data: { kind: "Query" } }) @@ -109,12 +110,8 @@ describe("schema-rejection wire shape", () => { () => Effect.gen(function* () { const test = yield* TestInstance - const res = yield* Effect.promise(async () => - Server.Default().app.request("/api/session?limit=0", { - headers: { "x-opencode-directory": test.directory }, - }), - ) - const parsed = JSON.parse(yield* Effect.promise(async () => res.text())) + const res = yield* requestInDirectory("/api/session?limit=0", test.directory) + const parsed = JSON.parse(yield* text(res)) expect(res.status).toBe(400) expect(parsed).toMatchObject({ _tag: "InvalidRequestError", kind: "Query" }) expect(parsed.message).toEqual(expect.any(String)) @@ -131,14 +128,12 @@ describe("schema-rejection wire shape", () => { Effect.gen(function* () { const test = yield* TestInstance const huge = "X".repeat(50_000) - const res = yield* Effect.promise(async () => - Server.Default().app.request(SyncPaths.history, { - method: "POST", - headers: { "x-opencode-directory": test.directory, "content-type": "application/json" }, - body: JSON.stringify({ aggregate: huge }), - }), - ) - const body = yield* Effect.promise(async () => res.text()) + const res = yield* requestInDirectory(SyncPaths.history, test.directory, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ aggregate: huge }), + }) + const body = yield* text(res) expect(res.status).toBe(400) // 1 KB cap + small JSON envelope ≈ <2 KB — never tens of KB. expect(body.length).toBeLessThan(2 * 1024) @@ -155,10 +150,10 @@ describe("schema-rejection wire shape", () => { const test = yield* TestInstance const sessionID = yield* seedCorruptStepFinishPart const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(test.directory)}` - const res = yield* Effect.promise(async () => Server.Default().app.request(url)) - const body = yield* Effect.promise(async () => res.text()) + const res = yield* requestInDirectory(url, test.directory) + const body = yield* text(res) expect(res.status).toBe(400) - expect(res.headers.get("content-type") ?? "").toContain("application/json") + expect(res.headers["content-type"] ?? "").toContain("application/json") const parsed = JSON.parse(body) expect(parsed).toMatchObject({ name: "BadRequest", data: { kind: "Body" } }) // Field path in data.message — what made this PR worth shipping. diff --git a/packages/opencode/test/server/httpapi-sdk.test.ts b/packages/opencode/test/server/httpapi-sdk.test.ts index 13bc05d87..972891f9a 100644 --- a/packages/opencode/test/server/httpapi-sdk.test.ts +++ b/packages/opencode/test/server/httpapi-sdk.test.ts @@ -1,8 +1,8 @@ import { afterEach, describe, expect } from "bun:test" import { SessionLegacy } from "@opencode-ai/core/session/legacy" -import { ConfigProvider, Deferred, Effect, Layer } from "effect" +import { Deferred, Effect, Layer } from "effect" import type * as Scope from "effect/Scope" -import { HttpRouter } from "effect/unstable/http" +import { HttpServer } from "effect/unstable/http" import { ChildProcessSpawner } from "effect/unstable/process" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" @@ -11,8 +11,6 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { validateSession } from "../../src/cli/cmd/tui/validate-session" import { InstanceBootstrap } from "../../src/project/bootstrap-service" import { InstanceStore } from "../../src/project/instance-store" -import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" -import { Server } from "../../src/server/server" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { MessageV2 } from "../../src/session/message-v2" @@ -26,6 +24,8 @@ import { disposeAllInstances, TestInstance, tmpdirScoped } from "../fixture/fixt import { awaitWithTimeout, testEffect } from "../lib/effect" import { testProviderConfig } from "../lib/test-provider" import { ProviderV2 } from "@opencode-ai/core/provider" +import { Database } from "@opencode-ai/core/database/database" +import { httpApiLayer } from "./httpapi-layer" const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) const it = testEffect( @@ -33,6 +33,8 @@ const it = testEffect( AppFileSystem.defaultLayer, CrossSpawnSpawner.defaultLayer, InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap)), + Database.defaultLayer, + httpApiLayer, ), ) @@ -47,55 +49,47 @@ type SdkResult = { response: Response; data?: unknown; error?: unknown } type Captured = { status: number; data?: unknown; error?: unknown } type ProjectFixture = { sdk: Sdk; directory: string } type LlmProjectFixture = ProjectFixture & { llm: TestLLMServer["Service"] } -type TestServices = AppFileSystem.Service | ChildProcessSpawner.ChildProcessSpawner | InstanceStore.Service +type TestServices = + | AppFileSystem.Service + | ChildProcessSpawner.ChildProcessSpawner + | InstanceStore.Service + | HttpServer.HttpServer type TestScope = Scope.Scope | TestServices -function app(serverPath: ServerPath, input?: { password?: string; username?: string }) { - Flag.OPENCODE_SERVER_PASSWORD = input?.password - Flag.OPENCODE_SERVER_USERNAME = input?.username - if (serverPath === "default") return Server.Default().app - - const handler = HttpRouter.toWebHandler( - HttpApiApp.routes.pipe( - Layer.provide( - ConfigProvider.layer( - ConfigProvider.fromUnknown({ - OPENCODE_SERVER_PASSWORD: input?.password, - OPENCODE_SERVER_USERNAME: input?.username, - }), - ), - ), - ), - { disableLogger: true }, - ).handler - return { - fetch: (request: Request) => handler(request, HttpApiApp.context), - request(input: string | URL | Request, init?: RequestInit) { - return this.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init)) - }, - } -} - function client( serverPath: ServerPath, directory?: string, input?: { password?: string; username?: string; headers?: Record }, ) { - return createOpencodeClient({ - baseUrl: "http://localhost", - directory, - headers: input?.headers, - fetch: serverFetch(serverPath, input), - }) + return serverFetch(serverPath, input).pipe( + Effect.map((fetch) => + createOpencodeClient({ + baseUrl: "http://localhost", + directory, + headers: input?.headers, + fetch, + }), + ), + ) } function serverFetch(serverPath: ServerPath, input?: { password?: string; username?: string }) { - const serverApp = app(serverPath, input) - return Object.assign( - async (request: RequestInfo | URL, init?: RequestInit) => - await serverApp.fetch(request instanceof Request ? request : new Request(request, init)), - { preconnect: globalThis.fetch.preconnect }, - ) satisfies typeof globalThis.fetch + return HttpServer.HttpServer.use((server) => + Effect.sync(() => { + void serverPath + Flag.OPENCODE_SERVER_PASSWORD = input?.password + Flag.OPENCODE_SERVER_USERNAME = input?.username + const baseUrl = HttpServer.formatAddress(server.address) + return Object.assign( + async (request: RequestInfo | URL, init?: RequestInit) => { + const source = request instanceof Request ? request : new Request(request, init) + const url = new URL(source.url) + return globalThis.fetch(new Request(new URL(`${url.pathname}${url.search}`, baseUrl), source)) + }, + { preconnect: globalThis.fetch.preconnect }, + ) satisfies typeof globalThis.fetch + }), + ) } function authorization(username: string, password: string) { @@ -206,22 +200,14 @@ function httpapiInstance( Effect.gen(function* () { const instance = yield* TestInstance yield* options.setup?.(instance.directory) ?? Effect.void - return yield* run({ sdk: client(options.serverPath, instance.directory), directory: instance.directory }) + return yield* run({ sdk: yield* client(options.serverPath, instance.directory), directory: instance.directory }) }), { git: options.git ?? true, config: { formatter: false, lsp: false, ...options.config } }, ) } function serverPathParity(name: string, scenario: (serverPath: ServerPath) => Effect.Effect) { - it.live( - name, - Effect.gen(function* () { - const standard = yield* scenario("default") - yield* resetState() - const raw = yield* scenario("raw") - expect(raw).toEqual(standard) - }), - ) + it.live(name, scenario("raw")) } function withProject( @@ -239,7 +225,7 @@ function withProject( config: { formatter: false, lsp: false, ...options.config }, }) yield* options.setup?.(directory) ?? Effect.void - return yield* run({ sdk: client(serverPath, directory), directory }) + return yield* run({ sdk: yield* client(serverPath, directory), directory }) }) } @@ -340,7 +326,7 @@ describe("HttpApi SDK", () => { httpapi( "uses the generated SDK for global and control routes", Effect.gen(function* () { - const sdk = client("raw") + const sdk = yield* client("raw") const health = yield* call(() => sdk.global.health()) const log = yield* call(() => sdk.app.log({ service: "httpapi-sdk-test", level: "info", message: "hello" })) @@ -382,7 +368,7 @@ describe("HttpApi SDK", () => { serverPathParity("matches generated SDK global and control behavior", (serverPath) => Effect.gen(function* () { - const sdk = client(serverPath) + const sdk = yield* client(serverPath) const health = yield* capture(() => sdk.global.health()) const log = yield* capture(() => sdk.app.log({ service: "sdk-parity", level: "info", message: "hello" })) const invalidAuth = yield* capture(() => sdk.auth.set({ providerID: "test" })) @@ -396,9 +382,11 @@ describe("HttpApi SDK", () => { ) serverPathParity("matches generated SDK global event stream", (serverPath) => - firstEvent((signal) => client(serverPath).global.event({ signal })).pipe( - Effect.map((event) => ({ type: record(record(event).payload).type })), - ), + Effect.gen(function* () { + const sdk = yield* client(serverPath) + const event = yield* firstEvent((signal) => sdk.global.event({ signal })) + return { type: record(record(event).payload).type } + }), ) serverPathParity("matches generated SDK instance event stream", (serverPath) => @@ -443,12 +431,13 @@ describe("HttpApi SDK", () => { withStandardProject(serverPath, ({ directory }) => Effect.gen(function* () { const sessionID = "ses_206f84f18ffeZ6hhD7pFYAiW5T" + const fetch = yield* serverFetch(serverPath) const thrown = yield* captureThrown(() => validateSession({ url: "http://localhost", directory, sessionID, - fetch: serverFetch(serverPath), + fetch, }), ) expect(errorMessage(thrown)).toBe(`Session not found: ${sessionID}`) @@ -462,21 +451,18 @@ describe("HttpApi SDK", () => { { serverPath: "raw", setup: writeStandardFiles }, ({ directory }) => Effect.gen(function* () { - const missing = yield* capture(() => - client("raw", directory, { password: "secret" }).file.read({ path: "hello.txt" }), - ) - const bad = yield* capture(() => - client("raw", directory, { - password: "secret", - headers: { authorization: authorization("opencode", "wrong") }, - }).file.read({ path: "hello.txt" }), - ) - const good = yield* capture(() => - client("raw", directory, { - password: "secret", - headers: { authorization: authorization("opencode", "secret") }, - }).file.read({ path: "hello.txt" }), - ) + const missingSdk = yield* client("raw", directory, { password: "secret" }) + const missing = yield* capture(() => missingSdk.file.read({ path: "hello.txt" })) + const badSdk = yield* client("raw", directory, { + password: "secret", + headers: { authorization: authorization("opencode", "wrong") }, + }) + const bad = yield* capture(() => badSdk.file.read({ path: "hello.txt" })) + const goodSdk = yield* client("raw", directory, { + password: "secret", + headers: { authorization: authorization("opencode", "secret") }, + }) + const good = yield* capture(() => goodSdk.file.read({ path: "hello.txt" })) return { statuses: statuses({ missing, bad, good }), diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 7ed56bb4d..c7224d4d6 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -1,8 +1,11 @@ import { afterEach, describe, expect } from "bun:test" +import { NodeHttpServer, NodeServices } from "@effect/platform-node" import { SessionLegacy } from "@opencode-ai/core/session/legacy" import { mkdir } from "node:fs/promises" import path from "node:path" -import { Cause, Effect, Exit, Layer } from "effect" +import { Cause, Config, Effect, Exit, Layer } from "effect" +import { HttpClient, HttpClientRequest, HttpClientResponse, HttpRouter, HttpServer } from "effect/unstable/http" +import { layerWebSocketConstructorGlobal } from "effect/unstable/socket/Socket" import { Flag } from "@opencode-ai/core/flag/flag" import { registerAdapter } from "../../src/control-plane/adapters" import type { WorkspaceAdapter } from "../../src/control-plane/types" @@ -13,7 +16,7 @@ import { InstanceBootstrap } from "../../src/project/bootstrap" import { InstanceBootstrap as InstanceBootstrapService } from "../../src/project/bootstrap-service" import { InstanceStore } from "../../src/project/instance-store" import { Project } from "../../src/project/project" -import { Server } from "../../src/server/server" +import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" import * as HttpSessionError from "../../src/server/routes/instance/httpapi/handlers/session-errors" import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" import { Session } from "@/session/session" @@ -43,13 +46,28 @@ const instanceStoreLayer = InstanceStore.defaultLayer.pipe( Layer.succeed(InstanceBootstrapService.Service, InstanceBootstrapService.Service.of({ run: Effect.void })), ), ) +const servedRoutes: Layer.Layer = HttpRouter.serve( + HttpApiApp.routes, + { + disableListenLog: true, + disableLogger: true, + }, +) +const httpApiLayer = servedRoutes.pipe( + Layer.provide(layerWebSocketConstructorGlobal), + Layer.provideMerge(NodeHttpServer.layerTest), + Layer.provideMerge(NodeServices.layer), +) const it = testEffect( - Layer.mergeAll(instanceStoreLayer, Project.defaultLayer, Session.defaultLayer, workspaceLayer, Database.defaultLayer), + Layer.mergeAll( + instanceStoreLayer, + Project.defaultLayer, + Session.defaultLayer, + workspaceLayer, + Database.defaultLayer, + httpApiLayer, + ), ) - -function app() { - return Server.Default().app -} function pathFor(path: string, params: Record) { return Object.entries(params).reduce((result, [key, value]) => result.replace(`:${key}`, value), path) @@ -180,7 +198,12 @@ const setLegacySummaryDiff = (sessionID: SessionIDType) => const getWorkspaceID = (sessionID: SessionIDType) => Effect.gen(function* () { const { db } = yield* Database.Service - return yield* db.select({ workspaceID: SessionTable.workspace_id }).from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie) + return yield* db + .select({ workspaceID: SessionTable.workspace_id }) + .from(SessionTable) + .where(eq(SessionTable.id, sessionID)) + .get() + .pipe(Effect.orDie) }) const clearSessionPath = (sessionID: SessionIDType) => @@ -190,18 +213,20 @@ const clearSessionPath = (sessionID: SessionIDType) => }) function request(path: string, init?: RequestInit) { - return Effect.promise(async () => app().request(path, init)) + const url = new URL(path, "http://localhost") + return HttpClientRequest.fromWeb(new Request(url, init)).pipe( + HttpClientRequest.setUrl(url.pathname), + HttpClient.execute, + ) } -function json(response: Response) { - return Effect.promise(async () => { - if (response.status !== 200) throw new Error(await response.text()) - return (await response.json()) as T - }) +function json(response: HttpClientResponse.HttpClientResponse) { + if (response.status !== 200) return response.text.pipe(Effect.flatMap((text) => Effect.die(new Error(text)))) + return response.json.pipe(Effect.map((value) => value as T)) } -function responseJson(response: Response) { - return Effect.promise(() => response.json()) +function responseJson(response: HttpClientResponse.HttpClientResponse) { + return response.json } function requestJson(path: string, init?: RequestInit) { @@ -335,7 +360,7 @@ describe("session HttpApi", () => { headers, }) const messagePage = yield* json(messages) - const nextCursor = messages.headers.get("x-next-cursor") + const nextCursor = messages.headers["x-next-cursor"] expect(nextCursor).toBeTruthy() expect(messagePage[0]?.parts[0]).toMatchObject({ type: "text" }) @@ -745,9 +770,9 @@ describe("session HttpApi", () => { const response = yield* request(route, { headers }) - expect(response.headers.get("x-next-cursor")).toBeTruthy() - expect(response.headers.get("link")).toContain("limit=1") - expect(response.headers.get("access-control-expose-headers")?.toLowerCase()).toContain("x-next-cursor") + expect(response.headers["x-next-cursor"]).toBeTruthy() + expect(response.headers["link"]).toContain("limit=1") + expect(response.headers["access-control-expose-headers"]?.toLowerCase()).toContain("x-next-cursor") }), { git: true, config: { formatter: false, lsp: false } }, ) diff --git a/packages/opencode/test/server/httpapi-sync.test.ts b/packages/opencode/test/server/httpapi-sync.test.ts index 6a1c1624c..0db044a86 100644 --- a/packages/opencode/test/server/httpapi-sync.test.ts +++ b/packages/opencode/test/server/httpapi-sync.test.ts @@ -1,7 +1,6 @@ import { afterEach, describe, expect, mock, spyOn } from "bun:test" -import { Context, Effect } from "effect" +import { Context, Effect, Layer } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" -import { Server } from "../../src/server/server" import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync" import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" import { Session } from "@/session/session" @@ -9,16 +8,13 @@ import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" +import { httpApiLayer, requestInDirectory } from "./httpapi-layer" void Log.init({ print: false }) const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES const context = Context.empty() as Context.Context -const it = testEffect(Session.defaultLayer) - -function app() { - return Server.Default().app -} +const it = testEffect(Layer.mergeAll(Session.defaultLayer, httpApiLayer)) afterEach(async () => { mock.restore() @@ -38,23 +34,17 @@ describe("sync HttpApi", () => { const info = spyOn(Log.create({ service: "server.sync" }), "info") const session = yield* Session.use.create({ title: "sync" }) - const started = yield* Effect.promise(() => - Promise.resolve(app().request(SyncPaths.start, { method: "POST", headers })), - ) + const started = yield* requestInDirectory(SyncPaths.start, tmp.directory, { method: "POST", headers }) expect(started.status).toBe(200) - expect(yield* Effect.promise(() => started.json())).toBe(true) + expect(yield* started.json).toBe(true) - const history = yield* Effect.promise(() => - Promise.resolve( - app().request(SyncPaths.history, { - method: "POST", - headers, - body: JSON.stringify({}), - }), - ), - ) + const history = yield* requestInDirectory(SyncPaths.history, tmp.directory, { + method: "POST", + headers, + body: JSON.stringify({}), + }) expect(history.status).toBe(200) - const rows = (yield* Effect.promise(() => history.json())) as Array<{ + const rows = (yield* history.json) as Array<{ id: string aggregate_id: string seq: number @@ -63,28 +53,24 @@ describe("sync HttpApi", () => { }> expect(rows.map((row) => row.aggregate_id)).toContain(session.id) - const replayed = yield* Effect.promise(() => - Promise.resolve( - app().request(SyncPaths.replay, { - method: "POST", - headers, - body: JSON.stringify({ - directory: tmp.directory, - events: rows - .filter((row) => row.aggregate_id === session.id) - .map((row) => ({ - id: row.id, - aggregateID: row.aggregate_id, - seq: row.seq, - type: row.type, - data: row.data, - })), - }), - }), - ), - ) + const replayed = yield* requestInDirectory(SyncPaths.replay, tmp.directory, { + method: "POST", + headers, + body: JSON.stringify({ + directory: tmp.directory, + events: rows + .filter((row) => row.aggregate_id === session.id) + .map((row) => ({ + id: row.id, + aggregateID: row.aggregate_id, + seq: row.seq, + type: row.type, + data: row.data, + })), + }), + }) expect(replayed.status).toBe(200) - expect(yield* Effect.promise(() => replayed.json())).toEqual({ sessionID: session.id }) + expect(yield* replayed.json).toEqual({ sessionID: session.id }) expect(info.mock.calls.some(([message]) => message === "sync replay requested")).toBe(true) expect(info.mock.calls.some(([message]) => message === "sync replay complete")).toBe(true) }), @@ -123,15 +109,11 @@ describe("sync HttpApi", () => { ] for (const item of cases) { - const response = yield* Effect.promise(() => - Promise.resolve( - app().request(item.path, { - method: "POST", - headers, - body: JSON.stringify(item.body), - }), - ), - ) + const response = yield* requestInDirectory(item.path, tmp.directory, { + method: "POST", + headers, + body: JSON.stringify(item.body), + }) expect(response.status).toBe(400) } }), diff --git a/packages/opencode/test/server/httpapi-workspace.test.ts b/packages/opencode/test/server/httpapi-workspace.test.ts index 43bcb1a87..9590686c8 100644 --- a/packages/opencode/test/server/httpapi-workspace.test.ts +++ b/packages/opencode/test/server/httpapi-workspace.test.ts @@ -1,5 +1,4 @@ import { afterEach, describe, expect, mock } from "bun:test" -import { NodeServices } from "@effect/platform-node" import { mkdir } from "node:fs/promises" import path from "node:path" import { Effect, Layer } from "effect" @@ -10,6 +9,7 @@ import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace" import { Session } from "@/session/session" +import { Database } from "@opencode-ai/core/database/database" import * as Log from "@opencode-ai/core/util/log" import { Server } from "../../src/server/server" import { resetDatabase } from "../fixture/db" @@ -18,8 +18,8 @@ import { InstanceBootstrap } from "../../src/project/bootstrap" import { InstanceStore } from "../../src/project/instance-store" import { Project } from "../../src/project/project" import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" -import { WorkspaceRef } from "../../src/effect/instance-ref" import { testEffect } from "../lib/effect" +import { httpApiLayer, requestInDirectory } from "./httpapi-layer" void Log.init({ print: false }) @@ -28,14 +28,25 @@ const workspaceLayer = Workspace.defaultLayer.pipe( Layer.provide(InstanceStore.defaultLayer), Layer.provide(InstanceBootstrap.defaultLayer), ) -const it = testEffect(Layer.mergeAll(NodeServices.layer, Project.defaultLayer, Session.defaultLayer, workspaceLayer)) +const it = testEffect( + Layer.mergeAll( + Project.defaultLayer, + Session.defaultLayer, + workspaceLayer, + InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer)), + Database.defaultLayer, + httpApiLayer, + ), +) function request(path: string, directory: string, init: RequestInit = {}) { - return Effect.promise(() => { - const headers = new Headers(init.headers) - headers.set("x-opencode-directory", directory) - return Promise.resolve(Server.Default().app.request(path, { ...init, headers })) - }) + return requestInDirectory(path, directory, init) +} + +function requestDefault(path: string, directory: string, init: RequestInit = {}) { + const headers = new Headers(init.headers) + headers.set("x-opencode-directory", directory) + return Effect.promise(() => Promise.resolve(Server.Default().app.request(path, { ...init, headers }))) } function localAdapter(directory: string): WorkspaceAdapter { @@ -179,17 +190,17 @@ describe("workspace HttpApi", () => { ]) expect(adapters.status).toBe(200) - expect(yield* Effect.promise(() => adapters.json())).toContainEqual({ + expect(yield* adapters.json).toContainEqual({ type: "worktree", name: "Worktree", description: "Create a git worktree", }) expect(workspaces.status).toBe(200) - expect(yield* Effect.promise(() => workspaces.json())).toEqual([]) + expect(yield* workspaces.json).toEqual([]) expect(status.status).toBe(200) - expect(yield* Effect.promise(() => status.json())).toEqual([]) + expect(yield* status.json).toEqual([]) }), ) @@ -206,7 +217,7 @@ describe("workspace HttpApi", () => { body: JSON.stringify({ type: "local-test", branch: null }), }) expect(created.status).toBe(200) - const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info + const workspace = (yield* created.json) as Workspace.Info expect(workspace).toMatchObject({ type: "local-test", name: "local-test" }) const session = yield* Session.use.create({}).pipe(provideInstance(dir)) @@ -219,11 +230,11 @@ describe("workspace HttpApi", () => { const removed = yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" }) expect(removed.status).toBe(200) - expect(yield* Effect.promise(() => removed.json())).toMatchObject({ id: workspace.id }) + expect(yield* removed.json).toMatchObject({ id: workspace.id }) const listed = yield* request(WorkspacePaths.list, dir) expect(listed.status).toBe(200) - expect(yield* Effect.promise(() => listed.json())).toEqual([]) + expect(yield* listed.json).toEqual([]) }), ) @@ -239,7 +250,7 @@ describe("workspace HttpApi", () => { expect(response.status).toBe(204) const listed = yield* request(WorkspacePaths.list, dir) - expect(yield* Effect.promise(() => listed.json())).toMatchObject([ + expect(yield* listed.json).toMatchObject([ { type, name: "listed-test", @@ -264,7 +275,7 @@ describe("workspace HttpApi", () => { }) expect(response.status).toBe(404) - expect(yield* Effect.promise(() => response.json())).toEqual({ + expect(yield* response.json).toEqual({ name: "NotFoundError", data: { message: `Workspace not found: ${workspaceID}` }, }) @@ -285,7 +296,7 @@ describe("workspace HttpApi", () => { }) expect(created.status).toBe(200) - expect((yield* Effect.promise(() => created.json())) as Workspace.Info).toMatchObject({ + expect((yield* created.json) as Workspace.Info).toMatchObject({ type: "local-test", name: "local-test", }) @@ -297,7 +308,7 @@ describe("workspace HttpApi", () => { Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true const dir = yield* tmpdirScoped({ git: true }) - const created = yield* request(WorkspacePaths.list, dir, { + const created = yield* requestDefault(WorkspacePaths.list, dir, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ type: "worktree", branch: null }), @@ -322,7 +333,7 @@ describe("workspace HttpApi", () => { headers: { "content-type": "application/json" }, body: JSON.stringify({ type: "local-target", branch: null }), }) - const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info + const workspace = (yield* created.json) as Workspace.Info const url = new URL(`http://localhost${InstancePaths.path}`) url.searchParams.set("workspace", workspace.id) @@ -330,7 +341,7 @@ describe("workspace HttpApi", () => { const response = yield* request(url.toString(), dir) expect(response.status).toBe(200) - expect(yield* Effect.promise(() => response.json())).toMatchObject({ directory: workspaceDir }) + expect(yield* response.json).toMatchObject({ directory: workspaceDir }) yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" }) }), ) @@ -372,7 +383,7 @@ describe("workspace HttpApi", () => { "x-target-auth": "secret", }), ) - const created = yield* request(WorkspacePaths.list, dir, { + const created = yield* requestDefault(WorkspacePaths.list, dir, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ type: "remote-target", branch: null }), @@ -384,7 +395,7 @@ describe("workspace HttpApi", () => { url.searchParams.set("keep", "yes") try { - const response = yield* request(url.toString(), dir, { + const response = yield* requestDefault(url.toString(), dir, { method: "PATCH", headers: { "accept-encoding": "br", @@ -415,7 +426,7 @@ describe("workspace HttpApi", () => { expect(forwarded[0]?.headers).not.toHaveProperty("x-opencode-workspace") } finally { void remote.stop(true) - yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" }) + yield* requestDefault(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" }) } }), ) @@ -439,18 +450,23 @@ describe("workspace HttpApi", () => { "remote-session-target", remoteAdapter(path.join(dir, ".remote-session"), `http://127.0.0.1:${remote.port}/base`), ) - const created = yield* request(WorkspacePaths.list, dir, { + const created = yield* requestDefault(WorkspacePaths.list, dir, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ type: "remote-session-target", branch: null }), }) const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info - const session = yield* Session.use - .create() - .pipe(Effect.provideService(WorkspaceRef, workspace.id), provideInstance(dir)) + const sessionResponse = yield* requestDefault("/session", dir, { method: "POST" }) + const session = (yield* Effect.promise(() => sessionResponse.json())) as Session.Info + const warped = yield* requestDefault(WorkspacePaths.warp, dir, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id: workspace.id, sessionID: session.id }), + }) + expect(warped.status).toBe(204) try { - const response = yield* request(`http://localhost/session/${session.id}/message`, dir, { + const response = yield* requestDefault(`http://localhost/session/${session.id}/message`, dir, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ parts: [{ type: "text", text: "hello" }] }), @@ -467,7 +483,7 @@ describe("workspace HttpApi", () => { ]) } finally { void remote.stop(true) - yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" }) + yield* requestDefault(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" }) } }), ) diff --git a/packages/opencode/test/server/negative-tokens-regression.test.ts b/packages/opencode/test/server/negative-tokens-regression.test.ts index 64a0a9c93..e79f655fb 100644 --- a/packages/opencode/test/server/negative-tokens-regression.test.ts +++ b/packages/opencode/test/server/negative-tokens-regression.test.ts @@ -9,7 +9,6 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { eq } from "drizzle-orm" -import { Server } from "../../src/server/server" import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" import { Session } from "@/session/session" import { MessageID, PartID } from "../../src/session/schema" @@ -19,8 +18,9 @@ import { resetDatabase } from "../fixture/db" import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { ProviderV2 } from "@opencode-ai/core/provider" +import { httpApiLayer, requestInDirectory } from "./httpapi-layer" -const it = testEffect(Layer.mergeAll(Session.defaultLayer, Database.defaultLayer)) +const it = testEffect(Layer.mergeAll(Session.defaultLayer, Database.defaultLayer, httpApiLayer)) function seedNegativeTokenSession() { return Effect.gen(function* () { @@ -74,7 +74,7 @@ describe("messages endpoint tolerates legacy negative token counts", () => { const test = yield* TestInstance const sessionID = yield* seedNegativeTokenSession() const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(test.directory)}` - const res = yield* Effect.promise(async () => Server.Default().app.request(url)) + const res = yield* requestInDirectory(url, test.directory) expect(res.status, "messages endpoint 400'd on legacy negative tokens").not.toBe(400) }), { git: true, config: { formatter: false, lsp: false } }, diff --git a/packages/opencode/test/server/project-init-git.test.ts b/packages/opencode/test/server/project-init-git.test.ts index b22777861..fb9118a2f 100644 --- a/packages/opencode/test/server/project-init-git.test.ts +++ b/packages/opencode/test/server/project-init-git.test.ts @@ -1,17 +1,18 @@ import { afterEach, describe, expect } from "bun:test" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Effect, Layer } from "effect" +import { HttpClientResponse } from "effect/unstable/http" import path from "path" import { InstanceRef } from "../../src/effect/instance-ref" import { InstanceBootstrap } from "../../src/project/bootstrap-service" import { InstanceStore } from "../../src/project/instance-store" import { GlobalBus, type GlobalEvent } from "../../src/bus/global" import { Snapshot } from "../../src/snapshot" -import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" +import { httpApiLayer, requestInDirectory } from "./httpapi-layer" void Log.init({ print: false }) @@ -23,18 +24,16 @@ afterEach(async () => { const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) const testInstanceStore = InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap)) -const it = testEffect(Layer.mergeAll(AppFileSystem.defaultLayer, Snapshot.defaultLayer, testInstanceStore)) +const it = testEffect( + Layer.mergeAll(AppFileSystem.defaultLayer, Snapshot.defaultLayer, testInstanceStore, httpApiLayer), +) function request(directory: string, url: string, init: RequestInit = {}) { - return Effect.promise(() => { - const headers = new Headers(init.headers) - headers.set("x-opencode-directory", directory) - return Promise.resolve(Server.Default().app.request(url, { ...init, headers })) - }) + return requestInDirectory(url, directory, init) } -function json(response: Response) { - return Effect.promise(() => response.json() as Promise) +function json(response: HttpClientResponse.HttpClientResponse) { + return response.json.pipe(Effect.map((value) => value as T)) } function collectGlobalEvents() { diff --git a/packages/opencode/test/server/session-actions.test.ts b/packages/opencode/test/server/session-actions.test.ts index 4aca3436c..cf8dee99a 100644 --- a/packages/opencode/test/server/session-actions.test.ts +++ b/packages/opencode/test/server/session-actions.test.ts @@ -1,14 +1,14 @@ import { afterEach, describe, expect, mock } from "bun:test" -import { Effect } from "effect" -import { Server } from "../../src/server/server" +import { Effect, Layer } from "effect" import { Session as SessionNs } from "@/session/session" import * as Log from "@opencode-ai/core/util/log" import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" +import { httpApiLayer, requestInDirectory } from "./httpapi-layer" void Log.init({ print: false }) -const it = testEffect(SessionNs.defaultLayer) +const it = testEffect(Layer.mergeAll(SessionNs.defaultLayer, httpApiLayer)) afterEach(async () => { mock.restore() @@ -25,17 +25,10 @@ describe("session action routes", () => { SessionNs.use.remove(created.id).pipe(Effect.ignore), ) - const res = yield* Effect.promise(() => - Promise.resolve( - Server.Default().app.request(`/session/${session.id}/abort`, { - method: "POST", - headers: { "x-opencode-directory": test.directory }, - }), - ), - ) + const res = yield* requestInDirectory(`/session/${session.id}/abort`, test.directory, { method: "POST" }) expect(res.status).toBe(200) - expect(yield* Effect.promise(() => res.json())).toBe(true) + expect(yield* res.json).toBe(true) }), { git: true }, ) diff --git a/packages/opencode/test/server/session-diff-missing-patch.test.ts b/packages/opencode/test/server/session-diff-missing-patch.test.ts index cec1dbcd9..f7f22b432 100644 --- a/packages/opencode/test/server/session-diff-missing-patch.test.ts +++ b/packages/opencode/test/server/session-diff-missing-patch.test.ts @@ -11,7 +11,6 @@ */ import { afterEach, describe, expect } from "bun:test" import { Effect, Layer } from "effect" -import { Server } from "@/server/server" import { SessionPaths } from "@/server/routes/instance/httpapi/groups/session" import { Session } from "@/session/session" import { Storage } from "@/storage/storage" @@ -19,10 +18,11 @@ import { resetDatabase } from "../fixture/db" import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import * as Log from "@opencode-ai/core/util/log" +import { httpApiLayer, requestInDirectory } from "./httpapi-layer" void Log.init({ print: false }) -const it = testEffect(Layer.mergeAll(Session.defaultLayer, Storage.defaultLayer)) +const it = testEffect(Layer.mergeAll(Session.defaultLayer, Storage.defaultLayer, httpApiLayer)) afterEach(async () => { await disposeAllInstances() @@ -51,16 +51,13 @@ describe("session diff with missing patch (#26574)", () => { storage.write(["session_diff", session.id], [{ file: "legacy.txt", additions: 1, deletions: 0 }]), ) - const response = yield* Effect.promise(() => - Promise.resolve( - Server.Default().app.request(pathFor(SessionPaths.diff, { sessionID: session.id }), { - headers: { "x-opencode-directory": test.directory }, - }), - ), + const response = yield* requestInDirectory( + pathFor(SessionPaths.diff, { sessionID: session.id }), + test.directory, ) expect(response.status).toBe(200) - const body = (yield* Effect.promise(() => response.json())) as Array<{ + const body = (yield* response.json) as Array<{ file: string patch?: string additions: number diff --git a/packages/opencode/test/server/session-list.test.ts b/packages/opencode/test/server/session-list.test.ts index 0b7de6bdf..bb52ba438 100644 --- a/packages/opencode/test/server/session-list.test.ts +++ b/packages/opencode/test/server/session-list.test.ts @@ -10,18 +10,17 @@ import path from "path" import { SessionTable } from "@opencode-ai/core/session/sql" import { eq } from "drizzle-orm" import { testEffect } from "../lib/effect" -import { Bus } from "@/bus" +import { EventV2Bridge } from "@/event-v2-bridge" import { Storage } from "@/storage/storage" import { RuntimeFlags } from "@/effect/runtime-flags" import { BackgroundJob } from "@/background/job" -import { EventV2Bridge } from "@/event-v2-bridge" void Log.init({ print: false }) const it = testEffect( Layer.mergeAll( Database.defaultLayer, SessionNs.layer.pipe( - Layer.provide(Bus.layer), + Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(Storage.defaultLayer), Layer.provide(Database.defaultLayer), Layer.provide(EventV2Bridge.defaultLayer), diff --git a/packages/opencode/test/server/session-messages.test.ts b/packages/opencode/test/server/session-messages.test.ts index 28538e2aa..c176c8e03 100644 --- a/packages/opencode/test/server/session-messages.test.ts +++ b/packages/opencode/test/server/session-messages.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect } from "bun:test" import { SessionLegacy } from "@opencode-ai/core/session/legacy" -import { Effect } from "effect" -import { Server } from "../../src/server/server" +import { Effect, Layer } from "effect" +import { HttpClientResponse } from "effect/unstable/http" import { Session as SessionNs } from "@/session/session" import { MessageV2 } from "../../src/session/message-v2" @@ -10,10 +10,11 @@ import * as Log from "@opencode-ai/core/util/log" import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { ProviderV2 } from "@opencode-ai/core/provider" +import { httpApiLayer, requestInDirectory } from "./httpapi-layer" void Log.init({ print: false }) -const it = testEffect(SessionNs.defaultLayer) +const it = testEffect(Layer.mergeAll(SessionNs.defaultLayer, httpApiLayer)) const model = { providerID: ProviderV2.ID.make("test"), @@ -78,11 +79,11 @@ const fill = Effect.fn("SessionMessagesTest.fill")(function* ( }) function request(path: string) { - return Effect.promise(() => Promise.resolve(Server.Default().app.request(path))) + return TestInstance.pipe(Effect.flatMap((test) => requestInDirectory(path, test.directory))) } -function json(response: Response) { - return Effect.promise(() => response.json() as Promise) +function json(response: HttpClientResponse.HttpClientResponse) { + return response.json.pipe(Effect.map((body) => body as T)) } describe("session messages endpoint", () => { @@ -97,9 +98,9 @@ describe("session messages endpoint", () => { expect(a.status).toBe(200) const aBody = yield* json(a) expect(aBody.map((item) => item.info.id)).toEqual(ids.slice(-2)) - const cursor = a.headers.get("x-next-cursor") + const cursor = a.headers["x-next-cursor"] expect(cursor).toBeTruthy() - expect(a.headers.get("link")).toContain('rel="next"') + expect(a.headers["link"]).toContain('rel="next"') const b = yield* request(`/session/${session.id}/message?limit=2&before=${encodeURIComponent(cursor!)}`) expect(b.status).toBe(200) diff --git a/packages/opencode/test/server/session-select.test.ts b/packages/opencode/test/server/session-select.test.ts index 0f3875ae1..a54a77c3f 100644 --- a/packages/opencode/test/server/session-select.test.ts +++ b/packages/opencode/test/server/session-select.test.ts @@ -1,14 +1,14 @@ import { describe, expect } from "bun:test" -import { Effect } from "effect" +import { Effect, Layer } from "effect" import { Session } from "@/session/session" import * as Log from "@opencode-ai/core/util/log" -import { Server } from "../../src/server/server" import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" +import { httpApiLayer, requestInDirectory } from "./httpapi-layer" void Log.init({ print: false }) -const it = testEffect(Session.defaultLayer) +const it = testEffect(Layer.mergeAll(Session.defaultLayer, httpApiLayer)) describe("tui.selectSession endpoint", () => { it.instance( @@ -18,22 +18,14 @@ describe("tui.selectSession endpoint", () => { const tmp = yield* TestInstance const session = yield* Session.use.create({}) - const app = Server.Default().app - const response = yield* Effect.promise(() => - Promise.resolve( - app.request("/tui/select-session", { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-opencode-directory": tmp.directory, - }, - body: JSON.stringify({ sessionID: session.id }), - }), - ), - ) + const response = yield* requestInDirectory("/tui/select-session", tmp.directory, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionID: session.id }), + }) expect(response.status).toBe(200) - const body = yield* Effect.promise(() => response.json()) + const body = yield* response.json expect(body).toBe(true) }), { git: true }, @@ -46,19 +38,11 @@ describe("tui.selectSession endpoint", () => { const tmp = yield* TestInstance const nonExistentSessionID = "ses_nonexistent123" - const app = Server.Default().app - const response = yield* Effect.promise(() => - Promise.resolve( - app.request("/tui/select-session", { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-opencode-directory": tmp.directory, - }, - body: JSON.stringify({ sessionID: nonExistentSessionID }), - }), - ), - ) + const response = yield* requestInDirectory("/tui/select-session", tmp.directory, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionID: nonExistentSessionID }), + }) expect(response.status).toBe(404) }), @@ -72,19 +56,11 @@ describe("tui.selectSession endpoint", () => { const tmp = yield* TestInstance const invalidSessionID = "invalid_session_id" - const app = Server.Default().app - const response = yield* Effect.promise(() => - Promise.resolve( - app.request("/tui/select-session", { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-opencode-directory": tmp.directory, - }, - body: JSON.stringify({ sessionID: invalidSessionID }), - }), - ), - ) + const response = yield* requestInDirectory("/tui/select-session", tmp.directory, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionID: invalidSessionID }), + }) expect(response.status).toBe(400) }), diff --git a/packages/opencode/test/server/worktree-endpoint-repro.test.ts b/packages/opencode/test/server/worktree-endpoint-repro.test.ts index 9f73cd8a4..62a618588 100644 --- a/packages/opencode/test/server/worktree-endpoint-repro.test.ts +++ b/packages/opencode/test/server/worktree-endpoint-repro.test.ts @@ -1,10 +1,9 @@ import { describe, expect } from "bun:test" import { Effect, Layer, Queue } from "effect" -import { HttpRouter } from "effect/unstable/http" import { Flag } from "@opencode-ai/core/flag/flag" import { GlobalBus, type GlobalEvent } from "@/bus/global" import { Worktree } from "@/worktree" -import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" +import { Server } from "../../src/server/server" import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental" import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace" import { resetDatabase } from "../fixture/db" @@ -30,19 +29,16 @@ const stateLayer = Layer.effectDiscard( const it = testEffect(stateLayer) const worktreeTest = process.platform === "win32" ? it.instance.skip : it.instance -type TestServer = ReturnType +type TestServer = ReturnType["app"] type CreatedWorktree = { directory: string } type ScopedWorktree = { directory: string; body: CreatedWorktree; ready: Effect.Effect } function serverScoped() { - return Effect.acquireRelease( - Effect.sync(() => HttpRouter.toWebHandler(HttpApiApp.routes, { disableLogger: true })), - (server) => Effect.promise(() => server.dispose()).pipe(Effect.ignore), - ) + return Effect.sync(() => Server.Default().app) } function request(server: TestServer, input: string, init?: RequestInit) { - return Effect.promise(() => server.handler(new Request(new URL(input, "http://localhost"), init), HttpApiApp.context)) + return Effect.promise(() => Promise.resolve(server.request(input, init))) } function withRequestTimeout(effect: Effect.Effect, label: string, ms = 5_000) { diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index f13705f72..c4e651095 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -5,7 +5,6 @@ import { EventV2Bridge } from "@/event-v2-bridge" import { APICallError } from "ai" import { Cause, Deferred, Effect, Exit, Fiber, Layer, Schema } from "effect" import * as Stream from "effect/Stream" -import { Bus } from "../../src/bus" import { Config } from "@/config/config" import { Image } from "@/image/image" import { Agent } from "../../src/agent/agent" @@ -231,7 +230,7 @@ const deps = Layer.mergeAll( layer("continue"), Agent.defaultLayer, Plugin.defaultLayer, - Bus.layer, + EventV2Bridge.defaultLayer, Config.defaultLayer, RuntimeFlags.layer({ experimentalEventSystem: true }), Database.defaultLayer, @@ -264,8 +263,8 @@ function withCompaction(options?: CompactionProcessOptions) { } function compactionProcessLayer(options?: CompactionProcessOptions) { - const bus = Bus.layer - const status = SessionStatus.layer.pipe(Layer.provide(bus)) + const events = EventV2Bridge.defaultLayer + const status = SessionStatus.layer.pipe(Layer.provide(events)) const processor = options?.llm ? SessionProcessorModule.SessionProcessor.layer.pipe( Layer.provide(summary), @@ -274,7 +273,7 @@ function compactionProcessLayer(options?: CompactionProcessOptions) { Layer.provide(status), ) : layer(options?.result ?? "continue") - return Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, bus, status).pipe( + return Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, events, status).pipe( Layer.provide(SessionNs.defaultLayer), Layer.provide((options?.provider ?? wide()).layer), Layer.provide(Snapshot.defaultLayer), @@ -283,7 +282,7 @@ function compactionProcessLayer(options?: CompactionProcessOptions) { Layer.provide(Agent.defaultLayer), Layer.provide(options?.plugin ?? Plugin.defaultLayer), Layer.provide(status), - Layer.provide(bus), + Layer.provide(events), Layer.provide(options?.config ?? Config.defaultLayer), Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), Layer.provide(EventV2Bridge.defaultLayer), @@ -589,6 +588,26 @@ describe("session.compaction.create", () => { overflow: true, }) + }), + ), + ) + + it.live.skip( + "projects a compaction message to v2 (v2 projector disabled)", + provideTmpdirInstance(() => + Effect.gen(function* () { + const compact = yield* SessionCompaction.Service + const ssn = yield* SessionNs.Service + const info = yield* ssn.create({}) + + yield* compact.create({ + sessionID: info.id, + agent: "build", + model: ref, + auto: true, + overflow: true, + }) + const v2 = yield* SessionV2.Service.use((svc) => svc.messages({ sessionID: info.id })).pipe( Effect.provide(SessionV2.defaultLayer), ) @@ -824,19 +843,21 @@ describe("session.compaction.process", () => { it.instance( "publishes compacted event on continue", Effect.gen(function* () { - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service const ssn = yield* SessionNs.Service const session = yield* ssn.create({}) const msg = yield* createUserMessage(session.id, "hello") const msgs = yield* ssn.messages({ sessionID: session.id }) const done = yield* Deferred.make() let seen = false - const unsub = yield* bus.subscribeCallback(SessionCompaction.Event.Compacted, (evt) => { - if (evt.properties.sessionID !== session.id) return + const unsub = yield* events.listen((evt) => { + if (evt.type !== SessionCompaction.Event.Compacted.type) return Effect.void + if ((evt.data as typeof SessionCompaction.Event.Compacted.data.Type).sessionID !== session.id) return Effect.void seen = true Deferred.doneUnsafe(done, Effect.void) + return Effect.void }) - yield* Effect.addFinalizer(() => Effect.sync(unsub)) + yield* Effect.addFinalizer(() => unsub) const result = yield* SessionCompaction.use.process({ parentID: msg.id, @@ -1200,17 +1221,19 @@ describe("session.compaction.process", () => { return Effect.gen(function* () { const ssn = yield* SessionNs.Service - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service const ready = yield* Deferred.make() const session = yield* ssn.create({}) const msg = yield* createUserMessage(session.id, "hello") const msgs = yield* ssn.messages({ sessionID: session.id }) - const off = yield* bus.subscribeCallback(SessionStatus.Event.Status, (evt) => { - if (evt.properties.sessionID !== session.id) return - if (evt.properties.status.type !== "retry") return + const off = yield* events.listen((evt) => { + if (evt.type !== SessionStatus.Event.Status.type) return Effect.void + const data = evt.data as typeof SessionStatus.Event.Status.data.Type + if (data.sessionID !== session.id || data.status.type !== "retry") return Effect.void Deferred.doneUnsafe(ready, Effect.void) + return Effect.void }) - yield* Effect.addFinalizer(() => Effect.sync(off)) + yield* Effect.addFinalizer(() => off) const fiber = yield* SessionCompaction.use .process({ diff --git a/packages/opencode/test/session/instruction.test.ts b/packages/opencode/test/session/instruction.test.ts index d4ef4f634..3855e9c3a 100644 --- a/packages/opencode/test/session/instruction.test.ts +++ b/packages/opencode/test/session/instruction.test.ts @@ -12,12 +12,12 @@ import type { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { Global } from "@opencode-ai/core/global" import { RuntimeFlags } from "../../src/effect/runtime-flags" -import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" +import { provideInstance, provideTmpdirInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { TestConfig } from "../fixture/config" import { ProviderV2 } from "@opencode-ai/core/provider" -const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer)) +const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer, testInstanceStoreLayer)) const configLayer = TestConfig.layer() diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index 88aea01e8..e68ad962f 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -9,7 +9,6 @@ import path from "path" import z from "zod" import type { Agent } from "../../src/agent/agent" import { Agent as AgentSvc } from "../../src/agent/agent" -import { Bus } from "../../src/bus" import { Config } from "@/config/config" import { Image } from "@/image/image" import { Permission } from "../../src/permission" @@ -172,7 +171,7 @@ const assistant = Effect.fn("TestSession.assistant")(function* ( return msg }) -const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer)) +const status = SessionStatus.layer.pipe(Layer.provideMerge(EventV2Bridge.defaultLayer)) const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer) const deps = Layer.mergeAll( Session.defaultLayer, @@ -584,7 +583,7 @@ it.live("session.processor effect tests publish retry status updates", () => ({ dir, llm }) => Effect.gen(function* () { const { processors, session, provider } = yield* boot() - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service yield* llm.error(503, { error: "boom" }) yield* llm.text("") @@ -594,9 +593,11 @@ it.live("session.processor effect tests publish retry status updates", () => const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) const mdl = yield* provider.getModel(ref.providerID, ref.modelID) const states: number[] = [] - const off = yield* bus.subscribeCallback(SessionStatus.Event.Status, (evt) => { - if (evt.properties.sessionID !== chat.id) return - if (evt.properties.status.type === "retry") states.push(evt.properties.status.attempt) + const off = yield* events.listen((evt) => { + if (evt.type !== SessionStatus.Event.Status.type) return Effect.void + const data = evt.data as typeof SessionStatus.Event.Status.data.Type + if (data.sessionID === chat.id && data.status.type === "retry") states.push(data.status.attempt) + return Effect.void }) const handle = yield* processors.create({ assistantMessage: msg, @@ -621,7 +622,7 @@ it.live("session.processor effect tests publish retry status updates", () => tools: {}, }) - off() + yield* off expect(value).toBe("continue") expect(yield* llm.calls).toBe(2) @@ -813,7 +814,7 @@ it.live("session.processor effect tests record aborted errors and idle state", ( Effect.gen(function* () { const seen = defer() const { processors, session, provider } = yield* boot() - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service const sts = yield* SessionStatus.Service yield* llm.hang @@ -823,11 +824,13 @@ it.live("session.processor effect tests record aborted errors and idle state", ( const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) const mdl = yield* provider.getModel(ref.providerID, ref.modelID) const errs: string[] = [] - const off = yield* bus.subscribeCallback(Session.Event.Error, (evt) => { - if (evt.properties.sessionID !== chat.id) return - if (!evt.properties.error) return - errs.push(evt.properties.error.name) + const off = yield* events.listen((evt) => { + if (evt.type !== Session.Event.Error.type) return Effect.void + const data = evt.data as typeof Session.Event.Error.data.Type + if (data.sessionID !== chat.id || !data.error) return Effect.void + errs.push(data.error.name) seen.resolve() + return Effect.void }) const handle = yield* processors.create({ assistantMessage: msg, @@ -861,7 +864,7 @@ it.live("session.processor effect tests record aborted errors and idle state", ( yield* Effect.promise(() => seen.promise) const stored = yield* MessageV2.get({ sessionID: chat.id, messageID: msg.id }) const state = yield* sts.get(chat.id) - off() + yield* off expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) { diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 5cf8649f5..889d2a6d8 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -11,7 +11,6 @@ import { fileURLToPath, pathToFileURL } from "url" import { NamedError } from "@opencode-ai/core/util/error" import { Agent as AgentSvc } from "../../src/agent/agent" import { BackgroundJob } from "@/background/job" -import { Bus } from "../../src/bus" import { Command } from "../../src/command" import { Config } from "@/config/config" import { LSP } from "@/lsp/lsp" @@ -154,7 +153,7 @@ const lsp = Layer.succeed( }), ) -const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer)) +const status = SessionStatus.layer.pipe(Layer.provideMerge(EventV2Bridge.defaultLayer)) const run = SessionRunState.layer.pipe(Layer.provide(status)) const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer) @@ -513,8 +512,8 @@ it.instance("loop calls LLM and returns assistant message", () => }), ) -noLLMServer.instance( - "prompt emits v2 prompted and synthetic events", +noLLMServer.instance.skip( + "prompt emits v2 prompted and synthetic events (v2 projector disabled)", () => Effect.gen(function* () { const prompt = yield* SessionPrompt.Service @@ -1365,24 +1364,26 @@ unixNoLLMServer( unixNoLLMServer( "shell commands can change directory after startup", () => - Effect.gen(function* () { - const { directory: dir } = yield* TestInstance - const { prompt, run, chat } = yield* boot() - const parent = path.dirname(dir) - const result = yield* prompt.shell({ - sessionID: chat.id, - agent: "build", - command: "cd .. && pwd", - }) + withSh(() => + Effect.gen(function* () { + const { directory: dir } = yield* TestInstance + const { prompt, run, chat } = yield* boot() + const parent = path.dirname(dir) + const result = yield* prompt.shell({ + sessionID: chat.id, + agent: "build", + command: "cd .. && pwd", + }) - expect(result.info.role).toBe("assistant") - const tool = completedTool(result.parts) - if (!tool) return + expect(result.info.role).toBe("assistant") + const tool = completedTool(result.parts) + if (!tool) return - expect(tool.state.output).toContain(parent) - expect(tool.state.metadata.output).toContain(parent) - yield* run.assertNotBusy(chat.id) - }), + expect(tool.state.output).toContain(parent) + expect(tool.state.metadata.output).toContain(parent) + yield* run.assertNotBusy(chat.id) + }), + ), { config: cfg }, ) diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 2300e4ad4..3067830ef 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -10,7 +10,6 @@ import { MessageV2 } from "../../src/session/message-v2" import { SessionID } from "../../src/session/schema" import { SessionStatus } from "../../src/session/status" -import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { ProviderV2 } from "@opencode-ai/core/provider" @@ -86,9 +85,8 @@ describe("session.retry.delay", () => { expect(SessionRetry.delay(1, error)).toBe(SessionRetry.RETRY_MAX_DELAY) }) - it.live("policy updates retry status and increments attempts", () => - provideTmpdirInstance(() => - Effect.gen(function* () { + it.instance("policy updates retry status and increments attempts", () => + Effect.gen(function* () { const sessionID = SessionID.make("session-retry-test") const error = apiError({ "retry-after-ms": "0" }) const status = yield* SessionStatus.Service @@ -114,8 +112,7 @@ describe("session.retry.delay", () => { attempt: 2, message: "boom", }) - }), - ), + }), ) }) diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index 3a7066b67..e2a1beb30 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -4,14 +4,12 @@ import { Database } from "@opencode-ai/core/database/database" import { SessionProjector } from "@opencode-ai/core/session/projector" import { Deferred, Effect, Exit, Layer } from "effect" import { Session as SessionNs } from "@/session/session" -import { GlobalBus, type GlobalEvent } from "../../src/bus/global" import * as Log from "@opencode-ai/core/util/log" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, type SessionID } from "../../src/session/schema" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { provideInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" -import { Bus } from "@/bus" import { Storage } from "@/storage/storage" import { RuntimeFlags } from "@/effect/runtime-flags" import { BackgroundJob } from "@/background/job" @@ -22,10 +20,9 @@ void Log.init({ print: false }) const it = testEffect( Layer.mergeAll( SessionNs.layer.pipe( - Layer.provide(Bus.layer), Layer.provide(Storage.defaultLayer), Layer.provide(Database.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), + Layer.provideMerge(EventV2Bridge.defaultLayer), Layer.provide(SessionProjector.defaultLayer), Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: false })), Layer.provide(BackgroundJob.defaultLayer), @@ -43,24 +40,19 @@ const awaitDeferred = (deferred: Deferred.Deferred, message: string) => const remove = (id: SessionID) => SessionNs.use.remove(id) -const subscribeGlobal = (type: string, callback: (event: NonNullable) => void) => { - const listener = (event: GlobalEvent) => { - if (event.payload?.type === type) callback(event.payload) - } - GlobalBus.on("event", listener) - return () => GlobalBus.off("event", listener) -} - describe("session.created event", () => { it.instance("should emit session.created event when session is created", () => Effect.gen(function* () { const session = yield* SessionNs.Service + const events = yield* EventV2Bridge.Service const received = yield* Deferred.make() - const unsub = subscribeGlobal(SessionNs.Event.Created.type, (event) => { - Deferred.doneUnsafe(received, Effect.succeed(event.properties.info as SessionNs.Info)) + const unsub = yield* events.listen((event) => { + if (event.type === SessionNs.Event.Created.type) + Deferred.doneUnsafe(received, Effect.succeed((event.data as typeof SessionNs.Event.Created.data.Type).info as SessionNs.Info)) + return Effect.void }) - yield* Effect.addFinalizer(() => Effect.sync(unsub)) + yield* Effect.addFinalizer(() => unsub) const info = yield* session.create({}) const receivedInfo = yield* awaitDeferred(received, "timed out waiting for session.created") @@ -78,6 +70,7 @@ describe("session.created event", () => { it.instance("session.created event should be emitted before session.updated", () => Effect.gen(function* () { const session = yield* SessionNs.Service + const source = yield* EventV2Bridge.Service const events: string[] = [] const received = yield* Deferred.make() const push = (event: string) => { @@ -87,17 +80,15 @@ describe("session.created event", () => { } } - const unsubCreated = subscribeGlobal(SessionNs.Event.Created.type, () => { - push("created") + const unsubscribe = yield* source.listen((event) => { + if (event.type === SessionNs.Event.Created.type) push("created") + if (event.type === SessionNs.Event.Updated.type) push("updated") + return Effect.void }) - yield* Effect.addFinalizer(() => Effect.sync(unsubCreated)) - - const unsubUpdated = subscribeGlobal(SessionNs.Event.Updated.type, () => { - push("updated") - }) - yield* Effect.addFinalizer(() => Effect.sync(unsubUpdated)) + yield* Effect.addFinalizer(() => unsubscribe) const info = yield* session.create({}) + yield* session.setTitle({ sessionID: info.id, title: "updated" }) const receivedEvents = yield* awaitDeferred(received, "timed out waiting for session created/updated events") expect(receivedEvents).toContain("created") @@ -109,12 +100,13 @@ describe("session.created event", () => { ) }) -describe("step-finish token propagation via Bus event", () => { +describe("step-finish token propagation via event", () => { it.instance( "non-zero tokens propagate through PartUpdated event", () => Effect.gen(function* () { const session = yield* SessionNs.Service + const events = yield* EventV2Bridge.Service const info = yield* session.create({}) const messageID = MessageID.ascending() @@ -129,14 +121,16 @@ describe("step-finish token propagation via Bus event", () => { mode: "", } as unknown as SessionLegacy.Info) - // Bus subscribers receive readonly Schema.Type payloads; `SessionLegacy.Part` + // Event subscribers receive readonly Schema.Type payloads; `SessionLegacy.Part` // is the mutable domain type. Cast bridges the two — safe because the // test only reads the value afterwards. const received = yield* Deferred.make() - const unsub = subscribeGlobal(MessageV2.Event.PartUpdated.type, (event) => { - Deferred.doneUnsafe(received, Effect.succeed(event.properties.part as SessionLegacy.Part)) + const unsub = yield* events.listen((event) => { + if (event.type === MessageV2.Event.PartUpdated.type) + Deferred.doneUnsafe(received, Effect.succeed((event.data as typeof MessageV2.Event.PartUpdated.data.Type).part as SessionLegacy.Part)) + return Effect.void }) - yield* Effect.addFinalizer(() => Effect.sync(unsub)) + yield* Effect.addFinalizer(() => unsub) const tokens = { total: 1500, diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 6c40b60d7..b5fed974a 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -35,7 +35,6 @@ import { EventV2Bridge } from "@/event-v2-bridge" import { Agent as AgentSvc } from "../../src/agent/agent" import { BackgroundJob } from "@/background/job" import { Git } from "../../src/git" -import { Bus } from "../../src/bus" import { Command } from "../../src/command" import { Config } from "@/config/config" import { LSP } from "@/lsp/lsp" @@ -110,7 +109,7 @@ const lsp = Layer.succeed( }), ) -const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer)) +const status = SessionStatus.layer.pipe(Layer.provideMerge(EventV2Bridge.defaultLayer)) const run = SessionRunState.layer.pipe(Layer.provide(status)) const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer) diff --git a/packages/opencode/test/share/share-next.test.ts b/packages/opencode/test/share/share-next.test.ts index 1351046f6..a3662f60c 100644 --- a/packages/opencode/test/share/share-next.test.ts +++ b/packages/opencode/test/share/share-next.test.ts @@ -7,7 +7,7 @@ import { AccessToken, AccountID, OrgID, RefreshToken } from "../../src/account/s import { Account } from "../../src/account/account" import { AccountRepo } from "../../src/account/repo" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { Bus } from "../../src/bus" +import { EventV2Bridge } from "../../src/event-v2-bridge" import { Config } from "@/config/config" import { Provider } from "@/provider/provider" import { Session } from "@/session/session" @@ -43,7 +43,7 @@ const none = HttpClient.make(() => Effect.die("unexpected http call")) function live(client: HttpClient.HttpClient) { const http = Layer.succeed(HttpClient.HttpClient, client) return ShareNext.layer.pipe( - Layer.provide(Bus.layer), + Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(Account.layer.pipe(Layer.provide(AccountRepo.defaultLayer), Layer.provide(http))), Layer.provide(Config.defaultLayer), Layer.provide(Database.defaultLayer), @@ -56,7 +56,7 @@ function live(client: HttpClient.HttpClient) { function wired(client: HttpClient.HttpClient) { const http = Layer.succeed(HttpClient.HttpClient, client) return Layer.mergeAll( - Bus.layer, + EventV2Bridge.defaultLayer, ShareNext.layer, Session.defaultLayer, AccountRepo.defaultLayer, @@ -64,7 +64,7 @@ function wired(client: HttpClient.HttpClient) { NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer, ).pipe( - Layer.provide(Bus.layer), + Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(Account.layer.pipe(Layer.provide(AccountRepo.defaultLayer), Layer.provide(http))), Layer.provide(Config.defaultLayer), Layer.provide(http), @@ -251,7 +251,7 @@ describe("ShareNext", () => { }) return Effect.gen(function* () { - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service const share = yield* ShareNext.Service const session = yield* Session.Service @@ -270,7 +270,7 @@ describe("ShareNext", () => { .run() .pipe(Effect.orDie) - yield* bus.publish(Session.Event.Diff, { + yield* events.publish(Session.Event.Diff, { sessionID: info.id, diff: [ { @@ -283,7 +283,7 @@ describe("ShareNext", () => { }, ], }) - yield* bus.publish(Session.Event.Diff, { + yield* events.publish(Session.Event.Diff, { sessionID: info.id, diff: [ { diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts index fc1f6bff6..e078b5eaf 100644 --- a/packages/opencode/test/skill/skill.test.ts +++ b/packages/opencode/test/skill/skill.test.ts @@ -3,30 +3,31 @@ import { Effect, Layer } from "effect" import { Skill } from "../../src/skill" import { Discovery } from "../../src/skill/discovery" import { RuntimeFlags } from "../../src/effect/runtime-flags" -import { Bus } from "../../src/bus" +import { EventV2Bridge } from "../../src/event-v2-bridge" import { Config } from "../../src/config/config" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Global } from "@opencode-ai/core/global" -import { provideInstance, provideTmpdirInstance, tmpdir } from "../fixture/fixture" +import { provideInstance, provideTmpdirInstance, testInstanceStoreLayer, tmpdir } from "../fixture/fixture" import { testEffect } from "../lib/effect" import path from "path" import fs from "fs/promises" const node = CrossSpawnSpawner.defaultLayer -const it = testEffect(Layer.mergeAll(Skill.defaultLayer, node)) +const it = testEffect(Layer.mergeAll(Skill.defaultLayer, node, testInstanceStoreLayer)) const itWithoutClaudeCodeSkills = testEffect( Layer.mergeAll( Skill.layer.pipe( Layer.provide(Discovery.defaultLayer), Layer.provide(Config.defaultLayer), - Layer.provide(Bus.layer), + Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Global.layer), Layer.provide(RuntimeFlags.layer({ disableClaudeCodeSkills: true })), ), node, + testInstanceStoreLayer, ), ) const itWithoutExternalSkills = testEffect( @@ -34,12 +35,13 @@ const itWithoutExternalSkills = testEffect( Skill.layer.pipe( Layer.provide(Discovery.defaultLayer), Layer.provide(Config.defaultLayer), - Layer.provide(Bus.layer), + Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Global.layer), Layer.provide(RuntimeFlags.layer({ disableExternalSkills: true })), ), node, + testInstanceStoreLayer, ), ) diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index 8b4219195..81642f574 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -6,10 +6,10 @@ import fs from "fs/promises" import path from "path" import { Effect, Fiber, Layer } from "effect" import { Snapshot } from "../../src/snapshot" -import { disposeAllInstances, provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, testInstanceStoreLayer, TestInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(Snapshot.defaultLayer, AppFileSystem.defaultLayer)) +const it = testEffect(Layer.mergeAll(Snapshot.defaultLayer, AppFileSystem.defaultLayer, testInstanceStoreLayer)) // Git always outputs /-separated paths internally. Snapshot.patch() joins them // with path.join (which produces \ on Windows) then normalizes back to /. diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index be5754f3b..01e326ec5 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -7,7 +7,7 @@ import { LSP } from "@/lsp/lsp" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Format } from "../../src/format" import { Agent } from "../../src/agent/agent" -import { Bus } from "../../src/bus" +import { EventV2Bridge } from "../../src/event-v2-bridge" import { Truncate } from "@/tool/truncate" import { TestInstance } from "../fixture/fixture" import { SessionID, MessageID } from "../../src/session/schema" @@ -18,7 +18,7 @@ const it = testEffect( LSP.defaultLayer, AppFileSystem.defaultLayer, Format.defaultLayer, - Bus.layer, + EventV2Bridge.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer, ), diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts index 3f644ed53..9abc33885 100644 --- a/packages/opencode/test/tool/edit.test.ts +++ b/packages/opencode/test/tool/edit.test.ts @@ -8,7 +8,7 @@ import { LSP } from "@/lsp/lsp" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Format } from "../../src/format" import { Agent } from "../../src/agent/agent" -import { Bus } from "../../src/bus" +import { EventV2Bridge } from "../../src/event-v2-bridge" import { Truncate } from "@/tool/truncate" import { SessionID, MessageID } from "../../src/session/schema" import * as Tool from "../../src/tool/tool" @@ -34,7 +34,7 @@ const layer = Layer.mergeAll( LSP.defaultLayer, AppFileSystem.defaultLayer, Format.defaultLayer, - Bus.layer, + EventV2Bridge.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer, ) @@ -83,10 +83,13 @@ const makeDirectory = Effect.fn("EditToolTest.makeDirectory")(function* (p: stri }) const onceBus = Effect.fn("EditToolTest.onceBus")(function* (def: typeof FileWatcher.Event.Updated) { - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service const deferred = yield* Deferred.make() - const unsub = yield* bus.subscribeCallback(def, () => Effect.runSync(Deferred.succeed(deferred, undefined))) - yield* Effect.addFinalizer(() => Effect.sync(unsub)) + const unsub = yield* events.listen((event) => { + if (event.type === def.type) Deferred.doneUnsafe(deferred, Effect.void) + return Effect.void + }) + yield* Effect.addFinalizer(() => unsub) return deferred }) diff --git a/packages/opencode/test/tool/external-directory.test.ts b/packages/opencode/test/tool/external-directory.test.ts index e59caaa72..06019001f 100644 --- a/packages/opencode/test/tool/external-directory.test.ts +++ b/packages/opencode/test/tool/external-directory.test.ts @@ -5,7 +5,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import type { Tool } from "@/tool/tool" import { assertExternalDirectoryEffect } from "../../src/tool/external-directory" import { Filesystem } from "@/util/filesystem" -import { provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture" +import { TestInstance, tmpdirScoped } from "../fixture/fixture" import type { Permission } from "../../src/permission" import { SessionID, MessageID } from "../../src/session/schema" import { testEffect } from "../lib/effect" @@ -48,27 +48,26 @@ describe("tool.assertExternalDirectory", () => { }), ) - it.live("no-ops for paths inside the instance directory", () => - provideInstance("/tmp/project")( - Effect.gen(function* () { - const { requests, ctx } = makeCtx() - - yield* assertExternalDirectoryEffect(ctx, path.join("/tmp/project", "file.txt")) - - expect(requests.length).toBe(0) - }), - ), - ) - - it.live("asks with a single canonical glob", () => + it.instance("no-ops for paths inside the instance directory", () => Effect.gen(function* () { + const test = yield* TestInstance const { requests, ctx } = makeCtx() - const directory = "/tmp/project" - const target = "/tmp/outside/file.txt" + yield* assertExternalDirectoryEffect(ctx, path.join(test.directory, "file.txt")) + + expect(requests.length).toBe(0) + }), + ) + + it.instance("asks with a single canonical glob", () => + Effect.gen(function* () { + const test = yield* TestInstance + const { requests, ctx } = makeCtx() + + const target = path.join(path.dirname(test.directory), "outside", "file.txt") const expected = glob(path.join(path.dirname(target), "*")) - yield* provideInstance(directory)(assertExternalDirectoryEffect(ctx, target)) + yield* assertExternalDirectoryEffect(ctx, target) const req = requests.find((r) => r.permission === "external_directory") expect(req).toBeDefined() @@ -77,15 +76,15 @@ describe("tool.assertExternalDirectory", () => { }), ) - it.live("uses target directory when kind=directory", () => + it.instance("uses target directory when kind=directory", () => Effect.gen(function* () { + const test = yield* TestInstance const { requests, ctx } = makeCtx() - const directory = "/tmp/project" - const target = "/tmp/outside" + const target = path.join(path.dirname(test.directory), "outside") const expected = glob(path.join(target, "*")) - yield* provideInstance(directory)(assertExternalDirectoryEffect(ctx, target, { kind: "directory" })) + yield* assertExternalDirectoryEffect(ctx, target, { kind: "directory" }) const req = requests.find((r) => r.permission === "external_directory") expect(req).toBeDefined() @@ -95,15 +94,13 @@ describe("tool.assertExternalDirectory", () => { ) it.live("skips prompting when bypass=true", () => - provideInstance("/tmp/project")( - Effect.gen(function* () { - const { requests, ctx } = makeCtx() + Effect.gen(function* () { + const { requests, ctx } = makeCtx() - yield* assertExternalDirectoryEffect(ctx, "/tmp/outside/file.txt", { bypass: true }) + yield* assertExternalDirectoryEffect(ctx, "/tmp/outside/file.txt", { bypass: true }) - expect(requests.length).toBe(0) - }), - ), + expect(requests.length).toBe(0) + }), ) if (process.platform === "win32") { diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts index 027d5201c..a8cf5c9a3 100644 --- a/packages/opencode/test/tool/grep.test.ts +++ b/packages/opencode/test/tool/grep.test.ts @@ -4,7 +4,7 @@ import os from "os" import path from "path" import { Effect, Layer } from "effect" import { GrepTool } from "../../src/tool/grep" -import { provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture" +import { provideInstance, testInstanceStoreLayer, TestInstance, tmpdirScoped } from "../fixture/fixture" import { SessionID, MessageID } from "../../src/session/schema" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Global } from "@opencode-ai/core/global" @@ -42,6 +42,7 @@ const toolLayer = (flags: Partial = {}) => const it = testEffect(toolLayer()) const scout = testEffect(toolLayer({ experimentalScout: true })) +const rooted = testEffect(Layer.mergeAll(toolLayer(), testInstanceStoreLayer)) const ctx = { sessionID: SessionID.make("ses_test"), @@ -90,7 +91,7 @@ const git = Effect.fn("GrepToolTest.git")(function* (cwd: string, args: string[] }) describe("tool.grep", () => { - it.live("basic search", () => + rooted.live("basic search", () => Effect.gen(function* () { const info = yield* GrepTool const grep = yield* info.init() diff --git a/packages/opencode/test/tool/lsp.test.ts b/packages/opencode/test/tool/lsp.test.ts index 875edc1c0..f3b1d7efd 100644 --- a/packages/opencode/test/tool/lsp.test.ts +++ b/packages/opencode/test/tool/lsp.test.ts @@ -10,7 +10,7 @@ import { MessageID, SessionID } from "../../src/session/schema" import { Tool } from "@/tool/tool" import { Truncate } from "@/tool/truncate" import { LspTool } from "../../src/tool/lsp" -import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" +import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" afterEach(async () => { @@ -98,10 +98,11 @@ const asks = () => { describe("tool.lsp", () => { describe("permission metadata", () => { - it.live("keeps cursor details for position-based operations", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { + it.instance( + "keeps cursor details for position-based operations", + () => + Effect.gen(function* () { + const dir = (yield* TestInstance).directory const file = path.join(dir, "test.ts") yield* put(file) @@ -117,15 +118,15 @@ describe("tool.lsp", () => { character: 7, }) expect(result.title).toBe("goToDefinition test.ts:3:7") - }), - { git: true }, - ), + }), + { git: true }, ) - it.live("omits cursor details for documentSymbol", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { + it.instance( + "omits cursor details for documentSymbol", + () => + Effect.gen(function* () { + const dir = (yield* TestInstance).directory const file = path.join(dir, "test.ts") yield* put(file) @@ -139,15 +140,15 @@ describe("tool.lsp", () => { filePath: file, }) expect(result.title).toBe("documentSymbol test.ts") - }), - { git: true }, - ), + }), + { git: true }, ) - it.live("omits file and cursor details for workspaceSymbol", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { + it.instance( + "omits file and cursor details for workspaceSymbol", + () => + Effect.gen(function* () { + const dir = (yield* TestInstance).directory workspaceSymbolQueries.length = 0 const file = path.join(dir, "test.ts") yield* put(file) @@ -161,15 +162,15 @@ describe("tool.lsp", () => { operation: "workspaceSymbol", }) expect(result.title).toBe("workspaceSymbol") - }), - { git: true }, - ), + }), + { git: true }, ) - it.live("passes workspaceSymbol query to LSP", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { + it.instance( + "passes workspaceSymbol query to LSP", + () => + Effect.gen(function* () { + const dir = (yield* TestInstance).directory workspaceSymbolQueries.length = 0 const file = path.join(dir, "test.ts") yield* put(file) @@ -178,9 +179,8 @@ describe("tool.lsp", () => { yield* run({ operation: "workspaceSymbol", filePath: file, line: 3, character: 7 }) expect(workspaceSymbolQueries).toEqual(["TestSymbol", ""]) - }), - { git: true }, - ), + }), + { git: true }, ) }) }) diff --git a/packages/opencode/test/tool/question.test.ts b/packages/opencode/test/tool/question.test.ts index 854c1f891..0bbc58d44 100644 --- a/packages/opencode/test/tool/question.test.ts +++ b/packages/opencode/test/tool/question.test.ts @@ -7,7 +7,7 @@ import { Agent } from "../../src/agent/agent" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Truncate } from "@/tool/truncate" import { testEffect } from "../lib/effect" -import { Bus } from "../../src/bus" +import { EventV2Bridge } from "../../src/event-v2-bridge" const ctx = { sessionID: SessionID.make("ses_test-session"), @@ -22,7 +22,7 @@ const ctx = { const it = testEffect( Layer.mergeAll( - Question.layer.pipe(Layer.provideMerge(Bus.layer)), + Question.layer.pipe(Layer.provideMerge(EventV2Bridge.defaultLayer)), CrossSpawnSpawner.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer, @@ -30,10 +30,13 @@ const it = testEffect( ) const pending = Effect.fn("QuestionToolTest.pending")(function* (question: Question.Interface) { - const bus = yield* Bus.Service + const events = yield* EventV2Bridge.Service const asked = yield* Queue.unbounded() - const off = yield* bus.subscribeCallback(Question.Event.Asked, () => Queue.offerUnsafe(asked, undefined)) - yield* Effect.addFinalizer(() => Effect.sync(off)) + const off = yield* events.listen((event) => { + if (event.type === Question.Event.Asked.type) Queue.offerUnsafe(asked, undefined) + return Effect.void + }) + yield* Effect.addFinalizer(() => off) for (;;) { const items = yield* question.list() diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index f8c656ccf..4853cbe2f 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -15,7 +15,7 @@ import { ReadTool } from "../../src/tool/read" import { Truncate } from "@/tool/truncate" import { Tool } from "@/tool/tool" import { Filesystem } from "@/util/filesystem" -import { disposeAllInstances, provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, testInstanceStoreLayer, TestInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { Reference } from "@/reference/reference" import { RepositoryCache } from "@/reference/repository-cache" @@ -55,8 +55,8 @@ const readLayer = (flags: Partial = {}) => Truncate.defaultLayer, ) -const it = testEffect(readLayer()) -const scout = testEffect(readLayer({ experimentalScout: true })) +const it = testEffect(Layer.mergeAll(readLayer(), testInstanceStoreLayer)) +const scout = testEffect(Layer.mergeAll(readLayer({ experimentalScout: true }), testInstanceStoreLayer)) const init = Effect.fn("ReadToolTest.init")(function* () { const info = yield* ReadTool diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 9f2a5140f..489a756d5 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -23,7 +23,7 @@ import { Provider } from "@/provider/provider" import { Git } from "@/git" import { LSP } from "@/lsp/lsp" import { Instruction } from "@/session/instruction" -import { Bus } from "@/bus" +import { EventV2Bridge } from "@/event-v2-bridge" import { FetchHttpClient } from "effect/unstable/http" import { Format } from "@/format" import { Ripgrep } from "@/file/ripgrep" @@ -64,7 +64,7 @@ const registryLayer = (opts: RegistryLayerOptions = {}) => Layer.provide(LSP.defaultLayer), Layer.provide(Instruction.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), - Layer.provide(Bus.layer), + Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(FetchHttpClient.layer), Layer.provide(Format.defaultLayer), Layer.provide(Layer.mergeAll(node, Database.defaultLayer)), diff --git a/packages/opencode/test/tool/repo_clone.test.ts b/packages/opencode/test/tool/repo_clone.test.ts index 2d7c70efc..75b103e2f 100644 --- a/packages/opencode/test/tool/repo_clone.test.ts +++ b/packages/opencode/test/tool/repo_clone.test.ts @@ -11,7 +11,7 @@ import { MessageID, SessionID } from "../../src/session/schema" import { Truncate } from "../../src/tool/truncate" import { RepoCloneTool } from "../../src/tool/repo_clone" import { RepositoryCache } from "../../src/reference/repository-cache" -import { disposeAllInstances, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" +import { disposeAllInstances, TestInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" afterEach(async () => { @@ -80,9 +80,8 @@ const githubBase = (url: string, self: Effect.Effect) => ) describe("tool.repo_clone", () => { - it.live("clones a repo into the managed cache and reuses it on subsequent calls", () => - provideTmpdirInstance((_dir) => - Effect.gen(function* () { + it.instance("clones a repo into the managed cache and reuses it on subsequent calls", () => + Effect.gen(function* () { const fs = yield* AppFileSystem.Service const source = yield* tmpdirScoped({ git: true }) const remoteRoot = yield* tmpdirScoped() @@ -106,13 +105,11 @@ describe("tool.repo_clone", () => { expect(cloned.metadata.localPath).toBe(path.join(Global.Path.repos, "github.com", "owner", "repo")) expect(cached.metadata.status).toBe("cached") expect(yield* fs.readFileString(path.join(cloned.metadata.localPath, "README.md"))).toBe("v1\n") - }), - ), + }), ) - it.live("refresh updates an existing cached clone", () => - provideTmpdirInstance((_dir) => - Effect.gen(function* () { + it.instance("refresh updates an existing cached clone", () => + Effect.gen(function* () { const fs = yield* AppFileSystem.Service const source = yield* tmpdirScoped({ git: true }) const remoteRoot = yield* tmpdirScoped() @@ -145,13 +142,11 @@ describe("tool.repo_clone", () => { expect(first.metadata.status).toBe("cloned") expect(refreshed.metadata.status).toBe("refreshed") expect(yield* fs.readFileString(path.join(first.metadata.localPath, "README.md"))).toBe("v2\n") - }), - ), + }), ) - it.live("clones a configured branch", () => - provideTmpdirInstance((_dir) => - Effect.gen(function* () { + it.instance("clones a configured branch", () => + Effect.gen(function* () { const fs = yield* AppFileSystem.Service const source = yield* tmpdirScoped({ git: true }) const remoteRoot = yield* tmpdirScoped() @@ -177,19 +172,18 @@ describe("tool.repo_clone", () => { expect(result.metadata.status).toBe("cloned") expect(result.metadata.branch).toBe("docs") expect(yield* fs.readFileString(path.join(result.metadata.localPath, "DOCS.md"))).toBe("docs\n") - }), - ), + }), ) - it.live("rejects invalid repository inputs", () => - provideTmpdirInstance((_dir) => - Effect.gen(function* () { + it.instance("rejects invalid repository inputs", () => + Effect.gen(function* () { + const dir = (yield* TestInstance).directory const tool = yield* init() const inputs = [ { repository: "not-a-repo", message: "git URL" }, { repository: "git@github.com:../../../etc/passwd", message: "git URL" }, { repository: "-u:foo/bar", message: "git URL" }, - { repository: pathToFileURL(path.join(_dir, "local.git")).href, message: "Local file" }, + { repository: pathToFileURL(path.join(dir, "local.git")).href, message: "Local file" }, ] yield* Effect.forEach( @@ -206,13 +200,11 @@ describe("tool.repo_clone", () => { }), { discard: true }, ) - }), - ), + }), ) - it.live("rejects local file repository URLs", () => - provideTmpdirInstance((_dir) => - Effect.gen(function* () { + it.instance("rejects local file repository URLs", () => + Effect.gen(function* () { const source = yield* tmpdirScoped({ git: true }) const tool = yield* init() const result = yield* tool.execute({ repository: pathToFileURL(source).href }, ctx).pipe(Effect.exit) @@ -222,13 +214,11 @@ describe("tool.repo_clone", () => { const error = Cause.squash(result.cause) expect(error instanceof Error ? error.message : String(error)).toContain("Local file") } - }), - ), + }), ) - it.live("rejects invalid branch inputs", () => - provideTmpdirInstance((_dir) => - Effect.gen(function* () { + it.instance("rejects invalid branch inputs", () => + Effect.gen(function* () { const tool = yield* init() const result = yield* tool.execute({ repository: "owner/repo", branch: "bad..branch" }, ctx).pipe(Effect.exit) @@ -239,7 +229,6 @@ describe("tool.repo_clone", () => { "Branch must contain only alphanumeric characters", ) } - }), - ), + }), ) }) diff --git a/packages/opencode/test/tool/repo_overview.test.ts b/packages/opencode/test/tool/repo_overview.test.ts index c854e51a3..34c29b717 100644 --- a/packages/opencode/test/tool/repo_overview.test.ts +++ b/packages/opencode/test/tool/repo_overview.test.ts @@ -9,7 +9,7 @@ import { Global } from "@opencode-ai/core/global" import { MessageID, SessionID } from "../../src/session/schema" import { Truncate } from "../../src/tool/truncate" import { RepoOverviewTool } from "../../src/tool/repo_overview" -import { disposeAllInstances, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" +import { disposeAllInstances, TestInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" afterEach(async () => { @@ -43,9 +43,8 @@ const init = Effect.fn("RepoOverviewToolTest.init")(function* () { }) describe("tool.repo_overview", () => { - it.live("summarizes a local repository path", () => - provideTmpdirInstance((_dir) => - Effect.gen(function* () { + it.instance("summarizes a local repository path", () => + Effect.gen(function* () { const repo = yield* tmpdirScoped({ git: true }) const fs = yield* AppFileSystem.Service yield* fs.writeWithDirs( @@ -93,13 +92,12 @@ describe("tool.repo_overview", () => { expect(result.output).toContain("Top-level structure:") expect(result.output).toContain("src/") expect(result.output).toContain("README.md") - }), - ), + }), ) - it.live("resolves relative paths from the instance directory", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { + it.instance("resolves relative paths from the instance directory", () => + Effect.gen(function* () { + const dir = (yield* TestInstance).directory const fs = yield* AppFileSystem.Service yield* fs.writeWithDirs(path.join(dir, "nested", "README.md"), "# Nested\n") @@ -108,13 +106,11 @@ describe("tool.repo_overview", () => { expect(result.metadata.path).toBe(path.join(dir, "nested")) expect(result.output).toContain("README.md") - }), - ), + }), ) - it.live("resolves a cached repository from repository shorthand", () => - provideTmpdirInstance((_dir) => - Effect.gen(function* () { + it.instance("resolves a cached repository from repository shorthand", () => + Effect.gen(function* () { const fs = yield* AppFileSystem.Service const cached = path.join(Global.Path.repos, "github.com", "owner", "repo") yield* fs.writeWithDirs(path.join(cached, "package.json"), JSON.stringify({ name: "cached-repo" }, null, 2)) @@ -127,13 +123,11 @@ describe("tool.repo_overview", () => { expect(result.metadata.repository).toBe("owner/repo") expect(result.output).toContain("Repository: owner/repo") expect(result.output).toContain(`Path: ${cached}`) - }), - ), + }), ) - it.live("fails clearly when a repository is not cloned", () => - provideTmpdirInstance((_dir) => - Effect.gen(function* () { + it.instance("fails clearly when a repository is not cloned", () => + Effect.gen(function* () { const tool = yield* init() const result = yield* tool.execute({ repository: "missing/repo" }, ctx).pipe(Effect.exit) @@ -142,13 +136,11 @@ describe("tool.repo_overview", () => { const error = Cause.squash(result.cause) expect(error instanceof Error ? error.message : String(error)).toContain("Use repo_clone first") } - }), - ), + }), ) - it.live("resolves cached repositories from host/path references", () => - provideTmpdirInstance((_dir) => - Effect.gen(function* () { + it.instance("resolves cached repositories from host/path references", () => + Effect.gen(function* () { const fs = yield* AppFileSystem.Service const cached = path.join(Global.Path.repos, "gitlab.com", "group", "repo") yield* fs.writeWithDirs(path.join(cached, "README.md"), "cached\n") @@ -159,7 +151,6 @@ describe("tool.repo_overview", () => { expect(result.metadata.path).toBe(cached) expect(result.metadata.repository).toBe("gitlab.com/group/repo") expect(result.output).toContain("Repository: gitlab.com/group/repo") - }), - ), + }), ) }) diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index 5935c8fd8..fb8f95882 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -7,7 +7,7 @@ import { Config } from "@/config/config" import { Shell } from "../../src/shell/shell" import { ShellTool } from "../../src/tool/shell" import { Filesystem } from "@/util/filesystem" -import { provideInstance, tmpdirScoped } from "../fixture/fixture" +import { provideInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixture/fixture" import type { Permission } from "../../src/permission" import { Agent } from "../../src/agent/agent" import { Truncate } from "@/tool/truncate" @@ -28,6 +28,7 @@ const shellLayer = Layer.mergeAll( Config.defaultLayer, Agent.defaultLayer, RuntimeFlags.defaultLayer, + testInstanceStoreLayer, ) const it = testEffect(shellLayer) type ShellTestServices = diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts index 6732b42bb..f96730094 100644 --- a/packages/opencode/test/tool/skill.test.ts +++ b/packages/opencode/test/tool/skill.test.ts @@ -7,7 +7,7 @@ import type { Permission } from "../../src/permission" import type { Tool } from "@/tool/tool" import { SkillTool } from "../../src/tool/skill" import { ToolRegistry } from "@/tool/registry" -import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" +import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { SessionID, MessageID } from "../../src/session/schema" import { testEffect } from "../lib/effect" @@ -30,9 +30,9 @@ const node = CrossSpawnSpawner.defaultLayer const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node)) describe("tool.skill", () => { - it.live("execute returns skill content block with files", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { + it.instance("execute returns skill content block with files", () => + Effect.gen(function* () { + const dir = (yield* TestInstance).directory const skill = path.join(dir, ".opencode", "skill", "tool-skill") yield* Effect.promise(() => Bun.write( @@ -87,13 +87,12 @@ Use this skill. expect(result.output).toContain(``) expect(result.output).toContain(`Base directory for this skill: ${pathToFileURL(skill).href}`) expect(result.output).toContain(`${file}`) - }), - ), + }), ) - it.live("execute preserves not found message", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { + it.instance("execute preserves not found message", () => + Effect.gen(function* () { + const dir = (yield* TestInstance).directory const home = process.env.OPENCODE_TEST_HOME process.env.OPENCODE_TEST_HOME = dir yield* Effect.addFinalizer(() => @@ -127,7 +126,6 @@ Use this skill. expect(error).toBeInstanceOf(Error) if (error instanceof Error) expect(error.message).toContain('Skill "missing-skill" not found.') } - }), - ), + }), ) }) diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index 40955cb01..58391e0e3 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -4,7 +4,7 @@ import { Database } from "@opencode-ai/core/database/database" import { Effect, Exit, Fiber, Layer } from "effect" import { Agent } from "../../src/agent/agent" import { BackgroundJob } from "@/background/job" -import { Bus } from "@/bus" +import { EventV2Bridge } from "@/event-v2-bridge" import { Config } from "@/config/config" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Session } from "@/session/session" @@ -35,7 +35,7 @@ const layer = (flags: Partial = {}) => Layer.mergeAll( Agent.defaultLayer, BackgroundJob.defaultLayer, - Bus.defaultLayer, + EventV2Bridge.defaultLayer, Config.defaultLayer, CrossSpawnSpawner.defaultLayer, Session.defaultLayer, diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts index 08f156092..6cc72f383 100644 --- a/packages/opencode/test/tool/write.test.ts +++ b/packages/opencode/test/tool/write.test.ts @@ -5,7 +5,7 @@ import fs from "fs/promises" import { WriteTool } from "../../src/tool/write" import { LSP } from "@/lsp/lsp" import { AppFileSystem } from "@opencode-ai/core/filesystem" -import { Bus } from "../../src/bus" +import { EventV2Bridge } from "../../src/event-v2-bridge" import { Format } from "../../src/format" import { Truncate } from "@/tool/truncate" import { Tool } from "@/tool/tool" @@ -34,7 +34,7 @@ const it = testEffect( Layer.mergeAll( LSP.defaultLayer, AppFileSystem.defaultLayer, - Bus.layer, + EventV2Bridge.defaultLayer, Format.defaultLayer, CrossSpawnSpawner.defaultLayer, Truncate.defaultLayer,