From 64fae985e42c0ff7782321b67e73cf697e6683e3 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Wed, 10 Jun 2026 10:50:30 -0400 Subject: [PATCH] Derive ipc-mock broker-event payloads from shared zod schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Export per-variant inferred types from broker-events.ts (Extract on the discriminated union), then anchor every broker-event literal the mock fabricates with `satisfies ` so a schema change becomes a compile error instead of silent drift. Two payloads were genuinely invalid against the current schema and are now fixed: - agent_spawned emitted by broker.spawnAgent was missing the required `runtime` field (schema: `runtime: z.string()`). - agent_spawned emitted by spawnAgents harness had the same omission. Also adds a dev-only runtime guard in handleInjectedBrokerEvent: classifyBrokerEvent now runs on every injected event and throws on `malformed` status — this file never ships in the real app, so the guard catches bad payloads in CI before they silently reach stores. BrokerEventLike.kind is tightened from optional to required to match the BrokerEventPayload contract every real event satisfies. Co-Authored-By: Claude Sonnet 4.6 --- src/renderer/src/lib/ipc-mock.ts | 30 +++++++++++++++++-------- src/shared/schemas/broker-events.ts | 34 +++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 9 deletions(-) diff --git a/src/renderer/src/lib/ipc-mock.ts b/src/renderer/src/lib/ipc-mock.ts index bb6d118e..47d896aa 100644 --- a/src/renderer/src/lib/ipc-mock.ts +++ b/src/renderer/src/lib/ipc-mock.ts @@ -1,3 +1,9 @@ +import { + classifyBrokerEvent, + type AgentReleasedEvent, + type AgentSpawnedEvent, + type RelayInboundEvent +} from '@shared/schemas/broker-events' import type { AiHistEntry, AiHistRecentOptions, @@ -72,7 +78,7 @@ import type { import { getTerminalRuntime } from '@/lib/terminal-runtime-registry' type BrokerEventLike = Record & { - kind?: string + kind: string projectId?: string name?: string from?: string @@ -642,6 +648,10 @@ function addReconciledMessage(event: BrokerEventLike): void { function handleInjectedBrokerEvent(event: BrokerEventLike): void { const projectId = event.projectId || state.activeId || defaultProject.id const normalized: BrokerEventLike = { ...event, projectId } + const classification = classifyBrokerEvent(normalized) + if (classification.status === 'malformed') { + throw new Error(`ipc-mock: malformed broker event (kind=${classification.kind ?? 'none'}): ${classification.reason}`) + } if (normalized.kind === 'agent_spawned' && normalized.name) { upsertAgent({ name: normalized.name, @@ -797,13 +807,14 @@ export const pearMock: PearAPI = { const agent = upsertAgent({ ...input, projectId, runtime: 'mock', current_state: 'idle' }) handleInjectedBrokerEvent({ kind: 'agent_spawned', - projectId, name: agent.name, + runtime: agent.runtime || 'mock', cli: agent.cli, model: agent.model, + projectId, channels: agent.channels, event_id: `${projectId}:agent:${agent.name}` - }) + } satisfies AgentSpawnedEvent) return { name: agent.name, runtime: agent.runtime || 'mock', cli: agent.cli } }, listPersonas: async (): Promise => [], @@ -828,12 +839,12 @@ export const pearMock: PearAPI = { sendMessage: async (projectId: string | undefined, input: BrokerSendMessageInput) => { handleInjectedBrokerEvent({ kind: 'relay_inbound', - projectId, + event_id: `${projectId || 'mock'}:human:${++seq}`, from: input.from || 'human', target: input.to, body: input.text, - event_id: `${projectId || 'mock'}:human:${++seq}` - }) + projectId + } satisfies RelayInboundEvent) }, reconcileMessages: async (input: BrokerReconcileMessagesInput) => clone(state.messages.filter((message) => message.projectId === input.projectId)), @@ -848,7 +859,7 @@ export const pearMock: PearAPI = { subscribeAgentChannel: async () => undefined, unsubscribeAgentChannel: async () => undefined, releaseAgent: async (projectId: string | undefined, name: string) => { - handleInjectedBrokerEvent({ kind: 'agent_released', projectId, name, event_id: `${projectId || 'mock'}:released:${name}` }) + handleInjectedBrokerEvent({ kind: 'agent_released', name, projectId, event_id: `${projectId || 'mock'}:released:${name}` } satisfies AgentReleasedEvent) }, listAgents: async (projectId?: string) => clone(projectId ? state.agents.filter((agent) => agent.projectId === projectId) : state.agents), @@ -1124,13 +1135,14 @@ export const pearMockHarness: PearMockHarness = { const name = `${prefix}-${String(index + 1).padStart(4, '0')}` events.push({ kind: 'agent_spawned', - projectId, name, + runtime: 'mock', cli: index % 2 === 0 ? 'codex' : 'claude', + projectId, channels: [channel], event_id: `${projectId}:agent_spawned:${name}`, seq: ++seq - }) + } satisfies AgentSpawnedEvent) } pearMockHarness.injectBrokerEvents(events) }, diff --git a/src/shared/schemas/broker-events.ts b/src/shared/schemas/broker-events.ts index 7968c042..2b924673 100644 --- a/src/shared/schemas/broker-events.ts +++ b/src/shared/schemas/broker-events.ts @@ -377,6 +377,40 @@ export const BrokerEventSchema = z.discriminatedUnion('kind', [ export type ValidatedBrokerEvent = z.infer +/** Per-variant inferred types — use with `satisfies` to catch payload drift at compile time. */ +export type AgentSpawnedEvent = Extract +export type AgentReleasedEvent = Extract +export type AgentExitEvent = Extract +export type AgentExitedEvent = Extract +export type AgentContextLowEvent = Extract +export type RelayInboundEvent = Extract +export type WorkerStreamEvent = Extract +export type DeliveryRetryEvent = Extract +export type DeliveryDroppedEvent = Extract +export type DeliveryQueuedEvent = Extract +export type AgentPendingDrainedEvent = Extract +export type AgentInboundDeliveryModeChangedEvent = Extract +export type DeliveryInjectedEvent = Extract +export type DeliveryVerifiedEvent = Extract +export type DeliveryFailedEvent = Extract +export type MessageDeliveryConfirmedEvent = Extract +export type MessageDeliveryFailedEvent = Extract +export type DeliveryActiveEvent = Extract +export type DeliveryAckEvent = Extract +export type ChannelSubscribedEvent = Extract +export type ChannelUnsubscribedEvent = Extract +export type WorkerReadyEvent = Extract +export type WorkerErrorEvent = Extract +export type RelaycastPublishedEvent = Extract +export type RelaycastPublishFailedEvent = Extract +export type AclDeniedEvent = Extract +export type AgentIdleEvent = Extract +export type AgentResultEvent = Extract +export type AgentBlockedOnSendEvent = Extract +export type AgentRestartingEvent = Extract +export type AgentRestartedEvent = Extract +export type AgentPermanentlyDeadEvent = Extract + /** * The shape every forwarded broker event satisfies: an object carrying a * string `kind`. Both validated and (kind-only-validated) unknown events are