refactor(core): make v2 session inputs event sourced (#30785)

This commit is contained in:
Kit Langton
2026-06-04 19:24:30 -04:00
committed by GitHub
parent 057958c933
commit 76ecf2e58c
43 changed files with 4632 additions and 718 deletions
+138 -50
View File
@@ -1,7 +1,9 @@
import { describe, expect } from "bun:test"
import { DateTime, Effect, Fiber, Layer, Stream } from "effect"
import { eq } from "drizzle-orm"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { EventTable } from "@opencode-ai/core/event/sql"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
@@ -12,7 +14,7 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionInput } from "@opencode-ai/core/session/input"
import { SessionInputTable, SessionTable } from "@opencode-ai/core/session/sql"
import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { testEffect } from "./lib/effect"
@@ -80,6 +82,18 @@ const admittedCount = Database.Service.use(({ db }) =>
Effect.map((rows) => rows.length),
),
)
const eventCount = (type: string) =>
Database.Service.use(({ db }) =>
db
.select()
.from(EventTable)
.where(eq(EventTable.type, type))
.all()
.pipe(
Effect.orDie,
Effect.map((rows) => rows.length),
),
)
describe("SessionV2.prompt", () => {
it.effect("delegates execution continuation through SessionExecution", () =>
@@ -105,8 +119,7 @@ describe("SessionV2.prompt", () => {
resume: false,
})
expect(message.type).toBe("user")
expect(message.text).toBe("Fix the failing tests")
expect(message.prompt.text).toBe("Fix the failing tests")
expect(yield* session.messages({ sessionID })).toEqual([])
expect(yield* admitted(message.id)).toMatchObject({
id: message.id,
@@ -123,25 +136,25 @@ describe("SessionV2.prompt", () => {
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const { db } = yield* Database.Service
const fiber = yield* session.events({ sessionID }).pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
const fiber = yield* session.events({ sessionID }).pipe(Stream.take(4), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
yield* SessionInput.promoteSteers(db, events, sessionID)
yield* SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER)
const streamed = Array.from(yield* Fiber.join(fiber))
expect(
streamed.map((event) => [event.cursor, event.event.type, (event.event.data as { prompt: Prompt }).prompt.text]),
).toEqual([
[EventV2.Cursor.make(0), "session.next.prompted", "First"],
[EventV2.Cursor.make(1), "session.next.prompted", "Second"],
expect(streamed.map((event) => [event.cursor, event.event.type])).toEqual([
[EventV2.Cursor.make(0), "session.next.prompt.admitted"],
[EventV2.Cursor.make(1), "session.next.prompt.admitted"],
[EventV2.Cursor.make(2), "session.next.prompt.promoted"],
[EventV2.Cursor.make(3), "session.next.prompt.promoted"],
])
expect(
Array.from(
yield* session.events({ sessionID, after: streamed[0]!.cursor }).pipe(Stream.take(1), Stream.runCollect),
).map((event) => [event.cursor, (event.event.data as { prompt: Prompt }).prompt.text]),
).toEqual([[EventV2.Cursor.make(1), "Second"]])
).map((event) => [event.cursor, event.event.type]),
).toEqual([[EventV2.Cursor.make(1), "session.next.prompt.admitted"]])
}),
)
@@ -271,17 +284,6 @@ describe("SessionV2.prompt", () => {
}),
)
it.effect("does not match pending inputs when no delivery modes are eligible", () =>
Effect.gen(function* () {
yield* setup
const { db } = yield* Database.Service
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Wait" }), resume: false })
expect(yield* SessionInput.hasPending(db, sessionID, [])).toBe(false)
}),
)
it.effect("returns one recorded message to concurrent exact retries", () =>
Effect.gen(function* () {
yield* setup
@@ -298,43 +300,127 @@ describe("SessionV2.prompt", () => {
expect(messages[1]).toEqual(messages[0])
expect(yield* session.messages({ sessionID })).toEqual([])
expect(yield* admittedCount).toBe(1)
expect(yield* eventCount(EventV2.versionedType(SessionEvent.PromptLifecycle.Admitted.type, 1))).toBe(1)
}),
)
it.effect("reconciles an existing projected prompt into a promoted inbox record", () =>
it.effect("promotes one message once under concurrent promotion attempts", () =>
Effect.gen(function* () {
yield* setup
const { db } = yield* Database.Service
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
yield* session.prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "Promote once" }), resume: false })
yield* Effect.all(
[
SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER),
SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER),
],
{ concurrency: "unbounded" },
)
expect(yield* eventCount(EventV2.versionedType(SessionEvent.PromptLifecycle.Promoted.type, 1))).toBe(1)
expect(yield* admitted(messageID)).toMatchObject({ promotedSeq: 1 })
expect(yield* session.messages({ sessionID })).toMatchObject([
{ id: messageID, type: "user", text: "Promote once" },
])
}),
)
it.effect("promotes steers only through the captured aggregate cutoff", () =>
Effect.gen(function* () {
yield* setup
const { db } = yield* Database.Service
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const first = yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Before cutoff" }), resume: false })
const cutoff = yield* SessionInput.latestSeq(db, sessionID)
const second = yield* session.prompt({ sessionID, prompt: new Prompt({ text: "After cutoff" }), resume: false })
yield* SessionInput.promoteSteers(db, events, sessionID, cutoff)
expect(yield* admitted(first.id)).toHaveProperty("promotedSeq")
expect(yield* admitted(second.id)).not.toHaveProperty("promotedSeq")
}),
)
it.effect("reprojects one pending lifecycle without scheduling execution", () =>
Effect.gen(function* () {
yield* setup
const { db } = yield* Database.Service
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
wakeCalls.length = 0
yield* session.prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "Replay pending" }), resume: false })
const recorded = yield* db
.select()
.from(EventTable)
.where(eq(EventTable.aggregate_id, sessionID))
.all()
.pipe(Effect.orDie)
yield* events.remove(sessionID)
yield* db.delete(SessionInputTable).where(eq(SessionInputTable.session_id, sessionID)).run().pipe(Effect.orDie)
yield* db
.delete(SessionMessageTable)
.where(eq(SessionMessageTable.session_id, sessionID))
.run()
.pipe(Effect.orDie)
yield* events.replayAll(
recorded.map((event) => ({
id: event.id,
aggregateID: event.aggregate_id,
seq: event.seq,
type: event.type,
data: event.data,
})),
)
expect(yield* admitted(messageID)).toMatchObject({ id: messageID, prompt: { text: "Replay pending" } })
expect(yield* session.messages({ sessionID })).toEqual([])
expect(wakeCalls).toEqual([])
}),
)
it.effect("returns an exact retry of a legacy projected prompt", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const prompt = new Prompt({ text: "Historical prompt" })
yield* events.publish(
SessionEvent.Prompted,
{ sessionID, timestamp: yield* DateTime.now, prompt, delivery: "steer" },
{ id: messageID },
)
yield* events.publish(SessionEvent.Prompted, {
sessionID,
messageID,
timestamp: yield* DateTime.now,
prompt,
delivery: "steer",
})
const retried = yield* session.prompt({ id: messageID, sessionID, prompt, resume: false })
expect(retried).toMatchObject({ id: messageID, text: "Historical prompt" })
expect(retried).toMatchObject({ id: messageID, prompt: { text: "Historical prompt" } })
expect(yield* admitted(messageID)).toHaveProperty("promotedSeq")
}),
)
it.effect("reconciles an existing projected queued prompt with its delivery mode", () =>
it.effect("returns an exact retry of a legacy projected queued prompt", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const prompt = new Prompt({ text: "Historical queued prompt" })
yield* events.publish(
SessionEvent.Prompted,
{ sessionID, timestamp: yield* DateTime.now, prompt, delivery: "queue" },
{ id: messageID },
)
yield* events.publish(SessionEvent.Prompted, {
sessionID,
messageID,
timestamp: yield* DateTime.now,
prompt,
delivery: "queue",
})
const retried = yield* session.prompt({ id: messageID, sessionID, prompt, delivery: "queue", resume: false })
expect(retried).toMatchObject({ id: messageID, text: "Historical queued prompt" })
expect(retried).toMatchObject({ id: messageID, prompt: { text: "Historical queued prompt" } })
expect(yield* admitted(messageID)).toMatchObject({ delivery: "queue" })
}),
)
@@ -344,11 +430,12 @@ describe("SessionV2.prompt", () => {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
yield* events.publish(
SessionEvent.Synthetic,
{ sessionID, timestamp: yield* DateTime.now, text: "Collision" },
{ id: messageID },
)
yield* events.publish(SessionEvent.Synthetic, {
sessionID,
messageID,
timestamp: yield* DateTime.now,
text: "Collision",
})
const failure = yield* session
.prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "Collision" }), resume: false })
@@ -369,20 +456,21 @@ describe("SessionV2.prompt", () => {
yield* session.prompt({ id: messageID, sessionID, prompt, resume: false })
const failure = yield* events
.publish(
SessionEvent.Synthetic,
{ sessionID, timestamp: yield* DateTime.now, text: "Conflicting synthetic" },
{ id: messageID },
)
.publish(SessionEvent.Synthetic, {
sessionID,
messageID,
timestamp: yield* DateTime.now,
text: "Conflicting synthetic",
})
.pipe(Effect.catchDefect(Effect.succeed))
expect(failure).toBe("Durable event conflicts with admitted prompt input")
expect(String(failure)).toContain("SessionInput.LifecycleConflict")
expect(yield* admitted(messageID)).not.toHaveProperty("promotedSeq")
expect(yield* session.messages({ sessionID })).toEqual([])
yield* SessionInput.promoteSteers(db, events, sessionID)
yield* SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER)
expect(yield* admitted(messageID)).toMatchObject({ promotedSeq: 0 })
expect(yield* admitted(messageID)).toMatchObject({ promotedSeq: 1 })
expect(yield* session.messages({ sessionID })).toMatchObject([
{ id: messageID, type: "user", text: "Reserved prompt" },
])