fix(sdk): preserve session stream ordering

This commit is contained in:
Kit Langton 2026-06-25 23:37:44 -04:00
commit 5d27fac711
7 changed files with 228 additions and 88 deletions

View file

@ -450,6 +450,30 @@ describe("EventV2", () => {
}),
)
it.effect("observes replay commits that are not republished", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const aggregateID = Session.ID.create()
const observed = yield* events.observeAggregate({ aggregateID, live: () => false })
const update = yield* observed.updates.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* events.replay(
{
id: EventV2.ID.create(),
aggregateID,
seq: 0,
type: EventV2.versionedType(DurableMessage.type, 1),
data: durableData(aggregateID, "replayed"),
},
{ publish: false },
)
expect(Array.from(yield* Fiber.join(update)).map((event) => event.data)).toEqual([
durableData(aggregateID, "replayed"),
])
}),
)
it.effect("drains causally preceding durable rows before a live payload", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
@ -488,6 +512,33 @@ describe("EventV2", () => {
}),
)
it.effect("coalesces durable notifications without repeated empty reads", () =>
Effect.gen(function* () {
let reads = 0
const eventLayer = EventV2.layerWith({
beforeAggregateRead: () =>
Effect.sync(() => {
reads++
}),
}).pipe(Layer.provide(Database.defaultLayer))
yield* Effect.gen(function* () {
const events = yield* EventV2.Service
const aggregateID = Session.ID.create()
const count = 20
const observed = yield* events.observeAggregate({ aggregateID, live: () => false })
for (let index = 0; index < count; index++) {
yield* events.publish(DurableMessage, durableData(aggregateID, String(index)))
}
const updates = yield* observed.updates.pipe(Stream.take(count), Stream.runCollect)
expect(updates).toHaveLength(count)
expect(reads).toBe(2)
}).pipe(Effect.provide(Layer.mergeAll(Database.defaultLayer, eventLayer)))
}),
)
it.effect("coalesces durable aggregate wakes while draining every committed event", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service

View file

@ -257,6 +257,84 @@ describe("SessionV2.prompt", () => {
}),
)
it.effect("orders activity around the durable rows owned by a run", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const assistantMessageID = SessionMessage.ID.create()
const streamed = yield* session.events({ sessionID }).pipe(Stream.take(5), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* setActivity(true)
yield* events.publish(SessionEvent.Text.Started, {
sessionID,
assistantMessageID,
textID: "text-activity",
timestamp: yield* DateTime.now,
})
yield* events.publish(SessionEvent.Text.Ended, {
sessionID,
assistantMessageID,
textID: "text-activity",
text: "done",
timestamp: yield* DateTime.now,
})
yield* setActivity(false)
expect(Array.from(yield* Fiber.join(streamed)).map((event) => [event.type, event.data])).toEqual([
["session.activity", { sessionID, active: false }],
["session.activity", { sessionID, active: true }],
["session.next.text.started", expect.objectContaining({ sessionID })],
["session.next.text.ended", expect.objectContaining({ sessionID })],
["session.activity", { sessionID, active: false }],
])
}),
)
it.effect("terminates the Session stream when live event buffering saturates", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const initial = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
let seenInitial = false
const streamed = yield* session.events({ sessionID }).pipe(
Stream.mapEffect((event) => {
if (!seenInitial) {
seenInitial = true
return Deferred.succeed(initial, undefined).pipe(Effect.as(event))
}
return Deferred.await(release).pipe(Effect.as(event))
}),
Stream.runCollect,
Effect.forkScoped,
)
yield* Deferred.await(initial)
const timestamp = yield* DateTime.now
const assistantMessageID = SessionMessage.ID.create()
yield* Effect.forEach(
Array.from({ length: 1_000 }, (_, index) => index),
(index) =>
events.publish(SessionEvent.Text.Delta, {
sessionID,
assistantMessageID,
textID: "text-saturation",
delta: String(index),
timestamp,
}),
{ concurrency: "unbounded", discard: true },
)
yield* Deferred.succeed(release, undefined)
const result = Array.from(yield* Fiber.join(streamed))
expect(result[0]?.type).toBe("session.activity")
expect(result.length).toBeLessThan(1_001)
}),
)
it.effect("resumes through a recorded message without appending another prompt", () =>
Effect.gen(function* () {
yield* setup