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/20260603141458_session_input_inbox"),
|
||||
import("./migration/20260603160727_jittery_ezekiel_stane"),
|
||||
import("./migration/20260604172448_event_sourced_session_input"),
|
||||
])
|
||||
).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"
|
||||
|
||||
import { Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
|
||||
import { Cause, Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
|
||||
import { and, asc, eq, gt } from "drizzle-orm"
|
||||
import { Database } from "./database/database"
|
||||
import { EventSequenceTable, EventTable } from "./event/sql"
|
||||
import { Location } from "./location"
|
||||
import { externalID, type ExternalID, NonNegativeInt, withStatics } from "./schema"
|
||||
import { Identifier } from "./util/identifier"
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
|
||||
export const ID = Schema.String.pipe(
|
||||
export const ID = Schema.String.check(Schema.isStartsWith("evt_")).pipe(
|
||||
Schema.brand("Event.ID"),
|
||||
withStatics((schema) => ({
|
||||
create: () => schema.make("evt_" + Identifier.ascending()),
|
||||
|
|
@ -258,16 +259,47 @@ export const layerWith = (options?: LayerOptions) =>
|
|||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const latest = row?.seq ?? -1
|
||||
if (input && input.seq <= latest) return
|
||||
if (input && row?.ownerID && row.ownerID !== input.ownerID) {
|
||||
if (input.strictOwner) {
|
||||
yield* Effect.die(
|
||||
new InvalidSyncEventError({
|
||||
type: event.type,
|
||||
message: `Replay owner mismatch for aggregate ${aggregateID}: expected ${row.ownerID}, got ${input.ownerID ?? "none"}`,
|
||||
}),
|
||||
)
|
||||
const encoded = syncRegistry
|
||||
.get(versionedType(definition.type, sync.version))!
|
||||
.encode(event.data) as Record<string, unknown>
|
||||
if (input?.strictOwner && row?.ownerID && row.ownerID !== input.ownerID) {
|
||||
yield* Effect.die(
|
||||
new InvalidSyncEventError({
|
||||
type: event.type,
|
||||
message: `Replay owner mismatch for aggregate ${aggregateID}: expected ${row.ownerID}, got ${input.ownerID ?? "none"}`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (input && input.seq <= latest) {
|
||||
const stored = yield* db
|
||||
.select()
|
||||
.from(EventTable)
|
||||
.where(and(eq(EventTable.aggregate_id, aggregateID), eq(EventTable.seq, input.seq)))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (
|
||||
stored?.id === event.id &&
|
||||
stored.type === versionedType(definition.type, sync.version) &&
|
||||
isDeepStrictEqual(stored.data, encoded)
|
||||
) {
|
||||
if (input.ownerID && row?.ownerID == null) {
|
||||
yield* db
|
||||
.update(EventSequenceTable)
|
||||
.set({ owner_id: input.ownerID })
|
||||
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}
|
||||
return
|
||||
}
|
||||
yield* Effect.die(
|
||||
new InvalidSyncEventError({
|
||||
type: event.type,
|
||||
message: `Replay diverged at aggregate ${aggregateID} sequence ${input.seq}`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (input && row?.ownerID && row.ownerID !== input.ownerID) {
|
||||
return
|
||||
}
|
||||
const seq = input?.seq ?? latest + 1
|
||||
|
|
@ -279,15 +311,25 @@ export const layerWith = (options?: LayerOptions) =>
|
|||
}),
|
||||
)
|
||||
}
|
||||
const stored = yield* db
|
||||
.select({ aggregateID: EventTable.aggregate_id, seq: EventTable.seq })
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.id, event.id))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (stored)
|
||||
yield* Effect.die(
|
||||
new InvalidSyncEventError({
|
||||
type: event.type,
|
||||
message: `Event ${event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`,
|
||||
}),
|
||||
)
|
||||
for (const guard of commitGuards) {
|
||||
yield* guard(event)
|
||||
}
|
||||
for (const projector of list) {
|
||||
yield* projector({ ...event, seq } as Payload)
|
||||
}
|
||||
const encoded = syncRegistry
|
||||
.get(versionedType(definition.type, sync.version))!
|
||||
.encode(event.data)
|
||||
yield* db
|
||||
.insert(EventSequenceTable)
|
||||
.values([{ aggregate_id: aggregateID, seq, owner_id: input?.ownerID }])
|
||||
|
|
@ -308,7 +350,7 @@ export const layerWith = (options?: LayerOptions) =>
|
|||
aggregate_id: aggregateID,
|
||||
seq,
|
||||
type: versionedType(definition.type, sync.version),
|
||||
data: encoded as Record<string, unknown>,
|
||||
data: encoded,
|
||||
},
|
||||
])
|
||||
.run()
|
||||
|
|
@ -337,22 +379,43 @@ export const layerWith = (options?: LayerOptions) =>
|
|||
return Effect.gen(function* () {
|
||||
const durable = registry.get(event.type)?.sync !== undefined
|
||||
if (durable) {
|
||||
for (const sync of syncHandlers) {
|
||||
yield* sync(event as Payload)
|
||||
}
|
||||
const committed = yield* commitSyncEvent(event as Payload)
|
||||
if (committed) event = { ...event, seq: committed.seq }
|
||||
if (committed) {
|
||||
event = { ...event, seq: committed.seq }
|
||||
yield* Effect.forEach(syncHandlers, (sync) => observe(event as Payload, "sync", sync), { discard: true })
|
||||
yield* notify(event as Payload, true)
|
||||
return event
|
||||
}
|
||||
}
|
||||
for (const listener of listeners) {
|
||||
yield* listener(event as Payload)
|
||||
}
|
||||
const pubsub = typed.get(event.type)
|
||||
if (pubsub) yield* PubSub.publish(pubsub, event as Payload)
|
||||
yield* PubSub.publish(all, event as Payload)
|
||||
yield* notify(event as Payload, false)
|
||||
return event
|
||||
})
|
||||
}
|
||||
|
||||
const observe = (event: Payload, kind: "sync" | "listener", observer: (event: Payload) => Effect.Effect<void>) =>
|
||||
Effect.suspend(() => observer(event)).pipe(
|
||||
Effect.catchCauseIf(
|
||||
(cause) => !Cause.hasInterrupts(cause),
|
||||
(cause) =>
|
||||
Effect.logError("Event observer failed").pipe(
|
||||
Effect.annotateLogs({ eventID: event.id, eventType: event.type, kind, cause }),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
function notify(event: Payload, isolateListeners: boolean) {
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.forEach(
|
||||
listeners,
|
||||
(listener) => (isolateListeners ? observe(event, "listener", listener) : listener(event)),
|
||||
{ discard: true },
|
||||
)
|
||||
const pubsub = typed.get(event.type)
|
||||
if (pubsub) yield* PubSub.publish(pubsub, event)
|
||||
yield* PubSub.publish(all, event)
|
||||
})
|
||||
}
|
||||
|
||||
function publish<D extends Definition>(definition: D, data: Data<D>, options?: PublishOptions) {
|
||||
return Effect.gen(function* () {
|
||||
const serviceLocation = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service))
|
||||
|
|
@ -396,13 +459,7 @@ export const layerWith = (options?: LayerOptions) =>
|
|||
strictOwner: options?.strictOwner,
|
||||
})
|
||||
if (committed && options?.publish) {
|
||||
const published = { ...payload, seq: committed.seq }
|
||||
for (const listener of listeners) {
|
||||
yield* listener(published)
|
||||
}
|
||||
const pubsub = typed.get(payload.type)
|
||||
if (pubsub) yield* PubSub.publish(pubsub, published)
|
||||
yield* PubSub.publish(all, published)
|
||||
yield* notify({ ...payload, seq: committed.seq }, true)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core"
|
||||
import { sqliteTable, text, integer, index, uniqueIndex } from "drizzle-orm/sqlite-core"
|
||||
import type { EventV2 } from "../event"
|
||||
|
||||
export const EventSequenceTable = sqliteTable("event_sequence", {
|
||||
|
|
@ -19,7 +19,7 @@ export const EventTable = sqliteTable(
|
|||
data: text({ mode: "json" }).$type<Record<string, unknown>>().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("event_aggregate_seq_idx").on(table.aggregate_id, table.seq),
|
||||
uniqueIndex("event_aggregate_seq_idx").on(table.aggregate_id, table.seq),
|
||||
index("event_aggregate_type_seq_idx").on(table.aggregate_id, table.type, table.seq),
|
||||
],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
export * as SessionV2 from "./session"
|
||||
export * from "./session/schema"
|
||||
|
||||
import { Cause, DateTime, Effect, Layer, Schema, Context, Stream } from "effect"
|
||||
import { Cause, Effect, Layer, Schema, Context, Stream } from "effect"
|
||||
import { and, asc, desc, eq, gt, like, lt, or, type SQL } from "drizzle-orm"
|
||||
import { ProjectV2 } from "./project"
|
||||
import { WorkspaceV2 } from "./workspace"
|
||||
|
|
@ -10,7 +10,6 @@ import { Location } from "./location"
|
|||
import { SessionMessage } from "./session/message"
|
||||
import { Prompt } from "./session/prompt"
|
||||
import { EventV2 } from "./event"
|
||||
import { ProviderV2 } from "./provider"
|
||||
import { Database } from "./database/database"
|
||||
import { SessionProjector } from "./session/projector"
|
||||
import { SessionMessageTable, SessionTable } from "./session/sql"
|
||||
|
|
@ -140,7 +139,7 @@ export interface Interface {
|
|||
prompt: Prompt
|
||||
delivery?: SessionInput.Delivery
|
||||
resume?: boolean
|
||||
}) => Effect.Effect<SessionMessage.User, NotFoundError | PromptConflictError>
|
||||
}) => Effect.Effect<SessionInput.Admitted, NotFoundError | PromptConflictError>
|
||||
readonly shell: (input: {
|
||||
id?: EventV2.ID
|
||||
sessionID: SessionSchema.ID
|
||||
|
|
@ -198,20 +197,6 @@ export const layer = Layer.effect(
|
|||
),
|
||||
)
|
||||
|
||||
const findExistingPrompt = Effect.fnUntraced(function* (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
messageID: SessionMessage.ID
|
||||
prompt: Prompt
|
||||
delivery: SessionInput.Delivery
|
||||
}) {
|
||||
const stored = yield* SessionInput.find(db, input.messageID)
|
||||
if (!stored) return yield* SessionInput.reconcileProjected(db, { id: input.messageID, ...input })
|
||||
if (!SessionInput.equivalent(stored, input)) {
|
||||
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID: input.messageID })
|
||||
}
|
||||
return stored
|
||||
})
|
||||
|
||||
const result = Service.of({
|
||||
create: Effect.fn("V2Session.create")(function* (input) {
|
||||
const sessionID = input.id ?? SessionSchema.ID.create()
|
||||
|
|
@ -367,20 +352,23 @@ export const layer = Layer.effect(
|
|||
yield* result.get(input.sessionID)
|
||||
const returnPrompt = Effect.fnUntraced(function* (admitted: SessionInput.Admitted) {
|
||||
if (input.resume !== false) yield* enqueueWake(input.sessionID)
|
||||
return SessionInput.toMessage(admitted)
|
||||
return admitted
|
||||
}, Effect.uninterruptible)
|
||||
const messageID = input.id ?? SessionMessage.ID.create()
|
||||
const delivery = input.delivery ?? "steer"
|
||||
const expected = { sessionID: input.sessionID, messageID, prompt: input.prompt, delivery }
|
||||
const existing = yield* findExistingPrompt(expected)
|
||||
if (existing) return yield* returnPrompt(existing)
|
||||
const admitted = yield* SessionInput.admit(db, {
|
||||
const admitted = yield* SessionInput.admit(db, events, {
|
||||
id: messageID,
|
||||
sessionID: input.sessionID,
|
||||
prompt: input.prompt,
|
||||
delivery,
|
||||
})
|
||||
if (!admitted) return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
|
||||
}).pipe(
|
||||
Effect.catchDefect((defect) =>
|
||||
defect instanceof SessionInput.LifecycleConflict
|
||||
? new PromptConflictError({ sessionID: input.sessionID, messageID })
|
||||
: Effect.die(defect),
|
||||
),
|
||||
)
|
||||
if (!SessionInput.equivalent(admitted, expected))
|
||||
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
|
||||
return yield* returnPrompt(admitted)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { FileAttachment, Prompt } from "./prompt"
|
|||
import { SessionSchema } from "./schema"
|
||||
import { Location } from "../location"
|
||||
import { RelativePath } from "../schema"
|
||||
import { SessionMessageID } from "./message-id"
|
||||
|
||||
export { FileAttachment }
|
||||
|
||||
|
|
@ -52,6 +53,7 @@ export const AgentSwitched = EventV2.define({
|
|||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
messageID: SessionMessageID.ID,
|
||||
agent: Schema.String,
|
||||
},
|
||||
})
|
||||
|
|
@ -62,6 +64,7 @@ export const ModelSwitched = EventV2.define({
|
|||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
messageID: SessionMessageID.ID,
|
||||
model: ModelV2.Ref,
|
||||
},
|
||||
})
|
||||
|
|
@ -83,17 +86,45 @@ export const Prompted = EventV2.define({
|
|||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
messageID: SessionMessageID.ID,
|
||||
prompt: Prompt,
|
||||
delivery: Schema.Literals(["steer", "queue"]),
|
||||
},
|
||||
})
|
||||
export type Prompted = typeof Prompted.Type
|
||||
|
||||
export namespace PromptLifecycle {
|
||||
export const Admitted = EventV2.define({
|
||||
type: "session.next.prompt.admitted",
|
||||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
messageID: SessionMessageID.ID,
|
||||
prompt: Prompt,
|
||||
delivery: Schema.Literals(["steer", "queue"]),
|
||||
},
|
||||
})
|
||||
export type Admitted = typeof Admitted.Type
|
||||
|
||||
export const Promoted = EventV2.define({
|
||||
type: "session.next.prompt.promoted",
|
||||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
messageID: SessionMessageID.ID,
|
||||
prompt: Prompt,
|
||||
timeCreated: V2Schema.DateTimeUtcFromMillis,
|
||||
},
|
||||
})
|
||||
export type Promoted = typeof Promoted.Type
|
||||
}
|
||||
|
||||
export const Synthetic = EventV2.define({
|
||||
type: "session.next.synthetic",
|
||||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
messageID: SessionMessageID.ID,
|
||||
text: Schema.String,
|
||||
},
|
||||
})
|
||||
|
|
@ -105,6 +136,7 @@ export namespace Shell {
|
|||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
messageID: SessionMessageID.ID,
|
||||
callID: Schema.String,
|
||||
command: Schema.String,
|
||||
},
|
||||
|
|
@ -129,6 +161,7 @@ export namespace Step {
|
|||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
assistantMessageID: SessionMessageID.ID,
|
||||
agent: Schema.String,
|
||||
model: ModelV2.Ref,
|
||||
snapshot: Schema.String.pipe(Schema.optional),
|
||||
|
|
@ -141,7 +174,7 @@ export namespace Step {
|
|||
...stepSettlementOptions,
|
||||
schema: {
|
||||
...Base,
|
||||
assistantMessageID: EventV2.ID,
|
||||
assistantMessageID: SessionMessageID.ID,
|
||||
finish: Schema.String,
|
||||
cost: Schema.Finite,
|
||||
tokens: Schema.Struct({
|
||||
|
|
@ -163,7 +196,7 @@ export namespace Step {
|
|||
...stepSettlementOptions,
|
||||
schema: {
|
||||
...Base,
|
||||
assistantMessageID: EventV2.ID,
|
||||
assistantMessageID: SessionMessageID.ID,
|
||||
error: UnknownError,
|
||||
},
|
||||
})
|
||||
|
|
@ -176,6 +209,7 @@ export namespace Text {
|
|||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
assistantMessageID: SessionMessageID.ID,
|
||||
textID: Schema.String,
|
||||
},
|
||||
})
|
||||
|
|
@ -186,6 +220,7 @@ export namespace Text {
|
|||
type: "session.next.text.delta",
|
||||
schema: {
|
||||
...Base,
|
||||
assistantMessageID: SessionMessageID.ID,
|
||||
textID: Schema.String,
|
||||
delta: Schema.String,
|
||||
},
|
||||
|
|
@ -197,6 +232,7 @@ export namespace Text {
|
|||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
assistantMessageID: SessionMessageID.ID,
|
||||
textID: Schema.String,
|
||||
text: Schema.String,
|
||||
},
|
||||
|
|
@ -210,6 +246,7 @@ export namespace Reasoning {
|
|||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
assistantMessageID: SessionMessageID.ID,
|
||||
reasoningID: Schema.String,
|
||||
providerMetadata: ProviderMetadata.pipe(Schema.optional),
|
||||
},
|
||||
|
|
@ -221,6 +258,7 @@ export namespace Reasoning {
|
|||
type: "session.next.reasoning.delta",
|
||||
schema: {
|
||||
...Base,
|
||||
assistantMessageID: SessionMessageID.ID,
|
||||
reasoningID: Schema.String,
|
||||
delta: Schema.String,
|
||||
},
|
||||
|
|
@ -232,6 +270,7 @@ export namespace Reasoning {
|
|||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
assistantMessageID: SessionMessageID.ID,
|
||||
reasoningID: Schema.String,
|
||||
text: Schema.String,
|
||||
providerMetadata: ProviderMetadata.pipe(Schema.optional),
|
||||
|
|
@ -243,7 +282,7 @@ export namespace Reasoning {
|
|||
export namespace Tool {
|
||||
const ToolBase = {
|
||||
...Base,
|
||||
assistantMessageID: EventV2.ID,
|
||||
assistantMessageID: SessionMessageID.ID,
|
||||
callID: Schema.String,
|
||||
}
|
||||
|
||||
|
|
@ -370,6 +409,7 @@ export namespace Compaction {
|
|||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
messageID: SessionMessageID.ID,
|
||||
reason: Schema.Union([Schema.Literal("auto"), Schema.Literal("manual")]),
|
||||
},
|
||||
})
|
||||
|
|
@ -402,6 +442,8 @@ const DurableDefinitions = [
|
|||
ModelSwitched,
|
||||
Moved,
|
||||
Prompted,
|
||||
PromptLifecycle.Admitted,
|
||||
PromptLifecycle.Promoted,
|
||||
Synthetic,
|
||||
Shell.Started,
|
||||
Shell.Ended,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
export * as SessionInput from "./input"
|
||||
|
||||
import { and, asc, eq, inArray, isNull } from "drizzle-orm"
|
||||
import { and, asc, eq, isNull, lte } from "drizzle-orm"
|
||||
import { DateTime, Effect, Schema } from "effect"
|
||||
import type { Database } from "../database/database"
|
||||
import type { EventV2 } from "../event"
|
||||
import { EventTable } from "../event/sql"
|
||||
import { NonNegativeInt, PositiveInt } from "../schema"
|
||||
import { EventSequenceTable } from "../event/sql"
|
||||
import { NonNegativeInt } from "../schema"
|
||||
import { V2Schema } from "../v2-schema"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionMessage } from "./message"
|
||||
|
|
@ -19,7 +19,7 @@ export const Delivery = Schema.Literals(["steer", "queue"])
|
|||
export type Delivery = typeof Delivery.Type
|
||||
|
||||
export class Admitted extends Schema.Class<Admitted>("SessionInput.Admitted")({
|
||||
seq: PositiveInt,
|
||||
admittedSeq: NonNegativeInt,
|
||||
id: SessionMessage.ID,
|
||||
sessionID: SessionSchema.ID,
|
||||
prompt: Prompt,
|
||||
|
|
@ -30,11 +30,10 @@ export class Admitted extends Schema.Class<Admitted>("SessionInput.Admitted")({
|
|||
|
||||
const decodePrompt = Schema.decodeUnknownSync(Prompt)
|
||||
const encodePrompt = Schema.encodeSync(Prompt)
|
||||
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message)
|
||||
|
||||
const fromRow = (row: typeof SessionInputTable.$inferSelect): Admitted =>
|
||||
new Admitted({
|
||||
seq: row.seq,
|
||||
admittedSeq: row.admitted_seq,
|
||||
id: SessionMessage.ID.make(row.id),
|
||||
sessionID: SessionSchema.ID.make(row.session_id),
|
||||
prompt: decodePrompt(row.prompt),
|
||||
|
|
@ -48,8 +47,13 @@ export const find = Effect.fn("SessionInput.find")(function* (db: DatabaseServic
|
|||
return row === undefined ? undefined : fromRow(row)
|
||||
})
|
||||
|
||||
export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()("SessionInput.LifecycleConflict", {
|
||||
id: SessionMessage.ID,
|
||||
}) {}
|
||||
|
||||
export const admit = Effect.fn("SessionInput.admit")(function* (
|
||||
db: DatabaseService,
|
||||
events: EventV2.Interface,
|
||||
input: {
|
||||
readonly id: SessionMessage.ID
|
||||
readonly sessionID: SessionSchema.ID
|
||||
|
|
@ -57,49 +61,124 @@ export const admit = Effect.fn("SessionInput.admit")(function* (
|
|||
readonly delivery: Delivery
|
||||
},
|
||||
) {
|
||||
return yield* db
|
||||
.transaction(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const existing = yield* find(db, input.id)
|
||||
if (existing !== undefined) return existing
|
||||
const event = yield* db
|
||||
.select({ id: EventTable.id })
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.id, input.id))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const message = yield* db
|
||||
.select({ id: SessionMessageTable.id })
|
||||
.from(SessionMessageTable)
|
||||
.where(eq(SessionMessageTable.id, input.id))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (event !== undefined || message !== undefined) return undefined
|
||||
const row = yield* db
|
||||
.insert(SessionInputTable)
|
||||
.values({
|
||||
id: input.id,
|
||||
session_id: input.sessionID,
|
||||
prompt: encodePrompt(input.prompt),
|
||||
delivery: input.delivery,
|
||||
})
|
||||
.returning()
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
return fromRow(row)
|
||||
}),
|
||||
{ behavior: "immediate" },
|
||||
const existing = yield* find(db, input.id)
|
||||
if (existing !== undefined) return existing
|
||||
const timestamp = yield* DateTime.now
|
||||
return yield* events
|
||||
.publish(SessionEvent.PromptLifecycle.Admitted, {
|
||||
messageID: input.id,
|
||||
sessionID: input.sessionID,
|
||||
timestamp,
|
||||
prompt: input.prompt,
|
||||
delivery: input.delivery,
|
||||
})
|
||||
.pipe(
|
||||
Effect.flatMap((event) =>
|
||||
event.seq === undefined
|
||||
? Effect.die("Prompt admission event is missing aggregate sequence")
|
||||
: Effect.succeed(
|
||||
new Admitted({
|
||||
admittedSeq: event.seq,
|
||||
id: input.id,
|
||||
sessionID: input.sessionID,
|
||||
prompt: input.prompt,
|
||||
delivery: input.delivery,
|
||||
timeCreated: timestamp,
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.catchDefect((defect) =>
|
||||
find(db, input.id).pipe(Effect.flatMap((stored) => (stored ? Effect.succeed(stored) : Effect.die(defect)))),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
export const latestSeq = Effect.fn("SessionInput.latestSeq")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
const row = yield* db
|
||||
.select({ seq: EventSequenceTable.seq })
|
||||
.from(EventSequenceTable)
|
||||
.where(eq(EventSequenceTable.aggregate_id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
return row?.seq ?? -1
|
||||
})
|
||||
|
||||
export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(function* (
|
||||
db: DatabaseService,
|
||||
input: {
|
||||
readonly admittedSeq: number
|
||||
readonly id: SessionMessage.ID
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly prompt: Prompt
|
||||
readonly delivery: Delivery
|
||||
readonly timeCreated: DateTime.Utc
|
||||
},
|
||||
) {
|
||||
const message = yield* db
|
||||
.select({ id: SessionMessageTable.id })
|
||||
.from(SessionMessageTable)
|
||||
.where(eq(SessionMessageTable.id, input.id))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (message) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||
const stored = yield* db
|
||||
.insert(SessionInputTable)
|
||||
.values({
|
||||
id: input.id,
|
||||
session_id: input.sessionID,
|
||||
admitted_seq: input.admittedSeq,
|
||||
prompt: encodePrompt(input.prompt),
|
||||
delivery: input.delivery,
|
||||
time_created: DateTime.toEpochMillis(input.timeCreated),
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning({ id: SessionInputTable.id })
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!stored) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||
})
|
||||
|
||||
export const projectPromoted = Effect.fn("SessionInput.projectPromoted")(function* (
|
||||
db: DatabaseService,
|
||||
input: {
|
||||
readonly id: SessionMessage.ID
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly prompt: Prompt
|
||||
readonly timeCreated: DateTime.Utc
|
||||
readonly promotedSeq: number
|
||||
},
|
||||
) {
|
||||
const updated = yield* db
|
||||
.update(SessionInputTable)
|
||||
.set({ promoted_seq: input.promotedSeq })
|
||||
.where(
|
||||
and(
|
||||
eq(SessionInputTable.id, input.id),
|
||||
eq(SessionInputTable.session_id, input.sessionID),
|
||||
isNull(SessionInputTable.promoted_seq),
|
||||
),
|
||||
)
|
||||
.returning()
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!updated) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||
const stored = fromRow(updated)
|
||||
if (
|
||||
!matchesPrompt(stored, input) ||
|
||||
DateTime.toEpochMillis(stored.timeCreated) !== DateTime.toEpochMillis(input.timeCreated)
|
||||
)
|
||||
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||
return toMessage(stored)
|
||||
})
|
||||
|
||||
export const hasPending = Effect.fn("SessionInput.hasPending")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
deliveries: ReadonlyArray<Delivery> = ["steer", "queue"],
|
||||
delivery: Delivery,
|
||||
) {
|
||||
if (deliveries.length === 0) return false
|
||||
const row = yield* db
|
||||
.select({ id: SessionInputTable.id })
|
||||
.from(SessionInputTable)
|
||||
|
|
@ -107,7 +186,7 @@ export const hasPending = Effect.fn("SessionInput.hasPending")(function* (
|
|||
and(
|
||||
eq(SessionInputTable.session_id, sessionID),
|
||||
isNull(SessionInputTable.promoted_seq),
|
||||
inArray(SessionInputTable.delivery, deliveries),
|
||||
eq(SessionInputTable.delivery, delivery),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
|
@ -133,14 +212,34 @@ export const guardReservedID = Effect.fn("SessionInput.guardReservedID")(functio
|
|||
db: DatabaseService,
|
||||
event: EventV2.Payload,
|
||||
) {
|
||||
const admitted = yield* find(db, event.id)
|
||||
if (
|
||||
Schema.is(SessionEvent.PromptLifecycle.Admitted)(event) ||
|
||||
Schema.is(SessionEvent.PromptLifecycle.Promoted)(event)
|
||||
)
|
||||
return
|
||||
const id = reservedID(event)
|
||||
if (id === undefined) return
|
||||
const admitted = yield* db
|
||||
.select({ id: SessionInputTable.id })
|
||||
.from(SessionInputTable)
|
||||
.where(eq(SessionInputTable.id, id))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (admitted === undefined) return
|
||||
if (!Schema.is(SessionEvent.Prompted)(event))
|
||||
return yield* Effect.die("Durable event conflicts with admitted prompt input")
|
||||
if (!equivalent(admitted, event.data)) return yield* Effect.die("Prompt projection conflicts with admitted input")
|
||||
return yield* Effect.die(new LifecycleConflict({ id }))
|
||||
})
|
||||
|
||||
export const project = Effect.fn("SessionInput.project")(function* (
|
||||
const reservedID = (event: EventV2.Payload) => {
|
||||
if (Schema.is(SessionEvent.Step.Started)(event)) return event.data.assistantMessageID
|
||||
if (Schema.is(SessionEvent.AgentSwitched)(event)) return event.data.messageID
|
||||
if (Schema.is(SessionEvent.ModelSwitched)(event)) return event.data.messageID
|
||||
if (Schema.is(SessionEvent.Prompted)(event)) return event.data.messageID
|
||||
if (Schema.is(SessionEvent.Synthetic)(event)) return event.data.messageID
|
||||
if (Schema.is(SessionEvent.Shell.Started)(event)) return event.data.messageID
|
||||
if (Schema.is(SessionEvent.Compaction.Started)(event)) return event.data.messageID
|
||||
}
|
||||
|
||||
export const projectLegacyPrompted = Effect.fn("SessionInput.projectLegacyPrompted")(function* (
|
||||
db: DatabaseService,
|
||||
input: {
|
||||
readonly id: SessionMessage.ID
|
||||
|
|
@ -151,82 +250,49 @@ export const project = Effect.fn("SessionInput.project")(function* (
|
|||
readonly promotedSeq: number
|
||||
},
|
||||
) {
|
||||
yield* db
|
||||
const inserted = yield* db
|
||||
.insert(SessionInputTable)
|
||||
.values({
|
||||
id: input.id,
|
||||
session_id: input.sessionID,
|
||||
admitted_seq: input.promotedSeq,
|
||||
prompt: encodePrompt(input.prompt),
|
||||
delivery: input.delivery,
|
||||
promoted_seq: input.promotedSeq,
|
||||
time_created: DateTime.toEpochMillis(input.timeCreated),
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const admitted = yield* find(db, input.id)
|
||||
if (admitted === undefined || admitted.delivery !== input.delivery || !matchesPrompt(admitted, input))
|
||||
return yield* Effect.die("Prompt projection conflicts with admitted input")
|
||||
yield* db
|
||||
.update(SessionInputTable)
|
||||
.set({ promoted_seq: input.promotedSeq })
|
||||
.where(
|
||||
and(
|
||||
eq(SessionInputTable.id, input.id),
|
||||
eq(SessionInputTable.session_id, input.sessionID),
|
||||
isNull(SessionInputTable.promoted_seq),
|
||||
),
|
||||
)
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
return yield* find(db, input.id)
|
||||
})
|
||||
|
||||
export const reconcileProjected = Effect.fn("SessionInput.reconcileProjected")(function* (
|
||||
db: DatabaseService,
|
||||
expected: {
|
||||
readonly id: SessionMessage.ID
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly prompt: Prompt
|
||||
readonly delivery: Delivery
|
||||
},
|
||||
) {
|
||||
if (expected.delivery !== "steer") return undefined
|
||||
const row = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(eq(SessionMessageTable.id, expected.id))
|
||||
.returning()
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (row === undefined || row.session_id !== expected.sessionID || row.type !== "user") return undefined
|
||||
const message = decodeMessage({ ...row.data, id: row.id, type: row.type })
|
||||
if (message.type !== "user" || !Prompt.equivalence(Prompt.fromUserMessage(message), expected.prompt)) return undefined
|
||||
return yield* project(db, {
|
||||
id: expected.id,
|
||||
sessionID: expected.sessionID,
|
||||
prompt: expected.prompt,
|
||||
delivery: expected.delivery,
|
||||
timeCreated: message.time.created,
|
||||
promotedSeq: row.seq,
|
||||
})
|
||||
if (!inserted) return yield* Effect.die("Prompt projection conflicts with admitted input")
|
||||
return fromRow(inserted)
|
||||
})
|
||||
|
||||
const publish = Effect.fn("SessionInput.publish")(function* (
|
||||
db: DatabaseService,
|
||||
events: EventV2.Interface,
|
||||
sessionID: SessionSchema.ID,
|
||||
rows: ReadonlyArray<typeof SessionInputTable.$inferSelect>,
|
||||
) {
|
||||
for (const row of rows) {
|
||||
yield* events.publish(
|
||||
SessionEvent.Prompted,
|
||||
{
|
||||
yield* events
|
||||
.publish(SessionEvent.PromptLifecycle.Promoted, {
|
||||
sessionID,
|
||||
timestamp: DateTime.makeUnsafe(row.time_created),
|
||||
timestamp: yield* DateTime.now,
|
||||
messageID: SessionMessage.ID.make(row.id),
|
||||
prompt: decodePrompt(row.prompt),
|
||||
delivery: row.delivery,
|
||||
},
|
||||
{ id: SessionMessage.ID.make(row.id) },
|
||||
)
|
||||
timeCreated: DateTime.makeUnsafe(row.time_created),
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchDefect((defect) =>
|
||||
defect instanceof LifecycleConflict
|
||||
? find(db, SessionMessage.ID.make(row.id)).pipe(
|
||||
Effect.flatMap((stored) => (stored?.promotedSeq === undefined ? Effect.die(defect) : Effect.void)),
|
||||
)
|
||||
: Effect.die(defect),
|
||||
),
|
||||
)
|
||||
}
|
||||
return rows.length
|
||||
})
|
||||
|
|
@ -235,6 +301,7 @@ export const promoteSteers = Effect.fn("SessionInput.promoteSteers")(function* (
|
|||
db: DatabaseService,
|
||||
events: EventV2.Interface,
|
||||
sessionID: SessionSchema.ID,
|
||||
cutoff: number,
|
||||
) {
|
||||
const rows = yield* db
|
||||
.select()
|
||||
|
|
@ -244,12 +311,13 @@ export const promoteSteers = Effect.fn("SessionInput.promoteSteers")(function* (
|
|||
eq(SessionInputTable.session_id, sessionID),
|
||||
isNull(SessionInputTable.promoted_seq),
|
||||
eq(SessionInputTable.delivery, "steer"),
|
||||
lte(SessionInputTable.admitted_seq, cutoff),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(SessionInputTable.seq))
|
||||
.orderBy(asc(SessionInputTable.admitted_seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
return yield* publish(events, sessionID, rows)
|
||||
return yield* publish(db, events, sessionID, rows)
|
||||
})
|
||||
|
||||
export const promoteNextQueued = Effect.fn("SessionInput.promoteNextQueued")(function* (
|
||||
|
|
@ -267,14 +335,14 @@ export const promoteNextQueued = Effect.fn("SessionInput.promoteNextQueued")(fun
|
|||
eq(SessionInputTable.delivery, "queue"),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(SessionInputTable.seq))
|
||||
.orderBy(asc(SessionInputTable.admitted_seq))
|
||||
.limit(1)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
return row === undefined ? false : yield* publish(events, sessionID, [row]).pipe(Effect.as(true))
|
||||
return row === undefined ? false : yield* publish(db, events, sessionID, [row]).pipe(Effect.as(true))
|
||||
})
|
||||
|
||||
export const toMessage = (input: Admitted) =>
|
||||
const toMessage = (input: Admitted) =>
|
||||
new SessionMessage.User({
|
||||
id: input.id,
|
||||
type: "user",
|
||||
|
|
|
|||
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) => {
|
||||
return adapter.appendMessage(
|
||||
new SessionMessage.AgentSwitched({
|
||||
id: event.id,
|
||||
id: event.data.messageID,
|
||||
type: "agent-switched",
|
||||
metadata: event.metadata,
|
||||
agent: event.data.agent,
|
||||
|
|
@ -134,7 +134,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||
"session.next.model.switched": (event) => {
|
||||
return adapter.appendMessage(
|
||||
new SessionMessage.ModelSwitched({
|
||||
id: event.id,
|
||||
id: event.data.messageID,
|
||||
type: "model-switched",
|
||||
metadata: event.metadata,
|
||||
model: event.data.model,
|
||||
|
|
@ -146,7 +146,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||
"session.next.prompted": (event) => {
|
||||
return adapter.appendMessage(
|
||||
new SessionMessage.User({
|
||||
id: event.id,
|
||||
id: event.data.messageID,
|
||||
type: "user",
|
||||
metadata: event.metadata,
|
||||
text: event.data.prompt.text,
|
||||
|
|
@ -157,12 +157,14 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||
}),
|
||||
)
|
||||
},
|
||||
"session.next.prompt.admitted": () => Effect.void,
|
||||
"session.next.prompt.promoted": () => Effect.void,
|
||||
"session.next.synthetic": (event) => {
|
||||
return adapter.appendMessage(
|
||||
new SessionMessage.Synthetic({
|
||||
sessionID: event.data.sessionID,
|
||||
text: event.data.text,
|
||||
id: event.id,
|
||||
id: event.data.messageID,
|
||||
type: "synthetic",
|
||||
time: { created: event.data.timestamp },
|
||||
}),
|
||||
|
|
@ -171,7 +173,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||
"session.next.shell.started": (event) => {
|
||||
return adapter.appendMessage(
|
||||
new SessionMessage.Shell({
|
||||
id: event.id,
|
||||
id: event.data.messageID,
|
||||
type: "shell",
|
||||
metadata: event.metadata,
|
||||
callID: event.data.callID,
|
||||
|
|
@ -206,7 +208,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||
}
|
||||
yield* adapter.appendMessage(
|
||||
new SessionMessage.Assistant({
|
||||
id: event.id,
|
||||
id: event.data.assistantMessageID,
|
||||
type: "assistant",
|
||||
agent: event.data.agent,
|
||||
model: event.data.model,
|
||||
|
|
@ -234,43 +236,22 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||
})
|
||||
},
|
||||
"session.next.text.started": (event) => {
|
||||
return Effect.gen(function* () {
|
||||
const currentAssistant = yield* adapter.getCurrentAssistant()
|
||||
if (currentAssistant) {
|
||||
yield* adapter.updateAssistant(
|
||||
produce(currentAssistant, (draft) => {
|
||||
draft.content.push(
|
||||
castDraft(new SessionMessage.AssistantText({ type: "text", id: event.data.textID, text: "" })),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
draft.content.push(
|
||||
castDraft(new SessionMessage.AssistantText({ type: "text", id: event.data.textID, text: "" })),
|
||||
)
|
||||
})
|
||||
},
|
||||
"session.next.text.delta": (event) => {
|
||||
return Effect.gen(function* () {
|
||||
const currentAssistant = yield* adapter.getCurrentAssistant()
|
||||
if (currentAssistant) {
|
||||
yield* adapter.updateAssistant(
|
||||
produce(currentAssistant, (draft) => {
|
||||
const match = latestText(draft, event.data.textID)
|
||||
if (match) match.text += event.data.delta
|
||||
}),
|
||||
)
|
||||
}
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
const match = latestText(draft, event.data.textID)
|
||||
if (match) match.text += event.data.delta
|
||||
})
|
||||
},
|
||||
"session.next.text.ended": (event) => {
|
||||
return Effect.gen(function* () {
|
||||
const currentAssistant = yield* adapter.getCurrentAssistant()
|
||||
if (currentAssistant) {
|
||||
yield* adapter.updateAssistant(
|
||||
produce(currentAssistant, (draft) => {
|
||||
const match = latestText(draft, event.data.textID)
|
||||
if (match) match.text = event.data.text
|
||||
}),
|
||||
)
|
||||
}
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
const match = latestText(draft, event.data.textID)
|
||||
if (match) match.text = event.data.text
|
||||
})
|
||||
},
|
||||
"session.next.tool.input.started": (event) => {
|
||||
|
|
@ -367,52 +348,31 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||
})
|
||||
},
|
||||
"session.next.reasoning.started": (event) => {
|
||||
return Effect.gen(function* () {
|
||||
const currentAssistant = yield* adapter.getCurrentAssistant()
|
||||
if (currentAssistant) {
|
||||
yield* adapter.updateAssistant(
|
||||
produce(currentAssistant, (draft) => {
|
||||
draft.content.push(
|
||||
castDraft(
|
||||
new SessionMessage.AssistantReasoning({
|
||||
type: "reasoning",
|
||||
id: event.data.reasoningID,
|
||||
text: "",
|
||||
providerMetadata: event.data.providerMetadata,
|
||||
}),
|
||||
),
|
||||
)
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
draft.content.push(
|
||||
castDraft(
|
||||
new SessionMessage.AssistantReasoning({
|
||||
type: "reasoning",
|
||||
id: event.data.reasoningID,
|
||||
text: "",
|
||||
providerMetadata: event.data.providerMetadata,
|
||||
}),
|
||||
)
|
||||
}
|
||||
),
|
||||
)
|
||||
})
|
||||
},
|
||||
"session.next.reasoning.delta": (event) => {
|
||||
return Effect.gen(function* () {
|
||||
const currentAssistant = yield* adapter.getCurrentAssistant()
|
||||
if (currentAssistant) {
|
||||
yield* adapter.updateAssistant(
|
||||
produce(currentAssistant, (draft) => {
|
||||
const match = latestReasoning(draft, event.data.reasoningID)
|
||||
if (match) match.text += event.data.delta
|
||||
}),
|
||||
)
|
||||
}
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
const match = latestReasoning(draft, event.data.reasoningID)
|
||||
if (match) match.text += event.data.delta
|
||||
})
|
||||
},
|
||||
"session.next.reasoning.ended": (event) => {
|
||||
return Effect.gen(function* () {
|
||||
const currentAssistant = yield* adapter.getCurrentAssistant()
|
||||
if (currentAssistant) {
|
||||
yield* adapter.updateAssistant(
|
||||
produce(currentAssistant, (draft) => {
|
||||
const match = latestReasoning(draft, event.data.reasoningID)
|
||||
if (match) {
|
||||
match.text = event.data.text
|
||||
if (event.data.providerMetadata !== undefined) match.providerMetadata = event.data.providerMetadata
|
||||
}
|
||||
}),
|
||||
)
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
const match = latestReasoning(draft, event.data.reasoningID)
|
||||
if (match) {
|
||||
match.text = event.data.text
|
||||
if (event.data.providerMetadata !== undefined) match.providerMetadata = event.data.providerMetadata
|
||||
}
|
||||
})
|
||||
},
|
||||
|
|
@ -420,7 +380,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||
"session.next.compaction.started": (event) => {
|
||||
return adapter.appendMessage(
|
||||
new SessionMessage.Compaction({
|
||||
id: event.id,
|
||||
id: event.data.messageID,
|
||||
type: "compaction",
|
||||
metadata: event.metadata,
|
||||
reason: event.data.reason,
|
||||
|
|
|
|||
|
|
@ -2,15 +2,15 @@ export * as SessionMessage from "./message"
|
|||
|
||||
import { Schema } from "effect"
|
||||
import { ProviderMetadata } from "@opencode-ai/llm"
|
||||
import { EventV2 } from "../event"
|
||||
import { ModelV2 } from "../model"
|
||||
import { ToolOutput } from "../tool-output"
|
||||
import { V2Schema } from "../v2-schema"
|
||||
import { SessionEvent } from "./event"
|
||||
import { Prompt } from "./prompt"
|
||||
import { SessionMessageID } from "./message-id"
|
||||
|
||||
export const ID = EventV2.ID
|
||||
export type ID = Schema.Schema.Type<typeof ID>
|
||||
export const ID = SessionMessageID.ID
|
||||
export type ID = typeof ID.Type
|
||||
|
||||
const Base = {
|
||||
id: ID,
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ type DatabaseService = Database.Interface["db"]
|
|||
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message)
|
||||
const encodeMessage = Schema.encodeSync(SessionMessage.Message)
|
||||
|
||||
export class PromptAlreadyProjected extends Error {}
|
||||
class PromptAlreadyProjected extends Error {}
|
||||
export class SessionAlreadyProjected extends Error {}
|
||||
|
||||
type Usage = {
|
||||
|
|
@ -112,29 +112,23 @@ function run(db: DatabaseService, event: SessionEvent.Event) {
|
|||
return Effect.gen(function* () {
|
||||
const decodeRow = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||
decodeMessage({ ...row.data, id: row.id, type: row.type })
|
||||
const writeMessage = (message: SessionMessage.Message) => {
|
||||
const updateMessage = (message: SessionMessage.Message) => {
|
||||
if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence")
|
||||
const encoded = encodeMessage(message)
|
||||
const { id, type, ...data } = encoded
|
||||
return db
|
||||
.insert(SessionMessageTable)
|
||||
.values([
|
||||
{
|
||||
id: SessionMessage.ID.make(id),
|
||||
session_id: event.data.sessionID,
|
||||
type,
|
||||
seq: event.seq,
|
||||
time_created: DateTime.toEpochMillis(message.time.created),
|
||||
data,
|
||||
},
|
||||
])
|
||||
.onConflictDoUpdate({
|
||||
target: SessionMessageTable.id,
|
||||
set: { type, time_created: DateTime.toEpochMillis(message.time.created), data },
|
||||
})
|
||||
.update(SessionMessageTable)
|
||||
.set({ type, time_created: DateTime.toEpochMillis(message.time.created), data })
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.id, SessionMessage.ID.make(id)),
|
||||
eq(SessionMessageTable.session_id, event.data.sessionID),
|
||||
),
|
||||
)
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}
|
||||
const appendMessage = (message: SessionMessage.Message) => insertMessage(db, event, message)
|
||||
const adapter: SessionMessageUpdater.Adapter = {
|
||||
getCurrentAssistant() {
|
||||
return Effect.gen(function* () {
|
||||
|
|
@ -204,15 +198,33 @@ function run(db: DatabaseService, event: SessionEvent.Event) {
|
|||
.find((message): message is SessionMessage.Shell => message.type === "shell" && message.callID === callID)
|
||||
})
|
||||
},
|
||||
updateAssistant: writeMessage,
|
||||
updateCompaction: writeMessage,
|
||||
updateShell: writeMessage,
|
||||
appendMessage: writeMessage,
|
||||
updateAssistant: updateMessage,
|
||||
updateCompaction: updateMessage,
|
||||
updateShell: updateMessage,
|
||||
appendMessage,
|
||||
}
|
||||
yield* SessionMessageUpdater.update(adapter, event)
|
||||
})
|
||||
}
|
||||
|
||||
function insertMessage(db: DatabaseService, event: SessionEvent.Event, message: SessionMessage.Message) {
|
||||
if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence")
|
||||
const encoded = encodeMessage(message)
|
||||
const { id, type, ...data } = encoded
|
||||
return db
|
||||
.insert(SessionMessageTable)
|
||||
.values({
|
||||
id: SessionMessage.ID.make(id),
|
||||
session_id: event.data.sessionID,
|
||||
type,
|
||||
seq: event.seq,
|
||||
time_created: DateTime.toEpochMillis(message.time.created),
|
||||
data,
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}
|
||||
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
|
|
@ -349,27 +361,19 @@ export const layer = Layer.effectDiscard(
|
|||
)
|
||||
yield* events.project(SessionEvent.Prompted, (event) =>
|
||||
Effect.gen(function* () {
|
||||
const messageID = event.data.messageID
|
||||
const existing = yield* db
|
||||
.select({ id: SessionMessageTable.id })
|
||||
.from(SessionMessageTable)
|
||||
.where(eq(SessionMessageTable.id, event.id))
|
||||
.where(eq(SessionMessageTable.id, messageID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (existing) return yield* Effect.die(new PromptAlreadyProjected())
|
||||
yield* run(db, event)
|
||||
const row = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(eq(SessionMessageTable.id, event.id))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!row) return yield* Effect.die("Prompt projection was not stored")
|
||||
const message = decodeMessage({ ...row.data, id: row.id, type: row.type })
|
||||
if (message.type !== "user") return yield* Effect.die("Prompt projection did not produce a user message")
|
||||
if (event.seq === undefined)
|
||||
return yield* Effect.die("Synchronized Session event is missing aggregate sequence")
|
||||
yield* SessionInput.project(db, {
|
||||
id: SessionMessage.ID.make(event.id),
|
||||
yield* SessionInput.projectLegacyPrompted(db, {
|
||||
id: messageID,
|
||||
sessionID: event.data.sessionID,
|
||||
prompt: event.data.prompt,
|
||||
delivery: event.data.delivery,
|
||||
|
|
@ -378,6 +382,37 @@ export const layer = Layer.effectDiscard(
|
|||
})
|
||||
}),
|
||||
)
|
||||
yield* events.project(SessionEvent.PromptLifecycle.Admitted, (event) =>
|
||||
Effect.gen(function* () {
|
||||
if (event.seq === undefined)
|
||||
return yield* Effect.die("Synchronized Session event is missing aggregate sequence")
|
||||
yield* SessionInput.projectAdmitted(db, {
|
||||
admittedSeq: event.seq,
|
||||
id: event.data.messageID,
|
||||
sessionID: event.data.sessionID,
|
||||
prompt: event.data.prompt,
|
||||
delivery: event.data.delivery,
|
||||
timeCreated: event.data.timestamp,
|
||||
})
|
||||
}),
|
||||
)
|
||||
yield* events.project(SessionEvent.PromptLifecycle.Promoted, (event) =>
|
||||
Effect.gen(function* () {
|
||||
if (event.seq === undefined)
|
||||
return yield* Effect.die("Synchronized Session event is missing aggregate sequence")
|
||||
yield* insertMessage(
|
||||
db,
|
||||
event,
|
||||
yield* SessionInput.projectPromoted(db, {
|
||||
id: event.data.messageID,
|
||||
sessionID: event.data.sessionID,
|
||||
prompt: event.data.prompt,
|
||||
timeCreated: event.data.timeCreated,
|
||||
promotedSeq: event.seq,
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
yield* events.project(SessionEvent.Synthetic, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Shell.Ended, (event) => run(db, event))
|
||||
|
|
|
|||
|
|
@ -132,10 +132,13 @@ export const layer = Layer.effect(
|
|||
const model = yield* models.resolve(session)
|
||||
const toolFibers = yield* FiberSet.make<void, never>()
|
||||
let needsContinuation = false
|
||||
if (promotion === "steer") yield* SessionInput.promoteSteers(db, events, session.id)
|
||||
if (promotion === "queue") {
|
||||
yield* SessionInput.promoteNextQueued(db, events, session.id)
|
||||
yield* SessionInput.promoteSteers(db, events, session.id)
|
||||
if (promotion) {
|
||||
const cutoff = yield* SessionInput.latestSeq(db, session.id)
|
||||
if (promotion === "steer") yield* SessionInput.promoteSteers(db, events, session.id, cutoff)
|
||||
if (promotion === "queue") {
|
||||
yield* SessionInput.promoteNextQueued(db, events, session.id)
|
||||
yield* SessionInput.promoteSteers(db, events, session.id, cutoff)
|
||||
}
|
||||
}
|
||||
yield* failInterruptedTools(session.id)
|
||||
const context = yield* getContext(session.id)
|
||||
|
|
@ -233,8 +236,8 @@ export const layer = Layer.effect(
|
|||
readonly force?: boolean
|
||||
}) {
|
||||
const session = yield* getSession(input.sessionID)
|
||||
const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, ["steer"])
|
||||
const hasQueue = yield* SessionInput.hasPending(db, input.sessionID, ["queue"])
|
||||
const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer")
|
||||
const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue")
|
||||
if (input.force !== true && !hasSteer && !hasQueue) return
|
||||
let promotion: "steer" | "queue" | undefined = hasSteer ? "steer" : hasQueue ? "queue" : undefined
|
||||
let openActivity = input.force === true || hasSteer || hasQueue
|
||||
|
|
@ -243,12 +246,12 @@ export const layer = Layer.effect(
|
|||
for (let step = 0; step < MAX_STEPS; step++) {
|
||||
needsContinuation = yield* runTurn(session, promotion)
|
||||
promotion = "steer"
|
||||
if (!needsContinuation) needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, ["steer"])
|
||||
if (!needsContinuation) needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer")
|
||||
if (!needsContinuation) break
|
||||
}
|
||||
if (needsContinuation)
|
||||
return yield* new StepLimitExceededError({ sessionID: input.sessionID, limit: MAX_STEPS })
|
||||
openActivity = yield* SessionInput.hasPending(db, input.sessionID, ["queue"])
|
||||
openActivity = yield* SessionInput.hasPending(db, input.sessionID, "queue")
|
||||
promotion = openActivity ? "queue" : undefined
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { DateTime, Effect } from "effect"
|
|||
import { EventV2 } from "../../event"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { SessionEvent } from "../event"
|
||||
import { SessionMessage } from "../message"
|
||||
import { SessionSchema } from "../schema"
|
||||
|
||||
type Input = {
|
||||
|
|
@ -60,7 +61,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
|||
const tools = new Map<
|
||||
string,
|
||||
{
|
||||
readonly assistantMessageID: EventV2.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly name: string
|
||||
inputEnded: boolean
|
||||
called: boolean
|
||||
|
|
@ -70,13 +71,17 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
|||
}
|
||||
>()
|
||||
const timestamp = DateTime.now
|
||||
let assistantMessageID: EventV2.ID | undefined
|
||||
let assistantMessageID: SessionMessage.ID | undefined
|
||||
let providerFailed = false
|
||||
|
||||
const startAssistant = Effect.fnUntraced(function* () {
|
||||
if (assistantMessageID !== undefined) return assistantMessageID
|
||||
assistantMessageID = (yield* events.publish(SessionEvent.Step.Started, { ...input, timestamp: yield* timestamp }))
|
||||
.id
|
||||
assistantMessageID = SessionMessage.ID.create()
|
||||
yield* events.publish(SessionEvent.Step.Started, {
|
||||
...input,
|
||||
assistantMessageID,
|
||||
timestamp: yield* timestamp,
|
||||
})
|
||||
return assistantMessageID
|
||||
})
|
||||
const currentAssistantMessageID = () =>
|
||||
|
|
@ -118,6 +123,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
|||
Effect.gen(function* () {
|
||||
yield* events.publish(SessionEvent.Text.Ended, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: yield* currentAssistantMessageID(),
|
||||
timestamp: yield* timestamp,
|
||||
textID,
|
||||
text: value,
|
||||
|
|
@ -128,6 +134,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
|||
Effect.gen(function* () {
|
||||
yield* events.publish(SessionEvent.Reasoning.Ended, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: yield* currentAssistantMessageID(),
|
||||
timestamp: yield* timestamp,
|
||||
reasoningID,
|
||||
text: value,
|
||||
|
|
@ -220,6 +227,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
|||
yield* text.start(event.id)
|
||||
yield* events.publish(SessionEvent.Text.Started, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: yield* startAssistant(),
|
||||
timestamp: yield* timestamp,
|
||||
textID: event.id,
|
||||
})
|
||||
|
|
@ -228,6 +236,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
|||
yield* text.append(event.id, event.text)
|
||||
yield* events.publish(SessionEvent.Text.Delta, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: yield* currentAssistantMessageID(),
|
||||
timestamp: yield* timestamp,
|
||||
textID: event.id,
|
||||
delta: event.text,
|
||||
|
|
@ -240,6 +249,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
|||
yield* reasoning.start(event.id)
|
||||
yield* events.publish(SessionEvent.Reasoning.Started, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: yield* startAssistant(),
|
||||
timestamp: yield* timestamp,
|
||||
reasoningID: event.id,
|
||||
providerMetadata: event.providerMetadata,
|
||||
|
|
@ -249,6 +259,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
|||
yield* reasoning.append(event.id, event.text)
|
||||
yield* events.publish(SessionEvent.Reasoning.Delta, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: yield* currentAssistantMessageID(),
|
||||
timestamp: yield* timestamp,
|
||||
reasoningID: event.id,
|
||||
delta: event.text,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { sqliteTable, text, integer, index, primaryKey, real } from "drizzle-orm/sqlite-core"
|
||||
import { sqliteTable, text, integer, index, primaryKey, real, uniqueIndex } from "drizzle-orm/sqlite-core"
|
||||
import * as DatabasePath from "../database/path"
|
||||
import { ProjectTable } from "../project/sql"
|
||||
import type { SessionMessage } from "./message"
|
||||
|
|
@ -127,7 +127,7 @@ export const SessionMessageTable = sqliteTable(
|
|||
data: text({ mode: "json" }).notNull().$type<SessionMessageData>(),
|
||||
},
|
||||
(table) => [
|
||||
index("session_message_session_seq_idx").on(table.session_id, table.seq),
|
||||
uniqueIndex("session_message_session_seq_idx").on(table.session_id, table.seq),
|
||||
index("session_message_session_type_seq_idx").on(table.session_id, table.type, table.seq),
|
||||
index("session_message_session_time_created_id_idx").on(table.session_id, table.time_created, table.id),
|
||||
index("session_message_time_created_idx").on(table.time_created),
|
||||
|
|
@ -137,14 +137,14 @@ export const SessionMessageTable = sqliteTable(
|
|||
export const SessionInputTable = sqliteTable(
|
||||
"session_input",
|
||||
{
|
||||
seq: integer().primaryKey({ autoIncrement: true }),
|
||||
id: text().$type<SessionMessage.ID>().notNull().unique(),
|
||||
id: text().$type<SessionMessage.ID>().primaryKey(),
|
||||
session_id: text()
|
||||
.$type<SessionSchema.ID>()
|
||||
.notNull()
|
||||
.references(() => SessionTable.id, { onDelete: "cascade" }),
|
||||
prompt: text({ mode: "json" }).notNull().$type<Prompt>(),
|
||||
delivery: text().$type<SessionInput.Delivery>().notNull(),
|
||||
admitted_seq: integer().notNull(),
|
||||
promoted_seq: integer(),
|
||||
time_created: integer()
|
||||
.notNull()
|
||||
|
|
@ -155,7 +155,9 @@ export const SessionInputTable = sqliteTable(
|
|||
table.session_id,
|
||||
table.promoted_seq,
|
||||
table.delivery,
|
||||
table.seq,
|
||||
table.admitted_seq,
|
||||
),
|
||||
uniqueIndex("session_input_session_admitted_seq_idx").on(table.session_id, table.admitted_seq),
|
||||
uniqueIndex("session_input_session_promoted_seq_idx").on(table.session_id, table.promoted_seq),
|
||||
],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { DatabaseMigration } from "@opencode-ai/core/database/migration"
|
|||
import sessionUsageMigration from "@opencode-ai/core/database/migration/20260510033149_session_usage"
|
||||
import normalizeStoragePathsMigration from "@opencode-ai/core/database/migration/20260601010001_normalize_storage_paths"
|
||||
import sessionMessageProjectionOrderMigration from "@opencode-ai/core/database/migration/20260603040000_session_message_projection_order"
|
||||
import eventSourcedSessionInputMigration from "@opencode-ai/core/database/migration/20260604172448_event_sourced_session_input"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
|
|
@ -62,15 +63,17 @@ describe("DatabaseMigration", () => {
|
|||
expect(
|
||||
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_input'`),
|
||||
).toEqual({ name: "session_input" })
|
||||
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 29 })
|
||||
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 30 })
|
||||
expect(
|
||||
yield* db.all(
|
||||
sql`SELECT name FROM sqlite_master WHERE type = 'index' AND name IN ('event_aggregate_seq_idx', 'event_aggregate_type_seq_idx', 'session_input_session_pending_seq_idx', 'session_input_session_pending_delivery_seq_idx', 'session_message_session_idx', 'session_message_session_type_idx', 'session_message_session_seq_idx', 'session_message_session_type_seq_idx', 'session_message_session_time_created_id_idx') ORDER BY name`,
|
||||
sql`SELECT name FROM sqlite_master WHERE type = 'index' AND name IN ('event_aggregate_seq_idx', 'event_aggregate_type_seq_idx', 'session_input_session_pending_seq_idx', 'session_input_session_pending_delivery_seq_idx', 'session_input_session_admitted_seq_idx', 'session_input_session_promoted_seq_idx', 'session_message_session_idx', 'session_message_session_type_idx', 'session_message_session_seq_idx', 'session_message_session_type_seq_idx', 'session_message_session_time_created_id_idx') ORDER BY name`,
|
||||
),
|
||||
).toEqual([
|
||||
{ name: "event_aggregate_seq_idx" },
|
||||
{ name: "event_aggregate_type_seq_idx" },
|
||||
{ name: "session_input_session_admitted_seq_idx" },
|
||||
{ name: "session_input_session_pending_delivery_seq_idx" },
|
||||
{ name: "session_input_session_promoted_seq_idx" },
|
||||
{ name: "session_message_session_seq_idx" },
|
||||
{ name: "session_message_session_time_created_id_idx" },
|
||||
{ name: "session_message_session_type_seq_idx" },
|
||||
|
|
@ -79,6 +82,82 @@ describe("DatabaseMigration", () => {
|
|||
)
|
||||
})
|
||||
|
||||
test("resets beta history and rebuilds event-sourced Session input storage", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, workspace_id text)`)
|
||||
yield* db.run(sql`CREATE TABLE workspace (id text PRIMARY KEY)`)
|
||||
yield* db.run(sql`CREATE TABLE message (id text PRIMARY KEY)`)
|
||||
yield* db.run(sql`CREATE TABLE part (id text PRIMARY KEY)`)
|
||||
yield* db.run(sql`CREATE TABLE event_sequence (aggregate_id text PRIMARY KEY, seq integer NOT NULL)`)
|
||||
yield* db.run(
|
||||
sql`CREATE TABLE event (id text PRIMARY KEY, aggregate_id text NOT NULL, seq integer NOT NULL, type text NOT NULL, data text NOT NULL)`,
|
||||
)
|
||||
yield* db.run(sql`CREATE INDEX event_aggregate_seq_idx ON event (aggregate_id, seq)`)
|
||||
yield* db.run(sql`CREATE INDEX event_aggregate_type_seq_idx ON event (aggregate_id, type, seq)`)
|
||||
yield* db.run(
|
||||
sql`CREATE TABLE session_message (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, seq integer NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)`,
|
||||
)
|
||||
yield* db.run(sql`CREATE INDEX session_message_session_seq_idx ON session_message (session_id, seq)`)
|
||||
yield* db.run(
|
||||
sql`CREATE TABLE session_input (seq integer PRIMARY KEY AUTOINCREMENT, id text NOT NULL UNIQUE, session_id text NOT NULL, prompt text NOT NULL, delivery text NOT NULL, promoted_seq integer, time_created integer NOT NULL)`,
|
||||
)
|
||||
yield* db.run(
|
||||
sql`CREATE INDEX session_input_session_pending_delivery_seq_idx ON session_input (session_id, promoted_seq, delivery, seq)`,
|
||||
)
|
||||
yield* db.run(sql`INSERT INTO session (id, workspace_id) VALUES ('session', 'wrk_old')`)
|
||||
yield* db.run(sql`INSERT INTO workspace (id) VALUES ('wrk_old')`)
|
||||
yield* db.run(sql`INSERT INTO message (id) VALUES ('message')`)
|
||||
yield* db.run(sql`INSERT INTO part (id) VALUES ('part')`)
|
||||
yield* db.run(sql`INSERT INTO event_sequence (aggregate_id, seq) VALUES ('session', 0)`)
|
||||
yield* db.run(
|
||||
sql`INSERT INTO event (id, aggregate_id, seq, type, data) VALUES ('evt_old', 'session', 0, 'old.1', '{}')`,
|
||||
)
|
||||
yield* db.run(
|
||||
sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('msg_old', 'session', 'user', 0, 1, 1, '{}')`,
|
||||
)
|
||||
yield* db.run(
|
||||
sql`INSERT INTO session_input (id, session_id, prompt, delivery, time_created) VALUES ('msg_pending', 'session', '{}', 'steer', 1)`,
|
||||
)
|
||||
|
||||
yield* DatabaseMigration.applyOnly(db, [eventSourcedSessionInputMigration])
|
||||
|
||||
expect(yield* db.all(sql`SELECT id, workspace_id FROM session`)).toEqual([
|
||||
{ id: "session", workspace_id: null },
|
||||
])
|
||||
expect(yield* db.all(sql`SELECT id FROM workspace`)).toEqual([])
|
||||
expect(yield* db.all(sql`SELECT id FROM message`)).toEqual([{ id: "message" }])
|
||||
expect(yield* db.all(sql`SELECT id FROM part`)).toEqual([{ id: "part" }])
|
||||
expect(yield* db.all(sql`SELECT id FROM event`)).toEqual([])
|
||||
expect(yield* db.all(sql`SELECT aggregate_id FROM event_sequence`)).toEqual([])
|
||||
expect(yield* db.all(sql`SELECT id FROM session_message`)).toEqual([])
|
||||
expect(yield* db.all(sql`SELECT id FROM session_input`)).toEqual([])
|
||||
expect(
|
||||
(yield* db.all<{ name: string }>(sql`PRAGMA table_info(session_input)`)).map((column) => column.name),
|
||||
).toEqual(["id", "session_id", "prompt", "delivery", "admitted_seq", "promoted_seq", "time_created"])
|
||||
expect(
|
||||
(yield* db.all<{ name: string; unique: number }>(sql`PRAGMA index_list(session_message)`)).find(
|
||||
(index) => index.name === "session_message_session_seq_idx",
|
||||
),
|
||||
).toMatchObject({ unique: 1 })
|
||||
expect(
|
||||
(yield* db.all<{ name: string; unique: number }>(sql`PRAGMA index_list(event)`)).find(
|
||||
(index) => index.name === "event_aggregate_seq_idx",
|
||||
),
|
||||
).toMatchObject({ unique: 1 })
|
||||
expect(
|
||||
(yield* db.all<{ name: string; unique: number }>(sql`PRAGMA index_list(session_input)`)).filter((index) =>
|
||||
["session_input_session_admitted_seq_idx", "session_input_session_promoted_seq_idx"].includes(index.name),
|
||||
),
|
||||
).toEqual([
|
||||
expect.objectContaining({ name: "session_input_session_promoted_seq_idx", unique: 1 }),
|
||||
expect.objectContaining({ name: "session_input_session_admitted_seq_idx", unique: 1 }),
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("resets incompatible projected Session messages before adding sequence order", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { DateTime, Deferred, Effect, Fiber, Layer, Schema, Stream } from "effect"
|
||||
import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } from "effect"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { EventSequenceTable, EventTable } from "@opencode-ai/core/event/sql"
|
||||
|
|
@ -236,6 +236,56 @@ describe("EventV2", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("isolates observer defects after durable events commit", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const received = new Array<string>()
|
||||
yield* events.sync(() => Effect.die("sync defect"))
|
||||
yield* events.listen(() => {
|
||||
throw new Error("listener defect")
|
||||
})
|
||||
yield* events.listen((event) =>
|
||||
Effect.sync(() => {
|
||||
received.push(event.type)
|
||||
}),
|
||||
)
|
||||
|
||||
const event = yield* events.publish(SyncMessage, { id: "one", text: "hello" })
|
||||
|
||||
expect(received).toEqual([SyncMessage.type])
|
||||
expect(event.seq).toBeNumber()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves observer interruption", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const { db } = yield* Database.Service
|
||||
yield* events.listen(() => Effect.interrupt)
|
||||
|
||||
const exit = yield* events.publish(SyncMessage, { id: "interrupted", text: "hello" }).pipe(Effect.exit)
|
||||
const committed = yield* db
|
||||
.select({ id: EventTable.id })
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.aggregate_id, "interrupted"))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
expect(Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause)).toBeTrue()
|
||||
expect(committed).toBeDefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps live-only listener defects fail-fast", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const defect = new Error("listener defect")
|
||||
yield* events.listen(() => Effect.die(defect))
|
||||
|
||||
expect(yield* events.publish(Message, { text: "hello" }).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not synchronize live-only events", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
|
|
@ -254,6 +304,30 @@ describe("EventV2", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("synchronizes only after the durable event commits", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const { db } = yield* Database.Service
|
||||
const synchronized = new Array<boolean>()
|
||||
yield* events.sync((event) =>
|
||||
db
|
||||
.select({ id: EventTable.id })
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.id, event.id))
|
||||
.get()
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((row) => synchronized.push(row !== undefined)),
|
||||
Effect.asVoid,
|
||||
),
|
||||
)
|
||||
|
||||
yield* events.publish(SyncMessage, { id: EventV2.ID.create(), text: "durable" })
|
||||
|
||||
expect(synchronized).toEqual([true])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("inserts sync event rows on publish", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
|
|
@ -690,6 +764,59 @@ describe("EventV2", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("strict owner fences exact replay", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const aggregateID = EventV2.ID.create()
|
||||
const id = EventV2.ID.create()
|
||||
const replayed = {
|
||||
id,
|
||||
type: EventV2.versionedType(SyncMessage.type, 1),
|
||||
seq: 0,
|
||||
aggregateID,
|
||||
data: { id: aggregateID, text: "owned" },
|
||||
}
|
||||
yield* events.replay(replayed, { ownerID: "owner-a" })
|
||||
|
||||
const exit = yield* events.replay(replayed, { ownerID: "owner-b", strictOwner: true }).pipe(Effect.exit)
|
||||
|
||||
expect(String(exit)).toContain("Replay owner mismatch")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("exact replay claims an unowned aggregate", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const { db } = yield* Database.Service
|
||||
const aggregateID = EventV2.ID.create()
|
||||
const published = yield* events.publish(SyncMessage, { id: aggregateID, text: "owned" })
|
||||
const replayed = {
|
||||
id: published.id,
|
||||
type: EventV2.versionedType(SyncMessage.type, 1),
|
||||
seq: published.seq!,
|
||||
aggregateID,
|
||||
data: published.data,
|
||||
}
|
||||
|
||||
yield* events.replay(replayed, { ownerID: "owner-a", strictOwner: true })
|
||||
const row = yield* db
|
||||
.select({ ownerID: EventSequenceTable.owner_id })
|
||||
.from(EventSequenceTable)
|
||||
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
expect(row?.ownerID).toBe("owner-a")
|
||||
const exit = yield* events
|
||||
.replay(
|
||||
{ ...replayed, id: EventV2.ID.create(), seq: 1, data: { id: aggregateID, text: "conflict" } },
|
||||
{ ownerID: "owner-b", strictOwner: true },
|
||||
)
|
||||
.pipe(Effect.exit)
|
||||
expect(String(exit)).toContain("Replay owner mismatch")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replay with owner claims an unowned sequence", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
|
|
@ -815,6 +942,57 @@ describe("EventV2", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects divergent stale replay without publishing it", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const received = new Array<EventV2.Payload>()
|
||||
const aggregateID = EventV2.ID.create()
|
||||
const replayed = {
|
||||
id: EventV2.ID.create(),
|
||||
type: EventV2.versionedType(SyncMessage.type, 1),
|
||||
seq: 0,
|
||||
aggregateID,
|
||||
data: { id: aggregateID, text: "original" },
|
||||
}
|
||||
yield* events.listen((event) => Effect.sync(() => received.push(event)))
|
||||
yield* events.replay(replayed, { publish: true })
|
||||
|
||||
const exit = yield* events
|
||||
.replay({ ...replayed, data: { id: aggregateID, text: "divergent" } }, { publish: true })
|
||||
.pipe(Effect.exit)
|
||||
|
||||
expect(String(exit)).toContain("Replay diverged")
|
||||
expect(received).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects an event ID reused at another aggregate position", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const aggregateID = EventV2.ID.create()
|
||||
const id = EventV2.ID.create()
|
||||
yield* events.replay({
|
||||
id,
|
||||
type: EventV2.versionedType(SyncMessage.type, 1),
|
||||
seq: 0,
|
||||
aggregateID,
|
||||
data: { id: aggregateID, text: "first" },
|
||||
})
|
||||
|
||||
const exit = yield* events
|
||||
.replay({
|
||||
id,
|
||||
type: EventV2.versionedType(SyncMessage.type, 1),
|
||||
seq: 1,
|
||||
aggregateID,
|
||||
data: { id: aggregateID, text: "second" },
|
||||
})
|
||||
.pipe(Effect.exit)
|
||||
|
||||
expect(String(exit)).toContain(`Event ${id} already exists`)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replay from a different owner leaves claimed sequence unchanged", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { asc, eq } from "drizzle-orm"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { EventTable } from "@opencode-ai/core/event/sql"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
|
|
@ -16,10 +18,12 @@ import { Prompt } from "@opencode-ai/core/session/prompt"
|
|||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionInput } from "@opencode-ai/core/session/input"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
|
||||
const database = Database.layerFromPath(":memory:")
|
||||
const events = EventV2.layer.pipe(Layer.provide(database))
|
||||
|
|
@ -211,11 +215,99 @@ describe("SessionV2.create", () => {
|
|||
const { db } = yield* Database.Service
|
||||
const created = yield* session.create({ location })
|
||||
yield* session.prompt({ sessionID: created.id, prompt: new Prompt({ text: "Hello" }), resume: false })
|
||||
yield* SessionInput.promoteSteers(db, events, created.id)
|
||||
yield* SessionInput.promoteSteers(db, events, created.id, Number.MAX_SAFE_INTEGER)
|
||||
|
||||
expect(
|
||||
Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(1), Stream.runCollect)),
|
||||
).toMatchObject([{ cursor: 1, event: { type: "session.next.prompted", data: { prompt: { text: "Hello" } } } }])
|
||||
Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(2), Stream.runCollect)),
|
||||
).toMatchObject([
|
||||
{ cursor: 1, event: { type: "session.next.prompt.admitted", data: { prompt: { text: "Hello" } } } },
|
||||
{ cursor: 2, event: { type: "session.next.prompt.promoted" } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replays one prompt lifecycle into a fresh target database", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
const sourceEvents = yield* EventV2.Service
|
||||
const sourceDb = (yield* Database.Service).db
|
||||
const created = yield* session.create({ id: SessionV2.ID.make("ses_fresh_target_replay"), location })
|
||||
const admitted = yield* session.prompt({
|
||||
sessionID: created.id,
|
||||
prompt: new Prompt({ text: "Replay lifecycle" }),
|
||||
resume: false,
|
||||
})
|
||||
yield* SessionInput.promoteSteers(sourceDb, sourceEvents, created.id, Number.MAX_SAFE_INTEGER)
|
||||
const serialized = (yield* sourceDb
|
||||
.select()
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.aggregate_id, created.id))
|
||||
.orderBy(asc(EventTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)).map((event) => ({
|
||||
id: event.id,
|
||||
aggregateID: event.aggregate_id,
|
||||
seq: event.seq,
|
||||
type: event.type,
|
||||
data: event.data,
|
||||
}))
|
||||
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
const targetDatabase = Database.layerFromPath(path.join(tmp.path, "target.sqlite"))
|
||||
const targetEvents = EventV2.layer.pipe(Layer.provide(targetDatabase))
|
||||
const targetProjector = SessionProjector.layer.pipe(Layer.provide(targetEvents), Layer.provide(targetDatabase))
|
||||
const targetStore = SessionStore.layer.pipe(Layer.provide(targetDatabase))
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
const events = yield* EventV2.Service
|
||||
const store = yield* SessionStore.Service
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: ProjectV2.ID.global, worktree: location.directory, sandboxes: [] })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
expect(yield* store.get(created.id)).toBeUndefined()
|
||||
expect(yield* events.replayAll(serialized.slice(0, 2))).toBe(created.id)
|
||||
expect(yield* SessionInput.find(db, admitted.id)).toMatchObject({
|
||||
id: admitted.id,
|
||||
sessionID: created.id,
|
||||
prompt: { text: "Replay lifecycle" },
|
||||
delivery: "steer",
|
||||
admittedSeq: 1,
|
||||
})
|
||||
expect(yield* store.context(created.id)).toEqual([])
|
||||
|
||||
expect(yield* events.replayAll(serialized.slice(2))).toBe(created.id)
|
||||
expect(yield* SessionInput.find(db, admitted.id)).toMatchObject({
|
||||
id: admitted.id,
|
||||
sessionID: created.id,
|
||||
prompt: { text: "Replay lifecycle" },
|
||||
delivery: "steer",
|
||||
admittedSeq: 1,
|
||||
promotedSeq: 2,
|
||||
})
|
||||
expect(yield* store.context(created.id)).toMatchObject([
|
||||
{ id: admitted.id, type: "user", text: "Replay lifecycle" },
|
||||
])
|
||||
expect(
|
||||
(yield* db
|
||||
.select()
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.aggregate_id, created.id))
|
||||
.orderBy(asc(EventTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)).map((event) => [event.seq, event.type]),
|
||||
).toEqual([
|
||||
[0, EventV2.versionedType(SessionV1.Event.Created.type, 1)],
|
||||
[1, EventV2.versionedType(SessionEvent.PromptLifecycle.Admitted.type, 1)],
|
||||
[2, EventV2.versionedType(SessionEvent.PromptLifecycle.Promoted.type, 1)],
|
||||
])
|
||||
}).pipe(Effect.provide(Layer.fresh(Layer.mergeAll(targetDatabase, targetEvents, targetProjector, targetStore))))
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -67,13 +67,25 @@ describe("SessionProjector", () => {
|
|||
|
||||
yield* events.publish(
|
||||
SessionEvent.Prompted,
|
||||
{ sessionID, timestamp: created, prompt: new Prompt({ text: "first" }), delivery: "steer" },
|
||||
{ id: SessionMessage.ID.make("evt_z") },
|
||||
{
|
||||
sessionID,
|
||||
messageID: SessionMessage.ID.make("msg_first"),
|
||||
timestamp: created,
|
||||
prompt: new Prompt({ text: "first" }),
|
||||
delivery: "steer",
|
||||
},
|
||||
{ id: EventV2.ID.make("evt_z") },
|
||||
)
|
||||
yield* events.publish(
|
||||
SessionEvent.Prompted,
|
||||
{ sessionID, timestamp: created, prompt: new Prompt({ text: "second" }), delivery: "steer" },
|
||||
{ id: SessionMessage.ID.make("evt_a") },
|
||||
{
|
||||
sessionID,
|
||||
messageID: SessionMessage.ID.make("msg_second"),
|
||||
timestamp: created,
|
||||
prompt: new Prompt({ text: "second" }),
|
||||
delivery: "steer",
|
||||
},
|
||||
{ id: EventV2.ID.make("evt_a") },
|
||||
)
|
||||
|
||||
const sessions = yield* SessionV2.Service
|
||||
|
|
@ -110,7 +122,7 @@ describe("SessionProjector", () => {
|
|||
),
|
||||
)
|
||||
|
||||
it.effect("marks an admitted inbox row promoted with the Prompted event sequence", () =>
|
||||
it.effect("marks an admitted lifecycle row promoted with the PromptPromoted event sequence", () =>
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
|
|
@ -131,14 +143,21 @@ describe("SessionProjector", () => {
|
|||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const events = yield* EventV2.Service
|
||||
const id = SessionMessage.ID.make("evt_admitted")
|
||||
yield* SessionInput.admit(db, { id, sessionID, prompt: new Prompt({ text: "promote me" }), delivery: "steer" })
|
||||
const id = SessionMessage.ID.make("msg_admitted")
|
||||
yield* SessionInput.admit(db, events, {
|
||||
id,
|
||||
sessionID,
|
||||
prompt: new Prompt({ text: "promote me" }),
|
||||
delivery: "steer",
|
||||
})
|
||||
|
||||
const event = yield* events.publish(
|
||||
SessionEvent.Prompted,
|
||||
{ sessionID, timestamp: created, prompt: new Prompt({ text: "promote me" }), delivery: "steer" },
|
||||
{ id },
|
||||
)
|
||||
const event = yield* events.publish(SessionEvent.PromptLifecycle.Promoted, {
|
||||
sessionID,
|
||||
timestamp: created,
|
||||
messageID: id,
|
||||
prompt: new Prompt({ text: "promote me" }),
|
||||
timeCreated: created,
|
||||
})
|
||||
|
||||
expect(
|
||||
yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie),
|
||||
|
|
@ -168,11 +187,27 @@ describe("SessionProjector", () => {
|
|||
.pipe(Effect.orDie)
|
||||
const events = yield* EventV2.Service
|
||||
|
||||
yield* events.publish(SessionEvent.AgentSwitched, { sessionID, timestamp: created, agent: "build" })
|
||||
yield* events.publish(SessionEvent.ModelSwitched, { sessionID, timestamp: created, model })
|
||||
yield* events.publish(SessionEvent.Synthetic, { sessionID, timestamp: created, text: "synthetic context" })
|
||||
yield* events.publish(SessionEvent.AgentSwitched, {
|
||||
sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: created,
|
||||
agent: "build",
|
||||
})
|
||||
yield* events.publish(SessionEvent.ModelSwitched, {
|
||||
sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: created,
|
||||
model,
|
||||
})
|
||||
yield* events.publish(SessionEvent.Synthetic, {
|
||||
sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: created,
|
||||
text: "synthetic context",
|
||||
})
|
||||
yield* events.publish(SessionEvent.Shell.Started, {
|
||||
sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: created,
|
||||
callID: "shell-1",
|
||||
command: "pwd",
|
||||
|
|
@ -183,7 +218,12 @@ describe("SessionProjector", () => {
|
|||
callID: "shell-1",
|
||||
output: "/project",
|
||||
})
|
||||
yield* events.publish(SessionEvent.Compaction.Started, { sessionID, timestamp: created, reason: "manual" })
|
||||
yield* events.publish(SessionEvent.Compaction.Started, {
|
||||
sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: created,
|
||||
reason: "manual",
|
||||
})
|
||||
yield* events.publish(SessionEvent.Compaction.Delta, { sessionID, timestamp: created, text: "partial" })
|
||||
yield* events.publish(SessionEvent.Compaction.Ended, {
|
||||
sessionID,
|
||||
|
|
@ -228,6 +268,47 @@ describe("SessionProjector", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects distinct creator events that reuse one projected message ID", () =>
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "test",
|
||||
directory: "/project",
|
||||
title: "test",
|
||||
version: "test",
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const events = yield* EventV2.Service
|
||||
const id = SessionMessage.ID.make("msg_creator_collision")
|
||||
|
||||
yield* events.publish(SessionEvent.Synthetic, { sessionID, messageID: id, timestamp: created, text: "keep me" })
|
||||
const exit = yield* events
|
||||
.publish(SessionEvent.Step.Started, {
|
||||
sessionID,
|
||||
assistantMessageID: id,
|
||||
timestamp: created,
|
||||
agent: "build",
|
||||
model,
|
||||
})
|
||||
.pipe(Effect.exit)
|
||||
|
||||
expect(exit._tag).toBe("Failure")
|
||||
expect(
|
||||
yield* db.select().from(SessionMessageTable).where(eq(SessionMessageTable.id, id)).get().pipe(Effect.orDie),
|
||||
).toMatchObject({ type: "synthetic" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects a Prompted event that conflicts with an admitted inbox row", () =>
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
|
|
@ -249,24 +330,77 @@ describe("SessionProjector", () => {
|
|||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const events = yield* EventV2.Service
|
||||
const id = SessionMessage.ID.make("evt_conflict")
|
||||
yield* SessionInput.admit(db, { id, sessionID, prompt: new Prompt({ text: "admitted" }), delivery: "steer" })
|
||||
const id = SessionMessage.ID.make("msg_conflict")
|
||||
yield* SessionInput.admit(db, events, {
|
||||
id,
|
||||
sessionID,
|
||||
prompt: new Prompt({ text: "admitted" }),
|
||||
delivery: "steer",
|
||||
})
|
||||
|
||||
const exit = yield* events
|
||||
.publish(
|
||||
SessionEvent.Prompted,
|
||||
{ sessionID, timestamp: created, prompt: new Prompt({ text: "different" }), delivery: "steer" },
|
||||
{ id },
|
||||
)
|
||||
.publish(SessionEvent.Prompted, {
|
||||
sessionID,
|
||||
messageID: id,
|
||||
timestamp: created,
|
||||
prompt: new Prompt({ text: "different" }),
|
||||
delivery: "steer",
|
||||
})
|
||||
.pipe(Effect.exit)
|
||||
|
||||
expect(String(exit)).toContain("Prompt projection conflicts with admitted input")
|
||||
expect(String(exit)).toContain("SessionInput.LifecycleConflict")
|
||||
expect(
|
||||
yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie),
|
||||
).toMatchObject({ promoted_seq: null })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects an assistant message ID that conflicts with an admitted inbox row", () =>
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "test",
|
||||
directory: "/project",
|
||||
title: "test",
|
||||
version: "test",
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const events = yield* EventV2.Service
|
||||
const id = SessionMessage.ID.make("msg_conflict")
|
||||
yield* SessionInput.admit(db, events, {
|
||||
id,
|
||||
sessionID,
|
||||
prompt: new Prompt({ text: "admitted" }),
|
||||
delivery: "steer",
|
||||
})
|
||||
|
||||
const exit = yield* events
|
||||
.publish(SessionEvent.Step.Started, {
|
||||
sessionID,
|
||||
timestamp: created,
|
||||
assistantMessageID: id,
|
||||
agent: "build",
|
||||
model,
|
||||
})
|
||||
.pipe(Effect.exit)
|
||||
|
||||
expect(String(exit)).toContain("SessionInput.LifecycleConflict")
|
||||
expect(
|
||||
yield* db.select().from(SessionMessageTable).where(eq(SessionMessageTable.id, id)).get().pipe(Effect.orDie),
|
||||
).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects a Prompted delivery mode that conflicts with an admitted inbox row", () =>
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
|
|
@ -288,15 +422,15 @@ describe("SessionProjector", () => {
|
|||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const events = yield* EventV2.Service
|
||||
const id = SessionMessage.ID.make("evt_delivery_conflict")
|
||||
const id = SessionMessage.ID.make("msg_delivery_conflict")
|
||||
const prompt = new Prompt({ text: "admitted" })
|
||||
yield* SessionInput.admit(db, { id, sessionID, prompt, delivery: "queue" })
|
||||
yield* SessionInput.admit(db, events, { id, sessionID, prompt, delivery: "queue" })
|
||||
|
||||
const exit = yield* events
|
||||
.publish(SessionEvent.Prompted, { sessionID, timestamp: created, prompt, delivery: "steer" }, { id })
|
||||
.publish(SessionEvent.Prompted, { sessionID, messageID: id, timestamp: created, prompt, delivery: "steer" })
|
||||
.pipe(Effect.exit)
|
||||
|
||||
expect(String(exit)).toContain("Prompt projection conflicts with admitted input")
|
||||
expect(String(exit)).toContain("SessionInput.LifecycleConflict")
|
||||
expect(
|
||||
yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie),
|
||||
).toMatchObject({ delivery: "queue", promoted_seq: null })
|
||||
|
|
@ -306,7 +440,7 @@ describe("SessionProjector", () => {
|
|||
it.effect("does not revive a stale incomplete in-memory assistant projection", () =>
|
||||
Effect.gen(function* () {
|
||||
const stale = new SessionMessage.Assistant({
|
||||
id: SessionMessage.ID.make("evt_assistant_stale"),
|
||||
id: SessionMessage.ID.make("msg_assistant_stale"),
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model,
|
||||
|
|
@ -314,7 +448,7 @@ describe("SessionProjector", () => {
|
|||
time: { created },
|
||||
})
|
||||
const completed = new SessionMessage.Assistant({
|
||||
id: SessionMessage.ID.make("evt_assistant_completed"),
|
||||
id: SessionMessage.ID.make("msg_assistant_completed"),
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model,
|
||||
|
|
@ -351,8 +485,8 @@ describe("SessionProjector", () => {
|
|||
yield* db
|
||||
.insert(SessionMessageTable)
|
||||
.values([
|
||||
assistantRow(SessionMessage.ID.make("evt_assistant_1"), 0),
|
||||
assistantRow(SessionMessage.ID.make("evt_assistant_2"), 1),
|
||||
assistantRow(SessionMessage.ID.make("msg_assistant_1"), 0),
|
||||
assistantRow(SessionMessage.ID.make("msg_assistant_2"), 1),
|
||||
])
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
|
@ -361,7 +495,7 @@ describe("SessionProjector", () => {
|
|||
yield* service.publish(SessionEvent.Step.Ended, {
|
||||
sessionID,
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
assistantMessageID: SessionMessage.ID.make("evt_assistant_2"),
|
||||
assistantMessageID: SessionMessage.ID.make("msg_assistant_2"),
|
||||
finish: "stop",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
|
|
@ -409,8 +543,8 @@ describe("SessionProjector", () => {
|
|||
yield* db
|
||||
.insert(SessionMessageTable)
|
||||
.values([
|
||||
assistantRow(SessionMessage.ID.make("evt_assistant_stale"), 0),
|
||||
assistantRow(SessionMessage.ID.make("evt_assistant_completed"), 1, {
|
||||
assistantRow(SessionMessage.ID.make("msg_assistant_stale"), 0),
|
||||
assistantRow(SessionMessage.ID.make("msg_assistant_completed"), 1, {
|
||||
created: DateTime.makeUnsafe(1),
|
||||
completed: DateTime.makeUnsafe(2),
|
||||
}),
|
||||
|
|
@ -421,6 +555,7 @@ describe("SessionProjector", () => {
|
|||
const service = yield* EventV2.Service
|
||||
yield* service.publish(SessionEvent.Text.Started, {
|
||||
sessionID,
|
||||
assistantMessageID: SessionMessage.ID.make("msg_assistant_completed"),
|
||||
timestamp: DateTime.makeUnsafe(3),
|
||||
textID: "text-stale",
|
||||
})
|
||||
|
|
@ -437,15 +572,15 @@ describe("SessionProjector", () => {
|
|||
)
|
||||
expect(messages).toEqual([
|
||||
new SessionMessage.Assistant({
|
||||
id: SessionMessage.ID.make("evt_assistant_completed"),
|
||||
id: SessionMessage.ID.make("msg_assistant_completed"),
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model,
|
||||
content: [],
|
||||
content: [new SessionMessage.AssistantText({ type: "text", id: "text-stale", text: "" })],
|
||||
time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) },
|
||||
}),
|
||||
new SessionMessage.Assistant({
|
||||
id: SessionMessage.ID.make("evt_assistant_stale"),
|
||||
id: SessionMessage.ID.make("msg_assistant_stale"),
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { DateTime, Effect, Fiber, Layer, Stream } from "effect"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { EventTable } from "@opencode-ai/core/event/sql"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
|
|
@ -12,7 +14,7 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
|
|||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionInput } from "@opencode-ai/core/session/input"
|
||||
import { SessionInputTable, SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
|
|
@ -80,6 +82,18 @@ const admittedCount = Database.Service.use(({ db }) =>
|
|||
Effect.map((rows) => rows.length),
|
||||
),
|
||||
)
|
||||
const eventCount = (type: string) =>
|
||||
Database.Service.use(({ db }) =>
|
||||
db
|
||||
.select()
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.type, type))
|
||||
.all()
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((rows) => rows.length),
|
||||
),
|
||||
)
|
||||
|
||||
describe("SessionV2.prompt", () => {
|
||||
it.effect("delegates execution continuation through SessionExecution", () =>
|
||||
|
|
@ -105,8 +119,7 @@ describe("SessionV2.prompt", () => {
|
|||
resume: false,
|
||||
})
|
||||
|
||||
expect(message.type).toBe("user")
|
||||
expect(message.text).toBe("Fix the failing tests")
|
||||
expect(message.prompt.text).toBe("Fix the failing tests")
|
||||
expect(yield* session.messages({ sessionID })).toEqual([])
|
||||
expect(yield* admitted(message.id)).toMatchObject({
|
||||
id: message.id,
|
||||
|
|
@ -123,25 +136,25 @@ describe("SessionV2.prompt", () => {
|
|||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const { db } = yield* Database.Service
|
||||
const fiber = yield* session.events({ sessionID }).pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
|
||||
const fiber = yield* session.events({ sessionID }).pipe(Stream.take(4), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
|
||||
yield* SessionInput.promoteSteers(db, events, sessionID)
|
||||
yield* SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER)
|
||||
const streamed = Array.from(yield* Fiber.join(fiber))
|
||||
|
||||
expect(
|
||||
streamed.map((event) => [event.cursor, event.event.type, (event.event.data as { prompt: Prompt }).prompt.text]),
|
||||
).toEqual([
|
||||
[EventV2.Cursor.make(0), "session.next.prompted", "First"],
|
||||
[EventV2.Cursor.make(1), "session.next.prompted", "Second"],
|
||||
expect(streamed.map((event) => [event.cursor, event.event.type])).toEqual([
|
||||
[EventV2.Cursor.make(0), "session.next.prompt.admitted"],
|
||||
[EventV2.Cursor.make(1), "session.next.prompt.admitted"],
|
||||
[EventV2.Cursor.make(2), "session.next.prompt.promoted"],
|
||||
[EventV2.Cursor.make(3), "session.next.prompt.promoted"],
|
||||
])
|
||||
expect(
|
||||
Array.from(
|
||||
yield* session.events({ sessionID, after: streamed[0]!.cursor }).pipe(Stream.take(1), Stream.runCollect),
|
||||
).map((event) => [event.cursor, (event.event.data as { prompt: Prompt }).prompt.text]),
|
||||
).toEqual([[EventV2.Cursor.make(1), "Second"]])
|
||||
).map((event) => [event.cursor, event.event.type]),
|
||||
).toEqual([[EventV2.Cursor.make(1), "session.next.prompt.admitted"]])
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -271,17 +284,6 @@ describe("SessionV2.prompt", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("does not match pending inputs when no delivery modes are eligible", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const { db } = yield* Database.Service
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Wait" }), resume: false })
|
||||
|
||||
expect(yield* SessionInput.hasPending(db, sessionID, [])).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("returns one recorded message to concurrent exact retries", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
|
|
@ -298,43 +300,127 @@ describe("SessionV2.prompt", () => {
|
|||
expect(messages[1]).toEqual(messages[0])
|
||||
expect(yield* session.messages({ sessionID })).toEqual([])
|
||||
expect(yield* admittedCount).toBe(1)
|
||||
expect(yield* eventCount(EventV2.versionedType(SessionEvent.PromptLifecycle.Admitted.type, 1))).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reconciles an existing projected prompt into a promoted inbox record", () =>
|
||||
it.effect("promotes one message once under concurrent promotion attempts", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const { db } = yield* Database.Service
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
yield* session.prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "Promote once" }), resume: false })
|
||||
|
||||
yield* Effect.all(
|
||||
[
|
||||
SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER),
|
||||
SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER),
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
|
||||
expect(yield* eventCount(EventV2.versionedType(SessionEvent.PromptLifecycle.Promoted.type, 1))).toBe(1)
|
||||
expect(yield* admitted(messageID)).toMatchObject({ promotedSeq: 1 })
|
||||
expect(yield* session.messages({ sessionID })).toMatchObject([
|
||||
{ id: messageID, type: "user", text: "Promote once" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("promotes steers only through the captured aggregate cutoff", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const { db } = yield* Database.Service
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const first = yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Before cutoff" }), resume: false })
|
||||
const cutoff = yield* SessionInput.latestSeq(db, sessionID)
|
||||
const second = yield* session.prompt({ sessionID, prompt: new Prompt({ text: "After cutoff" }), resume: false })
|
||||
|
||||
yield* SessionInput.promoteSteers(db, events, sessionID, cutoff)
|
||||
|
||||
expect(yield* admitted(first.id)).toHaveProperty("promotedSeq")
|
||||
expect(yield* admitted(second.id)).not.toHaveProperty("promotedSeq")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reprojects one pending lifecycle without scheduling execution", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const { db } = yield* Database.Service
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
wakeCalls.length = 0
|
||||
yield* session.prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "Replay pending" }), resume: false })
|
||||
const recorded = yield* db
|
||||
.select()
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.aggregate_id, sessionID))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
yield* events.remove(sessionID)
|
||||
yield* db.delete(SessionInputTable).where(eq(SessionInputTable.session_id, sessionID)).run().pipe(Effect.orDie)
|
||||
yield* db
|
||||
.delete(SessionMessageTable)
|
||||
.where(eq(SessionMessageTable.session_id, sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* events.replayAll(
|
||||
recorded.map((event) => ({
|
||||
id: event.id,
|
||||
aggregateID: event.aggregate_id,
|
||||
seq: event.seq,
|
||||
type: event.type,
|
||||
data: event.data,
|
||||
})),
|
||||
)
|
||||
|
||||
expect(yield* admitted(messageID)).toMatchObject({ id: messageID, prompt: { text: "Replay pending" } })
|
||||
expect(yield* session.messages({ sessionID })).toEqual([])
|
||||
expect(wakeCalls).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("returns an exact retry of a legacy projected prompt", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const prompt = new Prompt({ text: "Historical prompt" })
|
||||
yield* events.publish(
|
||||
SessionEvent.Prompted,
|
||||
{ sessionID, timestamp: yield* DateTime.now, prompt, delivery: "steer" },
|
||||
{ id: messageID },
|
||||
)
|
||||
yield* events.publish(SessionEvent.Prompted, {
|
||||
sessionID,
|
||||
messageID,
|
||||
timestamp: yield* DateTime.now,
|
||||
prompt,
|
||||
delivery: "steer",
|
||||
})
|
||||
|
||||
const retried = yield* session.prompt({ id: messageID, sessionID, prompt, resume: false })
|
||||
|
||||
expect(retried).toMatchObject({ id: messageID, text: "Historical prompt" })
|
||||
expect(retried).toMatchObject({ id: messageID, prompt: { text: "Historical prompt" } })
|
||||
expect(yield* admitted(messageID)).toHaveProperty("promotedSeq")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reconciles an existing projected queued prompt with its delivery mode", () =>
|
||||
it.effect("returns an exact retry of a legacy projected queued prompt", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const prompt = new Prompt({ text: "Historical queued prompt" })
|
||||
yield* events.publish(
|
||||
SessionEvent.Prompted,
|
||||
{ sessionID, timestamp: yield* DateTime.now, prompt, delivery: "queue" },
|
||||
{ id: messageID },
|
||||
)
|
||||
yield* events.publish(SessionEvent.Prompted, {
|
||||
sessionID,
|
||||
messageID,
|
||||
timestamp: yield* DateTime.now,
|
||||
prompt,
|
||||
delivery: "queue",
|
||||
})
|
||||
|
||||
const retried = yield* session.prompt({ id: messageID, sessionID, prompt, delivery: "queue", resume: false })
|
||||
|
||||
expect(retried).toMatchObject({ id: messageID, text: "Historical queued prompt" })
|
||||
expect(retried).toMatchObject({ id: messageID, prompt: { text: "Historical queued prompt" } })
|
||||
expect(yield* admitted(messageID)).toMatchObject({ delivery: "queue" })
|
||||
}),
|
||||
)
|
||||
|
|
@ -344,11 +430,12 @@ describe("SessionV2.prompt", () => {
|
|||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
yield* events.publish(
|
||||
SessionEvent.Synthetic,
|
||||
{ sessionID, timestamp: yield* DateTime.now, text: "Collision" },
|
||||
{ id: messageID },
|
||||
)
|
||||
yield* events.publish(SessionEvent.Synthetic, {
|
||||
sessionID,
|
||||
messageID,
|
||||
timestamp: yield* DateTime.now,
|
||||
text: "Collision",
|
||||
})
|
||||
|
||||
const failure = yield* session
|
||||
.prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "Collision" }), resume: false })
|
||||
|
|
@ -369,20 +456,21 @@ describe("SessionV2.prompt", () => {
|
|||
yield* session.prompt({ id: messageID, sessionID, prompt, resume: false })
|
||||
|
||||
const failure = yield* events
|
||||
.publish(
|
||||
SessionEvent.Synthetic,
|
||||
{ sessionID, timestamp: yield* DateTime.now, text: "Conflicting synthetic" },
|
||||
{ id: messageID },
|
||||
)
|
||||
.publish(SessionEvent.Synthetic, {
|
||||
sessionID,
|
||||
messageID,
|
||||
timestamp: yield* DateTime.now,
|
||||
text: "Conflicting synthetic",
|
||||
})
|
||||
.pipe(Effect.catchDefect(Effect.succeed))
|
||||
|
||||
expect(failure).toBe("Durable event conflicts with admitted prompt input")
|
||||
expect(String(failure)).toContain("SessionInput.LifecycleConflict")
|
||||
expect(yield* admitted(messageID)).not.toHaveProperty("promotedSeq")
|
||||
expect(yield* session.messages({ sessionID })).toEqual([])
|
||||
|
||||
yield* SessionInput.promoteSteers(db, events, sessionID)
|
||||
yield* SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER)
|
||||
|
||||
expect(yield* admitted(messageID)).toMatchObject({ promotedSeq: 0 })
|
||||
expect(yield* admitted(messageID)).toMatchObject({ promotedSeq: 1 })
|
||||
expect(yield* session.messages({ sessionID })).toMatchObject([
|
||||
{ id: messageID, type: "user", text: "Reserved prompt" },
|
||||
])
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { Message, Model } from "@opencode-ai/llm"
|
||||
import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
|
|
@ -12,7 +11,7 @@ import { ToolOutput } from "@opencode-ai/core/tool-output"
|
|||
import { DateTime } from "effect"
|
||||
|
||||
const created = DateTime.makeUnsafe(0)
|
||||
const id = (value: string) => EventV2.ID.make(`evt_${value}`)
|
||||
const id = (value: string) => SessionMessage.ID.make(`msg_${value}`)
|
||||
const model = Model.make({ id: "model", provider: "provider", route: OpenAIChat.route })
|
||||
|
||||
describe("toLLMMessages", () => {
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import { Prompt } from "@opencode-ai/core/session/prompt"
|
|||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator"
|
||||
import { SessionRunner } from "@opencode-ai/core/session/runner"
|
||||
import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm"
|
||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool-registry"
|
||||
|
|
@ -131,7 +130,7 @@ describe("SessionRunnerLLM recorded", () => {
|
|||
|
||||
const messages = yield* session.context(sessionID)
|
||||
expect(messages).toHaveLength(2)
|
||||
expect(messages[0]).toEqual(prompt)
|
||||
expect(messages[0]).toMatchObject({ id: prompt.id, type: "user", text: "Say hello in one short sentence." })
|
||||
expect(messages[1]).toMatchObject({ type: "assistant", agent: "build", finish: "stop" })
|
||||
expect(messages[1]?.type === "assistant" ? messages[1].content : []).toMatchObject([
|
||||
{ type: "text", text: "Hello!" },
|
||||
|
|
@ -144,7 +143,8 @@ describe("SessionRunnerLLM recorded", () => {
|
|||
.orderBy(EventTable.seq)
|
||||
.all()).map((event) => event.type),
|
||||
).toEqual([
|
||||
"session.next.prompted.1",
|
||||
"session.next.prompt.admitted.1",
|
||||
"session.next.prompt.promoted.1",
|
||||
"session.next.step.started.1",
|
||||
"session.next.text.started.1",
|
||||
"session.next.text.ended.1",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
|
|||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionInput } from "@opencode-ai/core/session/input"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { Prompt } from "@opencode-ai/core/session/prompt"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
|
|
@ -29,7 +30,7 @@ import { SessionRunner } from "@opencode-ai/core/session/runner"
|
|||
import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm"
|
||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool-registry"
|
||||
import { SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
|
|
@ -240,6 +241,7 @@ const replaySessionProjection = (id: SessionV2.ID) =>
|
|||
.pipe(Effect.orDie)
|
||||
|
||||
yield* events.remove(id)
|
||||
yield* db.delete(SessionInputTable).where(eq(SessionInputTable.session_id, id)).run().pipe(Effect.orDie)
|
||||
yield* db.delete(SessionMessageTable).where(eq(SessionMessageTable.session_id, id)).run().pipe(Effect.orDie)
|
||||
yield* events.replayAll(
|
||||
recorded.map((event) => ({
|
||||
|
|
@ -425,7 +427,9 @@ describe("SessionRunnerLLM", () => {
|
|||
const message = yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Run automatically" }) })
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(yield* session.messages({ sessionID })).toEqual([message])
|
||||
expect(yield* session.messages({ sessionID })).toMatchObject([
|
||||
{ id: message.id, type: "user", text: "Run automatically" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -1217,9 +1221,11 @@ describe("SessionRunnerLLM", () => {
|
|||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Recover interrupted tool" }), resume: false })
|
||||
yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID)
|
||||
const assistant = yield* events.publish(SessionEvent.Step.Started, {
|
||||
yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID, Number.MAX_SAFE_INTEGER)
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
yield* events.publish(SessionEvent.Step.Started, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
timestamp: yield* DateTime.now,
|
||||
agent: "build",
|
||||
model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") },
|
||||
|
|
@ -1227,21 +1233,21 @@ describe("SessionRunnerLLM", () => {
|
|||
yield* events.publish(SessionEvent.Tool.Input.Started, {
|
||||
sessionID,
|
||||
timestamp: yield* DateTime.now,
|
||||
assistantMessageID: assistant.id,
|
||||
assistantMessageID,
|
||||
callID: "call-interrupted",
|
||||
name: "echo",
|
||||
})
|
||||
yield* events.publish(SessionEvent.Tool.Input.Ended, {
|
||||
sessionID,
|
||||
timestamp: yield* DateTime.now,
|
||||
assistantMessageID: assistant.id,
|
||||
assistantMessageID,
|
||||
callID: "call-interrupted",
|
||||
text: '{"text":"stale"}',
|
||||
})
|
||||
yield* events.publish(SessionEvent.Tool.Called, {
|
||||
sessionID,
|
||||
timestamp: yield* DateTime.now,
|
||||
assistantMessageID: assistant.id,
|
||||
assistantMessageID,
|
||||
callID: "call-interrupted",
|
||||
tool: "echo",
|
||||
input: { text: "stale" },
|
||||
|
|
@ -1279,9 +1285,11 @@ describe("SessionRunnerLLM", () => {
|
|||
prompt: new Prompt({ text: "Recover interrupted hosted tool" }),
|
||||
resume: false,
|
||||
})
|
||||
yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID)
|
||||
const assistant = yield* events.publish(SessionEvent.Step.Started, {
|
||||
yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID, Number.MAX_SAFE_INTEGER)
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
yield* events.publish(SessionEvent.Step.Started, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
timestamp: yield* DateTime.now,
|
||||
agent: "build",
|
||||
model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") },
|
||||
|
|
@ -1289,21 +1297,21 @@ describe("SessionRunnerLLM", () => {
|
|||
yield* events.publish(SessionEvent.Tool.Input.Started, {
|
||||
sessionID,
|
||||
timestamp: yield* DateTime.now,
|
||||
assistantMessageID: assistant.id,
|
||||
assistantMessageID,
|
||||
callID: "call-hosted-interrupted",
|
||||
name: "web_search",
|
||||
})
|
||||
yield* events.publish(SessionEvent.Tool.Input.Ended, {
|
||||
sessionID,
|
||||
timestamp: yield* DateTime.now,
|
||||
assistantMessageID: assistant.id,
|
||||
assistantMessageID,
|
||||
callID: "call-hosted-interrupted",
|
||||
text: '{"query":"stale"}',
|
||||
})
|
||||
yield* events.publish(SessionEvent.Tool.Called, {
|
||||
sessionID,
|
||||
timestamp: yield* DateTime.now,
|
||||
assistantMessageID: assistant.id,
|
||||
assistantMessageID,
|
||||
callID: "call-hosted-interrupted",
|
||||
tool: "web_search",
|
||||
input: { query: "stale" },
|
||||
|
|
@ -1337,9 +1345,11 @@ describe("SessionRunnerLLM", () => {
|
|||
prompt: new Prompt({ text: "Recover interrupted tool input" }),
|
||||
resume: false,
|
||||
})
|
||||
yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID)
|
||||
const assistant = yield* events.publish(SessionEvent.Step.Started, {
|
||||
yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID, Number.MAX_SAFE_INTEGER)
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
yield* events.publish(SessionEvent.Step.Started, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
timestamp: yield* DateTime.now,
|
||||
agent: "build",
|
||||
model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") },
|
||||
|
|
@ -1347,7 +1357,7 @@ describe("SessionRunnerLLM", () => {
|
|||
yield* events.publish(SessionEvent.Tool.Input.Started, {
|
||||
sessionID,
|
||||
timestamp: yield* DateTime.now,
|
||||
assistantMessageID: assistant.id,
|
||||
assistantMessageID,
|
||||
callID: "call-pending-interrupted",
|
||||
name: "echo",
|
||||
})
|
||||
|
|
@ -1414,7 +1424,7 @@ describe("SessionRunnerLLM", () => {
|
|||
const events = yield* EventV2.Service
|
||||
const defect = new Error("fail after prompt promotion")
|
||||
let fail = true
|
||||
yield* events.project(SessionEvent.Prompted, () => (fail ? Effect.die(defect) : Effect.void))
|
||||
yield* events.project(SessionEvent.PromptLifecycle.Promoted, () => (fail ? Effect.die(defect) : Effect.void))
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Recover promoted input" }), resume: false })
|
||||
|
||||
expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect)
|
||||
|
|
@ -1433,6 +1443,30 @@ describe("SessionRunnerLLM", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("does not strand a committed promotion when a post-commit listener defects", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
yield* events.listen((event) =>
|
||||
event.type === SessionEvent.PromptLifecycle.Promoted.type
|
||||
? Effect.die("fail after prompt promotion commits")
|
||||
: Effect.void,
|
||||
)
|
||||
yield* session.prompt({
|
||||
sessionID,
|
||||
prompt: new Prompt({ text: "Run committed promotion" }),
|
||||
resume: false,
|
||||
})
|
||||
|
||||
requests.length = 0
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(userTexts(requests[0]!)).toEqual(["Run committed promotion"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("runs different sessions concurrently", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
|
|
|
|||
|
|
@ -50,12 +50,14 @@ describe("Tool.Progress", () => {
|
|||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const assistantMessageID = (yield* service.publish(SessionEvent.Step.Started, {
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
yield* service.publish(SessionEvent.Step.Started, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
timestamp,
|
||||
agent: "build",
|
||||
model,
|
||||
})).id
|
||||
})
|
||||
const readAssistant = Effect.gen(function* () {
|
||||
const row = yield* db
|
||||
.select()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useEvent } from "@tui/context/event"
|
||||
import type {
|
||||
Event,
|
||||
SessionMessage,
|
||||
SessionMessageAssistant,
|
||||
SessionMessageAssistantReasoning,
|
||||
|
|
@ -54,6 +55,11 @@ function latestReasoning(assistant: SessionMessageAssistant | undefined, reasoni
|
|||
)
|
||||
}
|
||||
|
||||
function prepend(messages: SessionMessage[], message: SessionMessage) {
|
||||
if (messages.some((item) => item.id === message.id)) return
|
||||
messages.unshift(message)
|
||||
}
|
||||
|
||||
export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext({
|
||||
name: "SyncV2",
|
||||
init: () => {
|
||||
|
|
@ -67,6 +73,18 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
|||
|
||||
const event = useEvent()
|
||||
const sdk = useSDK()
|
||||
const applied = new Set<string>()
|
||||
const buffering = new Map<string, Event[]>()
|
||||
const syncing = new Map<string, Promise<void>>()
|
||||
|
||||
function duplicate(id: string) {
|
||||
if (applied.has(id)) return true
|
||||
applied.add(id)
|
||||
if (applied.size <= 1000) return false
|
||||
const oldest = applied.values().next()
|
||||
if (!oldest.done) applied.delete(oldest.value)
|
||||
return false
|
||||
}
|
||||
|
||||
function update(sessionID: string, fn: (messages: SessionMessage[]) => void) {
|
||||
setStore(
|
||||
|
|
@ -77,12 +95,41 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
|||
)
|
||||
}
|
||||
|
||||
event.subscribe((event) => {
|
||||
async function hydrate(sessionID: string) {
|
||||
const pending: Event[] = []
|
||||
const before = JSON.parse(JSON.stringify(store.messages[sessionID] ?? [])) as SessionMessage[]
|
||||
buffering.set(sessionID, pending)
|
||||
try {
|
||||
const response = await sdk.client.v2.session.messages({ sessionID })
|
||||
const messages = response.data?.data ?? []
|
||||
const snapshotIDs = new Set(messages.map((message) => message.id))
|
||||
setStore(
|
||||
"messages",
|
||||
sessionID,
|
||||
reconcile([...messages, ...before.filter((message) => !snapshotIDs.has(message.id))]),
|
||||
)
|
||||
buffering.delete(sessionID)
|
||||
for (const event of pending) apply(event)
|
||||
} catch (error) {
|
||||
buffering.delete(sessionID)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function sync(sessionID: string) {
|
||||
const existing = syncing.get(sessionID)
|
||||
if (existing) return existing
|
||||
const result = hydrate(sessionID).finally(() => syncing.delete(sessionID))
|
||||
syncing.set(sessionID, result)
|
||||
return result
|
||||
}
|
||||
|
||||
function apply(event: Event) {
|
||||
switch (event.type) {
|
||||
case "session.next.agent.switched":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
draft.unshift({
|
||||
id: event.id,
|
||||
prepend(draft, {
|
||||
id: event.properties.messageID,
|
||||
type: "agent-switched",
|
||||
agent: event.properties.agent,
|
||||
time: { created: event.properties.timestamp },
|
||||
|
|
@ -91,8 +138,8 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
|||
break
|
||||
case "session.next.model.switched":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
draft.unshift({
|
||||
id: event.id,
|
||||
prepend(draft, {
|
||||
id: event.properties.messageID,
|
||||
type: "model-switched",
|
||||
model: event.properties.model,
|
||||
time: { created: event.properties.timestamp },
|
||||
|
|
@ -101,8 +148,8 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
|||
break
|
||||
case "session.next.prompted": {
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
draft.unshift({
|
||||
id: event.id,
|
||||
prepend(draft, {
|
||||
id: event.properties.messageID,
|
||||
type: "user",
|
||||
text: event.properties.prompt.text,
|
||||
files: event.properties.prompt.files,
|
||||
|
|
@ -113,10 +160,25 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
|||
})
|
||||
break
|
||||
}
|
||||
case "session.next.prompt.admitted":
|
||||
break
|
||||
case "session.next.prompt.promoted":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
prepend(draft, {
|
||||
id: event.properties.messageID,
|
||||
type: "user",
|
||||
text: event.properties.prompt.text,
|
||||
files: event.properties.prompt.files,
|
||||
agents: event.properties.prompt.agents,
|
||||
references: event.properties.prompt.references,
|
||||
time: { created: event.properties.timeCreated },
|
||||
})
|
||||
})
|
||||
break
|
||||
case "session.next.synthetic":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
draft.unshift({
|
||||
id: event.id,
|
||||
prepend(draft, {
|
||||
id: event.properties.messageID,
|
||||
type: "synthetic",
|
||||
sessionID: event.properties.sessionID,
|
||||
text: event.properties.text,
|
||||
|
|
@ -126,8 +188,8 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
|||
break
|
||||
case "session.next.shell.started":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
draft.unshift({
|
||||
id: event.id,
|
||||
prepend(draft, {
|
||||
id: event.properties.messageID,
|
||||
type: "shell",
|
||||
callID: event.properties.callID,
|
||||
command: event.properties.command,
|
||||
|
|
@ -146,10 +208,11 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
|||
break
|
||||
case "session.next.step.started":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
if (draft.some((message) => message.id === event.properties.assistantMessageID)) return
|
||||
const currentAssistant = activeAssistant(draft)
|
||||
if (currentAssistant) currentAssistant.time.completed = event.properties.timestamp
|
||||
draft.unshift({
|
||||
id: event.id,
|
||||
prepend(draft, {
|
||||
id: event.properties.assistantMessageID,
|
||||
type: "assistant",
|
||||
agent: event.properties.agent,
|
||||
model: event.properties.model,
|
||||
|
|
@ -182,18 +245,28 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
|||
break
|
||||
case "session.next.text.started":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
activeAssistant(draft)?.content.push({ type: "text", id: event.properties.textID, text: "" })
|
||||
ownedAssistant(draft, event.properties.assistantMessageID)?.content.push({
|
||||
type: "text",
|
||||
id: event.properties.textID,
|
||||
text: "",
|
||||
})
|
||||
})
|
||||
break
|
||||
case "session.next.text.delta":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestText(activeAssistant(draft), event.properties.textID)
|
||||
const match = latestText(
|
||||
ownedAssistant(draft, event.properties.assistantMessageID),
|
||||
event.properties.textID,
|
||||
)
|
||||
if (match) match.text += event.properties.delta
|
||||
})
|
||||
break
|
||||
case "session.next.text.ended":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestText(activeAssistant(draft), event.properties.textID)
|
||||
const match = latestText(
|
||||
ownedAssistant(draft, event.properties.assistantMessageID),
|
||||
event.properties.textID,
|
||||
)
|
||||
if (match) match.text = event.properties.text
|
||||
})
|
||||
break
|
||||
|
|
@ -263,7 +336,11 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
|||
content: [...event.properties.content],
|
||||
result: event.properties.result,
|
||||
}
|
||||
match.provider = event.properties.provider
|
||||
match.provider = {
|
||||
executed: event.properties.provider.executed || match.provider?.executed === true,
|
||||
metadata: match.provider?.metadata,
|
||||
resultMetadata: event.properties.provider.metadata,
|
||||
}
|
||||
match.time.completed = event.properties.timestamp
|
||||
})
|
||||
break
|
||||
|
|
@ -282,13 +359,17 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
|||
content: match.state.status === "running" ? match.state.content : [],
|
||||
result: event.properties.result,
|
||||
}
|
||||
match.provider = event.properties.provider
|
||||
match.provider = {
|
||||
executed: event.properties.provider.executed || match.provider?.executed === true,
|
||||
metadata: match.provider?.metadata,
|
||||
resultMetadata: event.properties.provider.metadata,
|
||||
}
|
||||
match.time.completed = event.properties.timestamp
|
||||
})
|
||||
break
|
||||
case "session.next.reasoning.started":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
activeAssistant(draft)?.content.push({
|
||||
ownedAssistant(draft, event.properties.assistantMessageID)?.content.push({
|
||||
type: "reasoning",
|
||||
id: event.properties.reasoningID,
|
||||
text: "",
|
||||
|
|
@ -298,13 +379,19 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
|||
break
|
||||
case "session.next.reasoning.delta":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestReasoning(activeAssistant(draft), event.properties.reasoningID)
|
||||
const match = latestReasoning(
|
||||
ownedAssistant(draft, event.properties.assistantMessageID),
|
||||
event.properties.reasoningID,
|
||||
)
|
||||
if (match) match.text += event.properties.delta
|
||||
})
|
||||
break
|
||||
case "session.next.reasoning.ended":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestReasoning(activeAssistant(draft), event.properties.reasoningID)
|
||||
const match = latestReasoning(
|
||||
ownedAssistant(draft, event.properties.assistantMessageID),
|
||||
event.properties.reasoningID,
|
||||
)
|
||||
if (match) {
|
||||
match.text = event.properties.text
|
||||
if (event.properties.providerMetadata !== undefined)
|
||||
|
|
@ -316,8 +403,8 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
|||
break
|
||||
case "session.next.compaction.started":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
draft.unshift({
|
||||
id: event.id,
|
||||
prepend(draft, {
|
||||
id: event.properties.messageID,
|
||||
type: "compaction",
|
||||
reason: event.properties.reason,
|
||||
summary: "",
|
||||
|
|
@ -340,16 +427,20 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
|||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
event.subscribe((event) => {
|
||||
if (duplicate(event.id)) return
|
||||
if ("sessionID" in event.properties && typeof event.properties.sessionID === "string")
|
||||
buffering.get(event.properties.sessionID)?.push(event)
|
||||
apply(event)
|
||||
})
|
||||
|
||||
const result = {
|
||||
data: store,
|
||||
session: {
|
||||
message: {
|
||||
async sync(sessionID: string) {
|
||||
const response = await sdk.client.v2.session.messages({ sessionID })
|
||||
setStore("messages", sessionID, reconcile(response.data?.data ?? []))
|
||||
},
|
||||
sync,
|
||||
fromSession(sessionID: string) {
|
||||
const messages = store.messages[sessionID]
|
||||
if (!messages) return []
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ const EventSchema = Schema.Union([
|
|||
.values()
|
||||
.map((definition) =>
|
||||
Schema.Struct({
|
||||
id: Schema.String,
|
||||
id: EventV2.ID,
|
||||
type: Schema.Literal(definition.type),
|
||||
properties: definition.data,
|
||||
}).annotate({ identifier: `Event.${definition.type}` }),
|
||||
|
|
|
|||
|
|
@ -20,10 +20,10 @@ const SyncEventSchemas = EventV2.registry
|
|||
return [
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("sync"),
|
||||
id: Schema.String,
|
||||
id: EventV2.ID,
|
||||
syncEvent: Schema.Struct({
|
||||
type: Schema.Literal(EventV2.versionedType(definition.type, definition.sync.version)),
|
||||
id: Schema.String,
|
||||
id: EventV2.ID,
|
||||
seq: Schema.Finite,
|
||||
aggregateID: Schema.String,
|
||||
data: definition.data,
|
||||
|
|
@ -41,7 +41,7 @@ const GlobalEventSchema = Schema.Struct({
|
|||
...EventV2.registry
|
||||
.values()
|
||||
.map((definition) =>
|
||||
Schema.Struct({ id: Schema.String, type: Schema.Literal(definition.type), properties: definition.data }),
|
||||
Schema.Struct({ id: EventV2.ID, type: Schema.Literal(definition.type), properties: definition.data }),
|
||||
)
|
||||
.toArray(),
|
||||
InstanceDisposed,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { NonNegativeInt } from "@opencode-ai/core/schema"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
|
|
@ -9,7 +10,7 @@ import { described } from "./metadata"
|
|||
|
||||
const root = "/sync"
|
||||
export const ReplayEvent = Schema.Struct({
|
||||
id: Schema.String,
|
||||
id: EventV2.ID,
|
||||
aggregateID: Schema.String,
|
||||
seq: NonNegativeInt,
|
||||
type: Schema.String,
|
||||
|
|
@ -27,7 +28,7 @@ export const SessionPayload = Schema.Struct({
|
|||
})
|
||||
export const HistoryPayload = Schema.Record(Schema.String, NonNegativeInt)
|
||||
export const HistoryEvent = Schema.Struct({
|
||||
id: Schema.String,
|
||||
id: EventV2.ID,
|
||||
aggregate_id: Schema.String,
|
||||
seq: NonNegativeInt,
|
||||
type: Schema.String,
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl
|
|||
|
||||
const replay = Effect.fn("SyncHttpApi.replay")(function* (ctx: { payload: typeof ReplayPayload.Type }) {
|
||||
const payload: EventV2.SerializedEvent[] = ctx.payload.events.map((event) => ({
|
||||
id: EventV2.ID.make(event.id),
|
||||
id: event.id,
|
||||
aggregateID: event.aggregateID,
|
||||
seq: event.seq,
|
||||
type: event.type,
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { Plugin } from "@/plugin"
|
|||
import { Config } from "@/config/config"
|
||||
import { NotFoundError } from "@/storage/storage"
|
||||
|
||||
import { Effect, Layer, Context, Schema } from "effect"
|
||||
import { Effect, Layer, Context } from "effect"
|
||||
import * as DateTime from "effect/DateTime"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { isOverflow as overflow, usable } from "./overflow"
|
||||
|
|
@ -20,6 +20,7 @@ import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
|||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
|
|
@ -609,6 +610,7 @@ export const layer = Layer.effect(
|
|||
if (flags.experimentalEventSystem) {
|
||||
yield* events.publish(SessionEvent.Compaction.Started, {
|
||||
sessionID: input.sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
reason: input.auto ? "auto" : "manual",
|
||||
})
|
||||
|
|
|
|||
|
|
@ -25,13 +25,13 @@ import { isRecord } from "@/util/record"
|
|||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import * as DateTime from "effect/DateTime"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { toolFileSourceFromUri, Usage, type LLMEvent } from "@opencode-ai/llm"
|
||||
import { ToolOutput } from "@opencode-ai/core/tool-output"
|
||||
import type { EventV2 } from "@opencode-ai/core/event"
|
||||
|
||||
const DOOM_LOOP_THRESHOLD = 3
|
||||
const log = Log.create({ service: "session.processor" })
|
||||
|
|
@ -67,7 +67,7 @@ export interface Interface {
|
|||
}
|
||||
|
||||
type ToolCall = {
|
||||
assistantMessageID?: EventV2.ID
|
||||
assistantMessageID?: SessionMessage.ID
|
||||
partID: SessionV1.ToolPart["id"]
|
||||
messageID: SessionV1.ToolPart["messageID"]
|
||||
sessionID: SessionV1.ToolPart["sessionID"]
|
||||
|
|
@ -85,7 +85,7 @@ interface ProcessorContext extends Input {
|
|||
currentText: SessionV1.TextPart | undefined
|
||||
currentTextID: string | undefined
|
||||
reasoningMap: Record<string, SessionV1.ReasoningPart>
|
||||
v2AssistantMessageID: EventV2.ID | undefined
|
||||
v2AssistantMessageID: SessionMessage.ID | undefined
|
||||
}
|
||||
|
||||
type StreamEvent = LLMEvent
|
||||
|
|
@ -129,6 +129,7 @@ export const layer = Layer.effect(
|
|||
reasoningMap: {},
|
||||
v2AssistantMessageID: undefined,
|
||||
}
|
||||
const mirrorAssistant = flags.experimentalEventSystem && !input.assistantMessage.summary
|
||||
let aborted = false
|
||||
const slog = log.clone().tag("session.id", input.sessionID).tag("messageID", input.assistantMessage.id)
|
||||
|
||||
|
|
@ -146,8 +147,10 @@ export const layer = Layer.effect(
|
|||
|
||||
const ensureV2AssistantMessage = Effect.fn("SessionProcessor.ensureV2AssistantMessage")(function* () {
|
||||
if (ctx.v2AssistantMessageID) return ctx.v2AssistantMessageID
|
||||
ctx.v2AssistantMessageID = (yield* events.publish(SessionEvent.Step.Started, {
|
||||
ctx.v2AssistantMessageID = SessionMessage.ID.create()
|
||||
yield* events.publish(SessionEvent.Step.Started, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID: ctx.v2AssistantMessageID,
|
||||
agent: input.assistantMessage.agent,
|
||||
model: {
|
||||
id: ModelV2.ID.make(ctx.model.id),
|
||||
|
|
@ -156,7 +159,7 @@ export const layer = Layer.effect(
|
|||
},
|
||||
snapshot: ctx.snapshot,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})).id
|
||||
})
|
||||
return ctx.v2AssistantMessageID
|
||||
})
|
||||
|
||||
|
|
@ -249,9 +252,10 @@ export const layer = Layer.effect(
|
|||
const finishReasoning = Effect.fn("SessionProcessor.finishReasoning")(function* (reasoningID: string) {
|
||||
if (!(reasoningID in ctx.reasoningMap)) return
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
if (mirrorAssistant) {
|
||||
yield* events.publish(SessionEvent.Reasoning.Ended, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID: yield* currentV2AssistantMessage(),
|
||||
reasoningID,
|
||||
text: ctx.reasoningMap[reasoningID].text,
|
||||
providerMetadata: ctx.reasoningMap[reasoningID].metadata,
|
||||
|
|
@ -266,23 +270,29 @@ export const layer = Layer.effect(
|
|||
})
|
||||
|
||||
const flushV2Fragments = Effect.fn("SessionProcessor.flushV2Fragments")(function* () {
|
||||
if (!flags.experimentalEventSystem) return
|
||||
if (!mirrorAssistant) return
|
||||
if (!ctx.assistantMessage.summary && ctx.currentText && ctx.currentTextID) {
|
||||
yield* events.publish(SessionEvent.Text.Ended, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID: yield* currentV2AssistantMessage(),
|
||||
textID: ctx.currentTextID,
|
||||
text: ctx.currentText.text,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
yield* Effect.forEach(Object.entries(ctx.reasoningMap), ([reasoningID, part]) =>
|
||||
events.publish(SessionEvent.Reasoning.Ended, {
|
||||
sessionID: ctx.sessionID,
|
||||
reasoningID,
|
||||
text: part.text,
|
||||
providerMetadata: part.metadata,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
}),
|
||||
currentV2AssistantMessage().pipe(
|
||||
Effect.flatMap((assistantMessageID) =>
|
||||
events.publish(SessionEvent.Reasoning.Ended, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID,
|
||||
reasoningID,
|
||||
text: part.text,
|
||||
providerMetadata: part.metadata,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
|
|
@ -307,7 +317,7 @@ export const layer = Layer.effect(
|
|||
return { call: ctx.toolcalls[input.id], part }
|
||||
}
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
const assistantMessageID = flags.experimentalEventSystem ? yield* ensureV2AssistantMessage() : undefined
|
||||
const assistantMessageID = mirrorAssistant ? yield* ensureV2AssistantMessage() : undefined
|
||||
if (assistantMessageID) {
|
||||
yield* events.publish(SessionEvent.Tool.Input.Started, {
|
||||
sessionID: ctx.sessionID,
|
||||
|
|
@ -367,9 +377,10 @@ export const layer = Layer.effect(
|
|||
case "reasoning-start":
|
||||
if (value.id in ctx.reasoningMap) return
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
if (mirrorAssistant) {
|
||||
yield* events.publish(SessionEvent.Reasoning.Started, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID: yield* ensureV2AssistantMessage(),
|
||||
reasoningID: value.id,
|
||||
providerMetadata: value.providerMetadata,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
|
|
@ -392,9 +403,10 @@ export const layer = Layer.effect(
|
|||
if (!(value.id in ctx.reasoningMap)) return
|
||||
ctx.reasoningMap[value.id].text += value.text
|
||||
if (value.providerMetadata) ctx.reasoningMap[value.id].metadata = value.providerMetadata
|
||||
if (flags.experimentalEventSystem) {
|
||||
if (mirrorAssistant) {
|
||||
yield* events.publish(SessionEvent.Reasoning.Delta, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID: yield* currentV2AssistantMessage(),
|
||||
reasoningID: value.id,
|
||||
delta: value.text,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
|
|
@ -426,9 +438,7 @@ export const layer = Layer.effect(
|
|||
case "tool-input-delta":
|
||||
{
|
||||
const toolCall = yield* ensureToolCall(value)
|
||||
const assistantMessageID = flags.experimentalEventSystem
|
||||
? yield* requireV2AssistantMessage(toolCall.call)
|
||||
: undefined
|
||||
const assistantMessageID = mirrorAssistant ? yield* requireV2AssistantMessage(toolCall.call) : undefined
|
||||
if (assistantMessageID) {
|
||||
yield* events.publish(SessionEvent.Tool.Input.Delta, {
|
||||
sessionID: ctx.sessionID,
|
||||
|
|
@ -445,7 +455,7 @@ export const layer = Layer.effect(
|
|||
case "tool-input-end": {
|
||||
const toolCall = yield* ensureToolCall(value)
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
if (mirrorAssistant) {
|
||||
const assistantMessageID = yield* requireV2AssistantMessage(toolCall.call)
|
||||
yield* events.publish(SessionEvent.Tool.Input.Ended, {
|
||||
sessionID: ctx.sessionID,
|
||||
|
|
@ -467,7 +477,7 @@ export const layer = Layer.effect(
|
|||
const input = isRecord(value.input) ? value.input : { value: value.input }
|
||||
if (!toolCall.call.inputEnded) {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
if (mirrorAssistant) {
|
||||
const assistantMessageID = yield* requireV2AssistantMessage(toolCall.call)
|
||||
yield* events.publish(SessionEvent.Tool.Input.Ended, {
|
||||
sessionID: ctx.sessionID,
|
||||
|
|
@ -479,7 +489,7 @@ export const layer = Layer.effect(
|
|||
}
|
||||
}
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
if (mirrorAssistant) {
|
||||
const assistantMessageID = yield* requireV2AssistantMessage(toolCall.call)
|
||||
yield* events.publish(SessionEvent.Tool.Called, {
|
||||
sessionID: ctx.sessionID,
|
||||
|
|
@ -545,7 +555,7 @@ export const layer = Layer.effect(
|
|||
if (!toolCall && value.result.type === "error") return
|
||||
if (value.result.type === "error") {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
if (mirrorAssistant) {
|
||||
const assistantMessageID = yield* requireV2AssistantMessage(toolCall?.call)
|
||||
yield* events.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID: ctx.sessionID,
|
||||
|
|
@ -586,7 +596,7 @@ export const layer = Layer.effect(
|
|||
attachments: attachments.length ? attachments : undefined,
|
||||
}
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
if (mirrorAssistant) {
|
||||
const assistantMessageID = yield* requireV2AssistantMessage(toolCall?.call)
|
||||
const content = [
|
||||
ToolOutput.text({ type: "text", text: output.output }),
|
||||
|
|
@ -642,7 +652,7 @@ export const layer = Layer.effect(
|
|||
case "tool-error": {
|
||||
const toolCall = yield* readToolCall(value.id)
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
if (mirrorAssistant) {
|
||||
const assistantMessageID = yield* requireV2AssistantMessage(toolCall?.call)
|
||||
yield* events.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID: ctx.sessionID,
|
||||
|
|
@ -670,7 +680,7 @@ export const layer = Layer.effect(
|
|||
if (!ctx.snapshot) ctx.snapshot = yield* snapshot.track()
|
||||
if (!ctx.assistantMessage.summary) {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
if (mirrorAssistant) {
|
||||
yield* ensureV2AssistantMessage()
|
||||
}
|
||||
}
|
||||
|
|
@ -693,7 +703,7 @@ export const layer = Layer.effect(
|
|||
})
|
||||
if (!ctx.assistantMessage.summary) {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
if (mirrorAssistant) {
|
||||
yield* events.publish(SessionEvent.Step.Ended, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID: yield* currentV2AssistantMessage(),
|
||||
|
|
@ -752,9 +762,10 @@ export const layer = Layer.effect(
|
|||
case "text-start":
|
||||
if (!ctx.assistantMessage.summary) {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
if (mirrorAssistant) {
|
||||
yield* events.publish(SessionEvent.Text.Started, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID: yield* ensureV2AssistantMessage(),
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
textID: value.id,
|
||||
})
|
||||
|
|
@ -777,9 +788,10 @@ export const layer = Layer.effect(
|
|||
if (!ctx.currentText) return
|
||||
ctx.currentText.text += value.text
|
||||
if (value.providerMetadata) ctx.currentText.metadata = value.providerMetadata
|
||||
if (flags.experimentalEventSystem) {
|
||||
if (mirrorAssistant) {
|
||||
yield* events.publish(SessionEvent.Text.Delta, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID: yield* currentV2AssistantMessage(),
|
||||
textID: value.id,
|
||||
delta: value.text,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
|
|
@ -809,9 +821,10 @@ export const layer = Layer.effect(
|
|||
)).text
|
||||
if (!ctx.assistantMessage.summary) {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
if (mirrorAssistant) {
|
||||
yield* events.publish(SessionEvent.Text.Ended, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID: yield* currentV2AssistantMessage(),
|
||||
text: ctx.currentText.text,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
textID: value.id,
|
||||
|
|
@ -876,7 +889,7 @@ export const layer = Layer.effect(
|
|||
const match = yield* readToolCall(toolCallID)
|
||||
if (!match) continue
|
||||
const part = match.part
|
||||
if (flags.experimentalEventSystem && match.call.assistantMessageID) {
|
||||
if (mirrorAssistant && match.call.assistantMessageID) {
|
||||
yield* events.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID: match.call.assistantMessageID,
|
||||
|
|
@ -922,7 +935,7 @@ export const layer = Layer.effect(
|
|||
}
|
||||
if (!ctx.assistantMessage.summary) {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
if (mirrorAssistant) {
|
||||
yield* events.publish(SessionEvent.Step.Failed, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID: yield* ensureV2AssistantMessage(),
|
||||
|
|
@ -979,7 +992,7 @@ export const layer = Layer.effect(
|
|||
parse,
|
||||
set: (info) => {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
const event = flags.experimentalEventSystem
|
||||
const event = mirrorAssistant
|
||||
? events.publish(SessionEvent.Retried, {
|
||||
sessionID: ctx.sessionID,
|
||||
attempt: info.attempt,
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
|
|||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { AgentAttachment, FileAttachment, Prompt, ReferenceAttachment, Source } from "@opencode-ai/core/session/prompt"
|
||||
|
|
@ -563,6 +564,7 @@ export const layer = Layer.effect(
|
|||
if (flags.experimentalEventSystem) {
|
||||
yield* events.publish(SessionEvent.Shell.Started, {
|
||||
sessionID: input.sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: DateTime.makeUnsafe(started),
|
||||
callID: part.callID,
|
||||
command: input.command,
|
||||
|
|
@ -738,6 +740,7 @@ export const layer = Layer.effect(
|
|||
if (current?.agent !== info.agent) {
|
||||
yield* events.publish(SessionEvent.AgentSwitched, {
|
||||
sessionID: input.sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: DateTime.makeUnsafe(info.time.created),
|
||||
agent: info.agent,
|
||||
})
|
||||
|
|
@ -749,6 +752,7 @@ export const layer = Layer.effect(
|
|||
) {
|
||||
yield* events.publish(SessionEvent.ModelSwitched, {
|
||||
sessionID: input.sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: DateTime.makeUnsafe(info.time.created),
|
||||
model: {
|
||||
id: ModelV2.ID.make(info.model.modelID),
|
||||
|
|
@ -1190,6 +1194,7 @@ export const layer = Layer.effect(
|
|||
if (flags.experimentalEventSystem) {
|
||||
yield* events.publish(SessionEvent.Prompted, {
|
||||
sessionID: input.sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: DateTime.makeUnsafe(info.time.created),
|
||||
delivery: "steer",
|
||||
prompt: new Prompt({
|
||||
|
|
@ -1205,6 +1210,7 @@ export const layer = Layer.effect(
|
|||
if (flags.experimentalEventSystem) {
|
||||
yield* events.publish(SessionEvent.Synthetic, {
|
||||
sessionID: input.sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: DateTime.makeUnsafe(info.time.created),
|
||||
text,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { onMount } from "solid-js"
|
|||
import { ProjectProvider } from "../../../src/cli/cmd/tui/context/project"
|
||||
import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk"
|
||||
import { SyncProviderV2, useSyncV2 } from "../../../src/cli/cmd/tui/context/sync-v2"
|
||||
import { createEventSource, createFetch, directory } from "../../fixture/tui-sdk"
|
||||
import { createEventSource, createFetch, directory, json } from "../../fixture/tui-sdk"
|
||||
|
||||
async function wait(fn: () => boolean, timeout = 2000) {
|
||||
const start = Date.now()
|
||||
|
|
@ -20,6 +20,12 @@ function global(payload: Event): GlobalEvent {
|
|||
return { directory, project: "proj_test", payload }
|
||||
}
|
||||
|
||||
function emitTwice(events: ReturnType<typeof createEventSource>, payload: Event) {
|
||||
const event = global(payload)
|
||||
events.emit(event)
|
||||
events.emit(event)
|
||||
}
|
||||
|
||||
test("sync v2 settles pending tools when a live failure arrives", async () => {
|
||||
const events = createEventSource()
|
||||
const calls = createFetch()
|
||||
|
|
@ -47,63 +53,68 @@ test("sync v2 settles pending tools when a live failure arrives", async () => {
|
|||
|
||||
try {
|
||||
await mounted
|
||||
events.emit(
|
||||
global({
|
||||
id: "agent-1",
|
||||
type: "session.next.agent.switched",
|
||||
properties: { sessionID: "session-1", timestamp: 0, agent: "build" },
|
||||
}),
|
||||
)
|
||||
events.emit(
|
||||
global({
|
||||
id: "model-1",
|
||||
type: "session.next.model.switched",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
timestamp: 0,
|
||||
model: { id: "model-1", providerID: "provider-1" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
events.emit(
|
||||
global({
|
||||
id: "assistant-1",
|
||||
type: "session.next.step.started",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
timestamp: 1,
|
||||
agent: "build",
|
||||
model: { id: "model-1", providerID: "provider-1" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
events.emit(
|
||||
global({
|
||||
id: "input-1",
|
||||
type: "session.next.tool.input.started",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
timestamp: 2,
|
||||
assistantMessageID: "assistant-1",
|
||||
callID: "call-1",
|
||||
name: "bash",
|
||||
},
|
||||
}),
|
||||
)
|
||||
events.emit(
|
||||
global({
|
||||
id: "failed-1",
|
||||
type: "session.next.tool.failed",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
timestamp: 3,
|
||||
assistantMessageID: "assistant-1",
|
||||
callID: "call-1",
|
||||
error: { type: "unknown", message: "aborted" },
|
||||
provider: { executed: false },
|
||||
},
|
||||
}),
|
||||
)
|
||||
emitTwice(events, {
|
||||
id: "evt_agent_1",
|
||||
type: "session.next.agent.switched",
|
||||
properties: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 0, agent: "build" },
|
||||
})
|
||||
emitTwice(events, {
|
||||
id: "evt_model_1",
|
||||
type: "session.next.model.switched",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
messageID: "msg_model_1",
|
||||
timestamp: 0,
|
||||
model: { id: "model-1", providerID: "provider-1" },
|
||||
},
|
||||
})
|
||||
emitTwice(events, {
|
||||
id: "evt_step_started_1",
|
||||
type: "session.next.step.started",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
assistantMessageID: "msg_explicit_assistant_9",
|
||||
timestamp: 1,
|
||||
agent: "build",
|
||||
model: { id: "model-1", providerID: "provider-1" },
|
||||
},
|
||||
})
|
||||
emitTwice(events, {
|
||||
id: "evt_input_1",
|
||||
type: "session.next.tool.input.started",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
assistantMessageID: "msg_explicit_assistant_9",
|
||||
timestamp: 2,
|
||||
callID: "call-1",
|
||||
name: "bash",
|
||||
},
|
||||
})
|
||||
emitTwice(events, {
|
||||
id: "evt_called_1",
|
||||
type: "session.next.tool.called",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
timestamp: 2,
|
||||
assistantMessageID: "msg_explicit_assistant_9",
|
||||
callID: "call-1",
|
||||
tool: "bash",
|
||||
input: {},
|
||||
provider: { executed: false, metadata: { fake: { call: true } } },
|
||||
},
|
||||
})
|
||||
emitTwice(events, {
|
||||
id: "evt_failed_1",
|
||||
type: "session.next.tool.failed",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
timestamp: 3,
|
||||
assistantMessageID: "msg_explicit_assistant_9",
|
||||
callID: "call-1",
|
||||
error: { type: "unknown", message: "aborted" },
|
||||
provider: { executed: false, metadata: { fake: { result: true } } },
|
||||
},
|
||||
})
|
||||
|
||||
await wait(() => {
|
||||
const assistant = sync.session.message.fromSession("session-1")[0]
|
||||
|
|
@ -117,6 +128,7 @@ test("sync v2 settles pending tools when a live failure arrives", async () => {
|
|||
const assistant = sync.session.message.fromSession("session-1")[0]
|
||||
expect(assistant?.type).toBe("assistant")
|
||||
if (assistant?.type !== "assistant") return
|
||||
expect(assistant.id).toBe("msg_explicit_assistant_9")
|
||||
const tool = assistant.content[0]
|
||||
expect(tool?.type).toBe("tool")
|
||||
if (tool?.type !== "tool") return
|
||||
|
|
@ -126,6 +138,11 @@ test("sync v2 settles pending tools when a live failure arrives", async () => {
|
|||
expect(tool.state.input).toEqual({})
|
||||
expect(tool.state.structured).toEqual({})
|
||||
expect(tool.state.content).toEqual([])
|
||||
expect(tool.provider).toEqual({
|
||||
executed: false,
|
||||
metadata: { fake: { call: true } },
|
||||
resultMetadata: { fake: { result: true } },
|
||||
})
|
||||
expect(sync.session.message.fromSession("session-1").map((message) => message.type)).toEqual([
|
||||
"assistant",
|
||||
"model-switched",
|
||||
|
|
@ -135,3 +152,358 @@ test("sync v2 settles pending tools when a live failure arrives", async () => {
|
|||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("sync v2 renders admitted prompts only after promotion", async () => {
|
||||
const events = createEventSource()
|
||||
const calls = createFetch()
|
||||
let sync!: ReturnType<typeof useSyncV2>
|
||||
let ready!: () => void
|
||||
const mounted = new Promise<void>((resolve) => {
|
||||
ready = resolve
|
||||
})
|
||||
|
||||
function Probe() {
|
||||
sync = useSyncV2()
|
||||
onMount(ready)
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||
<ProjectProvider>
|
||||
<SyncProviderV2>
|
||||
<Probe />
|
||||
</SyncProviderV2>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
))
|
||||
|
||||
try {
|
||||
await mounted
|
||||
emitTwice(events, {
|
||||
id: "evt_admitted_1",
|
||||
type: "session.next.prompt.admitted",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
messageID: "msg_user_1",
|
||||
timestamp: 0,
|
||||
prompt: { text: "hello" },
|
||||
delivery: "steer",
|
||||
},
|
||||
})
|
||||
expect(sync.session.message.fromSession("session-1")).toEqual([])
|
||||
|
||||
emitTwice(events, {
|
||||
id: "evt_promoted_1",
|
||||
type: "session.next.prompt.promoted",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
messageID: "msg_user_1",
|
||||
timestamp: 1,
|
||||
prompt: { text: "hello" },
|
||||
timeCreated: 0,
|
||||
},
|
||||
})
|
||||
|
||||
await wait(() => sync.session.message.fromSession("session-1").length === 1)
|
||||
const message = sync.session.message.fromSession("session-1")[0]
|
||||
expect(message?.type).toBe("user")
|
||||
if (message?.type !== "user") return
|
||||
expect(message).toMatchObject({ id: "msg_user_1", text: "hello" })
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("sync v2 renders a promoted prompt when admission was missed", async () => {
|
||||
const events = createEventSource()
|
||||
const calls = createFetch()
|
||||
let sync!: ReturnType<typeof useSyncV2>
|
||||
let ready!: () => void
|
||||
const mounted = new Promise<void>((resolve) => {
|
||||
ready = resolve
|
||||
})
|
||||
|
||||
function Probe() {
|
||||
sync = useSyncV2()
|
||||
onMount(ready)
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||
<ProjectProvider>
|
||||
<SyncProviderV2>
|
||||
<Probe />
|
||||
</SyncProviderV2>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
))
|
||||
|
||||
try {
|
||||
await mounted
|
||||
emitTwice(events, {
|
||||
id: "evt_promoted_1",
|
||||
type: "session.next.prompt.promoted",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
messageID: "msg_user_1",
|
||||
timestamp: 1,
|
||||
prompt: { text: "hello" },
|
||||
timeCreated: 0,
|
||||
},
|
||||
})
|
||||
|
||||
await wait(() => sync.session.message.fromSession("session-1").length === 1)
|
||||
expect(sync.session.message.fromSession("session-1")[0]?.id).toBe("msg_user_1")
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("sync v2 preserves live events while snapshot hydration is in flight", async () => {
|
||||
const events = createEventSource()
|
||||
const response = Promise.withResolvers<Response>()
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === "/api/session/session-1/message") return response.promise
|
||||
return undefined
|
||||
})
|
||||
let sync!: ReturnType<typeof useSyncV2>
|
||||
let ready!: () => void
|
||||
const mounted = new Promise<void>((resolve) => {
|
||||
ready = resolve
|
||||
})
|
||||
|
||||
function Probe() {
|
||||
sync = useSyncV2()
|
||||
onMount(ready)
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||
<ProjectProvider>
|
||||
<SyncProviderV2>
|
||||
<Probe />
|
||||
</SyncProviderV2>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
))
|
||||
|
||||
try {
|
||||
await mounted
|
||||
const hydration = sync.session.message.sync("session-1")
|
||||
emitTwice(events, {
|
||||
id: "evt_agent_1",
|
||||
type: "session.next.agent.switched",
|
||||
properties: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 0, agent: "build" },
|
||||
})
|
||||
response.resolve(json({ data: [] }))
|
||||
await hydration
|
||||
|
||||
expect(sync.session.message.fromSession("session-1").map((message) => [message.id, message.type])).toEqual([
|
||||
["msg_agent_1", "agent-switched"],
|
||||
])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("sync v2 replaces stale cached rows while preserving in-flight live rows", async () => {
|
||||
const events = createEventSource()
|
||||
const response = Promise.withResolvers<Response>()
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === "/api/session/session-1/message") return response.promise
|
||||
return undefined
|
||||
})
|
||||
let sync!: ReturnType<typeof useSyncV2>
|
||||
let ready!: () => void
|
||||
const mounted = new Promise<void>((resolve) => {
|
||||
ready = resolve
|
||||
})
|
||||
|
||||
function Probe() {
|
||||
sync = useSyncV2()
|
||||
onMount(ready)
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||
<ProjectProvider>
|
||||
<SyncProviderV2>
|
||||
<Probe />
|
||||
</SyncProviderV2>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
))
|
||||
|
||||
try {
|
||||
await mounted
|
||||
emitTwice(events, {
|
||||
id: "evt_promoted_1",
|
||||
type: "session.next.prompt.promoted",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
messageID: "msg_user_1",
|
||||
timestamp: 1,
|
||||
prompt: { text: "stale" },
|
||||
timeCreated: 0,
|
||||
},
|
||||
})
|
||||
await wait(() => sync.session.message.fromSession("session-1")[0]?.id === "msg_user_1")
|
||||
const hydration = sync.session.message.sync("session-1")
|
||||
emitTwice(events, {
|
||||
id: "evt_agent_1",
|
||||
type: "session.next.agent.switched",
|
||||
properties: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 2, agent: "build" },
|
||||
})
|
||||
await wait(() => sync.session.message.fromSession("session-1")[0]?.id === "msg_agent_1")
|
||||
response.resolve(
|
||||
json({
|
||||
data: [{ id: "msg_user_1", type: "user", text: "fresh", time: { created: 0 } }],
|
||||
}),
|
||||
)
|
||||
await hydration
|
||||
|
||||
expect(sync.session.message.fromSession("session-1").map((message) => [message.id, message.type])).toEqual([
|
||||
["msg_agent_1", "agent-switched"],
|
||||
["msg_user_1", "user"],
|
||||
])
|
||||
expect(sync.session.message.fromSession("session-1")[1]).toMatchObject({ text: "fresh" })
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("sync v2 preserves snapshot order and metadata for in-flight updates", async () => {
|
||||
const events = createEventSource()
|
||||
const response = Promise.withResolvers<Response>()
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === "/api/session/session-1/message") return response.promise
|
||||
return undefined
|
||||
})
|
||||
let sync!: ReturnType<typeof useSyncV2>
|
||||
let ready!: () => void
|
||||
const mounted = new Promise<void>((resolve) => {
|
||||
ready = resolve
|
||||
})
|
||||
|
||||
function Probe() {
|
||||
sync = useSyncV2()
|
||||
onMount(ready)
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||
<ProjectProvider>
|
||||
<SyncProviderV2>
|
||||
<Probe />
|
||||
</SyncProviderV2>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
))
|
||||
|
||||
try {
|
||||
await mounted
|
||||
emitTwice(events, {
|
||||
id: "evt_step_older",
|
||||
type: "session.next.step.started",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
assistantMessageID: "msg_assistant_older",
|
||||
timestamp: 0,
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
})
|
||||
emitTwice(events, {
|
||||
id: "evt_step_1",
|
||||
type: "session.next.step.started",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
assistantMessageID: "msg_assistant_old",
|
||||
timestamp: 1,
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
})
|
||||
await wait(() => sync.session.message.fromSession("session-1")[0]?.id === "msg_assistant_old")
|
||||
const hydration = sync.session.message.sync("session-1")
|
||||
emitTwice(events, {
|
||||
id: "evt_text_1",
|
||||
type: "session.next.text.started",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
assistantMessageID: "msg_assistant_old",
|
||||
timestamp: 2,
|
||||
textID: "text-1",
|
||||
},
|
||||
})
|
||||
emitTwice(events, {
|
||||
id: "evt_text_older",
|
||||
type: "session.next.text.started",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
assistantMessageID: "msg_assistant_older",
|
||||
timestamp: 2,
|
||||
textID: "text-older",
|
||||
},
|
||||
})
|
||||
await wait(() => {
|
||||
const messages = sync.session.message.fromSession("session-1")
|
||||
return messages.every((message) => message.type !== "assistant" || message.content[0]?.type === "text")
|
||||
})
|
||||
response.resolve(
|
||||
json({
|
||||
data: [
|
||||
{
|
||||
id: "msg_assistant_new",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [],
|
||||
time: { created: 3 },
|
||||
},
|
||||
{
|
||||
id: "msg_assistant_old",
|
||||
type: "assistant",
|
||||
metadata: { source: "snapshot" },
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [],
|
||||
time: { created: 1 },
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
await hydration
|
||||
emitTwice(events, {
|
||||
id: "evt_step_late_duplicate",
|
||||
type: "session.next.step.started",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
assistantMessageID: "msg_assistant_old",
|
||||
timestamp: 1,
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
})
|
||||
|
||||
expect(sync.session.message.fromSession("session-1").map((message) => message.id)).toEqual([
|
||||
"msg_assistant_new",
|
||||
"msg_assistant_old",
|
||||
"msg_assistant_older",
|
||||
])
|
||||
expect(JSON.parse(JSON.stringify(sync.session.message.fromSession("session-1")[1]))).toMatchObject({
|
||||
metadata: { source: "snapshot" },
|
||||
content: [{ type: "text", id: "text-1", text: "" }],
|
||||
})
|
||||
expect(JSON.parse(JSON.stringify(sync.session.message.fromSession("session-1")[2]))).toMatchObject({
|
||||
content: [{ type: "text", id: "text-older", text: "" }],
|
||||
})
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -584,17 +584,19 @@ describe("session HttpApi", () => {
|
|||
request(`/api/session/${session.id}/prompt`, {
|
||||
method: "POST",
|
||||
headers: { ...headers, "content-type": "application/json" },
|
||||
body: JSON.stringify({ id: "evt_http_prompt", prompt: { text: "hello" } }),
|
||||
body: JSON.stringify({ id: "msg_http_prompt", prompt: { text: "hello" } }),
|
||||
})
|
||||
const first = yield* recordPrompt()
|
||||
const retried = yield* recordPrompt()
|
||||
type PromptBody = { id: string; type: string; text: string }
|
||||
type PromptBody = { id: string; prompt: { text: string }; delivery: string; promotedSeq?: number }
|
||||
const firstBody = yield* json<{ data: PromptBody }>(first)
|
||||
const retriedBody = yield* json<{ data: PromptBody }>(retried)
|
||||
expect(first.status).toBe(200)
|
||||
expect(retried.status).toBe(200)
|
||||
expect(retriedBody).toEqual(firstBody)
|
||||
expect(firstBody).toMatchObject({ data: { type: "user", text: "hello" } })
|
||||
expect(firstBody).toMatchObject({
|
||||
data: { id: "msg_http_prompt", prompt: { text: "hello" }, delivery: "steer" },
|
||||
})
|
||||
|
||||
const messages = yield* requestJson<{ data: PromptBody[] }>(`/api/session/${session.id}/message`, {
|
||||
headers,
|
||||
|
|
@ -604,27 +606,26 @@ describe("session HttpApi", () => {
|
|||
db
|
||||
.select()
|
||||
.from(SessionInputTable)
|
||||
.where(eq(SessionInputTable.id, SessionMessage.ID.make("evt_http_prompt")))
|
||||
.where(eq(SessionInputTable.id, SessionMessage.ID.make("msg_http_prompt")))
|
||||
.get()
|
||||
.pipe(Effect.orDie),
|
||||
)
|
||||
expect(admitted).toMatchObject({
|
||||
id: "evt_http_prompt",
|
||||
id: "msg_http_prompt",
|
||||
session_id: session.id,
|
||||
delivery: "steer",
|
||||
promoted_seq: null,
|
||||
})
|
||||
|
||||
const conflict = yield* request(`/api/session/${session.id}/prompt`, {
|
||||
method: "POST",
|
||||
headers: { ...headers, "content-type": "application/json" },
|
||||
body: JSON.stringify({ id: "evt_http_prompt", prompt: { text: "goodbye" } }),
|
||||
body: JSON.stringify({ id: "msg_http_prompt", prompt: { text: "goodbye" } }),
|
||||
})
|
||||
expect(conflict.status).toBe(409)
|
||||
expect(yield* responseJson(conflict)).toEqual({
|
||||
_tag: "ConflictError",
|
||||
message: "Prompt message ID conflicts with an existing durable record: evt_http_prompt",
|
||||
resource: "evt_http_prompt",
|
||||
message: "Prompt message ID conflicts with an existing durable record: msg_http_prompt",
|
||||
resource: "msg_http_prompt",
|
||||
})
|
||||
}),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
|
|
|
|||
|
|
@ -106,6 +106,13 @@ describe("sync HttpApi", () => {
|
|||
events: [{ id: "event", aggregateID: "session", seq: 1.5, type: "session.created", data: {} }],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: SyncPaths.replay,
|
||||
body: {
|
||||
directory: tmp.directory,
|
||||
events: [{ id: "event", aggregateID: "session", seq: 0, type: "session.created", data: {} }],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
for (const item of cases) {
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ function migrations() {
|
|||
}
|
||||
|
||||
describe("workspace time migration", () => {
|
||||
test("migrates existing workspace rows", () => {
|
||||
test("discards existing workspace rows during the beta reset", () => {
|
||||
const sqlite = new Database(":memory:")
|
||||
const db = drizzle({ client: sqlite })
|
||||
const entries = migrations()
|
||||
|
|
@ -45,6 +45,6 @@ describe("workspace time migration", () => {
|
|||
)
|
||||
|
||||
expect(() => migrate(db, entries.slice(index))).not.toThrow()
|
||||
expect(sqlite.query("SELECT time_used FROM workspace WHERE id = ?").get("workspace_1")).toEqual({ time_used: 0 })
|
||||
expect(sqlite.query("SELECT time_used FROM workspace WHERE id = ?").get("workspace_1")).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -7,19 +7,21 @@ import { ModelV2 } from "@opencode-ai/core/model"
|
|||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { ToolOutput } from "@opencode-ai/core/tool-output"
|
||||
|
||||
test.skip("step snapshots carry over to assistant messages", () => {
|
||||
const state: SessionMessageUpdater.MemoryState = { messages: [] }
|
||||
const sessionID = SessionID.make("session")
|
||||
const assistantMessageID = EventV2.ID.create()
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
|
||||
Effect.runSync(
|
||||
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
|
||||
id: assistantMessageID,
|
||||
id: EventV2.ID.create(),
|
||||
type: "session.next.step.started",
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
agent: "build",
|
||||
model: {
|
||||
|
|
@ -62,6 +64,7 @@ test.skip("step snapshots carry over to assistant messages", () => {
|
|||
test.skip("text ended populates assistant text content", () => {
|
||||
const state: SessionMessageUpdater.MemoryState = { messages: [] }
|
||||
const sessionID = SessionID.make("session")
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
|
||||
Effect.runSync(
|
||||
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
|
||||
|
|
@ -69,6 +72,7 @@ test.skip("text ended populates assistant text content", () => {
|
|||
type: "session.next.step.started",
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
agent: "build",
|
||||
model: {
|
||||
|
|
@ -86,6 +90,7 @@ test.skip("text ended populates assistant text content", () => {
|
|||
type: "session.next.text.started",
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
timestamp: DateTime.makeUnsafe(2),
|
||||
textID: "text-1",
|
||||
},
|
||||
|
|
@ -98,6 +103,7 @@ test.skip("text ended populates assistant text content", () => {
|
|||
type: "session.next.text.ended",
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
timestamp: DateTime.makeUnsafe(3),
|
||||
textID: "text-1",
|
||||
text: "hello assistant",
|
||||
|
|
@ -114,14 +120,15 @@ test.skip("tool completion stores completed timestamp", () => {
|
|||
const state: SessionMessageUpdater.MemoryState = { messages: [] }
|
||||
const sessionID = SessionID.make("session")
|
||||
const callID = "call"
|
||||
const assistantMessageID = EventV2.ID.create()
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
|
||||
Effect.runSync(
|
||||
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
|
||||
id: assistantMessageID,
|
||||
id: EventV2.ID.create(),
|
||||
type: "session.next.step.started",
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
agent: "build",
|
||||
model: {
|
||||
|
|
@ -198,6 +205,7 @@ test.skip("compaction events reduce to compaction message", () => {
|
|||
type: "session.next.compaction.started",
|
||||
data: {
|
||||
sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
reason: "auto",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ export type Event =
|
|||
| EventSessionNextModelSwitched
|
||||
| EventSessionNextMoved
|
||||
| EventSessionNextPrompted
|
||||
| EventSessionNextPromptAdmitted
|
||||
| EventSessionNextPromptPromoted
|
||||
| EventSessionNextSynthetic
|
||||
| EventSessionNextShellStarted
|
||||
| EventSessionNextShellEnded
|
||||
|
|
@ -804,6 +806,7 @@ export type GlobalEvent = {
|
|||
properties: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
messageID: string
|
||||
agent: string
|
||||
}
|
||||
}
|
||||
|
|
@ -813,6 +816,7 @@ export type GlobalEvent = {
|
|||
properties: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
messageID: string
|
||||
model: {
|
||||
id: string
|
||||
providerID: string
|
||||
|
|
@ -836,16 +840,40 @@ export type GlobalEvent = {
|
|||
properties: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
messageID: string
|
||||
prompt: Prompt
|
||||
delivery: "steer" | "queue"
|
||||
}
|
||||
}
|
||||
| {
|
||||
id: string
|
||||
type: "session.next.prompt.admitted"
|
||||
properties: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
messageID: string
|
||||
prompt: Prompt
|
||||
delivery: "steer" | "queue"
|
||||
}
|
||||
}
|
||||
| {
|
||||
id: string
|
||||
type: "session.next.prompt.promoted"
|
||||
properties: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
messageID: string
|
||||
prompt: Prompt
|
||||
timeCreated: number
|
||||
}
|
||||
}
|
||||
| {
|
||||
id: string
|
||||
type: "session.next.synthetic"
|
||||
properties: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
messageID: string
|
||||
text: string
|
||||
}
|
||||
}
|
||||
|
|
@ -855,6 +883,7 @@ export type GlobalEvent = {
|
|||
properties: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
messageID: string
|
||||
callID: string
|
||||
command: string
|
||||
}
|
||||
|
|
@ -875,6 +904,7 @@ export type GlobalEvent = {
|
|||
properties: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
agent: string
|
||||
model: {
|
||||
id: string
|
||||
|
|
@ -921,6 +951,7 @@ export type GlobalEvent = {
|
|||
properties: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
textID: string
|
||||
}
|
||||
}
|
||||
|
|
@ -930,6 +961,7 @@ export type GlobalEvent = {
|
|||
properties: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
textID: string
|
||||
delta: string
|
||||
}
|
||||
|
|
@ -940,6 +972,7 @@ export type GlobalEvent = {
|
|||
properties: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
textID: string
|
||||
text: string
|
||||
}
|
||||
|
|
@ -950,6 +983,7 @@ export type GlobalEvent = {
|
|||
properties: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
reasoningID: string
|
||||
providerMetadata?: {
|
||||
[key: string]: {
|
||||
|
|
@ -964,6 +998,7 @@ export type GlobalEvent = {
|
|||
properties: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
reasoningID: string
|
||||
delta: string
|
||||
}
|
||||
|
|
@ -974,6 +1009,7 @@ export type GlobalEvent = {
|
|||
properties: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
reasoningID: string
|
||||
text: string
|
||||
providerMetadata?: {
|
||||
|
|
@ -1111,6 +1147,7 @@ export type GlobalEvent = {
|
|||
properties: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
messageID: string
|
||||
reason: "auto" | "manual"
|
||||
}
|
||||
}
|
||||
|
|
@ -1576,6 +1613,8 @@ export type GlobalEvent = {
|
|||
| SyncEventSessionNextModelSwitched
|
||||
| SyncEventSessionNextMoved
|
||||
| SyncEventSessionNextPrompted
|
||||
| SyncEventSessionNextPromptAdmitted
|
||||
| SyncEventSessionNextPromptPromoted
|
||||
| SyncEventSessionNextSynthetic
|
||||
| SyncEventSessionNextShellStarted
|
||||
| SyncEventSessionNextShellEnded
|
||||
|
|
@ -3122,6 +3161,7 @@ export type SyncEventSessionNextAgentSwitched = {
|
|||
data: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
messageID: string
|
||||
agent: string
|
||||
}
|
||||
}
|
||||
|
|
@ -3138,6 +3178,7 @@ export type SyncEventSessionNextModelSwitched = {
|
|||
data: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
messageID: string
|
||||
model: {
|
||||
id: string
|
||||
providerID: string
|
||||
|
|
@ -3175,12 +3216,49 @@ export type SyncEventSessionNextPrompted = {
|
|||
data: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
messageID: string
|
||||
prompt: Prompt
|
||||
delivery: "steer" | "queue"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type SyncEventSessionNextPromptAdmitted = {
|
||||
type: "sync"
|
||||
id: string
|
||||
syncEvent: {
|
||||
type: "session.next.prompt.admitted.1"
|
||||
id: string
|
||||
seq: number
|
||||
aggregateID: string
|
||||
data: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
messageID: string
|
||||
prompt: Prompt
|
||||
delivery: "steer" | "queue"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type SyncEventSessionNextPromptPromoted = {
|
||||
type: "sync"
|
||||
id: string
|
||||
syncEvent: {
|
||||
type: "session.next.prompt.promoted.1"
|
||||
id: string
|
||||
seq: number
|
||||
aggregateID: string
|
||||
data: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
messageID: string
|
||||
prompt: Prompt
|
||||
timeCreated: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type SyncEventSessionNextSynthetic = {
|
||||
type: "sync"
|
||||
id: string
|
||||
|
|
@ -3192,6 +3270,7 @@ export type SyncEventSessionNextSynthetic = {
|
|||
data: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
messageID: string
|
||||
text: string
|
||||
}
|
||||
}
|
||||
|
|
@ -3208,6 +3287,7 @@ export type SyncEventSessionNextShellStarted = {
|
|||
data: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
messageID: string
|
||||
callID: string
|
||||
command: string
|
||||
}
|
||||
|
|
@ -3242,6 +3322,7 @@ export type SyncEventSessionNextStepStarted = {
|
|||
data: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
agent: string
|
||||
model: {
|
||||
id: string
|
||||
|
|
@ -3309,6 +3390,7 @@ export type SyncEventSessionNextTextStarted = {
|
|||
data: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
textID: string
|
||||
}
|
||||
}
|
||||
|
|
@ -3325,6 +3407,7 @@ export type SyncEventSessionNextTextEnded = {
|
|||
data: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
textID: string
|
||||
text: string
|
||||
}
|
||||
|
|
@ -3342,6 +3425,7 @@ export type SyncEventSessionNextReasoningStarted = {
|
|||
data: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
reasoningID: string
|
||||
providerMetadata?: {
|
||||
[key: string]: {
|
||||
|
|
@ -3363,6 +3447,7 @@ export type SyncEventSessionNextReasoningEnded = {
|
|||
data: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
reasoningID: string
|
||||
text: string
|
||||
providerMetadata?: {
|
||||
|
|
@ -3545,6 +3630,7 @@ export type SyncEventSessionNextCompactionStarted = {
|
|||
data: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
messageID: string
|
||||
reason: "auto" | "manual"
|
||||
}
|
||||
}
|
||||
|
|
@ -3670,19 +3756,14 @@ export type SessionV2Info = {
|
|||
subpath?: string
|
||||
}
|
||||
|
||||
export type SessionMessageUser = {
|
||||
export type SessionInputAdmitted = {
|
||||
admittedSeq: number
|
||||
id: string
|
||||
metadata?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
time: {
|
||||
created: number
|
||||
}
|
||||
text: string
|
||||
files?: Array<PromptFileAttachment>
|
||||
agents?: Array<PromptAgentAttachment>
|
||||
references?: Array<PromptReferenceAttachment>
|
||||
type: "user"
|
||||
sessionID: string
|
||||
prompt: Prompt
|
||||
delivery: "steer" | "queue"
|
||||
timeCreated: number
|
||||
promotedSeq?: number
|
||||
}
|
||||
|
||||
export type SessionMessageAgentSwitched = {
|
||||
|
|
@ -3713,6 +3794,21 @@ export type SessionMessageModelSwitched = {
|
|||
}
|
||||
}
|
||||
|
||||
export type SessionMessageUser = {
|
||||
id: string
|
||||
metadata?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
time: {
|
||||
created: number
|
||||
}
|
||||
text: string
|
||||
files?: Array<PromptFileAttachment>
|
||||
agents?: Array<PromptAgentAttachment>
|
||||
references?: Array<PromptReferenceAttachment>
|
||||
type: "user"
|
||||
}
|
||||
|
||||
export type SessionMessageSynthetic = {
|
||||
id: string
|
||||
metadata?: {
|
||||
|
|
@ -4176,6 +4272,7 @@ export type EventSessionNextAgentSwitched = {
|
|||
properties: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
messageID: string
|
||||
agent: string
|
||||
}
|
||||
}
|
||||
|
|
@ -4186,6 +4283,7 @@ export type EventSessionNextModelSwitched = {
|
|||
properties: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
messageID: string
|
||||
model: {
|
||||
id: string
|
||||
providerID: string
|
||||
|
|
@ -4211,17 +4309,43 @@ export type EventSessionNextPrompted = {
|
|||
properties: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
messageID: string
|
||||
prompt: Prompt
|
||||
delivery: "steer" | "queue"
|
||||
}
|
||||
}
|
||||
|
||||
export type EventSessionNextPromptAdmitted = {
|
||||
id: string
|
||||
type: "session.next.prompt.admitted"
|
||||
properties: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
messageID: string
|
||||
prompt: Prompt
|
||||
delivery: "steer" | "queue"
|
||||
}
|
||||
}
|
||||
|
||||
export type EventSessionNextPromptPromoted = {
|
||||
id: string
|
||||
type: "session.next.prompt.promoted"
|
||||
properties: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
messageID: string
|
||||
prompt: Prompt
|
||||
timeCreated: number
|
||||
}
|
||||
}
|
||||
|
||||
export type EventSessionNextSynthetic = {
|
||||
id: string
|
||||
type: "session.next.synthetic"
|
||||
properties: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
messageID: string
|
||||
text: string
|
||||
}
|
||||
}
|
||||
|
|
@ -4232,6 +4356,7 @@ export type EventSessionNextShellStarted = {
|
|||
properties: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
messageID: string
|
||||
callID: string
|
||||
command: string
|
||||
}
|
||||
|
|
@ -4254,6 +4379,7 @@ export type EventSessionNextStepStarted = {
|
|||
properties: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
agent: string
|
||||
model: {
|
||||
id: string
|
||||
|
|
@ -4303,6 +4429,7 @@ export type EventSessionNextTextStarted = {
|
|||
properties: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
textID: string
|
||||
}
|
||||
}
|
||||
|
|
@ -4313,6 +4440,7 @@ export type EventSessionNextTextDelta = {
|
|||
properties: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
textID: string
|
||||
delta: string
|
||||
}
|
||||
|
|
@ -4324,6 +4452,7 @@ export type EventSessionNextTextEnded = {
|
|||
properties: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
textID: string
|
||||
text: string
|
||||
}
|
||||
|
|
@ -4335,6 +4464,7 @@ export type EventSessionNextReasoningStarted = {
|
|||
properties: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
reasoningID: string
|
||||
providerMetadata?: {
|
||||
[key: string]: {
|
||||
|
|
@ -4350,6 +4480,7 @@ export type EventSessionNextReasoningDelta = {
|
|||
properties: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
reasoningID: string
|
||||
delta: string
|
||||
}
|
||||
|
|
@ -4361,6 +4492,7 @@ export type EventSessionNextReasoningEnded = {
|
|||
properties: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
reasoningID: string
|
||||
text: string
|
||||
providerMetadata?: {
|
||||
|
|
@ -4507,6 +4639,7 @@ export type EventSessionNextCompactionStarted = {
|
|||
properties: {
|
||||
timestamp: number
|
||||
sessionID: string
|
||||
messageID: string
|
||||
reason: "auto" | "manual"
|
||||
}
|
||||
}
|
||||
|
|
@ -9263,7 +9396,7 @@ export type V2SessionPromptResponses = {
|
|||
* Success
|
||||
*/
|
||||
200: {
|
||||
data: SessionMessageUser
|
||||
data: SessionInputAdmitted
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -114,7 +114,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session")
|
|||
delivery: SessionInput.Delivery.pipe(Schema.optional),
|
||||
resume: Schema.Boolean.pipe(Schema.optional),
|
||||
}),
|
||||
success: Schema.Struct({ data: SessionMessage.User }),
|
||||
success: Schema.Struct({ data: SessionInput.Admitted }),
|
||||
error: [ConflictError, SessionNotFoundError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue