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,