Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions packages/sdk/src/__tests__/lifecycle-hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
AfterAgentSpawnContext,
BeforeAgentReleaseContext,
BeforeAgentSpawnContext,
BeforeAgentSpawnHandler,
SpawnPatch,
} from '../lifecycle-hooks.js';
import type { SpawnPtyInput } from '../types.js';
Expand Down Expand Up @@ -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<void | SpawnPatch>) 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.
Expand Down
22 changes: 20 additions & 2 deletions packages/sdk/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<K extends keyof AgentRelayEvents>(
event: K,
handler: (...args: AgentRelayEvents[K]) => void | Promise<void>
): () => void;
addListener<K extends keyof AgentRelayEvents>(
event: K,
handler: ((...args: AgentRelayEvents[K]) => void | Promise<void>) | BeforeAgentSpawnHandler
): () => void {
return this.eventBus.addListener(event, handler);
return this.eventBus.addListener(
event,
handler as (...args: AgentRelayEvents[K]) => void | Promise<void>
);
}

/** Remove a previously-registered listener. */
removeListener(event: 'beforeAgentSpawn', handler: BeforeAgentSpawnHandler): void;
removeListener<K extends keyof AgentRelayEvents>(
event: K,
handler: (...args: AgentRelayEvents[K]) => void | Promise<void>
): void;
removeListener<K extends keyof AgentRelayEvents>(
event: K,
handler: ((...args: AgentRelayEvents[K]) => void | Promise<void>) | BeforeAgentSpawnHandler
): void {
this.eventBus.removeListener(event, handler);
this.eventBus.removeListener(event, handler as (...args: AgentRelayEvents[K]) => void | Promise<void>);
}

/**
Expand Down
33 changes: 24 additions & 9 deletions packages/sdk/src/event-bus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,22 +18,37 @@
*/
export type EventMap = Record<string, readonly unknown[]>;

export type EventHandler<Args extends readonly unknown[]> = (...args: Args) => void | Promise<void>;
/**
* 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 extends readonly unknown[], R = void> = (...args: Args) => R | Promise<R>;

export class EventBus<E extends EventMap> {
private handlers: Map<keyof E, Set<EventHandler<readonly unknown[]>>> = 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<keyof E, Set<EventHandler<readonly unknown[], unknown>>> = 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<K extends keyof E>(event: K, handler: EventHandler<E[K]>): () => void {
addListener<K extends keyof E, R = void>(event: K, handler: EventHandler<E[K], R>): () => void {
let set = this.handlers.get(event);
if (!set) {
set = new Set();
this.handlers.set(event, set);
}
set.add(handler as EventHandler<readonly unknown[]>);
set.add(handler as EventHandler<readonly unknown[], unknown>);
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
Expand All @@ -42,18 +57,18 @@ export class EventBus<E extends EventMap> {
// every listener for the event.
const current = this.handlers.get(event);
if (current !== set) return;
current.delete(handler as EventHandler<readonly unknown[]>);
current.delete(handler as EventHandler<readonly unknown[], unknown>);
if (current.size === 0) {
this.handlers.delete(event);
}
};
}

/** Remove a previously-registered handler. Idempotent. */
removeListener<K extends keyof E>(event: K, handler: EventHandler<E[K]>): void {
removeListener<K extends keyof E, R = void>(event: K, handler: EventHandler<E[K], R>): void {
const set = this.handlers.get(event);
if (!set) return;
set.delete(handler as EventHandler<readonly unknown[]>);
set.delete(handler as EventHandler<readonly unknown[], unknown>);
if (set.size === 0) {
this.handlers.delete(event);
}
Expand All @@ -65,9 +80,9 @@ export class EventBus<E extends EventMap> {
}

/** Snapshot the handlers for `event` so iteration is safe under concurrent mutation. */
listeners<K extends keyof E>(event: K): Array<EventHandler<E[K]>> {
listeners<K extends keyof E, R = void>(event: K): Array<EventHandler<E[K], R>> {
const set = this.handlers.get(event);
return set ? (Array.from(set) as Array<EventHandler<E[K]>>) : [];
return set ? (Array.from(set) as Array<EventHandler<E[K], R>>) : [];
}

/**
Expand Down
21 changes: 18 additions & 3 deletions packages/sdk/src/relay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<K extends keyof AgentRelayEvents>(
event: K,
handler: (...args: AgentRelayEvents[K]) => void | Promise<void>
): () => void;
addListener<K extends keyof AgentRelayEvents>(
event: K,
handler: ((...args: AgentRelayEvents[K]) => void | Promise<void>) | BeforeAgentSpawnHandler
): () => void {
return this.bus.addListener(event, handler);
return this.bus.addListener(event, handler as (...args: AgentRelayEvents[K]) => void | Promise<void>);
}

/** Remove a previously-registered listener. Idempotent. */
removeListener(event: 'beforeAgentSpawn', handler: BeforeAgentSpawnHandler): void;
removeListener<K extends keyof AgentRelayEvents>(
event: K,
handler: (...args: AgentRelayEvents[K]) => void | Promise<void>
): void;
removeListener<K extends keyof AgentRelayEvents>(
event: K,
handler: ((...args: AgentRelayEvents[K]) => void | Promise<void>) | BeforeAgentSpawnHandler
): void {
this.bus.removeListener(event, handler);
this.bus.removeListener(event, handler as (...args: AgentRelayEvents[K]) => void | Promise<void>);
}

// ── Public accessors ────────────────────────────────────────────────────
Expand Down
Loading