refactor(core): make v2 session inputs event sourced (#30785)
This commit is contained in:
parent
057958c933
commit
76ecf2e58c
43 changed files with 4671 additions and 757 deletions
|
|
@ -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`);
|
||||||
1898
packages/core/migration/20260604172448_event_sourced_session_input/snapshot.json
generated
Normal file
1898
packages/core/migration/20260604172448_event_sourced_session_input/snapshot.json
generated
Normal file
File diff suppressed because it is too large
Load diff
1
packages/core/src/database/migration.gen.ts
generated
1
packages/core/src/database/migration.gen.ts
generated
|
|
@ -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[]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
13
packages/core/src/session/message-id.ts
Normal file
13
packages/core/src/session/message-id.ts
Normal 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
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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* () {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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))))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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" },
|
||||||
])
|
])
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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 []
|
||||||
|
|
|
||||||
|
|
@ -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}` }),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -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 } },
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue