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

This commit is contained in:
Kit Langton 2026-07-03 14:25:59 -04:00 committed by GitHub
commit 394e0b9045
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
63 changed files with 1888 additions and 1989 deletions

View file

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

View file

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

View file

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

View file

@ -90,12 +90,12 @@ function durable(sessionID: string) {
function stepStarted(id: string, sessionID = "session"): V2Event {
return {
id,
type: "session.next.step.started",
created: 0,
type: "step.started",
durable: durable(sessionID),
data: {
sessionID,
assistantMessageID: `msg_${id}`,
timestamp: 0,
agent: "build",
model: { id: "model", providerID: "provider" },
},
@ -105,12 +105,12 @@ function stepStarted(id: string, sessionID = "session"): V2Event {
function stepEnded(id: string, sessionID = "session", finish = "stop"): V2Event {
return {
id,
type: "session.next.step.ended",
created: 0,
type: "step.ended",
durable: durable(sessionID),
data: {
sessionID,
assistantMessageID: `msg_${id}`,
timestamp: 0,
finish,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
@ -121,12 +121,12 @@ function stepEnded(id: string, sessionID = "session", finish = "stop"): V2Event
function stepFailed(id: string, sessionID = "session"): V2Event {
return {
id,
type: "session.next.step.failed",
created: 0,
type: "step.failed",
durable: durable(sessionID),
data: {
sessionID,
assistantMessageID: `msg_${id}`,
timestamp: 0,
error: { type: "unknown", message: "boom" },
},
}
@ -150,8 +150,8 @@ describe("internal notifications TUI plugin", () => {
test("notifies for question and permission requests with blurred notifications and always-on sounds", async () => {
const harness = await setup()
harness.emit({ id: "event-1", type: "question.asked", data: question("question-1") })
harness.emit({ id: "event-2", type: "permission.asked", data: permission("permission-1") })
harness.emit({ id: "event-1", created: 0, type: "question.asked", data: question("question-1") })
harness.emit({ id: "event-2", created: 0, type: "permission.asked", data: permission("permission-1") })
expect(harness.notifications).toEqual([questionNotification, permissionNotification])
})
@ -159,23 +159,25 @@ describe("internal notifications TUI plugin", () => {
test("dedupes pending questions and permissions until they are resolved", async () => {
const harness = await setup()
harness.emit({ id: "event-1", type: "question.asked", data: question("question-1") })
harness.emit({ id: "event-2", type: "question.asked", data: question("question-1") })
harness.emit({ id: "event-1", created: 0, type: "question.asked", data: question("question-1") })
harness.emit({ id: "event-2", created: 0, type: "question.asked", data: question("question-1") })
harness.emit({
id: "event-3",
created: 0,
type: "question.replied",
data: { sessionID: "session", requestID: "question-1", answers: [] },
})
harness.emit({ id: "event-4", type: "question.asked", data: question("question-1") })
harness.emit({ id: "event-4", created: 0, type: "question.asked", data: question("question-1") })
harness.emit({ id: "event-5", type: "permission.asked", data: permission("permission-1") })
harness.emit({ id: "event-6", type: "permission.asked", data: permission("permission-1") })
harness.emit({ id: "event-5", created: 0, type: "permission.asked", data: permission("permission-1") })
harness.emit({ id: "event-6", created: 0, type: "permission.asked", data: permission("permission-1") })
harness.emit({
id: "event-7",
created: 0,
type: "permission.replied",
data: { sessionID: "session", requestID: "permission-1", reply: "once" },
})
harness.emit({ id: "event-8", type: "permission.asked", data: permission("permission-1") })
harness.emit({ id: "event-8", created: 0, type: "permission.asked", data: permission("permission-1") })
expect(harness.notifications).toEqual([
questionNotification,
@ -205,7 +207,7 @@ describe("internal notifications TUI plugin", () => {
test("uses sound-only notifications and subagent_done sound for subagent sessions", async () => {
const harness = await setup()
harness.emit({ id: "event-1", type: "question.asked", data: question("question-1", "subagent") })
harness.emit({ id: "event-1", created: 0, type: "question.asked", data: question("question-1", "subagent") })
harness.emit(stepStarted("event-2", "subagent"))
harness.emit(stepEnded("event-3", "subagent"))
@ -248,12 +250,14 @@ describe("internal notifications TUI plugin", () => {
harness.emit(stepStarted("event-1", "abort"))
harness.emit({
id: "event-2",
created: 0,
type: "session.error",
data: { sessionID: "abort", error: { name: "MessageAbortedError", data: { message: "Aborted" } } },
})
harness.emit(stepStarted("event-3", "timeout"))
harness.emit({
id: "event-4",
created: 0,
type: "session.error",
data: { sessionID: "timeout", error: { name: "UnknownError", data: { message: "SSE read timed out" } } },
})

View file

@ -2,6 +2,8 @@
import { expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import type { V2Event } from "@opencode-ai/sdk/v2"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { EventV2 } from "@opencode-ai/core/event"
import { onMount } from "solid-js"
import { ProjectProvider } from "../../../src/context/project"
import { SDKProvider } from "../../../src/context/sdk"
@ -47,8 +49,8 @@ test("refreshes resources into reactive getters", async () => {
if (url.pathname === "/api/session/ses_test/message")
return json({
data: [
{ id: "msg_second", type: "user", text: "Second", time: { created: 2 } },
{ id: "msg_first", type: "user", text: "First", time: { created: 1 } },
{ id: "msg_second", created: 0, type: "user", text: "Second", time: { created: 2 } },
{ id: "msg_first", created: 0, type: "user", text: "First", time: { created: 1 } },
],
cursor: {},
})
@ -187,7 +189,9 @@ test("connectedOnce is false until first connect and persists across disconnect"
)
const connect = () =>
stream?.enqueue(
encoder.encode(`data: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n`),
encoder.encode(
`data: ${JSON.stringify({ id: "evt_connected", created: 0, type: "server.connected", data: {} })}\n\n`,
),
)
const disconnect = () => {
stream?.close()
@ -264,12 +268,12 @@ test("tracks session status from active sessions and execution events", async ()
emitEvent(events, {
id: "evt_step_started",
type: "session.next.step.started",
created: 0,
type: "step.started",
durable: durable("session-live"),
data: {
sessionID: "session-live",
assistantMessageID: "message-live",
timestamp: 1,
agent: "build",
model: { id: "model", providerID: "provider" },
},
@ -278,12 +282,12 @@ test("tracks session status from active sessions and execution events", async ()
emitEvent(events, {
id: "evt_step_ended",
type: "session.next.step.ended",
created: 0,
type: "step.ended",
durable: durable("session-live", 1, 2),
data: {
sessionID: "session-live",
assistantMessageID: "message-live",
timestamp: 2,
finish: "stop",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
@ -297,10 +301,10 @@ test("tracks session status from active sessions and execution events", async ()
emitEvent(events, {
id: "evt_execution_settled",
type: "session.next.execution.settled",
created: 0,
type: "execution.settled",
data: {
sessionID: "session-live",
timestamp: 3,
outcome: "success",
},
})
@ -308,12 +312,12 @@ test("tracks session status from active sessions and execution events", async ()
emitEvent(events, {
id: "evt_failed_step_started",
type: "session.next.step.started",
created: 0,
type: "step.started",
durable: durable("session-failed"),
data: {
sessionID: "session-failed",
assistantMessageID: "message-failed",
timestamp: 3,
agent: "build",
model: { id: "model", providerID: "provider" },
},
@ -322,12 +326,12 @@ test("tracks session status from active sessions and execution events", async ()
emitEvent(events, {
id: "evt_step_failed",
type: "session.next.step.failed",
created: 0,
type: "step.failed",
durable: durable("session-failed", 1, 2),
data: {
sessionID: "session-failed",
assistantMessageID: "message-failed",
timestamp: 4,
error: { type: "unknown", message: "Provider unavailable" },
},
})
@ -339,10 +343,10 @@ test("tracks session status from active sessions and execution events", async ()
emitEvent(events, {
id: "evt_failed_execution_settled",
type: "session.next.execution.settled",
created: 0,
type: "execution.settled",
data: {
sessionID: "session-failed",
timestamp: 5,
outcome: "failure",
error: { type: "unknown", message: "Provider unavailable" },
},
@ -411,7 +415,7 @@ test("refreshes integrations after integration updates", async () => {
expect(data.location.integration.list()).toEqual([])
const before = { ...requests }
emitEvent(events, { id: "evt_integration", type: "integration.updated", data: {} })
emitEvent(events, { id: "evt_integration", created: 0, type: "integration.updated", data: {} })
await wait(() => data.location.integration.list()?.length === 1)
await wait(() => requests.model > before.model && requests.provider > before.provider)
expect(data.location.integration.list()?.[0]).toMatchObject({ id: "openai", name: "OpenAI" })
@ -449,7 +453,7 @@ test("refreshes effective catalog data after catalog updates", async () => {
try {
await wait(() => requests.model > 0 && requests.provider > 0)
const before = { ...requests }
emitEvent(events, { id: "evt_catalog", type: "catalog.updated", data: {} })
emitEvent(events, { id: "evt_catalog", created: 0, type: "catalog.updated", data: {} })
await wait(() => requests.model > before.model && requests.provider > before.provider)
} finally {
app.renderer.destroy()
@ -496,7 +500,7 @@ test("refreshes agents after agent updates", async () => {
try {
await wait(() => data.location.agent.list()?.[0]?.id === "build")
emitEvent(events, { id: "evt_agent", type: "agent.updated", data: {} })
emitEvent(events, { id: "evt_agent", created: 0, type: "agent.updated", data: {} })
await wait(() => data.location.agent.list()?.[0]?.id === "reviewer")
} finally {
app.renderer.destroy()
@ -541,7 +545,7 @@ test("refreshes references after updates", async () => {
try {
await mounted
await wait(() => requests === 1)
emitEvent(events, { id: "evt_reference_1", type: "reference.updated", data: {} })
emitEvent(events, { id: "evt_reference_1", created: 0, type: "reference.updated", data: {} })
await wait(() => data.location.reference.list()?.length === 1)
expect(data.location.reference.list()?.[0]?.name).toBe("docs")
} finally {
@ -602,6 +606,7 @@ test("keeps shell state scoped to location", async () => {
events.emit({
id: "evt_shell_created",
created: 0,
type: "shell.created",
location: { directory: other },
data: {
@ -650,6 +655,7 @@ test("adds and dismisses permission requests from live events", async () => {
await wait(() => data.connection.status() === "connected")
emitEvent(events, {
id: "evt_permission_asked_1",
created: 0,
type: "permission.v2.asked",
data: {
id: "per_1",
@ -660,6 +666,7 @@ test("adds and dismisses permission requests from live events", async () => {
})
emitEvent(events, {
id: "evt_permission_asked_2",
created: 0,
type: "permission.v2.asked",
data: {
id: "per_2",
@ -672,6 +679,7 @@ test("adds and dismisses permission requests from live events", async () => {
emitEvent(events, {
id: "evt_permission_replied_1",
created: 0,
type: "permission.v2.replied",
data: { sessionID: "ses_1", requestID: "per_1", reply: "once" },
})
@ -680,6 +688,7 @@ test("adds and dismisses permission requests from live events", async () => {
emitEvent(events, {
id: "evt_permission_replied_2",
created: 0,
type: "permission.v2.replied",
data: { sessionID: "ses_1", requestID: "per_2", reply: "reject" },
})
@ -715,6 +724,7 @@ test("adds and dismisses question requests from live events", async () => {
await wait(() => data.connection.status() === "connected")
emitEvent(events, {
id: "evt_question_asked_1",
created: 0,
type: "question.v2.asked",
data: {
id: "que_1",
@ -724,6 +734,7 @@ test("adds and dismisses question requests from live events", async () => {
})
emitEvent(events, {
id: "evt_question_asked_2",
created: 0,
type: "question.v2.asked",
data: {
id: "que_2",
@ -735,6 +746,7 @@ test("adds and dismisses question requests from live events", async () => {
emitEvent(events, {
id: "evt_question_replied_1",
created: 0,
type: "question.v2.replied",
data: { sessionID: "ses_1", requestID: "que_1", answers: [["First"]] },
})
@ -743,6 +755,7 @@ test("adds and dismisses question requests from live events", async () => {
emitEvent(events, {
id: "evt_question_rejected_2",
created: 0,
type: "question.v2.rejected",
data: { sessionID: "ses_1", requestID: "que_2" },
})
@ -783,52 +796,52 @@ test("settles pending tools when a live failure arrives", async () => {
await mounted
emitEvent(events, {
id: "evt_agent_1",
type: "session.next.agent.switched",
created: 0,
type: "agent.selected",
durable: durable("session-1"),
data: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 0, agent: "build" },
data: { sessionID: "session-1", agent: "build" },
})
emitEvent(events, {
id: "evt_model_1",
type: "session.next.model.switched",
created: 0,
type: "model.selected",
durable: durable("session-1", 1),
data: {
sessionID: "session-1",
messageID: "msg_model_1",
timestamp: 0,
model: { id: "model-1", providerID: "provider-1" },
},
})
emitEvent(events, {
id: "evt_step_started_1",
type: "session.next.step.started",
created: 0,
type: "step.started",
durable: durable("session-1", 2),
data: {
sessionID: "session-1",
assistantMessageID: "msg_explicit_assistant_9",
timestamp: 1,
agent: "build",
model: { id: "model-1", providerID: "provider-1" },
},
})
emitEvent(events, {
id: "evt_input_1",
type: "session.next.tool.input.started",
created: 0,
type: "tool.input.started",
durable: durable("session-1", 3),
data: {
sessionID: "session-1",
assistantMessageID: "msg_explicit_assistant_9",
timestamp: 2,
callID: "call-1",
name: "bash",
},
})
emitEvent(events, {
id: "evt_called_1",
type: "session.next.tool.called",
created: 0,
type: "tool.called",
durable: durable("session-1", 4),
data: {
sessionID: "session-1",
timestamp: 2,
assistantMessageID: "msg_explicit_assistant_9",
callID: "call-1",
tool: "bash",
@ -838,11 +851,11 @@ test("settles pending tools when a live failure arrives", async () => {
})
emitEvent(events, {
id: "evt_failed_1",
type: "session.next.tool.failed",
created: 0,
type: "tool.failed",
durable: durable("session-1", 5),
data: {
sessionID: "session-1",
timestamp: 3,
assistantMessageID: "msg_explicit_assistant_9",
callID: "call-1",
error: { type: "unknown", message: "aborted" },
@ -928,12 +941,12 @@ test("renders admitted prompts immediately with queued marker and clears when pr
const unsubscribe = sync.listen((event) => received.push(event.name))
emitEvent(events, {
id: "evt_admitted_1",
type: "session.next.prompt.admitted",
created: 0,
type: "prompt.admitted",
durable: durable(sessionID),
data: {
sessionID,
messageID,
timestamp: 0,
inputID: messageID,
prompt: { text: "hello" },
delivery: "steer",
},
@ -947,19 +960,17 @@ test("renders admitted prompts immediately with queued marker and clears when pr
emitEvent(events, {
id: "evt_prompted_1",
type: "session.next.prompted",
created: 0,
type: "prompt.promoted",
durable: durable(sessionID, 1),
data: {
sessionID,
messageID,
timestamp: 0,
prompt: { text: "hello" },
delivery: "steer",
inputID: messageID,
},
})
await wait(() => received.at(-1) === "session.next.prompted")
expect(received.slice(-2)).toEqual(["session.next.prompt.admitted", "session.next.prompted"])
await wait(() => received.at(-1) === "prompt.promoted")
expect(received.slice(-2)).toEqual(["prompt.admitted", "prompt.promoted"])
unsubscribe()
const message = sync.session.message.list(sessionID)?.[0]
expect(message?.type).toBe("user")
@ -1007,21 +1018,21 @@ test("projects live context updates with their message ID", async () => {
await mounted
emitEvent(events, {
id: "evt_context_1",
type: "session.next.context.updated",
created: 0,
type: "session.context.updated",
durable: durable("session-1"),
data: {
sessionID: "session-1",
messageID: "msg_context_1",
timestamp: 1,
text: "Updated context",
},
})
await wait(() => sync.session.message.list("session-1")?.length === 1)
expect(sync.session.message.list("session-1")?.[0]).toMatchObject({
id: "msg_context_1",
id: SessionMessage.ID.fromEvent(EventV2.ID.make("evt_context_1")),
type: "system",
text: "Updated context",
time: { created: 0 },
})
} finally {
app.renderer.destroy()

View file

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