test(server): port event SSE tests to it.instance + testEffectShared (#28569)

This commit is contained in:
Kit Langton
2026-05-20 22:55:54 -04:00
committed by GitHub
parent fccfef1aaf
commit ddf18a7f9c
4 changed files with 139 additions and 161 deletions

View File

@@ -1,53 +1,52 @@
// Diagnostic suite for /event SSE delivery.
//
// Each test isolates ONE variable in the publisher chain while keeping the
// subscriber path constant (raw `app().request` reading the SSE body — no SDK
// consumer involvement). The pass/fail pattern across tests tells us where the
// bug lives:
// subscriber path constant (in-process HttpApi via Server.Default reading the
// SSE body). The pass/fail pattern across tests tells us where the bug lives:
//
// D1 (baseline): publish via Bus.Service.use via AppRuntime — mirror of the
// existing httpapi-event.test.ts test 3. Confirms /event SSE delivery
// works for a SOME publish path.
// D1 (baseline): publish via Bus.use.publish — mirror of httpapi-event.test.ts
// test 3. Confirms /event SSE delivery works for SOME publish path.
//
// D2: publish N times in quick succession via Bus.Service.use. If the bus
// D2: publish N times in quick succession via Bus.use.publish. If the bus
// subscription is acquired correctly there should be no message loss.
//
// D3: publish via SyncEvent.use.run via AppRuntime — exercises the same path
// the HTTP handlers use (Session.updatePart → sync.run → bus.publish)
// without the HTTP roundtrip. Tells us whether the sync path itself can
// deliver in-process.
// D3: publish via SyncEvent.use.run — exercises the same path the HTTP
// handlers use (Session.updatePart → sync.run → bus.publish) without
// the HTTP roundtrip. Tells us whether the sync path itself can deliver
// in-process.
//
// D4: publish via SyncEvent.use.run from a fresh `Effect.provide` scope
// (mimicking what happens if a handler's layer was scoped per-request).
// D4: publish via SyncEvent.use.run; subscriber is an in-process Bus
// callback. Confirms pub/sub identity end-to-end without /event SSE.
//
// D5: in-process Bus.Service callback subscriber AND raw /event SSE subscriber
// D5: in-process Bus callback subscriber AND raw /event SSE subscriber
// receive the same publish. If both receive: no bug. If only the
// callback receives: the /event handler has an acquisition race.
//
// D6: same as D5 but the callback subscriber is attached AFTER /event SSE
// subscription is established. Order-of-setup variable.
import { afterEach, describe, expect } from "bun:test"
import { Deferred, Effect, Schema } from "effect"
import { Deferred, Effect, Layer, Schema } from "effect"
import * as Log from "@opencode-ai/core/util/log"
import { Bus } from "../../src/bus"
import { type AppServices, AppRuntime } from "../../src/effect/app-runtime"
import { InstanceRef } from "../../src/effect/instance-ref"
import { Server } from "../../src/server/server"
import { Event as ServerEvent } from "../../src/server/event"
import { Server } from "../../src/server/server"
import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event"
import { MessageV2 } from "../../src/session/message-v2"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { SyncEvent } from "../../src/sync"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { it } from "../lib/effect"
import { testEffectShared } from "../lib/effect"
void Log.init({ print: false })
const EventData = Schema.Struct({
const SseEvent = Schema.Struct({
id: Schema.optional(Schema.String),
type: Schema.String,
properties: Schema.Record(Schema.String, Schema.Any),
})
type SseEvent = Schema.Schema.Type<typeof EventData>
type SseEvent = Schema.Schema.Type<typeof SseEvent>
type BusEvent = { type: string; properties: unknown }
afterEach(async () => {
@@ -55,30 +54,21 @@ afterEach(async () => {
await resetDatabase()
})
const inApp = <A, E>(eff: Effect.Effect<A, E, AppServices>) =>
Effect.gen(function* () {
const ctx = yield* InstanceRef
if (!ctx) return yield* Effect.die("InstanceRef not provided in test scope")
return yield* Effect.promise(() => AppRuntime.runPromise(eff.pipe(Effect.provideService(InstanceRef, ctx))))
})
const it = testEffectShared(Layer.mergeAll(Bus.defaultLayer, SyncEvent.defaultLayer))
const publishConnected = inApp(Bus.Service.use((svc) => svc.publish(ServerEvent.Connected, {})))
const publishConnected = Bus.use.publish(ServerEvent.Connected, {})
const publishPartUpdated = (partID: ReturnType<typeof PartID.ascending>) => {
const sessionID = SessionID.make(`ses_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`)
return inApp(
SyncEvent.use.run(MessageV2.Event.PartUpdated, {
sessionID,
part: { id: partID, sessionID, messageID: MessageID.ascending(), type: "text", text: "diag" },
time: Date.now(),
}),
)
return SyncEvent.use.run(MessageV2.Event.PartUpdated, {
sessionID,
part: { id: partID, sessionID, messageID: MessageID.ascending(), type: "text", text: "diag" },
time: Date.now(),
})
}
const subscribeAllCallback = (handler: (event: BusEvent) => void) =>
Effect.acquireRelease(inApp(Bus.Service.use((svc) => svc.subscribeAllCallback(handler))), (dispose) =>
Effect.sync(() => dispose()),
)
Effect.acquireRelease(Bus.use.subscribeAllCallback(handler), (dispose) => Effect.sync(() => dispose()))
const openEventStream = (directory: string) =>
Effect.gen(function* () {
@@ -99,7 +89,7 @@ function decodeFrame(value: Uint8Array): SseEvent[] {
.split(/\n\n+/)
.map((part) => part.trim())
.filter((part) => part.length > 0)
.map((part) => Schema.decodeUnknownSync(EventData)(JSON.parse(part.replace(/^data: /, ""))))
.map((part) => Schema.decodeUnknownSync(SseEvent)(JSON.parse(part.replace(/^data: /, ""))))
}
const readNextEvent = (reader: ReadableStreamDefaultReader<Uint8Array>) =>
@@ -112,7 +102,7 @@ const readNextEvent = (reader: ReadableStreamDefaultReader<Uint8Array>) =>
if (result.done || !result.value) return Effect.fail(new Error("event stream closed"))
const frames = decodeFrame(result.value)
if (frames.length === 0) return Effect.fail(new Error("empty SSE frame"))
return Effect.succeed(frames[0])
return Effect.succeed(frames[0]!)
}),
)
@@ -172,7 +162,7 @@ describe("/event SSE delivery diagnostics", () => {
// The critical test. If D1 passes but this fails, the bus-identity fix is
// incomplete OR the sync.run publish path doesn't reach the same bus
// /event subscribes to, even within the same AppRuntime.
// /event subscribes to, even when both share the memoMap.
it.instance(
"D3: delivers a SyncEvent published via SyncEvent.use.run after server.connected",
() =>
@@ -198,7 +188,7 @@ describe("/event SSE delivery diagnostics", () => {
// D4: ensure the publish reaches an in-process Bus subscriber too. Confirms
// pub/sub identity end-to-end without involving /event SSE.
it.instance(
"D4: SyncEvent.use.run publish reaches an in-process Bus.Service.use callback",
"D4: SyncEvent.use.run publish reaches an in-process Bus callback",
() =>
Effect.gen(function* () {
const received = yield* Deferred.make<BusEvent>()
@@ -255,29 +245,6 @@ describe("/event SSE delivery diagnostics", () => {
{ git: true, config: { formatter: false, lsp: false } },
)
// D7: like D5 but the "second subscriber" is a NO-OP AppRuntime.runPromise
// call (no PubSub.subscribe). If D7 passes, the specific subscribeAllCallback
// is what breaks SSE — not arbitrary AppRuntime usage. If D7 fails, anything
// running through AppRuntime concurrently with /event SSE breaks delivery.
it.instance(
"D7: SSE receives sync.run publish even with concurrent no-op AppRuntime activity",
() =>
Effect.gen(function* () {
const { directory } = yield* TestInstance
yield* inApp(Effect.void)
const reader = yield* openEventStream(directory)
expect((yield* readNextEvent(reader)).type).toBe("server.connected")
const partID = PartID.ascending()
yield* publishPartUpdated(partID)
const collected = yield* collectUntilEvent(reader, isPartUpdated)
expect(collected.find(isPartUpdated)).toBeDefined()
}),
{ git: true, config: { formatter: false, lsp: false } },
)
// D6: same as D5 but the callback subscriber is attached AFTER /event SSE
// subscription is established. If D5 fails and D6 passes, the order of
// subscriber setup is the determining factor.