From 733830ddcfb4fddeba26aad83386934549d6c86e Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sat, 8 Aug 2026 23:23:05 +0200 Subject: [PATCH 1/6] fix(cli): expose direct-message enqueue state --- .agents/skills/using-agent-relay/SKILL.md | 11 ++- .claude/skills/using-agent-relay/SKILL.md | 11 ++- CHANGELOG.md | 1 + packages/cli/README.md | 5 +- packages/cli/src/cli/commands/message.ts | 22 +++-- .../src/cli/commands/relaycast-groups.test.ts | 8 +- .../src/cli/lib/message-delivery-receipts.ts | 84 ++++++++++++++++++ .../cli/mcp/messaging-tools.delivery.test.ts | 88 +++++++++++++++++++ .../cli/mcp/messaging-tools.protocol.test.ts | 51 +++++++++++ packages/cli/src/cli/mcp/messaging-tools.ts | 59 ++++++++++--- packages/sdk-py/README.md | 5 +- packages/sdk/src/__tests__/messaging.test.ts | 1 + packages/sdk/src/messaging/normalize.ts | 2 + packages/sdk/src/messaging/relaycast.ts | 6 +- packages/sdk/src/messaging/types.ts | 7 ++ 15 files changed, 336 insertions(+), 25 deletions(-) create mode 100644 packages/cli/src/cli/lib/message-delivery-receipts.ts create mode 100644 packages/cli/src/cli/mcp/messaging-tools.delivery.test.ts create mode 100644 packages/cli/src/cli/mcp/messaging-tools.protocol.test.ts diff --git a/.agents/skills/using-agent-relay/SKILL.md b/.agents/skills/using-agent-relay/SKILL.md index e7a76f730..b2383eef5 100644 --- a/.agents/skills/using-agent-relay/SKILL.md +++ b/.agents/skills/using-agent-relay/SKILL.md @@ -160,10 +160,19 @@ Prefer `send_dm` for lead/worker coordination. Use `post_message` when the whole channel needs the update. Use `reply_to_thread` for follow-ups on a specific message. +Choose the injection mode deliberately. Omit `mode` (or use `mode: "wait"`) +for normal coordination: Relay queues the message until the recipient reaches +a safe idle boundary, so it can remain unread while that agent is busy. Use +`mode: "steer"` only when immediate injection justifies interrupting active +work. In either mode, a successful send and message ID confirm enqueue, not +consumption; call `get_message_readers(message_id: "...")` before interpreting +silence as acknowledgement or refusal. + Examples: ```text -send_dm(to: "Lead", text: "STATUS: Auth routes are implemented; running tests next.") +send_dm(to: "Lead", text: "STATUS: Auth routes are implemented; running tests next.", mode: "wait") +send_dm(to: "Lead", text: "URGENT: Stop the deploy.", mode: "steer") post_message(channel: "general", text: "The API endpoints are ready for review.") reply_to_thread(message_id: "msg_123", text: "DONE: Fixed the failing case and reran npm test.") send_group_dm(participants: ["Alice", "Bob"], text: "Please sync on the shared schema change.") diff --git a/.claude/skills/using-agent-relay/SKILL.md b/.claude/skills/using-agent-relay/SKILL.md index e7a76f730..b2383eef5 100644 --- a/.claude/skills/using-agent-relay/SKILL.md +++ b/.claude/skills/using-agent-relay/SKILL.md @@ -160,10 +160,19 @@ Prefer `send_dm` for lead/worker coordination. Use `post_message` when the whole channel needs the update. Use `reply_to_thread` for follow-ups on a specific message. +Choose the injection mode deliberately. Omit `mode` (or use `mode: "wait"`) +for normal coordination: Relay queues the message until the recipient reaches +a safe idle boundary, so it can remain unread while that agent is busy. Use +`mode: "steer"` only when immediate injection justifies interrupting active +work. In either mode, a successful send and message ID confirm enqueue, not +consumption; call `get_message_readers(message_id: "...")` before interpreting +silence as acknowledgement or refusal. + Examples: ```text -send_dm(to: "Lead", text: "STATUS: Auth routes are implemented; running tests next.") +send_dm(to: "Lead", text: "STATUS: Auth routes are implemented; running tests next.", mode: "wait") +send_dm(to: "Lead", text: "URGENT: Stop the deploy.", mode: "steer") post_message(channel: "general", text: "The API endpoints are ready for review.") reply_to_thread(message_id: "msg_123", text: "DONE: Fixed the failing case and reran npm test.") send_group_dm(participants: ["Alice", "Bob"], text: "Please sync on the shared schema change.") diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ea10c986..c6a0c0110 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- `send_dm` and `agent-relay message dm send` distinguish durable enqueue from recipient delivery: receipts name the exact requested/resolved recipient and report queued-unconfirmed state, empty reader lists surface a queued-or-unread signal, and `wait` versus `steer` semantics are documented at the choice point. - Fleet brokers now periodically renew authoritative worker inventory, including empty snapshots that clear stale server entries, so quiet agents remain active in Relaycast instead of aging offline while their node is healthy. ## [11.6.0] - 2026-08-13 diff --git a/packages/cli/README.md b/packages/cli/README.md index eceb7a325..17ef6946e 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -146,7 +146,10 @@ agent-relay fleet spawn codex \ agent-relay fleet spawn codex --name api-worker --task "Review the current diff." agent-relay message dm send api-worker "Detailed task instructions" -# Wake an idle worker immediately instead of queueing for its next tool boundary. +# wait is the default: it queues for the recipient's next safe idle boundary and +# can remain unread while that recipient is busy. steer requests immediate +# injection and may interrupt active work. A send ID confirms enqueue only; +# use `message inbox get_readers ` to confirm that the recipient consumed it. agent-relay message dm send api-worker "Please check Relay now." --mode steer agent-relay message inbox check --limit 20 agent-relay fleet release api-worker --reason "Work accepted" diff --git a/packages/cli/src/cli/commands/message.ts b/packages/cli/src/cli/commands/message.ts index c065fb722..46a19adae 100644 --- a/packages/cli/src/cli/commands/message.ts +++ b/packages/cli/src/cli/commands/message.ts @@ -8,6 +8,7 @@ import { withSdkDefaults, type SdkCommandDeps, } from '../lib/sdk-command.js'; +import { directMessageReceipt } from '../lib/message-delivery-receipts.js'; export type MessageCommandDependencies = SdkCommandDeps; @@ -119,16 +120,25 @@ export function registerMessageCommands( .description('Send a direct message to an agent') .argument('', 'Recipient agent') .argument('', 'Message text') - .option('--mode ', 'Delivery mode: wait or steer', parseMessageMode) + .option( + '--mode ', + 'wait (default): inject on idle; steer: inject immediately and may interrupt active work', + parseMessageMode + ) ).action(async (agent: string, text: string, o: Record) => { await runSdk(deps, async () => { + const mode = o.mode as 'wait' | 'steer' | undefined; printJson( deps, - await deps.createAgentRelay(opts(o)).messages.direct({ - to: agent, - text, - ...(o.mode ? { mode: o.mode as 'wait' | 'steer' } : {}), - }) + directMessageReceipt( + await deps.createAgentRelay(opts(o)).messages.direct({ + to: agent, + text, + ...(mode ? { mode } : {}), + }), + agent, + mode + ) ); }); }); diff --git a/packages/cli/src/cli/commands/relaycast-groups.test.ts b/packages/cli/src/cli/commands/relaycast-groups.test.ts index d0a0e1acf..ceb5690c0 100644 --- a/packages/cli/src/cli/commands/relaycast-groups.test.ts +++ b/packages/cli/src/cli/commands/relaycast-groups.test.ts @@ -145,13 +145,15 @@ describe('SDK-backed CLI groups', () => { }); it('message dm send routes to messages.direct', async () => { - const { program, relay } = harness(registerMessageCommands); + const { program, relay, log } = harness(registerMessageCommands); await program.parseAsync(['message', 'dm', 'send', 'lead', 'hi'], { from: 'user' }); expect(relay.messages.direct).toHaveBeenCalledWith({ to: 'lead', text: 'hi' }); + expect(log).toHaveBeenCalledWith(expect.stringContaining('"status": "queued_unconfirmed"')); + expect(log).toHaveBeenCalledWith(expect.stringContaining('"resolvedRecipient": "lead"')); }); it('message dm send exposes immediate Relay delivery', async () => { - const { program, relay } = harness(registerMessageCommands); + const { program, relay, log } = harness(registerMessageCommands); await program.parseAsync(['message', 'dm', 'send', 'lead', 'wake up', '--mode', 'steer'], { from: 'user', }); @@ -160,6 +162,8 @@ describe('SDK-backed CLI groups', () => { text: 'wake up', mode: 'steer', }); + expect(log).toHaveBeenCalledWith(expect.stringContaining('"mode": "steer"')); + expect(log).toHaveBeenCalledWith(expect.stringContaining('immediate injection')); }); it('integration webhook create routes to integrations.webhooks.create', async () => { diff --git a/packages/cli/src/cli/lib/message-delivery-receipts.ts b/packages/cli/src/cli/lib/message-delivery-receipts.ts new file mode 100644 index 000000000..267327804 --- /dev/null +++ b/packages/cli/src/cli/lib/message-delivery-receipts.ts @@ -0,0 +1,84 @@ +export type DirectMessageMode = 'wait' | 'steer'; + +export type DirectMessageDeliveryReceipt = Record & { + target: { kind: 'agent'; agentName: string }; + delivery: { + status: 'queued_unconfirmed' | 'recipient_mismatch'; + mode: DirectMessageMode; + requestedRecipient: string; + resolvedRecipient: string; + recipientMatched: boolean; + readConfirmed: false; + note: string; + }; +}; + +function asRecord(value: unknown): Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : { value }; +} + +function resolvedDirectRecipient(message: Record, requestedRecipient: string): string { + const target = asRecord(message.target); + const targetName = target.agentName ?? target.agent_name; + if (typeof targetName === 'string' && targetName.length > 0) return targetName; + + const recipient = asRecord(message.recipient); + const recipientName = recipient.agentName ?? recipient.agent_name ?? recipient.name; + if (typeof recipientName === 'string' && recipientName.length > 0) return recipientName; + + const directName = message.recipientName ?? message.recipient_name ?? message.to; + return typeof directName === 'string' && directName.length > 0 ? directName : requestedRecipient; +} + +/** + * Add the delivery facts that Relaycast's create-message response does not + * contain. A message id confirms durable enqueue only; delivery/read + * confirmation remains observable through get_message_readers. + */ +export function directMessageReceipt( + value: unknown, + requestedRecipient: string, + mode: DirectMessageMode = 'wait' +): DirectMessageDeliveryReceipt { + const message = asRecord(value); + const resolvedRecipient = resolvedDirectRecipient(message, requestedRecipient); + const recipientMatched = resolvedRecipient === requestedRecipient; + const note = recipientMatched + ? mode === 'steer' + ? 'Queued as an immediate injection request that may interrupt active work. This receipt does not confirm delivery or reading; call get_message_readers with the message id.' + : "Queued for injection at the recipient's next safe idle boundary. It can remain unread while the recipient is busy. This receipt does not confirm delivery or reading; call get_message_readers with the message id." + : `Recipient mismatch: requested ${requestedRecipient}, but the send response resolved ${resolvedRecipient}.`; + + return { + ...message, + target: { kind: 'agent', agentName: resolvedRecipient }, + delivery: { + status: recipientMatched ? 'queued_unconfirmed' : 'recipient_mismatch', + mode, + requestedRecipient, + resolvedRecipient, + recipientMatched, + readConfirmed: false, + note, + }, + }; +} + +export function messageReadersReceipt(readers: unknown[]): { + readers: unknown[]; + delivery: { status: 'read' | 'queued_or_unread'; readConfirmed: boolean; signal: string }; +} { + const readConfirmed = readers.length > 0; + return { + readers, + delivery: { + status: readConfirmed ? 'read' : 'queued_or_unread', + readConfirmed, + signal: readConfirmed + ? 'At least one agent has read this message.' + : 'No agent has read this message. A send receipt confirms enqueue only; the recipient may still be busy or offline.', + }, + }; +} diff --git a/packages/cli/src/cli/mcp/messaging-tools.delivery.test.ts b/packages/cli/src/cli/mcp/messaging-tools.delivery.test.ts new file mode 100644 index 000000000..53441fe9b --- /dev/null +++ b/packages/cli/src/cli/mcp/messaging-tools.delivery.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; + +import { directMessageReceipt, messageReadersReceipt } from '../lib/message-delivery-receipts.js'; + +describe('direct message delivery receipts', () => { + it('labels default wait-mode sends as queued and preserves the exact requested recipient', () => { + const receipt = directMessageReceipt( + { id: 'msg_wait', text: 'status', agentName: 'sender' }, + 'chief-khaliq' + ); + + expect(receipt).toMatchObject({ + id: 'msg_wait', + target: { kind: 'agent', agentName: 'chief-khaliq' }, + delivery: { + status: 'queued_unconfirmed', + mode: 'wait', + requestedRecipient: 'chief-khaliq', + resolvedRecipient: 'chief-khaliq', + recipientMatched: true, + readConfirmed: false, + }, + }); + }); + + it('labels steer-mode sends as immediate injection requests without claiming delivery', () => { + const receipt = directMessageReceipt( + { id: 'msg_steer', text: 'urgent', agentName: 'sender' }, + 'busy-worker', + 'steer' + ); + + expect(receipt.delivery).toMatchObject({ + status: 'queued_unconfirmed', + mode: 'steer', + requestedRecipient: 'busy-worker', + resolvedRecipient: 'busy-worker', + readConfirmed: false, + }); + expect(receipt.delivery.note).toContain('immediate injection'); + }); + + it('fails the recipient-match signal when the send response names a different agent', () => { + const receipt = directMessageReceipt( + { + id: 'msg_misdirected', + target: { kind: 'agent', agentName: 'chief' }, + }, + 'chief-khaliq' + ); + + expect(receipt).toMatchObject({ + target: { kind: 'agent', agentName: 'chief' }, + delivery: { + status: 'recipient_mismatch', + requestedRecipient: 'chief-khaliq', + resolvedRecipient: 'chief', + recipientMatched: false, + }, + }); + expect(receipt.delivery.note).toContain('Recipient mismatch'); + }); + + it('surfaces an explicit signal when no recipient has consumed the message', () => { + expect(messageReadersReceipt([])).toEqual({ + readers: [], + delivery: { + status: 'queued_or_unread', + readConfirmed: false, + signal: + 'No agent has read this message. A send receipt confirms enqueue only; the recipient may still be busy or offline.', + }, + }); + }); + + it('reports read only when the reader list is non-empty', () => { + const readers = [{ agentName: 'busy-worker', readAt: '2026-08-08T20:00:00Z' }]; + + expect(messageReadersReceipt(readers)).toEqual({ + readers, + delivery: { + status: 'read', + readConfirmed: true, + signal: 'At least one agent has read this message.', + }, + }); + }); +}); diff --git a/packages/cli/src/cli/mcp/messaging-tools.protocol.test.ts b/packages/cli/src/cli/mcp/messaging-tools.protocol.test.ts new file mode 100644 index 000000000..5525a38c7 --- /dev/null +++ b/packages/cli/src/cli/mcp/messaging-tools.protocol.test.ts @@ -0,0 +1,51 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { describe, expect, it, vi } from 'vitest'; + +import { registerMessagingTools } from './messaging-tools.js'; + +describe('messaging delivery receipts over MCP', () => { + it('exposes enqueue state on send and an explicit signal for an empty reader list', async () => { + const dm = vi.fn(async () => ({ id: 'msg_1', text: 'hello' })); + const readers = vi.fn(async () => []); + const server = new McpServer({ name: 'messaging-test', version: '1.0.0' }); + registerMessagingTools(server, () => ({ dm, readers }) as never); + + const client = new Client({ name: 'messaging-client-test', version: '1.0.0' }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + + const sent = await client.callTool({ + name: 'send_dm', + arguments: { to: 'chief-khaliq', text: 'hello' }, + }); + expect(sent.structuredContent).toMatchObject({ + id: 'msg_1', + target: { kind: 'agent', agentName: 'chief-khaliq' }, + delivery: { + status: 'queued_unconfirmed', + mode: 'wait', + requestedRecipient: 'chief-khaliq', + resolvedRecipient: 'chief-khaliq', + readConfirmed: false, + }, + }); + + const unread = await client.callTool({ + name: 'get_message_readers', + arguments: { message_id: 'msg_1' }, + }); + expect(unread.structuredContent).toMatchObject({ + readers: [], + delivery: { status: 'queued_or_unread', readConfirmed: false }, + }); + } finally { + await client.close(); + await server.close(); + } + }); +}); diff --git a/packages/cli/src/cli/mcp/messaging-tools.ts b/packages/cli/src/cli/mcp/messaging-tools.ts index 5eec8a7f5..228e46a0a 100644 --- a/packages/cli/src/cli/mcp/messaging-tools.ts +++ b/packages/cli/src/cli/mcp/messaging-tools.ts @@ -1,10 +1,33 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; +import { directMessageReceipt, messageReadersReceipt } from '../lib/message-delivery-receipts.js'; import { jsonContent, jsonResult, textContent } from './tool-results.js'; import { identityOverrideInputShape, messageResult } from './tool-shapes.js'; import type { AgentClientLike } from './types.js'; +const directMessageResult = z.looseObject({ + target: z.object({ kind: z.literal('agent'), agentName: z.string() }), + delivery: z.object({ + status: z.enum(['queued_unconfirmed', 'recipient_mismatch']), + mode: z.enum(['wait', 'steer']), + requestedRecipient: z.string(), + resolvedRecipient: z.string(), + recipientMatched: z.boolean(), + readConfirmed: z.literal(false), + note: z.string(), + }), +}); + +const messageReadersResult = { + readers: z.array(z.looseObject({})).describe('Readers'), + delivery: z.object({ + status: z.enum(['read', 'queued_or_unread']), + readConfirmed: z.boolean(), + signal: z.string(), + }), +}; + function resolveEmoji(input: string): string { const normalized = input.trim().replace(/^:/, '').replace(/:$/, '').toLowerCase(); const aliases: Record = { @@ -187,7 +210,12 @@ export function registerMessagingTools( channel: z.string().describe('Channel name'), text: z.string().describe('Message text'), attachments: z.array(z.string()).optional().describe('File attachment IDs'), - mode: z.enum(['wait', 'steer']).optional().describe('Delivery mode'), + mode: z + .enum(['wait', 'steer']) + .optional() + .describe( + 'wait (default): queue delivery until each recipient reaches a safe idle boundary; steer: request immediate injection, which may interrupt active work.' + ), ...identityOverrideInputShape, }, outputSchema: jsonResult, @@ -275,15 +303,23 @@ export function registerMessagingTools( title: 'Send Direct Message', description: 'Send a private direct message visible only to the recipient and this agent. ' + - 'Returns the created message record, including its message ID.', + 'Returns the created message and an explicit queued/unconfirmed delivery receipt. ' + + 'A message ID confirms enqueue, not injection or reading; use "get_message_readers" to confirm consumption. ' + + 'Mode "wait" (the default) waits for the recipient\'s next safe idle boundary and can remain unread while they are busy. ' + + 'Mode "steer" requests immediate injection and may interrupt active work.', inputSchema: { to: z.string().describe('Recipient agent name'), text: z.string().describe('DM text'), - mode: z.enum(['wait', 'steer']).optional().describe('Delivery mode'), + mode: z + .enum(['wait', 'steer']) + .optional() + .describe( + 'wait (default): queue until the recipient reaches a safe idle boundary; steer: request immediate injection, which may interrupt active work. Both modes return before reading is confirmed.' + ), attachments: z.array(z.string()).optional().describe('File attachment IDs'), ...identityOverrideInputShape, }, - outputSchema: jsonResult, + outputSchema: directMessageResult, annotations: { readOnlyHint: false, destructiveHint: false, @@ -291,8 +327,10 @@ export function registerMessagingTools( openWorldHint: true, }, }, - async ({ to, text, mode, attachments, as }) => - jsonContent(await getAgentClient(as).dm(to, text, { mode, attachments })) + async ({ to, text, mode, attachments, as }) => { + const message = await getAgentClient(as).dm(to, text, { mode, attachments }); + return jsonContent(directMessageReceipt(message, to, mode)); + } ); server.registerTool( @@ -453,16 +491,15 @@ export function registerMessagingTools( title: 'Get Readers', description: 'Check which agents have read a message, to confirm delivery before acting on silence. ' + - 'Returns a `readers` array of the agents that have read it; an empty array means nobody has.', + 'Returns a `readers` array plus an explicit delivery signal. An empty array is reported as queued-or-unread: nobody has consumed the message yet, even if the recipient is live.', inputSchema: { message_id: z.string().describe('Message ID'), ...identityOverrideInputShape, }, - outputSchema: { - readers: z.array(z.looseObject({})).describe('Readers'), - }, + outputSchema: messageReadersResult, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, }, - async ({ message_id, as }) => jsonContent({ readers: await getAgentClient(as).readers(message_id) }) + async ({ message_id, as }) => + jsonContent(messageReadersReceipt(await getAgentClient(as).readers(message_id))) ); } diff --git a/packages/sdk-py/README.md b/packages/sdk-py/README.md index bfe8f627b..eb5f383e9 100644 --- a/packages/sdk-py/README.md +++ b/packages/sdk-py/README.md @@ -155,7 +155,10 @@ human = relay.system() await human.send_message( to="Agent1", text="Please start the analysis", - mode="wait", # or "steer" + # wait (default) queues for the next safe idle boundary and can remain + # unread while the recipient is busy. steer requests immediate injection + # and may interrupt active work. Send success confirms enqueue only. + mode="wait", ) ``` diff --git a/packages/sdk/src/__tests__/messaging.test.ts b/packages/sdk/src/__tests__/messaging.test.ts index f7f01a884..8ae3765d1 100644 --- a/packages/sdk/src/__tests__/messaging.test.ts +++ b/packages/sdk/src/__tests__/messaging.test.ts @@ -522,6 +522,7 @@ describe('RelaycastMessagingClient', () => { kind: 'dm', conversationId: 'dm-1', createdAt: '2026-05-27T11:20:00.000Z', + target: { kind: 'agent', agentName: 'Lead' }, }); const groupDirect = await client.messages.groupDirect({ diff --git a/packages/sdk/src/messaging/normalize.ts b/packages/sdk/src/messaging/normalize.ts index 96a3703e1..314d87b3b 100644 --- a/packages/sdk/src/messaging/normalize.ts +++ b/packages/sdk/src/messaging/normalize.ts @@ -401,6 +401,7 @@ export function normalizeReaction(input: unknown): RelayMessageReaction { interface MessageContext { kind?: RelayMessageKind; + agentName?: string; channelId?: string; channelName?: string; conversationId?: string; @@ -426,6 +427,7 @@ export function normalizeMessage(input: unknown, context: MessageContext = {}): kind, text: message.text ?? message.body ?? '', from: compact({ id: opt(message.agent_id), name: opt(message.agent_name) }), + target: context.agentName ? { kind: 'agent', agentName: context.agentName } : undefined, channel: channelId || channelName ? compact({ id: opt(channelId), name: opt(channelName) }) diff --git a/packages/sdk/src/messaging/relaycast.ts b/packages/sdk/src/messaging/relaycast.ts index 8354f41ec..1fb7a7f66 100644 --- a/packages/sdk/src/messaging/relaycast.ts +++ b/packages/sdk/src/messaging/relaycast.ts @@ -285,7 +285,7 @@ export class RelaycastMessagingClient implements RelayMessagingClient { idempotencyKey: input.idempotencyKey, }) ); - return this.normalizeDirectResponse(response, 'dm'); + return this.normalizeDirectResponse(response, 'dm', undefined, input.to); }, groupDirect: async (input: RelaySendGroupDirectMessageInput): Promise => { const agent = this.requireAgentClient('messages.groupDirect'); @@ -1074,7 +1074,8 @@ export class RelaycastMessagingClient implements RelayMessagingClient { private normalizeDirectResponse( input: unknown, kind: 'dm' | 'group_dm', - conversationId?: string + conversationId?: string, + agentName?: string ): RelayMessage { const record = input !== null && typeof input === 'object' && !Array.isArray(input) @@ -1096,6 +1097,7 @@ export class RelaycastMessagingClient implements RelayMessagingClient { return normalizeMessage(record.message, { kind, + agentName, conversationId: resolvedConversationId, createdAt, }); diff --git a/packages/sdk/src/messaging/types.ts b/packages/sdk/src/messaging/types.ts index b1bf6cadf..6f329d99a 100644 --- a/packages/sdk/src/messaging/types.ts +++ b/packages/sdk/src/messaging/types.ts @@ -18,6 +18,13 @@ export type RelayAgentType = wire.AgentType; /** Canonical agent statuses plus the relay-only `unknown` fallback. */ export type RelayAgentStatus = wire.AgentStatus | 'unknown'; export type RelayChannelMemberRole = wire.ChannelMemberInfo['role']; +/** + * Message injection policy. `wait` (the default when omitted) queues for the + * recipient's next safe idle boundary and can remain unread while they are + * busy. `steer` requests immediate injection and may interrupt active work. + * A successful send confirms enqueue only; use read receipts to confirm + * consumption. + */ export type RelayMessageMode = wire.MessageInjectionMode; export type RelayMessageKind = 'channel' | 'dm' | 'group_dm' | 'thread_reply' | 'unknown'; From 8b768d013561eb648f3887f71fca312fd9dc28d8 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sat, 8 Aug 2026 23:34:37 +0200 Subject: [PATCH 2/6] fix(cli): resolve DM recipients independently --- packages/cli/src/cli/agent-relay-mcp.ts | 5 +- packages/cli/src/cli/commands/message.ts | 9 ++- .../src/cli/lib/message-delivery-receipts.ts | 66 +++++++++++-------- .../cli/mcp/messaging-tools.delivery.test.ts | 45 ++++++++++++- .../cli/mcp/messaging-tools.protocol.test.ts | 6 +- packages/cli/src/cli/mcp/messaging-tools.ts | 21 ++++-- packages/sdk/src/__tests__/messaging.test.ts | 1 - packages/sdk/src/messaging/normalize.ts | 2 - packages/sdk/src/messaging/relaycast.ts | 6 +- 9 files changed, 112 insertions(+), 49 deletions(-) diff --git a/packages/cli/src/cli/agent-relay-mcp.ts b/packages/cli/src/cli/agent-relay-mcp.ts index ccb9cd39d..a45257f67 100644 --- a/packages/cli/src/cli/agent-relay-mcp.ts +++ b/packages/cli/src/cli/agent-relay-mcp.ts @@ -725,7 +725,10 @@ function registerAgentRelayTools( } ); - registerMessagingTools(server, getAgentClient); + registerMessagingTools(server, getAgentClient, async () => { + requireWorkspaceKey(getSession()); + return getRelay().agents.list(); + }); server.registerTool( 'add_agent', diff --git a/packages/cli/src/cli/commands/message.ts b/packages/cli/src/cli/commands/message.ts index 46a19adae..7918eb767 100644 --- a/packages/cli/src/cli/commands/message.ts +++ b/packages/cli/src/cli/commands/message.ts @@ -8,7 +8,7 @@ import { withSdkDefaults, type SdkCommandDeps, } from '../lib/sdk-command.js'; -import { directMessageReceipt } from '../lib/message-delivery-receipts.js'; +import { directMessageReceipt, resolveExactAgentName } from '../lib/message-delivery-receipts.js'; export type MessageCommandDependencies = SdkCommandDeps; @@ -128,16 +128,19 @@ export function registerMessageCommands( ).action(async (agent: string, text: string, o: Record) => { await runSdk(deps, async () => { const mode = o.mode as 'wait' | 'steer' | undefined; + const relay = deps.createAgentRelay(opts(o)); + const resolvedRecipient = resolveExactAgentName(await relay.agents.list(), agent); printJson( deps, directMessageReceipt( - await deps.createAgentRelay(opts(o)).messages.direct({ + await relay.messages.direct({ to: agent, text, ...(mode ? { mode } : {}), }), agent, - mode + mode, + resolvedRecipient ) ); }); diff --git a/packages/cli/src/cli/lib/message-delivery-receipts.ts b/packages/cli/src/cli/lib/message-delivery-receipts.ts index 267327804..0b9597fb1 100644 --- a/packages/cli/src/cli/lib/message-delivery-receipts.ts +++ b/packages/cli/src/cli/lib/message-delivery-receipts.ts @@ -1,13 +1,13 @@ export type DirectMessageMode = 'wait' | 'steer'; export type DirectMessageDeliveryReceipt = Record & { - target: { kind: 'agent'; agentName: string }; + target?: { kind: 'agent'; agentName: string }; delivery: { - status: 'queued_unconfirmed' | 'recipient_mismatch'; + status: 'queued_unconfirmed' | 'recipient_mismatch' | 'recipient_unresolved'; mode: DirectMessageMode; requestedRecipient: string; - resolvedRecipient: string; - recipientMatched: boolean; + resolvedRecipient: string | null; + recipientMatched: boolean | null; readConfirmed: false; note: string; }; @@ -19,46 +19,58 @@ function asRecord(value: unknown): Record { : { value }; } -function resolvedDirectRecipient(message: Record, requestedRecipient: string): string { - const target = asRecord(message.target); - const targetName = target.agentName ?? target.agent_name; - if (typeof targetName === 'string' && targetName.length > 0) return targetName; - - const recipient = asRecord(message.recipient); - const recipientName = recipient.agentName ?? recipient.agent_name ?? recipient.name; - if (typeof recipientName === 'string' && recipientName.length > 0) return recipientName; - - const directName = message.recipientName ?? message.recipient_name ?? message.to; - return typeof directName === 'string' && directName.length > 0 ? directName : requestedRecipient; +/** Resolve only a full, exact agent name; never fall back to a prefix. */ +export function resolveExactAgentName(agents: readonly unknown[], requestedRecipient: string): string { + const resolvedRecipient = agents + .map((agent) => { + const name = asRecord(agent).name; + return typeof name === 'string' ? name : undefined; + }) + .find((name) => name === requestedRecipient); + if (!resolvedRecipient) { + throw new Error(`Recipient "${requestedRecipient}" was not found by exact agent-name match.`); + } + return resolvedRecipient; } /** * Add the delivery facts that Relaycast's create-message response does not * contain. A message id confirms durable enqueue only; delivery/read - * confirmation remains observable through get_message_readers. + * confirmation remains observable through get_message_readers. The resolved + * recipient must come from an independent directory lookup; the created + * message's target may only echo the request and is deliberately not trusted. */ export function directMessageReceipt( value: unknown, requestedRecipient: string, - mode: DirectMessageMode = 'wait' + mode: DirectMessageMode = 'wait', + resolvedRecipient?: string ): DirectMessageDeliveryReceipt { const message = asRecord(value); - const resolvedRecipient = resolvedDirectRecipient(message, requestedRecipient); - const recipientMatched = resolvedRecipient === requestedRecipient; - const note = recipientMatched - ? mode === 'steer' - ? 'Queued as an immediate injection request that may interrupt active work. This receipt does not confirm delivery or reading; call get_message_readers with the message id.' - : "Queued for injection at the recipient's next safe idle boundary. It can remain unread while the recipient is busy. This receipt does not confirm delivery or reading; call get_message_readers with the message id." - : `Recipient mismatch: requested ${requestedRecipient}, but the send response resolved ${resolvedRecipient}.`; + const recipientMatched = resolvedRecipient ? resolvedRecipient === requestedRecipient : null; + const status = + recipientMatched === null + ? 'recipient_unresolved' + : recipientMatched + ? 'queued_unconfirmed' + : 'recipient_mismatch'; + const note = + recipientMatched === null + ? `Recipient resolution was unavailable for ${requestedRecipient}; enqueue is not reported as successful delivery.` + : recipientMatched + ? mode === 'steer' + ? 'Queued as an immediate injection request that may interrupt active work. This receipt does not confirm delivery or reading; call get_message_readers with the message id.' + : "Queued for injection at the recipient's next safe idle boundary. It can remain unread while the recipient is busy. This receipt does not confirm delivery or reading; call get_message_readers with the message id." + : `Recipient mismatch: requested ${requestedRecipient}, but the directory resolved ${resolvedRecipient}.`; return { ...message, - target: { kind: 'agent', agentName: resolvedRecipient }, + ...(resolvedRecipient ? { target: { kind: 'agent' as const, agentName: resolvedRecipient } } : {}), delivery: { - status: recipientMatched ? 'queued_unconfirmed' : 'recipient_mismatch', + status, mode, requestedRecipient, - resolvedRecipient, + resolvedRecipient: resolvedRecipient ?? null, recipientMatched, readConfirmed: false, note, diff --git a/packages/cli/src/cli/mcp/messaging-tools.delivery.test.ts b/packages/cli/src/cli/mcp/messaging-tools.delivery.test.ts index 53441fe9b..bba4b1415 100644 --- a/packages/cli/src/cli/mcp/messaging-tools.delivery.test.ts +++ b/packages/cli/src/cli/mcp/messaging-tools.delivery.test.ts @@ -1,11 +1,35 @@ import { describe, expect, it } from 'vitest'; -import { directMessageReceipt, messageReadersReceipt } from '../lib/message-delivery-receipts.js'; +import { + directMessageReceipt, + messageReadersReceipt, + resolveExactAgentName, +} from '../lib/message-delivery-receipts.js'; + +describe('exact agent-name resolution', () => { + it('chooses the full hyphenated name instead of an existing strict prefix', () => { + expect(resolveExactAgentName([{ name: 'chief' }, { name: 'chief-khaliq' }], 'chief-khaliq')).toBe( + 'chief-khaliq' + ); + }); + + it('resolves an exact prefix-name request to that agent', () => { + expect(resolveExactAgentName([{ name: 'chief' }, { name: 'chief-khaliq' }], 'chief')).toBe('chief'); + }); + + it('fails visibly when there is no exact match', () => { + expect(() => resolveExactAgentName([{ name: 'chief' }], 'chief-missing')).toThrow( + 'Recipient "chief-missing" was not found by exact agent-name match.' + ); + }); +}); describe('direct message delivery receipts', () => { it('labels default wait-mode sends as queued and preserves the exact requested recipient', () => { const receipt = directMessageReceipt( { id: 'msg_wait', text: 'status', agentName: 'sender' }, + 'chief-khaliq', + 'wait', 'chief-khaliq' ); @@ -27,7 +51,8 @@ describe('direct message delivery receipts', () => { const receipt = directMessageReceipt( { id: 'msg_steer', text: 'urgent', agentName: 'sender' }, 'busy-worker', - 'steer' + 'steer', + 'busy-worker' ); expect(receipt.delivery).toMatchObject({ @@ -46,7 +71,9 @@ describe('direct message delivery receipts', () => { id: 'msg_misdirected', target: { kind: 'agent', agentName: 'chief' }, }, - 'chief-khaliq' + 'chief-khaliq', + 'wait', + 'chief' ); expect(receipt).toMatchObject({ @@ -61,6 +88,18 @@ describe('direct message delivery receipts', () => { expect(receipt.delivery.note).toContain('Recipient mismatch'); }); + it('does not present the request as independently resolved when directory lookup is unavailable', () => { + const receipt = directMessageReceipt({ id: 'msg_unresolved' }, 'chief-khaliq'); + + expect(receipt.delivery).toMatchObject({ + status: 'recipient_unresolved', + requestedRecipient: 'chief-khaliq', + resolvedRecipient: null, + recipientMatched: null, + }); + expect(receipt.target).toBeUndefined(); + }); + it('surfaces an explicit signal when no recipient has consumed the message', () => { expect(messageReadersReceipt([])).toEqual({ readers: [], diff --git a/packages/cli/src/cli/mcp/messaging-tools.protocol.test.ts b/packages/cli/src/cli/mcp/messaging-tools.protocol.test.ts index 5525a38c7..bbe6e320a 100644 --- a/packages/cli/src/cli/mcp/messaging-tools.protocol.test.ts +++ b/packages/cli/src/cli/mcp/messaging-tools.protocol.test.ts @@ -10,7 +10,11 @@ describe('messaging delivery receipts over MCP', () => { const dm = vi.fn(async () => ({ id: 'msg_1', text: 'hello' })); const readers = vi.fn(async () => []); const server = new McpServer({ name: 'messaging-test', version: '1.0.0' }); - registerMessagingTools(server, () => ({ dm, readers }) as never); + registerMessagingTools( + server, + () => ({ dm, readers }) as never, + async () => [{ name: 'chief' }, { name: 'chief-khaliq' }] + ); const client = new Client({ name: 'messaging-client-test', version: '1.0.0' }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); diff --git a/packages/cli/src/cli/mcp/messaging-tools.ts b/packages/cli/src/cli/mcp/messaging-tools.ts index 228e46a0a..b69528960 100644 --- a/packages/cli/src/cli/mcp/messaging-tools.ts +++ b/packages/cli/src/cli/mcp/messaging-tools.ts @@ -1,19 +1,23 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; -import { directMessageReceipt, messageReadersReceipt } from '../lib/message-delivery-receipts.js'; +import { + directMessageReceipt, + messageReadersReceipt, + resolveExactAgentName, +} from '../lib/message-delivery-receipts.js'; import { jsonContent, jsonResult, textContent } from './tool-results.js'; import { identityOverrideInputShape, messageResult } from './tool-shapes.js'; import type { AgentClientLike } from './types.js'; const directMessageResult = z.looseObject({ - target: z.object({ kind: z.literal('agent'), agentName: z.string() }), + target: z.object({ kind: z.literal('agent'), agentName: z.string() }).optional(), delivery: z.object({ - status: z.enum(['queued_unconfirmed', 'recipient_mismatch']), + status: z.enum(['queued_unconfirmed', 'recipient_mismatch', 'recipient_unresolved']), mode: z.enum(['wait', 'steer']), requestedRecipient: z.string(), - resolvedRecipient: z.string(), - recipientMatched: z.boolean(), + resolvedRecipient: z.string().nullable(), + recipientMatched: z.boolean().nullable(), readConfirmed: z.literal(false), note: z.string(), }), @@ -51,7 +55,8 @@ function resolveEmoji(input: string): string { */ export function registerMessagingTools( server: McpServer, - getAgentClient: (asIdentity?: string) => AgentClientLike + getAgentClient: (asIdentity?: string) => AgentClientLike, + listAgentsForRecipientResolution?: () => Promise ): void { server.registerTool( 'create_channel', @@ -328,8 +333,10 @@ export function registerMessagingTools( }, }, async ({ to, text, mode, attachments, as }) => { + const agents = await listAgentsForRecipientResolution?.(); + const resolvedRecipient = agents ? resolveExactAgentName(agents, to) : undefined; const message = await getAgentClient(as).dm(to, text, { mode, attachments }); - return jsonContent(directMessageReceipt(message, to, mode)); + return jsonContent(directMessageReceipt(message, to, mode, resolvedRecipient)); } ); diff --git a/packages/sdk/src/__tests__/messaging.test.ts b/packages/sdk/src/__tests__/messaging.test.ts index 8ae3765d1..f7f01a884 100644 --- a/packages/sdk/src/__tests__/messaging.test.ts +++ b/packages/sdk/src/__tests__/messaging.test.ts @@ -522,7 +522,6 @@ describe('RelaycastMessagingClient', () => { kind: 'dm', conversationId: 'dm-1', createdAt: '2026-05-27T11:20:00.000Z', - target: { kind: 'agent', agentName: 'Lead' }, }); const groupDirect = await client.messages.groupDirect({ diff --git a/packages/sdk/src/messaging/normalize.ts b/packages/sdk/src/messaging/normalize.ts index 314d87b3b..96a3703e1 100644 --- a/packages/sdk/src/messaging/normalize.ts +++ b/packages/sdk/src/messaging/normalize.ts @@ -401,7 +401,6 @@ export function normalizeReaction(input: unknown): RelayMessageReaction { interface MessageContext { kind?: RelayMessageKind; - agentName?: string; channelId?: string; channelName?: string; conversationId?: string; @@ -427,7 +426,6 @@ export function normalizeMessage(input: unknown, context: MessageContext = {}): kind, text: message.text ?? message.body ?? '', from: compact({ id: opt(message.agent_id), name: opt(message.agent_name) }), - target: context.agentName ? { kind: 'agent', agentName: context.agentName } : undefined, channel: channelId || channelName ? compact({ id: opt(channelId), name: opt(channelName) }) diff --git a/packages/sdk/src/messaging/relaycast.ts b/packages/sdk/src/messaging/relaycast.ts index 1fb7a7f66..8354f41ec 100644 --- a/packages/sdk/src/messaging/relaycast.ts +++ b/packages/sdk/src/messaging/relaycast.ts @@ -285,7 +285,7 @@ export class RelaycastMessagingClient implements RelayMessagingClient { idempotencyKey: input.idempotencyKey, }) ); - return this.normalizeDirectResponse(response, 'dm', undefined, input.to); + return this.normalizeDirectResponse(response, 'dm'); }, groupDirect: async (input: RelaySendGroupDirectMessageInput): Promise => { const agent = this.requireAgentClient('messages.groupDirect'); @@ -1074,8 +1074,7 @@ export class RelaycastMessagingClient implements RelayMessagingClient { private normalizeDirectResponse( input: unknown, kind: 'dm' | 'group_dm', - conversationId?: string, - agentName?: string + conversationId?: string ): RelayMessage { const record = input !== null && typeof input === 'object' && !Array.isArray(input) @@ -1097,7 +1096,6 @@ export class RelaycastMessagingClient implements RelayMessagingClient { return normalizeMessage(record.message, { kind, - agentName, conversationId: resolvedConversationId, createdAt, }); From 875e0996998725b5ea49c4c5c9bc90d612ac6321 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sat, 8 Aug 2026 23:47:16 +0200 Subject: [PATCH 3/6] fix(cli): harden DM delivery receipts --- CHANGELOG.md | 4 +++- packages/cli/src/cli/commands/message.ts | 11 +++++++++-- .../cli/src/cli/commands/relaycast-groups.test.ts | 9 +++++++++ packages/cli/src/cli/lib/message-delivery-receipts.ts | 4 +++- .../cli/src/cli/mcp/messaging-tools.delivery.test.ts | 8 +++++++- 5 files changed, 31 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6a0c0110..ab8055555 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- `send_dm` and `agent-relay message dm send` distinguish durable enqueue from recipient delivery: receipts name the exact requested/resolved recipient and report queued-unconfirmed state, empty reader lists surface a queued-or-unread signal, and `wait` versus `steer` semantics are documented at the choice point. +- `send_dm` and `agent-relay message dm send` receipts name the exact requested and resolved recipients and report unconfirmed enqueue without claiming delivery. +- `get_message_readers` and `agent-relay message inbox get_readers` surface a queued-or-unread signal for an empty reader list. +- `send_dm` mode docs and `agent-relay message dm send --mode` help explain that `wait` injects on idle while `steer` injects immediately and may interrupt active work. - Fleet brokers now periodically renew authoritative worker inventory, including empty snapshots that clear stale server entries, so quiet agents remain active in Relaycast instead of aging offline while their node is healthy. ## [11.6.0] - 2026-08-13 diff --git a/packages/cli/src/cli/commands/message.ts b/packages/cli/src/cli/commands/message.ts index 7918eb767..813e3640d 100644 --- a/packages/cli/src/cli/commands/message.ts +++ b/packages/cli/src/cli/commands/message.ts @@ -8,7 +8,11 @@ import { withSdkDefaults, type SdkCommandDeps, } from '../lib/sdk-command.js'; -import { directMessageReceipt, resolveExactAgentName } from '../lib/message-delivery-receipts.js'; +import { + directMessageReceipt, + messageReadersReceipt, + resolveExactAgentName, +} from '../lib/message-delivery-receipts.js'; export type MessageCommandDependencies = SdkCommandDeps; @@ -241,7 +245,10 @@ export function registerMessageCommands( .argument('', 'Message id') ).action(async (messageId: string, o: Record) => { await runSdk(deps, async () => { - printJson(deps, await deps.createAgentRelay(opts(o)).messages.readers(messageId)); + printJson( + deps, + messageReadersReceipt(await deps.createAgentRelay(opts(o)).messages.readers(messageId)) + ); }); }); diff --git a/packages/cli/src/cli/commands/relaycast-groups.test.ts b/packages/cli/src/cli/commands/relaycast-groups.test.ts index ceb5690c0..11ebded44 100644 --- a/packages/cli/src/cli/commands/relaycast-groups.test.ts +++ b/packages/cli/src/cli/commands/relaycast-groups.test.ts @@ -58,6 +58,7 @@ function createRelayMock() { messages: { send: vi.fn(async (i: unknown) => ({ id: 'm1', ...(i as object) })), direct: vi.fn(async (i: unknown) => ({ id: 'd1', ...(i as object) })), + readers: vi.fn(async () => []), react: vi.fn(async () => ({ emoji: 'eyes', count: 1, agents: [] })), }, integrations: { @@ -166,6 +167,14 @@ describe('SDK-backed CLI groups', () => { expect(log).toHaveBeenCalledWith(expect.stringContaining('immediate injection')); }); + it('message inbox get_readers signals that an empty reader list is still queued or unread', async () => { + const { program, relay, log } = harness(registerMessageCommands); + await program.parseAsync(['message', 'inbox', 'get_readers', 'd1'], { from: 'user' }); + expect(relay.messages.readers).toHaveBeenCalledWith('d1'); + expect(log).toHaveBeenCalledWith(expect.stringContaining('"status": "queued_or_unread"')); + expect(log).toHaveBeenCalledWith(expect.stringContaining('"readConfirmed": false')); + }); + it('integration webhook create routes to integrations.webhooks.create', async () => { const { program, relay } = harness(registerIntegrationCommands); await program.parseAsync( diff --git a/packages/cli/src/cli/lib/message-delivery-receipts.ts b/packages/cli/src/cli/lib/message-delivery-receipts.ts index 0b9597fb1..5da057179 100644 --- a/packages/cli/src/cli/lib/message-delivery-receipts.ts +++ b/packages/cli/src/cli/lib/message-delivery-receipts.ts @@ -47,6 +47,8 @@ export function directMessageReceipt( resolvedRecipient?: string ): DirectMessageDeliveryReceipt { const message = asRecord(value); + const messageWithoutUntrustedTarget = { ...message }; + delete messageWithoutUntrustedTarget.target; const recipientMatched = resolvedRecipient ? resolvedRecipient === requestedRecipient : null; const status = recipientMatched === null @@ -64,7 +66,7 @@ export function directMessageReceipt( : `Recipient mismatch: requested ${requestedRecipient}, but the directory resolved ${resolvedRecipient}.`; return { - ...message, + ...messageWithoutUntrustedTarget, ...(resolvedRecipient ? { target: { kind: 'agent' as const, agentName: resolvedRecipient } } : {}), delivery: { status, diff --git a/packages/cli/src/cli/mcp/messaging-tools.delivery.test.ts b/packages/cli/src/cli/mcp/messaging-tools.delivery.test.ts index bba4b1415..33225c986 100644 --- a/packages/cli/src/cli/mcp/messaging-tools.delivery.test.ts +++ b/packages/cli/src/cli/mcp/messaging-tools.delivery.test.ts @@ -89,7 +89,13 @@ describe('direct message delivery receipts', () => { }); it('does not present the request as independently resolved when directory lookup is unavailable', () => { - const receipt = directMessageReceipt({ id: 'msg_unresolved' }, 'chief-khaliq'); + const receipt = directMessageReceipt( + { + id: 'msg_unresolved', + target: { kind: 'agent', agentName: 'chief-khaliq' }, + }, + 'chief-khaliq' + ); expect(receipt.delivery).toMatchObject({ status: 'recipient_unresolved', From 7b850af7eaee6bcc8982e683fac66e5033ca6b41 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sat, 8 Aug 2026 23:56:56 +0200 Subject: [PATCH 4/6] docs(changelog): name DM receipt interfaces --- CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab8055555..0268944c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,9 +18,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- `send_dm` and `agent-relay message dm send` receipts name the exact requested and resolved recipients and report unconfirmed enqueue without claiming delivery. -- `get_message_readers` and `agent-relay message inbox get_readers` surface a queued-or-unread signal for an empty reader list. -- `send_dm` mode docs and `agent-relay message dm send --mode` help explain that `wait` injects on idle while `steer` injects immediately and may interrupt active work. +- `send_dm` and `agent-relay message dm send` receipts now name the exact requested and resolved recipients and report unconfirmed enqueue without claiming delivery. +- `get_message_readers` and `agent-relay message inbox get_readers` now surface a queued-or-unread signal for an empty reader list. +- `send_dm` mode docs and `agent-relay message dm send --mode` help now explain that `wait` injects on idle while `steer` injects immediately and may interrupt active work. - Fleet brokers now periodically renew authoritative worker inventory, including empty snapshots that clear stale server entries, so quiet agents remain active in Relaycast instead of aging offline while their node is healthy. ## [11.6.0] - 2026-08-13 From a76156ddd10c7e1c800972b93ed0d245a0ea5e8b Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Fri, 14 Aug 2026 00:05:02 +0200 Subject: [PATCH 5/6] fix(cli): preserve DM fallback and spawn deadlines --- CHANGELOG.md | 3 +- .../src/cli/agent-relay-mcp.startup.test.ts | 66 ++++++ packages/cli/src/cli/agent-relay-mcp.ts | 200 +++++++++++++----- .../src/cli/commands/relaycast-groups.test.ts | 11 + .../src/cli/lib/message-delivery-receipts.ts | 11 +- .../cli/mcp/messaging-tools.delivery.test.ts | 6 +- .../cli/mcp/messaging-tools.protocol.test.ts | 21 ++ packages/cli/src/cli/mcp/messaging-tools.ts | 2 +- 8 files changed, 253 insertions(+), 67 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0268944c1..5e96f1c1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- `send_dm` and `agent-relay message dm send` receipts now name the exact requested and resolved recipients and report unconfirmed enqueue without claiming delivery. +- `send_dm` and `agent-relay message dm send` receipts now name the exact requested and resolved recipients. +- `send_dm` and `agent-relay message dm send` now report unconfirmed enqueue without claiming delivery, including when recipient resolution is unavailable. - `get_message_readers` and `agent-relay message inbox get_readers` now surface a queued-or-unread signal for an empty reader list. - `send_dm` mode docs and `agent-relay message dm send --mode` help now explain that `wait` injects on idle while `steer` injects immediately and may interrupt active work. - Fleet brokers now periodically renew authoritative worker inventory, including empty snapshots that clear stale server entries, so quiet agents remain active in Relaycast instead of aging offline while their node is healthy. diff --git a/packages/cli/src/cli/agent-relay-mcp.startup.test.ts b/packages/cli/src/cli/agent-relay-mcp.startup.test.ts index 3c8016f03..9f6c10a44 100644 --- a/packages/cli/src/cli/agent-relay-mcp.startup.test.ts +++ b/packages/cli/src/cli/agent-relay-mcp.startup.test.ts @@ -570,6 +570,35 @@ describe('createAgentRelayMcpServer', () => { expect(promptResult.messages[0].content.text).not.toContain('workspace.create'); }); + it('sends an unresolved DM from an agent-token-only session', async () => { + const { mod, mocks } = await loadAgentRelayMcpModule(); + + mod.createAgentRelayMcpServer({ + agentToken: 'at_live_token_only', + agentName: 'TokenWorker', + }); + const server = mocks.serverInstances[0]; + const result = await server.tools.get('send_dm')?.handler({ + to: 'chief', + text: 'token-scoped send', + }); + + expect(result.structuredContent).toMatchObject({ + id: 'dm_1', + delivery: { + status: 'recipient_unresolved', + requestedRecipient: 'chief', + resolvedRecipient: null, + recipientMatched: null, + }, + }); + expect(result.structuredContent).not.toHaveProperty('target'); + const agentRelay = mocks.relayInstances.find( + (instance) => instance.config.apiKey === 'at_live_token_only' + ); + expect(agentRelay?.as).toHaveBeenCalledWith('at_live_token_only', { autoHeartbeatMs: false }); + }); + it('returns a created workspace key when local session persistence fails', async () => { const { mod, mocks } = await loadAgentRelayMcpModule(); mocks.persistWorkspaceSession.mockImplementationOnce(() => { @@ -654,6 +683,43 @@ describe('createAgentRelayMcpServer', () => { expect(mocks.agentRelayMessagingCommands.getInvocation).toHaveBeenNthCalledWith(2, 'spawn', 'inv_nested'); }); + it('times out when a persona spawn invocation lookup never settles', async () => { + vi.useFakeTimers(); + try { + const { mod, mocks } = await loadAgentRelayMcpModule(); + mocks.agentRelayMessagingCommands.getInvocation.mockImplementation( + async () => new Promise(() => undefined) + ); + + mod.createAgentRelayMcpServer({ + agentToken: 'at_live_fleet', + agentName: 'orchestrator', + }); + const server = mocks.serverInstances[0]; + const spawn = server.tools.get('spawn')?.handler({ + name: 'PersonaWorker', + persona: 'reviewer', + }); + const outcome = Promise.race([ + spawn?.then( + () => 'resolved unexpectedly', + (error: unknown) => (error instanceof Error ? error.message : String(error)) + ), + new Promise((resolve) => + setTimeout(() => resolve('still pending after the persona spawn deadline'), 130_001) + ), + ]); + + await vi.advanceTimersByTimeAsync(130_001); + + await expect(outcome).resolves.toBe( + 'Persona spawn timed out before broker registration and harness readiness.' + ); + } finally { + vi.useRealTimers(); + } + }); + it('surfaces a nested verified spawn failure from the workforce persona result', async () => { const { mod, mocks } = await loadAgentRelayMcpModule(); mocks.agentRelayMessagingCommands.invoke.mockResolvedValueOnce({ diff --git a/packages/cli/src/cli/agent-relay-mcp.ts b/packages/cli/src/cli/agent-relay-mcp.ts index a45257f67..809ebab55 100644 --- a/packages/cli/src/cli/agent-relay-mcp.ts +++ b/packages/cli/src/cli/agent-relay-mcp.ts @@ -65,6 +65,10 @@ function withExitAfterTaskInstruction(task: string): string { const PERSONA_SPAWN_TIMEOUT_MS = 130_000; const PERSONA_SPAWN_POLL_MS = 250; +const PERSONA_SPAWN_TIMEOUT_MESSAGE = + 'Persona spawn timed out before broker registration and harness readiness.'; +const PERSONA_SPAWN_SUCCESS_STATUSES = new Set(['completed', 'succeeded', 'success']); +const PERSONA_SPAWN_FAILURE_STATUSES = new Set(['failed', 'error', 'cancelled', 'canceled']); type InvocationReader = { getInvocation(name: string, invocationId: string): Promise; @@ -106,6 +110,53 @@ function nestedPersonaSpawnRef(invocation: Record): InvocationR return undefined; } +async function getInvocationBeforeDeadline( + actions: InvocationReader, + current: InvocationRef, + deadline: number +): Promise { + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) throw new Error(PERSONA_SPAWN_TIMEOUT_MESSAGE); + + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + actions.getInvocation(current.actionName, current.invocationId), + new Promise((_resolve, reject) => { + timeout = setTimeout(() => reject(new Error(PERSONA_SPAWN_TIMEOUT_MESSAGE)), remainingMs); + }), + ]); + } finally { + if (timeout) clearTimeout(timeout); + } +} + +function isInvocationAuthorizationError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return ( + isInvalidAgentTokenError(error) || + /invalid.?agent.?token|unauthori[sz]ed|forbidden/i.test(message) + ); +} + +async function pollInvocation( + actions: InvocationReader, + current: InvocationRef, + deadline: number +): Promise { + for (;;) { + try { + return await getInvocationBeforeDeadline(actions, current, deadline); + } catch (error) { + if (isInvocationAuthorizationError(error)) throw error; + if (Date.now() >= deadline) { + throw new Error(PERSONA_SPAWN_TIMEOUT_MESSAGE, { cause: error }); + } + await new Promise((resolve) => setTimeout(resolve, PERSONA_SPAWN_POLL_MS)); + } + } +} + async function waitForPersonaSpawn( actions: InvocationReader, ackValue: unknown, @@ -118,26 +169,10 @@ async function waitForPersonaSpawn( const deadline = Date.now() + timeoutMs; const followed = new Set([`${current.actionName}\u001f${current.invocationId}`]); for (;;) { - let invocation: unknown; - try { - invocation = await actions.getInvocation(current.actionName, current.invocationId); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if ( - isInvalidAgentTokenError(error) || - /invalid.?agent.?token|unauthori[sz]ed|forbidden/i.test(message) - ) { - throw error; - } - if (Date.now() >= deadline) { - throw new Error('Persona spawn timed out before broker registration and harness readiness.'); - } - await new Promise((resolve) => setTimeout(resolve, PERSONA_SPAWN_POLL_MS)); - continue; - } + const invocation = await pollInvocation(actions, current, deadline); const record = recordValue(invocation); const status = invocationText(record, 'status')?.toLowerCase(); - if (status === 'completed' || status === 'succeeded' || status === 'success') { + if (status && PERSONA_SPAWN_SUCCESS_STATUSES.has(status)) { const nested = nestedPersonaSpawnRef(record); if (nested) { const key = `${nested.actionName}\u001f${nested.invocationId}`; @@ -150,11 +185,11 @@ async function waitForPersonaSpawn( } return invocation; } - if (status === 'failed' || status === 'error' || status === 'cancelled' || status === 'canceled') { + if (status && PERSONA_SPAWN_FAILURE_STATUSES.has(status)) { throw new Error(invocationText(record, 'error') ?? `Persona spawn ${status}.`); } if (Date.now() >= deadline) { - throw new Error('Persona spawn timed out before broker registration and harness readiness.'); + throw new Error(PERSONA_SPAWN_TIMEOUT_MESSAGE); } await new Promise((resolve) => setTimeout(resolve, PERSONA_SPAWN_POLL_MS)); } @@ -492,6 +527,78 @@ export async function registerAgentWithRebind({ }; } +type SpawnToolRequest = { + name: string; + cli?: string; + persona?: string; + task?: string; + cwd?: string; + channel?: string; + channels?: string[]; + model?: string; + sessionRef?: string; + targetNode?: string; +}; + +function requireSpawnActions(client: AgentClientLike): NonNullable { + if (!client.actions) { + throw new Error('spawn requires an agent-scoped Relaycast actions client.'); + } + return client.actions; +} + +function validateSpawnRequest({ cli, persona, model, sessionRef }: SpawnToolRequest): void { + if (Boolean(cli) === Boolean(persona)) { + throw new Error('spawn requires exactly one of `cli` or `persona`.'); + } + if (persona && model) { + throw new Error('Persona harness and model come from the persona spec; omit `model`.'); + } + if (persona && sessionRef) { + throw new Error('Persona session settings come from the persona launch plan; omit `session_ref`.'); + } +} + +function buildSpawnActionInput({ + name, + cli, + persona, + task, + cwd, + channel, + channels, + model, + sessionRef, + targetNode, +}: SpawnToolRequest): Record { + const selectedChannels = channels ?? (channel ? [channel] : undefined); + return { + name, + ...(cli ? { cli } : { persona, capability: 'spawn:persona' }), + ...(task ? { task } : {}), + ...(persona && cwd ? { cwd } : {}), + ...(model ? { model } : {}), + ...(sessionRef ? { session_ref: sessionRef } : {}), + ...(targetNode ? { target_node: targetNode } : {}), + ...(selectedChannels ? { channels: selectedChannels } : {}), + }; +} + +async function invokeVerifiedPersonaSpawn( + session: SessionState, + asIdentity: string | undefined, + baseUrl: string | undefined, + actionInput: Record +): Promise { + const agentToken = asIdentity ? session.agents.get(asIdentity)?.agentToken : session.agentToken; + if (!agentToken) { + throw new Error('Persona spawn requires a registered agent identity.'); + } + const commands = new AgentRelay({ agentToken, baseUrl }).messaging.commands; + const invocation = await commands.invoke('spawn', actionInput); + return waitForPersonaSpawn(commands, invocation); +} + function registerAgentRelayTools( server: McpServer, getRelay: () => RelayCastLike, @@ -726,7 +833,7 @@ function registerAgentRelayTools( ); registerMessagingTools(server, getAgentClient, async () => { - requireWorkspaceKey(getSession()); + if (!getSession().workspaceKey) return undefined; return getRelay().agents.list(); }); @@ -830,42 +937,25 @@ function registerAgentRelayTools( }, }, async ({ name, cli, persona, task, cwd, channel, channels, model, session_ref, target_node, as }) => { - const actions = getAgentClient(as).actions; - if (!actions) { - throw new Error('spawn requires an agent-scoped Relaycast actions client.'); - } - if (Boolean(cli) === Boolean(persona)) { - throw new Error('spawn requires exactly one of `cli` or `persona`.'); - } - if (persona && model) { - throw new Error('Persona harness and model come from the persona spec; omit `model`.'); - } - if (persona && session_ref) { - throw new Error('Persona session settings come from the persona launch plan; omit `session_ref`.'); - } - const actionInput = { + const actions = requireSpawnActions(getAgentClient(as)); + const request = { name, - ...(cli ? { cli } : { persona, capability: 'spawn:persona' }), - ...(task ? { task } : {}), - ...(persona && cwd ? { cwd } : {}), - ...(model ? { model } : {}), - ...(session_ref ? { session_ref } : {}), - ...(target_node ? { target_node } : {}), - ...((channels ?? (channel ? [channel] : undefined)) ? { channels: channels ?? [channel] } : {}), + cli, + persona, + task, + cwd, + channel, + channels, + model, + sessionRef: session_ref, + targetNode: target_node, }; - if (!persona) { - return jsonContent({ invocation: await actions.invoke('spawn', actionInput) }); - } - const session = getSession(); - const agentToken = as ? session.agents.get(as)?.agentToken : session.agentToken; - if (!agentToken) { - throw new Error('Persona spawn requires a registered agent identity.'); - } - const relay = new AgentRelay({ agentToken, baseUrl }); - const invocation = await relay.messaging.commands.invoke('spawn', actionInput); - return jsonContent({ - invocation: await waitForPersonaSpawn(relay.messaging.commands, invocation), - }); + validateSpawnRequest(request); + const actionInput = buildSpawnActionInput(request); + const invocation = persona + ? await invokeVerifiedPersonaSpawn(getSession(), as, baseUrl, actionInput) + : await actions.invoke('spawn', actionInput); + return jsonContent({ invocation }); } ); diff --git a/packages/cli/src/cli/commands/relaycast-groups.test.ts b/packages/cli/src/cli/commands/relaycast-groups.test.ts index 11ebded44..518329eba 100644 --- a/packages/cli/src/cli/commands/relaycast-groups.test.ts +++ b/packages/cli/src/cli/commands/relaycast-groups.test.ts @@ -153,6 +153,17 @@ describe('SDK-backed CLI groups', () => { expect(log).toHaveBeenCalledWith(expect.stringContaining('"resolvedRecipient": "lead"')); }); + it('message dm send still enqueues when exact recipient resolution is unavailable', async () => { + const { program, relay, log } = harness(registerMessageCommands); + relay.agents.list.mockResolvedValueOnce([]); + + await program.parseAsync(['message', 'dm', 'send', 'missing-agent', 'hi'], { from: 'user' }); + + expect(relay.messages.direct).toHaveBeenCalledWith({ to: 'missing-agent', text: 'hi' }); + expect(log).toHaveBeenCalledWith(expect.stringContaining('"status": "recipient_unresolved"')); + expect(log).toHaveBeenCalledWith(expect.stringContaining('"resolvedRecipient": null')); + }); + it('message dm send exposes immediate Relay delivery', async () => { const { program, relay, log } = harness(registerMessageCommands); await program.parseAsync(['message', 'dm', 'send', 'lead', 'wake up', '--mode', 'steer'], { diff --git a/packages/cli/src/cli/lib/message-delivery-receipts.ts b/packages/cli/src/cli/lib/message-delivery-receipts.ts index 5da057179..a2a65943d 100644 --- a/packages/cli/src/cli/lib/message-delivery-receipts.ts +++ b/packages/cli/src/cli/lib/message-delivery-receipts.ts @@ -20,17 +20,16 @@ function asRecord(value: unknown): Record { } /** Resolve only a full, exact agent name; never fall back to a prefix. */ -export function resolveExactAgentName(agents: readonly unknown[], requestedRecipient: string): string { - const resolvedRecipient = agents +export function resolveExactAgentName( + agents: readonly unknown[], + requestedRecipient: string +): string | undefined { + return agents .map((agent) => { const name = asRecord(agent).name; return typeof name === 'string' ? name : undefined; }) .find((name) => name === requestedRecipient); - if (!resolvedRecipient) { - throw new Error(`Recipient "${requestedRecipient}" was not found by exact agent-name match.`); - } - return resolvedRecipient; } /** diff --git a/packages/cli/src/cli/mcp/messaging-tools.delivery.test.ts b/packages/cli/src/cli/mcp/messaging-tools.delivery.test.ts index 33225c986..484868e99 100644 --- a/packages/cli/src/cli/mcp/messaging-tools.delivery.test.ts +++ b/packages/cli/src/cli/mcp/messaging-tools.delivery.test.ts @@ -17,10 +17,8 @@ describe('exact agent-name resolution', () => { expect(resolveExactAgentName([{ name: 'chief' }, { name: 'chief-khaliq' }], 'chief')).toBe('chief'); }); - it('fails visibly when there is no exact match', () => { - expect(() => resolveExactAgentName([{ name: 'chief' }], 'chief-missing')).toThrow( - 'Recipient "chief-missing" was not found by exact agent-name match.' - ); + it('leaves the recipient unresolved when there is no exact match', () => { + expect(resolveExactAgentName([{ name: 'chief' }], 'chief-missing')).toBeUndefined(); }); }); diff --git a/packages/cli/src/cli/mcp/messaging-tools.protocol.test.ts b/packages/cli/src/cli/mcp/messaging-tools.protocol.test.ts index bbe6e320a..77aee9b3a 100644 --- a/packages/cli/src/cli/mcp/messaging-tools.protocol.test.ts +++ b/packages/cli/src/cli/mcp/messaging-tools.protocol.test.ts @@ -39,6 +39,27 @@ describe('messaging delivery receipts over MCP', () => { }, }); + const unresolved = await client.callTool({ + name: 'send_dm', + arguments: { to: 'missing-agent', text: 'still enqueue this' }, + }); + expect(unresolved.isError).not.toBe(true); + expect(unresolved.structuredContent).toMatchObject({ + id: 'msg_1', + delivery: { + status: 'recipient_unresolved', + requestedRecipient: 'missing-agent', + resolvedRecipient: null, + recipientMatched: null, + readConfirmed: false, + }, + }); + expect(unresolved.structuredContent).not.toHaveProperty('target'); + expect(dm).toHaveBeenLastCalledWith('missing-agent', 'still enqueue this', { + mode: undefined, + attachments: undefined, + }); + const unread = await client.callTool({ name: 'get_message_readers', arguments: { message_id: 'msg_1' }, diff --git a/packages/cli/src/cli/mcp/messaging-tools.ts b/packages/cli/src/cli/mcp/messaging-tools.ts index b69528960..3f03fc7b6 100644 --- a/packages/cli/src/cli/mcp/messaging-tools.ts +++ b/packages/cli/src/cli/mcp/messaging-tools.ts @@ -56,7 +56,7 @@ function resolveEmoji(input: string): string { export function registerMessagingTools( server: McpServer, getAgentClient: (asIdentity?: string) => AgentClientLike, - listAgentsForRecipientResolution?: () => Promise + listAgentsForRecipientResolution?: () => Promise ): void { server.registerTool( 'create_channel', From ccc75672eefc9f9950116b453e913b3ff8939dd2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 13 Aug 2026 22:06:06 +0000 Subject: [PATCH 6/6] style: auto-format with Prettier --- packages/cli/src/cli/agent-relay-mcp.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/cli/src/cli/agent-relay-mcp.ts b/packages/cli/src/cli/agent-relay-mcp.ts index 809ebab55..dae030715 100644 --- a/packages/cli/src/cli/agent-relay-mcp.ts +++ b/packages/cli/src/cli/agent-relay-mcp.ts @@ -133,10 +133,7 @@ async function getInvocationBeforeDeadline( function isInvocationAuthorizationError(error: unknown): boolean { const message = error instanceof Error ? error.message : String(error); - return ( - isInvalidAgentTokenError(error) || - /invalid.?agent.?token|unauthori[sz]ed|forbidden/i.test(message) - ); + return isInvalidAgentTokenError(error) || /invalid.?agent.?token|unauthori[sz]ed|forbidden/i.test(message); } async function pollInvocation(