fix(protocol): keep internal events off SSE (#35378)
This commit is contained in:
@@ -5402,14 +5402,6 @@ export type EventSubscribeOutput =
|
|||||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||||
readonly data: { readonly branch?: string }
|
readonly data: { readonly branch?: string }
|
||||||
}
|
}
|
||||||
| {
|
|
||||||
readonly id: string
|
|
||||||
readonly created: number
|
|
||||||
readonly metadata?: { readonly [x: string]: unknown }
|
|
||||||
readonly type: "mcp.tools.changed"
|
|
||||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
|
||||||
readonly data: { readonly server: string }
|
|
||||||
}
|
|
||||||
| {
|
| {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly created: number
|
readonly created: number
|
||||||
|
|||||||
@@ -160,13 +160,20 @@ export interface Interface {
|
|||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Event") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Event") {}
|
||||||
|
|
||||||
export const liveBounded = (events: Interface, capacity: number) =>
|
export const liveBounded = (
|
||||||
|
events: Interface,
|
||||||
|
options: { readonly capacity: number; readonly accept?: (event: Payload) => boolean },
|
||||||
|
) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const queue = yield* Queue.dropping<Payload, SubscriberOverflowError>(capacity)
|
const queue = yield* Queue.dropping<Payload, SubscriberOverflowError>(options.capacity)
|
||||||
const unsubscribe = yield* events.listen((event) =>
|
const unsubscribe = yield* events.listen((event) =>
|
||||||
Queue.offer(queue, event).pipe(
|
options.accept && !options.accept(event)
|
||||||
|
? Effect.void
|
||||||
|
: Queue.offer(queue, event).pipe(
|
||||||
Effect.flatMap((accepted) =>
|
Effect.flatMap((accepted) =>
|
||||||
accepted ? Effect.void : Queue.fail(queue, new SubscriberOverflowError({ capacity })).pipe(Effect.asVoid),
|
accepted
|
||||||
|
? Effect.void
|
||||||
|
: Queue.fail(queue, new SubscriberOverflowError({ capacity: options.capacity })).pipe(Effect.asVoid),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -23,8 +23,6 @@ import { ToolHooks } from "../tool/hooks"
|
|||||||
import { WorkspaceV2 } from "../workspace"
|
import { WorkspaceV2 } from "../workspace"
|
||||||
|
|
||||||
const mutable = <T>(value: T) => value as DeepMutable<T>
|
const mutable = <T>(value: T) => value as DeepMutable<T>
|
||||||
const isEvent = Schema.is(Schema.Union(EventManifest.ServerDefinitions))
|
|
||||||
|
|
||||||
export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Interface) {
|
export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Interface) {
|
||||||
const agents = yield* AgentV2.Service
|
const agents = yield* AgentV2.Service
|
||||||
const aisdk = yield* AISDK.Service
|
const aisdk = yield* AISDK.Service
|
||||||
@@ -160,7 +158,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
event: {
|
event: {
|
||||||
subscribe: () => events.live().pipe(Stream.filter(isEvent)),
|
subscribe: () => events.live().pipe(Stream.filter(EventManifest.isServer)),
|
||||||
},
|
},
|
||||||
integration: {
|
integration: {
|
||||||
list: () => response(integration.list()),
|
list: () => response(integration.list()),
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { describe, expect } from "bun:test"
|
|||||||
import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Option, Ref, Schema, Stream } from "effect"
|
import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Option, Ref, Schema, Stream } from "effect"
|
||||||
import { EventV2 } from "@opencode-ai/core/event"
|
import { EventV2 } from "@opencode-ai/core/event"
|
||||||
import { Event } from "@opencode-ai/schema/event"
|
import { Event } from "@opencode-ai/schema/event"
|
||||||
|
import { EventManifest } from "@opencode-ai/schema/event-manifest"
|
||||||
|
import { McpEvent } from "@opencode-ai/schema/mcp-event"
|
||||||
import { Session } from "@opencode-ai/schema/session"
|
import { Session } from "@opencode-ai/schema/session"
|
||||||
import { SessionEvent } from "@opencode-ai/schema/session-event"
|
import { SessionEvent } from "@opencode-ai/schema/session-event"
|
||||||
import { SessionV1 } from "@opencode-ai/schema/session-v1"
|
import { SessionV1 } from "@opencode-ai/schema/session-v1"
|
||||||
@@ -329,8 +331,8 @@ describe("EventV2", () => {
|
|||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const consuming = yield* Deferred.make<void>()
|
const consuming = yield* Deferred.make<void>()
|
||||||
const release = yield* Deferred.make<void>()
|
const release = yield* Deferred.make<void>()
|
||||||
const slowStream = yield* EventV2.liveBounded(events, 1)
|
const slowStream = yield* EventV2.liveBounded(events, { capacity: 1 })
|
||||||
const fastStream = yield* EventV2.liveBounded(events, 8)
|
const fastStream = yield* EventV2.liveBounded(events, { capacity: 8 })
|
||||||
const slow = yield* slowStream.pipe(
|
const slow = yield* slowStream.pipe(
|
||||||
Stream.runForEach(() => Deferred.succeed(consuming, undefined).pipe(Effect.andThen(Deferred.await(release)))),
|
Stream.runForEach(() => Deferred.succeed(consuming, undefined).pipe(Effect.andThen(Deferred.await(release)))),
|
||||||
Effect.forkScoped,
|
Effect.forkScoped,
|
||||||
@@ -355,6 +357,20 @@ describe("EventV2", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("filters internal events before they enter a bounded server stream", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const events = yield* EventV2.Service
|
||||||
|
const stream = yield* EventV2.liveBounded(events, { capacity: 1, accept: EventManifest.isServer })
|
||||||
|
const received = yield* stream.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||||
|
|
||||||
|
yield* events.publish(McpEvent.ToolsChanged, { server: "one" })
|
||||||
|
yield* events.publish(McpEvent.ToolsChanged, { server: "two" })
|
||||||
|
const published = yield* events.publish(McpEvent.StatusChanged, { server: "example" })
|
||||||
|
|
||||||
|
expect(Array.from(yield* Fiber.join(received))).toEqual([published])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("preserves observer interruption", () =>
|
it.effect("preserves observer interruption", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
|
|||||||
@@ -67,3 +67,5 @@ export const EventGroup = event.group
|
|||||||
export const OpenCodeEvent = event.schema
|
export const OpenCodeEvent = event.schema
|
||||||
export type OpenCodeEvent = typeof OpenCodeEvent.Type
|
export type OpenCodeEvent = typeof OpenCodeEvent.Type
|
||||||
export type OpenCodeEventEncoded = typeof OpenCodeEvent.Encoded
|
export type OpenCodeEventEncoded = typeof OpenCodeEvent.Encoded
|
||||||
|
export const isOpenCodeEvent = (event: { readonly type: string }): event is OpenCodeEvent =>
|
||||||
|
event.type === "server.connected" || EventManifest.isServer(event)
|
||||||
|
|||||||
@@ -1,23 +1,8 @@
|
|||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { Event } from "@opencode-ai/schema/event"
|
import { isOpenCodeEvent } from "../src/groups/event.js"
|
||||||
import { AbsolutePath } from "@opencode-ai/schema/schema"
|
|
||||||
import { DateTime, Schema } from "effect"
|
|
||||||
import { OpenCodeEvent } from "../src/groups/event.js"
|
|
||||||
|
|
||||||
test("encodes MCP tool changes emitted by the server", () => {
|
test("classifies public events by type", () => {
|
||||||
expect(
|
expect(isOpenCodeEvent({ type: "server.connected" })).toBe(true)
|
||||||
Schema.encodeSync(OpenCodeEvent)({
|
expect(isOpenCodeEvent({ type: "mcp.status.changed" })).toBe(true)
|
||||||
id: Event.ID.make("evt_test"),
|
expect(isOpenCodeEvent({ type: "mcp.tools.changed" })).toBe(false)
|
||||||
created: DateTime.makeUnsafe(0),
|
|
||||||
type: "mcp.tools.changed",
|
|
||||||
location: { directory: AbsolutePath.make("/tmp") },
|
|
||||||
data: { server: "example" },
|
|
||||||
}),
|
|
||||||
).toEqual({
|
|
||||||
id: "evt_test",
|
|
||||||
created: 0,
|
|
||||||
type: "mcp.tools.changed",
|
|
||||||
location: { directory: "/tmp" },
|
|
||||||
data: { server: "example" },
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * as EventManifest from "./event-manifest.js"
|
export * as EventManifest from "./event-manifest.js"
|
||||||
|
|
||||||
|
import { Schema } from "effect"
|
||||||
import { Agent } from "./agent.js"
|
import { Agent } from "./agent.js"
|
||||||
import { Catalog } from "./catalog.js"
|
import { Catalog } from "./catalog.js"
|
||||||
import { Command } from "./command.js"
|
import { Command } from "./command.js"
|
||||||
@@ -78,7 +79,7 @@ export const ServerDefinitions = Event.inventory(
|
|||||||
...TuiEvent.Definitions,
|
...TuiEvent.Definitions,
|
||||||
...InstallationEvent.Definitions,
|
...InstallationEvent.Definitions,
|
||||||
...VcsEvent.Definitions,
|
...VcsEvent.Definitions,
|
||||||
...McpEvent.Definitions,
|
McpEvent.StatusChanged,
|
||||||
// Shared transitional: V1 contracts the current TUI still consumes during
|
// Shared transitional: V1 contracts the current TUI still consumes during
|
||||||
// the migration (permission.asked/replied, question.asked, session.error).
|
// the migration (permission.asked/replied, question.asked, session.error).
|
||||||
// Remove when the TUI moves to the current permission/question surfaces.
|
// Remove when the TUI moves to the current permission/question surfaces.
|
||||||
@@ -86,6 +87,9 @@ export const ServerDefinitions = Event.inventory(
|
|||||||
...QuestionV1.Event.Definitions,
|
...QuestionV1.Event.Definitions,
|
||||||
SessionV1.Error,
|
SessionV1.Error,
|
||||||
)
|
)
|
||||||
|
export const Server = Event.latest(ServerDefinitions)
|
||||||
|
export type ServerEvent = Schema.Schema.Type<(typeof ServerDefinitions)[number]>
|
||||||
|
export const isServer = (event: { readonly type: string }): event is ServerEvent => Server.has(event.type)
|
||||||
|
|
||||||
export const Definitions = Event.inventory(
|
export const Definitions = Event.inventory(
|
||||||
...foundationDefinitions,
|
...foundationDefinitions,
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ describe("public event manifest", () => {
|
|||||||
Agent.Event.Updated,
|
Agent.Event.Updated,
|
||||||
])
|
])
|
||||||
expect(EventManifest.Definitions).toContain(Agent.Event.Updated)
|
expect(EventManifest.Definitions).toContain(Agent.Event.Updated)
|
||||||
expect(EventManifest.ServerDefinitions).toContain(McpEvent.ToolsChanged)
|
|
||||||
expect(EventManifest.Definitions.filter((definition) => definition.type === "agent.updated")).toEqual([
|
expect(EventManifest.Definitions.filter((definition) => definition.type === "agent.updated")).toEqual([
|
||||||
Agent.Event.Updated,
|
Agent.Event.Updated,
|
||||||
])
|
])
|
||||||
@@ -47,6 +46,8 @@ describe("public event manifest", () => {
|
|||||||
EventManifest.Definitions.map((definition) => definition.type),
|
EventManifest.Definitions.map((definition) => definition.type),
|
||||||
)
|
)
|
||||||
expect(EventManifest.Latest.get("agent.updated")).toBe(Agent.Event.Updated)
|
expect(EventManifest.Latest.get("agent.updated")).toBe(Agent.Event.Updated)
|
||||||
|
expect(EventManifest.Server.get("mcp.status.changed")).toBe(McpEvent.StatusChanged)
|
||||||
|
expect(EventManifest.Server.has("mcp.tools.changed")).toBe(false)
|
||||||
expect(Agent.Event.Updated.durable).toBeUndefined()
|
expect(Agent.Event.Updated.durable).toBeUndefined()
|
||||||
expect(EventManifest.Durable.has("agent.updated")).toBe(false)
|
expect(EventManifest.Durable.has("agent.updated")).toBe(false)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { EventV2 } from "@opencode-ai/core/event"
|
import { EventV2 } from "@opencode-ai/core/event"
|
||||||
import { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
|
import { isOpenCodeEvent, OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
|
||||||
import { Effect, Schema, Stream } from "effect"
|
import { Effect, Schema, Stream } from "effect"
|
||||||
import { Sse } from "effect/unstable/encoding"
|
import { Sse } from "effect/unstable/encoding"
|
||||||
import { HttpServerResponse } from "effect/unstable/http"
|
import { HttpServerResponse } from "effect/unstable/http"
|
||||||
@@ -35,7 +35,10 @@ export const EventHandler = HttpApiBuilder.group(Api, "server.event", (handlers)
|
|||||||
const output = Stream.unwrap(
|
const output = Stream.unwrap(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
// Acquiring the bounded stream installs its listener before readiness is observable.
|
// Acquiring the bounded stream installs its listener before readiness is observable.
|
||||||
const live = yield* EventV2.liveBounded(events, subscriberCapacity)
|
const live = yield* EventV2.liveBounded(events, {
|
||||||
|
capacity: subscriberCapacity,
|
||||||
|
accept: isOpenCodeEvent,
|
||||||
|
})
|
||||||
return Stream.make(connected).pipe(Stream.concat(live))
|
return Stream.make(connected).pipe(Stream.concat(live))
|
||||||
}),
|
}),
|
||||||
).pipe(Stream.map(eventData), Stream.pipeThroughChannel(Sse.encode()))
|
).pipe(Stream.map(eventData), Stream.pipeThroughChannel(Sse.encode()))
|
||||||
|
|||||||
Reference in New Issue
Block a user