diff --git a/packages/sdk/src/__tests__/lifecycle-hooks.test.ts b/packages/sdk/src/__tests__/lifecycle-hooks.test.ts index 200fd4e32..73de966e0 100644 --- a/packages/sdk/src/__tests__/lifecycle-hooks.test.ts +++ b/packages/sdk/src/__tests__/lifecycle-hooks.test.ts @@ -6,6 +6,7 @@ import type { AfterAgentSpawnContext, BeforeAgentReleaseContext, BeforeAgentSpawnContext, + BeforeAgentSpawnHandler, SpawnPatch, } from '../lifecycle-hooks.js'; import type { SpawnPtyInput } from '../types.js'; @@ -230,6 +231,34 @@ describe('AgentRelayClient lifecycle hooks', () => { expect(fn).toHaveBeenCalledTimes(1); }); + it('accepts a BeforeAgentSpawnHandler-typed function without a cast', async () => { + // Regression for the addListener overload: a handler that's typed + // separately as BeforeAgentSpawnHandler (return: void | SpawnPatch + // | Promise) must satisfy the addListener signature + // without `as` gymnastics. Before the overload landed this assignment + // failed with TS2345 because the default `void`-returning shape didn't + // cover the SpawnPatch return. + const { fetchFn, captures } = makeMockFetch(); + const client = makeClient(fetchFn); + + const handler: BeforeAgentSpawnHandler = (ctx) => { + if (ctx.input.cli !== 'claude') return; + return { args: [...(ctx.input.args ?? []), '--from-typed-handler'] }; + }; + client.addListener('beforeAgentSpawn', handler); + + await client.spawnPty({ name: 'typed', cli: 'claude', args: ['--orig'] }); + + expect(captures[0].body).toMatchObject({ + args: ['--orig', '--from-typed-handler'], + }); + + // removeListener must accept the same handler shape without casting. + client.removeListener('beforeAgentSpawn', handler); + await client.spawnPty({ name: 'typed-2', cli: 'claude', args: ['--orig'] }); + expect(captures[1].body).toMatchObject({ args: ['--orig'] }); + }); + it('patch shape: extending args requires explicit spread', async () => { // Documents the array-replace contract: a patch's `args` overrides // the previous `args` outright; handlers must spread to extend. diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index f2459bfdb..5d5f18b30 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -259,20 +259,38 @@ export class AgentRelayClient { * function. Equivalent to `client.eventBus.addListener(...)` but mirrors * the `AgentRelay` facade API so direct-client callers don't need to * reach through `.eventBus`. + * + * `beforeAgentSpawn` is the one event whose handler may return a + * `SpawnPatch` to mutate the spawn input — the dedicated overload + * keeps that contract type-safe without forcing other events to accept + * non-void returns. */ + addListener(event: 'beforeAgentSpawn', handler: BeforeAgentSpawnHandler): () => void; addListener( event: K, handler: (...args: AgentRelayEvents[K]) => void | Promise + ): () => void; + addListener( + event: K, + handler: ((...args: AgentRelayEvents[K]) => void | Promise) | BeforeAgentSpawnHandler ): () => void { - return this.eventBus.addListener(event, handler); + return this.eventBus.addListener( + event, + handler as (...args: AgentRelayEvents[K]) => void | Promise + ); } /** Remove a previously-registered listener. */ + removeListener(event: 'beforeAgentSpawn', handler: BeforeAgentSpawnHandler): void; removeListener( event: K, handler: (...args: AgentRelayEvents[K]) => void | Promise + ): void; + removeListener( + event: K, + handler: ((...args: AgentRelayEvents[K]) => void | Promise) | BeforeAgentSpawnHandler ): void { - this.eventBus.removeListener(event, handler); + this.eventBus.removeListener(event, handler as (...args: AgentRelayEvents[K]) => void | Promise); } /** diff --git a/packages/sdk/src/event-bus.ts b/packages/sdk/src/event-bus.ts index 1867b1503..90cd3817a 100644 --- a/packages/sdk/src/event-bus.ts +++ b/packages/sdk/src/event-bus.ts @@ -18,22 +18,37 @@ */ export type EventMap = Record; -export type EventHandler = (...args: Args) => void | Promise; +/** + * Handler signature for an event. The optional `R` generic lets a + * specific event widen its return type past `void` — used by the + * `beforeAgentSpawn` lifecycle hook, whose handlers may return a + * `SpawnPatch` that the dispatcher folds into the spawn input. The + * default `R = void` keeps the common case strict. + */ +export type EventHandler = (...args: Args) => R | Promise; export class EventBus { - private handlers: Map>> = new Map(); + // Stored type uses `unknown` for `R` so a single Set can hold handlers + // from any overload — the dispatcher casts back when it cares about a + // non-void return value (see `runBeforeSpawn` in `client.ts`). + private handlers: Map>> = new Map(); /** * Register a handler for `event`. Returns an unsubscribe function that * removes the handler when called. + * + * The `R` generic is inferred from the handler return type; callers + * that don't care about the return get the default `void` shape, while + * events like `beforeAgentSpawn` can pass handlers that return a + * `SpawnPatch`. */ - addListener(event: K, handler: EventHandler): () => void { + addListener(event: K, handler: EventHandler): () => void { let set = this.handlers.get(event); if (!set) { set = new Set(); this.handlers.set(event, set); } - set.add(handler as EventHandler); + set.add(handler as EventHandler); return () => { // Re-read the current Set from the map so a stale closure can't blow // away the new Set if the caller unsubscribes, re-registers a fresh @@ -42,7 +57,7 @@ export class EventBus { // every listener for the event. const current = this.handlers.get(event); if (current !== set) return; - current.delete(handler as EventHandler); + current.delete(handler as EventHandler); if (current.size === 0) { this.handlers.delete(event); } @@ -50,10 +65,10 @@ export class EventBus { } /** Remove a previously-registered handler. Idempotent. */ - removeListener(event: K, handler: EventHandler): void { + removeListener(event: K, handler: EventHandler): void { const set = this.handlers.get(event); if (!set) return; - set.delete(handler as EventHandler); + set.delete(handler as EventHandler); if (set.size === 0) { this.handlers.delete(event); } @@ -65,9 +80,9 @@ export class EventBus { } /** Snapshot the handlers for `event` so iteration is safe under concurrent mutation. */ - listeners(event: K): Array> { + listeners(event: K): Array> { const set = this.handlers.get(event); - return set ? (Array.from(set) as Array>) : []; + return set ? (Array.from(set) as Array>) : []; } /** diff --git a/packages/sdk/src/relay.ts b/packages/sdk/src/relay.ts index b32b64da2..d336d232e 100644 --- a/packages/sdk/src/relay.ts +++ b/packages/sdk/src/relay.ts @@ -31,7 +31,7 @@ import { RelayCast } from '@relaycast/sdk'; import { AgentRelayClient, type AgentRelayBrokerInitArgs, type AgentRelaySpawnOptions } from './client.js'; import { EventBus } from './event-bus.js'; -import type { AgentRelayEvents } from './lifecycle-hooks.js'; +import type { AgentRelayEvents, BeforeAgentSpawnHandler } from './lifecycle-hooks.js'; import { buildPersonaSpawnSpec, composePersonaTask, @@ -466,20 +466,35 @@ export class AgentRelay { * can register for the same event; they fire sequentially in * registration order. Async handlers are awaited. Handler exceptions * are caught and logged; one bad listener never blocks the others. + * + * `beforeAgentSpawn` is the one event whose handler may return a + * `SpawnPatch` to mutate the spawn input before the broker POST — the + * dedicated overload below keeps that contract type-safe without + * forcing other events to accept non-void returns. */ + addListener(event: 'beforeAgentSpawn', handler: BeforeAgentSpawnHandler): () => void; addListener( event: K, handler: (...args: AgentRelayEvents[K]) => void | Promise + ): () => void; + addListener( + event: K, + handler: ((...args: AgentRelayEvents[K]) => void | Promise) | BeforeAgentSpawnHandler ): () => void { - return this.bus.addListener(event, handler); + return this.bus.addListener(event, handler as (...args: AgentRelayEvents[K]) => void | Promise); } /** Remove a previously-registered listener. Idempotent. */ + removeListener(event: 'beforeAgentSpawn', handler: BeforeAgentSpawnHandler): void; removeListener( event: K, handler: (...args: AgentRelayEvents[K]) => void | Promise + ): void; + removeListener( + event: K, + handler: ((...args: AgentRelayEvents[K]) => void | Promise) | BeforeAgentSpawnHandler ): void { - this.bus.removeListener(event, handler); + this.bus.removeListener(event, handler as (...args: AgentRelayEvents[K]) => void | Promise); } // ── Public accessors ────────────────────────────────────────────────────