refactor(core): simplify session input promotion (#33443)
This commit is contained in:
+1
@@ -39,5 +39,6 @@ export const migrations = (
|
|||||||
import("./migration/20260612174303_project_dir_strategy"),
|
import("./migration/20260612174303_project_dir_strategy"),
|
||||||
import("./migration/20260622142730_simplify_session_context_epoch"),
|
import("./migration/20260622142730_simplify_session_context_epoch"),
|
||||||
import("./migration/20260622170816_reset_v2_session_state"),
|
import("./migration/20260622170816_reset_v2_session_state"),
|
||||||
|
import("./migration/20260622202450_simplify_session_input"),
|
||||||
])
|
])
|
||||||
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Effect } from "effect"
|
||||||
|
import type { DatabaseMigration } from "../migration"
|
||||||
|
|
||||||
|
export default {
|
||||||
|
id: "20260622202450_simplify_session_input",
|
||||||
|
up(tx) {
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
yield* tx.run(`DELETE FROM \`session_context_epoch\`;`)
|
||||||
|
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 WHERE \`workspace_id\` IS NOT NULL;`)
|
||||||
|
yield* tx.run(`DELETE FROM \`workspace\`;`)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
} satisfies DatabaseMigration.Migration
|
||||||
@@ -46,6 +46,19 @@ export type Payload<D extends Definition = Definition> = {
|
|||||||
export type Subscriber<D extends Definition = Definition> = (event: Payload<D>) => Effect.Effect<void>
|
export type Subscriber<D extends Definition = Definition> = (event: Payload<D>) => Effect.Effect<void>
|
||||||
export type Unsubscribe = Effect.Effect<void>
|
export type Unsubscribe = Effect.Effect<void>
|
||||||
|
|
||||||
|
export const latestSequence = Effect.fn("EventV2.latestSequence")(function* (
|
||||||
|
db: Database.Interface["db"],
|
||||||
|
aggregateID: string,
|
||||||
|
) {
|
||||||
|
const row = yield* db
|
||||||
|
.select({ seq: EventSequenceTable.seq })
|
||||||
|
.from(EventSequenceTable)
|
||||||
|
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
|
||||||
|
.get()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
return row?.seq ?? -1
|
||||||
|
})
|
||||||
|
|
||||||
export type SerializedEvent = {
|
export type SerializedEvent = {
|
||||||
readonly id: ID
|
readonly id: ID
|
||||||
readonly type: string
|
readonly type: string
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ const prepareOnce = Effect.fnUntraced(function* (
|
|||||||
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
|
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
|
||||||
}
|
}
|
||||||
if (result._tag === "ReplacementReady") {
|
if (result._tag === "ReplacementReady") {
|
||||||
const baselineSeq = replacementSeq ?? (yield* SessionInput.latestSeq(db, sessionID))
|
const baselineSeq = replacementSeq ?? (yield* EventV2.latestSequence(db, sessionID))
|
||||||
yield* replace(db, sessionID, baselineSeq, result.generation)
|
yield* replace(db, sessionID, baselineSeq, result.generation)
|
||||||
return { baseline: result.generation.baseline, baselineSeq }
|
return { baseline: result.generation.baseline, baselineSeq }
|
||||||
}
|
}
|
||||||
@@ -124,7 +124,7 @@ const insert = Effect.fnUntraced(function* (
|
|||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
generation: SystemContext.Generation,
|
generation: SystemContext.Generation,
|
||||||
) {
|
) {
|
||||||
const baselineSeq = yield* SessionInput.latestSeq(db, sessionID)
|
const baselineSeq = yield* EventV2.latestSequence(db, sessionID)
|
||||||
yield* db
|
yield* db
|
||||||
.insert(SessionContextEpochTable)
|
.insert(SessionContextEpochTable)
|
||||||
.values({
|
.values({
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ const Base = {
|
|||||||
timestamp: V2Schema.DateTimeUtcFromMillis,
|
timestamp: V2Schema.DateTimeUtcFromMillis,
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
}
|
}
|
||||||
|
const PromptFields = {
|
||||||
|
...Base,
|
||||||
|
messageID: SessionMessageID.ID,
|
||||||
|
prompt: Prompt,
|
||||||
|
delivery: Schema.Literals(["steer", "queue"]),
|
||||||
|
}
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
durable: {
|
durable: {
|
||||||
@@ -83,40 +89,16 @@ export type Moved = typeof Moved.Type
|
|||||||
export const Prompted = EventV2.define({
|
export const Prompted = EventV2.define({
|
||||||
type: "session.next.prompted",
|
type: "session.next.prompted",
|
||||||
...options,
|
...options,
|
||||||
schema: {
|
schema: PromptFields,
|
||||||
...Base,
|
|
||||||
messageID: SessionMessageID.ID,
|
|
||||||
prompt: Prompt,
|
|
||||||
delivery: Schema.Literals(["steer", "queue"]),
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
export type Prompted = typeof Prompted.Type
|
export type Prompted = typeof Prompted.Type
|
||||||
|
|
||||||
export namespace PromptLifecycle {
|
export const PromptAdmitted = EventV2.define({
|
||||||
export const Admitted = EventV2.define({
|
|
||||||
type: "session.next.prompt.admitted",
|
type: "session.next.prompt.admitted",
|
||||||
...options,
|
...options,
|
||||||
schema: {
|
schema: PromptFields,
|
||||||
...Base,
|
|
||||||
messageID: SessionMessageID.ID,
|
|
||||||
prompt: Prompt,
|
|
||||||
delivery: Schema.Literals(["steer", "queue"]),
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
export type Admitted = typeof Admitted.Type
|
export type PromptAdmitted = typeof PromptAdmitted.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 ContextUpdated = EventV2.define({
|
export const ContextUpdated = EventV2.define({
|
||||||
type: "session.next.context.updated",
|
type: "session.next.context.updated",
|
||||||
@@ -455,8 +437,7 @@ const DurableDefinitions = [
|
|||||||
ModelSwitched,
|
ModelSwitched,
|
||||||
Moved,
|
Moved,
|
||||||
Prompted,
|
Prompted,
|
||||||
PromptLifecycle.Admitted,
|
PromptAdmitted,
|
||||||
PromptLifecycle.Promoted,
|
|
||||||
ContextUpdated,
|
ContextUpdated,
|
||||||
Synthetic,
|
Synthetic,
|
||||||
Shell.Started,
|
Shell.Started,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ 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 { EventSequenceTable } from "../event/sql"
|
|
||||||
import { NonNegativeInt } from "../schema"
|
import { NonNegativeInt } from "../schema"
|
||||||
import { V2Schema } from "../v2-schema"
|
import { V2Schema } from "../v2-schema"
|
||||||
import { SessionEvent } from "./event"
|
import { SessionEvent } from "./event"
|
||||||
@@ -65,7 +64,7 @@ export const admit = Effect.fn("SessionInput.admit")(function* (
|
|||||||
if (existing !== undefined) return existing
|
if (existing !== undefined) return existing
|
||||||
const timestamp = yield* DateTime.now
|
const timestamp = yield* DateTime.now
|
||||||
return yield* events
|
return yield* events
|
||||||
.publish(SessionEvent.PromptLifecycle.Admitted, {
|
.publish(SessionEvent.PromptAdmitted, {
|
||||||
messageID: input.id,
|
messageID: input.id,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
timestamp,
|
timestamp,
|
||||||
@@ -93,19 +92,6 @@ export const admit = Effect.fn("SessionInput.admit")(function* (
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export const latestSeq = Effect.fn("SessionInput.latestSeq")(function* (
|
|
||||||
db: DatabaseService,
|
|
||||||
sessionID: SessionSchema.ID,
|
|
||||||
) {
|
|
||||||
const row = yield* db
|
|
||||||
.select({ seq: EventSequenceTable.seq })
|
|
||||||
.from(EventSequenceTable)
|
|
||||||
.where(eq(EventSequenceTable.aggregate_id, sessionID))
|
|
||||||
.get()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
return row?.seq ?? -1
|
|
||||||
})
|
|
||||||
|
|
||||||
export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(function* (
|
export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(function* (
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
input: {
|
input: {
|
||||||
@@ -117,6 +103,13 @@ export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(functio
|
|||||||
readonly timeCreated: DateTime.Utc
|
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 !== undefined) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||||
const stored = yield* db
|
const stored = yield* db
|
||||||
.insert(SessionInputTable)
|
.insert(SessionInputTable)
|
||||||
.values({
|
.values({
|
||||||
@@ -134,12 +127,13 @@ export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(functio
|
|||||||
if (!stored) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
if (!stored) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||||
})
|
})
|
||||||
|
|
||||||
export const projectPromoted = Effect.fn("SessionInput.projectPromoted")(function* (
|
export const projectPrompted = Effect.fn("SessionInput.projectPrompted")(function* (
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
input: {
|
input: {
|
||||||
readonly id: SessionMessage.ID
|
readonly id: SessionMessage.ID
|
||||||
readonly sessionID: SessionSchema.ID
|
readonly sessionID: SessionSchema.ID
|
||||||
readonly prompt: Prompt
|
readonly prompt: Prompt
|
||||||
|
readonly delivery: Delivery
|
||||||
readonly timeCreated: DateTime.Utc
|
readonly timeCreated: DateTime.Utc
|
||||||
readonly promotedSeq: number
|
readonly promotedSeq: number
|
||||||
},
|
},
|
||||||
@@ -157,14 +151,32 @@ export const projectPromoted = Effect.fn("SessionInput.projectPromoted")(functio
|
|||||||
.returning()
|
.returning()
|
||||||
.get()
|
.get()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
if (!updated) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
if (updated) {
|
||||||
const stored = fromRow(updated)
|
const stored = fromRow(updated)
|
||||||
if (
|
if (!matchesProjection(stored, input)) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||||
!matchesPrompt(stored, input) ||
|
return
|
||||||
DateTime.toEpochMillis(stored.timeCreated) !== DateTime.toEpochMillis(input.timeCreated)
|
}
|
||||||
)
|
|
||||||
|
const stored = yield* find(db, input.id)
|
||||||
|
if (stored) {
|
||||||
|
if (!matchesProjection(stored, input) || stored.promotedSeq !== input.promotedSeq)
|
||||||
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||||
return toMessage(stored)
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
yield* db
|
||||||
|
.insert(SessionInputTable)
|
||||||
|
.values({
|
||||||
|
id: input.id,
|
||||||
|
session_id: input.sessionID,
|
||||||
|
prompt: encodePrompt(input.prompt),
|
||||||
|
delivery: input.delivery,
|
||||||
|
admitted_seq: input.promotedSeq,
|
||||||
|
promoted_seq: input.promotedSeq,
|
||||||
|
time_created: DateTime.toEpochMillis(input.timeCreated),
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
})
|
})
|
||||||
|
|
||||||
export const hasPending = Effect.fn("SessionInput.hasPending")(function* (
|
export const hasPending = Effect.fn("SessionInput.hasPending")(function* (
|
||||||
@@ -201,35 +213,17 @@ const matchesPrompt = (input: Admitted, expected: { readonly sessionID: SessionS
|
|||||||
input.sessionID === expected.sessionID &&
|
input.sessionID === expected.sessionID &&
|
||||||
JSON.stringify(encodePrompt(input.prompt)) === JSON.stringify(encodePrompt(expected.prompt))
|
JSON.stringify(encodePrompt(input.prompt)) === JSON.stringify(encodePrompt(expected.prompt))
|
||||||
|
|
||||||
export const projectLegacyPrompted = Effect.fn("SessionInput.projectLegacyPrompted")(function* (
|
const matchesProjection = (
|
||||||
db: DatabaseService,
|
input: Admitted,
|
||||||
input: {
|
expected: {
|
||||||
readonly id: SessionMessage.ID
|
|
||||||
readonly sessionID: SessionSchema.ID
|
readonly sessionID: SessionSchema.ID
|
||||||
readonly prompt: Prompt
|
readonly prompt: Prompt
|
||||||
readonly delivery: Delivery
|
readonly delivery: Delivery
|
||||||
readonly timeCreated: DateTime.Utc
|
readonly timeCreated: DateTime.Utc
|
||||||
readonly promotedSeq: number
|
|
||||||
},
|
},
|
||||||
) {
|
) =>
|
||||||
const inserted = yield* db
|
equivalent(input, expected) &&
|
||||||
.insert(SessionInputTable)
|
DateTime.toEpochMillis(input.timeCreated) === DateTime.toEpochMillis(expected.timeCreated)
|
||||||
.values({
|
|
||||||
id: input.id,
|
|
||||||
session_id: input.sessionID,
|
|
||||||
admitted_seq: input.promotedSeq,
|
|
||||||
prompt: encodePrompt(input.prompt),
|
|
||||||
delivery: input.delivery,
|
|
||||||
promoted_seq: input.promotedSeq,
|
|
||||||
time_created: DateTime.toEpochMillis(input.timeCreated),
|
|
||||||
})
|
|
||||||
.onConflictDoNothing()
|
|
||||||
.returning()
|
|
||||||
.get()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
if (!inserted) return yield* Effect.die("Prompt projection conflicts with admitted input")
|
|
||||||
return fromRow(inserted)
|
|
||||||
})
|
|
||||||
|
|
||||||
const publish = Effect.fn("SessionInput.publish")(function* (
|
const publish = Effect.fn("SessionInput.publish")(function* (
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
@@ -238,18 +232,19 @@ const publish = Effect.fn("SessionInput.publish")(function* (
|
|||||||
rows: ReadonlyArray<typeof SessionInputTable.$inferSelect>,
|
rows: ReadonlyArray<typeof SessionInputTable.$inferSelect>,
|
||||||
) {
|
) {
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
|
const id = SessionMessage.ID.make(row.id)
|
||||||
yield* events
|
yield* events
|
||||||
.publish(SessionEvent.PromptLifecycle.Promoted, {
|
.publish(SessionEvent.Prompted, {
|
||||||
sessionID,
|
sessionID,
|
||||||
timestamp: yield* DateTime.now,
|
timestamp: DateTime.makeUnsafe(row.time_created),
|
||||||
messageID: SessionMessage.ID.make(row.id),
|
messageID: id,
|
||||||
prompt: decodePrompt(row.prompt),
|
prompt: decodePrompt(row.prompt),
|
||||||
timeCreated: DateTime.makeUnsafe(row.time_created),
|
delivery: row.delivery,
|
||||||
})
|
})
|
||||||
.pipe(
|
.pipe(
|
||||||
Effect.catchDefect((defect) =>
|
Effect.catchDefect((defect) =>
|
||||||
defect instanceof LifecycleConflict
|
defect instanceof LifecycleConflict
|
||||||
? find(db, SessionMessage.ID.make(row.id)).pipe(
|
? find(db, id).pipe(
|
||||||
Effect.flatMap((stored) => (stored?.promotedSeq === undefined ? Effect.die(defect) : Effect.void)),
|
Effect.flatMap((stored) => (stored?.promotedSeq === undefined ? Effect.die(defect) : Effect.void)),
|
||||||
)
|
)
|
||||||
: Effect.die(defect),
|
: Effect.die(defect),
|
||||||
@@ -303,13 +298,3 @@ export const promoteNextQueued = Effect.fn("SessionInput.promoteNextQueued")(fun
|
|||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
return row === undefined ? false : yield* publish(db, events, sessionID, [row]).pipe(Effect.as(true))
|
return row === undefined ? false : yield* publish(db, events, sessionID, [row]).pipe(Effect.as(true))
|
||||||
})
|
})
|
||||||
|
|
||||||
const toMessage = (input: Admitted) =>
|
|
||||||
new SessionMessage.User({
|
|
||||||
id: input.id,
|
|
||||||
type: "user",
|
|
||||||
text: input.prompt.text,
|
|
||||||
files: input.prompt.files,
|
|
||||||
agents: input.prompt.agents,
|
|
||||||
time: { created: input.timeCreated },
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -137,7 +137,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
"session.next.prompt.admitted": () => Effect.void,
|
"session.next.prompt.admitted": () => Effect.void,
|
||||||
"session.next.prompt.promoted": () => Effect.void,
|
|
||||||
"session.next.context.updated": (event) =>
|
"session.next.context.updated": (event) =>
|
||||||
adapter.appendMessage(
|
adapter.appendMessage(
|
||||||
new SessionMessage.System({
|
new SessionMessage.System({
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ 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)
|
||||||
|
|
||||||
class PromptAlreadyProjected extends Error {}
|
|
||||||
export class SessionAlreadyProjected extends Error {}
|
export class SessionAlreadyProjected extends Error {}
|
||||||
|
|
||||||
type Usage = {
|
type Usage = {
|
||||||
@@ -350,27 +349,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
|
|
||||||
.select({ id: SessionMessageTable.id })
|
|
||||||
.from(SessionMessageTable)
|
|
||||||
.where(eq(SessionMessageTable.id, messageID))
|
|
||||||
.get()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
if (existing) return yield* Effect.die(new PromptAlreadyProjected())
|
|
||||||
yield* run(db, event)
|
|
||||||
if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence")
|
if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence")
|
||||||
yield* SessionInput.projectLegacyPrompted(db, {
|
yield* SessionInput.projectPrompted(db, {
|
||||||
id: messageID,
|
id: event.data.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,
|
||||||
timeCreated: event.data.timestamp,
|
timeCreated: event.data.timestamp,
|
||||||
promotedSeq: event.durable.seq,
|
promotedSeq: event.durable.seq,
|
||||||
})
|
})
|
||||||
|
yield* run(db, event)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
yield* events.project(SessionEvent.PromptLifecycle.Admitted, (event) =>
|
yield* events.project(SessionEvent.PromptAdmitted, (event) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence")
|
if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence")
|
||||||
yield* SessionInput.projectAdmitted(db, {
|
yield* SessionInput.projectAdmitted(db, {
|
||||||
@@ -383,22 +374,6 @@ export const layer = Layer.effectDiscard(
|
|||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
yield* events.project(SessionEvent.PromptLifecycle.Promoted, (event) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
if (event.durable === undefined) return yield* Effect.die("Durable 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.durable.seq,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
yield* events.project(SessionEvent.ContextUpdated, (event) => run(db, event))
|
yield* events.project(SessionEvent.ContextUpdated, (event) => run(db, event))
|
||||||
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))
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ export const layer = Layer.effect(
|
|||||||
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>()
|
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>()
|
||||||
let needsContinuation = false
|
let needsContinuation = false
|
||||||
if (promotion) {
|
if (promotion) {
|
||||||
const cutoff = yield* SessionInput.latestSeq(db, session.id)
|
const cutoff = yield* EventV2.latestSequence(db, session.id)
|
||||||
if (promotion === "steer") yield* SessionInput.promoteSteers(db, events, session.id, cutoff)
|
if (promotion === "steer") yield* SessionInput.promoteSteers(db, events, session.id, cutoff)
|
||||||
if (promotion === "queue") {
|
if (promotion === "queue") {
|
||||||
yield* SessionInput.promoteNextQueued(db, events, session.id)
|
yield* SessionInput.promoteNextQueued(db, events, session.id)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import sessionMessageProjectionOrderMigration from "@opencode-ai/core/database/m
|
|||||||
import eventSourcedSessionInputMigration from "@opencode-ai/core/database/migration/20260604172448_event_sourced_session_input"
|
import eventSourcedSessionInputMigration from "@opencode-ai/core/database/migration/20260604172448_event_sourced_session_input"
|
||||||
import contextEpochAgentMigration from "@opencode-ai/core/database/migration/20260605042240_add_context_epoch_agent"
|
import contextEpochAgentMigration from "@opencode-ai/core/database/migration/20260605042240_add_context_epoch_agent"
|
||||||
import simplifyIntegrationCredentialsMigration from "@opencode-ai/core/database/migration/20260611192811_lush_chimera"
|
import simplifyIntegrationCredentialsMigration from "@opencode-ai/core/database/migration/20260611192811_lush_chimera"
|
||||||
import resetV2SessionStateMigration from "@opencode-ai/core/database/migration/20260622170816_reset_v2_session_state"
|
import simplifySessionInputMigration from "@opencode-ai/core/database/migration/20260622202450_simplify_session_input"
|
||||||
import { EventV2 } from "@opencode-ai/core/event"
|
import { EventV2 } from "@opencode-ai/core/event"
|
||||||
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"
|
||||||
@@ -264,8 +264,8 @@ describe("DatabaseMigration", () => {
|
|||||||
yield* db.run(
|
yield* db.run(
|
||||||
sql`INSERT INTO session_context_epoch (session_id, baseline, snapshot, baseline_seq) VALUES ('session', 'baseline', '{}', 9)`,
|
sql`INSERT INTO session_context_epoch (session_id, baseline, snapshot, baseline_seq) VALUES ('session', 'baseline', '{}', 9)`,
|
||||||
)
|
)
|
||||||
yield* db.run(sql`DELETE FROM migration WHERE id = ${resetV2SessionStateMigration.id}`)
|
yield* db.run(sql`DELETE FROM migration WHERE id = ${simplifySessionInputMigration.id}`)
|
||||||
yield* DatabaseMigration.applyOnly(db, [resetV2SessionStateMigration])
|
yield* DatabaseMigration.applyOnly(db, [simplifySessionInputMigration])
|
||||||
|
|
||||||
const database = Layer.succeed(Database.Service, { db })
|
const database = Layer.succeed(Database.Service, { db })
|
||||||
const events = EventV2.layer.pipe(Layer.provide(database))
|
const events = EventV2.layer.pipe(Layer.provide(database))
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ describe("SessionV2.create", () => {
|
|||||||
Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(2), Stream.runCollect)),
|
Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(2), Stream.runCollect)),
|
||||||
).toMatchObject([
|
).toMatchObject([
|
||||||
{ durable: { seq: 1 }, type: "session.next.prompt.admitted", data: { prompt: { text: "Hello" } } },
|
{ durable: { seq: 1 }, type: "session.next.prompt.admitted", data: { prompt: { text: "Hello" } } },
|
||||||
{ durable: { seq: 2 }, type: "session.next.prompt.promoted" },
|
{ durable: { seq: 2 }, type: "session.next.prompted" },
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -308,8 +308,8 @@ describe("SessionV2.create", () => {
|
|||||||
.pipe(Effect.orDie)).map((event) => [event.seq, event.type]),
|
.pipe(Effect.orDie)).map((event) => [event.seq, event.type]),
|
||||||
).toEqual([
|
).toEqual([
|
||||||
[0, EventV2.versionedType(SessionV1.Event.Created.type, 1)],
|
[0, EventV2.versionedType(SessionV1.Event.Created.type, 1)],
|
||||||
[1, EventV2.versionedType(SessionEvent.PromptLifecycle.Admitted.type, 1)],
|
[1, EventV2.versionedType(SessionEvent.PromptAdmitted.type, 1)],
|
||||||
[2, EventV2.versionedType(SessionEvent.PromptLifecycle.Promoted.type, 1)],
|
[2, EventV2.versionedType(SessionEvent.Prompted.type, 1)],
|
||||||
])
|
])
|
||||||
}).pipe(Effect.provide(Layer.fresh(Layer.mergeAll(targetDatabase, targetEvents, targetProjector, targetStore))))
|
}).pipe(Effect.provide(Layer.fresh(Layer.mergeAll(targetDatabase, targetEvents, targetProjector, targetStore))))
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ describe("SessionProjector", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("marks an admitted lifecycle row promoted with the PromptPromoted event sequence", () =>
|
it.effect("marks an inbox row promoted with the Prompted event sequence", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const { db } = yield* Database.Service
|
const { db } = yield* Database.Service
|
||||||
yield* db
|
yield* db
|
||||||
@@ -142,19 +142,20 @@ describe("SessionProjector", () => {
|
|||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const id = SessionMessage.ID.make("msg_admitted")
|
const id = SessionMessage.ID.make("msg_admitted")
|
||||||
yield* SessionInput.admit(db, events, {
|
const admitted = yield* SessionInput.admit(db, events, {
|
||||||
id,
|
id,
|
||||||
sessionID,
|
sessionID,
|
||||||
prompt: new Prompt({ text: "promote me" }),
|
prompt: new Prompt({ text: "promote me" }),
|
||||||
delivery: "steer",
|
delivery: "steer",
|
||||||
})
|
})
|
||||||
|
if (!admitted) return yield* Effect.die("Prompt admission failed")
|
||||||
|
|
||||||
const event = yield* events.publish(SessionEvent.PromptLifecycle.Promoted, {
|
const event = yield* events.publish(SessionEvent.Prompted, {
|
||||||
sessionID,
|
sessionID,
|
||||||
timestamp: created,
|
timestamp: admitted.timeCreated,
|
||||||
messageID: id,
|
messageID: id,
|
||||||
prompt: new Prompt({ text: "promote me" }),
|
prompt: new Prompt({ text: "promote me" }),
|
||||||
timeCreated: created,
|
delivery: "steer",
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -179,8 +179,8 @@ describe("SessionV2.prompt", () => {
|
|||||||
expect(streamed.map((event) => [event.durable?.seq, event.type])).toEqual([
|
expect(streamed.map((event) => [event.durable?.seq, event.type])).toEqual([
|
||||||
[0, "session.next.prompt.admitted"],
|
[0, "session.next.prompt.admitted"],
|
||||||
[1, "session.next.prompt.admitted"],
|
[1, "session.next.prompt.admitted"],
|
||||||
[2, "session.next.prompt.promoted"],
|
[2, "session.next.prompted"],
|
||||||
[3, "session.next.prompt.promoted"],
|
[3, "session.next.prompted"],
|
||||||
])
|
])
|
||||||
expect(
|
expect(
|
||||||
Array.from(
|
Array.from(
|
||||||
@@ -334,7 +334,7 @@ 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)
|
expect(yield* eventCount(EventV2.versionedType(SessionEvent.PromptAdmitted.type, 1))).toBe(1)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -354,7 +354,7 @@ describe("SessionV2.prompt", () => {
|
|||||||
{ concurrency: "unbounded" },
|
{ concurrency: "unbounded" },
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(yield* eventCount(EventV2.versionedType(SessionEvent.PromptLifecycle.Promoted.type, 1))).toBe(1)
|
expect(yield* eventCount(EventV2.versionedType(SessionEvent.Prompted.type, 1))).toBe(1)
|
||||||
expect(yield* admitted(messageID)).toMatchObject({ promotedSeq: 1 })
|
expect(yield* admitted(messageID)).toMatchObject({ promotedSeq: 1 })
|
||||||
expect(yield* session.messages({ sessionID })).toMatchObject([
|
expect(yield* session.messages({ sessionID })).toMatchObject([
|
||||||
{ id: messageID, type: "user", text: "Promote once" },
|
{ id: messageID, type: "user", text: "Promote once" },
|
||||||
@@ -362,14 +362,14 @@ describe("SessionV2.prompt", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("promotes steers only through the captured aggregate cutoff", () =>
|
it.effect("promotes steers only through the captured inbox cutoff", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* setup
|
yield* setup
|
||||||
const { db } = yield* Database.Service
|
const { db } = yield* Database.Service
|
||||||
const session = yield* SessionV2.Service
|
const session = yield* SessionV2.Service
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const first = yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Before cutoff" }), resume: false })
|
const first = yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Before cutoff" }), resume: false })
|
||||||
const cutoff = yield* SessionInput.latestSeq(db, sessionID)
|
const cutoff = first.admittedSeq
|
||||||
const second = yield* session.prompt({ sessionID, prompt: new Prompt({ text: "After cutoff" }), resume: false })
|
const second = yield* session.prompt({ sessionID, prompt: new Prompt({ text: "After cutoff" }), resume: false })
|
||||||
|
|
||||||
yield* SessionInput.promoteSteers(db, events, sessionID, cutoff)
|
yield* SessionInput.promoteSteers(db, events, sessionID, cutoff)
|
||||||
@@ -379,7 +379,7 @@ describe("SessionV2.prompt", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("reprojects one pending lifecycle without scheduling execution", () =>
|
it.effect("reprojects pending inbox input without scheduling execution", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* setup
|
yield* setup
|
||||||
const { db } = yield* Database.Service
|
const { db } = yield* Database.Service
|
||||||
@@ -489,6 +489,27 @@ describe("SessionV2.prompt", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("rejects a prompt ID already used by visible Session history", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* setup
|
||||||
|
const session = yield* SessionV2.Service
|
||||||
|
const events = yield* EventV2.Service
|
||||||
|
yield* events.publish(SessionEvent.Synthetic, {
|
||||||
|
sessionID,
|
||||||
|
messageID,
|
||||||
|
timestamp: yield* DateTime.now,
|
||||||
|
text: "Existing history",
|
||||||
|
})
|
||||||
|
|
||||||
|
const failure = yield* session
|
||||||
|
.prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "Conflicting prompt" }), resume: false })
|
||||||
|
.pipe(Effect.flip)
|
||||||
|
|
||||||
|
expect(failure).toMatchObject({ _tag: "Session.PromptConflictError", sessionID, messageID })
|
||||||
|
expect(yield* admitted(messageID)).toBeUndefined()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("starts execution by default after recording the prompt", () =>
|
it.effect("starts execution by default after recording the prompt", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* setup
|
yield* setup
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ describe("SessionRunnerLLM recorded", () => {
|
|||||||
.all()).map((event) => event.type),
|
.all()).map((event) => event.type),
|
||||||
).toEqual([
|
).toEqual([
|
||||||
"session.next.prompt.admitted.1",
|
"session.next.prompt.admitted.1",
|
||||||
"session.next.prompt.promoted.1",
|
"session.next.prompted.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",
|
||||||
|
|||||||
@@ -2404,7 +2404,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.PromptLifecycle.Promoted, () => (fail ? Effect.die(defect) : Effect.void))
|
yield* events.project(SessionEvent.Prompted, () => (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)
|
||||||
@@ -2429,9 +2429,7 @@ describe("SessionRunnerLLM", () => {
|
|||||||
const session = yield* SessionV2.Service
|
const session = yield* SessionV2.Service
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
yield* events.listen((event) =>
|
yield* events.listen((event) =>
|
||||||
event.type === SessionEvent.PromptLifecycle.Promoted.type
|
event.type === SessionEvent.Prompted.type ? Effect.die("fail after prompt promotion commits") : Effect.void,
|
||||||
? Effect.die("fail after prompt promotion commits")
|
|
||||||
: Effect.void,
|
|
||||||
)
|
)
|
||||||
yield* session.prompt({
|
yield* session.prompt({
|
||||||
sessionID,
|
sessionID,
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ export type Event =
|
|||||||
| EventSessionNextMoved
|
| EventSessionNextMoved
|
||||||
| EventSessionNextPrompted
|
| EventSessionNextPrompted
|
||||||
| EventSessionNextPromptAdmitted
|
| EventSessionNextPromptAdmitted
|
||||||
| EventSessionNextPromptPromoted
|
|
||||||
| EventSessionNextContextUpdated
|
| EventSessionNextContextUpdated
|
||||||
| EventSessionNextSynthetic
|
| EventSessionNextSynthetic
|
||||||
| EventSessionNextShellStarted
|
| EventSessionNextShellStarted
|
||||||
@@ -864,17 +863,6 @@ export type GlobalEvent = {
|
|||||||
delivery: "steer" | "queue"
|
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.context.updated"
|
type: "session.next.context.updated"
|
||||||
@@ -1628,7 +1616,6 @@ export type GlobalEvent = {
|
|||||||
| SyncEventSessionNextMoved
|
| SyncEventSessionNextMoved
|
||||||
| SyncEventSessionNextPrompted
|
| SyncEventSessionNextPrompted
|
||||||
| SyncEventSessionNextPromptAdmitted
|
| SyncEventSessionNextPromptAdmitted
|
||||||
| SyncEventSessionNextPromptPromoted
|
|
||||||
| SyncEventSessionNextContextUpdated
|
| SyncEventSessionNextContextUpdated
|
||||||
| SyncEventSessionNextSynthetic
|
| SyncEventSessionNextSynthetic
|
||||||
| SyncEventSessionNextShellStarted
|
| SyncEventSessionNextShellStarted
|
||||||
@@ -2770,7 +2757,6 @@ export type V2Event =
|
|||||||
| V2EventSessionNextMoved
|
| V2EventSessionNextMoved
|
||||||
| V2EventSessionNextPrompted
|
| V2EventSessionNextPrompted
|
||||||
| V2EventSessionNextPromptAdmitted
|
| V2EventSessionNextPromptAdmitted
|
||||||
| V2EventSessionNextPromptPromoted
|
|
||||||
| V2EventSessionNextContextUpdated
|
| V2EventSessionNextContextUpdated
|
||||||
| V2EventSessionNextSynthetic
|
| V2EventSessionNextSynthetic
|
||||||
| V2EventSessionNextShellStarted
|
| V2EventSessionNextShellStarted
|
||||||
@@ -3220,24 +3206,6 @@ export type SyncEventSessionNextPromptAdmitted = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 SyncEventSessionNextContextUpdated = {
|
export type SyncEventSessionNextContextUpdated = {
|
||||||
type: "sync"
|
type: "sync"
|
||||||
id: string
|
id: string
|
||||||
@@ -4523,27 +4491,6 @@ export type V2EventSessionNextPromptAdmitted = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type V2EventSessionNextPromptPromoted = {
|
|
||||||
id: string
|
|
||||||
metadata?: {
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
durable?: {
|
|
||||||
aggregateID: string
|
|
||||||
seq: number
|
|
||||||
version: number
|
|
||||||
}
|
|
||||||
location?: LocationRef
|
|
||||||
type: "session.next.prompt.promoted"
|
|
||||||
data: {
|
|
||||||
timestamp: number
|
|
||||||
sessionID: string
|
|
||||||
messageID: string
|
|
||||||
prompt: Prompt
|
|
||||||
timeCreated: number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type V2EventSessionNextContextUpdated = {
|
export type V2EventSessionNextContextUpdated = {
|
||||||
id: string
|
id: string
|
||||||
metadata?: {
|
metadata?: {
|
||||||
@@ -6168,18 +6115,6 @@ export type EventSessionNextPromptAdmitted = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EventSessionNextPromptPromoted = {
|
|
||||||
id: string
|
|
||||||
type: "session.next.prompt.promoted"
|
|
||||||
properties: {
|
|
||||||
timestamp: number
|
|
||||||
sessionID: string
|
|
||||||
messageID: string
|
|
||||||
prompt: Prompt
|
|
||||||
timeCreated: number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type EventSessionNextContextUpdated = {
|
export type EventSessionNextContextUpdated = {
|
||||||
id: string
|
id: string
|
||||||
type: "session.next.context.updated"
|
type: "session.next.context.updated"
|
||||||
|
|||||||
@@ -14686,9 +14686,6 @@
|
|||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/EventSessionNextPromptAdmitted"
|
"$ref": "#/components/schemas/EventSessionNextPromptAdmitted"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/EventSessionNextPromptPromoted"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/EventSessionNextContextUpdated"
|
"$ref": "#/components/schemas/EventSessionNextContextUpdated"
|
||||||
},
|
},
|
||||||
@@ -17255,45 +17252,6 @@
|
|||||||
"required": ["id", "type", "properties"],
|
"required": ["id", "type", "properties"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"id": {
|
|
||||||
"type": "string",
|
|
||||||
"pattern": "^evt_"
|
|
||||||
},
|
|
||||||
"type": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": ["session.next.prompt.promoted"]
|
|
||||||
},
|
|
||||||
"properties": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"timestamp": {
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
"sessionID": {
|
|
||||||
"type": "string",
|
|
||||||
"pattern": "^ses"
|
|
||||||
},
|
|
||||||
"messageID": {
|
|
||||||
"type": "string",
|
|
||||||
"pattern": "^msg_"
|
|
||||||
},
|
|
||||||
"prompt": {
|
|
||||||
"$ref": "#/components/schemas/Prompt"
|
|
||||||
},
|
|
||||||
"timeCreated": {
|
|
||||||
"type": "number"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["timestamp", "sessionID", "messageID", "prompt", "timeCreated"],
|
|
||||||
"additionalProperties": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["id", "type", "properties"],
|
|
||||||
"additionalProperties": false
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -19832,9 +19790,6 @@
|
|||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/SyncEventSessionNextPromptAdmitted"
|
"$ref": "#/components/schemas/SyncEventSessionNextPromptAdmitted"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/SyncEventSessionNextPromptPromoted"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/SyncEventSessionNextContextUpdated"
|
"$ref": "#/components/schemas/SyncEventSessionNextContextUpdated"
|
||||||
},
|
},
|
||||||
@@ -23073,9 +23028,6 @@
|
|||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/V2EventSessionNextPromptAdmitted"
|
"$ref": "#/components/schemas/V2EventSessionNextPromptAdmitted"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/V2EventSessionNextPromptPromoted"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/V2EventSessionNextContextUpdated"
|
"$ref": "#/components/schemas/V2EventSessionNextContextUpdated"
|
||||||
},
|
},
|
||||||
@@ -24400,66 +24352,6 @@
|
|||||||
"required": ["type", "id", "syncEvent"],
|
"required": ["type", "id", "syncEvent"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"SyncEventSessionNextPromptPromoted": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"type": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": ["sync"]
|
|
||||||
},
|
|
||||||
"id": {
|
|
||||||
"type": "string",
|
|
||||||
"pattern": "^evt_"
|
|
||||||
},
|
|
||||||
"syncEvent": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"type": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": ["session.next.prompt.promoted.1"]
|
|
||||||
},
|
|
||||||
"id": {
|
|
||||||
"type": "string",
|
|
||||||
"pattern": "^evt_"
|
|
||||||
},
|
|
||||||
"seq": {
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
"aggregateID": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"data": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"timestamp": {
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
"sessionID": {
|
|
||||||
"type": "string",
|
|
||||||
"pattern": "^ses"
|
|
||||||
},
|
|
||||||
"messageID": {
|
|
||||||
"type": "string",
|
|
||||||
"pattern": "^msg_"
|
|
||||||
},
|
|
||||||
"prompt": {
|
|
||||||
"$ref": "#/components/schemas/Prompt"
|
|
||||||
},
|
|
||||||
"timeCreated": {
|
|
||||||
"type": "number"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["timestamp", "sessionID", "messageID", "prompt", "timeCreated"],
|
|
||||||
"additionalProperties": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["type", "id", "seq", "aggregateID", "data"],
|
|
||||||
"additionalProperties": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["type", "id", "syncEvent"],
|
|
||||||
"additionalProperties": false
|
|
||||||
},
|
|
||||||
"SyncEventSessionNextContextUpdated": {
|
"SyncEventSessionNextContextUpdated": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -28622,67 +28514,6 @@
|
|||||||
"required": ["id", "type", "data"],
|
"required": ["id", "type", "data"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"V2EventSessionNextPromptPromoted": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"id": {
|
|
||||||
"type": "string",
|
|
||||||
"pattern": "^evt_"
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"type": "object"
|
|
||||||
},
|
|
||||||
"durable": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"aggregateID": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"seq": {
|
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
"version": {
|
|
||||||
"type": "integer"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["aggregateID", "seq", "version"],
|
|
||||||
"additionalProperties": false
|
|
||||||
},
|
|
||||||
"location": {
|
|
||||||
"$ref": "#/components/schemas/LocationRef"
|
|
||||||
},
|
|
||||||
"type": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": ["session.next.prompt.promoted"]
|
|
||||||
},
|
|
||||||
"data": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"timestamp": {
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
"sessionID": {
|
|
||||||
"type": "string",
|
|
||||||
"pattern": "^ses"
|
|
||||||
},
|
|
||||||
"messageID": {
|
|
||||||
"type": "string",
|
|
||||||
"pattern": "^msg_"
|
|
||||||
},
|
|
||||||
"prompt": {
|
|
||||||
"$ref": "#/components/schemas/Prompt"
|
|
||||||
},
|
|
||||||
"timeCreated": {
|
|
||||||
"type": "number"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["timestamp", "sessionID", "messageID", "prompt", "timeCreated"],
|
|
||||||
"additionalProperties": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["id", "type", "data"],
|
|
||||||
"additionalProperties": false
|
|
||||||
},
|
|
||||||
"V2EventSessionNextContextUpdated": {
|
"V2EventSessionNextContextUpdated": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -33282,45 +33113,6 @@
|
|||||||
"required": ["id", "type", "properties"],
|
"required": ["id", "type", "properties"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"EventSessionNextPromptPromoted": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"id": {
|
|
||||||
"type": "string",
|
|
||||||
"pattern": "^evt_"
|
|
||||||
},
|
|
||||||
"type": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": ["session.next.prompt.promoted"]
|
|
||||||
},
|
|
||||||
"properties": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"timestamp": {
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
"sessionID": {
|
|
||||||
"type": "string",
|
|
||||||
"pattern": "^ses"
|
|
||||||
},
|
|
||||||
"messageID": {
|
|
||||||
"type": "string",
|
|
||||||
"pattern": "^msg_"
|
|
||||||
},
|
|
||||||
"prompt": {
|
|
||||||
"$ref": "#/components/schemas/Prompt"
|
|
||||||
},
|
|
||||||
"timeCreated": {
|
|
||||||
"type": "number"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["timestamp", "sessionID", "messageID", "prompt", "timeCreated"],
|
|
||||||
"additionalProperties": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["id", "type", "properties"],
|
|
||||||
"additionalProperties": false
|
|
||||||
},
|
|
||||||
"EventSessionNextContextUpdated": {
|
"EventSessionNextContextUpdated": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -164,18 +164,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
}
|
}
|
||||||
case "session.next.prompt.admitted":
|
case "session.next.prompt.admitted":
|
||||||
break
|
break
|
||||||
case "session.next.prompt.promoted":
|
|
||||||
message.update(event.data.sessionID, (draft) => {
|
|
||||||
message.prepend(draft, {
|
|
||||||
id: event.data.messageID,
|
|
||||||
type: "user",
|
|
||||||
text: event.data.prompt.text,
|
|
||||||
files: event.data.prompt.files,
|
|
||||||
agents: event.data.prompt.agents,
|
|
||||||
time: { created: event.data.timeCreated },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
break
|
|
||||||
case "session.next.context.updated":
|
case "session.next.context.updated":
|
||||||
message.update(event.data.sessionID, (draft) => {
|
message.update(event.data.sessionID, (draft) => {
|
||||||
message.prepend(draft, {
|
message.prepend(draft, {
|
||||||
|
|||||||
@@ -370,7 +370,7 @@ test("settles pending tools when a live failure arrives", async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("renders admitted prompts only after promotion", async () => {
|
test("renders admitted prompts only after they become model-visible", async () => {
|
||||||
const events = createEventSource()
|
const events = createEventSource()
|
||||||
const calls = createFetch(undefined, events)
|
const calls = createFetch(undefined, events)
|
||||||
let sync!: ReturnType<typeof useData>
|
let sync!: ReturnType<typeof useData>
|
||||||
@@ -413,14 +413,14 @@ test("renders admitted prompts only after promotion", async () => {
|
|||||||
expect(sync.session.message.list("session-1") ?? []).toEqual([])
|
expect(sync.session.message.list("session-1") ?? []).toEqual([])
|
||||||
|
|
||||||
emitEvent(events, {
|
emitEvent(events, {
|
||||||
id: "evt_promoted_1",
|
id: "evt_prompted_1",
|
||||||
type: "session.next.prompt.promoted",
|
type: "session.next.prompted",
|
||||||
properties: {
|
properties: {
|
||||||
sessionID: "session-1",
|
sessionID: "session-1",
|
||||||
messageID: "msg_user_1",
|
messageID: "msg_user_1",
|
||||||
timestamp: 1,
|
timestamp: 0,
|
||||||
prompt: { text: "hello" },
|
prompt: { text: "hello" },
|
||||||
timeCreated: 0,
|
delivery: "steer",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -434,54 +434,6 @@ test("renders admitted prompts only after promotion", async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("renders a promoted prompt when admission was missed", async () => {
|
|
||||||
const events = createEventSource()
|
|
||||||
const calls = createFetch(undefined, events)
|
|
||||||
let sync!: ReturnType<typeof useData>
|
|
||||||
let ready!: () => void
|
|
||||||
const mounted = new Promise<void>((resolve) => {
|
|
||||||
ready = resolve
|
|
||||||
})
|
|
||||||
|
|
||||||
function Probe() {
|
|
||||||
sync = useData()
|
|
||||||
onMount(ready)
|
|
||||||
return <box />
|
|
||||||
}
|
|
||||||
|
|
||||||
const app = await testRender(() => (
|
|
||||||
<TestTuiContexts>
|
|
||||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
|
||||||
<ProjectProvider>
|
|
||||||
<DataProvider>
|
|
||||||
<Probe />
|
|
||||||
</DataProvider>
|
|
||||||
</ProjectProvider>
|
|
||||||
</SDKProvider>
|
|
||||||
</TestTuiContexts>
|
|
||||||
))
|
|
||||||
|
|
||||||
try {
|
|
||||||
await mounted
|
|
||||||
emitEvent(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.list("session-1")?.length === 1)
|
|
||||||
expect(sync.session.message.list("session-1")?.[0]?.id).toBe("msg_user_1")
|
|
||||||
} finally {
|
|
||||||
app.renderer.destroy()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("projects live context updates with their message ID", async () => {
|
test("projects live context updates with their message ID", async () => {
|
||||||
const events = createEventSource()
|
const events = createEventSource()
|
||||||
const calls = createFetch(undefined, events)
|
const calls = createFetch(undefined, events)
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
# V2 Schema Changelog
|
# V2 Schema Changelog
|
||||||
|
|
||||||
|
## 2026-06-22: Simplify Session Input Promotion
|
||||||
|
|
||||||
|
- Keep `session.next.prompt.admitted.1` as the durable, client-visible record of pending Session input.
|
||||||
|
- Replace `session.next.prompt.promoted.1` with the existing `session.next.prompted.1` event when input becomes model-visible.
|
||||||
|
- Preserve the prompt endpoint, admission receipt, idempotency, steer/queue ordering, and atomic user-message projection.
|
||||||
|
- Reset experimental V2 events, projections, inputs, Context Epochs, and synchronized workspace state while preserving canonical V1 `session`, `message`, and `part` rows.
|
||||||
|
|
||||||
## 2026-06-22: Reset Unpublished Compaction Event
|
## 2026-06-22: Reset Unpublished Compaction Event
|
||||||
|
|
||||||
- Replace the unpublished `session.next.compaction.ended.1` payload with the current checkpoint payload and remove its legacy decoder.
|
- Replace the unpublished `session.next.compaction.ended.1` payload with the current checkpoint payload and remove its legacy decoder.
|
||||||
|
|||||||
+5
-3
@@ -12,8 +12,8 @@ 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 inserts one durable Session inbox row when absent
|
||||||
-> exact reuse returns the same admitted lifecycle receipt
|
-> exact reuse returns the same admission 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
|
||||||
@@ -27,7 +27,9 @@ sessions.interrupt(sessionID)
|
|||||||
-> idle or missing Session is a no-op
|
-> idle or missing Session is a no-op
|
||||||
```
|
```
|
||||||
|
|
||||||
`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.
|
`session_input` is the durable admission inbox. `PromptAdmitted` records and projects accepted input so pending queue state can be replayed, replicated, and observed by clients. Admitted inputs remain outside model-visible Session history until the serialized runner publishes `Prompted`. Its projector atomically writes the visible user message and marks the inbox row promoted in the same event transaction. The V1-to-V2 shadow bridge publishes the same `Prompted` event for already-visible V1 prompts.
|
||||||
|
|
||||||
|
`admittedSeq` is the durable Session event sequence of `PromptAdmitted`. Clients may use the admission event to represent queued input before `Prompted` makes it part of visible conversation history.
|
||||||
|
|
||||||
Execution routing starts from only the Session ID:
|
Execution routing starts from only the Session ID:
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user