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

@ -0,0 +1,28 @@
DELETE FROM `session_input`;--> statement-breakpoint
DELETE FROM `session_message`;--> statement-breakpoint
DELETE FROM `event`;--> statement-breakpoint
DELETE FROM `event_sequence`;--> statement-breakpoint
UPDATE `session` SET `workspace_id` = NULL;--> statement-breakpoint
DELETE FROM `workspace`;--> statement-breakpoint
DROP INDEX IF EXISTS `event_aggregate_seq_idx`;--> statement-breakpoint
CREATE UNIQUE INDEX `event_aggregate_seq_idx` ON `event` (`aggregate_id`,`seq`);--> statement-breakpoint
DROP INDEX IF EXISTS `session_message_session_seq_idx`;--> statement-breakpoint
CREATE UNIQUE INDEX `session_message_session_seq_idx` ON `session_message` (`session_id`,`seq`);--> statement-breakpoint
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_session_input` (
`id` text PRIMARY KEY,
`session_id` text NOT NULL,
`prompt` text NOT NULL,
`delivery` text NOT NULL,
`admitted_seq` integer NOT NULL,
`promoted_seq` integer,
`time_created` integer NOT NULL,
CONSTRAINT `fk_session_input_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
DROP TABLE `session_input`;--> statement-breakpoint
ALTER TABLE `__new_session_input` RENAME TO `session_input`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
CREATE INDEX `session_input_session_pending_delivery_seq_idx` ON `session_input` (`session_id`,`promoted_seq`,`delivery`,`admitted_seq`);--> statement-breakpoint
CREATE UNIQUE INDEX `session_input_session_admitted_seq_idx` ON `session_input` (`session_id`,`admitted_seq`);--> statement-breakpoint
CREATE UNIQUE INDEX `session_input_session_promoted_seq_idx` ON `session_input` (`session_id`,`promoted_seq`);

File diff suppressed because it is too large Load diff

View file

@ -31,5 +31,6 @@ export const migrations = (
import("./migration/20260603040000_session_message_projection_order"),
import("./migration/20260603141458_session_input_inbox"),
import("./migration/20260603160727_jittery_ezekiel_stane"),
import("./migration/20260604172448_event_sourced_session_input"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]

View file

@ -0,0 +1,47 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260604172448_event_sourced_session_input",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`DELETE FROM \`session_input\`;`)
yield* tx.run(`DELETE FROM \`session_message\`;`)
yield* tx.run(`DELETE FROM \`event\`;`)
yield* tx.run(`DELETE FROM \`event_sequence\`;`)
yield* tx.run(`UPDATE \`session\` SET \`workspace_id\` = NULL;`)
yield* tx.run(`DELETE FROM \`workspace\`;`)
yield* tx.run(`DROP INDEX IF EXISTS \`event_aggregate_seq_idx\`;`)
yield* tx.run(`CREATE UNIQUE INDEX \`event_aggregate_seq_idx\` ON \`event\` (\`aggregate_id\`,\`seq\`);`)
yield* tx.run(`DROP INDEX IF EXISTS \`session_message_session_seq_idx\`;`)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`,
)
yield* tx.run(`PRAGMA foreign_keys=OFF;`)
yield* tx.run(`
CREATE TABLE \`__new_session_input\` (
\`id\` text PRIMARY KEY,
\`session_id\` text NOT NULL,
\`prompt\` text NOT NULL,
\`delivery\` text NOT NULL,
\`admitted_seq\` integer NOT NULL,
\`promoted_seq\` integer,
\`time_created\` integer NOT NULL,
CONSTRAINT \`fk_session_input_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`DROP TABLE \`session_input\`;`)
yield* tx.run(`ALTER TABLE \`__new_session_input\` RENAME TO \`session_input\`;`)
yield* tx.run(`PRAGMA foreign_keys=ON;`)
yield* tx.run(
`CREATE INDEX \`session_input_session_pending_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`delivery\`,\`admitted_seq\`);`,
)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_input_session_admitted_seq_idx\` ON \`session_input\` (\`session_id\`,\`admitted_seq\`);`,
)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_input_session_promoted_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`);`,
)
})
},
} satisfies DatabaseMigration.Migration

View file

@ -1,14 +1,15 @@
export * as EventV2 from "./event"
import { Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
import { Cause, Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
import { and, asc, eq, gt } from "drizzle-orm"
import { Database } from "./database/database"
import { EventSequenceTable, EventTable } from "./event/sql"
import { Location } from "./location"
import { externalID, type ExternalID, NonNegativeInt, withStatics } from "./schema"
import { Identifier } from "./util/identifier"
import { isDeepStrictEqual } from "node:util"
export const ID = Schema.String.pipe(
export const ID = Schema.String.check(Schema.isStartsWith("evt_")).pipe(
Schema.brand("Event.ID"),
withStatics((schema) => ({
create: () => schema.make("evt_" + Identifier.ascending()),
@ -258,16 +259,47 @@ export const layerWith = (options?: LayerOptions) =>
.get()
.pipe(Effect.orDie)
const latest = row?.seq ?? -1
if (input && input.seq <= latest) return
if (input && row?.ownerID && row.ownerID !== input.ownerID) {
if (input.strictOwner) {
yield* Effect.die(
new InvalidSyncEventError({
type: event.type,
message: `Replay owner mismatch for aggregate ${aggregateID}: expected ${row.ownerID}, got ${input.ownerID ?? "none"}`,
}),
)
const encoded = syncRegistry
.get(versionedType(definition.type, sync.version))!
.encode(event.data) as Record<string, unknown>
if (input?.strictOwner && row?.ownerID && row.ownerID !== input.ownerID) {
yield* Effect.die(
new InvalidSyncEventError({
type: event.type,
message: `Replay owner mismatch for aggregate ${aggregateID}: expected ${row.ownerID}, got ${input.ownerID ?? "none"}`,
}),
)
}
if (input && input.seq <= latest) {
const stored = yield* db
.select()
.from(EventTable)
.where(and(eq(EventTable.aggregate_id, aggregateID), eq(EventTable.seq, input.seq)))
.get()
.pipe(Effect.orDie)
if (
stored?.id === event.id &&
stored.type === versionedType(definition.type, sync.version) &&
isDeepStrictEqual(stored.data, encoded)
) {
if (input.ownerID && row?.ownerID == null) {
yield* db
.update(EventSequenceTable)
.set({ owner_id: input.ownerID })
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
.run()
.pipe(Effect.orDie)
}
return
}
yield* Effect.die(
new InvalidSyncEventError({
type: event.type,
message: `Replay diverged at aggregate ${aggregateID} sequence ${input.seq}`,
}),
)
}
if (input && row?.ownerID && row.ownerID !== input.ownerID) {
return
}
const seq = input?.seq ?? latest + 1
@ -279,15 +311,25 @@ export const layerWith = (options?: LayerOptions) =>
}),
)
}
const stored = yield* db
.select({ aggregateID: EventTable.aggregate_id, seq: EventTable.seq })
.from(EventTable)
.where(eq(EventTable.id, event.id))
.get()
.pipe(Effect.orDie)
if (stored)
yield* Effect.die(
new InvalidSyncEventError({
type: event.type,
message: `Event ${event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`,
}),
)
for (const guard of commitGuards) {
yield* guard(event)
}
for (const projector of list) {
yield* projector({ ...event, seq } as Payload)
}
const encoded = syncRegistry
.get(versionedType(definition.type, sync.version))!
.encode(event.data)
yield* db
.insert(EventSequenceTable)
.values([{ aggregate_id: aggregateID, seq, owner_id: input?.ownerID }])
@ -308,7 +350,7 @@ export const layerWith = (options?: LayerOptions) =>
aggregate_id: aggregateID,
seq,
type: versionedType(definition.type, sync.version),
data: encoded as Record<string, unknown>,
data: encoded,
},
])
.run()
@ -337,22 +379,43 @@ export const layerWith = (options?: LayerOptions) =>
return Effect.gen(function* () {
const durable = registry.get(event.type)?.sync !== undefined
if (durable) {
for (const sync of syncHandlers) {
yield* sync(event as Payload)
}
const committed = yield* commitSyncEvent(event as Payload)
if (committed) event = { ...event, seq: committed.seq }
if (committed) {
event = { ...event, seq: committed.seq }
yield* Effect.forEach(syncHandlers, (sync) => observe(event as Payload, "sync", sync), { discard: true })
yield* notify(event as Payload, true)
return event
}
}
for (const listener of listeners) {
yield* listener(event as Payload)
}
const pubsub = typed.get(event.type)
if (pubsub) yield* PubSub.publish(pubsub, event as Payload)
yield* PubSub.publish(all, event as Payload)
yield* notify(event as Payload, false)
return event
})
}
const observe = (event: Payload, kind: "sync" | "listener", observer: (event: Payload) => Effect.Effect<void>) =>
Effect.suspend(() => observer(event)).pipe(
Effect.catchCauseIf(
(cause) => !Cause.hasInterrupts(cause),
(cause) =>
Effect.logError("Event observer failed").pipe(
Effect.annotateLogs({ eventID: event.id, eventType: event.type, kind, cause }),
),
),
)
function notify(event: Payload, isolateListeners: boolean) {
return Effect.gen(function* () {
yield* Effect.forEach(
listeners,
(listener) => (isolateListeners ? observe(event, "listener", listener) : listener(event)),
{ discard: true },
)
const pubsub = typed.get(event.type)
if (pubsub) yield* PubSub.publish(pubsub, event)
yield* PubSub.publish(all, event)
})
}
function publish<D extends Definition>(definition: D, data: Data<D>, options?: PublishOptions) {
return Effect.gen(function* () {
const serviceLocation = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service))
@ -396,13 +459,7 @@ export const layerWith = (options?: LayerOptions) =>
strictOwner: options?.strictOwner,
})
if (committed && options?.publish) {
const published = { ...payload, seq: committed.seq }
for (const listener of listeners) {
yield* listener(published)
}
const pubsub = typed.get(payload.type)
if (pubsub) yield* PubSub.publish(pubsub, published)
yield* PubSub.publish(all, published)
yield* notify({ ...payload, seq: committed.seq }, true)
}
}
})

View file

