refactor(core): make v2 session inputs event sourced (#30785)
This commit is contained in:
parent
057958c933
commit
76ecf2e58c
43 changed files with 4671 additions and 757 deletions
|
|
@ -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* () {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))))
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
])
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue