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

@ -10,6 +10,7 @@ import { DatabaseMigration } from "@opencode-ai/core/database/migration"
import sessionUsageMigration from "@opencode-ai/core/database/migration/20260510033149_session_usage"
import normalizeStoragePathsMigration from "@opencode-ai/core/database/migration/20260601010001_normalize_storage_paths"
import sessionMessageProjectionOrderMigration from "@opencode-ai/core/database/migration/20260603040000_session_message_projection_order"
import eventSourcedSessionInputMigration from "@opencode-ai/core/database/migration/20260604172448_event_sourced_session_input"
import { ProjectV2 } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
@ -62,15 +63,17 @@ describe("DatabaseMigration", () => {
expect(
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_input'`),
).toEqual({ name: "session_input" })
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 29 })
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 30 })
expect(
yield* db.all(
sql`SELECT name FROM sqlite_master WHERE type = 'index' AND name IN ('event_aggregate_seq_idx', 'event_aggregate_type_seq_idx', 'session_input_session_pending_seq_idx', 'session_input_session_pending_delivery_seq_idx', 'session_message_session_idx', 'session_message_session_type_idx', 'session_message_session_seq_idx', 'session_message_session_type_seq_idx', 'session_message_session_time_created_id_idx') ORDER BY name`,
sql`SELECT name FROM sqlite_master WHERE type = 'index' AND name IN ('event_aggregate_seq_idx', 'event_aggregate_type_seq_idx', 'session_input_session_pending_seq_idx', 'session_input_session_pending_delivery_seq_idx', 'session_input_session_admitted_seq_idx', 'session_input_session_promoted_seq_idx', 'session_message_session_idx', 'session_message_session_type_idx', 'session_message_session_seq_idx', 'session_message_session_type_seq_idx', 'session_message_session_time_created_id_idx') ORDER BY name`,
),
).toEqual([
{ name: "event_aggregate_seq_idx" },
{ name: "event_aggregate_type_seq_idx" },
{ name: "session_input_session_admitted_seq_idx" },
{ name: "session_input_session_pending_delivery_seq_idx" },
{ name: "session_input_session_promoted_seq_idx" },
{ name: "session_message_session_seq_idx" },
{ name: "session_message_session_time_created_id_idx" },
{ name: "session_message_session_type_seq_idx" },
@ -79,6 +82,82 @@ describe("DatabaseMigration", () => {
)
})
test("resets beta history and rebuilds event-sourced Session input storage", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, workspace_id text)`)
yield* db.run(sql`CREATE TABLE workspace (id text PRIMARY KEY)`)
yield* db.run(sql`CREATE TABLE message (id text PRIMARY KEY)`)
yield* db.run(sql`CREATE TABLE part (id text PRIMARY KEY)`)
yield* db.run(sql`CREATE TABLE event_sequence (aggregate_id text PRIMARY KEY, seq integer NOT NULL)`)
yield* db.run(
sql`CREATE TABLE event (id text PRIMARY KEY, aggregate_id text NOT NULL, seq integer NOT NULL, type text NOT NULL, data text NOT NULL)`,
)
yield* db.run(sql`CREATE INDEX event_aggregate_seq_idx ON event (aggregate_id, seq)`)
yield* db.run(sql`CREATE INDEX event_aggregate_type_seq_idx ON event (aggregate_id, type, seq)`)
yield* db.run(
sql`CREATE TABLE session_message (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, seq integer NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)`,
)
yield* db.run(sql`CREATE INDEX session_message_session_seq_idx ON session_message (session_id, seq)`)
yield* db.run(
sql`CREATE TABLE session_input (seq integer PRIMARY KEY AUTOINCREMENT, id text NOT NULL UNIQUE, session_id text NOT NULL, prompt text NOT NULL, delivery text NOT NULL, promoted_seq integer, time_created integer NOT NULL)`,
)
yield* db.run(
sql`CREATE INDEX session_input_session_pending_delivery_seq_idx ON session_input (session_id, promoted_seq, delivery, seq)`,
)
yield* db.run(sql`INSERT INTO session (id, workspace_id) VALUES ('session', 'wrk_old')`)
yield* db.run(sql`INSERT INTO workspace (id) VALUES ('wrk_old')`)
yield* db.run(sql`INSERT INTO message (id) VALUES ('message')`)
yield* db.run(sql`INSERT INTO part (id) VALUES ('part')`)
yield* db.run(sql`INSERT INTO event_sequence (aggregate_id, seq) VALUES ('session', 0)`)
yield* db.run(
sql`INSERT INTO event (id, aggregate_id, seq, type, data) VALUES ('evt_old', 'session', 0, 'old.1', '{}')`,
)
yield* db.run(
sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('msg_old', 'session', 'user', 0, 1, 1, '{}')`,
)
yield* db.run(
sql`INSERT INTO session_input (id, session_id, prompt, delivery, time_created) VALUES ('msg_pending', 'session', '{}', 'steer', 1)`,
)
yield* DatabaseMigration.applyOnly(db, [eventSourcedSessionInputMigration])
expect(yield* db.all(sql`SELECT id, workspace_id FROM session`)).toEqual([
{ id: "session", workspace_id: null },
])
expect(yield* db.all(sql`SELECT id FROM workspace`)).toEqual([])
expect(yield* db.all(sql`SELECT id FROM message`)).toEqual([{ id: "message" }])
expect(yield* db.all(sql`SELECT id FROM part`)).toEqual([{ id: "part" }])
expect(yield* db.all(sql`SELECT id FROM event`)).toEqual([])
expect(yield* db.all(sql`SELECT aggregate_id FROM event_sequence`)).toEqual([])
expect(yield* db.all(sql`SELECT id FROM session_message`)).toEqual([])
expect(yield* db.all(sql`SELECT id FROM session_input`)).toEqual([])
expect(
(yield* db.all<{ name: string }>(sql`PRAGMA table_info(session_input)`)).map((column) => column.name),
).toEqual(["id", "session_id", "prompt", "delivery", "admitted_seq", "promoted_seq", "time_created"])
expect(
(yield* db.all<{ name: string; unique: number }>(sql`PRAGMA index_list(session_message)`)).find(
(index) => index.name === "session_message_session_seq_idx",
),
).toMatchObject({ unique: 1 })
expect(
(yield* db.all<{ name: string; unique: number }>(sql`PRAGMA index_list(event)`)).find(
(index) => index.name === "event_aggregate_seq_idx",
),
).toMatchObject({ unique: 1 })
expect(
(yield* db.all<{ name: string; unique: number }>(sql`PRAGMA index_list(session_input)`)).filter((index) =>
["session_input_session_admitted_seq_idx", "session_input_session_promoted_seq_idx"].includes(index.name),
),
).toEqual([
expect.objectContaining({ name: "session_input_session_promoted_seq_idx", unique: 1 }),
expect.objectContaining({ name: "session_input_session_admitted_seq_idx", unique: 1 }),
])
}),
)
})
test("resets incompatible projected Session messages before adding sequence order", async () => {
await run(
Effect.gen(function* () {

View file

@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { DateTime, Deferred, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } from "effect"
import { EventV2 } from "@opencode-ai/core/event"
import { Database } from "@opencode-ai/core/database/database"
import { EventSequenceTable, EventTable } from "@opencode-ai/core/event/sql"
@ -236,6 +236,56 @@ describe("EventV2", () => {
}),
)
it.effect("isolates observer defects after durable events commit", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const received = new Array<string>()
yield* events.sync(() => Effect.die("sync defect"))
yield* events.listen(() => {
throw new Error("listener defect")
})
yield* events.listen((event) =>
Effect.sync(() => {
received.push(event.type)
}),
)
const event = yield* events.publish(SyncMessage, { id: "one", text: "hello" })
expect(received).toEqual([SyncMessage.type])
expect(event.seq).toBeNumber()
}),
)
it.effect("preserves observer interruption", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const { db } = yield* Database.Service
yield* events.listen(() => Effect.interrupt)
const exit = yield* events.publish(SyncMessage, { id: "interrupted", text: "hello" }).pipe(Effect.exit)
const committed = yield* db
.select({ id: EventTable.id })
.from(EventTable)
.where(eq(EventTable.aggregate_id, "interrupted"))
.get()
.pipe(Effect.orDie)
expect(Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause)).toBeTrue()
expect(committed).toBeDefined()
}),
)
it.effect("keeps live-only listener defects fail-fast", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const defect = new Error("listener defect")
yield* events.listen(() => Effect.die(defect))
expect(yield* events.publish(Message, { text: "hello" }).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect)
}),
)
it.effect("does not synchronize live-only events", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
@ -254,6 +304,30 @@ describe("EventV2", () => {
}),
)
it.effect("synchronizes only after the durable event commits", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const { db } = yield* Database.Service
const synchronized = new Array<boolean>()
yield* events.sync((event) =>
db
.select({ id: EventTable.id })
.from(EventTable)
.where(eq(EventTable.id, event.id))
.get()
.pipe(
Effect.orDie,
Effect.map((row) => synchronized.push(row !== undefined)),
Effect.asVoid,
),
)
yield* events.publish(SyncMessage, { id: EventV2.ID.create(), text: "durable" })
expect(synchronized).toEqual([true])
}),
)
it.effect("inserts sync event rows on publish", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
@ -690,6 +764,59 @@ describe("EventV2", () => {
}),
)
it.effect("strict owner fences exact replay", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const aggregateID = EventV2.ID.create()
const id = EventV2.ID.create()
const replayed = {
id,
type: EventV2.versionedType(SyncMessage.type, 1),
seq: 0,
aggregateID,
data: { id: aggregateID, text: "owned" },
}
yield* events.replay(replayed, { ownerID: "owner-a" })
const exit = yield* events.replay(replayed, { ownerID: "owner-b", strictOwner: true }).pipe(Effect.exit)
expect(String(exit)).toContain("Replay owner mismatch")
}),
)
it.effect("exact replay claims an unowned aggregate", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const { db } = yield* Database.Service
const aggregateID = EventV2.ID.create()
const published = yield* events.publish(SyncMessage, { id: aggregateID, text: "owned" })
const replayed = {
id: published.id,
type: EventV2.versionedType(SyncMessage.type, 1),
seq: published.seq!,
aggregateID,
data: published.data,
}
yield* events.replay(replayed, { ownerID: "owner-a", strictOwner: true })
const row = yield* db
.select({ ownerID: EventSequenceTable.owner_id })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
.get()
.pipe(Effect.orDie)
expect(row?.ownerID).toBe("owner-a")
const exit = yield* events
.replay(
{ ...replayed, id: EventV2.ID.create(), seq: 1, data: { id: aggregateID, text: "conflict" } },
{ ownerID: "owner-b", strictOwner: true },
)
.pipe(Effect.exit)
expect(String(exit)).toContain("Replay owner mismatch")
}),
)
it.effect("replay with owner claims an unowned sequence", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
@ -815,6 +942,57 @@ describe("EventV2", () => {
}),
)
it.effect("rejects divergent stale replay without publishing it", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const received = new Array<EventV2.Payload>()
const aggregateID = EventV2.ID.create()
const replayed = {
id: EventV2.ID.create(),
type: EventV2.versionedType(SyncMessage.type, 1),
seq: 0,
aggregateID,
data: { id: aggregateID, text: "original" },
}
yield* events.listen((event) => Effect.sync(() => received.push(event)))
yield* events.replay(replayed, { publish: true })
const exit = yield* events
.replay({ ...replayed, data: { id: aggregateID, text: "divergent" } }, { publish: true })
.pipe(Effect.exit)
expect(String(exit)).toContain("Replay diverged")
expect(received).toHaveLength(1)
}),
)
it.effect("rejects an event ID reused at another aggregate position", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
const aggregateID = EventV2.ID.create()
const id = EventV2.ID.create()
yield* events.replay({
id,
type: EventV2.versionedType(SyncMessage.type, 1),
seq: 0,
aggregateID,
data: { id: aggregateID, text: "first" },
})
const exit = yield* events
.replay({
id,
type: EventV2.versionedType(SyncMessage.type, 1),
seq: 1,
aggregateID,
data: { id: aggregateID, text: "second" },
})
.pipe(Effect.exit)
expect(String(exit)).toContain(`Event ${id} already exists`)
}),
)
it.effect("replay from a different owner leaves claimed sequence unchanged", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service

View file

@ -1,13 +1,15 @@
import { describe, expect } from "bun:test"
import path from "path"
import { Effect, Layer, Stream } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { eq } from "drizzle-orm"
import { asc, 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 { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProjectV2 } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
@ -16,10 +18,12 @@ import { Prompt } from "@opencode-ai/core/session/prompt"
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 { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { testEffect } from "./lib/effect"
import { tmpdir } from "./fixture/tmpdir"
const database = Database.layerFromPath(":memory:")
const events = EventV2.layer.pipe(Layer.provide(database))
@ -211,11 +215,99 @@ describe("SessionV2.create", () => {
const { db } = yield* Database.Service
const created = yield* session.create({ location })
yield* session.prompt({ sessionID: created.id, prompt: new Prompt({ text: "Hello" }), resume: false })
yield* SessionInput.promoteSteers(db, events, created.id)
yield* SessionInput.promoteSteers(db, events, created.id, Number.MAX_SAFE_INTEGER)
expect(
Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(1), Stream.runCollect)),
).toMatchObject([{ cursor: 1, event: { type: "session.next.prompted", data: { prompt: { text: "Hello" } } } }])
Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(2), Stream.runCollect)),
).toMatchObject([
{ cursor: 1, event: { type: "session.next.prompt.admitted", data: { prompt: { text: "Hello" } } } },
{ cursor: 2, event: { type: "session.next.prompt.promoted" } },
])
}),
)
it.effect("replays one prompt lifecycle into a fresh target database", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const sourceEvents = yield* EventV2.Service
const sourceDb = (yield* Database.Service).db
const created = yield* session.create({ id: SessionV2.ID.make("ses_fresh_target_replay"), location })
const admitted = yield* session.prompt({
sessionID: created.id,
prompt: new Prompt({ text: "Replay lifecycle" }),
resume: false,
})
yield* SessionInput.promoteSteers(sourceDb, sourceEvents, created.id, Number.MAX_SAFE_INTEGER)
const serialized = (yield* sourceDb
.select()
.from(EventTable)
.where(eq(EventTable.aggregate_id, created.id))
.orderBy(asc(EventTable.seq))
.all()
.pipe(Effect.orDie)).map((event) => ({
id: event.id,
aggregateID: event.aggregate_id,
seq: event.seq,
type: event.type,
data: event.data,
}))
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const targetDatabase = Database.layerFromPath(path.join(tmp.path, "target.sqlite"))
const targetEvents = EventV2.layer.pipe(Layer.provide(targetDatabase))
const targetProjector = SessionProjector.layer.pipe(Layer.provide(targetEvents), Layer.provide(targetDatabase))
const targetStore = SessionStore.layer.pipe(Layer.provide(targetDatabase))
yield* Effect.gen(function* () {
const db = (yield* Database.Service).db
const events = yield* EventV2.Service
const store = yield* SessionStore.Service
yield* db
.insert(ProjectTable)
.values({ id: ProjectV2.ID.global, worktree: location.directory, sandboxes: [] })
.run()
.pipe(Effect.orDie)
expect(yield* store.get(created.id)).toBeUndefined()
expect(yield* events.replayAll(serialized.slice(0, 2))).toBe(created.id)
expect(yield* SessionInput.find(db, admitted.id)).toMatchObject({
id: admitted.id,
sessionID: created.id,
prompt: { text: "Replay lifecycle" },
delivery: "steer",
admittedSeq: 1,
})
expect(yield* store.context(created.id)).toEqual([])
expect(yield* events.replayAll(serialized.slice(2))).toBe(created.id)
expect(yield* SessionInput.find(db, admitted.id)).toMatchObject({
id: admitted.id,
sessionID: created.id,
prompt: { text: "Replay lifecycle" },
delivery: "steer",
admittedSeq: 1,
promotedSeq: 2,
})
expect(yield* store.context(created.id)).toMatchObject([
{ id: admitted.id, type: "user", text: "Replay lifecycle" },
])
expect(
(yield* db
.select()
.from(EventTable)
.where(eq(EventTable.aggregate_id, created.id))
.orderBy(asc(EventTable.seq))
.all()
.pipe(Effect.orDie)).map((event) => [event.seq, event.type]),
).toEqual([
[0, EventV2.versionedType(SessionV1.Event.Created.type, 1)],
[1, EventV2.versionedType(SessionEvent.PromptLifecycle.Admitted.type, 1)],
[2, EventV2.versionedType(SessionEvent.PromptLifecycle.Promoted.type, 1)],
])
}).pipe(Effect.provide(Layer.fresh(Layer.mergeAll(targetDatabase, targetEvents, targetProjector, targetStore))))
}),
)

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,

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" },
])

View file

@ -1,7 +1,6 @@
import { describe, expect, test } from "bun:test"
import { Message, Model } from "@opencode-ai/llm"
import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
import { EventV2 } from "@opencode-ai/core/event"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { SessionMessage } from "@opencode-ai/core/session/message"
@ -12,7 +11,7 @@ import { ToolOutput } from "@opencode-ai/core/tool-output"
import { DateTime } from "effect"
const created = DateTime.makeUnsafe(0)
const id = (value: string) => EventV2.ID.make(`evt_${value}`)
const id = (value: string) => SessionMessage.ID.make(`msg_${value}`)
const model = Model.make({ id: "model", provider: "provider", route: OpenAIChat.route })
describe("toLLMMessages", () => {

View file

@ -14,7 +14,6 @@ import { Prompt } from "@opencode-ai/core/session/prompt"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator"
import { SessionRunner } from "@opencode-ai/core/session/runner"
import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { ToolRegistry } from "@opencode-ai/core/tool-registry"
@ -131,7 +130,7 @@ describe("SessionRunnerLLM recorded", () => {
const messages = yield* session.context(sessionID)
expect(messages).toHaveLength(2)
expect(messages[0]).toEqual(prompt)
expect(messages[0]).toMatchObject({ id: prompt.id, type: "user", text: "Say hello in one short sentence." })
expect(messages[1]).toMatchObject({ type: "assistant", agent: "build", finish: "stop" })
expect(messages[1]?.type === "assistant" ? messages[1].content : []).toMatchObject([
{ type: "text", text: "Hello!" },
@ -144,7 +143,8 @@ describe("SessionRunnerLLM recorded", () => {
.orderBy(EventTable.seq)
.all()).map((event) => event.type),
).toEqual([
"session.next.prompted.1",
"session.next.prompt.admitted.1",
"session.next.prompt.promoted.1",
"session.next.step.started.1",
"session.next.text.started.1",
"session.next.text.ended.1",

View file

@ -21,6 +21,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionInput } from "@opencode-ai/core/session/input"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { Prompt } from "@opencode-ai/core/session/prompt"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
@ -29,7 +30,7 @@ import { SessionRunner } from "@opencode-ai/core/session/runner"
import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { ToolRegistry } from "@opencode-ai/core/tool-registry"
import { SessionMessageTable, 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 { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
@ -240,6 +241,7 @@ const replaySessionProjection = (id: SessionV2.ID) =>
.pipe(Effect.orDie)
yield* events.remove(id)
yield* db.delete(SessionInputTable).where(eq(SessionInputTable.session_id, id)).run().pipe(Effect.orDie)
yield* db.delete(SessionMessageTable).where(eq(SessionMessageTable.session_id, id)).run().pipe(Effect.orDie)
yield* events.replayAll(
recorded.map((event) => ({
@ -425,7 +427,9 @@ describe("SessionRunnerLLM", () => {
const message = yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Run automatically" }) })
expect(requests).toHaveLength(1)
expect(yield* session.messages({ sessionID })).toEqual([message])
expect(yield* session.messages({ sessionID })).toMatchObject([
{ id: message.id, type: "user", text: "Run automatically" },
])
}),
)
@ -1217,9 +1221,11 @@ describe("SessionRunnerLLM", () => {
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Recover interrupted tool" }), resume: false })
yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID)
const assistant = yield* events.publish(SessionEvent.Step.Started, {
yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID, Number.MAX_SAFE_INTEGER)
const assistantMessageID = SessionMessage.ID.create()
yield* events.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID,
timestamp: yield* DateTime.now,
agent: "build",
model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") },
@ -1227,21 +1233,21 @@ describe("SessionRunnerLLM", () => {
yield* events.publish(SessionEvent.Tool.Input.Started, {
sessionID,
timestamp: yield* DateTime.now,
assistantMessageID: assistant.id,
assistantMessageID,
callID: "call-interrupted",
name: "echo",
})
yield* events.publish(SessionEvent.Tool.Input.Ended, {
sessionID,
timestamp: yield* DateTime.now,
assistantMessageID: assistant.id,
assistantMessageID,
callID: "call-interrupted",
text: '{"text":"stale"}',
})
yield* events.publish(SessionEvent.Tool.Called, {
sessionID,
timestamp: yield* DateTime.now,
assistantMessageID: assistant.id,
assistantMessageID,
callID: "call-interrupted",
tool: "echo",
input: { text: "stale" },
@ -1279,9 +1285,11 @@ describe("SessionRunnerLLM", () => {
prompt: new Prompt({ text: "Recover interrupted hosted tool" }),
resume: false,
})
yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID)
const assistant = yield* events.publish(SessionEvent.Step.Started, {
yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID, Number.MAX_SAFE_INTEGER)
const assistantMessageID = SessionMessage.ID.create()
yield* events.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID,
timestamp: yield* DateTime.now,
agent: "build",
model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") },
@ -1289,21 +1297,21 @@ describe("SessionRunnerLLM", () => {
yield* events.publish(SessionEvent.Tool.Input.Started, {
sessionID,
timestamp: yield* DateTime.now,
assistantMessageID: assistant.id,
assistantMessageID,
callID: "call-hosted-interrupted",
name: "web_search",
})
yield* events.publish(SessionEvent.Tool.Input.Ended, {
sessionID,
timestamp: yield* DateTime.now,
assistantMessageID: assistant.id,
assistantMessageID,
callID: "call-hosted-interrupted",
text: '{"query":"stale"}',
})
yield* events.publish(SessionEvent.Tool.Called, {
sessionID,
timestamp: yield* DateTime.now,
assistantMessageID: assistant.id,
assistantMessageID,
callID: "call-hosted-interrupted",
tool: "web_search",
input: { query: "stale" },
@ -1337,9 +1345,11 @@ describe("SessionRunnerLLM", () => {
prompt: new Prompt({ text: "Recover interrupted tool input" }),
resume: false,
})
yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID)
const assistant = yield* events.publish(SessionEvent.Step.Started, {
yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID, Number.MAX_SAFE_INTEGER)
const assistantMessageID = SessionMessage.ID.create()
yield* events.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID,
timestamp: yield* DateTime.now,
agent: "build",
model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") },
@ -1347,7 +1357,7 @@ describe("SessionRunnerLLM", () => {
yield* events.publish(SessionEvent.Tool.Input.Started, {
sessionID,
timestamp: yield* DateTime.now,
assistantMessageID: assistant.id,
assistantMessageID,
callID: "call-pending-interrupted",
name: "echo",
})
@ -1414,7 +1424,7 @@ describe("SessionRunnerLLM", () => {
const events = yield* EventV2.Service
const defect = new Error("fail after prompt promotion")
let fail = true
yield* events.project(SessionEvent.Prompted, () => (fail ? Effect.die(defect) : Effect.void))
yield* events.project(SessionEvent.PromptLifecycle.Promoted, () => (fail ? Effect.die(defect) : Effect.void))
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Recover promoted input" }), resume: false })
expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect)
@ -1433,6 +1443,30 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("does not strand a committed promotion when a post-commit listener defects", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
yield* events.listen((event) =>
event.type === SessionEvent.PromptLifecycle.Promoted.type
? Effect.die("fail after prompt promotion commits")
: Effect.void,
)
yield* session.prompt({
sessionID,
prompt: new Prompt({ text: "Run committed promotion" }),
resume: false,
})
requests.length = 0
yield* session.resume(sessionID)
expect(requests).toHaveLength(1)
expect(userTexts(requests[0]!)).toEqual(["Run committed promotion"])
}),
)
it.effect("runs different sessions concurrently", () =>
Effect.gen(function* () {
yield* setup

View file

@ -50,12 +50,14 @@ describe("Tool.Progress", () => {
})
.run()
.pipe(Effect.orDie)
const assistantMessageID = (yield* service.publish(SessionEvent.Step.Started, {
const assistantMessageID = SessionMessage.ID.create()
yield* service.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID,
timestamp,
agent: "build",
model,
})).id
})
const readAssistant = Effect.gen(function* () {
const row = yield* db
.select()