From cfb970dc566d7702361636945dfcfd610edf7e12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Tue, 18 Aug 2026 12:52:19 +0200 Subject: [PATCH] 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. --- packages/server/src/api-types.ts | 7 ++-- .../permissions/auto-accept-manager.test.ts | 11 ++++-- .../src/permissions/auto-accept-manager.ts | 34 ++++++++----------- .../src/workspaces/instance-events.test.ts | 12 +++---- .../server/src/workspaces/instance-events.ts | 25 ++------------ packages/ui/src/lib/sse-manager.ts | 1 - .../stores/instance-runtime-authority.test.ts | 2 +- packages/ui/src/stores/ptys.ts | 5 +-- packages/ui/src/stores/session-events.ts | 2 +- 9 files changed, 39 insertions(+), 60 deletions(-) diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index ca9b1a0a..8104b040 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -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 - [key: string]: unknown -} +export type InstanceStreamEvent = OpenCodeEvent export type SideCarKind = "port" diff --git a/packages/server/src/permissions/auto-accept-manager.test.ts b/packages/server/src/permissions/auto-accept-manager.test.ts index a045d2cf..e7eb7fe7 100644 --- a/packages/server/src/permissions/auto-accept-manager.test.ts +++ b/packages/server/src/permissions/auto-accept-manager.test.ts @@ -26,10 +26,15 @@ function publishInstanceEvent(bus: EventBus, instanceId: string, event: Record } | 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 }, }) } diff --git a/packages/server/src/permissions/auto-accept-manager.ts b/packages/server/src/permissions/auto-accept-manager.ts index c1f566f4..86db4f5d 100644 --- a/packages/server/src/permissions/auto-accept-manager.ts +++ b/packages/server/src/permissions/auto-accept-manager.ts @@ -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() - 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 + data?: unknown } interface SessionProperties { id?: string + sessionID?: string parentID?: string | null parentId?: string | null fork?: unknown diff --git a/packages/server/src/workspaces/instance-events.test.ts b/packages/server/src/workspaces/instance-events.test.ts index 12a5d5f8..05a3b8ac 100644 --- a/packages/server/src/workspaces/instance-events.test.ts +++ b/packages/server/src/workspaces/instance-events.test.ts @@ -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() } diff --git a/packages/server/src/workspaces/instance-events.ts b/packages/server/src/workspaces/instance-events.ts index de49d031..bd4abd5b 100644 --- a/packages/server/src/workspaces/instance-events.ts +++ b/packages/server/src/workspaces/instance-events.ts @@ -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 { - 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 - } - private updateStatus(status: InstanceStreamStatus, reason?: string) { this.status = status for (const workspace of this.options.workspaceManager.list()) { diff --git a/packages/ui/src/lib/sse-manager.ts b/packages/ui/src/lib/sse-manager.ts index b28e4c86..f47af465 100644 --- a/packages/ui/src/lib/sse-manager.ts +++ b/packages/ui/src/lib/sse-manager.ts @@ -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 = diff --git a/packages/ui/src/stores/instance-runtime-authority.test.ts b/packages/ui/src/stores/instance-runtime-authority.test.ts index 98a330c9..b9f0d91a 100644 --- a/packages/ui/src/stores/instance-runtime-authority.test.ts +++ b/packages/ui/src/stores/instance-runtime-authority.test.ts @@ -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")) diff --git a/packages/ui/src/stores/ptys.ts b/packages/ui/src/stores/ptys.ts index f27fdd31..a8d633dc 100644 --- a/packages/ui/src/stores/ptys.ts +++ b/packages/ui/src/stores/ptys.ts @@ -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) => { diff --git a/packages/ui/src/stores/session-events.ts b/packages/ui/src/stores/session-events.ts index f34f32ea..48570ebf 100644 --- a/packages/ui/src/stores/session-events.ts +++ b/packages/ui/src/stores/session-events.ts @@ -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}`)