refactor(schema): rename V2 session events and normalize payloads (#35217)

This commit is contained in:
Kit Langton
2026-07-03 14:25:59 -04:00
committed by GitHub
parent 392cb3b080
commit 0033be88fc
63 changed files with 1939 additions and 2040 deletions
File diff suppressed because it is too large Load Diff
+19 -10
View File
@@ -1,7 +1,17 @@
import { expect, test } from "bun:test" import { expect, test } from "bun:test"
import { DateTime, Effect, Stream } from "effect" import { DateTime, Effect, Stream } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http" import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { AbsolutePath, Agent, Event, Location, Model, OpenCode, Prompt, Session, SessionMessage } from "../src/effect/index" import {
AbsolutePath,
Agent,
Event,
Location,
Model,
OpenCode,
Prompt,
Session,
SessionMessage,
} from "../src/effect/index"
const synced = { type: "log.synced" as const, aggregateID: "ses_test", seq: Event.Seq.make(1) } const synced = { type: "log.synced" as const, aggregateID: "ses_test", seq: Event.Seq.make(1) }
@@ -23,7 +33,7 @@ test("event.subscribe exposes and decodes the native Effect event stream", async
HttpClientResponse.fromWeb( HttpClientResponse.fromWeb(
request, request,
new Response( new Response(
`data: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n` + `data: ${JSON.stringify({ id: "evt_connected", created: 0, type: "server.connected", data: {} })}\n\n` +
`data: ${JSON.stringify(modelSwitchedEvent)}\n\n`, `data: ${JSON.stringify(modelSwitchedEvent)}\n\n`,
{ headers: { "content-type": "text/event-stream" } }, { headers: { "content-type": "text/event-stream" } },
), ),
@@ -35,10 +45,10 @@ test("event.subscribe exposes and decodes the native Effect event stream", async
return yield* client.event.subscribe().pipe(Stream.runCollect) return yield* client.event.subscribe().pipe(Stream.runCollect)
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise) }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(Array.from(events).map((event) => event.type)).toEqual(["server.connected", "session.next.model.switched"]) expect(Array.from(events).map((event) => event.type)).toEqual(["server.connected", "model.selected"])
const durable = events[1] const durable = events[1]
if (durable?.type !== "session.next.model.switched") throw new Error("Expected model event") if (durable?.type !== "model.selected") throw new Error("Expected model event")
expect(DateTime.toEpochMillis(durable.data.timestamp)).toBe(1_717_171_717_000) expect(DateTime.toEpochMillis(durable.created)).toBe(1_717_171_717_000)
expect(durable.durable).toEqual({ aggregateID: "ses_test", seq: 1, version: 1 }) expect(durable.durable).toEqual({ aggregateID: "ses_test", seq: 1, version: 1 })
}) })
@@ -149,8 +159,8 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
expect(result.context).toEqual([]) expect(result.context).toEqual([])
expect(logQueries[0]).toEqual({ after: "0" }) expect(logQueries[0]).toEqual({ after: "0" })
const logged = Array.from(result.log) const logged = Array.from(result.log)
expect(logged.map((item) => item.type)).toEqual(["session.next.model.switched", "log.synced"]) expect(logged.map((item) => item.type)).toEqual(["model.selected", "log.synced"])
expect(logged[0]?.type === "session.next.model.switched" && DateTime.toEpochMillis(logged[0].data.timestamp)).toBe( expect(logged[0]?.type === "model.selected" && DateTime.toEpochMillis(logged[0].created)).toBe(
1_717_171_717_000, 1_717_171_717_000,
) )
expect(logged.at(-1)).toEqual(synced) expect(logged.at(-1)).toEqual(synced)
@@ -217,12 +227,11 @@ const modelSwitchedMessage = {
const modelSwitchedEvent = { const modelSwitchedEvent = {
id: "evt_model", id: "evt_model",
type: "session.next.model.switched", created: 1_717_171_717_000,
type: "model.selected",
durable: { aggregateID: "ses_test", seq: 1, version: 1 }, durable: { aggregateID: "ses_test", seq: 1, version: 1 },
data: { data: {
timestamp: 1_717_171_717_000,
sessionID: "ses_test", sessionID: "ses_test",
messageID: "msg_model",
model: { id: "claude", providerID: "anthropic" }, model: { id: "claude", providerID: "anthropic" },
}, },
} }
+5 -6
View File
@@ -151,7 +151,7 @@ test("event.subscribe exposes the Promise event stream wire projection", async (
baseUrl: "http://localhost:3000", baseUrl: "http://localhost:3000",
fetch: async () => fetch: async () =>
new Response( new Response(
`: heartbeat\n\ndata: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n` + `: heartbeat\n\ndata: ${JSON.stringify({ id: "evt_connected", created: 0, type: "server.connected", data: {} })}\n\n` +
`data: ${JSON.stringify(modelSwitchedEvent)}\n\n`, `data: ${JSON.stringify(modelSwitchedEvent)}\n\n`,
{ headers: { "content-type": "text/event-stream" } }, { headers: { "content-type": "text/event-stream" } },
), ),
@@ -159,8 +159,8 @@ test("event.subscribe exposes the Promise event stream wire projection", async (
const events = [] const events = []
for await (const event of client.event.subscribe()) events.push(event) for await (const event of client.event.subscribe()) events.push(event)
expect(events).toEqual([{ id: "evt_connected", type: "server.connected", data: {} }, modelSwitchedEvent]) expect(events).toEqual([{ id: "evt_connected", created: 0, type: "server.connected", data: {} }, modelSwitchedEvent])
expect(events[1]?.type === "session.next.model.switched" && events[1].data.timestamp).toBe(1_717_171_717_000) expect(events[1]?.type === "model.selected" && events[1].created).toBe(1_717_171_717_000)
}) })
test("event.subscribe terminates on malformed Promise SSE data", async () => { test("event.subscribe terminates on malformed Promise SSE data", async () => {
@@ -328,12 +328,11 @@ const synced = { type: "log.synced", aggregateID: "ses_test", seq: 1 }
const modelSwitchedEvent = { const modelSwitchedEvent = {
id: "evt_model", id: "evt_model",
type: "session.next.model.switched", created: 1_717_171_717_000,
type: "model.selected",
durable: { aggregateID: "ses_test", seq: 1, version: 1 }, durable: { aggregateID: "ses_test", seq: 1, version: 1 },
data: { data: {
timestamp: 1_717_171_717_000,
sessionID: "ses_test", sessionID: "ses_test",
messageID: "msg_model",
model: { id: "claude", providerID: "anthropic" }, model: { id: "claude", providerID: "anthropic" },
}, },
} }
+12 -2
View File
@@ -1,9 +1,9 @@
{ {
"version": "7", "version": "7",
"dialect": "sqlite", "dialect": "sqlite",
"id": "22e57fed-b9b8-4e94-a3b4-f94bece680a8", "id": "96e9fe64-d810-4102-8f79-3317a88bb6d2",
"prevIds": [ "prevIds": [
"f14a9b18-8207-487e-a3d3-227e629ba9ad" "22e57fed-b9b8-4e94-a3b4-f94bece680a8"
], ],
"ddl": [ "ddl": [
{ {
@@ -526,6 +526,16 @@
"entityType": "columns", "entityType": "columns",
"table": "event" "table": "event"
}, },
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "created",
"entityType": "columns",
"table": "event"
},
{ {
"type": "text", "type": "text",
"notNull": true, "notNull": true,
@@ -106,8 +106,7 @@ const layer = Layer.effect(
yield* events.publish(SessionEvent.Moved, { yield* events.publish(SessionEvent.Moved, {
sessionID: input.sessionID, sessionID: input.sessionID,
location: Location.Ref.make({ directory }), location: Location.Ref.make({ directory }),
subdirectory: RelativePath.make(path.relative(destination.directory, directory).replaceAll("\\", "/")), subpath: RelativePath.make(path.relative(destination.directory, directory).replaceAll("\\", "/")),
timestamp: yield* DateTime.now,
}) })
if (patch) { if (patch) {
+2
View File
@@ -41,5 +41,7 @@ export const migrations = (
import("./migration/20260622170816_reset_v2_session_state"), import("./migration/20260622170816_reset_v2_session_state"),
import("./migration/20260622202450_simplify_session_input"), import("./migration/20260622202450_simplify_session_input"),
import("./migration/20260702134641_add_session_context_entry"), import("./migration/20260702134641_add_session_context_entry"),
import("./migration/20260703090000_reset_v2_event_rename_sweep"),
import("./migration/20260703181610_event_created_column"),
]) ])
).map((module) => module.default) satisfies DatabaseMigration.Migration[] ).map((module) => module.default) satisfies DatabaseMigration.Migration[]
@@ -0,0 +1,17 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260703090000_reset_v2_event_rename_sweep",
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\`;`)
// `created` column is added by the generated 20260703181610_event_created_column
// migration, which runs after this wipe (NOT NULL without default is safe on the
// emptied table).
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,11 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260703181610_event_created_column",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`event\` ADD \`created\` integer NOT NULL;`)
})
},
} satisfies DatabaseMigration.Migration
+1
View File
@@ -81,6 +81,7 @@ export default {
\`id\` text PRIMARY KEY, \`id\` text PRIMARY KEY,
\`aggregate_id\` text NOT NULL, \`aggregate_id\` text NOT NULL,
\`seq\` integer NOT NULL, \`seq\` integer NOT NULL,
\`created\` integer NOT NULL,
\`type\` text NOT NULL, \`type\` text NOT NULL,
\`data\` text NOT NULL, \`data\` text NOT NULL,
CONSTRAINT \`fk_event_aggregate_id_event_sequence_aggregate_id_fk\` FOREIGN KEY (\`aggregate_id\`) REFERENCES \`event_sequence\`(\`aggregate_id\`) ON DELETE CASCADE CONSTRAINT \`fk_event_aggregate_id_event_sequence_aggregate_id_fk\` FOREIGN KEY (\`aggregate_id\`) REFERENCES \`event_sequence\`(\`aggregate_id\`) ON DELETE CASCADE
+8 -1
View File
@@ -1,6 +1,6 @@
export * as EventV2 from "./event" export * as EventV2 from "./event"
import { Cause, Context, Effect, Layer, Option, PubSub, Queue, Schema, Stream } from "effect" import { Cause, Context, DateTime, Effect, Layer, Option, PubSub, Queue, Schema, Stream } from "effect"
import { Event } from "@opencode-ai/schema/event" import { Event } from "@opencode-ai/schema/event"
import type { Data, Definition, Payload } from "@opencode-ai/schema/event" import type { Data, Definition, Payload } from "@opencode-ai/schema/event"
import type { EventLog } from "@opencode-ai/schema/event-log" import type { EventLog } from "@opencode-ai/schema/event-log"
@@ -55,6 +55,7 @@ export const reserveSequence = Effect.fn("EventV2.reserveSequence")(function* (
export type SerializedEvent = { export type SerializedEvent = {
readonly id: ID readonly id: ID
readonly type: string readonly type: string
readonly created?: DateTime.Utc
readonly seq: number readonly seq: number
readonly aggregateID: string readonly aggregateID: string
readonly data: Record<string, unknown> readonly data: Record<string, unknown>
@@ -81,6 +82,7 @@ const decodeSerializedEvent = (event: SerializedEvent): Payload => {
} }
return { return {
id: event.id, id: event.id,
created: event.created ?? DateTime.makeUnsafe(0),
type: definition.type, type: definition.type,
durable: envelope(event.aggregateID, event.seq, definition.durable.version), durable: envelope(event.aggregateID, event.seq, definition.durable.version),
data: Schema.decodeUnknownSync(definition.data)(event.data), data: Schema.decodeUnknownSync(definition.data)(event.data),
@@ -295,6 +297,7 @@ export const layerWith = (options?: LayerOptions) =>
if ( if (
stored?.id === event.id && stored?.id === event.id &&
stored.type === versionedType(definition.type, durable.version) && stored.type === versionedType(definition.type, durable.version) &&
stored.created === DateTime.toEpochMillis(event.created ?? DateTime.makeUnsafe(0)) &&
isDeepStrictEqual(stored.data, encoded) isDeepStrictEqual(stored.data, encoded)
) { ) {
if (input.ownerID && row?.ownerID == null) { if (input.ownerID && row?.ownerID == null) {
@@ -366,6 +369,7 @@ export const layerWith = (options?: LayerOptions) =>
id: event.id, id: event.id,
aggregate_id: aggregateID, aggregate_id: aggregateID,
seq, seq,
created: DateTime.toEpochMillis(event.created ?? DateTime.makeUnsafe(0)),
type: versionedType(definition.type, durable.version), type: versionedType(definition.type, durable.version),
data: encoded, data: encoded,
}, },
@@ -471,6 +475,7 @@ export const layerWith = (options?: LayerOptions) =>
definition, definition,
{ {
id: options?.id ?? ID.create(), id: options?.id ?? ID.create(),
created: yield* DateTime.now,
...(options?.metadata ? { metadata: options.metadata } : {}), ...(options?.metadata ? { metadata: options.metadata } : {}),
type: definition.type, type: definition.type,
...(location ? { location } : {}), ...(location ? { location } : {}),
@@ -494,6 +499,7 @@ export const layerWith = (options?: LayerOptions) =>
} else { } else {
const payload = { const payload = {
id: event.id, id: event.id,
created: event.created ?? DateTime.makeUnsafe(0),
type: definition.type, type: definition.type,
data: Schema.decodeUnknownSync(definition.data)(event.data), data: Schema.decodeUnknownSync(definition.data)(event.data),
} as Payload } as Payload
@@ -610,6 +616,7 @@ export const layerWith = (options?: LayerOptions) =>
return [ return [
decodeSerializedEvent({ decodeSerializedEvent({
id: event.id, id: event.id,
created: DateTime.makeUnsafe(event.created),
aggregateID: event.aggregate_id, aggregateID: event.aggregate_id,
seq: event.seq, seq: event.seq,
type: event.type, type: event.type,
+1
View File
@@ -15,6 +15,7 @@ export const EventTable = sqliteTable(
.notNull() .notNull()
.references(() => EventSequenceTable.aggregate_id, { onDelete: "cascade" }), .references(() => EventSequenceTable.aggregate_id, { onDelete: "cascade" }),
seq: integer().notNull(), seq: integer().notNull(),
created: integer().notNull(),
type: text().notNull(), type: text().notNull(),
data: text({ mode: "json" }).$type<Record<string, unknown>>().notNull(), data: text({ mode: "json" }).$type<Record<string, unknown>>().notNull(),
}, },
+3 -17
View File
@@ -363,8 +363,7 @@ const layer = Layer.effect(
yield* events.publish(SessionEvent.Forked, { yield* events.publish(SessionEvent.Forked, {
sessionID, sessionID,
parentID: parent.id, parentID: parent.id,
messageID: input.messageID, from: input.messageID,
timestamp: yield* DateTime.now,
}) })
return yield* result.get(sessionID).pipe(Effect.orDie) return yield* result.get(sessionID).pipe(Effect.orDie)
}), }),
@@ -551,16 +550,13 @@ const layer = Layer.effect(
Effect.gen(function* () { Effect.gen(function* () {
activeShells.add(input.sessionID) activeShells.add(input.sessionID)
if ((yield* execution.active).has(input.sessionID)) yield* execution.awaitIdle(input.sessionID) if ((yield* execution.active).has(input.sessionID)) yield* execution.awaitIdle(input.sessionID)
const messageID = SessionMessage.ID.create()
const callID = Identifier.ascending() const callID = Identifier.ascending()
yield* events.publish( yield* events.publish(
SessionEvent.Shell.Started, SessionEvent.Shell.Started,
{ {
sessionID: input.sessionID, sessionID: input.sessionID,
messageID,
callID, callID,
command: input.command, command: input.command,
timestamp: yield* DateTime.now,
}, },
{ id: input.id }, { id: input.id },
) )
@@ -571,7 +567,6 @@ const layer = Layer.effect(
sessionID: input.sessionID, sessionID: input.sessionID,
callID, callID,
output, output,
timestamp: yield* DateTime.now,
}) })
}).pipe( }).pipe(
Effect.ensuring( Effect.ensuring(
@@ -590,8 +585,6 @@ const layer = Layer.effect(
if (!skill) return yield* new SkillNotFoundError({ skill: input.skill }) if (!skill) return yield* new SkillNotFoundError({ skill: input.skill })
yield* events.publish(SessionEvent.Skill.Activated, { yield* events.publish(SessionEvent.Skill.Activated, {
sessionID: input.sessionID, sessionID: input.sessionID,
messageID: input.id ?? SessionMessage.ID.create(),
timestamp: yield* DateTime.now,
name: skill.name, name: skill.name,
text: skill.content, text: skill.content,
}) })
@@ -602,10 +595,8 @@ const layer = Layer.effect(
}), }),
switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) { switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) {
yield* result.get(input.sessionID) yield* result.get(input.sessionID)
yield* events.publish(SessionEvent.AgentSwitched, { yield* events.publish(SessionEvent.AgentSelected, {
sessionID: input.sessionID, sessionID: input.sessionID,
messageID: SessionMessage.ID.create(),
timestamp: yield* DateTime.now,
agent: input.agent, agent: input.agent,
}) })
}), }),
@@ -617,10 +608,8 @@ const layer = Layer.effect(
(session.model.variant ?? "default") === (input.model.variant ?? "default") (session.model.variant ?? "default") === (input.model.variant ?? "default")
) )
return return
yield* events.publish(SessionEvent.ModelSwitched, { yield* events.publish(SessionEvent.ModelSelected, {
sessionID: input.sessionID, sessionID: input.sessionID,
messageID: SessionMessage.ID.create(),
timestamp: yield* DateTime.now,
model: input.model, model: input.model,
}) })
}), }),
@@ -628,7 +617,6 @@ const layer = Layer.effect(
yield* result.get(input.sessionID) yield* result.get(input.sessionID)
yield* events.publish(SessionEvent.Renamed, { yield* events.publish(SessionEvent.Renamed, {
sessionID: input.sessionID, sessionID: input.sessionID,
timestamp: yield* DateTime.now,
title: input.title, title: input.title,
}) })
}), }),
@@ -676,8 +664,6 @@ const layer = Layer.effect(
yield* result.get(input.sessionID) yield* result.get(input.sessionID)
yield* events.publish(SessionEvent.Synthetic, { yield* events.publish(SessionEvent.Synthetic, {
sessionID: input.sessionID, sessionID: input.sessionID,
messageID: SessionMessage.ID.create(),
timestamp: yield* DateTime.now,
text: input.text, text: input.text,
description: input.description, description: input.description,
metadata: input.metadata, metadata: input.metadata,
+1 -6
View File
@@ -7,7 +7,7 @@ import { EventV2 } from "../event"
import { makeLocationNode } from "../effect/app-node" import { makeLocationNode } from "../effect/app-node"
import { llmClient } from "../effect/app-node-platform" import { llmClient } from "../effect/app-node-platform"
import { SessionEvent } from "./event" import { SessionEvent } from "./event"
import { SessionMessage } from "./message" import type { SessionMessage } from "./message"
import { SessionRunnerModel } from "./runner/model" import { SessionRunnerModel } from "./runner/model"
import { SessionSchema } from "./schema" import { SessionSchema } from "./schema"
import { Token } from "../util/token" import { Token } from "../util/token"
@@ -206,11 +206,8 @@ const make = (dependencies: Dependencies) => {
const summaryPrompt = buildPrompt({ previousSummary: input.previousSummary, context: input.context }) const summaryPrompt = buildPrompt({ previousSummary: input.previousSummary, context: input.context })
const summaryOutput = Math.min(output || SUMMARY_OUTPUT_TOKENS, SUMMARY_OUTPUT_TOKENS) const summaryOutput = Math.min(output || SUMMARY_OUTPUT_TOKENS, SUMMARY_OUTPUT_TOKENS)
if (Token.estimate(summaryPrompt) > context - summaryOutput) return false if (Token.estimate(summaryPrompt) > context - summaryOutput) return false
const messageID = SessionMessage.ID.create()
yield* dependencies.events.publish(SessionEvent.Compaction.Started, { yield* dependencies.events.publish(SessionEvent.Compaction.Started, {
sessionID: input.sessionID, sessionID: input.sessionID,
messageID,
timestamp: yield* DateTime.now,
reason: input.reason, reason: input.reason,
}) })
@@ -238,8 +235,6 @@ const make = (dependencies: Dependencies) => {
if (!summarized || failed || !summary.trim()) return false if (!summarized || failed || !summary.trim()) return false
yield* dependencies.events.publish(SessionEvent.Compaction.Ended, { yield* dependencies.events.publish(SessionEvent.Compaction.Ended, {
sessionID: input.sessionID, sessionID: input.sessionID,
messageID,
timestamp: yield* DateTime.now,
reason: input.reason, reason: input.reason,
text: summary, text: summary,
recent: input.recent, recent: input.recent,
@@ -1,13 +1,12 @@
export * as SessionContextCheckpoint from "./context-checkpoint" export * as SessionContextCheckpoint from "./context-checkpoint"
import { eq } from "drizzle-orm" import { eq } from "drizzle-orm"
import { DateTime, Effect, Option, Schema } from "effect" import { Effect, Option, Schema } from "effect"
import type { Database } from "../database/database" import type { Database } from "../database/database"
import { EventV2 } from "../event" import { EventV2 } from "../event"
import { SystemContext } from "../system-context/index" import { SystemContext } from "../system-context/index"
import { SessionEvent } from "./event" import { SessionEvent } from "./event"
import { SessionHistory } from "./history" import { SessionHistory } from "./history"
import { SessionMessage } from "./message"
import { SessionSchema } from "./schema" import { SessionSchema } from "./schema"
import { SessionContextCheckpointTable } from "./sql" import { SessionContextCheckpointTable } from "./sql"
@@ -50,7 +49,7 @@ export const prepare = Effect.fn("SessionContextCheckpoint.prepare")(function* (
yield* events.publish( yield* events.publish(
SessionEvent.ContextUpdated, SessionEvent.ContextUpdated,
{ sessionID, messageID: SessionMessage.ID.create(), timestamp: yield* DateTime.now, text: result.text }, { sessionID, text: result.text },
{ commit: () => advance(db, sessionID, result.applied).pipe(Effect.orDie) }, { commit: () => advance(db, sessionID, result.applied).pipe(Effect.orDie) },
) )
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq } return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
@@ -36,7 +36,6 @@ const layer = Layer.effect(
Exit.isFailure(exit) && !Cause.hasInterrupts(exit.cause) ? Cause.squash(exit.cause) : undefined Exit.isFailure(exit) && !Cause.hasInterrupts(exit.cause) ? Cause.squash(exit.cause) : undefined
yield* events.publish(SessionEvent.ExecutionSettled, { yield* events.publish(SessionEvent.ExecutionSettled, {
sessionID, sessionID,
timestamp: yield* DateTime.now,
outcome: Exit.isSuccess(exit) ? "success" : Cause.hasInterrupts(exit.cause) ? "interrupted" : "failure", outcome: Exit.isSuccess(exit) ? "success" : Cause.hasInterrupts(exit.cause) ? "interrupted" : "failure",
error: error:
failure !== undefined failure !== undefined
+10 -17
View File
@@ -50,12 +50,10 @@ export const admit = Effect.fn("SessionInput.admit")(function* (
) { ) {
const existing = yield* find(db, input.id) const existing = yield* find(db, input.id)
if (existing !== undefined) return existing if (existing !== undefined) return existing
const timestamp = yield* DateTime.now
return yield* events return yield* events
.publish(SessionEvent.PromptAdmitted, { .publish(SessionEvent.PromptAdmitted, {
messageID: input.id, inputID: input.id,
sessionID: input.sessionID, sessionID: input.sessionID,
timestamp,
prompt: input.prompt, prompt: input.prompt,
delivery: input.delivery, delivery: input.delivery,
}) })
@@ -70,7 +68,7 @@ export const admit = Effect.fn("SessionInput.admit")(function* (
sessionID: input.sessionID, sessionID: input.sessionID,
prompt: input.prompt, prompt: input.prompt,
delivery: input.delivery, delivery: input.delivery,
timeCreated: timestamp, timeCreated: event.created,
}), }),
), ),
), ),
@@ -115,14 +113,11 @@ export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(functio
if (!stored) return yield* Effect.die(new LifecycleConflict({ id: input.id })) if (!stored) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
}) })
export const projectPrompted = Effect.fn("SessionInput.projectPrompted")(function* ( export const projectPromptPromoted = Effect.fn("SessionInput.projectPromptPromoted")(function* (
db: DatabaseService, db: DatabaseService,
input: { input: {
readonly id: SessionMessage.ID readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID readonly sessionID: SessionSchema.ID
readonly prompt: Prompt
readonly delivery: Delivery
readonly timeCreated: DateTime.Utc
readonly promotedSeq: number readonly promotedSeq: number
}, },
) { ) {
@@ -141,15 +136,16 @@ export const projectPrompted = Effect.fn("SessionInput.projectPrompted")(functio
.pipe(Effect.orDie) .pipe(Effect.orDie)
if (updated) { if (updated) {
const stored = fromRow(updated) const stored = fromRow(updated)
if (!matchesProjection(stored, input)) return yield* Effect.die(new LifecycleConflict({ id: input.id })) if (stored.sessionID !== input.sessionID) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
return return stored
} }
// Every Prompted event is published from an admitted inbox row, so a missing or // Every PromptPromoted event is published from an admitted inbox row, so a missing or
// divergent row on replay is an invariant violation. // divergent row on replay is an invariant violation.
const stored = yield* find(db, input.id) const stored = yield* find(db, input.id)
if (!stored || !matchesProjection(stored, input) || stored.promotedSeq !== input.promotedSeq) if (!stored || stored.sessionID !== input.sessionID || stored.promotedSeq !== input.promotedSeq)
return yield* Effect.die(new LifecycleConflict({ id: input.id })) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
return stored
}) })
export const hasPending = Effect.fn("SessionInput.hasPending")(function* ( export const hasPending = Effect.fn("SessionInput.hasPending")(function* (
@@ -206,12 +202,9 @@ const publish = Effect.fn("SessionInput.publish")(function* (
for (const row of rows) { for (const row of rows) {
const id = SessionMessage.ID.make(row.id) const id = SessionMessage.ID.make(row.id)
yield* events yield* events
.publish(SessionEvent.Prompted, { .publish(SessionEvent.PromptPromoted, {
sessionID, sessionID,
timestamp: DateTime.makeUnsafe(row.time_created), inputID: id,
messageID: id,
prompt: decodePrompt(row.prompt),
delivery: row.delivery,
}) })
.pipe( .pipe(
Effect.catchDefect((defect) => Effect.catchDefect((defect) =>
+3 -5
View File
@@ -60,9 +60,9 @@ const layer = Layer.effect(
const files = yield* Effect.forEach( const files = yield* Effect.forEach(
toInject, toInject,
(path) => (path) =>
fs.readFileStringSafe(path).pipe( fs
Effect.map((content) => (content === undefined ? undefined : { path, content })), .readFileStringSafe(path)
), .pipe(Effect.map((content) => (content === undefined ? undefined : { path, content }))),
{ concurrency: "unbounded" }, { concurrency: "unbounded" },
) )
const readable = files.filter((file): file is { path: string; content: string } => file !== undefined) const readable = files.filter((file): file is { path: string; content: string } => file !== undefined)
@@ -74,8 +74,6 @@ const layer = Layer.effect(
// metadata so it survives across Location layer restarts. // metadata so it survives across Location layer restarts.
yield* events.publish(SessionEvent.Synthetic, { yield* events.publish(SessionEvent.Synthetic, {
sessionID: input.sessionID, sessionID: input.sessionID,
messageID: SessionMessage.ID.create(),
timestamp: yield* DateTime.now,
text: readable.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n"), text: readable.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n"),
description: `Loaded ${readable.map((file) => describePath(root, file.path)).join(", ")}`, description: `Loaded ${readable.map((file) => describePath(root, file.path)).join(", ")}`,
metadata: { instruction: { paths: readable.map((file) => file.path) } }, metadata: { instruction: { paths: readable.map((file) => file.path) } },
+63 -75
View File
@@ -100,112 +100,100 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
return Effect.gen(function* () { return Effect.gen(function* () {
yield* SessionEvent.All.match(event, { yield* SessionEvent.All.match(event, {
"session.next.agent.switched": (event) => { "agent.selected": (event) => {
return adapter.appendMessage( return adapter.appendMessage(
SessionMessage.AgentSwitched.make({ SessionMessage.AgentSelected.make({
id: event.data.messageID, id: SessionMessage.ID.fromEvent(event.id),
type: "agent-switched", type: "agent-switched",
metadata: event.metadata, metadata: event.metadata,
agent: event.data.agent, agent: event.data.agent,
time: { created: event.data.timestamp }, time: { created: event.created },
}), }),
) )
}, },
"session.next.model.switched": (event) => { "model.selected": (event) => {
return adapter.appendMessage( return adapter.appendMessage(
SessionMessage.ModelSwitched.make({ SessionMessage.ModelSelected.make({
id: event.data.messageID, id: SessionMessage.ID.fromEvent(event.id),
type: "model-switched", type: "model-switched",
metadata: event.metadata, metadata: event.metadata,
model: event.data.model, model: event.data.model,
time: { created: event.data.timestamp }, time: { created: event.created },
}), }),
) )
}, },
"session.next.moved": () => Effect.void, "session.moved": () => Effect.void,
"session.next.renamed": () => Effect.void, renamed: () => Effect.void,
"session.next.forked": () => Effect.void, forked: () => Effect.void,
"session.next.prompted": (event) => { "prompt.promoted": () => Effect.void,
return adapter.appendMessage( "prompt.admitted": () => Effect.void,
SessionMessage.User.make({ "execution.settled": () => Effect.void,
id: event.data.messageID, "session.context.updated": (event) =>
type: "user",
metadata: event.metadata,
text: event.data.prompt.text,
files: event.data.prompt.files,
agents: event.data.prompt.agents,
time: { created: event.data.timestamp },
}),
)
},
"session.next.prompt.admitted": () => Effect.void,
"session.next.execution.settled": () => Effect.void,
"session.next.context.updated": (event) =>
adapter.appendMessage( adapter.appendMessage(
SessionMessage.System.make({ SessionMessage.System.make({
id: event.data.messageID, id: SessionMessage.ID.fromEvent(event.id),
type: "system", type: "system",
text: event.data.text, text: event.data.text,
time: { created: event.data.timestamp }, time: { created: event.created },
}), }),
), ),
"session.next.synthetic": (event) => { synthetic: (event) => {
return adapter.appendMessage( return adapter.appendMessage(
SessionMessage.Synthetic.make({ SessionMessage.Synthetic.make({
sessionID: event.data.sessionID, sessionID: event.data.sessionID,
text: event.data.text, text: event.data.text,
description: event.data.description, description: event.data.description,
metadata: event.data.metadata, metadata: event.data.metadata,
id: event.data.messageID, id: SessionMessage.ID.fromEvent(event.id),
type: "synthetic", type: "synthetic",
time: { created: event.data.timestamp }, time: { created: event.created },
}), }),
) )
}, },
"session.next.skill.activated": (event) => { "skill.activated": (event) => {
return adapter.appendMessage( return adapter.appendMessage(
SessionMessage.Skill.make({ SessionMessage.Skill.make({
id: event.data.messageID, id: SessionMessage.ID.fromEvent(event.id),
type: "skill", type: "skill",
name: event.data.name, name: event.data.name,
text: event.data.text, text: event.data.text,
time: { created: event.data.timestamp }, time: { created: event.created },
}), }),
) )
}, },
"session.next.shell.started": (event) => { "shell.started": (event) => {
return adapter.appendMessage( return adapter.appendMessage(
SessionMessage.Shell.make({ SessionMessage.Shell.make({
id: event.data.messageID, id: SessionMessage.ID.fromEvent(event.id),
type: "shell", type: "shell",
metadata: event.metadata, metadata: event.metadata,
callID: event.data.callID, callID: event.data.callID,
command: event.data.command, command: event.data.command,
output: "", output: "",
time: { created: event.data.timestamp }, time: { created: event.created },
}), }),
) )
}, },
"session.next.shell.ended": (event) => { "shell.ended": (event) => {
return Effect.gen(function* () { return Effect.gen(function* () {
const currentShell = yield* adapter.getCurrentShell(event.data.callID) const currentShell = yield* adapter.getCurrentShell(event.data.callID)
if (currentShell) { if (currentShell) {
yield* adapter.updateShell( yield* adapter.updateShell(
produce(currentShell, (draft) => { produce(currentShell, (draft) => {
draft.output = event.data.output draft.output = event.data.output
draft.time.completed = event.data.timestamp draft.time.completed = event.created
}), }),
) )
} }
}) })
}, },
"session.next.step.started": (event) => { "step.started": (event) => {
return Effect.gen(function* () { return Effect.gen(function* () {
const currentAssistant = yield* adapter.getCurrentAssistant() const currentAssistant = yield* adapter.getCurrentAssistant()
if (currentAssistant) { if (currentAssistant) {
yield* adapter.updateAssistant( yield* adapter.updateAssistant(
produce(currentAssistant, (draft) => { produce(currentAssistant, (draft) => {
draft.time.completed = event.data.timestamp draft.time.completed = event.created
}), }),
) )
} }
@@ -215,16 +203,16 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
type: "assistant", type: "assistant",
agent: event.data.agent, agent: event.data.agent,
model: event.data.model, model: event.data.model,
time: { created: event.data.timestamp }, time: { created: event.created },
content: [], content: [],
snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined, snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined,
}), }),
) )
}) })
}, },
"session.next.step.ended": (event) => { "step.ended": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.time.completed = event.data.timestamp draft.time.completed = event.created
draft.finish = event.data.finish draft.finish = event.data.finish
draft.cost = event.data.cost draft.cost = event.data.cost
draft.tokens = event.data.tokens draft.tokens = event.data.tokens
@@ -236,33 +224,33 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
} }
}) })
}, },
"session.next.step.failed": (event) => { "step.failed": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.time.completed = event.data.timestamp draft.time.completed = event.created
draft.finish = "error" draft.finish = "error"
draft.error = event.data.error draft.error = event.data.error
}) })
}, },
"session.next.text.started": (event) => { "text.started": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.content.push( draft.content.push(
castDraft(SessionMessage.AssistantText.make({ type: "text", id: event.data.textID, text: "" })), castDraft(SessionMessage.AssistantText.make({ type: "text", id: event.data.textID, text: "" })),
) )
}) })
}, },
"session.next.text.delta": (event) => { "text.delta": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestText(draft, event.data.textID) const match = latestText(draft, event.data.textID)
if (match) match.text += event.data.delta if (match) match.text += event.data.delta
}) })
}, },
"session.next.text.ended": (event) => { "text.ended": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestText(draft, event.data.textID) const match = latestText(draft, event.data.textID)
if (match) match.text = event.data.text if (match) match.text = event.data.text
}) })
}, },
"session.next.tool.input.started": (event) => { "tool.input.started": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.content.push( draft.content.push(
castDraft( castDraft(
@@ -270,26 +258,26 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
type: "tool", type: "tool",
id: event.data.callID, id: event.data.callID,
name: event.data.name, name: event.data.name,
time: { created: event.data.timestamp }, time: { created: event.created },
state: SessionMessage.ToolStatePending.make({ status: "pending", input: "" }), state: SessionMessage.ToolStatePending.make({ status: "pending", input: "" }),
}), }),
), ),
) )
}) })
}, },
"session.next.tool.input.delta": () => Effect.void, "tool.input.delta": () => Effect.void,
"session.next.tool.input.ended": (event) => { "tool.input.ended": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID) const match = latestTool(draft, event.data.callID)
if (match && match.state.status === "pending") match.state.input = event.data.text if (match && match.state.status === "pending") match.state.input = event.data.text
}) })
}, },
"session.next.tool.called": (event) => { "tool.called": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID) const match = latestTool(draft, event.data.callID)
if (match) { if (match) {
match.provider = event.data.provider match.provider = event.data.provider
match.time.ran = event.data.timestamp match.time.ran = event.created
match.state = castDraft( match.state = castDraft(
SessionMessage.ToolStateRunning.make({ SessionMessage.ToolStateRunning.make({
status: "running", status: "running",
@@ -301,7 +289,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
} }
}) })
}, },
"session.next.tool.progress": (event) => { "tool.progress": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID) const match = latestTool(draft, event.data.callID)
if (match && match.state.status === "running") { if (match && match.state.status === "running") {
@@ -310,7 +298,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
} }
}) })
}, },
"session.next.tool.success": (event) => { "tool.success": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID) const match = latestTool(draft, event.data.callID)
if (match && match.state.status === "running") { if (match && match.state.status === "running") {
@@ -319,7 +307,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
metadata: match.provider?.metadata, metadata: match.provider?.metadata,
resultMetadata: event.data.provider.metadata, resultMetadata: event.data.provider.metadata,
} }
match.time.completed = event.data.timestamp match.time.completed = event.created
match.state = castDraft( match.state = castDraft(
SessionMessage.ToolStateCompleted.make({ SessionMessage.ToolStateCompleted.make({
status: "completed", status: "completed",
@@ -333,7 +321,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
} }
}) })
}, },
"session.next.tool.failed": (event) => { "tool.failed": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID) const match = latestTool(draft, event.data.callID)
if (match && (match.state.status === "pending" || match.state.status === "running")) { if (match && (match.state.status === "pending" || match.state.status === "running")) {
@@ -342,7 +330,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
metadata: match.provider?.metadata, metadata: match.provider?.metadata,
resultMetadata: event.data.provider.metadata, resultMetadata: event.data.provider.metadata,
} }
match.time.completed = event.data.timestamp match.time.completed = event.created
match.state = castDraft( match.state = castDraft(
SessionMessage.ToolStateError.make({ SessionMessage.ToolStateError.make({
status: "error", status: "error",
@@ -356,7 +344,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
} }
}) })
}, },
"session.next.reasoning.started": (event) => { "reasoning.started": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.content.push( draft.content.push(
castDraft( castDraft(
@@ -365,47 +353,47 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
id: event.data.reasoningID, id: event.data.reasoningID,
text: "", text: "",
providerMetadata: event.data.providerMetadata, providerMetadata: event.data.providerMetadata,
time: { created: event.data.timestamp }, time: { created: event.created },
}), }),
), ),
) )
}) })
}, },
"session.next.reasoning.delta": (event) => { "reasoning.delta": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestReasoning(draft, event.data.reasoningID) const match = latestReasoning(draft, event.data.reasoningID)
if (match) match.text += event.data.delta if (match) match.text += event.data.delta
}) })
}, },
"session.next.reasoning.ended": (event) => { "reasoning.ended": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestReasoning(draft, event.data.reasoningID) const match = latestReasoning(draft, event.data.reasoningID)
if (match) { if (match) {
match.text = event.data.text match.text = event.data.text
match.time = { created: match.time?.created ?? event.data.timestamp, completed: event.data.timestamp } match.time = { created: match.time?.created ?? event.created, completed: event.created }
if (event.data.providerMetadata !== undefined) match.providerMetadata = event.data.providerMetadata if (event.data.providerMetadata !== undefined) match.providerMetadata = event.data.providerMetadata
} }
}) })
}, },
"session.next.retried": () => Effect.void, retried: () => Effect.void,
"session.next.compaction.started": () => Effect.void, "compaction.started": () => Effect.void,
"session.next.compaction.delta": () => Effect.void, "compaction.delta": () => Effect.void,
"session.next.compaction.ended": (event) => { "compaction.ended": (event) => {
return adapter.appendMessage( return adapter.appendMessage(
SessionMessage.Compaction.make({ SessionMessage.Compaction.make({
id: event.data.messageID, id: SessionMessage.ID.fromEvent(event.id),
type: "compaction", type: "compaction",
metadata: event.metadata, metadata: event.metadata,
reason: event.data.reason, reason: event.data.reason,
summary: event.data.text, summary: event.data.text,
recent: event.data.recent, recent: event.data.recent,
time: { created: event.data.timestamp }, time: { created: event.created },
}), }),
) )
}, },
"session.next.revert.staged": () => Effect.void, "revert.staged": () => Effect.void,
"session.next.revert.cleared": () => Effect.void, "revert.cleared": () => Effect.void,
"session.next.revert.committed": () => Effect.void, "revert.committed": () => Effect.void,
}) })
}) })
} }
+31 -30
View File
@@ -158,21 +158,17 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
.get() .get()
.pipe(Effect.orDie) .pipe(Effect.orDie)
if (!parent) return yield* Effect.die(new Error(`Fork parent session not found: ${event.data.parentID}`)) if (!parent) return yield* Effect.die(new Error(`Fork parent session not found: ${event.data.parentID}`))
const boundary = event.data.messageID const boundary = event.data.from
? yield* db ? yield* db
.select({ seq: SessionMessageTable.seq }) .select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable) .from(SessionMessageTable)
.where( .where(
and( and(eq(SessionMessageTable.session_id, event.data.parentID), eq(SessionMessageTable.id, event.data.from)),
eq(SessionMessageTable.session_id, event.data.parentID),
eq(SessionMessageTable.id, event.data.messageID),
),
) )
.get() .get()
.pipe(Effect.orDie) .pipe(Effect.orDie)
: undefined : undefined
if (event.data.messageID && !boundary) if (event.data.from && !boundary) return yield* Effect.die(new Error(`Fork boundary message not found: ${event.data.from}`))
return yield* Effect.die(new Error(`Fork boundary message not found: ${event.data.messageID}`))
const copied = yield* db const copied = yield* db
.select({ seq: SessionMessageTable.seq }) .select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable) .from(SessionMessageTable)
@@ -208,8 +204,8 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
tokens_reasoning: 0, tokens_reasoning: 0,
tokens_cache_read: 0, tokens_cache_read: 0,
tokens_cache_write: 0, tokens_cache_write: 0,
time_created: DateTime.toEpochMillis(event.data.timestamp), time_created: DateTime.toEpochMillis(event.created),
time_updated: DateTime.toEpochMillis(event.data.timestamp), time_updated: DateTime.toEpochMillis(event.created),
}) })
.onConflictDoNothing() .onConflictDoNothing()
.returning({ sessionID: SessionTable.id }) .returning({ sessionID: SessionTable.id })
@@ -474,9 +470,9 @@ const layer = Layer.effectDiscard(
.update(SessionTable) .update(SessionTable)
.set({ .set({
directory: event.data.location.directory, directory: event.data.location.directory,
path: event.data.subdirectory, path: event.data.subpath,
workspace_id: event.data.location.workspaceID ? WorkspaceV2.ID.make(event.data.location.workspaceID) : null, workspace_id: event.data.location.workspaceID ? WorkspaceV2.ID.make(event.data.location.workspaceID) : null,
time_updated: DateTime.toEpochMillis(event.data.timestamp), time_updated: DateTime.toEpochMillis(event.created),
}) })
.where(eq(SessionTable.id, event.data.sessionID)) .where(eq(SessionTable.id, event.data.sessionID))
.run() .run()
@@ -556,19 +552,19 @@ const layer = Layer.effectDiscard(
if (next) yield* applyUsage(db, sessionID, next) if (next) yield* applyUsage(db, sessionID, next)
}), }),
) )
yield* events.project(SessionEvent.AgentSwitched, (event) => yield* events.project(SessionEvent.AgentSelected, (event) =>
db db
.update(SessionTable) .update(SessionTable)
.set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.data.timestamp) }) .set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.created) })
.where(eq(SessionTable.id, event.data.sessionID)) .where(eq(SessionTable.id, event.data.sessionID))
.run() .run()
.pipe(Effect.orDie, Effect.andThen(run(db, event))), .pipe(Effect.orDie, Effect.andThen(run(db, event))),
) )
yield* events.project(SessionEvent.ModelSwitched, (event) => yield* events.project(SessionEvent.ModelSelected, (event) =>
Effect.gen(function* () { Effect.gen(function* () {
yield* db yield* db
.update(SessionTable) .update(SessionTable)
.set({ model: event.data.model, time_updated: DateTime.toEpochMillis(event.data.timestamp) }) .set({ model: event.data.model, time_updated: DateTime.toEpochMillis(event.created) })
.where(eq(SessionTable.id, event.data.sessionID)) .where(eq(SessionTable.id, event.data.sessionID))
.run() .run()
.pipe(Effect.orDie) .pipe(Effect.orDie)
@@ -578,25 +574,30 @@ const layer = Layer.effectDiscard(
yield* events.project(SessionEvent.Renamed, (event) => yield* events.project(SessionEvent.Renamed, (event) =>
db db
.update(SessionTable) .update(SessionTable)
.set({ title: event.data.title, time_updated: DateTime.toEpochMillis(event.data.timestamp) }) .set({ title: event.data.title, time_updated: DateTime.toEpochMillis(event.created) })
.where(eq(SessionTable.id, event.data.sessionID)) .where(eq(SessionTable.id, event.data.sessionID))
.run() .run()
.pipe(Effect.orDie), .pipe(Effect.orDie),
) )
yield* events.project(SessionEvent.Forked, (event) => projectFork(db, event)) yield* events.project(SessionEvent.Forked, (event) => projectFork(db, event))
yield* events.project(SessionEvent.Prompted, (event) => yield* events.project(SessionEvent.PromptPromoted, (event) =>
Effect.gen(function* () { Effect.gen(function* () {
if (event.durable === undefined) if (event.durable === undefined)
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence")) return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
yield* SessionInput.projectPrompted(db, { const input = yield* SessionInput.projectPromptPromoted(db, {
id: event.data.messageID, id: event.data.inputID,
sessionID: event.data.sessionID, sessionID: event.data.sessionID,
prompt: event.data.prompt,
delivery: event.data.delivery,
timeCreated: event.data.timestamp,
promotedSeq: event.durable.seq, promotedSeq: event.durable.seq,
}) })
yield* run(db, event) yield* insertMessage(db, event, {
id: input.id,
type: "user",
metadata: event.metadata,
text: input.prompt.text,
files: input.prompt.files,
agents: input.prompt.agents,
time: { created: event.created },
})
}), }),
) )
yield* events.project(SessionEvent.PromptAdmitted, (event) => yield* events.project(SessionEvent.PromptAdmitted, (event) =>
@@ -605,11 +606,11 @@ const layer = Layer.effectDiscard(
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence")) return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
yield* SessionInput.projectAdmitted(db, { yield* SessionInput.projectAdmitted(db, {
admittedSeq: event.durable.seq, admittedSeq: event.durable.seq,
id: event.data.messageID, id: event.data.inputID,
sessionID: event.data.sessionID, sessionID: event.data.sessionID,
prompt: event.data.prompt, prompt: event.data.prompt,
delivery: event.data.delivery, delivery: event.data.delivery,
timeCreated: event.data.timestamp, timeCreated: event.created,
}) })
}), }),
) )
@@ -617,11 +618,11 @@ const layer = Layer.effectDiscard(
yield* events.project(SessionEvent.Synthetic, (event) => run(db, event)) yield* events.project(SessionEvent.Synthetic, (event) => run(db, event))
yield* events.project(SessionEvent.Skill.Activated, (event) => yield* events.project(SessionEvent.Skill.Activated, (event) =>
insertMessage(db, event, { insertMessage(db, event, {
id: event.data.messageID, id: SessionMessage.ID.fromEvent(event.id),
type: "skill", type: "skill",
name: event.data.name, name: event.data.name,
text: event.data.text, text: event.data.text,
time: { created: event.data.timestamp }, time: { created: event.created },
}), }),
) )
yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event)) yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event))
@@ -646,7 +647,7 @@ const layer = Layer.effectDiscard(
.update(SessionTable) .update(SessionTable)
.set({ .set({
revert: { ...event.data.revert, files: event.data.revert.files ? [...event.data.revert.files] : undefined }, revert: { ...event.data.revert, files: event.data.revert.files ? [...event.data.revert.files] : undefined },
time_updated: DateTime.toEpochMillis(event.data.timestamp), time_updated: DateTime.toEpochMillis(event.created),
}) })
.where(eq(SessionTable.id, event.data.sessionID)) .where(eq(SessionTable.id, event.data.sessionID))
.run() .run()
@@ -655,7 +656,7 @@ const layer = Layer.effectDiscard(
yield* events.project(SessionEvent.RevertEvent.Cleared, (event) => yield* events.project(SessionEvent.RevertEvent.Cleared, (event) =>
db db
.update(SessionTable) .update(SessionTable)
.set({ revert: null, time_updated: DateTime.toEpochMillis(event.data.timestamp) }) .set({ revert: null, time_updated: DateTime.toEpochMillis(event.created) })
.where(eq(SessionTable.id, event.data.sessionID)) .where(eq(SessionTable.id, event.data.sessionID))
.run() .run()
.pipe(Effect.orDie, Effect.asVoid), .pipe(Effect.orDie, Effect.asVoid),
@@ -693,7 +694,7 @@ const layer = Layer.effectDiscard(
.pipe(Effect.orDie) .pipe(Effect.orDie)
yield* db yield* db
.update(SessionTable) .update(SessionTable)
.set({ revert: null, time_updated: DateTime.toEpochMillis(event.data.timestamp) }) .set({ revert: null, time_updated: DateTime.toEpochMillis(event.created) })
.where(eq(SessionTable.id, event.data.sessionID)) .where(eq(SessionTable.id, event.data.sessionID))
.run() .run()
.pipe(Effect.orDie) .pipe(Effect.orDie)
-3
View File
@@ -89,7 +89,6 @@ export const stage = Effect.fn("SessionRevert.stage")(function* (input: {
} satisfies SessionSchema.Info["revert"] } satisfies SessionSchema.Info["revert"]
yield* events.publish(SessionEvent.RevertEvent.Staged, { yield* events.publish(SessionEvent.RevertEvent.Staged, {
sessionID: input.session.id, sessionID: input.session.id,
timestamp: yield* DateTime.now,
revert, revert,
}) })
return revert return revert
@@ -106,7 +105,6 @@ export const clear = Effect.fn("SessionRevert.clear")(function* (session: Sessio
const events = yield* EventV2.Service const events = yield* EventV2.Service
yield* events.publish(SessionEvent.RevertEvent.Cleared, { yield* events.publish(SessionEvent.RevertEvent.Cleared, {
sessionID: session.id, sessionID: session.id,
timestamp: yield* DateTime.now,
}) })
}) })
@@ -116,6 +114,5 @@ export const commit = Effect.fn("SessionRevert.commit")(function* (session: Sess
yield* events.publish(SessionEvent.RevertEvent.Committed, { yield* events.publish(SessionEvent.RevertEvent.Committed, {
sessionID: session.id, sessionID: session.id,
messageID: session.revert.messageID, messageID: session.revert.messageID,
timestamp: yield* DateTime.now,
}) })
}) })
-2
View File
@@ -134,7 +134,6 @@ const layer = Layer.effect(
if (tool.type !== "tool" || (tool.state.status !== "pending" && tool.state.status !== "running")) continue if (tool.type !== "tool" || (tool.state.status !== "pending" && tool.state.status !== "running")) continue
yield* events.publish(SessionEvent.Tool.Failed, { yield* events.publish(SessionEvent.Tool.Failed, {
sessionID, sessionID,
timestamp: yield* DateTime.now,
assistantMessageID: message.id, assistantMessageID: message.id,
callID: tool.id, callID: tool.id,
error: { type: "unknown", message: "Tool execution interrupted" }, error: { type: "unknown", message: "Tool execution interrupted" },
@@ -297,7 +296,6 @@ const layer = Layer.effect(
yield* serialized( yield* serialized(
events.publish(SessionEvent.Step.Ended, { events.publish(SessionEvent.Step.Ended, {
sessionID: session.id, sessionID: session.id,
timestamp: yield* DateTime.now,
assistantMessageID: yield* publisher.startAssistant(), assistantMessageID: yield* publisher.startAssistant(),
finish: settlement.finish, finish: settlement.finish,
cost: 0, cost: 0,
@@ -78,7 +78,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
yield* events.publish(SessionEvent.Step.Started, { yield* events.publish(SessionEvent.Step.Started, {
...input, ...input,
assistantMessageID, assistantMessageID,
timestamp: yield* timestamp,
snapshot: input.snapshot, snapshot: input.snapshot,
}) })
return assistantMessageID return assistantMessageID
@@ -123,7 +122,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
yield* events.publish(SessionEvent.Text.Ended, { yield* events.publish(SessionEvent.Text.Ended, {
sessionID: input.sessionID, sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(), assistantMessageID: yield* currentAssistantMessageID(),
timestamp: yield* timestamp,
textID, textID,
text: value, text: value,
}) })
@@ -134,7 +132,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
yield* events.publish(SessionEvent.Reasoning.Ended, { yield* events.publish(SessionEvent.Reasoning.Ended, {
sessionID: input.sessionID, sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(), assistantMessageID: yield* currentAssistantMessageID(),
timestamp: yield* timestamp,
reasoningID, reasoningID,
text: value, text: value,
providerMetadata, providerMetadata,
@@ -147,7 +144,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
if (!tool) return yield* Effect.die(new Error(`Tool input end before start: ${callID}`)) if (!tool) return yield* Effect.die(new Error(`Tool input end before start: ${callID}`))
yield* events.publish(SessionEvent.Tool.Input.Ended, { yield* events.publish(SessionEvent.Tool.Input.Ended, {
sessionID: input.sessionID, sessionID: input.sessionID,
timestamp: yield* timestamp,
assistantMessageID: tool.assistantMessageID, assistantMessageID: tool.assistantMessageID,
callID, callID,
text: value, text: value,
@@ -176,7 +172,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
yield* toolInput.start(event.id) yield* toolInput.start(event.id)
yield* events.publish(SessionEvent.Tool.Input.Started, { yield* events.publish(SessionEvent.Tool.Input.Started, {
sessionID: input.sessionID, sessionID: input.sessionID,
timestamp: yield* timestamp,
assistantMessageID, assistantMessageID,
callID: event.id, callID: event.id,
name: event.name, name: event.name,
@@ -204,7 +199,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
assistantFailed = true assistantFailed = true
yield* events.publish(SessionEvent.Step.Failed, { yield* events.publish(SessionEvent.Step.Failed, {
sessionID: input.sessionID, sessionID: input.sessionID,
timestamp: yield* timestamp,
assistantMessageID, assistantMessageID,
error: { type: "unknown", message }, error: { type: "unknown", message },
}) })
@@ -219,7 +213,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
tool.settled = true tool.settled = true
yield* events.publish(SessionEvent.Tool.Failed, { yield* events.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID, sessionID: input.sessionID,
timestamp: yield* timestamp,
assistantMessageID: tool.assistantMessageID, assistantMessageID: tool.assistantMessageID,
callID, callID,
error: { type: "unknown", message }, error: { type: "unknown", message },
@@ -248,7 +241,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
yield* events.publish(SessionEvent.Text.Started, { yield* events.publish(SessionEvent.Text.Started, {
sessionID: input.sessionID, sessionID: input.sessionID,
assistantMessageID: yield* startAssistant(), assistantMessageID: yield* startAssistant(),
timestamp: yield* timestamp,
textID: event.id, textID: event.id,
}) })
return return
@@ -257,7 +249,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
yield* events.publish(SessionEvent.Text.Delta, { yield* events.publish(SessionEvent.Text.Delta, {
sessionID: input.sessionID, sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(), assistantMessageID: yield* currentAssistantMessageID(),
timestamp: yield* timestamp,
textID: event.id, textID: event.id,
delta: event.text, delta: event.text,
}) })
@@ -270,7 +261,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
yield* events.publish(SessionEvent.Reasoning.Started, { yield* events.publish(SessionEvent.Reasoning.Started, {
sessionID: input.sessionID, sessionID: input.sessionID,
assistantMessageID: yield* startAssistant(), assistantMessageID: yield* startAssistant(),
timestamp: yield* timestamp,
reasoningID: event.id, reasoningID: event.id,
providerMetadata: event.providerMetadata, providerMetadata: event.providerMetadata,
}) })
@@ -280,7 +270,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
yield* events.publish(SessionEvent.Reasoning.Delta, { yield* events.publish(SessionEvent.Reasoning.Delta, {
sessionID: input.sessionID, sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(), assistantMessageID: yield* currentAssistantMessageID(),
timestamp: yield* timestamp,
reasoningID: event.id, reasoningID: event.id,
delta: event.text, delta: event.text,
}) })
@@ -300,7 +289,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
yield* toolInput.append(event.id, event.text) yield* toolInput.append(event.id, event.text)
yield* events.publish(SessionEvent.Tool.Input.Delta, { yield* events.publish(SessionEvent.Tool.Input.Delta, {
sessionID: input.sessionID, sessionID: input.sessionID,
timestamp: yield* timestamp,
assistantMessageID: tool.assistantMessageID, assistantMessageID: tool.assistantMessageID,
callID: event.id, callID: event.id,
delta: event.text, delta: event.text,
@@ -322,7 +310,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
tool.providerMetadata = event.providerMetadata tool.providerMetadata = event.providerMetadata
yield* events.publish(SessionEvent.Tool.Called, { yield* events.publish(SessionEvent.Tool.Called, {
sessionID: input.sessionID, sessionID: input.sessionID,
timestamp: yield* timestamp,
assistantMessageID: tool.assistantMessageID, assistantMessageID: tool.assistantMessageID,
callID: event.id, callID: event.id,
tool: event.name, tool: event.name,
@@ -352,7 +339,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
if ("error" in result) { if ("error" in result) {
yield* events.publish(SessionEvent.Tool.Failed, { yield* events.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID, sessionID: input.sessionID,
timestamp: yield* timestamp,
assistantMessageID: tool.assistantMessageID, assistantMessageID: tool.assistantMessageID,
callID: event.id, callID: event.id,
error: result.error, error: result.error,
@@ -363,7 +349,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
} }
yield* events.publish(SessionEvent.Tool.Success, { yield* events.publish(SessionEvent.Tool.Success, {
sessionID: input.sessionID, sessionID: input.sessionID,
timestamp: yield* timestamp,
assistantMessageID: tool.assistantMessageID, assistantMessageID: tool.assistantMessageID,
callID: event.id, callID: event.id,
...result, ...result,
@@ -382,7 +367,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
tool.settled = true tool.settled = true
yield* events.publish(SessionEvent.Tool.Failed, { yield* events.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID, sessionID: input.sessionID,
timestamp: yield* timestamp,
assistantMessageID: tool.assistantMessageID, assistantMessageID: tool.assistantMessageID,
callID: event.id, callID: event.id,
error: { type: "unknown", message: event.message }, error: { type: "unknown", message: event.message },
+4 -4
View File
@@ -42,9 +42,10 @@ const make = (dependencies: Dependencies) => {
if (!firstUser) return if (!firstUser) return
const agent = yield* dependencies.agents.get(AgentV2.ID.make("title")) const agent = yield* dependencies.agents.get(AgentV2.ID.make("title"))
if (!agent) return if (!agent) return
const resolved = yield* (agent.model const resolved = yield* (
? dependencies.models.resolve({ ...session, model: agent.model }) agent.model
: dependencies.models.resolve(session) ? dependencies.models.resolve({ ...session, model: agent.model })
: dependencies.models.resolve(session)
).pipe(Effect.catch(() => Effect.succeed(undefined))) ).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!resolved) return if (!resolved) return
const chunks: string[] = [] const chunks: string[] = []
@@ -76,7 +77,6 @@ const make = (dependencies: Dependencies) => {
if (!title) return if (!title) return
yield* dependencies.events.publish(SessionEvent.Renamed, { yield* dependencies.events.publish(SessionEvent.Renamed, {
sessionID: session.id, sessionID: session.id,
timestamp: yield* DateTime.now,
title: truncate(title), title: truncate(title),
}) })
}) })
@@ -255,7 +255,7 @@ describe("DatabaseMigration", () => {
) )
yield* db.run(sql`INSERT INTO event_sequence (aggregate_id, seq) VALUES ('session', 9)`) yield* db.run(sql`INSERT INTO event_sequence (aggregate_id, seq) VALUES ('session', 9)`)
yield* db.run( yield* db.run(
sql`INSERT INTO event (id, aggregate_id, seq, type, data) VALUES ('event', 'session', 9, 'session.updated.1', '{}')`, sql`INSERT INTO event (id, aggregate_id, seq, type, data, created) VALUES ('event', 'session', 9, 'session.updated.1', '{}', 1)`,
) )
yield* db.run( yield* db.run(
sql`INSERT INTO session_input (id, session_id, prompt, delivery, admitted_seq, time_created) VALUES ('input', 'session', '{}', 'steer', 9, 1)`, sql`INSERT INTO session_input (id, session_id, prompt, delivery, admitted_seq, time_created) VALUES ('input', 'session', '{}', 'steer', 9, 1)`,
+30 -2
View File
@@ -554,6 +554,7 @@ describe("EventV2", () => {
yield* events.replay({ yield* events.replay({
id: EventV2.ID.create(), id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1), type: EventV2.versionedType(DurableMessage.type, 1),
seq: 0, seq: 0,
aggregateID, aggregateID,
@@ -573,6 +574,7 @@ describe("EventV2", () => {
yield* events.replay({ yield* events.replay({
id: EventV2.ID.create(), id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1), type: EventV2.versionedType(DurableMessage.type, 1),
seq: 0, seq: 0,
aggregateID, aggregateID,
@@ -609,6 +611,7 @@ describe("EventV2", () => {
const exit = yield* events const exit = yield* events
.replay({ .replay({
id: EventV2.ID.create(), id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1), type: EventV2.versionedType(DurableMessage.type, 1),
seq: 1, seq: 1,
aggregateID: envelopeAggregateID, aggregateID: envelopeAggregateID,
@@ -642,6 +645,7 @@ describe("EventV2", () => {
yield* events.replay({ yield* events.replay({
id: EventV2.ID.create(), id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1), type: EventV2.versionedType(DurableMessage.type, 1),
seq: 0, seq: 0,
aggregateID, aggregateID,
@@ -650,6 +654,7 @@ describe("EventV2", () => {
const exit = yield* events const exit = yield* events
.replay({ .replay({
id: EventV2.ID.create(), id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1), type: EventV2.versionedType(DurableMessage.type, 1),
seq: 5, seq: 5,
aggregateID, aggregateID,
@@ -674,13 +679,14 @@ describe("EventV2", () => {
yield* events.replay({ yield* events.replay({
id: EventV2.ID.create(), id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(SessionEvent.ContextUpdated.type, 1), type: EventV2.versionedType(SessionEvent.ContextUpdated.type, 1),
seq: 0, seq: 0,
aggregateID, aggregateID,
data: { sessionID: aggregateID, messageID: "msg_context", timestamp: 0, text: "context" }, data: { sessionID: aggregateID, text: "context" },
}) })
expect(received[0]?.data.timestamp).toEqual(DateTime.makeUnsafe(0)) expect(received[0]?.created).toEqual(DateTime.makeUnsafe(0))
}), }),
) )
@@ -690,6 +696,7 @@ describe("EventV2", () => {
const exit = yield* events const exit = yield* events
.replay({ .replay({
id: EventV2.ID.create(), id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: "unknown.event.1", type: "unknown.event.1",
seq: 0, seq: 0,
aggregateID: EventV2.ID.create(), aggregateID: EventV2.ID.create(),
@@ -708,6 +715,7 @@ describe("EventV2", () => {
const source = yield* events.replayAll([ const source = yield* events.replayAll([
{ {
id: EventV2.ID.create(), id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1), type: EventV2.versionedType(DurableMessage.type, 1),
seq: 0, seq: 0,
aggregateID, aggregateID,
@@ -715,6 +723,7 @@ describe("EventV2", () => {
}, },
{ {
id: EventV2.ID.create(), id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1), type: EventV2.versionedType(DurableMessage.type, 1),
seq: 1, seq: 1,
aggregateID, aggregateID,
@@ -735,6 +744,7 @@ describe("EventV2", () => {
const one = yield* events.replayAll([ const one = yield* events.replayAll([
{ {
id: EventV2.ID.create(), id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1), type: EventV2.versionedType(DurableMessage.type, 1),
seq: 0, seq: 0,
aggregateID, aggregateID,
@@ -742,6 +752,7 @@ describe("EventV2", () => {
}, },
{ {
id: EventV2.ID.create(), id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1), type: EventV2.versionedType(DurableMessage.type, 1),
seq: 1, seq: 1,
aggregateID, aggregateID,
@@ -751,6 +762,7 @@ describe("EventV2", () => {
const two = yield* events.replayAll([ const two = yield* events.replayAll([
{ {
id: EventV2.ID.create(), id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1), type: EventV2.versionedType(DurableMessage.type, 1),
seq: 2, seq: 2,
aggregateID, aggregateID,
@@ -758,6 +770,7 @@ describe("EventV2", () => {
}, },
{ {
id: EventV2.ID.create(), id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1), type: EventV2.versionedType(DurableMessage.type, 1),
seq: 3, seq: 3,
aggregateID, aggregateID,
@@ -793,6 +806,7 @@ describe("EventV2", () => {
yield* events.replay( yield* events.replay(
{ {
id: EventV2.ID.create(), id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1), type: EventV2.versionedType(DurableMessage.type, 1),
seq: 1, seq: 1,
aggregateID, aggregateID,
@@ -812,6 +826,7 @@ describe("EventV2", () => {
const id = EventV2.ID.create() const id = EventV2.ID.create()
const replayed = { const replayed = {
id, id,
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1), type: EventV2.versionedType(DurableMessage.type, 1),
seq: 0, seq: 0,
aggregateID, aggregateID,
@@ -833,6 +848,7 @@ describe("EventV2", () => {
const published = yield* events.publish(DurableMessage, durableData(aggregateID, "owned")) const published = yield* events.publish(DurableMessage, durableData(aggregateID, "owned"))
const replayed = { const replayed = {
id: published.id, id: published.id,
created: published.created,
type: EventV2.versionedType(DurableMessage.type, 1), type: EventV2.versionedType(DurableMessage.type, 1),
seq: published.durable!.seq, seq: published.durable!.seq,
aggregateID, aggregateID,
@@ -867,6 +883,7 @@ describe("EventV2", () => {
yield* events.replay( yield* events.replay(
{ {
id: EventV2.ID.create(), id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1), type: EventV2.versionedType(DurableMessage.type, 1),
seq: 0, seq: 0,
aggregateID, aggregateID,
@@ -895,6 +912,7 @@ describe("EventV2", () => {
yield* events.replay( yield* events.replay(
{ {
id: EventV2.ID.create(), id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1), type: EventV2.versionedType(DurableMessage.type, 1),
seq: 1, seq: 1,
aggregateID, aggregateID,
@@ -905,6 +923,7 @@ describe("EventV2", () => {
yield* events.replay( yield* events.replay(
{ {
id: EventV2.ID.create(), id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1), type: EventV2.versionedType(DurableMessage.type, 1),
seq: 2, seq: 2,
aggregateID, aggregateID,
@@ -937,6 +956,7 @@ describe("EventV2", () => {
yield* events.replay( yield* events.replay(
{ {
id: EventV2.ID.create(), id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1), type: EventV2.versionedType(DurableMessage.type, 1),
seq: 0, seq: 0,
aggregateID, aggregateID,
@@ -949,6 +969,7 @@ describe("EventV2", () => {
.replay( .replay(
{ {
id: EventV2.ID.create(), id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1), type: EventV2.versionedType(DurableMessage.type, 1),
seq: 1, seq: 1,
aggregateID, aggregateID,
@@ -970,6 +991,7 @@ describe("EventV2", () => {
yield* events.listen((event) => Effect.sync(() => received.push(event))) yield* events.listen((event) => Effect.sync(() => received.push(event)))
const replayed = { const replayed = {
id: EventV2.ID.create(), id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1), type: EventV2.versionedType(DurableMessage.type, 1),
seq: 0, seq: 0,
aggregateID, aggregateID,
@@ -990,6 +1012,7 @@ describe("EventV2", () => {
const aggregateID = Session.ID.create() const aggregateID = Session.ID.create()
const replayed = { const replayed = {
id: EventV2.ID.create(), id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1), type: EventV2.versionedType(DurableMessage.type, 1),
seq: 0, seq: 0,
aggregateID, aggregateID,
@@ -1014,6 +1037,7 @@ describe("EventV2", () => {
const id = EventV2.ID.create() const id = EventV2.ID.create()
yield* events.replay({ yield* events.replay({
id, id,
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1), type: EventV2.versionedType(DurableMessage.type, 1),
seq: 0, seq: 0,
aggregateID, aggregateID,
@@ -1023,6 +1047,7 @@ describe("EventV2", () => {
const exit = yield* events const exit = yield* events
.replay({ .replay({
id, id,
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1), type: EventV2.versionedType(DurableMessage.type, 1),
seq: 1, seq: 1,
aggregateID, aggregateID,
@@ -1045,6 +1070,7 @@ describe("EventV2", () => {
yield* events.replay( yield* events.replay(
{ {
id: EventV2.ID.create(), id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1), type: EventV2.versionedType(DurableMessage.type, 1),
seq: 0, seq: 0,
aggregateID, aggregateID,
@@ -1055,6 +1081,7 @@ describe("EventV2", () => {
yield* events.replay( yield* events.replay(
{ {
id: EventV2.ID.create(), id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1), type: EventV2.versionedType(DurableMessage.type, 1),
seq: 1, seq: 1,
aggregateID, aggregateID,
@@ -1116,6 +1143,7 @@ describe("EventV2", () => {
yield* events.replay({ yield* events.replay({
id: EventV2.ID.create(), id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1), type: EventV2.versionedType(DurableMessage.type, 1),
seq: 0, seq: 0,
aggregateID, aggregateID,
+3 -7
View File
@@ -85,17 +85,13 @@ describe("SessionV2.compact", () => {
const prompt = Prompt.make({ text: "Please compact this session history." }) const prompt = Prompt.make({ text: "Please compact this session history." })
yield* events.publish(SessionEvent.PromptAdmitted, { yield* events.publish(SessionEvent.PromptAdmitted, {
sessionID: created.id, sessionID: created.id,
messageID, inputID: messageID,
timestamp: DateTime.makeUnsafe(0),
prompt, prompt,
delivery: "steer", delivery: "steer",
}) })
yield* events.publish(SessionEvent.Prompted, { yield* events.publish(SessionEvent.PromptPromoted, {
sessionID: created.id, sessionID: created.id,
messageID, inputID: messageID,
timestamp: DateTime.makeUnsafe(0),
prompt,
delivery: "steer",
}) })
yield* session.compact({ sessionID: created.id }) yield* session.compact({ sessionID: created.id })
+8 -9
View File
@@ -197,8 +197,6 @@ describe("SessionV2.create", () => {
yield* SessionInput.promoteSteers(db, events, parent.id) yield* SessionInput.promoteSteers(db, events, parent.id)
yield* events.publish(SessionEvent.Synthetic, { yield* events.publish(SessionEvent.Synthetic, {
sessionID: parent.id, sessionID: parent.id,
messageID: SessionMessage.ID.create(),
timestamp: yield* DateTime.now,
text: "parent note", text: "parent note",
}) })
@@ -215,7 +213,7 @@ describe("SessionV2.create", () => {
expect(forkContext.map((message) => message.id)).not.toEqual(parentContext.map((message) => message.id)) expect(forkContext.map((message) => message.id)).not.toEqual(parentContext.map((message) => message.id))
expect(history).toHaveLength(1) expect(history).toHaveLength(1)
expect(history[0]).toMatchObject({ expect(history[0]).toMatchObject({
type: "session.next.forked", type: "forked",
durable: { seq: 0 }, durable: { seq: 0 },
data: { sessionID: forked.id, parentID: parent.id }, data: { sessionID: forked.id, parentID: parent.id },
}) })
@@ -267,7 +265,7 @@ describe("SessionV2.create", () => {
const history = Array.from(yield* Stream.runCollect(logEvents(session, forked.id))) const history = Array.from(yield* Stream.runCollect(logEvents(session, forked.id)))
expect(context).toMatchObject([{ text: "First" }]) expect(context).toMatchObject([{ text: "First" }])
expect(context[0]?.id).not.toBe(first.id) expect(context[0]?.id).not.toBe(first.id)
expect(history[0]).toMatchObject({ data: { messageID: second.id } }) expect(history[0]).toMatchObject({ data: { from: second.id } })
}), }),
) )
@@ -380,8 +378,8 @@ describe("SessionV2.create", () => {
expect( expect(
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(2), Stream.runCollect)), Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(2), Stream.runCollect)),
).toMatchObject([ ).toMatchObject([
{ durable: { seq: 1 }, type: "session.next.prompt.admitted", data: { prompt: { text: "Hello" } } }, { durable: { seq: 1 }, type: "prompt.admitted", data: { prompt: { text: "Hello" } } },
{ durable: { seq: 2 }, type: "session.next.prompted" }, { durable: { seq: 2 }, type: "prompt.promoted" },
]) ])
}), }),
) )
@@ -406,6 +404,7 @@ describe("SessionV2.create", () => {
.all() .all()
.pipe(Effect.orDie)).map((event) => ({ .pipe(Effect.orDie)).map((event) => ({
id: event.id, id: event.id,
created: DateTime.makeUnsafe(event.created),
aggregateID: event.aggregate_id, aggregateID: event.aggregate_id,
seq: event.seq, seq: event.seq,
type: event.type, type: event.type,
@@ -466,7 +465,7 @@ describe("SessionV2.create", () => {
).toEqual([ ).toEqual([
[0, EventV2.versionedType(SessionV1.Event.Created.type, 1)], [0, EventV2.versionedType(SessionV1.Event.Created.type, 1)],
[1, EventV2.versionedType(SessionEvent.PromptAdmitted.type, 1)], [1, EventV2.versionedType(SessionEvent.PromptAdmitted.type, 1)],
[2, EventV2.versionedType(SessionEvent.Prompted.type, 1)], [2, EventV2.versionedType(SessionEvent.PromptPromoted.type, 1)],
]) ])
}).pipe(Effect.provide(Layer.fresh(targetLayer))) }).pipe(Effect.provide(Layer.fresh(targetLayer)))
}), }),
@@ -530,7 +529,7 @@ describe("SessionV2.create", () => {
expect(yield* session.get(created.id)).toMatchObject({ agent: "plan" }) expect(yield* session.get(created.id)).toMatchObject({ agent: "plan" })
expect( expect(
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(1), Stream.runCollect)), Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(1), Stream.runCollect)),
).toMatchObject([{ type: "session.next.agent.switched", data: { agent: "plan" } }]) ).toMatchObject([{ type: "agent.selected", data: { agent: "plan" } }])
}), }),
) )
@@ -563,7 +562,7 @@ describe("SessionV2.create", () => {
expect(yield* session.get(created.id)).toMatchObject({ model }) expect(yield* session.get(created.id)).toMatchObject({ model })
expect( expect(
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(1), Stream.runCollect)), Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(1), Stream.runCollect)),
).toMatchObject([{ type: "session.next.model.switched", data: { model } }]) ).toMatchObject([{ type: "model.selected", data: { model } }])
}), }),
) )
@@ -133,8 +133,6 @@ const seedSynthetic = (sessionID: SessionV2.ID, paths: string[]) =>
const events = yield* EventV2.Service const events = yield* EventV2.Service
yield* events.publish(SessionEvent.Synthetic, { yield* events.publish(SessionEvent.Synthetic, {
sessionID, sessionID,
messageID: SessionMessage.ID.create(),
timestamp: yield* DateTime.now,
text: `Instructions from: ${paths[0]}\nprior`, text: `Instructions from: ${paths[0]}\nprior`,
description: `Loaded ${paths[0]}`, description: `Loaded ${paths[0]}`,
metadata: { instruction: { paths } }, metadata: { instruction: { paths } },
+2 -2
View File
@@ -47,7 +47,7 @@ describe("SessionV2.log", () => {
// Session creation commits a non-public durable event, so the marker's // Session creation commits a non-public durable event, so the marker's
// seq covers more of the aggregate than the public events emitted. // seq covers more of the aggregate than the public events emitted.
expect(items.map((item) => item.type)).toEqual(["session.next.renamed", "log.synced"]) expect(items.map((item) => item.type)).toEqual(["renamed", "log.synced"])
expect(items.at(-1)).toEqual({ type: "log.synced", aggregateID: created.id, seq: watermark }) expect(items.at(-1)).toEqual({ type: "log.synced", aggregateID: created.id, seq: watermark })
}), }),
) )
@@ -64,7 +64,7 @@ describe("SessionV2.log", () => {
yield* session.rename({ sessionID: created.id, title: "renamed live" }) yield* session.rename({ sessionID: created.id, title: "renamed live" })
const items = Array.from(yield* Fiber.join(fiber)) const items = Array.from(yield* Fiber.join(fiber))
expect(items.map((item) => item.type)).toEqual(["log.synced", "session.next.renamed"]) expect(items.map((item) => item.type)).toEqual(["log.synced", "renamed"])
}), }),
) )
+23 -47
View File
@@ -79,7 +79,6 @@ describe("SessionProjector", () => {
const events = yield* EventV2.Service const events = yield* EventV2.Service
yield* events.publish(SessionEvent.RevertEvent.Staged, { yield* events.publish(SessionEvent.RevertEvent.Staged, {
sessionID, sessionID,
timestamp: DateTime.makeUnsafe(1),
revert: { messageID: boundary, snapshot: Snapshot.ID.make("tree"), diff: "patch", files: [] }, revert: { messageID: boundary, snapshot: Snapshot.ID.make("tree"), diff: "patch", files: [] },
}) })
expect((yield* db.select({ revert: SessionTable.revert }).from(SessionTable).get())?.revert).toMatchObject({ expect((yield* db.select({ revert: SessionTable.revert }).from(SessionTable).get())?.revert).toMatchObject({
@@ -87,17 +86,15 @@ describe("SessionProjector", () => {
snapshot: "tree", snapshot: "tree",
files: [], files: [],
}) })
yield* events.publish(SessionEvent.RevertEvent.Cleared, { sessionID, timestamp: DateTime.makeUnsafe(2) }) yield* events.publish(SessionEvent.RevertEvent.Cleared, { sessionID })
expect((yield* db.select({ revert: SessionTable.revert }).from(SessionTable).get())?.revert).toBeNull() expect((yield* db.select({ revert: SessionTable.revert }).from(SessionTable).get())?.revert).toBeNull()
yield* events.publish(SessionEvent.RevertEvent.Staged, { yield* events.publish(SessionEvent.RevertEvent.Staged, {
sessionID, sessionID,
timestamp: DateTime.makeUnsafe(3),
revert: { messageID: boundary, files: [] }, revert: { messageID: boundary, files: [] },
}) })
yield* events.publish(SessionEvent.RevertEvent.Committed, { yield* events.publish(SessionEvent.RevertEvent.Committed, {
sessionID, sessionID,
messageID: boundary, messageID: boundary,
timestamp: DateTime.makeUnsafe(4),
}) })
expect( expect(
(yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all()).map((row) => row.id), (yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all()).map((row) => row.id),
@@ -131,37 +128,29 @@ describe("SessionProjector", () => {
yield* events.publish(SessionEvent.PromptAdmitted, { yield* events.publish(SessionEvent.PromptAdmitted, {
sessionID, sessionID,
messageID: SessionMessage.ID.make("msg_first"), inputID: SessionMessage.ID.make("msg_first"),
timestamp: created,
prompt: Prompt.make({ text: "first" }), prompt: Prompt.make({ text: "first" }),
delivery: "steer", delivery: "steer",
}) })
yield* events.publish( yield* events.publish(
SessionEvent.Prompted, SessionEvent.PromptPromoted,
{ {
sessionID, sessionID,
messageID: SessionMessage.ID.make("msg_first"), inputID: SessionMessage.ID.make("msg_first"),
timestamp: created,
prompt: Prompt.make({ text: "first" }),
delivery: "steer",
}, },
{ id: EventV2.ID.make("evt_z") }, { id: EventV2.ID.make("evt_z") },
) )
yield* events.publish(SessionEvent.PromptAdmitted, { yield* events.publish(SessionEvent.PromptAdmitted, {
sessionID, sessionID,
messageID: SessionMessage.ID.make("msg_second"), inputID: SessionMessage.ID.make("msg_second"),
timestamp: created,
prompt: Prompt.make({ text: "second" }), prompt: Prompt.make({ text: "second" }),
delivery: "steer", delivery: "steer",
}) })
yield* events.publish( yield* events.publish(
SessionEvent.Prompted, SessionEvent.PromptPromoted,
{ {
sessionID, sessionID,
messageID: SessionMessage.ID.make("msg_second"), inputID: SessionMessage.ID.make("msg_second"),
timestamp: created,
prompt: Prompt.make({ text: "second" }),
delivery: "steer",
}, },
{ id: EventV2.ID.make("evt_a") }, { id: EventV2.ID.make("evt_a") },
) )
@@ -190,7 +179,7 @@ describe("SessionProjector", () => {
}).pipe(Effect.provide(sessionsLayer)), }).pipe(Effect.provide(sessionsLayer)),
) )
it.effect("marks an inbox row promoted with the Prompted event sequence", () => it.effect("marks an inbox row promoted with the PromptPromoted event sequence", () =>
Effect.gen(function* () { Effect.gen(function* () {
const { db } = yield* Database.Service const { db } = yield* Database.Service
yield* db yield* db
@@ -220,12 +209,9 @@ describe("SessionProjector", () => {
}) })
if (!admitted) return yield* Effect.die("Prompt admission failed") if (!admitted) return yield* Effect.die("Prompt admission failed")
const event = yield* events.publish(SessionEvent.Prompted, { const event = yield* events.publish(SessionEvent.PromptPromoted, {
sessionID, sessionID,
timestamp: admitted.timeCreated, inputID: id,
messageID: id,
prompt: Prompt.make({ text: "promote me" }),
delivery: "steer",
}) })
expect( expect(
@@ -256,49 +242,36 @@ describe("SessionProjector", () => {
.pipe(Effect.orDie) .pipe(Effect.orDie)
const events = yield* EventV2.Service const events = yield* EventV2.Service
yield* events.publish(SessionEvent.AgentSwitched, { yield* events.publish(SessionEvent.AgentSelected, {
sessionID, sessionID,
messageID: SessionMessage.ID.create(),
timestamp: created,
agent: "build", agent: "build",
}) })
yield* events.publish(SessionEvent.ModelSwitched, { yield* events.publish(SessionEvent.ModelSelected, {
sessionID, sessionID,
messageID: SessionMessage.ID.create(),
timestamp: created,
model, model,
}) })
yield* events.publish(SessionEvent.Synthetic, { yield* events.publish(SessionEvent.Synthetic, {
sessionID, sessionID,
messageID: SessionMessage.ID.create(),
timestamp: created,
text: "synthetic context", text: "synthetic context",
metadata: { source: "projector-test" }, metadata: { source: "projector-test" },
}) })
yield* events.publish(SessionEvent.Shell.Started, { yield* events.publish(SessionEvent.Shell.Started, {
sessionID, sessionID,
messageID: SessionMessage.ID.create(),
timestamp: created,
callID: "shell-1", callID: "shell-1",
command: "pwd", command: "pwd",
}) })
yield* events.publish(SessionEvent.Shell.Ended, { yield* events.publish(SessionEvent.Shell.Ended, {
sessionID, sessionID,
timestamp: DateTime.makeUnsafe(1),
callID: "shell-1", callID: "shell-1",
output: "/project", output: "/project",
}) })
const compactionID = SessionMessage.ID.create() const compactionID = SessionMessage.ID.create()
yield* events.publish(SessionEvent.Compaction.Started, { yield* events.publish(SessionEvent.Compaction.Started, {
sessionID, sessionID,
messageID: compactionID,
timestamp: created,
reason: "manual", reason: "manual",
}) })
yield* events.publish(SessionEvent.Compaction.Delta, { yield* events.publish(SessionEvent.Compaction.Delta, {
sessionID, sessionID,
messageID: compactionID,
timestamp: created,
text: "partial", text: "partial",
}) })
expect( expect(
@@ -319,8 +292,6 @@ describe("SessionProjector", () => {
).toEqual([]) ).toEqual([])
yield* events.publish(SessionEvent.Compaction.Ended, { yield* events.publish(SessionEvent.Compaction.Ended, {
sessionID, sessionID,
messageID: compactionID,
timestamp: DateTime.makeUnsafe(1),
reason: "manual", reason: "manual",
text: "summary", text: "summary",
recent: "recent context", recent: "recent context",
@@ -350,7 +321,7 @@ describe("SessionProjector", () => {
}) })
expect(messages.find((message) => message.type === "shell")).toMatchObject({ expect(messages.find((message) => message.type === "shell")).toMatchObject({
output: "/project", output: "/project",
time: { completed: DateTime.makeUnsafe(1) }, time: { completed: DateTime.makeUnsafe(0) },
}) })
expect(messages.find((message) => message.type === "compaction")).toMatchObject({ expect(messages.find((message) => message.type === "compaction")).toMatchObject({
summary: "summary", summary: "summary",
@@ -388,13 +359,20 @@ describe("SessionProjector", () => {
.pipe(Effect.orDie) .pipe(Effect.orDie)
const events = yield* EventV2.Service const events = yield* EventV2.Service
const id = SessionMessage.ID.make("msg_creator_collision") const id = SessionMessage.ID.make("msg_creator_collision")
const {
id: _,
type,
...data
} = encodeMessage({ id, sessionID, type: "synthetic", text: "existing", time: { created } })
yield* db
.insert(SessionMessageTable)
.values({ id, session_id: sessionID, type, seq: 0, time_created: 0, data })
.run()
yield* events.publish(SessionEvent.Synthetic, { sessionID, messageID: id, timestamp: created, text: "keep me" })
const exit = yield* events const exit = yield* events
.publish(SessionEvent.Step.Started, { .publish(SessionEvent.Step.Started, {
sessionID, sessionID,
assistantMessageID: id, assistantMessageID: id,
timestamp: created,
agent: "build", agent: "build",
model, model,
}) })
@@ -464,7 +442,6 @@ describe("SessionProjector", () => {
const service = yield* EventV2.Service const service = yield* EventV2.Service
yield* service.publish(SessionEvent.Step.Ended, { yield* service.publish(SessionEvent.Step.Ended, {
sessionID, sessionID,
timestamp: DateTime.makeUnsafe(1),
assistantMessageID: SessionMessage.ID.make("msg_assistant_2"), assistantMessageID: SessionMessage.ID.make("msg_assistant_2"),
finish: "stop", finish: "stop",
cost: 0, cost: 0,
@@ -485,7 +462,7 @@ describe("SessionProjector", () => {
expect(messages[1]).toMatchObject({ expect(messages[1]).toMatchObject({
type: "assistant", type: "assistant",
finish: "stop", finish: "stop",
time: { completed: DateTime.makeUnsafe(1) }, time: { completed: DateTime.makeUnsafe(0) },
}) })
}), }),
) )
@@ -526,7 +503,6 @@ describe("SessionProjector", () => {
yield* service.publish(SessionEvent.Text.Started, { yield* service.publish(SessionEvent.Text.Started, {
sessionID, sessionID,
assistantMessageID: SessionMessage.ID.make("msg_assistant_completed"), assistantMessageID: SessionMessage.ID.make("msg_assistant_completed"),
timestamp: DateTime.makeUnsafe(3),
textID: "text-stale", textID: "text-stale",
}) })
+21 -11
View File
@@ -201,7 +201,6 @@ describe("SessionV2.prompt", () => {
yield* db.insert(SessionMessageTable).values(assistantRow(stale, 100)).run().pipe(Effect.orDie) yield* db.insert(SessionMessageTable).values(assistantRow(stale, 100)).run().pipe(Effect.orDie)
yield* events.publish(SessionEvent.RevertEvent.Staged, { yield* events.publish(SessionEvent.RevertEvent.Staged, {
sessionID, sessionID,
timestamp: yield* DateTime.now,
revert: { messageID: boundary.id, files: [] }, revert: { messageID: boundary.id, files: [] },
}) })
expect((yield* session.get(sessionID)).revert?.messageID).toBe(boundary.id) expect((yield* session.get(sessionID)).revert?.messageID).toBe(boundary.id)
@@ -257,16 +256,16 @@ describe("SessionV2.prompt", () => {
const streamed = Array.from(yield* Fiber.join(fiber)) const streamed = Array.from(yield* Fiber.join(fiber))
expect(streamed.map((event): [number | undefined, string] => [event.durable?.seq, event.type])).toEqual([ expect(streamed.map((event): [number | undefined, string] => [event.durable?.seq, event.type])).toEqual([
[0, "session.next.prompt.admitted"], [0, "prompt.admitted"],
[1, "session.next.prompt.admitted"], [1, "prompt.admitted"],
[2, "session.next.prompted"], [2, "prompt.promoted"],
[3, "session.next.prompted"], [3, "prompt.promoted"],
]) ])
expect( expect(
Array.from( Array.from(
yield* publicEvents({ sessionID, after: streamed[0].durable?.seq }).pipe(Stream.take(1), Stream.runCollect), yield* publicEvents({ sessionID, after: streamed[0].durable?.seq }).pipe(Stream.take(1), Stream.runCollect),
).map((event): [number | undefined, string] => [event.durable?.seq, event.type]), ).map((event): [number | undefined, string] => [event.durable?.seq, event.type]),
).toEqual([[1, "session.next.prompt.admitted"]]) ).toEqual([[1, "prompt.admitted"]])
}), }),
) )
@@ -429,7 +428,7 @@ describe("SessionV2.prompt", () => {
{ concurrency: "unbounded" }, { concurrency: "unbounded" },
) )
expect(yield* eventCount(EventV2.versionedType(SessionEvent.Prompted.type, 1))).toBe(1) expect(yield* eventCount(EventV2.versionedType(SessionEvent.PromptPromoted.type, 1))).toBe(1)
expect(yield* admitted(messageID)).toMatchObject({ promotedSeq: 1 }) expect(yield* admitted(messageID)).toMatchObject({ promotedSeq: 1 })
expect(yield* session.messages({ sessionID })).toMatchObject([ expect(yield* session.messages({ sessionID })).toMatchObject([
{ id: messageID, type: "user", text: "Promote once" }, { id: messageID, type: "user", text: "Promote once" },
@@ -467,6 +466,7 @@ describe("SessionV2.prompt", () => {
yield* events.replayAll( yield* events.replayAll(
recorded.map((event) => ({ recorded.map((event) => ({
id: event.id, id: event.id,
created: DateTime.makeUnsafe(event.created),
aggregateID: event.aggregate_id, aggregateID: event.aggregate_id,
seq: event.seq, seq: event.seq,
type: event.type, type: event.type,
@@ -514,13 +514,23 @@ describe("SessionV2.prompt", () => {
Effect.gen(function* () { Effect.gen(function* () {
yield* setup yield* setup
const session = yield* SessionV2.Service const session = yield* SessionV2.Service
const events = yield* EventV2.Service const { db } = yield* Database.Service
yield* events.publish(SessionEvent.Synthetic, { const {
id: _,
type,
...data
} = encodeMessage({
id: messageID,
sessionID, sessionID,
messageID, type: "synthetic",
timestamp: yield* DateTime.now,
text: "Existing history", text: "Existing history",
time: { created: DateTime.makeUnsafe(0) },
}) })
yield* db
.insert(SessionMessageTable)
.values({ id: messageID, session_id: sessionID, type, seq: 0, time_created: 0, data })
.run()
.pipe(Effect.orDie)
const failure = yield* session const failure = yield* session
.prompt({ id: messageID, sessionID, prompt: Prompt.make({ text: "Conflicting prompt" }), resume: false }) .prompt({ id: messageID, sessionID, prompt: Prompt.make({ text: "Conflicting prompt" }), resume: false })
@@ -51,13 +51,13 @@ describe("toLLMMessages", () => {
const file = FileAttachment.make({ uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" }) const file = FileAttachment.make({ uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" })
const messages = toLLMMessages( const messages = toLLMMessages(
[ [
SessionMessage.AgentSwitched.make({ SessionMessage.AgentSelected.make({
id: id("agent"), id: id("agent"),
type: "agent-switched", type: "agent-switched",
agent: "build", agent: "build",
time: { created }, time: { created },
}), }),
SessionMessage.ModelSwitched.make({ SessionMessage.ModelSelected.make({
id: id("model"), id: id("model"),
type: "model-switched", type: "model-switched",
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
@@ -193,12 +193,12 @@ describe("SessionRunnerLLM recorded", () => {
.orderBy(EventTable.seq) .orderBy(EventTable.seq)
.all()).map((event) => event.type), .all()).map((event) => event.type),
).toEqual([ ).toEqual([
"session.next.prompt.admitted.1", "prompt.admitted.1",
"session.next.prompted.1", "prompt.promoted.1",
"session.next.step.started.1", "step.started.1",
"session.next.text.started.1", "text.started.1",
"session.next.text.ended.1", "text.ended.1",
"session.next.step.ended.2", "step.ended.1",
]) ])
}), }),
) )
@@ -76,7 +76,7 @@ test("local tool success serializes media base64 once and reconstructs from stru
await Effect.runPromise(publisher.publish(call)) await Effect.runPromise(publisher.publish(call))
await Effect.runPromise(publisher.publish(result)) await Effect.runPromise(publisher.publish(result))
const success = published.find((event) => event.type === "session.next.tool.success.1") const success = published.find((event) => event.type === "tool.success.1")
expect(success).toBeDefined() expect(success).toBeDefined()
const serialized = JSON.stringify(success) const serialized = JSON.stringify(success)
expect(serialized.split(base64)).toHaveLength(2) expect(serialized.split(base64)).toHaveLength(2)
@@ -94,7 +94,7 @@ test("provider-executed success retains its compatibility result", async () => {
const { published, publisher } = capture() const { published, publisher } = capture()
await Effect.runPromise(publisher.publish(LLMEvent.toolCall({ ...call, providerExecuted: true }))) await Effect.runPromise(publisher.publish(LLMEvent.toolCall({ ...call, providerExecuted: true })))
await Effect.runPromise(publisher.publish(LLMEvent.toolResult({ ...result, providerExecuted: true }))) await Effect.runPromise(publisher.publish(LLMEvent.toolResult({ ...result, providerExecuted: true })))
const success = published.find((event) => event.type === "session.next.tool.success.1") const success = published.find((event) => event.type === "tool.success.1")
expect(success?.data).toHaveProperty("result") expect(success?.data).toHaveProperty("result")
}) })
@@ -110,14 +110,13 @@ test("binary failure emits no success event", async () => {
}), }),
), ),
) )
expect(published.some((event) => event.type === "session.next.tool.success.1")).toBe(false) expect(published.some((event) => event.type === "tool.success.1")).toBe(false)
expect(published.some((event) => event.type === "session.next.tool.failed.1")).toBe(true) expect(published.some((event) => event.type === "tool.failed.1")).toBe(true)
}) })
test("old success event data containing result still decodes", () => { test("old success event data containing result still decodes", () => {
const decoded = Schema.decodeUnknownSync(SessionEvent.Tool.Success.data)({ const decoded = Schema.decodeUnknownSync(SessionEvent.Tool.Success.data)({
sessionID, sessionID,
timestamp: Date.now(),
assistantMessageID: SessionMessage.ID.create(), assistantMessageID: SessionMessage.ID.create(),
callID: "call-old", callID: "call-old",
structured: { type: "media", mime: "image/png" }, structured: { type: "media", mime: "image/png" },
@@ -133,6 +132,6 @@ test("step finish records settlement without publishing step ended", async () =>
await Effect.runPromise(publisher.publish(LLMEvent.stepStart({ index: 0 }))) await Effect.runPromise(publisher.publish(LLMEvent.stepStart({ index: 0 })))
await Effect.runPromise(publisher.publish(LLMEvent.stepFinish({ index: 0, reason: "stop" }))) await Effect.runPromise(publisher.publish(LLMEvent.stepFinish({ index: 0, reason: "stop" })))
expect(published.some((event) => event.type === "session.next.step.ended.2")).toBe(false) expect(published.some((event) => event.type === "step.ended.2")).toBe(false)
expect(publisher.stepSettlement()).toMatchObject({ finish: "stop" }) expect(publisher.stepSettlement()).toMatchObject({ finish: "stop" })
}) })
+15 -43
View File
@@ -423,6 +423,7 @@ const replaySessionProjection = (id: SessionV2.ID) =>
yield* events.replayAll( yield* events.replayAll(
recorded.map((event) => ({ recorded.map((event) => ({
id: event.id, id: event.id,
created: DateTime.makeUnsafe(event.created),
aggregateID: event.aggregate_id, aggregateID: event.aggregate_id,
seq: event.seq, seq: event.seq,
type: event.type, type: event.type,
@@ -740,7 +741,6 @@ describe("SessionRunnerLLM", () => {
yield* events.publish(SessionEvent.Moved, { yield* events.publish(SessionEvent.Moved, {
sessionID, sessionID,
timestamp: DateTime.makeUnsafe(1),
location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }), location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }),
}) })
expect( expect(
@@ -848,7 +848,7 @@ describe("SessionRunnerLLM", () => {
yield* db yield* db
.select({ id: EventTable.id }) .select({ id: EventTable.id })
.from(EventTable) .from(EventTable)
.where(eq(EventTable.type, "session.next.context.updated.1")) .where(eq(EventTable.type, "session.context.updated.1"))
.all() .all()
.pipe(Effect.orDie), .pipe(Effect.orDie),
).toHaveLength(1) ).toHaveLength(1)
@@ -1010,10 +1010,8 @@ describe("SessionRunnerLLM", () => {
response = [] response = []
yield* session.resume(sessionID) yield* session.resume(sessionID)
skillBaselines.set(AgentV2.ID.make("reviewer"), "Reviewer skills") skillBaselines.set(AgentV2.ID.make("reviewer"), "Reviewer skills")
yield* events.publish(SessionEvent.AgentSwitched, { yield* events.publish(SessionEvent.AgentSelected, {
sessionID, sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1),
agent: "reviewer", agent: "reviewer",
}) })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
@@ -1039,10 +1037,8 @@ describe("SessionRunnerLLM", () => {
if (switched) return Effect.void if (switched) return Effect.void
switched = true switched = true
return events return events
.publish(SessionEvent.AgentSwitched, { .publish(SessionEvent.AgentSelected, {
sessionID, sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1),
agent: "reviewer", agent: "reviewer",
}) })
.pipe(Effect.asVoid) .pipe(Effect.asVoid)
@@ -1069,10 +1065,8 @@ describe("SessionRunnerLLM", () => {
if (switched) return Effect.void if (switched) return Effect.void
switched = true switched = true
return events return events
.publish(SessionEvent.ModelSwitched, { .publish(SessionEvent.ModelSelected, {
sessionID, sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1),
model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") }, model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
}) })
.pipe(Effect.asVoid) .pipe(Effect.asVoid)
@@ -1175,10 +1169,8 @@ describe("SessionRunnerLLM", () => {
systemBaseline = "Changed context" systemBaseline = "Changed context"
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
yield* session.resume(sessionID) yield* session.resume(sessionID)
yield* events.publish(SessionEvent.ModelSwitched, { yield* events.publish(SessionEvent.ModelSelected, {
sessionID, sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1),
model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") }, model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
}) })
systemBaseline = "Replacement context" systemBaseline = "Replacement context"
@@ -1217,10 +1209,8 @@ describe("SessionRunnerLLM", () => {
requests.length = 0 requests.length = 0
response = [] response = []
yield* session.resume(sessionID) yield* session.resume(sessionID)
yield* events.publish(SessionEvent.ModelSwitched, { yield* events.publish(SessionEvent.ModelSelected, {
sessionID, sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1),
model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") }, model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
}) })
systemUnavailable = true systemUnavailable = true
@@ -1252,14 +1242,10 @@ describe("SessionRunnerLLM", () => {
const compactionID = SessionMessage.ID.create() const compactionID = SessionMessage.ID.create()
yield* events.publish(SessionEvent.Compaction.Started, { yield* events.publish(SessionEvent.Compaction.Started, {
sessionID, sessionID,
messageID: compactionID,
timestamp: DateTime.makeUnsafe(1),
reason: "manual", reason: "manual",
}) })
yield* events.publish(SessionEvent.Compaction.Ended, { yield* events.publish(SessionEvent.Compaction.Ended, {
sessionID, sessionID,
messageID: compactionID,
timestamp: DateTime.makeUnsafe(2),
reason: "manual", reason: "manual",
text: "summary", text: "summary",
recent: "", recent: "",
@@ -1482,14 +1468,10 @@ describe("SessionRunnerLLM", () => {
const compactionID = SessionMessage.ID.create() const compactionID = SessionMessage.ID.create()
yield* events.publish(SessionEvent.Compaction.Started, { yield* events.publish(SessionEvent.Compaction.Started, {
sessionID, sessionID,
messageID: compactionID,
timestamp: DateTime.makeUnsafe(1),
reason: "manual", reason: "manual",
}) })
yield* events.publish(SessionEvent.Compaction.Ended, { yield* events.publish(SessionEvent.Compaction.Ended, {
sessionID, sessionID,
messageID: compactionID,
timestamp: DateTime.makeUnsafe(2),
reason: "manual", reason: "manual",
text: "summary", text: "summary",
recent: "", recent: "",
@@ -1686,10 +1668,8 @@ describe("SessionRunnerLLM", () => {
toolExecutionsReady = 1 toolExecutionsReady = 1
const run = yield* Effect.forkChild(session.resume(sessionID)) const run = yield* Effect.forkChild(session.resume(sessionID))
yield* Deferred.await(toolExecutionsStarted) yield* Deferred.await(toolExecutionsStarted)
yield* events.publish(SessionEvent.ModelSwitched, { yield* events.publish(SessionEvent.ModelSelected, {
sessionID, sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1),
model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") }, model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
}) })
systemBaseline = "Replacement context" systemBaseline = "Replacement context"
@@ -2403,27 +2383,23 @@ describe("SessionRunnerLLM", () => {
yield* events.publish(SessionEvent.Step.Started, { yield* events.publish(SessionEvent.Step.Started, {
sessionID, sessionID,
assistantMessageID, assistantMessageID,
timestamp: yield* DateTime.now,
agent: "build", agent: "build",
model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") }, model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") },
}) })
yield* events.publish(SessionEvent.Tool.Input.Started, { yield* events.publish(SessionEvent.Tool.Input.Started, {
sessionID, sessionID,
timestamp: yield* DateTime.now,
assistantMessageID, assistantMessageID,
callID: "call-interrupted", callID: "call-interrupted",
name: "echo", name: "echo",
}) })
yield* events.publish(SessionEvent.Tool.Input.Ended, { yield* events.publish(SessionEvent.Tool.Input.Ended, {
sessionID, sessionID,
timestamp: yield* DateTime.now,
assistantMessageID, assistantMessageID,
callID: "call-interrupted", callID: "call-interrupted",
text: '{"text":"stale"}', text: '{"text":"stale"}',
}) })
yield* events.publish(SessionEvent.Tool.Called, { yield* events.publish(SessionEvent.Tool.Called, {
sessionID, sessionID,
timestamp: yield* DateTime.now,
assistantMessageID, assistantMessageID,
callID: "call-interrupted", callID: "call-interrupted",
tool: "echo", tool: "echo",
@@ -2467,27 +2443,23 @@ describe("SessionRunnerLLM", () => {
yield* events.publish(SessionEvent.Step.Started, { yield* events.publish(SessionEvent.Step.Started, {
sessionID, sessionID,
assistantMessageID, assistantMessageID,
timestamp: yield* DateTime.now,
agent: "build", agent: "build",
model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") }, model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") },
}) })
yield* events.publish(SessionEvent.Tool.Input.Started, { yield* events.publish(SessionEvent.Tool.Input.Started, {
sessionID, sessionID,
timestamp: yield* DateTime.now,
assistantMessageID, assistantMessageID,
callID: "call-hosted-interrupted", callID: "call-hosted-interrupted",
name: "web_search", name: "web_search",
}) })
yield* events.publish(SessionEvent.Tool.Input.Ended, { yield* events.publish(SessionEvent.Tool.Input.Ended, {
sessionID, sessionID,
timestamp: yield* DateTime.now,
assistantMessageID, assistantMessageID,
callID: "call-hosted-interrupted", callID: "call-hosted-interrupted",
text: '{"query":"stale"}', text: '{"query":"stale"}',
}) })
yield* events.publish(SessionEvent.Tool.Called, { yield* events.publish(SessionEvent.Tool.Called, {
sessionID, sessionID,
timestamp: yield* DateTime.now,
assistantMessageID, assistantMessageID,
callID: "call-hosted-interrupted", callID: "call-hosted-interrupted",
tool: "web_search", tool: "web_search",
@@ -2527,13 +2499,11 @@ describe("SessionRunnerLLM", () => {
yield* events.publish(SessionEvent.Step.Started, { yield* events.publish(SessionEvent.Step.Started, {
sessionID, sessionID,
assistantMessageID, assistantMessageID,
timestamp: yield* DateTime.now,
agent: "build", agent: "build",
model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") }, model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") },
}) })
yield* events.publish(SessionEvent.Tool.Input.Started, { yield* events.publish(SessionEvent.Tool.Input.Started, {
sessionID, sessionID,
timestamp: yield* DateTime.now,
assistantMessageID, assistantMessageID,
callID: "call-pending-interrupted", callID: "call-pending-interrupted",
name: "echo", name: "echo",
@@ -2578,7 +2548,7 @@ describe("SessionRunnerLLM", () => {
const events = yield* EventV2.Service const events = yield* EventV2.Service
const defect = new Error("fail after prompt promotion") const defect = new Error("fail after prompt promotion")
let fail = true let fail = true
yield* events.project(SessionEvent.Prompted, () => (fail ? Effect.die(defect) : Effect.void)) yield* events.project(SessionEvent.PromptPromoted, () => (fail ? Effect.die(defect) : Effect.void))
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Recover promoted input" }), resume: false }) yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Recover promoted input" }), resume: false })
expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect) expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect)
@@ -2603,7 +2573,9 @@ describe("SessionRunnerLLM", () => {
const session = yield* SessionV2.Service const session = yield* SessionV2.Service
const events = yield* EventV2.Service const events = yield* EventV2.Service
yield* events.listen((event) => yield* events.listen((event) =>
event.type === SessionEvent.Prompted.type ? Effect.die("fail after prompt promotion commits") : Effect.void, event.type === SessionEvent.PromptPromoted.type
? Effect.die("fail after prompt promotion commits")
: Effect.void,
) )
yield* session.prompt({ yield* session.prompt({
sessionID, sessionID,
@@ -3013,7 +2985,7 @@ describe("SessionRunnerLLM", () => {
{ type: "user", text: "Interrupt provider" }, { type: "user", text: "Interrupt provider" },
{ type: "assistant", finish: "error", error: { type: "unknown", message: "Provider turn interrupted" } }, { type: "assistant", finish: "error", error: { type: "unknown", message: "Provider turn interrupted" } },
]) ])
expect(yield* recordedEventTypes(sessionID)).toContain("session.next.step.failed.2") expect(yield* recordedEventTypes(sessionID)).toContain("step.failed.1")
yield* session.interrupt(sessionID) yield* session.interrupt(sessionID)
}), }),
) )
@@ -3057,8 +3029,8 @@ describe("SessionRunnerLLM", () => {
}, },
]) ])
const eventTypes = yield* recordedEventTypes(sessionID) const eventTypes = yield* recordedEventTypes(sessionID)
expect(eventTypes).toContain("session.next.step.failed.2") expect(eventTypes).toContain("step.failed.1")
expect(eventTypes).not.toContain("session.next.step.ended.2") expect(eventTypes).not.toContain("step.ended.1")
}), }),
) )
+3 -7
View File
@@ -86,17 +86,13 @@ const prompt = (sessionID: SessionV2.ID, text: string) =>
const messageID = SessionMessage.ID.create() const messageID = SessionMessage.ID.create()
yield* events.publish(SessionEvent.PromptAdmitted, { yield* events.publish(SessionEvent.PromptAdmitted, {
sessionID, sessionID,
messageID, inputID: messageID,
timestamp: DateTime.makeUnsafe(0),
prompt: Prompt.make({ text }), prompt: Prompt.make({ text }),
delivery: "steer", delivery: "steer",
}) })
yield* events.publish(SessionEvent.Prompted, { yield* events.publish(SessionEvent.PromptPromoted, {
sessionID, sessionID,
messageID, inputID: messageID,
timestamp: DateTime.makeUnsafe(0),
prompt: Prompt.make({ text }),
delivery: "steer",
}) })
}) })
@@ -51,7 +51,6 @@ describe("Tool.Progress", () => {
yield* service.publish(SessionEvent.Step.Started, { yield* service.publish(SessionEvent.Step.Started, {
sessionID, sessionID,
assistantMessageID, assistantMessageID,
timestamp,
agent: "build", agent: "build",
model, model,
}) })
@@ -69,14 +68,12 @@ describe("Tool.Progress", () => {
Effect.gen(function* () { Effect.gen(function* () {
yield* service.publish(SessionEvent.Tool.Input.Started, { yield* service.publish(SessionEvent.Tool.Input.Started, {
sessionID, sessionID,
timestamp,
assistantMessageID, assistantMessageID,
callID, callID,
name: "bash", name: "bash",
}) })
yield* service.publish(SessionEvent.Tool.Called, { yield* service.publish(SessionEvent.Tool.Called, {
sessionID, sessionID,
timestamp,
assistantMessageID, assistantMessageID,
callID, callID,
tool: "bash", tool: "bash",
@@ -92,7 +89,6 @@ describe("Tool.Progress", () => {
yield* service.publish(SessionEvent.Tool.Progress, { yield* service.publish(SessionEvent.Tool.Progress, {
sessionID, sessionID,
timestamp,
assistantMessageID, assistantMessageID,
callID: "call-success", callID: "call-success",
structured: { phase: "checkpoint" }, structured: { phase: "checkpoint" },
@@ -104,7 +100,6 @@ describe("Tool.Progress", () => {
const success = yield* service.publish(SessionEvent.Tool.Success, { const success = yield* service.publish(SessionEvent.Tool.Success, {
sessionID, sessionID,
timestamp,
assistantMessageID, assistantMessageID,
callID: "call-success", callID: "call-success",
structured: { phase: "done" }, structured: { phase: "done" },
@@ -118,7 +113,6 @@ describe("Tool.Progress", () => {
yield* start("call-failed") yield* start("call-failed")
yield* service.publish(SessionEvent.Tool.Progress, { yield* service.publish(SessionEvent.Tool.Progress, {
sessionID, sessionID,
timestamp,
assistantMessageID, assistantMessageID,
callID: "call-failed", callID: "call-failed",
structured: { phase: "checkpoint" }, structured: { phase: "checkpoint" },
@@ -126,7 +120,6 @@ describe("Tool.Progress", () => {
}) })
const failed = yield* service.publish(SessionEvent.Tool.Failed, { const failed = yield* service.publish(SessionEvent.Tool.Failed, {
sessionID, sessionID,
timestamp,
assistantMessageID, assistantMessageID,
callID: "call-failed", callID: "call-failed",
error: { type: "unknown", message: "boom" }, error: { type: "unknown", message: "boom" },
+2 -2
View File
@@ -148,8 +148,8 @@ test("Core reuses the canonical shared schemas", async () => {
[coreSessionInput.Admitted, SessionInput.Admitted], [coreSessionInput.Admitted, SessionInput.Admitted],
[coreSessionMessage.ID, SessionMessage.ID], [coreSessionMessage.ID, SessionMessage.ID],
[coreSessionMessage.UnknownError, SessionMessage.UnknownError], [coreSessionMessage.UnknownError, SessionMessage.UnknownError],
[coreSessionMessage.AgentSwitched, SessionMessage.AgentSwitched], [coreSessionMessage.AgentSelected, SessionMessage.AgentSelected],
[coreSessionMessage.ModelSwitched, SessionMessage.ModelSwitched], [coreSessionMessage.ModelSelected, SessionMessage.ModelSelected],
[coreSessionMessage.User, SessionMessage.User], [coreSessionMessage.User, SessionMessage.User],
[coreSessionMessage.Synthetic, SessionMessage.Synthetic], [coreSessionMessage.Synthetic, SessionMessage.Synthetic],
[coreSessionMessage.System, SessionMessage.System], [coreSessionMessage.System, SessionMessage.System],
-4
View File
@@ -79,27 +79,23 @@ const executionNode = makeGlobalNode({
yield* events.publish(SessionEvent.Step.Started, { yield* events.publish(SessionEvent.Step.Started, {
sessionID: id, sessionID: id,
assistantMessageID, assistantMessageID,
timestamp: yield* DateTime.now,
agent: session.agent ?? AgentV2.ID.make("code"), agent: session.agent ?? AgentV2.ID.make("code"),
model: sessionModel, model: sessionModel,
}) })
yield* events.publish(SessionEvent.Text.Started, { yield* events.publish(SessionEvent.Text.Started, {
sessionID: id, sessionID: id,
assistantMessageID, assistantMessageID,
timestamp: yield* DateTime.now,
textID, textID,
}) })
yield* events.publish(SessionEvent.Text.Ended, { yield* events.publish(SessionEvent.Text.Ended, {
sessionID: id, sessionID: id,
assistantMessageID, assistantMessageID,
timestamp: yield* DateTime.now,
textID, textID,
text: "ok", text: "ok",
}) })
yield* events.publish(SessionEvent.Step.Ended, { yield* events.publish(SessionEvent.Step.Ended, {
sessionID: id, sessionID: id,
assistantMessageID, assistantMessageID,
timestamp: yield* DateTime.now,
finish: "stop", finish: "stop",
cost: 0, cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
-4
View File
@@ -53,27 +53,23 @@ const executionNode = makeGlobalNode({
yield* events.publish(SessionEvent.Step.Started, { yield* events.publish(SessionEvent.Step.Started, {
sessionID, sessionID,
assistantMessageID, assistantMessageID,
timestamp: yield* DateTime.now,
agent: AgentV2.ID.make("reviewer"), agent: AgentV2.ID.make("reviewer"),
model: childModel, model: childModel,
}) })
yield* events.publish(SessionEvent.Text.Started, { yield* events.publish(SessionEvent.Text.Started, {
sessionID, sessionID,
assistantMessageID, assistantMessageID,
timestamp: yield* DateTime.now,
textID, textID,
}) })
yield* events.publish(SessionEvent.Text.Ended, { yield* events.publish(SessionEvent.Text.Ended, {
sessionID, sessionID,
assistantMessageID, assistantMessageID,
timestamp: yield* DateTime.now,
textID, textID,
text: childText, text: childText,
}) })
yield* events.publish(SessionEvent.Step.Ended, { yield* events.publish(SessionEvent.Step.Ended, {
sessionID, sessionID,
assistantMessageID, assistantMessageID,
timestamp: yield* DateTime.now,
finish: "stop", finish: "stop",
cost: 0, cost: 0,
tokens, tokens,
@@ -156,17 +156,16 @@ export async function runNonInteractivePrompt(input: Input) {
continue continue
} }
if (!("sessionID" in event.data) || event.data.sessionID !== input.sessionID) continue if (!("sessionID" in event.data) || event.data.sessionID !== input.sessionID) continue
const time = "timestamp" in event.data ? toMillis(event.data.timestamp) : Date.now() const time = toMillis(event.created)
if (event.type === "session.next.prompted") { if (event.type === "prompt.promoted") {
if (event.data.messageID === messageID) { if (event.data.inputID === messageID) {
promoted = true promoted = true
continue continue
} }
if (promoted && event.data.delivery === "queue") return
} }
if ( if (
event.type === "session.next.execution.settled" && event.type === "execution.settled" &&
event.data.outcome === "interrupted" && event.data.outcome === "interrupted" &&
(interrupted || permissionRejected || questionRejected || formCancelled) (interrupted || permissionRejected || questionRejected || formCancelled)
) { ) {
@@ -174,7 +173,7 @@ export async function runNonInteractivePrompt(input: Input) {
} }
if (!promoted) continue if (!promoted) continue
if (event.type === "session.next.step.started") { if (event.type === "step.started") {
const part: StepStartPart = { const part: StepStartPart = {
id: partID(event.id), id: partID(event.id),
sessionID: input.sessionID, sessionID: input.sessionID,
@@ -190,11 +189,11 @@ export async function runNonInteractivePrompt(input: Input) {
continue continue
} }
if (event.type === "session.next.text.started") { if (event.type === "text.started") {
starts.set(event.data.textID, { id: partID(event.id), timestamp: time }) starts.set(event.data.textID, { id: partID(event.id), timestamp: time })
continue continue
} }
if (event.type === "session.next.text.ended") { if (event.type === "text.ended") {
const started = starts.get(event.data.textID) const started = starts.get(event.data.textID)
const part: TextPart = { const part: TextPart = {
id: started?.id ?? partID(event.id), id: started?.id ?? partID(event.id),
@@ -208,11 +207,11 @@ export async function runNonInteractivePrompt(input: Input) {
continue continue
} }
if (event.type === "session.next.reasoning.started") { if (event.type === "reasoning.started") {
starts.set(event.data.reasoningID, { id: partID(event.id), timestamp: time }) starts.set(event.data.reasoningID, { id: partID(event.id), timestamp: time })
continue continue
} }
if (event.type === "session.next.reasoning.ended" && input.thinking) { if (event.type === "reasoning.ended" && input.thinking) {
const started = starts.get(event.data.reasoningID) const started = starts.get(event.data.reasoningID)
const part: ReasoningPart = { const part: ReasoningPart = {
id: started?.id ?? partID(event.id), id: started?.id ?? partID(event.id),
@@ -237,7 +236,7 @@ export async function runNonInteractivePrompt(input: Input) {
continue continue
} }
if (event.type === "session.next.tool.input.started") { if (event.type === "tool.input.started") {
tools.set(event.data.callID, { tools.set(event.data.callID, {
id: partID(event.id), id: partID(event.id),
timestamp: time, timestamp: time,
@@ -247,12 +246,12 @@ export async function runNonInteractivePrompt(input: Input) {
}) })
continue continue
} }
if (event.type === "session.next.tool.input.ended") { if (event.type === "tool.input.ended") {
const current = tools.get(event.data.callID) const current = tools.get(event.data.callID)
if (current) current.raw = event.data.text if (current) current.raw = event.data.text
continue continue
} }
if (event.type === "session.next.tool.called") { if (event.type === "tool.called") {
const current = tools.get(event.data.callID) const current = tools.get(event.data.callID)
tools.set(event.data.callID, { tools.set(event.data.callID, {
id: current?.id ?? partID(event.id), id: current?.id ?? partID(event.id),
@@ -265,7 +264,7 @@ export async function runNonInteractivePrompt(input: Input) {
}) })
continue continue
} }
if (event.type === "session.next.tool.success") { if (event.type === "tool.success") {
const current = tools.get(event.data.callID) ?? fallbackTool(event) const current = tools.get(event.data.callID) ?? fallbackTool(event)
const part: ToolPart = { const part: ToolPart = {
id: current.id, id: current.id,
@@ -298,7 +297,7 @@ export async function runNonInteractivePrompt(input: Input) {
if (!emit("tool_use", time, { part })) await input.renderTool(part) if (!emit("tool_use", time, { part })) await input.renderTool(part)
continue continue
} }
if (event.type === "session.next.tool.failed") { if (event.type === "tool.failed") {
const current = tools.get(event.data.callID) ?? fallbackTool(event) const current = tools.get(event.data.callID) ?? fallbackTool(event)
const error = event.data.error.message const error = event.data.error.message
const part: ToolPart = { const part: ToolPart = {
@@ -329,7 +328,7 @@ export async function runNonInteractivePrompt(input: Input) {
continue continue
} }
if (event.type === "session.next.step.ended") { if (event.type === "step.ended") {
const part: StepFinishPart = { const part: StepFinishPart = {
id: partID(event.id), id: partID(event.id),
sessionID: input.sessionID, sessionID: input.sessionID,
@@ -343,19 +342,19 @@ export async function runNonInteractivePrompt(input: Input) {
emit("step_finish", time, { part }) emit("step_finish", time, { part })
continue continue
} }
if (event.type === "session.next.step.failed") { if (event.type === "step.failed") {
if (interrupted || permissionRejected || questionRejected || formCancelled) continue if (interrupted || permissionRejected || questionRejected || formCancelled) continue
emittedError = true emittedError = true
process.exitCode = 1 process.exitCode = 1
if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message) if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message)
continue continue
} }
if (event.type === "session.next.execution.settled") { if (event.type === "execution.settled") {
if (event.data.outcome === "failure" && !emittedError && !questionRejected && !formCancelled) { if (event.data.outcome === "failure" && !emittedError && !questionRejected && !formCancelled) {
emittedError = true emittedError = true
process.exitCode = 1 process.exitCode = 1
const error = event.data.error ?? { type: "unknown", message: "Session execution failed" } const error = event.data.error ?? { type: "unknown", message: "Session execution failed" }
if (!emit("error", toMillis(event.data.timestamp), { error })) UI.error(error.message) if (!emit("error", time, { error })) UI.error(error.message)
} }
if (event.data.outcome === "interrupted" && interrupted) process.exitCode = 130 if (event.data.outcome === "interrupted" && interrupted) process.exitCode = 130
return return
@@ -458,11 +457,12 @@ function partID(eventID: string) {
function fallbackTool(event: { function fallbackTool(event: {
id: string id: string
data: { timestamp: number; assistantMessageID: string; callID: string } created: number
data: { assistantMessageID: string; callID: string }
}): ToolState { }): ToolState {
return { return {
id: partID(event.id), id: partID(event.id),
timestamp: toMillis(event.data.timestamp), timestamp: toMillis(event.created),
assistantMessageID: event.data.assistantMessageID, assistantMessageID: event.data.assistantMessageID,
tool: "tool", tool: "tool",
input: {}, input: {},
@@ -728,7 +728,7 @@ export function reduceSessionData(input: SessionDataInput): SessionDataOutput {
const data = input.data const data = input.data
const event = input.event const event = input.event
if (event.type === "session.next.shell.started") { if (event.type === "shell.started") {
if (event.properties.sessionID !== input.sessionID) { if (event.properties.sessionID !== input.sessionID) {
return out(data, commits) return out(data, commits)
} }
@@ -748,7 +748,7 @@ export function reduceSessionData(input: SessionDataInput): SessionDataOutput {
return out(data, commits, patch({ status: "running shell" })) return out(data, commits, patch({ status: "running shell" }))
} }
if (event.type === "session.next.shell.ended") { if (event.type === "shell.ended") {
if (event.properties.sessionID !== input.sessionID) { if (event.properties.sessionID !== input.sessionID) {
return out(data, commits) return out(data, commits)
} }
@@ -424,21 +424,21 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
} }
const reduce = (child: ChildState, event: V2Event) => { const reduce = (child: ChildState, event: V2Event) => {
if (event.type === "session.next.prompted") { if (event.type === "prompt.promoted") {
if (userFrame(child, event.data.messageID, event.data.prompt.text)) { if (userFrame(child, event.data.inputID, "")) {
touch(child, event.data.timestamp) touch(child, event.created)
notifyDetail(child) notifyDetail(child)
} }
return return
} }
if (event.type === "session.next.step.started") { if (event.type === "step.started") {
touch(child, event.data.timestamp) touch(child, event.created)
if (child.label === FALLBACK_LABEL && event.data.agent) child.label = Locale.titlecase(event.data.agent) if (child.label === FALLBACK_LABEL && event.data.agent) child.label = Locale.titlecase(event.data.agent)
if (child.status !== "running") child.status = "running" if (child.status !== "running") child.status = "running"
input.emit() input.emit()
return return
} }
if (event.type === "session.next.text.delta") { if (event.type === "text.delta") {
const projected = child.projectedText.get(event.data.textID) const projected = child.projectedText.get(event.data.textID)
const covered = projected?.indexOf(event.data.delta) ?? -1 const covered = projected?.indexOf(event.data.delta) ?? -1
if (projected && covered >= 0) { if (projected && covered >= 0) {
@@ -455,11 +455,11 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
messageID: event.data.assistantMessageID, messageID: event.data.assistantMessageID,
partID: event.data.textID, partID: event.data.textID,
}) })
touch(child, event.data.timestamp) touch(child, event.created)
notifyDetail(child) notifyDetail(child)
return return
} }
if (event.type === "session.next.text.ended") { if (event.type === "text.ended") {
child.text.set(event.data.textID, event.data.text) child.text.set(event.data.textID, event.data.text)
child.projectedText.delete(event.data.textID) child.projectedText.delete(event.data.textID)
setFrame(child, `text:${event.data.textID}`, { setFrame(child, `text:${event.data.textID}`, {
@@ -470,11 +470,11 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
messageID: event.data.assistantMessageID, messageID: event.data.assistantMessageID,
partID: event.data.textID, partID: event.data.textID,
}) })
touch(child, event.data.timestamp) touch(child, event.created)
notifyDetail(child) notifyDetail(child)
return return
} }
if (event.type === "session.next.reasoning.delta") { if (event.type === "reasoning.delta") {
const projected = child.projectedReasoning.get(event.data.reasoningID) const projected = child.projectedReasoning.get(event.data.reasoningID)
const covered = projected?.indexOf(event.data.delta) ?? -1 const covered = projected?.indexOf(event.data.delta) ?? -1
if (projected && covered >= 0) { if (projected && covered >= 0) {
@@ -495,7 +495,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
notifyDetail(child) notifyDetail(child)
return return
} }
if (event.type === "session.next.reasoning.ended") { if (event.type === "reasoning.ended") {
child.reasoning.set(event.data.reasoningID, event.data.text) child.reasoning.set(event.data.reasoningID, event.data.text)
child.projectedReasoning.delete(event.data.reasoningID) child.projectedReasoning.delete(event.data.reasoningID)
if (!input.thinking) return if (!input.thinking) return
@@ -510,16 +510,16 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
notifyDetail(child) notifyDetail(child)
return return
} }
if (event.type === "session.next.tool.input.started") { if (event.type === "tool.input.started") {
child.tools.set(event.data.callID, { name: event.data.name, input: {}, started: event.data.timestamp }) child.tools.set(event.data.callID, { name: event.data.name, input: {}, started: event.created })
return return
} }
if (event.type === "session.next.tool.called") { if (event.type === "tool.called") {
const current = child.tools.get(event.data.callID) const current = child.tools.get(event.data.callID)
child.tools.set(event.data.callID, { child.tools.set(event.data.callID, {
name: event.data.tool, name: event.data.tool,
input: event.data.input, input: event.data.input,
started: current?.started ?? event.data.timestamp, started: current?.started ?? event.created,
}) })
childTool( childTool(
child, child,
@@ -529,18 +529,18 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
name: event.data.tool, name: event.data.tool,
provider: event.data.provider, provider: event.data.provider,
state: { status: "running", input: event.data.input, structured: {}, content: [] }, state: { status: "running", input: event.data.input, structured: {}, content: [] },
time: { created: current?.started ?? event.data.timestamp, ran: event.data.timestamp }, time: { created: current?.started ?? event.created, ran: event.created },
}, },
event.data.assistantMessageID, event.data.assistantMessageID,
) )
touch(child, event.data.timestamp) touch(child, event.created)
notifyDetail(child) notifyDetail(child)
return return
} }
if (event.type === "session.next.tool.success" || event.type === "session.next.tool.failed") { if (event.type === "tool.success" || event.type === "tool.failed") {
if (child.finishedTools.has(event.data.callID)) return if (child.finishedTools.has(event.data.callID)) return
const current = child.tools.get(event.data.callID) const current = child.tools.get(event.data.callID)
const failed = event.type === "session.next.tool.failed" const failed = event.type === "tool.failed"
childTool( childTool(
child, child,
{ {
@@ -566,18 +566,18 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
result: event.data.result, result: event.data.result,
}, },
time: { time: {
created: current?.started ?? event.data.timestamp, created: current?.started ?? event.created,
ran: current?.started, ran: current?.started,
completed: event.data.timestamp, completed: event.created,
}, },
}, },
event.data.assistantMessageID, event.data.assistantMessageID,
) )
touch(child, event.data.timestamp) touch(child, event.created)
notifyDetail(child) notifyDetail(child)
return return
} }
if (event.type === "session.next.step.failed") { if (event.type === "step.failed") {
setFrame(child, `error:step:${event.data.assistantMessageID}`, { setFrame(child, `error:step:${event.data.assistantMessageID}`, {
kind: "error", kind: "error",
source: "system", source: "system",
@@ -585,14 +585,14 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
phase: "start", phase: "start",
messageID: event.data.assistantMessageID, messageID: event.data.assistantMessageID,
}) })
touch(child, event.data.timestamp) touch(child, event.created)
notifyDetail(child) notifyDetail(child)
return return
} }
if (event.type === "session.next.execution.settled") { if (event.type === "execution.settled") {
child.status = child.status =
event.data.outcome === "success" ? "completed" : event.data.outcome === "interrupted" ? "cancelled" : "error" event.data.outcome === "success" ? "completed" : event.data.outcome === "interrupted" ? "cancelled" : "error"
touch(child, event.data.timestamp) touch(child, event.created)
input.emit() input.emit()
} }
} }
@@ -613,15 +613,15 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
return { return {
main(event) { main(event) {
if (event.type === "session.next.tool.called") { if (event.type === "tool.called") {
if (event.data.tool === "subagent") pendingCalls.set(event.data.callID, event.data.input) if (event.data.tool === "subagent") pendingCalls.set(event.data.callID, event.data.input)
return return
} }
if (event.type === "session.next.tool.failed") { if (event.type === "tool.failed") {
pendingCalls.delete(event.data.callID) pendingCalls.delete(event.data.callID)
return return
} }
if (event.type !== "session.next.tool.success") return if (event.type !== "tool.success") return
const pending = pendingCalls.get(event.data.callID) const pending = pendingCalls.get(event.data.callID)
pendingCalls.delete(event.data.callID) pendingCalls.delete(event.data.callID)
const found = childSessionID(record(event.data.structured)) const found = childSessionID(record(event.data.structured))
@@ -633,7 +633,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
child.status = "running" child.status = "running"
} }
if (!found.running && child.status === "running") child.status = "completed" if (!found.running && child.status === "running") child.status = "completed"
touch(child, event.data.timestamp) touch(child, event.created)
input.emit() input.emit()
if (!child.hydrated) void hydrateChild(child) if (!child.hydrated) void hydrateChild(child)
}, },
@@ -294,7 +294,14 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
if (!state.tools.get(item.id)?.running) write([toolCommit(part, "start")]) if (!state.tools.get(item.id)?.running) write([toolCommit(part, "start")])
state.finishedTools.add(item.id) state.finishedTools.add(item.id)
state.tools.delete(item.id) state.tools.delete(item.id)
write([toolCommit(part, item.state.status === "completed" && part.state.status === "completed" && part.state.output ? "progress" : "final")]) write([
toolCommit(
part,
item.state.status === "completed" && part.state.status === "completed" && part.state.output
? "progress"
: "final",
),
])
} }
const renderMessage = (message: SessionMessage, render: boolean, reuseVisibleWait: boolean) => { const renderMessage = (message: SessionMessage, render: boolean, reuseVisibleWait: boolean) => {
@@ -395,17 +402,17 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
} }
input.trace?.write("recv.event", event) input.trace?.write("recv.event", event)
subagents.main(event) subagents.main(event)
if (event.type === "session.next.prompted") { if (event.type === "prompt.promoted") {
if (state.wait?.messageID === event.data.messageID) state.wait.promoted = true if (state.wait?.messageID === event.data.inputID) state.wait.promoted = true
state.messageIDs.add(event.data.messageID) state.messageIDs.add(event.data.inputID)
write([], { phase: "running", status: "waiting for assistant" }) write([], { phase: "running", status: "waiting for assistant" })
return return
} }
if (event.type === "session.next.step.started") { if (event.type === "step.started") {
write([], { phase: "running", status: "assistant responding" }) write([], { phase: "running", status: "assistant responding" })
return return
} }
if (event.type === "session.next.text.delta") { if (event.type === "text.delta") {
const key = streamPartKey(event.data.assistantMessageID, event.data.textID) const key = streamPartKey(event.data.assistantMessageID, event.data.textID)
const projected = state.projectedText.get(key) const projected = state.projectedText.get(key)
const covered = projected?.indexOf(event.data.delta) ?? -1 const covered = projected?.indexOf(event.data.delta) ?? -1
@@ -427,7 +434,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
]) ])
return return
} }
if (event.type === "session.next.text.ended") { if (event.type === "text.ended") {
const key = streamPartKey(event.data.assistantMessageID, event.data.textID) const key = streamPartKey(event.data.assistantMessageID, event.data.textID)
const previous = state.text.get(key) ?? "" const previous = state.text.get(key) ?? ""
if (event.data.text.length > previous.length) if (event.data.text.length > previous.length)
@@ -445,7 +452,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
state.projectedText.delete(key) state.projectedText.delete(key)
return return
} }
if (event.type === "session.next.reasoning.delta") { if (event.type === "reasoning.delta") {
const key = streamPartKey(event.data.assistantMessageID, event.data.reasoningID) const key = streamPartKey(event.data.assistantMessageID, event.data.reasoningID)
const projected = state.projectedReasoning.get(key) const projected = state.projectedReasoning.get(key)
const covered = projected?.indexOf(event.data.delta) ?? -1 const covered = projected?.indexOf(event.data.delta) ?? -1
@@ -468,7 +475,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
]) ])
return return
} }
if (event.type === "session.next.reasoning.ended") { if (event.type === "reasoning.ended") {
const key = streamPartKey(event.data.assistantMessageID, event.data.reasoningID) const key = streamPartKey(event.data.assistantMessageID, event.data.reasoningID)
const previous = state.reasoning.get(key) ?? "" const previous = state.reasoning.get(key) ?? ""
if (input.thinking && event.data.text.length > previous.length) if (input.thinking && event.data.text.length > previous.length)
@@ -486,17 +493,17 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
state.projectedReasoning.delete(key) state.projectedReasoning.delete(key)
return return
} }
if (event.type === "session.next.tool.input.started") { if (event.type === "tool.input.started") {
state.tools.set(event.data.callID, { state.tools.set(event.data.callID, {
messageID: event.data.assistantMessageID, messageID: event.data.assistantMessageID,
name: event.data.name, name: event.data.name,
input: {}, input: {},
started: event.data.timestamp, started: event.created,
running: false, running: false,
}) })
return return
} }
if (event.type === "session.next.tool.called") { if (event.type === "tool.called") {
if (state.finishedTools.has(event.data.callID)) return if (state.finishedTools.has(event.data.callID)) return
const current = state.tools.get(event.data.callID) const current = state.tools.get(event.data.callID)
const item: SessionMessageAssistantTool = { const item: SessionMessageAssistantTool = {
@@ -505,22 +512,29 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
name: event.data.tool, name: event.data.tool,
provider: event.data.provider, provider: event.data.provider,
state: { status: "running", input: event.data.input, structured: {}, content: [] }, state: { status: "running", input: event.data.input, structured: {}, content: [] },
time: { created: current?.started ?? event.data.timestamp, ran: event.data.timestamp }, time: { created: current?.started ?? event.created, ran: event.created },
} }
renderTool(event.data.assistantMessageID, item) renderTool(event.data.assistantMessageID, item)
return return
} }
if (event.type === "session.next.tool.progress") return if (event.type === "tool.progress") return
if (event.type === "session.next.tool.success" || event.type === "session.next.tool.failed") { if (event.type === "tool.success" || event.type === "tool.failed") {
const current = state.tools.get(event.data.callID) const current = state.tools.get(event.data.callID)
const failed = event.type === "session.next.tool.failed" const failed = event.type === "tool.failed"
const item: SessionMessageAssistantTool = { const item: SessionMessageAssistantTool = {
type: "tool", type: "tool",
id: event.data.callID, id: event.data.callID,
name: current?.name ?? "tool", name: current?.name ?? "tool",
provider: event.data.provider, provider: event.data.provider,
state: failed state: failed
? { status: "error", input: current?.input ?? {}, structured: {}, content: [], error: event.data.error, result: event.data.result } ? {
status: "error",
input: current?.input ?? {},
structured: {},
content: [],
error: event.data.error,
result: event.data.result,
}
: { : {
status: "completed", status: "completed",
input: current?.input ?? {}, input: current?.input ?? {},
@@ -529,7 +543,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
outputPaths: event.data.outputPaths, outputPaths: event.data.outputPaths,
result: event.data.result, result: event.data.result,
}, },
time: { created: current?.started ?? event.data.timestamp, ran: current?.started, completed: event.data.timestamp }, time: { created: current?.started ?? event.created, ran: current?.started, completed: event.created },
} }
renderTool(event.data.assistantMessageID, item) renderTool(event.data.assistantMessageID, item)
return return
@@ -554,7 +568,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
syncBlockers() syncBlockers()
return return
} }
if (event.type === "session.next.step.ended") { if (event.type === "step.ended") {
const total = const total =
event.data.tokens.input + event.data.tokens.input +
event.data.tokens.output + event.data.tokens.output +
@@ -562,16 +576,19 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
event.data.tokens.cache.read + event.data.tokens.cache.read +
event.data.tokens.cache.write event.data.tokens.cache.write
const usage = total > 0 ? total.toLocaleString() : "" const usage = total > 0 ? total.toLocaleString() : ""
write([], { phase: event.data.finish === "tool-calls" ? "running" : "idle", usage: event.data.cost ? `${usage} · ${money.format(event.data.cost)}` : usage }) write([], {
phase: event.data.finish === "tool-calls" ? "running" : "idle",
usage: event.data.cost ? `${usage} · ${money.format(event.data.cost)}` : usage,
})
return return
} }
if (event.type === "session.next.step.failed") { if (event.type === "step.failed") {
state.errors.add(event.data.assistantMessageID) state.errors.add(event.data.assistantMessageID)
if (state.wait) state.wait.failureRendered = true if (state.wait) state.wait.failureRendered = true
write([{ kind: "error", source: "system", text: errorMessage(event.data.error), phase: "start" }]) write([{ kind: "error", source: "system", text: errorMessage(event.data.error), phase: "start" }])
return return
} }
if (event.type === "session.next.execution.settled") { if (event.type === "execution.settled") {
write([], { phase: "idle", status: "" }) write([], { phase: "idle", status: "" })
const current = state.wait const current = state.wait
if (!current || (!current.promoted && !current.interrupted)) return if (!current || (!current.promoted && !current.interrupted)) return
@@ -611,7 +628,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
sseMaxRetryAttempts: 0, sseMaxRetryAttempts: 0,
throwOnError: true, throwOnError: true,
}) })
const stream = response.stream[Symbol.asyncIterator]() as AsyncGenerator<RunV2Event> const stream = response.stream[Symbol.asyncIterator]() as AsyncGenerator<RunV2Event>
try { try {
const first = await stream.next() const first = await stream.next()
if (first.done || first.value.type !== "server.connected") throw new Error("Event stream disconnected") if (first.done || first.value.type !== "server.connected") throw new Error("Event stream disconnected")
@@ -689,10 +706,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
] ]
: [], : [],
) )
const attachments = [ const attachments = [...prepared.flatMap((file) => (file.attachment ? [file.attachment] : [])), ...promptFiles]
...prepared.flatMap((file) => (file.attachment ? [file.attachment] : [])),
...promptFiles,
]
const agents = next.prompt.parts.flatMap((part) => const agents = next.prompt.parts.flatMap((part) =>
part.type === "agent" part.type === "agent"
? [ ? [
@@ -735,10 +749,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
sessionID: input.sessionID, sessionID: input.sessionID,
id: messageID, id: messageID,
prompt: { prompt: {
text: [ text: [next.prompt.text, ...prepared.flatMap((file) => (file.text ? [file.text] : []))].join("\n\n"),
next.prompt.text,
...prepared.flatMap((file) => (file.text ? [file.text] : [])),
].join("\n\n"),
files: attachments.length ? attachments : undefined, files: attachments.length ? attachments : undefined,
agents: agents.length ? agents : undefined, agents: agents.length ? agents : undefined,
}, },
@@ -18,31 +18,33 @@ function form(id: string, sessionID: string): FormInfo {
} }
function formCreated(info: FormInfo): V2Event { function formCreated(info: FormInfo): V2Event {
return { id: `evt_${info.id}`, type: "form.created", data: { form: info } } return { id: `evt_${info.id}`, created: 0, type: "form.created", data: { form: info } }
} }
function prompted(messageID: string): V2Event { function prompted(inputID: string): V2Event {
return { return {
id: "evt_prompted", id: "evt_prompted",
type: "session.next.prompted", created: 0,
type: "prompt.promoted",
durable: { aggregateID: "ses_1", seq: 0, version: 1 }, durable: { aggregateID: "ses_1", seq: 0, version: 1 },
data: { timestamp: 1, sessionID: "ses_1", messageID, prompt: { text: "hello" }, delivery: "steer" }, data: { sessionID: "ses_1", inputID },
} }
} }
function settled(outcome: "success" | "interrupted" = "success"): V2Event { function settled(outcome: "success" | "interrupted" = "success"): V2Event {
return { return {
id: "evt_settled", id: "evt_settled",
type: "session.next.execution.settled", created: 0,
data: { timestamp: 2, sessionID: "ses_1", outcome }, type: "execution.settled",
data: { sessionID: "ses_1", outcome },
} }
} }
// Runs one non-interactive prompt against a mocked SDK. `turn` produces the // Runs one non-interactive prompt against a mocked SDK. `turn` produces the
// live events the prompt admission triggers, keyed by the generated message ID. // live events the prompt admission triggers, keyed by the generated message ID.
async function run(input: { turn: (messageID: string) => V2Event[]; pendingForms?: FormInfo[]; attached?: boolean }) { async function run(input: { turn: (inputID: string) => V2Event[]; pendingForms?: FormInfo[]; attached?: boolean }) {
const sdk = new OpencodeClient() const sdk = new OpencodeClient()
const values: V2Event[] = [{ id: "evt_connected", type: "server.connected", data: {} }] const values: V2Event[] = [{ id: "evt_connected", created: 0, type: "server.connected", data: {} }]
let wake: (() => void) | undefined let wake: (() => void) | undefined
const stream = (async function* (): AsyncGenerator<V2Event, void, unknown> { const stream = (async function* (): AsyncGenerator<V2Event, void, unknown> {
while (true) { while (true) {
@@ -329,7 +329,7 @@ describe("run session data", () => {
test("renders direct shell mode from first-class shell events", () => { test("renders direct shell mode from first-class shell events", () => {
let data = createSessionData() let data = createSessionData()
const started = reduce(data, { const started = reduce(data, {
type: "session.next.shell.started", type: "shell.started",
properties: { properties: {
sessionID: "session-1", sessionID: "session-1",
timestamp: 1, timestamp: 1,
@@ -353,7 +353,7 @@ describe("run session data", () => {
data = started.data data = started.data
const ended = reduce(data, { const ended = reduce(data, {
type: "session.next.shell.ended", type: "shell.ended",
properties: { properties: {
sessionID: "session-1", sessionID: "session-1",
timestamp: 2, timestamp: 2,
@@ -380,7 +380,7 @@ describe("run session data", () => {
test("suppresses legacy bash part updates once shell events claim the call", () => { test("suppresses legacy bash part updates once shell events claim the call", () => {
let data = reduce(createSessionData(), { let data = reduce(createSessionData(), {
type: "session.next.shell.started", type: "shell.started",
properties: { properties: {
sessionID: "session-1", sessionID: "session-1",
timestamp: 1, timestamp: 1,
@@ -409,7 +409,7 @@ describe("run session data", () => {
).toEqual([]) ).toEqual([])
data = reduce(data, { data = reduce(data, {
type: "session.next.shell.ended", type: "shell.ended",
properties: { properties: {
sessionID: "session-1", sessionID: "session-1",
timestamp: 2, timestamp: 2,
@@ -463,7 +463,7 @@ describe("run session data", () => {
expect( expect(
reduce(data, { reduce(data, {
type: "session.next.shell.started", type: "shell.started",
properties: { properties: {
sessionID: "session-1", sessionID: "session-1",
timestamp: 1, timestamp: 1,
@@ -497,7 +497,7 @@ describe("run session data", () => {
expect( expect(
reduce(data, { reduce(data, {
type: "session.next.shell.ended", type: "shell.ended",
properties: { properties: {
sessionID: "session-1", sessionID: "session-1",
timestamp: 2, timestamp: 2,
@@ -50,7 +50,7 @@ function ok<T>(data: T) {
} }
function connected(id = "evt_connected") { function connected(id = "evt_connected") {
return { id, type: "server.connected", data: {} } satisfies RunV2Event return { id, created: 0, type: "server.connected", data: {} } satisfies RunV2Event
} }
function durable(sessionID: string, seq = 0, version = 1) { function durable(sessionID: string, seq = 0, version = 1) {
@@ -125,22 +125,20 @@ function sdk(input: {
spyOn(client.v2.session, "switchModel").mockImplementation(() => ok(undefined)) spyOn(client.v2.session, "switchModel").mockImplementation(() => ok(undefined))
// The generated methods have conditional return types for throwOnError; the // The generated methods have conditional return types for throwOnError; the
// minimal shapes below are enough for family discovery and model fallback. // minimal shapes below are enough for family discovery and model fallback.
spyOn(client.v2.session, "list").mockImplementation( spyOn(client.v2.session, "list").mockImplementation((request) => {
(request) => { const parentID = request?.parentID
const parentID = request?.parentID return ok({
return ok({ location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, data:
data: input.sessions?.filter((session) =>
input.sessions?.filter((session) => parentID === undefined
parentID === undefined ? true
? true : parentID === null
: parentID === null ? session.parentID === undefined
? session.parentID === undefined : session.parentID === parentID,
: session.parentID === parentID, ) ?? [],
) ?? [], }) as never
}) as never })
},
)
spyOn(client.v2.model, "default").mockImplementation( spyOn(client.v2.model, "default").mockImplementation(
() => () =>
ok({ ok({
@@ -201,21 +199,19 @@ describe("V2 mini transport", () => {
while (!admitted) await Bun.sleep(0) while (!admitted) await Bun.sleep(0)
events.push({ events.push({
id: "evt_prompted", id: "evt_prompted",
type: "session.next.prompted", created: 0,
type: "prompt.promoted",
durable: durable("ses_1"), durable: durable("ses_1"),
data: { data: {
timestamp: 2,
sessionID: "ses_1", sessionID: "ses_1",
messageID: "msg_prompt", inputID: "msg_prompt",
prompt: { text: "hello" },
delivery: "steer",
}, },
}) })
events.push({ events.push({
id: "evt_text", id: "evt_text",
type: "session.next.text.delta", created: 0,
type: "text.delta",
data: { data: {
timestamp: 3,
sessionID: "ses_1", sessionID: "ses_1",
assistantMessageID: "msg_assistant", assistantMessageID: "msg_assistant",
textID: "txt_1", textID: "txt_1",
@@ -224,8 +220,9 @@ describe("V2 mini transport", () => {
}) })
events.push({ events.push({
id: "evt_settled", id: "evt_settled",
type: "session.next.execution.settled", created: 0,
data: { timestamp: 4, sessionID: "ses_1", outcome: "success" }, type: "execution.settled",
data: { sessionID: "ses_1", outcome: "success" },
}) })
await turn await turn
@@ -261,20 +258,19 @@ describe("V2 mini transport", () => {
queueMicrotask(() => { queueMicrotask(() => {
events.push({ events.push({
id: "evt_prompted", id: "evt_prompted",
type: "session.next.prompted", created: 0,
type: "prompt.promoted",
durable: durable("ses_1"), durable: durable("ses_1"),
data: { data: {
timestamp: 2,
sessionID: "ses_1", sessionID: "ses_1",
messageID: "msg_prompt", inputID: "msg_prompt",
prompt: { text: input.prompt?.text ?? "" },
delivery: "steer",
}, },
}) })
events.push({ events.push({
id: "evt_settled", id: "evt_settled",
type: "session.next.execution.settled", created: 0,
data: { timestamp: 3, sessionID: "ses_1", outcome: "success" }, type: "execution.settled",
data: { sessionID: "ses_1", outcome: "success" },
}) })
}) })
return ok({ return ok({
@@ -356,20 +352,19 @@ describe("V2 mini transport", () => {
queueMicrotask(() => { queueMicrotask(() => {
events.push({ events.push({
id: "evt_prompted", id: "evt_prompted",
type: "session.next.prompted", created: 0,
type: "prompt.promoted",
durable: durable("ses_1"), durable: durable("ses_1"),
data: { data: {
timestamp: 2,
sessionID: "ses_1", sessionID: "ses_1",
messageID: "msg_prompt", inputID: "msg_prompt",
prompt: { text: input.prompt?.text ?? "" },
delivery: "steer",
}, },
}) })
events.push({ events.push({
id: "evt_settled", id: "evt_settled",
type: "session.next.execution.settled", created: 0,
data: { timestamp: 3, sessionID: "ses_1", outcome: "success" }, type: "execution.settled",
data: { sessionID: "ses_1", outcome: "success" },
}) })
}) })
return ok({ return ok({
@@ -454,20 +449,19 @@ describe("V2 mini transport", () => {
queueMicrotask(() => { queueMicrotask(() => {
events.push({ events.push({
id: "evt_prompted", id: "evt_prompted",
type: "session.next.prompted", created: 0,
type: "prompt.promoted",
durable: durable("ses_1"), durable: durable("ses_1"),
data: { data: {
timestamp: 2,
sessionID: "ses_1", sessionID: "ses_1",
messageID: "msg_prompt", inputID: "msg_prompt",
prompt: { text: input.prompt?.text ?? "" },
delivery: "steer",
}, },
}) })
events.push({ events.push({
id: "evt_settled", id: "evt_settled",
type: "session.next.execution.settled", created: 0,
data: { timestamp: 3, sessionID: "ses_1", outcome: "success" }, type: "execution.settled",
data: { sessionID: "ses_1", outcome: "success" },
}) })
}) })
return ok({ return ok({
@@ -528,6 +522,7 @@ describe("V2 mini transport", () => {
}) })
events.push({ events.push({
id: "evt_permission", id: "evt_permission",
created: 0,
type: "permission.v2.asked", type: "permission.v2.asked",
data: { id: "per_1", sessionID: "ses_1", action: "read", resources: ["/tmp/file"] }, data: { id: "per_1", sessionID: "ses_1", action: "read", resources: ["/tmp/file"] },
}) })
@@ -728,9 +723,9 @@ describe("V2 mini transport", () => {
const replay = transport.replayOnResize({ localRows: () => [], reset: () => resetting }) const replay = transport.replayOnResize({ localRows: () => [], reset: () => resetting })
events.push({ events.push({
id: "evt_text", id: "evt_text",
type: "session.next.text.delta", created: 0,
type: "text.delta",
data: { data: {
timestamp: 3,
sessionID: "ses_1", sessionID: "ses_1",
assistantMessageID: "msg_assistant", assistantMessageID: "msg_assistant",
textID: "txt_1", textID: "txt_1",
@@ -813,10 +808,10 @@ describe("V2 mini transport", () => {
}) })
events.push({ events.push({
id: "evt_reasoning", id: "evt_reasoning",
type: "session.next.reasoning.ended", created: 0,
type: "reasoning.ended",
durable: durable("ses_1"), durable: durable("ses_1"),
data: { data: {
timestamp: 3,
sessionID: "ses_1", sessionID: "ses_1",
assistantMessageID: "msg_assistant", assistantMessageID: "msg_assistant",
reasoningID: "reasoning_1", reasoningID: "reasoning_1",
@@ -869,8 +864,9 @@ describe("V2 mini transport", () => {
await transport.interruptActiveTurn() await transport.interruptActiveTurn()
events.push({ events.push({
id: "evt_settled", id: "evt_settled",
type: "session.next.execution.settled", created: 0,
data: { timestamp: 3, sessionID: "ses_1", outcome: "interrupted" }, type: "execution.settled",
data: { sessionID: "ses_1", outcome: "success" },
}) })
await turn await turn
@@ -924,20 +920,19 @@ describe("V2 mini transport", () => {
while (!admitted) await Bun.sleep(0) while (!admitted) await Bun.sleep(0)
events.push({ events.push({
id: "evt_prompted", id: "evt_prompted",
type: "session.next.prompted", created: 0,
type: "prompt.promoted",
durable: durable("ses_1"), durable: durable("ses_1"),
data: { data: {
timestamp: 2,
sessionID: "ses_1", sessionID: "ses_1",
messageID: "msg_prompt", inputID: "msg_prompt",
prompt: { text: "hello" },
delivery: "steer",
}, },
}) })
events.push({ events.push({
id: "evt_settled", id: "evt_settled",
type: "session.next.execution.settled", created: 0,
data: { timestamp: 3, sessionID: "ses_1", outcome: "success" }, type: "execution.settled",
data: { sessionID: "ses_1", outcome: "success" },
}) })
await turn await turn
@@ -985,22 +980,21 @@ describe("V2 mini transport", () => {
while (!admitted) await Bun.sleep(0) while (!admitted) await Bun.sleep(0)
events.push({ events.push({
id: "evt_prompted", id: "evt_prompted",
type: "session.next.prompted", created: 0,
type: "prompt.promoted",
durable: durable("ses_1"), durable: durable("ses_1"),
data: { data: {
timestamp: 2,
sessionID: "ses_1", sessionID: "ses_1",
messageID: "msg_prompt", inputID: "msg_prompt",
prompt: { text: "hello" },
delivery: "steer",
}, },
}) })
await Bun.sleep(0) await Bun.sleep(0)
controller.abort() controller.abort()
events.push({ events.push({
id: "evt_settled", id: "evt_settled",
type: "session.next.execution.settled", created: 0,
data: { timestamp: 3, sessionID: "ses_1", outcome: "interrupted" }, type: "execution.settled",
data: { sessionID: "ses_1", outcome: "success" },
}) })
await turn await turn
@@ -1054,10 +1048,10 @@ describe("V2 mini transport", () => {
events.push({ events.push({
id: "evt_child_step", id: "evt_child_step",
type: "session.next.step.started", created: 0,
type: "step.started",
durable: durable("ses_child"), durable: durable("ses_child"),
data: { data: {
timestamp: 2,
sessionID: "ses_child", sessionID: "ses_child",
assistantMessageID: "msg_child_a", assistantMessageID: "msg_child_a",
agent: "explore", agent: "explore",
@@ -1072,9 +1066,9 @@ describe("V2 mini transport", () => {
events.push({ events.push({
id: "evt_child_text", id: "evt_child_text",
type: "session.next.text.delta", created: 0,
type: "text.delta",
data: { data: {
timestamp: 3,
sessionID: "ses_child", sessionID: "ses_child",
assistantMessageID: "msg_child_a", assistantMessageID: "msg_child_a",
textID: "txt_child", textID: "txt_child",
@@ -1086,8 +1080,9 @@ describe("V2 mini transport", () => {
events.push({ events.push({
id: "evt_child_settled", id: "evt_child_settled",
type: "session.next.execution.settled", created: 0,
data: { timestamp: 4, sessionID: "ses_child", outcome: "success" }, type: "execution.settled",
data: { sessionID: "ses_child", outcome: "success" },
}) })
while (!states().some((state) => state.tabs.some((tab) => tab.status === "completed"))) await Bun.sleep(0) while (!states().some((state) => state.tabs.some((tab) => tab.status === "completed"))) await Bun.sleep(0)
await transport.close() await transport.close()
@@ -1130,10 +1125,10 @@ describe("V2 mini transport", () => {
// Both events arrive while session.get is still in flight. // Both events arrive while session.get is still in flight.
events.push({ events.push({
id: "evt_child_step", id: "evt_child_step",
type: "session.next.step.started", created: 0,
type: "step.started",
durable: durable("ses_child"), durable: durable("ses_child"),
data: { data: {
timestamp: 2,
sessionID: "ses_child", sessionID: "ses_child",
assistantMessageID: "msg_child_a", assistantMessageID: "msg_child_a",
agent: "explore", agent: "explore",
@@ -1142,8 +1137,9 @@ describe("V2 mini transport", () => {
}) })
events.push({ events.push({
id: "evt_child_settled", id: "evt_child_settled",
type: "session.next.execution.settled", created: 0,
data: { timestamp: 3, sessionID: "ses_child", outcome: "interrupted" }, type: "execution.settled",
data: { sessionID: "ses_child", outcome: "interrupted" },
}) })
await Bun.sleep(0) await Bun.sleep(0)
resolveGet?.() resolveGet?.()
@@ -1188,10 +1184,10 @@ describe("V2 mini transport", () => {
// Child event arrives first and gets buffered behind the gated session.get. // Child event arrives first and gets buffered behind the gated session.get.
events.push({ events.push({
id: "evt_child_step", id: "evt_child_step",
type: "session.next.step.started", created: 0,
type: "step.started",
durable: durable("ses_child"), durable: durable("ses_child"),
data: { data: {
timestamp: 2,
sessionID: "ses_child", sessionID: "ses_child",
assistantMessageID: "msg_child_a", assistantMessageID: "msg_child_a",
agent: "explore", agent: "explore",
@@ -1201,10 +1197,10 @@ describe("V2 mini transport", () => {
// Parent's background subagent tool.success adopts the child mid-discovery. // Parent's background subagent tool.success adopts the child mid-discovery.
events.push({ events.push({
id: "evt_parent_call", id: "evt_parent_call",
type: "session.next.tool.called", created: 0,
type: "tool.called",
durable: durable("ses_1"), durable: durable("ses_1"),
data: { data: {
timestamp: 3,
sessionID: "ses_1", sessionID: "ses_1",
assistantMessageID: "msg_parent_a", assistantMessageID: "msg_parent_a",
callID: "call_sub", callID: "call_sub",
@@ -1215,10 +1211,10 @@ describe("V2 mini transport", () => {
}) })
events.push({ events.push({
id: "evt_parent_success", id: "evt_parent_success",
type: "session.next.tool.success", created: 0,
type: "tool.success",
durable: durable("ses_1", 1), durable: durable("ses_1", 1),
data: { data: {
timestamp: 4,
sessionID: "ses_1", sessionID: "ses_1",
assistantMessageID: "msg_parent_a", assistantMessageID: "msg_parent_a",
callID: "call_sub", callID: "call_sub",
@@ -1230,8 +1226,9 @@ describe("V2 mini transport", () => {
// The settled event arrives after adoption, so it applies directly. // The settled event arrives after adoption, so it applies directly.
events.push({ events.push({
id: "evt_child_settled", id: "evt_child_settled",
type: "session.next.execution.settled", created: 0,
data: { timestamp: 5, sessionID: "ses_child", outcome: "interrupted" }, type: "execution.settled",
data: { sessionID: "ses_child", outcome: "interrupted" },
}) })
while (!states().some((state) => state.tabs.some((tab) => tab.status === "cancelled"))) await Bun.sleep(0) while (!states().some((state) => state.tabs.some((tab) => tab.status === "cancelled"))) await Bun.sleep(0)
@@ -21,12 +21,12 @@ test.skip("step snapshots carry over to assistant messages", () => {
Effect.runSync( Effect.runSync(
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
id: EventV2.ID.create(), id: EventV2.ID.create(),
type: "session.next.step.started", created: DateTime.makeUnsafe(0),
type: "step.started",
durable: durable(sessionID), durable: durable(sessionID),
data: { data: {
sessionID, sessionID,
assistantMessageID, assistantMessageID,
timestamp: DateTime.makeUnsafe(1),
agent: "build", agent: "build",
model: { model: {
id: ModelV2.ID.make("model"), id: ModelV2.ID.make("model"),
@@ -43,12 +43,12 @@ test.skip("step snapshots carry over to assistant messages", () => {
Effect.runSync( Effect.runSync(
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
id: EventV2.ID.create(), id: EventV2.ID.create(),
type: "session.next.step.ended", created: DateTime.makeUnsafe(0),
type: "step.ended",
durable: durable(sessionID, 1, 2), durable: durable(sessionID, 1, 2),
data: { data: {
sessionID, sessionID,
assistantMessageID, assistantMessageID,
timestamp: DateTime.makeUnsafe(2),
finish: "stop", finish: "stop",
cost: 0, cost: 0,
tokens: { tokens: {
@@ -76,12 +76,12 @@ test.skip("text ended populates assistant text content", () => {
Effect.runSync( Effect.runSync(
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
id: EventV2.ID.create(), id: EventV2.ID.create(),
type: "session.next.step.started", created: DateTime.makeUnsafe(0),
type: "step.started",
durable: durable(sessionID), durable: durable(sessionID),
data: { data: {
sessionID, sessionID,
assistantMessageID, assistantMessageID,
timestamp: DateTime.makeUnsafe(1),
agent: "build", agent: "build",
model: { model: {
id: ModelV2.ID.make("model"), id: ModelV2.ID.make("model"),
@@ -95,12 +95,12 @@ test.skip("text ended populates assistant text content", () => {
Effect.runSync( Effect.runSync(
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
id: EventV2.ID.create(), id: EventV2.ID.create(),
type: "session.next.text.started", created: DateTime.makeUnsafe(0),
type: "text.started",
durable: durable(sessionID, 1), durable: durable(sessionID, 1),
data: { data: {
sessionID, sessionID,
assistantMessageID, assistantMessageID,
timestamp: DateTime.makeUnsafe(2),
textID: "text-1", textID: "text-1",
}, },
} satisfies SessionEvent.Event), } satisfies SessionEvent.Event),
@@ -109,12 +109,12 @@ test.skip("text ended populates assistant text content", () => {
Effect.runSync( Effect.runSync(
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
id: EventV2.ID.create(), id: EventV2.ID.create(),
type: "session.next.text.ended", created: DateTime.makeUnsafe(0),
type: "text.ended",
durable: durable(sessionID, 2), durable: durable(sessionID, 2),
data: { data: {
sessionID, sessionID,
assistantMessageID, assistantMessageID,
timestamp: DateTime.makeUnsafe(3),
textID: "text-1", textID: "text-1",
text: "hello assistant", text: "hello assistant",
}, },
@@ -135,12 +135,12 @@ test.skip("tool completion stores completed timestamp", () => {
Effect.runSync( Effect.runSync(
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
id: EventV2.ID.create(), id: EventV2.ID.create(),
type: "session.next.step.started", created: DateTime.makeUnsafe(0),
type: "step.started",
durable: durable(sessionID), durable: durable(sessionID),
data: { data: {
sessionID, sessionID,
assistantMessageID, assistantMessageID,
timestamp: DateTime.makeUnsafe(1),
agent: "build", agent: "build",
model: { model: {
id: ModelV2.ID.make("model"), id: ModelV2.ID.make("model"),
@@ -154,12 +154,12 @@ test.skip("tool completion stores completed timestamp", () => {
Effect.runSync( Effect.runSync(
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
id: EventV2.ID.create(), id: EventV2.ID.create(),
type: "session.next.tool.input.started", created: DateTime.makeUnsafe(0),
type: "tool.input.started",
durable: durable(sessionID, 1), durable: durable(sessionID, 1),
data: { data: {
sessionID, sessionID,
assistantMessageID, assistantMessageID,
timestamp: DateTime.makeUnsafe(2),
callID, callID,
name: "bash", name: "bash",
}, },
@@ -169,12 +169,12 @@ test.skip("tool completion stores completed timestamp", () => {
Effect.runSync( Effect.runSync(
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
id: EventV2.ID.create(), id: EventV2.ID.create(),
type: "session.next.tool.called", created: DateTime.makeUnsafe(0),
type: "tool.called",
durable: durable(sessionID, 2), durable: durable(sessionID, 2),
data: { data: {
sessionID, sessionID,
assistantMessageID, assistantMessageID,
timestamp: DateTime.makeUnsafe(3),
callID, callID,
tool: "bash", tool: "bash",
input: { command: "pwd" }, input: { command: "pwd" },
@@ -186,12 +186,12 @@ test.skip("tool completion stores completed timestamp", () => {
Effect.runSync( Effect.runSync(
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
id: EventV2.ID.create(), id: EventV2.ID.create(),
type: "session.next.tool.success", created: DateTime.makeUnsafe(0),
type: "tool.success",
durable: durable(sessionID, 3), durable: durable(sessionID, 3),
data: { data: {
sessionID, sessionID,
assistantMessageID, assistantMessageID,
timestamp: DateTime.makeUnsafe(4),
callID, callID,
structured: {}, structured: {},
content: [{ type: "text", text: "/tmp" }], content: [{ type: "text", text: "/tmp" }],
@@ -212,17 +212,16 @@ test("compaction events reduce to compaction message only when completed", () =>
const state: SessionMessageUpdater.MemoryState = { messages: [] } const state: SessionMessageUpdater.MemoryState = { messages: [] }
const sessionID = SessionID.make("session") const sessionID = SessionID.make("session")
const id = EventV2.ID.create() const id = EventV2.ID.create()
const compactionID = SessionMessage.ID.create() const endedID = EventV2.ID.create()
Effect.runSync( Effect.runSync(
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
id, id,
type: "session.next.compaction.started", created: DateTime.makeUnsafe(0),
type: "compaction.started",
durable: durable(sessionID), durable: durable(sessionID),
data: { data: {
sessionID, sessionID,
messageID: compactionID,
timestamp: DateTime.makeUnsafe(1),
reason: "auto", reason: "auto",
}, },
} satisfies SessionEvent.Event), } satisfies SessionEvent.Event),
@@ -233,11 +232,10 @@ test("compaction events reduce to compaction message only when completed", () =>
Effect.runSync( Effect.runSync(
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
id: EventV2.ID.create(), id: EventV2.ID.create(),
type: "session.next.compaction.delta", created: DateTime.makeUnsafe(0),
type: "compaction.delta",
data: { data: {
sessionID, sessionID,
messageID: compactionID,
timestamp: DateTime.makeUnsafe(2),
text: "hello ", text: "hello ",
}, },
} satisfies SessionEvent.Event), } satisfies SessionEvent.Event),
@@ -246,11 +244,10 @@ test("compaction events reduce to compaction message only when completed", () =>
Effect.runSync( Effect.runSync(
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
id: EventV2.ID.create(), id: EventV2.ID.create(),
type: "session.next.compaction.delta", created: DateTime.makeUnsafe(0),
type: "compaction.delta",
data: { data: {
sessionID, sessionID,
messageID: compactionID,
timestamp: DateTime.makeUnsafe(3),
text: "summary", text: "summary",
}, },
} satisfies SessionEvent.Event), } satisfies SessionEvent.Event),
@@ -258,13 +255,12 @@ test("compaction events reduce to compaction message only when completed", () =>
Effect.runSync( Effect.runSync(
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
id: EventV2.ID.create(), id: endedID,
type: "session.next.compaction.ended", created: DateTime.makeUnsafe(0),
type: "compaction.ended",
durable: durable(sessionID, 1), durable: durable(sessionID, 1),
data: { data: {
sessionID, sessionID,
messageID: compactionID,
timestamp: DateTime.makeUnsafe(4),
reason: "auto", reason: "auto",
text: "final summary", text: "final summary",
recent: "recent context", recent: "recent context",
@@ -274,11 +270,11 @@ test("compaction events reduce to compaction message only when completed", () =>
expect(state.messages).toHaveLength(1) expect(state.messages).toHaveLength(1)
expect(state.messages[0]).toMatchObject({ expect(state.messages[0]).toMatchObject({
id: compactionID, id: SessionMessage.ID.fromEvent(endedID),
type: "compaction", type: "compaction",
reason: "auto", reason: "auto",
summary: "final summary", summary: "final summary",
recent: "recent context", recent: "recent context",
time: { created: DateTime.makeUnsafe(4) }, time: { created: DateTime.makeUnsafe(0) },
}) })
}) })
+4 -1
View File
@@ -4,7 +4,7 @@ import { Schema } from "effect"
import { optional } from "./schema.js" import { optional } from "./schema.js"
import { ascending } from "./identifier.js" import { ascending } from "./identifier.js"
import { Location } from "./location.js" import { Location } from "./location.js"
import { statics } from "./schema.js" import { DateTimeUtcFromMillis, statics } from "./schema.js"
export const ID = Schema.String.check(Schema.isStartsWith("evt_")).pipe( export const ID = Schema.String.check(Schema.isStartsWith("evt_")).pipe(
Schema.brand("Event.ID"), Schema.brand("Event.ID"),
@@ -60,6 +60,7 @@ export type Data<D extends Definition> = Schema.Schema.Type<D["data"]>
type PayloadBase<D extends Definition> = { type PayloadBase<D extends Definition> = {
readonly id: ID readonly id: ID
readonly type: D["type"] readonly type: D["type"]
readonly created: typeof DateTimeUtcFromMillis.Type
readonly data: Data<D> readonly data: Data<D>
readonly location?: Location.Ref readonly location?: Location.Ref
readonly metadata?: Record<string, unknown> readonly metadata?: Record<string, unknown>
@@ -85,6 +86,7 @@ export function durable<
const data = Schema.Struct(input.schema) const data = Schema.Struct(input.schema)
return Schema.Struct({ return Schema.Struct({
id: ID, id: ID,
created: DateTimeUtcFromMillis,
metadata: optional(Schema.Record(Schema.String, Schema.Unknown)), metadata: optional(Schema.Record(Schema.String, Schema.Unknown)),
type: Schema.Literal(input.type), type: Schema.Literal(input.type),
durable: DurableEnvelope, durable: DurableEnvelope,
@@ -109,6 +111,7 @@ export function ephemeral<
const data = Schema.Struct(input.schema) const data = Schema.Struct(input.schema)
return Schema.Struct({ return Schema.Struct({
id: ID, id: ID,
created: DateTimeUtcFromMillis,
metadata: optional(Schema.Record(Schema.String, Schema.Unknown)), metadata: optional(Schema.Record(Schema.String, Schema.Unknown)),
type: Schema.Literal(input.type), type: Schema.Literal(input.type),
location: optional(Location.Ref), location: optional(Location.Ref),
+56 -63
View File
@@ -6,7 +6,7 @@ import { Event } from "./event.js"
import { ProviderMetadata, ToolContent } from "./llm.js" import { ProviderMetadata, ToolContent } from "./llm.js"
import { Delivery } from "./session-delivery.js" import { Delivery } from "./session-delivery.js"
import { Model } from "./model.js" import { Model } from "./model.js"
import { DateTimeUtcFromMillis, NonNegativeInt, RelativePath } from "./schema.js" import { NonNegativeInt, RelativePath } from "./schema.js"
import { FileAttachment, Prompt } from "./prompt.js" import { FileAttachment, Prompt } from "./prompt.js"
import { SessionID } from "./session-id.js" import { SessionID } from "./session-id.js"
import { Location } from "./location.js" import { Location } from "./location.js"
@@ -20,17 +20,16 @@ export const Source = Schema.Struct({
end: NonNegativeInt, end: NonNegativeInt,
text: Schema.String, text: Schema.String,
}).annotate({ }).annotate({
identifier: "session.next.event.source", identifier: "session.event.source",
}) })
export interface Source extends Schema.Schema.Type<typeof Source> {} export interface Source extends Schema.Schema.Type<typeof Source> {}
const Base = { const Base = {
timestamp: DateTimeUtcFromMillis,
sessionID: SessionID, sessionID: SessionID,
} }
const PromptFields = { const PromptFields = {
...Base, ...Base,
messageID: SessionMessage.ID, inputID: SessionMessage.ID,
prompt: Prompt, prompt: Prompt,
delivery: Delivery, delivery: Delivery,
} }
@@ -44,48 +43,46 @@ const options = {
const stepSettlementOptions = { const stepSettlementOptions = {
durable: { durable: {
aggregate: "sessionID", aggregate: "sessionID",
version: 2, version: 1,
}, },
} as const } as const
export const UnknownError = SessionMessage.UnknownError export const UnknownError = SessionMessage.UnknownError
export type UnknownError = SessionMessage.UnknownError export type UnknownError = SessionMessage.UnknownError
export const AgentSwitched = Event.durable({ export const AgentSelected = Event.durable({
type: "session.next.agent.switched", type: "agent.selected",
...options, ...options,
schema: { schema: {
...Base, ...Base,
messageID: SessionMessage.ID,
agent: Schema.String, agent: Schema.String,
}, },
}) })
export type AgentSwitched = typeof AgentSwitched.Type export type AgentSelected = typeof AgentSelected.Type
export const ModelSwitched = Event.durable({ export const ModelSelected = Event.durable({
type: "session.next.model.switched", type: "model.selected",
...options, ...options,
schema: { schema: {
...Base, ...Base,
messageID: SessionMessage.ID,
model: Model.Ref, model: Model.Ref,
}, },
}) })
export type ModelSwitched = typeof ModelSwitched.Type export type ModelSelected = typeof ModelSelected.Type
export const Moved = Event.durable({ export const Moved = Event.durable({
type: "session.next.moved", type: "session.moved",
...options, ...options,
schema: { schema: {
...Base, ...Base,
location: Location.Ref, location: Location.Ref,
subdirectory: RelativePath.pipe(optional), subpath: RelativePath.pipe(optional),
}, },
}) })
export type Moved = typeof Moved.Type export type Moved = typeof Moved.Type
export const Renamed = Event.durable({ export const Renamed = Event.durable({
type: "session.next.renamed", type: "renamed",
...options, ...options,
schema: { schema: {
...Base, ...Base,
@@ -95,32 +92,35 @@ export const Renamed = Event.durable({
export type Renamed = typeof Renamed.Type export type Renamed = typeof Renamed.Type
export const Forked = Event.durable({ export const Forked = Event.durable({
type: "session.next.forked", type: "forked",
...options, ...options,
schema: { schema: {
...Base, ...Base,
parentID: SessionID, parentID: SessionID,
messageID: SessionMessage.ID.pipe(optional), from: SessionMessage.ID.pipe(optional),
}, },
}) })
export type Forked = typeof Forked.Type export type Forked = typeof Forked.Type
export const Prompted = Event.durable({ export const PromptPromoted = Event.durable({
type: "session.next.prompted", type: "prompt.promoted",
...options, ...options,
schema: PromptFields, schema: {
sessionID: SessionID,
inputID: SessionMessage.ID,
},
}) })
export type Prompted = typeof Prompted.Type export type PromptPromoted = typeof PromptPromoted.Type
export const PromptAdmitted = Event.durable({ export const PromptAdmitted = Event.durable({
type: "session.next.prompt.admitted", type: "prompt.admitted",
...options, ...options,
schema: PromptFields, schema: PromptFields,
}) })
export type PromptAdmitted = typeof PromptAdmitted.Type export type PromptAdmitted = typeof PromptAdmitted.Type
export const ExecutionSettled = Event.ephemeral({ export const ExecutionSettled = Event.ephemeral({
type: "session.next.execution.settled", type: "execution.settled",
schema: { schema: {
...Base, ...Base,
outcome: Schema.Literals(["success", "failure", "interrupted"]), outcome: Schema.Literals(["success", "failure", "interrupted"]),
@@ -130,22 +130,20 @@ export const ExecutionSettled = Event.ephemeral({
export type ExecutionSettled = typeof ExecutionSettled.Type export type ExecutionSettled = typeof ExecutionSettled.Type
export const ContextUpdated = Event.durable({ export const ContextUpdated = Event.durable({
type: "session.next.context.updated", type: "session.context.updated",
...options, ...options,
schema: { schema: {
...Base, ...Base,
messageID: SessionMessage.ID,
text: Schema.String, text: Schema.String,
}, },
}) })
export type ContextUpdated = typeof ContextUpdated.Type export type ContextUpdated = typeof ContextUpdated.Type
export const Synthetic = Event.durable({ export const Synthetic = Event.durable({
type: "session.next.synthetic", type: "synthetic",
...options, ...options,
schema: { schema: {
...Base, ...Base,
messageID: SessionMessage.ID,
text: Schema.String, text: Schema.String,
description: Schema.String.pipe(optional), description: Schema.String.pipe(optional),
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional), metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional),
@@ -155,11 +153,10 @@ export type Synthetic = typeof Synthetic.Type
export namespace Skill { export namespace Skill {
export const Activated = Event.durable({ export const Activated = Event.durable({
type: "session.next.skill.activated", type: "skill.activated",
...options, ...options,
schema: { schema: {
...Base, ...Base,
messageID: SessionMessage.ID,
name: Schema.String, name: Schema.String,
text: Schema.String, text: Schema.String,
}, },
@@ -169,11 +166,10 @@ export namespace Skill {
export namespace Shell { export namespace Shell {
export const Started = Event.durable({ export const Started = Event.durable({
type: "session.next.shell.started", type: "shell.started",
...options, ...options,
schema: { schema: {
...Base, ...Base,
messageID: SessionMessage.ID,
callID: Schema.String, callID: Schema.String,
command: Schema.String, command: Schema.String,
}, },
@@ -181,7 +177,7 @@ export namespace Shell {
export type Started = typeof Started.Type export type Started = typeof Started.Type
export const Ended = Event.durable({ export const Ended = Event.durable({
type: "session.next.shell.ended", type: "shell.ended",
...options, ...options,
schema: { schema: {
...Base, ...Base,
@@ -194,7 +190,7 @@ export namespace Shell {
export namespace Step { export namespace Step {
export const Started = Event.durable({ export const Started = Event.durable({
type: "session.next.step.started", type: "step.started",
...options, ...options,
schema: { schema: {
...Base, ...Base,
@@ -207,7 +203,7 @@ export namespace Step {
export type Started = typeof Started.Type export type Started = typeof Started.Type
export const Ended = Event.durable({ export const Ended = Event.durable({
type: "session.next.step.ended", type: "step.ended",
...stepSettlementOptions, ...stepSettlementOptions,
schema: { schema: {
...Base, ...Base,
@@ -230,7 +226,7 @@ export namespace Step {
export type Ended = typeof Ended.Type export type Ended = typeof Ended.Type
export const Failed = Event.durable({ export const Failed = Event.durable({
type: "session.next.step.failed", type: "step.failed",
...stepSettlementOptions, ...stepSettlementOptions,
schema: { schema: {
...Base, ...Base,
@@ -243,7 +239,7 @@ export namespace Step {
export namespace Text { export namespace Text {
export const Started = Event.durable({ export const Started = Event.durable({
type: "session.next.text.started", type: "text.started",
...options, ...options,
schema: { schema: {
...Base, ...Base,
@@ -255,7 +251,7 @@ export namespace Text {
// Stream fragments are live-only; Text.Ended is the replayable full-value boundary. // Stream fragments are live-only; Text.Ended is the replayable full-value boundary.
export const Delta = Event.ephemeral({ export const Delta = Event.ephemeral({
type: "session.next.text.delta", type: "text.delta",
schema: { schema: {
...Base, ...Base,
assistantMessageID: SessionMessage.ID, assistantMessageID: SessionMessage.ID,
@@ -266,7 +262,7 @@ export namespace Text {
export type Delta = typeof Delta.Type export type Delta = typeof Delta.Type
export const Ended = Event.durable({ export const Ended = Event.durable({
type: "session.next.text.ended", type: "text.ended",
...options, ...options,
schema: { schema: {
...Base, ...Base,
@@ -280,7 +276,7 @@ export namespace Text {
export namespace Reasoning { export namespace Reasoning {
export const Started = Event.durable({ export const Started = Event.durable({
type: "session.next.reasoning.started", type: "reasoning.started",
...options, ...options,
schema: { schema: {
...Base, ...Base,
@@ -293,7 +289,7 @@ export namespace Reasoning {
// Stream fragments are live-only; Reasoning.Ended is the replayable full-value boundary. // Stream fragments are live-only; Reasoning.Ended is the replayable full-value boundary.
export const Delta = Event.ephemeral({ export const Delta = Event.ephemeral({
type: "session.next.reasoning.delta", type: "reasoning.delta",
schema: { schema: {
...Base, ...Base,
assistantMessageID: SessionMessage.ID, assistantMessageID: SessionMessage.ID,
@@ -304,7 +300,7 @@ export namespace Reasoning {
export type Delta = typeof Delta.Type export type Delta = typeof Delta.Type
export const Ended = Event.durable({ export const Ended = Event.durable({
type: "session.next.reasoning.ended", type: "reasoning.ended",
...options, ...options,
schema: { schema: {
...Base, ...Base,
@@ -326,7 +322,7 @@ export namespace Tool {
export namespace Input { export namespace Input {
export const Started = Event.durable({ export const Started = Event.durable({
type: "session.next.tool.input.started", type: "tool.input.started",
...options, ...options,
schema: { schema: {
...ToolBase, ...ToolBase,
@@ -337,7 +333,7 @@ export namespace Tool {
// Stream fragments are live-only; Input.Ended is the replayable raw-input boundary. // Stream fragments are live-only; Input.Ended is the replayable raw-input boundary.
export const Delta = Event.ephemeral({ export const Delta = Event.ephemeral({
type: "session.next.tool.input.delta", type: "tool.input.delta",
schema: { schema: {
...ToolBase, ...ToolBase,
delta: Schema.String, delta: Schema.String,
@@ -346,7 +342,7 @@ export namespace Tool {
export type Delta = typeof Delta.Type export type Delta = typeof Delta.Type
export const Ended = Event.durable({ export const Ended = Event.durable({
type: "session.next.tool.input.ended", type: "tool.input.ended",
...options, ...options,
schema: { schema: {
...ToolBase, ...ToolBase,
@@ -357,7 +353,7 @@ export namespace Tool {
} }
export const Called = Event.durable({ export const Called = Event.durable({
type: "session.next.tool.called", type: "tool.called",
...options, ...options,
schema: { schema: {
...ToolBase, ...ToolBase,
@@ -376,7 +372,7 @@ export namespace Tool {
* transitions or at a bounded cadence, not persist every stdout/stderr chunk. * transitions or at a bounded cadence, not persist every stdout/stderr chunk.
*/ */
export const Progress = Event.durable({ export const Progress = Event.durable({
type: "session.next.tool.progress", type: "tool.progress",
...options, ...options,
schema: { schema: {
...ToolBase, ...ToolBase,
@@ -387,7 +383,7 @@ export namespace Tool {
export type Progress = typeof Progress.Type export type Progress = typeof Progress.Type
export const Success = Event.durable({ export const Success = Event.durable({
type: "session.next.tool.success", type: "tool.success",
...options, ...options,
schema: { schema: {
...ToolBase, ...ToolBase,
@@ -404,7 +400,7 @@ export namespace Tool {
export type Success = typeof Success.Type export type Success = typeof Success.Type
export const Failed = Event.durable({ export const Failed = Event.durable({
type: "session.next.tool.failed", type: "tool.failed",
...options, ...options,
schema: { schema: {
...ToolBase, ...ToolBase,
@@ -427,12 +423,12 @@ export const RetryError = Schema.Struct({
responseBody: Schema.String.pipe(optional), responseBody: Schema.String.pipe(optional),
metadata: Schema.Record(Schema.String, Schema.String).pipe(optional), metadata: Schema.Record(Schema.String, Schema.String).pipe(optional),
}).annotate({ }).annotate({
identifier: "session.next.retry_error", identifier: "session.retry.error",
}) })
export interface RetryError extends Schema.Schema.Type<typeof RetryError> {} export interface RetryError extends Schema.Schema.Type<typeof RetryError> {}
export const Retried = Event.durable({ export const Retried = Event.durable({
type: "session.next.retried", type: "retried",
...options, ...options,
schema: { schema: {
...Base, ...Base,
@@ -444,32 +440,29 @@ export type Retried = typeof Retried.Type
export namespace Compaction { export namespace Compaction {
export const Started = Event.durable({ export const Started = Event.durable({
type: "session.next.compaction.started", type: "compaction.started",
...options, ...options,
schema: { schema: {
...Base, ...Base,
messageID: SessionMessage.ID,
reason: Schema.Union([Schema.Literal("auto"), Schema.Literal("manual")]), reason: Schema.Union([Schema.Literal("auto"), Schema.Literal("manual")]),
}, },
}) })
export type Started = typeof Started.Type export type Started = typeof Started.Type
export const Delta = Event.ephemeral({ export const Delta = Event.ephemeral({
type: "session.next.compaction.delta", type: "compaction.delta",
schema: { schema: {
...Base, ...Base,
messageID: SessionMessage.ID,
text: Schema.String, text: Schema.String,
}, },
}) })
export type Delta = typeof Delta.Type export type Delta = typeof Delta.Type
export const Ended = Event.durable({ export const Ended = Event.durable({
type: "session.next.compaction.ended", type: "compaction.ended",
...options, ...options,
schema: { schema: {
...Base, ...Base,
messageID: SessionMessage.ID,
reason: Started.data.fields.reason, reason: Started.data.fields.reason,
text: Schema.String, text: Schema.String,
recent: Schema.String, recent: Schema.String,
@@ -480,25 +473,25 @@ export namespace Compaction {
export namespace RevertEvent { export namespace RevertEvent {
export const Staged = Event.durable({ export const Staged = Event.durable({
type: "session.next.revert.staged", type: "revert.staged",
...options, ...options,
schema: { ...Base, revert: Revert.State }, schema: { ...Base, revert: Revert.State },
}) })
export const Cleared = Event.durable({ type: "session.next.revert.cleared", ...options, schema: Base }) export const Cleared = Event.durable({ type: "revert.cleared", ...options, schema: Base })
export const Committed = Event.durable({ export const Committed = Event.durable({
type: "session.next.revert.committed", type: "revert.committed",
...options, ...options,
schema: { ...Base, messageID: SessionMessage.ID }, schema: { ...Base, messageID: SessionMessage.ID },
}) })
} }
export const Definitions = Event.inventory( export const Definitions = Event.inventory(
AgentSwitched, AgentSelected,
ModelSwitched, ModelSelected,
Moved, Moved,
Renamed, Renamed,
Forked, Forked,
Prompted, PromptPromoted,
PromptAdmitted, PromptAdmitted,
ExecutionSettled, ExecutionSettled,
ContextUpdated, ContextUpdated,
+14 -10
View File
@@ -8,10 +8,14 @@ import { FileAttachment, Prompt } from "./prompt.js"
import { DateTimeUtcFromMillis, RelativePath, statics } from "./schema.js" import { DateTimeUtcFromMillis, RelativePath, statics } from "./schema.js"
import { SessionID } from "./session-id.js" import { SessionID } from "./session-id.js"
import { ascending } from "./identifier.js" import { ascending } from "./identifier.js"
import { Event } from "./event.js"
export const ID = Schema.String.check(Schema.isStartsWith("msg_")).pipe( export const ID = Schema.String.check(Schema.isStartsWith("msg_")).pipe(
Schema.brand("Session.Message.ID"), Schema.brand("Session.Message.ID"),
statics((schema) => ({ create: () => schema.make("msg_" + ascending()) })), statics((schema) => ({
create: () => schema.make("msg_" + ascending()),
fromEvent: (eventID: Event.ID) => schema.make(eventID.replace(/^evt_/, "msg_")),
})),
) )
export type ID = typeof ID.Type export type ID = typeof ID.Type
@@ -27,19 +31,19 @@ const Base = {
time: Schema.Struct({ created: DateTimeUtcFromMillis }), time: Schema.Struct({ created: DateTimeUtcFromMillis }),
} }
export interface AgentSwitched extends Schema.Schema.Type<typeof AgentSwitched> {} export interface AgentSelected extends Schema.Schema.Type<typeof AgentSelected> {}
export const AgentSwitched = Schema.Struct({ export const AgentSelected = Schema.Struct({
...Base, ...Base,
type: Schema.Literal("agent-switched"), type: Schema.Literal("agent-switched"),
agent: Schema.String, agent: Schema.String,
}).annotate({ identifier: "Session.Message.AgentSwitched" }) }).annotate({ identifier: "Session.Message.AgentSelected" })
export interface ModelSwitched extends Schema.Schema.Type<typeof ModelSwitched> {} export interface ModelSelected extends Schema.Schema.Type<typeof ModelSelected> {}
export const ModelSwitched = Schema.Struct({ export const ModelSelected = Schema.Struct({
...Base, ...Base,
type: Schema.Literal("model-switched"), type: Schema.Literal("model-switched"),
model: Model.Ref, model: Model.Ref,
}).annotate({ identifier: "Session.Message.ModelSwitched" }) }).annotate({ identifier: "Session.Message.ModelSelected" })
export interface User extends Schema.Schema.Type<typeof User> {} export interface User extends Schema.Schema.Type<typeof User> {}
export const User = Schema.Struct({ export const User = Schema.Struct({
@@ -207,8 +211,8 @@ export const Compaction = Schema.Struct({
}).annotate({ identifier: "Session.Message.Compaction" }) }).annotate({ identifier: "Session.Message.Compaction" })
export const Message = Schema.Union([ export const Message = Schema.Union([
AgentSwitched, AgentSelected,
ModelSwitched, ModelSelected,
User, User,
Synthetic, Synthetic,
System, System,
@@ -219,5 +223,5 @@ export const Message = Schema.Union([
]) ])
.pipe(Schema.toTaggedUnion("type")) .pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "Session.Message" }) .annotate({ identifier: "Session.Message" })
export type Message = AgentSwitched | ModelSwitched | User | Synthetic | System | Skill | Shell | Assistant | Compaction export type Message = AgentSelected | ModelSelected | User | Synthetic | System | Skill | Shell | Assistant | Compaction
export type Type = Message["type"] export type Type = Message["type"]
+34 -34
View File
@@ -52,7 +52,7 @@ describe("public event manifest", () => {
expect(Session.Event.Definitions).toBe(SessionEvent.Definitions) expect(Session.Event.Definitions).toBe(SessionEvent.Definitions)
expect(Workspace.Event).toBe(WorkspaceEvent) expect(Workspace.Event).toBe(WorkspaceEvent)
expect(Workspace.Event.Definitions).toBe(WorkspaceEvent.Definitions) expect(Workspace.Event.Definitions).toBe(WorkspaceEvent.Definitions)
expect(EventManifest.Latest.get("session.next.step.ended")).toBe(SessionEvent.Step.Ended) expect(EventManifest.Latest.get("step.ended")).toBe(SessionEvent.Step.Ended)
expect(EventManifest.Latest.get("todo.updated")).toBe(SessionTodo.Event.Updated) expect(EventManifest.Latest.get("todo.updated")).toBe(SessionTodo.Event.Updated)
expect(EventManifest.Latest.get("agent.updated")).toBe(Agent.Event.Updated) expect(EventManifest.Latest.get("agent.updated")).toBe(Agent.Event.Updated)
expect(EventManifest.Latest.get("project.updated")).toBe(Project.Event.Updated) expect(EventManifest.Latest.get("project.updated")).toBe(Project.Event.Updated)
@@ -71,8 +71,8 @@ describe("public event manifest", () => {
SessionV1.Event.Diff, SessionV1.Event.Diff,
SessionV1.Event.Error, SessionV1.Event.Error,
]) ])
expect(EventManifest.Durable.has("session.next.step.ended.1")).toBe(false) expect(EventManifest.Durable.get("step.ended.1")).toBe(SessionEvent.Step.Ended)
expect(EventManifest.Durable.get("session.next.step.ended.2")).toBe(SessionEvent.Step.Ended) expect(EventManifest.Durable.has("step.ended.2")).toBe(false)
}) })
test("derives durable definitions from explicit definition durability", () => { test("derives durable definitions from explicit definition durability", () => {
@@ -85,37 +85,37 @@ describe("public event manifest", () => {
"message.removed.1", "message.removed.1",
"message.part.updated.1", "message.part.updated.1",
"message.part.removed.1", "message.part.removed.1",
"session.next.agent.switched.1", "agent.selected.1",
"session.next.model.switched.1", "model.selected.1",
"session.next.moved.1", "session.moved.1",
"session.next.renamed.1", "renamed.1",
"session.next.forked.1", "forked.1",
"session.next.prompted.1", "prompt.promoted.1",
"session.next.prompt.admitted.1", "prompt.admitted.1",
"session.next.context.updated.1", "session.context.updated.1",
"session.next.synthetic.1", "synthetic.1",
"session.next.skill.activated.1", "skill.activated.1",
"session.next.shell.started.1", "shell.started.1",
"session.next.shell.ended.1", "shell.ended.1",
"session.next.step.started.1", "step.started.1",
"session.next.step.ended.2", "step.ended.1",
"session.next.step.failed.2", "step.failed.1",
"session.next.text.started.1", "text.started.1",
"session.next.text.ended.1", "text.ended.1",
"session.next.tool.input.started.1", "tool.input.started.1",
"session.next.tool.input.ended.1", "tool.input.ended.1",
"session.next.tool.called.1", "tool.called.1",
"session.next.tool.progress.1", "tool.progress.1",
"session.next.tool.success.1", "tool.success.1",
"session.next.tool.failed.1", "tool.failed.1",
"session.next.reasoning.started.1", "reasoning.started.1",
"session.next.reasoning.ended.1", "reasoning.ended.1",
"session.next.retried.1", "retried.1",
"session.next.compaction.started.1", "compaction.started.1",
"session.next.compaction.ended.1", "compaction.ended.1",
"session.next.revert.staged.1", "revert.staged.1",
"session.next.revert.cleared.1", "revert.cleared.1",
"session.next.revert.committed.1", "revert.committed.1",
].toSorted(), ].toSorted(),
) )
expect(SessionEvent.DurableDefinitions).toEqual( expect(SessionEvent.DurableDefinitions).toEqual(
+6 -6
View File
@@ -78,7 +78,7 @@ it.live(
prompt: fixture.sdk.Prompt.make({ text: "Promote this input" }), prompt: fixture.sdk.Prompt.make({ text: "Promote this input" }),
}) })
const prompted = yield* opencode.sessions.log({ sessionID: id, follow: true }).pipe( const prompted = yield* opencode.sessions.log({ sessionID: id, follow: true }).pipe(
Stream.filter((event) => event.type === "session.next.prompted" && event.data.messageID === wake.id), Stream.filter((event) => event.type === "prompt.promoted" && event.data.inputID === wake.id),
Stream.runHead, Stream.runHead,
Effect.timeout("10 seconds"), Effect.timeout("10 seconds"),
Effect.map(Option.getOrThrow), Effect.map(Option.getOrThrow),
@@ -119,7 +119,7 @@ it.live(
expect(page.data.some((session) => session.id === id)).toBe(true) expect(page.data.some((session) => session.id === id)).toBe(true)
expect(active).toEqual({ data: {}, watermarks: {} }) expect(active).toEqual({ data: {}, watermarks: {} })
expect(admitted.sessionID).toBe(id) expect(admitted.sessionID).toBe(id)
expect(prompted.type).toBe("session.next.prompted") expect(prompted.type).toBe("prompt.promoted")
expect(wakeContext).toContainEqual(expect.objectContaining({ id: wake.id, type: "user" })) expect(wakeContext).toContainEqual(expect.objectContaining({ id: wake.id, type: "user" }))
expect(contextEntries).toEqual([ expect(contextEntries).toEqual([
{ key: "deploy-target", value: "production" }, { key: "deploy-target", value: "production" },
@@ -127,7 +127,7 @@ it.live(
]) ])
expect(remainingContextEntries).toEqual([{ key: "deploy-target", value: "production" }]) expect(remainingContextEntries).toEqual([{ key: "deploy-target", value: "production" }])
expect(context.some((message) => message.type === "model-switched")).toBe(true) expect(context.some((message) => message.type === "model-switched")).toBe(true)
expect(event).toMatchObject({ type: "session.next.model.switched", durable: { seq: 1 } }) expect(event).toMatchObject({ type: "model.selected", durable: { seq: 1 } })
expect(message).toEqual(modelMessage) expect(message).toEqual(modelMessage)
expect(missing.map((error) => error._tag)).toEqual([ expect(missing.map((error) => error._tag)).toEqual([
"SessionNotFoundError", "SessionNotFoundError",
@@ -149,13 +149,13 @@ it.live(
const opencode = yield* fixture.sdk.OpenCode.create() const opencode = yield* fixture.sdk.OpenCode.create()
const id = sessionID(fixture) const id = sessionID(fixture)
const connected = yield* Latch.make(false) const connected = yield* Latch.make(false)
const prompted = yield* Deferred.make<Extract<OpenCodeEvent, { type: "session.next.prompted" }>>() const prompted = yield* Deferred.make<Extract<OpenCodeEvent, { type: "prompt.promoted" }>>()
yield* opencode.events.subscribe().pipe( yield* opencode.events.subscribe().pipe(
Stream.runForEach((event) => Stream.runForEach((event) =>
event.type === "server.connected" event.type === "server.connected"
? connected.open ? connected.open
: event.type === "session.next.prompted" && event.data.sessionID === id : event.type === "prompt.promoted" && event.data.sessionID === id
? Deferred.succeed(prompted, event).pipe(Effect.asVoid) ? Deferred.succeed(prompted, event).pipe(Effect.asVoid)
: Effect.void, : Effect.void,
), ),
@@ -191,7 +191,7 @@ it.live(
Stream.runForEach((notification: OpenCodeEvent) => Stream.runForEach((notification: OpenCodeEvent) =>
notification.type === "server.connected" notification.type === "server.connected"
? ready.open ? ready.open
: notification.type === "session.next.agent.switched" && notification.data.sessionID === id : notification.type === "agent.selected" && notification.data.sessionID === id
? event.open ? event.open
: Effect.void, : Effect.void,
) )
+12 -2
View File
@@ -59,7 +59,13 @@ if (schemas) {
} }
visit({ ...document, components: { ...document.components, schemas: undefined } }) visit({ ...document, components: { ...document.components, schemas: undefined } })
for (const name of Object.keys(schemas)) { for (const name of Object.keys(schemas)) {
if (/^SessionNext\w+1$/.test(name) && !reachable.has(name)) delete schemas[name] if (
/^(AgentSelected|ModelSelected|SessionMoved|Renamed|Forked|PromptPromoted|PromptAdmitted|ExecutionSettled|ContextUpdated|Synthetic|SkillActivated|ShellStarted|ShellEnded|StepStarted|StepEnded|StepFailed|TextStarted|TextDelta|TextEnded|ReasoningStarted|ReasoningDelta|ReasoningEnded|ToolInputStarted|ToolInputDelta|ToolInputEnded|ToolCalled|ToolProgress|ToolSuccess|ToolFailed|Retried|CompactionStarted|CompactionDelta|CompactionEnded|RevertStaged|RevertCleared|RevertCommitted)1$/.test(
name,
) &&
!reachable.has(name)
)
delete schemas[name]
} }
await Bun.write("./openapi.json", JSON.stringify(document)) await Bun.write("./openapi.json", JSON.stringify(document))
} }
@@ -93,7 +99,11 @@ await createClient({
const generatedTypesPath = "./src/v2/gen/types.gen.ts" const generatedTypesPath = "./src/v2/gen/types.gen.ts"
const generatedTypes = await Bun.file(generatedTypesPath).text() const generatedTypes = await Bun.file(generatedTypesPath).text()
if (/export type SessionNext\w+1 =/.test(generatedTypes)) { if (
/export type (AgentSelected|ModelSelected|SessionMoved|Renamed|Forked|PromptPromoted|PromptAdmitted|ExecutionSettled|ContextUpdated|Synthetic|SkillActivated|ShellStarted|ShellEnded|StepStarted|StepEnded|StepFailed|TextStarted|TextDelta|TextEnded|ReasoningStarted|ReasoningDelta|ReasoningEnded|ToolInputStarted|ToolInputDelta|ToolInputEnded|ToolCalled|ToolProgress|ToolSuccess|ToolFailed|Retried|CompactionStarted|CompactionDelta|CompactionEnded|RevertStaged|RevertCleared|RevertCommitted)1 =/.test(
generatedTypes,
)
) {
throw new Error("Session history generated duplicate Session event variants") throw new Error("Session history generated duplicate Session event variants")
} }
const logTypesPatched = generatedTypes.replace( const logTypesPatched = generatedTypes.replace(
File diff suppressed because it is too large Load Diff
+77 -83
View File
@@ -27,6 +27,8 @@ import { createSignal, onCleanup } from "solid-js"
export type DataSessionStatus = "idle" | "running" export type DataSessionStatus = "idle" | "running"
const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_")
type LocationData = { type LocationData = {
agent?: AgentV2Info[] agent?: AgentV2Info[]
command?: CommandV2Info[] command?: CommandV2Info[]
@@ -181,17 +183,21 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
const info = store.session.info[sessionID] const info = store.session.info[sessionID]
if (!info) return if (!info) return
const rootID = resolveRoot(sessionID) const rootID = resolveRoot(sessionID)
setStore("session", "family", produce((draft) => { setStore(
if (sessionID !== rootID && draft[sessionID]) { "session",
const members = draft[rootID] ??= [] "family",
for (const id of draft[sessionID]) { produce((draft) => {
if (!members.includes(id)) members.push(id) if (sessionID !== rootID && draft[sessionID]) {
const members = (draft[rootID] ??= [])
for (const id of draft[sessionID]) {
if (!members.includes(id)) members.push(id)
}
delete draft[sessionID]
} }
delete draft[sessionID] const family = (draft[rootID] ??= [])
} if (!family.includes(sessionID)) family.push(sessionID)
const family = draft[rootID] ??= [] }),
if (!family.includes(sessionID)) family.push(sessionID) )
}))
} }
function handleEvent(event: V2Event) { function handleEvent(event: V2Event) {
@@ -214,124 +220,113 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
case "skill.updated": case "skill.updated":
void result.location.skill.refresh(event.location) void result.location.skill.refresh(event.location)
break break
case "session.next.agent.switched": case "agent.selected":
if (store.session.info[event.data.sessionID]) if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "agent", event.data.agent) setStore("session", "info", event.data.sessionID, "agent", event.data.agent)
message.update(event.data.sessionID, (draft, index) => { message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, { message.append(draft, index, {
id: event.data.messageID, id: messageIDFromEvent(event.id),
type: "agent-switched", type: "agent-switched",
agent: event.data.agent, agent: event.data.agent,
time: { created: event.data.timestamp }, time: { created: event.created },
}) })
}) })
break break
case "session.next.model.switched": case "model.selected":
if (store.session.info[event.data.sessionID]) if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "model", event.data.model) setStore("session", "info", event.data.sessionID, "model", event.data.model)
message.update(event.data.sessionID, (draft, index) => { message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, { message.append(draft, index, {
id: event.data.messageID, id: messageIDFromEvent(event.id),
type: "model-switched", type: "model-switched",
model: event.data.model, model: event.data.model,
time: { created: event.data.timestamp }, time: { created: event.created },
}) })
}) })
break break
case "session.next.renamed": case "renamed":
if (store.session.info[event.data.sessionID]) if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "title", event.data.title) setStore("session", "info", event.data.sessionID, "title", event.data.title)
break break
case "session.next.prompted": { case "prompt.promoted": {
setStore("session", "status", event.data.sessionID, "running") setStore("session", "status", event.data.sessionID, "running")
message.update(event.data.sessionID, (draft, index) => { message.update(event.data.sessionID, (draft, index) => {
const position = index.get(event.data.messageID) const position = index.get(event.data.inputID)
const existing = position === undefined ? undefined : draft[position] const existing = position === undefined ? undefined : draft[position]
if (existing?.type === "user") { if (existing?.type === "user") {
existing.text = event.data.prompt.text existing.time.created = event.created
existing.files = event.data.prompt.files
existing.agents = event.data.prompt.agents
existing.time.created = event.data.timestamp
if (existing.metadata?.queued === true) { if (existing.metadata?.queued === true) {
delete existing.metadata.queued delete existing.metadata.queued
if (Object.keys(existing.metadata).length === 0) existing.metadata = undefined if (Object.keys(existing.metadata).length === 0) existing.metadata = undefined
} }
return return
} }
message.append(draft, index, {
id: event.data.messageID,
type: "user",
text: event.data.prompt.text,
files: event.data.prompt.files,
agents: event.data.prompt.agents,
time: { created: event.data.timestamp },
})
}) })
break break
} }
case "session.next.prompt.admitted": case "prompt.admitted":
message.update(event.data.sessionID, (draft, index) => { message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, { message.append(draft, index, {
id: event.data.messageID, id: event.data.inputID,
type: "user", type: "user",
text: event.data.prompt.text, text: event.data.prompt.text,
files: event.data.prompt.files, files: event.data.prompt.files,
agents: event.data.prompt.agents, agents: event.data.prompt.agents,
metadata: { queued: true }, metadata: { queued: true },
time: { created: event.data.timestamp }, time: { created: event.created },
}) })
}) })
break break
case "session.next.context.updated": case "session.context.updated":
message.update(event.data.sessionID, (draft, index) => { message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, { message.append(draft, index, {
id: event.data.messageID, id: messageIDFromEvent(event.id),
type: "system", type: "system",
text: event.data.text, text: event.data.text,
time: { created: event.data.timestamp }, time: { created: event.created },
}) })
}) })
break break
case "session.next.synthetic": case "synthetic":
message.update(event.data.sessionID, (draft, index) => { message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, { message.append(draft, index, {
id: event.data.messageID, id: messageIDFromEvent(event.id),
type: "synthetic", type: "synthetic",
sessionID: event.data.sessionID, sessionID: event.data.sessionID,
text: event.data.text, text: event.data.text,
description: event.data.description, description: event.data.description,
time: { created: event.data.timestamp }, time: { created: event.created },
}) })
}) })
break break
case "session.next.shell.started": case "shell.started":
setStore("session", "status", event.data.sessionID, "running") setStore("session", "status", event.data.sessionID, "running")
message.update(event.data.sessionID, (draft, index) => { message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, { message.append(draft, index, {
id: event.data.messageID, id: messageIDFromEvent(event.id),
type: "shell", type: "shell",
callID: event.data.callID, callID: event.data.callID,
command: event.data.command, command: event.data.command,
output: "", output: "",
time: { created: event.data.timestamp }, time: { created: event.created },
}) })
}) })
break break
case "session.next.shell.ended": case "shell.ended":
setStore("session", "status", event.data.sessionID, "idle") setStore("session", "status", event.data.sessionID, "idle")
message.update(event.data.sessionID, (draft, index) => { message.update(event.data.sessionID, (draft, index) => {
const match = message.activeShell(draft, event.data.callID) const match = message.activeShell(draft, event.data.callID)
if (!match) return if (!match) return
match.output = event.data.output match.output = event.data.output
match.time.completed = event.data.timestamp match.time.completed = event.created
}) })
break break
case "session.next.step.started": case "step.started":
setStore("session", "status", event.data.sessionID, "running") setStore("session", "status", event.data.sessionID, "running")
message.update(event.data.sessionID, (draft, index) => { message.update(event.data.sessionID, (draft, index) => {
if (index.has(event.data.assistantMessageID)) return if (index.has(event.data.assistantMessageID)) return
const currentAssistant = message.activeAssistant(draft) const currentAssistant = message.activeAssistant(draft)
if (currentAssistant) currentAssistant.time.completed = event.data.timestamp if (currentAssistant) currentAssistant.time.completed = event.created
message.append(draft, index, { message.append(draft, index, {
id: event.data.assistantMessageID, id: event.data.assistantMessageID,
type: "assistant", type: "assistant",
@@ -339,16 +334,16 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
model: event.data.model, model: event.data.model,
content: [], content: [],
snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined, snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined,
time: { created: event.data.timestamp }, time: { created: event.created },
}) })
}) })
break break
case "session.next.step.ended": case "step.ended":
setStore("session", "status", event.data.sessionID, "running") setStore("session", "status", event.data.sessionID, "running")
message.update(event.data.sessionID, (draft, index) => { message.update(event.data.sessionID, (draft, index) => {
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID) const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
if (!currentAssistant) return if (!currentAssistant) return
currentAssistant.time.completed = event.data.timestamp currentAssistant.time.completed = event.created
currentAssistant.finish = event.data.finish currentAssistant.finish = event.data.finish
currentAssistant.cost = event.data.cost currentAssistant.cost = event.data.cost
currentAssistant.tokens = event.data.tokens currentAssistant.tokens = event.data.tokens
@@ -356,16 +351,16 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.data.snapshot } currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.data.snapshot }
}) })
break break
case "session.next.step.failed": case "step.failed":
message.update(event.data.sessionID, (draft, index) => { message.update(event.data.sessionID, (draft, index) => {
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID) const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
if (!currentAssistant) return if (!currentAssistant) return
currentAssistant.time.completed = event.data.timestamp currentAssistant.time.completed = event.created
currentAssistant.finish = "error" currentAssistant.finish = "error"
currentAssistant.error = event.data.error currentAssistant.error = event.data.error
}) })
break break
case "session.next.text.started": case "text.started":
message.update(event.data.sessionID, (draft, index) => { message.update(event.data.sessionID, (draft, index) => {
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({ message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
type: "text", type: "text",
@@ -374,7 +369,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
}) })
}) })
break break
case "session.next.text.delta": case "text.delta":
message.update(event.data.sessionID, (draft, index) => { message.update(event.data.sessionID, (draft, index) => {
const match = message.latestText( const match = message.latestText(
message.assistant(draft, index, event.data.assistantMessageID), message.assistant(draft, index, event.data.assistantMessageID),
@@ -383,7 +378,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
if (match) match.text += event.data.delta if (match) match.text += event.data.delta
}) })
break break
case "session.next.text.ended": case "text.ended":
message.update(event.data.sessionID, (draft, index) => { message.update(event.data.sessionID, (draft, index) => {
const match = message.latestText( const match = message.latestText(
message.assistant(draft, index, event.data.assistantMessageID), message.assistant(draft, index, event.data.assistantMessageID),
@@ -392,18 +387,18 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
if (match) match.text = event.data.text if (match) match.text = event.data.text
}) })
break break
case "session.next.tool.input.started": case "tool.input.started":
message.update(event.data.sessionID, (draft, index) => { message.update(event.data.sessionID, (draft, index) => {
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({ message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
type: "tool", type: "tool",
id: event.data.callID, id: event.data.callID,
name: event.data.name, name: event.data.name,
time: { created: event.data.timestamp }, time: { created: event.created },
state: { status: "pending", input: "" }, state: { status: "pending", input: "" },
}) })
}) })
break break
case "session.next.tool.input.delta": case "tool.input.delta":
message.update(event.data.sessionID, (draft, index) => { message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool( const match = message.latestTool(
message.assistant(draft, index, event.data.assistantMessageID), message.assistant(draft, index, event.data.assistantMessageID),
@@ -412,7 +407,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
if (match?.state.status === "pending") match.state.input += event.data.delta if (match?.state.status === "pending") match.state.input += event.data.delta
}) })
break break
case "session.next.tool.input.ended": case "tool.input.ended":
message.update(event.data.sessionID, (draft, index) => { message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool( const match = message.latestTool(
message.assistant(draft, index, event.data.assistantMessageID), message.assistant(draft, index, event.data.assistantMessageID),
@@ -421,19 +416,19 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
if (match?.state.status === "pending") match.state.input = event.data.text if (match?.state.status === "pending") match.state.input = event.data.text
}) })
break break
case "session.next.tool.called": case "tool.called":
message.update(event.data.sessionID, (draft, index) => { message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool( const match = message.latestTool(
message.assistant(draft, index, event.data.assistantMessageID), message.assistant(draft, index, event.data.assistantMessageID),
event.data.callID, event.data.callID,
) )
if (!match) return if (!match) return
match.time.ran = event.data.timestamp match.time.ran = event.created
match.provider = event.data.provider match.provider = event.data.provider
match.state = { status: "running", input: event.data.input, structured: {}, content: [] } match.state = { status: "running", input: event.data.input, structured: {}, content: [] }
}) })
break break
case "session.next.tool.progress": case "tool.progress":
message.update(event.data.sessionID, (draft, index) => { message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool( const match = message.latestTool(
message.assistant(draft, index, event.data.assistantMessageID), message.assistant(draft, index, event.data.assistantMessageID),
@@ -444,7 +439,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
match.state.content = [...event.data.content] match.state.content = [...event.data.content]
}) })
break break
case "session.next.tool.success": case "tool.success":
message.update(event.data.sessionID, (draft, index) => { message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool( const match = message.latestTool(
message.assistant(draft, index, event.data.assistantMessageID), message.assistant(draft, index, event.data.assistantMessageID),
@@ -463,10 +458,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
metadata: match.provider?.metadata, metadata: match.provider?.metadata,
resultMetadata: event.data.provider.metadata, resultMetadata: event.data.provider.metadata,
} }
match.time.completed = event.data.timestamp match.time.completed = event.created
}) })
break break
case "session.next.tool.failed": case "tool.failed":
message.update(event.data.sessionID, (draft, index) => { message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool( const match = message.latestTool(
message.assistant(draft, index, event.data.assistantMessageID), message.assistant(draft, index, event.data.assistantMessageID),
@@ -486,21 +481,21 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
metadata: match.provider?.metadata, metadata: match.provider?.metadata,
resultMetadata: event.data.provider.metadata, resultMetadata: event.data.provider.metadata,
} }
match.time.completed = event.data.timestamp match.time.completed = event.created
}) })
break break
case "session.next.reasoning.started": case "reasoning.started":
message.update(event.data.sessionID, (draft, index) => { message.update(event.data.sessionID, (draft, index) => {
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({ message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
type: "reasoning", type: "reasoning",
id: event.data.reasoningID, id: event.data.reasoningID,
text: "", text: "",
providerMetadata: event.data.providerMetadata, providerMetadata: event.data.providerMetadata,
time: { created: event.data.timestamp }, time: { created: event.created },
}) })
}) })
break break
case "session.next.reasoning.delta": case "reasoning.delta":
message.update(event.data.sessionID, (draft, index) => { message.update(event.data.sessionID, (draft, index) => {
const match = message.latestReasoning( const match = message.latestReasoning(
message.assistant(draft, index, event.data.assistantMessageID), message.assistant(draft, index, event.data.assistantMessageID),
@@ -509,7 +504,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
if (match) match.text += event.data.delta if (match) match.text += event.data.delta
}) })
break break
case "session.next.reasoning.ended": case "reasoning.ended":
message.update(event.data.sessionID, (draft, index) => { message.update(event.data.sessionID, (draft, index) => {
const match = message.latestReasoning( const match = message.latestReasoning(
message.assistant(draft, index, event.data.assistantMessageID), message.assistant(draft, index, event.data.assistantMessageID),
@@ -517,38 +512,38 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
) )
if (match) { if (match) {
match.text = event.data.text match.text = event.data.text
match.time = { created: match.time?.created ?? event.data.timestamp, completed: event.data.timestamp } match.time = { created: match.time?.created ?? event.created, completed: event.created }
if (event.data.providerMetadata !== undefined) match.providerMetadata = event.data.providerMetadata if (event.data.providerMetadata !== undefined) match.providerMetadata = event.data.providerMetadata
} }
}) })
break break
case "session.next.retried": case "retried":
case "session.next.compaction.started": case "compaction.started":
setStore("session", "status", event.data.sessionID, "running") setStore("session", "status", event.data.sessionID, "running")
break break
case "session.next.execution.settled": case "execution.settled":
setStore("session", "status", event.data.sessionID, "idle") setStore("session", "status", event.data.sessionID, "idle")
break break
case "session.next.revert.staged": case "revert.staged":
if (store.session.info[event.data.sessionID]) if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "revert", event.data.revert) setStore("session", "info", event.data.sessionID, "revert", event.data.revert)
break break
case "session.next.revert.cleared": case "revert.cleared":
case "session.next.revert.committed": case "revert.committed":
if (store.session.info[event.data.sessionID]) if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "revert", undefined) setStore("session", "info", event.data.sessionID, "revert", undefined)
break break
case "session.next.compaction.delta": case "compaction.delta":
break break
case "session.next.compaction.ended": case "compaction.ended":
message.update(event.data.sessionID, (draft, index) => { message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, { message.append(draft, index, {
id: event.data.messageID, id: messageIDFromEvent(event.id),
type: "compaction", type: "compaction",
reason: event.data.reason, reason: event.data.reason,
summary: event.data.text, summary: event.data.text,
recent: event.data.recent, recent: event.data.recent,
time: { created: event.data.timestamp }, time: { created: event.created },
}) })
}) })
break break
@@ -692,8 +687,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return liveByID.get(message.id) ?? message return liveByID.get(message.id) ?? message
}), }),
...live.filter((message) => !loadedIDs.has(message.id)), ...live.filter((message) => !loadedIDs.has(message.id)),
] ].toSorted((a, b) => a.time.created - b.time.created)
.toSorted((a, b) => a.time.created - b.time.created)
messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index]))) messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index])))
setStore("session", "message", sessionID, messages) setStore("session", "message", sessionID, messages)
}, },
@@ -74,17 +74,17 @@ const tui: TuiPlugin = async (api) => {
notify(api, sessionID, "Session done", session?.parentID ? "subagent_done" : "done") notify(api, sessionID, "Session done", session?.parentID ? "subagent_done" : "done")
} }
api.event.on("session.next.prompted", (event) => started(event.data.sessionID)) api.event.on("prompt.promoted", (event) => started(event.data.sessionID))
api.event.on("session.next.shell.started", (event) => started(event.data.sessionID)) api.event.on("shell.started", (event) => started(event.data.sessionID))
api.event.on("session.next.step.started", (event) => started(event.data.sessionID)) api.event.on("step.started", (event) => started(event.data.sessionID))
api.event.on("session.next.retried", (event) => started(event.data.sessionID)) api.event.on("retried", (event) => started(event.data.sessionID))
api.event.on("session.next.compaction.started", (event) => started(event.data.sessionID)) api.event.on("compaction.started", (event) => started(event.data.sessionID))
api.event.on("session.next.shell.ended", (event) => ended(event.data.sessionID)) api.event.on("shell.ended", (event) => ended(event.data.sessionID))
api.event.on("session.next.step.ended", (event) => { api.event.on("step.ended", (event) => {
if (event.data.finish === "tool-calls") return if (event.data.finish === "tool-calls") return
ended(event.data.sessionID) ended(event.data.sessionID)
}) })
api.event.on("session.next.step.failed", (event) => { api.event.on("step.failed", (event) => {
const sessionID = event.data.sessionID const sessionID = event.data.sessionID
if (!active.has(sessionID)) return if (!active.has(sessionID)) return
errored.add(sessionID) errored.add(sessionID)
+23 -21
View File
@@ -126,45 +126,49 @@ export function createSessionRows(sessionID: Accessor<string>) {
return index === -1 ? rows.length : index return index === -1 ? rows.length : index
} }
const message = (event: { data: { sessionID: string; messageID: string } }) => { const message = (event: { id: string; data: { sessionID: string } }) => {
if (event.data.sessionID === sessionID()) appendMessage(event.data.messageID) if (event.data.sessionID === sessionID()) appendMessage(event.id.replace(/^evt_/, "msg_"))
}
const input = (event: { data: { sessionID: string; inputID: string } }) => {
if (event.data.sessionID === sessionID()) appendMessage(event.data.inputID)
} }
const subscriptions = [ const subscriptions = [
data.on("session.next.prompt.admitted", message), data.on("prompt.admitted", input),
data.on("session.next.prompted", message), data.on("prompt.promoted", input),
data.on("session.next.context.updated", message), data.on("session.context.updated", message),
data.on("session.next.synthetic", (event) => { data.on("synthetic", (event) => {
if (event.data.sessionID === sessionID() && event.data.description?.trim()) appendMessage(event.data.messageID) if (event.data.sessionID === sessionID() && event.data.description?.trim())
appendMessage(event.id.replace(/^evt_/, "msg_"))
}), }),
data.on("session.next.shell.started", message), data.on("shell.started", message),
data.on("session.next.agent.switched", message), data.on("agent.selected", message),
data.on("session.next.model.switched", message), data.on("model.selected", message),
data.on("session.next.compaction.ended", message), data.on("compaction.ended", message),
data.on("session.next.text.delta", (event) => { data.on("text.delta", (event) => {
if (event.data.sessionID === sessionID()) if (event.data.sessionID === sessionID())
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.textID }) appendPart({ messageID: event.data.assistantMessageID, partID: event.data.textID })
}), }),
data.on("session.next.text.ended", (event) => { data.on("text.ended", (event) => {
if (event.data.sessionID === sessionID() && event.data.text.trim()) if (event.data.sessionID === sessionID() && event.data.text.trim())
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.textID }) appendPart({ messageID: event.data.assistantMessageID, partID: event.data.textID })
}), }),
data.on("session.next.reasoning.delta", (event) => { data.on("reasoning.delta", (event) => {
if (event.data.sessionID === sessionID()) if (event.data.sessionID === sessionID())
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.reasoningID }) appendPart({ messageID: event.data.assistantMessageID, partID: event.data.reasoningID })
}), }),
data.on("session.next.reasoning.ended", (event) => { data.on("reasoning.ended", (event) => {
if (event.data.sessionID === sessionID() && event.data.text.trim()) if (event.data.sessionID === sessionID() && event.data.text.trim())
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.reasoningID }) appendPart({ messageID: event.data.assistantMessageID, partID: event.data.reasoningID })
}), }),
data.on("session.next.tool.input.started", (event) => { data.on("tool.input.started", (event) => {
if (event.data.sessionID === sessionID()) if (event.data.sessionID === sessionID())
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.callID }, event.data.name) appendPart({ messageID: event.data.assistantMessageID, partID: event.data.callID }, event.data.name)
}), }),
data.on("session.next.step.ended", (event) => { data.on("step.ended", (event) => {
if (event.data.sessionID !== sessionID() || ["tool-calls", "unknown"].includes(event.data.finish)) return if (event.data.sessionID !== sessionID() || ["tool-calls", "unknown"].includes(event.data.finish)) return
appendFooter(event.data.assistantMessageID) appendFooter(event.data.assistantMessageID)
}), }),
data.on("session.next.step.failed", (event) => { data.on("step.failed", (event) => {
if (event.data.sessionID === sessionID()) appendFooter(event.data.assistantMessageID) if (event.data.sessionID === sessionID()) appendFooter(event.data.assistantMessageID)
}), }),
] ]
@@ -229,8 +233,6 @@ function hasPart(rows: SessionRow[], ref: PartRef) {
return rows.some((row) => { return rows.some((row) => {
if (row.type === "part") return row.ref.messageID === ref.messageID && row.ref.partID === ref.partID if (row.type === "part") return row.ref.messageID === ref.messageID && row.ref.partID === ref.partID
if (row.type !== "group") return false if (row.type !== "group") return false
return [...row.refs, ...row.pending].some( return [...row.refs, ...row.pending].some((item) => item.messageID === ref.messageID && item.partID === ref.partID)
(item) => item.messageID === ref.messageID && item.partID === ref.partID,
)
}) })
} }
@@ -90,12 +90,12 @@ function durable(sessionID: string) {
function stepStarted(id: string, sessionID = "session"): V2Event { function stepStarted(id: string, sessionID = "session"): V2Event {
return { return {
id, id,
type: "session.next.step.started", created: 0,
type: "step.started",
durable: durable(sessionID), durable: durable(sessionID),
data: { data: {
sessionID, sessionID,
assistantMessageID: `msg_${id}`, assistantMessageID: `msg_${id}`,
timestamp: 0,
agent: "build", agent: "build",
model: { id: "model", providerID: "provider" }, model: { id: "model", providerID: "provider" },
}, },
@@ -105,12 +105,12 @@ function stepStarted(id: string, sessionID = "session"): V2Event {
function stepEnded(id: string, sessionID = "session", finish = "stop"): V2Event { function stepEnded(id: string, sessionID = "session", finish = "stop"): V2Event {
return { return {
id, id,
type: "session.next.step.ended", created: 0,
type: "step.ended",
durable: durable(sessionID), durable: durable(sessionID),
data: { data: {
sessionID, sessionID,
assistantMessageID: `msg_${id}`, assistantMessageID: `msg_${id}`,
timestamp: 0,
finish, finish,
cost: 0, cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
@@ -121,12 +121,12 @@ function stepEnded(id: string, sessionID = "session", finish = "stop"): V2Event
function stepFailed(id: string, sessionID = "session"): V2Event { function stepFailed(id: string, sessionID = "session"): V2Event {
return { return {
id, id,
type: "session.next.step.failed", created: 0,
type: "step.failed",
durable: durable(sessionID), durable: durable(sessionID),
data: { data: {
sessionID, sessionID,
assistantMessageID: `msg_${id}`, assistantMessageID: `msg_${id}`,
timestamp: 0,
error: { type: "unknown", message: "boom" }, error: { type: "unknown", message: "boom" },
}, },
} }
@@ -150,8 +150,8 @@ describe("internal notifications TUI plugin", () => {
test("notifies for question and permission requests with blurred notifications and always-on sounds", async () => { test("notifies for question and permission requests with blurred notifications and always-on sounds", async () => {
const harness = await setup() const harness = await setup()
harness.emit({ id: "event-1", type: "question.asked", data: question("question-1") }) harness.emit({ id: "event-1", created: 0, type: "question.asked", data: question("question-1") })
harness.emit({ id: "event-2", type: "permission.asked", data: permission("permission-1") }) harness.emit({ id: "event-2", created: 0, type: "permission.asked", data: permission("permission-1") })
expect(harness.notifications).toEqual([questionNotification, permissionNotification]) expect(harness.notifications).toEqual([questionNotification, permissionNotification])
}) })
@@ -159,23 +159,25 @@ describe("internal notifications TUI plugin", () => {
test("dedupes pending questions and permissions until they are resolved", async () => { test("dedupes pending questions and permissions until they are resolved", async () => {
const harness = await setup() const harness = await setup()
harness.emit({ id: "event-1", type: "question.asked", data: question("question-1") }) harness.emit({ id: "event-1", created: 0, type: "question.asked", data: question("question-1") })
harness.emit({ id: "event-2", type: "question.asked", data: question("question-1") }) harness.emit({ id: "event-2", created: 0, type: "question.asked", data: question("question-1") })
harness.emit({ harness.emit({
id: "event-3", id: "event-3",
created: 0,
type: "question.replied", type: "question.replied",
data: { sessionID: "session", requestID: "question-1", answers: [] }, data: { sessionID: "session", requestID: "question-1", answers: [] },
}) })
harness.emit({ id: "event-4", type: "question.asked", data: question("question-1") }) harness.emit({ id: "event-4", created: 0, type: "question.asked", data: question("question-1") })
harness.emit({ id: "event-5", type: "permission.asked", data: permission("permission-1") }) harness.emit({ id: "event-5", created: 0, type: "permission.asked", data: permission("permission-1") })
harness.emit({ id: "event-6", type: "permission.asked", data: permission("permission-1") }) harness.emit({ id: "event-6", created: 0, type: "permission.asked", data: permission("permission-1") })
harness.emit({ harness.emit({
id: "event-7", id: "event-7",
created: 0,
type: "permission.replied", type: "permission.replied",
data: { sessionID: "session", requestID: "permission-1", reply: "once" }, data: { sessionID: "session", requestID: "permission-1", reply: "once" },
}) })
harness.emit({ id: "event-8", type: "permission.asked", data: permission("permission-1") }) harness.emit({ id: "event-8", created: 0, type: "permission.asked", data: permission("permission-1") })
expect(harness.notifications).toEqual([ expect(harness.notifications).toEqual([
questionNotification, questionNotification,
@@ -205,7 +207,7 @@ describe("internal notifications TUI plugin", () => {
test("uses sound-only notifications and subagent_done sound for subagent sessions", async () => { test("uses sound-only notifications and subagent_done sound for subagent sessions", async () => {
const harness = await setup() const harness = await setup()
harness.emit({ id: "event-1", type: "question.asked", data: question("question-1", "subagent") }) harness.emit({ id: "event-1", created: 0, type: "question.asked", data: question("question-1", "subagent") })
harness.emit(stepStarted("event-2", "subagent")) harness.emit(stepStarted("event-2", "subagent"))
harness.emit(stepEnded("event-3", "subagent")) harness.emit(stepEnded("event-3", "subagent"))
@@ -248,12 +250,14 @@ describe("internal notifications TUI plugin", () => {
harness.emit(stepStarted("event-1", "abort")) harness.emit(stepStarted("event-1", "abort"))
harness.emit({ harness.emit({
id: "event-2", id: "event-2",
created: 0,
type: "session.error", type: "session.error",
data: { sessionID: "abort", error: { name: "MessageAbortedError", data: { message: "Aborted" } } }, data: { sessionID: "abort", error: { name: "MessageAbortedError", data: { message: "Aborted" } } },
}) })
harness.emit(stepStarted("event-3", "timeout")) harness.emit(stepStarted("event-3", "timeout"))
harness.emit({ harness.emit({
id: "event-4", id: "event-4",
created: 0,
type: "session.error", type: "session.error",
data: { sessionID: "timeout", error: { name: "UnknownError", data: { message: "SSE read timed out" } } }, data: { sessionID: "timeout", error: { name: "UnknownError", data: { message: "SSE read timed out" } } },
}) })
+57 -46
View File
@@ -2,6 +2,8 @@
import { expect, test } from "bun:test" import { expect, test } from "bun:test"
import { testRender } from "@opentui/solid" import { testRender } from "@opentui/solid"
import type { V2Event } from "@opencode-ai/sdk/v2" import type { V2Event } from "@opencode-ai/sdk/v2"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { EventV2 } from "@opencode-ai/core/event"
import { onMount } from "solid-js" import { onMount } from "solid-js"
import { ProjectProvider } from "../../../src/context/project" import { ProjectProvider } from "../../../src/context/project"
import { SDKProvider } from "../../../src/context/sdk" import { SDKProvider } from "../../../src/context/sdk"
@@ -47,8 +49,8 @@ test("refreshes resources into reactive getters", async () => {
if (url.pathname === "/api/session/ses_test/message") if (url.pathname === "/api/session/ses_test/message")
return json({ return json({
data: [ data: [
{ id: "msg_second", type: "user", text: "Second", time: { created: 2 } }, { id: "msg_second", created: 0, type: "user", text: "Second", time: { created: 2 } },
{ id: "msg_first", type: "user", text: "First", time: { created: 1 } }, { id: "msg_first", created: 0, type: "user", text: "First", time: { created: 1 } },
], ],
cursor: {}, cursor: {},
}) })
@@ -187,7 +189,9 @@ test("connectedOnce is false until first connect and persists across disconnect"
) )
const connect = () => const connect = () =>
stream?.enqueue( stream?.enqueue(
encoder.encode(`data: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n`), encoder.encode(
`data: ${JSON.stringify({ id: "evt_connected", created: 0, type: "server.connected", data: {} })}\n\n`,
),
) )
const disconnect = () => { const disconnect = () => {
stream?.close() stream?.close()
@@ -264,12 +268,12 @@ test("tracks session status from active sessions and execution events", async ()
emitEvent(events, { emitEvent(events, {
id: "evt_step_started", id: "evt_step_started",
type: "session.next.step.started", created: 0,
type: "step.started",
durable: durable("session-live"), durable: durable("session-live"),
data: { data: {
sessionID: "session-live", sessionID: "session-live",
assistantMessageID: "message-live", assistantMessageID: "message-live",
timestamp: 1,
agent: "build", agent: "build",
model: { id: "model", providerID: "provider" }, model: { id: "model", providerID: "provider" },
}, },
@@ -278,12 +282,12 @@ test("tracks session status from active sessions and execution events", async ()
emitEvent(events, { emitEvent(events, {
id: "evt_step_ended", id: "evt_step_ended",
type: "session.next.step.ended", created: 0,
type: "step.ended",
durable: durable("session-live", 1, 2), durable: durable("session-live", 1, 2),
data: { data: {
sessionID: "session-live", sessionID: "session-live",
assistantMessageID: "message-live", assistantMessageID: "message-live",
timestamp: 2,
finish: "stop", finish: "stop",
cost: 0, cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
@@ -297,10 +301,10 @@ test("tracks session status from active sessions and execution events", async ()
emitEvent(events, { emitEvent(events, {
id: "evt_execution_settled", id: "evt_execution_settled",
type: "session.next.execution.settled", created: 0,
type: "execution.settled",
data: { data: {
sessionID: "session-live", sessionID: "session-live",
timestamp: 3,
outcome: "success", outcome: "success",
}, },
}) })
@@ -308,12 +312,12 @@ test("tracks session status from active sessions and execution events", async ()
emitEvent(events, { emitEvent(events, {
id: "evt_failed_step_started", id: "evt_failed_step_started",
type: "session.next.step.started", created: 0,
type: "step.started",
durable: durable("session-failed"), durable: durable("session-failed"),
data: { data: {
sessionID: "session-failed", sessionID: "session-failed",
assistantMessageID: "message-failed", assistantMessageID: "message-failed",
timestamp: 3,
agent: "build", agent: "build",
model: { id: "model", providerID: "provider" }, model: { id: "model", providerID: "provider" },
}, },
@@ -322,12 +326,12 @@ test("tracks session status from active sessions and execution events", async ()
emitEvent(events, { emitEvent(events, {
id: "evt_step_failed", id: "evt_step_failed",
type: "session.next.step.failed", created: 0,
type: "step.failed",
durable: durable("session-failed", 1, 2), durable: durable("session-failed", 1, 2),
data: { data: {
sessionID: "session-failed", sessionID: "session-failed",
assistantMessageID: "message-failed", assistantMessageID: "message-failed",
timestamp: 4,
error: { type: "unknown", message: "Provider unavailable" }, error: { type: "unknown", message: "Provider unavailable" },
}, },
}) })
@@ -339,10 +343,10 @@ test("tracks session status from active sessions and execution events", async ()
emitEvent(events, { emitEvent(events, {
id: "evt_failed_execution_settled", id: "evt_failed_execution_settled",
type: "session.next.execution.settled", created: 0,
type: "execution.settled",
data: { data: {
sessionID: "session-failed", sessionID: "session-failed",
timestamp: 5,
outcome: "failure", outcome: "failure",
error: { type: "unknown", message: "Provider unavailable" }, error: { type: "unknown", message: "Provider unavailable" },
}, },
@@ -411,7 +415,7 @@ test("refreshes integrations after integration updates", async () => {
expect(data.location.integration.list()).toEqual([]) expect(data.location.integration.list()).toEqual([])
const before = { ...requests } const before = { ...requests }
emitEvent(events, { id: "evt_integration", type: "integration.updated", data: {} }) emitEvent(events, { id: "evt_integration", created: 0, type: "integration.updated", data: {} })
await wait(() => data.location.integration.list()?.length === 1) await wait(() => data.location.integration.list()?.length === 1)
await wait(() => requests.model > before.model && requests.provider > before.provider) await wait(() => requests.model > before.model && requests.provider > before.provider)
expect(data.location.integration.list()?.[0]).toMatchObject({ id: "openai", name: "OpenAI" }) expect(data.location.integration.list()?.[0]).toMatchObject({ id: "openai", name: "OpenAI" })
@@ -449,7 +453,7 @@ test("refreshes effective catalog data after catalog updates", async () => {
try { try {
await wait(() => requests.model > 0 && requests.provider > 0) await wait(() => requests.model > 0 && requests.provider > 0)
const before = { ...requests } const before = { ...requests }
emitEvent(events, { id: "evt_catalog", type: "catalog.updated", data: {} }) emitEvent(events, { id: "evt_catalog", created: 0, type: "catalog.updated", data: {} })
await wait(() => requests.model > before.model && requests.provider > before.provider) await wait(() => requests.model > before.model && requests.provider > before.provider)
} finally { } finally {
app.renderer.destroy() app.renderer.destroy()
@@ -496,7 +500,7 @@ test("refreshes agents after agent updates", async () => {
try { try {
await wait(() => data.location.agent.list()?.[0]?.id === "build") await wait(() => data.location.agent.list()?.[0]?.id === "build")
emitEvent(events, { id: "evt_agent", type: "agent.updated", data: {} }) emitEvent(events, { id: "evt_agent", created: 0, type: "agent.updated", data: {} })
await wait(() => data.location.agent.list()?.[0]?.id === "reviewer") await wait(() => data.location.agent.list()?.[0]?.id === "reviewer")
} finally { } finally {
app.renderer.destroy() app.renderer.destroy()
@@ -541,7 +545,7 @@ test("refreshes references after updates", async () => {
try { try {
await mounted await mounted
await wait(() => requests === 1) await wait(() => requests === 1)
emitEvent(events, { id: "evt_reference_1", type: "reference.updated", data: {} }) emitEvent(events, { id: "evt_reference_1", created: 0, type: "reference.updated", data: {} })
await wait(() => data.location.reference.list()?.length === 1) await wait(() => data.location.reference.list()?.length === 1)
expect(data.location.reference.list()?.[0]?.name).toBe("docs") expect(data.location.reference.list()?.[0]?.name).toBe("docs")
} finally { } finally {
@@ -602,6 +606,7 @@ test("keeps shell state scoped to location", async () => {
events.emit({ events.emit({
id: "evt_shell_created", id: "evt_shell_created",
created: 0,
type: "shell.created", type: "shell.created",
location: { directory: other }, location: { directory: other },
data: { data: {
@@ -650,6 +655,7 @@ test("adds and dismisses permission requests from live events", async () => {
await wait(() => data.connection.status() === "connected") await wait(() => data.connection.status() === "connected")
emitEvent(events, { emitEvent(events, {
id: "evt_permission_asked_1", id: "evt_permission_asked_1",
created: 0,
type: "permission.v2.asked", type: "permission.v2.asked",
data: { data: {
id: "per_1", id: "per_1",
@@ -660,6 +666,7 @@ test("adds and dismisses permission requests from live events", async () => {
}) })
emitEvent(events, { emitEvent(events, {
id: "evt_permission_asked_2", id: "evt_permission_asked_2",
created: 0,
type: "permission.v2.asked", type: "permission.v2.asked",
data: { data: {
id: "per_2", id: "per_2",
@@ -672,6 +679,7 @@ test("adds and dismisses permission requests from live events", async () => {
emitEvent(events, { emitEvent(events, {
id: "evt_permission_replied_1", id: "evt_permission_replied_1",
created: 0,
type: "permission.v2.replied", type: "permission.v2.replied",
data: { sessionID: "ses_1", requestID: "per_1", reply: "once" }, data: { sessionID: "ses_1", requestID: "per_1", reply: "once" },
}) })
@@ -680,6 +688,7 @@ test("adds and dismisses permission requests from live events", async () => {
emitEvent(events, { emitEvent(events, {
id: "evt_permission_replied_2", id: "evt_permission_replied_2",
created: 0,
type: "permission.v2.replied", type: "permission.v2.replied",
data: { sessionID: "ses_1", requestID: "per_2", reply: "reject" }, data: { sessionID: "ses_1", requestID: "per_2", reply: "reject" },
}) })
@@ -715,6 +724,7 @@ test("adds and dismisses question requests from live events", async () => {
await wait(() => data.connection.status() === "connected") await wait(() => data.connection.status() === "connected")
emitEvent(events, { emitEvent(events, {
id: "evt_question_asked_1", id: "evt_question_asked_1",
created: 0,
type: "question.v2.asked", type: "question.v2.asked",
data: { data: {
id: "que_1", id: "que_1",
@@ -724,6 +734,7 @@ test("adds and dismisses question requests from live events", async () => {
}) })
emitEvent(events, { emitEvent(events, {
id: "evt_question_asked_2", id: "evt_question_asked_2",
created: 0,
type: "question.v2.asked", type: "question.v2.asked",
data: { data: {
id: "que_2", id: "que_2",
@@ -735,6 +746,7 @@ test("adds and dismisses question requests from live events", async () => {
emitEvent(events, { emitEvent(events, {
id: "evt_question_replied_1", id: "evt_question_replied_1",
created: 0,
type: "question.v2.replied", type: "question.v2.replied",
data: { sessionID: "ses_1", requestID: "que_1", answers: [["First"]] }, data: { sessionID: "ses_1", requestID: "que_1", answers: [["First"]] },
}) })
@@ -743,6 +755,7 @@ test("adds and dismisses question requests from live events", async () => {
emitEvent(events, { emitEvent(events, {
id: "evt_question_rejected_2", id: "evt_question_rejected_2",
created: 0,
type: "question.v2.rejected", type: "question.v2.rejected",
data: { sessionID: "ses_1", requestID: "que_2" }, data: { sessionID: "ses_1", requestID: "que_2" },
}) })
@@ -783,52 +796,52 @@ test("settles pending tools when a live failure arrives", async () => {
await mounted await mounted
emitEvent(events, { emitEvent(events, {
id: "evt_agent_1", id: "evt_agent_1",
type: "session.next.agent.switched", created: 0,
type: "agent.selected",
durable: durable("session-1"), durable: durable("session-1"),
data: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 0, agent: "build" }, data: { sessionID: "session-1", agent: "build" },
}) })
emitEvent(events, { emitEvent(events, {
id: "evt_model_1", id: "evt_model_1",
type: "session.next.model.switched", created: 0,
type: "model.selected",
durable: durable("session-1", 1), durable: durable("session-1", 1),
data: { data: {
sessionID: "session-1", sessionID: "session-1",
messageID: "msg_model_1",
timestamp: 0,
model: { id: "model-1", providerID: "provider-1" }, model: { id: "model-1", providerID: "provider-1" },
}, },
}) })
emitEvent(events, { emitEvent(events, {
id: "evt_step_started_1", id: "evt_step_started_1",
type: "session.next.step.started", created: 0,
type: "step.started",
durable: durable("session-1", 2), durable: durable("session-1", 2),
data: { data: {
sessionID: "session-1", sessionID: "session-1",
assistantMessageID: "msg_explicit_assistant_9", assistantMessageID: "msg_explicit_assistant_9",
timestamp: 1,
agent: "build", agent: "build",
model: { id: "model-1", providerID: "provider-1" }, model: { id: "model-1", providerID: "provider-1" },
}, },
}) })
emitEvent(events, { emitEvent(events, {
id: "evt_input_1", id: "evt_input_1",
type: "session.next.tool.input.started", created: 0,
type: "tool.input.started",
durable: durable("session-1", 3), durable: durable("session-1", 3),
data: { data: {
sessionID: "session-1", sessionID: "session-1",
assistantMessageID: "msg_explicit_assistant_9", assistantMessageID: "msg_explicit_assistant_9",
timestamp: 2,
callID: "call-1", callID: "call-1",
name: "bash", name: "bash",
}, },
}) })
emitEvent(events, { emitEvent(events, {
id: "evt_called_1", id: "evt_called_1",
type: "session.next.tool.called", created: 0,
type: "tool.called",
durable: durable("session-1", 4), durable: durable("session-1", 4),
data: { data: {
sessionID: "session-1", sessionID: "session-1",
timestamp: 2,
assistantMessageID: "msg_explicit_assistant_9", assistantMessageID: "msg_explicit_assistant_9",
callID: "call-1", callID: "call-1",
tool: "bash", tool: "bash",
@@ -838,11 +851,11 @@ test("settles pending tools when a live failure arrives", async () => {
}) })
emitEvent(events, { emitEvent(events, {
id: "evt_failed_1", id: "evt_failed_1",
type: "session.next.tool.failed", created: 0,
type: "tool.failed",
durable: durable("session-1", 5), durable: durable("session-1", 5),
data: { data: {
sessionID: "session-1", sessionID: "session-1",
timestamp: 3,
assistantMessageID: "msg_explicit_assistant_9", assistantMessageID: "msg_explicit_assistant_9",
callID: "call-1", callID: "call-1",
error: { type: "unknown", message: "aborted" }, error: { type: "unknown", message: "aborted" },
@@ -928,12 +941,12 @@ test("renders admitted prompts immediately with queued marker and clears when pr
const unsubscribe = sync.listen((event) => received.push(event.name)) const unsubscribe = sync.listen((event) => received.push(event.name))
emitEvent(events, { emitEvent(events, {
id: "evt_admitted_1", id: "evt_admitted_1",
type: "session.next.prompt.admitted", created: 0,
type: "prompt.admitted",
durable: durable(sessionID), durable: durable(sessionID),
data: { data: {
sessionID, sessionID,
messageID, inputID: messageID,
timestamp: 0,
prompt: { text: "hello" }, prompt: { text: "hello" },
delivery: "steer", delivery: "steer",
}, },
@@ -947,19 +960,17 @@ test("renders admitted prompts immediately with queued marker and clears when pr
emitEvent(events, { emitEvent(events, {
id: "evt_prompted_1", id: "evt_prompted_1",
type: "session.next.prompted", created: 0,
type: "prompt.promoted",
durable: durable(sessionID, 1), durable: durable(sessionID, 1),
data: { data: {
sessionID, sessionID,
messageID, inputID: messageID,
timestamp: 0,
prompt: { text: "hello" },
delivery: "steer",
}, },
}) })
await wait(() => received.at(-1) === "session.next.prompted") await wait(() => received.at(-1) === "prompt.promoted")
expect(received.slice(-2)).toEqual(["session.next.prompt.admitted", "session.next.prompted"]) expect(received.slice(-2)).toEqual(["prompt.admitted", "prompt.promoted"])
unsubscribe() unsubscribe()
const message = sync.session.message.list(sessionID)?.[0] const message = sync.session.message.list(sessionID)?.[0]
expect(message?.type).toBe("user") expect(message?.type).toBe("user")
@@ -1007,21 +1018,21 @@ test("projects live context updates with their message ID", async () => {
await mounted await mounted
emitEvent(events, { emitEvent(events, {
id: "evt_context_1", id: "evt_context_1",
type: "session.next.context.updated", created: 0,
type: "session.context.updated",
durable: durable("session-1"), durable: durable("session-1"),
data: { data: {
sessionID: "session-1", sessionID: "session-1",
messageID: "msg_context_1",
timestamp: 1,
text: "Updated context", text: "Updated context",
}, },
}) })
await wait(() => sync.session.message.list("session-1")?.length === 1) await wait(() => sync.session.message.list("session-1")?.length === 1)
expect(sync.session.message.list("session-1")?.[0]).toMatchObject({ expect(sync.session.message.list("session-1")?.[0]).toMatchObject({
id: "msg_context_1", id: SessionMessage.ID.fromEvent(EventV2.ID.make("evt_context_1")),
type: "system", type: "system",
text: "Updated context", text: "Updated context",
time: { created: 0 },
}) })
} finally { } finally {
app.renderer.destroy() app.renderer.destroy()
@@ -30,6 +30,7 @@ function event(payload: V2Event, input: { directory: string; project?: string; w
function vcs(branch: string): V2Event { function vcs(branch: string): V2Event {
return { return {
id: `evt_vcs_${branch}`, id: `evt_vcs_${branch}`,
created: 0,
type: "vcs.branch.updated", type: "vcs.branch.updated",
data: { data: {
branch, branch,
@@ -40,6 +41,7 @@ function vcs(branch: string): V2Event {
function update(version: string): V2Event { function update(version: string): V2Event {
return { return {
id: `evt_update_${version}`, id: `evt_update_${version}`,
created: 0,
type: "installation.update-available", type: "installation.update-available",
data: { data: {
version, version,
+14
View File
@@ -904,3 +904,17 @@ Compatibility:
- Existing Context Epoch rows migrate in place by dropping the obsolete selection and pending-replacement columns. - Existing Context Epoch rows migrate in place by dropping the obsolete selection and pending-replacement columns.
- Model and agent switches no longer discard earlier chronological System Context updates by forcing a new baseline. - Model and agent switches no longer discard earlier chronological System Context updates by forcing a new baseline.
## 2026-07-03: Normalize Session Event Names And Envelope Time
- Drop the experimental `session.next.` prefix from current Session event names.
- Rename `agent.switched` to `agent.selected`, `model.switched` to `model.selected`, and `prompted` to `prompt.promoted`; add `prompt.admitted` as the durable prompt admission record.
- Add an envelope-level `created` timestamp stored on each event row; payload-level `timestamp` fields are removed.
- Remove projection-only `messageID` fields from selected/message-producing events; projected message IDs derive from the event ID. `revert.committed.messageID` remains, and `forked.messageID` is now `forked.from`.
- Rename `session.moved.subdirectory` to `subpath` and normalize event-related schema identifiers.
Compatibility:
- V2 durable events and projections are experimental and are reset by `20260703090000_reset_v2_event_rename_sweep`; existing V2 event rows, event sequences, projected session messages, and admitted inputs are wiped.
- All renamed durable event types restart at version 1 under their normalized names.
- Generated Promise, Effect, and legacy JavaScript SDK surfaces were regenerated from the normalized schemas.
+3 -3
View File
@@ -114,7 +114,7 @@ Before each provider turn, the runner estimates the complete model-visible reque
Compaction keeps the full transcript durable while replacing its active model representation with one hidden checkpoint containing a structured rolling summary and token-bounded serialized recent context. Provider-native assistant, reasoning, and tool messages never survive across the boundary, avoiding signature and encrypted-reasoning failures when the earlier prefix changes. Compaction keeps the full transcript durable while replacing its active model representation with one hidden checkpoint containing a structured rolling summary and token-bounded serialized recent context. Provider-native assistant, reasoning, and tool messages never survive across the boundary, avoiding signature and encrypted-reasoning failures when the earlier prefix changes.
`session.next.compaction.started.1` durably identifies the attempt. Compaction deltas are live-only progress. `session.next.compaction.ended.1` durably stores the final summary and serialized recent context; only this completed event projects a model-visible compaction message. On the next provider attempt, the runner observes that completed compaction and directly renders a fresh Context Epoch baseline. A failed or interrupted attempt therefore leaves the previous history boundary active. `compaction.started.1` durably identifies the attempt. Compaction deltas are live-only progress. `compaction.ended.1` durably stores the final summary and serialized recent context; only this completed event projects a model-visible compaction message. On the next provider attempt, the runner observes that completed compaction and directly renders a fresh Context Epoch baseline. A failed or interrupted attempt therefore leaves the previous history boundary active.
Repeated compactions update the previous structured summary with newly compacted messages. The runner then reloads projected history and executes the original pending turn. Repeated compactions update the previous structured summary with newly compacted messages. The runner then reloads projected history and executes the original pending turn.
@@ -170,9 +170,9 @@ The coordinator's active registry is also the source for `sessions.active()`. It
Inbox promotion coalesces pending steers in durable admission order. Once continuation would otherwise end, it promotes one queued input at a time in FIFO order. Add explicit inbox backlog and steering-batch limits before exposing broad multi-caller admission or untrusted queue growth. Inbox promotion coalesces pending steers in durable admission order. Once continuation would otherwise end, it promotes one queued input at a time in FIFO order. Add explicit inbox backlog and steering-batch limits before exposing broad multi-caller admission or untrusted queue growth.
Eager local-tool execution is intentionally unbounded in the current local slice. This minimizes tool latency but does not increase SQLite settlement throughput: Session-event publication remains serialized per provider turn. Before broadening exposure, revisit per-turn call limits, output truncation, and operational backpressure using observed workloads. The `session.next.*` event schemas remain experimental and unshipped; databases created by earlier experimental builds are disposable rather than compatibility targets. Eager local-tool execution is intentionally unbounded in the current local slice. This minimizes tool latency but does not increase SQLite settlement throughput: Session-event publication remains serialized per provider turn. Before broadening exposure, revisit per-turn call limits, output truncation, and operational backpressure using observed workloads. The normalized Session event schemas remain experimental and unshipped; databases created by earlier experimental builds are disposable rather than compatibility targets.
The synchronized `session.next.*` event family and projected Session-message model predate this branch. This slice refines their replay contract: projected Session messages retain their source aggregate sequence so canonical context ordering and `sessions.messages(...)` pagination follow durable event order even when caller-supplied IDs or timestamps do not. Consumers can use `sessions.log({ sessionID, after? })` to replay durable `session.next.*` events after an aggregate sequence cursor, then tail durable events without a race. Live-only text, reasoning, and tool-input fragments remain available through EventV2 subscriptions for connected renderers; they are intentionally absent from the replayable Session stream. The normalized Session event family and projected Session-message model predate this branch. This slice refines their replay contract: projected Session messages retain their source aggregate sequence so canonical context ordering and `sessions.messages(...)` pagination follow durable event order even when caller-supplied IDs differ; event time is carried by the envelope `created` field rather than duplicated in payloads. Consumers can use `sessions.log({ sessionID, after? })` to replay durable Session events after an aggregate sequence cursor, then tail durable events without a race. Live-only text, reasoning, and tool-input fragments remain available through EventV2 subscriptions for connected renderers; they are intentionally absent from the replayable Session stream.
The first `sessions.log(...)` contract is durable-only during both replay and live tailing. This keeps one cursor equal to one persisted aggregate sequence and is sufficient for reconnect-safe consumers. A later UI-facing API may optionally interleave live-only deltas while connected, but those fragments must remain explicitly ephemeral: they cannot advance the durable cursor, replay after reconnect, or be mistaken for publication boundaries. The first `sessions.log(...)` contract is durable-only during both replay and live tailing. This keeps one cursor equal to one persisted aggregate sequence and is sufficient for reconnect-safe consumers. A later UI-facing API may optionally interleave live-only deltas while connected, but those fragments must remain explicitly ephemeral: they cannot advance the durable cursor, replay after reconnect, or be mistaken for publication boundaries.