feat(sdk): expose live event stream (#34098)
This commit is contained in:
parent
2c02f8bace
commit
42e6b7db32
19 changed files with 395 additions and 56 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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue