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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Packages without a separate changelog are covered by the cross-package notes bel
### Fixed

- TypeScript SDK: `agent.on.*` event handlers can be registered before `connect()` instead of throwing.
- TypeScript SDK: `agent.exited`, `node.status.online`, and `node.status.offline` have camelCase event types and `on.agentExited`, `on.nodeOnline`, and `on.nodeOffline` handlers.

## [9.2.0] - 2026-10-09

Expand Down
1 change: 1 addition & 0 deletions packages/sdk-typescript/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht
### Fixed

- `agent.on.*` no longer throws when registered before `connect()`; handlers attach immediately and fire once the socket opens.
- `agent.exited`, `node.status.online`, and `node.status.offline` WebSocket events now have camelCase SDK types (`AgentExitedEvent`, `NodeStatusOnlineEvent`, `NodeStatusOfflineEvent`) and `on.agentExited`/`on.nodeOnline`/`on.nodeOffline` handlers. Previously these events had no SDK type at all, so consumers reached for the snake_case raw type exported by `@relaycast/types` — which type-checked but silently read `undefined` against the camelized payload the client actually delivers.

## [9.1.0] - 2026-10-08

Expand Down
61 changes: 61 additions & 0 deletions packages/sdk-typescript/src/__tests__/agent-ws.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AgentClient, type AgentClientOptions } from '../agent.js';
import { stableRelaycastEventId } from '../event-id.js';
import { HttpClient } from '../client.js';
import type { AgentExitedEvent, NodeStatusOfflineEvent, NodeStatusOnlineEvent } from '../types.js';

class MockWebSocket {
static readonly OPEN = 1;
Expand Down Expand Up @@ -360,6 +361,66 @@ describe('AgentClient WebSocket integration', () => {
);
});

it('on.agentExited camelizes a real captured agent.exited frame', async () => {
const agent = createAgent();
agent.connect();
const ws = await nextSocket();
ws.simulateOpen();

let received: AgentExitedEvent | undefined;
agent.on.agentExited((event) => {
received = event;
});

// Real wire shape: the engine's wsTransform.ts emits snake_case fields
// matching AgentExitedEventSchema in @relaycast/types.
ws.simulateMessage({
type: 'agent.exited',
agent_id: 'agt_01J7FLEET000000000000101',
agent_name: 'claude-worker-7',
node_id: 'node_1',
invocation_id: null,
reason: 'deregistered',
});

expect(received).toEqual({
type: 'agent.exited',
agentId: 'agt_01J7FLEET000000000000101',
agentName: 'claude-worker-7',
nodeId: 'node_1',
invocationId: null,
reason: 'deregistered',
});
});

it('on.nodeOnline/on.nodeOffline camelize real captured node.status frames', async () => {
const agent = createAgent();
agent.connect();
const ws = await nextSocket();
ws.simulateOpen();

let online: NodeStatusOnlineEvent | undefined;
let offline: NodeStatusOfflineEvent | undefined;
agent.on.nodeOnline((event) => { online = event; });
agent.on.nodeOffline((event) => { offline = event; });

ws.simulateMessage({ type: 'node.status.online', node_id: 'node_1', node_name: 'builder-1' });
ws.simulateMessage({
type: 'node.status.offline',
node_id: 'node_1',
node_name: 'builder-1',
reason: 'liveness_timeout',
});

expect(online).toEqual({ type: 'node.status.online', nodeId: 'node_1', nodeName: 'builder-1' });
expect(offline).toEqual({
type: 'node.status.offline',
nodeId: 'node_1',
nodeName: 'builder-1',
reason: 'liveness_timeout',
});
});

// --- subscribe / unsubscribe proxy ---

it('subscribe() is local state on direct node transport', async () => {
Expand Down
6 changes: 6 additions & 0 deletions packages/sdk-typescript/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,12 @@ import type {
DmReceivedEvent,
GroupDmReceivedEvent,
RelaycastMessageEvent,
AgentExitedEvent,
AgentStatusActiveEvent,
AgentStatusChangedEvent,
AgentStatusOfflineEvent,
NodeStatusOnlineEvent,
NodeStatusOfflineEvent,
ChannelCreatedEvent,
ChannelUpdatedEvent,
ChannelArchivedEvent,
Expand Down Expand Up @@ -448,6 +451,9 @@ export class AgentClient {
agentStatusChanged: (handler: (e: AgentStatusChangedEvent) => void): (() => void) => this.onEvent('agent.status.changed', handler),
agentActive: (handler: (e: AgentStatusActiveEvent) => void): (() => void) => this.onEvent('agent.status.active', handler),
agentOffline: (handler: (e: AgentStatusOfflineEvent) => void): (() => void) => this.onEvent('agent.status.offline', handler),
agentExited: (handler: (e: AgentExitedEvent) => void): (() => void) => this.onEvent('agent.exited', handler),
nodeOnline: (handler: (e: NodeStatusOnlineEvent) => void): (() => void) => this.onEvent('node.status.online', handler),
nodeOffline: (handler: (e: NodeStatusOfflineEvent) => void): (() => void) => this.onEvent('node.status.offline', handler),
Comment on lines +455 to +456

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Route node status events to AgentClient

When consumers register these callbacks on AgentClient, they can never fire in production: AgentClient connects exclusively through /v1/node/ws, while the canonical engine's emitNodeStatusEffects sends node.status.online and node.status.offline only to the workspace stream and webhook outbox, with no node/agent audience. The new test bypasses that routing by injecting a synthetic node frame directly. Either route these events to the direct-node client or expose these handlers only on RelayCast; otherwise the advertised agent.on.nodeOnline/nodeOffline API silently does nothing.

AGENTS.md reference: AGENTS.md:L10-L12

Useful? React with 👍 / 👎.

Comment on lines +455 to +456

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Agent node-status handlers never fire

AgentClient.on.nodeOnline and nodeOffline listen on the direct-node socket. emitNodeStatusEffects sends these events only to workspace observers and webhooks, so both callbacks remain silent.

Learn more

Node-status transitions are workspace-wide events. AgentClient connects through /v1/node/ws, which carries node control frames and events explicitly delivered to an agent. The engine's emitNodeStatusEffects publishes each transition to the workspace stream and webhook queue, but never calls sendNodeDeliveriesToAgents. The test injects a workspace-stream event directly into the node socket, a frame the engine does not produce on that transport.

Example: A node times out and the engine emits node.status.offline. RelayCast.on.nodeOffline can receive it through /v1/ws, but an AgentClient.on.nodeOffline callback never runs.

Recommended fix: Remove nodeOnline and nodeOffline from AgentClient.on, or add an intentional server delivery path for node-status events to defined agent recipients. If these remain workspace-only, move their runtime coverage to RelayCast tests using /v1/ws.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

channelCreated: (handler: (e: ChannelCreatedEvent) => void): (() => void) => this.onEvent('channel.created', handler),
channelUpdated: (handler: (e: ChannelUpdatedEvent) => void): (() => void) => this.onEvent('channel.updated', handler),
channelArchived: (handler: (e: ChannelArchivedEvent) => void): (() => void) => this.onEvent('channel.archived', handler),
Expand Down
6 changes: 6 additions & 0 deletions packages/sdk-typescript/src/relay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,12 @@ import type {
ConsoleCostStats,
ThreadReplyEvent,
WsClientEvent,
AgentExitedEvent,
AgentStatusActiveEvent,
AgentStatusChangedEvent,
AgentStatusOfflineEvent,
NodeStatusOnlineEvent,
NodeStatusOfflineEvent,
ChannelCreatedEvent,
ChannelUpdatedEvent,
ChannelArchivedEvent,
Expand Down Expand Up @@ -417,6 +420,9 @@ export class RelayCast {
agentStatusChanged: (handler: (e: AgentStatusChangedEvent) => void): (() => void) => this.onEvent('agent.status.changed', handler),
agentActive: (handler: (e: AgentStatusActiveEvent) => void): (() => void) => this.onEvent('agent.status.active', handler),
agentOffline: (handler: (e: AgentStatusOfflineEvent) => void): (() => void) => this.onEvent('agent.status.offline', handler),
agentExited: (handler: (e: AgentExitedEvent) => void): (() => void) => this.onEvent('agent.exited', handler),
nodeOnline: (handler: (e: NodeStatusOnlineEvent) => void): (() => void) => this.onEvent('node.status.online', handler),
nodeOffline: (handler: (e: NodeStatusOfflineEvent) => void): (() => void) => this.onEvent('node.status.offline', handler),
channelCreated: (handler: (e: ChannelCreatedEvent) => void): (() => void) => this.onEvent('channel.created', handler),
channelUpdated: (handler: (e: ChannelUpdatedEvent) => void): (() => void) => this.onEvent('channel.updated', handler),
channelArchived: (handler: (e: ChannelArchivedEvent) => void): (() => void) => this.onEvent('channel.archived', handler),
Expand Down
3 changes: 3 additions & 0 deletions packages/sdk-typescript/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -633,6 +633,7 @@ export interface ConsoleCostStats {
export type ActivityItem = Camelize<Raw.ActivityItem>;
export type Agent = Camelize<Raw.Agent>;
export type AgentListQuery = Camelize<Raw.AgentListQuery>;
export type AgentExitedEvent = Camelize<Raw.AgentExitedEvent>;
export type AgentStatusActiveEvent = Camelize<Raw.AgentStatusActiveEvent>;
export type AgentStatusBlockedEvent = Camelize<Raw.AgentStatusBlockedEvent>;
export type AgentStatusChangedEvent = Camelize<Raw.AgentStatusChangedEvent>;
Expand Down Expand Up @@ -688,6 +689,8 @@ export type MemberLeftEvent = Camelize<Raw.MemberLeftEvent>;
export type ChannelMutedEvent = Camelize<Raw.ChannelMutedEvent>;
export type ChannelUnmutedEvent = Camelize<Raw.ChannelUnmutedEvent>;
export type MuteChannelResponse = Camelize<Raw.MuteChannelResponse>;
export type NodeStatusOnlineEvent = Camelize<Raw.NodeStatusOnlineEvent>;
export type NodeStatusOfflineEvent = Camelize<Raw.NodeStatusOfflineEvent>;
export type MessageBlock = Camelize<Raw.MessageBlock>;
export type MessageCreatedEvent = Camelize<Raw.MessageCreatedEvent>;
export type MessageListQuery = Camelize<Raw.MessageListQuery>;
Expand Down