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
commit 394e0b9045
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
63 changed files with 1888 additions and 1989 deletions

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,17 @@
import { expect, test } from "bun:test"
import { DateTime, Effect, Stream } from "effect"
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) }
@ -23,7 +33,7 @@ test("event.subscribe exposes and decodes the native Effect event stream", async
HttpClientResponse.fromWeb(
request,
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`,
{ 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)
}).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]
if (durable?.type !== "session.next.model.switched") throw new Error("Expected model event")
expect(DateTime.toEpochMillis(durable.data.timestamp)).toBe(1_717_171_717_000)
if (durable?.type !== "model.selected") throw new Error("Expected model event")
expect(DateTime.toEpochMillis(durable.created)).toBe(1_717_171_717_000)
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(logQueries[0]).toEqual({ after: "0" })
const logged = Array.from(result.log)
expect(logged.map((item) => item.type)).toEqual(["session.next.model.switched", "log.synced"])
expect(logged[0]?.type === "session.next.model.switched" && DateTime.toEpochMillis(logged[0].data.timestamp)).toBe(
expect(logged.map((item) => item.type)).toEqual(["model.selected", "log.synced"])
expect(logged[0]?.type === "model.selected" && DateTime.toEpochMillis(logged[0].created)).toBe(
1_717_171_717_000,
)
expect(logged.at(-1)).toEqual(synced)
@ -217,12 +227,11 @@ const modelSwitchedMessage = {
const modelSwitchedEvent = {
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 },
data: {
timestamp: 1_717_171_717_000,
sessionID: "ses_test",
messageID: "msg_model",
model: { id: "claude", providerID: "anthropic" },
},
}

View file

@ -151,7 +151,7 @@ test("event.subscribe exposes the Promise event stream wire projection", async (
baseUrl: "http://localhost:3000",
fetch: async () =>
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`,
{ headers: { "content-type": "text/event-stream" } },
),
@ -159,8 +159,8 @@ test("event.subscribe exposes the Promise event stream wire projection", async (
const events = []
for await (const event of client.event.subscribe()) events.push(event)
expect(events).toEqual([{ id: "evt_connected", 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).toEqual([{ id: "evt_connected", created: 0, type: "server.connected", data: {} }, modelSwitchedEvent])
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 () => {
@ -328,12 +328,11 @@ const synced = { type: "log.synced", aggregateID: "ses_test", seq: 1 }
const modelSwitchedEvent = {
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 },
data: {
timestamp: 1_717_171_717_000,
sessionID: "ses_test",
messageID: "msg_model",
model: { id: "claude", providerID: "anthropic" },
},
}

View file

@ -1,9 +1,9 @@
{
"version": "7",
"dialect": "sqlite",
"id": "22e57fed-b9b8-4e94-a3b4-f94bece680a8",
"id": "96e9fe64-d810-4102-8f79-3317a88bb6d2",
"prevIds": [
"f14a9b18-8207-487e-a3d3-227e629ba9ad"
"22e57fed-b9b8-4e94-a3b4-f94bece680a8"
],
"ddl": [
{
@ -526,6 +526,16 @@
"entityType": "columns",
"table": "event"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "created",
"entityType": "columns",
"table": "event"
},
{
"type": "text",
"notNull": true,

View file

@ -106,8 +106,7 @@ const layer = Layer.effect(
yield* events.publish(SessionEvent.Moved, {
sessionID: input.sessionID,
location: Location.Ref.make({ directory }),
subdirectory: RelativePath.make(path.relative(destination.directory, directory).replaceAll("\\", "/")),
timestamp: yield* DateTime.now,
subpath: RelativePath.make(path.relative(destination.directory, directory).replaceAll("\\", "/")),
})
if (patch) {

View file

@ -41,5 +41,7 @@ export const migrations = (
import("./migration/20260622170816_reset_v2_session_state"),
import("./migration/20260622202450_simplify_session_input"),
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[]

View file

@ -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

View file

@ -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

View file

@ -81,6 +81,7 @@ export default {
\`id\` text PRIMARY KEY,
\`aggregate_id\` text NOT NULL,
\`seq\` integer NOT NULL,
\`created\` integer NOT NULL,
\`type\` 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

View file

@ -1,6 +1,6 @@
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 type { Data, Definition, Payload } from "@opencode-ai/schema/event"
import type { EventLog } from "@opencode-ai/schema/event-log"
@ -55,6 +55,7 @@ export const reserveSequence = Effect.fn("EventV2.reserveSequence")(function* (
export type SerializedEvent = {
readonly id: ID
readonly type: string
readonly created?: DateTime.Utc
readonly seq: number
readonly aggregateID: string
readonly data: Record<string, unknown>
@ -81,6 +82,7 @@ const decodeSerializedEvent = (event: SerializedEvent): Payload => {
}
return {
id: event.id,
created: event.created ?? DateTime.makeUnsafe(0),
type: definition.type,
durable: envelope(event.aggregateID, event.seq, definition.durable.version),
data: Schema.decodeUnknownSync(definition.data)(event.data),
@ -295,6 +297,7 @@ export const layerWith = (options?: LayerOptions) =>
if (
stored?.id === event.id &&
stored.type === versionedType(definition.type, durable.version) &&
stored.created === DateTime.toEpochMillis(event.created ?? DateTime.makeUnsafe(0)) &&
isDeepStrictEqual(stored.data, encoded)
) {
if (input.ownerID && row?.ownerID == null) {
@ -366,6 +369,7 @@ export const layerWith = (options?: LayerOptions) =>
id: event.id,
aggregate_id: aggregateID,
seq,
created: DateTime.toEpochMillis(event.created ?? DateTime.makeUnsafe(0)),
type: versionedType(definition.type, durable.version),
data: encoded,
},
@ -471,6 +475,7 @@ export const layerWith = (options?: LayerOptions) =>
definition,
{
id: options?.id ?? ID.create(),
created: yield* DateTime.now,
...(options?.metadata ? { metadata: options.metadata } : {}),
type: definition.type,
...(location ? { location } : {}),
@ -494,6 +499,7 @@ export const layerWith = (options?: LayerOptions) =>
} else {
const payload = {
id: event.id,
created: event.created ?? DateTime.makeUnsafe(0),
type: definition.type,
data: Schema.decodeUnknownSync(definition.data)(event.data),
} as Payload
@ -610,6 +616,7 @@ export const layerWith = (options?: LayerOptions) =>
return [
decodeSerializedEvent({
id: event.id,
created: DateTime.makeUnsafe(event.created),
aggregateID: event.aggregate_id,
seq: event.seq,
type: event.type,

View file

@ -15,6 +15,7 @@ export const EventTable = sqliteTable(
.notNull()
.references(() => EventSequenceTable.aggregate_id, { onDelete: "cascade" }),
seq: integer().notNull(),
created: integer().notNull(),
type: text().notNull(),
data: text({ mode: "json" }).$type<Record<string, unknown>>().notNull(),
},

View file

@ -363,8 +363,7 @@ const layer = Layer.effect(
yield* events.publish(SessionEvent.Forked, {
sessionID,
parentID: parent.id,
messageID: input.messageID,
timestamp: yield* DateTime.now,
from: input.messageID,
})
return yield* result.get(sessionID).pipe(Effect.orDie)
}),
@ -551,16 +550,13 @@ const layer = Layer.effect(
Effect.gen(function* () {
activeShells.add(input.sessionID)
if ((yield* execution.active).has(input.sessionID)) yield* execution.awaitIdle(input.sessionID)
const messageID = SessionMessage.ID.create()
const callID = Identifier.ascending()
yield* events.publish(
SessionEvent.Shell.Started,
{
sessionID: input.sessionID,
messageID,
callID,
command: input.command,
timestamp: yield* DateTime.now,
},
{ id: input.id },
)
@ -571,7 +567,6 @@ const layer = Layer.effect(
sessionID: input.sessionID,
callID,
output,
timestamp: yield* DateTime.now,
})
}).pipe(
Effect.ensuring(
@ -590,8 +585,6 @@ const layer = Layer.effect(
if (!skill) return yield* new SkillNotFoundError({ skill: input.skill })
yield* events.publish(SessionEvent.Skill.Activated, {
sessionID: input.sessionID,
messageID: input.id ?? SessionMessage.ID.create(),
timestamp: yield* DateTime.now,
name: skill.name,
text: skill.content,
})
@ -602,10 +595,8 @@ const layer = Layer.effect(
}),
switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) {
yield* result.get(input.sessionID)
yield* events.publish(SessionEvent.AgentSwitched, {
yield* events.publish(SessionEvent.AgentSelected, {
sessionID: input.sessionID,
messageID: SessionMessage.ID.create(),
timestamp: yield* DateTime.now,
agent: input.agent,
})
}),
@ -617,10 +608,8 @@ const layer = Layer.effect(
(session.model.variant ?? "default") === (input.model.variant ?? "default")
)
return
yield* events.publish(SessionEvent.ModelSwitched, {
yield* events.publish(SessionEvent.ModelSelected, {
sessionID: input.sessionID,
messageID: SessionMessage.ID.create(),
timestamp: yield* DateTime.now,
model: input.model,
})
}),
@ -628,7 +617,6 @@ const layer = Layer.effect(
yield* result.get(input.sessionID)
yield* events.publish(SessionEvent.Renamed, {
sessionID: input.sessionID,
timestamp: yield* DateTime.now,
title: input.title,
})
}),
@ -676,8 +664,6 @@ const layer = Layer.effect(
yield* result.get(input.sessionID)
yield* events.publish(SessionEvent.Synthetic, {
sessionID: input.sessionID,
messageID: SessionMessage.ID.create(),
timestamp: yield* DateTime.now,
text: input.text,
description: input.description,
metadata: input.metadata,

View file

@ -7,7 +7,7 @@ import { EventV2 } from "../event"
import { makeLocationNode } from "../effect/app-node"
import { llmClient } from "../effect/app-node-platform"
import { SessionEvent } from "./event"
import { SessionMessage } from "./message"
import type { SessionMessage } from "./message"
import { SessionRunnerModel } from "./runner/model"
import { SessionSchema } from "./schema"
import { Token } from "../util/token"
@ -206,11 +206,8 @@ const make = (dependencies: Dependencies) => {
const summaryPrompt = buildPrompt({ previousSummary: input.previousSummary, context: input.context })
const summaryOutput = Math.min(output || SUMMARY_OUTPUT_TOKENS, SUMMARY_OUTPUT_TOKENS)
if (Token.estimate(summaryPrompt) > context - summaryOutput) return false
const messageID = SessionMessage.ID.create()
yield* dependencies.events.publish(SessionEvent.Compaction.Started, {
sessionID: input.sessionID,
messageID,
timestamp: yield* DateTime.now,
reason: input.reason,
})
@ -238,8 +235,6 @@ const make = (dependencies: Dependencies) => {
if (!summarized || failed || !summary.trim()) return false
yield* dependencies.events.publish(SessionEvent.Compaction.Ended, {
sessionID: input.sessionID,
messageID,
timestamp: yield* DateTime.now,
reason: input.reason,
text: summary,
recent: input.recent,

View file

@ -1,13 +1,12 @@
export * as SessionContextCheckpoint from "./context-checkpoint"
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 { EventV2 } from "../event"
import { SystemContext } from "../system-context/index"
import { SessionEvent } from "./event"
import { SessionHistory } from "./history"
import { SessionMessage } from "./message"
import { SessionSchema } from "./schema"
import { SessionContextCheckpointTable } from "./sql"
@ -50,7 +49,7 @@ export const prepare = Effect.fn("SessionContextCheckpoint.prepare")(function* (
yield* events.publish(
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) },
)
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }

View file

@ -36,7 +36,6 @@ const layer = Layer.effect(
Exit.isFailure(exit) && !Cause.hasInterrupts(exit.cause) ? Cause.squash(exit.cause) : undefined
yield* events.publish(SessionEvent.ExecutionSettled, {
sessionID,
timestamp: yield* DateTime.now,
outcome: Exit.isSuccess(exit) ? "success" : Cause.hasInterrupts(exit.cause) ? "interrupted" : "failure",
error:
failure !== undefined

View file

@ -50,12 +50,10 @@ export const admit = Effect.fn("SessionInput.admit")(function* (
) {
const existing = yield* find(db, input.id)
if (existing !== undefined) return existing
const timestamp = yield* DateTime.now
return yield* events
.publish(SessionEvent.PromptAdmitted, {
messageID: input.id,
inputID: input.id,
sessionID: input.sessionID,
timestamp,
prompt: input.prompt,
delivery: input.delivery,
})
@ -70,7 +68,7 @@ export const admit = Effect.fn("SessionInput.admit")(function* (
sessionID: input.sessionID,
prompt: input.prompt,
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 }))
})
export const projectPrompted = Effect.fn("SessionInput.projectPrompted")(function* (
export const projectPromptPromoted = Effect.fn("SessionInput.projectPromptPromoted")(function* (
db: DatabaseService,
input: {
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
readonly prompt: Prompt
readonly delivery: Delivery
readonly timeCreated: DateTime.Utc
readonly promotedSeq: number
},
) {
@ -141,15 +136,16 @@ export const projectPrompted = Effect.fn("SessionInput.projectPrompted")(functio
.pipe(Effect.orDie)
if (updated) {
const stored = fromRow(updated)
if (!matchesProjection(stored, input)) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
return
if (stored.sessionID !== input.sessionID) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
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.
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 stored
})
export const hasPending = Effect.fn("SessionInput.hasPending")(function* (
@ -206,12 +202,9 @@ const publish = Effect.fn("SessionInput.publish")(function* (
for (const row of rows) {
const id = SessionMessage.ID.make(row.id)
yield* events
.publish(SessionEvent.Prompted, {
.publish(SessionEvent.PromptPromoted, {
sessionID,
timestamp: DateTime.makeUnsafe(row.time_created),
messageID: id,
prompt: decodePrompt(row.prompt),
delivery: row.delivery,
inputID: id,
})
.pipe(
Effect.catchDefect((defect) =>

View file

@ -60,9 +60,9 @@ const layer = Layer.effect(
const files = yield* Effect.forEach(
toInject,
(path) =>
fs.readFileStringSafe(path).pipe(
Effect.map((content) => (content === undefined ? undefined : { path, content })),
),
fs
.readFileStringSafe(path)
.pipe(Effect.map((content) => (content === undefined ? undefined : { path, content }))),
{ concurrency: "unbounded" },
)
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.
yield* events.publish(SessionEvent.Synthetic, {
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"),
description: `Loaded ${readable.map((file) => describePath(root, file.path)).join(", ")}`,
metadata: { instruction: { paths: readable.map((file) => file.path) } },

View file

@ -100,112 +100,100 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
return Effect.gen(function* () {
yield* SessionEvent.All.match(event, {
"session.next.agent.switched": (event) => {
"agent.selected": (event) => {
return adapter.appendMessage(
SessionMessage.AgentSwitched.make({
id: event.data.messageID,
SessionMessage.AgentSelected.make({
id: SessionMessage.ID.fromEvent(event.id),
type: "agent-switched",
metadata: event.metadata,
agent: event.data.agent,
time: { created: event.data.timestamp },
time: { created: event.created },
}),
)
},
"session.next.model.switched": (event) => {
"model.selected": (event) => {
return adapter.appendMessage(
SessionMessage.ModelSwitched.make({
id: event.data.messageID,
SessionMessage.ModelSelected.make({
id: SessionMessage.ID.fromEvent(event.id),
type: "model-switched",
metadata: event.metadata,
model: event.data.model,
time: { created: event.data.timestamp },
time: { created: event.created },
}),
)
},
"session.next.moved": () => Effect.void,
"session.next.renamed": () => Effect.void,
"session.next.forked": () => Effect.void,
"session.next.prompted": (event) => {
return adapter.appendMessage(
SessionMessage.User.make({
id: event.data.messageID,
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) =>
"session.moved": () => Effect.void,
renamed: () => Effect.void,
forked: () => Effect.void,
"prompt.promoted": () => Effect.void,
"prompt.admitted": () => Effect.void,
"execution.settled": () => Effect.void,
"session.context.updated": (event) =>
adapter.appendMessage(
SessionMessage.System.make({
id: event.data.messageID,
id: SessionMessage.ID.fromEvent(event.id),
type: "system",
text: event.data.text,
time: { created: event.data.timestamp },
time: { created: event.created },
}),
),
"session.next.synthetic": (event) => {
synthetic: (event) => {
return adapter.appendMessage(
SessionMessage.Synthetic.make({
sessionID: event.data.sessionID,
text: event.data.text,
description: event.data.description,
metadata: event.data.metadata,
id: event.data.messageID,
id: SessionMessage.ID.fromEvent(event.id),
type: "synthetic",
time: { created: event.data.timestamp },
time: { created: event.created },
}),
)
},
"session.next.skill.activated": (event) => {
"skill.activated": (event) => {
return adapter.appendMessage(
SessionMessage.Skill.make({
id: event.data.messageID,
id: SessionMessage.ID.fromEvent(event.id),
type: "skill",
name: event.data.name,
text: event.data.text,
time: { created: event.data.timestamp },
time: { created: event.created },
}),
)
},
"session.next.shell.started": (event) => {
"shell.started": (event) => {
return adapter.appendMessage(
SessionMessage.Shell.make({
id: event.data.messageID,
id: SessionMessage.ID.fromEvent(event.id),
type: "shell",
metadata: event.metadata,
callID: event.data.callID,
command: event.data.command,
output: "",
time: { created: event.data.timestamp },
time: { created: event.created },
}),
)
},
"session.next.shell.ended": (event) => {
"shell.ended": (event) => {
return Effect.gen(function* () {
const currentShell = yield* adapter.getCurrentShell(event.data.callID)
if (currentShell) {
yield* adapter.updateShell(
produce(currentShell, (draft) => {
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* () {
const currentAssistant = yield* adapter.getCurrentAssistant()
if (currentAssistant) {
yield* adapter.updateAssistant(
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",
agent: event.data.agent,
model: event.data.model,
time: { created: event.data.timestamp },
time: { created: event.created },
content: [],
snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined,
}),
)
})
},
"session.next.step.ended": (event) => {
"step.ended": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.time.completed = event.data.timestamp
draft.time.completed = event.created
draft.finish = event.data.finish
draft.cost = event.data.cost
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) => {
draft.time.completed = event.data.timestamp
draft.time.completed = event.created
draft.finish = "error"
draft.error = event.data.error
})
},
"session.next.text.started": (event) => {
"text.started": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.content.push(
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) => {
const match = latestText(draft, event.data.textID)
if (match) match.text += event.data.delta
})
},
"session.next.text.ended": (event) => {
"text.ended": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestText(draft, event.data.textID)
if (match) match.text = event.data.text
})
},
"session.next.tool.input.started": (event) => {
"tool.input.started": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.content.push(
castDraft(
@ -270,26 +258,26 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
type: "tool",
id: event.data.callID,
name: event.data.name,
time: { created: event.data.timestamp },
time: { created: event.created },
state: SessionMessage.ToolStatePending.make({ status: "pending", input: "" }),
}),
),
)
})
},
"session.next.tool.input.delta": () => Effect.void,
"session.next.tool.input.ended": (event) => {
"tool.input.delta": () => Effect.void,
"tool.input.ended": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID)
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) => {
const match = latestTool(draft, event.data.callID)
if (match) {
match.provider = event.data.provider
match.time.ran = event.data.timestamp
match.time.ran = event.created
match.state = castDraft(
SessionMessage.ToolStateRunning.make({
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) => {
const match = latestTool(draft, event.data.callID)
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) => {
const match = latestTool(draft, event.data.callID)
if (match && match.state.status === "running") {
@ -319,7 +307,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
metadata: match.provider?.metadata,
resultMetadata: event.data.provider.metadata,
}
match.time.completed = event.data.timestamp
match.time.completed = event.created
match.state = castDraft(
SessionMessage.ToolStateCompleted.make({
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) => {
const match = latestTool(draft, event.data.callID)
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,
resultMetadata: event.data.provider.metadata,
}
match.time.completed = event.data.timestamp
match.time.completed = event.created
match.state = castDraft(
SessionMessage.ToolStateError.make({
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) => {
draft.content.push(
castDraft(
@ -365,47 +353,47 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
id: event.data.reasoningID,
text: "",
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) => {
const match = latestReasoning(draft, event.data.reasoningID)
if (match) match.text += event.data.delta
})
},
"session.next.reasoning.ended": (event) => {
"reasoning.ended": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestReasoning(draft, event.data.reasoningID)
if (match) {
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
}
})
},
"session.next.retried": () => Effect.void,
"session.next.compaction.started": () => Effect.void,
"session.next.compaction.delta": () => Effect.void,
"session.next.compaction.ended": (event) => {
retried: () => Effect.void,
"compaction.started": () => Effect.void,
"compaction.delta": () => Effect.void,
"compaction.ended": (event) => {
return adapter.appendMessage(
SessionMessage.Compaction.make({
id: event.data.messageID,
id: SessionMessage.ID.fromEvent(event.id),
type: "compaction",
metadata: event.metadata,
reason: event.data.reason,
summary: event.data.text,
recent: event.data.recent,
time: { created: event.data.timestamp },
time: { created: event.created },
}),
)
},
"session.next.revert.staged": () => Effect.void,
"session.next.revert.cleared": () => Effect.void,
"session.next.revert.committed": () => Effect.void,
"revert.staged": () => Effect.void,
"revert.cleared": () => Effect.void,
"revert.committed": () => Effect.void,
})
})
}

View file

@ -158,21 +158,17 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
.get()
.pipe(Effect.orDie)
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
.select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, event.data.parentID),
eq(SessionMessageTable.id, event.data.messageID),
),
and(eq(SessionMessageTable.session_id, event.data.parentID), eq(SessionMessageTable.id, event.data.from)),
)
.get()
.pipe(Effect.orDie)
: undefined
if (event.data.messageID && !boundary)
return yield* Effect.die(new Error(`Fork boundary message not found: ${event.data.messageID}`))
if (event.data.from && !boundary) return yield* Effect.die(new Error(`Fork boundary message not found: ${event.data.from}`))
const copied = yield* db
.select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable)
@ -208,8 +204,8 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
tokens_reasoning: 0,
tokens_cache_read: 0,
tokens_cache_write: 0,
time_created: DateTime.toEpochMillis(event.data.timestamp),
time_updated: DateTime.toEpochMillis(event.data.timestamp),
time_created: DateTime.toEpochMillis(event.created),
time_updated: DateTime.toEpochMillis(event.created),
})
.onConflictDoNothing()
.returning({ sessionID: SessionTable.id })
@ -474,9 +470,9 @@ const layer = Layer.effectDiscard(
.update(SessionTable)
.set({
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,
time_updated: DateTime.toEpochMillis(event.data.timestamp),
time_updated: DateTime.toEpochMillis(event.created),
})
.where(eq(SessionTable.id, event.data.sessionID))
.run()
@ -556,19 +552,19 @@ const layer = Layer.effectDiscard(
if (next) yield* applyUsage(db, sessionID, next)
}),
)
yield* events.project(SessionEvent.AgentSwitched, (event) =>
yield* events.project(SessionEvent.AgentSelected, (event) =>
db
.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))
.run()
.pipe(Effect.orDie, Effect.andThen(run(db, event))),
)
yield* events.project(SessionEvent.ModelSwitched, (event) =>
yield* events.project(SessionEvent.ModelSelected, (event) =>
Effect.gen(function* () {
yield* db
.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))
.run()
.pipe(Effect.orDie)
@ -578,25 +574,30 @@ const layer = Layer.effectDiscard(
yield* events.project(SessionEvent.Renamed, (event) =>
db
.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))
.run()
.pipe(Effect.orDie),
)
yield* events.project(SessionEvent.Forked, (event) => projectFork(db, event))
yield* events.project(SessionEvent.Prompted, (event) =>
yield* events.project(SessionEvent.PromptPromoted, (event) =>
Effect.gen(function* () {
if (event.durable === undefined)
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
yield* SessionInput.projectPrompted(db, {
id: event.data.messageID,
const input = yield* SessionInput.projectPromptPromoted(db, {
id: event.data.inputID,
sessionID: event.data.sessionID,
prompt: event.data.prompt,
delivery: event.data.delivery,
timeCreated: event.data.timestamp,
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) =>
@ -605,11 +606,11 @@ const layer = Layer.effectDiscard(
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
yield* SessionInput.projectAdmitted(db, {
admittedSeq: event.durable.seq,
id: event.data.messageID,
id: event.data.inputID,
sessionID: event.data.sessionID,
prompt: event.data.prompt,
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.Skill.Activated, (event) =>
insertMessage(db, event, {
id: event.data.messageID,
id: SessionMessage.ID.fromEvent(event.id),
type: "skill",
name: event.data.name,
text: event.data.text,
time: { created: event.data.timestamp },
time: { created: event.created },
}),
)
yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event))
@ -646,7 +647,7 @@ const layer = Layer.effectDiscard(
.update(SessionTable)
.set({
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))
.run()
@ -655,7 +656,7 @@ const layer = Layer.effectDiscard(
yield* events.project(SessionEvent.RevertEvent.Cleared, (event) =>
db
.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))
.run()
.pipe(Effect.orDie, Effect.asVoid),
@ -693,7 +694,7 @@ const layer = Layer.effectDiscard(
.pipe(Effect.orDie)
yield* db
.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))
.run()
.pipe(Effect.orDie)

View file

@ -89,7 +89,6 @@ export const stage = Effect.fn("SessionRevert.stage")(function* (input: {
} satisfies SessionSchema.Info["revert"]
yield* events.publish(SessionEvent.RevertEvent.Staged, {
sessionID: input.session.id,
timestamp: yield* DateTime.now,
revert,
})
return revert
@ -106,7 +105,6 @@ export const clear = Effect.fn("SessionRevert.clear")(function* (session: Sessio
const events = yield* EventV2.Service
yield* events.publish(SessionEvent.RevertEvent.Cleared, {
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, {
sessionID: session.id,
messageID: session.revert.messageID,
timestamp: yield* DateTime.now,
})
})

View file

@ -134,7 +134,6 @@ const layer = Layer.effect(
if (tool.type !== "tool" || (tool.state.status !== "pending" && tool.state.status !== "running")) continue
yield* events.publish(SessionEvent.Tool.Failed, {
sessionID,
timestamp: yield* DateTime.now,
assistantMessageID: message.id,
callID: tool.id,
error: { type: "unknown", message: "Tool execution interrupted" },
@ -297,7 +296,6 @@ const layer = Layer.effect(
yield* serialized(
events.publish(SessionEvent.Step.Ended, {
sessionID: session.id,
timestamp: yield* DateTime.now,
assistantMessageID: yield* publisher.startAssistant(),
finish: settlement.finish,
cost: 0,

View file

@ -78,7 +78,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
yield* events.publish(SessionEvent.Step.Started, {
...input,
assistantMessageID,
timestamp: yield* timestamp,
snapshot: input.snapshot,
})
return assistantMessageID
@ -123,7 +122,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
yield* events.publish(SessionEvent.Text.Ended, {
sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(),
timestamp: yield* timestamp,
textID,
text: value,
})
@ -134,7 +132,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
yield* events.publish(SessionEvent.Reasoning.Ended, {
sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(),
timestamp: yield* timestamp,
reasoningID,
text: value,
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}`))
yield* events.publish(SessionEvent.Tool.Input.Ended, {
sessionID: input.sessionID,
timestamp: yield* timestamp,
assistantMessageID: tool.assistantMessageID,
callID,
text: value,
@ -176,7 +172,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
yield* toolInput.start(event.id)
yield* events.publish(SessionEvent.Tool.Input.Started, {
sessionID: input.sessionID,
timestamp: yield* timestamp,
assistantMessageID,
callID: event.id,
name: event.name,
@ -204,7 +199,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
assistantFailed = true
yield* events.publish(SessionEvent.Step.Failed, {
sessionID: input.sessionID,
timestamp: yield* timestamp,
assistantMessageID,
error: { type: "unknown", message },
})
@ -219,7 +213,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
tool.settled = true
yield* events.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID,
timestamp: yield* timestamp,
assistantMessageID: tool.assistantMessageID,
callID,
error: { type: "unknown", message },
@ -248,7 +241,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
yield* events.publish(SessionEvent.Text.Started, {
sessionID: input.sessionID,
assistantMessageID: yield* startAssistant(),
timestamp: yield* timestamp,
textID: event.id,
})
return
@ -257,7 +249,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
yield* events.publish(SessionEvent.Text.Delta, {
sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(),
timestamp: yield* timestamp,
textID: event.id,
delta: event.text,
})
@ -270,7 +261,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
yield* events.publish(SessionEvent.Reasoning.Started, {
sessionID: input.sessionID,
assistantMessageID: yield* startAssistant(),
timestamp: yield* timestamp,
reasoningID: event.id,
providerMetadata: event.providerMetadata,
})
@ -280,7 +270,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
yield* events.publish(SessionEvent.Reasoning.Delta, {
sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(),
timestamp: yield* timestamp,
reasoningID: event.id,
delta: event.text,
})
@ -300,7 +289,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
yield* toolInput.append(event.id, event.text)
yield* events.publish(SessionEvent.Tool.Input.Delta, {
sessionID: input.sessionID,
timestamp: yield* timestamp,
assistantMessageID: tool.assistantMessageID,
callID: event.id,
delta: event.text,
@ -322,7 +310,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
tool.providerMetadata = event.providerMetadata
yield* events.publish(SessionEvent.Tool.Called, {
sessionID: input.sessionID,
timestamp: yield* timestamp,
assistantMessageID: tool.assistantMessageID,
callID: event.id,
tool: event.name,
@ -352,7 +339,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
if ("error" in result) {
yield* events.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID,
timestamp: yield* timestamp,
assistantMessageID: tool.assistantMessageID,
callID: event.id,
error: result.error,
@ -363,7 +349,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
}
yield* events.publish(SessionEvent.Tool.Success, {
sessionID: input.sessionID,
timestamp: yield* timestamp,
assistantMessageID: tool.assistantMessageID,
callID: event.id,
...result,
@ -382,7 +367,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
tool.settled = true
yield* events.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID,
timestamp: yield* timestamp,
assistantMessageID: tool.assistantMessageID,
callID: event.id,
error: { type: "unknown", message: event.message },

View file

@ -42,9 +42,10 @@ const make = (dependencies: Dependencies) => {
if (!firstUser) return
const agent = yield* dependencies.agents.get(AgentV2.ID.make("title"))
if (!agent) return
const resolved = yield* (agent.model
? dependencies.models.resolve({ ...session, model: agent.model })
: dependencies.models.resolve(session)
const resolved = yield* (
agent.model
? dependencies.models.resolve({ ...session, model: agent.model })
: dependencies.models.resolve(session)
).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!resolved) return
const chunks: string[] = []
@ -76,7 +77,6 @@ const make = (dependencies: Dependencies) => {
if (!title) return
yield* dependencies.events.publish(SessionEvent.Renamed, {
sessionID: session.id,
timestamp: yield* DateTime.now,
title: truncate(title),
})
})

View file

@ -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 (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(
sql`INSERT INTO session_input (id, session_id, prompt, delivery, admitted_seq, time_created) VALUES ('input', 'session', '{}', 'steer', 9, 1)`,

View file

@ -554,6 +554,7 @@ describe("EventV2", () => {
yield* events.replay({
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
@ -573,6 +574,7 @@ describe("EventV2", () => {
yield* events.replay({
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
@ -609,6 +611,7 @@ describe("EventV2", () => {
const exit = yield* events
.replay({
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 1,
aggregateID: envelopeAggregateID,
@ -642,6 +645,7 @@ describe("EventV2", () => {
yield* events.replay({
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
@ -650,6 +654,7 @@ describe("EventV2", () => {
const exit = yield* events
.replay({
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 5,
aggregateID,
@ -674,13 +679,14 @@ describe("EventV2", () => {
yield* events.replay({
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(SessionEvent.ContextUpdated.type, 1),
seq: 0,
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
.replay({
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: "unknown.event.1",
seq: 0,
aggregateID: EventV2.ID.create(),
@ -708,6 +715,7 @@ describe("EventV2", () => {
const source = yield* events.replayAll([
{
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
@ -715,6 +723,7 @@ describe("EventV2", () => {
},
{
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 1,
aggregateID,
@ -735,6 +744,7 @@ describe("EventV2", () => {
const one = yield* events.replayAll([
{
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
@ -742,6 +752,7 @@ describe("EventV2", () => {
},
{
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 1,
aggregateID,
@ -751,6 +762,7 @@ describe("EventV2", () => {
const two = yield* events.replayAll([
{
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 2,
aggregateID,
@ -758,6 +770,7 @@ describe("EventV2", () => {
},
{
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 3,
aggregateID,
@ -793,6 +806,7 @@ describe("EventV2", () => {
yield* events.replay(
{
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 1,
aggregateID,
@ -812,6 +826,7 @@ describe("EventV2", () => {
const id = EventV2.ID.create()
const replayed = {
id,
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
@ -833,6 +848,7 @@ describe("EventV2", () => {
const published = yield* events.publish(DurableMessage, durableData(aggregateID, "owned"))
const replayed = {
id: published.id,
created: published.created,
type: EventV2.versionedType(DurableMessage.type, 1),
seq: published.durable!.seq,
aggregateID,
@ -867,6 +883,7 @@ describe("EventV2", () => {
yield* events.replay(
{
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
@ -895,6 +912,7 @@ describe("EventV2", () => {
yield* events.replay(
{
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 1,
aggregateID,
@ -905,6 +923,7 @@ describe("EventV2", () => {
yield* events.replay(
{
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 2,
aggregateID,
@ -937,6 +956,7 @@ describe("EventV2", () => {
yield* events.replay(
{
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
@ -949,6 +969,7 @@ describe("EventV2", () => {
.replay(
{
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 1,
aggregateID,
@ -970,6 +991,7 @@ describe("EventV2", () => {
yield* events.listen((event) => Effect.sync(() => received.push(event)))
const replayed = {
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
@ -990,6 +1012,7 @@ describe("EventV2", () => {
const aggregateID = Session.ID.create()
const replayed = {
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
@ -1014,6 +1037,7 @@ describe("EventV2", () => {
const id = EventV2.ID.create()
yield* events.replay({
id,
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
@ -1023,6 +1047,7 @@ describe("EventV2", () => {
const exit = yield* events
.replay({
id,
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 1,
aggregateID,
@ -1045,6 +1070,7 @@ describe("EventV2", () => {
yield* events.replay(
{
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
@ -1055,6 +1081,7 @@ describe("EventV2", () => {
yield* events.replay(
{
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 1,
aggregateID,
@ -1116,6 +1143,7 @@ describe("EventV2", () => {
yield* events.replay({
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,

View file

@ -85,17 +85,13 @@ describe("SessionV2.compact", () => {
const prompt = Prompt.make({ text: "Please compact this session history." })
yield* events.publish(SessionEvent.PromptAdmitted, {
sessionID: created.id,
messageID,
timestamp: DateTime.makeUnsafe(0),
inputID: messageID,
prompt,
delivery: "steer",
})
yield* events.publish(SessionEvent.Prompted, {
yield* events.publish(SessionEvent.PromptPromoted, {
sessionID: created.id,
messageID,
timestamp: DateTime.makeUnsafe(0),
prompt,
delivery: "steer",
inputID: messageID,
})
yield* session.compact({ sessionID: created.id })

View file

@ -197,8 +197,6 @@ describe("SessionV2.create", () => {
yield* SessionInput.promoteSteers(db, events, parent.id)
yield* events.publish(SessionEvent.Synthetic, {
sessionID: parent.id,
messageID: SessionMessage.ID.create(),
timestamp: yield* DateTime.now,
text: "parent note",
})
@ -215,7 +213,7 @@ describe("SessionV2.create", () => {
expect(forkContext.map((message) => message.id)).not.toEqual(parentContext.map((message) => message.id))
expect(history).toHaveLength(1)
expect(history[0]).toMatchObject({
type: "session.next.forked",
type: "forked",
durable: { seq: 0 },
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)))
expect(context).toMatchObject([{ text: "First" }])
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(
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(2), Stream.runCollect)),
).toMatchObject([
{ durable: { seq: 1 }, type: "session.next.prompt.admitted", data: { prompt: { text: "Hello" } } },
{ durable: { seq: 2 }, type: "session.next.prompted" },
{ durable: { seq: 1 }, type: "prompt.admitted", data: { prompt: { text: "Hello" } } },
{ durable: { seq: 2 }, type: "prompt.promoted" },
])
}),
)
@ -406,6 +404,7 @@ describe("SessionV2.create", () => {
.all()
.pipe(Effect.orDie)).map((event) => ({
id: event.id,
created: DateTime.makeUnsafe(event.created),
aggregateID: event.aggregate_id,
seq: event.seq,
type: event.type,
@ -466,7 +465,7 @@ describe("SessionV2.create", () => {
).toEqual([
[0, EventV2.versionedType(SessionV1.Event.Created.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)))
}),
@ -530,7 +529,7 @@ describe("SessionV2.create", () => {
expect(yield* session.get(created.id)).toMatchObject({ agent: "plan" })
expect(
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(
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 } }])
}),
)

View file

@ -133,8 +133,6 @@ const seedSynthetic = (sessionID: SessionV2.ID, paths: string[]) =>
const events = yield* EventV2.Service
yield* events.publish(SessionEvent.Synthetic, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: yield* DateTime.now,
text: `Instructions from: ${paths[0]}\nprior`,
description: `Loaded ${paths[0]}`,
metadata: { instruction: { paths } },

View file

@ -47,7 +47,7 @@ describe("SessionV2.log", () => {
// Session creation commits a non-public durable event, so the marker's
// 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 })
}),
)
@ -64,7 +64,7 @@ describe("SessionV2.log", () => {
yield* session.rename({ sessionID: created.id, title: "renamed live" })
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"])
}),
)

View file

@ -79,7 +79,6 @@ describe("SessionProjector", () => {
const events = yield* EventV2.Service
yield* events.publish(SessionEvent.RevertEvent.Staged, {
sessionID,
timestamp: DateTime.makeUnsafe(1),
revert: { messageID: boundary, snapshot: Snapshot.ID.make("tree"), diff: "patch", files: [] },
})
expect((yield* db.select({ revert: SessionTable.revert }).from(SessionTable).get())?.revert).toMatchObject({
@ -87,17 +86,15 @@ describe("SessionProjector", () => {
snapshot: "tree",
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()
yield* events.publish(SessionEvent.RevertEvent.Staged, {
sessionID,
timestamp: DateTime.makeUnsafe(3),
revert: { messageID: boundary, files: [] },
})
yield* events.publish(SessionEvent.RevertEvent.Committed, {
sessionID,
messageID: boundary,
timestamp: DateTime.makeUnsafe(4),
})
expect(
(yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all()).map((row) => row.id),
@ -131,37 +128,29 @@ describe("SessionProjector", () => {
yield* events.publish(SessionEvent.PromptAdmitted, {
sessionID,
messageID: SessionMessage.ID.make("msg_first"),
timestamp: created,
inputID: SessionMessage.ID.make("msg_first"),
prompt: Prompt.make({ text: "first" }),
delivery: "steer",
})
yield* events.publish(
SessionEvent.Prompted,
SessionEvent.PromptPromoted,
{
sessionID,
messageID: SessionMessage.ID.make("msg_first"),
timestamp: created,
prompt: Prompt.make({ text: "first" }),
delivery: "steer",
inputID: SessionMessage.ID.make("msg_first"),
},
{ id: EventV2.ID.make("evt_z") },
)
yield* events.publish(SessionEvent.PromptAdmitted, {
sessionID,
messageID: SessionMessage.ID.make("msg_second"),
timestamp: created,
inputID: SessionMessage.ID.make("msg_second"),
prompt: Prompt.make({ text: "second" }),
delivery: "steer",
})
yield* events.publish(
SessionEvent.Prompted,
SessionEvent.PromptPromoted,
{
sessionID,
messageID: SessionMessage.ID.make("msg_second"),
timestamp: created,
prompt: Prompt.make({ text: "second" }),
delivery: "steer",
inputID: SessionMessage.ID.make("msg_second"),
},
{ id: EventV2.ID.make("evt_a") },
)
@ -190,7 +179,7 @@ describe("SessionProjector", () => {
}).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* () {
const { db } = yield* Database.Service
yield* db
@ -220,12 +209,9 @@ describe("SessionProjector", () => {
})
if (!admitted) return yield* Effect.die("Prompt admission failed")
const event = yield* events.publish(SessionEvent.Prompted, {
const event = yield* events.publish(SessionEvent.PromptPromoted, {
sessionID,
timestamp: admitted.timeCreated,
messageID: id,
prompt: Prompt.make({ text: "promote me" }),
delivery: "steer",
inputID: id,
})
expect(
@ -256,49 +242,36 @@ describe("SessionProjector", () => {
.pipe(Effect.orDie)
const events = yield* EventV2.Service
yield* events.publish(SessionEvent.AgentSwitched, {
yield* events.publish(SessionEvent.AgentSelected, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: created,
agent: "build",
})
yield* events.publish(SessionEvent.ModelSwitched, {
yield* events.publish(SessionEvent.ModelSelected, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: created,
model,
})
yield* events.publish(SessionEvent.Synthetic, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: created,
text: "synthetic context",
metadata: { source: "projector-test" },
})
yield* events.publish(SessionEvent.Shell.Started, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: created,
callID: "shell-1",
command: "pwd",
})
yield* events.publish(SessionEvent.Shell.Ended, {
sessionID,
timestamp: DateTime.makeUnsafe(1),
callID: "shell-1",
output: "/project",
})
const compactionID = SessionMessage.ID.create()
yield* events.publish(SessionEvent.Compaction.Started, {
sessionID,
messageID: compactionID,
timestamp: created,
reason: "manual",
})
yield* events.publish(SessionEvent.Compaction.Delta, {
sessionID,
messageID: compactionID,
timestamp: created,
text: "partial",
})
expect(
@ -319,8 +292,6 @@ describe("SessionProjector", () => {
).toEqual([])
yield* events.publish(SessionEvent.Compaction.Ended, {
sessionID,
messageID: compactionID,
timestamp: DateTime.makeUnsafe(1),
reason: "manual",
text: "summary",
recent: "recent context",
@ -350,7 +321,7 @@ describe("SessionProjector", () => {
})
expect(messages.find((message) => message.type === "shell")).toMatchObject({
output: "/project",
time: { completed: DateTime.makeUnsafe(1) },
time: { completed: DateTime.makeUnsafe(0) },
})
expect(messages.find((message) => message.type === "compaction")).toMatchObject({
summary: "summary",
@ -388,13 +359,20 @@ describe("SessionProjector", () => {
.pipe(Effect.orDie)
const events = yield* EventV2.Service
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
.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID: id,
timestamp: created,
agent: "build",
model,
})
@ -464,7 +442,6 @@ describe("SessionProjector", () => {
const service = yield* EventV2.Service
yield* service.publish(SessionEvent.Step.Ended, {
sessionID,
timestamp: DateTime.makeUnsafe(1),
assistantMessageID: SessionMessage.ID.make("msg_assistant_2"),
finish: "stop",
cost: 0,
@ -485,7 +462,7 @@ describe("SessionProjector", () => {
expect(messages[1]).toMatchObject({
type: "assistant",
finish: "stop",
time: { completed: DateTime.makeUnsafe(1) },
time: { completed: DateTime.makeUnsafe(0) },
})
}),
)
@ -526,7 +503,6 @@ describe("SessionProjector", () => {
yield* service.publish(SessionEvent.Text.Started, {
sessionID,
assistantMessageID: SessionMessage.ID.make("msg_assistant_completed"),
timestamp: DateTime.makeUnsafe(3),
textID: "text-stale",
})

View file

@ -201,7 +201,6 @@ describe("SessionV2.prompt", () => {
yield* db.insert(SessionMessageTable).values(assistantRow(stale, 100)).run().pipe(Effect.orDie)
yield* events.publish(SessionEvent.RevertEvent.Staged, {
sessionID,
timestamp: yield* DateTime.now,
revert: { messageID: boundary.id, files: [] },
})
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))
expect(streamed.map((event): [number | undefined, string] => [event.durable?.seq, event.type])).toEqual([
[0, "session.next.prompt.admitted"],
[1, "session.next.prompt.admitted"],
[2, "session.next.prompted"],
[3, "session.next.prompted"],
[0, "prompt.admitted"],
[1, "prompt.admitted"],
[2, "prompt.promoted"],
[3, "prompt.promoted"],
])
expect(
Array.from(
yield* publicEvents({ sessionID, after: streamed[0].durable?.seq }).pipe(Stream.take(1), Stream.runCollect),
).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" },
)
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* session.messages({ sessionID })).toMatchObject([
{ id: messageID, type: "user", text: "Promote once" },
@ -467,6 +466,7 @@ describe("SessionV2.prompt", () => {
yield* events.replayAll(
recorded.map((event) => ({
id: event.id,
created: DateTime.makeUnsafe(event.created),
aggregateID: event.aggregate_id,
seq: event.seq,
type: event.type,
@ -514,13 +514,23 @@ describe("SessionV2.prompt", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
yield* events.publish(SessionEvent.Synthetic, {
const { db } = yield* Database.Service
const {
id: _,
type,
...data
} = encodeMessage({
id: messageID,
sessionID,
messageID,
timestamp: yield* DateTime.now,
type: "synthetic",
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
.prompt({ id: messageID, sessionID, prompt: Prompt.make({ text: "Conflicting prompt" }), resume: false })

View file

@ -51,13 +51,13 @@ describe("toLLMMessages", () => {
const file = FileAttachment.make({ uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" })
const messages = toLLMMessages(
[
SessionMessage.AgentSwitched.make({
SessionMessage.AgentSelected.make({
id: id("agent"),
type: "agent-switched",
agent: "build",
time: { created },
}),
SessionMessage.ModelSwitched.make({
SessionMessage.ModelSelected.make({
id: id("model"),
type: "model-switched",
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },

View file

@ -193,12 +193,12 @@ describe("SessionRunnerLLM recorded", () => {
.orderBy(EventTable.seq)
.all()).map((event) => event.type),
).toEqual([
"session.next.prompt.admitted.1",
"session.next.prompted.1",
"session.next.step.started.1",
"session.next.text.started.1",
"session.next.text.ended.1",
"session.next.step.ended.2",
"prompt.admitted.1",
"prompt.promoted.1",
"step.started.1",
"text.started.1",
"text.ended.1",
"step.ended.1",
])
}),
)

View file

@ -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(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()
const serialized = JSON.stringify(success)
expect(serialized.split(base64)).toHaveLength(2)
@ -94,7 +94,7 @@ test("provider-executed success retains its compatibility result", async () => {
const { published, publisher } = capture()
await Effect.runPromise(publisher.publish(LLMEvent.toolCall({ ...call, 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")
})
@ -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 === "session.next.tool.failed.1")).toBe(true)
expect(published.some((event) => event.type === "tool.success.1")).toBe(false)
expect(published.some((event) => event.type === "tool.failed.1")).toBe(true)
})
test("old success event data containing result still decodes", () => {
const decoded = Schema.decodeUnknownSync(SessionEvent.Tool.Success.data)({
sessionID,
timestamp: Date.now(),
assistantMessageID: SessionMessage.ID.create(),
callID: "call-old",
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.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" })
})

View file

@ -423,6 +423,7 @@ const replaySessionProjection = (id: SessionV2.ID) =>
yield* events.replayAll(
recorded.map((event) => ({
id: event.id,
created: DateTime.makeUnsafe(event.created),
aggregateID: event.aggregate_id,
seq: event.seq,
type: event.type,
@ -740,7 +741,6 @@ describe("SessionRunnerLLM", () => {
yield* events.publish(SessionEvent.Moved, {
sessionID,
timestamp: DateTime.makeUnsafe(1),
location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }),
})
expect(
@ -848,7 +848,7 @@ describe("SessionRunnerLLM", () => {
yield* db
.select({ id: EventTable.id })
.from(EventTable)
.where(eq(EventTable.type, "session.next.context.updated.1"))
.where(eq(EventTable.type, "session.context.updated.1"))
.all()
.pipe(Effect.orDie),
).toHaveLength(1)
@ -1010,10 +1010,8 @@ describe("SessionRunnerLLM", () => {
response = []
yield* session.resume(sessionID)
skillBaselines.set(AgentV2.ID.make("reviewer"), "Reviewer skills")
yield* events.publish(SessionEvent.AgentSwitched, {
yield* events.publish(SessionEvent.AgentSelected, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1),
agent: "reviewer",
})
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
@ -1039,10 +1037,8 @@ describe("SessionRunnerLLM", () => {
if (switched) return Effect.void
switched = true
return events
.publish(SessionEvent.AgentSwitched, {
.publish(SessionEvent.AgentSelected, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1),
agent: "reviewer",
})
.pipe(Effect.asVoid)
@ -1069,10 +1065,8 @@ describe("SessionRunnerLLM", () => {
if (switched) return Effect.void
switched = true
return events
.publish(SessionEvent.ModelSwitched, {
.publish(SessionEvent.ModelSelected, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1),
model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
})
.pipe(Effect.asVoid)
@ -1175,10 +1169,8 @@ describe("SessionRunnerLLM", () => {
systemBaseline = "Changed context"
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
yield* session.resume(sessionID)
yield* events.publish(SessionEvent.ModelSwitched, {
yield* events.publish(SessionEvent.ModelSelected, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1),
model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
})
systemBaseline = "Replacement context"
@ -1217,10 +1209,8 @@ describe("SessionRunnerLLM", () => {
requests.length = 0
response = []
yield* session.resume(sessionID)
yield* events.publish(SessionEvent.ModelSwitched, {
yield* events.publish(SessionEvent.ModelSelected, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1),
model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
})
systemUnavailable = true
@ -1252,14 +1242,10 @@ describe("SessionRunnerLLM", () => {
const compactionID = SessionMessage.ID.create()
yield* events.publish(SessionEvent.Compaction.Started, {
sessionID,
messageID: compactionID,
timestamp: DateTime.makeUnsafe(1),
reason: "manual",
})
yield* events.publish(SessionEvent.Compaction.Ended, {
sessionID,
messageID: compactionID,
timestamp: DateTime.makeUnsafe(2),
reason: "manual",
text: "summary",
recent: "",
@ -1482,14 +1468,10 @@ describe("SessionRunnerLLM", () => {
const compactionID = SessionMessage.ID.create()
yield* events.publish(SessionEvent.Compaction.Started, {
sessionID,
messageID: compactionID,
timestamp: DateTime.makeUnsafe(1),
reason: "manual",
})
yield* events.publish(SessionEvent.Compaction.Ended, {
sessionID,
messageID: compactionID,
timestamp: DateTime.makeUnsafe(2),
reason: "manual",
text: "summary",
recent: "",
@ -1686,10 +1668,8 @@ describe("SessionRunnerLLM", () => {
toolExecutionsReady = 1
const run = yield* Effect.forkChild(session.resume(sessionID))
yield* Deferred.await(toolExecutionsStarted)
yield* events.publish(SessionEvent.ModelSwitched, {
yield* events.publish(SessionEvent.ModelSelected, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1),
model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
})
systemBaseline = "Replacement context"
@ -2403,27 +2383,23 @@ describe("SessionRunnerLLM", () => {
yield* events.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID,
timestamp: yield* DateTime.now,
agent: "build",
model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") },
})
yield* events.publish(SessionEvent.Tool.Input.Started, {
sessionID,
timestamp: yield* DateTime.now,
assistantMessageID,
callID: "call-interrupted",
name: "echo",
})
yield* events.publish(SessionEvent.Tool.Input.Ended, {
sessionID,
timestamp: yield* DateTime.now,
assistantMessageID,
callID: "call-interrupted",
text: '{"text":"stale"}',
})
yield* events.publish(SessionEvent.Tool.Called, {
sessionID,
timestamp: yield* DateTime.now,
assistantMessageID,
callID: "call-interrupted",
tool: "echo",
@ -2467,27 +2443,23 @@ describe("SessionRunnerLLM", () => {
yield* events.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID,
timestamp: yield* DateTime.now,
agent: "build",
model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") },
})
yield* events.publish(SessionEvent.Tool.Input.Started, {
sessionID,
timestamp: yield* DateTime.now,
assistantMessageID,
callID: "call-hosted-interrupted",
name: "web_search",
})
yield* events.publish(SessionEvent.Tool.Input.Ended, {
sessionID,
timestamp: yield* DateTime.now,
assistantMessageID,
callID: "call-hosted-interrupted",
text: '{"query":"stale"}',
})
yield* events.publish(SessionEvent.Tool.Called, {
sessionID,
timestamp: yield* DateTime.now,
assistantMessageID,
callID: "call-hosted-interrupted",
tool: "web_search",
@ -2527,13 +2499,11 @@ describe("SessionRunnerLLM", () => {
yield* events.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID,
timestamp: yield* DateTime.now,
agent: "build",
model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") },
})
yield* events.publish(SessionEvent.Tool.Input.Started, {
sessionID,
timestamp: yield* DateTime.now,
assistantMessageID,
callID: "call-pending-interrupted",
name: "echo",
@ -2578,7 +2548,7 @@ describe("SessionRunnerLLM", () => {
const events = yield* EventV2.Service
const defect = new Error("fail after prompt promotion")
let fail = true
yield* events.project(SessionEvent.Prompted, () => (fail ? Effect.die(defect) : Effect.void))
yield* events.project(SessionEvent.PromptPromoted, () => (fail ? Effect.die(defect) : Effect.void))
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)
@ -2603,7 +2573,9 @@ describe("SessionRunnerLLM", () => {
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
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({
sessionID,
@ -3013,7 +2985,7 @@ describe("SessionRunnerLLM", () => {
{ type: "user", text: "Interrupt provider" },
{ 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)
}),
)
@ -3057,8 +3029,8 @@ describe("SessionRunnerLLM", () => {
},
])
const eventTypes = yield* recordedEventTypes(sessionID)
expect(eventTypes).toContain("session.next.step.failed.2")
expect(eventTypes).not.toContain("session.next.step.ended.2")
expect(eventTypes).toContain("step.failed.1")
expect(eventTypes).not.toContain("step.ended.1")
}),
)

View file

@ -86,17 +86,13 @@ const prompt = (sessionID: SessionV2.ID, text: string) =>
const messageID = SessionMessage.ID.create()
yield* events.publish(SessionEvent.PromptAdmitted, {
sessionID,
messageID,
timestamp: DateTime.makeUnsafe(0),
inputID: messageID,
prompt: Prompt.make({ text }),
delivery: "steer",
})
yield* events.publish(SessionEvent.Prompted, {
yield* events.publish(SessionEvent.PromptPromoted, {
sessionID,
messageID,
timestamp: DateTime.makeUnsafe(0),
prompt: Prompt.make({ text }),
delivery: "steer",
inputID: messageID,
})
})

View file

@ -51,7 +51,6 @@ describe("Tool.Progress", () => {
yield* service.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID,
timestamp,
agent: "build",
model,
})
@ -69,14 +68,12 @@ describe("Tool.Progress", () => {
Effect.gen(function* () {
yield* service.publish(SessionEvent.Tool.Input.Started, {
sessionID,
timestamp,
assistantMessageID,
callID,
name: "bash",
})
yield* service.publish(SessionEvent.Tool.Called, {
sessionID,
timestamp,
assistantMessageID,
callID,
tool: "bash",
@ -92,7 +89,6 @@ describe("Tool.Progress", () => {
yield* service.publish(SessionEvent.Tool.Progress, {
sessionID,
timestamp,
assistantMessageID,
callID: "call-success",
structured: { phase: "checkpoint" },
@ -104,7 +100,6 @@ describe("Tool.Progress", () => {
const success = yield* service.publish(SessionEvent.Tool.Success, {
sessionID,
timestamp,
assistantMessageID,
callID: "call-success",
structured: { phase: "done" },
@ -118,7 +113,6 @@ describe("Tool.Progress", () => {
yield* start("call-failed")
yield* service.publish(SessionEvent.Tool.Progress, {
sessionID,
timestamp,
assistantMessageID,
callID: "call-failed",
structured: { phase: "checkpoint" },
@ -126,7 +120,6 @@ describe("Tool.Progress", () => {
})
const failed = yield* service.publish(SessionEvent.Tool.Failed, {
sessionID,
timestamp,
assistantMessageID,
callID: "call-failed",
error: { type: "unknown", message: "boom" },

View file

@ -148,8 +148,8 @@ test("Core reuses the canonical shared schemas", async () => {
[coreSessionInput.Admitted, SessionInput.Admitted],
[coreSessionMessage.ID, SessionMessage.ID],
[coreSessionMessage.UnknownError, SessionMessage.UnknownError],
[coreSessionMessage.AgentSwitched, SessionMessage.AgentSwitched],
[coreSessionMessage.ModelSwitched, SessionMessage.ModelSwitched],
[coreSessionMessage.AgentSelected, SessionMessage.AgentSelected],
[coreSessionMessage.ModelSelected, SessionMessage.ModelSelected],
[coreSessionMessage.User, SessionMessage.User],
[coreSessionMessage.Synthetic, SessionMessage.Synthetic],
[coreSessionMessage.System, SessionMessage.System],

View file

@ -79,27 +79,23 @@ const executionNode = makeGlobalNode({
yield* events.publish(SessionEvent.Step.Started, {
sessionID: id,
assistantMessageID,
timestamp: yield* DateTime.now,
agent: session.agent ?? AgentV2.ID.make("code"),
model: sessionModel,
})
yield* events.publish(SessionEvent.Text.Started, {
sessionID: id,
assistantMessageID,
timestamp: yield* DateTime.now,
textID,
})
yield* events.publish(SessionEvent.Text.Ended, {
sessionID: id,
assistantMessageID,
timestamp: yield* DateTime.now,
textID,
text: "ok",
})
yield* events.publish(SessionEvent.Step.Ended, {
sessionID: id,
assistantMessageID,
timestamp: yield* DateTime.now,
finish: "stop",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },

View file

@ -53,27 +53,23 @@ const executionNode = makeGlobalNode({
yield* events.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID,
timestamp: yield* DateTime.now,
agent: AgentV2.ID.make("reviewer"),
model: childModel,
})
yield* events.publish(SessionEvent.Text.Started, {
sessionID,
assistantMessageID,
timestamp: yield* DateTime.now,
textID,
})
yield* events.publish(SessionEvent.Text.Ended, {
sessionID,
assistantMessageID,
timestamp: yield* DateTime.now,
textID,
text: childText,
})
yield* events.publish(SessionEvent.Step.Ended, {
sessionID,
assistantMessageID,
timestamp: yield* DateTime.now,
finish: "stop",
cost: 0,
tokens,

View file

@ -156,17 +156,16 @@ export async function runNonInteractivePrompt(input: Input) {
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.data.messageID === messageID) {
if (event.type === "prompt.promoted") {
if (event.data.inputID === messageID) {
promoted = true
continue
}
if (promoted && event.data.delivery === "queue") return
}
if (
event.type === "session.next.execution.settled" &&
event.type === "execution.settled" &&
event.data.outcome === "interrupted" &&
(interrupted || permissionRejected || questionRejected || formCancelled)
) {
@ -174,7 +173,7 @@ export async function runNonInteractivePrompt(input: Input) {
}
if (!promoted) continue
if (event.type === "session.next.step.started") {
if (event.type === "step.started") {
const part: StepStartPart = {
id: partID(event.id),
sessionID: input.sessionID,
@ -190,11 +189,11 @@ export async function runNonInteractivePrompt(input: Input) {
continue
}
if (event.type === "session.next.text.started") {
if (event.type === "text.started") {
starts.set(event.data.textID, { id: partID(event.id), timestamp: time })
continue
}
if (event.type === "session.next.text.ended") {
if (event.type === "text.ended") {
const started = starts.get(event.data.textID)
const part: TextPart = {
id: started?.id ?? partID(event.id),
@ -208,11 +207,11 @@ export async function runNonInteractivePrompt(input: Input) {
continue
}
if (event.type === "session.next.reasoning.started") {
if (event.type === "reasoning.started") {
starts.set(event.data.reasoningID, { id: partID(event.id), timestamp: time })
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 part: ReasoningPart = {
id: started?.id ?? partID(event.id),
@ -237,7 +236,7 @@ export async function runNonInteractivePrompt(input: Input) {
continue
}
if (event.type === "session.next.tool.input.started") {
if (event.type === "tool.input.started") {
tools.set(event.data.callID, {
id: partID(event.id),
timestamp: time,
@ -247,12 +246,12 @@ export async function runNonInteractivePrompt(input: Input) {
})
continue
}
if (event.type === "session.next.tool.input.ended") {
if (event.type === "tool.input.ended") {
const current = tools.get(event.data.callID)
if (current) current.raw = event.data.text
continue
}
if (event.type === "session.next.tool.called") {
if (event.type === "tool.called") {
const current = tools.get(event.data.callID)
tools.set(event.data.callID, {
id: current?.id ?? partID(event.id),
@ -265,7 +264,7 @@ export async function runNonInteractivePrompt(input: Input) {
})
continue
}
if (event.type === "session.next.tool.success") {
if (event.type === "tool.success") {
const current = tools.get(event.data.callID) ?? fallbackTool(event)
const part: ToolPart = {
id: current.id,
@ -298,7 +297,7 @@ export async function runNonInteractivePrompt(input: Input) {
if (!emit("tool_use", time, { part })) await input.renderTool(part)
continue
}
if (event.type === "session.next.tool.failed") {
if (event.type === "tool.failed") {
const current = tools.get(event.data.callID) ?? fallbackTool(event)
const error = event.data.error.message
const part: ToolPart = {
@ -329,7 +328,7 @@ export async function runNonInteractivePrompt(input: Input) {
continue
}
if (event.type === "session.next.step.ended") {
if (event.type === "step.ended") {
const part: StepFinishPart = {
id: partID(event.id),
sessionID: input.sessionID,
@ -343,19 +342,19 @@ export async function runNonInteractivePrompt(input: Input) {
emit("step_finish", time, { part })
continue
}
if (event.type === "session.next.step.failed") {
if (event.type === "step.failed") {
if (interrupted || permissionRejected || questionRejected || formCancelled) continue
emittedError = true
process.exitCode = 1
if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message)
continue
}
if (event.type === "session.next.execution.settled") {
if (event.type === "execution.settled") {
if (event.data.outcome === "failure" && !emittedError && !questionRejected && !formCancelled) {
emittedError = true
process.exitCode = 1
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
return
@ -458,11 +457,12 @@ function partID(eventID: string) {
function fallbackTool(event: {
id: string
data: { timestamp: number; assistantMessageID: string; callID: string }
created: number
data: { assistantMessageID: string; callID: string }
}): ToolState {
return {
id: partID(event.id),
timestamp: toMillis(event.data.timestamp),
timestamp: toMillis(event.created),
assistantMessageID: event.data.assistantMessageID,
tool: "tool",
input: {},

View file

@ -728,7 +728,7 @@ export function reduceSessionData(input: SessionDataInput): SessionDataOutput {
const data = input.data
const event = input.event
if (event.type === "session.next.shell.started") {
if (event.type === "shell.started") {
if (event.properties.sessionID !== input.sessionID) {
return out(data, commits)
}
@ -748,7 +748,7 @@ export function reduceSessionData(input: SessionDataInput): SessionDataOutput {
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) {
return out(data, commits)
}

View file

@ -424,21 +424,21 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
}
const reduce = (child: ChildState, event: V2Event) => {
if (event.type === "session.next.prompted") {
if (userFrame(child, event.data.messageID, event.data.prompt.text)) {
touch(child, event.data.timestamp)
if (event.type === "prompt.promoted") {
if (userFrame(child, event.data.inputID, "")) {
touch(child, event.created)
notifyDetail(child)
}
return
}
if (event.type === "session.next.step.started") {
touch(child, event.data.timestamp)
if (event.type === "step.started") {
touch(child, event.created)
if (child.label === FALLBACK_LABEL && event.data.agent) child.label = Locale.titlecase(event.data.agent)
if (child.status !== "running") child.status = "running"
input.emit()
return
}
if (event.type === "session.next.text.delta") {
if (event.type === "text.delta") {
const projected = child.projectedText.get(event.data.textID)
const covered = projected?.indexOf(event.data.delta) ?? -1
if (projected && covered >= 0) {
@ -455,11 +455,11 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
messageID: event.data.assistantMessageID,
partID: event.data.textID,
})
touch(child, event.data.timestamp)
touch(child, event.created)
notifyDetail(child)
return
}
if (event.type === "session.next.text.ended") {
if (event.type === "text.ended") {
child.text.set(event.data.textID, event.data.text)
child.projectedText.delete(event.data.textID)
setFrame(child, `text:${event.data.textID}`, {
@ -470,11 +470,11 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
messageID: event.data.assistantMessageID,
partID: event.data.textID,
})
touch(child, event.data.timestamp)
touch(child, event.created)
notifyDetail(child)
return
}
if (event.type === "session.next.reasoning.delta") {
if (event.type === "reasoning.delta") {
const projected = child.projectedReasoning.get(event.data.reasoningID)
const covered = projected?.indexOf(event.data.delta) ?? -1
if (projected && covered >= 0) {
@ -495,7 +495,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
notifyDetail(child)
return
}
if (event.type === "session.next.reasoning.ended") {
if (event.type === "reasoning.ended") {
child.reasoning.set(event.data.reasoningID, event.data.text)
child.projectedReasoning.delete(event.data.reasoningID)
if (!input.thinking) return
@ -510,16 +510,16 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
notifyDetail(child)
return
}
if (event.type === "session.next.tool.input.started") {
child.tools.set(event.data.callID, { name: event.data.name, input: {}, started: event.data.timestamp })
if (event.type === "tool.input.started") {
child.tools.set(event.data.callID, { name: event.data.name, input: {}, started: event.created })
return
}
if (event.type === "session.next.tool.called") {
if (event.type === "tool.called") {
const current = child.tools.get(event.data.callID)
child.tools.set(event.data.callID, {
name: event.data.tool,
input: event.data.input,
started: current?.started ?? event.data.timestamp,
started: current?.started ?? event.created,
})
childTool(
child,
@ -529,18 +529,18 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
name: event.data.tool,
provider: event.data.provider,
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,
)
touch(child, event.data.timestamp)
touch(child, event.created)
notifyDetail(child)
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
const current = child.tools.get(event.data.callID)
const failed = event.type === "session.next.tool.failed"
const failed = event.type === "tool.failed"
childTool(
child,
{
@ -566,18 +566,18 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
result: event.data.result,
},
time: {
created: current?.started ?? event.data.timestamp,
created: current?.started ?? event.created,
ran: current?.started,
completed: event.data.timestamp,
completed: event.created,
},
},
event.data.assistantMessageID,
)
touch(child, event.data.timestamp)
touch(child, event.created)
notifyDetail(child)
return
}
if (event.type === "session.next.step.failed") {
if (event.type === "step.failed") {
setFrame(child, `error:step:${event.data.assistantMessageID}`, {
kind: "error",
source: "system",
@ -585,14 +585,14 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
phase: "start",
messageID: event.data.assistantMessageID,
})
touch(child, event.data.timestamp)
touch(child, event.created)
notifyDetail(child)
return
}
if (event.type === "session.next.execution.settled") {
if (event.type === "execution.settled") {
child.status =
event.data.outcome === "success" ? "completed" : event.data.outcome === "interrupted" ? "cancelled" : "error"
touch(child, event.data.timestamp)
touch(child, event.created)
input.emit()
}
}
@ -613,15 +613,15 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
return {
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)
return
}
if (event.type === "session.next.tool.failed") {
if (event.type === "tool.failed") {
pendingCalls.delete(event.data.callID)
return
}
if (event.type !== "session.next.tool.success") return
if (event.type !== "tool.success") return
const pending = pendingCalls.get(event.data.callID)
pendingCalls.delete(event.data.callID)
const found = childSessionID(record(event.data.structured))
@ -633,7 +633,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
child.status = "running"
}
if (!found.running && child.status === "running") child.status = "completed"
touch(child, event.data.timestamp)
touch(child, event.created)
input.emit()
if (!child.hydrated) void hydrateChild(child)
},

View file

@ -294,7 +294,14 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
if (!state.tools.get(item.id)?.running) write([toolCommit(part, "start")])
state.finishedTools.add(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) => {
@ -395,17 +402,17 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
}
input.trace?.write("recv.event", event)
subagents.main(event)
if (event.type === "session.next.prompted") {
if (state.wait?.messageID === event.data.messageID) state.wait.promoted = true
state.messageIDs.add(event.data.messageID)
if (event.type === "prompt.promoted") {
if (state.wait?.messageID === event.data.inputID) state.wait.promoted = true
state.messageIDs.add(event.data.inputID)
write([], { phase: "running", status: "waiting for assistant" })
return
}
if (event.type === "session.next.step.started") {
if (event.type === "step.started") {
write([], { phase: "running", status: "assistant responding" })
return
}
if (event.type === "session.next.text.delta") {
if (event.type === "text.delta") {
const key = streamPartKey(event.data.assistantMessageID, event.data.textID)
const projected = state.projectedText.get(key)
const covered = projected?.indexOf(event.data.delta) ?? -1
@ -427,7 +434,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
])
return
}
if (event.type === "session.next.text.ended") {
if (event.type === "text.ended") {
const key = streamPartKey(event.data.assistantMessageID, event.data.textID)
const previous = state.text.get(key) ?? ""
if (event.data.text.length > previous.length)
@ -445,7 +452,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
state.projectedText.delete(key)
return
}
if (event.type === "session.next.reasoning.delta") {
if (event.type === "reasoning.delta") {
const key = streamPartKey(event.data.assistantMessageID, event.data.reasoningID)
const projected = state.projectedReasoning.get(key)
const covered = projected?.indexOf(event.data.delta) ?? -1
@ -468,7 +475,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
])
return
}
if (event.type === "session.next.reasoning.ended") {
if (event.type === "reasoning.ended") {
const key = streamPartKey(event.data.assistantMessageID, event.data.reasoningID)
const previous = state.reasoning.get(key) ?? ""
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)
return
}
if (event.type === "session.next.tool.input.started") {
if (event.type === "tool.input.started") {
state.tools.set(event.data.callID, {
messageID: event.data.assistantMessageID,
name: event.data.name,
input: {},
started: event.data.timestamp,
started: event.created,
running: false,
})
return
}
if (event.type === "session.next.tool.called") {
if (event.type === "tool.called") {
if (state.finishedTools.has(event.data.callID)) return
const current = state.tools.get(event.data.callID)
const item: SessionMessageAssistantTool = {
@ -505,22 +512,29 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
name: event.data.tool,
provider: event.data.provider,
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)
return
}
if (event.type === "session.next.tool.progress") return
if (event.type === "session.next.tool.success" || event.type === "session.next.tool.failed") {
if (event.type === "tool.progress") return
if (event.type === "tool.success" || event.type === "tool.failed") {
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 = {
type: "tool",
id: event.data.callID,
name: current?.name ?? "tool",
provider: event.data.provider,
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",
input: current?.input ?? {},
@ -529,7 +543,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
outputPaths: event.data.outputPaths,
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)
return
@ -554,7 +568,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
syncBlockers()
return
}
if (event.type === "session.next.step.ended") {
if (event.type === "step.ended") {
const total =
event.data.tokens.input +
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.write
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
}
if (event.type === "session.next.step.failed") {
if (event.type === "step.failed") {
state.errors.add(event.data.assistantMessageID)
if (state.wait) state.wait.failureRendered = true
write([{ kind: "error", source: "system", text: errorMessage(event.data.error), phase: "start" }])
return
}
if (event.type === "session.next.execution.settled") {
if (event.type === "execution.settled") {
write([], { phase: "idle", status: "" })
const current = state.wait
if (!current || (!current.promoted && !current.interrupted)) return
@ -611,7 +628,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
sseMaxRetryAttempts: 0,
throwOnError: true,
})
const stream = response.stream[Symbol.asyncIterator]() as AsyncGenerator<RunV2Event>
const stream = response.stream[Symbol.asyncIterator]() as AsyncGenerator<RunV2Event>
try {
const first = await stream.next()
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 = [
...prepared.flatMap((file) => (file.attachment ? [file.attachment] : [])),
...promptFiles,
]
const attachments = [...prepared.flatMap((file) => (file.attachment ? [file.attachment] : [])), ...promptFiles]
const agents = next.prompt.parts.flatMap((part) =>
part.type === "agent"
? [
@ -735,10 +749,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
sessionID: input.sessionID,
id: messageID,
prompt: {
text: [
next.prompt.text,
...prepared.flatMap((file) => (file.text ? [file.text] : [])),
].join("\n\n"),
text: [next.prompt.text, ...prepared.flatMap((file) => (file.text ? [file.text] : []))].join("\n\n"),
files: attachments.length ? attachments : undefined,
agents: agents.length ? agents : undefined,
},

View file

@ -18,31 +18,33 @@ function form(id: string, sessionID: string): FormInfo {
}
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 {
id: "evt_prompted",
type: "session.next.prompted",
created: 0,
type: "prompt.promoted",
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 {
return {
id: "evt_settled",
type: "session.next.execution.settled",
data: { timestamp: 2, sessionID: "ses_1", outcome },
created: 0,
type: "execution.settled",
data: { sessionID: "ses_1", outcome },
}
}
// Runs one non-interactive prompt against a mocked SDK. `turn` produces the
// 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 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
const stream = (async function* (): AsyncGenerator<V2Event, void, unknown> {
while (true) {

View file

@ -329,7 +329,7 @@ describe("run session data", () => {
test("renders direct shell mode from first-class shell events", () => {
let data = createSessionData()
const started = reduce(data, {
type: "session.next.shell.started",
type: "shell.started",
properties: {
sessionID: "session-1",
timestamp: 1,
@ -353,7 +353,7 @@ describe("run session data", () => {
data = started.data
const ended = reduce(data, {
type: "session.next.shell.ended",
type: "shell.ended",
properties: {
sessionID: "session-1",
timestamp: 2,
@ -380,7 +380,7 @@ describe("run session data", () => {
test("suppresses legacy bash part updates once shell events claim the call", () => {
let data = reduce(createSessionData(), {
type: "session.next.shell.started",
type: "shell.started",
properties: {
sessionID: "session-1",
timestamp: 1,
@ -409,7 +409,7 @@ describe("run session data", () => {
).toEqual([])
data = reduce(data, {
type: "session.next.shell.ended",
type: "shell.ended",
properties: {
sessionID: "session-1",
timestamp: 2,
@ -463,7 +463,7 @@ describe("run session data", () => {
expect(
reduce(data, {
type: "session.next.shell.started",
type: "shell.started",
properties: {
sessionID: "session-1",
timestamp: 1,
@ -497,7 +497,7 @@ describe("run session data", () => {
expect(
reduce(data, {
type: "session.next.shell.ended",
type: "shell.ended",
properties: {
sessionID: "session-1",
timestamp: 2,

View file

@ -50,7 +50,7 @@ function ok<T>(data: T) {
}
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) {
@ -125,22 +125,20 @@ function sdk(input: {
spyOn(client.v2.session, "switchModel").mockImplementation(() => ok(undefined))
// The generated methods have conditional return types for throwOnError; the
// minimal shapes below are enough for family discovery and model fallback.
spyOn(client.v2.session, "list").mockImplementation(
(request) => {
const parentID = request?.parentID
return ok({
location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
data:
input.sessions?.filter((session) =>
parentID === undefined
? true
: parentID === null
? session.parentID === undefined
: session.parentID === parentID,
) ?? [],
}) as never
},
)
spyOn(client.v2.session, "list").mockImplementation((request) => {
const parentID = request?.parentID
return ok({
location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
data:
input.sessions?.filter((session) =>
parentID === undefined
? true
: parentID === null
? session.parentID === undefined
: session.parentID === parentID,
) ?? [],
}) as never
})
spyOn(client.v2.model, "default").mockImplementation(
() =>
ok({
@ -201,21 +199,19 @@ describe("V2 mini transport", () => {
while (!admitted) await Bun.sleep(0)
events.push({
id: "evt_prompted",
type: "session.next.prompted",
created: 0,
type: "prompt.promoted",
durable: durable("ses_1"),
data: {
timestamp: 2,
sessionID: "ses_1",
messageID: "msg_prompt",
prompt: { text: "hello" },
delivery: "steer",
inputID: "msg_prompt",
},
})
events.push({
id: "evt_text",
type: "session.next.text.delta",
created: 0,
type: "text.delta",
data: {
timestamp: 3,
sessionID: "ses_1",
assistantMessageID: "msg_assistant",
textID: "txt_1",
@ -224,8 +220,9 @@ describe("V2 mini transport", () => {
})
events.push({
id: "evt_settled",
type: "session.next.execution.settled",
data: { timestamp: 4, sessionID: "ses_1", outcome: "success" },
created: 0,
type: "execution.settled",
data: { sessionID: "ses_1", outcome: "success" },
})
await turn
@ -261,20 +258,19 @@ describe("V2 mini transport", () => {
queueMicrotask(() => {
events.push({
id: "evt_prompted",
type: "session.next.prompted",
created: 0,
type: "prompt.promoted",
durable: durable("ses_1"),
data: {
timestamp: 2,
sessionID: "ses_1",
messageID: "msg_prompt",
prompt: { text: input.prompt?.text ?? "" },
delivery: "steer",
inputID: "msg_prompt",
},
})
events.push({
id: "evt_settled",
type: "session.next.execution.settled",
data: { timestamp: 3, sessionID: "ses_1", outcome: "success" },
created: 0,
type: "execution.settled",
data: { sessionID: "ses_1", outcome: "success" },
})
})
return ok({
@ -356,20 +352,19 @@ describe("V2 mini transport", () => {
queueMicrotask(() => {
events.push({
id: "evt_prompted",
type: "session.next.prompted",
created: 0,
type: "prompt.promoted",
durable: durable("ses_1"),
data: {
timestamp: 2,
sessionID: "ses_1",
messageID: "msg_prompt",
prompt: { text: input.prompt?.text ?? "" },
delivery: "steer",
inputID: "msg_prompt",
},
})
events.push({
id: "evt_settled",
type: "session.next.execution.settled",
data: { timestamp: 3, sessionID: "ses_1", outcome: "success" },
created: 0,
type: "execution.settled",
data: { sessionID: "ses_1", outcome: "success" },
})
})
return ok({
@ -454,20 +449,19 @@ describe("V2 mini transport", () => {
queueMicrotask(() => {
events.push({
id: "evt_prompted",
type: "session.next.prompted",
created: 0,
type: "prompt.promoted",
durable: durable("ses_1"),
data: {
timestamp: 2,
sessionID: "ses_1",
messageID: "msg_prompt",
prompt: { text: input.prompt?.text ?? "" },
delivery: "steer",
inputID: "msg_prompt",
},
})
events.push({
id: "evt_settled",
type: "session.next.execution.settled",
data: { timestamp: 3, sessionID: "ses_1", outcome: "success" },
created: 0,
type: "execution.settled",
data: { sessionID: "ses_1", outcome: "success" },
})
})
return ok({
@ -528,6 +522,7 @@ describe("V2 mini transport", () => {
})
events.push({
id: "evt_permission",
created: 0,
type: "permission.v2.asked",
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 })
events.push({
id: "evt_text",
type: "session.next.text.delta",
created: 0,
type: "text.delta",
data: {
timestamp: 3,
sessionID: "ses_1",
assistantMessageID: "msg_assistant",
textID: "txt_1",
@ -813,10 +808,10 @@ describe("V2 mini transport", () => {
})
events.push({
id: "evt_reasoning",
type: "session.next.reasoning.ended",
created: 0,
type: "reasoning.ended",
durable: durable("ses_1"),
data: {
timestamp: 3,
sessionID: "ses_1",
assistantMessageID: "msg_assistant",
reasoningID: "reasoning_1",
@ -869,8 +864,9 @@ describe("V2 mini transport", () => {
await transport.interruptActiveTurn()
events.push({
id: "evt_settled",
type: "session.next.execution.settled",
data: { timestamp: 3, sessionID: "ses_1", outcome: "interrupted" },
created: 0,
type: "execution.settled",
data: { sessionID: "ses_1", outcome: "success" },
})
await turn
@ -924,20 +920,19 @@ describe("V2 mini transport", () => {
while (!admitted) await Bun.sleep(0)
events.push({
id: "evt_prompted",
type: "session.next.prompted",
created: 0,
type: "prompt.promoted",
durable: durable("ses_1"),
data: {
timestamp: 2,
sessionID: "ses_1",
messageID: "msg_prompt",
prompt: { text: "hello" },
delivery: "steer",
inputID: "msg_prompt",
},
})
events.push({
id: "evt_settled",
type: "session.next.execution.settled",
data: { timestamp: 3, sessionID: "ses_1", outcome: "success" },
created: 0,
type: "execution.settled",
data: { sessionID: "ses_1", outcome: "success" },
})
await turn
@ -985,22 +980,21 @@ describe("V2 mini transport", () => {
while (!admitted) await Bun.sleep(0)
events.push({
id: "evt_prompted",
type: "session.next.prompted",
created: 0,
type: "prompt.promoted",
durable: durable("ses_1"),
data: {
timestamp: 2,
sessionID: "ses_1",
messageID: "msg_prompt",
prompt: { text: "hello" },
delivery: "steer",
inputID: "msg_prompt",
},
})
await Bun.sleep(0)
controller.abort()
events.push({
id: "evt_settled",
type: "session.next.execution.settled",
data: { timestamp: 3, sessionID: "ses_1", outcome: "interrupted" },
created: 0,
type: "execution.settled",
data: { sessionID: "ses_1", outcome: "success" },
})
await turn
@ -1054,10 +1048,10 @@ describe("V2 mini transport", () => {
events.push({
id: "evt_child_step",
type: "session.next.step.started",
created: 0,
type: "step.started",
durable: durable("ses_child"),
data: {
timestamp: 2,
sessionID: "ses_child",
assistantMessageID: "msg_child_a",
agent: "explore",
@ -1072,9 +1066,9 @@ describe("V2 mini transport", () => {
events.push({
id: "evt_child_text",
type: "session.next.text.delta",
created: 0,
type: "text.delta",
data: {
timestamp: 3,
sessionID: "ses_child",
assistantMessageID: "msg_child_a",
textID: "txt_child",
@ -1086,8 +1080,9 @@ describe("V2 mini transport", () => {
events.push({
id: "evt_child_settled",
type: "session.next.execution.settled",
data: { timestamp: 4, sessionID: "ses_child", outcome: "success" },
created: 0,
type: "execution.settled",
data: { sessionID: "ses_child", outcome: "success" },
})
while (!states().some((state) => state.tabs.some((tab) => tab.status === "completed"))) await Bun.sleep(0)
await transport.close()
@ -1130,10 +1125,10 @@ describe("V2 mini transport", () => {
// Both events arrive while session.get is still in flight.
events.push({
id: "evt_child_step",
type: "session.next.step.started",
created: 0,
type: "step.started",
durable: durable("ses_child"),
data: {
timestamp: 2,
sessionID: "ses_child",
assistantMessageID: "msg_child_a",
agent: "explore",
@ -1142,8 +1137,9 @@ describe("V2 mini transport", () => {
})
events.push({
id: "evt_child_settled",
type: "session.next.execution.settled",
data: { timestamp: 3, sessionID: "ses_child", outcome: "interrupted" },
created: 0,
type: "execution.settled",
data: { sessionID: "ses_child", outcome: "interrupted" },
})
await Bun.sleep(0)
resolveGet?.()
@ -1188,10 +1184,10 @@ describe("V2 mini transport", () => {
// Child event arrives first and gets buffered behind the gated session.get.
events.push({
id: "evt_child_step",
type: "session.next.step.started",
created: 0,
type: "step.started",
durable: durable("ses_child"),
data: {
timestamp: 2,
sessionID: "ses_child",
assistantMessageID: "msg_child_a",
agent: "explore",
@ -1201,10 +1197,10 @@ describe("V2 mini transport", () => {
// Parent's background subagent tool.success adopts the child mid-discovery.
events.push({
id: "evt_parent_call",
type: "session.next.tool.called",
created: 0,
type: "tool.called",
durable: durable("ses_1"),
data: {
timestamp: 3,
sessionID: "ses_1",
assistantMessageID: "msg_parent_a",
callID: "call_sub",
@ -1215,10 +1211,10 @@ describe("V2 mini transport", () => {
})
events.push({
id: "evt_parent_success",
type: "session.next.tool.success",
created: 0,
type: "tool.success",
durable: durable("ses_1", 1),
data: {
timestamp: 4,
sessionID: "ses_1",
assistantMessageID: "msg_parent_a",
callID: "call_sub",
@ -1230,8 +1226,9 @@ describe("V2 mini transport", () => {
// The settled event arrives after adoption, so it applies directly.
events.push({
id: "evt_child_settled",
type: "session.next.execution.settled",
data: { timestamp: 5, sessionID: "ses_child", outcome: "interrupted" },
created: 0,
type: "execution.settled",
data: { sessionID: "ses_child", outcome: "interrupted" },
})
while (!states().some((state) => state.tabs.some((tab) => tab.status === "cancelled"))) await Bun.sleep(0)

View file

@ -21,12 +21,12 @@ test.skip("step snapshots carry over to assistant messages", () => {
Effect.runSync(
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
id: EventV2.ID.create(),
type: "session.next.step.started",
created: DateTime.makeUnsafe(0),
type: "step.started",
durable: durable(sessionID),
data: {
sessionID,
assistantMessageID,
timestamp: DateTime.makeUnsafe(1),
agent: "build",
model: {
id: ModelV2.ID.make("model"),
@ -43,12 +43,12 @@ test.skip("step snapshots carry over to assistant messages", () => {
Effect.runSync(
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
id: EventV2.ID.create(),
type: "session.next.step.ended",
created: DateTime.makeUnsafe(0),
type: "step.ended",
durable: durable(sessionID, 1, 2),
data: {
sessionID,
assistantMessageID,
timestamp: DateTime.makeUnsafe(2),
finish: "stop",
cost: 0,
tokens: {
@ -76,12 +76,12 @@ test.skip("text ended populates assistant text content", () => {
Effect.runSync(
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
id: EventV2.ID.create(),
type: "session.next.step.started",
created: DateTime.makeUnsafe(0),
type: "step.started",
durable: durable(sessionID),
data: {
sessionID,
assistantMessageID,
timestamp: DateTime.makeUnsafe(1),
agent: "build",
model: {
id: ModelV2.ID.make("model"),
@ -95,12 +95,12 @@ test.skip("text ended populates assistant text content", () => {
Effect.runSync(
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
id: EventV2.ID.create(),
type: "session.next.text.started",
created: DateTime.makeUnsafe(0),
type: "text.started",
durable: durable(sessionID, 1),
data: {
sessionID,
assistantMessageID,
timestamp: DateTime.makeUnsafe(2),
textID: "text-1",
},
} satisfies SessionEvent.Event),
@ -109,12 +109,12 @@ test.skip("text ended populates assistant text content", () => {
Effect.runSync(
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
id: EventV2.ID.create(),
type: "session.next.text.ended",
created: DateTime.makeUnsafe(0),
type: "text.ended",
durable: durable(sessionID, 2),
data: {
sessionID,
assistantMessageID,
timestamp: DateTime.makeUnsafe(3),
textID: "text-1",
text: "hello assistant",
},
@ -135,12 +135,12 @@ test.skip("tool completion stores completed timestamp", () => {
Effect.runSync(
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
id: EventV2.ID.create(),
type: "session.next.step.started",
created: DateTime.makeUnsafe(0),
type: "step.started",
durable: durable(sessionID),
data: {
sessionID,
assistantMessageID,
timestamp: DateTime.makeUnsafe(1),
agent: "build",
model: {
id: ModelV2.ID.make("model"),
@ -154,12 +154,12 @@ test.skip("tool completion stores completed timestamp", () => {
Effect.runSync(
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
id: EventV2.ID.create(),
type: "session.next.tool.input.started",
created: DateTime.makeUnsafe(0),
type: "tool.input.started",
durable: durable(sessionID, 1),
data: {
sessionID,
assistantMessageID,
timestamp: DateTime.makeUnsafe(2),
callID,
name: "bash",
},
@ -169,12 +169,12 @@ test.skip("tool completion stores completed timestamp", () => {
Effect.runSync(
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
id: EventV2.ID.create(),
type: "session.next.tool.called",
created: DateTime.makeUnsafe(0),
type: "tool.called",
durable: durable(sessionID, 2),
data: {
sessionID,
assistantMessageID,
timestamp: DateTime.makeUnsafe(3),
callID,
tool: "bash",
input: { command: "pwd" },
@ -186,12 +186,12 @@ test.skip("tool completion stores completed timestamp", () => {
Effect.runSync(
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
id: EventV2.ID.create(),
type: "session.next.tool.success",
created: DateTime.makeUnsafe(0),
type: "tool.success",
durable: durable(sessionID, 3),
data: {
sessionID,
assistantMessageID,
timestamp: DateTime.makeUnsafe(4),
callID,
structured: {},
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 sessionID = SessionID.make("session")
const id = EventV2.ID.create()
const compactionID = SessionMessage.ID.create()
const endedID = EventV2.ID.create()
Effect.runSync(
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
id,
type: "session.next.compaction.started",
created: DateTime.makeUnsafe(0),
type: "compaction.started",
durable: durable(sessionID),
data: {
sessionID,
messageID: compactionID,
timestamp: DateTime.makeUnsafe(1),
reason: "auto",
},
} satisfies SessionEvent.Event),
@ -233,11 +232,10 @@ test("compaction events reduce to compaction message only when completed", () =>
Effect.runSync(
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
id: EventV2.ID.create(),
type: "session.next.compaction.delta",
created: DateTime.makeUnsafe(0),
type: "compaction.delta",
data: {
sessionID,
messageID: compactionID,
timestamp: DateTime.makeUnsafe(2),
text: "hello ",
},
} satisfies SessionEvent.Event),
@ -246,11 +244,10 @@ test("compaction events reduce to compaction message only when completed", () =>
Effect.runSync(
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
id: EventV2.ID.create(),
type: "session.next.compaction.delta",
created: DateTime.makeUnsafe(0),
type: "compaction.delta",
data: {
sessionID,
messageID: compactionID,
timestamp: DateTime.makeUnsafe(3),
text: "summary",
},
} satisfies SessionEvent.Event),
@ -258,13 +255,12 @@ test("compaction events reduce to compaction message only when completed", () =>
Effect.runSync(
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
id: EventV2.ID.create(),
type: "session.next.compaction.ended",
id: endedID,
created: DateTime.makeUnsafe(0),
type: "compaction.ended",
durable: durable(sessionID, 1),
data: {
sessionID,
messageID: compactionID,
timestamp: DateTime.makeUnsafe(4),
reason: "auto",
text: "final summary",
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[0]).toMatchObject({
id: compactionID,
id: SessionMessage.ID.fromEvent(endedID),
type: "compaction",
reason: "auto",
summary: "final summary",
recent: "recent context",
time: { created: DateTime.makeUnsafe(4) },
time: { created: DateTime.makeUnsafe(0) },
})
})

View file

@ -4,7 +4,7 @@ import { Schema } from "effect"
import { optional } from "./schema.js"
import { ascending } from "./identifier.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(
Schema.brand("Event.ID"),
@ -60,6 +60,7 @@ export type Data<D extends Definition> = Schema.Schema.Type<D["data"]>
type PayloadBase<D extends Definition> = {
readonly id: ID
readonly type: D["type"]
readonly created: typeof DateTimeUtcFromMillis.Type
readonly data: Data<D>
readonly location?: Location.Ref
readonly metadata?: Record<string, unknown>
@ -85,6 +86,7 @@ export function durable<
const data = Schema.Struct(input.schema)
return Schema.Struct({
id: ID,
created: DateTimeUtcFromMillis,
metadata: optional(Schema.Record(Schema.String, Schema.Unknown)),
type: Schema.Literal(input.type),
durable: DurableEnvelope,
@ -109,6 +111,7 @@ export function ephemeral<
const data = Schema.Struct(input.schema)
return Schema.Struct({
id: ID,
created: DateTimeUtcFromMillis,
metadata: optional(Schema.Record(Schema.String, Schema.Unknown)),
type: Schema.Literal(input.type),
location: optional(Location.Ref),

View file

@ -6,7 +6,7 @@ import { Event } from "./event.js"
import { ProviderMetadata, ToolContent } from "./llm.js"
import { Delivery } from "./session-delivery.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 { SessionID } from "./session-id.js"
import { Location } from "./location.js"
@ -20,17 +20,16 @@ export const Source = Schema.Struct({
end: NonNegativeInt,
text: Schema.String,
}).annotate({
identifier: "session.next.event.source",
identifier: "session.event.source",
})
export interface Source extends Schema.Schema.Type<typeof Source> {}
const Base = {
timestamp: DateTimeUtcFromMillis,
sessionID: SessionID,
}
const PromptFields = {
...Base,
messageID: SessionMessage.ID,
inputID: SessionMessage.ID,
prompt: Prompt,
delivery: Delivery,
}
@ -44,48 +43,46 @@ const options = {
const stepSettlementOptions = {
durable: {
aggregate: "sessionID",
version: 2,
version: 1,
},
} as const
export const UnknownError = SessionMessage.UnknownError
export type UnknownError = SessionMessage.UnknownError
export const AgentSwitched = Event.durable({
type: "session.next.agent.switched",
export const AgentSelected = Event.durable({
type: "agent.selected",
...options,
schema: {
...Base,
messageID: SessionMessage.ID,
agent: Schema.String,
},
})
export type AgentSwitched = typeof AgentSwitched.Type
export type AgentSelected = typeof AgentSelected.Type
export const ModelSwitched = Event.durable({
type: "session.next.model.switched",
export const ModelSelected = Event.durable({
type: "model.selected",
...options,
schema: {
...Base,
messageID: SessionMessage.ID,
model: Model.Ref,
},
})
export type ModelSwitched = typeof ModelSwitched.Type
export type ModelSelected = typeof ModelSelected.Type
export const Moved = Event.durable({
type: "session.next.moved",
type: "session.moved",
...options,
schema: {
...Base,
location: Location.Ref,
subdirectory: RelativePath.pipe(optional),
subpath: RelativePath.pipe(optional),
},
})
export type Moved = typeof Moved.Type
export const Renamed = Event.durable({
type: "session.next.renamed",
type: "renamed",
...options,
schema: {
...Base,
@ -95,32 +92,35 @@ export const Renamed = Event.durable({
export type Renamed = typeof Renamed.Type
export const Forked = Event.durable({
type: "session.next.forked",
type: "forked",
...options,
schema: {
...Base,
parentID: SessionID,
messageID: SessionMessage.ID.pipe(optional),
from: SessionMessage.ID.pipe(optional),
},
})
export type Forked = typeof Forked.Type
export const Prompted = Event.durable({
type: "session.next.prompted",
export const PromptPromoted = Event.durable({
type: "prompt.promoted",
...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({
type: "session.next.prompt.admitted",
type: "prompt.admitted",
...options,
schema: PromptFields,
})
export type PromptAdmitted = typeof PromptAdmitted.Type
export const ExecutionSettled = Event.ephemeral({
type: "session.next.execution.settled",
type: "execution.settled",
schema: {
...Base,
outcome: Schema.Literals(["success", "failure", "interrupted"]),
@ -130,22 +130,20 @@ export const ExecutionSettled = Event.ephemeral({
export type ExecutionSettled = typeof ExecutionSettled.Type
export const ContextUpdated = Event.durable({
type: "session.next.context.updated",
type: "session.context.updated",
...options,
schema: {
...Base,
messageID: SessionMessage.ID,
text: Schema.String,
},
})
export type ContextUpdated = typeof ContextUpdated.Type
export const Synthetic = Event.durable({
type: "session.next.synthetic",
type: "synthetic",
...options,
schema: {
...Base,
messageID: SessionMessage.ID,
text: Schema.String,
description: Schema.String.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 const Activated = Event.durable({
type: "session.next.skill.activated",
type: "skill.activated",
...options,
schema: {
...Base,
messageID: SessionMessage.ID,
name: Schema.String,
text: Schema.String,
},
@ -169,11 +166,10 @@ export namespace Skill {
export namespace Shell {
export const Started = Event.durable({
type: "session.next.shell.started",
type: "shell.started",
...options,
schema: {
...Base,
messageID: SessionMessage.ID,
callID: Schema.String,
command: Schema.String,
},
@ -181,7 +177,7 @@ export namespace Shell {
export type Started = typeof Started.Type
export const Ended = Event.durable({
type: "session.next.shell.ended",
type: "shell.ended",
...options,
schema: {
...Base,
@ -194,7 +190,7 @@ export namespace Shell {
export namespace Step {
export const Started = Event.durable({
type: "session.next.step.started",
type: "step.started",
...options,
schema: {
...Base,
@ -207,7 +203,7 @@ export namespace Step {
export type Started = typeof Started.Type
export const Ended = Event.durable({
type: "session.next.step.ended",
type: "step.ended",
...stepSettlementOptions,
schema: {
...Base,
@ -230,7 +226,7 @@ export namespace Step {
export type Ended = typeof Ended.Type
export const Failed = Event.durable({
type: "session.next.step.failed",
type: "step.failed",
...stepSettlementOptions,
schema: {
...Base,
@ -243,7 +239,7 @@ export namespace Step {
export namespace Text {
export const Started = Event.durable({
type: "session.next.text.started",
type: "text.started",
...options,
schema: {
...Base,
@ -255,7 +251,7 @@ export namespace Text {
// Stream fragments are live-only; Text.Ended is the replayable full-value boundary.
export const Delta = Event.ephemeral({
type: "session.next.text.delta",
type: "text.delta",
schema: {
...Base,
assistantMessageID: SessionMessage.ID,
@ -266,7 +262,7 @@ export namespace Text {
export type Delta = typeof Delta.Type
export const Ended = Event.durable({
type: "session.next.text.ended",
type: "text.ended",
...options,
schema: {
...Base,
@ -280,7 +276,7 @@ export namespace Text {
export namespace Reasoning {
export const Started = Event.durable({
type: "session.next.reasoning.started",
type: "reasoning.started",
...options,
schema: {
...Base,
@ -293,7 +289,7 @@ export namespace Reasoning {
// Stream fragments are live-only; Reasoning.Ended is the replayable full-value boundary.
export const Delta = Event.ephemeral({
type: "session.next.reasoning.delta",
type: "reasoning.delta",
schema: {
...Base,
assistantMessageID: SessionMessage.ID,
@ -304,7 +300,7 @@ export namespace Reasoning {
export type Delta = typeof Delta.Type
export const Ended = Event.durable({
type: "session.next.reasoning.ended",
type: "reasoning.ended",
...options,
schema: {
...Base,
@ -326,7 +322,7 @@ export namespace Tool {
export namespace Input {
export const Started = Event.durable({
type: "session.next.tool.input.started",
type: "tool.input.started",
...options,
schema: {
...ToolBase,
@ -337,7 +333,7 @@ export namespace Tool {
// Stream fragments are live-only; Input.Ended is the replayable raw-input boundary.
export const Delta = Event.ephemeral({
type: "session.next.tool.input.delta",
type: "tool.input.delta",
schema: {
...ToolBase,
delta: Schema.String,
@ -346,7 +342,7 @@ export namespace Tool {
export type Delta = typeof Delta.Type
export const Ended = Event.durable({
type: "session.next.tool.input.ended",
type: "tool.input.ended",
...options,
schema: {
...ToolBase,
@ -357,7 +353,7 @@ export namespace Tool {
}
export const Called = Event.durable({
type: "session.next.tool.called",
type: "tool.called",
...options,
schema: {
...ToolBase,
@ -376,7 +372,7 @@ export namespace Tool {
* transitions or at a bounded cadence, not persist every stdout/stderr chunk.
*/
export const Progress = Event.durable({
type: "session.next.tool.progress",
type: "tool.progress",
...options,
schema: {
...ToolBase,
@ -387,7 +383,7 @@ export namespace Tool {
export type Progress = typeof Progress.Type
export const Success = Event.durable({
type: "session.next.tool.success",
type: "tool.success",
...options,
schema: {
...ToolBase,
@ -404,7 +400,7 @@ export namespace Tool {
export type Success = typeof Success.Type
export const Failed = Event.durable({
type: "session.next.tool.failed",
type: "tool.failed",
...options,
schema: {
...ToolBase,
@ -427,12 +423,12 @@ export const RetryError = Schema.Struct({
responseBody: Schema.String.pipe(optional),
metadata: Schema.Record(Schema.String, Schema.String).pipe(optional),
}).annotate({
identifier: "session.next.retry_error",
identifier: "session.retry.error",
})
export interface RetryError extends Schema.Schema.Type<typeof RetryError> {}
export const Retried = Event.durable({
type: "session.next.retried",
type: "retried",
...options,
schema: {
...Base,
@ -444,32 +440,29 @@ export type Retried = typeof Retried.Type
export namespace Compaction {
export const Started = Event.durable({
type: "session.next.compaction.started",
type: "compaction.started",
...options,
schema: {
...Base,
messageID: SessionMessage.ID,
reason: Schema.Union([Schema.Literal("auto"), Schema.Literal("manual")]),
},
})
export type Started = typeof Started.Type
export const Delta = Event.ephemeral({
type: "session.next.compaction.delta",
type: "compaction.delta",
schema: {
...Base,
messageID: SessionMessage.ID,
text: Schema.String,
},
})
export type Delta = typeof Delta.Type
export const Ended = Event.durable({
type: "session.next.compaction.ended",
type: "compaction.ended",
...options,
schema: {
...Base,
messageID: SessionMessage.ID,
reason: Started.data.fields.reason,
text: Schema.String,
recent: Schema.String,
@ -480,25 +473,25 @@ export namespace Compaction {
export namespace RevertEvent {
export const Staged = Event.durable({
type: "session.next.revert.staged",
type: "revert.staged",
...options,
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({
type: "session.next.revert.committed",
type: "revert.committed",
...options,
schema: { ...Base, messageID: SessionMessage.ID },
})
}
export const Definitions = Event.inventory(
AgentSwitched,
ModelSwitched,
AgentSelected,
ModelSelected,
Moved,
Renamed,
Forked,
Prompted,
PromptPromoted,
PromptAdmitted,
ExecutionSettled,
ContextUpdated,

View file

@ -8,10 +8,14 @@ import { FileAttachment, Prompt } from "./prompt.js"
import { DateTimeUtcFromMillis, RelativePath, statics } from "./schema.js"
import { SessionID } from "./session-id.js"
import { ascending } from "./identifier.js"
import { Event } from "./event.js"
export const ID = Schema.String.check(Schema.isStartsWith("msg_")).pipe(
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
@ -27,19 +31,19 @@ const Base = {
time: Schema.Struct({ created: DateTimeUtcFromMillis }),
}
export interface AgentSwitched extends Schema.Schema.Type<typeof AgentSwitched> {}
export const AgentSwitched = Schema.Struct({
export interface AgentSelected extends Schema.Schema.Type<typeof AgentSelected> {}
export const AgentSelected = Schema.Struct({
...Base,
type: Schema.Literal("agent-switched"),
agent: Schema.String,
}).annotate({ identifier: "Session.Message.AgentSwitched" })
}).annotate({ identifier: "Session.Message.AgentSelected" })
export interface ModelSwitched extends Schema.Schema.Type<typeof ModelSwitched> {}
export const ModelSwitched = Schema.Struct({
export interface ModelSelected extends Schema.Schema.Type<typeof ModelSelected> {}
export const ModelSelected = Schema.Struct({
...Base,
type: Schema.Literal("model-switched"),
model: Model.Ref,
}).annotate({ identifier: "Session.Message.ModelSwitched" })
}).annotate({ identifier: "Session.Message.ModelSelected" })
export interface User extends Schema.Schema.Type<typeof User> {}
export const User = Schema.Struct({
@ -207,8 +211,8 @@ export const Compaction = Schema.Struct({
}).annotate({ identifier: "Session.Message.Compaction" })
export const Message = Schema.Union([
AgentSwitched,
ModelSwitched,
AgentSelected,
ModelSelected,
User,
Synthetic,
System,
@ -219,5 +223,5 @@ export const Message = Schema.Union([
])
.pipe(Schema.toTaggedUnion("type"))
.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"]

View file

@ -52,7 +52,7 @@ describe("public event manifest", () => {
expect(Session.Event.Definitions).toBe(SessionEvent.Definitions)
expect(Workspace.Event).toBe(WorkspaceEvent)
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("agent.updated")).toBe(Agent.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.Error,
])
expect(EventManifest.Durable.has("session.next.step.ended.1")).toBe(false)
expect(EventManifest.Durable.get("session.next.step.ended.2")).toBe(SessionEvent.Step.Ended)
expect(EventManifest.Durable.get("step.ended.1")).toBe(SessionEvent.Step.Ended)
expect(EventManifest.Durable.has("step.ended.2")).toBe(false)
})
test("derives durable definitions from explicit definition durability", () => {
@ -85,37 +85,37 @@ describe("public event manifest", () => {
"message.removed.1",
"message.part.updated.1",
"message.part.removed.1",
"session.next.agent.switched.1",
"session.next.model.switched.1",
"session.next.moved.1",
"session.next.renamed.1",
"session.next.forked.1",
"session.next.prompted.1",
"session.next.prompt.admitted.1",
"session.next.context.updated.1",
"session.next.synthetic.1",
"session.next.skill.activated.1",
"session.next.shell.started.1",
"session.next.shell.ended.1",
"session.next.step.started.1",
"session.next.step.ended.2",
"session.next.step.failed.2",
"session.next.text.started.1",
"session.next.text.ended.1",
"session.next.tool.input.started.1",
"session.next.tool.input.ended.1",
"session.next.tool.called.1",
"session.next.tool.progress.1",
"session.next.tool.success.1",
"session.next.tool.failed.1",
"session.next.reasoning.started.1",
"session.next.reasoning.ended.1",
"session.next.retried.1",
"session.next.compaction.started.1",
"session.next.compaction.ended.1",
"session.next.revert.staged.1",
"session.next.revert.cleared.1",
"session.next.revert.committed.1",
"agent.selected.1",
"model.selected.1",
"session.moved.1",
"renamed.1",
"forked.1",
"prompt.promoted.1",
"prompt.admitted.1",
"session.context.updated.1",
"synthetic.1",
"skill.activated.1",
"shell.started.1",
"shell.ended.1",
"step.started.1",
"step.ended.1",
"step.failed.1",
"text.started.1",
"text.ended.1",
"tool.input.started.1",
"tool.input.ended.1",
"tool.called.1",
"tool.progress.1",
"tool.success.1",
"tool.failed.1",
"reasoning.started.1",
"reasoning.ended.1",
"retried.1",
"compaction.started.1",
"compaction.ended.1",
"revert.staged.1",
"revert.cleared.1",
"revert.committed.1",
].toSorted(),
)
expect(SessionEvent.DurableDefinitions).toEqual(

View file

@ -78,7 +78,7 @@ it.live(
prompt: fixture.sdk.Prompt.make({ text: "Promote this input" }),
})
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,
Effect.timeout("10 seconds"),
Effect.map(Option.getOrThrow),
@ -119,7 +119,7 @@ it.live(
expect(page.data.some((session) => session.id === id)).toBe(true)
expect(active).toEqual({ data: {}, watermarks: {} })
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(contextEntries).toEqual([
{ key: "deploy-target", value: "production" },
@ -127,7 +127,7 @@ it.live(
])
expect(remainingContextEntries).toEqual([{ key: "deploy-target", value: "production" }])
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(missing.map((error) => error._tag)).toEqual([
"SessionNotFoundError",
@ -149,13 +149,13 @@ it.live(
const opencode = yield* fixture.sdk.OpenCode.create()
const id = sessionID(fixture)
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(
Stream.runForEach((event) =>
event.type === "server.connected"
? 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)
: Effect.void,
),
@ -191,7 +191,7 @@ it.live(
Stream.runForEach((notification: OpenCodeEvent) =>
notification.type === "server.connected"
? ready.open
: notification.type === "session.next.agent.switched" && notification.data.sessionID === id
: notification.type === "agent.selected" && notification.data.sessionID === id
? event.open
: Effect.void,
)

View file

@ -59,7 +59,13 @@ if (schemas) {
}
visit({ ...document, components: { ...document.components, schemas: undefined } })
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))
}
@ -93,7 +99,11 @@ await createClient({
const generatedTypesPath = "./src/v2/gen/types.gen.ts"
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")
}
const logTypesPatched = generatedTypes.replace(

File diff suppressed because it is too large Load diff

View file

@ -27,6 +27,8 @@ import { createSignal, onCleanup } from "solid-js"
export type DataSessionStatus = "idle" | "running"
const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_")
type LocationData = {
agent?: AgentV2Info[]
command?: CommandV2Info[]
@ -181,17 +183,21 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
const info = store.session.info[sessionID]
if (!info) return
const rootID = resolveRoot(sessionID)
setStore("session", "family", produce((draft) => {
if (sessionID !== rootID && draft[sessionID]) {
const members = draft[rootID] ??= []
for (const id of draft[sessionID]) {
if (!members.includes(id)) members.push(id)
setStore(
"session",
"family",
produce((draft) => {
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) {
@ -214,124 +220,113 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
case "skill.updated":
void result.location.skill.refresh(event.location)
break
case "session.next.agent.switched":
case "agent.selected":
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "agent", event.data.agent)
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: event.data.messageID,
id: messageIDFromEvent(event.id),
type: "agent-switched",
agent: event.data.agent,
time: { created: event.data.timestamp },
time: { created: event.created },
})
})
break
case "session.next.model.switched":
case "model.selected":
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "model", event.data.model)
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: event.data.messageID,
id: messageIDFromEvent(event.id),
type: "model-switched",
model: event.data.model,
time: { created: event.data.timestamp },
time: { created: event.created },
})
})
break
case "session.next.renamed":
case "renamed":
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "title", event.data.title)
break
case "session.next.prompted": {
case "prompt.promoted": {
setStore("session", "status", event.data.sessionID, "running")
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]
if (existing?.type === "user") {
existing.text = event.data.prompt.text
existing.files = event.data.prompt.files
existing.agents = event.data.prompt.agents
existing.time.created = event.data.timestamp
existing.time.created = event.created
if (existing.metadata?.queued === true) {
delete existing.metadata.queued
if (Object.keys(existing.metadata).length === 0) existing.metadata = undefined
}
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
}
case "session.next.prompt.admitted":
case "prompt.admitted":
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: event.data.messageID,
id: event.data.inputID,
type: "user",
text: event.data.prompt.text,
files: event.data.prompt.files,
agents: event.data.prompt.agents,
metadata: { queued: true },
time: { created: event.data.timestamp },
time: { created: event.created },
})
})
break
case "session.next.context.updated":
case "session.context.updated":
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: event.data.messageID,
id: messageIDFromEvent(event.id),
type: "system",
text: event.data.text,
time: { created: event.data.timestamp },
time: { created: event.created },
})
})
break
case "session.next.synthetic":
case "synthetic":
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: event.data.messageID,
id: messageIDFromEvent(event.id),
type: "synthetic",
sessionID: event.data.sessionID,
text: event.data.text,
description: event.data.description,
time: { created: event.data.timestamp },
time: { created: event.created },
})
})
break
case "session.next.shell.started":
case "shell.started":
setStore("session", "status", event.data.sessionID, "running")
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: event.data.messageID,
id: messageIDFromEvent(event.id),
type: "shell",
callID: event.data.callID,
command: event.data.command,
output: "",
time: { created: event.data.timestamp },
time: { created: event.created },
})
})
break
case "session.next.shell.ended":
case "shell.ended":
setStore("session", "status", event.data.sessionID, "idle")
message.update(event.data.sessionID, (draft, index) => {
const match = message.activeShell(draft, event.data.callID)
if (!match) return
match.output = event.data.output
match.time.completed = event.data.timestamp
match.time.completed = event.created
})
break
case "session.next.step.started":
case "step.started":
setStore("session", "status", event.data.sessionID, "running")
message.update(event.data.sessionID, (draft, index) => {
if (index.has(event.data.assistantMessageID)) return
const currentAssistant = message.activeAssistant(draft)
if (currentAssistant) currentAssistant.time.completed = event.data.timestamp
if (currentAssistant) currentAssistant.time.completed = event.created
message.append(draft, index, {
id: event.data.assistantMessageID,
type: "assistant",
@ -339,16 +334,16 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
model: event.data.model,
content: [],
snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined,
time: { created: event.data.timestamp },
time: { created: event.created },
})
})
break
case "session.next.step.ended":
case "step.ended":
setStore("session", "status", event.data.sessionID, "running")
message.update(event.data.sessionID, (draft, index) => {
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
if (!currentAssistant) return
currentAssistant.time.completed = event.data.timestamp
currentAssistant.time.completed = event.created
currentAssistant.finish = event.data.finish
currentAssistant.cost = event.data.cost
currentAssistant.tokens = event.data.tokens
@ -356,16 +351,16 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.data.snapshot }
})
break
case "session.next.step.failed":
case "step.failed":
message.update(event.data.sessionID, (draft, index) => {
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
if (!currentAssistant) return
currentAssistant.time.completed = event.data.timestamp
currentAssistant.time.completed = event.created
currentAssistant.finish = "error"
currentAssistant.error = event.data.error
})
break
case "session.next.text.started":
case "text.started":
message.update(event.data.sessionID, (draft, index) => {
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
type: "text",
@ -374,7 +369,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
})
break
case "session.next.text.delta":
case "text.delta":
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestText(
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
})
break
case "session.next.text.ended":
case "text.ended":
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestText(
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
})
break
case "session.next.tool.input.started":
case "tool.input.started":
message.update(event.data.sessionID, (draft, index) => {
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
type: "tool",
id: event.data.callID,
name: event.data.name,
time: { created: event.data.timestamp },
time: { created: event.created },
state: { status: "pending", input: "" },
})
})
break
case "session.next.tool.input.delta":
case "tool.input.delta":
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool(
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
})
break
case "session.next.tool.input.ended":
case "tool.input.ended":
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool(
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
})
break
case "session.next.tool.called":
case "tool.called":
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool(
message.assistant(draft, index, event.data.assistantMessageID),
event.data.callID,
)
if (!match) return
match.time.ran = event.data.timestamp
match.time.ran = event.created
match.provider = event.data.provider
match.state = { status: "running", input: event.data.input, structured: {}, content: [] }
})
break
case "session.next.tool.progress":
case "tool.progress":
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool(
message.assistant(draft, index, event.data.assistantMessageID),
@ -444,7 +439,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
match.state.content = [...event.data.content]
})
break
case "session.next.tool.success":
case "tool.success":
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool(
message.assistant(draft, index, event.data.assistantMessageID),
@ -463,10 +458,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
metadata: match.provider?.metadata,
resultMetadata: event.data.provider.metadata,
}
match.time.completed = event.data.timestamp
match.time.completed = event.created
})
break
case "session.next.tool.failed":
case "tool.failed":
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool(
message.assistant(draft, index, event.data.assistantMessageID),
@ -486,21 +481,21 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
metadata: match.provider?.metadata,
resultMetadata: event.data.provider.metadata,
}
match.time.completed = event.data.timestamp
match.time.completed = event.created
})
break
case "session.next.reasoning.started":
case "reasoning.started":
message.update(event.data.sessionID, (draft, index) => {
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
type: "reasoning",
id: event.data.reasoningID,
text: "",
providerMetadata: event.data.providerMetadata,
time: { created: event.data.timestamp },
time: { created: event.created },
})
})
break
case "session.next.reasoning.delta":
case "reasoning.delta":
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestReasoning(
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
})
break
case "session.next.reasoning.ended":
case "reasoning.ended":
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestReasoning(
message.assistant(draft, index, event.data.assistantMessageID),
@ -517,38 +512,38 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
)
if (match) {
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
}
})
break
case "session.next.retried":
case "session.next.compaction.started":
case "retried":
case "compaction.started":
setStore("session", "status", event.data.sessionID, "running")
break
case "session.next.execution.settled":
case "execution.settled":
setStore("session", "status", event.data.sessionID, "idle")
break
case "session.next.revert.staged":
case "revert.staged":
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "revert", event.data.revert)
break
case "session.next.revert.cleared":
case "session.next.revert.committed":
case "revert.cleared":
case "revert.committed":
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "revert", undefined)
break
case "session.next.compaction.delta":
case "compaction.delta":
break
case "session.next.compaction.ended":
case "compaction.ended":
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: event.data.messageID,
id: messageIDFromEvent(event.id),
type: "compaction",
reason: event.data.reason,
summary: event.data.text,
recent: event.data.recent,
time: { created: event.data.timestamp },
time: { created: event.created },
})
})
break
@ -692,8 +687,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return liveByID.get(message.id) ?? message
}),
...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])))
setStore("session", "message", sessionID, messages)
},

View file

@ -74,17 +74,17 @@ const tui: TuiPlugin = async (api) => {
notify(api, sessionID, "Session done", session?.parentID ? "subagent_done" : "done")
}
api.event.on("session.next.prompted", (event) => started(event.data.sessionID))
api.event.on("session.next.shell.started", (event) => started(event.data.sessionID))
api.event.on("session.next.step.started", (event) => started(event.data.sessionID))
api.event.on("session.next.retried", (event) => started(event.data.sessionID))
api.event.on("session.next.compaction.started", (event) => started(event.data.sessionID))
api.event.on("session.next.shell.ended", (event) => ended(event.data.sessionID))
api.event.on("session.next.step.ended", (event) => {
api.event.on("prompt.promoted", (event) => started(event.data.sessionID))
api.event.on("shell.started", (event) => started(event.data.sessionID))
api.event.on("step.started", (event) => started(event.data.sessionID))
api.event.on("retried", (event) => started(event.data.sessionID))
api.event.on("compaction.started", (event) => started(event.data.sessionID))
api.event.on("shell.ended", (event) => ended(event.data.sessionID))
api.event.on("step.ended", (event) => {
if (event.data.finish === "tool-calls") return
ended(event.data.sessionID)
})
api.event.on("session.next.step.failed", (event) => {
api.event.on("step.failed", (event) => {
const sessionID = event.data.sessionID
if (!active.has(sessionID)) return
errored.add(sessionID)

View file

@ -126,45 +126,49 @@ export function createSessionRows(sessionID: Accessor<string>) {
return index === -1 ? rows.length : index
}
const message = (event: { data: { sessionID: string; messageID: string } }) => {
if (event.data.sessionID === sessionID()) appendMessage(event.data.messageID)
const message = (event: { id: string; data: { sessionID: string } }) => {
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 = [
data.on("session.next.prompt.admitted", message),
data.on("session.next.prompted", message),
data.on("session.next.context.updated", message),
data.on("session.next.synthetic", (event) => {
if (event.data.sessionID === sessionID() && event.data.description?.trim()) appendMessage(event.data.messageID)
data.on("prompt.admitted", input),
data.on("prompt.promoted", input),
data.on("session.context.updated", message),
data.on("synthetic", (event) => {
if (event.data.sessionID === sessionID() && event.data.description?.trim())
appendMessage(event.id.replace(/^evt_/, "msg_"))
}),
data.on("session.next.shell.started", message),
data.on("session.next.agent.switched", message),
data.on("session.next.model.switched", message),
data.on("session.next.compaction.ended", message),
data.on("session.next.text.delta", (event) => {
data.on("shell.started", message),
data.on("agent.selected", message),
data.on("model.selected", message),
data.on("compaction.ended", message),
data.on("text.delta", (event) => {
if (event.data.sessionID === sessionID())
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())
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())
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())
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())
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
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)
}),
]
@ -229,8 +233,6 @@ function hasPart(rows: SessionRow[], ref: PartRef) {
return rows.some((row) => {
if (row.type === "part") return row.ref.messageID === ref.messageID && row.ref.partID === ref.partID
if (row.type !== "group") return false
return [...row.refs, ...row.pending].some(
(item) => item.messageID === ref.messageID && item.partID === ref.partID,
)
return [...row.refs, ...row.pending].some((item) => item.messageID === ref.messageID && item.partID === ref.partID)
})
}

View file

@ -90,12 +90,12 @@ function durable(sessionID: string) {
function stepStarted(id: string, sessionID = "session"): V2Event {
return {
id,
type: "session.next.step.started",
created: 0,
type: "step.started",
durable: durable(sessionID),
data: {
sessionID,
assistantMessageID: `msg_${id}`,
timestamp: 0,
agent: "build",
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 {
return {
id,
type: "session.next.step.ended",
created: 0,
type: "step.ended",
durable: durable(sessionID),
data: {
sessionID,
assistantMessageID: `msg_${id}`,
timestamp: 0,
finish,
cost: 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 {
return {
id,
type: "session.next.step.failed",
created: 0,
type: "step.failed",
durable: durable(sessionID),
data: {
sessionID,
assistantMessageID: `msg_${id}`,
timestamp: 0,
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 () => {
const harness = await setup()
harness.emit({ id: "event-1", type: "question.asked", data: question("question-1") })
harness.emit({ id: "event-2", type: "permission.asked", data: permission("permission-1") })
harness.emit({ id: "event-1", created: 0, type: "question.asked", data: question("question-1") })
harness.emit({ id: "event-2", created: 0, type: "permission.asked", data: permission("permission-1") })
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 () => {
const harness = await setup()
harness.emit({ id: "event-1", type: "question.asked", data: question("question-1") })
harness.emit({ id: "event-2", 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", created: 0, type: "question.asked", data: question("question-1") })
harness.emit({
id: "event-3",
created: 0,
type: "question.replied",
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-6", 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", created: 0, type: "permission.asked", data: permission("permission-1") })
harness.emit({
id: "event-7",
created: 0,
type: "permission.replied",
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([
questionNotification,
@ -205,7 +207,7 @@ describe("internal notifications TUI plugin", () => {
test("uses sound-only notifications and subagent_done sound for subagent sessions", async () => {
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(stepEnded("event-3", "subagent"))
@ -248,12 +250,14 @@ describe("internal notifications TUI plugin", () => {
harness.emit(stepStarted("event-1", "abort"))
harness.emit({
id: "event-2",
created: 0,
type: "session.error",
data: { sessionID: "abort", error: { name: "MessageAbortedError", data: { message: "Aborted" } } },
})
harness.emit(stepStarted("event-3", "timeout"))
harness.emit({
id: "event-4",
created: 0,
type: "session.error",
data: { sessionID: "timeout", error: { name: "UnknownError", data: { message: "SSE read timed out" } } },
})

View file

@ -2,6 +2,8 @@
import { expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
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 { ProjectProvider } from "../../../src/context/project"
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")
return json({
data: [
{ id: "msg_second", type: "user", text: "Second", time: { created: 2 } },
{ id: "msg_first", type: "user", text: "First", time: { created: 1 } },
{ id: "msg_second", created: 0, type: "user", text: "Second", time: { created: 2 } },
{ id: "msg_first", created: 0, type: "user", text: "First", time: { created: 1 } },
],
cursor: {},
})
@ -187,7 +189,9 @@ test("connectedOnce is false until first connect and persists across disconnect"
)
const connect = () =>
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 = () => {
stream?.close()
@ -264,12 +268,12 @@ test("tracks session status from active sessions and execution events", async ()
emitEvent(events, {
id: "evt_step_started",
type: "session.next.step.started",
created: 0,
type: "step.started",
durable: durable("session-live"),
data: {
sessionID: "session-live",
assistantMessageID: "message-live",
timestamp: 1,
agent: "build",
model: { id: "model", providerID: "provider" },
},
@ -278,12 +282,12 @@ test("tracks session status from active sessions and execution events", async ()
emitEvent(events, {
id: "evt_step_ended",
type: "session.next.step.ended",
created: 0,
type: "step.ended",
durable: durable("session-live", 1, 2),
data: {
sessionID: "session-live",
assistantMessageID: "message-live",
timestamp: 2,
finish: "stop",
cost: 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, {
id: "evt_execution_settled",
type: "session.next.execution.settled",
created: 0,
type: "execution.settled",
data: {
sessionID: "session-live",
timestamp: 3,
outcome: "success",
},
})
@ -308,12 +312,12 @@ test("tracks session status from active sessions and execution events", async ()
emitEvent(events, {
id: "evt_failed_step_started",
type: "session.next.step.started",
created: 0,
type: "step.started",
durable: durable("session-failed"),
data: {
sessionID: "session-failed",
assistantMessageID: "message-failed",
timestamp: 3,
agent: "build",
model: { id: "model", providerID: "provider" },
},
@ -322,12 +326,12 @@ test("tracks session status from active sessions and execution events", async ()
emitEvent(events, {
id: "evt_step_failed",
type: "session.next.step.failed",
created: 0,
type: "step.failed",
durable: durable("session-failed", 1, 2),
data: {
sessionID: "session-failed",
assistantMessageID: "message-failed",
timestamp: 4,
error: { type: "unknown", message: "Provider unavailable" },
},
})
@ -339,10 +343,10 @@ test("tracks session status from active sessions and execution events", async ()
emitEvent(events, {
id: "evt_failed_execution_settled",
type: "session.next.execution.settled",
created: 0,
type: "execution.settled",
data: {
sessionID: "session-failed",
timestamp: 5,
outcome: "failure",
error: { type: "unknown", message: "Provider unavailable" },
},
@ -411,7 +415,7 @@ test("refreshes integrations after integration updates", async () => {
expect(data.location.integration.list()).toEqual([])
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(() => requests.model > before.model && requests.provider > before.provider)
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 {
await wait(() => requests.model > 0 && requests.provider > 0)
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)
} finally {
app.renderer.destroy()
@ -496,7 +500,7 @@ test("refreshes agents after agent updates", async () => {
try {
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")
} finally {
app.renderer.destroy()
@ -541,7 +545,7 @@ test("refreshes references after updates", async () => {
try {
await mounted
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)
expect(data.location.reference.list()?.[0]?.name).toBe("docs")
} finally {
@ -602,6 +606,7 @@ test("keeps shell state scoped to location", async () => {
events.emit({
id: "evt_shell_created",
created: 0,
type: "shell.created",
location: { directory: other },
data: {
@ -650,6 +655,7 @@ test("adds and dismisses permission requests from live events", async () => {
await wait(() => data.connection.status() === "connected")
emitEvent(events, {
id: "evt_permission_asked_1",
created: 0,
type: "permission.v2.asked",
data: {
id: "per_1",
@ -660,6 +666,7 @@ test("adds and dismisses permission requests from live events", async () => {
})
emitEvent(events, {
id: "evt_permission_asked_2",
created: 0,
type: "permission.v2.asked",
data: {
id: "per_2",
@ -672,6 +679,7 @@ test("adds and dismisses permission requests from live events", async () => {
emitEvent(events, {
id: "evt_permission_replied_1",
created: 0,
type: "permission.v2.replied",
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, {
id: "evt_permission_replied_2",
created: 0,
type: "permission.v2.replied",
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")
emitEvent(events, {
id: "evt_question_asked_1",
created: 0,
type: "question.v2.asked",
data: {
id: "que_1",
@ -724,6 +734,7 @@ test("adds and dismisses question requests from live events", async () => {
})
emitEvent(events, {
id: "evt_question_asked_2",
created: 0,
type: "question.v2.asked",
data: {
id: "que_2",
@ -735,6 +746,7 @@ test("adds and dismisses question requests from live events", async () => {
emitEvent(events, {
id: "evt_question_replied_1",
created: 0,
type: "question.v2.replied",
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, {
id: "evt_question_rejected_2",
created: 0,
type: "question.v2.rejected",
data: { sessionID: "ses_1", requestID: "que_2" },
})
@ -783,52 +796,52 @@ test("settles pending tools when a live failure arrives", async () => {
await mounted
emitEvent(events, {
id: "evt_agent_1",
type: "session.next.agent.switched",
created: 0,
type: "agent.selected",
durable: durable("session-1"),
data: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 0, agent: "build" },
data: { sessionID: "session-1", agent: "build" },
})
emitEvent(events, {
id: "evt_model_1",
type: "session.next.model.switched",
created: 0,
type: "model.selected",
durable: durable("session-1", 1),
data: {
sessionID: "session-1",
messageID: "msg_model_1",
timestamp: 0,
model: { id: "model-1", providerID: "provider-1" },
},
})
emitEvent(events, {
id: "evt_step_started_1",
type: "session.next.step.started",
created: 0,
type: "step.started",
durable: durable("session-1", 2),
data: {
sessionID: "session-1",
assistantMessageID: "msg_explicit_assistant_9",
timestamp: 1,
agent: "build",
model: { id: "model-1", providerID: "provider-1" },
},
})
emitEvent(events, {
id: "evt_input_1",
type: "session.next.tool.input.started",
created: 0,
type: "tool.input.started",
durable: durable("session-1", 3),
data: {
sessionID: "session-1",
assistantMessageID: "msg_explicit_assistant_9",
timestamp: 2,
callID: "call-1",
name: "bash",
},
})
emitEvent(events, {
id: "evt_called_1",
type: "session.next.tool.called",
created: 0,
type: "tool.called",
durable: durable("session-1", 4),
data: {
sessionID: "session-1",
timestamp: 2,
assistantMessageID: "msg_explicit_assistant_9",
callID: "call-1",
tool: "bash",
@ -838,11 +851,11 @@ test("settles pending tools when a live failure arrives", async () => {
})
emitEvent(events, {
id: "evt_failed_1",
type: "session.next.tool.failed",
created: 0,
type: "tool.failed",
durable: durable("session-1", 5),
data: {
sessionID: "session-1",
timestamp: 3,
assistantMessageID: "msg_explicit_assistant_9",
callID: "call-1",
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))
emitEvent(events, {
id: "evt_admitted_1",
type: "session.next.prompt.admitted",
created: 0,
type: "prompt.admitted",
durable: durable(sessionID),
data: {
sessionID,
messageID,
timestamp: 0,
inputID: messageID,
prompt: { text: "hello" },
delivery: "steer",
},
@ -947,19 +960,17 @@ test("renders admitted prompts immediately with queued marker and clears when pr
emitEvent(events, {
id: "evt_prompted_1",
type: "session.next.prompted",
created: 0,
type: "prompt.promoted",
durable: durable(sessionID, 1),
data: {
sessionID,
messageID,
timestamp: 0,
prompt: { text: "hello" },
delivery: "steer",
inputID: messageID,
},
})
await wait(() => received.at(-1) === "session.next.prompted")
expect(received.slice(-2)).toEqual(["session.next.prompt.admitted", "session.next.prompted"])
await wait(() => received.at(-1) === "prompt.promoted")
expect(received.slice(-2)).toEqual(["prompt.admitted", "prompt.promoted"])
unsubscribe()
const message = sync.session.message.list(sessionID)?.[0]
expect(message?.type).toBe("user")
@ -1007,21 +1018,21 @@ test("projects live context updates with their message ID", async () => {
await mounted
emitEvent(events, {
id: "evt_context_1",
type: "session.next.context.updated",
created: 0,
type: "session.context.updated",
durable: durable("session-1"),
data: {
sessionID: "session-1",
messageID: "msg_context_1",
timestamp: 1,
text: "Updated context",
},
})
await wait(() => sync.session.message.list("session-1")?.length === 1)
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",
text: "Updated context",
time: { created: 0 },
})
} finally {
app.renderer.destroy()

View file

@ -30,6 +30,7 @@ function event(payload: V2Event, input: { directory: string; project?: string; w
function vcs(branch: string): V2Event {
return {
id: `evt_vcs_${branch}`,
created: 0,
type: "vcs.branch.updated",
data: {
branch,
@ -40,6 +41,7 @@ function vcs(branch: string): V2Event {
function update(version: string): V2Event {
return {
id: `evt_update_${version}`,
created: 0,
type: "installation.update-available",
data: {
version,

View file

@ -904,3 +904,17 @@ Compatibility:
- 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.
## 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.

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.
`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.
@ -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.
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.