refactor(core): make v2 session inputs event sourced (#30785)
This commit is contained in:
parent
057958c933
commit
76ecf2e58c
43 changed files with 4671 additions and 757 deletions
|
|
@ -1,5 +1,6 @@
|
|||
import { useEvent } from "@tui/context/event"
|
||||
import type {
|
||||
Event,
|
||||
SessionMessage,
|
||||
SessionMessageAssistant,
|
||||
SessionMessageAssistantReasoning,
|
||||
|
|
@ -54,6 +55,11 @@ function latestReasoning(assistant: SessionMessageAssistant | undefined, reasoni
|
|||
)
|
||||
}
|
||||
|
||||
function prepend(messages: SessionMessage[], message: SessionMessage) {
|
||||
if (messages.some((item) => item.id === message.id)) return
|
||||
messages.unshift(message)
|
||||
}
|
||||
|
||||
export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext({
|
||||
name: "SyncV2",
|
||||
init: () => {
|
||||
|
|
@ -67,6 +73,18 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
|||
|
||||
const event = useEvent()
|
||||
const sdk = useSDK()
|
||||
const applied = new Set<string>()
|
||||
const buffering = new Map<string, Event[]>()
|
||||
const syncing = new Map<string, Promise<void>>()
|
||||
|
||||
function duplicate(id: string) {
|
||||
if (applied.has(id)) return true
|
||||
applied.add(id)
|
||||
if (applied.size <= 1000) return false
|
||||
const oldest = applied.values().next()
|
||||
if (!oldest.done) applied.delete(oldest.value)
|
||||
return false
|
||||
}
|
||||
|
||||
function update(sessionID: string, fn: (messages: SessionMessage[]) => void) {
|
||||
setStore(
|
||||
|
|
@ -77,12 +95,41 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
|||
)
|
||||
}
|
||||
|
||||
event.subscribe((event) => {
|
||||
async function hydrate(sessionID: string) {
|
||||
const pending: Event[] = []
|
||||
const before = JSON.parse(JSON.stringify(store.messages[sessionID] ?? [])) as SessionMessage[]
|
||||
buffering.set(sessionID, pending)
|
||||
try {
|
||||
const response = await sdk.client.v2.session.messages({ sessionID })
|
||||
const messages = response.data?.data ?? []
|
||||
const snapshotIDs = new Set(messages.map((message) => message.id))
|
||||
setStore(
|
||||
"messages",
|
||||
sessionID,
|
||||
reconcile([...messages, ...before.filter((message) => !snapshotIDs.has(message.id))]),
|
||||
)
|
||||
buffering.delete(sessionID)
|
||||
for (const event of pending) apply(event)
|
||||
} catch (error) {
|
||||
buffering.delete(sessionID)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function sync(sessionID: string) {
|
||||
const existing = syncing.get(sessionID)
|
||||
if (existing) return existing
|
||||
const result = hydrate(sessionID).finally(() => syncing.delete(sessionID))
|
||||
syncing.set(sessionID, result)
|
||||
return result
|
||||
}
|
||||
|
||||
function apply(event: Event) {
|
||||
switch (event.type) {
|
||||
case "session.next.agent.switched":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
draft.unshift({
|
||||
id: event.id,
|
||||
prepend(draft, {
|
||||
id: event.properties.messageID,
|
||||
type: "agent-switched",
|
||||
agent: event.properties.agent,
|
||||
time: { created: event.properties.timestamp },
|
||||
|
|
@ -91,8 +138,8 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
|||
break
|
||||
case "session.next.model.switched":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
draft.unshift({
|
||||
id: event.id,
|
||||
prepend(draft, {
|
||||
id: event.properties.messageID,
|
||||
type: "model-switched",
|
||||
model: event.properties.model,
|
||||
time: { created: event.properties.timestamp },
|
||||
|
|
@ -101,8 +148,8 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
|||
break
|
||||
case "session.next.prompted": {
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
draft.unshift({
|
||||
id: event.id,
|
||||
prepend(draft, {
|
||||
id: event.properties.messageID,
|
||||
type: "user",
|
||||
text: event.properties.prompt.text,
|
||||
files: event.properties.prompt.files,
|
||||
|
|
@ -113,10 +160,25 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
|||
})
|
||||
break
|
||||
}
|
||||
case "session.next.prompt.admitted":
|
||||
break
|
||||
case "session.next.prompt.promoted":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
prepend(draft, {
|
||||
id: event.properties.messageID,
|
||||
type: "user",
|
||||
text: event.properties.prompt.text,
|
||||
files: event.properties.prompt.files,
|
||||
agents: event.properties.prompt.agents,
|
||||
references: event.properties.prompt.references,
|
||||
time: { created: event.properties.timeCreated },
|
||||
})
|
||||
})
|
||||
break
|
||||
case "session.next.synthetic":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
draft.unshift({
|
||||
id: event.id,
|
||||
prepend(draft, {
|
||||
id: event.properties.messageID,
|
||||
type: "synthetic",
|
||||
sessionID: event.properties.sessionID,
|
||||
text: event.properties.text,
|
||||
|
|
@ -126,8 +188,8 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
|||
break
|
||||
case "session.next.shell.started":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
draft.unshift({
|
||||
id: event.id,
|
||||
prepend(draft, {
|
||||
id: event.properties.messageID,
|
||||
type: "shell",
|
||||
callID: event.properties.callID,
|
||||
command: event.properties.command,
|
||||
|
|
@ -146,10 +208,11 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
|||
break
|
||||
case "session.next.step.started":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
if (draft.some((message) => message.id === event.properties.assistantMessageID)) return
|
||||
const currentAssistant = activeAssistant(draft)
|
||||
if (currentAssistant) currentAssistant.time.completed = event.properties.timestamp
|
||||
draft.unshift({
|
||||
id: event.id,
|
||||
prepend(draft, {
|
||||
id: event.properties.assistantMessageID,
|
||||
type: "assistant",
|
||||
agent: event.properties.agent,
|
||||
model: event.properties.model,
|
||||
|
|
@ -182,18 +245,28 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
|||
break
|
||||
case "session.next.text.started":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
activeAssistant(draft)?.content.push({ type: "text", id: event.properties.textID, text: "" })
|
||||
ownedAssistant(draft, event.properties.assistantMessageID)?.content.push({
|
||||
type: "text",
|
||||
id: event.properties.textID,
|
||||
text: "",
|
||||
})
|
||||
})
|
||||
break
|
||||
case "session.next.text.delta":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestText(activeAssistant(draft), event.properties.textID)
|
||||
const match = latestText(
|
||||
ownedAssistant(draft, event.properties.assistantMessageID),
|
||||
event.properties.textID,
|
||||
)
|
||||
if (match) match.text += event.properties.delta
|
||||
})
|
||||
break
|
||||
case "session.next.text.ended":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestText(activeAssistant(draft), event.properties.textID)
|
||||
const match = latestText(
|
||||
ownedAssistant(draft, event.properties.assistantMessageID),
|
||||
event.properties.textID,
|
||||
)
|
||||
if (match) match.text = event.properties.text
|
||||
})
|
||||
break
|
||||
|
|
@ -263,7 +336,11 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
|||
content: [...event.properties.content],
|
||||
result: event.properties.result,
|
||||
}
|
||||
match.provider = event.properties.provider
|
||||
match.provider = {
|
||||
executed: event.properties.provider.executed || match.provider?.executed === true,
|
||||
metadata: match.provider?.metadata,
|
||||
resultMetadata: event.properties.provider.metadata,
|
||||
}
|
||||
match.time.completed = event.properties.timestamp
|
||||
})
|
||||
break
|
||||
|
|
@ -282,13 +359,17 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
|||
content: match.state.status === "running" ? match.state.content : [],
|
||||
result: event.properties.result,
|
||||
}
|
||||
match.provider = event.properties.provider
|
||||
match.provider = {
|
||||
executed: event.properties.provider.executed || match.provider?.executed === true,
|
||||
metadata: match.provider?.metadata,
|
||||
resultMetadata: event.properties.provider.metadata,
|
||||
}
|
||||
match.time.completed = event.properties.timestamp
|
||||
})
|
||||
break
|
||||
case "session.next.reasoning.started":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
activeAssistant(draft)?.content.push({
|
||||
ownedAssistant(draft, event.properties.assistantMessageID)?.content.push({
|
||||
type: "reasoning",
|
||||
id: event.properties.reasoningID,
|
||||
text: "",
|
||||
|
|
@ -298,13 +379,19 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
|||
break
|
||||
case "session.next.reasoning.delta":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestReasoning(activeAssistant(draft), event.properties.reasoningID)
|
||||
const match = latestReasoning(
|
||||
ownedAssistant(draft, event.properties.assistantMessageID),
|
||||
event.properties.reasoningID,
|
||||
)
|
||||
if (match) match.text += event.properties.delta
|
||||
})
|
||||
break
|
||||
case "session.next.reasoning.ended":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestReasoning(activeAssistant(draft), event.properties.reasoningID)
|
||||
const match = latestReasoning(
|
||||
ownedAssistant(draft, event.properties.assistantMessageID),
|
||||
event.properties.reasoningID,
|
||||
)
|
||||
if (match) {
|
||||
match.text = event.properties.text
|
||||
if (event.properties.providerMetadata !== undefined)
|
||||
|
|
@ -316,8 +403,8 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
|||
break
|
||||
case "session.next.compaction.started":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
draft.unshift({
|
||||
id: event.id,
|
||||
prepend(draft, {
|
||||
id: event.properties.messageID,
|
||||
type: "compaction",
|
||||
reason: event.properties.reason,
|
||||
summary: "",
|
||||
|
|
@ -340,16 +427,20 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
|||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
event.subscribe((event) => {
|
||||
if (duplicate(event.id)) return
|
||||
if ("sessionID" in event.properties && typeof event.properties.sessionID === "string")
|
||||
buffering.get(event.properties.sessionID)?.push(event)
|
||||
apply(event)
|
||||
})
|
||||
|
||||
const result = {
|
||||
data: store,
|
||||
session: {
|
||||
message: {
|
||||
async sync(sessionID: string) {
|
||||
const response = await sdk.client.v2.session.messages({ sessionID })
|
||||
setStore("messages", sessionID, reconcile(response.data?.data ?? []))
|
||||
},
|
||||
sync,
|
||||
fromSession(sessionID: string) {
|
||||
const messages = store.messages[sessionID]
|
||||
if (!messages) return []
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ const EventSchema = Schema.Union([
|
|||
.values()
|
||||
.map((definition) =>
|
||||
Schema.Struct({
|
||||
id: Schema.String,
|
||||
id: EventV2.ID,
|
||||
type: Schema.Literal(definition.type),
|
||||
properties: definition.data,
|
||||
}).annotate({ identifier: `Event.${definition.type}` }),
|
||||
|
|
|
|||
|
|
@ -20,10 +20,10 @@ const SyncEventSchemas = EventV2.registry
|
|||
return [
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("sync"),
|
||||
id: Schema.String,
|
||||
id: EventV2.ID,
|
||||
syncEvent: Schema.Struct({
|
||||
type: Schema.Literal(EventV2.versionedType(definition.type, definition.sync.version)),
|
||||
id: Schema.String,
|
||||
id: EventV2.ID,
|
||||
seq: Schema.Finite,
|
||||
aggregateID: Schema.String,
|
||||
data: definition.data,
|
||||
|
|
@ -41,7 +41,7 @@ const GlobalEventSchema = Schema.Struct({
|
|||
...EventV2.registry
|
||||
.values()
|
||||
.map((definition) =>
|
||||
Schema.Struct({ id: Schema.String, type: Schema.Literal(definition.type), properties: definition.data }),
|
||||
Schema.Struct({ id: EventV2.ID, type: Schema.Literal(definition.type), properties: definition.data }),
|
||||
)
|
||||
.toArray(),
|
||||
InstanceDisposed,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { NonNegativeInt } from "@opencode-ai/core/schema"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
|
|
@ -9,7 +10,7 @@ import { described } from "./metadata"
|
|||
|
||||
const root = "/sync"
|
||||
export const ReplayEvent = Schema.Struct({
|
||||
id: Schema.String,
|
||||
id: EventV2.ID,
|
||||
aggregateID: Schema.String,
|
||||
seq: NonNegativeInt,
|
||||
type: Schema.String,
|
||||
|
|
@ -27,7 +28,7 @@ export const SessionPayload = Schema.Struct({
|
|||
})
|
||||
export const HistoryPayload = Schema.Record(Schema.String, NonNegativeInt)
|
||||
export const HistoryEvent = Schema.Struct({
|
||||
id: Schema.String,
|
||||
id: EventV2.ID,
|
||||
aggregate_id: Schema.String,
|
||||
seq: NonNegativeInt,
|
||||
type: Schema.String,
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl
|
|||
|
||||
const replay = Effect.fn("SyncHttpApi.replay")(function* (ctx: { payload: typeof ReplayPayload.Type }) {
|
||||
const payload: EventV2.SerializedEvent[] = ctx.payload.events.map((event) => ({
|
||||
id: EventV2.ID.make(event.id),
|
||||
id: event.id,
|
||||
aggregateID: event.aggregateID,
|
||||
seq: event.seq,
|
||||
type: event.type,
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { Plugin } from "@/plugin"
|
|||
import { Config } from "@/config/config"
|
||||
import { NotFoundError } from "@/storage/storage"
|
||||
|
||||
import { Effect, Layer, Context, Schema } from "effect"
|
||||
import { Effect, Layer, Context } from "effect"
|
||||
import * as DateTime from "effect/DateTime"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { isOverflow as overflow, usable } from "./overflow"
|
||||
|
|
@ -20,6 +20,7 @@ import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
|||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
|
|
@ -609,6 +610,7 @@ export const layer = Layer.effect(
|
|||
if (flags.experimentalEventSystem) {
|
||||
yield* events.publish(SessionEvent.Compaction.Started, {
|
||||
sessionID: input.sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
reason: input.auto ? "auto" : "manual",
|
||||
})
|
||||
|
|
|
|||
|
|
@ -25,13 +25,13 @@ import { isRecord } from "@/util/record"
|
|||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import * as DateTime from "effect/DateTime"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { toolFileSourceFromUri, Usage, type LLMEvent } from "@opencode-ai/llm"
|
||||
import { ToolOutput } from "@opencode-ai/core/tool-output"
|
||||
import type { EventV2 } from "@opencode-ai/core/event"
|
||||
|
||||
const DOOM_LOOP_THRESHOLD = 3
|
||||
const log = Log.create({ service: "session.processor" })
|
||||
|
|
@ -67,7 +67,7 @@ export interface Interface {
|
|||
}
|
||||
|
||||
type ToolCall = {
|
||||
assistantMessageID?: EventV2.ID
|
||||
assistantMessageID?: SessionMessage.ID
|
||||
partID: SessionV1.ToolPart["id"]
|
||||
messageID: SessionV1.ToolPart["messageID"]
|
||||
sessionID: SessionV1.ToolPart["sessionID"]
|
||||
|
|
@ -85,7 +85,7 @@ interface ProcessorContext extends Input {
|
|||
currentText: SessionV1.TextPart | undefined
|
||||
currentTextID: string | undefined
|
||||
reasoningMap: Record<string, SessionV1.ReasoningPart>
|
||||
v2AssistantMessageID: EventV2.ID | undefined
|
||||
v2AssistantMessageID: SessionMessage.ID | undefined
|
||||
}
|
||||
|
||||
type StreamEvent = LLMEvent
|
||||
|
|
@ -129,6 +129,7 @@ export const layer = Layer.effect(
|
|||
reasoningMap: {},
|
||||
v2AssistantMessageID: undefined,
|
||||
}
|
||||
const mirrorAssistant = flags.experimentalEventSystem && !input.assistantMessage.summary
|
||||
let aborted = false
|
||||
const slog = log.clone().tag("session.id", input.sessionID).tag("messageID", input.assistantMessage.id)
|
||||
|
||||
|
|
@ -146,8 +147,10 @@ export const layer = Layer.effect(
|
|||
|
||||
const ensureV2AssistantMessage = Effect.fn("SessionProcessor.ensureV2AssistantMessage")(function* () {
|
||||
if (ctx.v2AssistantMessageID) return ctx.v2AssistantMessageID
|
||||
ctx.v2AssistantMessageID = (yield* events.publish(SessionEvent.Step.Started, {
|
||||
ctx.v2AssistantMessageID = SessionMessage.ID.create()
|
||||
yield* events.publish(SessionEvent.Step.Started, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID: ctx.v2AssistantMessageID,
|
||||
agent: input.assistantMessage.agent,
|
||||
model: {
|
||||
id: ModelV2.ID.make(ctx.model.id),
|
||||
|
|
@ -156,7 +159,7 @@ export const layer = Layer.effect(
|
|||
},
|
||||
snapshot: ctx.snapshot,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})).id
|
||||
})
|
||||
return ctx.v2AssistantMessageID
|
||||
})
|
||||
|
||||
|
|
@ -249,9 +252,10 @@ export const layer = Layer.effect(
|
|||
const finishReasoning = Effect.fn("SessionProcessor.finishReasoning")(function* (reasoningID: string) {
|
||||
if (!(reasoningID in ctx.reasoningMap)) return
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
if (mirrorAssistant) {
|
||||
yield* events.publish(SessionEvent.Reasoning.Ended, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID: yield* currentV2AssistantMessage(),
|
||||
reasoningID,
|
||||
text: ctx.reasoningMap[reasoningID].text,
|
||||
providerMetadata: ctx.reasoningMap[reasoningID].metadata,
|
||||
|
|
@ -266,23 +270,29 @@ export const layer = Layer.effect(
|
|||
})
|
||||
|
||||
const flushV2Fragments = Effect.fn("SessionProcessor.flushV2Fragments")(function* () {
|
||||
if (!flags.experimentalEventSystem) return
|
||||
if (!mirrorAssistant) return
|
||||
if (!ctx.assistantMessage.summary && ctx.currentText && ctx.currentTextID) {
|
||||
yield* events.publish(SessionEvent.Text.Ended, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID: yield* currentV2AssistantMessage(),
|
||||
textID: ctx.currentTextID,
|
||||
text: ctx.currentText.text,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
yield* Effect.forEach(Object.entries(ctx.reasoningMap), ([reasoningID, part]) =>
|
||||
events.publish(SessionEvent.Reasoning.Ended, {
|
||||
sessionID: ctx.sessionID,
|
||||
reasoningID,
|
||||
text: part.text,
|
||||
providerMetadata: part.metadata,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
}),
|
||||
currentV2AssistantMessage().pipe(
|
||||
Effect.flatMap((assistantMessageID) =>
|
||||
events.publish(SessionEvent.Reasoning.Ended, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID,
|
||||
reasoningID,
|
||||
text: part.text,
|
||||
providerMetadata: part.metadata,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
|
|
@ -307,7 +317,7 @@ export const layer = Layer.effect(
|
|||
return { call: ctx.toolcalls[input.id], part }
|
||||
}
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
const assistantMessageID = flags.experimentalEventSystem ? yield* ensureV2AssistantMessage() : undefined
|
||||
const assistantMessageID = mirrorAssistant ? yield* ensureV2AssistantMessage() : undefined
|
||||
if (assistantMessageID) {
|
||||
yield* events.publish(SessionEvent.Tool.Input.Started, {
|
||||
sessionID: ctx.sessionID,
|
||||
|
|
@ -367,9 +377,10 @@ export const layer = Layer.effect(
|
|||
case "reasoning-start":
|
||||
if (value.id in ctx.reasoningMap) return
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
if (mirrorAssistant) {
|
||||
yield* events.publish(SessionEvent.Reasoning.Started, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID: yield* ensureV2AssistantMessage(),
|
||||
reasoningID: value.id,
|
||||
providerMetadata: value.providerMetadata,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
|
|
@ -392,9 +403,10 @@ export const layer = Layer.effect(
|
|||
if (!(value.id in ctx.reasoningMap)) return
|
||||
ctx.reasoningMap[value.id].text += value.text
|
||||
if (value.providerMetadata) ctx.reasoningMap[value.id].metadata = value.providerMetadata
|
||||
if (flags.experimentalEventSystem) {
|
||||
if (mirrorAssistant) {
|
||||
yield* events.publish(SessionEvent.Reasoning.Delta, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID: yield* currentV2AssistantMessage(),
|
||||
reasoningID: value.id,
|
||||
delta: value.text,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
|
|
@ -426,9 +438,7 @@ export const layer = Layer.effect(
|
|||
case "tool-input-delta":
|
||||
{
|
||||
const toolCall = yield* ensureToolCall(value)
|
||||
const assistantMessageID = flags.experimentalEventSystem
|
||||
? yield* requireV2AssistantMessage(toolCall.call)
|
||||
: undefined
|
||||
const assistantMessageID = mirrorAssistant ? yield* requireV2AssistantMessage(toolCall.call) : undefined
|
||||
if (assistantMessageID) {
|
||||
yield* events.publish(SessionEvent.Tool.Input.Delta, {
|
||||
sessionID: ctx.sessionID,
|
||||
|
|
@ -445,7 +455,7 @@ export const layer = Layer.effect(
|
|||
case "tool-input-end": {
|
||||
const toolCall = yield* ensureToolCall(value)
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
if (mirrorAssistant) {
|
||||
const assistantMessageID = yield* requireV2AssistantMessage(toolCall.call)
|
||||
yield* events.publish(SessionEvent.Tool.Input.Ended, {
|
||||
sessionID: ctx.sessionID,
|
||||
|
|
@ -467,7 +477,7 @@ export const layer = Layer.effect(
|
|||
const input = isRecord(value.input) ? value.input : { value: value.input }
|
||||
if (!toolCall.call.inputEnded) {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
if (mirrorAssistant) {
|
||||
const assistantMessageID = yield* requireV2AssistantMessage(toolCall.call)
|
||||
yield* events.publish(SessionEvent.Tool.Input.Ended, {
|
||||
sessionID: ctx.sessionID,
|
||||
|
|
@ -479,7 +489,7 @@ export const layer = Layer.effect(
|
|||
}
|
||||
}
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
if (mirrorAssistant) {
|
||||
const assistantMessageID = yield* requireV2AssistantMessage(toolCall.call)
|
||||
yield* events.publish(SessionEvent.Tool.Called, {
|
||||
sessionID: ctx.sessionID,
|
||||
|
|
@ -545,7 +555,7 @@ export const layer = Layer.effect(
|
|||
if (!toolCall && value.result.type === "error") return
|
||||
if (value.result.type === "error") {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
if (mirrorAssistant) {
|
||||
const assistantMessageID = yield* requireV2AssistantMessage(toolCall?.call)
|
||||
yield* events.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID: ctx.sessionID,
|
||||
|
|
@ -586,7 +596,7 @@ export const layer = Layer.effect(
|
|||
attachments: attachments.length ? attachments : undefined,
|
||||
}
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
if (mirrorAssistant) {
|
||||
const assistantMessageID = yield* requireV2AssistantMessage(toolCall?.call)
|
||||
const content = [
|
||||
ToolOutput.text({ type: "text", text: output.output }),
|
||||
|
|
@ -642,7 +652,7 @@ export const layer = Layer.effect(
|
|||
case "tool-error": {
|
||||
const toolCall = yield* readToolCall(value.id)
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
if (mirrorAssistant) {
|
||||
const assistantMessageID = yield* requireV2AssistantMessage(toolCall?.call)
|
||||
yield* events.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID: ctx.sessionID,
|
||||
|
|
@ -670,7 +680,7 @@ export const layer = Layer.effect(
|
|||
if (!ctx.snapshot) ctx.snapshot = yield* snapshot.track()
|
||||
if (!ctx.assistantMessage.summary) {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
if (mirrorAssistant) {
|
||||
yield* ensureV2AssistantMessage()
|
||||
}
|
||||
}
|
||||
|
|
@ -693,7 +703,7 @@ export const layer = Layer.effect(
|
|||
})
|
||||
if (!ctx.assistantMessage.summary) {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
if (mirrorAssistant) {
|
||||
yield* events.publish(SessionEvent.Step.Ended, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID: yield* currentV2AssistantMessage(),
|
||||
|
|
@ -752,9 +762,10 @@ export const layer = Layer.effect(
|
|||
case "text-start":
|
||||
if (!ctx.assistantMessage.summary) {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
if (mirrorAssistant) {
|
||||
yield* events.publish(SessionEvent.Text.Started, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID: yield* ensureV2AssistantMessage(),
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
textID: value.id,
|
||||
})
|
||||
|
|
@ -777,9 +788,10 @@ export const layer = Layer.effect(
|
|||
if (!ctx.currentText) return
|
||||
ctx.currentText.text += value.text
|
||||
if (value.providerMetadata) ctx.currentText.metadata = value.providerMetadata
|
||||
if (flags.experimentalEventSystem) {
|
||||
if (mirrorAssistant) {
|
||||
yield* events.publish(SessionEvent.Text.Delta, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID: yield* currentV2AssistantMessage(),
|
||||
textID: value.id,
|
||||
delta: value.text,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
|
|
@ -809,9 +821,10 @@ export const layer = Layer.effect(
|
|||
)).text
|
||||
if (!ctx.assistantMessage.summary) {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
if (mirrorAssistant) {
|
||||
yield* events.publish(SessionEvent.Text.Ended, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID: yield* currentV2AssistantMessage(),
|
||||
text: ctx.currentText.text,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
textID: value.id,
|
||||
|
|
@ -876,7 +889,7 @@ export const layer = Layer.effect(
|
|||
const match = yield* readToolCall(toolCallID)
|
||||
if (!match) continue
|
||||
const part = match.part
|
||||
if (flags.experimentalEventSystem && match.call.assistantMessageID) {
|
||||
if (mirrorAssistant && match.call.assistantMessageID) {
|
||||
yield* events.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID: match.call.assistantMessageID,
|
||||
|
|
@ -922,7 +935,7 @@ export const layer = Layer.effect(
|
|||
}
|
||||
if (!ctx.assistantMessage.summary) {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
if (mirrorAssistant) {
|
||||
yield* events.publish(SessionEvent.Step.Failed, {
|
||||
sessionID: ctx.sessionID,
|
||||
assistantMessageID: yield* ensureV2AssistantMessage(),
|
||||
|
|
@ -979,7 +992,7 @@ export const layer = Layer.effect(
|
|||
parse,
|
||||
set: (info) => {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
const event = flags.experimentalEventSystem
|
||||
const event = mirrorAssistant
|
||||
? events.publish(SessionEvent.Retried, {
|
||||
sessionID: ctx.sessionID,
|
||||
attempt: info.attempt,
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
|
|||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { AgentAttachment, FileAttachment, Prompt, ReferenceAttachment, Source } from "@opencode-ai/core/session/prompt"
|
||||
|
|
@ -563,6 +564,7 @@ export const layer = Layer.effect(
|
|||
if (flags.experimentalEventSystem) {
|
||||
yield* events.publish(SessionEvent.Shell.Started, {
|
||||
sessionID: input.sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: DateTime.makeUnsafe(started),
|
||||
callID: part.callID,
|
||||
command: input.command,
|
||||
|
|
@ -738,6 +740,7 @@ export const layer = Layer.effect(
|
|||
if (current?.agent !== info.agent) {
|
||||
yield* events.publish(SessionEvent.AgentSwitched, {
|
||||
sessionID: input.sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: DateTime.makeUnsafe(info.time.created),
|
||||
agent: info.agent,
|
||||
})
|
||||
|
|
@ -749,6 +752,7 @@ export const layer = Layer.effect(
|
|||
) {
|
||||
yield* events.publish(SessionEvent.ModelSwitched, {
|
||||
sessionID: input.sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: DateTime.makeUnsafe(info.time.created),
|
||||
model: {
|
||||
id: ModelV2.ID.make(info.model.modelID),
|
||||
|
|
@ -1190,6 +1194,7 @@ export const layer = Layer.effect(
|
|||
if (flags.experimentalEventSystem) {
|
||||
yield* events.publish(SessionEvent.Prompted, {
|
||||
sessionID: input.sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: DateTime.makeUnsafe(info.time.created),
|
||||
delivery: "steer",
|
||||
prompt: new Prompt({
|
||||
|
|
@ -1205,6 +1210,7 @@ export const layer = Layer.effect(
|
|||
if (flags.experimentalEventSystem) {
|
||||
yield* events.publish(SessionEvent.Synthetic, {
|
||||
sessionID: input.sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: DateTime.makeUnsafe(info.time.created),
|
||||
text,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { onMount } from "solid-js"
|
|||
import { ProjectProvider } from "../../../src/cli/cmd/tui/context/project"
|
||||
import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk"
|
||||
import { SyncProviderV2, useSyncV2 } from "../../../src/cli/cmd/tui/context/sync-v2"
|
||||
import { createEventSource, createFetch, directory } from "../../fixture/tui-sdk"
|
||||
import { createEventSource, createFetch, directory, json } from "../../fixture/tui-sdk"
|
||||
|
||||
async function wait(fn: () => boolean, timeout = 2000) {
|
||||
const start = Date.now()
|
||||
|
|
@ -20,6 +20,12 @@ function global(payload: Event): GlobalEvent {
|
|||
return { directory, project: "proj_test", payload }
|
||||
}
|
||||
|
||||
function emitTwice(events: ReturnType<typeof createEventSource>, payload: Event) {
|
||||
const event = global(payload)
|
||||
events.emit(event)
|
||||
events.emit(event)
|
||||
}
|
||||
|
||||
test("sync v2 settles pending tools when a live failure arrives", async () => {
|
||||
const events = createEventSource()
|
||||
const calls = createFetch()
|
||||
|
|
@ -47,63 +53,68 @@ test("sync v2 settles pending tools when a live failure arrives", async () => {
|
|||
|
||||
try {
|
||||
await mounted
|
||||
events.emit(
|
||||
global({
|
||||
id: "agent-1",
|
||||
type: "session.next.agent.switched",
|
||||
properties: { sessionID: "session-1", timestamp: 0, agent: "build" },
|
||||
}),
|
||||
)
|
||||
events.emit(
|
||||
global({
|
||||
id: "model-1",
|
||||
type: "session.next.model.switched",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
timestamp: 0,
|
||||
model: { id: "model-1", providerID: "provider-1" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
events.emit(
|
||||
global({
|
||||
id: "assistant-1",
|
||||
type: "session.next.step.started",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
timestamp: 1,
|
||||
agent: "build",
|
||||
model: { id: "model-1", providerID: "provider-1" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
events.emit(
|
||||
global({
|
||||
id: "input-1",
|
||||
type: "session.next.tool.input.started",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
timestamp: 2,
|
||||
assistantMessageID: "assistant-1",
|
||||
callID: "call-1",
|
||||
name: "bash",
|
||||
},
|
||||
}),
|
||||
)
|
||||
events.emit(
|
||||
global({
|
||||
id: "failed-1",
|
||||
type: "session.next.tool.failed",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
timestamp: 3,
|
||||
assistantMessageID: "assistant-1",
|
||||
callID: "call-1",
|
||||
error: { type: "unknown", message: "aborted" },
|
||||
provider: { executed: false },
|
||||
},
|
||||
}),
|
||||
)
|
||||
emitTwice(events, {
|
||||
id: "evt_agent_1",
|
||||
type: "session.next.agent.switched",
|
||||
properties: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 0, agent: "build" },
|
||||
})
|
||||
emitTwice(events, {
|
||||
id: "evt_model_1",
|
||||
type: "session.next.model.switched",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
messageID: "msg_model_1",
|
||||
timestamp: 0,
|
||||
model: { id: "model-1", providerID: "provider-1" },
|
||||
},
|
||||
})
|
||||
emitTwice(events, {
|
||||
id: "evt_step_started_1",
|
||||
type: "session.next.step.started",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
assistantMessageID: "msg_explicit_assistant_9",
|
||||
timestamp: 1,
|
||||
agent: "build",
|
||||
model: { id: "model-1", providerID: "provider-1" },
|
||||
},
|
||||
})
|
||||
emitTwice(events, {
|
||||
id: "evt_input_1",
|
||||
type: "session.next.tool.input.started",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
assistantMessageID: "msg_explicit_assistant_9",
|
||||
timestamp: 2,
|
||||
callID: "call-1",
|
||||
name: "bash",
|
||||
},
|
||||
})
|
||||
emitTwice(events, {
|
||||
id: "evt_called_1",
|
||||
type: "session.next.tool.called",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
timestamp: 2,
|
||||
assistantMessageID: "msg_explicit_assistant_9",
|
||||
callID: "call-1",
|
||||
tool: "bash",
|
||||
input: {},
|
||||
provider: { executed: false, metadata: { fake: { call: true } } },
|
||||
},
|
||||
})
|
||||
emitTwice(events, {
|
||||
id: "evt_failed_1",
|
||||
type: "session.next.tool.failed",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
timestamp: 3,
|
||||
assistantMessageID: "msg_explicit_assistant_9",
|
||||
callID: "call-1",
|
||||
error: { type: "unknown", message: "aborted" },
|
||||
provider: { executed: false, metadata: { fake: { result: true } } },
|
||||
},
|
||||
})
|
||||
|
||||
await wait(() => {
|
||||
const assistant = sync.session.message.fromSession("session-1")[0]
|
||||
|
|
@ -117,6 +128,7 @@ test("sync v2 settles pending tools when a live failure arrives", async () => {
|
|||
const assistant = sync.session.message.fromSession("session-1")[0]
|
||||
expect(assistant?.type).toBe("assistant")
|
||||
if (assistant?.type !== "assistant") return
|
||||
expect(assistant.id).toBe("msg_explicit_assistant_9")
|
||||
const tool = assistant.content[0]
|
||||
expect(tool?.type).toBe("tool")
|
||||
if (tool?.type !== "tool") return
|
||||
|
|
@ -126,6 +138,11 @@ test("sync v2 settles pending tools when a live failure arrives", async () => {
|
|||
expect(tool.state.input).toEqual({})
|
||||
expect(tool.state.structured).toEqual({})
|
||||
expect(tool.state.content).toEqual([])
|
||||
expect(tool.provider).toEqual({
|
||||
executed: false,
|
||||
metadata: { fake: { call: true } },
|
||||
resultMetadata: { fake: { result: true } },
|
||||
})
|
||||
expect(sync.session.message.fromSession("session-1").map((message) => message.type)).toEqual([
|
||||
"assistant",
|
||||
"model-switched",
|
||||
|
|
@ -135,3 +152,358 @@ test("sync v2 settles pending tools when a live failure arrives", async () => {
|
|||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("sync v2 renders admitted prompts only after promotion", async () => {
|
||||
const events = createEventSource()
|
||||
const calls = createFetch()
|
||||
let sync!: ReturnType<typeof useSyncV2>
|
||||
let ready!: () => void
|
||||
const mounted = new Promise<void>((resolve) => {
|
||||
ready = resolve
|
||||
})
|
||||
|
||||
function Probe() {
|
||||
sync = useSyncV2()
|
||||
onMount(ready)
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||
<ProjectProvider>
|
||||
<SyncProviderV2>
|
||||
<Probe />
|
||||
</SyncProviderV2>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
))
|
||||
|
||||
try {
|
||||
await mounted
|
||||
emitTwice(events, {
|
||||
id: "evt_admitted_1",
|
||||
type: "session.next.prompt.admitted",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
messageID: "msg_user_1",
|
||||
timestamp: 0,
|
||||
prompt: { text: "hello" },
|
||||
delivery: "steer",
|
||||
},
|
||||
})
|
||||
expect(sync.session.message.fromSession("session-1")).toEqual([])
|
||||
|
||||
emitTwice(events, {
|
||||
id: "evt_promoted_1",
|
||||
type: "session.next.prompt.promoted",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
messageID: "msg_user_1",
|
||||
timestamp: 1,
|
||||
prompt: { text: "hello" },
|
||||
timeCreated: 0,
|
||||
},
|
||||
})
|
||||
|
||||
await wait(() => sync.session.message.fromSession("session-1").length === 1)
|
||||
const message = sync.session.message.fromSession("session-1")[0]
|
||||
expect(message?.type).toBe("user")
|
||||
if (message?.type !== "user") return
|
||||
expect(message).toMatchObject({ id: "msg_user_1", text: "hello" })
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("sync v2 renders a promoted prompt when admission was missed", async () => {
|
||||
const events = createEventSource()
|
||||
const calls = createFetch()
|
||||
let sync!: ReturnType<typeof useSyncV2>
|
||||
let ready!: () => void
|
||||
const mounted = new Promise<void>((resolve) => {
|
||||
ready = resolve
|
||||
})
|
||||
|
||||
function Probe() {
|
||||
sync = useSyncV2()
|
||||
onMount(ready)
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||
<ProjectProvider>
|
||||
<SyncProviderV2>
|
||||
<Probe />
|
||||
</SyncProviderV2>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
))
|
||||
|
||||
try {
|
||||
await mounted
|
||||
emitTwice(events, {
|
||||
id: "evt_promoted_1",
|
||||
type: "session.next.prompt.promoted",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
messageID: "msg_user_1",
|
||||
timestamp: 1,
|
||||
prompt: { text: "hello" },
|
||||
timeCreated: 0,
|
||||
},
|
||||
})
|
||||
|
||||
await wait(() => sync.session.message.fromSession("session-1").length === 1)
|
||||
expect(sync.session.message.fromSession("session-1")[0]?.id).toBe("msg_user_1")
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("sync v2 preserves live events while snapshot hydration is in flight", async () => {
|
||||
const events = createEventSource()
|
||||
const response = Promise.withResolvers<Response>()
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === "/api/session/session-1/message") return response.promise
|
||||
return undefined
|
||||
})
|
||||
let sync!: ReturnType<typeof useSyncV2>
|
||||
let ready!: () => void
|
||||
const mounted = new Promise<void>((resolve) => {
|
||||
ready = resolve
|
||||
})
|
||||
|
||||
function Probe() {
|
||||
sync = useSyncV2()
|
||||
onMount(ready)
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||
<ProjectProvider>
|
||||
<SyncProviderV2>
|
||||
<Probe />
|
||||
</SyncProviderV2>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
))
|
||||
|
||||
try {
|
||||
await mounted
|
||||
const hydration = sync.session.message.sync("session-1")
|
||||
emitTwice(events, {
|
||||
id: "evt_agent_1",
|
||||
type: "session.next.agent.switched",
|
||||
properties: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 0, agent: "build" },
|
||||
})
|
||||
response.resolve(json({ data: [] }))
|
||||
await hydration
|
||||
|
||||
expect(sync.session.message.fromSession("session-1").map((message) => [message.id, message.type])).toEqual([
|
||||
["msg_agent_1", "agent-switched"],
|
||||
])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("sync v2 replaces stale cached rows while preserving in-flight live rows", async () => {
|
||||
const events = createEventSource()
|
||||
const response = Promise.withResolvers<Response>()
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === "/api/session/session-1/message") return response.promise
|
||||
return undefined
|
||||
})
|
||||
let sync!: ReturnType<typeof useSyncV2>
|
||||
let ready!: () => void
|
||||
const mounted = new Promise<void>((resolve) => {
|
||||
ready = resolve
|
||||
})
|
||||
|
||||
function Probe() {
|
||||
sync = useSyncV2()
|
||||
onMount(ready)
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||
<ProjectProvider>
|
||||
<SyncProviderV2>
|
||||
<Probe />
|
||||
</SyncProviderV2>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
))
|
||||
|
||||
try {
|
||||
await mounted
|
||||
emitTwice(events, {
|
||||
id: "evt_promoted_1",
|
||||
type: "session.next.prompt.promoted",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
messageID: "msg_user_1",
|
||||
timestamp: 1,
|
||||
prompt: { text: "stale" },
|
||||
timeCreated: 0,
|
||||
},
|
||||
})
|
||||
await wait(() => sync.session.message.fromSession("session-1")[0]?.id === "msg_user_1")
|
||||
const hydration = sync.session.message.sync("session-1")
|
||||
emitTwice(events, {
|
||||
id: "evt_agent_1",
|
||||
type: "session.next.agent.switched",
|
||||
properties: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 2, agent: "build" },
|
||||
})
|
||||
await wait(() => sync.session.message.fromSession("session-1")[0]?.id === "msg_agent_1")
|
||||
response.resolve(
|
||||
json({
|
||||
data: [{ id: "msg_user_1", type: "user", text: "fresh", time: { created: 0 } }],
|
||||
}),
|
||||
)
|
||||
await hydration
|
||||
|
||||
expect(sync.session.message.fromSession("session-1").map((message) => [message.id, message.type])).toEqual([
|
||||
["msg_agent_1", "agent-switched"],
|
||||
["msg_user_1", "user"],
|
||||
])
|
||||
expect(sync.session.message.fromSession("session-1")[1]).toMatchObject({ text: "fresh" })
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("sync v2 preserves snapshot order and metadata for in-flight updates", async () => {
|
||||
const events = createEventSource()
|
||||
const response = Promise.withResolvers<Response>()
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === "/api/session/session-1/message") return response.promise
|
||||
return undefined
|
||||
})
|
||||
let sync!: ReturnType<typeof useSyncV2>
|
||||
let ready!: () => void
|
||||
const mounted = new Promise<void>((resolve) => {
|
||||
ready = resolve
|
||||
})
|
||||
|
||||
function Probe() {
|
||||
sync = useSyncV2()
|
||||
onMount(ready)
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||
<ProjectProvider>
|
||||
<SyncProviderV2>
|
||||
<Probe />
|
||||
</SyncProviderV2>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
))
|
||||
|
||||
try {
|
||||
await mounted
|
||||
emitTwice(events, {
|
||||
id: "evt_step_older",
|
||||
type: "session.next.step.started",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
assistantMessageID: "msg_assistant_older",
|
||||
timestamp: 0,
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
})
|
||||
emitTwice(events, {
|
||||
id: "evt_step_1",
|
||||
type: "session.next.step.started",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
assistantMessageID: "msg_assistant_old",
|
||||
timestamp: 1,
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
})
|
||||
await wait(() => sync.session.message.fromSession("session-1")[0]?.id === "msg_assistant_old")
|
||||
const hydration = sync.session.message.sync("session-1")
|
||||
emitTwice(events, {
|
||||
id: "evt_text_1",
|
||||
type: "session.next.text.started",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
assistantMessageID: "msg_assistant_old",
|
||||
timestamp: 2,
|
||||
textID: "text-1",
|
||||
},
|
||||
})
|
||||
emitTwice(events, {
|
||||
id: "evt_text_older",
|
||||
type: "session.next.text.started",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
assistantMessageID: "msg_assistant_older",
|
||||
timestamp: 2,
|
||||
textID: "text-older",
|
||||
},
|
||||
})
|
||||
await wait(() => {
|
||||
const messages = sync.session.message.fromSession("session-1")
|
||||
return messages.every((message) => message.type !== "assistant" || message.content[0]?.type === "text")
|
||||
})
|
||||
response.resolve(
|
||||
json({
|
||||
data: [
|
||||
{
|
||||
id: "msg_assistant_new",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [],
|
||||
time: { created: 3 },
|
||||
},
|
||||
{
|
||||
id: "msg_assistant_old",
|
||||
type: "assistant",
|
||||
metadata: { source: "snapshot" },
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [],
|
||||
time: { created: 1 },
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
await hydration
|
||||
emitTwice(events, {
|
||||
id: "evt_step_late_duplicate",
|
||||
type: "session.next.step.started",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
assistantMessageID: "msg_assistant_old",
|
||||
timestamp: 1,
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
})
|
||||
|
||||
expect(sync.session.message.fromSession("session-1").map((message) => message.id)).toEqual([
|
||||
"msg_assistant_new",
|
||||
"msg_assistant_old",
|
||||
"msg_assistant_older",
|
||||
])
|
||||
expect(JSON.parse(JSON.stringify(sync.session.message.fromSession("session-1")[1]))).toMatchObject({
|
||||
metadata: { source: "snapshot" },
|
||||
content: [{ type: "text", id: "text-1", text: "" }],
|
||||
})
|
||||
expect(JSON.parse(JSON.stringify(sync.session.message.fromSession("session-1")[2]))).toMatchObject({
|
||||
content: [{ type: "text", id: "text-older", text: "" }],
|
||||
})
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -584,17 +584,19 @@ describe("session HttpApi", () => {
|
|||
request(`/api/session/${session.id}/prompt`, {
|
||||
method: "POST",
|
||||
headers: { ...headers, "content-type": "application/json" },
|
||||
body: JSON.stringify({ id: "evt_http_prompt", prompt: { text: "hello" } }),
|
||||
body: JSON.stringify({ id: "msg_http_prompt", prompt: { text: "hello" } }),
|
||||
})
|
||||
const first = yield* recordPrompt()
|
||||
const retried = yield* recordPrompt()
|
||||
type PromptBody = { id: string; type: string; text: string }
|
||||
type PromptBody = { id: string; prompt: { text: string }; delivery: string; promotedSeq?: number }
|
||||
const firstBody = yield* json<{ data: PromptBody }>(first)
|
||||
const retriedBody = yield* json<{ data: PromptBody }>(retried)
|
||||
expect(first.status).toBe(200)
|
||||
expect(retried.status).toBe(200)
|
||||
expect(retriedBody).toEqual(firstBody)
|
||||
expect(firstBody).toMatchObject({ data: { type: "user", text: "hello" } })
|
||||
expect(firstBody).toMatchObject({
|
||||
data: { id: "msg_http_prompt", prompt: { text: "hello" }, delivery: "steer" },
|
||||
})
|
||||
|
||||
const messages = yield* requestJson<{ data: PromptBody[] }>(`/api/session/${session.id}/message`, {
|
||||
headers,
|
||||
|
|
@ -604,27 +606,26 @@ describe("session HttpApi", () => {
|
|||
db
|
||||
.select()
|
||||
.from(SessionInputTable)
|
||||
.where(eq(SessionInputTable.id, SessionMessage.ID.make("evt_http_prompt")))
|
||||
.where(eq(SessionInputTable.id, SessionMessage.ID.make("msg_http_prompt")))
|
||||
.get()
|
||||
.pipe(Effect.orDie),
|
||||
)
|
||||
expect(admitted).toMatchObject({
|
||||
id: "evt_http_prompt",
|
||||
id: "msg_http_prompt",
|
||||
session_id: session.id,
|
||||
delivery: "steer",
|
||||
promoted_seq: null,
|
||||
})
|
||||
|
||||
const conflict = yield* request(`/api/session/${session.id}/prompt`, {
|
||||
method: "POST",
|
||||
headers: { ...headers, "content-type": "application/json" },
|
||||
body: JSON.stringify({ id: "evt_http_prompt", prompt: { text: "goodbye" } }),
|
||||
body: JSON.stringify({ id: "msg_http_prompt", prompt: { text: "goodbye" } }),
|
||||
})
|
||||
expect(conflict.status).toBe(409)
|
||||
expect(yield* responseJson(conflict)).toEqual({
|
||||
_tag: "ConflictError",
|
||||
message: "Prompt message ID conflicts with an existing durable record: evt_http_prompt",
|
||||
resource: "evt_http_prompt",
|
||||
message: "Prompt message ID conflicts with an existing durable record: msg_http_prompt",
|
||||
resource: "msg_http_prompt",
|
||||
})
|
||||
}),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
|
|
|
|||
|
|
@ -106,6 +106,13 @@ describe("sync HttpApi", () => {
|
|||
events: [{ id: "event", aggregateID: "session", seq: 1.5, type: "session.created", data: {} }],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: SyncPaths.replay,
|
||||
body: {
|
||||
directory: tmp.directory,
|
||||
events: [{ id: "event", aggregateID: "session", seq: 0, type: "session.created", data: {} }],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
for (const item of cases) {
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ function migrations() {
|
|||
}
|
||||
|
||||
describe("workspace time migration", () => {
|
||||
test("migrates existing workspace rows", () => {
|
||||
test("discards existing workspace rows during the beta reset", () => {
|
||||
const sqlite = new Database(":memory:")
|
||||
const db = drizzle({ client: sqlite })
|
||||
const entries = migrations()
|
||||
|
|
@ -45,6 +45,6 @@ describe("workspace time migration", () => {
|
|||
)
|
||||
|
||||
expect(() => migrate(db, entries.slice(index))).not.toThrow()
|
||||
expect(sqlite.query("SELECT time_used FROM workspace WHERE id = ?").get("workspace_1")).toEqual({ time_used: 0 })
|
||||
expect(sqlite.query("SELECT time_used FROM workspace WHERE id = ?").get("workspace_1")).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -7,19 +7,21 @@ import { ModelV2 } from "@opencode-ai/core/model"
|
|||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { ToolOutput } from "@opencode-ai/core/tool-output"
|
||||
|
||||
test.skip("step snapshots carry over to assistant messages", () => {
|
||||
const state: SessionMessageUpdater.MemoryState = { messages: [] }
|
||||
const sessionID = SessionID.make("session")
|
||||
const assistantMessageID = EventV2.ID.create()
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
|
||||
Effect.runSync(
|
||||
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
|
||||
id: assistantMessageID,
|
||||
id: EventV2.ID.create(),
|
||||
type: "session.next.step.started",
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
agent: "build",
|
||||
model: {
|
||||
|
|
@ -62,6 +64,7 @@ test.skip("step snapshots carry over to assistant messages", () => {
|
|||
test.skip("text ended populates assistant text content", () => {
|
||||
const state: SessionMessageUpdater.MemoryState = { messages: [] }
|
||||
const sessionID = SessionID.make("session")
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
|
||||
Effect.runSync(
|
||||
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
|
||||
|
|
@ -69,6 +72,7 @@ test.skip("text ended populates assistant text content", () => {
|
|||
type: "session.next.step.started",
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
agent: "build",
|
||||
model: {
|
||||
|
|
@ -86,6 +90,7 @@ test.skip("text ended populates assistant text content", () => {
|
|||
type: "session.next.text.started",
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
timestamp: DateTime.makeUnsafe(2),
|
||||
textID: "text-1",
|
||||
},
|
||||
|
|
@ -98,6 +103,7 @@ test.skip("text ended populates assistant text content", () => {
|
|||
type: "session.next.text.ended",
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
timestamp: DateTime.makeUnsafe(3),
|
||||
textID: "text-1",
|
||||
text: "hello assistant",
|
||||
|
|
@ -114,14 +120,15 @@ test.skip("tool completion stores completed timestamp", () => {
|
|||
const state: SessionMessageUpdater.MemoryState = { messages: [] }
|
||||
const sessionID = SessionID.make("session")
|
||||
const callID = "call"
|
||||
const assistantMessageID = EventV2.ID.create()
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
|
||||
Effect.runSync(
|
||||
SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
|
||||
id: assistantMessageID,
|
||||
id: EventV2.ID.create(),
|
||||
type: "session.next.step.started",
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
agent: "build",
|
||||
model: {
|
||||
|
|
@ -198,6 +205,7 @@ test.skip("compaction events reduce to compaction message", () => {
|
|||
type: "session.next.compaction.started",
|
||||
data: {
|
||||
sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
reason: "auto",
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue