Browse Source

feat(sdk): expose live event stream (#34098)

Kit Langton 2 months ago
parent
commit
42e6b7db32

+ 15 - 6
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)),
       ),
     ],

+ 3 - 1
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<LocationMiddleware>()(
   "@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)

+ 1 - 0
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"

+ 18 - 7
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<typeof Api>
+type RawClient = HttpApiClient.ForApi<typeof ClientApi>
 
 const mapClientError = <E>(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))

+ 8 - 0
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<EventsSubscribeOutput> =>
+        sse<EventsSubscribeOutput>(
+          { method: "GET", path: `/api/event`, successStatus: 200, declaredStatuses: [401, 400], empty: false },
+          requestOptions,
+        ),
+    },
   }
 }
 

+ 4 - 0
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

+ 1 - 0
packages/client/src/index.ts

@@ -1 +1,2 @@
 export * from "./generated/index"
+export type { EventsSubscribeOutput as OpenCodeEvent } from "./generated/types"

+ 2 - 1
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")

+ 44 - 0
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<Record<string, string>> = []
   let historyPage = 0

+ 29 - 0
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

+ 20 - 1
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* <A>(
   }
 })
 
+export class SubscriberOverflowError extends Schema.TaggedErrorClass<SubscriberOverflowError>()(
+  "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<Service, Interface>()("@opencode/Event") {}
 
+export const allBounded = (events: Interface, capacity: number) =>
+  Effect.gen(function* () {
+    const queue = yield* Queue.dropping<Payload, SubscriberOverflowError>(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<void>
 }

+ 64 - 1
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<void>()
+      const release = yield* Deferred.make<void>()
+      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

+ 22 - 11
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<Record<string, { readonly name: string; readonly import: string }>>
+  },
+): 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<Group>, endpoints: Reado
   return { imports: [...new Set(imports)], source }
 }
 
-function renderPromiseTypes(groups: ReadonlyArray<Group>) {
+function renderPromiseTypes(
+  groups: ReadonlyArray<Group>,
+  outputTypes?: Readonly<Record<string, { readonly name: string; readonly import: string }>>,
+) {
   const types = new Map<SchemaAST.AST, string>()
   const typeOf = (schema: Schema.Top, decoded = false) => {
     const projected = decoded ? Schema.toType(schema) : Schema.toEncoded(schema)
@@ -453,13 +461,15 @@ function renderPromiseTypes(groups: ReadonlyArray<Group>) {
           })
           .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<Group>) {
   const json = operations.includes("JsonValue")
     ? "export type JsonValue = null | boolean | number | string | ReadonlyArray<JsonValue> | { 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<Group>) {

+ 18 - 0
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(

+ 37 - 6
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<Uint8Array>) {
-  const value = await reader.read()
+async function* eventStream(body: ReadableStream<Uint8Array>) {
+  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 readEvent(reader: AsyncIterator<typeof Event.Type>) {
+  const value = await reader.next()
   if (value.done) throw new Error("event stream closed")
-  return Schema.decodeUnknownSync(Event)(JSON.parse(new TextDecoder().decode(value.value).replace(/^data: /, "")))
+  return value.value
 }
 
-async function readEventType(reader: ReadableStreamDefaultReader<Uint8Array>, type: string) {
+async function readEventType(reader: AsyncIterator<typeof Event.Type>, 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)
   })
 })

+ 10 - 13
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<Definition>) =>
+const schema = <const Definitions extends ReadonlyArray<Definition>>(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<Definition>) =>
         ]),
   ]).annotate({ identifier: "V2Event" })
 
-const make = (definitions: ReadonlyArray<Definition>) => {
+const make = <const Definitions extends ReadonlyArray<Definition>>(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<Definition>) => {
   }
 }
 
-export const makeEventGroup = (definitions: ReadonlyArray<Definition>) => make(definitions).group
+export const makeEventGroup = <const Definitions extends ReadonlyArray<Definition>>(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

+ 1 - 0
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"

+ 84 - 1
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<OpenCodeEvent>()
+      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

+ 14 - 8
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: {