refactor(core): make v2 session inputs event sourced (#30785)

This commit is contained in:
Kit Langton 2026-06-04 19:24:30 -04:00 committed by GitHub
commit 76ecf2e58c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 4671 additions and 757 deletions

View file

@ -1,5 +1,6 @@
import { useEvent } from "@tui/context/event"
import type {
Event,
SessionMessage,
SessionMessageAssistant,
SessionMessageAssistantReasoning,
@ -54,6 +55,11 @@ function latestReasoning(assistant: SessionMessageAssistant | undefined, reasoni
)
}
function prepend(messages: SessionMessage[], message: SessionMessage) {
if (messages.some((item) => item.id === message.id)) return
messages.unshift(message)
}
export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext({
name: "SyncV2",
init: () => {
@ -67,6 +73,18 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
const event = useEvent()
const sdk = useSDK()
const applied = new Set<string>()
const buffering = new Map<string, Event[]>()
const syncing = new Map<string, Promise<void>>()
function duplicate(id: string) {
if (applied.has(id)) return true
applied.add(id)
if (applied.size <= 1000) return false
const oldest = applied.values().next()
if (!oldest.done) applied.delete(oldest.value)
return false
}
function update(sessionID: string, fn: (messages: SessionMessage[]) => void) {
setStore(
@ -77,12 +95,41 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
)
}
event.subscribe((event) => {
async function hydrate(sessionID: string) {
const pending: Event[] = []
const before = JSON.parse(JSON.stringify(store.messages[sessionID] ?? [])) as SessionMessage[]
buffering.set(sessionID, pending)
try {
const response = await sdk.client.v2.session.messages({ sessionID })
const messages = response.data?.data ?? []
const snapshotIDs = new Set(messages.map((message) => message.id))
setStore(
"messages",
sessionID,
reconcile([...messages, ...before.filter((message) => !snapshotIDs.has(message.id))]),
)
buffering.delete(sessionID)
for (const event of pending) apply(event)
} catch (error) {
buffering.delete(sessionID)
throw error
}
}
function sync(sessionID: string) {
const existing = syncing.get(sessionID)
if (existing) return existing
const result = hydrate(sessionID).finally(() => syncing.delete(sessionID))
syncing.set(sessionID, result)
return result
}
function apply(event: Event) {
switch (event.type) {
case "session.next.agent.switched":
update(event.properties.sessionID, (draft) => {
draft.unshift({
id: event.id,
prepend(draft, {
id: event.properties.messageID,
type: "agent-switched",
agent: event.properties.agent,
time: { created: event.properties.timestamp },
@ -91,8 +138,8 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
break
case "session.next.model.switched":
update(event.properties.sessionID, (draft) => {
draft.unshift({
id: event.id,
prepend(draft, {
id: event.properties.messageID,
type: "model-switched",
model: event.properties.model,
time: { created: event.properties.timestamp },
@ -101,8 +148,8 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
break
case "session.next.prompted": {
update(event.properties.sessionID, (draft) => {
draft.unshift({
id: event.id,
prepend(draft, {
id: event.properties.messageID,
type: "user",
text: event.properties.prompt.text,
files: event.properties.prompt.files,
@ -113,10 +160,25 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
})
break
}
case "session.next.prompt.admitted":
break
case "session.next.prompt.promoted":
update(event.properties.sessionID, (draft) => {
prepend(draft, {
id: event.properties.messageID,
type: "user",
text: event.properties.prompt.text,
files: event.properties.prompt.files,
agents: event.properties.prompt.agents,
references: event.properties.prompt.references,
time: { created: event.properties.timeCreated },
})
})
break
case "session.next.synthetic":
update(event.properties.sessionID, (draft) => {
draft.unshift({
id: event.id,
prepend(draft, {
id: event.properties.messageID,
type: "synthetic",
sessionID: event.properties.sessionID,
text: event.properties.text,
@ -126,8 +188,8 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
break
case "session.next.shell.started":
update(event.properties.sessionID, (draft) => {
draft.unshift({
id: event.id,
prepend(draft, {
id: event.properties.messageID,
type: "shell",
callID: event.properties.callID,
command: event.properties.command,
@ -146,10 +208,11 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
break
case "session.next.step.started":
update(event.properties.sessionID, (draft) => {
if (draft.some((message) => message.id === event.properties.assistantMessageID)) return
const currentAssistant = activeAssistant(draft)
if (currentAssistant) currentAssistant.time.completed = event.properties.timestamp
draft.unshift({
id: event.id,
prepend(draft, {
id: event.properties.assistantMessageID,
type: "assistant",
agent: event.properties.agent,
model: event.properties.model,
@ -182,18 +245,28 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
break
case "session.next.text.started":
update(event.properties.sessionID, (draft) => {
activeAssistant(draft)?.content.push({ type: "text", id: event.properties.textID, text: "" })
ownedAssistant(draft, event.properties.assistantMessageID)?.content.push({
type: "text",
id: event.properties.textID,
text: "",
})
})
break
case "session.next.text.delta":
update(event.properties.sessionID, (draft) => {
const match = latestText(activeAssistant(draft), event.properties.textID)
const match = latestText(
ownedAssistant(draft, event.properties.assistantMessageID),
event.properties.textID,
)
if (match) match.text += event.properties.delta
})
break
case "session.next.text.ended":
update(event.properties.sessionID, (draft) => {
const match = latestText(activeAssistant(draft), event.properties.textID)
const match = latestText(
ownedAssistant(draft, event.properties.assistantMessageID),
event.properties.textID,
)
if (match) match.text = event.properties.text
})
break
@ -263,7 +336,11 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
content: [...event.properties.content],
result: event.properties.result,
}
match.provider = event.properties.provider
match.provider = {
executed: event.properties.provider.executed || match.provider?.executed === true,
metadata: match.provider?.metadata,
resultMetadata: event.properties.provider.metadata,
}
match.time.completed = event.properties.timestamp
})
break
@ -282,13 +359,17 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
content: match.state.status === "running" ? match.state.content : [],
result: event.properties.result,
}
match.provider = event.properties.provider
match.provider = {
executed: event.properties.provider.executed || match.provider?.executed === true,
metadata: match.provider?.metadata,
resultMetadata: event.properties.provider.metadata,
}
match.time.completed = event.properties.timestamp
})
break
case "session.next.reasoning.started":
update(event.properties.sessionID, (draft) => {
activeAssistant(draft)?.content.push({
ownedAssistant(draft, event.properties.assistantMessageID)?.content.push({
type: "reasoning",
id: event.properties.reasoningID,
text: "",
@ -298,13 +379,19 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
break
case "session.next.reasoning.delta":
update(event.properties.sessionID, (draft) => {
const match = latestReasoning(activeAssistant(draft), event.properties.reasoningID)
const match = latestReasoning(
ownedAssistant(draft, event.properties.assistantMessageID),
event.properties.reasoningID,
)
if (match) match.text += event.properties.delta
})
break
case "session.next.reasoning.ended":
update(event.properties.sessionID, (draft) => {
const match = latestReasoning(activeAssistant(draft), event.properties.reasoningID)
const match = latestReasoning(
ownedAssistant(draft, event.properties.assistantMessageID),
event.properties.reasoningID,
)
if (match) {
match.text = event.properties.text
if (event.properties.providerMetadata !== undefined)
@ -316,8 +403,8 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
break
case "session.next.compaction.started":
update(event.properties.sessionID, (draft) => {
draft.unshift({
id: event.id,
prepend(draft, {
id: event.properties.messageID,
type: "compaction",
reason: event.properties.reason,
summary: "",
@ -340,16 +427,20 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
})
break
}
}
event.subscribe((event) => {
if (duplicate(event.id)) return
if ("sessionID" in event.properties && typeof event.properties.sessionID === "string")
buffering.get(event.properties.sessionID)?.push(event)
apply(event)
})
const result = {
data: store,
session: {
message: {
async sync(sessionID: string) {
const response = await sdk.client.v2.session.messages({ sessionID })
setStore("messages", sessionID, reconcile(response.data?.data ?? []))
},
sync,
fromSession(sessionID: string) {
const messages = store.messages[sessionID]
if (!messages) return []

View file

@ -32,7 +32,7 @@ const EventSchema = Schema.Union([
.values()
.map((definition) =>
Schema.Struct({
id: Schema.String,
id: EventV2.ID,
type: Schema.Literal(definition.type),
properties: definition.data,
}).annotate({ identifier: `Event.${definition.type}` }),

View file

@ -20,10 +20,10 @@ const SyncEventSchemas = EventV2.registry
return [
Schema.Struct({
type: Schema.Literal("sync"),
id: Schema.String,
id: EventV2.ID,
syncEvent: Schema.Struct({
type: Schema.Literal(EventV2.versionedType(definition.type, definition.sync.version)),
id: Schema.String,
id: EventV2.ID,
seq: Schema.Finite,
aggregateID: Schema.String,
data: definition.data,
@ -41,7 +41,7 @@ const GlobalEventSchema = Schema.Struct({
...EventV2.registry
.values()
.map((definition) =>
Schema.Struct({ id: Schema.String, type: Schema.Literal(definition.type), properties: definition.data }),
Schema.Struct({ id: EventV2.ID, type: Schema.Literal(definition.type), properties: definition.data }),
)
.toArray(),
InstanceDisposed,

View file

@ -1,4 +1,5 @@
import { NonNegativeInt } from "@opencode-ai/core/schema"
import { EventV2 } from "@opencode-ai/core/event"
import { SessionID } from "@/session/schema"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
@ -9,7 +10,7 @@ import { described } from "./metadata"
const root = "/sync"
export const ReplayEvent = Schema.Struct({
id: Schema.String,
id: EventV2.ID,
aggregateID: Schema.String,
seq: NonNegativeInt,
type: Schema.String,
@ -27,7 +28,7 @@ export const SessionPayload = Schema.Struct({
})
export const HistoryPayload = Schema.Record(Schema.String, NonNegativeInt)
export const HistoryEvent = Schema.Struct({
id: Schema.String,
id: EventV2.ID,
aggregate_id: Schema.String,
seq: NonNegativeInt,
type: Schema.String,

View file

@ -36,7 +36,7 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl
const replay = Effect.fn("SyncHttpApi.replay")(function* (ctx: { payload: typeof ReplayPayload.Type }) {
const payload: EventV2.SerializedEvent[] = ctx.payload.events.map((event) => ({
id: EventV2.ID.make(event.id),
id: event.id,
aggregateID: event.aggregateID,
seq: event.seq,
type: event.type,

View file

@ -12,7 +12,7 @@ import { Plugin } from "@/plugin"
import { Config } from "@/config/config"
import { NotFoundError } from "@/storage/storage"
import { Effect, Layer, Context, Schema } from "effect"
import { Effect, Layer, Context } from "effect"
import * as DateTime from "effect/DateTime"
import { InstanceState } from "@/effect/instance-state"
import { isOverflow as overflow, usable } from "./overflow"
@ -20,6 +20,7 @@ import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { EventV2Bridge } from "@/event-v2-bridge"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { EventV2 } from "@opencode-ai/core/event"
@ -609,6 +610,7 @@ export const layer = Layer.effect(
if (flags.experimentalEventSystem) {
yield* events.publish(SessionEvent.Compaction.Started, {
sessionID: input.sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(Date.now()),
reason: input.auto ? "auto" : "manual",
})

View file

@ -25,13 +25,13 @@ import { isRecord } from "@/util/record"
import { EventV2Bridge } from "@/event-v2-bridge"
import { Database } from "@opencode-ai/core/database/database"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import * as DateTime from "effect/DateTime"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { toolFileSourceFromUri, Usage, type LLMEvent } from "@opencode-ai/llm"
import { ToolOutput } from "@opencode-ai/core/tool-output"
import type { EventV2 } from "@opencode-ai/core/event"
const DOOM_LOOP_THRESHOLD = 3
const log = Log.create({ service: "session.processor" })
@ -67,7 +67,7 @@ export interface Interface {
}
type ToolCall = {
assistantMessageID?: EventV2.ID
assistantMessageID?: SessionMessage.ID
partID: SessionV1.ToolPart["id"]
messageID: SessionV1.ToolPart["messageID"]
sessionID: SessionV1.ToolPart["sessionID"]
@ -85,7 +85,7 @@ interface ProcessorContext extends Input {
currentText: SessionV1.TextPart | undefined
currentTextID: string | undefined
reasoningMap: Record<string, SessionV1.ReasoningPart>
v2AssistantMessageID: EventV2.ID | undefined
v2AssistantMessageID: SessionMessage.ID | undefined
}
type StreamEvent = LLMEvent
@ -129,6 +129,7 @@ export const layer = Layer.effect(
reasoningMap: {},
v2AssistantMessageID: undefined,
}
const mirrorAssistant = flags.experimentalEventSystem && !input.assistantMessage.summary
let aborted = false
const slog = log.clone().tag("session.id", input.sessionID).tag("messageID", input.assistantMessage.id)
@ -146,8 +147,10 @@ export const layer = Layer.effect(
const ensureV2AssistantMessage = Effect.fn("SessionProcessor.ensureV2AssistantMessage")(function* () {
if (ctx.v2AssistantMessageID) return ctx.v2AssistantMessageID
ctx.v2AssistantMessageID = (yield* events.publish(SessionEvent.Step.Started, {
ctx.v2AssistantMessageID = SessionMessage.ID.create()
yield* events.publish(SessionEvent.Step.Started, {
sessionID: ctx.sessionID,
assistantMessageID: ctx.v2AssistantMessageID,
agent: input.assistantMessage.agent,
model: {
id: ModelV2.ID.make(ctx.model.id),
@ -156,7 +159,7 @@ export const layer = Layer.effect(
},
snapshot: ctx.snapshot,
timestamp: DateTime.makeUnsafe(Date.now()),
})).id
})
return ctx.v2AssistantMessageID
})
@ -249,9 +252,10 @@ export const layer = Layer.effect(
const finishReasoning = Effect.fn("SessionProcessor.finishReasoning")(function* (reasoningID: string) {
if (!(reasoningID in ctx.reasoningMap)) return
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (flags.experimentalEventSystem) {
if (mirrorAssistant) {
yield* events.publish(SessionEvent.Reasoning.Ended, {
sessionID: ctx.sessionID,
assistantMessageID: yield* currentV2AssistantMessage(),
reasoningID,
text: ctx.reasoningMap[reasoningID].text,
providerMetadata: ctx.reasoningMap[reasoningID].metadata,
@ -266,23 +270,29 @@ export const layer = Layer.effect(
})
const flushV2Fragments = Effect.fn("SessionProcessor.flushV2Fragments")(function* () {
if (!flags.experimentalEventSystem) return
if (!mirrorAssistant) return
if (!ctx.assistantMessage.summary && ctx.currentText && ctx.currentTextID) {
yield* events.publish(SessionEvent.Text.Ended, {
sessionID: ctx.sessionID,
assistantMessageID: yield* currentV2AssistantMessage(),
textID: ctx.currentTextID,
text: ctx.currentText.text,
timestamp: DateTime.makeUnsafe(Date.now()),
})
}
yield* Effect.forEach(Object.entries(ctx.reasoningMap), ([reasoningID, part]) =>
events.publish(SessionEvent.Reasoning.Ended, {
sessionID: ctx.sessionID,
reasoningID,
text: part.text,
providerMetadata: part.metadata,
timestamp: DateTime.makeUnsafe(Date.now()),
}),
currentV2AssistantMessage().pipe(
Effect.flatMap((assistantMessageID) =>
events.publish(SessionEvent.Reasoning.Ended, {
sessionID: ctx.sessionID,
assistantMessageID,
reasoningID,
text: part.text,
providerMetadata: part.metadata,
timestamp: DateTime.makeUnsafe(Date.now()),
}),
),
),
)
})
@ -307,7 +317,7 @@ export const layer = Layer.effect(
return { call: ctx.toolcalls[input.id], part }
}
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
const assistantMessageID = flags.experimentalEventSystem ? yield* ensureV2AssistantMessage() : undefined
const assistantMessageID = mirrorAssistant ? yield* ensureV2AssistantMessage() : undefined
if (assistantMessageID) {
yield* events.publish(SessionEvent.Tool.Input.Started, {
sessionID: ctx.sessionID,
@ -367,9 +377,10 @@ export const layer = Layer.effect(
case "reasoning-start":
if (value.id in ctx.reasoningMap) return
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (flags.experimentalEventSystem) {
if (mirrorAssistant) {
yield* events.publish(SessionEvent.Reasoning.Started, {
sessionID: ctx.sessionID,
assistantMessageID: yield* ensureV2AssistantMessage(),
reasoningID: value.id,
providerMetadata: value.providerMetadata,
timestamp: DateTime.makeUnsafe(Date.now()),
@ -392,9 +403,10 @@ export const layer = Layer.effect(
if (!(value.id in ctx.reasoningMap)) return
ctx.reasoningMap[value.id].text += value.text
if (value.providerMetadata) ctx.reasoningMap[value.id].metadata = value.providerMetadata
if (flags.experimentalEventSystem) {
if (mirrorAssistant) {
yield* events.publish(SessionEvent.Reasoning.Delta, {
sessionID: ctx.sessionID,
assistantMessageID: yield* currentV2AssistantMessage(),
reasoningID: value.id,
delta: value.text,
timestamp: DateTime.makeUnsafe(Date.now()),
@ -426,9 +438,7 @@ export const layer = Layer.effect(
case "tool-input-delta":
{
const toolCall = yield* ensureToolCall(value)
const assistantMessageID = flags.experimentalEventSystem
? yield* requireV2AssistantMessage(toolCall.call)
: undefined
const assistantMessageID = mirrorAssistant ? yield* requireV2AssistantMessage(toolCall.call) : undefined
if (assistantMessageID) {
yield* events.publish(SessionEvent.Tool.Input.Delta, {
sessionID: ctx.sessionID,
@ -445,7 +455,7 @@ export const layer = Layer.effect(
case "tool-input-end": {
const toolCall = yield* ensureToolCall(value)
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (flags.experimentalEventSystem) {
if (mirrorAssistant) {
const assistantMessageID = yield* requireV2AssistantMessage(toolCall.call)
yield* events.publish(SessionEvent.Tool.Input.Ended, {
sessionID: ctx.sessionID,
@ -467,7 +477,7 @@ export const layer = Layer.effect(
const input = isRecord(value.input) ? value.input : { value: value.input }
if (!toolCall.call.inputEnded) {
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (flags.experimentalEventSystem) {
if (mirrorAssistant) {
const assistantMessageID = yield* requireV2AssistantMessage(toolCall.call)
yield* events.publish(SessionEvent.Tool.Input.Ended, {
sessionID: ctx.sessionID,
@ -479,7 +489,7 @@ export const layer = Layer.effect(
}
}
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (flags.experimentalEventSystem) {
if (mirrorAssistant) {
const assistantMessageID = yield* requireV2AssistantMessage(toolCall.call)
yield* events.publish(SessionEvent.Tool.Called, {
sessionID: ctx.sessionID,
@ -545,7 +555,7 @@ export const layer = Layer.effect(
if (!toolCall && value.result.type === "error") return
if (value.result.type === "error") {
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (flags.experimentalEventSystem) {
if (mirrorAssistant) {
const assistantMessageID = yield* requireV2AssistantMessage(toolCall?.call)
yield* events.publish(SessionEvent.Tool.Failed, {
sessionID: ctx.sessionID,
@ -586,7 +596,7 @@ export const layer = Layer.effect(
attachments: attachments.length ? attachments : undefined,
}
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (flags.experimentalEventSystem) {
if (mirrorAssistant) {
const assistantMessageID = yield* requireV2AssistantMessage(toolCall?.call)
const content = [
ToolOutput.text({ type: "text", text: output.output }),
@ -642,7 +652,7 @@ export const layer = Layer.effect(
case "tool-error": {
const toolCall = yield* readToolCall(value.id)
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (flags.experimentalEventSystem) {
if (mirrorAssistant) {
const assistantMessageID = yield* requireV2AssistantMessage(toolCall?.call)
yield* events.publish(SessionEvent.Tool.Failed, {
sessionID: ctx.sessionID,
@ -670,7 +680,7 @@ export const layer = Layer.effect(
if (!ctx.snapshot) ctx.snapshot = yield* snapshot.track()
if (!ctx.assistantMessage.summary) {
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (flags.experimentalEventSystem) {
if (mirrorAssistant) {
yield* ensureV2AssistantMessage()
}
}
@ -693,7 +703,7 @@ export const layer = Layer.effect(
})
if (!ctx.assistantMessage.summary) {
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (flags.experimentalEventSystem) {
if (mirrorAssistant) {
yield* events.publish(SessionEvent.Step.Ended, {
sessionID: ctx.sessionID,
assistantMessageID: yield* currentV2AssistantMessage(),
@ -752,9 +762,10 @@ export const layer = Layer.effect(
case "text-start":
if (!ctx.assistantMessage.summary) {
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (flags.experimentalEventSystem) {
if (mirrorAssistant) {
yield* events.publish(SessionEvent.Text.Started, {
sessionID: ctx.sessionID,
assistantMessageID: yield* ensureV2AssistantMessage(),
timestamp: DateTime.makeUnsafe(Date.now()),
textID: value.id,
})
@ -777,9 +788,10 @@ export const layer = Layer.effect(
if (!ctx.currentText) return
ctx.currentText.text += value.text
if (value.providerMetadata) ctx.currentText.metadata = value.providerMetadata
if (flags.experimentalEventSystem) {
if (mirrorAssistant) {
yield* events.publish(SessionEvent.Text.Delta, {
sessionID: ctx.sessionID,
assistantMessageID: yield* currentV2AssistantMessage(),
textID: value.id,
delta: value.text,
timestamp: DateTime.makeUnsafe(Date.now()),
@ -809,9 +821,10 @@ export const layer = Layer.effect(
)).text
if (!ctx.assistantMessage.summary) {
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (flags.experimentalEventSystem) {
if (mirrorAssistant) {
yield* events.publish(SessionEvent.Text.Ended, {
sessionID: ctx.sessionID,
assistantMessageID: yield* currentV2AssistantMessage(),
text: ctx.currentText.text,
timestamp: DateTime.makeUnsafe(Date.now()),
textID: value.id,
@ -876,7 +889,7 @@ export const layer = Layer.effect(
const match = yield* readToolCall(toolCallID)
if (!match) continue
const part = match.part
if (flags.experimentalEventSystem && match.call.assistantMessageID) {
if (mirrorAssistant && match.call.assistantMessageID) {
yield* events.publish(SessionEvent.Tool.Failed, {
sessionID: ctx.sessionID,
assistantMessageID: match.call.assistantMessageID,
@ -922,7 +935,7 @@ export const layer = Layer.effect(
}
if (!ctx.assistantMessage.summary) {
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (flags.experimentalEventSystem) {
if (mirrorAssistant) {
yield* events.publish(SessionEvent.Step.Failed, {
sessionID: ctx.sessionID,
assistantMessageID: yield* ensureV2AssistantMessage(),
@ -979,7 +992,7 @@ export const layer = Layer.effect(
parse,
set: (info) => {
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
const event = flags.experimentalEventSystem
const event = mirrorAssistant
? events.publish(SessionEvent.Retried, {
sessionID: ctx.sessionID,
attempt: info.attempt,

View file

@ -51,6 +51,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
import { EventV2Bridge } from "@/event-v2-bridge"
import { Database } from "@opencode-ai/core/database/database"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AgentAttachment, FileAttachment, Prompt, ReferenceAttachment, Source } from "@opencode-ai/core/session/prompt"
@ -563,6 +564,7 @@ export const layer = Layer.effect(
if (flags.experimentalEventSystem) {
yield* events.publish(SessionEvent.Shell.Started, {
sessionID: input.sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(started),
callID: part.callID,
command: input.command,
@ -738,6 +740,7 @@ export const layer = Layer.effect(
if (current?.agent !== info.agent) {
yield* events.publish(SessionEvent.AgentSwitched, {
sessionID: input.sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(info.time.created),
agent: info.agent,
})
@ -749,6 +752,7 @@ export const layer = Layer.effect(
) {
yield* events.publish(SessionEvent.ModelSwitched, {
sessionID: input.sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(info.time.created),
model: {
id: ModelV2.ID.make(info.model.modelID),
@ -1190,6 +1194,7 @@ export const layer = Layer.effect(
if (flags.experimentalEventSystem) {
yield* events.publish(SessionEvent.Prompted, {
sessionID: input.sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(info.time.created),
delivery: "steer",
prompt: new Prompt({
@ -1205,6 +1210,7 @@ export const layer = Layer.effect(
if (flags.experimentalEventSystem) {
yield* events.publish(SessionEvent.Synthetic, {
sessionID: input.sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(info.time.created),
text,
})

View file

@ -6,7 +6,7 @@ import { onMount } from "solid-js"
import { ProjectProvider } from "../../../src/cli/cmd/tui/context/project"
import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk"
import { SyncProviderV2, useSyncV2 } from "../../../src/cli/cmd/tui/context/sync-v2"
import { createEventSource, createFetch, directory } from "../../fixture/tui-sdk"
import { createEventSource, createFetch, directory, json } from "../../fixture/tui-sdk"
async function wait(fn: () => boolean, timeout = 2000) {
const start = Date.now()
@ -20,6 +20,12 @@ function global(payload: Event): GlobalEvent {
return { directory, project: "proj_test", payload }
}
function emitTwice(events: ReturnType<typeof createEventSource>, payload: Event) {
const event = global(payload)
events.emit(event)
events.emit(event)
}
test("sync v2 settles pending tools when a live failure arrives", async () => {
const events = createEventSource()
const calls = createFetch()
@ -47,63 +53,68 @@ test("sync v2 settles pending tools when a live failure arrives", async () => {
try {
await mounted
events.emit(
global({
id: "agent-1",
type: "session.next.agent.switched",
properties: { sessionID: "session-1", timestamp: 0, agent: "build" },
}),
)
events.emit(
global({
id: "model-1",
type: "session.next.model.switched",
properties: {
sessionID: "session-1",
timestamp: 0,
model: { id: "model-1", providerID: "provider-1" },
},
}),
)
events.emit(
global({
id: "assistant-1",
type: "session.next.step.started",
properties: {
sessionID: "session-1",
timestamp: 1,
agent: "build",
model: { id: "model-1", providerID: "provider-1" },
},
}),
)
events.emit(
global({
id: "input-1",
type: "session.next.tool.input.started",
properties: {
sessionID: "session-1",
timestamp: 2,
assistantMessageID: "assistant-1",
callID: "call-1",
name: "bash",
},
}),
)
events.emit(
global({
id: "failed-1",
type: "session.next.tool.failed",
properties: {
sessionID: "session-1",
timestamp: 3,
assistantMessageID: "assistant-1",
callID: "call-1",
error: { type: "unknown", message: "aborted" },
provider: { executed: false },
},
}),
)
emitTwice(events, {
id: "evt_agent_1",
type: "session.next.agent.switched",
properties: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 0, agent: "build" },
})
emitTwice(events, {
id: "evt_model_1",
type: "session.next.model.switched",
properties: {
sessionID: "session-1",
messageID: "msg_model_1",
timestamp: 0,
model: { id: "model-1", providerID: "provider-1" },
},
})
emitTwice(events, {
id: "evt_step_started_1",
type: "session.next.step.started",
properties: {
sessionID: "session-1",
assistantMessageID: "msg_explicit_assistant_9",
timestamp: 1,
agent: "build",
model: { id: "model-1", providerID: "provider-1" },
},
})
emitTwice(events, {
id: "evt_input_1",
type: "session.next.tool.input.started",
properties: {
sessionID: "session-1",
assistantMessageID: "msg_explicit_assistant_9",
timestamp: 2,
callID: "call-1",
name: "bash",
},
})
emitTwice(events, {
id: "evt_called_1",
type: "session.next.tool.called",
properties: {
sessionID: "session-1",
timestamp: 2,
assistantMessageID: "msg_explicit_assistant_9",
callID: "call-1",
tool: "bash",
input: {},
provider: { executed: false, metadata: { fake: { call: true } } },
},
})
emitTwice(events, {
id: "evt_failed_1",
type: "session.next.tool.failed",
properties: {
sessionID: "session-1",
timestamp: 3,
assistantMessageID: "msg_explicit_assistant_9",
callID: "call-1",
error: { type: "unknown", message: "aborted" },
provider: { executed: false, metadata: { fake: { result: true } } },
},
})
await wait(() => {
const assistant = sync.session.message.fromSession("session-1")[0]
@ -117,6 +128,7 @@ test("sync v2 settles pending tools when a live failure arrives", async () => {
const assistant = sync.session.message.fromSession("session-1")[0]
expect(assistant?.type).toBe("assistant")
if (assistant?.type !== "assistant") return
expect(assistant.id).toBe("msg_explicit_assistant_9")
const tool = assistant.content[0]
expect(tool?.type).toBe("tool")
if (tool?.type !== "tool") return
@ -126,6 +138,11 @@ test("sync v2 settles pending tools when a live failure arrives", async () => {
expect(tool.state.input).toEqual({})
expect(tool.state.structured).toEqual({})
expect(tool.state.content).toEqual([])
expect(tool.provider).toEqual({
executed: false,
metadata: { fake: { call: true } },
resultMetadata: { fake: { result: true } },
})
expect(sync.session.message.fromSession("session-1").map((message) => message.type)).toEqual([
"assistant",
"model-switched",
@ -135,3 +152,358 @@ test("sync v2 settles pending tools when a live failure arrives", async () => {
app.renderer.destroy()
}
})
test("sync v2 renders admitted prompts only after promotion", async () => {
const events = createEventSource()
const calls = createFetch()
let sync!: ReturnType<typeof useSyncV2>
let ready!: () => void
const mounted = new Promise<void>((resolve) => {
ready = resolve
})
function Probe() {
sync = useSyncV2()
onMount(ready)
return <box />
}
const app = await testRender(() => (
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<ProjectProvider>
<SyncProviderV2>
<Probe />
</SyncProviderV2>
</ProjectProvider>
</SDKProvider>
))
try {
await mounted
emitTwice(events, {
id: "evt_admitted_1",
type: "session.next.prompt.admitted",
properties: {
sessionID: "session-1",
messageID: "msg_user_1",
timestamp: 0,
prompt: { text: "hello" },
delivery: "steer",
},
})
expect(sync.session.message.fromSession("session-1")).toEqual([])
emitTwice(events, {
id: "evt_promoted_1",
type: "session.next.prompt.promoted",
properties: {
sessionID: "session-1",
messageID: "msg_user_1",
timestamp: 1,
prompt: { text: "hello" },
timeCreated: 0,
},
})
await wait(() => sync.session.message.fromSession("session-1").length === 1)
const message = sync.session.message.fromSession("session-1")[0]
expect(message?.type).toBe("user")
if (message?.type !== "user") return
expect(message).toMatchObject({ id: "msg_user_1", text: "hello" })
} finally {
app.renderer.destroy()
}
})
test("sync v2 renders a promoted prompt when admission was missed", async () => {
const events = createEventSource()
const calls = createFetch()
let sync!: ReturnType<typeof useSyncV2>
let ready!: () => void
const mounted = new Promise<void>((resolve) => {
ready = resolve
})
function Probe() {
sync = useSyncV2()
onMount(ready)
return <box />
}
const app = await testRender(() => (
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<ProjectProvider>
<SyncProviderV2>
<Probe />
</SyncProviderV2>
</ProjectProvider>
</SDKProvider>
))
try {
await mounted
emitTwice(events, {
id: "evt_promoted_1",
type: "session.next.prompt.promoted",
properties: {
sessionID: "session-1",
messageID: "msg_user_1",
timestamp: 1,
prompt: { text: "hello" },
timeCreated: 0,
},
})
await wait(() => sync.session.message.fromSession("session-1").length === 1)
expect(sync.session.message.fromSession("session-1")[0]?.id).toBe("msg_user_1")
} finally {
app.renderer.destroy()
}
})
test("sync v2 preserves live events while snapshot hydration is in flight", async () => {
const events = createEventSource()
const response = Promise.withResolvers<Response>()
const calls = createFetch((url) => {
if (url.pathname === "/api/session/session-1/message") return response.promise
return undefined
})
let sync!: ReturnType<typeof useSyncV2>
let ready!: () => void
const mounted = new Promise<void>((resolve) => {
ready = resolve
})
function Probe() {
sync = useSyncV2()
onMount(ready)
return <box />
}
const app = await testRender(() => (
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<ProjectProvider>
<SyncProviderV2>
<Probe />
</SyncProviderV2>
</ProjectProvider>
</SDKProvider>
))
try {
await mounted
const hydration = sync.session.message.sync("session-1")
emitTwice(events, {
id: "evt_agent_1",
type: "session.next.agent.switched",
properties: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 0, agent: "build" },
})
response.resolve(json({ data: [] }))
await hydration
expect(sync.session.message.fromSession("session-1").map((message) => [message.id, message.type])).toEqual([
["msg_agent_1", "agent-switched"],
])
} finally {
app.renderer.destroy()
}
})
test("sync v2 replaces stale cached rows while preserving in-flight live rows", async () => {
const events = createEventSource()
const response = Promise.withResolvers<Response>()
const calls = createFetch((url) => {
if (url.pathname === "/api/session/session-1/message") return response.promise
return undefined
})
let sync!: ReturnType<typeof useSyncV2>
let ready!: () => void
const mounted = new Promise<void>((resolve) => {
ready = resolve
})
function Probe() {
sync = useSyncV2()
onMount(ready)
return <box />
}
const app = await testRender(() => (
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<ProjectProvider>
<SyncProviderV2>
<Probe />
</SyncProviderV2>
</ProjectProvider>
</SDKProvider>
))
try {
await mounted
emitTwice(events, {
id: "evt_promoted_1",
type: "session.next.prompt.promoted",
properties: {
sessionID: "session-1",
messageID: "msg_user_1",
timestamp: 1,
prompt: { text: "stale" },
timeCreated: 0,
},
})
await wait(() => sync.session.message.fromSession("session-1")[0]?.id === "msg_user_1")
const hydration = sync.session.message.sync("session-1")
emitTwice(events, {
id: "evt_agent_1",
type: "session.next.agent.switched",
properties: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 2, agent: "build" },
})
await wait(() => sync.session.message.fromSession("session-1")[0]?.id === "msg_agent_1")
response.resolve(
json({
data: [{ id: "msg_user_1", type: "user", text: "fresh", time: { created: 0 } }],
}),
)
await hydration
expect(sync.session.message.fromSession("session-1").map((message) => [message.id, message.type])).toEqual([
["msg_agent_1", "agent-switched"],
["msg_user_1", "user"],
])
expect(sync.session.message.fromSession("session-1")[1]).toMatchObject({ text: "fresh" })
} finally {
app.renderer.destroy()
}
})
test("sync v2 preserves snapshot order and metadata for in-flight updates", async () => {
const events = createEventSource()
const response = Promise.withResolvers<Response>()
const calls = createFetch((url) => {
if (url.pathname === "/api/session/session-1/message") return response.promise
return undefined
})
let sync!: ReturnType<typeof useSyncV2>
let ready!: () => void
const mounted = new Promise<void>((resolve) => {
ready = resolve
})
function Probe() {
sync = useSyncV2()
onMount(ready)
return <box />
}
const app = await testRender(() => (
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<ProjectProvider>
<SyncProviderV2>
<Probe />
</SyncProviderV2>
</ProjectProvider>
</SDKProvider>
))
try {
await mounted
emitTwice(events, {
id: "evt_step_older",
type: "session.next.step.started",
properties: {
sessionID: "session-1",
assistantMessageID: "msg_assistant_older",
timestamp: 0,
agent: "build",
model: { id: "model", providerID: "provider" },
},
})
emitTwice(events, {
id: "evt_step_1",
type: "session.next.step.started",
properties: {
sessionID: "session-1",
assistantMessageID: "msg_assistant_old",
timestamp: 1,
agent: "build",
model: { id: "model", providerID: "provider" },
},
})
await wait(() => sync.session.message.fromSession("session-1")[0]?.id === "msg_assistant_old")
const hydration = sync.session.message.sync("session-1")
emitTwice(events, {
id: "evt_text_1",
type: "session.next.text.started",
properties: {
sessionID: "session-1",
assistantMessageID: "msg_assistant_old",
timestamp: 2,
textID: "text-1",
},
})
emitTwice(events, {
id: "evt_text_older",
type: "session.next.text.started",
properties: {
sessionID: "session-1",
assistantMessageID: "msg_assistant_older",
timestamp: 2,
textID: "text-older",
},
})
await wait(() => {
const messages = sync.session.message.fromSession("session-1")
return messages.every((message) => message.type !== "assistant" || message.content[0]?.type === "text")
})
response.resolve(
json({
data: [
{
id: "msg_assistant_new",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [],
time: { created: 3 },
},
{
id: "msg_assistant_old",
type: "assistant",
metadata: { source: "snapshot" },
agent: "build",
model: { id: "model", providerID: "provider" },
content: [],
time: { created: 1 },
},
],
}),
)
await hydration
emitTwice(events, {
id: "evt_step_late_duplicate",
type: "session.next.step.started",
properties: {
sessionID: "session-1",
assistantMessageID: "msg_assistant_old",
timestamp: 1,
agent: "build",
model: { id: "model", providerID: "provider" },
},
})
expect(sync.session.message.fromSession("session-1").map((message) => message.id)).toEqual([
"msg_assistant_new",
"msg_assistant_old",
"msg_assistant_older",
])
expect(JSON.parse(JSON.stringify(sync.session.message.fromSession("session-1")[1]))).toMatchObject({
metadata: { source: "snapshot" },
content: [{ type: "text", id: "text-1", text: "" }],
})
expect(JSON.parse(JSON.stringify(sync.session.message.fromSession("session-1")[2]))).toMatchObject({
content: [{ type: "text", id: "text-older", text: "" }],
})
} finally {
app.renderer.destroy()
}
})

View file

@ -584,17 +584,19 @@ describe("session HttpApi", () => {
request(`/api/session/${session.id}/prompt`, {
method: "POST",
headers: { ...headers, "content-type": "application/json" },
body: JSON.stringify({ id: "evt_http_prompt", prompt: { text: "hello" } }),
body: JSON.stringify({ id: "msg_http_prompt", prompt: { text: "hello" } }),
})
const first = yield* recordPrompt()
const retried = yield* recordPrompt()
type PromptBody = { id: string; type: string; text: string }
type PromptBody = { id: string; prompt: { text: string }; delivery: string; promotedSeq?: number }
const firstBody = yield* json<{ data: PromptBody }>(first)
const retriedBody = yield* json<{ data: PromptBody }>(retried)
expect(first.status).toBe(200)
expect(retried.status).toBe(200)
expect(retriedBody).toEqual(firstBody)
expect(firstBody).toMatchObject({ data: { type: "user", text: "hello" } })
expect(firstBody).toMatchObject({
data: { id: "msg_http_prompt", prompt: { text: "hello" }, delivery: "steer" },
})
const messages = yield* requestJson<{ data: PromptBody[] }>(`/api/session/${session.id}/message`, {
headers,
@ -604,27 +606,26 @@ describe("session HttpApi", () => {
db
.select()
.from(SessionInputTable)
.where(eq(SessionInputTable.id, SessionMessage.ID.make("evt_http_prompt")))
.where(eq(SessionInputTable.id, SessionMessage.ID.make("msg_http_prompt")))
.get()
.pipe(Effect.orDie),
)
expect(admitted).toMatchObject({
id: "evt_http_prompt",
id: "msg_http_prompt",
session_id: session.id,
delivery: "steer",
promoted_seq: null,
})
const conflict = yield* request(`/api/session/${session.id}/prompt`, {
method: "POST",
headers: { ...headers, "content-type": "application/json" },
body: JSON.stringify({ id: "evt_http_prompt", prompt: { text: "goodbye" } }),
body: JSON.stringify({ id: "msg_http_prompt", prompt: { text: "goodbye" } }),
})
expect(conflict.status).toBe(409)
expect(yield* responseJson(conflict)).toEqual({
_tag: "ConflictError",
message: "Prompt message ID conflicts with an existing durable record: evt_http_prompt",
resource: "evt_http_prompt",
message: "Prompt message ID conflicts with an existing durable record: msg_http_prompt",
resource: "msg_http_prompt",
})
}),
{ git: true, config: { formatter: false, lsp: false } },

View file

@ -106,6 +106,13 @@ describe("sync HttpApi", () => {
events: [{ id: "event", aggregateID: "session", seq: 1.5, type: "session.created", data: {} }],
},
},
{
path: SyncPaths.replay,
body: {
directory: tmp.directory,
events: [{ id: "event", aggregateID: "session", seq: 0, type: "session.created", data: {} }],
},
},
]
for (const item of cases) {

View file

@ -26,7 +26,7 @@ function migrations() {
}
describe("workspace time migration", () => {
test("migrates existing workspace rows", () => {
test("discards existing workspace rows during the beta reset", () => {
const sqlite = new Database(":memory:")
const db = drizzle({ client: sqlite })
const entries = migrations()
@ -45,6 +45,6 @@ describe("workspace time migration", () => {
)
expect(() => migrate(db, entries.slice(index))).not.toThrow()
expect(sqlite.query("SELECT time_used FROM workspace WHERE id = ?").get("workspace_1")).toEqual({ time_used: 0 })
expect(sqlite.query("SELECT time_used FROM workspace WHERE id = ?").get("workspace_1")).toBeNull()
})
})

View file

@ -7,19 +7,21 @@ import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { ToolOutput } from "@opencode-ai/core/tool-output"
test.skip("step snapshots carry over to assistant messages", () => {
const state: SessionMessageUpdater.MemoryState = { messages: [] }
const sessionID = SessionID.make("session")
const assistantMessageID = EventV2.ID.create()
const assistantMessageID = SessionMessage.ID.create()
Effect.runSync(
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
id: assistantMessageID,
id: EventV2.ID.create(),
type: "session.next.step.started",
data: {
sessionID,
assistantMessageID,
timestamp: DateTime.makeUnsafe(1),
agent: "build",
model: {
@ -62,6 +64,7 @@ test.skip("step snapshots carry over to assistant messages", () => {
test.skip("text ended populates assistant text content", () => {
const state: SessionMessageUpdater.MemoryState = { messages: [] }
const sessionID = SessionID.make("session")
const assistantMessageID = SessionMessage.ID.create()
Effect.runSync(
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
@ -69,6 +72,7 @@ test.skip("text ended populates assistant text content", () => {
type: "session.next.step.started",
data: {
sessionID,
assistantMessageID,
timestamp: DateTime.makeUnsafe(1),
agent: "build",
model: {
@ -86,6 +90,7 @@ test.skip("text ended populates assistant text content", () => {
type: "session.next.text.started",
data: {
sessionID,
assistantMessageID,
timestamp: DateTime.makeUnsafe(2),
textID: "text-1",
},
@ -98,6 +103,7 @@ test.skip("text ended populates assistant text content", () => {
type: "session.next.text.ended",
data: {
sessionID,
assistantMessageID,
timestamp: DateTime.makeUnsafe(3),
textID: "text-1",
text: "hello assistant",
@ -114,14 +120,15 @@ test.skip("tool completion stores completed timestamp", () => {
const state: SessionMessageUpdater.MemoryState = { messages: [] }
const sessionID = SessionID.make("session")
const callID = "call"
const assistantMessageID = EventV2.ID.create()
const assistantMessageID = SessionMessage.ID.create()
Effect.runSync(
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
id: assistantMessageID,
id: EventV2.ID.create(),
type: "session.next.step.started",
data: {
sessionID,
assistantMessageID,
timestamp: DateTime.makeUnsafe(1),
agent: "build",
model: {
@ -198,6 +205,7 @@ test.skip("compaction events reduce to compaction message", () => {
type: "session.next.compaction.started",
data: {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1),
reason: "auto",
},