@ -1,4 +1,4 @@
import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core"
import { sqliteTable, text, integer, index, uniqueIndex } from "drizzle-orm/sqlite-core"
import type { EventV2 } from "../event"
export const EventSequenceTable = sqliteTable("event_sequence", {
@ -19,7 +19,7 @@ export const EventTable = sqliteTable(
data: text({ mode: "json" }).$type<Record<string, unknown>>().notNull(),
},
(table) => [
index("event_aggregate_seq_idx").on(table.aggregate_id, table.seq),
uniqueIndex("event_aggregate_seq_idx").on(table.aggregate_id, table.seq),
index("event_aggregate_type_seq_idx").on(table.aggregate_id, table.type, table.seq),
],
)

View file

@ -1,7 +1,7 @@
export * as SessionV2 from "./session"
export * from "./session/schema"
import { Cause, DateTime, Effect, Layer, Schema, Context, Stream } from "effect"
import { Cause, Effect, Layer, Schema, Context, Stream } from "effect"
import { and, asc, desc, eq, gt, like, lt, or, type SQL } from "drizzle-orm"
import { ProjectV2 } from "./project"
import { WorkspaceV2 } from "./workspace"
@ -10,7 +10,6 @@ import { Location } from "./location"
import { SessionMessage } from "./session/message"
import { Prompt } from "./session/prompt"
import { EventV2 } from "./event"
import { ProviderV2 } from "./provider"
import { Database } from "./database/database"
import { SessionProjector } from "./session/projector"
import { SessionMessageTable, SessionTable } from "./session/sql"
@ -140,7 +139,7 @@ export interface Interface {
prompt: Prompt
delivery?: SessionInput.Delivery
resume?: boolean
}) => Effect.Effect<SessionMessage.User, NotFoundError | PromptConflictError>
}) => Effect.Effect<SessionInput.Admitted, NotFoundError | PromptConflictError>
readonly shell: (input: {
id?: EventV2.ID
sessionID: SessionSchema.ID
@ -198,20 +197,6 @@ export const layer = Layer.effect(
),
)
const findExistingPrompt = Effect.fnUntraced(function* (input: {
sessionID: SessionSchema.ID
messageID: SessionMessage.ID
prompt: Prompt
delivery: SessionInput.Delivery
}) {
const stored = yield* SessionInput.find(db, input.messageID)
if (!stored) return yield* SessionInput.reconcileProjected(db, { id: input.messageID, ...input })
if (!SessionInput.equivalent(stored, input)) {
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID: input.messageID })
}
return stored
})
const result = Service.of({
create: Effect.fn("V2Session.create")(function* (input) {
const sessionID = input.id ?? SessionSchema.ID.create()
@ -367,20 +352,23 @@ export const layer = Layer.effect(
yield* result.get(input.sessionID)
const returnPrompt = Effect.fnUntraced(function* (admitted: SessionInput.Admitted) {
if (input.resume !== false) yield* enqueueWake(input.sessionID)
return SessionInput.toMessage(admitted)
return admitted
}, Effect.uninterruptible)
const messageID = input.id ?? SessionMessage.ID.create()
const delivery = input.delivery ?? "steer"
const expected = { sessionID: input.sessionID, messageID, prompt: input.prompt, delivery }
const existing = yield* findExistingPrompt(expected)
if (existing) return yield* returnPrompt(existing)
const admitted = yield* SessionInput.admit(db, {
const admitted = yield* SessionInput.admit(db, events, {
id: messageID,
sessionID: input.sessionID,
prompt: input.prompt,
delivery,
})
if (!admitted) return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
}).pipe(
Effect.catchDefect((defect) =>
defect instanceof SessionInput.LifecycleConflict
? new PromptConflictError({ sessionID: input.sessionID, messageID })
: Effect.die(defect),
),
)
if (!SessionInput.equivalent(admitted, expected))
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
return yield* returnPrompt(admitted)

View file

@ -9,6 +9,7 @@ import { FileAttachment, Prompt } from "./prompt"
import { SessionSchema } from "./schema"
import { Location } from "../location"
import { RelativePath } from "../schema"
import { SessionMessageID } from "./message-id"
export { FileAttachment }
@ -52,6 +53,7 @@ export const AgentSwitched = EventV2.define({
...options,
schema: {
...Base,
messageID: SessionMessageID.ID,
agent: Schema.String,
},
})
@ -62,6 +64,7 @@ export const ModelSwitched = EventV2.define({
...options,
schema: {
...Base,
messageID: SessionMessageID.ID,
model: ModelV2.Ref,
},
})
@ -83,17 +86,45 @@ export const Prompted = EventV2.define({
...options,
schema: {
...Base,
messageID: SessionMessageID.ID,
prompt: Prompt,
delivery: Schema.Literals(["steer", "queue"]),
},
})
export type Prompted = typeof Prompted.Type
export namespace PromptLifecycle {
export const Admitted = EventV2.define({
type: "session.next.prompt.admitted",
...options,
schema: {
...Base,
messageID: SessionMessageID.ID,
prompt: Prompt,
delivery: Schema.Literals(["steer", "queue"]),
},
})
export type Admitted = typeof Admitted.Type
export const Promoted = EventV2.define({
type: "session.next.prompt.promoted",
...options,
schema: {
...Base,
messageID: SessionMessageID.ID,
prompt: Prompt,
timeCreated: V2Schema.DateTimeUtcFromMillis,
},
})
export type Promoted = typeof Promoted.Type
}
export const Synthetic = EventV2.define({
type: "session.next.synthetic",
...options,
schema: {
...Base,
messageID: SessionMessageID.ID,
text: Schema.String,
},
})
@ -105,6 +136,7 @@ export namespace Shell {
...options,
schema: {
...Base,
messageID: SessionMessageID.ID,
callID: Schema.String,
command: Schema.String,
},
@ -129,6 +161,7 @@ export namespace Step {
...options,
schema: {
...Base,
assistantMessageID: SessionMessageID.ID,
agent: Schema.String,
model: ModelV2.Ref,
snapshot: Schema.String.pipe(Schema.optional),
@ -141,7 +174,7 @@ export namespace Step {
...stepSettlementOptions,
schema: {
...Base,
assistantMessageID: EventV2.ID,
assistantMessageID: SessionMessageID.ID,
finish: Schema.String,
cost: Schema.Finite,
tokens: Schema.Struct({
@ -163,7 +196,7 @@ export namespace Step {
...stepSettlementOptions,
schema: {
...Base,
assistantMessageID: EventV2.ID,
assistantMessageID: SessionMessageID.ID,
error: UnknownError,
},
})
@ -176,6 +209,7 @@ export namespace Text {
...options,
schema: {
...Base,
assistantMessageID: SessionMessageID.ID,
textID: Schema.String,
},
})
@ -186,6 +220,7 @@ export namespace Text {
type: "session.next.text.delta",
schema: {
...Base,
assistantMessageID: SessionMessageID.ID,
textID: Schema.String,
delta: Schema.String,
},
@ -197,6 +232,7 @@ export namespace Text {
...options,
schema: {
...Base,
assistantMessageID: SessionMessageID.ID,
textID: Schema.String,
text: Schema.String,
},
@ -210,6 +246,7 @@ export namespace Reasoning {
...options,
schema: {
...Base,
assistantMessageID: SessionMessageID.ID,
reasoningID: Schema.String,
providerMetadata: ProviderMetadata.pipe(Schema.optional),
},
@ -221,6 +258,7 @@ export namespace Reasoning {
type: "session.next.reasoning.delta",
schema: {
...Base,
assistantMessageID: SessionMessageID.ID,
reasoningID: Schema.String,
delta: Schema.String,
},
@ -232,6 +270,7 @@ export namespace Reasoning {
...options,
schema: {
...Base,
assistantMessageID: SessionMessageID.ID,
reasoningID: Schema.String,
text: Schema.String,
providerMetadata: ProviderMetadata.pipe(Schema.optional),
@ -243,7 +282,7 @@ export namespace Reasoning {
export namespace Tool {
const ToolBase = {
...Base,
assistantMessageID: EventV2.ID,
assistantMessageID: SessionMessageID.ID,
callID: Schema.String,
}
@ -370,6 +409,7 @@ export namespace Compaction {
...options,
schema: {
...Base,
messageID: SessionMessageID.ID,
reason: Schema.Union([Schema.Literal("auto"), Schema.Literal("manual")]),
},
})
@ -402,6 +442,8 @@ const DurableDefinitions = [
ModelSwitched,
Moved,
Prompted,
PromptLifecycle.Admitted,
PromptLifecycle.Promoted,
Synthetic,
Shell.Started,
Shell.Ended,

View file

@ -1,11 +1,11 @@
export * as SessionInput from "./input"
import { and, asc, eq, inArray, isNull } from "drizzle-orm"
import { and, asc, eq, isNull, lte } from "drizzle-orm"
import { DateTime, Effect, Schema } from "effect"
import type { Database } from "../database/database"
import type { EventV2 } from "../event"
import { EventTable } from "../event/sql"
import { NonNegativeInt, PositiveInt } from "../schema"
import { EventSequenceTable } from "../event/sql"
import { NonNegativeInt } from "../schema"
import { V2Schema } from "../v2-schema"
import { SessionEvent } from "./event"
import { SessionMessage } from "./message"
@ -19,7 +19,7 @@ export const Delivery = Schema.Literals(["steer", "queue"])
export type Delivery = typeof Delivery.Type
export class Admitted extends Schema.Class<Admitted>("SessionInput.Admitted")({
seq: PositiveInt,
admittedSeq: NonNegativeInt,
id: SessionMessage.ID,
sessionID: SessionSchema.ID,
prompt: Prompt,
@ -30,11 +30,10 @@ export class Admitted extends Schema.Class<Admitted>("SessionInput.Admitted")({
const decodePrompt = Schema.decodeUnknownSync(Prompt)
const encodePrompt = Schema.encodeSync(Prompt)
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message)
const fromRow = (row: typeof SessionInputTable.$inferSelect): Admitted =>
new Admitted({
seq: row.seq,
admittedSeq: row.admitted_seq,
id: SessionMessage.ID.make(row.id),
sessionID: SessionSchema.ID.make(row.session_id),
prompt: decodePrompt(row.prompt),
@ -48,8 +47,13 @@ export const find = Effect.fn("SessionInput.find")(function* (db: DatabaseServic
return row === undefined ? undefined : fromRow(row)
})
export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()("SessionInput.LifecycleConflict", {
id: SessionMessage.ID,
}) {}
export const admit = Effect.fn("SessionInput.admit")(function* (
db: DatabaseService,
events: EventV2.Interface,
input: {
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
@ -57,49 +61,124 @@ export const admit = Effect.fn("SessionInput.admit")(function* (
readonly delivery: Delivery
},
) {
return yield* db
.transaction(
() =>
Effect.gen(function* () {
const existing = yield* find(db, input.id)
if (existing !== undefined) return existing
const event = yield* db
.select({ id: EventTable.id })
.from(EventTable)
.where(eq(EventTable.id, input.id))
.get()
.pipe(Effect.orDie)
const message = yield* db
.select({ id: SessionMessageTable.id })
.from(SessionMessageTable)
.where(eq(SessionMessageTable.id, input.id))
.get()
.pipe(Effect.orDie)
if (event !== undefined || message !== undefined) return undefined
const row = yield* db
.insert(SessionInputTable)
.values({
id: input.id,
session_id: input.sessionID,
prompt: encodePrompt(input.prompt),
delivery: input.delivery,
})
.returning()
.get()
.pipe(Effect.orDie)
return fromRow(row)
}),
{ behavior: "immediate" },
const existing = yield* find(db, input.id)
if (existing !== undefined) return existing
const timestamp = yield* DateTime.now
return yield* events
.publish(SessionEvent.PromptLifecycle.Admitted, {
messageID: input.id,
sessionID: input.sessionID,
timestamp,
prompt: input.prompt,
delivery: input.delivery,
})
.pipe(
Effect.flatMap((event) =>
event.seq === undefined
? Effect.die("Prompt admission event is missing aggregate sequence")
: Effect.succeed(
new Admitted({
admittedSeq: event.seq,
id: input.id,
sessionID: input.sessionID,
prompt: input.prompt,
delivery: input.delivery,
timeCreated: timestamp,
}),
),
),
Effect.catchDefect((defect) =>
find(db, input.id).pipe(Effect.flatMap((stored) => (stored ? Effect.succeed(stored) : Effect.die(defect)))),
),
)
})
export const latestSeq = Effect.fn("SessionInput.latestSeq")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
) {
const row = yield* db
.select({ seq: EventSequenceTable.seq })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, sessionID))
.get()
.pipe(Effect.orDie)
return row?.seq ?? -1
})
export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(function* (
db: DatabaseService,
input: {
readonly admittedSeq: number
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
readonly prompt: Prompt
readonly delivery: Delivery
readonly timeCreated: DateTime.Utc
},
) {
const message = yield* db
.select({ id: SessionMessageTable.id })
.from(SessionMessageTable)
.where(eq(SessionMessageTable.id, input.id))
.get()
.pipe(Effect.orDie)
if (message) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
const stored = yield* db
.insert(SessionInputTable)
.values({
id: input.id,
session_id: input.sessionID,
admitted_seq: input.admittedSeq,
prompt: encodePrompt(input.prompt),
delivery: input.delivery,
time_created: DateTime.toEpochMillis(input.timeCreated),
})
.onConflictDoNothing()
.returning({ id: SessionInputTable.id })
.get()
.pipe(Effect.orDie)
if (!stored) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
})
export const projectPromoted = Effect.fn("SessionInput.projectPromoted")(function* (
db: DatabaseService,
input: {
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
readonly prompt: Prompt
readonly timeCreated: DateTime.Utc
readonly promotedSeq: number
},
) {
const updated = yield* db
.update(SessionInputTable)
.set({ promoted_seq: input.promotedSeq })
.where(
and(
eq(SessionInputTable.id, input.id),
eq(SessionInputTable.session_id, input.sessionID),
isNull(SessionInputTable.promoted_seq),
),
)
.returning()
.get()
.pipe(Effect.orDie)
if (!updated) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
const stored = fromRow(updated)
if (
!matchesPrompt(stored, input) ||
DateTime.toEpochMillis(stored.timeCreated) !== DateTime.toEpochMillis(input.timeCreated)
)
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
return toMessage(stored)
})
export const hasPending = Effect.fn("SessionInput.hasPending")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
deliveries: ReadonlyArray<Delivery> = ["steer", "queue"],
delivery: Delivery,
) {
if (deliveries.length === 0) return false
const row = yield* db
.select({ id: SessionInputTable.id })
.from(SessionInputTable)
@ -107,7 +186,7 @@ export const hasPending = Effect.fn("SessionInput.hasPending")(function* (
and(
eq(SessionInputTable.session_id, sessionID),
isNull(SessionInputTable.promoted_seq),
inArray(SessionInputTable.delivery, deliveries),
eq(SessionInputTable.delivery, delivery),
),
)
.limit(1)
@ -133,14 +212,34 @@ export const guardReservedID = Effect.fn("SessionInput.guardReservedID")(functio
db: DatabaseService,
event: EventV2.Payload,
) {
const admitted = yield* find(db, event.id)
if (
Schema.is(SessionEvent.PromptLifecycle.Admitted)(event) ||
Schema.is(SessionEvent.PromptLifecycle.Promoted)(event)
)
return
const id = reservedID(event)
if (id === undefined) return
const admitted = yield* db
.select({ id: SessionInputTable.id })
.from(SessionInputTable)
.where(eq(SessionInputTable.id, id))
.get()
.pipe(Effect.orDie)
if (admitted === undefined) return
if (!Schema.is(SessionEvent.Prompted)(event))
return yield* Effect.die("Durable event conflicts with admitted prompt input")
if (!equivalent(admitted, event.data)) return yield* Effect.die("Prompt projection conflicts with admitted input")
return yield* Effect.die(new LifecycleConflict({ id }))
})
export const project = Effect.fn("SessionInput.project")(function* (
const reservedID = (event: EventV2.Payload) => {
if (Schema.is(SessionEvent.Step.Started)(event)) return event.data.assistantMessageID
if (Schema.is(SessionEvent.AgentSwitched)(event)) return event.data.messageID
if (Schema.is(SessionEvent.ModelSwitched)(event)) return event.data.messageID
if (Schema.is(SessionEvent.Prompted)(event)) return event.data.messageID
if (Schema.is(SessionEvent.Synthetic)(event)) return event.data.messageID
if (Schema.is(SessionEvent.Shell.Started)(event)) return event.data.messageID
if (Schema.is(SessionEvent.Compaction.Started)(event)) return event.data.messageID
}
export const projectLegacyPrompted = Effect.fn("SessionInput.projectLegacyPrompted")(function* (
db: DatabaseService,
input: {
readonly id: SessionMessage.ID
@ -151,82 +250,49 @@ export const project = Effect.fn("SessionInput.project")(function* (
readonly promotedSeq: number
},
) {
yield* db
const inserted = yield* db
.insert(SessionInputTable)
.values({
id: input.id,
session_id: input.sessionID,
admitted_seq: input.promotedSeq,
prompt: encodePrompt(input.prompt),
delivery: input.delivery,
promoted_seq: input.promotedSeq,
time_created: DateTime.toEpochMillis(input.timeCreated),
})
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
const admitted = yield* find(db, input.id)
if (admitted === undefined || admitted.delivery !== input.delivery || !matchesPrompt(admitted, input))
return yield* Effect.die("Prompt projection conflicts with admitted input")
yield* db
.update(SessionInputTable)
.set({ promoted_seq: input.promotedSeq })
.where(
and(
eq(SessionInputTable.id, input.id),
eq(SessionInputTable.session_id, input.sessionID),
isNull(SessionInputTable.promoted_seq),
),
)
.run()
.pipe(Effect.orDie)
return yield* find(db, input.id)
})
export const reconcileProjected = Effect.fn("SessionInput.reconcileProjected")(function* (
db: DatabaseService,
expected: {
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
readonly prompt: Prompt
readonly delivery: Delivery
},
) {
if (expected.delivery !== "steer") return undefined
const row = yield* db
.select()
.from(SessionMessageTable)
.where(eq(SessionMessageTable.id, expected.id))
.returning()
.get()
.pipe(Effect.orDie)
if (row === undefined || row.session_id !== expected.sessionID || row.type !== "user") return undefined
const message = decodeMessage({ ...row.data, id: row.id, type: row.type })
if (message.type !== "user" || !Prompt.equivalence(Prompt.fromUserMessage(message), expected.prompt)) return undefined
return yield* project(db, {
id: expected.id,
sessionID: expected.sessionID,
prompt: expected.prompt,
delivery: expected.delivery,
timeCreated: message.time.created,
promotedSeq: row.seq,
})
if (!inserted) return yield* Effect.die("Prompt projection conflicts with admitted input")
return fromRow(inserted)
})
const publish = Effect.fn("SessionInput.publish")(function* (
db: DatabaseService,
events: EventV2.Interface,
sessionID: SessionSchema.ID,
rows: ReadonlyArray<typeof SessionInputTable.$inferSelect>,
) {
for (const row of rows) {
yield* events.publish(
SessionEvent.Prompted,
{
yield* events
.publish(SessionEvent.PromptLifecycle.Promoted, {
sessionID,
timestamp: DateTime.makeUnsafe(row.time_created),
timestamp: yield* DateTime.now,
messageID: SessionMessage.ID.make(row.id),
prompt: decodePrompt(row.prompt),
delivery: row.delivery,
},
{ id: SessionMessage.ID.make(row.id) },
)
timeCreated: DateTime.makeUnsafe(row.time_created),
})
.pipe(
Effect.catchDefect((defect) =>
defect instanceof LifecycleConflict
? find(db, SessionMessage.ID.make(row.id)).pipe(
Effect.flatMap((stored) => (stored?.promotedSeq === undefined ? Effect.die(defect) : Effect.void)),
)
: Effect.die(defect),
),
)
}
return rows.length
})
@ -235,6 +301,7 @@ export const promoteSteers = Effect.fn("SessionInput.promoteSteers")(function* (
db: DatabaseService,
events: EventV2.Interface,
sessionID: SessionSchema.ID,
cutoff: number,
) {
const rows = yield* db
.select()
@ -244,12 +311,13 @@ export const promoteSteers = Effect.fn("SessionInput.promoteSteers")(function* (
eq(SessionInputTable.session_id, sessionID),
isNull(SessionInputTable.promoted_seq),
eq(SessionInputTable.delivery, "steer"),
lte(SessionInputTable.admitted_seq, cutoff),
),
)
.orderBy(asc(SessionInputTable.seq))
.orderBy(asc(SessionInputTable.admitted_seq))
.all()
.pipe(Effect.orDie)
return yield* publish(events, sessionID, rows)
return yield* publish(db, events, sessionID, rows)
})
export const promoteNextQueued = Effect.fn("SessionInput.promoteNextQueued")(function* (
@ -267,14 +335,14 @@ export const promoteNextQueued = Effect.fn("SessionInput.promoteNextQueued")(fun
eq(SessionInputTable.delivery, "queue"),
),
)
.orderBy(asc(SessionInputTable.seq))
.orderBy(asc(SessionInputTable.admitted_seq))
.limit(1)
.get()
.pipe(Effect.orDie)
return row === undefined ? false : yield* publish(events, sessionID, [row]).pipe(Effect.as(true))
return row === undefined ? false : yield* publish(db, events, sessionID, [row]).pipe(Effect.as(true))
})
export const toMessage = (input: Admitted) =>
const toMessage = (input: Admitted) =>
new SessionMessage.User({
id: input.id,
type: "user",

View file

@ -0,0 +1,13 @@
export * as SessionMessageID from "./message-id"
import { Schema } from "effect"
import { withStatics } from "../schema"
import { Identifier } from "../util/identifier"
export const ID = Schema.String.check(Schema.isStartsWith("msg_")).pipe(
Schema.brand("Session.Message.ID"),
withStatics((schema) => ({
create: () => schema.make("msg_" + Identifier.ascending()),
})),
)
export type ID = typeof ID.Type

View file

@ -123,7 +123,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
"session.next.agent.switched": (event) => {
return adapter.appendMessage(
new SessionMessage.AgentSwitched({
id: event.id,
id: event.data.messageID,
type: "agent-switched",
metadata: event.metadata,
agent: event.data.agent,
@ -134,7 +134,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
"session.next.model.switched": (event) => {
return adapter.appendMessage(
new SessionMessage.ModelSwitched({
id: event.id,
id: event.data.messageID,
type: "model-switched",
metadata: event.metadata,
model: event.data.model,
@ -146,7 +146,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
"session.next.prompted": (event) => {
return adapter.appendMessage(
new SessionMessage.User({
id: event.id,
id: event.data.messageID,
type: "user",
metadata: event.metadata,
text: event.data.prompt.text,
@ -157,12 +157,14 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}),
)
},
"session.next.prompt.admitted": () => Effect.void,
"session.next.prompt.promoted": () => Effect.void,
"session.next.synthetic": (event) => {
return adapter.appendMessage(
new SessionMessage.Synthetic({
sessionID: event.data.sessionID,
text: event.data.text,
id: event.id,
id: event.data.messageID,
type: "synthetic",
time: { created: event.data.timestamp },
}),
@ -171,7 +173,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
"session.next.shell.started": (event) => {
return adapter.appendMessage(
new SessionMessage.Shell({
id: event.id,
id: event.data.messageID,
type: "shell",
metadata: event.metadata,
callID: event.data.callID,
@ -206,7 +208,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}
yield* adapter.appendMessage(
new SessionMessage.Assistant({
id: event.id,
id: event.data.assistantMessageID,
type: "assistant",
agent: event.data.agent,
model: event.data.model,
@ -234,43 +236,22 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
})
},
"session.next.text.started": (event) => {
return Effect.gen(function* () {
const currentAssistant = yield* adapter.getCurrentAssistant()
if (currentAssistant) {
yield* adapter.updateAssistant(
produce(currentAssistant, (draft) => {
draft.content.push(
castDraft(new SessionMessage.AssistantText({ type: "text", id: event.data.textID, text: "" })),
)
}),
)
}
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.content.push(
castDraft(new SessionMessage.AssistantText({ type: "text", id: event.data.textID, text: "" })),
)
})
},
"session.next.text.delta": (event) => {
return Effect.gen(function* () {
const currentAssistant = yield* adapter.getCurrentAssistant()
if (currentAssistant) {
yield* adapter.updateAssistant(
produce(currentAssistant, (draft) => {
const match = latestText(draft, event.data.textID)
if (match) match.text += event.data.delta
}),
)
}
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestText(draft, event.data.textID)
if (match) match.text += event.data.delta
})
},
"session.next.text.ended": (event) => {
return Effect.gen(function* () {
const currentAssistant = yield* adapter.getCurrentAssistant()
if (currentAssistant) {
yield* adapter.updateAssistant(
produce(currentAssistant, (draft) => {
const match = latestText(draft, event.data.textID)
if (match) match.text = event.data.text
}),
)
}
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestText(draft, event.data.textID)
if (match) match.text = event.data.text
})
},
"session.next.tool.input.started": (event) => {
@ -367,52 +348,31 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
})
},
"session.next.reasoning.started": (event) => {
return Effect.gen(function* () {
const currentAssistant = yield* adapter.getCurrentAssistant()
if (currentAssistant) {
yield* adapter.updateAssistant(
produce(currentAssistant, (draft) => {
draft.content.push(
castDraft(
new SessionMessage.AssistantReasoning({
type: "reasoning",
id: event.data.reasoningID,
text: "",
providerMetadata: event.data.providerMetadata,
}),
),
)
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.content.push(
castDraft(
new SessionMessage.AssistantReasoning({
type: "reasoning",
id: event.data.reasoningID,
text: "",
providerMetadata: event.data.providerMetadata,
}),
)
}
),
)
})
},
"session.next.reasoning.delta": (event) => {
return Effect.gen(function* () {
const currentAssistant = yield* adapter.getCurrentAssistant()
if (currentAssistant) {
yield* adapter.updateAssistant(
produce(currentAssistant, (draft) => {
const match = latestReasoning(draft, event.data.reasoningID)
if (match) match.text += event.data.delta
}),
)
}
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestReasoning(draft, event.data.reasoningID)
if (match) match.text += event.data.delta
})
},
"session.next.reasoning.ended": (event) => {
return Effect.gen(function* () {
const currentAssistant = yield* adapter.getCurrentAssistant()
if (currentAssistant) {
yield* adapter.updateAssistant(
produce(currentAssistant, (draft) => {
const match = latestReasoning(draft, event.data.reasoningID)
if (match) {
match.text = event.data.text
if (event.data.providerMetadata !== undefined) match.providerMetadata = event.data.providerMetadata
}
}),
)
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestReasoning(draft, event.data.reasoningID)
if (match) {
match.text = event.data.text
if (event.data.providerMetadata !== undefined) match.providerMetadata = event.data.providerMetadata
}
})
},
@ -420,7 +380,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
"session.next.compaction.started": (event) => {
return adapter.appendMessage(
new SessionMessage.Compaction({
id: event.id,
id: event.data.messageID,
type: "compaction",
metadata: event.metadata,
reason: event.data.reason,

View file

@ -2,15 +2,15 @@ export * as SessionMessage from "./message"
import { Schema } from "effect"
import { ProviderMetadata } from "@opencode-ai/llm"
import { EventV2 } from "../event"
import { ModelV2 } from "../model"
import { ToolOutput } from "../tool-output"
import { V2Schema } from "../v2-schema"
import { SessionEvent } from "./event"
import { Prompt } from "./prompt"
import { SessionMessageID } from "./message-id"
export const ID = EventV2.ID
export type ID = Schema.Schema.Type<typeof ID>
export const ID = SessionMessageID.ID
export type ID = typeof ID.Type
const Base = {
id: ID,

View file

@ -19,7 +19,7 @@ type DatabaseService = Database.Interface["db"]
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message)
const encodeMessage = Schema.encodeSync(SessionMessage.Message)
export class PromptAlreadyProjected extends Error {}
class PromptAlreadyProjected extends Error {}
export class SessionAlreadyProjected extends Error {}
type Usage = {
@ -112,29 +112,23 @@ function run(db: DatabaseService, event: SessionEvent.Event) {
return Effect.gen(function* () {
const decodeRow = (row: typeof SessionMessageTable.$inferSelect) =>
decodeMessage({ ...row.data, id: row.id, type: row.type })
const writeMessage = (message: SessionMessage.Message) => {
const updateMessage = (message: SessionMessage.Message) => {
if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence")
const encoded = encodeMessage(message)
const { id, type, ...data } = encoded
return db
.insert(SessionMessageTable)
.values([
{
id: SessionMessage.ID.make(id),
session_id: event.data.sessionID,
type,
seq: event.seq,
time_created: DateTime.toEpochMillis(message.time.created),
data,
},
])
.onConflictDoUpdate({
target: SessionMessageTable.id,
set: { type, time_created: DateTime.toEpochMillis(message.time.created), data },
})
.update(SessionMessageTable)
.set({ type, time_created: DateTime.toEpochMillis(message.time.created), data })
.where(
and(
eq(SessionMessageTable.id, SessionMessage.ID.make(id)),
eq(SessionMessageTable.session_id, event.data.sessionID),
),
)
.run()
.pipe(Effect.orDie)
}
const appendMessage = (message: SessionMessage.Message) => insertMessage(db, event, message)
const adapter: SessionMessageUpdater.Adapter = {
getCurrentAssistant() {
return Effect.gen(function* () {
@ -204,15 +198,33 @@ function run(db: DatabaseService, event: SessionEvent.Event) {
.find((message): message is SessionMessage.Shell => message.type === "shell" && message.callID === callID)
})
},
updateAssistant: writeMessage,
updateCompaction: writeMessage,
updateShell: writeMessage,
appendMessage: writeMessage,
updateAssistant: updateMessage,
updateCompaction: updateMessage,
updateShell: updateMessage,
appendMessage,
}
yield* SessionMessageUpdater.update(adapter, event)
})
}
function insertMessage(db: DatabaseService, event: SessionEvent.Event, message: SessionMessage.Message) {
if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence")
const encoded = encodeMessage(message)
const { id, type, ...data } = encoded
return db
.insert(SessionMessageTable)
.values({
id: SessionMessage.ID.make(id),
session_id: event.data.sessionID,
type,
seq: event.seq,
time_created: DateTime.toEpochMillis(message.time.created),
data,
})
.run()
.pipe(Effect.orDie)
}
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const events = yield* EventV2.Service
@ -349,27 +361,19 @@ export const layer = Layer.effectDiscard(
)
yield* events.project(SessionEvent.Prompted, (event) =>
Effect.gen(function* () {
const messageID = event.data.messageID
const existing = yield* db
.select({ id: SessionMessageTable.id })
.from(SessionMessageTable)
.where(eq(SessionMessageTable.id, event.id))
.where(eq(SessionMessageTable.id, messageID))
.get()
.pipe(Effect.orDie)
if (existing) return yield* Effect.die(new PromptAlreadyProjected())
yield* run(db, event)
const row = yield* db
.select()
.from(SessionMessageTable)
.where(eq(SessionMessageTable.id, event.id))
.get()
.pipe(Effect.orDie)
if (!row) return yield* Effect.die("Prompt projection was not stored")
const message = decodeMessage({ ...row.data, id: row.id, type: row.type })
if (message.type !== "user") return yield* Effect.die("Prompt projection did not produce a user message")
if (event.seq === undefined)
return yield* Effect.die("Synchronized Session event is missing aggregate sequence")
yield* SessionInput.project(db, {
id: SessionMessage.ID.make(event.id),
yield* SessionInput.projectLegacyPrompted(db, {
id: messageID,
sessionID: event.data.sessionID,
prompt: event.data.prompt,
delivery: event.data.delivery,
@ -378,6 +382,37 @@ export const layer = Layer.effectDiscard(
})
}),
)
yield* events.project(SessionEvent.PromptLifecycle.Admitted, (event) =>
Effect.gen(function* () {
if (event.seq === undefined)
return yield* Effect.die("Synchronized Session event is missing aggregate sequence")
yield* SessionInput.projectAdmitted(db, {
admittedSeq: event.seq,
id: event.data.messageID,
sessionID: event.data.sessionID,
prompt: event.data.prompt,
delivery: event.data.delivery,
timeCreated: event.data.timestamp,
})
}),
)
yield* events.project(SessionEvent.PromptLifecycle.Promoted, (event) =>
Effect.gen(function* () {
if (event.seq === undefined)
return yield* Effect.die("Synchronized Session event is missing aggregate sequence")
yield* insertMessage(
db,
event,
yield* SessionInput.projectPromoted(db, {
id: event.data.messageID,
sessionID: event.data.sessionID,
prompt: event.data.prompt,
timeCreated: event.data.timeCreated,
promotedSeq: event.seq,
}),
)
}),
)
yield* events.project(SessionEvent.Synthetic, (event) => run(db, event))
yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Shell.Ended, (event) => run(db, event))

View file

@ -132,10 +132,13 @@ export const layer = Layer.effect(
const model = yield* models.resolve(session)
const toolFibers = yield* FiberSet.make<void, never>()
let needsContinuation = false
if (promotion === "steer") yield* SessionInput.promoteSteers(db, events, session.id)
if (promotion === "queue") {
yield* SessionInput.promoteNextQueued(db, events, session.id)
yield* SessionInput.promoteSteers(db, events, session.id)
if (promotion) {
const cutoff = yield* SessionInput.latestSeq(db, session.id)
if (promotion === "steer") yield* SessionInput.promoteSteers(db, events, session.id, cutoff)
if (promotion === "queue") {
yield* SessionInput.promoteNextQueued(db, events, session.id)
yield* SessionInput.promoteSteers(db, events, session.id, cutoff)
}
}
yield* failInterruptedTools(session.id)
const context = yield* getContext(session.id)
@ -233,8 +236,8 @@ export const layer = Layer.effect(
readonly force?: boolean
}) {
const session = yield* getSession(input.sessionID)
const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, ["steer"])
const hasQueue = yield* SessionInput.hasPending(db, input.sessionID, ["queue"])
const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer")
const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue")
if (input.force !== true && !hasSteer && !hasQueue) return
let promotion: "steer" | "queue" | undefined = hasSteer ? "steer" : hasQueue ? "queue" : undefined
let openActivity = input.force === true || hasSteer || hasQueue
@ -243,12 +246,12 @@ export const layer = Layer.effect(
for (let step = 0; step < MAX_STEPS; step++) {
needsContinuation = yield* runTurn(session, promotion)
promotion = "steer"
if (!needsContinuation) needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, ["steer"])
if (!needsContinuation) needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer")
if (!needsContinuation) break
}
if (needsContinuation)
return yield* new StepLimitExceededError({ sessionID: input.sessionID, limit: MAX_STEPS })
openActivity = yield* SessionInput.hasPending(db, input.sessionID, ["queue"])
openActivity = yield* SessionInput.hasPending(db, input.sessionID, "queue")
promotion = openActivity ? "queue" : undefined
}
})

View file

@ -10,6 +10,7 @@ import { DateTime, Effect } from "effect"
import { EventV2 } from "../../event"
import { ModelV2 } from "../../model"
import { SessionEvent } from "../event"
import { SessionMessage } from "../message"
import { SessionSchema } from "../schema"
type Input = {
@ -60,7 +61,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
const tools = new Map<
string,
{
readonly assistantMessageID: EventV2.ID
readonly assistantMessageID: SessionMessage.ID
readonly name: string
inputEnded: boolean
called: boolean
@ -70,13 +71,17 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
}
>()
const timestamp = DateTime.now
let assistantMessageID: EventV2.ID | undefined
let assistantMessageID: SessionMessage.ID | undefined
let providerFailed = false
const startAssistant = Effect.fnUntraced(function* () {
if (assistantMessageID !== undefined) return assistantMessageID
assistantMessageID = (yield* events.publish(SessionEvent.Step.Started, { ...input, timestamp: yield* timestamp }))
.id
assistantMessageID = SessionMessage.ID.create()
yield* events.publish(SessionEvent.Step.Started, {
...input,
assistantMessageID,
timestamp: yield* timestamp,
})
return assistantMessageID
})
const currentAssistantMessageID = () =>
@ -118,6 +123,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
Effect.gen(function* () {
yield* events.publish(SessionEvent.Text.Ended, {
sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(),
timestamp: yield* timestamp,
textID,
text: value,
@ -128,6 +134,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
Effect.gen(function* () {
yield* events.publish(SessionEvent.Reasoning.Ended, {
sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(),
timestamp: yield* timestamp,
reasoningID,
text: value,
@ -220,6 +227,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
yield* text.start(event.id)
yield* events.publish(SessionEvent.Text.Started, {
sessionID: input.sessionID,
assistantMessageID: yield* startAssistant(),
timestamp: yield* timestamp,
textID: event.id,
})
@ -228,6 +236,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
yield* text.append(event.id, event.text)
yield* events.publish(SessionEvent.Text.Delta, {
sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(),
timestamp: yield* timestamp,
textID: event.id,
delta: event.text,
@ -240,6 +249,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
yield* reasoning.start(event.id)
yield* events.publish(SessionEvent.Reasoning.Started, {
sessionID: input.sessionID,
assistantMessageID: yield* startAssistant(),
timestamp: yield* timestamp,
reasoningID: event.id,
providerMetadata: event.providerMetadata,
@ -249,6 +259,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
yield* reasoning.append(event.id, event.text)
yield* events.publish(SessionEvent.Reasoning.Delta, {
sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(),
timestamp: yield* timestamp,
reasoningID: event.id,
delta: event.text,

View file

@ -1,4 +1,4 @@
import { sqliteTable, text, integer, index, primaryKey, real } from "drizzle-orm/sqlite-core"
import { sqliteTable, text, integer, index, primaryKey, real, uniqueIndex } from "drizzle-orm/sqlite-core"
import * as DatabasePath from "../database/path"
import { ProjectTable } from "../project/sql"
import type { SessionMessage } from "./message"
@ -127,7 +127,7 @@ export const SessionMessageTable = sqliteTable(
data: text({ mode: "json" }).notNull().$type<SessionMessageData>(),
},
(table) => [
index("session_message_session_seq_idx").on(table.session_id, table.seq),
uniqueIndex("session_message_session_seq_idx").on(table.session_id, table.seq),
index("session_message_session_type_seq_idx").on(table.session_id, table.type, table.seq),
index("session_message_session_time_created_id_idx").on(table.session_id, table.time_created, table.id),
index("session_message_time_created_idx").on(table.time_created),
@ -137,14 +137,14 @@ export const SessionMessageTable = sqliteTable(
export const SessionInputTable = sqliteTable(
"session_input",
{
seq: integer().primaryKey({ autoIncrement: true }),
id: text().$type<SessionMessage.ID>().notNull().unique(),
id: text().$type<SessionMessage.ID>().primaryKey(),
session_id: text()
.$type<SessionSchema.ID>()
.notNull()
.references(() => SessionTable.id, { onDelete: "cascade" }),
prompt: text({ mode: "json" }).notNull().$type<Prompt>(),
delivery: text().$type<SessionInput.Delivery>().notNull(),
admitted_seq: integer().notNull(),
promoted_seq: integer(),
time_created: integer()
.notNull()
@ -155,7 +155,9 @@ export const SessionInputTable = sqliteTable(
table.session_id,
table.promoted_seq,
table.delivery,
table.seq,
table.admitted_seq,
),
uniqueIndex("session_input_session_admitted_seq_idx").on(table.session_id, table.admitted_seq),
uniqueIndex("session_input_session_promoted_seq_idx").on(table.session_id, table.promoted_seq),
],
)

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()

View file

@ -1,5 +1,6 @@
import { useEvent } from "@tui/context/event"
import type {
Event,
SessionMessage,
SessionMessageAssistant,
SessionMessageAssistantReasoning,
@ -54,6 +55,11 @@ function latestReasoning(assistant: SessionMessageAssistant | undefined, reasoni
)
}
function prepend(messages: SessionMessage[], message: SessionMessage) {
if (messages.some((item) => item.id === message.id)) return
messages.unshift(message)
}
export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext({
name: "SyncV2",
init: () => {
@ -67,6 +73,18 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
const event = useEvent()
const sdk = useSDK()
const applied = new Set<string>()
const buffering = new Map<string, Event[]>()
const syncing = new Map<string, Promise<void>>()
function duplicate(id: string) {
if (applied.has(id)) return true
applied.add(id)
if (applied.size <= 1000) return false
const oldest = applied.values().next()
if (!oldest.done) applied.delete(oldest.value)
return false
}
function update(sessionID: string, fn: (messages: SessionMessage[]) => void) {
setStore(
@ -77,12 +95,41 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
)
}
event.subscribe((event) => {
async function hydrate(sessionID: string) {
const pending: Event[] = []
const before = JSON.parse(JSON.stringify(store.messages[sessionID] ?? [])) as SessionMessage[]
buffering.set(sessionID, pending)
try {
const response = await sdk.client.v2.session.messages({ sessionID })
const messages = response.data?.data ?? []
const snapshotIDs = new Set(messages.map((message) => message.id))
setStore(
"messages",
sessionID,
reconcile([...messages, ...before.filter((message) => !snapshotIDs.has(message.id))]),
)
buffering.delete(sessionID)
for (const event of pending) apply(event)
} catch (error) {
buffering.delete(sessionID)
throw error
}
}
function sync(sessionID: string) {
const existing = syncing.get(sessionID)
if (existing) return existing
const result = hydrate(sessionID).finally(() => syncing.delete(sessionID))
syncing.set(sessionID, result)
return result
}
function apply(event: Event) {
switch (event.type) {
case "session.next.agent.switched":
update(event.properties.sessionID, (draft) => {
draft.unshift({
id: event.id,
prepend(draft, {
id: event.properties.messageID,
type: "agent-switched",
agent: event.properties.agent,
time: { created: event.properties.timestamp },
@ -91,8 +138,8 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
break
case "session.next.model.switched":
update(event.properties.sessionID, (draft) => {
draft.unshift({
id: event.id,
prepend(draft, {
id: event.properties.messageID,
type: "model-switched",
model: event.properties.model,
time: { created: event.properties.timestamp },
@ -101,8 +148,8 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
break
case "session.next.prompted": {
update(event.properties.sessionID, (draft) => {
draft.unshift({
id: event.id,
prepend(draft, {
id: event.properties.messageID,
type: "user",
text: event.properties.prompt.text,
files: event.properties.prompt.files,
@ -113,10 +160,25 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
})
break
}
case "session.next.prompt.admitted":
break
case "session.next.prompt.promoted":
update(event.properties.sessionID, (draft) => {
prepend(draft, {
id: event.properties.messageID,
type: "user",
text: event.properties.prompt.text,
files: event.properties.prompt.files,
agents: event.properties.prompt.agents,
references: event.properties.prompt.references,
time: { created: event.properties.timeCreated },
})
})
break
case "session.next.synthetic":
update(event.properties.sessionID, (draft) => {
draft.unshift({
id: event.id,
prepend(draft, {
id: event.properties.messageID,
type: "synthetic",
sessionID: event.properties.sessionID,
text: event.properties.text,
@ -126,8 +188,8 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
break
case "session.next.shell.started":
update(event.properties.sessionID, (draft) => {
draft.unshift({
id: event.id,
prepend(draft, {
id: event.properties.messageID,
type: "shell",
callID: event.properties.callID,
command: event.properties.command,
@ -146,10 +208,11 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
break
case "session.next.step.started":
update(event.properties.sessionID, (draft) => {
if (draft.some((message) => message.id === event.properties.assistantMessageID)) return
const currentAssistant = activeAssistant(draft)
if (currentAssistant) currentAssistant.time.completed = event.properties.timestamp
draft.unshift({
id: event.id,
prepend(draft, {
id: event.properties.assistantMessageID,
type: "assistant",
agent: event.properties.agent,
model: event.properties.model,
@ -182,18 +245,28 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
break
case "session.next.text.started":
update(event.properties.sessionID, (draft) => {
activeAssistant(draft)?.content.push({ type: "text", id: event.properties.textID, text: "" })
ownedAssistant(draft, event.properties.assistantMessageID)?.content.push({
type: "text",
id: event.properties.textID,
text: "",
})
})
break
case "session.next.text.delta":
update(event.properties.sessionID, (draft) => {
const match = latestText(activeAssistant(draft), event.properties.textID)
const match = latestText(
ownedAssistant(draft, event.properties.assistantMessageID),
event.properties.textID,
)
if (match) match.text += event.properties.delta
})
break
case "session.next.text.ended":
update(event.properties.sessionID, (draft) => {
const match = latestText(activeAssistant(draft), event.properties.textID)
const match = latestText(
ownedAssistant(draft, event.properties.assistantMessageID),
event.properties.textID,
)
if (match) match.text = event.properties.text
})
break
@ -263,7 +336,11 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
content: [...event.properties.content],
result: event.properties.result,
}
match.provider = event.properties.provider
match.provider = {
executed: event.properties.provider.executed || match.provider?.executed === true,
metadata: match.provider?.metadata,
resultMetadata: event.properties.provider.metadata,
}
match.time.completed = event.properties.timestamp
})
break
@ -282,13 +359,17 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
content: match.state.status === "running" ? match.state.content : [],
result: event.properties.result,
}
match.provider = event.properties.provider
match.provider = {
executed: event.properties.provider.executed || match.provider?.executed === true,
metadata: match.provider?.metadata,
resultMetadata: event.properties.provider.metadata,
}
match.time.completed = event.properties.timestamp
})
break
case "session.next.reasoning.started":
update(event.properties.sessionID, (draft) => {
activeAssistant(draft)?.content.push({
ownedAssistant(draft, event.properties.assistantMessageID)?.content.push({
type: "reasoning",
id: event.properties.reasoningID,
text: "",
@ -298,13 +379,19 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
break
case "session.next.reasoning.delta":
update(event.properties.sessionID, (draft) => {
const match = latestReasoning(activeAssistant(draft), event.properties.reasoningID)
const match = latestReasoning(
ownedAssistant(draft, event.properties.assistantMessageID),
event.properties.reasoningID,
)
if (match) match.text += event.properties.delta
})
break
case "session.next.reasoning.ended":
update(event.properties.sessionID, (draft) => {
const match = latestReasoning(activeAssistant(draft), event.properties.reasoningID)
const match = latestReasoning(
ownedAssistant(draft, event.properties.assistantMessageID),
event.properties.reasoningID,
)
if (match) {
match.text = event.properties.text
if (event.properties.providerMetadata !== undefined)
@ -316,8 +403,8 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
break
case "session.next.compaction.started":
update(event.properties.sessionID, (draft) => {
draft.unshift({
id: event.id,
prepend(draft, {
id: event.properties.messageID,
type: "compaction",
reason: event.properties.reason,
summary: "",
@ -340,16 +427,20 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
})
break
}
}
event.subscribe((event) => {
if (duplicate(event.id)) return
if ("sessionID" in event.properties && typeof event.properties.sessionID === "string")
buffering.get(event.properties.sessionID)?.push(event)
apply(event)
})
const result = {
data: store,
session: {
message: {
async sync(sessionID: string) {
const response = await sdk.client.v2.session.messages({ sessionID })
setStore("messages", sessionID, reconcile(response.data?.data ?? []))
},
sync,
fromSession(sessionID: string) {
const messages = store.messages[sessionID]
if (!messages) return []

View file

@ -32,7 +32,7 @@ const EventSchema = Schema.Union([
.values()
.map((definition) =>
Schema.Struct({
id: Schema.String,
id: EventV2.ID,
type: Schema.Literal(definition.type),
properties: definition.data,
}).annotate({ identifier: `Event.${definition.type}` }),

View file

@ -20,10 +20,10 @@ const SyncEventSchemas = EventV2.registry
return [
Schema.Struct({
type: Schema.Literal("sync"),
id: Schema.String,
id: EventV2.ID,
syncEvent: Schema.Struct({
type: Schema.Literal(EventV2.versionedType(definition.type, definition.sync.version)),
id: Schema.String,
id: EventV2.ID,
seq: Schema.Finite,
aggregateID: Schema.String,
data: definition.data,
@ -41,7 +41,7 @@ const GlobalEventSchema = Schema.Struct({
...EventV2.registry
.values()
.map((definition) =>
Schema.Struct({ id: Schema.String, type: Schema.Literal(definition.type), properties: definition.data }),
Schema.Struct({ id: EventV2.ID, type: Schema.Literal(definition.type), properties: definition.data }),
)
.toArray(),
InstanceDisposed,

View file

@ -1,4 +1,5 @@
import { NonNegativeInt } from "@opencode-ai/core/schema"
import { EventV2 } from "@opencode-ai/core/event"
import { SessionID } from "@/session/schema"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
@ -9,7 +10,7 @@ import { described } from "./metadata"
const root = "/sync"
export const ReplayEvent = Schema.Struct({
id: Schema.String,
id: EventV2.ID,
aggregateID: Schema.String,
seq: NonNegativeInt,
type: Schema.String,
@ -27,7 +28,7 @@ export const SessionPayload = Schema.Struct({
})
export const HistoryPayload = Schema.Record(Schema.String, NonNegativeInt)
export const HistoryEvent = Schema.Struct({
id: Schema.String,
id: EventV2.ID,
aggregate_id: Schema.String,
seq: NonNegativeInt,
type: Schema.String,

View file

@ -36,7 +36,7 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl
const replay = Effect.fn("SyncHttpApi.replay")(function* (ctx: { payload: typeof ReplayPayload.Type }) {
const payload: EventV2.SerializedEvent[] = ctx.payload.events.map((event) => ({
id: EventV2.ID.make(event.id),
id: event.id,
aggregateID: event.aggregateID,
seq: event.seq,
type: event.type,

View file

@ -12,7 +12,7 @@ import { Plugin } from "@/plugin"
import { Config } from "@/config/config"
import { NotFoundError } from "@/storage/storage"
import { Effect, Layer, Context, Schema } from "effect"
import { Effect, Layer, Context } from "effect"
import * as DateTime from "effect/DateTime"
import { InstanceState } from "@/effect/instance-state"
import { isOverflow as overflow, usable } from "./overflow"
@ -20,6 +20,7 @@ import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { EventV2Bridge } from "@/event-v2-bridge"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { EventV2 } from "@opencode-ai/core/event"
@ -609,6 +610,7 @@ export const layer = Layer.effect(
if (flags.experimentalEventSystem) {
yield* events.publish(SessionEvent.Compaction.Started, {
sessionID: input.sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(Date.now()),
reason: input.auto ? "auto" : "manual",
})

View file

@ -25,13 +25,13 @@ import { isRecord } from "@/util/record"
import { EventV2Bridge } from "@/event-v2-bridge"
import { Database } from "@opencode-ai/core/database/database"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import * as DateTime from "effect/DateTime"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { toolFileSourceFromUri, Usage, type LLMEvent } from "@opencode-ai/llm"
import { ToolOutput } from "@opencode-ai/core/tool-output"
import type { EventV2 } from "@opencode-ai/core/event"
const DOOM_LOOP_THRESHOLD = 3
const log = Log.create({ service: "session.processor" })
@ -67,7 +67,7 @@ export interface Interface {
}
type ToolCall = {
assistantMessageID?: EventV2.ID
assistantMessageID?: SessionMessage.ID
partID: SessionV1.ToolPart["id"]
messageID: SessionV1.ToolPart["messageID"]
sessionID: SessionV1.ToolPart["sessionID"]
@ -85,7 +85,7 @@ interface ProcessorContext extends Input {
currentText: SessionV1.TextPart | undefined
currentTextID: string | undefined
reasoningMap: Record<string, SessionV1.ReasoningPart>
v2AssistantMessageID: EventV2.ID | undefined
v2AssistantMessageID: SessionMessage.ID | undefined
}
type StreamEvent = LLMEvent
@ -129,6 +129,7 @@ export const layer = Layer.effect(
reasoningMap: {},
v2AssistantMessageID: undefined,
}
const mirrorAssistant = flags.experimentalEventSystem && !input.assistantMessage.summary
let aborted = false
const slog = log.clone().tag("session.id", input.sessionID).tag("messageID", input.assistantMessage.id)
@ -146,8 +147,10 @@ export const layer = Layer.effect(
const ensureV2AssistantMessage = Effect.fn("SessionProcessor.ensureV2AssistantMessage")(function* () {
if (ctx.v2AssistantMessageID) return ctx.v2AssistantMessageID
ctx.v2AssistantMessageID = (yield* events.publish(SessionEvent.Step.Started, {
ctx.v2AssistantMessageID = SessionMessage.ID.create()
yield* events.publish(SessionEvent.Step.Started, {
sessionID: ctx.sessionID,
assistantMessageID: ctx.v2AssistantMessageID,
agent: input.assistantMessage.agent,
model: {
id: ModelV2.ID.make(ctx.model.id),
@ -156,7 +159,7 @@ export const layer = Layer.effect(
},
snapshot: ctx.snapshot,
timestamp: DateTime.makeUnsafe(Date.now()),
})).id
})
return ctx.v2AssistantMessageID
})
@ -249,9 +252,10 @@ export const layer = Layer.effect(
const finishReasoning = Effect.fn("SessionProcessor.finishReasoning")(function* (reasoningID: string) {
if (!(reasoningID in ctx.reasoningMap)) return
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (flags.experimentalEventSystem) {
if (mirrorAssistant) {
yield* events.publish(SessionEvent.Reasoning.Ended, {
sessionID: ctx.sessionID,
assistantMessageID: yield* currentV2AssistantMessage(),
reasoningID,
text: ctx.reasoningMap[reasoningID].text,
providerMetadata: ctx.reasoningMap[reasoningID].metadata,
@ -266,23 +270,29 @@ export const layer = Layer.effect(
})
const flushV2Fragments = Effect.fn("SessionProcessor.flushV2Fragments")(function* () {
if (!flags.experimentalEventSystem) return
if (!mirrorAssistant) return
if (!ctx.assistantMessage.summary && ctx.currentText && ctx.currentTextID) {
yield* events.publish(SessionEvent.Text.Ended, {
sessionID: ctx.sessionID,
assistantMessageID: yield* currentV2AssistantMessage(),
textID: ctx.currentTextID,
text: ctx.currentText.text,
timestamp: DateTime.makeUnsafe(Date.now()),
})
}
yield* Effect.forEach(Object.entries(ctx.reasoningMap), ([reasoningID, part]) =>
events.publish(SessionEvent.Reasoning.Ended, {
sessionID: ctx.sessionID,
reasoningID,
text: part.text,
providerMetadata: part.metadata,
timestamp: DateTime.makeUnsafe(Date.now()),
}),
currentV2AssistantMessage().pipe(
Effect.flatMap((assistantMessageID) =>
events.publish(SessionEvent.Reasoning.Ended, {
sessionID: ctx.sessionID,
assistantMessageID,
reasoningID,
text: part.text,
providerMetadata: part.metadata,
timestamp: DateTime.makeUnsafe(Date.now()),
}),
),
),
)
})
@ -307,7 +317,7 @@ export const layer = Layer.effect(
return { call: ctx.toolcalls[input.id], part }
}
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
const assistantMessageID = flags.experimentalEventSystem ? yield* ensureV2AssistantMessage() : undefined
const assistantMessageID = mirrorAssistant ? yield* ensureV2AssistantMessage() : undefined
if (assistantMessageID) {
yield* events.publish(SessionEvent.Tool.Input.Started, {
sessionID: ctx.sessionID,
@ -367,9 +377,10 @@ export const layer = Layer.effect(
case "reasoning-start":
if (value.id in ctx.reasoningMap) return
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (flags.experimentalEventSystem) {
if (mirrorAssistant) {
yield* events.publish(SessionEvent.Reasoning.Started, {
sessionID: ctx.sessionID,
assistantMessageID: yield* ensureV2AssistantMessage(),
reasoningID: value.id,
providerMetadata: value.providerMetadata,
timestamp: DateTime.makeUnsafe(Date.now()),
@ -392,9 +403,10 @@ export const layer = Layer.effect(
if (!(value.id in ctx.reasoningMap)) return
ctx.reasoningMap[value.id].text += value.text
if (value.providerMetadata) ctx.reasoningMap[value.id].metadata = value.providerMetadata
if (flags.experimentalEventSystem) {
if (mirrorAssistant) {
yield* events.publish(SessionEvent.Reasoning.Delta, {
sessionID: ctx.sessionID,
assistantMessageID: yield* currentV2AssistantMessage(),
reasoningID: value.id,
delta: value.text,
timestamp: DateTime.makeUnsafe(Date.now()),
@ -426,9 +438,7 @@ export const layer = Layer.effect(
case "tool-input-delta":
{
const toolCall = yield* ensureToolCall(value)
const assistantMessageID = flags.experimentalEventSystem
? yield* requireV2AssistantMessage(toolCall.call)
: undefined
const assistantMessageID = mirrorAssistant ? yield* requireV2AssistantMessage(toolCall.call) : undefined
if (assistantMessageID) {
yield* events.publish(SessionEvent.Tool.Input.Delta, {
sessionID: ctx.sessionID,
@ -445,7 +455,7 @@ export const layer = Layer.effect(
case "tool-input-end": {
const toolCall = yield* ensureToolCall(value)
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (flags.experimentalEventSystem) {
if (mirrorAssistant) {
const assistantMessageID = yield* requireV2AssistantMessage(toolCall.call)
yield* events.publish(SessionEvent.Tool.Input.Ended, {
sessionID: ctx.sessionID,
@ -467,7 +477,7 @@ export const layer = Layer.effect(
const input = isRecord(value.input) ? value.input : { value: value.input }
if (!toolCall.call.inputEnded) {
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (flags.experimentalEventSystem) {
if (mirrorAssistant) {
const assistantMessageID = yield* requireV2AssistantMessage(toolCall.call)
yield* events.publish(SessionEvent.Tool.Input.Ended, {
sessionID: ctx.sessionID,
@ -479,7 +489,7 @@ export const layer = Layer.effect(
}
}
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (flags.experimentalEventSystem) {
if (mirrorAssistant) {
const assistantMessageID = yield* requireV2AssistantMessage(toolCall.call)
yield* events.publish(SessionEvent.Tool.Called, {
sessionID: ctx.sessionID,
@ -545,7 +555,7 @@ export const layer = Layer.effect(
if (!toolCall && value.result.type === "error") return
if (value.result.type === "error") {
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (flags.experimentalEventSystem) {
if (mirrorAssistant) {
const assistantMessageID = yield* requireV2AssistantMessage(toolCall?.call)
yield* events.publish(SessionEvent.Tool.Failed, {
sessionID: ctx.sessionID,
@ -586,7 +596,7 @@ export const layer = Layer.effect(
attachments: attachments.length ? attachments : undefined,
}
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (flags.experimentalEventSystem) {
if (mirrorAssistant) {
const assistantMessageID = yield* requireV2AssistantMessage(toolCall?.call)
const content = [
ToolOutput.text({ type: "text", text: output.output }),
@ -642,7 +652,7 @@ export const layer = Layer.effect(
case "tool-error": {
const toolCall = yield* readToolCall(value.id)
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (flags.experimentalEventSystem) {
if (mirrorAssistant) {
const assistantMessageID = yield* requireV2AssistantMessage(toolCall?.call)
yield* events.publish(SessionEvent.Tool.Failed, {
sessionID: ctx.sessionID,
@ -670,7 +680,7 @@ export const layer = Layer.effect(
if (!ctx.snapshot) ctx.snapshot = yield* snapshot.track()
if (!ctx.assistantMessage.summary) {
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (flags.experimentalEventSystem) {
if (mirrorAssistant) {
yield* ensureV2AssistantMessage()
}
}
@ -693,7 +703,7 @@ export const layer = Layer.effect(
})
if (!ctx.assistantMessage.summary) {
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (flags.experimentalEventSystem) {
if (mirrorAssistant) {
yield* events.publish(SessionEvent.Step.Ended, {
sessionID: ctx.sessionID,
assistantMessageID: yield* currentV2AssistantMessage(),
@ -752,9 +762,10 @@ export const layer = Layer.effect(
case "text-start":
if (!ctx.assistantMessage.summary) {
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (flags.experimentalEventSystem) {
if (mirrorAssistant) {
yield* events.publish(SessionEvent.Text.Started, {
sessionID: ctx.sessionID,
assistantMessageID: yield* ensureV2AssistantMessage(),
timestamp: DateTime.makeUnsafe(Date.now()),
textID: value.id,
})
@ -777,9 +788,10 @@ export const layer = Layer.effect(
if (!ctx.currentText) return
ctx.currentText.text += value.text
if (value.providerMetadata) ctx.currentText.metadata = value.providerMetadata
if (flags.experimentalEventSystem) {
if (mirrorAssistant) {
yield* events.publish(SessionEvent.Text.Delta, {
sessionID: ctx.sessionID,
assistantMessageID: yield* currentV2AssistantMessage(),
textID: value.id,
delta: value.text,
timestamp: DateTime.makeUnsafe(Date.now()),
@ -809,9 +821,10 @@ export const layer = Layer.effect(
)).text
if (!ctx.assistantMessage.summary) {
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (flags.experimentalEventSystem) {
if (mirrorAssistant) {
yield* events.publish(SessionEvent.Text.Ended, {
sessionID: ctx.sessionID,
assistantMessageID: yield* currentV2AssistantMessage(),
text: ctx.currentText.text,
timestamp: DateTime.makeUnsafe(Date.now()),
textID: value.id,
@ -876,7 +889,7 @@ export const layer = Layer.effect(
const match = yield* readToolCall(toolCallID)
if (!match) continue
const part = match.part
if (flags.experimentalEventSystem && match.call.assistantMessageID) {
if (mirrorAssistant && match.call.assistantMessageID) {
yield* events.publish(SessionEvent.Tool.Failed, {
sessionID: ctx.sessionID,
assistantMessageID: match.call.assistantMessageID,
@ -922,7 +935,7 @@ export const layer = Layer.effect(
}
if (!ctx.assistantMessage.summary) {
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (flags.experimentalEventSystem) {
if (mirrorAssistant) {
yield* events.publish(SessionEvent.Step.Failed, {
sessionID: ctx.sessionID,
assistantMessageID: yield* ensureV2AssistantMessage(),
@ -979,7 +992,7 @@ export const layer = Layer.effect(
parse,
set: (info) => {
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
const event = flags.experimentalEventSystem
const event = mirrorAssistant
? events.publish(SessionEvent.Retried, {
sessionID: ctx.sessionID,
attempt: info.attempt,

View file

@ -51,6 +51,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
import { EventV2Bridge } from "@/event-v2-bridge"
import { Database } from "@opencode-ai/core/database/database"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AgentAttachment, FileAttachment, Prompt, ReferenceAttachment, Source } from "@opencode-ai/core/session/prompt"
@ -563,6 +564,7 @@ export const layer = Layer.effect(
if (flags.experimentalEventSystem) {
yield* events.publish(SessionEvent.Shell.Started, {
sessionID: input.sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(started),
callID: part.callID,
command: input.command,
@ -738,6 +740,7 @@ export const layer = Layer.effect(
if (current?.agent !== info.agent) {
yield* events.publish(SessionEvent.AgentSwitched, {
sessionID: input.sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(info.time.created),
agent: info.agent,
})
@ -749,6 +752,7 @@ export const layer = Layer.effect(
) {
yield* events.publish(SessionEvent.ModelSwitched, {
sessionID: input.sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(info.time.created),
model: {
id: ModelV2.ID.make(info.model.modelID),
@ -1190,6 +1194,7 @@ export const layer = Layer.effect(
if (flags.experimentalEventSystem) {
yield* events.publish(SessionEvent.Prompted, {
sessionID: input.sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(info.time.created),
delivery: "steer",
prompt: new Prompt({
@ -1205,6 +1210,7 @@ export const layer = Layer.effect(
if (flags.experimentalEventSystem) {
yield* events.publish(SessionEvent.Synthetic, {
sessionID: input.sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(info.time.created),
text,
})

View file

@ -6,7 +6,7 @@ import { onMount } from "solid-js"
import { ProjectProvider } from "../../../src/cli/cmd/tui/context/project"
import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk"
import { SyncProviderV2, useSyncV2 } from "../../../src/cli/cmd/tui/context/sync-v2"
import { createEventSource, createFetch, directory } from "../../fixture/tui-sdk"
import { createEventSource, createFetch, directory, json } from "../../fixture/tui-sdk"
async function wait(fn: () => boolean, timeout = 2000) {
const start = Date.now()
@ -20,6 +20,12 @@ function global(payload: Event): GlobalEvent {
return { directory, project: "proj_test", payload }
}
function emitTwice(events: ReturnType<typeof createEventSource>, payload: Event) {
const event = global(payload)
events.emit(event)
events.emit(event)
}
test("sync v2 settles pending tools when a live failure arrives", async () => {
const events = createEventSource()
const calls = createFetch()
@ -47,63 +53,68 @@ test("sync v2 settles pending tools when a live failure arrives", async () => {
try {
await mounted
events.emit(
global({
id: "agent-1",
type: "session.next.agent.switched",
properties: { sessionID: "session-1", timestamp: 0, agent: "build" },
}),
)
events.emit(
global({
id: "model-1",
type: "session.next.model.switched",
properties: {
sessionID: "session-1",
timestamp: 0,
model: { id: "model-1", providerID: "provider-1" },
},
}),
)
events.emit(
global({
id: "assistant-1",
type: "session.next.step.started",
properties: {
sessionID: "session-1",
timestamp: 1,
agent: "build",
model: { id: "model-1", providerID: "provider-1" },
},
}),
)
events.emit(
global({
id: "input-1",
type: "session.next.tool.input.started",
properties: {
sessionID: "session-1",
timestamp: 2,
assistantMessageID: "assistant-1",
callID: "call-1",
name: "bash",
},
}),
)
events.emit(
global({
id: "failed-1",
type: "session.next.tool.failed",
properties: {
sessionID: "session-1",
timestamp: 3,
assistantMessageID: "assistant-1",
callID: "call-1",
error: { type: "unknown", message: "aborted" },
provider: { executed: false },
},
}),
)
emitTwice(events, {
id: "evt_agent_1",
type: "session.next.agent.switched",
properties: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 0, agent: "build" },
})
emitTwice(events, {
id: "evt_model_1",
type: "session.next.model.switched",
properties: {
sessionID: "session-1",
messageID: "msg_model_1",
timestamp: 0,
model: { id: "model-1", providerID: "provider-1" },
},
})
emitTwice(events, {
id: "evt_step_started_1",
type: "session.next.step.started",
properties: {
sessionID: "session-1",
assistantMessageID: "msg_explicit_assistant_9",
timestamp: 1,
agent: "build",
model: { id: "model-1", providerID: "provider-1" },
},
})
emitTwice(events, {
id: "evt_input_1",
type: "session.next.tool.input.started",
properties: {
sessionID: "session-1",
assistantMessageID: "msg_explicit_assistant_9",
timestamp: 2,
callID: "call-1",
name: "bash",
},
})
emitTwice(events, {
id: "evt_called_1",
type: "session.next.tool.called",
properties: {
sessionID: "session-1",
timestamp: 2,
assistantMessageID: "msg_explicit_assistant_9",
callID: "call-1",
tool: "bash",
input: {},
provider: { executed: false, metadata: { fake: { call: true } } },
},
})
emitTwice(events, {
id: "evt_failed_1",
type: "session.next.tool.failed",
properties: {
sessionID: "session-1",
timestamp: 3,
assistantMessageID: "msg_explicit_assistant_9",
callID: "call-1",
error: { type: "unknown", message: "aborted" },
provider: { executed: false, metadata: { fake: { result: true } } },
},
})
await wait(() => {
const assistant = sync.session.message.fromSession("session-1")[0]
@ -117,6 +128,7 @@ test("sync v2 settles pending tools when a live failure arrives", async () => {
const assistant = sync.session.message.fromSession("session-1")[0]
expect(assistant?.type).toBe("assistant")
if (assistant?.type !== "assistant") return
expect(assistant.id).toBe("msg_explicit_assistant_9")
const tool = assistant.content[0]
expect(tool?.type).toBe("tool")
if (tool?.type !== "tool") return
@ -126,6 +138,11 @@ test("sync v2 settles pending tools when a live failure arrives", async () => {
expect(tool.state.input).toEqual({})
expect(tool.state.structured).toEqual({})
expect(tool.state.content).toEqual([])
expect(tool.provider).toEqual({
executed: false,
metadata: { fake: { call: true } },
resultMetadata: { fake: { result: true } },
})
expect(sync.session.message.fromSession("session-1").map((message) => message.type)).toEqual([
"assistant",
"model-switched",
@ -135,3 +152,358 @@ test("sync v2 settles pending tools when a live failure arrives", async () => {
app.renderer.destroy()
}
})
test("sync v2 renders admitted prompts only after promotion", async () => {
const events = createEventSource()
const calls = createFetch()
let sync!: ReturnType<typeof useSyncV2>
let ready!: () => void
const mounted = new Promise<void>((resolve) => {
ready = resolve
})
function Probe() {
sync = useSyncV2()
onMount(ready)
return <box />
}
const app = await testRender(() => (
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<ProjectProvider>
<SyncProviderV2>
<Probe />
</SyncProviderV2>
</ProjectProvider>
</SDKProvider>
))
try {
await mounted
emitTwice(events, {
id: "evt_admitted_1",
type: "session.next.prompt.admitted",
properties: {
sessionID: "session-1",
messageID: "msg_user_1",
timestamp: 0,
prompt: { text: "hello" },
delivery: "steer",
},
})
expect(sync.session.message.fromSession("session-1")).toEqual([])
emitTwice(events, {
id: "evt_promoted_1",
type: "session.next.prompt.promoted",
properties: {
sessionID: "session-1",
messageID: "msg_user_1",
timestamp: 1,
prompt: { text: "hello" },
timeCreated: 0,
},
})
await wait(() => sync.session.message.fromSession("session-1").length === 1)
const message = sync.session.message.fromSession("session-1")[0]
expect(message?.type).toBe("user")
if (message?.type !== "user") return
expect(message).toMatchObject({ id: "msg_user_1", text: "hello" })
} finally {
app.renderer.destroy()
}
})
test("sync v2 renders a promoted prompt when admission was missed", async () => {
const events = createEventSource()
const calls = createFetch()
let sync!: ReturnType<typeof useSyncV2>
let ready!: () => void
const mounted = new Promise<void>((resolve) => {
ready = resolve
})
function Probe() {
sync = useSyncV2()
onMount(ready)
return <box />
}
const app = await testRender(() => (
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<ProjectProvider>
<SyncProviderV2>
<Probe />
</SyncProviderV2>
</ProjectProvider>
</SDKProvider>
))
try {
await mounted
emitTwice(events, {
id: "evt_promoted_1",
type: "session.next.prompt.promoted",
properties: {
sessionID: "session-1",
messageID: "msg_user_1",
timestamp: 1,
prompt: { text: "hello" },
timeCreated: 0,
},
})
await wait(() => sync.session.message.fromSession("session-1").length === 1)
expect(sync.session.message.fromSession("session-1")[0]?.id).toBe("msg_user_1")
} finally {
app.renderer.destroy()
}
})
test("sync v2 preserves live events while snapshot hydration is in flight", async () => {
const events = createEventSource()
const response = Promise.withResolvers<Response>()
const calls = createFetch((url) => {
if (url.pathname === "/api/session/session-1/message") return response.promise
return undefined
})
let sync!: ReturnType<typeof useSyncV2>
let ready!: () => void
const mounted = new Promise<void>((resolve) => {
ready = resolve
})
function Probe() {
sync = useSyncV2()
onMount(ready)
return <box />
}
const app = await testRender(() => (
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<ProjectProvider>
<SyncProviderV2>
<Probe />
</SyncProviderV2>
</ProjectProvider>
</SDKProvider>
))
try {
await mounted
const hydration = sync.session.message.sync("session-1")
emitTwice(events, {
id: "evt_agent_1",
type: "session.next.agent.switched",
properties: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 0, agent: "build" },
})
response.resolve(json({ data: [] }))
await hydration
expect(sync.session.message.fromSession("session-1").map((message) => [message.id, message.type])).toEqual([
["msg_agent_1", "agent-switched"],
])
} finally {
app.renderer.destroy()
}
})
test("sync v2 replaces stale cached rows while preserving in-flight live rows", async () => {
const events = createEventSource()
const response = Promise.withResolvers<Response>()
const calls = createFetch((url) => {
if (url.pathname === "/api/session/session-1/message") return response.promise
return undefined
})
let sync!: ReturnType<typeof useSyncV2>
let ready!: () => void
const mounted = new Promise<void>((resolve) => {
ready = resolve
})
function Probe() {
sync = useSyncV2()
onMount(ready)
return <box />
}
const app = await testRender(() => (
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<ProjectProvider>
<SyncProviderV2>
<Probe />
</SyncProviderV2>
</ProjectProvider>
</SDKProvider>
))
try {
await mounted
emitTwice(events, {
id: "evt_promoted_1",
type: "session.next.prompt.promoted",
properties: {
sessionID: "session-1",
messageID: "msg_user_1",
timestamp: 1,
prompt: { text: "stale" },
timeCreated: 0,
},
})
await wait(() => sync.session.message.fromSession("session-1")[0]?.id === "msg_user_1")
const hydration = sync.session.message.sync("session-1")
emitTwice(events, {
id: "evt_agent_1",
type: "session.next.agent.switched",
properties: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 2, agent: "build" },
})
await wait(() => sync.session.message.fromSession("session-1")[0]?.id === "msg_agent_1")
response.resolve(
json({
data: [{ id: "msg_user_1", type: "user", text: "fresh", time: { created: 0 } }],
}),
)
await hydration
expect(sync.session.message.fromSession("session-1").map((message) => [message.id, message.type])).toEqual([
["msg_agent_1", "agent-switched"],
["msg_user_1", "user"],
])
expect(sync.session.message.fromSession("session-1")[1]).toMatchObject({ text: "fresh" })
} finally {
app.renderer.destroy()
}
})
test("sync v2 preserves snapshot order and metadata for in-flight updates", async () => {
const events = createEventSource()
const response = Promise.withResolvers<Response>()
const calls = createFetch((url) => {
if (url.pathname === "/api/session/session-1/message") return response.promise
return undefined
})
let sync!: ReturnType<typeof useSyncV2>
let ready!: () => void
const mounted = new Promise<void>((resolve) => {
ready = resolve
})
function Probe() {
sync = useSyncV2()
onMount(ready)
return <box />
}
const app = await testRender(() => (
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<ProjectProvider>
<SyncProviderV2>
<Probe />
</SyncProviderV2>
</ProjectProvider>
</SDKProvider>
))
try {
await mounted
emitTwice(events, {
id: "evt_step_older",
type: "session.next.step.started",
properties: {
sessionID: "session-1",
assistantMessageID: "msg_assistant_older",
timestamp: 0,
agent: "build",
model: { id: "model", providerID: "provider" },
},
})
emitTwice(events, {
id: "evt_step_1",
type: "session.next.step.started",
properties: {
sessionID: "session-1",
assistantMessageID: "msg_assistant_old",
timestamp: 1,
agent: "build",
model: { id: "model", providerID: "provider" },
},
})
await wait(() => sync.session.message.fromSession("session-1")[0]?.id === "msg_assistant_old")
const hydration = sync.session.message.sync("session-1")
emitTwice(events, {
id: "evt_text_1",
type: "session.next.text.started",
properties: {
sessionID: "session-1",
assistantMessageID: "msg_assistant_old",
timestamp: 2,
textID: "text-1",
},
})
emitTwice(events, {
id: "evt_text_older",
type: "session.next.text.started",
properties: {
sessionID: "session-1",
assistantMessageID: "msg_assistant_older",
timestamp: 2,
textID: "text-older",
},
})
await wait(() => {
const messages = sync.session.message.fromSession("session-1")
return messages.every((message) => message.type !== "assistant" || message.content[0]?.type === "text")
})
response.resolve(
json({
data: [
{
id: "msg_assistant_new",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [],
time: { created: 3 },
},
{
id: "msg_assistant_old",
type: "assistant",
metadata: { source: "snapshot" },
agent: "build",
model: { id: "model", providerID: "provider" },
content: [],
time: { created: 1 },
},
],
}),
)
await hydration
emitTwice(events, {
id: "evt_step_late_duplicate",
type: "session.next.step.started",
properties: {
sessionID: "session-1",
assistantMessageID: "msg_assistant_old",
timestamp: 1,
agent: "build",
model: { id: "model", providerID: "provider" },
},
})
expect(sync.session.message.fromSession("session-1").map((message) => message.id)).toEqual([
"msg_assistant_new",
"msg_assistant_old",
"msg_assistant_older",
])
expect(JSON.parse(JSON.stringify(sync.session.message.fromSession("session-1")[1]))).toMatchObject({
metadata: { source: "snapshot" },
content: [{ type: "text", id: "text-1", text: "" }],
})
expect(JSON.parse(JSON.stringify(sync.session.message.fromSession("session-1")[2]))).toMatchObject({
content: [{ type: "text", id: "text-older", text: "" }],
})
} finally {
app.renderer.destroy()
}
})

View file

@ -584,17 +584,19 @@ describe("session HttpApi", () => {
request(`/api/session/${session.id}/prompt`, {
method: "POST",
headers: { ...headers, "content-type": "application/json" },
body: JSON.stringify({ id: "evt_http_prompt", prompt: { text: "hello" } }),
body: JSON.stringify({ id: "msg_http_prompt", prompt: { text: "hello" } }),
})
const first = yield* recordPrompt()
const retried = yield* recordPrompt()
type PromptBody = { id: string; type: string; text: string }
type PromptBody = { id: string; prompt: { text: string }; delivery: string; promotedSeq?: number }
const firstBody = yield* json<{ data: PromptBody }>(first)
const retriedBody = yield* json<{ data: PromptBody }>(retried)
expect(first.status).toBe(200)
expect(retried.status).toBe(200)
expect(retriedBody).toEqual(firstBody)
expect(firstBody).toMatchObject({ data: { type: "user", text: "hello" } })
expect(firstBody).toMatchObject({
data: { id: "msg_http_prompt", prompt: { text: "hello" }, delivery: "steer" },
})
const messages = yield* requestJson<{ data: PromptBody[] }>(`/api/session/${session.id}/message`, {
headers,
@ -604,27 +606,26 @@ describe("session HttpApi", () => {
db
.select()
.from(SessionInputTable)
.where(eq(SessionInputTable.id, SessionMessage.ID.make("evt_http_prompt")))
.where(eq(SessionInputTable.id, SessionMessage.ID.make("msg_http_prompt")))
.get()
.pipe(Effect.orDie),
)
expect(admitted).toMatchObject({
id: "evt_http_prompt",
id: "msg_http_prompt",
session_id: session.id,
delivery: "steer",
promoted_seq: null,
})
const conflict = yield* request(`/api/session/${session.id}/prompt`, {
method: "POST",
headers: { ...headers, "content-type": "application/json" },
body: JSON.stringify({ id: "evt_http_prompt", prompt: { text: "goodbye" } }),
body: JSON.stringify({ id: "msg_http_prompt", prompt: { text: "goodbye" } }),
})
expect(conflict.status).toBe(409)
expect(yield* responseJson(conflict)).toEqual({
_tag: "ConflictError",
message: "Prompt message ID conflicts with an existing durable record: evt_http_prompt",
resource: "evt_http_prompt",
message: "Prompt message ID conflicts with an existing durable record: msg_http_prompt",
resource: "msg_http_prompt",
})
}),
{ git: true, config: { formatter: false, lsp: false } },

View file

@ -106,6 +106,13 @@ describe("sync HttpApi", () => {
events: [{ id: "event", aggregateID: "session", seq: 1.5, type: "session.created", data: {} }],
},
},
{
path: SyncPaths.replay,
body: {
directory: tmp.directory,
events: [{ id: "event", aggregateID: "session", seq: 0, type: "session.created", data: {} }],
},
},
]
for (const item of cases) {

View file

@ -26,7 +26,7 @@ function migrations() {
}
describe("workspace time migration", () => {
test("migrates existing workspace rows", () => {
test("discards existing workspace rows during the beta reset", () => {
const sqlite = new Database(":memory:")
const db = drizzle({ client: sqlite })
const entries = migrations()
@ -45,6 +45,6 @@ describe("workspace time migration", () => {
)
expect(() => migrate(db, entries.slice(index))).not.toThrow()
expect(sqlite.query("SELECT time_used FROM workspace WHERE id = ?").get("workspace_1")).toEqual({ time_used: 0 })
expect(sqlite.query("SELECT time_used FROM workspace WHERE id = ?").get("workspace_1")).toBeNull()
})
})

View file

@ -7,19 +7,21 @@ import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { ToolOutput } from "@opencode-ai/core/tool-output"
test.skip("step snapshots carry over to assistant messages", () => {
const state: SessionMessageUpdater.MemoryState = { messages: [] }
const sessionID = SessionID.make("session")
const assistantMessageID = EventV2.ID.create()
const assistantMessageID = SessionMessage.ID.create()
Effect.runSync(
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
id: assistantMessageID,
id: EventV2.ID.create(),
type: "session.next.step.started",
data: {
sessionID,
assistantMessageID,
timestamp: DateTime.makeUnsafe(1),
agent: "build",
model: {
@ -62,6 +64,7 @@ test.skip("step snapshots carry over to assistant messages", () => {
test.skip("text ended populates assistant text content", () => {
const state: SessionMessageUpdater.MemoryState = { messages: [] }
const sessionID = SessionID.make("session")
const assistantMessageID = SessionMessage.ID.create()
Effect.runSync(
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
@ -69,6 +72,7 @@ test.skip("text ended populates assistant text content", () => {
type: "session.next.step.started",
data: {
sessionID,
assistantMessageID,
timestamp: DateTime.makeUnsafe(1),
agent: "build",
model: {
@ -86,6 +90,7 @@ test.skip("text ended populates assistant text content", () => {
type: "session.next.text.started",
data: {
sessionID,
assistantMessageID,
timestamp: DateTime.makeUnsafe(2),
textID: "text-1",
},
@ -98,6 +103,7 @@ test.skip("text ended populates assistant text content", () => {
type: "session.next.text.ended",
data: {
sessionID,
assistantMessageID,
timestamp: DateTime.makeUnsafe(3),
textID: "text-1",
text: "hello assistant",
@ -114,14 +120,15 @@ test.skip("tool completion stores completed timestamp", () => {
const state: SessionMessageUpdater.MemoryState = { messages: [] }
const sessionID = SessionID.make("session")
const callID = "call"
const assistantMessageID = EventV2.ID.create()
const assistantMessageID = SessionMessage.ID.create()
Effect.runSync(
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
id: assistantMessageID,
id: EventV2.ID.create(),
type: "session.next.step.started",
data: {
sessionID,
assistantMessageID,
timestamp: DateTime.makeUnsafe(1),
agent: "build",
model: {
@ -198,6 +205,7 @@ test.skip("compaction events reduce to compaction message", () => {
type: "session.next.compaction.started",
data: {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1),
reason: "auto",
},

View file

@ -19,6 +19,8 @@ export type Event =
| EventSessionNextModelSwitched
| EventSessionNextMoved
| EventSessionNextPrompted
| EventSessionNextPromptAdmitted
| EventSessionNextPromptPromoted
| EventSessionNextSynthetic
| EventSessionNextShellStarted
| EventSessionNextShellEnded
@ -804,6 +806,7 @@ export type GlobalEvent = {
properties: {
timestamp: number
sessionID: string
messageID: string
agent: string
}
}
@ -813,6 +816,7 @@ export type GlobalEvent = {
properties: {
timestamp: number
sessionID: string
messageID: string
model: {
id: string
providerID: string
@ -836,16 +840,40 @@ export type GlobalEvent = {
properties: {
timestamp: number
sessionID: string
messageID: string
prompt: Prompt
delivery: "steer" | "queue"
}
}
| {
id: string
type: "session.next.prompt.admitted"
properties: {
timestamp: number
sessionID: string
messageID: string
prompt: Prompt
delivery: "steer" | "queue"
}
}
| {
id: string
type: "session.next.prompt.promoted"
properties: {
timestamp: number
sessionID: string
messageID: string
prompt: Prompt
timeCreated: number
}
}
| {
id: string
type: "session.next.synthetic"
properties: {
timestamp: number
sessionID: string
messageID: string
text: string
}
}
@ -855,6 +883,7 @@ export type GlobalEvent = {
properties: {
timestamp: number
sessionID: string
messageID: string
callID: string
command: string
}
@ -875,6 +904,7 @@ export type GlobalEvent = {
properties: {
timestamp: number
sessionID: string
assistantMessageID: string
agent: string
model: {
id: string
@ -921,6 +951,7 @@ export type GlobalEvent = {
properties: {
timestamp: number
sessionID: string
assistantMessageID: string
textID: string
}
}
@ -930,6 +961,7 @@ export type GlobalEvent = {
properties: {
timestamp: number
sessionID: string
assistantMessageID: string
textID: string
delta: string
}
@ -940,6 +972,7 @@ export type GlobalEvent = {
properties: {
timestamp: number
sessionID: string
assistantMessageID: string
textID: string
text: string
}
@ -950,6 +983,7 @@ export type GlobalEvent = {
properties: {
timestamp: number
sessionID: string
assistantMessageID: string
reasoningID: string
providerMetadata?: {
[key: string]: {
@ -964,6 +998,7 @@ export type GlobalEvent = {
properties: {
timestamp: number
sessionID: string
assistantMessageID: string
reasoningID: string
delta: string
}
@ -974,6 +1009,7 @@ export type GlobalEvent = {
properties: {
timestamp: number
sessionID: string
assistantMessageID: string
reasoningID: string
text: string
providerMetadata?: {
@ -1111,6 +1147,7 @@ export type GlobalEvent = {
properties: {
timestamp: number
sessionID: string
messageID: string
reason: "auto" | "manual"
}
}
@ -1576,6 +1613,8 @@ export type GlobalEvent = {
| SyncEventSessionNextModelSwitched
| SyncEventSessionNextMoved
| SyncEventSessionNextPrompted
| SyncEventSessionNextPromptAdmitted
| SyncEventSessionNextPromptPromoted
| SyncEventSessionNextSynthetic
| SyncEventSessionNextShellStarted
| SyncEventSessionNextShellEnded
@ -3122,6 +3161,7 @@ export type SyncEventSessionNextAgentSwitched = {
data: {
timestamp: number
sessionID: string
messageID: string
agent: string
}
}
@ -3138,6 +3178,7 @@ export type SyncEventSessionNextModelSwitched = {
data: {
timestamp: number
sessionID: string
messageID: string
model: {
id: string
providerID: string
@ -3175,12 +3216,49 @@ export type SyncEventSessionNextPrompted = {
data: {
timestamp: number
sessionID: string
messageID: string
prompt: Prompt
delivery: "steer" | "queue"
}
}
}
export type SyncEventSessionNextPromptAdmitted = {
type: "sync"
id: string
syncEvent: {
type: "session.next.prompt.admitted.1"
id: string
seq: number
aggregateID: string
data: {
timestamp: number
sessionID: string
messageID: string
prompt: Prompt
delivery: "steer" | "queue"
}
}
}
export type SyncEventSessionNextPromptPromoted = {
type: "sync"
id: string
syncEvent: {
type: "session.next.prompt.promoted.1"
id: string
seq: number
aggregateID: string
data: {
timestamp: number
sessionID: string
messageID: string
prompt: Prompt
timeCreated: number
}
}
}
export type SyncEventSessionNextSynthetic = {
type: "sync"
id: string
@ -3192,6 +3270,7 @@ export type SyncEventSessionNextSynthetic = {
data: {
timestamp: number
sessionID: string
messageID: string
text: string
}
}
@ -3208,6 +3287,7 @@ export type SyncEventSessionNextShellStarted = {
data: {
timestamp: number
sessionID: string
messageID: string
callID: string
command: string
}
@ -3242,6 +3322,7 @@ export type SyncEventSessionNextStepStarted = {
data: {
timestamp: number
sessionID: string
assistantMessageID: string
agent: string
model: {
id: string
@ -3309,6 +3390,7 @@ export type SyncEventSessionNextTextStarted = {
data: {
timestamp: number
sessionID: string
assistantMessageID: string
textID: string
}
}
@ -3325,6 +3407,7 @@ export type SyncEventSessionNextTextEnded = {
data: {
timestamp: number
sessionID: string
assistantMessageID: string
textID: string
text: string
}
@ -3342,6 +3425,7 @@ export type SyncEventSessionNextReasoningStarted = {
data: {
timestamp: number
sessionID: string
assistantMessageID: string
reasoningID: string
providerMetadata?: {
[key: string]: {
@ -3363,6 +3447,7 @@ export type SyncEventSessionNextReasoningEnded = {
data: {
timestamp: number
sessionID: string
assistantMessageID: string
reasoningID: string
text: string
providerMetadata?: {
@ -3545,6 +3630,7 @@ export type SyncEventSessionNextCompactionStarted = {
data: {
timestamp: number
sessionID: string
messageID: string
reason: "auto" | "manual"
}
}
@ -3670,19 +3756,14 @@ export type SessionV2Info = {
subpath?: string
}
export type SessionMessageUser = {
export type SessionInputAdmitted = {
admittedSeq: number
id: string
metadata?: {
[key: string]: unknown
}
time: {
created: number
}
text: string
files?: Array<PromptFileAttachment>
agents?: Array<PromptAgentAttachment>
references?: Array<PromptReferenceAttachment>
type: "user"
sessionID: string
prompt: Prompt
delivery: "steer" | "queue"
timeCreated: number
promotedSeq?: number
}
export type SessionMessageAgentSwitched = {
@ -3713,6 +3794,21 @@ export type SessionMessageModelSwitched = {
}
}
export type SessionMessageUser = {
id: string
metadata?: {
[key: string]: unknown
}
time: {
created: number
}
text: string
files?: Array<PromptFileAttachment>
agents?: Array<PromptAgentAttachment>
references?: Array<PromptReferenceAttachment>
type: "user"
}
export type SessionMessageSynthetic = {
id: string
metadata?: {
@ -4176,6 +4272,7 @@ export type EventSessionNextAgentSwitched = {
properties: {
timestamp: number
sessionID: string
messageID: string
agent: string
}
}
@ -4186,6 +4283,7 @@ export type EventSessionNextModelSwitched = {
properties: {
timestamp: number
sessionID: string
messageID: string
model: {
id: string
providerID: string
@ -4211,17 +4309,43 @@ export type EventSessionNextPrompted = {
properties: {
timestamp: number
sessionID: string
messageID: string
prompt: Prompt
delivery: "steer" | "queue"
}
}
export type EventSessionNextPromptAdmitted = {
id: string
type: "session.next.prompt.admitted"
properties: {
timestamp: number
sessionID: string
messageID: string
prompt: Prompt
delivery: "steer" | "queue"
}
}
export type EventSessionNextPromptPromoted = {
id: string
type: "session.next.prompt.promoted"
properties: {
timestamp: number
sessionID: string
messageID: string
prompt: Prompt
timeCreated: number
}
}
export type EventSessionNextSynthetic = {
id: string
type: "session.next.synthetic"
properties: {
timestamp: number
sessionID: string
messageID: string
text: string
}
}
@ -4232,6 +4356,7 @@ export type EventSessionNextShellStarted = {
properties: {
timestamp: number
sessionID: string
messageID: string
callID: string
command: string
}
@ -4254,6 +4379,7 @@ export type EventSessionNextStepStarted = {
properties: {
timestamp: number
sessionID: string
assistantMessageID: string
agent: string
model: {
id: string
@ -4303,6 +4429,7 @@ export type EventSessionNextTextStarted = {
properties: {
timestamp: number
sessionID: string
assistantMessageID: string
textID: string
}
}
@ -4313,6 +4440,7 @@ export type EventSessionNextTextDelta = {
properties: {
timestamp: number
sessionID: string
assistantMessageID: string
textID: string
delta: string
}
@ -4324,6 +4452,7 @@ export type EventSessionNextTextEnded = {
properties: {
timestamp: number
sessionID: string
assistantMessageID: string
textID: string
text: string
}
@ -4335,6 +4464,7 @@ export type EventSessionNextReasoningStarted = {
properties: {
timestamp: number
sessionID: string
assistantMessageID: string
reasoningID: string
providerMetadata?: {
[key: string]: {
@ -4350,6 +4480,7 @@ export type EventSessionNextReasoningDelta = {
properties: {
timestamp: number
sessionID: string
assistantMessageID: string
reasoningID: string
delta: string
}
@ -4361,6 +4492,7 @@ export type EventSessionNextReasoningEnded = {
properties: {
timestamp: number
sessionID: string
assistantMessageID: string
reasoningID: string
text: string
providerMetadata?: {
@ -4507,6 +4639,7 @@ export type EventSessionNextCompactionStarted = {
properties: {
timestamp: number
sessionID: string
messageID: string
reason: "auto" | "manual"
}
}
@ -9263,7 +9396,7 @@ export type V2SessionPromptResponses = {
* Success
*/
200: {
data: SessionMessageUser
data: SessionInputAdmitted
}
}

File diff suppressed because it is too large Load diff

View file

@ -114,7 +114,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session")
delivery: SessionInput.Delivery.pipe(Schema.optional),
resume: Schema.Boolean.pipe(Schema.optional),
}),
success: Schema.Struct({ data: SessionMessage.User }),
success: Schema.Struct({ data: SessionInput.Admitted }),
error: [ConflictError, SessionNotFoundError],
}).annotateMerge(
OpenApi.annotations({