mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-10 01:48:40 +00:00
refactor(core): make v2 session inputs event sourced (#30785)
This commit is contained in:
parent
057958c933
commit
76ecf2e58c
43 changed files with 4632 additions and 718 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({
|
||||
|
|
|
|||
|
|
@ -4,6 +4,28 @@ Record V2 database, durable-event, projected-message, HTTP, and generated SDK sc
|
|||
|
||||
This document covers meaningful contract changes introduced on the `feat/opencode-embedded-api` branch since its divergence from `origin/dev`. Mechanical file moves and internal refactors are omitted unless they changed stored data, replay behavior, public HTTP or SDK shapes, or model-facing tool contracts.
|
||||
|
||||
## 2026-06-04 Event-Sourced Session Input Cutover
|
||||
|
||||
Affected schema:
|
||||
|
||||
- `session_input`, `session_message`, `event`, `event_sequence`, and disposable workspace beta storage.
|
||||
- New synchronized `session.next.prompt.admitted.1` and `session.next.prompt.promoted.1` events.
|
||||
- Experimental `SessionV2.prompt(...)`, HTTP, and generated SDK admission receipt.
|
||||
|
||||
Change:
|
||||
|
||||
- Replace inbox-local admission sequence with event-sourced prompt admission and promotion sequences.
|
||||
- Give projected Session messages stable `msg_*` resource IDs distinct from `evt_*` creator event IDs.
|
||||
- Give every event that creates a projected transcript resource an explicit `msg_*` resource ID. Assistant steps propagate one `assistantMessageID` through assistant-owned events.
|
||||
- Reset incompatible unreleased beta event history, derived Session projections, workspace rows, and Session workspace links.
|
||||
|
||||
Compatibility:
|
||||
|
||||
- The reset preserves canonical V1 `session`, `message`, and `part` rows.
|
||||
- Existing synchronized workspaces are disposable beta state and are removed by the reset.
|
||||
- Before starting the new build, discard adapter-managed external workspace resources created by unreleased builds. The SQL migration cannot remove external resources through runtime adapters, and rediscovering retained resources after startup can replay incompatible beta history.
|
||||
- Exact prompt retries reconcile one stable `msg_*` identity when Session, prompt, and delivery mode match.
|
||||
|
||||
## Earlier Branch History
|
||||
|
||||
### Replayable Session Event Refinement And Cursor Stream
|
||||
|
|
@ -60,7 +82,7 @@ Affected schema:
|
|||
|
||||
Change:
|
||||
|
||||
- Bind step settlement to an explicit `assistantMessageID`.
|
||||
- Bind step settlement to an explicit assistant message ID.
|
||||
|
||||
Reason:
|
||||
|
||||
|
|
@ -409,7 +431,7 @@ Affected schema:
|
|||
Change:
|
||||
|
||||
- Preserve stable IDs on projected assistant text parts.
|
||||
- Route durable tool projection updates through explicit owning `assistantMessageID` values rather than provider-local call IDs alone.
|
||||
- Route durable tool projection updates through explicit owning assistant message IDs rather than provider-local call IDs alone.
|
||||
- Replay full-value text and tool-input end checkpoints while keeping fragment deltas ephemeral.
|
||||
|
||||
Reason:
|
||||
|
|
|
|||
|
|
@ -13,14 +13,14 @@ sessions.create({ id?, location, ... })
|
|||
sessions.prompt({ id?, sessionID, prompt, delivery?, resume? })
|
||||
-> omitted ID generates one internal message ID
|
||||
-> supplied ID admits one durable Session input when absent
|
||||
-> exact reuse returns the same admitted user-shaped message
|
||||
-> exact reuse returns the same admitted lifecycle receipt
|
||||
-> reusing one message ID for another Session, prompt, or delivery mode fails
|
||||
-> exact retry schedules another wake unless resume is false
|
||||
-> resume omitted or true schedules execution after admission
|
||||
-> resume false admits only
|
||||
```
|
||||
|
||||
`session_input` is the durable admission inbox. Admitted inputs remain outside model-visible Session history until the serialized runner promotes them by publishing ordinary `Prompted` events. The existing projector atomically writes the visible user message and marks its inbox row promoted in the same event transaction.
|
||||
`session_input` is the durable admission inbox. Admitted inputs remain outside model-visible Session history until the serialized runner publishes `PromptLifecycle.Promoted`. The projector atomically writes the visible user message and marks its inbox row promoted in the same event transaction. The legacy V1-to-V2 shadow bridge continues publishing ordinary `Prompted` events for already-visible V1 prompts.
|
||||
|
||||
Execution routing starts from only the Session ID:
|
||||
|
||||
|
|
@ -33,7 +33,7 @@ SessionExecution.resume(sessionID)
|
|||
|
||||
`SessionExecution` and the read-side `SessionStore` are process-global. `SessionRunner`, catalog, model resolver, tool registry, permission state, and filesystem are cached per Location. No layer takes a Session ID. An omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
|
||||
|
||||
The local runner issues one explicit `llm.stream(request)` per provider turn, projects each complete local tool call durably before eagerly starting its structured child execution, awaits every started tool fiber after provider-stream closure, reloads projected history once before continuation, and fails after 25 provider turns within one local drain activity only when work remains. Tool settlement events carry the owning assistant-message ID because provider-local call IDs may repeat across turns. Before assembling a provider request, the runner durably fails any local tool still projected as `running` from a previous process with `Tool execution interrupted`; abandoned side effects are never silently replayed.
|
||||
The local runner issues one explicit `llm.stream(request)` per provider turn, projects each complete local tool call durably before eagerly starting its structured child execution, awaits every started tool fiber after provider-stream closure, reloads projected history once before continuation, and fails after 25 provider turns within one local drain activity only when work remains. Tool settlement events carry the owning assistant message ID because provider-local call IDs may repeat across turns. Before assembling a provider request, the runner durably fails any local tool still projected as `running` from a previous process with `Tool execution interrupted`; abandoned side effects are never silently replayed.
|
||||
|
||||
Projected hosted tools preserve call-side and settlement-side provider metadata separately so settlement and interruption recovery cannot erase continuation identifiers. Provider-native reasoning and provider metadata replay only while the historical assistant model matches the selected continuation model; after a model switch, visible reasoning text remains ordinary assistant text and provider-native metadata is omitted.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue