feat(server): stream events across locations

This commit is contained in:
Dax Raad 2026-06-22 08:12:38 -04:00
commit 595cc91ddc
11 changed files with 2172 additions and 310 deletions

View file

@ -1,9 +1,7 @@
import { useEvent } from "./event"
import type {
AgentV2Info,
CommandV2Info,
IntegrationInfo,
Event,
LocationRef,
ModelV2Info,
PermissionSavedInfo,
@ -18,11 +16,12 @@ import type {
SessionMessageAssistantTool,
SessionV2Info,
SkillV2Info,
V2Event,
} from "@opencode-ai/sdk/v2"
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useSDK } from "./sdk"
import { createSignal, onMount } from "solid-js"
import { createSignal, onCleanup, onMount } from "solid-js"
type LocationData = {
agent?: AgentV2Info[]
@ -71,7 +70,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
location: {},
})
const event = useEvent()
const sdk = useSDK()
const [defaultLocation, setDefaultLocation] = createSignal<LocationRef>({
directory: sdk.directory ?? process.cwd(),
@ -121,7 +119,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
},
}
event.subscribe((event, metadata) => {
function handleEvent(event: V2Event, metadata: { directory: string; workspace: string | undefined }) {
switch (event.type) {
case "catalog.updated":
void Promise.all([
@ -130,34 +128,34 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
])
break
case "session.next.agent.switched":
message.update(event.properties.sessionID, (draft) => {
message.update(event.data.sessionID, (draft) => {
message.prepend(draft, {
id: event.properties.messageID,
id: event.data.messageID,
type: "agent-switched",
agent: event.properties.agent,
time: { created: event.properties.timestamp },
agent: event.data.agent,
time: { created: event.data.timestamp },
})
})
break
case "session.next.model.switched":
message.update(event.properties.sessionID, (draft) => {
message.update(event.data.sessionID, (draft) => {
message.prepend(draft, {
id: event.properties.messageID,
id: event.data.messageID,
type: "model-switched",
model: event.properties.model,
time: { created: event.properties.timestamp },
model: event.data.model,
time: { created: event.data.timestamp },
})
})
break
case "session.next.prompted": {
message.update(event.properties.sessionID, (draft) => {
message.update(event.data.sessionID, (draft) => {
message.prepend(draft, {
id: event.properties.messageID,
id: event.data.messageID,
type: "user",
text: event.properties.prompt.text,
files: event.properties.prompt.files,
agents: event.properties.prompt.agents,
time: { created: event.properties.timestamp },
text: event.data.prompt.text,
files: event.data.prompt.files,
agents: event.data.prompt.agents,
time: { created: event.data.timestamp },
})
})
break
@ -165,248 +163,248 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
case "session.next.prompt.admitted":
break
case "session.next.prompt.promoted":
message.update(event.properties.sessionID, (draft) => {
message.update(event.data.sessionID, (draft) => {
message.prepend(draft, {
id: event.properties.messageID,
id: event.data.messageID,
type: "user",
text: event.properties.prompt.text,
files: event.properties.prompt.files,
agents: event.properties.prompt.agents,
time: { created: event.properties.timeCreated },
text: event.data.prompt.text,
files: event.data.prompt.files,
agents: event.data.prompt.agents,
time: { created: event.data.timeCreated },
})
})
break
case "session.next.context.updated":
message.update(event.properties.sessionID, (draft) => {
message.update(event.data.sessionID, (draft) => {
message.prepend(draft, {
id: event.properties.messageID,
id: event.data.messageID,
type: "system",
text: event.properties.text,
time: { created: event.properties.timestamp },
text: event.data.text,
time: { created: event.data.timestamp },
})
})
break
case "session.next.synthetic":
message.update(event.properties.sessionID, (draft) => {
message.update(event.data.sessionID, (draft) => {
message.prepend(draft, {
id: event.properties.messageID,
id: event.data.messageID,
type: "synthetic",
sessionID: event.properties.sessionID,
text: event.properties.text,
time: { created: event.properties.timestamp },
sessionID: event.data.sessionID,
text: event.data.text,
time: { created: event.data.timestamp },
})
})
break
case "session.next.shell.started":
message.update(event.properties.sessionID, (draft) => {
message.update(event.data.sessionID, (draft) => {
message.prepend(draft, {
id: event.properties.messageID,
id: event.data.messageID,
type: "shell",
callID: event.properties.callID,
command: event.properties.command,
callID: event.data.callID,
command: event.data.command,
output: "",
time: { created: event.properties.timestamp },
time: { created: event.data.timestamp },
})
})
break
case "session.next.shell.ended":
message.update(event.properties.sessionID, (draft) => {
const match = message.activeShell(draft, event.properties.callID)
message.update(event.data.sessionID, (draft) => {
const match = message.activeShell(draft, event.data.callID)
if (!match) return
match.output = event.properties.output
match.time.completed = event.properties.timestamp
match.output = event.data.output
match.time.completed = event.data.timestamp
})
break
case "session.next.step.started":
message.update(event.properties.sessionID, (draft) => {
if (draft.some((message) => message.id === event.properties.assistantMessageID)) return
message.update(event.data.sessionID, (draft) => {
if (draft.some((message) => message.id === event.data.assistantMessageID)) return
const currentAssistant = message.activeAssistant(draft)
if (currentAssistant) currentAssistant.time.completed = event.properties.timestamp
if (currentAssistant) currentAssistant.time.completed = event.data.timestamp
message.prepend(draft, {
id: event.properties.assistantMessageID,
id: event.data.assistantMessageID,
type: "assistant",
agent: event.properties.agent,
model: event.properties.model,
agent: event.data.agent,
model: event.data.model,
content: [],
snapshot: event.properties.snapshot ? { start: event.properties.snapshot } : undefined,
time: { created: event.properties.timestamp },
snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined,
time: { created: event.data.timestamp },
})
})
break
case "session.next.step.ended":
message.update(event.properties.sessionID, (draft) => {
const currentAssistant = message.assistant(draft, event.properties.assistantMessageID)
message.update(event.data.sessionID, (draft) => {
const currentAssistant = message.assistant(draft, event.data.assistantMessageID)
if (!currentAssistant) return
currentAssistant.time.completed = event.properties.timestamp
currentAssistant.finish = event.properties.finish
currentAssistant.cost = event.properties.cost
currentAssistant.tokens = event.properties.tokens
if (event.properties.snapshot)
currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.properties.snapshot }
currentAssistant.time.completed = event.data.timestamp
currentAssistant.finish = event.data.finish
currentAssistant.cost = event.data.cost
currentAssistant.tokens = event.data.tokens
if (event.data.snapshot)
currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.data.snapshot }
})
break
case "session.next.step.failed":
message.update(event.properties.sessionID, (draft) => {
const currentAssistant = message.assistant(draft, event.properties.assistantMessageID)
message.update(event.data.sessionID, (draft) => {
const currentAssistant = message.assistant(draft, event.data.assistantMessageID)
if (!currentAssistant) return
currentAssistant.time.completed = event.properties.timestamp
currentAssistant.time.completed = event.data.timestamp
currentAssistant.finish = "error"
currentAssistant.error = event.properties.error
currentAssistant.error = event.data.error
})
break
case "session.next.text.started":
message.update(event.properties.sessionID, (draft) => {
message.assistant(draft, event.properties.assistantMessageID)?.content.push({
message.update(event.data.sessionID, (draft) => {
message.assistant(draft, event.data.assistantMessageID)?.content.push({
type: "text",
id: event.properties.textID,
id: event.data.textID,
text: "",
})
})
break
case "session.next.text.delta":
message.update(event.properties.sessionID, (draft) => {
message.update(event.data.sessionID, (draft) => {
const match = message.latestText(
message.assistant(draft, event.properties.assistantMessageID),
event.properties.textID,
message.assistant(draft, event.data.assistantMessageID),
event.data.textID,
)
if (match) match.text += event.properties.delta
if (match) match.text += event.data.delta
})
break
case "session.next.text.ended":
message.update(event.properties.sessionID, (draft) => {
message.update(event.data.sessionID, (draft) => {
const match = message.latestText(
message.assistant(draft, event.properties.assistantMessageID),
event.properties.textID,
message.assistant(draft, event.data.assistantMessageID),
event.data.textID,
)
if (match) match.text = event.properties.text
if (match) match.text = event.data.text
})
break
case "session.next.tool.input.started":
message.update(event.properties.sessionID, (draft) => {
message.assistant(draft, event.properties.assistantMessageID)?.content.push({
message.update(event.data.sessionID, (draft) => {
message.assistant(draft, event.data.assistantMessageID)?.content.push({
type: "tool",
id: event.properties.callID,
name: event.properties.name,
time: { created: event.properties.timestamp },
id: event.data.callID,
name: event.data.name,
time: { created: event.data.timestamp },
state: { status: "pending", input: "" },
})
})
break
case "session.next.tool.input.delta":
message.update(event.properties.sessionID, (draft) => {
message.update(event.data.sessionID, (draft) => {
const match = message.latestTool(
message.assistant(draft, event.properties.assistantMessageID),
event.properties.callID,
message.assistant(draft, event.data.assistantMessageID),
event.data.callID,
)
if (match?.state.status === "pending") match.state.input += event.properties.delta
if (match?.state.status === "pending") match.state.input += event.data.delta
})
break
case "session.next.tool.input.ended":
message.update(event.properties.sessionID, (draft) => {
message.update(event.data.sessionID, (draft) => {
const match = message.latestTool(
message.assistant(draft, event.properties.assistantMessageID),
event.properties.callID,
message.assistant(draft, event.data.assistantMessageID),
event.data.callID,
)
if (match?.state.status === "pending") match.state.input = event.properties.text
if (match?.state.status === "pending") match.state.input = event.data.text
})
break
case "session.next.tool.called":
message.update(event.properties.sessionID, (draft) => {
message.update(event.data.sessionID, (draft) => {
const match = message.latestTool(
message.assistant(draft, event.properties.assistantMessageID),
event.properties.callID,
message.assistant(draft, event.data.assistantMessageID),
event.data.callID,
)
if (!match) return
match.time.ran = event.properties.timestamp
match.provider = event.properties.provider
match.state = { status: "running", input: event.properties.input, structured: {}, content: [] }
match.time.ran = event.data.timestamp
match.provider = event.data.provider
match.state = { status: "running", input: event.data.input, structured: {}, content: [] }
})
break
case "session.next.tool.progress":
message.update(event.properties.sessionID, (draft) => {
message.update(event.data.sessionID, (draft) => {
const match = message.latestTool(
message.assistant(draft, event.properties.assistantMessageID),
event.properties.callID,
message.assistant(draft, event.data.assistantMessageID),
event.data.callID,
)
if (match?.state.status !== "running") return
match.state.structured = event.properties.structured
match.state.content = [...event.properties.content]
match.state.structured = event.data.structured
match.state.content = [...event.data.content]
})
break
case "session.next.tool.success":
message.update(event.properties.sessionID, (draft) => {
message.update(event.data.sessionID, (draft) => {
const match = message.latestTool(
message.assistant(draft, event.properties.assistantMessageID),
event.properties.callID,
message.assistant(draft, event.data.assistantMessageID),
event.data.callID,
)
if (match?.state.status !== "running") return
match.state = {
status: "completed",
input: match.state.input,
structured: event.properties.structured,
content: [...event.properties.content],
result: event.properties.result,
structured: event.data.structured,
content: [...event.data.content],
result: event.data.result,
}
match.provider = {
executed: event.properties.provider.executed || match.provider?.executed === true,
executed: event.data.provider.executed || match.provider?.executed === true,
metadata: match.provider?.metadata,
resultMetadata: event.properties.provider.metadata,
resultMetadata: event.data.provider.metadata,
}
match.time.completed = event.properties.timestamp
match.time.completed = event.data.timestamp
})
break
case "session.next.tool.failed":
message.update(event.properties.sessionID, (draft) => {
message.update(event.data.sessionID, (draft) => {
const match = message.latestTool(
message.assistant(draft, event.properties.assistantMessageID),
event.properties.callID,
message.assistant(draft, event.data.assistantMessageID),
event.data.callID,
)
if (!match || (match.state.status !== "pending" && match.state.status !== "running")) return
match.state = {
status: "error",
error: event.properties.error,
error: event.data.error,
input: typeof match.state.input === "string" ? {} : match.state.input,
structured: match.state.status === "running" ? match.state.structured : {},
content: match.state.status === "running" ? match.state.content : [],
result: event.properties.result,
result: event.data.result,
}
match.provider = {
executed: event.properties.provider.executed || match.provider?.executed === true,
executed: event.data.provider.executed || match.provider?.executed === true,
metadata: match.provider?.metadata,
resultMetadata: event.properties.provider.metadata,
resultMetadata: event.data.provider.metadata,
}
match.time.completed = event.properties.timestamp
match.time.completed = event.data.timestamp
})
break
case "session.next.reasoning.started":
message.update(event.properties.sessionID, (draft) => {
message.assistant(draft, event.properties.assistantMessageID)?.content.push({
message.update(event.data.sessionID, (draft) => {
message.assistant(draft, event.data.assistantMessageID)?.content.push({
type: "reasoning",
id: event.properties.reasoningID,
id: event.data.reasoningID,
text: "",
providerMetadata: event.properties.providerMetadata,
providerMetadata: event.data.providerMetadata,
})
})
break
case "session.next.reasoning.delta":
message.update(event.properties.sessionID, (draft) => {
message.update(event.data.sessionID, (draft) => {
const match = message.latestReasoning(
message.assistant(draft, event.properties.assistantMessageID),
event.properties.reasoningID,
message.assistant(draft, event.data.assistantMessageID),
event.data.reasoningID,
)
if (match) match.text += event.properties.delta
if (match) match.text += event.data.delta
})
break
case "session.next.reasoning.ended":
message.update(event.properties.sessionID, (draft) => {
message.update(event.data.sessionID, (draft) => {
const match = message.latestReasoning(
message.assistant(draft, event.properties.assistantMessageID),
event.properties.reasoningID,
message.assistant(draft, event.data.assistantMessageID),
event.data.reasoningID,
)
if (match) {
match.text = event.properties.text
if (event.properties.providerMetadata !== undefined)
match.providerMetadata = event.properties.providerMetadata
match.text = event.data.text
if (event.data.providerMetadata !== undefined)
match.providerMetadata = event.data.providerMetadata
}
})
break
@ -415,14 +413,14 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
case "session.next.compaction.delta":
break
case "session.next.compaction.ended":
message.update(event.properties.sessionID, (draft) => {
message.update(event.data.sessionID, (draft) => {
message.prepend(draft, {
id: event.properties.messageID,
id: event.data.messageID,
type: "compaction",
reason: event.properties.reason,
summary: event.properties.text,
recent: event.properties.recent,
time: { created: event.properties.timestamp },
reason: event.data.reason,
summary: event.data.text,
recent: event.data.recent,
time: { created: event.data.timestamp },
})
})
break
@ -437,6 +435,20 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
])
break
}
}
onMount(() => {
const controller = new AbortController()
onCleanup(() => controller.abort())
void (async () => {
const events = await sdk.client.v2.event.subscribe({ signal: controller.signal })
for await (const event of events.stream) {
handleEvent(event, {
directory: event.location?.directory ?? defaultLocation().directory,
workspace: event.location?.workspaceID,
})
}
})().catch(() => {})
})
const result = {