diff --git a/packages/sdk/src/__tests__/orchestration-upgrades.test.ts b/packages/sdk/src/__tests__/orchestration-upgrades.test.ts index 945cf66c6..a64bf1737 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', () => { @@ -273,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(); @@ -295,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(); @@ -332,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(); @@ -369,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(); @@ -405,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(); @@ -424,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[] = []; @@ -463,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; @@ -491,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(); @@ -530,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(); @@ -551,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[] = []; @@ -591,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(); @@ -613,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', @@ -640,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(); @@ -680,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; @@ -708,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(); @@ -740,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(); @@ -764,9 +789,237 @@ describe('AgentRelay orchestration handles', () => { } }); + it('registerHuman returns the canonical routable identity', async () => { + const { client } = createMockFacadeClient(); + vi.spyOn(AgentRelayClient, 'spawn').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('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, 'spawn').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({ 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, 'spawn').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, 'spawn').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 = 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).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.ensureRegistered surfaces a clear SDK-level registration error', async () => { + const { client, mock } = createMockFacadeClient(); + vi.spyOn(AgentRelayClient, 'spawn').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.ensureRegistered()).rejects.toMatchObject({ + name: 'HumanRegistrationError', + requestedName: 'Reviewer', + }); + await expect(human.ensureRegistered()).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); + vi.spyOn(AgentRelayClient, 'spawn').mockResolvedValue(client); const relay = new AgentRelay(); try { @@ -804,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; @@ -850,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 }>; @@ -885,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 { @@ -954,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 { @@ -972,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 { @@ -992,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 { @@ -1014,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 { @@ -1035,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 { @@ -1060,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 { @@ -1084,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 { @@ -1108,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 { @@ -1133,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 { @@ -1158,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 { @@ -1182,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 { @@ -1206,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 8493572f1..bc0fa7a81 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,49 @@ 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', !!opts.ensureRegistered); + 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 () => { + return this.ensureHumanRegistered(normalizedRequestedName, kind); + }, sendMessage: async (input) => { + const from = autoEnsureRegistration + ? await this.ensureHumanRegistered(normalizedRequestedName, kind) + : resolveName(); const client = await this.ensureStarted(); 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 +714,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 +739,7 @@ export class AgentRelay { } system(): HumanHandle { - return this.human({ name: 'system' }); + return this.createHumanHandle('system', 'system', false); } // ── Messaging ───────────────────────────────────────────────────────── @@ -1027,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'); } @@ -1159,6 +1212,93 @@ 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 localCanonicalHumanName(name: string): string { + if (name.startsWith('human:')) { + const normalizedRest = name.slice('human:'.length).trim(); + return normalizedRest ? `human:${normalizedRest}` : 'human:orchestrator'; + } + return `human:${name}`; + } + + private localCanonicalSystemName(name: string): string { + 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); + if (existing?.canonicalName) { + return existing.canonicalName; + } + if (existing?.pending) { + return existing.pending; + } + + const state = existing ?? {}; + const pending = (async () => { + try { + 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 } : {}), + }); + + 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..b6d93c3ce 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 +988,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!({