diff --git a/packages/client/script/build.ts b/packages/client/script/build.ts index 35ce8387a8..88e5ea6e5b 100644 --- a/packages/client/script/build.ts +++ b/packages/client/script/build.ts @@ -1,20 +1,29 @@ import { NodeFileSystem } from "@effect/platform-node" import { compile, emitEffectImported, emitPromise, write } from "@opencode-ai/httpapi-codegen" -import { Api } from "@opencode-ai/server/api" +import { ClientApi } from "../src/contract" import { Effect } from "effect" -import { HttpApi } from "effect/unstable/httpapi" import { fileURLToPath } from "url" -const contract = compile(HttpApi.make("opencode-client").add(Api.groups["server.session"]), { - groupNames: { "server.session": "sessions" }, +const contract = compile(ClientApi, { + groupNames: { "server.session": "sessions", "server.event": "events" }, }) await Effect.runPromise( Effect.all( [ - write(emitPromise(contract), fileURLToPath(new URL("../src/generated", import.meta.url))), write( - emitEffectImported(contract, { module: "../contract", group: "SessionGroup" }), + emitPromise(contract, { + outputTypes: { + "events.subscribe": { + name: "OpenCodeEventEncoded", + import: 'import type { OpenCodeEventEncoded } from "@opencode-ai/protocol/groups/event"', + }, + }, + }), + fileURLToPath(new URL("../src/generated", import.meta.url)), + ), + write( + emitEffectImported(contract, { module: "../contract", api: "ClientApi" }), fileURLToPath(new URL("../src/generated-effect", import.meta.url)), ), ], diff --git a/packages/client/src/contract.ts b/packages/client/src/contract.ts index 2190c130ca..15769ad9c2 100644 --- a/packages/client/src/contract.ts +++ b/packages/client/src/contract.ts @@ -1,6 +1,6 @@ import { makeDefaultApi } from "@opencode-ai/protocol/api" import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors" -import { HttpApiMiddleware } from "effect/unstable/httpapi" +import { HttpApi, HttpApiMiddleware } from "effect/unstable/httpapi" class LocationMiddleware extends HttpApiMiddleware.Service()( "@opencode-ai/client/LocationMiddleware", @@ -17,3 +17,5 @@ const Api = makeDefaultApi({ }) export const SessionGroup = Api.groups["server.session"] +export const EventGroup = Api.groups["server.event"] +export const ClientApi = HttpApi.make("opencode-client").add(SessionGroup).add(EventGroup) diff --git a/packages/client/src/effect.ts b/packages/client/src/effect.ts index 6c0aca564e..6664ff2553 100644 --- a/packages/client/src/effect.ts +++ b/packages/client/src/effect.ts @@ -10,3 +10,4 @@ export { Session } from "@opencode-ai/schema/session" export { SessionInput } from "@opencode-ai/schema/session-input" export { SessionMessage } from "@opencode-ai/schema/session-message" export { Prompt } from "@opencode-ai/schema/prompt" +export type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event" diff --git a/packages/client/src/generated-effect/client.ts b/packages/client/src/generated-effect/client.ts index 4856a3ccde..ac7bba7b1f 100644 --- a/packages/client/src/generated-effect/client.ts +++ b/packages/client/src/generated-effect/client.ts @@ -2,13 +2,11 @@ import { Effect, Stream, Schema } from "effect" import { Sse } from "effect/unstable/encoding" import { HttpClientError } from "effect/unstable/http" -import { HttpApi, HttpApiClient } from "effect/unstable/httpapi" -import { SessionGroup } from "../contract" +import { HttpApiClient } from "effect/unstable/httpapi" +import { ClientApi } from "../contract" import { ClientError } from "./client-error" -const Api = HttpApi.make("generated").add(SessionGroup) - -type RawClient = HttpApiClient.ForApi +type RawClient = HttpApiClient.ForApi const mapClientError = (error: E) => HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) @@ -210,7 +208,20 @@ const adaptGroup0 = (raw: RawClient["server.session"]) => ({ message: Endpoint0_16(raw), }) -const adaptClient = (raw: RawClient) => ({ sessions: adaptGroup0(raw["server.session"]) }) +const Endpoint1_0 = (raw: RawClient["server.event"]) => () => + Stream.unwrap( + raw["event.subscribe"]({}).pipe( + Effect.mapError(mapClientError), + Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError))), + ), + ) + +const adaptGroup1 = (raw: RawClient["server.event"]) => ({ subscribe: Endpoint1_0(raw) }) + +const adaptClient = (raw: RawClient) => ({ + sessions: adaptGroup0(raw["server.session"]), + events: adaptGroup1(raw["server.event"]), +}) export const make = (options?: { readonly baseUrl?: URL | string }) => - HttpApiClient.make(Api, options).pipe(Effect.map(adaptClient)) + HttpApiClient.make(ClientApi, options).pipe(Effect.map(adaptClient)) diff --git a/packages/client/src/generated/client.ts b/packages/client/src/generated/client.ts index 6366fbb560..c14015f11c 100644 --- a/packages/client/src/generated/client.ts +++ b/packages/client/src/generated/client.ts @@ -32,6 +32,7 @@ import type { SessionsInterruptOutput, SessionsMessageInput, SessionsMessageOutput, + EventsSubscribeOutput, } from "./types" import { ClientError } from "./client-error" @@ -373,6 +374,13 @@ export function make(options: ClientOptions) { requestOptions, ).then((value) => value.data), }, + events: { + subscribe: (requestOptions?: RequestOptions): AsyncIterable => + sse( + { method: "GET", path: `/api/event`, successStatus: 200, declaredStatuses: [401, 400], empty: false }, + requestOptions, + ), + }, } } diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index bfa543ea23..da0b97a7c0 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -1,3 +1,5 @@ +import type { OpenCodeEventEncoded } from "@opencode-ai/protocol/groups/event" + export type JsonValue = | null | boolean @@ -1666,3 +1668,5 @@ export type SessionsMessageOutput = { readonly time: { readonly created: number } } }["data"] + +export type EventsSubscribeOutput = OpenCodeEventEncoded diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 92e36b1c60..6955d7d8c5 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1 +1,2 @@ export * from "./generated/index" +export type { EventsSubscribeOutput as OpenCodeEvent } from "./generated/types" diff --git a/packages/client/test/contract-identity.test.ts b/packages/client/test/contract-identity.test.ts index 8c9ca70e2d..e71d70751c 100644 --- a/packages/client/test/contract-identity.test.ts +++ b/packages/client/test/contract-identity.test.ts @@ -20,7 +20,7 @@ import { Workspace } from "@opencode-ai/schema/workspace" import { Api } from "@opencode-ai/server/api" import { compile, emitPromise } from "@opencode-ai/httpapi-codegen" import { HttpApi } from "effect/unstable/httpapi" -import { SessionGroup } from "../src/contract" +import { EventGroup, SessionGroup } from "../src/contract" test("Core and Server reuse the authoritative Schema and Protocol values", () => { expect(AgentV2.ID).toBe(Agent.ID) @@ -32,6 +32,7 @@ test("Core and Server reuse the authoritative Schema and Protocol values", () => expect(CorePrompt).toBe(Prompt) expect(Api.groups["server.session"].identifier).toBe("server.session") expect(SessionGroup.identifier).toBe(Api.groups["server.session"].identifier) + expect(EventGroup.identifier).toBe(Api.groups["server.event"].identifier) expect(Session.ID.create()).toStartWith("ses_") expect(Project.ID.global).toBe("global") expect(Provider.ID.anthropic).toBe("anthropic") diff --git a/packages/client/test/effect.test.ts b/packages/client/test/effect.test.ts index 5b07ad5d08..7bf4d26f8f 100644 --- a/packages/client/test/effect.test.ts +++ b/packages/client/test/effect.test.ts @@ -15,6 +15,50 @@ test("sessions.get returns the decoded Effect projection", async () => { expect(DateTime.toEpochMillis(result.time.created)).toBe(1_717_171_717_000) }) +test("events.subscribe exposes and decodes the native Effect event stream", async () => { + const httpClient = HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response( + `data: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n` + + `data: ${JSON.stringify(modelSwitchedEvent)}\n\n`, + { headers: { "content-type": "text/event-stream" } }, + ), + ), + ), + ) + const events = await Effect.gen(function* () { + const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" }) + return yield* client.events.subscribe().pipe(Stream.runCollect) + }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise) + + expect(Array.from(events).map((event) => event.type)).toEqual(["server.connected", "session.next.model.switched"]) + const durable = events[1] + if (durable?.type !== "session.next.model.switched") throw new Error("Expected model event") + expect(DateTime.toEpochMillis(durable.data.timestamp)).toBe(1_717_171_717_000) + expect(durable.durable).toEqual({ aggregateID: "ses_test", seq: 1, version: 1 }) +}) + +test("events.subscribe terminates on Effect protocol decode failures", async () => { + const httpClient = HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(`data: {"type":"server.connected"}\n\n`, { + headers: { "content-type": "text/event-stream" }, + }), + ), + ), + ) + const error = await Effect.gen(function* () { + const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" }) + return yield* client.events.subscribe().pipe(Stream.runCollect, Effect.flip) + }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise) + + expect(error._tag).toBe("ClientError") +}) + test("session methods retain decoded Effect inputs and outputs", async () => { const historyQueries: Array> = [] let historyPage = 0 diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index 5523da2da3..c5081053e5 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -17,6 +17,35 @@ test("sessions.get returns the wire projection", async () => { expect(result.time.created).toBe(1_717_171_717_000) }) +test("events.subscribe exposes the Promise event stream wire projection", async () => { + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: async () => + new Response( + `: heartbeat\n\ndata: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n` + + `data: ${JSON.stringify(modelSwitchedEvent)}\n\n`, + { headers: { "content-type": "text/event-stream" } }, + ), + }) + const events = [] + for await (const event of client.events.subscribe()) events.push(event) + + expect(events).toEqual([{ id: "evt_connected", type: "server.connected", data: {} }, modelSwitchedEvent]) + expect(events[1]?.type === "session.next.model.switched" && events[1].data.timestamp).toBe(1_717_171_717_000) +}) + +test("events.subscribe terminates on malformed Promise SSE data", async () => { + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: async () => new Response("data: {not-json}\n\n", { headers: { "content-type": "text/event-stream" } }), + }) + + await expect(client.events.subscribe()[Symbol.asyncIterator]().next()).rejects.toMatchObject({ + name: "ClientError", + reason: "MalformedResponse", + }) +}) + test("session methods use the public HTTP contract", async () => { const requests: Array<{ url: string; init?: RequestInit }> = [] let historyPage = 0 diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index 037cbe4392..46cec83beb 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -1,6 +1,6 @@ export * as EventV2 from "./event" -import { Cause, Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect" +import { Cause, Context, Effect, Layer, Option, PubSub, Queue, Schema, Stream } from "effect" import { Event } from "@opencode-ai/schema/event" import type { Data, Definition, Payload } from "@opencode-ai/schema/event" import { and, asc, eq, gt, inArray } from "drizzle-orm" @@ -107,6 +107,11 @@ export const readAggregate = Effect.fn("EventV2.readAggregate")(function* ( } }) +export class SubscriberOverflowError extends Schema.TaggedErrorClass()( + "EventV2.SubscriberOverflow", + { capacity: Schema.Int }, +) {} + export const define = Event.define export const versionedType = Event.versionedType @@ -144,6 +149,20 @@ export interface Interface { export class Service extends Context.Service()("@opencode/Event") {} +export const allBounded = (events: Interface, capacity: number) => + Effect.gen(function* () { + const queue = yield* Queue.dropping(capacity) + const unsubscribe = yield* events.listen((event) => + Queue.offer(queue, event).pipe( + Effect.flatMap((accepted) => + accepted ? Effect.void : Queue.fail(queue, new SubscriberOverflowError({ capacity })).pipe(Effect.asVoid), + ), + ), + ) + yield* Effect.addFinalizer(() => unsubscribe.pipe(Effect.andThen(Queue.shutdown(queue)), Effect.asVoid)) + return Stream.fromQueue(queue) + }) + export interface LayerOptions { readonly beforeAggregateRead?: (aggregateID: string) => Effect.Effect } diff --git a/packages/core/test/event.test.ts b/packages/core/test/event.test.ts index e2b2a5df04..c6eb0d9c82 100644 --- a/packages/core/test/event.test.ts +++ b/packages/core/test/event.test.ts @@ -1,5 +1,5 @@ import { describe, expect } from "bun:test" -import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } from "effect" +import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, Stream } from "effect" import { EventV2 } from "@opencode-ai/core/event" import { Event } from "@opencode-ai/schema/event" import { Session } from "@opencode-ai/schema/session" @@ -285,6 +285,69 @@ describe("EventV2", () => { }), ) + it.effect("notifies global listeners only after a durable event is committed", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const { db } = yield* Database.Service + const aggregateID = EventV2.ID.create() + const observed = new Array<{ id: string; seq: number }>() + yield* events.listen((event) => + event.type !== SyncMessage.type + ? Effect.void + : db + .select({ id: EventTable.id, seq: EventTable.seq }) + .from(EventTable) + .where(eq(EventTable.id, event.id)) + .get() + .pipe( + Effect.orDie, + Effect.tap((row) => + Effect.sync(() => { + if (row) observed.push(row) + }), + ), + Effect.asVoid, + ), + ) + + const event = yield* events.publish(SyncMessage, { id: aggregateID, text: "committed" }) + if (!event.durable) throw new Error("Expected durable event metadata") + + expect(observed).toEqual([{ id: event.id, seq: event.durable.seq }]) + }), + ) + + it.effect("ends only an overflowing bounded subscriber without blocking other listeners", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const consuming = yield* Deferred.make() + const release = yield* Deferred.make() + const slowStream = yield* EventV2.allBounded(events, 1) + const fastStream = yield* EventV2.allBounded(events, 8) + const slow = yield* slowStream.pipe( + Stream.runForEach(() => Deferred.succeed(consuming, undefined).pipe(Effect.andThen(Deferred.await(release)))), + Effect.forkScoped, + ) + const fast = yield* fastStream.pipe(Stream.take(4), Stream.runCollect, Effect.forkScoped) + + yield* events.publish(Message, { text: "one" }) + yield* Deferred.await(consuming) + yield* events.publish(Message, { text: "two" }) + yield* events.publish(Message, { text: "overflow" }) + const last = yield* events.publish(Message, { text: "still delivered" }) + yield* Deferred.succeed(release, undefined) + + const slowExit = yield* Fiber.await(slow) + expect(Exit.findErrorOption(slowExit).pipe(Option.getOrUndefined)).toBeInstanceOf(EventV2.SubscriberOverflowError) + expect(Array.from(yield* Fiber.join(fast))).toEqual([ + expect.objectContaining({ data: { text: "one" } }), + expect.objectContaining({ data: { text: "two" } }), + expect.objectContaining({ data: { text: "overflow" } }), + last, + ]) + }), + ) + it.effect("preserves observer interruption", () => Effect.gen(function* () { const events = yield* EventV2.Service diff --git a/packages/httpapi-codegen/src/index.ts b/packages/httpapi-codegen/src/index.ts index 704264778c..b993926dd7 100644 --- a/packages/httpapi-codegen/src/index.ts +++ b/packages/httpapi-codegen/src/index.ts @@ -230,7 +230,12 @@ export function emitEffectImported( } } -export function emitPromise(contract: Contract): Output { +export function emitPromise( + contract: Contract, + options?: { + readonly outputTypes?: Readonly> + }, +): Output { const groups = contract.groups for (const group of groups) { for (const endpoint of group.endpoints) assertPromiseEndpoint(endpoint) @@ -238,7 +243,7 @@ export function emitPromise(contract: Contract): Output { return { operations: operations(groups), files: [ - { path: "types.ts", content: renderPromiseTypes(groups) }, + { path: "types.ts", content: renderPromiseTypes(groups, options?.outputTypes) }, { path: "client-error.ts", content: `export type ClientErrorReason = "Transport" | "UnexpectedStatus" | "UnsupportedContentType" | "MalformedResponse"\n\nexport class ClientError extends Error {\n override readonly name = "ClientError"\n constructor(readonly reason: ClientErrorReason, options?: ErrorOptions) {\n super(reason, options)\n }\n}\n`, @@ -408,7 +413,10 @@ function renderImportedProjection(groups: ReadonlyArray, endpoints: Reado return { imports: [...new Set(imports)], source } } -function renderPromiseTypes(groups: ReadonlyArray) { +function renderPromiseTypes( + groups: ReadonlyArray, + outputTypes?: Readonly>, +) { const types = new Map() const typeOf = (schema: Schema.Top, decoded = false) => { const projected = decoded ? Schema.toType(schema) : Schema.toEncoded(schema) @@ -453,13 +461,15 @@ function renderPromiseTypes(groups: ReadonlyArray) { }) .join("; ") const successSchema = endpoint.successes[0] - const success = typeOf( - isStreamSchema(successSchema) && successSchema._tag === "StreamSse" - ? successSchema.sseMode === "data" - ? streamEncodedDataSchema(successSchema) - : successSchema.events - : successSchema, - ) + const success = + outputTypes?.[`${group.identifier}.${endpoint.operation.name}`]?.name ?? + typeOf( + isStreamSchema(successSchema) && successSchema._tag === "StreamSse" + ? successSchema.sseMode === "data" + ? streamEncodedDataSchema(successSchema) + : successSchema.events + : successSchema, + ) return [ ...(endpoint.operation.inputMode === "none" ? [] : [`export type ${prefix}Input = { ${input} }`]), `export type ${prefix}Output = ${endpoint.unwrapData ? `(${success})["data"]` : success}`, @@ -470,7 +480,8 @@ function renderPromiseTypes(groups: ReadonlyArray) { const json = operations.includes("JsonValue") ? "export type JsonValue = null | boolean | number | string | ReadonlyArray | { readonly [key: string]: JsonValue }" : "" - return [json, ...errorTypes, operations].filter(Boolean).join("\n\n") + const imports = [...new Set(Object.values(outputTypes ?? {}).map((override) => override.import))] + return [...imports, json, ...errorTypes, operations].filter(Boolean).join("\n\n") } function renderPromiseClient(groups: ReadonlyArray) { diff --git a/packages/httpapi-codegen/test/generate.test.ts b/packages/httpapi-codegen/test/generate.test.ts index 543b2a13f3..aa59ec60ed 100644 --- a/packages/httpapi-codegen/test/generate.test.ts +++ b/packages/httpapi-codegen/test/generate.test.ts @@ -48,6 +48,24 @@ describe("HttpApiCodegen.generate", () => { ) }) + test("allows Promise outputs to use an authoritative imported wire type", () => { + const contract = compileContract( + api(HttpApiEndpoint.get("events", "/event", { success: HttpApiSchema.StreamSse({ data: Schema.Unknown }) })), + ) + const output = emitPromise(contract, { + outputTypes: { + "session.events": { + name: "EventWire", + import: 'import type { EventWire } from "./event-wire"', + }, + }, + }) + const types = output.files.find((file) => file.path === "types.ts")?.content + + expect(types).toContain('import type { EventWire } from "./event-wire"') + expect(types).toContain("export type SessionEventsOutput = EventWire") + }) + test("emits an Effect client against an imported authoritative API", () => { const output = emitEffectImported( compileContract( diff --git a/packages/opencode/test/server/httpapi-v2-location.test.ts b/packages/opencode/test/server/httpapi-v2-location.test.ts index e178062e7a..cf009812b8 100644 --- a/packages/opencode/test/server/httpapi-v2-location.test.ts +++ b/packages/opencode/test/server/httpapi-v2-location.test.ts @@ -27,13 +27,44 @@ const Event = Schema.Struct({ data: Schema.Unknown, }) -async function readEvent(reader: ReadableStreamDefaultReader) { - const value = await reader.read() - if (value.done) throw new Error("event stream closed") - return Schema.decodeUnknownSync(Event)(JSON.parse(new TextDecoder().decode(value.value).replace(/^data: /, ""))) +async function* eventStream(body: ReadableStream) { + const reader = body.getReader() + const decoder = new TextDecoder() + let buffer = "" + try { + while (true) { + const boundary = buffer.match(/(?:\r\n|\r|\n){2}/) + if (!boundary || boundary.index === undefined) { + const value = await reader.read() + if (value.done) return + buffer += decoder.decode(value.value, { stream: true }) + continue + } + + const record = buffer.slice(0, boundary.index) + buffer = buffer.slice(boundary.index + boundary[0].length) + const data = record + .split(/\r\n|\r|\n/) + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice(5).replace(/^ /, "")) + if (data.length) yield Schema.decodeUnknownSync(Event)(JSON.parse(data.join("\n"))) + } + } finally { + try { + await reader.cancel() + } finally { + reader.releaseLock() + } + } } -async function readEventType(reader: ReadableStreamDefaultReader, type: string) { +async function readEvent(reader: AsyncIterator) { + const value = await reader.next() + if (value.done) throw new Error("event stream closed") + return value.value +} + +async function readEventType(reader: AsyncIterator, type: string) { for (let index = 0; index < 20; index++) { const event = await readEvent(reader) if (event.type === type) return event @@ -78,7 +109,7 @@ describe("v2 location HttpApi", () => { await using subscriber = await tmpdir({ git: true }) await using publisher = await tmpdir({ git: true }) const response = await request("/api/event", subscriber.path) - const reader = response.body!.getReader() + const reader = eventStream(response.body!) const connected = await readEvent(reader) expect(connected.type).toBe("server.connected") expect(connected.location).toBeUndefined() @@ -90,6 +121,6 @@ describe("v2 location HttpApi", () => { location: { directory: publisher.path }, data: { sessionID: expect.any(String) }, }) - await reader.cancel() + await reader.return(undefined) }) }) diff --git a/packages/protocol/src/groups/event.ts b/packages/protocol/src/groups/event.ts index 862337e54b..a6fc5692ea 100644 --- a/packages/protocol/src/groups/event.ts +++ b/packages/protocol/src/groups/event.ts @@ -3,7 +3,7 @@ import { EventManifest } from "@opencode-ai/schema/event-manifest" import { Location } from "@opencode-ai/schema/location" import type { Definition } from "@opencode-ai/schema/event" import { Schema } from "effect" -import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" const fields = { id: Event.ID, @@ -12,15 +12,9 @@ const fields = { location: Schema.optional(Location.Ref), } -const schema = (definitions: ReadonlyArray) => +const schema = >(definitions: Definitions) => Schema.Union([ - ...definitions.map((definition) => - Schema.Struct({ - ...fields, - type: Schema.Literal(definition.type), - data: definition.data, - }).annotate({ identifier: `V2Event.${definition.type}` }), - ), + ...definitions, ...(definitions.some((definition) => definition.type === "server.connected") ? [] : [ @@ -32,14 +26,14 @@ const schema = (definitions: ReadonlyArray) => ]), ]).annotate({ identifier: "V2Event" }) -const make = (definitions: ReadonlyArray) => { +const make = >(definitions: Definitions) => { const EventSchema = schema(definitions) return { schema: EventSchema, group: HttpApiGroup.make("server.event") .add( HttpApiEndpoint.get("event.subscribe", "/api/event", { - success: EventSchema, + success: HttpApiSchema.StreamSse({ data: EventSchema }), }).annotateMerge( OpenApi.annotations({ identifier: "v2.event.subscribe", @@ -52,8 +46,11 @@ const make = (definitions: ReadonlyArray) => { } } -export const makeEventGroup = (definitions: ReadonlyArray) => make(definitions).group +export const makeEventGroup = >(definitions: Definitions) => + make(definitions).group const event = make(EventManifest.ServerDefinitions) export const EventGroup = event.group -export type Event = typeof event.schema.Type +export const OpenCodeEvent = event.schema +export type OpenCodeEvent = typeof OpenCodeEvent.Type +export type OpenCodeEventEncoded = typeof OpenCodeEvent.Encoded diff --git a/packages/sdk-next/src/index.ts b/packages/sdk-next/src/index.ts index ec948e03ca..fc23219fc4 100644 --- a/packages/sdk-next/src/index.ts +++ b/packages/sdk-next/src/index.ts @@ -14,3 +14,4 @@ export { SessionInput, SessionMessage, } from "@opencode-ai/client/effect" +export type { OpenCodeEvent } from "@opencode-ai/client/effect" diff --git a/packages/sdk-next/test/embedded.test.ts b/packages/sdk-next/test/embedded.test.ts index dbd7ed9378..5c1b8b238a 100644 --- a/packages/sdk-next/test/embedded.test.ts +++ b/packages/sdk-next/test/embedded.test.ts @@ -3,7 +3,8 @@ import { mkdtemp, rm } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" import { Flag } from "@opencode-ai/core/flag/flag" -import { Effect, Option, Schema, Stream } from "effect" +import { Deferred, Effect, Latch, Option, Schema, Stream } from "effect" +import type { OpenCodeEvent } from "../src" test("embedded client uses the real router and handlers", async () => { const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-")) @@ -103,6 +104,88 @@ test("embedded client uses the real router and handlers", async () => { } }) +test("Location-owned runner events reach the ready global client", async () => { + const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-events-")) + const database = Flag.OPENCODE_DB + Flag.OPENCODE_DB = join(directory, "opencode.sqlite") + const { AbsolutePath, Location, OpenCode, Prompt, Session } = await import("../src") + const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`) + + try { + const program = Effect.gen(function* () { + const opencode = yield* OpenCode.create() + const connected = yield* Latch.make(false) + const prompted = yield* Deferred.make() + yield* opencode.events.subscribe().pipe( + Stream.runForEach((event) => + event.type === "server.connected" + ? connected.open + : event.type === "session.next.prompted" && event.data.sessionID === sessionID + ? Deferred.succeed(prompted, event).pipe(Effect.asVoid) + : Effect.void, + ), + Effect.forkScoped, + ) + yield* connected.await + yield* opencode.sessions.create({ + id: sessionID, + location: Location.Ref.make({ directory: AbsolutePath.make(directory) }), + }) + yield* opencode.sessions.prompt({ sessionID, prompt: Prompt.make({ text: "Observe this input" }) }) + + const event = yield* Deferred.await(prompted).pipe(Effect.timeout("4 seconds")) + expect(event.durable).toEqual(expect.objectContaining({ aggregateID: sessionID, seq: expect.any(Number) })) + }) + await Effect.runPromise(Effect.scoped(program)) + } finally { + Flag.OPENCODE_DB = database + await rm(directory, { recursive: true, force: true }) + } +}, 10_000) + +test("independent embedded hosts do not share live notifications", async () => { + const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-hosts-")) + const database = Flag.OPENCODE_DB + Flag.OPENCODE_DB = join(directory, "opencode.sqlite") + const { AbsolutePath, Agent, Location, OpenCode, Session } = await import("../src") + const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`) + + try { + const program = Effect.gen(function* () { + const first = yield* OpenCode.create() + const second = yield* OpenCode.create() + const firstReady = yield* Latch.make(false) + const secondReady = yield* Latch.make(false) + const firstEvent = yield* Latch.make(false) + const secondEvent = yield* Latch.make(false) + const observe = (ready: Latch.Latch, event: Latch.Latch) => + Stream.runForEach((notification: OpenCodeEvent) => + notification.type === "server.connected" + ? ready.open + : notification.type === "session.next.agent.switched" && notification.data.sessionID === sessionID + ? event.open + : Effect.void, + ) + + yield* first.events.subscribe().pipe(observe(firstReady, firstEvent), Effect.forkScoped) + yield* second.events.subscribe().pipe(observe(secondReady, secondEvent), Effect.forkScoped) + yield* Effect.all([firstReady.await, secondReady.await], { discard: true }) + yield* first.sessions.create({ + id: sessionID, + location: Location.Ref.make({ directory: AbsolutePath.make(directory) }), + }) + yield* first.sessions.switchAgent({ sessionID, agent: Agent.ID.make("plan") }) + + yield* firstEvent.await.pipe(Effect.timeout("2 seconds")) + expect(Option.isNone(yield* secondEvent.await.pipe(Effect.timeoutOption("100 millis")))).toBe(true) + }) + await Effect.runPromise(Effect.scoped(program)) + } finally { + Flag.OPENCODE_DB = database + await rm(directory, { recursive: true, force: true }) + } +}, 10_000) + test("embedded client is available as a Layer service", async () => { const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-layer-")) const database = Flag.OPENCODE_DB diff --git a/packages/server/src/handlers/event.ts b/packages/server/src/handlers/event.ts index 8001fb8748..47e94731ee 100644 --- a/packages/server/src/handlers/event.ts +++ b/packages/server/src/handlers/event.ts @@ -1,16 +1,19 @@ import { EventV2 } from "@opencode-ai/core/event" -import { Effect, Stream } from "effect" +import { OpenCodeEvent } from "@opencode-ai/protocol/groups/event" +import { Effect, Schema, Stream } from "effect" import { HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" import * as Sse from "effect/unstable/encoding/Sse" import { Api } from "../api" +const subscriberCapacity = 256 + function eventData(data: unknown): Sse.Event { return { _tag: "Event", event: "message", id: undefined, - data: JSON.stringify(data), + data: JSON.stringify(Schema.encodeUnknownSync(OpenCodeEvent)(data)), } } @@ -24,13 +27,16 @@ export const EventHandler = HttpApiBuilder.group(Api, "server.event", (handlers) type: "server.connected", data: {}, } + const output = Stream.unwrap( + Effect.gen(function* () { + // Acquiring the bounded stream installs its listener before readiness is observable. + const live = yield* EventV2.allBounded(events, subscriberCapacity) + return Stream.make(connected).pipe(Stream.concat(live)) + }), + ).pipe(Stream.map(eventData), Stream.pipeThroughChannel(Sse.encode())) + const heartbeat = Stream.tick("15 seconds").pipe(Stream.map(() => ": heartbeat\n\n")) return HttpServerResponse.stream( - Stream.make(connected).pipe( - Stream.concat(events.all()), - Stream.map(eventData), - Stream.pipeThroughChannel(Sse.encode()), - Stream.encodeText, - ), + output.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }), Stream.encodeText), { contentType: "text/event-stream", headers: {