From 38496ccbf59c24753625607de5fb883d1ea0186c Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sat, 8 Aug 2026 23:23:05 +0200 Subject: [PATCH 1/4] 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 37687938f..1eb5d2af6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- `send_dm` and `agent-relay message dm send` now 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. - `agent-relay node agent attach --mode drive` (and `--mode passthrough`) no longer floods the terminal with `input stream send failed: PTY input stream is closed` when the PTY input stream dies mid-session. The loss is now reported once, the stream is reopened with bounded backoff, and if that fails the command exits non-zero with a readable message instead of leaving a session that looks alive but accepts no input. Because attach forwards every byte except `Ctrl+C`/`Ctrl+]` while the stream is healthy, a source TUI with mouse tracking enabled could previously produce this flood from pointer movement alone, without a single keystroke; input is now dropped rather than forwarded for as long as the stream is down. - A reopened attach input stream is verified to belong to the same worker process before any keystroke is forwarded. The stream is reopened by agent name, so without this a replaced worker could silently receive input typed for the session you attached to; the check fails closed when identity cannot be established. 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 286146657..a2f07d6ae 100644 --- a/packages/sdk/src/__tests__/messaging.test.ts +++ b/packages/sdk/src/__tests__/messaging.test.ts @@ -521,6 +521,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 0f9a6201f11a179247220ee78ef43610fe207a2d Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sat, 8 Aug 2026 23:34:37 +0200 Subject: [PATCH 2/4] 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 d24af51b3..c6c53ee7d 100644 --- a/packages/cli/src/cli/agent-relay-mcp.ts +++ b/packages/cli/src/cli/agent-relay-mcp.ts @@ -620,7 +620,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 a2f07d6ae..286146657 100644 --- a/packages/sdk/src/__tests__/messaging.test.ts +++ b/packages/sdk/src/__tests__/messaging.test.ts @@ -521,7 +521,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 49f8ba3b2980a86abd982cac05aba7dab1008a71 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sat, 8 Aug 2026 23:47:16 +0200 Subject: [PATCH 3/4] 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 1eb5d2af6..7e68e3c19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- `send_dm` and `agent-relay message dm send` now 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. +- Direct-message send receipts now name the exact requested and resolved recipients and report unconfirmed enqueue without claiming delivery. +- Empty reader lists now surface a queued-or-unread signal instead of requiring callers to infer delivery state. +- Direct-message docs now explain that `wait` injects on idle while `steer` injects immediately and may interrupt active work. - `agent-relay node agent attach --mode drive` (and `--mode passthrough`) no longer floods the terminal with `input stream send failed: PTY input stream is closed` when the PTY input stream dies mid-session. The loss is now reported once, the stream is reopened with bounded backoff, and if that fails the command exits non-zero with a readable message instead of leaving a session that looks alive but accepts no input. Because attach forwards every byte except `Ctrl+C`/`Ctrl+]` while the stream is healthy, a source TUI with mouse tracking enabled could previously produce this flood from pointer movement alone, without a single keystroke; input is now dropped rather than forwarded for as long as the stream is down. - A reopened attach input stream is verified to belong to the same worker process before any keystroke is forwarded. The stream is reopened by agent name, so without this a replaced worker could silently receive input typed for the session you attached to; the check fails closed when identity cannot be established. 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 3698f0f7f6a34fae69d6cf3d81097fb1dc74b0f5 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sat, 8 Aug 2026 23:56:56 +0200 Subject: [PATCH 4/4] 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 7e68e3c19..0f2183428 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Direct-message send receipts now name the exact requested and resolved recipients and report unconfirmed enqueue without claiming delivery. -- Empty reader lists now surface a queued-or-unread signal instead of requiring callers to infer delivery state. -- Direct-message docs now 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. - `agent-relay node agent attach --mode drive` (and `--mode passthrough`) no longer floods the terminal with `input stream send failed: PTY input stream is closed` when the PTY input stream dies mid-session. The loss is now reported once, the stream is reopened with bounded backoff, and if that fails the command exits non-zero with a readable message instead of leaving a session that looks alive but accepts no input. Because attach forwards every byte except `Ctrl+C`/`Ctrl+]` while the stream is healthy, a source TUI with mouse tracking enabled could previously produce this flood from pointer movement alone, without a single keystroke; input is now dropped rather than forwarded for as long as the stream is down. - A reopened attach input stream is verified to belong to the same worker process before any keystroke is forwarded. The stream is reopened by agent name, so without this a replaced worker could silently receive input typed for the session you attached to; the check fails closed when identity cannot be established.