From 4fef222eda5ebe624957ef85f9253816ac6d4e59 Mon Sep 17 00:00:00 2001 From: Noodle Date: Wed, 1 Apr 2026 09:32:55 -0400 Subject: [PATCH 1/7] feat(sdk): register human identities before sending --- .../__tests__/orchestration-upgrades.test.ts | 152 ++++++++++++++++++ packages/sdk/src/relay.ts | 133 ++++++++++++++- src/message_bridge.rs | 43 ++++- 3 files changed, 319 insertions(+), 9 deletions(-) diff --git a/packages/sdk/src/__tests__/orchestration-upgrades.test.ts b/packages/sdk/src/__tests__/orchestration-upgrades.test.ts index 945cf66c6..f4f67a93d 100644 --- a/packages/sdk/src/__tests__/orchestration-upgrades.test.ts +++ b/packages/sdk/src/__tests__/orchestration-upgrades.test.ts @@ -11,6 +11,22 @@ import { PROTOCOL_VERSION, type BrokerEvent } from '../protocol.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +const relayCastMocks = vi.hoisted(() => { + const mockRelayCastRegisterAgent = vi.fn(); + const mockRelayCastSystem = vi.fn(); + const RelayCastCtor = vi.fn().mockImplementation(() => ({ + agents: { + registerAgent: mockRelayCastRegisterAgent, + }, + system: mockRelayCastSystem, + })); + return { mockRelayCastRegisterAgent, mockRelayCastSystem, RelayCastCtor }; +}); + +vi.mock('@relaycast/sdk', () => ({ + RelayCast: relayCastMocks.RelayCastCtor, +})); + function readWave0Fixture(name: string): T { const fixturePath = path.resolve(__dirname, '../../../../tests/fixtures/contracts/wave0', name); return JSON.parse(fs.readFileSync(fixturePath, 'utf8')) as T; @@ -66,7 +82,16 @@ function emitClientEvent(client: AgentRelayClient, event: BrokerEvent): void { } afterEach(() => { + relayCastMocks.mockRelayCastRegisterAgent.mockReset(); + relayCastMocks.mockRelayCastSystem.mockReset(); vi.restoreAllMocks(); + relayCastMocks.RelayCastCtor.mockReset(); + relayCastMocks.RelayCastCtor.mockImplementation(() => ({ + agents: { + registerAgent: relayCastMocks.mockRelayCastRegisterAgent, + }, + system: relayCastMocks.mockRelayCastSystem, + })); }); describe('AgentRelayClient orchestration payloads', () => { @@ -764,6 +789,133 @@ describe('AgentRelay orchestration handles', () => { } }); + it('registerHuman returns the canonical routable identity', async () => { + const { client } = createMockFacadeClient(); + vi.spyOn(AgentRelayClient, 'start').mockResolvedValue(client); + relayCastMocks.mockRelayCastRegisterAgent.mockResolvedValue({ + id: 'agt_human_1', + name: 'human:Alice-7f3c', + token: 'tok_1', + status: 'online', + createdAt: '2026-04-01T00:00:00.000Z', + }); + + const relay = new AgentRelay({ + env: { ...process.env, RELAY_API_KEY: 'relay-key' }, + }); + + try { + const human = await relay.registerHuman({ name: 'Alice' }); + + expect(relayCastMocks.RelayCastCtor).toHaveBeenCalledWith({ apiKey: 'relay-key' }); + expect(relayCastMocks.mockRelayCastRegisterAgent).toHaveBeenCalledWith({ + name: 'Alice', + type: 'human', + strict: false, + }); + await expect(human.ensureRegistered()).resolves.toBe('human:Alice-7f3c'); + expect(human.name).toBe('human:Alice-7f3c'); + } finally { + await relay.shutdown(); + } + }); + + it('human({ ensureRegistered: true }) resolves to the canonical handle', async () => { + const { client } = createMockFacadeClient(); + vi.spyOn(AgentRelayClient, 'start').mockResolvedValue(client); + relayCastMocks.mockRelayCastRegisterAgent.mockResolvedValue({ + id: 'agt_human_2', + name: 'human:Owner-2', + token: 'tok_2', + status: 'online', + createdAt: '2026-04-01T00:00:00.000Z', + }); + + const relay = new AgentRelay({ + env: { ...process.env, RELAY_API_KEY: 'relay-key' }, + }); + + try { + const human = await relay.human({ name: 'Owner', ensureRegistered: true }); + + expect(human.name).toBe('human:Owner-2'); + expect(relayCastMocks.mockRelayCastRegisterAgent).toHaveBeenCalledTimes(1); + } finally { + await relay.shutdown(); + } + }); + + it('human.sendMessage auto-registers once and sends from the canonical identity', async () => { + const { client, mock } = createMockFacadeClient(); + vi.spyOn(AgentRelayClient, 'start').mockResolvedValue(client); + relayCastMocks.mockRelayCastRegisterAgent.mockResolvedValue({ + id: 'agt_human_3', + name: 'human:Reviewer-canon', + token: 'tok_3', + status: 'online', + createdAt: '2026-04-01T00:00:00.000Z', + }); + + const relay = new AgentRelay({ + env: { ...process.env, RELAY_API_KEY: 'relay-key' }, + }); + + try { + const human = relay.human({ name: 'Reviewer' }); + + const first = await human.sendMessage({ to: 'worker-1', text: 'status?' }); + const second = await human.sendMessage({ to: 'worker-1', text: 'report back' }); + + expect(relayCastMocks.mockRelayCastRegisterAgent).toHaveBeenCalledTimes(1); + expect(mock.sendMessage).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + to: 'worker-1', + text: 'status?', + from: 'human:Reviewer-canon', + }) + ); + expect(mock.sendMessage).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + to: 'worker-1', + text: 'report back', + from: 'human:Reviewer-canon', + }) + ); + expect(first.from).toBe('human:Reviewer-canon'); + expect(second.from).toBe('human:Reviewer-canon'); + expect(human.name).toBe('human:Reviewer-canon'); + } finally { + await relay.shutdown(); + } + }); + + it('human.sendMessage surfaces a clear SDK-level registration error', async () => { + const { client, mock } = createMockFacadeClient(); + vi.spyOn(AgentRelayClient, 'start').mockResolvedValue(client); + relayCastMocks.mockRelayCastRegisterAgent.mockRejectedValue(new Error('name conflict upstream')); + + const relay = new AgentRelay({ + env: { ...process.env, RELAY_API_KEY: 'relay-key' }, + }); + + try { + const human = relay.human({ name: 'Reviewer' }); + + await expect(human.sendMessage({ to: 'worker-1', text: 'status?' })).rejects.toMatchObject({ + name: 'HumanRegistrationError', + requestedName: 'Reviewer', + }); + await expect(human.sendMessage({ to: 'worker-1', text: 'status?' })).rejects.toThrow( + 'Failed to register human identity "Reviewer": name conflict upstream' + ); + expect(mock.sendMessage).not.toHaveBeenCalled(); + } finally { + await relay.shutdown(); + } + }); + it('sendAndWaitForDelivery waits for delivery ack with typed response', async () => { const { client, mock, emit } = createMockFacadeClient(); vi.spyOn(AgentRelayClient, 'start').mockResolvedValue(client); diff --git a/packages/sdk/src/relay.ts b/packages/sdk/src/relay.ts index 8493572f1..2701b975b 100644 --- a/packages/sdk/src/relay.ts +++ b/packages/sdk/src/relay.ts @@ -273,6 +273,7 @@ export interface Agent { export interface HumanHandle { readonly name: string; + ensureRegistered(): Promise; sendMessage(input: { to: string; text: string; @@ -283,6 +284,18 @@ export interface HumanHandle { }): Promise; } +export class HumanRegistrationError extends Error { + readonly requestedName: string; + readonly cause?: unknown; + + constructor(requestedName: string, message: string, cause?: unknown) { + super(message); + this.name = 'HumanRegistrationError'; + this.requestedName = requestedName; + this.cause = cause; + } +} + export interface AgentSpawner { spawn(options?: SpawnerSpawnOptions): Promise; } @@ -340,6 +353,12 @@ type InternalAgent = Agent & { _setChannels: (channels: string[]) => void; }; +type HumanIdentityKind = 'human' | 'system'; +type HumanRegistrationState = { + canonicalName?: string; + pending?: Promise; +}; + // ── AgentRelay facade ─────────────────────────────────────────────────────── export class AgentRelay { @@ -394,6 +413,7 @@ export class AgentRelay { private readonly idleAgents = new Set(); private readonly deliveryStates = new Map(); private readonly outputListeners = new Map>(); + private readonly humanRegistrations = new Map(); private readonly exitResolvers = new Map< string, { resolve: (reason: 'exited' | 'released') => void; token: number } @@ -644,17 +664,52 @@ export class AgentRelay { // ── Human source ──────────────────────────────────────────────────────── - human(opts: { name: string }): HumanHandle { + async registerHuman(opts: { name: string }): Promise { + const handle = this.createHumanHandle(opts.name, 'human', true); + await handle.ensureRegistered(); + return handle; + } + + human(opts: { name: string; ensureRegistered: true }): Promise; + human(opts: { name: string; ensureRegistered?: false | undefined }): HumanHandle; + human(opts: { name: string; ensureRegistered?: boolean }): HumanHandle | Promise { + const handle = this.createHumanHandle(opts.name, 'human', true); + if (opts.ensureRegistered) { + return handle.ensureRegistered().then(() => handle); + } + return handle; + } + + private createHumanHandle( + requestedName: string, + kind: HumanIdentityKind, + autoEnsureRegistration: boolean + ): HumanHandle { + const normalizedRequestedName = this.normalizeHumanRequestedName(requestedName); + const stateKey = this.humanRegistrationKey(normalizedRequestedName, kind); + const resolveName = () => this.humanRegistrations.get(stateKey)?.canonicalName ?? normalizedRequestedName; + return { - name: opts.name, + get name() { + return resolveName(); + }, + ensureRegistered: async () => { + if (!autoEnsureRegistration) { + return resolveName(); + } + return this.ensureHumanRegistered(normalizedRequestedName, kind); + }, sendMessage: async (input) => { const client = await this.ensureStarted(); + const from = autoEnsureRegistration + ? await this.ensureHumanRegistered(normalizedRequestedName, kind) + : resolveName(); let result: Awaited>; try { result = await client.sendMessage({ to: input.to, text: input.text, - from: opts.name, + from, threadId: input.threadId, priority: input.priority, data: input.data, @@ -662,18 +717,18 @@ export class AgentRelay { }); } catch (error) { if (isUnsupportedOperation(error)) { - return buildUnsupportedOperationMessage(opts.name, input); + return buildUnsupportedOperationMessage(from, input); } throw error; } if (result?.event_id === 'unsupported_operation') { - return buildUnsupportedOperationMessage(opts.name, input); + return buildUnsupportedOperationMessage(from, input); } const eventId = result?.event_id ?? randomBytes(8).toString('hex'); const msg: Message = { eventId, - from: opts.name, + from, to: input.to, text: input.text, threadId: input.threadId, @@ -687,7 +742,7 @@ export class AgentRelay { } system(): HumanHandle { - return this.human({ name: 'system' }); + return this.createHumanHandle('system', 'system', false); } // ── Messaging ───────────────────────────────────────────────────────── @@ -1159,6 +1214,70 @@ export class AgentRelay { } } + private humanRegistrationKey(name: string, kind: HumanIdentityKind): string { + return `${kind}:${name}`; + } + + private normalizeHumanRequestedName(name: string): string { + const normalized = name.trim(); + if (!normalized) { + throw new HumanRegistrationError(name, 'Human registration requires a non-empty name.'); + } + return normalized; + } + + private async ensureHumanRegistered(name: string, kind: HumanIdentityKind): Promise { + const stateKey = this.humanRegistrationKey(name, kind); + const existing = this.humanRegistrations.get(stateKey); + if (existing?.canonicalName) { + return existing.canonicalName; + } + if (existing?.pending) { + return existing.pending; + } + + const state = existing ?? {}; + const pending = (async () => { + try { + await this.ensureStarted(); + if (!this.relayApiKey) { + throw new HumanRegistrationError( + name, + `Failed to register human identity "${name}": no Relaycast workspace key is available.` + ); + } + + const relaycast = new RelayCast({ + apiKey: this.relayApiKey, + ...(this.relaycastBaseUrl ? { baseUrl: this.relaycastBaseUrl } : {}), + }); + + const registration = + kind === 'system' + ? await relaycast.system({ name }) + : await relaycast.agents.registerAgent({ name, type: 'human', strict: false }); + state.canonicalName = registration.name; + return registration.name; + } catch (error) { + if (error instanceof HumanRegistrationError) { + throw error; + } + const message = error instanceof Error ? error.message : String(error); + throw new HumanRegistrationError( + name, + `Failed to register human identity "${name}": ${message}`, + error + ); + } finally { + state.pending = undefined; + } + })(); + + state.pending = pending; + this.humanRegistrations.set(stateKey, state); + return pending; + } + private async ensureStarted(): Promise { if (this.client) return this.client; if (this.startPromise) return this.startPromise; diff --git a/src/message_bridge.rs b/src/message_bridge.rs index 277ab9d92..c8c4a14ac 100644 --- a/src/message_bridge.rs +++ b/src/message_bridge.rs @@ -505,7 +505,7 @@ fn extract_target(accessor: EventAccessor<'_>, kind: &InboundKind) -> Option, kind: &InboundKind) -> Option Option { None } +fn target_value_to_string(value: &Value) -> Option { + if let Some(s) = scalar_to_string(value) { + return Some(s); + } + + let obj = value.as_object()?; + for key in ["id", "agent_id", "agentId", "handle", "name", "display_name", "username"] { + if let Some(v) = obj.get(key) { + if let Some(s) = scalar_to_string(v) { + if !s.is_empty() { + return Some(s); + } + } + } + } + None +} + fn scalar_to_string(value: &Value) -> Option { match value { Value::String(s) => Some(s.clone()), @@ -962,6 +980,27 @@ mod tests { assert_eq!(event.text, "reply from relaycast"); } + #[test] + fn dm_target_prefers_routable_identity_over_display_name() { + let event = map_event(&json!({ + "type": "message.created", + "conversation_id": "conv_10", + "message": { + "id": "dm_10", + "agent_name": "Lead", + "text": "reply using canonical target", + "to": { + "name": "Dashboard", + "id": "human:orchestrator" + } + } + })) + .expect("conversation message should map as dm"); + + assert_eq!(event.kind, InboundKind::DmReceived); + assert_eq!(event.target, "human:orchestrator"); + } + #[test] fn dm_target_prefers_explicit_recipient_over_conversation_id() { let event = map_event(&json!({ From 5dccec607a0c7048f6a78e1ae4fdcf42af9cb9a7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 1 Apr 2026 13:35:56 +0000 Subject: [PATCH 2/7] style: auto-format Rust code with cargo fmt --- src/message_bridge.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/message_bridge.rs b/src/message_bridge.rs index c8c4a14ac..b6d93c3ce 100644 --- a/src/message_bridge.rs +++ b/src/message_bridge.rs @@ -665,7 +665,15 @@ fn target_value_to_string(value: &Value) -> Option { } let obj = value.as_object()?; - for key in ["id", "agent_id", "agentId", "handle", "name", "display_name", "username"] { + for key in [ + "id", + "agent_id", + "agentId", + "handle", + "name", + "display_name", + "username", + ] { if let Some(v) = obj.get(key) { if let Some(s) = scalar_to_string(v) { if !s.is_empty() { From a6ba43360aa91f64292f08403a1e0344c8ebf053 Mon Sep 17 00:00:00 2001 From: Noodle Date: Wed, 1 Apr 2026 10:11:11 -0400 Subject: [PATCH 3/7] fix(sdk): keep human handles opt-in for registration --- .../__tests__/orchestration-upgrades.test.ts | 49 +++++++++++++++++-- packages/sdk/src/relay.ts | 2 +- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/packages/sdk/src/__tests__/orchestration-upgrades.test.ts b/packages/sdk/src/__tests__/orchestration-upgrades.test.ts index f4f67a93d..c5c421466 100644 --- a/packages/sdk/src/__tests__/orchestration-upgrades.test.ts +++ b/packages/sdk/src/__tests__/orchestration-upgrades.test.ts @@ -845,7 +845,46 @@ describe('AgentRelay orchestration handles', () => { } }); - it('human.sendMessage auto-registers once and sends from the canonical identity', async () => { + it('human.sendMessage preserves legacy non-registering behavior by default', async () => { + const { client, mock } = createMockFacadeClient(); + vi.spyOn(AgentRelayClient, 'start').mockResolvedValue(client); + + const relay = new AgentRelay({ + env: { ...process.env, RELAY_API_KEY: 'relay-key' }, + }); + + try { + const human = relay.human({ name: 'Reviewer' }); + + const first = await human.sendMessage({ to: 'worker-1', text: 'status?' }); + const second = await human.sendMessage({ to: 'worker-1', text: 'report back' }); + + expect(relayCastMocks.mockRelayCastRegisterAgent).not.toHaveBeenCalled(); + expect(mock.sendMessage).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + to: 'worker-1', + text: 'status?', + from: 'Reviewer', + }) + ); + expect(mock.sendMessage).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + to: 'worker-1', + text: 'report back', + from: 'Reviewer', + }) + ); + expect(first.from).toBe('Reviewer'); + expect(second.from).toBe('Reviewer'); + expect(human.name).toBe('Reviewer'); + } finally { + await relay.shutdown(); + } + }); + + it('registered human.sendMessage auto-registers once and sends from the canonical identity', async () => { const { client, mock } = createMockFacadeClient(); vi.spyOn(AgentRelayClient, 'start').mockResolvedValue(client); relayCastMocks.mockRelayCastRegisterAgent.mockResolvedValue({ @@ -861,7 +900,7 @@ describe('AgentRelay orchestration handles', () => { }); try { - const human = relay.human({ name: 'Reviewer' }); + const human = await relay.human({ name: 'Reviewer', ensureRegistered: true }); const first = await human.sendMessage({ to: 'worker-1', text: 'status?' }); const second = await human.sendMessage({ to: 'worker-1', text: 'report back' }); @@ -891,7 +930,7 @@ describe('AgentRelay orchestration handles', () => { } }); - it('human.sendMessage surfaces a clear SDK-level registration error', async () => { + it('human.ensureRegistered surfaces a clear SDK-level registration error', async () => { const { client, mock } = createMockFacadeClient(); vi.spyOn(AgentRelayClient, 'start').mockResolvedValue(client); relayCastMocks.mockRelayCastRegisterAgent.mockRejectedValue(new Error('name conflict upstream')); @@ -903,11 +942,11 @@ describe('AgentRelay orchestration handles', () => { try { const human = relay.human({ name: 'Reviewer' }); - await expect(human.sendMessage({ to: 'worker-1', text: 'status?' })).rejects.toMatchObject({ + await expect(human.ensureRegistered()).rejects.toMatchObject({ name: 'HumanRegistrationError', requestedName: 'Reviewer', }); - await expect(human.sendMessage({ to: 'worker-1', text: 'status?' })).rejects.toThrow( + await expect(human.ensureRegistered()).rejects.toThrow( 'Failed to register human identity "Reviewer": name conflict upstream' ); expect(mock.sendMessage).not.toHaveBeenCalled(); diff --git a/packages/sdk/src/relay.ts b/packages/sdk/src/relay.ts index 2701b975b..af5b5f1b5 100644 --- a/packages/sdk/src/relay.ts +++ b/packages/sdk/src/relay.ts @@ -673,7 +673,7 @@ export class AgentRelay { human(opts: { name: string; ensureRegistered: true }): Promise; human(opts: { name: string; ensureRegistered?: false | undefined }): HumanHandle; human(opts: { name: string; ensureRegistered?: boolean }): HumanHandle | Promise { - const handle = this.createHumanHandle(opts.name, 'human', true); + const handle = this.createHumanHandle(opts.name, 'human', !!opts.ensureRegistered); if (opts.ensureRegistered) { return handle.ensureRegistered().then(() => handle); } From 5d4662f06877ef736826300a13d6662ebe73f184 Mon Sep 17 00:00:00 2001 From: Noodle Date: Wed, 1 Apr 2026 11:15:42 -0400 Subject: [PATCH 4/7] fix(sdk): make human registration backend-neutral --- .../__tests__/orchestration-upgrades.test.ts | 136 +++++++++++++----- packages/sdk/src/relay.ts | 21 +++ 2 files changed, 120 insertions(+), 37 deletions(-) diff --git a/packages/sdk/src/__tests__/orchestration-upgrades.test.ts b/packages/sdk/src/__tests__/orchestration-upgrades.test.ts index c5c421466..a64bf1737 100644 --- a/packages/sdk/src/__tests__/orchestration-upgrades.test.ts +++ b/packages/sdk/src/__tests__/orchestration-upgrades.test.ts @@ -298,7 +298,7 @@ describe('AgentRelayClient orchestration payloads', () => { describe('AgentRelay orchestration handles', () => { it('agent.waitForReady resolves after worker_ready event', async () => { const { client, emit } = createMockFacadeClient(); - vi.spyOn(AgentRelayClient, 'start').mockResolvedValue(client); + vi.spyOn(AgentRelayClient, 'spawn').mockResolvedValue(client); const relay = new AgentRelay(); @@ -320,7 +320,7 @@ describe('AgentRelay orchestration handles', () => { it('waitForAgentMessage waits for relay_inbound from the agent', async () => { const { client, emit } = createMockFacadeClient(); - vi.spyOn(AgentRelayClient, 'start').mockResolvedValue(client); + vi.spyOn(AgentRelayClient, 'spawn').mockResolvedValue(client); const relay = new AgentRelay(); @@ -357,7 +357,7 @@ describe('AgentRelay orchestration handles', () => { it('spawnAndWait can wait for first agent message', async () => { const { client, mock, emit } = createMockFacadeClient(); - vi.spyOn(AgentRelayClient, 'start').mockResolvedValue(client); + vi.spyOn(AgentRelayClient, 'spawn').mockResolvedValue(client); const relay = new AgentRelay(); @@ -394,7 +394,7 @@ describe('AgentRelay orchestration handles', () => { it('spawnAndWait falls back to worker_ready when waitForMessage is false', async () => { const { client, mock, emit } = createMockFacadeClient(); - vi.spyOn(AgentRelayClient, 'start').mockResolvedValue(client); + vi.spyOn(AgentRelayClient, 'spawn').mockResolvedValue(client); const relay = new AgentRelay(); @@ -430,7 +430,7 @@ describe('AgentRelay orchestration handles', () => { channels: ['general'], }, ]); - vi.spyOn(AgentRelayClient, 'start').mockResolvedValue(client); + vi.spyOn(AgentRelayClient, 'spawn').mockResolvedValue(client); const relay = new AgentRelay(); @@ -449,7 +449,7 @@ describe('AgentRelay orchestration handles', () => { it('spawn lifecycle hooks fire for success', async () => { const { client } = createMockFacadeClient(); - vi.spyOn(AgentRelayClient, 'start').mockResolvedValue(client); + vi.spyOn(AgentRelayClient, 'spawn').mockResolvedValue(client); const relay = new AgentRelay(); const callOrder: string[] = []; @@ -488,7 +488,7 @@ describe('AgentRelay orchestration handles', () => { it('spawn lifecycle hooks await async callbacks', async () => { const { client } = createMockFacadeClient(); - vi.spyOn(AgentRelayClient, 'start').mockResolvedValue(client); + vi.spyOn(AgentRelayClient, 'spawn').mockResolvedValue(client); const relay = new AgentRelay(); let startDone = false; @@ -516,7 +516,7 @@ describe('AgentRelay orchestration handles', () => { it('spawn lifecycle hooks fire on error', async () => { const { client, mock } = createMockFacadeClient(); - vi.spyOn(AgentRelayClient, 'start').mockResolvedValue(client); + vi.spyOn(AgentRelayClient, 'spawn').mockResolvedValue(client); mock.spawnPty.mockRejectedValueOnce(new Error('spawn failed')); const relay = new AgentRelay(); @@ -555,7 +555,7 @@ describe('AgentRelay orchestration handles', () => { it('agent.release passes reason to the broker client', async () => { const { client, mock } = createMockFacadeClient(); - vi.spyOn(AgentRelayClient, 'start').mockResolvedValue(client); + vi.spyOn(AgentRelayClient, 'spawn').mockResolvedValue(client); const relay = new AgentRelay(); @@ -576,7 +576,7 @@ describe('AgentRelay orchestration handles', () => { it('agent.release lifecycle hooks fire for success', async () => { const { client, mock } = createMockFacadeClient(); - vi.spyOn(AgentRelayClient, 'start').mockResolvedValue(client); + vi.spyOn(AgentRelayClient, 'spawn').mockResolvedValue(client); const relay = new AgentRelay(); const callOrder: string[] = []; @@ -616,7 +616,7 @@ describe('AgentRelay orchestration handles', () => { it('agent.release is a no-op success after agent_exited', async () => { const { client, mock, emit } = createMockFacadeClient(); - vi.spyOn(AgentRelayClient, 'start').mockResolvedValue(client); + vi.spyOn(AgentRelayClient, 'spawn').mockResolvedValue(client); const relay = new AgentRelay(); @@ -638,7 +638,7 @@ describe('AgentRelay orchestration handles', () => { it('agent.release treats broker agent_not_found as idempotent success', async () => { const { client, mock } = createMockFacadeClient(); - vi.spyOn(AgentRelayClient, 'start').mockResolvedValue(client); + vi.spyOn(AgentRelayClient, 'spawn').mockResolvedValue(client); mock.release.mockRejectedValueOnce( new AgentRelayProtocolError({ code: 'agent_not_found', @@ -665,7 +665,7 @@ describe('AgentRelay orchestration handles', () => { it('agent.release lifecycle hooks fire on error', async () => { const { client, mock } = createMockFacadeClient(); - vi.spyOn(AgentRelayClient, 'start').mockResolvedValue(client); + vi.spyOn(AgentRelayClient, 'spawn').mockResolvedValue(client); mock.release.mockRejectedValueOnce(new Error('release failed')); const relay = new AgentRelay(); @@ -705,7 +705,7 @@ describe('AgentRelay orchestration handles', () => { it('agent.release lifecycle hooks await async callbacks', async () => { const { client } = createMockFacadeClient(); - vi.spyOn(AgentRelayClient, 'start').mockResolvedValue(client); + vi.spyOn(AgentRelayClient, 'spawn').mockResolvedValue(client); const relay = new AgentRelay(); let successDone = false; @@ -733,7 +733,7 @@ describe('AgentRelay orchestration handles', () => { it('agent.release does not fire lifecycle hooks if broker startup fails before release begins', async () => { const { client } = createMockFacadeClient(); - vi.spyOn(AgentRelayClient, 'start').mockResolvedValue(client); + vi.spyOn(AgentRelayClient, 'spawn').mockResolvedValue(client); const relay = new AgentRelay(); const onStart = vi.fn(); @@ -765,7 +765,7 @@ describe('AgentRelay orchestration handles', () => { it('system() sends messages from the system identity', async () => { const { client, mock } = createMockFacadeClient(); - vi.spyOn(AgentRelayClient, 'start').mockResolvedValue(client); + vi.spyOn(AgentRelayClient, 'spawn').mockResolvedValue(client); const relay = new AgentRelay(); @@ -791,7 +791,7 @@ describe('AgentRelay orchestration handles', () => { it('registerHuman returns the canonical routable identity', async () => { const { client } = createMockFacadeClient(); - vi.spyOn(AgentRelayClient, 'start').mockResolvedValue(client); + vi.spyOn(AgentRelayClient, 'spawn').mockResolvedValue(client); relayCastMocks.mockRelayCastRegisterAgent.mockResolvedValue({ id: 'agt_human_1', name: 'human:Alice-7f3c', @@ -820,9 +820,30 @@ describe('AgentRelay orchestration handles', () => { } }); + it('registerHuman returns a canonical local identity without RelayCast registration', async () => { + const { client } = createMockFacadeClient(); + vi.spyOn(AgentRelayClient, 'spawn').mockResolvedValue(client); + const envWithoutRelayKey = { ...process.env }; + delete envWithoutRelayKey.RELAY_API_KEY; + + const relay = new AgentRelay({ + env: envWithoutRelayKey, + }); + + try { + const human = await relay.registerHuman({ name: 'Alice' }); + + expect(relayCastMocks.mockRelayCastRegisterAgent).not.toHaveBeenCalled(); + await expect(human.ensureRegistered()).resolves.toBe('human:Alice'); + expect(human.name).toBe('human:Alice'); + } finally { + await relay.shutdown(); + } + }); + it('human({ ensureRegistered: true }) resolves to the canonical handle', async () => { const { client } = createMockFacadeClient(); - vi.spyOn(AgentRelayClient, 'start').mockResolvedValue(client); + vi.spyOn(AgentRelayClient, 'spawn').mockResolvedValue(client); relayCastMocks.mockRelayCastRegisterAgent.mockResolvedValue({ id: 'agt_human_2', name: 'human:Owner-2', @@ -845,9 +866,50 @@ describe('AgentRelay orchestration handles', () => { } }); + it('human({ ensureRegistered: true }) sends from the canonical local identity', async () => { + const { client, mock } = createMockFacadeClient(); + vi.spyOn(AgentRelayClient, 'spawn').mockResolvedValue(client); + const envWithoutRelayKey = { ...process.env }; + delete envWithoutRelayKey.RELAY_API_KEY; + + const relay = new AgentRelay({ + env: envWithoutRelayKey, + }); + + try { + const human = await relay.human({ name: 'Reviewer', ensureRegistered: true }); + + const first = await human.sendMessage({ to: 'worker-1', text: 'status?' }); + const second = await human.sendMessage({ to: 'worker-1', text: 'report back' }); + + expect(relayCastMocks.mockRelayCastRegisterAgent).not.toHaveBeenCalled(); + expect(mock.sendMessage).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + to: 'worker-1', + text: 'status?', + from: 'human:Reviewer', + }) + ); + expect(mock.sendMessage).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + to: 'worker-1', + text: 'report back', + from: 'human:Reviewer', + }) + ); + expect(first.from).toBe('human:Reviewer'); + expect(second.from).toBe('human:Reviewer'); + expect(human.name).toBe('human:Reviewer'); + } finally { + await relay.shutdown(); + } + }); + it('human.sendMessage preserves legacy non-registering behavior by default', async () => { const { client, mock } = createMockFacadeClient(); - vi.spyOn(AgentRelayClient, 'start').mockResolvedValue(client); + vi.spyOn(AgentRelayClient, 'spawn').mockResolvedValue(client); const relay = new AgentRelay({ env: { ...process.env, RELAY_API_KEY: 'relay-key' }, @@ -886,7 +948,7 @@ describe('AgentRelay orchestration handles', () => { it('registered human.sendMessage auto-registers once and sends from the canonical identity', async () => { const { client, mock } = createMockFacadeClient(); - vi.spyOn(AgentRelayClient, 'start').mockResolvedValue(client); + vi.spyOn(AgentRelayClient, 'spawn').mockResolvedValue(client); relayCastMocks.mockRelayCastRegisterAgent.mockResolvedValue({ id: 'agt_human_3', name: 'human:Reviewer-canon', @@ -932,7 +994,7 @@ describe('AgentRelay orchestration handles', () => { it('human.ensureRegistered surfaces a clear SDK-level registration error', async () => { const { client, mock } = createMockFacadeClient(); - vi.spyOn(AgentRelayClient, 'start').mockResolvedValue(client); + vi.spyOn(AgentRelayClient, 'spawn').mockResolvedValue(client); relayCastMocks.mockRelayCastRegisterAgent.mockRejectedValue(new Error('name conflict upstream')); const relay = new AgentRelay({ @@ -957,7 +1019,7 @@ describe('AgentRelay orchestration handles', () => { it('sendAndWaitForDelivery waits for delivery ack with typed response', async () => { const { client, mock, emit } = createMockFacadeClient(); - vi.spyOn(AgentRelayClient, 'start').mockResolvedValue(client); + vi.spyOn(AgentRelayClient, 'spawn').mockResolvedValue(client); const relay = new AgentRelay(); try { @@ -995,7 +1057,7 @@ describe('AgentRelay orchestration handles', () => { it('sendAndWaitForDelivery timeout remains terminal in delivery state timeline (Wave 0 contract)', async () => { const { client, mock, emit } = createMockFacadeClient(); - vi.spyOn(AgentRelayClient, 'start').mockResolvedValue(client); + vi.spyOn(AgentRelayClient, 'spawn').mockResolvedValue(client); const timeoutFixture = readWave0Fixture<{ event_id: string; @@ -1041,7 +1103,7 @@ describe('AgentRelay orchestration handles', () => { it('relay_inbound normalizes broker identities to Dashboard across repos (Wave 0 contract)', async () => { const { client, emit } = createMockFacadeClient(); - vi.spyOn(AgentRelayClient, 'start').mockResolvedValue(client); + vi.spyOn(AgentRelayClient, 'spawn').mockResolvedValue(client); const identityFixture = readWave0Fixture<{ cases: Array<{ input: string; normalized: string }>; @@ -1076,7 +1138,7 @@ describe('AgentRelay orchestration handles', () => { it('tracks per-event delivery state transitions', async () => { const { client, emit } = createMockFacadeClient(); - vi.spyOn(AgentRelayClient, 'start').mockResolvedValue(client); + vi.spyOn(AgentRelayClient, 'spawn').mockResolvedValue(client); const relay = new AgentRelay(); try { @@ -1145,7 +1207,7 @@ describe('AgentRelay orchestration handles', () => { describe('Agent.status computed getter', () => { it('returns spawning before worker_ready fires', async () => { const { client } = createMockFacadeClient(); - vi.spyOn(AgentRelayClient, 'start').mockResolvedValue(client); + vi.spyOn(AgentRelayClient, 'spawn').mockResolvedValue(client); const relay = new AgentRelay(); try { @@ -1163,7 +1225,7 @@ describe('Agent.status computed getter', () => { it('returns ready after worker_ready event', async () => { const { client, emit } = createMockFacadeClient(); - vi.spyOn(AgentRelayClient, 'start').mockResolvedValue(client); + vi.spyOn(AgentRelayClient, 'spawn').mockResolvedValue(client); const relay = new AgentRelay(); try { @@ -1183,7 +1245,7 @@ describe('Agent.status computed getter', () => { it('returns idle after agent_idle event', async () => { const { client, emit } = createMockFacadeClient(); - vi.spyOn(AgentRelayClient, 'start').mockResolvedValue(client); + vi.spyOn(AgentRelayClient, 'spawn').mockResolvedValue(client); const relay = new AgentRelay(); try { @@ -1205,7 +1267,7 @@ describe('Agent.status computed getter', () => { it('returns exited after agent_exited event', async () => { const { client, emit } = createMockFacadeClient(); - vi.spyOn(AgentRelayClient, 'start').mockResolvedValue(client); + vi.spyOn(AgentRelayClient, 'spawn').mockResolvedValue(client); const relay = new AgentRelay(); try { @@ -1226,7 +1288,7 @@ describe('Agent.status computed getter', () => { it('transitions from idle back to ready on worker_stream', async () => { const { client, emit } = createMockFacadeClient(); - vi.spyOn(AgentRelayClient, 'start').mockResolvedValue(client); + vi.spyOn(AgentRelayClient, 'spawn').mockResolvedValue(client); const relay = new AgentRelay(); try { @@ -1251,7 +1313,7 @@ describe('Agent.status computed getter', () => { describe('Agent.onOutput', () => { it('receives output chunks for the correct agent', async () => { const { client, emit } = createMockFacadeClient(); - vi.spyOn(AgentRelayClient, 'start').mockResolvedValue(client); + vi.spyOn(AgentRelayClient, 'spawn').mockResolvedValue(client); const relay = new AgentRelay(); try { @@ -1275,7 +1337,7 @@ describe('Agent.onOutput', () => { it('does not receive output for other agents', async () => { const { client, emit } = createMockFacadeClient(); - vi.spyOn(AgentRelayClient, 'start').mockResolvedValue(client); + vi.spyOn(AgentRelayClient, 'spawn').mockResolvedValue(client); const relay = new AgentRelay(); try { @@ -1299,7 +1361,7 @@ describe('Agent.onOutput', () => { it('unsubscribe stops receiving output', async () => { const { client, emit } = createMockFacadeClient(); - vi.spyOn(AgentRelayClient, 'start').mockResolvedValue(client); + vi.spyOn(AgentRelayClient, 'spawn').mockResolvedValue(client); const relay = new AgentRelay(); try { @@ -1324,7 +1386,7 @@ describe('Agent.onOutput', () => { it('onOutput with { stream: "stdout" } only receives stdout events', async () => { const { client, emit } = createMockFacadeClient(); - vi.spyOn(AgentRelayClient, 'start').mockResolvedValue(client); + vi.spyOn(AgentRelayClient, 'spawn').mockResolvedValue(client); const relay = new AgentRelay(); try { @@ -1349,7 +1411,7 @@ describe('Agent.onOutput', () => { it('onOutput without filter receives all streams', async () => { const { client, emit } = createMockFacadeClient(); - vi.spyOn(AgentRelayClient, 'start').mockResolvedValue(client); + vi.spyOn(AgentRelayClient, 'spawn').mockResolvedValue(client); const relay = new AgentRelay(); try { @@ -1373,7 +1435,7 @@ describe('Agent.onOutput', () => { it('onOutput with { stream: "stderr" } ignores stdout events', async () => { const { client, emit } = createMockFacadeClient(); - vi.spyOn(AgentRelayClient, 'start').mockResolvedValue(client); + vi.spyOn(AgentRelayClient, 'spawn').mockResolvedValue(client); const relay = new AgentRelay(); try { @@ -1397,7 +1459,7 @@ describe('Agent.onOutput', () => { it('onOutput with explicit mode: "structured" receives { stream, chunk } objects', async () => { const { client, emit } = createMockFacadeClient(); - vi.spyOn(AgentRelayClient, 'start').mockResolvedValue(client); + vi.spyOn(AgentRelayClient, 'spawn').mockResolvedValue(client); const relay = new AgentRelay(); try { diff --git a/packages/sdk/src/relay.ts b/packages/sdk/src/relay.ts index af5b5f1b5..1dd28a237 100644 --- a/packages/sdk/src/relay.ts +++ b/packages/sdk/src/relay.ts @@ -400,6 +400,7 @@ export class AgentRelay { private readonly requestedWorkspaceId?: string; private readonly workspaceName?: string; private readonly relaycastBaseUrl?: string; + private readonly prefersRelaycastHumanRegistration: boolean; private relayApiKey?: string; private resolvedWorkspaceId?: string; private client?: AgentRelayClient; @@ -437,6 +438,10 @@ export class AgentRelay { ); } this.relaycastBaseUrl = options.relaycastBaseUrl; + this.prefersRelaycastHumanRegistration = + typeof options.env?.RELAY_API_KEY === 'string' + ? options.env.RELAY_API_KEY.trim().length > 0 + : typeof process.env.RELAY_API_KEY === 'string' && process.env.RELAY_API_KEY.trim().length > 0; this.clientOptions = { binaryPath: options.binaryPath, binaryArgs: options.binaryArgs, @@ -1226,6 +1231,17 @@ export class AgentRelay { return normalized; } + private localCanonicalHumanName(name: string, kind: HumanIdentityKind): string { + if (kind === 'system') { + return 'system'; + } + if (name.startsWith('human:')) { + const normalizedRest = name.slice('human:'.length).trim(); + return normalizedRest ? `human:${normalizedRest}` : 'human:orchestrator'; + } + return `human:${name}`; + } + private async ensureHumanRegistered(name: string, kind: HumanIdentityKind): Promise { const stateKey = this.humanRegistrationKey(name, kind); const existing = this.humanRegistrations.get(stateKey); @@ -1240,6 +1256,11 @@ export class AgentRelay { const pending = (async () => { try { await this.ensureStarted(); + if (!this.prefersRelaycastHumanRegistration) { + const canonicalName = this.localCanonicalHumanName(name, kind); + state.canonicalName = canonicalName; + return canonicalName; + } if (!this.relayApiKey) { throw new HumanRegistrationError( name, From 0b5a8bd3269d70b79983b74f938c151484bf92df Mon Sep 17 00:00:00 2001 From: Noodle Date: Wed, 1 Apr 2026 11:31:54 -0400 Subject: [PATCH 5/7] fix(sdk): make ensureRegistered explicit --- packages/sdk/src/relay.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/sdk/src/relay.ts b/packages/sdk/src/relay.ts index 1dd28a237..f22f20ae3 100644 --- a/packages/sdk/src/relay.ts +++ b/packages/sdk/src/relay.ts @@ -699,9 +699,6 @@ export class AgentRelay { return resolveName(); }, ensureRegistered: async () => { - if (!autoEnsureRegistration) { - return resolveName(); - } return this.ensureHumanRegistered(normalizedRequestedName, kind); }, sendMessage: async (input) => { From 902cccc7a7f68a531923d067c999d70b32bd7e81 Mon Sep 17 00:00:00 2001 From: Noodle Date: Wed, 1 Apr 2026 11:37:53 -0400 Subject: [PATCH 6/7] refactor(sdk): separate local human and system registration --- packages/sdk/src/relay.ts | 26 ++++++++------------------ 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/packages/sdk/src/relay.ts b/packages/sdk/src/relay.ts index f22f20ae3..9535521d1 100644 --- a/packages/sdk/src/relay.ts +++ b/packages/sdk/src/relay.ts @@ -400,7 +400,6 @@ export class AgentRelay { private readonly requestedWorkspaceId?: string; private readonly workspaceName?: string; private readonly relaycastBaseUrl?: string; - private readonly prefersRelaycastHumanRegistration: boolean; private relayApiKey?: string; private resolvedWorkspaceId?: string; private client?: AgentRelayClient; @@ -438,10 +437,6 @@ export class AgentRelay { ); } this.relaycastBaseUrl = options.relaycastBaseUrl; - this.prefersRelaycastHumanRegistration = - typeof options.env?.RELAY_API_KEY === 'string' - ? options.env.RELAY_API_KEY.trim().length > 0 - : typeof process.env.RELAY_API_KEY === 'string' && process.env.RELAY_API_KEY.trim().length > 0; this.clientOptions = { binaryPath: options.binaryPath, binaryArgs: options.binaryArgs, @@ -1228,10 +1223,7 @@ export class AgentRelay { return normalized; } - private localCanonicalHumanName(name: string, kind: HumanIdentityKind): string { - if (kind === 'system') { - return 'system'; - } + private localCanonicalHumanName(name: string): string { if (name.startsWith('human:')) { const normalizedRest = name.slice('human:'.length).trim(); return normalizedRest ? `human:${normalizedRest}` : 'human:orchestrator'; @@ -1239,6 +1231,10 @@ export class AgentRelay { return `human:${name}`; } + private localCanonicalSystemName(name: string): string { + return name === 'system' ? 'system' : name.trim() || 'system'; + } + private async ensureHumanRegistered(name: string, kind: HumanIdentityKind): Promise { const stateKey = this.humanRegistrationKey(name, kind); const existing = this.humanRegistrations.get(stateKey); @@ -1253,18 +1249,12 @@ export class AgentRelay { const pending = (async () => { try { await this.ensureStarted(); - if (!this.prefersRelaycastHumanRegistration) { - const canonicalName = this.localCanonicalHumanName(name, kind); + if (!this.relayApiKey) { + const canonicalName = + kind === 'system' ? this.localCanonicalSystemName(name) : this.localCanonicalHumanName(name); state.canonicalName = canonicalName; return canonicalName; } - if (!this.relayApiKey) { - throw new HumanRegistrationError( - name, - `Failed to register human identity "${name}": no Relaycast workspace key is available.` - ); - } - const relaycast = new RelayCast({ apiKey: this.relayApiKey, ...(this.relaycastBaseUrl ? { baseUrl: this.relaycastBaseUrl } : {}), From c90f2a9d4551c90862978aaf671f3883fa0ea328 Mon Sep 17 00:00:00 2001 From: Noodle Date: Wed, 1 Apr 2026 11:53:31 -0400 Subject: [PATCH 7/7] fix(sdk): preserve local registration path --- packages/sdk/src/relay.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/sdk/src/relay.ts b/packages/sdk/src/relay.ts index 9535521d1..bc0fa7a81 100644 --- a/packages/sdk/src/relay.ts +++ b/packages/sdk/src/relay.ts @@ -697,10 +697,10 @@ export class AgentRelay { return this.ensureHumanRegistered(normalizedRequestedName, kind); }, sendMessage: async (input) => { - const client = await this.ensureStarted(); const from = autoEnsureRegistration ? await this.ensureHumanRegistered(normalizedRequestedName, kind) : resolveName(); + const client = await this.ensureStarted(); let result: Awaited>; try { result = await client.sendMessage({ @@ -1079,6 +1079,7 @@ export class AgentRelay { this.idleAgents.clear(); this.deliveryStates.clear(); this.outputListeners.clear(); + this.humanRegistrations.clear(); for (const entry of this.exitResolvers.values()) { entry.resolve('released'); } @@ -1235,6 +1236,17 @@ export class AgentRelay { return name === 'system' ? 'system' : name.trim() || 'system'; } + private hasConfiguredRelaycastRegistration(): boolean { + if (this.relayApiKey) { + return true; + } + const envKey = this.clientOptions.env?.RELAY_API_KEY ?? process.env.RELAY_API_KEY; + if (typeof envKey === 'string' && envKey.trim().length > 0) { + return true; + } + return typeof this.requestedWorkspaceId === 'string' && this.requestedWorkspaceId.trim().length > 0; + } + private async ensureHumanRegistered(name: string, kind: HumanIdentityKind): Promise { const stateKey = this.humanRegistrationKey(name, kind); const existing = this.humanRegistrations.get(stateKey); @@ -1248,13 +1260,14 @@ export class AgentRelay { const state = existing ?? {}; const pending = (async () => { try { - await this.ensureStarted(); - if (!this.relayApiKey) { + if (!this.hasConfiguredRelaycastRegistration()) { const canonicalName = kind === 'system' ? this.localCanonicalSystemName(name) : this.localCanonicalHumanName(name); state.canonicalName = canonicalName; return canonicalName; } + + await this.ensureStarted(); const relaycast = new RelayCast({ apiKey: this.relayApiKey, ...(this.relaycastBaseUrl ? { baseUrl: this.relaycastBaseUrl } : {}),