refactor(events): preserve native V2 envelopes

The instance bridge duplicated every OpenCode event payload into a synthetic properties field because server-side auto-accept still consumed the V1-style envelope. This expanded the SSE contract and left UI deletion handling with legacy fallbacks.

Move auto-accept and session deletion to native event.data, publish OpenCode events unchanged, type the shared stream as OpenCodeEvent, and guard PTY consumers before narrowing their event union. Internal CodeNomad worktree events remain separate.

Validated with server and UI typechecks, 51 targeted server tests, 21 targeted UI tests, 304 passing server tests with 3 platform skips, 535 UI tests, and git diff checks.
This commit is contained in:
Pascal André 2026-08-18 12:52:19 +02:00
parent 3bfaaafc51
commit cfb970dc56
No known key found for this signature in database
9 changed files with 39 additions and 60 deletions

View file

@ -6,6 +6,7 @@ import type {
Preferences,
RecentFolder,
} from "./config/schema"
import type { OpenCodeEvent } from "@opencode-ai/client"
/**
* Canonical HTTP/SSE contract for the CLI server.
@ -278,11 +279,7 @@ export interface InstanceData {
export type InstanceStreamStatus = "connecting" | "connected" | "error" | "disconnected"
export interface InstanceStreamEvent {
type: string
properties?: Record<string, unknown>
[key: string]: unknown
}
export type InstanceStreamEvent = OpenCodeEvent
export type SideCarKind = "port"

View file

@ -26,10 +26,15 @@ function publishInstanceEvent(bus: EventBus, instanceId: string, event: Record<s
: event.type === "permission.v2.replied"
? "permission.replied"
: event.type
bus.publish({ type: "instance.event", instanceId, event: { ...event, type } as InstanceStreamEvent })
const { properties, ...nativeEvent } = event
const wrapped = properties as { info?: Record<string, unknown> } | undefined
const data = event.data ?? (wrapped?.info
? { ...wrapped.info, sessionID: wrapped.info.sessionID ?? wrapped.info.id }
: properties)
bus.publish({ type: "instance.event", instanceId, event: { ...nativeEvent, type, data } as InstanceStreamEvent })
}
/** Publish session creation using the compatibility shape produced by InstanceEventBridge. */
/** Publish session lifecycle events using the native V2 data envelope. */
function publishSession(
bus: EventBus,
instanceId: string,
@ -38,7 +43,7 @@ function publishSession(
) {
publishInstanceEvent(bus, instanceId, {
type: eventType === "session.updated" ? "session.created" : eventType,
properties: { info: { ...info } },
data: { ...info, sessionID: info.sessionID ?? info.id },
})
}

View file

@ -258,16 +258,16 @@ export class AutoAcceptManager {
if (!event || typeof event.type !== "string") return
if (SESSION_UPSERT_TYPES.has(event.type)) {
this.ingestSession(instanceId, event.properties)
this.ingestSession(instanceId, event.data)
return
}
if (event.type === "session.forked") {
this.ingestSessionForked(instanceId, event.properties)
this.ingestSessionForked(instanceId, event.data)
return
}
if (SESSION_REMOVE_TYPES.has(event.type)) {
const info = (event.properties as { info?: SessionProperties } | undefined)?.info
const id = readString(info?.id) ?? readString(event.properties?.id)
const data = event.data as SessionProperties | undefined
const id = readString(data?.sessionID) ?? readString(data?.id)
if (id) {
this.store.removeSession(instanceId, id)
this.removePendingForSession(instanceId, id)
@ -275,29 +275,24 @@ export class AutoAcceptManager {
return
}
if (PERMISSION_REPLIED_TYPES.has(event.type)) {
this.handlePermissionReplied(instanceId, event.properties)
this.handlePermissionReplied(instanceId, event.data)
return
}
if (PERMISSION_ASK_TYPES.has(event.type)) {
this.handlePermissionRequest(instanceId, event.properties)
this.handlePermissionRequest(instanceId, event.data)
}
}
private ingestSession(instanceId: string, properties: unknown): void {
// OpenCode wraps session records under `properties.info` for
// session.created/updated/deleted (see SDK EventSessionUpdated). Accept a
// flat fallback only for defensive compatibility.
const info = (properties as { info?: SessionProperties } | SessionProperties | undefined)
const session = (info && typeof info === "object" && "info" in info ? info.info : info) as
| SessionProperties
| undefined
if (!session || typeof session.id !== "string") return
private ingestSession(instanceId: string, data: unknown): void {
const session = data as SessionProperties | undefined
const sessionId = readString(session?.sessionID) ?? readString(session?.id)
if (!session || !sessionId) return
const parentId = session.parentID ?? session.parentId ?? null
const enabledBefore = this.store.enabledRoots(instanceId)
this.store.upsertSession(instanceId, { id: session.id, parentId, fork: session.fork })
this.store.upsertSession(instanceId, { id: sessionId, parentId, fork: session.fork })
if (typeof session.workspaceID === "string" && session.workspaceID) {
const workspaces = this.sessionWorkspaces.get(instanceId) ?? new Map<string, string>()
workspaces.set(session.id, session.workspaceID)
workspaces.set(sessionId, session.workspaceID)
this.sessionWorkspaces.set(instanceId, workspaces)
}
this.persistRootMigration(instanceId, enabledBefore, this.store.enabledRoots(instanceId))
@ -305,7 +300,7 @@ export class AutoAcceptManager {
// Re-drain pending permissions whose family root may have migrated into
// an enabled family — mirrors the old UI's drainAutoAcceptPermissions-
// ForInstance trigger from the previous UI implementation (#497).
this.drainPending(instanceId, session.id)
this.drainPending(instanceId, sessionId)
}
private ingestSessionForked(instanceId: string, properties: unknown): void {
@ -484,11 +479,12 @@ export class AutoAcceptManager {
interface InstanceStreamPayload {
type?: string
properties?: Record<string, unknown>
data?: unknown
}
interface SessionProperties {
id?: string
sessionID?: string
parentID?: string | null
parentId?: string | null
fork?: unknown

View file

@ -176,18 +176,18 @@ describe("InstanceEventBridge", () => {
assert.equal(received[0].instanceId, "a")
assert.deepEqual(received[0].event.location, { directory: "/repo-a" })
assert.deepEqual(received[0].event.data, { id: "p1" })
assert.deepEqual(received[0].event.properties, { id: "p1" })
assert.equal(received[0].event.properties, undefined)
assert.equal(received[1].event.data.sessionID, "session-1")
assert.equal(received[1].event.properties.info.id, "session-1")
assert.equal(received[1].event.properties, undefined)
assert.equal(received[2].instanceId, "a")
assert.deepEqual(received[2].event.properties, {
assert.deepEqual(received[2].event.data, {
sessionID: "session-2",
assistantMessageID: "message-1",
ordinal: 0,
delta: "hello",
})
assert.equal(received[3].instanceId, "a")
assert.equal(received[3].event.properties.delta, " again")
assert.equal(received[3].event.data.delta, " again")
assert.equal(ownerLookups.get("/repo-a/.worktrees/feature"), 2)
} finally {
bridge.shutdown()
@ -294,7 +294,7 @@ describe("InstanceEventBridge", () => {
await waitFor(() => received.length === 2)
assert.equal(sessionGets(), 1)
assert.deepEqual(received.map((event) => event.instanceId), ["a", "b"])
assert.deepEqual(received.map((event) => event.event.properties.id), ["deleted", "deleted"])
assert.deepEqual(received.map((event) => event.event.data.sessionID), ["deleted", "deleted"])
} finally {
bridge.shutdown()
}
@ -337,7 +337,7 @@ describe("InstanceEventBridge", () => {
await waitFor(() => received.length === 1)
assert.equal(sessionGets(), 1)
assert.equal(received[0].instanceId, "b")
assert.equal(received[0].event.properties.form.sessionID, "owned")
assert.equal(received[0].event.data.form.sessionID, "owned")
} finally {
bridge.shutdown()
}

View file

@ -2,7 +2,7 @@ import type { OpenCodeEvent } from "@opencode-ai/client"
import { EventBus } from "../events/bus"
import { Logger } from "../logger"
import { WorkspaceManager } from "./manager"
import { InstanceStreamEvent, InstanceStreamStatus } from "../api-types"
import { InstanceStreamStatus } from "../api-types"
const RECONNECT_DELAY_MS = 1000
const DIRECTORY_OWNER_CACHE_MS = 2000
@ -128,13 +128,8 @@ export class InstanceEventBridge {
return
}
// The server's auto-accept boundary still reads the legacy property name.
const compatibleEvent: InstanceStreamEvent = {
...event,
properties: this.compatibilityProperties(event),
}
for (const instanceId of instanceIds) {
this.options.eventBus.publish({ type: "instance.event", instanceId, event: compatibleEvent })
this.options.eventBus.publish({ type: "instance.event", instanceId, event })
}
if (event.type === "session.deleted" && sessionId) this.sessionDirectories.delete(sessionId)
if (event.type === "pty.deleted" && ptyId) this.ptyDirectories.delete(ptyId)
@ -160,12 +155,8 @@ export class InstanceEventBridge {
}
private broadcastEvent(event: OpenCodeEvent): void {
const compatibleEvent: InstanceStreamEvent = {
...event,
properties: this.compatibilityProperties(event),
}
for (const workspace of this.options.workspaceManager.list()) {
this.options.eventBus.publish({ type: "instance.event", instanceId: workspace.id, event: compatibleEvent })
this.options.eventBus.publish({ type: "instance.event", instanceId: workspace.id, event })
}
}
@ -209,16 +200,6 @@ export class InstanceEventBridge {
this.ptyDirectories.clear()
}
private compatibilityProperties(event: OpenCodeEvent): Record<string, unknown> {
if (event.type === "session.created") {
return { info: { ...event.data, id: event.data.sessionID } }
}
if (event.type === "session.deleted") {
return { id: event.data.sessionID }
}
return event.data as Record<string, unknown>
}
private updateStatus(status: InstanceStreamStatus, reason?: string) {
this.status = status
for (const workspace of this.options.workspaceManager.list()) {

View file

@ -53,7 +53,6 @@ export interface WorktreeReadyEvent {
export interface EventSessionDeleted {
type: "session.deleted"
data?: { sessionID?: string }
properties?: { info?: { id?: string }; id?: string; sessionID?: string }
}
type SSEEvent =

View file

@ -79,7 +79,7 @@ describe("instance runtime authority", () => {
for (const test of [
{ label: "direct definitive session removal", remove: removeSessionRuntimeState },
{ label: "session.deleted event", remove: (id: string, sessionId: string) => handleSessionDeleted(id,
{ type: "session.deleted", properties: { info: { id: sessionId } } }) },
{ type: "session.deleted", data: { sessionID: sessionId } }) },
]) it(`removes attachment authority on ${test.label}`, () => {
const id = `authority-${test.label}`, sessionId = "deleted-session"
addAttachment(id, sessionId, createTextAttachment("pasted", "pasted #1", "paste.txt"))

View file

@ -1,12 +1,13 @@
import { serverEvents } from "../lib/server-events"
import { getRootClient } from "./opencode-client"
import { createPtyApi, createPtyStore } from "./pty-store"
import { createPtyApi, createPtyStore, type PtyRefreshEvent } from "./pty-store"
const ptyStore = createPtyStore((instanceId) => createPtyApi(getRootClient(instanceId)))
serverEvents.on("instance.event", (event) => {
if (event.type !== "instance.event") return
void ptyStore.refreshForEvent(event.instanceId, event.event)
if (!event.event.type.startsWith("pty.")) return
void ptyStore.refreshForEvent(event.instanceId, event.event as PtyRefreshEvent)
})
serverEvents.on("instance.eventStatus", (event) => {

View file

@ -572,7 +572,7 @@ function handleSessionUpdate(
}
function handleSessionDeleted(instanceId: string, event: EventSessionDeleted): void {
const sessionId = event.data?.sessionID ?? event.properties?.info?.id ?? event.properties?.sessionID ?? event.properties?.id
const sessionId = event.data?.sessionID
if (!sessionId) return
log.info(`[SSE] Session deleted: ${sessionId}`)