mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-10 01:39:36 +00:00
feat(sdk): expose live event stream (#34098)
This commit is contained in:
parent
2c02f8bace
commit
42e6b7db32
19 changed files with 396 additions and 57 deletions
|
|
@ -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)),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 +1,2 @@
|
|||
export * from "./generated/index"
|
||||
export type { EventsSubscribeOutput as OpenCodeEvent } from "./generated/types"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -27,13 +27,44 @@ const Event = Schema.Struct({
|
|||
data: Schema.Unknown,
|
||||
})
|
||||
|
||||
async function readEvent(reader: ReadableStreamDefaultReader<Uint8Array>) {
|
||||
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<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 readEventType(reader: ReadableStreamDefaultReader<Uint8Array>, type: string) {
|
||||
async function readEvent(reader: AsyncIterator<typeof Event.Type>) {
|
||||
const value = await reader.next()
|
||||
if (value.done) throw new Error("event stream closed")
|
||||
return value.value
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -14,3 +14,4 @@ export {
|
|||
SessionInput,
|
||||
SessionMessage,
|
||||
} from "@opencode-ai/client/effect"
|
||||
export type { OpenCodeEvent } from "@opencode-ai/client/effect"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue