Skip to content
Open
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
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

Packages without a separate changelog are covered by the cross-package notes below.

## [Unreleased]
## [Unreleased - Patch]

### Fixed

- TypeScript SDK: `agent.on.*` event handlers can be registered before `connect()` instead of throwing.

## [9.2.0] - 2026-10-09

Expand Down
6 changes: 5 additions & 1 deletion packages/sdk-typescript/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ See the [root changelog](../../CHANGELOG.md) for cross-package release highlight
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
## [Unreleased - Patch]

### Fixed

- `agent.on.*` no longer throws when registered before `connect()`; handlers attach immediately and fire once the socket opens.

## [9.1.0] - 2026-10-08

Expand Down
96 changes: 75 additions & 21 deletions packages/sdk-typescript/src/__tests__/agent-ws.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -720,41 +720,95 @@ describe('AgentClient WebSocket integration', () => {
expect(handler).toHaveBeenCalledTimes(2);
});

// --- error: call before connect ---
// --- handlers registered before connect() ---

it('on.messageCreated throws if called before connect()', () => {
it('on.messageCreated registered before connect() does not throw and fires after connecting', async () => {
const agent = createAgent();
expect(() => agent.on.messageCreated(vi.fn())).toThrow(
'WebSocket not connected. Call connect() first.',
);
const handler = vi.fn();

expect(() => agent.on.messageCreated(handler)).not.toThrow();

agent.connect();
const ws = await nextSocket();
ws.simulateOpen();
ws.simulateMessage({
type: 'message.created',
channel: 'general',
message: { id: 'm_1', agent_name: 'Bot', text: 'hi', attachments: [] },
});

expect(handler).toHaveBeenCalledTimes(1);
});

it('on.connected throws if called before connect()', () => {
it('on.connected registered before connect() fires once the socket opens', async () => {
const agent = createAgent();
expect(() => agent.on.connected(vi.fn())).toThrow(
'WebSocket not connected. Call connect() first.',
);
const handler = vi.fn();

agent.on.connected(handler);
agent.connect();
const ws = await nextSocket();
ws.simulateOpen();
await Promise.resolve();

expect(handler).toHaveBeenCalledTimes(1);
});

it('on.reconnecting throws if called before connect()', () => {
it('on.reconnecting registered before connect() fires with attempt count', async () => {
const agent = createAgent();
expect(() => agent.on.reconnecting(vi.fn())).toThrow(
'WebSocket not connected. Call connect() first.',
);
const handler = vi.fn();

agent.on.reconnecting(handler);
agent.connect();
const ws = await nextSocket();
ws.simulateOpen();
ws.simulateClose();

expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(1);
});

it('on.permanentlyDisconnected throws if called before connect()', () => {
it('on.permanentlyDisconnected registered before connect() fires with attempt count', async () => {
const agent = createAgent({
ws: { maxReconnectAttempts: 0, reconnectJitter: false },
});
const handler = vi.fn();

agent.on.permanentlyDisconnected(handler);
agent.connect();
const ws = await nextSocket();
ws.simulateOpen();
ws.simulateClose();

expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(0);
});

it('on.any registered before connect() receives events once connected', async () => {
const agent = createAgent();
expect(() => agent.on.permanentlyDisconnected(vi.fn())).toThrow(
'WebSocket not connected. Call connect() first.',
);
const handler = vi.fn();

agent.on.any(handler);
agent.connect();
const ws = await nextSocket();
ws.simulateOpen();
await Promise.resolve();
ws.simulateMessage({
type: 'message.created',
channel: 'general',
message: { id: 'm_1', agent_name: 'Bot', text: 'hi', attachments: [] },
});

// open + message.created
expect(handler).toHaveBeenCalledTimes(2);
});

it('on.any throws if called before connect()', () => {
it('registering handlers before connect() does not itself open a WebSocket', () => {
const agent = createAgent();
expect(() => agent.on.any(vi.fn())).toThrow(
'WebSocket not connected. Call connect() first.',
);
agent.on.messageCreated(vi.fn());
agent.on.connected(vi.fn());
agent.on.any(vi.fn());

expect(MockWebSocket.instances).toHaveLength(0);
});

// --- unsubscribe function ---
Expand Down
104 changes: 47 additions & 57 deletions packages/sdk-typescript/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,34 +270,44 @@ export class AgentClient {

// === WebSocket ===

/**
* Lazily create the underlying WsClient. Constructing it does not open a
* socket, so handlers registered via `on.*` before `connect()` attach to
* the same instance `connect()` later opens — no queueing needed.
*/
private ensureWs(): WsClient {
if (!this.ws) {
this.ws = new WsClient(withInternalWsOrigin(
{
...this.wsOptions,
token: () => this.fetchDirectNodeToken(),
baseUrl: this.client.baseUrl,
path: '/v1/node/ws',
nodeRegistration: () => this.directNodeRegistration(),
autoAckDeliveries: true,
},
this.client.internalOrigin,
));
this.ws.on('open', () => {
this.directNodeTokenUses = 0;
void this.presence.markOnline().catch(() => {});
this.startAutoHeartbeat();
this.syncDesiredSubscriptions({ resetRemoteState: true });
});
this.ws.on('close', () => {
this.stopAutoHeartbeat();
this.activeWsChannels.clear();
});
this.ws.on('permanently_disconnected', () => {
this.stopAutoHeartbeat();
this.activeWsChannels.clear();
});
}
return this.ws;
}

connect(): void {
if (this.ws) return;
this.ws = new WsClient(withInternalWsOrigin(
{
...this.wsOptions,
token: () => this.fetchDirectNodeToken(),
baseUrl: this.client.baseUrl,
path: '/v1/node/ws',
nodeRegistration: () => this.directNodeRegistration(),
autoAckDeliveries: true,
},
this.client.internalOrigin,
));
this.ws.on('open', () => {
this.directNodeTokenUses = 0;
void this.presence.markOnline().catch(() => {});
this.startAutoHeartbeat();
this.syncDesiredSubscriptions({ resetRemoteState: true });
});
this.ws.on('close', () => {
this.stopAutoHeartbeat();
this.activeWsChannels.clear();
});
this.ws.on('permanently_disconnected', () => {
this.stopAutoHeartbeat();
this.activeWsChannels.clear();
});
this.ws.connect();
this.ensureWs().connect();
}

/** Send a REST heartbeat to keep this agent online in PresenceDO without a WebSocket. */
Expand Down Expand Up @@ -424,10 +434,7 @@ export class AgentClient {
}

private onEvent<T extends WsClientEvent>(eventType: string, handler: (e: T) => void): () => void {
if (!this.ws) {
throw new Error('WebSocket not connected. Call connect() first.');
}
return this.ws.on(eventType, handler as (e: WsClientEvent) => void);
return this.ensureWs().on(eventType, handler as (e: WsClientEvent) => void);
}

on = {
Expand Down Expand Up @@ -464,35 +471,18 @@ export class AgentClient {
connected: (handler: () => void): (() => void) => this.onEvent('open', handler as (e: never) => void),
disconnected: (handler: () => void): (() => void) => this.onEvent('close', handler as (e: never) => void),
error: (handler: () => void): (() => void) => this.onEvent('error', handler as (e: never) => void),
reconnecting: (handler: (attempt: number) => void): (() => void) => {
if (!this.ws) {
throw new Error('WebSocket not connected. Call connect() first.');
}
return this.ws.on('reconnecting', (e: WsClientEvent) => handler((e as WsReconnectingEvent).attempt));
},
permanentlyDisconnected: (handler: (attempt: number) => void): (() => void) => {
if (!this.ws) {
throw new Error('WebSocket not connected. Call connect() first.');
}
return this.ws.on('permanently_disconnected', (e: WsClientEvent) =>
handler((e as WsPermanentlyDisconnectedEvent).attempt));
},
resynced: (handler: (info: { replayed: number; gapDetected: boolean }) => void): (() => void) => {
if (!this.ws) {
throw new Error('WebSocket not connected. Call connect() first.');
}
return this.ws.on('resynced', (e: WsClientEvent) => {
reconnecting: (handler: (attempt: number) => void): (() => void) =>
this.ensureWs().on('reconnecting', (e: WsClientEvent) => handler((e as WsReconnectingEvent).attempt)),
permanentlyDisconnected: (handler: (attempt: number) => void): (() => void) =>
this.ensureWs().on('permanently_disconnected', (e: WsClientEvent) =>
handler((e as WsPermanentlyDisconnectedEvent).attempt)),
resynced: (handler: (info: { replayed: number; gapDetected: boolean }) => void): (() => void) =>
this.ensureWs().on('resynced', (e: WsClientEvent) => {
const event = e as WsResyncedEvent;
handler({ replayed: event.replayed, gapDetected: event.gapDetected });
});
},
}),
// Wildcard
any: (handler: (e: WsClientEvent) => void): (() => void) => {
if (!this.ws) {
throw new Error('WebSocket not connected. Call connect() first.');
}
return this.ws.on('*', handler);
},
any: (handler: (e: WsClientEvent) => void): (() => void) => this.ensureWs().on('*', handler),
};

// === Messages ===
Expand Down
Loading