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/20260603040000_session_message_projection_order"),
import("./migration/20260603141458_session_input_inbox"), import("./migration/20260603141458_session_input_inbox"),
import("./migration/20260603160727_jittery_ezekiel_stane"), import("./migration/20260603160727_jittery_ezekiel_stane"),
import("./migration/20260604172448_event_sourced_session_input"),
]) ])
).map((module) => module.default) satisfies DatabaseMigration.Migration[] ).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" 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 { and, asc, eq, gt } from "drizzle-orm"
import { Database } from "./database/database" import { Database } from "./database/database"
import { EventSequenceTable, EventTable } from "./event/sql" import { EventSequenceTable, EventTable } from "./event/sql"
import { Location } from "./location" import { Location } from "./location"
import { externalID, type ExternalID, NonNegativeInt, withStatics } from "./schema" import { externalID, type ExternalID, NonNegativeInt, withStatics } from "./schema"
import { Identifier } from "./util/identifier" 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"), Schema.brand("Event.ID"),
withStatics((schema) => ({ withStatics((schema) => ({
create: () => schema.make("evt_" + Identifier.ascending()), create: () => schema.make("evt_" + Identifier.ascending()),
@ -258,16 +259,47 @@ export const layerWith = (options?: LayerOptions) =>
.get() .get()
.pipe(Effect.orDie) .pipe(Effect.orDie)
const latest = row?.seq ?? -1 const latest = row?.seq ?? -1
if (input && input.seq <= latest) return const encoded = syncRegistry
if (input && row?.ownerID && row.ownerID !== input.ownerID) { .get(versionedType(definition.type, sync.version))!
if (input.strictOwner) { .encode(event.data) as Record<string, unknown>
yield* Effect.die( if (input?.strictOwner && row?.ownerID && row.ownerID !== input.ownerID) {
new InvalidSyncEventError({ yield* Effect.die(
type: event.type, new InvalidSyncEventError({
message: `Replay owner mismatch for aggregate ${aggregateID}: expected ${row.ownerID}, got ${input.ownerID ?? "none"}`, 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 return
} }
const seq = input?.seq ?? latest + 1 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) { for (const guard of commitGuards) {
yield* guard(event) yield* guard(event)
} }
for (const projector of list) { for (const projector of list) {
yield* projector({ ...event, seq } as Payload) yield* projector({ ...event, seq } as Payload)
} }
const encoded = syncRegistry
.get(versionedType(definition.type, sync.version))!
.encode(event.data)
yield* db yield* db
.insert(EventSequenceTable) .insert(EventSequenceTable)
.values([{ aggregate_id: aggregateID, seq, owner_id: input?.ownerID }]) .values([{ aggregate_id: aggregateID, seq, owner_id: input?.ownerID }])
@ -308,7 +350,7 @@ export const layerWith = (options?: LayerOptions) =>
aggregate_id: aggregateID, aggregate_id: aggregateID,
seq, seq,
type: versionedType(definition.type, sync.version), type: versionedType(definition.type, sync.version),
data: encoded as Record<string, unknown>, data: encoded,
}, },
]) ])
.run() .run()
@ -337,22 +379,43 @@ export const layerWith = (options?: LayerOptions) =>
return Effect.gen(function* () { return Effect.gen(function* () {
const durable = registry.get(event.type)?.sync !== undefined const durable = registry.get(event.type)?.sync !== undefined
if (durable) { if (durable) {
for (const sync of syncHandlers) {
yield* sync(event as Payload)
}
const committed = yield* commitSyncEvent(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* notify(event as Payload, false)
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)
return event 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) { function publish<D extends Definition>(definition: D, data: Data<D>, options?: PublishOptions) {
return Effect.gen(function* () { return Effect.gen(function* () {
const serviceLocation = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service)) const serviceLocation = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service))
@ -396,13 +459,7 @@ export const layerWith = (options?: LayerOptions) =>
strictOwner: options?.strictOwner, strictOwner: options?.strictOwner,
}) })
if (committed && options?.publish) { if (committed && options?.publish) {
const published = { ...payload, seq: committed.seq } yield* notify({ ...payload, seq: committed.seq }, true)
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)
} }
} }
}) })

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

View file

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

View file

@ -1,11 +1,11 @@
export * as SessionInput from "./input" 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 { DateTime, Effect, Schema } from "effect"
import type { Database } from "../database/database" import type { Database } from "../database/database"
import type { EventV2 } from "../event" import type { EventV2 } from "../event"
import { EventTable } from "../event/sql" import { EventSequenceTable } from "../event/sql"
import { NonNegativeInt, PositiveInt } from "../schema" import { NonNegativeInt } from "../schema"
import { V2Schema } from "../v2-schema" import { V2Schema } from "../v2-schema"
import { SessionEvent } from "./event" import { SessionEvent } from "./event"
import { SessionMessage } from "./message" import { SessionMessage } from "./message"
@ -19,7 +19,7 @@ export const Delivery = Schema.Literals(["steer", "queue"])
export type Delivery = typeof Delivery.Type export type Delivery = typeof Delivery.Type
export class Admitted extends Schema.Class<Admitted>("SessionInput.Admitted")({ export class Admitted extends Schema.Class<Admitted>("SessionInput.Admitted")({
seq: PositiveInt, admittedSeq: NonNegativeInt,
id: SessionMessage.ID, id: SessionMessage.ID,
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
prompt: Prompt, prompt: Prompt,
@ -30,11 +30,10 @@ export class Admitted extends Schema.Class<Admitted>("SessionInput.Admitted")({
const decodePrompt = Schema.decodeUnknownSync(Prompt) const decodePrompt = Schema.decodeUnknownSync(Prompt)
const encodePrompt = Schema.encodeSync(Prompt) const encodePrompt = Schema.encodeSync(Prompt)
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message)
const fromRow = (row: typeof SessionInputTable.$inferSelect): Admitted => const fromRow = (row: typeof SessionInputTable.$inferSelect): Admitted =>
new Admitted({ new Admitted({
seq: row.seq, admittedSeq: row.admitted_seq,
id: SessionMessage.ID.make(row.id), id: SessionMessage.ID.make(row.id),
sessionID: SessionSchema.ID.make(row.session_id), sessionID: SessionSchema.ID.make(row.session_id),
prompt: decodePrompt(row.prompt), prompt: decodePrompt(row.prompt),
@ -48,8 +47,13 @@ export const find = Effect.fn("SessionInput.find")(function* (db: DatabaseServic
return row === undefined ? undefined : fromRow(row) 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* ( export const admit = Effect.fn("SessionInput.admit")(function* (
db: DatabaseService, db: DatabaseService,
events: EventV2.Interface,
input: { input: {
readonly id: SessionMessage.ID readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID readonly sessionID: SessionSchema.ID
@ -57,49 +61,124 @@ export const admit = Effect.fn("SessionInput.admit")(function* (
readonly delivery: Delivery readonly delivery: Delivery
}, },
) { ) {
return yield* db const existing = yield* find(db, input.id)
.transaction( if (existing !== undefined) return existing
() => const timestamp = yield* DateTime.now
Effect.gen(function* () { return yield* events
const existing = yield* find(db, input.id) .publish(SessionEvent.PromptLifecycle.Admitted, {
if (existing !== undefined) return existing messageID: input.id,
const event = yield* db sessionID: input.sessionID,
.select({ id: EventTable.id }) timestamp,
.from(EventTable) prompt: input.prompt,
.where(eq(EventTable.id, input.id)) delivery: input.delivery,
.get() })
.pipe(Effect.orDie) .pipe(
const message = yield* db Effect.flatMap((event) =>
.select({ id: SessionMessageTable.id }) event.seq === undefined
.from(SessionMessageTable) ? Effect.die("Prompt admission event is missing aggregate sequence")
.where(eq(SessionMessageTable.id, input.id)) : Effect.succeed(
.get() new Admitted({
.pipe(Effect.orDie) admittedSeq: event.seq,
if (event !== undefined || message !== undefined) return undefined id: input.id,
const row = yield* db sessionID: input.sessionID,
.insert(SessionInputTable) prompt: input.prompt,
.values({ delivery: input.delivery,
id: input.id, timeCreated: timestamp,
session_id: input.sessionID, }),
prompt: encodePrompt(input.prompt), ),
delivery: input.delivery, ),
}) Effect.catchDefect((defect) =>
.returning() find(db, input.id).pipe(Effect.flatMap((stored) => (stored ? Effect.succeed(stored) : Effect.die(defect)))),
.get() ),
.pipe(Effect.orDie)
return fromRow(row)
}),
{ behavior: "immediate" },
) )
})
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) .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* ( export const hasPending = Effect.fn("SessionInput.hasPending")(function* (
db: DatabaseService, db: DatabaseService,
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
deliveries: ReadonlyArray<Delivery> = ["steer", "queue"], delivery: Delivery,
) { ) {
if (deliveries.length === 0) return false
const row = yield* db const row = yield* db
.select({ id: SessionInputTable.id }) .select({ id: SessionInputTable.id })
.from(SessionInputTable) .from(SessionInputTable)
@ -107,7 +186,7 @@ export const hasPending = Effect.fn("SessionInput.hasPending")(function* (
and( and(
eq(SessionInputTable.session_id, sessionID), eq(SessionInputTable.session_id, sessionID),
isNull(SessionInputTable.promoted_seq), isNull(SessionInputTable.promoted_seq),
inArray(SessionInputTable.delivery, deliveries), eq(SessionInputTable.delivery, delivery),
), ),
) )
.limit(1) .limit(1)
@ -133,14 +212,34 @@ export const guardReservedID = Effect.fn("SessionInput.guardReservedID")(functio
db: DatabaseService, db: DatabaseService,
event: EventV2.Payload, 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 (admitted === undefined) return
if (!Schema.is(SessionEvent.Prompted)(event)) return yield* Effect.die(new LifecycleConflict({ id }))
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")
}) })
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, db: DatabaseService,
input: { input: {
readonly id: SessionMessage.ID readonly id: SessionMessage.ID
@ -151,82 +250,49 @@ export const project = Effect.fn("SessionInput.project")(function* (
readonly promotedSeq: number readonly promotedSeq: number
}, },
) { ) {
yield* db const inserted = yield* db
.insert(SessionInputTable) .insert(SessionInputTable)
.values({ .values({
id: input.id, id: input.id,
session_id: input.sessionID, session_id: input.sessionID,
admitted_seq: input.promotedSeq,
prompt: encodePrompt(input.prompt), prompt: encodePrompt(input.prompt),
delivery: input.delivery, delivery: input.delivery,
promoted_seq: input.promotedSeq, promoted_seq: input.promotedSeq,
time_created: DateTime.toEpochMillis(input.timeCreated), time_created: DateTime.toEpochMillis(input.timeCreated),
}) })
.onConflictDoNothing() .onConflictDoNothing()
.run() .returning()
.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))
.get() .get()
.pipe(Effect.orDie) .pipe(Effect.orDie)
if (row === undefined || row.session_id !== expected.sessionID || row.type !== "user") return undefined if (!inserted) return yield* Effect.die("Prompt projection conflicts with admitted input")
const message = decodeMessage({ ...row.data, id: row.id, type: row.type }) return fromRow(inserted)
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,
})
}) })
const publish = Effect.fn("SessionInput.publish")(function* ( const publish = Effect.fn("SessionInput.publish")(function* (
db: DatabaseService,
events: EventV2.Interface, events: EventV2.Interface,
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
rows: ReadonlyArray<typeof SessionInputTable.$inferSelect>, rows: ReadonlyArray<typeof SessionInputTable.$inferSelect>,
) { ) {
for (const row of rows) { for (const row of rows) {
yield* events.publish( yield* events
SessionEvent.Prompted, .publish(SessionEvent.PromptLifecycle.Promoted, {
{
sessionID, sessionID,
timestamp: DateTime.makeUnsafe(row.time_created), timestamp: yield* DateTime.now,
messageID: SessionMessage.ID.make(row.id),
prompt: decodePrompt(row.prompt), prompt: decodePrompt(row.prompt),
delivery: row.delivery, timeCreated: DateTime.makeUnsafe(row.time_created),
}, })
{ id: SessionMessage.ID.make(row.id) }, .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 return rows.length
}) })
@ -235,6 +301,7 @@ export const promoteSteers = Effect.fn("SessionInput.promoteSteers")(function* (
db: DatabaseService, db: DatabaseService,
events: EventV2.Interface, events: EventV2.Interface,
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
cutoff: number,
) { ) {
const rows = yield* db const rows = yield* db
.select() .select()
@ -244,12 +311,13 @@ export const promoteSteers = Effect.fn("SessionInput.promoteSteers")(function* (
eq(SessionInputTable.session_id, sessionID), eq(SessionInputTable.session_id, sessionID),
isNull(SessionInputTable.promoted_seq), isNull(SessionInputTable.promoted_seq),
eq(SessionInputTable.delivery, "steer"), eq(SessionInputTable.delivery, "steer"),
lte(SessionInputTable.admitted_seq, cutoff),
), ),
) )
.orderBy(asc(SessionInputTable.seq)) .orderBy(asc(SessionInputTable.admitted_seq))
.all() .all()
.pipe(Effect.orDie) .pipe(Effect.orDie)
return yield* publish(events, sessionID, rows) return yield* publish(db, events, sessionID, rows)
}) })
export const promoteNextQueued = Effect.fn("SessionInput.promoteNextQueued")(function* ( export const promoteNextQueued = Effect.fn("SessionInput.promoteNextQueued")(function* (
@ -267,14 +335,14 @@ export const promoteNextQueued = Effect.fn("SessionInput.promoteNextQueued")(fun
eq(SessionInputTable.delivery, "queue"), eq(SessionInputTable.delivery, "queue"),
), ),
) )
.orderBy(asc(SessionInputTable.seq)) .orderBy(asc(SessionInputTable.admitted_seq))
.limit(1) .limit(1)
.get() .get()
.pipe(Effect.orDie) .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({ new SessionMessage.User({
id: input.id, id: input.id,
type: "user", 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) => { "session.next.agent.switched": (event) => {
return adapter.appendMessage( return adapter.appendMessage(
new SessionMessage.AgentSwitched({ new SessionMessage.AgentSwitched({
id: event.id, id: event.data.messageID,
type: "agent-switched", type: "agent-switched",
metadata: event.metadata, metadata: event.metadata,
agent: event.data.agent, agent: event.data.agent,
@ -134,7 +134,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
"session.next.model.switched": (event) => { "session.next.model.switched": (event) => {
return adapter.appendMessage( return adapter.appendMessage(
new SessionMessage.ModelSwitched({ new SessionMessage.ModelSwitched({
id: event.id, id: event.data.messageID,
type: "model-switched", type: "model-switched",
metadata: event.metadata, metadata: event.metadata,
model: event.data.model, model: event.data.model,
@ -146,7 +146,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
"session.next.prompted": (event) => { "session.next.prompted": (event) => {
return adapter.appendMessage( return adapter.appendMessage(
new SessionMessage.User({ new SessionMessage.User({
id: event.id, id: event.data.messageID,
type: "user", type: "user",
metadata: event.metadata, metadata: event.metadata,
text: event.data.prompt.text, 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) => { "session.next.synthetic": (event) => {
return adapter.appendMessage( return adapter.appendMessage(
new SessionMessage.Synthetic({ new SessionMessage.Synthetic({
sessionID: event.data.sessionID, sessionID: event.data.sessionID,
text: event.data.text, text: event.data.text,
id: event.id, id: event.data.messageID,
type: "synthetic", type: "synthetic",
time: { created: event.data.timestamp }, time: { created: event.data.timestamp },
}), }),
@ -171,7 +173,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
"session.next.shell.started": (event) => { "session.next.shell.started": (event) => {
return adapter.appendMessage( return adapter.appendMessage(
new SessionMessage.Shell({ new SessionMessage.Shell({
id: event.id, id: event.data.messageID,
type: "shell", type: "shell",
metadata: event.metadata, metadata: event.metadata,
callID: event.data.callID, callID: event.data.callID,
@ -206,7 +208,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
} }
yield* adapter.appendMessage( yield* adapter.appendMessage(
new SessionMessage.Assistant({ new SessionMessage.Assistant({
id: event.id, id: event.data.assistantMessageID,
type: "assistant", type: "assistant",
agent: event.data.agent, agent: event.data.agent,
model: event.data.model, model: event.data.model,
@ -234,43 +236,22 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}) })
}, },
"session.next.text.started": (event) => { "session.next.text.started": (event) => {
return Effect.gen(function* () { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const currentAssistant = yield* adapter.getCurrentAssistant() draft.content.push(
if (currentAssistant) { castDraft(new SessionMessage.AssistantText({ type: "text", id: event.data.textID, text: "" })),
yield* adapter.updateAssistant( )
produce(currentAssistant, (draft) => {
draft.content.push(
castDraft(new SessionMessage.AssistantText({ type: "text", id: event.data.textID, text: "" })),
)
}),
)
}
}) })
}, },
"session.next.text.delta": (event) => { "session.next.text.delta": (event) => {
return Effect.gen(function* () { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const currentAssistant = yield* adapter.getCurrentAssistant() const match = latestText(draft, event.data.textID)
if (currentAssistant) { if (match) match.text += event.data.delta
yield* adapter.updateAssistant(
produce(currentAssistant, (draft) => {
const match = latestText(draft, event.data.textID)
if (match) match.text += event.data.delta
}),
)
}
}) })
}, },
"session.next.text.ended": (event) => { "session.next.text.ended": (event) => {
return Effect.gen(function* () { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const currentAssistant = yield* adapter.getCurrentAssistant() const match = latestText(draft, event.data.textID)
if (currentAssistant) { if (match) match.text = event.data.text
yield* adapter.updateAssistant(
produce(currentAssistant, (draft) => {
const match = latestText(draft, event.data.textID)
if (match) match.text = event.data.text
}),
)
}
}) })
}, },
"session.next.tool.input.started": (event) => { "session.next.tool.input.started": (event) => {
@ -367,52 +348,31 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}) })
}, },
"session.next.reasoning.started": (event) => { "session.next.reasoning.started": (event) => {
return Effect.gen(function* () { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const currentAssistant = yield* adapter.getCurrentAssistant() draft.content.push(
if (currentAssistant) { castDraft(
yield* adapter.updateAssistant( new SessionMessage.AssistantReasoning({
produce(currentAssistant, (draft) => { type: "reasoning",
draft.content.push( id: event.data.reasoningID,
castDraft( text: "",
new SessionMessage.AssistantReasoning({ providerMetadata: event.data.providerMetadata,
type: "reasoning",
id: event.data.reasoningID,
text: "",
providerMetadata: event.data.providerMetadata,
}),
),
)
}), }),
) ),
} )
}) })
}, },
"session.next.reasoning.delta": (event) => { "session.next.reasoning.delta": (event) => {
return Effect.gen(function* () { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const currentAssistant = yield* adapter.getCurrentAssistant() const match = latestReasoning(draft, event.data.reasoningID)
if (currentAssistant) { if (match) match.text += event.data.delta
yield* adapter.updateAssistant(
produce(currentAssistant, (draft) => {
const match = latestReasoning(draft, event.data.reasoningID)
if (match) match.text += event.data.delta
}),
)
}
}) })
}, },
"session.next.reasoning.ended": (event) => { "session.next.reasoning.ended": (event) => {
return Effect.gen(function* () { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const currentAssistant = yield* adapter.getCurrentAssistant() const match = latestReasoning(draft, event.data.reasoningID)
if (currentAssistant) { if (match) {
yield* adapter.updateAssistant( match.text = event.data.text
produce(currentAssistant, (draft) => { if (event.data.providerMetadata !== undefined) match.providerMetadata = event.data.providerMetadata
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) => { "session.next.compaction.started": (event) => {
return adapter.appendMessage( return adapter.appendMessage(
new SessionMessage.Compaction({ new SessionMessage.Compaction({
id: event.id, id: event.data.messageID,
type: "compaction", type: "compaction",
metadata: event.metadata, metadata: event.metadata,
reason: event.data.reason, reason: event.data.reason,

View file

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

View file

@ -19,7 +19,7 @@ type DatabaseService = Database.Interface["db"]
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message) const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message)
const encodeMessage = Schema.encodeSync(SessionMessage.Message) const encodeMessage = Schema.encodeSync(SessionMessage.Message)
export class PromptAlreadyProjected extends Error {} class PromptAlreadyProjected extends Error {}
export class SessionAlreadyProjected extends Error {} export class SessionAlreadyProjected extends Error {}
type Usage = { type Usage = {
@ -112,29 +112,23 @@ function run(db: DatabaseService, event: SessionEvent.Event) {
return Effect.gen(function* () { return Effect.gen(function* () {
const decodeRow = (row: typeof SessionMessageTable.$inferSelect) => const decodeRow = (row: typeof SessionMessageTable.$inferSelect) =>
decodeMessage({ ...row.data, id: row.id, type: row.type }) 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") if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence")
const encoded = encodeMessage(message) const encoded = encodeMessage(message)
const { id, type, ...data } = encoded const { id, type, ...data } = encoded
return db return db
.insert(SessionMessageTable) .update(SessionMessageTable)
.values([ .set({ type, time_created: DateTime.toEpochMillis(message.time.created), data })
{ .where(
id: SessionMessage.ID.make(id), and(
session_id: event.data.sessionID, eq(SessionMessageTable.id, SessionMessage.ID.make(id)),
type, eq(SessionMessageTable.session_id, event.data.sessionID),
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 },
})
.run() .run()
.pipe(Effect.orDie) .pipe(Effect.orDie)
} }
const appendMessage = (message: SessionMessage.Message) => insertMessage(db, event, message)
const adapter: SessionMessageUpdater.Adapter = { const adapter: SessionMessageUpdater.Adapter = {
getCurrentAssistant() { getCurrentAssistant() {
return Effect.gen(function* () { 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) .find((message): message is SessionMessage.Shell => message.type === "shell" && message.callID === callID)
}) })
}, },
updateAssistant: writeMessage, updateAssistant: updateMessage,
updateCompaction: writeMessage, updateCompaction: updateMessage,
updateShell: writeMessage, updateShell: updateMessage,
appendMessage: writeMessage, appendMessage,
} }
yield* SessionMessageUpdater.update(adapter, event) 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( export const layer = Layer.effectDiscard(
Effect.gen(function* () { Effect.gen(function* () {
const events = yield* EventV2.Service const events = yield* EventV2.Service
@ -349,27 +361,19 @@ export const layer = Layer.effectDiscard(
) )
yield* events.project(SessionEvent.Prompted, (event) => yield* events.project(SessionEvent.Prompted, (event) =>
Effect.gen(function* () { Effect.gen(function* () {
const messageID = event.data.messageID
const existing = yield* db const existing = yield* db
.select({ id: SessionMessageTable.id }) .select({ id: SessionMessageTable.id })
.from(SessionMessageTable) .from(SessionMessageTable)
.where(eq(SessionMessageTable.id, event.id)) .where(eq(SessionMessageTable.id, messageID))
.get() .get()
.pipe(Effect.orDie) .pipe(Effect.orDie)
if (existing) return yield* Effect.die(new PromptAlreadyProjected()) if (existing) return yield* Effect.die(new PromptAlreadyProjected())
yield* run(db, event) 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) if (event.seq === undefined)
return yield* Effect.die("Synchronized Session event is missing aggregate sequence") return yield* Effect.die("Synchronized Session event is missing aggregate sequence")
yield* SessionInput.project(db, { yield* SessionInput.projectLegacyPrompted(db, {
id: SessionMessage.ID.make(event.id), id: messageID,
sessionID: event.data.sessionID, sessionID: event.data.sessionID,
prompt: event.data.prompt, prompt: event.data.prompt,
delivery: event.data.delivery, 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.Synthetic, (event) => run(db, event))
yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event)) yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Shell.Ended, (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 model = yield* models.resolve(session)
const toolFibers = yield* FiberSet.make<void, never>() const toolFibers = yield* FiberSet.make<void, never>()
let needsContinuation = false let needsContinuation = false
if (promotion === "steer") yield* SessionInput.promoteSteers(db, events, session.id) if (promotion) {
if (promotion === "queue") { const cutoff = yield* SessionInput.latestSeq(db, session.id)
yield* SessionInput.promoteNextQueued(db, events, session.id) if (promotion === "steer") yield* SessionInput.promoteSteers(db, events, session.id, cutoff)
yield* SessionInput.promoteSteers(db, events, session.id) if (promotion === "queue") {
yield* SessionInput.promoteNextQueued(db, events, session.id)
yield* SessionInput.promoteSteers(db, events, session.id, cutoff)
}
} }
yield* failInterruptedTools(session.id) yield* failInterruptedTools(session.id)
const context = yield* getContext(session.id) const context = yield* getContext(session.id)
@ -233,8 +236,8 @@ export const layer = Layer.effect(
readonly force?: boolean readonly force?: boolean
}) { }) {
const session = yield* getSession(input.sessionID) const session = yield* getSession(input.sessionID)
const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, ["steer"]) const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer")
const hasQueue = yield* SessionInput.hasPending(db, input.sessionID, ["queue"]) const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue")
if (input.force !== true && !hasSteer && !hasQueue) return if (input.force !== true && !hasSteer && !hasQueue) return
let promotion: "steer" | "queue" | undefined = hasSteer ? "steer" : hasQueue ? "queue" : undefined let promotion: "steer" | "queue" | undefined = hasSteer ? "steer" : hasQueue ? "queue" : undefined
let openActivity = input.force === true || hasSteer || hasQueue let openActivity = input.force === true || hasSteer || hasQueue
@ -243,12 +246,12 @@ export const layer = Layer.effect(
for (let step = 0; step < MAX_STEPS; step++) { for (let step = 0; step < MAX_STEPS; step++) {
needsContinuation = yield* runTurn(session, promotion) needsContinuation = yield* runTurn(session, promotion)
promotion = "steer" 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) break
} }
if (needsContinuation) if (needsContinuation)
return yield* new StepLimitExceededError({ sessionID: input.sessionID, limit: MAX_STEPS }) 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 promotion = openActivity ? "queue" : undefined
} }
}) })

View file

@ -10,6 +10,7 @@ import { DateTime, Effect } from "effect"
import { EventV2 } from "../../event" import { EventV2 } from "../../event"
import { ModelV2 } from "../../model" import { ModelV2 } from "../../model"
import { SessionEvent } from "../event" import { SessionEvent } from "../event"
import { SessionMessage } from "../message"
import { SessionSchema } from "../schema" import { SessionSchema } from "../schema"
type Input = { type Input = {
@ -60,7 +61,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
const tools = new Map< const tools = new Map<
string, string,
{ {
readonly assistantMessageID: EventV2.ID readonly assistantMessageID: SessionMessage.ID
readonly name: string readonly name: string
inputEnded: boolean inputEnded: boolean
called: boolean called: boolean
@ -70,13 +71,17 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
} }
>() >()
const timestamp = DateTime.now const timestamp = DateTime.now
let assistantMessageID: EventV2.ID | undefined let assistantMessageID: SessionMessage.ID | undefined
let providerFailed = false let providerFailed = false
const startAssistant = Effect.fnUntraced(function* () { const startAssistant = Effect.fnUntraced(function* () {
if (assistantMessageID !== undefined) return assistantMessageID if (assistantMessageID !== undefined) return assistantMessageID
assistantMessageID = (yield* events.publish(SessionEvent.Step.Started, { ...input, timestamp: yield* timestamp })) assistantMessageID = SessionMessage.ID.create()
.id yield* events.publish(SessionEvent.Step.Started, {
...input,
assistantMessageID,
timestamp: yield* timestamp,
})
return assistantMessageID return assistantMessageID
}) })
const currentAssistantMessageID = () => const currentAssistantMessageID = () =>
@ -118,6 +123,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
Effect.gen(function* () { Effect.gen(function* () {
yield* events.publish(SessionEvent.Text.Ended, { yield* events.publish(SessionEvent.Text.Ended, {
sessionID: input.sessionID, sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(),
timestamp: yield* timestamp, timestamp: yield* timestamp,
textID, textID,
text: value, text: value,
@ -128,6 +134,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
Effect.gen(function* () { Effect.gen(function* () {
yield* events.publish(SessionEvent.Reasoning.Ended, { yield* events.publish(SessionEvent.Reasoning.Ended, {
sessionID: input.sessionID, sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(),
timestamp: yield* timestamp, timestamp: yield* timestamp,
reasoningID, reasoningID,
text: value, text: value,
@ -220,6 +227,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
yield* text.start(event.id) yield* text.start(event.id)
yield* events.publish(SessionEvent.Text.Started, { yield* events.publish(SessionEvent.Text.Started, {
sessionID: input.sessionID, sessionID: input.sessionID,
assistantMessageID: yield* startAssistant(),
timestamp: yield* timestamp, timestamp: yield* timestamp,
textID: event.id, textID: event.id,
}) })
@ -228,6 +236,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
yield* text.append(event.id, event.text) yield* text.append(event.id, event.text)
yield* events.publish(SessionEvent.Text.Delta, { yield* events.publish(SessionEvent.Text.Delta, {
sessionID: input.sessionID, sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(),
timestamp: yield* timestamp, timestamp: yield* timestamp,
textID: event.id, textID: event.id,
delta: event.text, delta: event.text,
@ -240,6 +249,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
yield* reasoning.start(event.id) yield* reasoning.start(event.id)
yield* events.publish(SessionEvent.Reasoning.Started, { yield* events.publish(SessionEvent.Reasoning.Started, {
sessionID: input.sessionID, sessionID: input.sessionID,
assistantMessageID: yield* startAssistant(),
timestamp: yield* timestamp, timestamp: yield* timestamp,
reasoningID: event.id, reasoningID: event.id,
providerMetadata: event.providerMetadata, providerMetadata: event.providerMetadata,
@ -249,6 +259,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
yield* reasoning.append(event.id, event.text) yield* reasoning.append(event.id, event.text)
yield* events.publish(SessionEvent.Reasoning.Delta, { yield* events.publish(SessionEvent.Reasoning.Delta, {
sessionID: input.sessionID, sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(),
timestamp: yield* timestamp, timestamp: yield* timestamp,
reasoningID: event.id, reasoningID: event.id,
delta: event.text, 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 * as DatabasePath from "../database/path"
import { ProjectTable } from "../project/sql" import { ProjectTable } from "../project/sql"
import type { SessionMessage } from "./message" import type { SessionMessage } from "./message"
@ -127,7 +127,7 @@ export const SessionMessageTable = sqliteTable(
data: text({ mode: "json" }).notNull().$type<SessionMessageData>(), data: text({ mode: "json" }).notNull().$type<SessionMessageData>(),
}, },
(table) => [ (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_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_session_time_created_id_idx").on(table.session_id, table.time_created, table.id),
index("session_message_time_created_idx").on(table.time_created), index("session_message_time_created_idx").on(table.time_created),
@ -137,14 +137,14 @@ export const SessionMessageTable = sqliteTable(
export const SessionInputTable = sqliteTable( export const SessionInputTable = sqliteTable(
"session_input", "session_input",
{ {
seq: integer().primaryKey({ autoIncrement: true }), id: text().$type<SessionMessage.ID>().primaryKey(),
id: text().$type<SessionMessage.ID>().notNull().unique(),
session_id: text() session_id: text()
.$type<SessionSchema.ID>() .$type<SessionSchema.ID>()
.notNull() .notNull()
.references(() => SessionTable.id, { onDelete: "cascade" }), .references(() => SessionTable.id, { onDelete: "cascade" }),
prompt: text({ mode: "json" }).notNull().$type<Prompt>(), prompt: text({ mode: "json" }).notNull().$type<Prompt>(),
delivery: text().$type<SessionInput.Delivery>().notNull(), delivery: text().$type<SessionInput.Delivery>().notNull(),
admitted_seq: integer().notNull(),
promoted_seq: integer(), promoted_seq: integer(),
time_created: integer() time_created: integer()
.notNull() .notNull()
@ -155,7 +155,9 @@ export const SessionInputTable = sqliteTable(
table.session_id, table.session_id,
table.promoted_seq, table.promoted_seq,
table.delivery, 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 sessionUsageMigration from "@opencode-ai/core/database/migration/20260510033149_session_usage"
import normalizeStoragePathsMigration from "@opencode-ai/core/database/migration/20260601010001_normalize_storage_paths" 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 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 { ProjectV2 } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql" import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
@ -62,15 +63,17 @@ describe("DatabaseMigration", () => {
expect( expect(
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_input'`), yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_input'`),
).toEqual({ 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( expect(
yield* db.all( 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([ ).toEqual([
{ name: "event_aggregate_seq_idx" }, { name: "event_aggregate_seq_idx" },
{ name: "event_aggregate_type_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_pending_delivery_seq_idx" },
{ name: "session_input_session_promoted_seq_idx" },
{ name: "session_message_session_seq_idx" }, { name: "session_message_session_seq_idx" },
{ name: "session_message_session_time_created_id_idx" }, { name: "session_message_session_time_created_id_idx" },
{ name: "session_message_session_type_seq_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 () => { test("resets incompatible projected Session messages before adding sequence order", async () => {
await run( await run(
Effect.gen(function* () { Effect.gen(function* () {

View file

@ -1,5 +1,5 @@
import { describe, expect } from "bun:test" 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 { EventV2 } from "@opencode-ai/core/event"
import { Database } from "@opencode-ai/core/database/database" import { Database } from "@opencode-ai/core/database/database"
import { EventSequenceTable, EventTable } from "@opencode-ai/core/event/sql" 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", () => it.effect("does not synchronize live-only events", () =>
Effect.gen(function* () { Effect.gen(function* () {
const events = yield* EventV2.Service 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", () => it.effect("inserts sync event rows on publish", () =>
Effect.gen(function* () { Effect.gen(function* () {
const events = yield* EventV2.Service 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", () => it.effect("replay with owner claims an unowned sequence", () =>
Effect.gen(function* () { Effect.gen(function* () {
const events = yield* EventV2.Service 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", () => it.effect("replay from a different owner leaves claimed sequence unchanged", () =>
Effect.gen(function* () { Effect.gen(function* () {
const events = yield* EventV2.Service const events = yield* EventV2.Service

View file

@ -1,13 +1,15 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import path from "path"
import { Effect, Layer, Stream } from "effect" import { Effect, Layer, Stream } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent" 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 { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event" import { EventV2 } from "@opencode-ai/core/event"
import { EventTable } from "@opencode-ai/core/event/sql" import { EventTable } from "@opencode-ai/core/event/sql"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { ProjectV2 } from "@opencode-ai/core/project" import { ProjectV2 } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session" 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 { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionInput } from "@opencode-ai/core/session/input" 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 { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store" import { SessionStore } from "@opencode-ai/core/session/store"
import { WorkspaceV2 } from "@opencode-ai/core/workspace" import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
import { tmpdir } from "./fixture/tmpdir"
const database = Database.layerFromPath(":memory:") const database = Database.layerFromPath(":memory:")
const events = EventV2.layer.pipe(Layer.provide(database)) const events = EventV2.layer.pipe(Layer.provide(database))
@ -211,11 +215,99 @@ describe("SessionV2.create", () => {
const { db } = yield* Database.Service const { db } = yield* Database.Service
const created = yield* session.create({ location }) const created = yield* session.create({ location })
yield* session.prompt({ sessionID: created.id, prompt: new Prompt({ text: "Hello" }), resume: false }) 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( expect(
Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(1), Stream.runCollect)), Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(2), Stream.runCollect)),
).toMatchObject([{ cursor: 1, event: { type: "session.next.prompted", data: { prompt: { text: "Hello" } } } }]) ).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( yield* events.publish(
SessionEvent.Prompted, 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( yield* events.publish(
SessionEvent.Prompted, 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 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* () { Effect.gen(function* () {
const { db } = yield* Database.Service const { db } = yield* Database.Service
yield* db yield* db
@ -131,14 +143,21 @@ describe("SessionProjector", () => {
.run() .run()
.pipe(Effect.orDie) .pipe(Effect.orDie)
const events = yield* EventV2.Service const events = yield* EventV2.Service
const id = SessionMessage.ID.make("evt_admitted") const id = SessionMessage.ID.make("msg_admitted")
yield* SessionInput.admit(db, { id, sessionID, prompt: new Prompt({ text: "promote me" }), delivery: "steer" }) yield* SessionInput.admit(db, events, {
id,
sessionID,
prompt: new Prompt({ text: "promote me" }),
delivery: "steer",
})
const event = yield* events.publish( const event = yield* events.publish(SessionEvent.PromptLifecycle.Promoted, {
SessionEvent.Prompted, sessionID,
{ sessionID, timestamp: created, prompt: new Prompt({ text: "promote me" }), delivery: "steer" }, timestamp: created,
{ id }, messageID: id,
) prompt: new Prompt({ text: "promote me" }),
timeCreated: created,
})
expect( expect(
yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie), yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie),
@ -168,11 +187,27 @@ describe("SessionProjector", () => {
.pipe(Effect.orDie) .pipe(Effect.orDie)
const events = yield* EventV2.Service const events = yield* EventV2.Service
yield* events.publish(SessionEvent.AgentSwitched, { sessionID, timestamp: created, agent: "build" }) yield* events.publish(SessionEvent.AgentSwitched, {
yield* events.publish(SessionEvent.ModelSwitched, { sessionID, timestamp: created, model }) sessionID,
yield* events.publish(SessionEvent.Synthetic, { sessionID, timestamp: created, text: "synthetic context" }) 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, { yield* events.publish(SessionEvent.Shell.Started, {
sessionID, sessionID,
messageID: SessionMessage.ID.create(),
timestamp: created, timestamp: created,
callID: "shell-1", callID: "shell-1",
command: "pwd", command: "pwd",
@ -183,7 +218,12 @@ describe("SessionProjector", () => {
callID: "shell-1", callID: "shell-1",
output: "/project", 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.Delta, { sessionID, timestamp: created, text: "partial" })
yield* events.publish(SessionEvent.Compaction.Ended, { yield* events.publish(SessionEvent.Compaction.Ended, {
sessionID, 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", () => it.effect("rejects a Prompted event that conflicts with an admitted inbox row", () =>
Effect.gen(function* () { Effect.gen(function* () {
const { db } = yield* Database.Service const { db } = yield* Database.Service
@ -249,24 +330,77 @@ describe("SessionProjector", () => {
.run() .run()
.pipe(Effect.orDie) .pipe(Effect.orDie)
const events = yield* EventV2.Service const events = yield* EventV2.Service
const id = SessionMessage.ID.make("evt_conflict") const id = SessionMessage.ID.make("msg_conflict")
yield* SessionInput.admit(db, { id, sessionID, prompt: new Prompt({ text: "admitted" }), delivery: "steer" }) yield* SessionInput.admit(db, events, {
id,
sessionID,
prompt: new Prompt({ text: "admitted" }),
delivery: "steer",
})
const exit = yield* events const exit = yield* events
.publish( .publish(SessionEvent.Prompted, {
SessionEvent.Prompted, sessionID,
{ sessionID, timestamp: created, prompt: new Prompt({ text: "different" }), delivery: "steer" }, messageID: id,
{ id }, timestamp: created,
) prompt: new Prompt({ text: "different" }),
delivery: "steer",
})
.pipe(Effect.exit) .pipe(Effect.exit)
expect(String(exit)).toContain("Prompt projection conflicts with admitted input") expect(String(exit)).toContain("SessionInput.LifecycleConflict")
expect( expect(
yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie), yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie),
).toMatchObject({ promoted_seq: null }) ).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", () => it.effect("rejects a Prompted delivery mode that conflicts with an admitted inbox row", () =>
Effect.gen(function* () { Effect.gen(function* () {
const { db } = yield* Database.Service const { db } = yield* Database.Service
@ -288,15 +422,15 @@ describe("SessionProjector", () => {
.run() .run()
.pipe(Effect.orDie) .pipe(Effect.orDie)
const events = yield* EventV2.Service 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" }) 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 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) .pipe(Effect.exit)
expect(String(exit)).toContain("Prompt projection conflicts with admitted input") expect(String(exit)).toContain("SessionInput.LifecycleConflict")
expect( expect(
yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie), yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie),
).toMatchObject({ delivery: "queue", promoted_seq: null }) ).toMatchObject({ delivery: "queue", promoted_seq: null })
@ -306,7 +440,7 @@ describe("SessionProjector", () => {
it.effect("does not revive a stale incomplete in-memory assistant projection", () => it.effect("does not revive a stale incomplete in-memory assistant projection", () =>
Effect.gen(function* () { Effect.gen(function* () {
const stale = new SessionMessage.Assistant({ const stale = new SessionMessage.Assistant({
id: SessionMessage.ID.make("evt_assistant_stale"), id: SessionMessage.ID.make("msg_assistant_stale"),
type: "assistant", type: "assistant",
agent: "build", agent: "build",
model, model,
@ -314,7 +448,7 @@ describe("SessionProjector", () => {
time: { created }, time: { created },
}) })
const completed = new SessionMessage.Assistant({ const completed = new SessionMessage.Assistant({
id: SessionMessage.ID.make("evt_assistant_completed"), id: SessionMessage.ID.make("msg_assistant_completed"),
type: "assistant", type: "assistant",
agent: "build", agent: "build",
model, model,
@ -351,8 +485,8 @@ describe("SessionProjector", () => {
yield* db yield* db
.insert(SessionMessageTable) .insert(SessionMessageTable)
.values([ .values([
assistantRow(SessionMessage.ID.make("evt_assistant_1"), 0), assistantRow(SessionMessage.ID.make("msg_assistant_1"), 0),
assistantRow(SessionMessage.ID.make("evt_assistant_2"), 1), assistantRow(SessionMessage.ID.make("msg_assistant_2"), 1),
]) ])
.run() .run()
.pipe(Effect.orDie) .pipe(Effect.orDie)
@ -361,7 +495,7 @@ describe("SessionProjector", () => {
yield* service.publish(SessionEvent.Step.Ended, { yield* service.publish(SessionEvent.Step.Ended, {
sessionID, sessionID,
timestamp: DateTime.makeUnsafe(1), timestamp: DateTime.makeUnsafe(1),
assistantMessageID: SessionMessage.ID.make("evt_assistant_2"), assistantMessageID: SessionMessage.ID.make("msg_assistant_2"),
finish: "stop", finish: "stop",
cost: 0, cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
@ -409,8 +543,8 @@ describe("SessionProjector", () => {
yield* db yield* db
.insert(SessionMessageTable) .insert(SessionMessageTable)
.values([ .values([
assistantRow(SessionMessage.ID.make("evt_assistant_stale"), 0), assistantRow(SessionMessage.ID.make("msg_assistant_stale"), 0),
assistantRow(SessionMessage.ID.make("evt_assistant_completed"), 1, { assistantRow(SessionMessage.ID.make("msg_assistant_completed"), 1, {
created: DateTime.makeUnsafe(1), created: DateTime.makeUnsafe(1),
completed: DateTime.makeUnsafe(2), completed: DateTime.makeUnsafe(2),
}), }),
@ -421,6 +555,7 @@ describe("SessionProjector", () => {
const service = yield* EventV2.Service const service = yield* EventV2.Service
yield* service.publish(SessionEvent.Text.Started, { yield* service.publish(SessionEvent.Text.Started, {
sessionID, sessionID,
assistantMessageID: SessionMessage.ID.make("msg_assistant_completed"),
timestamp: DateTime.makeUnsafe(3), timestamp: DateTime.makeUnsafe(3),
textID: "text-stale", textID: "text-stale",
}) })
@ -437,15 +572,15 @@ describe("SessionProjector", () => {
) )
expect(messages).toEqual([ expect(messages).toEqual([
new SessionMessage.Assistant({ new SessionMessage.Assistant({
id: SessionMessage.ID.make("evt_assistant_completed"), id: SessionMessage.ID.make("msg_assistant_completed"),
type: "assistant", type: "assistant",
agent: "build", agent: "build",
model, model,
content: [], content: [new SessionMessage.AssistantText({ type: "text", id: "text-stale", text: "" })],
time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) }, time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) },
}), }),
new SessionMessage.Assistant({ new SessionMessage.Assistant({
id: SessionMessage.ID.make("evt_assistant_stale"), id: SessionMessage.ID.make("msg_assistant_stale"),
type: "assistant", type: "assistant",
agent: "build", agent: "build",
model, model,

View file

@ -1,7 +1,9 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { DateTime, Effect, Fiber, Layer, Stream } from "effect" import { DateTime, Effect, Fiber, Layer, Stream } from "effect"
import { eq } from "drizzle-orm"
import { Database } from "@opencode-ai/core/database/database" import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event" import { EventV2 } from "@opencode-ai/core/event"
import { EventTable } from "@opencode-ai/core/event/sql"
import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionEvent } from "@opencode-ai/core/session/event"
import { Project } from "@opencode-ai/core/project" import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql" 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 { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionInput } from "@opencode-ai/core/session/input" 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 { SessionStore } from "@opencode-ai/core/session/store"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
@ -80,6 +82,18 @@ const admittedCount = Database.Service.use(({ db }) =>
Effect.map((rows) => rows.length), 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", () => { describe("SessionV2.prompt", () => {
it.effect("delegates execution continuation through SessionExecution", () => it.effect("delegates execution continuation through SessionExecution", () =>
@ -105,8 +119,7 @@ describe("SessionV2.prompt", () => {
resume: false, resume: false,
}) })
expect(message.type).toBe("user") expect(message.prompt.text).toBe("Fix the failing tests")
expect(message.text).toBe("Fix the failing tests")
expect(yield* session.messages({ sessionID })).toEqual([]) expect(yield* session.messages({ sessionID })).toEqual([])
expect(yield* admitted(message.id)).toMatchObject({ expect(yield* admitted(message.id)).toMatchObject({
id: message.id, id: message.id,
@ -123,25 +136,25 @@ describe("SessionV2.prompt", () => {
const session = yield* SessionV2.Service const session = yield* SessionV2.Service
const events = yield* EventV2.Service const events = yield* EventV2.Service
const { db } = yield* Database.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* Effect.yieldNow
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), 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)) const streamed = Array.from(yield* Fiber.join(fiber))
expect( expect(streamed.map((event) => [event.cursor, event.event.type])).toEqual([
streamed.map((event) => [event.cursor, event.event.type, (event.event.data as { prompt: Prompt }).prompt.text]), [EventV2.Cursor.make(0), "session.next.prompt.admitted"],
).toEqual([ [EventV2.Cursor.make(1), "session.next.prompt.admitted"],
[EventV2.Cursor.make(0), "session.next.prompted", "First"], [EventV2.Cursor.make(2), "session.next.prompt.promoted"],
[EventV2.Cursor.make(1), "session.next.prompted", "Second"], [EventV2.Cursor.make(3), "session.next.prompt.promoted"],
]) ])
expect( expect(
Array.from( Array.from(
yield* session.events({ sessionID, after: streamed[0]!.cursor }).pipe(Stream.take(1), Stream.runCollect), 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]), ).map((event) => [event.cursor, event.event.type]),
).toEqual([[EventV2.Cursor.make(1), "Second"]]) ).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", () => it.effect("returns one recorded message to concurrent exact retries", () =>
Effect.gen(function* () { Effect.gen(function* () {
yield* setup yield* setup
@ -298,43 +300,127 @@ describe("SessionV2.prompt", () => {
expect(messages[1]).toEqual(messages[0]) expect(messages[1]).toEqual(messages[0])
expect(yield* session.messages({ sessionID })).toEqual([]) expect(yield* session.messages({ sessionID })).toEqual([])
expect(yield* admittedCount).toBe(1) 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* () { Effect.gen(function* () {
yield* setup yield* setup
const session = yield* SessionV2.Service const session = yield* SessionV2.Service
const events = yield* EventV2.Service const events = yield* EventV2.Service
const prompt = new Prompt({ text: "Historical prompt" }) const prompt = new Prompt({ text: "Historical prompt" })
yield* events.publish( yield* events.publish(SessionEvent.Prompted, {
SessionEvent.Prompted, sessionID,
{ sessionID, timestamp: yield* DateTime.now, prompt, delivery: "steer" }, messageID,
{ id: messageID }, timestamp: yield* DateTime.now,
) prompt,
delivery: "steer",
})
const retried = yield* session.prompt({ id: messageID, sessionID, prompt, resume: false }) 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") 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* () { Effect.gen(function* () {
yield* setup yield* setup
const session = yield* SessionV2.Service const session = yield* SessionV2.Service
const events = yield* EventV2.Service const events = yield* EventV2.Service
const prompt = new Prompt({ text: "Historical queued prompt" }) const prompt = new Prompt({ text: "Historical queued prompt" })
yield* events.publish( yield* events.publish(SessionEvent.Prompted, {
SessionEvent.Prompted, sessionID,
{ sessionID, timestamp: yield* DateTime.now, prompt, delivery: "queue" }, messageID,
{ id: messageID }, timestamp: yield* DateTime.now,
) prompt,
delivery: "queue",
})
const retried = yield* session.prompt({ id: messageID, sessionID, prompt, delivery: "queue", resume: false }) 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" }) expect(yield* admitted(messageID)).toMatchObject({ delivery: "queue" })
}), }),
) )
@ -344,11 +430,12 @@ describe("SessionV2.prompt", () => {
yield* setup yield* setup
const session = yield* SessionV2.Service const session = yield* SessionV2.Service
const events = yield* EventV2.Service const events = yield* EventV2.Service
yield* events.publish( yield* events.publish(SessionEvent.Synthetic, {
SessionEvent.Synthetic, sessionID,
{ sessionID, timestamp: yield* DateTime.now, text: "Collision" }, messageID,
{ id: messageID }, timestamp: yield* DateTime.now,
) text: "Collision",
})
const failure = yield* session const failure = yield* session
.prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "Collision" }), resume: false }) .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 }) yield* session.prompt({ id: messageID, sessionID, prompt, resume: false })
const failure = yield* events const failure = yield* events
.publish( .publish(SessionEvent.Synthetic, {
SessionEvent.Synthetic, sessionID,
{ sessionID, timestamp: yield* DateTime.now, text: "Conflicting synthetic" }, messageID,
{ id: messageID }, timestamp: yield* DateTime.now,
) text: "Conflicting synthetic",
})
.pipe(Effect.catchDefect(Effect.succeed)) .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* admitted(messageID)).not.toHaveProperty("promotedSeq")
expect(yield* session.messages({ sessionID })).toEqual([]) 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([ expect(yield* session.messages({ sessionID })).toMatchObject([
{ id: messageID, type: "user", text: "Reserved prompt" }, { id: messageID, type: "user", text: "Reserved prompt" },
]) ])

View file

@ -1,7 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { Message, Model } from "@opencode-ai/llm" import { Message, Model } from "@opencode-ai/llm"
import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat" 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 { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionMessage } from "@opencode-ai/core/session/message"
@ -12,7 +11,7 @@ import { ToolOutput } from "@opencode-ai/core/tool-output"
import { DateTime } from "effect" import { DateTime } from "effect"
const created = DateTime.makeUnsafe(0) 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 }) const model = Model.make({ id: "model", provider: "provider", route: OpenAIChat.route })
describe("toLLMMessages", () => { 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 { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator" 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 * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { ToolRegistry } from "@opencode-ai/core/tool-registry" import { ToolRegistry } from "@opencode-ai/core/tool-registry"
@ -131,7 +130,7 @@ describe("SessionRunnerLLM recorded", () => {
const messages = yield* session.context(sessionID) const messages = yield* session.context(sessionID)
expect(messages).toHaveLength(2) 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]).toMatchObject({ type: "assistant", agent: "build", finish: "stop" })
expect(messages[1]?.type === "assistant" ? messages[1].content : []).toMatchObject([ expect(messages[1]?.type === "assistant" ? messages[1].content : []).toMatchObject([
{ type: "text", text: "Hello!" }, { type: "text", text: "Hello!" },
@ -144,7 +143,8 @@ describe("SessionRunnerLLM recorded", () => {
.orderBy(EventTable.seq) .orderBy(EventTable.seq)
.all()).map((event) => event.type), .all()).map((event) => event.type),
).toEqual([ ).toEqual([
"session.next.prompted.1", "session.next.prompt.admitted.1",
"session.next.prompt.promoted.1",
"session.next.step.started.1", "session.next.step.started.1",
"session.next.text.started.1", "session.next.text.started.1",
"session.next.text.ended.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 { SessionV2 } from "@opencode-ai/core/session"
import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionInput } from "@opencode-ai/core/session/input" 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 { Prompt } from "@opencode-ai/core/session/prompt"
import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution" 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 * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { ToolRegistry } from "@opencode-ai/core/tool-registry" 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 { SessionStore } from "@opencode-ai/core/session/store"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
@ -240,6 +241,7 @@ const replaySessionProjection = (id: SessionV2.ID) =>
.pipe(Effect.orDie) .pipe(Effect.orDie)
yield* events.remove(id) 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* db.delete(SessionMessageTable).where(eq(SessionMessageTable.session_id, id)).run().pipe(Effect.orDie)
yield* events.replayAll( yield* events.replayAll(
recorded.map((event) => ({ recorded.map((event) => ({
@ -425,7 +427,9 @@ describe("SessionRunnerLLM", () => {
const message = yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Run automatically" }) }) const message = yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Run automatically" }) })
expect(requests).toHaveLength(1) 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 session = yield* SessionV2.Service
const events = yield* EventV2.Service const events = yield* EventV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Recover interrupted tool" }), resume: false }) yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Recover interrupted tool" }), resume: false })
yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID) yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID, Number.MAX_SAFE_INTEGER)
const assistant = yield* events.publish(SessionEvent.Step.Started, { const assistantMessageID = SessionMessage.ID.create()
yield* events.publish(SessionEvent.Step.Started, {
sessionID, sessionID,
assistantMessageID,
timestamp: yield* DateTime.now, timestamp: yield* DateTime.now,
agent: "build", agent: "build",
model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") }, 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, { yield* events.publish(SessionEvent.Tool.Input.Started, {
sessionID, sessionID,
timestamp: yield* DateTime.now, timestamp: yield* DateTime.now,
assistantMessageID: assistant.id, assistantMessageID,
callID: "call-interrupted", callID: "call-interrupted",
name: "echo", name: "echo",
}) })
yield* events.publish(SessionEvent.Tool.Input.Ended, { yield* events.publish(SessionEvent.Tool.Input.Ended, {
sessionID, sessionID,
timestamp: yield* DateTime.now, timestamp: yield* DateTime.now,
assistantMessageID: assistant.id, assistantMessageID,
callID: "call-interrupted", callID: "call-interrupted",
text: '{"text":"stale"}', text: '{"text":"stale"}',
}) })
yield* events.publish(SessionEvent.Tool.Called, { yield* events.publish(SessionEvent.Tool.Called, {
sessionID, sessionID,
timestamp: yield* DateTime.now, timestamp: yield* DateTime.now,
assistantMessageID: assistant.id, assistantMessageID,
callID: "call-interrupted", callID: "call-interrupted",
tool: "echo", tool: "echo",
input: { text: "stale" }, input: { text: "stale" },
@ -1279,9 +1285,11 @@ describe("SessionRunnerLLM", () => {
prompt: new Prompt({ text: "Recover interrupted hosted tool" }), prompt: new Prompt({ text: "Recover interrupted hosted tool" }),
resume: false, resume: false,
}) })
yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID) yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID, Number.MAX_SAFE_INTEGER)
const assistant = yield* events.publish(SessionEvent.Step.Started, { const assistantMessageID = SessionMessage.ID.create()
yield* events.publish(SessionEvent.Step.Started, {
sessionID, sessionID,
assistantMessageID,
timestamp: yield* DateTime.now, timestamp: yield* DateTime.now,
agent: "build", agent: "build",
model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") }, 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, { yield* events.publish(SessionEvent.Tool.Input.Started, {
sessionID, sessionID,
timestamp: yield* DateTime.now, timestamp: yield* DateTime.now,
assistantMessageID: assistant.id, assistantMessageID,
callID: "call-hosted-interrupted", callID: "call-hosted-interrupted",
name: "web_search", name: "web_search",
}) })
yield* events.publish(SessionEvent.Tool.Input.Ended, { yield* events.publish(SessionEvent.Tool.Input.Ended, {
sessionID, sessionID,
timestamp: yield* DateTime.now, timestamp: yield* DateTime.now,
assistantMessageID: assistant.id, assistantMessageID,
callID: "call-hosted-interrupted", callID: "call-hosted-interrupted",
text: '{"query":"stale"}', text: '{"query":"stale"}',
}) })
yield* events.publish(SessionEvent.Tool.Called, { yield* events.publish(SessionEvent.Tool.Called, {
sessionID, sessionID,
timestamp: yield* DateTime.now, timestamp: yield* DateTime.now,
assistantMessageID: assistant.id, assistantMessageID,
callID: "call-hosted-interrupted", callID: "call-hosted-interrupted",
tool: "web_search", tool: "web_search",
input: { query: "stale" }, input: { query: "stale" },
@ -1337,9 +1345,11 @@ describe("SessionRunnerLLM", () => {
prompt: new Prompt({ text: "Recover interrupted tool input" }), prompt: new Prompt({ text: "Recover interrupted tool input" }),
resume: false, resume: false,
}) })
yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID) yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID, Number.MAX_SAFE_INTEGER)
const assistant = yield* events.publish(SessionEvent.Step.Started, { const assistantMessageID = SessionMessage.ID.create()
yield* events.publish(SessionEvent.Step.Started, {
sessionID, sessionID,
assistantMessageID,
timestamp: yield* DateTime.now, timestamp: yield* DateTime.now,
agent: "build", agent: "build",
model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") }, 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, { yield* events.publish(SessionEvent.Tool.Input.Started, {
sessionID, sessionID,
timestamp: yield* DateTime.now, timestamp: yield* DateTime.now,
assistantMessageID: assistant.id, assistantMessageID,
callID: "call-pending-interrupted", callID: "call-pending-interrupted",
name: "echo", name: "echo",
}) })
@ -1414,7 +1424,7 @@ describe("SessionRunnerLLM", () => {
const events = yield* EventV2.Service const events = yield* EventV2.Service
const defect = new Error("fail after prompt promotion") const defect = new Error("fail after prompt promotion")
let fail = true 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 }) 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) 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", () => it.effect("runs different sessions concurrently", () =>
Effect.gen(function* () { Effect.gen(function* () {
yield* setup yield* setup

View file

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

View file

@ -1,5 +1,6 @@
import { useEvent } from "@tui/context/event" import { useEvent } from "@tui/context/event"
import type { import type {
Event,
SessionMessage, SessionMessage,
SessionMessageAssistant, SessionMessageAssistant,
SessionMessageAssistantReasoning, 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({ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext({
name: "SyncV2", name: "SyncV2",
init: () => { init: () => {
@ -67,6 +73,18 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
const event = useEvent() const event = useEvent()
const sdk = useSDK() 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) { function update(sessionID: string, fn: (messages: SessionMessage[]) => void) {
setStore( 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) { switch (event.type) {
case "session.next.agent.switched": case "session.next.agent.switched":
update(event.properties.sessionID, (draft) => { update(event.properties.sessionID, (draft) => {
draft.unshift({ prepend(draft, {
id: event.id, id: event.properties.messageID,
type: "agent-switched", type: "agent-switched",
agent: event.properties.agent, agent: event.properties.agent,
time: { created: event.properties.timestamp }, time: { created: event.properties.timestamp },
@ -91,8 +138,8 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
break break
case "session.next.model.switched": case "session.next.model.switched":
update(event.properties.sessionID, (draft) => { update(event.properties.sessionID, (draft) => {
draft.unshift({ prepend(draft, {
id: event.id, id: event.properties.messageID,
type: "model-switched", type: "model-switched",
model: event.properties.model, model: event.properties.model,
time: { created: event.properties.timestamp }, time: { created: event.properties.timestamp },
@ -101,8 +148,8 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
break break
case "session.next.prompted": { case "session.next.prompted": {
update(event.properties.sessionID, (draft) => { update(event.properties.sessionID, (draft) => {
draft.unshift({ prepend(draft, {
id: event.id, id: event.properties.messageID,
type: "user", type: "user",
text: event.properties.prompt.text, text: event.properties.prompt.text,
files: event.properties.prompt.files, files: event.properties.prompt.files,
@ -113,10 +160,25 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
}) })
break 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": case "session.next.synthetic":
update(event.properties.sessionID, (draft) => { update(event.properties.sessionID, (draft) => {
draft.unshift({ prepend(draft, {
id: event.id, id: event.properties.messageID,
type: "synthetic", type: "synthetic",
sessionID: event.properties.sessionID, sessionID: event.properties.sessionID,
text: event.properties.text, text: event.properties.text,
@ -126,8 +188,8 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
break break
case "session.next.shell.started": case "session.next.shell.started":
update(event.properties.sessionID, (draft) => { update(event.properties.sessionID, (draft) => {
draft.unshift({ prepend(draft, {
id: event.id, id: event.properties.messageID,
type: "shell", type: "shell",
callID: event.properties.callID, callID: event.properties.callID,
command: event.properties.command, command: event.properties.command,
@ -146,10 +208,11 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
break break
case "session.next.step.started": case "session.next.step.started":
update(event.properties.sessionID, (draft) => { update(event.properties.sessionID, (draft) => {
if (draft.some((message) => message.id === event.properties.assistantMessageID)) return
const currentAssistant = activeAssistant(draft) const currentAssistant = activeAssistant(draft)
if (currentAssistant) currentAssistant.time.completed = event.properties.timestamp if (currentAssistant) currentAssistant.time.completed = event.properties.timestamp
draft.unshift({ prepend(draft, {
id: event.id, id: event.properties.assistantMessageID,
type: "assistant", type: "assistant",
agent: event.properties.agent, agent: event.properties.agent,
model: event.properties.model, model: event.properties.model,
@ -182,18 +245,28 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
break break
case "session.next.text.started": case "session.next.text.started":
update(event.properties.sessionID, (draft) => { 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 break
case "session.next.text.delta": case "session.next.text.delta":
update(event.properties.sessionID, (draft) => { 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 if (match) match.text += event.properties.delta
}) })
break break
case "session.next.text.ended": case "session.next.text.ended":
update(event.properties.sessionID, (draft) => { 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 if (match) match.text = event.properties.text
}) })
break break
@ -263,7 +336,11 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
content: [...event.properties.content], content: [...event.properties.content],
result: event.properties.result, 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 match.time.completed = event.properties.timestamp
}) })
break break
@ -282,13 +359,17 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
content: match.state.status === "running" ? match.state.content : [], content: match.state.status === "running" ? match.state.content : [],
result: event.properties.result, 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 match.time.completed = event.properties.timestamp
}) })
break break
case "session.next.reasoning.started": case "session.next.reasoning.started":
update(event.properties.sessionID, (draft) => { update(event.properties.sessionID, (draft) => {
activeAssistant(draft)?.content.push({ ownedAssistant(draft, event.properties.assistantMessageID)?.content.push({
type: "reasoning", type: "reasoning",
id: event.properties.reasoningID, id: event.properties.reasoningID,
text: "", text: "",
@ -298,13 +379,19 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
break break
case "session.next.reasoning.delta": case "session.next.reasoning.delta":
update(event.properties.sessionID, (draft) => { 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 if (match) match.text += event.properties.delta
}) })
break break
case "session.next.reasoning.ended": case "session.next.reasoning.ended":
update(event.properties.sessionID, (draft) => { 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) { if (match) {
match.text = event.properties.text match.text = event.properties.text
if (event.properties.providerMetadata !== undefined) if (event.properties.providerMetadata !== undefined)
@ -316,8 +403,8 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
break break
case "session.next.compaction.started": case "session.next.compaction.started":
update(event.properties.sessionID, (draft) => { update(event.properties.sessionID, (draft) => {
draft.unshift({ prepend(draft, {
id: event.id, id: event.properties.messageID,
type: "compaction", type: "compaction",
reason: event.properties.reason, reason: event.properties.reason,
summary: "", summary: "",
@ -340,16 +427,20 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
}) })
break 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 = { const result = {
data: store, data: store,
session: { session: {
message: { message: {
async sync(sessionID: string) { sync,
const response = await sdk.client.v2.session.messages({ sessionID })
setStore("messages", sessionID, reconcile(response.data?.data ?? []))
},
fromSession(sessionID: string) { fromSession(sessionID: string) {
const messages = store.messages[sessionID] const messages = store.messages[sessionID]
if (!messages) return [] if (!messages) return []

View file

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

View file

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

View file

@ -1,4 +1,5 @@
import { NonNegativeInt } from "@opencode-ai/core/schema" import { NonNegativeInt } from "@opencode-ai/core/schema"
import { EventV2 } from "@opencode-ai/core/event"
import { SessionID } from "@/session/schema" import { SessionID } from "@/session/schema"
import { Schema } from "effect" import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
@ -9,7 +10,7 @@ import { described } from "./metadata"
const root = "/sync" const root = "/sync"
export const ReplayEvent = Schema.Struct({ export const ReplayEvent = Schema.Struct({
id: Schema.String, id: EventV2.ID,
aggregateID: Schema.String, aggregateID: Schema.String,
seq: NonNegativeInt, seq: NonNegativeInt,
type: Schema.String, type: Schema.String,
@ -27,7 +28,7 @@ export const SessionPayload = Schema.Struct({
}) })
export const HistoryPayload = Schema.Record(Schema.String, NonNegativeInt) export const HistoryPayload = Schema.Record(Schema.String, NonNegativeInt)
export const HistoryEvent = Schema.Struct({ export const HistoryEvent = Schema.Struct({
id: Schema.String, id: EventV2.ID,
aggregate_id: Schema.String, aggregate_id: Schema.String,
seq: NonNegativeInt, seq: NonNegativeInt,
type: Schema.String, 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 replay = Effect.fn("SyncHttpApi.replay")(function* (ctx: { payload: typeof ReplayPayload.Type }) {
const payload: EventV2.SerializedEvent[] = ctx.payload.events.map((event) => ({ const payload: EventV2.SerializedEvent[] = ctx.payload.events.map((event) => ({
id: EventV2.ID.make(event.id), id: event.id,
aggregateID: event.aggregateID, aggregateID: event.aggregateID,
seq: event.seq, seq: event.seq,
type: event.type, type: event.type,

View file

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

View file

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

View file

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

View file

@ -6,7 +6,7 @@ import { onMount } from "solid-js"
import { ProjectProvider } from "../../../src/cli/cmd/tui/context/project" import { ProjectProvider } from "../../../src/cli/cmd/tui/context/project"
import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk" import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk"
import { SyncProviderV2, useSyncV2 } from "../../../src/cli/cmd/tui/context/sync-v2" 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) { async function wait(fn: () => boolean, timeout = 2000) {
const start = Date.now() const start = Date.now()
@ -20,6 +20,12 @@ function global(payload: Event): GlobalEvent {
return { directory, project: "proj_test", payload } 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 () => { test("sync v2 settles pending tools when a live failure arrives", async () => {
const events = createEventSource() const events = createEventSource()
const calls = createFetch() const calls = createFetch()
@ -47,63 +53,68 @@ test("sync v2 settles pending tools when a live failure arrives", async () => {
try { try {
await mounted await mounted
events.emit( emitTwice(events, {
global({ id: "evt_agent_1",
id: "agent-1", type: "session.next.agent.switched",
type: "session.next.agent.switched", properties: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 0, agent: "build" },
properties: { sessionID: "session-1", timestamp: 0, agent: "build" }, })
}), emitTwice(events, {
) id: "evt_model_1",
events.emit( type: "session.next.model.switched",
global({ properties: {
id: "model-1", sessionID: "session-1",
type: "session.next.model.switched", messageID: "msg_model_1",
properties: { timestamp: 0,
sessionID: "session-1", model: { id: "model-1", providerID: "provider-1" },
timestamp: 0, },
model: { id: "model-1", providerID: "provider-1" }, })
}, emitTwice(events, {
}), id: "evt_step_started_1",
) type: "session.next.step.started",
events.emit( properties: {
global({ sessionID: "session-1",
id: "assistant-1", assistantMessageID: "msg_explicit_assistant_9",
type: "session.next.step.started", timestamp: 1,
properties: { agent: "build",
sessionID: "session-1", model: { id: "model-1", providerID: "provider-1" },
timestamp: 1, },
agent: "build", })
model: { id: "model-1", providerID: "provider-1" }, emitTwice(events, {
}, id: "evt_input_1",
}), type: "session.next.tool.input.started",
) properties: {
events.emit( sessionID: "session-1",
global({ assistantMessageID: "msg_explicit_assistant_9",
id: "input-1", timestamp: 2,
type: "session.next.tool.input.started", callID: "call-1",
properties: { name: "bash",
sessionID: "session-1", },
timestamp: 2, })
assistantMessageID: "assistant-1", emitTwice(events, {
callID: "call-1", id: "evt_called_1",
name: "bash", type: "session.next.tool.called",
}, properties: {
}), sessionID: "session-1",
) timestamp: 2,
events.emit( assistantMessageID: "msg_explicit_assistant_9",
global({ callID: "call-1",
id: "failed-1", tool: "bash",
type: "session.next.tool.failed", input: {},
properties: { provider: { executed: false, metadata: { fake: { call: true } } },
sessionID: "session-1", },
timestamp: 3, })
assistantMessageID: "assistant-1", emitTwice(events, {
callID: "call-1", id: "evt_failed_1",
error: { type: "unknown", message: "aborted" }, type: "session.next.tool.failed",
provider: { executed: false }, 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(() => { await wait(() => {
const assistant = sync.session.message.fromSession("session-1")[0] 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] const assistant = sync.session.message.fromSession("session-1")[0]
expect(assistant?.type).toBe("assistant") expect(assistant?.type).toBe("assistant")
if (assistant?.type !== "assistant") return if (assistant?.type !== "assistant") return
expect(assistant.id).toBe("msg_explicit_assistant_9")
const tool = assistant.content[0] const tool = assistant.content[0]
expect(tool?.type).toBe("tool") expect(tool?.type).toBe("tool")
if (tool?.type !== "tool") return 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.input).toEqual({})
expect(tool.state.structured).toEqual({}) expect(tool.state.structured).toEqual({})
expect(tool.state.content).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([ expect(sync.session.message.fromSession("session-1").map((message) => message.type)).toEqual([
"assistant", "assistant",
"model-switched", "model-switched",
@ -135,3 +152,358 @@ test("sync v2 settles pending tools when a live failure arrives", async () => {
app.renderer.destroy() 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`, { request(`/api/session/${session.id}/prompt`, {
method: "POST", method: "POST",
headers: { ...headers, "content-type": "application/json" }, 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 first = yield* recordPrompt()
const retried = 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 firstBody = yield* json<{ data: PromptBody }>(first)
const retriedBody = yield* json<{ data: PromptBody }>(retried) const retriedBody = yield* json<{ data: PromptBody }>(retried)
expect(first.status).toBe(200) expect(first.status).toBe(200)
expect(retried.status).toBe(200) expect(retried.status).toBe(200)
expect(retriedBody).toEqual(firstBody) 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`, { const messages = yield* requestJson<{ data: PromptBody[] }>(`/api/session/${session.id}/message`, {
headers, headers,
@ -604,27 +606,26 @@ describe("session HttpApi", () => {
db db
.select() .select()
.from(SessionInputTable) .from(SessionInputTable)
.where(eq(SessionInputTable.id, SessionMessage.ID.make("evt_http_prompt"))) .where(eq(SessionInputTable.id, SessionMessage.ID.make("msg_http_prompt")))
.get() .get()
.pipe(Effect.orDie), .pipe(Effect.orDie),
) )
expect(admitted).toMatchObject({ expect(admitted).toMatchObject({
id: "evt_http_prompt", id: "msg_http_prompt",
session_id: session.id, session_id: session.id,
delivery: "steer", delivery: "steer",
promoted_seq: null, promoted_seq: null,
}) })
const conflict = yield* request(`/api/session/${session.id}/prompt`, { const conflict = yield* request(`/api/session/${session.id}/prompt`, {
method: "POST", method: "POST",
headers: { ...headers, "content-type": "application/json" }, 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(conflict.status).toBe(409)
expect(yield* responseJson(conflict)).toEqual({ expect(yield* responseJson(conflict)).toEqual({
_tag: "ConflictError", _tag: "ConflictError",
message: "Prompt message ID conflicts with an existing durable record: evt_http_prompt", message: "Prompt message ID conflicts with an existing durable record: msg_http_prompt",
resource: "evt_http_prompt", resource: "msg_http_prompt",
}) })
}), }),
{ git: true, config: { formatter: false, lsp: false } }, { 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: {} }], 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) { for (const item of cases) {

View file

@ -26,7 +26,7 @@ function migrations() {
} }
describe("workspace time migration", () => { describe("workspace time migration", () => {
test("migrates existing workspace rows", () => { test("discards existing workspace rows during the beta reset", () => {
const sqlite = new Database(":memory:") const sqlite = new Database(":memory:")
const db = drizzle({ client: sqlite }) const db = drizzle({ client: sqlite })
const entries = migrations() const entries = migrations()
@ -45,6 +45,6 @@ describe("workspace time migration", () => {
) )
expect(() => migrate(db, entries.slice(index))).not.toThrow() 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 { ProviderV2 } from "@opencode-ai/core/provider"
import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater" 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" import { ToolOutput } from "@opencode-ai/core/tool-output"
test.skip("step snapshots carry over to assistant messages", () => { test.skip("step snapshots carry over to assistant messages", () => {
const state: SessionMessageUpdater.MemoryState = { messages: [] } const state: SessionMessageUpdater.MemoryState = { messages: [] }
const sessionID = SessionID.make("session") const sessionID = SessionID.make("session")
const assistantMessageID = EventV2.ID.create() const assistantMessageID = SessionMessage.ID.create()
Effect.runSync( Effect.runSync(
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
id: assistantMessageID, id: EventV2.ID.create(),
type: "session.next.step.started", type: "session.next.step.started",
data: { data: {
sessionID, sessionID,
assistantMessageID,
timestamp: DateTime.makeUnsafe(1), timestamp: DateTime.makeUnsafe(1),
agent: "build", agent: "build",
model: { model: {
@ -62,6 +64,7 @@ test.skip("step snapshots carry over to assistant messages", () => {
test.skip("text ended populates assistant text content", () => { test.skip("text ended populates assistant text content", () => {
const state: SessionMessageUpdater.MemoryState = { messages: [] } const state: SessionMessageUpdater.MemoryState = { messages: [] }
const sessionID = SessionID.make("session") const sessionID = SessionID.make("session")
const assistantMessageID = SessionMessage.ID.create()
Effect.runSync( Effect.runSync(
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
@ -69,6 +72,7 @@ test.skip("text ended populates assistant text content", () => {
type: "session.next.step.started", type: "session.next.step.started",
data: { data: {
sessionID, sessionID,
assistantMessageID,
timestamp: DateTime.makeUnsafe(1), timestamp: DateTime.makeUnsafe(1),
agent: "build", agent: "build",
model: { model: {
@ -86,6 +90,7 @@ test.skip("text ended populates assistant text content", () => {
type: "session.next.text.started", type: "session.next.text.started",
data: { data: {
sessionID, sessionID,
assistantMessageID,
timestamp: DateTime.makeUnsafe(2), timestamp: DateTime.makeUnsafe(2),
textID: "text-1", textID: "text-1",
}, },
@ -98,6 +103,7 @@ test.skip("text ended populates assistant text content", () => {
type: "session.next.text.ended", type: "session.next.text.ended",
data: { data: {
sessionID, sessionID,
assistantMessageID,
timestamp: DateTime.makeUnsafe(3), timestamp: DateTime.makeUnsafe(3),
textID: "text-1", textID: "text-1",
text: "hello assistant", text: "hello assistant",
@ -114,14 +120,15 @@ test.skip("tool completion stores completed timestamp", () => {
const state: SessionMessageUpdater.MemoryState = { messages: [] } const state: SessionMessageUpdater.MemoryState = { messages: [] }
const sessionID = SessionID.make("session") const sessionID = SessionID.make("session")
const callID = "call" const callID = "call"
const assistantMessageID = EventV2.ID.create() const assistantMessageID = SessionMessage.ID.create()
Effect.runSync( Effect.runSync(
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
id: assistantMessageID, id: EventV2.ID.create(),
type: "session.next.step.started", type: "session.next.step.started",
data: { data: {
sessionID, sessionID,
assistantMessageID,
timestamp: DateTime.makeUnsafe(1), timestamp: DateTime.makeUnsafe(1),
agent: "build", agent: "build",
model: { model: {
@ -198,6 +205,7 @@ test.skip("compaction events reduce to compaction message", () => {
type: "session.next.compaction.started", type: "session.next.compaction.started",
data: { data: {
sessionID, sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1), timestamp: DateTime.makeUnsafe(1),
reason: "auto", reason: "auto",
}, },

View file

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

View file

@ -4,6 +4,28 @@ Record V2 database, durable-event, projected-message, HTTP, and generated SDK sc
This document covers meaningful contract changes introduced on the `feat/opencode-embedded-api` branch since its divergence from `origin/dev`. Mechanical file moves and internal refactors are omitted unless they changed stored data, replay behavior, public HTTP or SDK shapes, or model-facing tool contracts. This document covers meaningful contract changes introduced on the `feat/opencode-embedded-api` branch since its divergence from `origin/dev`. Mechanical file moves and internal refactors are omitted unless they changed stored data, replay behavior, public HTTP or SDK shapes, or model-facing tool contracts.
## 2026-06-04 Event-Sourced Session Input Cutover
Affected schema:
- `session_input`, `session_message`, `event`, `event_sequence`, and disposable workspace beta storage.
- New synchronized `session.next.prompt.admitted.1` and `session.next.prompt.promoted.1` events.
- Experimental `SessionV2.prompt(...)`, HTTP, and generated SDK admission receipt.
Change:
- Replace inbox-local admission sequence with event-sourced prompt admission and promotion sequences.
- Give projected Session messages stable `msg_*` resource IDs distinct from `evt_*` creator event IDs.
- Give every event that creates a projected transcript resource an explicit `msg_*` resource ID. Assistant steps propagate one `assistantMessageID` through assistant-owned events.
- Reset incompatible unreleased beta event history, derived Session projections, workspace rows, and Session workspace links.
Compatibility:
- The reset preserves canonical V1 `session`, `message`, and `part` rows.
- Existing synchronized workspaces are disposable beta state and are removed by the reset.
- Before starting the new build, discard adapter-managed external workspace resources created by unreleased builds. The SQL migration cannot remove external resources through runtime adapters, and rediscovering retained resources after startup can replay incompatible beta history.
- Exact prompt retries reconcile one stable `msg_*` identity when Session, prompt, and delivery mode match.
## Earlier Branch History ## Earlier Branch History
### Replayable Session Event Refinement And Cursor Stream ### Replayable Session Event Refinement And Cursor Stream
@ -60,7 +82,7 @@ Affected schema:
Change: Change:
- Bind step settlement to an explicit `assistantMessageID`. - Bind step settlement to an explicit assistant message ID.
Reason: Reason:
@ -409,7 +431,7 @@ Affected schema:
Change: Change:
- Preserve stable IDs on projected assistant text parts. - Preserve stable IDs on projected assistant text parts.
- Route durable tool projection updates through explicit owning `assistantMessageID` values rather than provider-local call IDs alone. - Route durable tool projection updates through explicit owning assistant message IDs rather than provider-local call IDs alone.
- Replay full-value text and tool-input end checkpoints while keeping fragment deltas ephemeral. - Replay full-value text and tool-input end checkpoints while keeping fragment deltas ephemeral.
Reason: Reason:

View file

@ -13,14 +13,14 @@ sessions.create({ id?, location, ... })
sessions.prompt({ id?, sessionID, prompt, delivery?, resume? }) sessions.prompt({ id?, sessionID, prompt, delivery?, resume? })
-> omitted ID generates one internal message ID -> omitted ID generates one internal message ID
-> supplied ID admits one durable Session input when absent -> supplied ID admits one durable Session input when absent
-> exact reuse returns the same admitted user-shaped message -> exact reuse returns the same admitted lifecycle receipt
-> reusing one message ID for another Session, prompt, or delivery mode fails -> reusing one message ID for another Session, prompt, or delivery mode fails
-> exact retry schedules another wake unless resume is false -> exact retry schedules another wake unless resume is false
-> resume omitted or true schedules execution after admission -> resume omitted or true schedules execution after admission
-> resume false admits only -> resume false admits only
``` ```
`session_input` is the durable admission inbox. Admitted inputs remain outside model-visible Session history until the serialized runner promotes them by publishing ordinary `Prompted` events. The existing projector atomically writes the visible user message and marks its inbox row promoted in the same event transaction. `session_input` is the durable admission inbox. Admitted inputs remain outside model-visible Session history until the serialized runner publishes `PromptLifecycle.Promoted`. The projector atomically writes the visible user message and marks its inbox row promoted in the same event transaction. The legacy V1-to-V2 shadow bridge continues publishing ordinary `Prompted` events for already-visible V1 prompts.
Execution routing starts from only the Session ID: Execution routing starts from only the Session ID:
@ -33,7 +33,7 @@ SessionExecution.resume(sessionID)
`SessionExecution` and the read-side `SessionStore` are process-global. `SessionRunner`, catalog, model resolver, tool registry, permission state, and filesystem are cached per Location. No layer takes a Session ID. An omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics. `SessionExecution` and the read-side `SessionStore` are process-global. `SessionRunner`, catalog, model resolver, tool registry, permission state, and filesystem are cached per Location. No layer takes a Session ID. An omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
The local runner issues one explicit `llm.stream(request)` per provider turn, projects each complete local tool call durably before eagerly starting its structured child execution, awaits every started tool fiber after provider-stream closure, reloads projected history once before continuation, and fails after 25 provider turns within one local drain activity only when work remains. Tool settlement events carry the owning assistant-message ID because provider-local call IDs may repeat across turns. Before assembling a provider request, the runner durably fails any local tool still projected as `running` from a previous process with `Tool execution interrupted`; abandoned side effects are never silently replayed. The local runner issues one explicit `llm.stream(request)` per provider turn, projects each complete local tool call durably before eagerly starting its structured child execution, awaits every started tool fiber after provider-stream closure, reloads projected history once before continuation, and fails after 25 provider turns within one local drain activity only when work remains. Tool settlement events carry the owning assistant message ID because provider-local call IDs may repeat across turns. Before assembling a provider request, the runner durably fails any local tool still projected as `running` from a previous process with `Tool execution interrupted`; abandoned side effects are never silently replayed.
Projected hosted tools preserve call-side and settlement-side provider metadata separately so settlement and interruption recovery cannot erase continuation identifiers. Provider-native reasoning and provider metadata replay only while the historical assistant model matches the selected continuation model; after a model switch, visible reasoning text remains ordinary assistant text and provider-native metadata is omitted. Projected hosted tools preserve call-side and settlement-side provider metadata separately so settlement and interruption recovery cannot erase continuation identifiers. Provider-native reasoning and provider metadata replay only while the historical assistant model matches the selected continuation model; after a model switch, visible reasoning text remains ordinary assistant text and provider-native metadata is omitted.