feat(server): stream events across locations

This commit is contained in:
Dax Raad
2026-06-22 08:12:43 -04:00
parent 57ce1b9ca8
commit 595cc91ddc
11 changed files with 2088 additions and 226 deletions
+12 -6
View File
@@ -30,6 +30,15 @@ export type Definition<Type extends string = string, DataSchema extends Schema.T
export type Data<D extends Definition> = Schema.Schema.Type<D["data"]> export type Data<D extends Definition> = Schema.Schema.Type<D["data"]>
export const Payload = Schema.Struct({
id: ID,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
type: Schema.String,
durable: Schema.optional(Schema.Struct({ aggregateID: Schema.String, seq: Schema.Int, version: Schema.Int })),
location: Schema.optional(Location.Ref),
data: Schema.Unknown,
})
export type Payload<D extends Definition = Definition> = { export type Payload<D extends Definition = Definition> = {
readonly id: ID readonly id: ID
readonly type: D["type"] readonly type: D["type"]
@@ -78,16 +87,13 @@ export function define<const Type extends string, Fields extends Schema.Struct.F
readonly schema: Fields readonly schema: Fields
}): Schema.Schema<Payload<Definition<Type, Schema.Struct<Fields>>>> & Definition<Type, Schema.Struct<Fields>> { }): Schema.Schema<Payload<Definition<Type, Schema.Struct<Fields>>>> & Definition<Type, Schema.Struct<Fields>> {
const Data = Schema.Struct(input.schema) const Data = Schema.Struct(input.schema)
const Payload = Schema.Struct({ const Event = Schema.Struct({
id: ID, ...Payload.fields,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
type: Schema.Literal(input.type), type: Schema.Literal(input.type),
durable: Schema.optional(Schema.Struct({ aggregateID: Schema.String, seq: Schema.Number, version: Schema.Number })),
location: Schema.optional(Location.Ref),
data: Data, data: Data,
}).annotate({ identifier: input.type }) }).annotate({ identifier: input.type })
const definition = Object.assign(Payload, { const definition = Object.assign(Event, {
type: input.type, type: input.type,
...(input.durable === undefined ? {} : { durable: input.durable }), ...(input.durable === undefined ? {} : { durable: input.durable }),
data: Data, data: Data,
@@ -152,7 +152,7 @@ function matchLegacyOpenApi(input: Record<string, unknown>) {
normalizeLegacyErrorResponses(operation) normalizeLegacyErrorResponses(operation)
} }
normalizeLegacyOperation(operation, path, method) normalizeLegacyOperation(operation, path, method)
if ((path === "/event" || path === "/global/event") && method === "get") { if ((path === "/event" || path === "/global/event" || path === "/api/event") && method === "get") {
// HttpApi has no first-class SSE response schema, and these handlers are // HttpApi has no first-class SSE response schema, and these handlers are
// raw/streaming routes. Document the actual wire protocol explicitly. // raw/streaming routes. Document the actual wire protocol explicitly.
operation.responses!["200"] = { operation.responses!["200"] = {
@@ -162,7 +162,9 @@ function matchLegacyOpenApi(input: Record<string, unknown>) {
schema: schema:
path === "/event" path === "/event"
? { $ref: "#/components/schemas/Event" } ? { $ref: "#/components/schemas/Event" }
: { $ref: "#/components/schemas/GlobalEvent" }, : path === "/global/event"
? { $ref: "#/components/schemas/GlobalEvent" }
: { $ref: "#/components/schemas/V2Event" },
}, },
}, },
} }
@@ -734,11 +734,11 @@ const scenarios: Scenario[] = [
.stream() .stream()
.status( .status(
200, 200,
(ctx, result) => (_ctx, result) =>
Effect.sync(() => { Effect.sync(() => {
check(result.contentType.includes("text/event-stream"), "v2 event should be an SSE stream") check(result.contentType.includes("text/event-stream"), "v2 event should be an SSE stream")
check(result.text.includes("server.connected"), "v2 event should emit initial connection event") check(result.text.includes("server.connected"), "v2 event should emit initial connection event")
check(!!ctx.directory && result.text.includes(ctx.directory), "v2 event should include the resolved location") check(!result.text.includes('"location"'), "v2 connection event should not be scoped to a location")
}), }),
"status", "status",
), ),
@@ -21,10 +21,12 @@ function request(route: string, directory: string, init: RequestInit = {}) {
const Event = Schema.Struct({ const Event = Schema.Struct({
id: Schema.String, id: Schema.String,
type: Schema.String, type: Schema.String,
location: Schema.Struct({ location: Schema.optional(
Schema.Struct({
directory: Schema.String, directory: Schema.String,
project: Schema.Struct({ id: Schema.String, directory: Schema.String }), project: Schema.Struct({ id: Schema.String, directory: Schema.String }),
}), }),
),
data: Schema.Unknown, data: Schema.Unknown,
}) })
@@ -64,17 +66,20 @@ describe("v2 location HttpApi", () => {
} }
}) })
test("streams native EventV2 payloads with resolved locations", async () => { test("streams native EventV2 payloads across locations", async () => {
await using tmp = await tmpdir({ git: true }) await using subscriber = await tmpdir({ git: true })
const response = await request("/api/event", tmp.path) await using publisher = await tmpdir({ git: true })
const response = await request("/api/event", subscriber.path)
const reader = response.body!.getReader() const reader = response.body!.getReader()
expect((await readEvent(reader)).type).toBe("server.connected") const connected = await readEvent(reader)
expect(connected.type).toBe("server.connected")
expect(connected.location).toBeUndefined()
const created = await request("/session", tmp.path, { method: "POST" }) const created = await request("/session", publisher.path, { method: "POST" })
expect(created.status).toBe(200) expect(created.status).toBe(200)
expect(await readEventType(reader, "session.created")).toMatchObject({ expect(await readEventType(reader, "session.created")).toMatchObject({
type: "session.created", type: "session.created",
location: { directory: tmp.path, project: { directory: tmp.path } }, location: { directory: publisher.path, project: { directory: publisher.path } },
data: { sessionID: expect.any(String) }, data: { sessionID: expect.any(String) },
}) })
await reader.cancel() await reader.cancel()
+2 -12
View File
@@ -6243,22 +6243,12 @@ export class Event2 extends HeyApiClient {
/** /**
* Subscribe to events * Subscribe to events
* *
* Subscribe to native event payloads for a location. * Subscribe to native event payloads for the server.
*/ */
public subscribe<ThrowOnError extends boolean = false>( public subscribe<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
parameters?: {
location?: {
directory?: string
workspace?: string
}
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }])
return (options?.client ?? this.client).sse.get<V2EventSubscribeResponses, V2EventSubscribeErrors, ThrowOnError>({ return (options?.client ?? this.client).sse.get<V2EventSubscribeResponses, V2EventSubscribeErrors, ThrowOnError>({
url: "/api/event", url: "/api/event",
...options, ...options,
...params,
}) })
} }
} }
File diff suppressed because it is too large Load Diff
+18 -18
View File
@@ -1,34 +1,34 @@
import { EventV2 } from "@opencode-ai/core/event" import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { Schema } from "effect" import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location"
const Event = Schema.Struct({ const Event = Schema.Union([
id: EventV2.ID, ...EventV2.definitions().map((definition) =>
type: Schema.String, Schema.Struct({
location: Location.Info.pipe(Schema.optional), ...EventV2.Payload.fields,
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), type: Schema.Literal(definition.type),
version: Schema.Number.pipe(Schema.optional), data: definition.data,
data: Schema.Unknown, }).annotate({ identifier: `V2Event.${definition.type}` }),
}) ),
Schema.Struct({
...EventV2.Payload.fields,
type: Schema.Literal("server.connected"),
data: Schema.Struct({}),
}).annotate({ identifier: "V2Event.server.connected" }),
]).annotate({ identifier: "V2Event" })
export const EventGroup = HttpApiGroup.make("server.event") export const EventGroup = HttpApiGroup.make("server.event")
.add( .add(
HttpApiEndpoint.get("event.subscribe", "/api/event", { HttpApiEndpoint.get("event.subscribe", "/api/event", {
query: LocationQuery, success: Event,
success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/event-stream" })), }).annotateMerge(
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "v2.event.subscribe", identifier: "v2.event.subscribe",
summary: "Subscribe to events", summary: "Subscribe to events",
description: "Subscribe to native event payloads for a location.", description: "Subscribe to native event payloads for the server.",
}), }),
), ),
) )
.annotateMerge(OpenApi.annotations({ title: "events", description: "Experimental event stream route." })) .annotateMerge(OpenApi.annotations({ title: "events", description: "Experimental event stream route." }))
.middleware(LocationMiddleware)
export type Event = typeof Event.Type export type Event = typeof Event.Type
+1 -18
View File
@@ -1,5 +1,4 @@
import { EventV2 } from "@opencode-ai/core/event" import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { Effect, Stream } from "effect" import { Effect, Stream } from "effect"
import { HttpServerResponse } from "effect/unstable/http" import { HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi" import { HttpApiBuilder } from "effect/unstable/httpapi"
@@ -20,30 +19,14 @@ export const EventHandler = HttpApiBuilder.group(Api, "server.event", (handlers)
const events = yield* EventV2.Service const events = yield* EventV2.Service
return handlers.handleRaw("event.subscribe", () => return handlers.handleRaw("event.subscribe", () =>
Effect.gen(function* () { Effect.gen(function* () {
const location = yield* Location.Service
const connected = { const connected = {
id: EventV2.ID.create(), id: EventV2.ID.create(),
type: "server.connected", type: "server.connected",
location: new Location.Info({
directory: location.directory,
workspaceID: location.workspaceID,
project: location.project,
}),
data: {}, data: {},
} }
return HttpServerResponse.stream( return HttpServerResponse.stream(
Stream.make(connected).pipe( Stream.make(connected).pipe(
Stream.concat( Stream.concat(events.all()),
events
.all()
.pipe(
Stream.filter(
(event) =>
event.location?.directory === location.directory &&
event.location.workspaceID === location.workspaceID,
),
),
),
Stream.map(eventData), Stream.map(eventData),
Stream.pipeThroughChannel(Sse.encode()), Stream.pipeThroughChannel(Sse.encode()),
Stream.encodeText, Stream.encodeText,
+147 -135
View File
@@ -1,9 +1,7 @@
import { useEvent } from "./event"
import type { import type {
AgentV2Info, AgentV2Info,
CommandV2Info, CommandV2Info,
IntegrationInfo, IntegrationInfo,
Event,
LocationRef, LocationRef,
ModelV2Info, ModelV2Info,
PermissionSavedInfo, PermissionSavedInfo,
@@ -18,11 +16,12 @@ import type {
SessionMessageAssistantTool, SessionMessageAssistantTool,
SessionV2Info, SessionV2Info,
SkillV2Info, SkillV2Info,
V2Event,
} from "@opencode-ai/sdk/v2" } from "@opencode-ai/sdk/v2"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "./helper" import { createSimpleContext } from "./helper"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
import { createSignal, onMount } from "solid-js" import { createSignal, onCleanup, onMount } from "solid-js"
type LocationData = { type LocationData = {
agent?: AgentV2Info[] agent?: AgentV2Info[]
@@ -71,7 +70,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
location: {}, location: {},
}) })
const event = useEvent()
const sdk = useSDK() const sdk = useSDK()
const [defaultLocation, setDefaultLocation] = createSignal<LocationRef>({ const [defaultLocation, setDefaultLocation] = createSignal<LocationRef>({
directory: sdk.directory ?? process.cwd(), directory: sdk.directory ?? process.cwd(),
@@ -121,7 +119,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
}, },
} }
event.subscribe((event, metadata) => { function handleEvent(event: V2Event, metadata: { directory: string; workspace: string | undefined }) {
switch (event.type) { switch (event.type) {
case "catalog.updated": case "catalog.updated":
void Promise.all([ void Promise.all([
@@ -130,34 +128,34 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
]) ])
break break
case "session.next.agent.switched": case "session.next.agent.switched":
message.update(event.properties.sessionID, (draft) => { message.update(event.data.sessionID, (draft) => {
message.prepend(draft, { message.prepend(draft, {
id: event.properties.messageID, id: event.data.messageID,
type: "agent-switched", type: "agent-switched",
agent: event.properties.agent, agent: event.data.agent,
time: { created: event.properties.timestamp }, time: { created: event.data.timestamp },
}) })
}) })
break break
case "session.next.model.switched": case "session.next.model.switched":
message.update(event.properties.sessionID, (draft) => { message.update(event.data.sessionID, (draft) => {
message.prepend(draft, { message.prepend(draft, {
id: event.properties.messageID, id: event.data.messageID,
type: "model-switched", type: "model-switched",
model: event.properties.model, model: event.data.model,
time: { created: event.properties.timestamp }, time: { created: event.data.timestamp },
}) })
}) })
break break
case "session.next.prompted": { case "session.next.prompted": {
message.update(event.properties.sessionID, (draft) => { message.update(event.data.sessionID, (draft) => {
message.prepend(draft, { message.prepend(draft, {
id: event.properties.messageID, id: event.data.messageID,
type: "user", type: "user",
text: event.properties.prompt.text, text: event.data.prompt.text,
files: event.properties.prompt.files, files: event.data.prompt.files,
agents: event.properties.prompt.agents, agents: event.data.prompt.agents,
time: { created: event.properties.timestamp }, time: { created: event.data.timestamp },
}) })
}) })
break break
@@ -165,248 +163,248 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
case "session.next.prompt.admitted": case "session.next.prompt.admitted":
break break
case "session.next.prompt.promoted": case "session.next.prompt.promoted":
message.update(event.properties.sessionID, (draft) => { message.update(event.data.sessionID, (draft) => {
message.prepend(draft, { message.prepend(draft, {
id: event.properties.messageID, id: event.data.messageID,
type: "user", type: "user",
text: event.properties.prompt.text, text: event.data.prompt.text,
files: event.properties.prompt.files, files: event.data.prompt.files,
agents: event.properties.prompt.agents, agents: event.data.prompt.agents,
time: { created: event.properties.timeCreated }, time: { created: event.data.timeCreated },
}) })
}) })
break break
case "session.next.context.updated": case "session.next.context.updated":
message.update(event.properties.sessionID, (draft) => { message.update(event.data.sessionID, (draft) => {
message.prepend(draft, { message.prepend(draft, {
id: event.properties.messageID, id: event.data.messageID,
type: "system", type: "system",
text: event.properties.text, text: event.data.text,
time: { created: event.properties.timestamp }, time: { created: event.data.timestamp },
}) })
}) })
break break
case "session.next.synthetic": case "session.next.synthetic":
message.update(event.properties.sessionID, (draft) => { message.update(event.data.sessionID, (draft) => {
message.prepend(draft, { message.prepend(draft, {
id: event.properties.messageID, id: event.data.messageID,
type: "synthetic", type: "synthetic",
sessionID: event.properties.sessionID, sessionID: event.data.sessionID,
text: event.properties.text, text: event.data.text,
time: { created: event.properties.timestamp }, time: { created: event.data.timestamp },
}) })
}) })
break break
case "session.next.shell.started": case "session.next.shell.started":
message.update(event.properties.sessionID, (draft) => { message.update(event.data.sessionID, (draft) => {
message.prepend(draft, { message.prepend(draft, {
id: event.properties.messageID, id: event.data.messageID,
type: "shell", type: "shell",
callID: event.properties.callID, callID: event.data.callID,
command: event.properties.command, command: event.data.command,
output: "", output: "",
time: { created: event.properties.timestamp }, time: { created: event.data.timestamp },
}) })
}) })
break break
case "session.next.shell.ended": case "session.next.shell.ended":
message.update(event.properties.sessionID, (draft) => { message.update(event.data.sessionID, (draft) => {
const match = message.activeShell(draft, event.properties.callID) const match = message.activeShell(draft, event.data.callID)
if (!match) return if (!match) return
match.output = event.properties.output match.output = event.data.output
match.time.completed = event.properties.timestamp match.time.completed = event.data.timestamp
}) })
break break
case "session.next.step.started": case "session.next.step.started":
message.update(event.properties.sessionID, (draft) => { message.update(event.data.sessionID, (draft) => {
if (draft.some((message) => message.id === event.properties.assistantMessageID)) return if (draft.some((message) => message.id === event.data.assistantMessageID)) return
const currentAssistant = message.activeAssistant(draft) const currentAssistant = message.activeAssistant(draft)
if (currentAssistant) currentAssistant.time.completed = event.properties.timestamp if (currentAssistant) currentAssistant.time.completed = event.data.timestamp
message.prepend(draft, { message.prepend(draft, {
id: event.properties.assistantMessageID, id: event.data.assistantMessageID,
type: "assistant", type: "assistant",
agent: event.properties.agent, agent: event.data.agent,
model: event.properties.model, model: event.data.model,
content: [], content: [],
snapshot: event.properties.snapshot ? { start: event.properties.snapshot } : undefined, snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined,
time: { created: event.properties.timestamp }, time: { created: event.data.timestamp },
}) })
}) })
break break
case "session.next.step.ended": case "session.next.step.ended":
message.update(event.properties.sessionID, (draft) => { message.update(event.data.sessionID, (draft) => {
const currentAssistant = message.assistant(draft, event.properties.assistantMessageID) const currentAssistant = message.assistant(draft, event.data.assistantMessageID)
if (!currentAssistant) return if (!currentAssistant) return
currentAssistant.time.completed = event.properties.timestamp currentAssistant.time.completed = event.data.timestamp
currentAssistant.finish = event.properties.finish currentAssistant.finish = event.data.finish
currentAssistant.cost = event.properties.cost currentAssistant.cost = event.data.cost
currentAssistant.tokens = event.properties.tokens currentAssistant.tokens = event.data.tokens
if (event.properties.snapshot) if (event.data.snapshot)
currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.properties.snapshot } currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.data.snapshot }
}) })
break break
case "session.next.step.failed": case "session.next.step.failed":
message.update(event.properties.sessionID, (draft) => { message.update(event.data.sessionID, (draft) => {
const currentAssistant = message.assistant(draft, event.properties.assistantMessageID) const currentAssistant = message.assistant(draft, event.data.assistantMessageID)
if (!currentAssistant) return if (!currentAssistant) return
currentAssistant.time.completed = event.properties.timestamp currentAssistant.time.completed = event.data.timestamp
currentAssistant.finish = "error" currentAssistant.finish = "error"
currentAssistant.error = event.properties.error currentAssistant.error = event.data.error
}) })
break break
case "session.next.text.started": case "session.next.text.started":
message.update(event.properties.sessionID, (draft) => { message.update(event.data.sessionID, (draft) => {
message.assistant(draft, event.properties.assistantMessageID)?.content.push({ message.assistant(draft, event.data.assistantMessageID)?.content.push({
type: "text", type: "text",
id: event.properties.textID, id: event.data.textID,
text: "", text: "",
}) })
}) })
break break
case "session.next.text.delta": case "session.next.text.delta":
message.update(event.properties.sessionID, (draft) => { message.update(event.data.sessionID, (draft) => {
const match = message.latestText( const match = message.latestText(
message.assistant(draft, event.properties.assistantMessageID), message.assistant(draft, event.data.assistantMessageID),
event.properties.textID, event.data.textID,
) )
if (match) match.text += event.properties.delta if (match) match.text += event.data.delta
}) })
break break
case "session.next.text.ended": case "session.next.text.ended":
message.update(event.properties.sessionID, (draft) => { message.update(event.data.sessionID, (draft) => {
const match = message.latestText( const match = message.latestText(
message.assistant(draft, event.properties.assistantMessageID), message.assistant(draft, event.data.assistantMessageID),
event.properties.textID, event.data.textID,
) )
if (match) match.text = event.properties.text if (match) match.text = event.data.text
}) })
break break
case "session.next.tool.input.started": case "session.next.tool.input.started":
message.update(event.properties.sessionID, (draft) => { message.update(event.data.sessionID, (draft) => {
message.assistant(draft, event.properties.assistantMessageID)?.content.push({ message.assistant(draft, event.data.assistantMessageID)?.content.push({
type: "tool", type: "tool",
id: event.properties.callID, id: event.data.callID,
name: event.properties.name, name: event.data.name,
time: { created: event.properties.timestamp }, time: { created: event.data.timestamp },
state: { status: "pending", input: "" }, state: { status: "pending", input: "" },
}) })
}) })
break break
case "session.next.tool.input.delta": case "session.next.tool.input.delta":
message.update(event.properties.sessionID, (draft) => { message.update(event.data.sessionID, (draft) => {
const match = message.latestTool( const match = message.latestTool(
message.assistant(draft, event.properties.assistantMessageID), message.assistant(draft, event.data.assistantMessageID),
event.properties.callID, event.data.callID,
) )
if (match?.state.status === "pending") match.state.input += event.properties.delta if (match?.state.status === "pending") match.state.input += event.data.delta
}) })
break break
case "session.next.tool.input.ended": case "session.next.tool.input.ended":
message.update(event.properties.sessionID, (draft) => { message.update(event.data.sessionID, (draft) => {
const match = message.latestTool( const match = message.latestTool(
message.assistant(draft, event.properties.assistantMessageID), message.assistant(draft, event.data.assistantMessageID),
event.properties.callID, event.data.callID,
) )
if (match?.state.status === "pending") match.state.input = event.properties.text if (match?.state.status === "pending") match.state.input = event.data.text
}) })
break break
case "session.next.tool.called": case "session.next.tool.called":
message.update(event.properties.sessionID, (draft) => { message.update(event.data.sessionID, (draft) => {
const match = message.latestTool( const match = message.latestTool(
message.assistant(draft, event.properties.assistantMessageID), message.assistant(draft, event.data.assistantMessageID),
event.properties.callID, event.data.callID,
) )
if (!match) return if (!match) return
match.time.ran = event.properties.timestamp match.time.ran = event.data.timestamp
match.provider = event.properties.provider match.provider = event.data.provider
match.state = { status: "running", input: event.properties.input, structured: {}, content: [] } match.state = { status: "running", input: event.data.input, structured: {}, content: [] }
}) })
break break
case "session.next.tool.progress": case "session.next.tool.progress":
message.update(event.properties.sessionID, (draft) => { message.update(event.data.sessionID, (draft) => {
const match = message.latestTool( const match = message.latestTool(
message.assistant(draft, event.properties.assistantMessageID), message.assistant(draft, event.data.assistantMessageID),
event.properties.callID, event.data.callID,
) )
if (match?.state.status !== "running") return if (match?.state.status !== "running") return
match.state.structured = event.properties.structured match.state.structured = event.data.structured
match.state.content = [...event.properties.content] match.state.content = [...event.data.content]
}) })
break break
case "session.next.tool.success": case "session.next.tool.success":
message.update(event.properties.sessionID, (draft) => { message.update(event.data.sessionID, (draft) => {
const match = message.latestTool( const match = message.latestTool(
message.assistant(draft, event.properties.assistantMessageID), message.assistant(draft, event.data.assistantMessageID),
event.properties.callID, event.data.callID,
) )
if (match?.state.status !== "running") return if (match?.state.status !== "running") return
match.state = { match.state = {
status: "completed", status: "completed",
input: match.state.input, input: match.state.input,
structured: event.properties.structured, structured: event.data.structured,
content: [...event.properties.content], content: [...event.data.content],
result: event.properties.result, result: event.data.result,
} }
match.provider = { match.provider = {
executed: event.properties.provider.executed || match.provider?.executed === true, executed: event.data.provider.executed || match.provider?.executed === true,
metadata: match.provider?.metadata, metadata: match.provider?.metadata,
resultMetadata: event.properties.provider.metadata, resultMetadata: event.data.provider.metadata,
} }
match.time.completed = event.properties.timestamp match.time.completed = event.data.timestamp
}) })
break break
case "session.next.tool.failed": case "session.next.tool.failed":
message.update(event.properties.sessionID, (draft) => { message.update(event.data.sessionID, (draft) => {
const match = message.latestTool( const match = message.latestTool(
message.assistant(draft, event.properties.assistantMessageID), message.assistant(draft, event.data.assistantMessageID),
event.properties.callID, event.data.callID,
) )
if (!match || (match.state.status !== "pending" && match.state.status !== "running")) return if (!match || (match.state.status !== "pending" && match.state.status !== "running")) return
match.state = { match.state = {
status: "error", status: "error",
error: event.properties.error, error: event.data.error,
input: typeof match.state.input === "string" ? {} : match.state.input, input: typeof match.state.input === "string" ? {} : match.state.input,
structured: match.state.status === "running" ? match.state.structured : {}, structured: match.state.status === "running" ? match.state.structured : {},
content: match.state.status === "running" ? match.state.content : [], content: match.state.status === "running" ? match.state.content : [],
result: event.properties.result, result: event.data.result,
} }
match.provider = { match.provider = {
executed: event.properties.provider.executed || match.provider?.executed === true, executed: event.data.provider.executed || match.provider?.executed === true,
metadata: match.provider?.metadata, metadata: match.provider?.metadata,
resultMetadata: event.properties.provider.metadata, resultMetadata: event.data.provider.metadata,
} }
match.time.completed = event.properties.timestamp match.time.completed = event.data.timestamp
}) })
break break
case "session.next.reasoning.started": case "session.next.reasoning.started":
message.update(event.properties.sessionID, (draft) => { message.update(event.data.sessionID, (draft) => {
message.assistant(draft, event.properties.assistantMessageID)?.content.push({ message.assistant(draft, event.data.assistantMessageID)?.content.push({
type: "reasoning", type: "reasoning",
id: event.properties.reasoningID, id: event.data.reasoningID,
text: "", text: "",
providerMetadata: event.properties.providerMetadata, providerMetadata: event.data.providerMetadata,
}) })
}) })
break break
case "session.next.reasoning.delta": case "session.next.reasoning.delta":
message.update(event.properties.sessionID, (draft) => { message.update(event.data.sessionID, (draft) => {
const match = message.latestReasoning( const match = message.latestReasoning(
message.assistant(draft, event.properties.assistantMessageID), message.assistant(draft, event.data.assistantMessageID),
event.properties.reasoningID, event.data.reasoningID,
) )
if (match) match.text += event.properties.delta if (match) match.text += event.data.delta
}) })
break break
case "session.next.reasoning.ended": case "session.next.reasoning.ended":
message.update(event.properties.sessionID, (draft) => { message.update(event.data.sessionID, (draft) => {
const match = message.latestReasoning( const match = message.latestReasoning(
message.assistant(draft, event.properties.assistantMessageID), message.assistant(draft, event.data.assistantMessageID),
event.properties.reasoningID, event.data.reasoningID,
) )
if (match) { if (match) {
match.text = event.properties.text match.text = event.data.text
if (event.properties.providerMetadata !== undefined) if (event.data.providerMetadata !== undefined)
match.providerMetadata = event.properties.providerMetadata match.providerMetadata = event.data.providerMetadata
} }
}) })
break break
@@ -415,14 +413,14 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
case "session.next.compaction.delta": case "session.next.compaction.delta":
break break
case "session.next.compaction.ended": case "session.next.compaction.ended":
message.update(event.properties.sessionID, (draft) => { message.update(event.data.sessionID, (draft) => {
message.prepend(draft, { message.prepend(draft, {
id: event.properties.messageID, id: event.data.messageID,
type: "compaction", type: "compaction",
reason: event.properties.reason, reason: event.data.reason,
summary: event.properties.text, summary: event.data.text,
recent: event.properties.recent, recent: event.data.recent,
time: { created: event.properties.timestamp }, time: { created: event.data.timestamp },
}) })
}) })
break break
@@ -437,6 +435,20 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
]) ])
break break
} }
}
onMount(() => {
const controller = new AbortController()
onCleanup(() => controller.abort())
void (async () => {
const events = await sdk.client.v2.event.subscribe({ signal: controller.signal })
for await (const event of events.stream) {
handleEvent(event, {
directory: event.location?.directory ?? defaultLocation().directory,
workspace: event.location?.workspaceID,
})
}
})().catch(() => {})
}) })
const result = { const result = {
+9 -9
View File
@@ -26,6 +26,7 @@ function emitEvent(events: ReturnType<typeof createEventSource>, payload: Event)
} }
test("refreshes resources into reactive getters", async () => { test("refreshes resources into reactive getters", async () => {
const events = createEventSource()
const location = { const location = {
directory, directory,
project: { id: "proj_test", directory }, project: { id: "proj_test", directory },
@@ -49,8 +50,7 @@ test("refreshes resources into reactive getters", async () => {
data: [{ id: "build", request: { headers: {}, body: {} }, mode: "primary", hidden: false, permissions: [] }], data: [{ id: "build", request: { headers: {}, body: {} }, mode: "primary", hidden: false, permissions: [] }],
}) })
return undefined return undefined
}) }, events)
const events = createEventSource()
let data!: ReturnType<typeof useData> let data!: ReturnType<typeof useData>
let ready!: () => void let ready!: () => void
const mounted = new Promise<void>((resolve) => { const mounted = new Promise<void>((resolve) => {
@@ -119,7 +119,7 @@ test("refreshes integrations after integration updates", async () => {
}, },
], ],
}) })
}) }, events)
let data!: ReturnType<typeof useData> let data!: ReturnType<typeof useData>
let ready!: () => void let ready!: () => void
const mounted = new Promise<void>((resolve) => { const mounted = new Promise<void>((resolve) => {
@@ -171,7 +171,7 @@ test("refreshes effective catalog data after catalog updates", async () => {
requests.provider++ requests.provider++
return json({ location: { directory, project: { id: "proj_test", directory } }, data: [] }) return json({ location: { directory, project: { id: "proj_test", directory } }, data: [] })
} }
}) }, events)
const app = await testRender(() => ( const app = await testRender(() => (
<TestTuiContexts> <TestTuiContexts>
@@ -205,7 +205,7 @@ test("refreshes references after updates", async () => {
location: { directory, project: { id: "proj_test", directory } }, location: { directory, project: { id: "proj_test", directory } },
data: requests === 1 ? [] : [{ name: "docs", path: "/docs", source: { type: "local", path: "/docs" } }], data: requests === 1 ? [] : [{ name: "docs", path: "/docs", source: { type: "local", path: "/docs" } }],
}) })
}) }, events)
let data!: ReturnType<typeof useData> let data!: ReturnType<typeof useData>
let ready!: () => void let ready!: () => void
const mounted = new Promise<void>((resolve) => { const mounted = new Promise<void>((resolve) => {
@@ -243,7 +243,7 @@ test("refreshes references after updates", async () => {
test("settles pending tools when a live failure arrives", async () => { test("settles pending tools when a live failure arrives", async () => {
const events = createEventSource() const events = createEventSource()
const calls = createFetch() const calls = createFetch(undefined, events)
let sync!: ReturnType<typeof useData> let sync!: ReturnType<typeof useData>
let ready!: () => void let ready!: () => void
const mounted = new Promise<void>((resolve) => { const mounted = new Promise<void>((resolve) => {
@@ -372,7 +372,7 @@ test("settles pending tools when a live failure arrives", async () => {
test("renders admitted prompts only after promotion", async () => { test("renders admitted prompts only after promotion", async () => {
const events = createEventSource() const events = createEventSource()
const calls = createFetch() const calls = createFetch(undefined, events)
let sync!: ReturnType<typeof useData> let sync!: ReturnType<typeof useData>
let ready!: () => void let ready!: () => void
const mounted = new Promise<void>((resolve) => { const mounted = new Promise<void>((resolve) => {
@@ -436,7 +436,7 @@ test("renders admitted prompts only after promotion", async () => {
test("renders a promoted prompt when admission was missed", async () => { test("renders a promoted prompt when admission was missed", async () => {
const events = createEventSource() const events = createEventSource()
const calls = createFetch() const calls = createFetch(undefined, events)
let sync!: ReturnType<typeof useData> let sync!: ReturnType<typeof useData>
let ready!: () => void let ready!: () => void
const mounted = new Promise<void>((resolve) => { const mounted = new Promise<void>((resolve) => {
@@ -484,7 +484,7 @@ test("renders a promoted prompt when admission was missed", async () => {
test("projects live context updates with their message ID", async () => { test("projects live context updates with their message ID", async () => {
const events = createEventSource() const events = createEventSource()
const calls = createFetch() const calls = createFetch(undefined, events)
let sync!: ReturnType<typeof useData> let sync!: ReturnType<typeof useData>
let ready!: () => void let ready!: () => void
const mounted = new Promise<void>((resolve) => { const mounted = new Promise<void>((resolve) => {
+28 -1
View File
@@ -17,6 +17,8 @@ export function eventSource(): EventSource {
export function createEventSource() { export function createEventSource() {
let fn: ((event: GlobalEvent) => void) | undefined let fn: ((event: GlobalEvent) => void) | undefined
let stream: ReadableStreamDefaultController<Uint8Array> | undefined
const pending: Uint8Array[] = []
return { return {
source: { source: {
subscribe: async (handler: (event: GlobalEvent) => void) => { subscribe: async (handler: (event: GlobalEvent) => void) => {
@@ -29,19 +31,44 @@ export function createEventSource() {
emit(event: GlobalEvent) { emit(event: GlobalEvent) {
if (!fn) throw new Error("event source not ready") if (!fn) throw new Error("event source not ready")
fn(event) fn(event)
if (!("properties" in event.payload)) return
const chunk = new TextEncoder().encode(
`data: ${JSON.stringify({
...event.payload,
location: { directory: event.directory, workspaceID: event.workspace },
data: event.payload.properties,
})}\n\n`,
)
if (stream) return stream.enqueue(chunk)
pending.push(chunk)
},
response() {
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
stream = controller
for (const chunk of pending.splice(0)) controller.enqueue(chunk)
},
cancel() {
stream = undefined
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
}, },
} }
} }
export type FetchHandler = (url: URL) => Response | Promise<Response> | undefined export type FetchHandler = (url: URL) => Response | Promise<Response> | undefined
export function createFetch(override?: FetchHandler) { export function createFetch(override?: FetchHandler, events?: ReturnType<typeof createEventSource>) {
const session = [] as URL[] const session = [] as URL[]
const fetch = (async (input: RequestInfo | URL) => { const fetch = (async (input: RequestInfo | URL) => {
const url = new URL(input instanceof Request ? input.url : String(input)) const url = new URL(input instanceof Request ? input.url : String(input))
if (url.pathname === "/session") session.push(url) if (url.pathname === "/session") session.push(url)
const overridden = await override?.(url) const overridden = await override?.(url)
if (overridden) return overridden if (overridden) return overridden
if (url.pathname === "/api/event" && events) return events.response()
if ( if (
[ [