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
commit 76ecf2e58c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 4671 additions and 757 deletions

View file

@ -67,13 +67,25 @@ describe("SessionProjector", () => {
yield* events.publish(
SessionEvent.Prompted,
{ sessionID, timestamp: created, prompt: new Prompt({ text: "first" }), delivery: "steer" },
{ id: SessionMessage.ID.make("evt_z") },
{
sessionID,
messageID: SessionMessage.ID.make("msg_first"),
timestamp: created,
prompt: new Prompt({ text: "first" }),
delivery: "steer",
},
{ id: EventV2.ID.make("evt_z") },
)
yield* events.publish(
SessionEvent.Prompted,
{ sessionID, timestamp: created, prompt: new Prompt({ text: "second" }), delivery: "steer" },
{ id: SessionMessage.ID.make("evt_a") },
{
sessionID,
messageID: SessionMessage.ID.make("msg_second"),
timestamp: created,
prompt: new Prompt({ text: "second" }),
delivery: "steer",
},
{ id: EventV2.ID.make("evt_a") },
)
const sessions = yield* SessionV2.Service
@ -110,7 +122,7 @@ describe("SessionProjector", () => {
),
)
it.effect("marks an admitted inbox row promoted with the Prompted event sequence", () =>
it.effect("marks an admitted lifecycle row promoted with the PromptPromoted event sequence", () =>
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
@ -131,14 +143,21 @@ describe("SessionProjector", () => {
.run()
.pipe(Effect.orDie)
const events = yield* EventV2.Service
const id = SessionMessage.ID.make("evt_admitted")
yield* SessionInput.admit(db, { id, sessionID, prompt: new Prompt({ text: "promote me" }), delivery: "steer" })
const id = SessionMessage.ID.make("msg_admitted")
yield* SessionInput.admit(db, events, {
id,
sessionID,
prompt: new Prompt({ text: "promote me" }),
delivery: "steer",
})
const event = yield* events.publish(
SessionEvent.Prompted,
{ sessionID, timestamp: created, prompt: new Prompt({ text: "promote me" }), delivery: "steer" },
{ id },
)
const event = yield* events.publish(SessionEvent.PromptLifecycle.Promoted, {
sessionID,
timestamp: created,
messageID: id,
prompt: new Prompt({ text: "promote me" }),
timeCreated: created,
})
expect(
yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie),
@ -168,11 +187,27 @@ describe("SessionProjector", () => {
.pipe(Effect.orDie)
const events = yield* EventV2.Service
yield* events.publish(SessionEvent.AgentSwitched, { sessionID, timestamp: created, agent: "build" })
yield* events.publish(SessionEvent.ModelSwitched, { sessionID, timestamp: created, model })
yield* events.publish(SessionEvent.Synthetic, { sessionID, timestamp: created, text: "synthetic context" })
yield* events.publish(SessionEvent.AgentSwitched, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: created,
agent: "build",
})
yield* events.publish(SessionEvent.ModelSwitched, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: created,
model,
})
yield* events.publish(SessionEvent.Synthetic, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: created,
text: "synthetic context",
})
yield* events.publish(SessionEvent.Shell.Started, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: created,
callID: "shell-1",
command: "pwd",
@ -183,7 +218,12 @@ describe("SessionProjector", () => {
callID: "shell-1",
output: "/project",
})
yield* events.publish(SessionEvent.Compaction.Started, { sessionID, timestamp: created, reason: "manual" })
yield* events.publish(SessionEvent.Compaction.Started, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: created,
reason: "manual",
})
yield* events.publish(SessionEvent.Compaction.Delta, { sessionID, timestamp: created, text: "partial" })
yield* events.publish(SessionEvent.Compaction.Ended, {
sessionID,
@ -228,6 +268,47 @@ describe("SessionProjector", () => {
}),
)
it.effect("rejects distinct creator events that reuse one projected message ID", () =>
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
.pipe(Effect.orDie)
const events = yield* EventV2.Service
const id = SessionMessage.ID.make("msg_creator_collision")
yield* events.publish(SessionEvent.Synthetic, { sessionID, messageID: id, timestamp: created, text: "keep me" })
const exit = yield* events
.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID: id,
timestamp: created,
agent: "build",
model,
})
.pipe(Effect.exit)
expect(exit._tag).toBe("Failure")
expect(
yield* db.select().from(SessionMessageTable).where(eq(SessionMessageTable.id, id)).get().pipe(Effect.orDie),
).toMatchObject({ type: "synthetic" })
}),
)
it.effect("rejects a Prompted event that conflicts with an admitted inbox row", () =>
Effect.gen(function* () {
const { db } = yield* Database.Service
@ -249,24 +330,77 @@ describe("SessionProjector", () => {
.run()
.pipe(Effect.orDie)
const events = yield* EventV2.Service
const id = SessionMessage.ID.make("evt_conflict")
yield* SessionInput.admit(db, { id, sessionID, prompt: new Prompt({ text: "admitted" }), delivery: "steer" })
const id = SessionMessage.ID.make("msg_conflict")
yield* SessionInput.admit(db, events, {
id,
sessionID,
prompt: new Prompt({ text: "admitted" }),
delivery: "steer",
})
const exit = yield* events
.publish(
SessionEvent.Prompted,
{ sessionID, timestamp: created, prompt: new Prompt({ text: "different" }), delivery: "steer" },
{ id },
)
.publish(SessionEvent.Prompted, {
sessionID,
messageID: id,
timestamp: created,
prompt: new Prompt({ text: "different" }),
delivery: "steer",
})
.pipe(Effect.exit)
expect(String(exit)).toContain("Prompt projection conflicts with admitted input")
expect(String(exit)).toContain("SessionInput.LifecycleConflict")
expect(
yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie),
).toMatchObject({ promoted_seq: null })
}),
)
it.effect("rejects an assistant message ID that conflicts with an admitted inbox row", () =>
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
.pipe(Effect.orDie)
const events = yield* EventV2.Service
const id = SessionMessage.ID.make("msg_conflict")
yield* SessionInput.admit(db, events, {
id,
sessionID,
prompt: new Prompt({ text: "admitted" }),
delivery: "steer",
})
const exit = yield* events
.publish(SessionEvent.Step.Started, {
sessionID,
timestamp: created,
assistantMessageID: id,
agent: "build",
model,
})
.pipe(Effect.exit)
expect(String(exit)).toContain("SessionInput.LifecycleConflict")
expect(
yield* db.select().from(SessionMessageTable).where(eq(SessionMessageTable.id, id)).get().pipe(Effect.orDie),
).toBeUndefined()
}),
)
it.effect("rejects a Prompted delivery mode that conflicts with an admitted inbox row", () =>
Effect.gen(function* () {
const { db } = yield* Database.Service
@ -288,15 +422,15 @@ describe("SessionProjector", () => {
.run()
.pipe(Effect.orDie)
const events = yield* EventV2.Service
const id = SessionMessage.ID.make("evt_delivery_conflict")
const id = SessionMessage.ID.make("msg_delivery_conflict")
const prompt = new Prompt({ text: "admitted" })
yield* SessionInput.admit(db, { id, sessionID, prompt, delivery: "queue" })
yield* SessionInput.admit(db, events, { id, sessionID, prompt, delivery: "queue" })
const exit = yield* events
.publish(SessionEvent.Prompted, { sessionID, timestamp: created, prompt, delivery: "steer" }, { id })
.publish(SessionEvent.Prompted, { sessionID, messageID: id, timestamp: created, prompt, delivery: "steer" })
.pipe(Effect.exit)
expect(String(exit)).toContain("Prompt projection conflicts with admitted input")
expect(String(exit)).toContain("SessionInput.LifecycleConflict")
expect(
yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie),
).toMatchObject({ delivery: "queue", promoted_seq: null })
@ -306,7 +440,7 @@ describe("SessionProjector", () => {
it.effect("does not revive a stale incomplete in-memory assistant projection", () =>
Effect.gen(function* () {
const stale = new SessionMessage.Assistant({
id: SessionMessage.ID.make("evt_assistant_stale"),
id: SessionMessage.ID.make("msg_assistant_stale"),
type: "assistant",
agent: "build",
model,
@ -314,7 +448,7 @@ describe("SessionProjector", () => {
time: { created },
})
const completed = new SessionMessage.Assistant({
id: SessionMessage.ID.make("evt_assistant_completed"),
id: SessionMessage.ID.make("msg_assistant_completed"),
type: "assistant",
agent: "build",
model,
@ -351,8 +485,8 @@ describe("SessionProjector", () => {
yield* db
.insert(SessionMessageTable)
.values([
assistantRow(SessionMessage.ID.make("evt_assistant_1"), 0),
assistantRow(SessionMessage.ID.make("evt_assistant_2"), 1),
assistantRow(SessionMessage.ID.make("msg_assistant_1"), 0),
assistantRow(SessionMessage.ID.make("msg_assistant_2"), 1),
])
.run()
.pipe(Effect.orDie)
@ -361,7 +495,7 @@ describe("SessionProjector", () => {
yield* service.publish(SessionEvent.Step.Ended, {
sessionID,
timestamp: DateTime.makeUnsafe(1),
assistantMessageID: SessionMessage.ID.make("evt_assistant_2"),
assistantMessageID: SessionMessage.ID.make("msg_assistant_2"),
finish: "stop",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
@ -409,8 +543,8 @@ describe("SessionProjector", () => {
yield* db
.insert(SessionMessageTable)
.values([
assistantRow(SessionMessage.ID.make("evt_assistant_stale"), 0),
assistantRow(SessionMessage.ID.make("evt_assistant_completed"), 1, {
assistantRow(SessionMessage.ID.make("msg_assistant_stale"), 0),
assistantRow(SessionMessage.ID.make("msg_assistant_completed"), 1, {
created: DateTime.makeUnsafe(1),
completed: DateTime.makeUnsafe(2),
}),
@ -421,6 +555,7 @@ describe("SessionProjector", () => {
const service = yield* EventV2.Service
yield* service.publish(SessionEvent.Text.Started, {
sessionID,
assistantMessageID: SessionMessage.ID.make("msg_assistant_completed"),
timestamp: DateTime.makeUnsafe(3),
textID: "text-stale",
})
@ -437,15 +572,15 @@ describe("SessionProjector", () => {
)
expect(messages).toEqual([
new SessionMessage.Assistant({
id: SessionMessage.ID.make("evt_assistant_completed"),
id: SessionMessage.ID.make("msg_assistant_completed"),
type: "assistant",
agent: "build",
model,
content: [],
content: [new SessionMessage.AssistantText({ type: "text", id: "text-stale", text: "" })],
time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) },
}),
new SessionMessage.Assistant({
id: SessionMessage.ID.make("evt_assistant_stale"),
id: SessionMessage.ID.make("msg_assistant_stale"),
type: "assistant",
agent: "build",
model,