From b9f3d8941a4de6ad8d3c395ddb83d5f5c97ffc52 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Wed, 14 Oct 2026 10:44:51 -0400 Subject: [PATCH 01/14] fix(channels): the host posts nothing into a channel its server declared inbound-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A channel descriptor's `direction` (MCPL SPEC §14.2) can say a channel is inbound-only: its messages come in, and nothing goes out through it. The host never acted on it; only channel_list reported it. pocket-body declares its live transcript, body:ears, inbound-only and refuses publishes there, and another agent speaking in the room arrives on it addressed. So a resident's locus moved to it, the [routing] notice said her plain speech "now lands" there, routeSpeech opened the channel by delivery if she had closed it, and the server refused the publish, which came back as a bare send failure. A resident met this on Oct 13 and asked to be told before her speech went where it would be refused. The rule is one predicate in channel-registry.ts, isInboundOnly: a descriptor that declares `direction: "inbound"`. A channel declared `outbound` or `bidirectional`, or with no direction, is unaffected. ChannelRegistry.inboundOnlyServer names the declaring server (exactly, when the caller knows the server). What the host refuses to post: - routeSpeech refuses plain speech there right after the channel lookup, before the open-by-delivery and the publish. The failure reason, which the send-failure marker shows, names the server's declaration and says to use that server's own tools. - handleToolPublish (channel_publish, and publishForAgent through it) returns an error naming the declaration and publishes nothing. - streamingServerFor skips the channel, so the outgoing stream, a copy of speech the host would post, doesn't go there either. - resolveProseTarget takes { forSpeech: true } on the paths whose target speech is posted to: explicit >> admission, hybrid >>> envelopes, and proseTargetFor's reply tokens. There an inbound-only channel resolves to an error naming the declaration, so the bounce gives that reason and nothing is published. Without the option, as for reading a channel's history (resolveProseTargetDurable), any registered channel still resolves. The name-matching body moved, unchanged, to resolveTargetByName. The outgoing stream's resolver doesn't take the option, since streamingServerFor already refuses. A server's own send tools post through that server, which decides for itself, so the host's open after a successful send is left as it is, and so is channel_decline's acknowledgement (channels/acknowledge), which the server grants and represents itself. publishForAgent (the subconscious's speak_in_channel) used to trace mcpl:speech-routed before publishing, refused or not. Its traces now follow the outcome as routeSpeech's do: speech-routed after a publish the server accepted and didn't report undelivered, with routeSpeech's fields (serverId, delivered, textLen) and the surface's messageId, which connectome-host's TTS relay reads; speech-route-failed otherwise. Its result keeps channel_publish's contract. The locus still follows the conversation to the channel: sending her speech somewhere else would be wrong. What changes is what the host tells her: - the turn's [routing] notice ("The conversation is now in ."), the mid-turn re-pin notice and channel_open's `routing` field say the server declared the channel inbound-only, that plain speech there is refused and stays in the archive, and to use that server's own tools (inboundOnlySpeechNote), instead of saying it lands there. They name the server rather than a tool, since the host doesn't know which of a server's tools speaks; - the closed-channel invitation offers no ">>" reply into such a channel, and says why; joining stays available; - the locus and hybrid mode primers say plain text goes to the locus unless its server declared that channel inbound-only; - buildChannelContext names no defaultOutgoing for an inbound-only locus. routeSpeech's and resolveLocus's doc comments had drifted into a stack above sendOutgoingChunk; they now sit on their functions, and routeSpeech's lists the inbound-only refusal among its failures. Reviewed in the house by two internal reviews, sw-af-inbound (22 issues) and sw-af-inbound-b of the fix round (27 issues), every issue judged in their ledgers. Tests: - test/channel-registry-routing.test.ts: plain speech into an inbound-only channel is refused before the channel is opened or published to, with the reason; channel_publish there is refused and publishes nothing; the outgoing stream skips it; inboundOnlyServer names the declaring server and nothing for any other channel; the refusal follows the last declaration through channels/register and channels/changed updates both ways, each refusal's reason checked; publishForAgent is refused and traced as failed into an inbound-only channel, traced once as routed (whole shape) after a delivered publish, and traced as failed when the server reports undelivered; buildChannelContext names no defaultOutgoing for an inbound-only locus; an inbound-only channel resolves as a speech target to the declaration's error, still resolves for reading (live and durable), gets no reply token, and a bidirectional channel resolves either way; and a control: outbound, bidirectional and direction-less channels still take speech, channel_publish and the stream. makeRegistry's mock server records outgoing stream calls. - test/present-while-acting.test.ts: the turn's routing notice, the mid-turn re-pin notice after an addressed arrival on an inbound-only channel, and channel_open's result each say plain speech there is refused and point to the server's own tools; the notice helper asserts the whole sentence. - test/trunk-channel-routing.test.ts: the closed-channel invitation offers no reply into an inbound-only channel and says why; its stub registries gain inboundOnlyServer. - test/explicit-prose-routing.test.ts: in hybrid and explicit modes, a target refused for speech bounces with its reason and publishes nothing (the stub registry models forSpeech for #ears); the hybrid and locus primers carry the inbound-only clause. Changelog: changelog.d/inbound-only-channels.fixed.md. Co-Authored-By: Claude Opus 5.5 --- changelog.d/inbound-only-channels.fixed.md | 22 +++ src/framework.ts | 60 ++++-- src/mcpl/channel-registry.ts | 129 ++++++++++-- src/types/trace.ts | 4 +- test/channel-registry-routing.test.ts | 217 ++++++++++++++++++++- test/explicit-prose-routing.test.ts | 50 ++++- test/present-while-acting.test.ts | 111 +++++++++++ test/trunk-channel-routing.test.ts | 46 +++++ 8 files changed, 599 insertions(+), 40 deletions(-) create mode 100644 changelog.d/inbound-only-channels.fixed.md diff --git a/changelog.d/inbound-only-channels.fixed.md b/changelog.d/inbound-only-channels.fixed.md new file mode 100644 index 000000000..d520961cc --- /dev/null +++ b/changelog.d/inbound-only-channels.fixed.md @@ -0,0 +1,22 @@ +- The host no longer posts into a channel its server declared inbound-only + (`direction: "inbound"`, SPEC §14.2). pocket-body's live transcript, + `body:ears`, is such a channel, and an agent speaking in the room arrives on + it addressed. Until now that moved a resident's plain speech there, the + delivery attempt opened the channel if she had closed it, and pocket-body + refused the publish. + - Plain speech aimed there is refused before the channel is opened or + published to, and the send-failure marker names the declaration. + - A `>>` or `>>>` target naming the channel bounces with the same reason, + and the closed-channel invitation offers no reply into it. + - `channel_publish` and the subconscious's `speak_in_channel` return an + error that names it. The outgoing stream skips the channel, and the + channel context names no default outgoing channel for it. + - The routing notices say plain speech is refused there, and to use that + server's own tools. The locus and hybrid mode primers, shown when an + agent's mode changes, say plain speech goes to the locus unless its server + declared that channel inbound-only. +- `speak_in_channel`'s `mcpl:speech-routed` trace now follows a publish the + server accepted and didn't report undelivered, with the fields + `routeSpeech`'s trace has (`serverId`, `delivered`, `textLen` and the + surface's `messageId`). A refused or undelivered publish is traced as + `mcpl:speech-route-failed` instead. diff --git a/src/framework.ts b/src/framework.ts index 9877d24c3..68be97892 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -234,6 +234,19 @@ function estimateAppendedRoundTokens( return Math.ceil(chars / 4) + images * 1600 + blockCount * 20; } +/** + * What a notice says in place of "your plain speech lands there" when the + * channel's server declared it inbound-only (isInboundOnly in the channel + * registry, which posts nothing there). ChannelRegistry.inboundOnlyServer + * names the declaring server. + */ +function inboundOnlySpeechNote(serverId: string): string { + return ( + `${serverId} declared this channel inbound-only, so plain speech there is refused ` + + `and stays in your archive. To speak there, use ${serverId}'s own tools.` + ); +} + /** * Explicit delivery tools whose successful use into a closed channel OPENS * it (send implies engagement — see openIfClosedForSend). Bare tool names, @@ -284,11 +297,13 @@ const isAddressedMessage = ( function proseModePrimer(mode: 'explicit' | 'hybrid' | 'locus' | 'disabled'): string { if (mode === 'locus') { return '[prose-routing] Mode change: your plain text auto-routes to the ' + - 'conversational locus again. `>>` destination prefixes are no longer needed.'; + 'conversational locus again, unless its server declared that channel inbound-only. ' + + '`>>` destination prefixes are no longer needed.'; } if (mode === 'hybrid') { return '[prose-routing] Hybrid routing enabled: unprefixed text still lands in the ' + - 'current conversational locus. A leading `>>>destination` envelope instead routes ' + + 'current conversational locus, unless its server declared that channel inbound-only. ' + + 'A leading `>>>destination` envelope instead routes ' + 'that prose to one uniquely resolved authorized Discord or Eidoverse channel. The ' + 'envelope remains in your memory but recipients see only its body; delivery or failure ' + 'is reported back to you.'; @@ -8687,12 +8702,15 @@ export class AgentFramework { const shown = label && label !== newLocus ? `${label.startsWith('#') ? label : `#${label}`} (${newLocus})` : newLocus; + const inboundOnlyServer = this.channelRegistry?.inboundOnlyServer(newLocus); const noticeContent: ContentBlock[] = [{ type: 'text', - text: - `[routing] The conversation moved to ${shown} — your plain ` + - 'speech now lands there for the rest of this turn. Other ' + - 'channels need an explicit send tool.', + text: inboundOnlyServer + ? `[routing] The conversation moved to ${shown}. ${inboundOnlySpeechNote(inboundOnlyServer)} ` + + 'Other channels need an explicit send tool.' + : `[routing] The conversation moved to ${shown} — your plain ` + + 'speech now lands there for the rest of this turn. Other ' + + 'channels need an explicit send tool.', }]; const noticeMeta = { system: true, kind: 'routing-notice' } as MessageMetadata; try { @@ -9493,10 +9511,14 @@ export class AgentFramework { const target = typeof namer.proseTargetFor === 'function' ? namer.proseTargetFor(opts.channelId, opts.serverId) : [label ? `#${label}` : undefined, opts.channelId].find((t): t is string => !!t && !/\s/.test(t)); - const replyOption = target - ? `1. Reply without joining — write your reply this turn prefixed with ">>${target}"; it will be delivered there. ` + - `Note: follow-ups to your reply will NOT reach you unless they @-mention you or use the reply feature on your message.\n` - : `1. Reply without joining isn't available here: no single-word target reaches this channel unambiguously. Join it (option 2) to reply.\n`; + const inboundOnlyServer = this.channelRegistry.inboundOnlyServer(opts.channelId, opts.serverId); + const replyOption = inboundOnlyServer + ? `1. Replying there isn't available: ${inboundOnlyServer} declared this channel inbound-only, so nothing ` + + `you write is posted into it. To speak there, use ${inboundOnlyServer}'s own tools.\n` + : target + ? `1. Reply without joining — write your reply this turn prefixed with ">>${target}"; it will be delivered there. ` + + `Note: follow-ups to your reply will NOT reach you unless they @-mention you or use the reply feature on your message.\n` + : `1. Reply without joining isn't available here: no single-word target reaches this channel unambiguously. Join it (option 2) to reply.\n`; const missedCount = opts.missedMessages ?? 0; const missedNote = missedCount > 0 @@ -10572,9 +10594,12 @@ export class AgentFramework { label && label !== locus ? `${label.startsWith('#') ? label : `#${label}`} (${locus})` : locus; - text = - `[routing] Your plain speech now lands in ${shown}. ` + - 'Other channels need an explicit send tool.'; + const inboundOnlyServer = this.channelRegistry?.inboundOnlyServer(locus); + text = inboundOnlyServer + ? `[routing] The conversation is now in ${shown}. ${inboundOnlySpeechNote(inboundOnlyServer)} ` + + 'Other channels need an explicit send tool.' + : `[routing] Your plain speech now lands in ${shown}. ` + + 'Other channels need an explicit send tool.'; } try { @@ -10700,7 +10725,7 @@ export class AgentFramework { const parsed = parseProsePrefix(text); if (parsed.kind !== 'target') return { text }; try { - return { text, resolved: this.channelRegistry!.resolveProseTarget(parsed.target!) }; + return { text, resolved: this.channelRegistry!.resolveProseTarget(parsed.target!, { forSpeech: true }) }; } catch (err) { // Admission runs in the stream's event loop, so a resolver that // throws makes the envelope bounce rather than stop the round. @@ -10769,7 +10794,7 @@ export class AgentFramework { continue; } if (parsed.kind === 'target') { - const resolved = this.channelRegistry!.resolveProseTarget(parsed.target!); + const resolved = this.channelRegistry!.resolveProseTarget(parsed.target!, { forSpeech: true }); if ('error' in resolved) { delete routing.pin; routing.hybridHold = true; @@ -17819,11 +17844,14 @@ export class AgentFramework { if (openerAgent && (openerAgent.proseRouting === 'locus' || openerAgent.proseRouting === 'hybrid')) { this.turnLocusPins.set(agentName, opened); this.lastAnnouncedLocus.set(agentName, opened); + const inboundOnlyServer = this.channelRegistry?.inboundOnlyServer(opened); result = { ...result, data: { ...(result.data as Record | undefined), - routing: 'Your plain speech now lands in this channel. Other channels need an explicit send tool.', + routing: inboundOnlyServer + ? `${inboundOnlySpeechNote(inboundOnlyServer)} Other channels need an explicit send tool.` + : 'Your plain speech now lands in this channel. Other channels need an explicit send tool.', }, }; console.error( diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 41da16ead..f1b13f324 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -213,6 +213,26 @@ interface ChannelEntry { open: boolean; } +/** + * A channel its server declared inbound-only (SPEC §14.2 `direction`): its + * messages come in, and the host posts nothing into it. No publish, from + * plain speech, `channel_publish` or `publishForAgent`, and no outgoing + * stream. A channel declared `outbound` or `bidirectional`, or with no + * direction, isn't one. + */ +function isInboundOnly(entry: ChannelEntry): boolean { + return entry.descriptor.direction === 'inbound'; +} + +/** Why the host sends nothing into an inbound-only channel, as the resident + * reads it in a send-failure marker or a tool error. */ +function inboundOnlyReason(entry: ChannelEntry): string { + return ( + `inbound-only: server "${entry.serverId}" declared this channel's direction "inbound", ` + + 'so the host posts nothing into it; to speak there, use that server\'s own tools' + ); +} + /** Minimal responder interface for sending JSON-RPC results back. */ interface Responder { respond(result: unknown): void; @@ -1452,7 +1472,8 @@ export class ChannelRegistry { const openChannels = this.getOpenChannels(); // Resolve the outbound locus the SAME way routeSpeech does, so the agent is - // told the channel its speech will actually land in: a conversation fork's + // told the channel its speech will actually land in (none, when its server + // declared it inbound-only, since speech isn't posted there): a conversation fork's // home channel, else this turn's triggering channel (single trunk agent — // item-3 redux), else the global default (heartbeats). Without this, a fork // was advertised the global locus but published somewhere else; and a trunk @@ -1478,8 +1499,10 @@ export class ChannelRegistry { }; } - // Default outgoing: the resolved outbound locus (home channel for forks). - if (outgoing) { + // Default outgoing: the resolved outbound locus (home channel for forks), + // unless its server declared it inbound-only: routeSpeech posts nothing + // there, so it isn't where the agent's speech goes. + if (outgoing && !this.inboundOnlyServer(outgoing)) { context.defaultOutgoing = { channelId: outgoing, }; @@ -2082,16 +2105,39 @@ export class ChannelRegistry { /** * Publish into a channel on behalf of a named non-resident agent (the - * subconscious's speak_in_channel). Same delivery path as the - * channel_publish tool; the agent name rides the speech-routed trace. + * subconscious's speak_in_channel). Same delivery path, and the same result, + * as the channel_publish tool. Its traces carry the agent's name and follow + * the outcome as routeSpeech's do: speech-routed after a publish the server + * accepted and didn't report undelivered, and speech-route-failed otherwise. */ async publishForAgent( channelId: string, text: string, agentName: string, ): Promise<{ success: boolean; data?: unknown; error?: string; isError?: boolean }> { - this.emitTraceFn({ type: 'mcpl:speech-routed', conversationId: agentName, channelId, text }); - return this.handleToolPublish({ channelId, content: text }); + const result = await this.handleToolPublish({ channelId, content: text }); + const reply = result.data as { delivered?: boolean; messageId?: string } | undefined; + if (result.success && reply?.delivered !== false) { + this.emitTraceFn({ + type: 'mcpl:speech-routed', + conversationId: agentName, + serverId: this.findChannelEntry(channelId)?.serverId ?? '', + channelId, + delivered: true, + textLen: text.length, + text, + ...(reply?.messageId !== undefined ? { messageId: reply.messageId } : {}), + }); + } else { + this.emitTraceFn({ + type: 'mcpl:speech-route-failed', + conversationId: agentName, + channelId, + reason: result.success ? 'the server reported delivered:false' : (result.error ?? 'the publish failed'), + textLen: text.length, + }); + } + return result; } /** @@ -2302,6 +2348,17 @@ export class ChannelRegistry { return this.findChannelEntry(channelId)?.serverId ?? null; } + /** + * The server that declared this channel inbound-only (isInboundOnly), or + * undefined when it isn't one. The notices name that server. A caller that + * knows the channel's server passes it; otherwise the channel is found as + * routeSpeech finds it. + */ + inboundOnlyServer(channelId: string, serverId?: string): string | undefined { + const entry = serverId ? this.channels.get(`${serverId}:${channelId}`) : this.findChannelEntry(channelId); + return entry && isInboundOnly(entry) ? entry.serverId : undefined; + } + /** * Find a channel entry by channelId (searches across all servers). * Returns the first match. @@ -2429,9 +2486,24 @@ export class ChannelRegistry { * * Ambiguity is an error carrying candidates — never a guess: this is the * mechanism that makes prose misdelivery structurally impossible. + * + * `forSpeech` is for a target the agent's speech will be posted to: a + * channel its server declared inbound-only (isInboundOnly) then resolves + * to an error that says so, since nothing is posted there. Without it, as + * for reading a channel's history, any registered channel resolves. */ resolveProseTarget( spec: string, + opts?: { forSpeech?: boolean }, + ): { channelId: string; label?: string } | { error: string; candidates?: string[] } { + const resolved = this.resolveTargetByName(spec); + if (!opts?.forSpeech || 'error' in resolved) return resolved; + const entry = this.findChannelEntry(resolved.channelId); + return entry && isInboundOnly(entry) ? { error: `${resolved.channelId} is ${inboundOnlyReason(entry)}` } : resolved; + } + + private resolveTargetByName( + spec: string, ): { channelId: string; label?: string } | { error: string; candidates?: string[] } { const entries = [...this.channels.values()]; const trimmed = spec.trim(); @@ -2590,7 +2662,7 @@ export class ChannelRegistry { ]; for (const c of candidates) { if (/\s/.test(c) || c === '#') continue; - const r = this.resolveProseTarget(c); + const r = this.resolveProseTarget(c, { forSpeech: true }); if ('channelId' in r && r.channelId === d.id) return c; } return undefined; @@ -2978,18 +3050,6 @@ export class ChannelRegistry { }; } - /** - * Host-owned output routing (see forking-knowledge-miner LOCUS-ROUTING-DESIGN). - * Publish the agent's plain-text speech to the current conversational locus - * (the most recent incoming channel, tracked cross-surface here in the host). - * Called by the framework on a text-only turn — replaces the per-surface - * sticky auto-post that used to live in discord-mcpl. Returns null when there - * is no locus / the channel or its server can't be resolved (in which case - * the speech simply stays in chronicle + module surfaces). - */ - /** Resolve the outbound locus (fork HOME → this-turn's TRIGGERING channel → - * process-global default). Public so a multi-segment caller can snapshot it - * ONCE and pin every segment to it via routeSpeech's `overrideChannelId`. */ /** * MCPL Spec 14.3 outgoing streaming: forward a moderated text delta to the * server owning the channel, AS THE MODEL GENERATES. Emitted only when that @@ -3038,6 +3098,9 @@ export class ChannelRegistry { // never-guess rule that governs delivery. const matches = [...this.channels.values()].filter((e) => e.descriptor.id === channelId); if (matches.length !== 1) return null; + // A stream is a copy of speech the host would post, and nothing is + // posted into an inbound-only channel. + if (isInboundOnly(matches[0]!)) return null; const server = this.serverRegistry.getServer(matches[0]!.serverId); // §5.4: the GRANT gates streaming, not the raw advertisement. The old // `capabilities?.channels?.streaming` check was doubly wrong: undefined @@ -3048,11 +3111,25 @@ export class ChannelRegistry { return server; } + /** Resolve the outbound locus (fork HOME → this-turn's TRIGGERING channel → + * process-global default). Public so a multi-segment caller can snapshot it + * ONCE and pin every segment to it via routeSpeech's `overrideChannelId`. */ resolveLocus(conversationId: string): string | null { const home = this.homeChannelResolver?.(conversationId); return home ?? this.activeChannelResolver?.(conversationId) ?? this.defaultPublishChannel ?? null; } + /** + * Host-owned output routing (see forking-knowledge-miner LOCUS-ROUTING-DESIGN). + * Publish the agent's plain-text speech to the current conversational locus + * (the most recent incoming channel, tracked cross-surface here in the host). + * Called by the framework on a text-only turn — replaces the per-surface + * sticky auto-post that used to live in discord-mcpl. Returns null when there + * is no locus, the channel or its server can't be resolved, its server + * declared it inbound-only, or the open or publish fails. Each of these goes + * through fail(): a trace, and the host's send-failure marker. The speech + * stays in chronicle + module surfaces. + */ async routeSpeech( conversationId: string, text: string, @@ -3099,6 +3176,11 @@ export class ChannelRegistry { if (!entry) { return fail(channelId, `no registered channel for locus "${channelId}"`); } + // Before the open below: speech that can't be posted here mustn't open + // the channel either. + if (isInboundOnly(entry)) { + return fail(channelId, inboundOnlyReason(entry)); + } // INVARIANT: it is not possible to send into a closed channel. Speech // routed into a closed-but-registered locus (the canonical case: a DM @@ -3207,6 +3289,13 @@ export class ChannelRegistry { isError: true, }; } + if (isInboundOnly(entry)) { + return { + success: false, + error: `Channel ${channelId} is ${inboundOnlyReason(entry)}`, + isError: true, + }; + } const server = this.serverRegistry.getServer(entry.serverId); if (!server) { diff --git a/src/types/trace.ts b/src/types/trace.ts index c96a9a579..1784d05a2 100644 --- a/src/types/trace.ts +++ b/src/types/trace.ts @@ -412,7 +412,9 @@ export type TraceEvent = }) // Host-owned speech routing (ChannelRegistry.routeSpeech). One trace per - // delivered prose segment. `text` is the segment verbatim — the same content + // delivered prose segment, and one per publish the subconscious's + // speak_in_channel delivers (ChannelRegistry.publishForAgent, whose + // conversationId is the speaking agent's name). `text` is the segment verbatim — the same content // already on the bus chunk-by-chunk via inference:tokens, and mcpl:* traces // are 'ops'-scoped on the webui wire — and `messageId` is the surface's id // for the posted message (when the MCPL server reports one). Together they diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index 80a825658..b5fd0c978 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -29,6 +29,7 @@ function makeRegistry( const openCalls: Array<{ channelId?: string }> = []; const closeCalls: Array<{ channelId?: string }> = []; + const streamCalls: Array<{ kind: 'chunk' | 'complete'; channelId?: string }> = []; let failOpens = false; const mockServer = { @@ -38,6 +39,12 @@ function makeRegistry( publishCalls.push(params); return publishResult; }, + sendChannelsOutgoingChunk: (params: { channelId?: string }) => { + streamCalls.push({ kind: 'chunk', channelId: params.channelId }); + }, + sendChannelsOutgoingComplete: (params: { channelId?: string }) => { + streamCalls.push({ kind: 'complete', channelId: params.channelId }); + }, sendChannelsOpen: async (params: { channelId?: string }) => { if (failOpens) throw new Error('open refused by server'); openCalls.push(params); @@ -73,7 +80,7 @@ function makeRegistry( }).findChannelEntry(channelId); return { - registry, failures, traces, publishCalls, openCalls, closeCalls, lookup, + registry, failures, traces, publishCalls, openCalls, closeCalls, streamCalls, lookup, setFailOpens: (v: boolean) => { failOpens = v; }, }; } @@ -590,3 +597,211 @@ test('proseTargetFor: no token when none is safe (shared id across servers, whit assert.equal(registry.proseTargetFor('chan:8', 'discord-a'), '#only-a'); assert.equal(registry.proseTargetFor('chan:8', 'discord-b'), undefined); }); + +// --------------------------------------------------------------------------- +// Inbound-only channels (SPEC §14.2 `direction: 'inbound'`): the host posts +// nothing into them. pocket-body declares its live transcript this way and +// refuses publishes there, and another agent speaking in the room arrives on +// it addressed, so a resident's plain speech can be aimed at it. +// --------------------------------------------------------------------------- + +async function registerInboundOnly(registry: ChannelRegistry): Promise { + await registry.handleChanged('body', { + added: [{ id: 'body:ears', type: 'chat', label: 'Body · ears', direction: 'inbound' }], + } as never); +} + +test('plain speech into a channel its server declared inbound-only is refused before the channel is opened or published to', async () => { + const { registry, failures, traces, publishCalls, openCalls, lookup } = makeRegistry({ delivered: true }); + await registerInboundOnly(registry); + assert.equal(lookup('body:ears')?.open, false, 'precondition: registered closed'); + + const result = await registry.routeSpeech('linn', 'Hello, room.', 'body:ears'); + + assert.equal(result, null, 'nothing was delivered'); + assert.deepEqual(publishCalls, [], 'nothing was published'); + assert.deepEqual(openCalls, [], 'the delivery attempt did not open the channel'); + assert.equal(lookup('body:ears')?.open, false); + assert.equal(failures.length, 1, 'the refusal reaches the resident as a send failure'); + assert.equal(failures[0]!.channelId, 'body:ears'); + assert.match(failures[0]!.reason, /^inbound-only: server "body" declared this channel's direction "inbound"/); + assert.match(failures[0]!.reason, /use that server's own tools$/); + assert.ok(traces.some((t) => t.type === 'mcpl:speech-route-failed' && t.channelId === 'body:ears')); +}); + +test('channel_publish into an inbound-only channel is refused, naming the declaration, and publishes nothing', async () => { + const { registry, publishCalls } = makeRegistry({ delivered: true }); + await registerInboundOnly(registry); + + const result = await registry.handleChannelToolCall('channel_publish', { + channelId: 'body:ears', + content: 'Hello, room.', + }); + + assert.equal(result.success, false); + assert.equal(result.isError, true); + assert.match(String(result.error), /^Channel body:ears is inbound-only: server "body" declared this channel's direction "inbound"/); + assert.deepEqual(publishCalls, [], 'nothing was published'); +}); + +test('an inbound-only channel gets no outgoing stream', async () => { + const { registry, streamCalls } = makeRegistry({ delivered: true }); + await registerInboundOnly(registry); + + registry.sendOutgoingChunk('body:ears', 'linn', 'inf_1', 0, 'Hello'); + registry.sendOutgoingComplete('body:ears', 'linn', 'inf_1', 'Hello, room.'); + + assert.deepEqual(streamCalls, []); +}); + +test('a channel declared outbound or bidirectional, or with no direction, still takes speech, channel_publish and the stream', async () => { + const { registry, failures, publishCalls, streamCalls } = makeRegistry({ delivered: true }); + await registry.handleChanged('body', { + added: [ + { id: 'body:out', type: 'chat', label: 'out', direction: 'outbound' }, + { id: 'body:both', type: 'chat', label: 'both', direction: 'bidirectional' }, + { id: 'body:unstated', type: 'chat', label: 'unstated' }, + ], + } as never); + + for (const id of ['body:out', 'body:both', 'body:unstated']) { + assert.ok(await registry.routeSpeech('linn', `speech to ${id}`, id), `speech delivered to ${id}`); + const published = await registry.handleChannelToolCall('channel_publish', { channelId: id, content: `publish to ${id}` }); + assert.equal(published.success, true, `channel_publish delivered to ${id}`); + registry.sendOutgoingChunk(id, 'linn', 'inf_1', 0, 'x'); + } + + assert.deepEqual(failures, []); + assert.deepEqual(publishCalls.map((c) => c.channelId), [ + 'body:out', 'body:out', 'body:both', 'body:both', 'body:unstated', 'body:unstated', + ]); + assert.deepEqual(streamCalls.map((c) => c.channelId), ['body:out', 'body:both', 'body:unstated']); +}); + +test('inboundOnlyServer names the declaring server for an inbound-only channel, and nothing for any other', async () => { + // The routing notices ask the registry this, so they say what routeSpeech does. + const { registry } = makeRegistry({ delivered: true }); + await registerInboundOnly(registry); + await registry.handleChanged('body', { + added: [ + { id: 'body:out', type: 'chat', label: 'out', direction: 'outbound' }, + { id: 'body:both', type: 'chat', label: 'both', direction: 'bidirectional' }, + { id: 'body:unstated', type: 'chat', label: 'unstated' }, + ], + } as never); + + assert.equal(registry.inboundOnlyServer('body:ears'), 'body'); + for (const id of ['body:out', 'body:both', 'body:unstated', 'body:unregistered']) { + assert.equal(registry.inboundOnlyServer(id), undefined, id); + } +}); + +test('the refusal follows the descriptor its server declared last: channels/register, and channels/changed updates either way', async () => { + const { registry, failures, publishCalls } = makeRegistry({ delivered: true }); + + // channels/register declares it inbound-only. + await registry.handleRegister('body', { + channels: [{ id: 'body:ears', type: 'chat', label: 'Body · ears', direction: 'inbound' }], + } as never); + assert.equal(await registry.routeSpeech('linn', 'one', 'body:ears'), null); + assert.equal(registry.inboundOnlyServer('body:ears'), 'body'); + + // An update declares it bidirectional: speech goes out. + await registry.handleChanged('body', { + updated: [{ id: 'body:ears', type: 'chat', label: 'Body · ears', direction: 'bidirectional' }], + } as never); + assert.ok(await registry.routeSpeech('linn', 'two', 'body:ears')); + assert.equal(registry.inboundOnlyServer('body:ears'), undefined); + + // Another declares it inbound-only again: refused again. + await registry.handleChanged('body', { + updated: [{ id: 'body:ears', type: 'chat', label: 'Body · ears', direction: 'inbound' }], + } as never); + assert.equal(await registry.routeSpeech('linn', 'three', 'body:ears'), null); + + assert.deepEqual(publishCalls.map((c) => c.channelId), ['body:ears'], 'only the bidirectional turn published'); + assert.equal(failures.length, 2); + for (const f of failures) { + assert.match(f.reason, /^inbound-only: server "body" declared this channel's direction "inbound"/); + } +}); + +test('publishForAgent into an inbound-only channel is refused and traced as failed; into any other channel it traces once as routed, after the publish', async () => { + const { registry, publishCalls, traces } = makeRegistry({ delivered: true, messageId: 'm-77' } as { delivered: boolean }); + await registerInboundOnly(registry); + await registry.handleChanged('body', { + added: [{ id: 'body:home', type: 'chat', label: 'Body · home', direction: 'bidirectional' }], + } as never); + const routed = () => traces.filter((t) => t.type === 'mcpl:speech-routed'); + + const refused = await registry.publishForAgent('body:ears', 'a thought', 'Subconscious'); + assert.equal(refused.success, false); + assert.match(String(refused.error), /inbound-only: server "body" declared this channel's direction "inbound"/); + assert.equal(publishCalls.length, 0, 'nothing was published'); + assert.equal(routed().length, 0, 'speech that was never posted is not traced as routed'); + const failed = traces.filter((t) => t.type === 'mcpl:speech-route-failed'); + assert.equal(failed.length, 1, 'the refusal is traced as a failure'); + assert.equal(failed[0]!.conversationId, 'Subconscious'); + assert.equal(failed[0]!.channelId, 'body:ears'); + assert.match(String(failed[0]!.reason), /inbound-only: server "body" declared/); + + const posted = await registry.publishForAgent('body:home', 'a thought', 'Subconscious'); + assert.equal(posted.success, true); + assert.deepEqual(publishCalls.map((c) => c.channelId), ['body:home']); + // Shaped as routeSpeech's trace is, with the surface's message id. + assert.deepEqual(routed(), [{ + type: 'mcpl:speech-routed', conversationId: 'Subconscious', serverId: 'body', channelId: 'body:home', + delivered: true, textLen: 'a thought'.length, text: 'a thought', messageId: 'm-77', + }]); +}); + +test('publishForAgent traces a publish the server reports undelivered as failed, not routed', async () => { + const { registry, publishCalls, traces } = makeRegistry({ delivered: false }); + await registry.handleChanged('body', { + added: [{ id: 'body:home', type: 'chat', label: 'Body · home', direction: 'bidirectional' }], + } as never); + + await registry.publishForAgent('body:home', 'a thought', 'Subconscious'); + + assert.deepEqual(publishCalls.map((c) => c.channelId), ['body:home']); + assert.deepEqual(traces.filter((t) => t.type === 'mcpl:speech-routed'), []); + assert.deepEqual( + traces.filter((t) => t.type === 'mcpl:speech-route-failed').map((t) => [t.conversationId, t.channelId, t.reason]), + [['Subconscious', 'body:home', 'the server reported delivered:false']], + ); +}); + +test('buildChannelContext names no defaultOutgoing for a locus its server declared inbound-only', async () => { + const active: Record = { linn: 'body:ears', nell: 'body:home' }; + const { registry } = makeRegistry({ delivered: true }, () => undefined, (n) => active[n]); + await registerInboundOnly(registry); + await registry.handleChanged('body', { + added: [{ id: 'body:home', type: 'chat', label: 'Body · home', direction: 'bidirectional' }], + } as never); + + assert.equal(registry.buildChannelContext('linn')?.defaultOutgoing, undefined, 'speech goes nowhere, so no channel is named'); + assert.equal(registry.buildChannelContext('nell')?.defaultOutgoing?.channelId, 'body:home'); +}); + +test('an inbound-only channel resolves as a speech target to an error naming the declaration, and still resolves for reading', async () => { + const { registry } = makeRegistry({ delivered: true }); + await registerInboundOnly(registry); + await registry.handleChanged('body', { + added: [{ id: 'body:home', type: 'chat', label: 'Body · home', direction: 'bidirectional' }], + } as never); + + const speech = registry.resolveProseTarget('body:ears', { forSpeech: true }); + assert.ok('error' in speech, 'refused as a target for speech'); + assert.match( + (speech as { error: string }).error, + /^body:ears is inbound-only: server "body" declared this channel's direction "inbound"/, + ); + // Reading its history still finds it. + assert.deepEqual(registry.resolveProseTarget('body:ears'), { channelId: 'body:ears', label: 'Body · ears' }); + assert.deepEqual(registry.resolveProseTargetDurable('body:ears'), { channelId: 'body:ears', label: 'Body · ears' }); + // No reply token is offered for it; one is for the bidirectional channel. + assert.equal(registry.proseTargetFor('body:ears', 'body'), undefined); + assert.ok(registry.proseTargetFor('body:home', 'body')); + // A bidirectional channel resolves either way. + assert.deepEqual(registry.resolveProseTarget('body:home', { forSpeech: true }), { channelId: 'body:home', label: 'Body · home' }); +}); diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index 648f32534..fc8f04324 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -79,8 +79,14 @@ function stubRegistry(framework: AgentFramework, plan: Array<'delivered' | 'fals 'discord:dm:99': { channelId: 'discord:dm:99', label: 'DM: laria' }, }; const explicit: Record = { - resolveProseTarget: (spec: string) => - known[spec] ?? { error: `no channel matches "${spec}"` }, + // As the registry does, a channel its server declared inbound-only + // resolves for reading but not as a target for speech. + resolveProseTarget: (spec: string, opts?: { forSpeech?: boolean }) => + spec === '#ears' + ? opts?.forSpeech + ? { error: 'body:ears is inbound-only: server "body" declared this channel\'s direction "inbound", so the host posts nothing into it; to speak there, use that server\'s own tools' } + : { channelId: 'body:ears', label: 'Body · ears' } + : known[spec] ?? { error: `no channel matches "${spec}"` }, routeSpeech: async (_agent: string, text: string, locus?: string | null) => { routed.push({ text, locus: locus ?? null }); const behavior = plan.shift() ?? 'delivered'; @@ -1353,6 +1359,24 @@ describe('explicit prose routing', () => { await framework.stop(); }); + it('the hybrid and locus primers say plain text goes to the locus unless its server declared that channel inbound-only', async () => { + const framework = await createFramework('hybrid'); + const agent = framework.getAgent('assistant')!; + const prime = (framework as unknown as { maybePrimeProseMode(agent: unknown): void }).maybePrimeProseMode.bind(framework); + prime(agent); + // The agent's mode is set from its config; a later config change is what + // brings the locus primer. + (agent as unknown as { proseRouting: string }).proseRouting = 'locus'; + prime(agent); + const primers = agent.getContextManager().getAllMessages().flatMap(m => m.content) + .filter(b => b.type === 'text').map(b => (b as { text: string }).text) + .filter(t => t.startsWith('[prose-routing]')); + assert.equal(primers.length, 2, String(primers)); + assert.match(primers[0]!, /^\[prose-routing\] Hybrid routing enabled: unprefixed text still lands in the current conversational locus, unless its server declared that channel inbound-only\. /); + assert.match(primers[1]!, /^\[prose-routing\] Mode change: your plain text auto-routes to the conversational locus again, unless its server declared that channel inbound-only\. /); + await framework.stop(); + }); + it('prose_help tool returns the routing reference on demand', async () => { membrane.pushResponse(createMockResponse([ { type: 'tool_use', id: 'c1', name: 'prose_help', input: {} }, @@ -1412,6 +1436,28 @@ describe('explicit prose routing', () => { await framework.stop(); }); + for (const [mode, authored] of [ + ['hybrid', '>>>#ears\nHello, room.'], + ['explicit', '>>#ears Hello, room.'], + ] as const) { + it(`${mode}: a target its server declared inbound-only bounces with that reason and publishes nothing`, async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: authored }] as ContentBlock[])); + const framework = await createFramework(mode); + const routed = stubRegistry(framework); + feedTurn(framework, 2, [{ type: 'text', text: (mode === 'hybrid' ? '>>>' : '>>') + 'skip_reply {{unsent}}' }] as ContentBlock[]); + trigger(framework); + await framework.runUntilIdle(); + assert.deepEqual(routed, [], 'nothing was published'); + const texts = framework.getAgent('assistant')!.getContextManager().getAllMessages().flatMap(m => m.content) + .filter(b => b.type === 'text').map(b => (b as { text: string }).text); + const bounce = texts.find(t => t.startsWith('[prose-routing]') && t.includes('not delivered')); + assert.ok(bounce, 'the text bounced'); + assert.ok(bounce!.includes('body:ears is inbound-only: server "body" declared'), `the bounce gives the declaration: ${bounce}`); + assert.ok(!bounce!.includes('was not confirmed'), `the bounce doesn't call it an unconfirmed delivery: ${bounce}`); + await framework.stop(); + }); + } + it('hybrid leaves unprefixed prose on the frozen locus', async () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Ordinary field speech.' }] as ContentBlock[])); const framework = await createFramework('hybrid'); diff --git a/test/present-while-acting.test.ts b/test/present-while-acting.test.ts index c5c3157bc..4b0dae9fa 100644 --- a/test/present-while-acting.test.ts +++ b/test/present-while-acting.test.ts @@ -731,6 +731,117 @@ describe('present while acting', () => { await framework.stop(); }); + // ------------------------------------------------------------------------- + // A channel its server declared inbound-only takes no plain speech (the + // registry refuses it), so every notice that says where plain speech goes + // says that instead. pocket-body's live transcript is one: another agent + // speaking in the room arrives there addressed. + // ------------------------------------------------------------------------- + + /** The stub registry answers that body:ears is inbound-only, declared by pocket-body. */ + function declareEarsInboundOnly(framework: AgentFramework): Record { + const registry = (framework as unknown as { channelRegistry: Record }).channelRegistry; + registry.inboundOnlyServer = (channelId: string) => (channelId === 'body:ears' ? 'pocket-body' : undefined); + return registry; + } + + function assertSaysRefused(text: string | undefined, what: string): void { + assert.ok(text, `${what} was written`); + assert.ok( + text!.includes( + 'pocket-body declared this channel inbound-only, so plain speech there is refused and stays in your archive. ' + + "To speak there, use pocket-body's own tools.", + ), + `${what} says plain speech there is refused, where it stays, and what to use instead: ${text}`, + ); + assert.ok(!text!.includes('lands'), `${what} doesn't say the speech lands there: ${text}`); + } + + it('the routing notice for a locus its server declared inbound-only says plain speech there is refused', async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'one' }] as ContentBlock[])); + + const framework = await createFramework(); + stubChannelRegistry(framework); + declareEarsInboundOnly(framework); + + trigger(framework); + await framework.runUntilIdle(); + + (framework as unknown as { announceLocusIfChanged(agentName: string, locus: string | null): void }) + .announceLocusIfChanged('assistant', 'body:ears'); + + const cm = (framework as unknown as { + agents: Map }> } }>; + }).agents.get('assistant')!.getContextManager(); + const notices = cm.getAllMessages() + .flatMap((m) => m.content) + .map((b) => b.text ?? '') + .filter((t) => t.startsWith('[routing]')); + const notice = notices.at(-1); + assert.ok(notice?.startsWith('[routing] The conversation is now in body:ears.'), String(notice)); + assertSaysRefused(notice, 'the routing notice'); + + await framework.stop(); + }); + + it('an addressed arrival on an inbound-only channel re-pins there, and its notice says plain speech there is refused', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Station four, proceeding.' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Answering the room.' }, + ] as ContentBlock[])); + + const framework = await createFramework(); + const routed = stubChannelRegistry(framework); + declareEarsInboundOnly(framework); + module.toolDelayMs = 25; + module.interjection = 'Linn, can you hear me?'; + module.interjectionMetadata = { channelId: 'body:ears', tags: ['chat:addressed'] }; + + trigger(framework); + await framework.runUntilIdle(); + + // The locus follows the conversation as before; the registry, not the + // pin, is what keeps the speech out of the channel. + assert.deepEqual(routed, [ + { text: 'Station four, proceeding.', locus: 'chan-live-1' }, + { text: 'Answering the room.', locus: 'body:ears' }, + ]); + const wired = membrane.lastStream!.receivedToolResultOptions + .flatMap((o) => o?.injectedMessages ?? []) + .flatMap((m) => m.content as Array<{ type: string; text?: string }>) + .map((b) => b.text ?? ''); + const notice = wired.find((t) => t.startsWith('[routing]')); + assert.ok(notice?.startsWith('[routing] The conversation moved to body:ears.'), String(notice)); + assertSaysRefused(notice, 'the re-pin notice'); + + await framework.stop(); + }); + + it('channel_open of an inbound-only channel says in its result that plain speech there is refused', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'c1', name: 'channel_open', input: { channelId: 'body:ears' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Listening.' }, + ] as ContentBlock[])); + + const framework = await createFramework(); + stubChannelRegistry(framework); + const registry = declareEarsInboundOnly(framework); + registry.handleChannelToolCall = async () => ({ success: true, data: { channelId: 'body:ears', opened: true } }); + + trigger(framework); + await framework.runUntilIdle(); + + const results = JSON.stringify(membrane.lastStream!.receivedToolResults.flat()); + assertSaysRefused(results, 'the channel_open result'); + + await framework.stop(); + }); + it('reactions and system markers injected mid-turn do not clear send suppression', async () => { // A reaction (`chat:reaction` tag) or a `system: true` marker is not // conversational input: prose following an explicit send stays suppressed, diff --git a/test/trunk-channel-routing.test.ts b/test/trunk-channel-routing.test.ts index dc61fc56c..df3c591ec 100644 --- a/test/trunk-channel-routing.test.ts +++ b/test/trunk-channel-routing.test.ts @@ -147,6 +147,7 @@ describe('Trunk channel routing (item-3 redux)', () => { ensureChannelRegistered: () => {}, isChannelOpen: () => false, getDescriptor: () => ({ capabilities: { history: { maxMessages: 80 } } }), + inboundOnlyServer: () => undefined, stopAll: () => {}, }; @@ -198,6 +199,7 @@ describe('Trunk channel routing (item-3 redux)', () => { ensureChannelRegistered: () => {}, isChannelOpen: () => false, getDescriptor: () => ({ capabilities: { history: { maxMessages: 80 } } }), + inboundOnlyServer: () => undefined, stopAll: () => {}, }; @@ -264,6 +266,7 @@ describe('Trunk channel routing (item-3 redux)', () => { isChannelOpen: () => false, getDescriptor: () => ({ label: 'DM: _reim0n', capabilities: { history: { maxMessages: 80 } } }), proseTargetFor: (id: string, serverId?: string) => { asked.push(`${serverId}/${id}`); return '@_reim0n'; }, + inboundOnlyServer: () => undefined, stopAll: () => {}, }; i.handleMcplPushEvent(dmEvent('m3')); @@ -282,6 +285,7 @@ describe('Trunk channel routing (item-3 redux)', () => { isChannelOpen: () => false, getDescriptor: () => ({ label: 'DM: _reim0n', capabilities: { history: { maxMessages: 80 } } }), proseTargetFor: () => undefined, + inboundOnlyServer: () => undefined, stopAll: () => {}, }; i2.handleMcplPushEvent(dmEvent('m4')); @@ -298,6 +302,7 @@ describe('Trunk channel routing (item-3 redux)', () => { ensureChannelRegistered: () => {}, isChannelOpen: () => false, getDescriptor: () => undefined, + inboundOnlyServer: () => undefined, stopAll: () => {}, }; i3.handleMcplPushEvent(dmEvent('m5')); @@ -305,6 +310,47 @@ describe('Trunk channel routing (item-3 redux)', () => { await fw3.stop(); }); + it('the invitation offers no reply into a channel its server declared inbound-only, and says why', async () => { + // pocket-body's live transcript: another agent speaking in the room arrives + // on it addressed, and the host posts nothing into it, so a ">>" reply would + // only be refused. + const framework = await makeFramework(); + const i = internals(framework); + i.channelRegistry = { + ensureChannelRegistered: () => {}, + isChannelOpen: () => false, + getDescriptor: () => ({ label: 'Body · ears', direction: 'inbound' }), + proseTargetFor: () => '#ears', + inboundOnlyServer: (id: string) => (id === 'body:ears' ? 'pocket-body' : undefined), + stopAll: () => {}, + }; + i.handleMcplPushEvent({ + type: 'mcpl:push-event', + serverId: 'pocket-body', + featureSet: 'body', + eventId: 'ears_1', + content: [{ type: 'text', text: 'Fable: Linn, can you hear me?' }], + origin: { source: 'pocket-body', messageId: 'e1', mcplChannelId: 'body:ears', channelName: 'Body · ears' }, + tags: ['chat:addressed'], + timestamp: new Date().toISOString(), + inferenceId: 'i-e1', + triggerInference: false, + }); + + const text = framework.getAgent('scout')!.getContextManager().queryMessages({}).messages.at(-1)?.content + .filter((block): block is { type: 'text'; text: string } => block.type === 'text') + .map((block) => block.text) + .join('\n') ?? ''; + assert.match(text, /Channel invitation/); + assert.match( + text, + /1\. Replying there isn't available: pocket-body declared this channel inbound-only, so nothing you write is posted into it\. To speak there, use pocket-body's own tools\./, + ); + assert.doesNotMatch(text, /prefixed with ">>/); + assert.match(text, /channel_open with channelId "body:ears"/, 'joining, to hear the room, stays available'); + await framework.stop(); + }); + it('a channel-incoming trunk turn records its triggering channel', async () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'the date is ...' }])); const framework = await makeFramework(); From 62f855bc02e2f066bd7c73bcb89e1dfaa28544f1 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Wed, 14 Oct 2026 11:37:28 -0400 Subject: [PATCH 02/14] fix(channels): recheck the declaration after the open, and trace the server the publish went to Two points from Greptile's review of #350: - routeSpeech checked a channel's direction before opening it by delivery, and the open awaits the server. A channels/changed update (which replaces the entry's descriptor) or a re-register (which replaces the entry) can declare the channel inbound-only meanwhile, and the publish then went ahead. routeSpeech now checks what's declared after the open too, reading the entry as it stands, and refuses with the same reason. The open itself already happened; the server answered it before declaring. - publishForAgent looked up the server for its speech-routed trace after the publish returned, so a channel removed while the reply was pending traced an empty serverId (or another server sharing the id). It now takes the server before publishing, as handleToolPublish finds it before it awaits. Tests (test/channel-registry-routing.test.ts): makeRegistry's mock server gains hooks that run while it answers an open or a publish. A channel declared inbound-only during the open is refused before the publish, with the declaration's reason; publishForAgent's routed trace names 'body' though the channel is removed while the reply is pending. Each fails with its fix removed. Co-Authored-By: Claude Opus 5.5 --- src/mcpl/channel-registry.ts | 11 ++++++- test/channel-registry-routing.test.ts | 42 +++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index f1b13f324..3cc77c384 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -2115,13 +2115,16 @@ export class ChannelRegistry { text: string, agentName: string, ): Promise<{ success: boolean; data?: unknown; error?: string; isError?: boolean }> { + // The server the publish goes to, as handleToolPublish finds it before + // it awaits: the channel may be removed while the reply is pending. + const serverId = this.findChannelEntry(channelId)?.serverId ?? ''; const result = await this.handleToolPublish({ channelId, content: text }); const reply = result.data as { delivered?: boolean; messageId?: string } | undefined; if (result.success && reply?.delivered !== false) { this.emitTraceFn({ type: 'mcpl:speech-routed', conversationId: agentName, - serverId: this.findChannelEntry(channelId)?.serverId ?? '', + serverId, channelId, delivered: true, textLen: text.length, @@ -3213,6 +3216,12 @@ export class ChannelRegistry { return fail(channelId, `locus channel is closed and open failed: ${(err as Error).message}`); } } + // The open awaited the server, and a channels/changed update or a + // re-register may have declared the channel inbound-only meanwhile. + const declared = this.channels.get(`${entry.serverId}:${entry.descriptor.id}`) ?? entry; + if (isInboundOnly(declared)) { + return fail(channelId, inboundOnlyReason(declared)); + } const server = this.serverRegistry.getServer(entry.serverId); if (!server) { diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index b5fd0c978..89e3dc9de 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -31,12 +31,15 @@ function makeRegistry( const closeCalls: Array<{ channelId?: string }> = []; const streamCalls: Array<{ kind: 'chunk' | 'complete'; channelId?: string }> = []; let failOpens = false; + let onOpen: (() => Promise) | undefined; + let onPublish: (() => Promise) | undefined; const mockServer = { // Post-policy state: full grant, so tests exercise delivery, not §5.3 denial. grant: new CapabilityGrant(new Set(ALL_CAPABILITY_PATHS), []), sendChannelsPublish: async (params: { channelId?: string; conversationId?: string }) => { publishCalls.push(params); + await onPublish?.(); return publishResult; }, sendChannelsOutgoingChunk: (params: { channelId?: string }) => { @@ -48,6 +51,7 @@ function makeRegistry( sendChannelsOpen: async (params: { channelId?: string }) => { if (failOpens) throw new Error('open refused by server'); openCalls.push(params); + await onOpen?.(); return {}; }, sendChannelsClose: async (params: { channelId?: string }) => { @@ -82,6 +86,10 @@ function makeRegistry( return { registry, failures, traces, publishCalls, openCalls, closeCalls, streamCalls, lookup, setFailOpens: (v: boolean) => { failOpens = v; }, + /** Runs while the server is answering an open, before it returns. */ + setOnOpen: (fn: (() => Promise) | undefined) => { onOpen = fn; }, + /** Runs while the server is answering a publish, before it returns. */ + setOnPublish: (fn: (() => Promise) | undefined) => { onPublish = fn; }, }; } @@ -805,3 +813,37 @@ test('an inbound-only channel resolves as a speech target to an error naming the // A bidirectional channel resolves either way. assert.deepEqual(registry.resolveProseTarget('body:home', { forSpeech: true }), { channelId: 'body:home', label: 'Body · home' }); }); + +test('a channel declared inbound-only while the host opens it for speech is refused before the publish', async () => { + const { registry, publishCalls, openCalls, failures, setOnOpen } = makeRegistry({ delivered: true }); + await registry.handleRegister('body', { + channels: [{ id: 'body:ears', type: 'chat', label: 'Body · ears', direction: 'bidirectional' }], + } as never); + setOnOpen(async () => { + await registry.handleChanged('body', { + updated: [{ id: 'body:ears', type: 'chat', label: 'Body · ears', direction: 'inbound' }], + } as never); + }); + + assert.equal(await registry.routeSpeech('linn', 'hello', 'body:ears'), null); + assert.deepEqual(openCalls.map((c) => c.channelId), ['body:ears'], 'the closed channel was opened'); + assert.equal(publishCalls.length, 0, 'nothing was published after the declaration'); + assert.equal(failures.length, 1); + assert.match(failures[0]!.reason, /^inbound-only: server "body" declared this channel's direction "inbound"/); +}); + +test("publishForAgent's routed trace names the server it published through, though the channel is removed while the reply is pending", async () => { + const { registry, traces, setOnPublish } = makeRegistry({ delivered: true }); + await registry.handleChanged('body', { + added: [{ id: 'body:home', type: 'chat', label: 'Body · home', direction: 'bidirectional' }], + } as never); + setOnPublish(async () => { + await registry.handleChanged('body', { removed: ['body:home'] } as never); + }); + + const posted = await registry.publishForAgent('body:home', 'a thought', 'Subconscious'); + assert.equal(posted.success, true); + const routed = traces.filter((t) => t.type === 'mcpl:speech-routed'); + assert.equal(routed.length, 1); + assert.equal(routed[0]!.serverId, 'body'); +}); From 9f369e647fa9f12311f6b815fbf5a6a7a6cc9a2e Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Thu, 15 Oct 2026 08:54:31 -0400 Subject: [PATCH 03/14] fix(channels): a refused >> target clears the destination, a server's refusal reaches the agent, and an open's answer counts Answers Anarchid's review of #350 at 9be04ce. - R1-1 (explicit mode): a >> target refused for speech, because its server declared the channel inbound-only, now clears the turn's destination. resolveProseTarget names the refused channel as refusedChannelId, roundDestination returns null for it, and delivery drops the pin before the bounce. The turn's later unprefixed text then bounces with "this turn has no destination now", rather than going to the destination named before the refusal, which this PR had made possible. A target that just doesn't resolve still leaves the destination, as explicit mode always has. prose_help says a destination lasts until another is named, and that an inbound-only one is refused and clears it. - R1-2: a server's JSON-RPC error response now rejects with McplErrorResponse (code, data), and routeSpeech turns one to its publish into fail(): the send-failure marker with the server's error, and an mcpl:speech-route-failed trace. Before, every plain-speech path only logged it. A publish that times out or loses its connection still rejects to its caller, since whether the server posted it is unknown, and a marker saying the human didn't get it could be false. routeSpeech's doc now says exactly that. - R1-3: openChannelNow applies an inbound-only declaration in the open's answer (its channel descriptor, SPEC 14.3) to the channel's current entry, so a channel the host knew only from a push is refused once opening it says so. The post-open recheck fails closed when a removal arrived during the open, where it fell back to the stale entry and published. - R1-6: the predicate's doc and the reason the resident reads say the host publishes nothing into the channel; typing and acknowledgements, which carry no content, still go. - R1-8: the changelog names no particular server, and covers the above. Not here: R1-4 (markers and bounces reach the primary agent), which predates this PR; R1-5 (the no-trigger fallback), where skipping an inbound-only channel would send that speech to an older channel; R1-7, which is connectome-host's TTS relay to filter by conversationId. Tests: Anarchid's R1-1 probe as an explicit-mode test, refused-target rounds in the round-destination agreement test, the registry naming a refused channel (and not an unknown name), a connection-level test of McplErrorResponse, routeSpeech with a refused and a timed-out publish, an open answering inbound-only with three controls, and a removal during the open. Co-Authored-By: Claude Opus 5.5 --- changelog.d/inbound-only-channels.fixed.md | 25 +++-- src/framework.ts | 21 +++- src/mcpl/channel-registry.ts | 64 +++++++++--- src/mcpl/errors.ts | 18 ++++ src/mcpl/server-connection.ts | 8 +- test/channel-registry-routing.test.ts | 107 ++++++++++++++++++++- test/explicit-prose-routing.test.ts | 37 ++++++- test/trunk-channel-routing.test.ts | 2 +- 8 files changed, 250 insertions(+), 32 deletions(-) diff --git a/changelog.d/inbound-only-channels.fixed.md b/changelog.d/inbound-only-channels.fixed.md index d520961cc..489cf3842 100644 --- a/changelog.d/inbound-only-channels.fixed.md +++ b/changelog.d/inbound-only-channels.fixed.md @@ -1,20 +1,31 @@ -- The host no longer posts into a channel its server declared inbound-only - (`direction: "inbound"`, SPEC §14.2). pocket-body's live transcript, - `body:ears`, is such a channel, and an agent speaking in the room arrives on - it addressed. Until now that moved a resident's plain speech there, the - delivery attempt opened the channel if she had closed it, and pocket-body - refused the publish. +- The host no longer publishes into a channel its server declared inbound-only + (`direction: "inbound"`, SPEC §14.2). A server's live-transcript channel can + be one, where an agent speaking in the room arrives addressed. Until now + that moved a resident's plain speech there, the delivery attempt opened the + channel if she had closed it, and the server refused the publish. - Plain speech aimed there is refused before the channel is opened or published to, and the send-failure marker names the declaration. - A `>>` or `>>>` target naming the channel bounces with the same reason, - and the closed-channel invitation offers no reply into it. + and the closed-channel invitation offers no reply into it. In explicit + mode the refused target also clears the turn's destination, so the turn's + later text without a prefix bounces too, rather than going to the + destination named before it. - `channel_publish` and the subconscious's `speak_in_channel` return an error that names it. The outgoing stream skips the channel, and the channel context names no default outgoing channel for it. + - A channel the host knows only from a push is refused once the server's + answer to opening it declares it inbound-only. - The routing notices say plain speech is refused there, and to use that server's own tools. The locus and hybrid mode primers, shown when an agent's mode changes, say plain speech goes to the locus unless its server declared that channel inbound-only. +- When a server answers a publish of plain speech with an error, the agent + gets the send-failure marker with the server's error, and an + `mcpl:speech-route-failed` trace goes out. Before, the error was only + logged. A publish that times out or loses its connection is still only + logged, since whether the server posted it is unknown. +- Speech into a channel that is removed while the host opens it is refused, + and nothing is published to it. - `speak_in_channel`'s `mcpl:speech-routed` trace now follows a publish the server accepted and didn't report undelivered, with the fields `routeSpeech`'s trace has (`serverId`, `delivered`, `textLen` and the diff --git a/src/framework.ts b/src/framework.ts index ec47c8c69..00458d5ab 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -237,7 +237,7 @@ function estimateAppendedRoundTokens( /** * What a notice says in place of "your plain speech lands there" when the * channel's server declared it inbound-only (isInboundOnly in the channel - * registry, which posts nothing there). ChannelRegistry.inboundOnlyServer + * registry, which publishes nothing there). ChannelRegistry.inboundOnlyServer * names the declaring server. */ function inboundOnlySpeechNote(serverId: string): string { @@ -331,7 +331,8 @@ const PROSE_ROUTING_HELP = ' >>#channel-name … or >>@person … (DM) or >>service:guild:id …\n' + ' One message may hold several sections: each line starting with ">>" begins\n' + ' a new envelope, routed to its own destination independently.\n' + - ' The first destination in a turn applies to the rest of that turn.\n' + + ' A destination applies to the rest of that turn, until another is named.\n' + + ' A channel its server declared inbound-only is refused, and clears it.\n' + ' Append " !" after the destination (e.g. ">>#ops !") to start your next turn\n' + ' immediately when this one ends, instead of pausing until the next event.\n' + ' >>skip_reply — text stays in your context only, like the skip_reply tool.\n' + @@ -534,7 +535,7 @@ function splitExplicitEnvelopes(rawText: string): string[] { */ interface AdmittedEnvelope { text: string; - resolved?: { channelId: string } | { error: string; candidates?: string[] }; + resolved?: { channelId: string } | { error: string; candidates?: string[]; refusedChannelId?: string }; } /** @@ -10959,11 +10960,17 @@ export class AgentFramework { * explicit gateway sets it once the round's envelopes run: each `>>` * envelope whose target resolved becomes the destination. One that didn't * resolve bounces and leaves it, as do `>>skip_reply` and unprefixed text. + * One refused for speech (its server declared the channel inbound-only) + * bounces and clears it: the text after it was meant for that channel, so + * it bounces too rather than going to the turn's earlier destination. */ private roundDestination(current: string | null, envelopes: AdmittedEnvelope[]): string | null { let destination = current; for (const envelope of envelopes) { - if (envelope.resolved && 'channelId' in envelope.resolved) destination = envelope.resolved.channelId; + const resolved = envelope.resolved; + if (!resolved) continue; + if ('channelId' in resolved) destination = resolved.channelId; + else if (resolved.refusedChannelId) destination = null; } return destination; } @@ -11103,6 +11110,10 @@ export class AgentFramework { const res = envelope.resolved; if (!res) throw new Error(`[prose] ${name}: a >> envelope reached delivery without the target admitted with it`); if ('error' in res) { + // A target refused for speech ends the turn's destination, as + // roundDestination does: the text after it was meant for that + // channel, not the earlier one, so it bounces too. + if (res.refusedChannelId) delete routing.pin; this.bounceProse(agent, parsed.body, res.error, res.candidates); return; } @@ -11113,7 +11124,7 @@ export class AgentFramework { } else { const sticky = routing.pin; if (!sticky) { - this.bounceProse(agent, rawText, 'no destination prefix and no destination set yet this turn'); + this.bounceProse(agent, rawText, 'no destination prefix, and this turn has no destination now'); return; } targetChannel = sticky; diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index e69d255d4..83b5f354c 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -42,6 +42,7 @@ import { isVisiblyEmptyContent } from './visible-content.js'; import { logDetail, logValue } from './log-value.js'; import { RefusalLines } from './refusal-lines.js'; import { CapabilityGrant } from './capability-grant.js'; +import { McplErrorResponse } from './errors.js'; // ============================================================================ // Typing indicator interval (Discord typing lasts ~10s, so 7s keeps it alive) @@ -215,10 +216,11 @@ interface ChannelEntry { /** * A channel its server declared inbound-only (SPEC §14.2 `direction`): its - * messages come in, and the host posts nothing into it. No publish, from + * messages come in, and the host publishes nothing into it. No publish, from * plain speech, `channel_publish` or `publishForAgent`, and no outgoing - * stream. A channel declared `outbound` or `bidirectional`, or with no - * direction, isn't one. + * stream. Typing and acknowledgements, which carry no content, still go + * (SPEC §14.5: "Delivery occurs only via `channels/publish`"). A channel + * declared `outbound` or `bidirectional`, or with no direction, isn't one. */ function isInboundOnly(entry: ChannelEntry): boolean { return entry.descriptor.direction === 'inbound'; @@ -229,7 +231,7 @@ function isInboundOnly(entry: ChannelEntry): boolean { function inboundOnlyReason(entry: ChannelEntry): string { return ( `inbound-only: server "${entry.serverId}" declared this channel's direction "inbound", ` + - 'so the host posts nothing into it; to speak there, use that server\'s own tools' + 'so the host publishes nothing into it; to speak there, use that server\'s own tools' ); } @@ -1503,7 +1505,7 @@ export class ChannelRegistry { } // Default outgoing: the resolved outbound locus (home channel for forks), - // unless its server declared it inbound-only: routeSpeech posts nothing + // unless its server declared it inbound-only: routeSpeech publishes nothing // there, so it isn't where the agent's speech goes. if (outgoing && !this.inboundOnlyServer(outgoing)) { context.defaultOutgoing = { @@ -2476,6 +2478,16 @@ export class ChannelRegistry { ...(history ? { history } : {}), }); entry.open = true; + // The open's answer carries the server's descriptor (§14.3), the first + // place a lazily registered channel's own declaration can arrive. An + // inbound-only declaration there is applied to the channel's current + // entry: whatever the server sent before answering has been applied by + // now, and whatever it sends after will be applied over it. + const answered = (result as { channel?: { id?: unknown; direction?: unknown } } | undefined)?.channel; + const current = this.channels.get(`${entry.serverId}:${entry.descriptor.id}`); + if (current && answered?.id === entry.descriptor.id && answered.direction === 'inbound') { + current.descriptor = { ...current.descriptor, direction: 'inbound' }; + } return result; } @@ -2495,17 +2507,21 @@ export class ChannelRegistry { * * `forSpeech` is for a target the agent's speech will be posted to: a * channel its server declared inbound-only (isInboundOnly) then resolves - * to an error that says so, since nothing is posted there. Without it, as - * for reading a channel's history, any registered channel resolves. + * to an error that says so, since nothing is published there, and that + * names the channel as `refusedChannelId`, so a caller can tell a refused + * channel from a name that resolved to nothing. Without it, as for reading + * a channel's history, any registered channel resolves. */ resolveProseTarget( spec: string, opts?: { forSpeech?: boolean }, - ): { channelId: string; label?: string } | { error: string; candidates?: string[] } { + ): { channelId: string; label?: string } | { error: string; candidates?: string[]; refusedChannelId?: string } { const resolved = this.resolveTargetByName(spec); if (!opts?.forSpeech || 'error' in resolved) return resolved; const entry = this.findChannelEntry(resolved.channelId); - return entry && isInboundOnly(entry) ? { error: `${resolved.channelId} is ${inboundOnlyReason(entry)}` } : resolved; + return entry && isInboundOnly(entry) + ? { error: `${resolved.channelId} is ${inboundOnlyReason(entry)}`, refusedChannelId: resolved.channelId } + : resolved; } private resolveTargetByName( @@ -3132,9 +3148,12 @@ export class ChannelRegistry { * Called by the framework on a text-only turn — replaces the per-surface * sticky auto-post that used to live in discord-mcpl. Returns null when there * is no locus, the channel or its server can't be resolved, its server - * declared it inbound-only, or the open or publish fails. Each of these goes - * through fail(): a trace, and the host's send-failure marker. The speech - * stays in chronicle + module surfaces. + * declared it inbound-only, the channel was removed while it opened, the + * open fails, or the server answers the publish with an error or reports it + * undelivered. Each of these goes through fail(): a trace, and the host's + * send-failure marker. A publish that times out or loses its connection + * rejects instead, since whether the server posted it is unknown, and the + * caller logs it. The speech stays in chronicle + module surfaces. */ async routeSpeech( conversationId: string, @@ -3220,8 +3239,12 @@ export class ChannelRegistry { } } // The open awaited the server, and a channels/changed update or a - // re-register may have declared the channel inbound-only meanwhile. - const declared = this.channels.get(`${entry.serverId}:${entry.descriptor.id}`) ?? entry; + // re-register may have declared the channel inbound-only meanwhile, or a + // removal taken it away: nothing is published to a channel that's gone. + const declared = this.channels.get(`${entry.serverId}:${entry.descriptor.id}`); + if (!declared) { + return fail(channelId, 'the channel was removed while it was opening'); + } if (isInboundOnly(declared)) { return fail(channelId, inboundOnlyReason(declared)); } @@ -3242,7 +3265,18 @@ export class ChannelRegistry { channelId, content: [{ type: 'text', text }], }; - const result = await server.sendChannelsPublish(publishParams); + let result: unknown; + try { + result = await server.sendChannelsPublish(publishParams); + } catch (err) { + // The server answered with an error, so it didn't post the speech. A + // timeout or a lost connection leaves that unknown, and goes to the + // caller as before. + if (err instanceof McplErrorResponse) { + return fail(channelId, err.message); + } + throw err; + } const delivered = (result as { delivered?: boolean } | undefined)?.delivered ?? true; // Surface the posted message's id (ChannelsPublishResult.messageId) so // trace consumers can act on the just-posted message — e.g. a TTS-relay diff --git a/src/mcpl/errors.ts b/src/mcpl/errors.ts index 7bbb90297..8694010c2 100644 --- a/src/mcpl/errors.ts +++ b/src/mcpl/errors.ts @@ -36,6 +36,24 @@ export const UNKNOWN_CHANNEL = -32023; /** Server could not open/connect the requested channel. */ export const CHANNEL_OPEN_FAILED = -32024; +/** + * A server's JSON-RPC error response to a request the host sent: the server + * answered with an error rather than a result. A request that times out, or + * whose connection closes first, rejects with a plain Error instead, since + * whether the server acted on it is unknown. + */ +export class McplErrorResponse extends Error { + readonly code: number; + readonly data?: unknown; + + constructor(message: string, code: number, data?: unknown) { + super(message); + this.name = 'McplErrorResponse'; + this.code = code; + this.data = data; + } +} + // ============================================================================ // Error Factories // ============================================================================ diff --git a/src/mcpl/server-connection.ts b/src/mcpl/server-connection.ts index 533f71be8..6cc953689 100644 --- a/src/mcpl/server-connection.ts +++ b/src/mcpl/server-connection.ts @@ -14,7 +14,7 @@ import { EventEmitter } from 'node:events'; import { openTransport, type McplTransport, type TransportCloseInfo } from './transport.js'; import { maskNegotiatedCapabilities } from './capability-mask.js'; import { CapabilityGrant, expandAdvertisementShorthand } from './capability-grant.js'; -import { CAPABILITY_DISABLED } from './errors.js'; +import { CAPABILITY_DISABLED, McplErrorResponse } from './errors.js'; import { logValue } from './log-value.js'; import type { @@ -1284,7 +1284,11 @@ export class McplServerConnection extends EventEmitter { if (response.error) { pending.reject( - new Error(`MCPL server "${this.id}" returned error for ${pending.method}: [${response.error.code}] ${response.error.message}`), + new McplErrorResponse( + `MCPL server "${this.id}" returned error for ${pending.method}: [${response.error.code}] ${response.error.message}`, + response.error.code, + response.error.data, + ), ); } else { pending.resolve(response.result); diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index 89e3dc9de..9df7a6773 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -2,6 +2,8 @@ import { CapabilityGrant, ALL_CAPABILITY_PATHS } from '../src/mcpl/capability-gr import { test } from 'node:test'; import assert from 'node:assert/strict'; import { ChannelRegistry } from '../src/mcpl/channel-registry.js'; +import { McplErrorResponse } from '../src/mcpl/errors.js'; +import { McplServerConnection } from '../src/mcpl/server-connection.js'; import { parseProsePrefix } from '../src/mcpl/prose-grammar.js'; import type { McplServerRegistry } from '../src/mcpl/server-registry.js'; import type { FeatureSetManager } from '../src/mcpl/feature-set-manager.js'; @@ -33,6 +35,7 @@ function makeRegistry( let failOpens = false; let onOpen: (() => Promise) | undefined; let onPublish: (() => Promise) | undefined; + let openResult: unknown = {}; const mockServer = { // Post-policy state: full grant, so tests exercise delivery, not §5.3 denial. @@ -52,7 +55,7 @@ function makeRegistry( if (failOpens) throw new Error('open refused by server'); openCalls.push(params); await onOpen?.(); - return {}; + return openResult; }, sendChannelsClose: async (params: { channelId?: string }) => { closeCalls.push(params); @@ -90,6 +93,8 @@ function makeRegistry( setOnOpen: (fn: (() => Promise) | undefined) => { onOpen = fn; }, /** Runs while the server is answering a publish, before it returns. */ setOnPublish: (fn: (() => Promise) | undefined) => { onPublish = fn; }, + /** What the server answers channels/open with. */ + setOpenResult: (result: unknown) => { openResult = result; }, }; } @@ -804,6 +809,10 @@ test('an inbound-only channel resolves as a speech target to an error naming the (speech as { error: string }).error, /^body:ears is inbound-only: server "body" declared this channel's direction "inbound"/, ); + // It names the refused channel, which a name that resolves to nothing doesn't. + assert.equal((speech as { refusedChannelId?: string }).refusedChannelId, 'body:ears'); + const nowhere = registry.resolveProseTarget('#nowhere', { forSpeech: true }); + assert.ok('error' in nowhere && !('refusedChannelId' in nowhere), 'an unknown name is no refusal'); // Reading its history still finds it. assert.deepEqual(registry.resolveProseTarget('body:ears'), { channelId: 'body:ears', label: 'Body · ears' }); assert.deepEqual(registry.resolveProseTargetDurable('body:ears'), { channelId: 'body:ears', label: 'Body · ears' }); @@ -847,3 +856,99 @@ test("publishForAgent's routed trace names the server it published through, thou assert.equal(routed.length, 1); assert.equal(routed[0]!.serverId, 'body'); }); + +test("a server's error response to a request rejects with McplErrorResponse, carrying its code and data", async () => { + const connection = new (McplServerConnection as any)('body', null, null) as McplServerConnection; + const internals = connection as unknown as { + pendingRequests: Map; + handleResponse(response: unknown): void; + }; + const answer = new Promise((resolve, reject) => { + internals.pendingRequests.set(7, { resolve, reject, method: 'channels/publish' }); + }); + internals.handleResponse({ jsonrpc: '2.0', id: 7, error: { code: -32017, message: 'Channel not permitted', data: { channelId: 'body:ears' } } }); + await assert.rejects(answer, (err: Error) => { + assert.ok(err instanceof McplErrorResponse); + assert.equal(err.code, -32017); + assert.deepEqual(err.data, { channelId: 'body:ears' }); + assert.equal(err.message, 'MCPL server "body" returned error for channels/publish: [-32017] Channel not permitted'); + return true; + }); +}); + +test("a server's error response to the publish goes through fail(): a trace and the send-failure marker (R1-2)", async () => { + const { registry, failures, traces, publishCalls, setOnPublish } = makeRegistry({ delivered: true }); + await registry.handleRegister('body', { + channels: [{ id: 'body:home', type: 'chat', label: 'Body · home', direction: 'bidirectional' }], + } as never); + const refusal = 'MCPL server "body" returned error for channels/publish: [-32017] Channel not permitted'; + setOnPublish(async () => { throw new McplErrorResponse(refusal, -32017); }); + + assert.equal(await registry.routeSpeech('linn', 'hello', 'body:home'), null); + assert.equal(publishCalls.length, 1, 'the publish was sent, and the server refused it'); + assert.deepEqual(failures.map((f) => f.reason), [refusal]); + const failed = traces.filter((t) => t.type === 'mcpl:speech-route-failed'); + assert.equal(failed.length, 1); + assert.equal(failed[0]!.channelId, 'body:home'); + assert.equal(traces.filter((t) => t.type === 'mcpl:speech-routed').length, 0); +}); + +test('a publish that times out or loses its connection still rejects, with no marker claiming it went undelivered (R1-2)', async () => { + const { registry, failures, traces, setOnPublish } = makeRegistry({ delivered: true }); + await registry.handleRegister('body', { + channels: [{ id: 'body:home', type: 'chat', label: 'Body · home', direction: 'bidirectional' }], + } as never); + setOnPublish(async () => { throw new Error('Request channels/publish to MCPL server "body" timed out after 30000ms'); }); + + await assert.rejects(registry.routeSpeech('linn', 'hello', 'body:home'), /timed out/); + assert.equal(failures.length, 0, 'whether the server posted it is unknown'); + assert.equal(traces.filter((t) => t.type === 'mcpl:speech-route-failed').length, 0); +}); + +test("a lazily registered channel whose open answers inbound-only is refused before the publish, and stays refused (R1-3)", async () => { + const { registry, publishCalls, openCalls, failures, setOpenResult } = makeRegistry({ delivered: true }); + registry.ensureChannelRegistered('body', 'body:ears', 'Body · ears'); + setOpenResult({ channel: { id: 'body:ears', type: 'chat', label: 'Body · ears', direction: 'inbound' } }); + + assert.equal(await registry.routeSpeech('linn', 'hello', 'body:ears'), null); + assert.deepEqual(openCalls.map((c) => c.channelId), ['body:ears'], 'speech opened the channel, as for any closed locus'); + assert.equal(publishCalls.length, 0, 'nothing was published after the server declared it'); + assert.match(failures[0]!.reason, /^inbound-only: server "body" declared this channel's direction "inbound"/); + + // The next speech is refused before anything goes to the server. + assert.equal(await registry.routeSpeech('linn', 'again', 'body:ears'), null); + assert.equal(openCalls.length, 1); + assert.equal(publishCalls.length, 0); + assert.equal(failures.length, 2); +}); + +test("an open's answer changes nothing unless it names the channel and declares it inbound-only (R1-3)", async () => { + for (const answer of [ + { channel: { id: 'body:ears', type: 'chat', label: 'Body · ears', direction: 'bidirectional' } }, + { channel: { id: 'body:other', type: 'chat', label: 'Body · other', direction: 'inbound' } }, + {}, + ]) { + const { registry, publishCalls, failures, setOpenResult } = makeRegistry({ delivered: true }); + registry.ensureChannelRegistered('body', 'body:ears', 'Body · ears'); + setOpenResult(answer); + assert.deepEqual(await registry.routeSpeech('linn', 'hello', 'body:ears'), { delivered: true, channelId: 'body:ears' }, + `published after the answer ${JSON.stringify(answer)}`); + assert.equal(publishCalls.length, 1); + assert.equal(failures.length, 0); + } +}); + +test('a channel removed while the host opens it for speech is refused, and nothing is published (R1-3)', async () => { + const { registry, publishCalls, openCalls, failures, setOnOpen } = makeRegistry({ delivered: true }); + await registry.handleRegister('body', { + channels: [{ id: 'body:home', type: 'chat', label: 'Body · home', direction: 'bidirectional' }], + } as never); + setOnOpen(async () => { + await registry.handleChanged('body', { removed: ['body:home'] } as never); + }); + + assert.equal(await registry.routeSpeech('linn', 'hello', 'body:home'), null); + assert.deepEqual(openCalls.map((c) => c.channelId), ['body:home']); + assert.equal(publishCalls.length, 0, 'nothing was published to the removed channel'); + assert.deepEqual(failures.map((f) => f.reason), ['the channel was removed while it was opening']); +}); diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index 08f8518ef..af1e07d83 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -85,7 +85,7 @@ function stubRegistry(framework: AgentFramework, plan: Array<'delivered' | 'fals resolveProseTarget: (spec: string, opts?: { forSpeech?: boolean }) => spec === '#ears' ? opts?.forSpeech - ? { error: 'body:ears is inbound-only: server "body" declared this channel\'s direction "inbound", so the host posts nothing into it; to speak there, use that server\'s own tools' } + ? { error: 'body:ears is inbound-only: server "body" declared this channel\'s direction "inbound", so the host publishes nothing into it; to speak there, use that server\'s own tools', refusedChannelId: 'body:ears' } : { channelId: 'body:ears', label: 'Body · ears' } : known[spec] ?? { error: `no channel matches "${spec}"` }, routeSpeech: async (_agent: string, text: string, locus?: string | null) => { @@ -1246,6 +1246,11 @@ describe('explicit prose routing', () => { ['>>#nowhere bounced'], ['>>skip_reply kept\nand unprefixed'], ['unprefixed', '>>#alpha second segment'], + // A target refused for speech clears the destination (R1-1), and a + // later one that resolves sets it again. + ['>>#ears for the room'], + ['>>#alpha first\n>>#ears for the room'], + ['>>#ears for the room', '>>#alpha after'], ]; try { for (const segments of rounds) { @@ -1500,6 +1505,36 @@ describe('explicit prose routing', () => { }); } + it("explicit: a target refused for speech clears the turn's destination, so later unprefixed text bounces rather than going to the earlier one", async () => { + // Anarchid's probe (agent-framework#350, R1-1): the turn names #alpha, + // then #ears, which its server declared inbound-only, then writes on + // without a prefix. That text was meant for the room, not #alpha. + membrane.pushResponse(createMockResponse([ + { type: 'text', text: '>>#alpha first' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: '>>#ears for the room' }, + { type: 'tool_use', id: 'c2', name: 'robot--move', input: { dir: 'south' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'and more for the room' }, + ] as ContentBlock[])); + const framework = await createFramework('explicit'); + const routed = stubRegistry(framework); + // The wakes the bounces ask for set the retained text aside. + feedTurn(framework, 2, [{ type: 'text', text: '>>skip_reply' }] as ContentBlock[]); + trigger(framework); + await framework.runUntilIdle(); + assert.deepEqual(routed, [{ text: 'first', locus: 'chan-alpha' }], 'only the text before the refusal was published'); + const bounces = framework.getAgent('assistant')!.getContextManager().getAllMessages().flatMap(m => m.content) + .filter(b => b.type === 'text').map(b => (b as { text: string }).text) + .filter(t => t.startsWith('[prose-routing]') && t.includes('not delivered')); + assert.ok(bounces.some(t => t.includes('body:ears is inbound-only')), `the refused target bounced with its reason: ${bounces.join(' | ')}`); + assert.ok(bounces.some(t => t.includes('this turn has no destination now')), `the later unprefixed text bounced: ${bounces.join(' | ')}`); + await framework.stop(); + }); + it('hybrid leaves unprefixed prose on the frozen locus', async () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Ordinary field speech.' }] as ContentBlock[])); const framework = await createFramework('hybrid'); diff --git a/test/trunk-channel-routing.test.ts b/test/trunk-channel-routing.test.ts index 85a13a57a..177d8feb7 100644 --- a/test/trunk-channel-routing.test.ts +++ b/test/trunk-channel-routing.test.ts @@ -323,7 +323,7 @@ describe('Trunk channel routing (item-3 redux)', () => { it('the invitation offers no reply into a channel its server declared inbound-only, and says why', async () => { // pocket-body's live transcript: another agent speaking in the room arrives - // on it addressed, and the host posts nothing into it, so a ">>" reply would + // on it addressed, and the host publishes nothing into it, so a ">>" reply would // only be refused. const framework = await makeFramework(); const i = internals(framework); From 487b8d935f0c4fbeff384695d9033f51496ee480 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Thu, 15 Oct 2026 10:13:19 -0400 Subject: [PATCH 04/14] fix(channels): an open's answer counts wherever the host opens, a refusal at delivery clears the destination, and the marker gives the server's words Completes the round answering Anarchid's review of #350. - Explicit mode: a delivery routeSpeech refuses because the channel's server has declared it inbound-only since the round was admitted (by a channels/changed, or in its answer to opening it) now clears the turn's pin, as a refusal at admission does, so later unprefixed text bounces rather than meeting the same refusal. Any other failed delivery keeps the pin, as before. - An open's answer is applied in one place, applyOpenAnswer, wherever the host opens a channel: openChannelNow and reconcile's reopening, which discarded it. It marks the channel's current entry open, which a register or channels/changed during the open may have replaced, and applies an inbound-only declaration the answer makes for that channel. It applies no other direction: a channels/changed that arrives in the same read as the answer is handled first, so an answer able to lift a refusal could publish into a channel just declared inbound-only. - McplErrorResponse keeps the server's own message as serverMessage, and the send-failure marker for a refused publish gives it, as 'server "X" refused the publish: ', without the method and the JSON-RPC code. Its doc says which rejections stay plain: a timeout, a connection lost before an answer or already closed, and a failed handshake. - routeSpeech's comments say that the auto-open notice reports an open that has happened even when the speech is then refused, and that a connection already closed rejects before anything is sent. - prose_help says a name matching no channel, or several, bounces and leaves the destination. TurnRouting.destination's doc says the turn's final text doesn't set it: the stream settles the turn before that text is delivered, so a later start, a ' !' continuation included, opens a turn of its own. - The changelog and comments are corrected where they overstated: a hybrid >>> target bounced a server's error before and still bounces a timeout, speak_in_channel traces any unsuccessful result as failed, and the host publishes rather than posts nothing into such channels. Tests: a delivery refused at delivery (and a control that keeps the pin), a target refused in the turn's final text with a ' !' continuation that streams nothing to the earlier channel, a reconcile reopening that applies the answer, an answer that can't lift an inbound-only declaration, one that arrives in the same read as a newer channels/changed, a channel announced again during the open recorded open, a removal during an open whose answer declares inbound, the marker's wording, the connection's serverMessage, the auto-open notice beside a refusal, and a timeout and a closed connection that aren't McplErrorResponse. Co-Authored-By: Claude Opus 5.5 --- changelog.d/inbound-only-channels.fixed.md | 22 ++-- src/framework.ts | 16 ++- src/mcpl/channel-registry.ts | 57 ++++++--- src/mcpl/errors.ts | 15 ++- src/mcpl/server-connection.ts | 1 + test/channel-registry-routing.test.ts | 142 +++++++++++++++++++-- test/explicit-prose-routing.test.ts | 66 ++++++++++ test/mcpl-request-timeout.test.ts | 4 + 8 files changed, 280 insertions(+), 43 deletions(-) diff --git a/changelog.d/inbound-only-channels.fixed.md b/changelog.d/inbound-only-channels.fixed.md index 489cf3842..8a2a48af7 100644 --- a/changelog.d/inbound-only-channels.fixed.md +++ b/changelog.d/inbound-only-channels.fixed.md @@ -3,8 +3,9 @@ be one, where an agent speaking in the room arrives addressed. Until now that moved a resident's plain speech there, the delivery attempt opened the channel if she had closed it, and the server refused the publish. - - Plain speech aimed there is refused before the channel is opened or - published to, and the send-failure marker names the declaration. + - Plain speech aimed at a channel the host knows is inbound-only is + refused before the channel is opened or published to, and the + send-failure marker names the declaration. - A `>>` or `>>>` target naming the channel bounces with the same reason, and the closed-channel invitation offers no reply into it. In explicit mode the refused target also clears the turn's destination, so the turn's @@ -14,20 +15,25 @@ error that names it. The outgoing stream skips the channel, and the channel context names no default outgoing channel for it. - A channel the host knows only from a push is refused once the server's - answer to opening it declares it inbound-only. + answer to opening it, wherever the host opens it, declares it + inbound-only. - The routing notices say plain speech is refused there, and to use that server's own tools. The locus and hybrid mode primers, shown when an agent's mode changes, say plain speech goes to the locus unless its server declared that channel inbound-only. - When a server answers a publish of plain speech with an error, the agent - gets the send-failure marker with the server's error, and an + gets the send-failure marker with the server's own message, and an `mcpl:speech-route-failed` trace goes out. Before, the error was only - logged. A publish that times out or loses its connection is still only - logged, since whether the server posted it is unknown. + logged, unless the text had a hybrid `>>>` target, which bounced it. Any + other failed publish still rejects as before: one that times out or loses + its connection before an answer, whose outcome is unknown, or one whose + connection was already closed. Plain speech logs it, and a hybrid `>>>` + target bounces it. - Speech into a channel that is removed while the host opens it is refused, and nothing is published to it. - `speak_in_channel`'s `mcpl:speech-routed` trace now follows a publish the server accepted and didn't report undelivered, with the fields `routeSpeech`'s trace has (`serverId`, `delivered`, `textLen` and the - surface's `messageId`). A refused or undelivered publish is traced as - `mcpl:speech-route-failed` instead. + surface's `messageId`). Any other outcome, as the tool's result reports + it, is traced as `mcpl:speech-route-failed`: a refusal, a publish the + server reported undelivered, or one that failed, a timeout included. diff --git a/src/framework.ts b/src/framework.ts index 00458d5ab..9b8d3e251 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -332,7 +332,8 @@ const PROSE_ROUTING_HELP = ' One message may hold several sections: each line starting with ">>" begins\n' + ' a new envelope, routed to its own destination independently.\n' + ' A destination applies to the rest of that turn, until another is named.\n' + - ' A channel its server declared inbound-only is refused, and clears it.\n' + + ' A name that matches no channel, or several, bounces and leaves it as it\n' + + ' was. A channel its server declared inbound-only is refused, and clears it.\n' + ' Append " !" after the destination (e.g. ">>#ops !") to start your next turn\n' + ' immediately when this one ends, instead of pausing until the next event.\n' + ' >>skip_reply — text stays in your context only, like the skip_reply tool.\n' + @@ -595,7 +596,9 @@ interface TurnRouting { /** * Explicit mode: the destination the turn's delivered rounds have set, * decided at each round boundary (roundDestination), where each of the - * turn's streams starts. + * turn's streams starts. The turn's final text doesn't set it: the stream + * settles the turn before delivering that text, so no later stream of the + * turn starts after it, and a later start opens a turn of its own. */ destination: string | null; /** @@ -11145,6 +11148,15 @@ export class AgentFramework { if (result?.delivered) { this.proseBounceStreaks.delete(name); if (usedClipboard) this.proseClipboards.delete(name); + } else if (routing.pin === targetChannel && this.channelRegistry!.inboundOnlyServer(targetChannel) !== undefined) { + // routeSpeech refused it for speech: its server declared it + // inbound-only after the round was admitted, or in its answer to + // opening it. The pin goes, as a refusal at admission takes it, so + // later text bounces rather than meeting the same refusal. The + // stream's destination is left: while the channel stays + // inbound-only, the stream withholds it anyway. Another failed + // delivery keeps the pin, as before. + delete routing.pin; } } catch (err) { console.error(`[prose] ${name}: delivery to ${targetChannel} failed:`, err); diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 83b5f354c..52f906cbb 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -2284,12 +2284,13 @@ export class ChannelRegistry { } try { - await server.sendChannelsOpen({ + const answer = await server.sendChannelsOpen({ channelId: channel.id, type: channel.type, address: channel.address, }); if (entry) entry.open = true; + this.applyOpenAnswer(serverId, channel.id, answer); } catch (err) { if (entry) entry.open = false; this.emitTraceFn({ @@ -2478,17 +2479,34 @@ export class ChannelRegistry { ...(history ? { history } : {}), }); entry.open = true; - // The open's answer carries the server's descriptor (§14.3), the first - // place a lazily registered channel's own declaration can arrive. An - // inbound-only declaration there is applied to the channel's current - // entry: whatever the server sent before answering has been applied by - // now, and whatever it sends after will be applied over it. - const answered = (result as { channel?: { id?: unknown; direction?: unknown } } | undefined)?.channel; - const current = this.channels.get(`${entry.serverId}:${entry.descriptor.id}`); - if (current && answered?.id === entry.descriptor.id && answered.direction === 'inbound') { + this.applyOpenAnswer(entry.serverId, entry.descriptor.id, result); + return result; + } + + /** + * What a successful `channels/open` tells the host, wherever the host + * opens a channel: the channel is open, and the answer carries the + * server's descriptor for it (§14.3), the first place a lazily registered + * channel's own declaration can arrive. Both go to the channel's current + * entry, which a register or `channels/changed` during the open may have + * replaced. + * + * Only an inbound-only declaration is applied. The answer isn't always the + * server's latest word: a `channels/changed` that arrives in the same read + * as the answer is handled before the open's caller resumes, so the answer + * is applied over it. Applying only a refusal keeps that race fail-closed: + * a stale answer can keep a channel refused until the server's next + * update, but never lift a newer refusal. A server lifts its declaration + * by changing or registering the channel again. + */ + private applyOpenAnswer(serverId: string, channelId: string, answer: unknown): void { + const current = this.channels.get(`${serverId}:${channelId}`); + if (!current) return; + current.open = true; + const declared = (answer as { channel?: { id?: unknown; direction?: unknown } } | undefined)?.channel; + if (declared?.id === channelId && declared.direction === 'inbound') { current.descriptor = { ...current.descriptor, direction: 'inbound' }; } - return result; } /** @@ -3120,8 +3138,8 @@ export class ChannelRegistry { // never-guess rule that governs delivery. const matches = [...this.channels.values()].filter((e) => e.descriptor.id === channelId); if (matches.length !== 1) return null; - // A stream is a copy of speech the host would post, and nothing is - // posted into an inbound-only channel. + // A stream is a copy of speech the host would publish, and nothing is + // published into an inbound-only channel. if (isInboundOnly(matches[0]!)) return null; const server = this.serverRegistry.getServer(matches[0]!.serverId); // §5.4: the GRANT gates streaming, not the raw advertisement. The old @@ -3214,6 +3232,10 @@ export class ChannelRegistry { // alive with the reply. If the open fails, the speech does NOT go out: // a half-alive delivery is worse than a loud marker. if (!entry.open) { + // The trace and the notice below report the open, which has happened + // whatever the checks after it decide: speech they refuse leaves the + // channel open, and the notice is how the agent learns that and how to + // close it. The send-failure marker reports the speech. try { await this.openChannelNow(entry, 'opened-by-delivery'); console.error( @@ -3269,11 +3291,14 @@ export class ChannelRegistry { try { result = await server.sendChannelsPublish(publishParams); } catch (err) { - // The server answered with an error, so it didn't post the speech. A - // timeout or a lost connection leaves that unknown, and goes to the - // caller as before. + // An error response is the server's answer that the publish failed, so + // the marker says the human didn't receive it, in the server's own + // words. Any other rejection goes to the caller as before: after a + // timeout, or a connection lost before an answer, whether the server + // posted it is unknown, and a connection already closed rejects before + // anything is sent. if (err instanceof McplErrorResponse) { - return fail(channelId, err.message); + return fail(channelId, `server "${entry.serverId}" refused the publish: ${err.serverMessage}`); } throw err; } diff --git a/src/mcpl/errors.ts b/src/mcpl/errors.ts index 8694010c2..9940720ea 100644 --- a/src/mcpl/errors.ts +++ b/src/mcpl/errors.ts @@ -37,19 +37,24 @@ export const UNKNOWN_CHANNEL = -32023; export const CHANNEL_OPEN_FAILED = -32024; /** - * A server's JSON-RPC error response to a request the host sent: the server - * answered with an error rather than a result. A request that times out, or - * whose connection closes first, rejects with a plain Error instead, since - * whether the server acted on it is unknown. + * A server's JSON-RPC error response to a request the host sent once + * connected: the server answered with an error rather than a result. + * `serverMessage` is the error's own message, as the server sent it, and + * `message` adds which server answered which request. A request that times + * out, whose connection closes before an answer, or that finds its connection + * already closed, rejects with a plain Error instead, as does a failed + * handshake. */ export class McplErrorResponse extends Error { readonly code: number; + readonly serverMessage: string; readonly data?: unknown; - constructor(message: string, code: number, data?: unknown) { + constructor(message: string, code: number, serverMessage: string, data?: unknown) { super(message); this.name = 'McplErrorResponse'; this.code = code; + this.serverMessage = serverMessage; this.data = data; } } diff --git a/src/mcpl/server-connection.ts b/src/mcpl/server-connection.ts index 6cc953689..8b6d1037a 100644 --- a/src/mcpl/server-connection.ts +++ b/src/mcpl/server-connection.ts @@ -1287,6 +1287,7 @@ export class McplServerConnection extends EventEmitter { new McplErrorResponse( `MCPL server "${this.id}" returned error for ${pending.method}: [${response.error.code}] ${response.error.message}`, response.error.code, + String(response.error.message), response.error.data, ), ); diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index 9df7a6773..6b352002e 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -872,21 +872,37 @@ test("a server's error response to a request rejects with McplErrorResponse, car assert.equal(err.code, -32017); assert.deepEqual(err.data, { channelId: 'body:ears' }); assert.equal(err.message, 'MCPL server "body" returned error for channels/publish: [-32017] Channel not permitted'); + assert.equal((err as McplErrorResponse).serverMessage, 'Channel not permitted'); return true; }); }); +test('a request on a connection already closed rejects with a plain Error, since nothing was sent (R1-2)', async () => { + const connection = new (McplServerConnection as any)('body', null, null) as McplServerConnection; + (connection as unknown as { closed: boolean }).closed = true; + await assert.rejects( + connection.sendChannelsPublish({ conversationId: 'linn', channelId: 'body:home', content: [{ type: 'text', text: 'hello' }] }), + (err: Error) => { + assert.equal(err instanceof McplErrorResponse, false, `a plain Error, not ${err.name}`); + assert.match(err.message, /is closed/); + return true; + }, + ); +}); + test("a server's error response to the publish goes through fail(): a trace and the send-failure marker (R1-2)", async () => { const { registry, failures, traces, publishCalls, setOnPublish } = makeRegistry({ delivered: true }); await registry.handleRegister('body', { channels: [{ id: 'body:home', type: 'chat', label: 'Body · home', direction: 'bidirectional' }], } as never); - const refusal = 'MCPL server "body" returned error for channels/publish: [-32017] Channel not permitted'; - setOnPublish(async () => { throw new McplErrorResponse(refusal, -32017); }); + setOnPublish(async () => { + throw new McplErrorResponse('MCPL server "body" returned error for channels/publish: [-32017] Channel not permitted', -32017, 'Channel not permitted'); + }); assert.equal(await registry.routeSpeech('linn', 'hello', 'body:home'), null); assert.equal(publishCalls.length, 1, 'the publish was sent, and the server refused it'); - assert.deepEqual(failures.map((f) => f.reason), [refusal]); + assert.deepEqual(failures.map((f) => f.reason), ['server "body" refused the publish: Channel not permitted'], + "the marker gives the server's own words, without the method or the code"); const failed = traces.filter((t) => t.type === 'mcpl:speech-route-failed'); assert.equal(failed.length, 1); assert.equal(failed[0]!.channelId, 'body:home'); @@ -906,7 +922,9 @@ test('a publish that times out or loses its connection still rejects, with no ma }); test("a lazily registered channel whose open answers inbound-only is refused before the publish, and stays refused (R1-3)", async () => { - const { registry, publishCalls, openCalls, failures, setOpenResult } = makeRegistry({ delivered: true }); + const announced: Array<{ source: string; channels: Array<{ channelId: string }> }> = []; + const { registry, publishCalls, openCalls, failures, traces, setOpenResult } = + makeRegistry({ delivered: true }, undefined, undefined, (info) => { announced.push(info); }); registry.ensureChannelRegistered('body', 'body:ears', 'Body · ears'); setOpenResult({ channel: { id: 'body:ears', type: 'chat', label: 'Body · ears', direction: 'inbound' } }); @@ -914,6 +932,11 @@ test("a lazily registered channel whose open answers inbound-only is refused bef assert.deepEqual(openCalls.map((c) => c.channelId), ['body:ears'], 'speech opened the channel, as for any closed locus'); assert.equal(publishCalls.length, 0, 'nothing was published after the server declared it'); assert.match(failures[0]!.reason, /^inbound-only: server "body" declared this channel's direction "inbound"/); + // The open happened, and the channel stays open: the notice says so, and + // how to close it, while the marker reports the refused speech. + assert.deepEqual(announced.map((a) => [a.source, a.channels.map((c) => c.channelId)]), [['opened-by-delivery', ['body:ears']]]); + assert.equal(traces.filter((t) => t.type === 'mcpl:channel-opened-by-send').length, 1); + assert.equal(registry.isChannelOpen('body:ears'), true); // The next speech is refused before anything goes to the server. assert.equal(await registry.routeSpeech('linn', 'again', 'body:ears'), null); @@ -922,7 +945,7 @@ test("a lazily registered channel whose open answers inbound-only is refused bef assert.equal(failures.length, 2); }); -test("an open's answer changes nothing unless it names the channel and declares it inbound-only (R1-3)", async () => { +test("an open's answer that names another channel, declares no direction or declares one that isn't inbound leaves speech published (R1-3)", async () => { for (const answer of [ { channel: { id: 'body:ears', type: 'chat', label: 'Body · ears', direction: 'bidirectional' } }, { channel: { id: 'body:other', type: 'chat', label: 'Body · other', direction: 'inbound' } }, @@ -939,16 +962,111 @@ test("an open's answer changes nothing unless it names the channel and declares }); test('a channel removed while the host opens it for speech is refused, and nothing is published (R1-3)', async () => { - const { registry, publishCalls, openCalls, failures, setOnOpen } = makeRegistry({ delivered: true }); + // Whatever the open's answer declares: there's no entry left to apply it to. + for (const answer of [{}, { channel: { id: 'body:home', type: 'chat', label: 'Body · home', direction: 'inbound' } }]) { + const { registry, publishCalls, openCalls, failures, setOnOpen, setOpenResult } = makeRegistry({ delivered: true }); + await registry.handleRegister('body', { + channels: [{ id: 'body:home', type: 'chat', label: 'Body · home', direction: 'bidirectional' }], + } as never); + setOpenResult(answer); + setOnOpen(async () => { + await registry.handleChanged('body', { removed: ['body:home'] } as never); + }); + + assert.equal(await registry.routeSpeech('linn', 'hello', 'body:home'), null); + assert.deepEqual(openCalls.map((c) => c.channelId), ['body:home']); + assert.equal(publishCalls.length, 0, 'nothing was published to the removed channel'); + assert.deepEqual(failures.map((f) => f.reason), ['the channel was removed while it was opening'], + `refused as removed, after the answer ${JSON.stringify(answer)}`); + } +}); + +test("an open's answer can't lift an inbound-only declaration: a server lifts it by changing or registering the channel (R1-3)", async () => { + const { registry, publishCalls, failures, setOpenResult } = makeRegistry({ delivered: true }); await registry.handleRegister('body', { - channels: [{ id: 'body:home', type: 'chat', label: 'Body · home', direction: 'bidirectional' }], + channels: [{ id: 'body:ears', type: 'chat', label: 'Body · ears', direction: 'inbound' }], + } as never); + setOpenResult({ channel: { id: 'body:ears', type: 'chat', label: 'Body · ears', direction: 'bidirectional' } }); + const opened = await registry.handleChannelToolCall('channel_open', { channelId: 'body:ears' }, { kind: 'agent', agentName: 'linn' }); + assert.equal(opened.success, true); + assert.equal(await registry.routeSpeech('linn', 'hello', 'body:ears'), null, 'still refused after the answer'); + assert.equal(publishCalls.length, 0); + assert.match(failures[0]!.reason, /^inbound-only: server "body" declared/); +}); + +test("an open's answer that arrives in the same read as a newer channels/changed can't lift its refusal (R1-3)", async () => { + // Greta-2425's probe: the connection resolves the open's answer, then + // handles the next line at once, so the answer is applied after, and + // over, the channels/changed the server sent after it. + let answerOpen!: (answer: unknown) => void; + const publishCalls: string[] = []; + const failures: string[] = []; + const mockServer = { + grant: new CapabilityGrant(new Set(ALL_CAPABILITY_PATHS), []), + sendChannelsPublish: async (p: { channelId?: string }) => { publishCalls.push(String(p.channelId)); return { delivered: true }; }, + sendChannelsOutgoingChunk: () => {}, + sendChannelsOutgoingComplete: () => {}, + sendChannelsOpen: () => new Promise((resolve) => { answerOpen = resolve; }), + sendChannelsClose: async () => ({}), + }; + const registry = new ChannelRegistry( + { getServer: () => mockServer } as unknown as McplServerRegistry, + {} as FeatureSetManager, + () => {}, + () => {}, + { onRouteFailure: (info) => { failures.push(info.reason); } }, + ); + await registry.handleChanged('body', { + added: [{ id: 'body:ears', type: 'chat', label: 'Body · ears', direction: 'bidirectional' }], } as never); + const delivery = registry.routeSpeech('linn', 'hello', 'body:ears'); + await new Promise((resolve) => setImmediate(resolve)); + answerOpen({ channel: { id: 'body:ears', type: 'chat', label: 'Body · ears', direction: 'bidirectional' } }); + void registry.handleChanged('body', { + updated: [{ id: 'body:ears', type: 'chat', label: 'Body · ears', direction: 'inbound' }], + } as never); + + assert.equal(await delivery, null); + assert.deepEqual(publishCalls, [], 'nothing was published into the channel declared inbound-only'); + assert.match(failures[0]!, /^inbound-only: server "body" declared/); +}); + +test("an open by reconcile applies its answer too: a channel the answer declares inbound-only is refused (R1-3)", async () => { + const { registry, publishCalls, openCalls, failures, setOpenResult } = makeRegistry({ delivered: true }); + const ears = { id: 'body:ears', type: 'chat', label: 'Body · ears' }; + await registry.handleRegister('body', { channels: [ears] } as never); + const opened = await registry.handleChannelToolCall('channel_open', { channelId: 'body:ears' }, { kind: 'agent', agentName: 'linn' }); + assert.equal(opened.success, true); + // The server registers again, as after a reconnect, and reconcile reopens + // the channel the agent opened: this time the answer declares it. + setOpenResult({ channel: { ...ears, direction: 'inbound' } }); + await registry.handleRegister('body', { channels: [ears] } as never); + assert.equal(openCalls.length, 2, 'reconcile opened it again'); + + assert.equal(await registry.routeSpeech('linn', 'hello', 'body:ears'), null); + assert.equal(publishCalls.length, 0); + assert.match(failures[0]!.reason, /^inbound-only: server "body" declared/); +}); + +test('a channel announced again while the host opens it is recorded open once the open answers, and the publish goes there (R1-3)', async () => { + const { registry, publishCalls, setOnOpen } = makeRegistry({ delivered: true }); + const home = { id: 'body:home', type: 'chat', label: 'Body · home', direction: 'bidirectional' }; + await registry.handleRegister('body', { channels: [home] } as never); + // The announcement replaces the entry at once, closed, and its reconcile's + // own open waits until the speech is done. + let release!: () => void; + const held = new Promise((resolve) => { release = resolve; }); + let announced: Promise | undefined; + let opens = 0; setOnOpen(async () => { - await registry.handleChanged('body', { removed: ['body:home'] } as never); + opens++; + if (opens === 1) announced = registry.handleChanged('body', { added: [home] } as never); + else await held; }); - assert.equal(await registry.routeSpeech('linn', 'hello', 'body:home'), null); - assert.deepEqual(openCalls.map((c) => c.channelId), ['body:home']); - assert.equal(publishCalls.length, 0, 'nothing was published to the removed channel'); - assert.deepEqual(failures.map((f) => f.reason), ['the channel was removed while it was opening']); + assert.deepEqual(await registry.routeSpeech('linn', 'hello', 'body:home'), { delivered: true, channelId: 'body:home' }); + assert.equal(publishCalls.length, 1); + assert.equal(registry.isChannelOpen('body:home'), true, "the channel's current entry is recorded open before its reconcile's open answers"); + release(); + await announced; }); diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index af1e07d83..1dcc15074 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -1535,6 +1535,72 @@ describe('explicit prose routing', () => { await framework.stop(); }); + for (const refusal of ['inbound-only', 'another failure'] as const) { + it(`explicit: a delivery routeSpeech fails (${refusal}) ${refusal === 'inbound-only' ? "clears the turn's destination, as a refusal at admission does" : 'keeps the destination, as before'}`, async () => { + // #alpha resolves when the round is admitted. By its delivery, its + // server has declared it inbound-only (as a channels/changed, or the + // answer to opening it, can), or the publish fails some other way. + membrane.pushResponse(createMockResponse([ + { type: 'text', text: '>>#alpha first' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'and more' }, + ] as ContentBlock[])); + const framework = await createFramework('explicit'); + const routed = stubRegistry(framework); + const registry = (framework as unknown as { channelRegistry: Record }).channelRegistry; + const declared = new Set(); + registry.inboundOnlyServer = (channelId: string) => (declared.has(channelId) ? 'body' : undefined); + registry.routeSpeech = async (_agent: string, text: string, locus?: string | null) => { + routed.push({ text, locus: locus ?? null }); + if (refusal === 'inbound-only') declared.add(locus ?? ''); + return null; + }; + feedTurn(framework, 2, [{ type: 'text', text: '>>skip_reply' }] as ContentBlock[]); + trigger(framework); + await framework.runUntilIdle(); + const bounces = context(framework).texts.filter((t) => t.startsWith('[prose-routing]') && t.includes('not delivered')); + if (refusal === 'inbound-only') { + assert.deepEqual(routed, [{ text: 'first', locus: 'chan-alpha' }], 'the later text never went to the refused channel'); + assert.ok(bounces.some((t) => t.includes('this turn has no destination now')), `the later text bounced: ${bounces.join(' | ')}`); + } else { + assert.deepEqual(routed, [{ text: 'first', locus: 'chan-alpha' }, { text: 'and more', locus: 'chan-alpha' }]); + assert.equal(bounces.length, 0); + } + await framework.stop(); + }); + } + + it("explicit: a target refused in the turn's final text reaches nothing of its ' !' continuation, which starts a turn of its own", async () => { + // The turn's final text is delivered after the turn settles, so it never + // set the destination a later stream of the turn would start at: the + // continuation is a new turn, with none. + membrane.pushResponse(createMockResponse([ + { type: 'text', text: '>>#alpha first' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: '>>#ears ! for the room' }, + ] as ContentBlock[])); + const framework = await createFramework('explicit'); + const routed = stubRegistry(framework); + const stream = captureStream(framework); + feedTurn(framework, 2, [{ type: 'text', text: 'and more for the room' }] as ContentBlock[]); + feedTurn(framework, 3, [{ type: 'text', text: '>>skip_reply' }] as ContentBlock[]); + feedTurn(framework, 4, [{ type: 'text', text: '>>skip_reply' }] as ContentBlock[]); + trigger(framework); + await framework.runUntilIdle(); + assert.ok(membrane.calls.length >= 2, `the continuation ran (${membrane.calls.length} calls)`); + assert.deepEqual(routed, [{ text: 'first', locus: 'chan-alpha' }]); + const toAlpha = stream.chunks.filter((c) => c.channelId === 'chan-alpha').map((c) => c.delta).join(''); + assert.equal(toAlpha.includes('and more'), false, `the continuation streamed nothing to #alpha: ${toAlpha}`); + assert.equal(stream.completes.some((c) => c.text.includes('and more')), false); + const bounces = context(framework).texts.filter((t) => t.startsWith('[prose-routing]') && t.includes('not delivered')); + assert.ok(bounces.some((t) => t.includes('this turn has no destination now')), `the continuation's text bounced: ${bounces.join(' | ')}`); + await framework.stop(); + }); + it('hybrid leaves unprefixed prose on the frozen locus', async () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Ordinary field speech.' }] as ContentBlock[])); const framework = await createFramework('hybrid'); diff --git a/test/mcpl-request-timeout.test.ts b/test/mcpl-request-timeout.test.ts index 2778469e3..ecbbc4dfb 100644 --- a/test/mcpl-request-timeout.test.ts +++ b/test/mcpl-request-timeout.test.ts @@ -9,6 +9,7 @@ import { test, afterEach } from 'node:test'; import assert from 'node:assert/strict'; import { McplServerConnection } from '../src/mcpl/server-connection.js'; +import { McplErrorResponse } from '../src/mcpl/errors.js'; import type { McplHostCapabilities, McplServerConfig } from '../src/mcpl/types.js'; // Stdio server: answers initialize and tools/list, but swallows tools/call — @@ -64,6 +65,9 @@ test('tools/call against a hung server rejects within the configured timeout', a assert.match(err.message, /did not respond to tools\/call/); assert.match(err.message, /250ms/); assert.match(err.message, /hung/); + // Not a server's answer: a caller that marks McplErrorResponse as + // undelivered (routeSpeech) mustn't mark a request whose outcome is unknown. + assert.equal(err instanceof McplErrorResponse, false); return true; }, ); From 6f656c10b7dd2bdd29605bc5a24bd5d87fb2b31b Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Thu, 15 Oct 2026 23:02:00 -0400 Subject: [PATCH 05/14] fix(channels): a refusal at delivery ends both halves of the destination, a server's error reaches the agent in its own words without claiming a publish failed, and an odd error response settles Follows five more internal reviews of the round answering Anarchid's review of #350, and corrects how that round read a publish's error response. - A server's error response to a publish no longer reads as proof that nothing was posted. An error can follow a partial post: a connector that sends a long reply in parts can fail on a later part after the first is posted. So routeSpeech's failure for it has the outcome 'unknown', and the send-failure marker says delivery wasn't confirmed and may or may not have happened, with the server's words, so sending it again could post it twice. It doesn't tell the reader to check the channel, since the subconscious, which speaks through speak_in_channel, has no tool that reads one. The marker for any other failure is unchanged. onRouteFailure's info and the mcpl:speech-route-failed trace gain the outcome, 'failed' or 'unknown'. channel_publish's error for an error response likewise names the channel and says the publish wasn't confirmed. A timeout or a lost connection still rejects as before. - Explicit mode: a delivery that fails because the channel is inbound-only by then, whether routeSpeech refused it or the publish rejected, also clears the destination later streams of the turn start at (routing.destination), as well as the pin, each while it still names that channel. So if the server lifts the declaration later in the turn, a later stream of the turn doesn't copy text there that delivery bounces. A failed delivery to a channel that isn't inbound-only keeps both, as before. TurnRouting.destination's doc says what sets and clears it. - channel_publish's, channel_open's and channel_close's errors, the error channel_decline gives when its acknowledgement request fails, and the send-failure marker for an open that fails, give a server's error response in its own words (failureText), as the publish marker does, saying the server "answered with an error" rather than that it refused. A failure whose message is empty gives its text instead. - speak_in_channel's mcpl:speech-route-failed trace (publishForAgent) carries the outcome too: 'unknown' after an error response, 'failed' after a refusal or delivered:false, and none after another rejection (a timeout, a lost connection, or a connection already closed), which it can't tell apart. handleToolPublish says which rejection it met. - McplErrorResponse carries what's read of it, the server's words as serverMessage. Its code stays in the operator's message, shown as it came. Any other failure gives its message, or its text when it has none. The open marker no longer says "locus". - An error response's text (handleResponse, for requests sent once the connection is up) is built without String() or a template's conversion of what the server sent. An object message whose toString and valueOf aren't callable threw there, after the request's entry was gone, so the request never settled. Now text a server sends in place of an error object is still its words. A message that isn't text, or an error that's neither an object nor text, is shown as JSON, and one nested too deeply to write is named. A missing message says so, where serverMessage read "undefined". What the agent reads is cut at 200 characters (code points, so a pair is never split), the server's own words included, since this PR is what puts them in front of the agent. The operator's message keeps a text message whole, and the code. The handshake's initialize error, which main builds the old way, is unchanged here. - showServerValue sits above McplServerConnection's doc, not between that doc and the class. routeSpeech's doc names every rejection that stays plain, a connection already closed included. McplErrorResponse's doc says what serverMessage holds when the server sent no text. The changelog says that a channel declared inbound-only after its target resolved is refused at delivery with the marker, unless a later round has named another channel, and that the tools and the open marker give the server's words. Tests: the marker's text for an unknown and a failed outcome, with and without a channel's label; an error response's failure and trace as unknown, delivered:false's and a failed open's as failed, and channel_publish's error for an error response; speak_in_channel's trace outcome after a refusal, delivered:false, an error response and another rejection; each error response's code in the operator's message; a long text error and a non-integer code; a falsy error read as no error; the destination a refusal at delivery leaves, for a refusal and for a rejected publish, and keeps for another failure, refused or rejected, for a delivery that went through, and when a later round has named another channel (Greta-2425's probe); a destination that still names the refused channel ending though the pin has moved off it; a ' !' continuation after a final target that's kept as well as one that's refused; a hybrid target whose publish is refused, with the next round's text held; a lift by channels/changed after the answer; the reconcile's own open entered; the race test waiting on the open itself, and the channel it refuses after opening staying open with the open's notice; every open, publish, close and acknowledgement failure in the server's words, or as a thrown object's message, a thrown string, or one that can't be printed; and error responses with no message, an empty one, an object one, an object code, a string or array error, a long value, a long message, exactly 200 characters, characters past the BMP, and one nested 100,000 deep, each with the operator's message. Co-Authored-By: Claude Opus 5.5 --- changelog.d/inbound-only-channels.fixed.md | 41 +++- src/framework.ts | 74 ++++--- src/mcpl/channel-registry.ts | 93 ++++++--- src/mcpl/errors.ts | 18 +- src/mcpl/server-connection.ts | 59 +++++- src/types/trace.ts | 7 + test/channel-registry-routing.test.ts | 224 +++++++++++++++++++-- test/explicit-prose-routing.test.ts | 211 +++++++++++++++++-- 8 files changed, 615 insertions(+), 112 deletions(-) diff --git a/changelog.d/inbound-only-channels.fixed.md b/changelog.d/inbound-only-channels.fixed.md index 8a2a48af7..45a94e559 100644 --- a/changelog.d/inbound-only-channels.fixed.md +++ b/changelog.d/inbound-only-channels.fixed.md @@ -10,7 +10,10 @@ and the closed-channel invitation offers no reply into it. In explicit mode the refused target also clears the turn's destination, so the turn's later text without a prefix bounces too, rather than going to the - destination named before it. + destination named before it. A channel declared inbound-only only after + the target resolved is refused when the text is delivered, with the + send-failure marker, and in explicit mode that ends the destination too, + unless a later round has named another channel by then. - `channel_publish` and the subconscious's `speak_in_channel` return an error that names it. The outgoing stream skips the channel, and the channel context names no default outgoing channel for it. @@ -21,14 +24,34 @@ server's own tools. The locus and hybrid mode primers, shown when an agent's mode changes, say plain speech goes to the locus unless its server declared that channel inbound-only. -- When a server answers a publish of plain speech with an error, the agent - gets the send-failure marker with the server's own message, and an - `mcpl:speech-route-failed` trace goes out. Before, the error was only - logged, unless the text had a hybrid `>>>` target, which bounced it. Any - other failed publish still rejects as before: one that times out or loses - its connection before an answer, whose outcome is unknown, or one whose - connection was already closed. Plain speech logs it, and a hybrid `>>>` - target bounces it. +- When a server answers a publish of plain speech with an error, the + send-failure marker says delivery wasn't confirmed and may or may not have + happened, since an error can follow a partial post (a long reply sent in + parts whose later part failed), so sending it again could post it twice. + It carries the server's own message, cut at 200 characters, or says it + sent none, and an `mcpl:speech-route-failed` trace goes out with + `outcome: "unknown"`. The marker's and the trace's `outcome` is + `"failed"` for every other failure of plain speech. Before, the error was + only logged, unless the text had a hybrid `>>>` target, which bounced it. + Any other failed publish still rejects as before: one that times out or + loses its connection before an answer, which the server may or may not + have posted, or one whose connection was already closed. Plain speech + logs it, and a hybrid `>>>` target bounces it. `channel_publish`'s error + for a server's error response likewise names the channel and says the + publish wasn't confirmed and that sending it again could post it twice. + For a server's error response, `channel_publish`, `speak_in_channel`, + `channel_open` and `channel_close` errors, the error `channel_decline` + gives when its acknowledgement request fails, and the marker for an open + that fails, give the server's own words, cut the same way. + `speak_in_channel`'s trace carries the outcome too: + `"unknown"` after an error response, `"failed"` after a refusal or + `delivered: false`, and none after another rejection, which it can't tell + apart. +- A server's error response, to a request the host sends once the + connection is up, now settles that request whatever its message is. + Building the error's text from a message that isn't text, such as an + object, could throw before, after the request's entry was gone, so the + request never settled. - Speech into a channel that is removed while the host opens it is refused, and nothing is published to it. - `speak_in_channel`'s `mcpl:speech-routed` trace now follows a publish the diff --git a/src/framework.ts b/src/framework.ts index a33e13c3d..03595faa2 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -602,9 +602,13 @@ interface TurnRouting { /** The sticky destination, set by each `>>` (hybrid: `>>>`) prefix that resolves. */ pin?: string; /** - * Explicit mode: the destination the turn's delivered rounds have set, - * decided at each round boundary (roundDestination), where each of the - * turn's streams starts. The turn's final text doesn't set it: the stream + * Explicit mode: where each of the turn's streams starts. It changes as a + * round is admitted, before its delivery (roundDestination): a `>>` target + * that resolves sets it, and one refused for speech clears it. A target + * that matches no channel, or several, leaves it, and so does a round that + * names none, or one kept private by skip_reply. A failed delivery to a channel that is + * inbound-only by then clears it too, while it still names that channel + * (deliverProseEnvelope). The turn's final text doesn't set it: the stream * settles the turn before delivering that text, so no later stream of the * turn starts after it, and a later start opens a turn of its own. */ @@ -11193,9 +11197,9 @@ export class AgentFramework { } /** - * Where a delivered round leaves its turn's sticky destination, as the - * explicit gateway sets it once the round's envelopes run: each `>>` - * envelope whose target resolved becomes the destination. One that didn't + * Where an admitted round leaves its turn's sticky destination, set as + * the round is admitted, before its delivery: each `>>` envelope whose + * target resolved becomes the destination. One that didn't * resolve bounces and leaves it, as do `>>skip_reply` and unprefixed text. * One refused for speech (its server declared the channel inbound-only) * bounces and clears it: the text after it was meant for that channel, so @@ -11347,9 +11351,12 @@ export class AgentFramework { const res = envelope.resolved; if (!res) throw new Error(`[prose] ${name}: a >> envelope reached delivery without the target admitted with it`); if ('error' in res) { - // A target refused for speech ends the turn's destination, as - // roundDestination does: the text after it was meant for that - // channel, not the earlier one, so it bounces too. + // A target refused for speech ends the turn's destination. This drops + // the pin, which later text follows. roundDestination cleared the + // destination later streams start at when it admitted the round, for + // any round but the turn's final text, after which nothing of the turn + // streams. The text after the target was meant for that channel, not + // the earlier one, so it bounces too. if (res.refusedChannelId) delete routing.pin; this.bounceProse(agent, parsed.body, res.error, res.candidates); return; @@ -11377,24 +11384,29 @@ export class AgentFramework { return; } + let delivered = false; try { const result = await this.channelRegistry!.routeSpeech(name, body, targetChannel); - if (result?.delivered) { + delivered = !!result?.delivered; + if (delivered) { this.proseBounceStreaks.delete(name); if (usedClipboard) this.proseClipboards.delete(name); - } else if (routing.pin === targetChannel && this.channelRegistry!.inboundOnlyServer(targetChannel) !== undefined) { - // routeSpeech refused it for speech: its server declared it - // inbound-only after the round was admitted, or in its answer to - // opening it. The pin goes, as a refusal at admission takes it, so - // later text bounces rather than meeting the same refusal. The - // stream's destination is left: while the channel stays - // inbound-only, the stream withholds it anyway. Another failed - // delivery keeps the pin, as before. - delete routing.pin; } } catch (err) { console.error(`[prose] ${name}: delivery to ${targetChannel} failed:`, err); } + if (!delivered && this.channelRegistry!.inboundOnlyServer(targetChannel) !== undefined) { + // The channel is inbound-only by now: its server declared it after the + // round was admitted, or in its answer to opening it. Whatever made this + // delivery fail, a refusal or a publish that rejected, later text there + // would be refused, so what still names the channel ends, as a refusal + // at admission ends it: the pin, unless a later delivery has moved it, + // and the destination later streams start at, unless a later round has + // named another. A failed delivery to a channel that isn't inbound-only + // keeps them, as before. + if (routing.pin === targetChannel) delete routing.pin; + if (routing.destination === targetChannel) routing.destination = null; + } } /** Cap on consecutive bounce-triggered wakes (notices still append after). */ @@ -12332,7 +12344,7 @@ export class AgentFramework { // Explicit text has no destination until a `>>` prefix names one, // so a fresh turn's stream starts suppressed. A later stream of the // turn (a resume, a retry, a re-inference, a capacity-deferred - // compile) starts at the destination the turn's delivered rounds + // compile) starts at the destination the turn's admitted rounds // have set: a private round's prefixes don't count, and the // gateway's queued deliveries needn't have run. The trigger channel // is only where explicit mode's typing indicator shows. @@ -12451,8 +12463,8 @@ export class AgentFramework { proseStream?.reset(); lastTextBlock = undefined; // Explicit mode's retried attempt starts where the turn's - // delivered rounds have set the destination, not at the stream's - // seed: rounds this stream delivered before the refusal stand. + // admitted rounds have set the destination, not at the stream's + // seed: rounds this stream admitted before the refusal stand. if (turnProseRouting === 'explicit') proseStream?.retarget(streamRouting.destination); this.emitTrace({ type: 'inference:tokens', @@ -16324,11 +16336,11 @@ export class AgentFramework { }, // A text-only turn whose speech couldn't be delivered must not vanish // silently: record a `[discord-send-failed]` marker in chronicle so the - // agent sees, on her next turn, that her reply never reached the human. - // addMessage() alone does not request inference, so this never wakes - // her (matching the `discord-send-failed-skip` gate intent: context - // yes, wake no). - onRouteFailure: ({ channelId, reason, textLen }) => { + // agent sees, on her next turn, that her reply didn't reach the human, + // or wasn't confirmed to. addMessage() alone does not request + // inference, so this never wakes her (matching the + // `discord-send-failed-skip` gate intent: context yes, wake no). + onRouteFailure: ({ channelId, reason, textLen, outcome }) => { try { // Render a human-readable channel name when we can — a bare // snowflake in the marker is unresolvable for the agent (the @@ -16347,9 +16359,13 @@ export class AgentFramework { 'user', [{ type: 'text', - text: `[discord-send-failed] Your previous reply (${textLen} chars) could not be delivered to ${where} (${reason}). It was saved to your archive but the human did not receive it.`, + text: outcome === 'unknown' + // A request went out and no valid receipt came back: it may + // have been posted (in part or whole). Never claim it wasn't. + ? `[discord-send-failed] Delivery of your previous reply (${textLen} chars) to ${where} was not confirmed (${reason}). It may or may not have been posted, so sending it again could post it twice. It is saved in your archive.` + : `[discord-send-failed] Your previous reply (${textLen} chars) could not be delivered to ${where} (${reason}). It was saved to your archive but the human did not receive it.`, }], - { system: true, kind: 'discord-send-failed', channelId: channelId ?? '', reason }, + { system: true, kind: 'discord-send-failed', channelId: channelId ?? '', reason, outcome }, ); } catch (err) { console.error('onRouteFailure: failed to record send-failure marker:', err); diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 52f906cbb..a3c83e4ec 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -226,6 +226,19 @@ function isInboundOnly(entry: ChannelEntry): boolean { return entry.descriptor.direction === 'inbound'; } +/** What a failed request to a server says to the agent: a server's error + * response in its own words, naming the server, and any other failure as its + * message, or as text when it has none. */ +function failureText(err: unknown, serverId: string): string { + if (err instanceof McplErrorResponse) return `server "${serverId}" answered with an error: ${err.serverMessage}`; + try { + const message = (err as { message?: unknown } | null | undefined)?.message; + return typeof message === 'string' && message !== '' ? message : String(err); + } catch { + return 'a failure that could not be printed'; + } +} + /** Why the host sends nothing into an inbound-only channel, as the resident * reads it in a send-failure marker or a tool error. */ function inboundOnlyReason(entry: ChannelEntry): string { @@ -511,16 +524,21 @@ interface ChannelRegistryOptions { /** * Called when a text-only turn's speech could NOT be delivered to its * conversational locus — no locus, an unregistered channel, a missing - * server, or the server reporting `delivered: false`. The host wires this - * to drop a `[discord-send-failed]` marker into chronicle so the failure is - * visible to the agent (and operator) rather than silently lost. Must not - * itself trigger inference (avoid wake loops). + * server, or the server reporting `delivered: false` — or the server + * answered its publish with an error, after which whether it was posted is + * unknown. The host wires this to drop a `[discord-send-failed]` marker into + * chronicle so the failure is visible to the agent (and operator) rather + * than silently lost. Must not itself trigger inference (avoid wake loops). */ onRouteFailure?: (info: { conversationId: string; channelId: string | null; reason: string; textLen: number; + /** `failed`: nothing was posted. `unknown`: the server answered the + * publish with an error, which can follow a partial post, so some or + * all of it may have been. */ + outcome: 'failed' | 'unknown'; }) => void; /** * Called when channels were opened WITHOUT the agent asking (subscription @@ -603,6 +621,7 @@ export class ChannelRegistry { channelId: string | null; reason: string; textLen: number; + outcome: 'failed' | 'unknown'; }) => void; private onChannelAutoOpened?: (info: { conversationId?: string; @@ -2123,7 +2142,8 @@ export class ChannelRegistry { // The server the publish goes to, as handleToolPublish finds it before // it awaits: the channel may be removed while the reply is pending. const serverId = this.findChannelEntry(channelId)?.serverId ?? ''; - const result = await this.handleToolPublish({ channelId, content: text }); + const seen: { rejection?: 'error-response' | 'other' } = {}; + const result = await this.handleToolPublish({ channelId, content: text }, seen); const reply = result.data as { delivered?: boolean; messageId?: string } | undefined; if (result.success && reply?.delivered !== false) { this.emitTraceFn({ @@ -2137,12 +2157,19 @@ export class ChannelRegistry { ...(reply?.messageId !== undefined ? { messageId: reply.messageId } : {}), }); } else { + // What the host knows of the text, as routeSpeech's trace says it: an + // error response may follow a partial post; a refusal before the + // publish, or delivered:false, posted nothing; after any other + // rejection (a timeout, a lost connection, or a connection already + // closed) it can't tell which, and the trace says neither. + const outcome = seen.rejection === 'error-response' ? 'unknown' : seen.rejection === undefined ? 'failed' : undefined; this.emitTraceFn({ type: 'mcpl:speech-route-failed', conversationId: agentName, channelId, reason: result.success ? 'the server reported delivered:false' : (result.error ?? 'the publish failed'), textLen: text.length, + ...(outcome ? { outcome } : {}), }); } return result; @@ -2820,7 +2847,7 @@ export class ChannelRegistry { } catch (err) { return { success: false, - error: `Failed to open channel: ${(err as Error).message}`, + error: `Failed to open channel: ${failureText(err, entry.serverId)}`, isError: true, }; } @@ -2934,7 +2961,7 @@ export class ChannelRegistry { } catch (err) { return { success: false, - error: `Failed to close channel: ${(err as Error).message}`, + error: `Failed to close channel: ${failureText(err, entry.serverId)}`, isError: true, }; } @@ -2987,7 +3014,7 @@ export class ChannelRegistry { 'The channel integration could not post the acknowledgment.'; } } catch (error) { - acknowledgmentError = (error as Error).message; + acknowledgmentError = failureText(error, entry.serverId); } } } @@ -3169,9 +3196,11 @@ export class ChannelRegistry { * declared it inbound-only, the channel was removed while it opened, the * open fails, or the server answers the publish with an error or reports it * undelivered. Each of these goes through fail(): a trace, and the host's - * send-failure marker. A publish that times out or loses its connection - * rejects instead, since whether the server posted it is unknown, and the - * caller logs it. The speech stays in chronicle + module surfaces. + * send-failure marker. Any other failed publish rejects to the caller, as + * before: one that times out or loses its connection before an answer, + * whose outcome is unknown, and one whose connection was already closed, + * which sent nothing. Plain speech logs it, and a hybrid `>>>` caller + * bounces it. The speech stays in chronicle + module surfaces. */ async routeSpeech( conversationId: string, @@ -3187,17 +3216,19 @@ export class ChannelRegistry { ): Promise<{ delivered: boolean; channelId: string; messageId?: string } | null> { // Surface a routing failure: emit a trace AND notify the host (which drops // a `[discord-send-failed]` marker into chronicle) so the agent learns her - // reply never reached the human, instead of it vanishing silently. - const fail = (channelId: string | null, reason: string): null => { - console.error(`[routeSpeech] ${conversationId}: ${reason} — speech NOT routed (${text.length} chars stay in chronicle)`); + // reply didn't reach the human, or wasn't confirmed to, instead of it + // vanishing silently. + const fail = (channelId: string | null, reason: string, outcome: 'failed' | 'unknown' = 'failed'): null => { + console.error(`[routeSpeech] ${conversationId}: ${reason} — speech ${outcome === 'unknown' ? 'delivery NOT confirmed' : 'NOT routed'} (${text.length} chars stay in chronicle)`); this.emitTraceFn({ type: 'mcpl:speech-route-failed', conversationId, channelId: channelId ?? '', reason, textLen: text.length, + outcome, }); - this.onRouteFailure?.({ conversationId, channelId, reason, textLen: text.length }); + this.onRouteFailure?.({ conversationId, channelId, reason, textLen: text.length, outcome }); return null; }; @@ -3257,7 +3288,7 @@ export class ChannelRegistry { console.error('onChannelAutoOpened (delivery) failed:', err); } } catch (err) { - return fail(channelId, `locus channel is closed and open failed: ${(err as Error).message}`); + return fail(channelId, `the channel was closed, and opening it failed: ${failureText(err, entry.serverId)}`); } } // The open awaited the server, and a channels/changed update or a @@ -3291,14 +3322,15 @@ export class ChannelRegistry { try { result = await server.sendChannelsPublish(publishParams); } catch (err) { - // An error response is the server's answer that the publish failed, so - // the marker says the human didn't receive it, in the server's own - // words. Any other rejection goes to the caller as before: after a - // timeout, or a connection lost before an answer, whether the server - // posted it is unknown, and a connection already closed rejects before - // anything is sent. + // An error response is the server's answer, in its own words, that the + // publish failed. It may follow a partial post (a multi-part send whose + // later part failed), so the marker says delivery wasn't confirmed, + // never that nothing was posted. Any other rejection goes to the caller + // as before: one after a timeout, or a connection lost before an + // answer, and one on a connection already closed, before anything is + // sent. if (err instanceof McplErrorResponse) { - return fail(channelId, `server "${entry.serverId}" refused the publish: ${err.serverMessage}`); + return fail(channelId, `server "${entry.serverId}" answered the publish with an error: ${err.serverMessage}`, 'unknown'); } throw err; } @@ -3331,7 +3363,12 @@ export class ChannelRegistry { return { delivered, channelId, ...(messageId !== undefined ? { messageId } : {}) }; } - private async handleToolPublish(input: { channelId?: string; content?: string; text?: string }): Promise { + /** `seen`, when given, says how the publish rejected, if it did: an error + * response, or another rejection. */ + private async handleToolPublish( + input: { channelId?: string; content?: string; text?: string }, + seen?: { rejection?: 'error-response' | 'other' }, + ): Promise { // Resolve content: accept both `content` and `text` (backward compat) const messageText = input.content ?? input.text; if (!messageText) { @@ -3397,9 +3434,15 @@ export class ChannelRegistry { data: result ?? { delivered: true }, }; } catch (err) { + if (seen) seen.rejection = err instanceof McplErrorResponse ? 'error-response' : 'other'; + // An error response may follow a partial post (a multi-part send whose + // later part failed), so it isn't called a failure, as routeSpeech's + // marker doesn't call it one. return { success: false, - error: `Failed to publish to channel: ${(err as Error).message}`, + error: err instanceof McplErrorResponse + ? `Publish to ${channelId} wasn't confirmed: ${failureText(err, entry.serverId)}. It may or may not have been posted, so sending it again could post it twice.` + : `Failed to publish to channel: ${failureText(err, entry.serverId)}`, isError: true, }; } diff --git a/src/mcpl/errors.ts b/src/mcpl/errors.ts index 9940720ea..3a5342118 100644 --- a/src/mcpl/errors.ts +++ b/src/mcpl/errors.ts @@ -39,23 +39,21 @@ export const CHANNEL_OPEN_FAILED = -32024; /** * A server's JSON-RPC error response to a request the host sent once * connected: the server answered with an error rather than a result. - * `serverMessage` is the error's own message, as the server sent it, and - * `message` adds which server answered which request. A request that times - * out, whose connection closes before an answer, or that finds its connection - * already closed, rejects with a plain Error instead, as does a failed - * handshake. + * `serverMessage` is what the agent reads: the server's own words (its + * error's message, or the error itself when it sent text there), cut at 200 + * characters, or else a sentence saying what came instead. `message` adds + * which server answered which request, and the code, with a text message + * whole. A request that times out, whose connection closes before an + * answer, or that finds its connection already closed, rejects with a plain + * Error instead, as does a failed handshake. */ export class McplErrorResponse extends Error { - readonly code: number; readonly serverMessage: string; - readonly data?: unknown; - constructor(message: string, code: number, serverMessage: string, data?: unknown) { + constructor(message: string, serverMessage: string) { super(message); this.name = 'McplErrorResponse'; - this.code = code; this.serverMessage = serverMessage; - this.data = data; } } diff --git a/src/mcpl/server-connection.ts b/src/mcpl/server-connection.ts index 8b6d1037a..af5fe33d0 100644 --- a/src/mcpl/server-connection.ts +++ b/src/mcpl/server-connection.ts @@ -103,6 +103,33 @@ interface RequestOptions { surfaceOrphanedResponse?: boolean; } +/** Text a server chose, as the agent reads it: past 200 characters (code + * points, so a pair is never split) it's cut, so one server's words can't + * fill what the agent reads. */ +function cutServerText(text: string): string { + let count = 0; + let end = 0; + for (const character of text) { + if (count === 200) return `${text.slice(0, end)}…`; + end += character.length; + count++; + } + return text; +} + +/** A value a server sent, as JSON, without String(): JSON.stringify can't + * call a server's toString, and one too deep to write is named instead. + * It's cut as the server's own words are. */ +function showServerValue(value: unknown): string { + let text: string; + try { + text = JSON.stringify(value) ?? 'nothing'; + } catch { + return '(a value nested too deeply to show)'; + } + return cutServerText(text); +} + /** * McplServerConnection manages a single JSON-RPC 2.0 connection to an * MCPL server over stdio. Use the static `connect()` factory to create @@ -1283,12 +1310,36 @@ export class McplServerConnection extends EventEmitter { if (pending.timer) clearTimeout(pending.timer); if (response.error) { + // Built without String() or a template's conversion of what the + // server sent: an object whose toString and valueOf aren't callable + // would throw here, after the entry is gone, and the request would + // never settle. + // JSON-RPC's error is an object with a code and a message. A server + // that sends text there instead has still said why. Anything else it + // sends there is shown as it came. A falsy error is read as no error, + // as before. + const error: unknown = response.error; + const isObject = typeof error === 'object' && error !== null && !Array.isArray(error); + const { code, message } = isObject ? (error as { code?: unknown; message?: unknown }) : {}; + const shownCode = typeof code === 'number' ? String(code) : code === undefined ? 'none' : showServerValue(code); + // What the agent reads as the server's words: its message, or the error + // itself when that's text, cut; or else what came instead. The + // operator's message keeps a text message whole, and the code, which + // the agent can't act on. + const text = isObject + ? (typeof message === 'string' && message !== '' ? message : undefined) + : (typeof error === 'string' ? error : undefined); + const serverMessage = text !== undefined + ? cutServerText(text) + : !isObject + ? `an error that came as ${showServerValue(error)}` + : message == null || message === '' + ? 'an error with no message' + : `an error whose message isn't text: ${showServerValue(message)}`; pending.reject( new McplErrorResponse( - `MCPL server "${this.id}" returned error for ${pending.method}: [${response.error.code}] ${response.error.message}`, - response.error.code, - String(response.error.message), - response.error.data, + `MCPL server "${this.id}" returned error for ${pending.method}: [${shownCode}] ${text ?? serverMessage}`, + serverMessage, ), ); } else { diff --git a/src/types/trace.ts b/src/types/trace.ts index 77b85806a..d1d287883 100644 --- a/src/types/trace.ts +++ b/src/types/trace.ts @@ -457,6 +457,13 @@ export type TraceEvent = channelId: string; reason: string; textLen: number; + /** What the host knows of the text. `failed`: nothing was posted. + * `unknown`: the server answered the publish with an error, which can + * follow a partial post, so some or all of it may have been. Absent + * on speak_in_channel's trace after any other rejection (a timeout, a + * lost connection, or a connection already closed), which it can't + * tell apart. */ + outcome?: 'failed' | 'unknown'; }) // Admin puppet: an operator executed a tool AS an agent and stored the diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index 6b352002e..41161aff8 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -8,7 +8,7 @@ import { parseProsePrefix } from '../src/mcpl/prose-grammar.js'; import type { McplServerRegistry } from '../src/mcpl/server-registry.js'; import type { FeatureSetManager } from '../src/mcpl/feature-set-manager.js'; -type RouteFailure = { conversationId: string; channelId: string | null; reason: string; textLen: number }; +type RouteFailure = { conversationId: string; channelId: string | null; reason: string; textLen: number; outcome: 'failed' | 'unknown' }; /** * Build a registry with a mock server whose publish result is configurable, @@ -35,6 +35,8 @@ function makeRegistry( let failOpens = false; let onOpen: (() => Promise) | undefined; let onPublish: (() => Promise) | undefined; + let onClose: (() => Promise) | undefined; + let onAcknowledge: (() => Promise) | undefined; let openResult: unknown = {}; const mockServer = { @@ -59,8 +61,13 @@ function makeRegistry( }, sendChannelsClose: async (params: { channelId?: string }) => { closeCalls.push(params); + await onClose?.(); return {}; }, + sendChannelsAcknowledge: async () => { + await onAcknowledge?.(); + return { acknowledged: true }; + }, }; const serverRegistry = { getServer: (_id: string) => mockServer, @@ -95,6 +102,9 @@ function makeRegistry( setOnPublish: (fn: (() => Promise) | undefined) => { onPublish = fn; }, /** What the server answers channels/open with. */ setOpenResult: (result: unknown) => { openResult = result; }, + /** Runs while the server is answering a close, or an acknowledgement. */ + setOnClose: (fn: (() => Promise) | undefined) => { onClose = fn; }, + setOnAcknowledge: (fn: (() => Promise) | undefined) => { onAcknowledge = fn; }, }; } @@ -153,7 +163,8 @@ test('routeSpeech surfaces a failure when the server reports delivered:false', a assert.equal(failures.length, 1, 'onRouteFailure should fire'); assert.equal(failures[0].channelId, 'ch-x'); assert.match(failures[0].reason, /delivered:false/); - assert.ok(traces.some(t => t.type === 'mcpl:speech-route-failed')); + assert.equal(failures[0].outcome, 'failed', 'the server said nothing was delivered'); + assert.ok(traces.some(t => t.type === 'mcpl:speech-route-failed' && t.outcome === 'failed')); }); test('routeSpeech routes a conversation fork to its HOME channel, not the global last-inbound (item 3)', async () => { @@ -373,7 +384,8 @@ test('routeSpeech does NOT deliver when the open-on-delivery fails', async () => assert.equal(res, null, 'no delivery into a channel that could not be opened'); assert.equal(publishCalls.length, publishesBefore, 'publish must not be attempted'); assert.equal(failures.length, 1); - assert.match(failures[0]!.reason, /closed and open failed/); + assert.match(failures[0]!.reason, /^the channel was closed, and opening it failed: /); + assert.equal(failures[0]!.outcome, 'failed', 'nothing was published'); }); test('routeSpeech with an omitted locus fails loudly as a routing bug', async () => { @@ -757,6 +769,7 @@ test('publishForAgent into an inbound-only channel is refused and traced as fail assert.equal(failed[0]!.conversationId, 'Subconscious'); assert.equal(failed[0]!.channelId, 'body:ears'); assert.match(String(failed[0]!.reason), /inbound-only: server "body" declared/); + assert.equal(failed[0]!.outcome, 'failed', 'nothing was published'); const posted = await registry.publishForAgent('body:home', 'a thought', 'Subconscious'); assert.equal(posted.success, true); @@ -779,11 +792,36 @@ test('publishForAgent traces a publish the server reports undelivered as failed, assert.deepEqual(publishCalls.map((c) => c.channelId), ['body:home']); assert.deepEqual(traces.filter((t) => t.type === 'mcpl:speech-routed'), []); assert.deepEqual( - traces.filter((t) => t.type === 'mcpl:speech-route-failed').map((t) => [t.conversationId, t.channelId, t.reason]), - [['Subconscious', 'body:home', 'the server reported delivered:false']], + traces.filter((t) => t.type === 'mcpl:speech-route-failed').map((t) => [t.conversationId, t.channelId, t.reason, t.outcome]), + [['Subconscious', 'body:home', 'the server reported delivered:false', 'failed']], ); }); +test("publishForAgent traces a server's error response as unknown, and another rejection with no outcome", async () => { + const { registry, traces, setOnPublish } = makeRegistry({ delivered: true }); + await registry.handleChanged('body', { + added: [{ id: 'body:home', type: 'chat', label: 'Body · home', direction: 'bidirectional' }], + } as never); + const failed = () => traces.filter((t) => t.type === 'mcpl:speech-route-failed'); + + // An error response may follow a partial post. + setOnPublish(async () => { + throw new McplErrorResponse('MCPL server "body" returned error for channels/publish: [-32000] Rate limited after part 1', 'Rate limited after part 1'); + }); + const unconfirmed = await registry.publishForAgent('body:home', 'a long thought', 'Subconscious'); + assert.equal(unconfirmed.error, 'Publish to body:home wasn\'t confirmed: server "body" answered with an error: Rate limited after part 1. It may or may not have been posted, so sending it again could post it twice.'); + assert.equal(failed().length, 1); + assert.equal(failed()[0]!.outcome, 'unknown'); + + // A timeout, a lost connection and a closed connection are plain Errors, + // which the host can't tell apart: the trace says neither. + setOnPublish(async () => { throw new Error('channels/publish timed out after 30000ms'); }); + const timedOut = await registry.publishForAgent('body:home', 'a thought', 'Subconscious'); + assert.equal(timedOut.error, 'Failed to publish to channel: channels/publish timed out after 30000ms'); + assert.equal(failed().length, 2); + assert.equal('outcome' in failed()[1]!, false, JSON.stringify(failed()[1])); +}); + test('buildChannelContext names no defaultOutgoing for a locus its server declared inbound-only', async () => { const active: Record = { linn: 'body:ears', nell: 'body:home' }; const { registry } = makeRegistry({ delivered: true }, () => undefined, (n) => active[n]); @@ -823,11 +861,16 @@ test('an inbound-only channel resolves as a speech target to an error naming the assert.deepEqual(registry.resolveProseTarget('body:home', { forSpeech: true }), { channelId: 'body:home', label: 'Body · home' }); }); -test('a channel declared inbound-only while the host opens it for speech is refused before the publish', async () => { - const { registry, publishCalls, openCalls, failures, setOnOpen } = makeRegistry({ delivered: true }); +test('a channel declared inbound-only while the host opens it for speech is refused before the publish, and stays open with the open\'s notice', async () => { + const autoOpened: Array<{ source: string; channels: Array<{ channelId: string }> }> = []; + const { registry, publishCalls, openCalls, closeCalls, failures, setOnOpen, lookup } = + makeRegistry({ delivered: true }, undefined, undefined, (info) => autoOpened.push(info)); await registry.handleRegister('body', { channels: [{ id: 'body:ears', type: 'chat', label: 'Body · ears', direction: 'bidirectional' }], } as never); + // Registering reconciles the channel to its default, closed, which sends + // one channels/close; the claim below is about what happens after that. + const closesAtRegistration = closeCalls.length; setOnOpen(async () => { await registry.handleChanged('body', { updated: [{ id: 'body:ears', type: 'chat', label: 'Body · ears', direction: 'inbound' }], @@ -839,6 +882,11 @@ test('a channel declared inbound-only while the host opens it for speech is refu assert.equal(publishCalls.length, 0, 'nothing was published after the declaration'); assert.equal(failures.length, 1); assert.match(failures[0]!.reason, /^inbound-only: server "body" declared this channel's direction "inbound"/); + // The open happened, whatever the recheck decided: the channel stays open, + // and the open's notice, which says how to close it, went out with the marker. + assert.equal(lookup('body:ears')?.open, true, 'the opened channel stays open'); + assert.equal(closeCalls.length, closesAtRegistration, 'nothing closes it after the open'); + assert.deepEqual(autoOpened.map((n) => [n.source, n.channels.map((c) => c.channelId)]), [['opened-by-delivery', ['body:ears']]]); }); test("publishForAgent's routed trace names the server it published through, though the channel is removed while the reply is pending", async () => { @@ -857,7 +905,7 @@ test("publishForAgent's routed trace names the server it published through, thou assert.equal(routed[0]!.serverId, 'body'); }); -test("a server's error response to a request rejects with McplErrorResponse, carrying its code and data", async () => { +test("a server's error response to a request rejects with McplErrorResponse, carrying the server's words, with the code in the operator's message", async () => { const connection = new (McplServerConnection as any)('body', null, null) as McplServerConnection; const internals = connection as unknown as { pendingRequests: Map; @@ -869,14 +917,81 @@ test("a server's error response to a request rejects with McplErrorResponse, car internals.handleResponse({ jsonrpc: '2.0', id: 7, error: { code: -32017, message: 'Channel not permitted', data: { channelId: 'body:ears' } } }); await assert.rejects(answer, (err: Error) => { assert.ok(err instanceof McplErrorResponse); - assert.equal(err.code, -32017); - assert.deepEqual(err.data, { channelId: 'body:ears' }); assert.equal(err.message, 'MCPL server "body" returned error for channels/publish: [-32017] Channel not permitted'); assert.equal((err as McplErrorResponse).serverMessage, 'Channel not permitted'); return true; }); }); +test("an error response with no message, or one that isn't text, still settles its request, and says what came", { timeout: 5000 }, async () => { + const connection = new (McplServerConnection as any)('body', null, null) as McplServerConnection; + const internals = connection as unknown as { + pendingRequests: Map; + handleResponse(response: unknown): void; + }; + // Each error, what the agent reads as the server's words, and the operator's + // message, which shows the code as it came. + const deep = JSON.parse('['.repeat(100_000) + ']'.repeat(100_000)); + const cases: Array<[unknown, string, string]> = [ + [{ code: -32000 }, 'an error with no message', '[-32000] an error with no message'], + [{ code: -32000, message: '' }, 'an error with no message', '[-32000] an error with no message'], + [{}, 'an error with no message', '[none] an error with no message'], + // String() can't convert this one: building the error must not throw, or the request would never settle. + [{ code: -32000, message: { toString: 1, valueOf: 1 } }, 'an error whose message isn\'t text: {"toString":1,"valueOf":1}', '[-32000] an error whose message isn\'t text: {"toString":1,"valueOf":1}'], + [{ message: 'no code' }, 'no code', '[none] no code'], + [{ code: { toString: 1 }, message: 'odd code' }, 'odd code', '[{"toString":1}] odd code'], + [{ code: 1.5, message: 'half a code' }, 'half a code', '[1.5] half a code'], + // Text sent in place of an error object is still the server's words. + ['Channel not permitted', 'Channel not permitted', '[none] Channel not permitted'], + // Anything else in its place is shown as it came. + [[1, 2], 'an error that came as [1,2]', '[none] an error that came as [1,2]'], + // A long value, or a long message, is cut at 200 characters for the + // agent; the operator's message keeps a text message whole. + [{ code: -32000, message: { text: 'x'.repeat(500) } }, `an error whose message isn't text: ${JSON.stringify({ text: 'x'.repeat(500) }).slice(0, 200)}…`, `[-32000] an error whose message isn't text: ${JSON.stringify({ text: 'x'.repeat(500) }).slice(0, 200)}…`], + [{ code: -32000, message: 'y'.repeat(500) }, `${'y'.repeat(200)}…`, `[-32000] ${'y'.repeat(500)}`], + ['w'.repeat(500), `${'w'.repeat(200)}…`, `[none] ${'w'.repeat(500)}`], + // Exactly 200 stays whole; characters past the BMP count once and are never split. + [{ code: -32000, message: 'z'.repeat(200) }, 'z'.repeat(200), `[-32000] ${'z'.repeat(200)}`], + [{ code: -32000, message: '\u{1F600}'.repeat(300) }, `${'\u{1F600}'.repeat(200)}…`, `[-32000] ${'\u{1F600}'.repeat(300)}`], + ]; + for (const [i, [error, serverMessage, operator]] of cases.entries()) { + const id = 100 + i; + const answer = new Promise((resolve, reject) => { + internals.pendingRequests.set(id, { resolve, reject, method: 'channels/publish' }); + }); + internals.handleResponse({ jsonrpc: '2.0', id, error }); + await assert.rejects(answer, (err: Error) => { + assert.ok(err instanceof McplErrorResponse); + assert.equal((err as McplErrorResponse).serverMessage, serverMessage, JSON.stringify(error)); + assert.equal(err.message, `MCPL server "body" returned error for channels/publish: ${operator}`); + return true; + }); + } + // A falsy error is no error: a result beside "error": null is the answer. + const answered = new Promise((resolve, reject) => { + internals.pendingRequests.set(199, { resolve, reject, method: 'channels/publish' }); + }); + internals.handleResponse({ jsonrpc: '2.0', id: 199, result: { delivered: true }, error: null }); + assert.deepEqual(await answered, { delivered: true }); + // A message nested deeper than JSON.stringify can write, which JSON.parse + // reads. Node 22 and 24 overflow the stack writing it back, and Node 26 + // doesn't, so the text is named or shown, and either way the request settles. + const answer = new Promise((resolve, reject) => { + internals.pendingRequests.set(200, { resolve, reject, method: 'channels/publish' }); + }); + internals.handleResponse({ jsonrpc: '2.0', id: 200, error: { code: -32000, message: deep } }); + await assert.rejects(answer, (err: Error) => { + assert.ok(err instanceof McplErrorResponse); + const text = (err as McplErrorResponse).serverMessage; + assert.ok( + text === "an error whose message isn't text: (a value nested too deeply to show)" || text.startsWith("an error whose message isn't text: [[["), + text.slice(0, 120), + ); + assert.equal(err.message, `MCPL server "body" returned error for channels/publish: [-32000] ${text}`); + return true; + }); +}); + test('a request on a connection already closed rejects with a plain Error, since nothing was sent (R1-2)', async () => { const connection = new (McplServerConnection as any)('body', null, null) as McplServerConnection; (connection as unknown as { closed: boolean }).closed = true; @@ -890,22 +1005,25 @@ test('a request on a connection already closed rejects with a plain Error, since ); }); -test("a server's error response to the publish goes through fail(): a trace and the send-failure marker (R1-2)", async () => { +test("a server's error response to the publish goes through fail() as unconfirmed: a trace and the send-failure marker (R1-2)", async () => { const { registry, failures, traces, publishCalls, setOnPublish } = makeRegistry({ delivered: true }); await registry.handleRegister('body', { channels: [{ id: 'body:home', type: 'chat', label: 'Body · home', direction: 'bidirectional' }], } as never); setOnPublish(async () => { - throw new McplErrorResponse('MCPL server "body" returned error for channels/publish: [-32017] Channel not permitted', -32017, 'Channel not permitted'); + throw new McplErrorResponse('MCPL server "body" returned error for channels/publish: [-32017] Channel not permitted', 'Channel not permitted'); }); assert.equal(await registry.routeSpeech('linn', 'hello', 'body:home'), null); - assert.equal(publishCalls.length, 1, 'the publish was sent, and the server refused it'); - assert.deepEqual(failures.map((f) => f.reason), ['server "body" refused the publish: Channel not permitted'], + assert.equal(publishCalls.length, 1, 'the publish was sent, and the server answered with an error'); + assert.deepEqual(failures.map((f) => f.reason), ['server "body" answered the publish with an error: Channel not permitted'], "the marker gives the server's own words, without the method or the code"); + // An error response may follow a partial post, so whether it was posted is unknown. + assert.deepEqual(failures.map((f) => f.outcome), ['unknown']); const failed = traces.filter((t) => t.type === 'mcpl:speech-route-failed'); assert.equal(failed.length, 1); assert.equal(failed[0]!.channelId, 'body:home'); + assert.equal(failed[0]!.outcome, 'unknown'); assert.equal(traces.filter((t) => t.type === 'mcpl:speech-routed').length, 0); }); @@ -981,6 +1099,70 @@ test('a channel removed while the host opens it for speech is refused, and nothi } }); +test("a server's refusal of an open or a tool's publish reaches the agent in its own words", async () => { + const refusal = () => new McplErrorResponse('MCPL server "body" returned error for channels/open: [-32024] Channel unavailable', 'Channel unavailable'); + const { registry, failures, setOnOpen, setOnPublish } = makeRegistry({ delivered: true }); + await registry.handleRegister('body', { + channels: [{ id: 'body:home', type: 'chat', label: 'Body · home', direction: 'bidirectional' }], + } as never); + setOnOpen(async () => { throw refusal(); }); + assert.equal(await registry.routeSpeech('linn', 'hello', 'body:home'), null); + assert.deepEqual(failures.map((f) => f.reason), ['the channel was closed, and opening it failed: server "body" answered with an error: Channel unavailable']); + const opened = await registry.handleChannelToolCall('channel_open', { channelId: 'body:home' }, { kind: 'agent', agentName: 'linn' }); + assert.equal(opened.error, 'Failed to open channel: server "body" answered with an error: Channel unavailable'); + + setOnOpen(undefined); + setOnPublish(async () => { throw new McplErrorResponse('MCPL server "body" returned error for channels/publish: [-32017] Channel not permitted', 'Channel not permitted'); }); + const published = await registry.handleChannelToolCall('channel_publish', { channelId: 'body:home', content: 'hi' }, { kind: 'agent', agentName: 'linn' }); + assert.equal(published.error, 'Publish to body:home wasn\'t confirmed: server "body" answered with an error: Channel not permitted. It may or may not have been posted, so sending it again could post it twice.'); +}); + +test("a failure that isn't an Error reaches the agent as its text", async () => { + const { registry, setOnOpen } = makeRegistry({ delivered: true }); + await registry.handleRegister('body', { + channels: [{ id: 'body:home', type: 'chat', label: 'Body · home', direction: 'bidirectional' }], + } as never); + setOnOpen(async () => { throw 'refused by fiat'; }); + const opened = await registry.handleChannelToolCall('channel_open', { channelId: 'body:home' }, { kind: 'agent', agentName: 'linn' }); + assert.equal(opened.error, 'Failed to open channel: refused by fiat'); +}); + +test("every failed open, publish, close and acknowledgement reaches the agent as the server's words, or the failure's text", async () => { + const { registry, failures, setOnOpen, setOnPublish, setOnClose, setOnAcknowledge, lookup } = makeRegistry({ delivered: true }); + await registry.handleRegister('body', { + channels: [{ id: 'body:home', type: 'chat', label: 'Body · home', direction: 'bidirectional' }], + } as never); + const agent = { kind: 'agent', agentName: 'linn' } as const; + const long = 'w'.repeat(300); + const refusal = (method: string) => new McplErrorResponse(`MCPL server "body" returned error for ${method}: [-32017] ${long}`, `${'w'.repeat(200)}…`); + const unprintable = { get message(): string { throw new Error('getter'); } }; + for (const [thrown, words] of [ + [{ message: 'channel full' }, 'channel full'], + [new TypeError(''), 'TypeError'], + [unprintable, 'a failure that could not be printed'], + ] as const) { + setOnOpen(async () => { throw thrown; }); + assert.equal(await registry.routeSpeech('linn', 'hello', 'body:home'), null); + assert.equal(failures.at(-1)!.reason, `the channel was closed, and opening it failed: ${words}`); + assert.equal((await registry.handleChannelToolCall('channel_open', { channelId: 'body:home' }, agent)).error, `Failed to open channel: ${words}`); + setOnOpen(undefined); + setOnPublish(async () => { throw thrown; }); + assert.equal((await registry.handleChannelToolCall('channel_publish', { channelId: 'body:home', content: 'hi' }, agent)).error, `Failed to publish to channel: ${words}`); + setOnPublish(undefined); + } + // A server's refusal of a close, cut as the agent reads it. + lookup('body:home')!.open = true; + setOnClose(async () => { throw refusal('channels/close'); }); + const closed = await registry.handleChannelToolCall('channel_close', { channelId: 'body:home' }, agent); + assert.equal(closed.error, `Failed to close channel: server "body" answered with an error: ${'w'.repeat(200)}…`); + // And of a decline's acknowledgement. The failed close left the channel + // open on the host, and decline is for a channel that isn't. + lookup('body:home')!.open = false; + setOnAcknowledge(async () => { throw refusal('channels/acknowledge'); }); + const declined = await registry.handleChannelToolCall('channel_decline', { channelId: 'body:home', messageId: 'm1', acknowledge: 'not now' }, agent); + assert.equal((declined.data as { acknowledgmentError?: string }).acknowledgmentError, `server "body" answered with an error: ${'w'.repeat(200)}…`); +}); + test("an open's answer can't lift an inbound-only declaration: a server lifts it by changing or registering the channel (R1-3)", async () => { const { registry, publishCalls, failures, setOpenResult } = makeRegistry({ delivered: true }); await registry.handleRegister('body', { @@ -992,6 +1174,13 @@ test("an open's answer can't lift an inbound-only declaration: a server lifts it assert.equal(await registry.routeSpeech('linn', 'hello', 'body:ears'), null, 'still refused after the answer'); assert.equal(publishCalls.length, 0); assert.match(failures[0]!.reason, /^inbound-only: server "body" declared/); + + // The server's own change lifts it. + await registry.handleChanged('body', { + updated: [{ id: 'body:ears', type: 'chat', label: 'Body · ears', direction: 'bidirectional' }], + } as never); + assert.deepEqual(await registry.routeSpeech('linn', 'now', 'body:ears'), { delivered: true, channelId: 'body:ears' }); + assert.equal(publishCalls.length, 1); }); test("an open's answer that arrives in the same read as a newer channels/changed can't lift its refusal (R1-3)", async () => { @@ -999,6 +1188,8 @@ test("an open's answer that arrives in the same read as a newer channels/changed // handles the next line at once, so the answer is applied after, and // over, the channels/changed the server sent after it. let answerOpen!: (answer: unknown) => void; + let openSent!: () => void; + const opening = new Promise((resolve) => { openSent = resolve; }); const publishCalls: string[] = []; const failures: string[] = []; const mockServer = { @@ -1006,7 +1197,7 @@ test("an open's answer that arrives in the same read as a newer channels/changed sendChannelsPublish: async (p: { channelId?: string }) => { publishCalls.push(String(p.channelId)); return { delivered: true }; }, sendChannelsOutgoingChunk: () => {}, sendChannelsOutgoingComplete: () => {}, - sendChannelsOpen: () => new Promise((resolve) => { answerOpen = resolve; }), + sendChannelsOpen: () => new Promise((resolve) => { answerOpen = resolve; openSent(); }), sendChannelsClose: async () => ({}), }; const registry = new ChannelRegistry( @@ -1020,7 +1211,7 @@ test("an open's answer that arrives in the same read as a newer channels/changed added: [{ id: 'body:ears', type: 'chat', label: 'Body · ears', direction: 'bidirectional' }], } as never); const delivery = registry.routeSpeech('linn', 'hello', 'body:ears'); - await new Promise((resolve) => setImmediate(resolve)); + await opening; answerOpen({ channel: { id: 'body:ears', type: 'chat', label: 'Body · ears', direction: 'bidirectional' } }); void registry.handleChanged('body', { updated: [{ id: 'body:ears', type: 'chat', label: 'Body · ears', direction: 'inbound' }], @@ -1067,6 +1258,7 @@ test('a channel announced again while the host opens it is recorded open once th assert.deepEqual(await registry.routeSpeech('linn', 'hello', 'body:home'), { delivered: true, channelId: 'body:home' }); assert.equal(publishCalls.length, 1); assert.equal(registry.isChannelOpen('body:home'), true, "the channel's current entry is recorded open before its reconcile's open answers"); + assert.equal(opens, 2, "the announcement's reconcile opened it too, and waits"); release(); await announced; }); diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index 1dcc15074..0b9fcc0cb 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -1535,8 +1535,9 @@ describe('explicit prose routing', () => { await framework.stop(); }); - for (const refusal of ['inbound-only', 'another failure'] as const) { - it(`explicit: a delivery routeSpeech fails (${refusal}) ${refusal === 'inbound-only' ? "clears the turn's destination, as a refusal at admission does" : 'keeps the destination, as before'}`, async () => { + for (const refusal of ['inbound-only', 'inbound-only, rejected', 'another failure', 'another failure, rejected', 'delivered, then declared'] as const) { + const inboundOnly = refusal === 'inbound-only' || refusal === 'inbound-only, rejected'; + it(`explicit: a delivery routeSpeech ${refusal === 'delivered, then declared' ? 'delivers while the channel turns inbound-only' : `fails (${refusal})`} ${inboundOnly ? "clears the turn's destination, as a refusal at admission does" : 'keeps the destination, as before'}`, async () => { // #alpha resolves when the round is admitted. By its delivery, its // server has declared it inbound-only (as a channels/changed, or the // answer to opening it, can), or the publish fails some other way. @@ -1552,52 +1553,224 @@ describe('explicit prose routing', () => { const registry = (framework as unknown as { channelRegistry: Record }).channelRegistry; const declared = new Set(); registry.inboundOnlyServer = (channelId: string) => (declared.has(channelId) ? 'body' : undefined); + // The turn's routing, as its first delivery sees it. + let turn: { pin?: string; destination: string | null } | undefined; registry.routeSpeech = async (_agent: string, text: string, locus?: string | null) => { + turn ??= (framework as unknown as { turnRouting: Map }).turnRouting.get('assistant'); routed.push({ text, locus: locus ?? null }); - if (refusal === 'inbound-only') declared.add(locus ?? ''); + if (inboundOnly || refusal === 'delivered, then declared') declared.add(locus ?? ''); + // A publish that times out or loses its connection rejects. + if (refusal.endsWith('rejected')) throw new Error('channels/publish timed out'); + if (refusal === 'delivered, then declared') return { delivered: true, channelId: locus ?? '' }; return null; }; feedTurn(framework, 2, [{ type: 'text', text: '>>skip_reply' }] as ContentBlock[]); trigger(framework); await framework.runUntilIdle(); const bounces = context(framework).texts.filter((t) => t.startsWith('[prose-routing]') && t.includes('not delivered')); - if (refusal === 'inbound-only') { + if (inboundOnly) { assert.deepEqual(routed, [{ text: 'first', locus: 'chan-alpha' }], 'the later text never went to the refused channel'); assert.ok(bounces.some((t) => t.includes('this turn has no destination now')), `the later text bounced: ${bounces.join(' | ')}`); + // Both halves of the destination end: where delivery sends later + // text, and where a later stream of the turn would start. + assert.equal(turn?.pin, undefined); + assert.equal(turn?.destination, null); } else { assert.deepEqual(routed, [{ text: 'first', locus: 'chan-alpha' }, { text: 'and more', locus: 'chan-alpha' }]); assert.equal(bounces.length, 0); + assert.equal(turn?.pin, 'chan-alpha'); + assert.equal(turn?.destination, 'chan-alpha'); } await framework.stop(); }); } - it("explicit: a target refused in the turn's final text reaches nothing of its ' !' continuation, which starts a turn of its own", async () => { - // The turn's final text is delivered after the turn settles, so it never - // set the destination a later stream of the turn would start at: the - // continuation is a new turn, with none. + it("the send-failure marker says an unknown outcome wasn't confirmed, and says of a failed one that the human didn't receive it", async () => { + const framework = await createFramework('explicit'); + // A framework configured with no MCPL server builds its channel registry + // when the first one connects. Build it here, connecting none, so the + // registry holds the framework's own route-failure handler. + await (framework as unknown as { initializeMcpl(configs: unknown[]): Promise }).initializeMcpl([]); + type Failure = { conversationId: string; channelId: string | null; reason: string; textLen: number; outcome: 'failed' | 'unknown' }; + const registry = (framework as unknown as { + channelRegistry: { onRouteFailure: (info: Failure) => void; getDescriptor: (id: string) => { label?: string } | undefined }; + }).channelRegistry; + // A real failure names its channel, which the marker shows by label. + registry.getDescriptor = (id: string) => (id === 'body:home' ? { label: 'Body · home' } : undefined); + registry.onRouteFailure({ conversationId: 'assistant', channelId: null, reason: 'server "body" answered the publish with an error: Channel not permitted', textLen: 12, outcome: 'unknown' }); + registry.onRouteFailure({ conversationId: 'assistant', channelId: null, reason: 'server "body" reported delivered:false for "body:home"', textLen: 7, outcome: 'failed' }); + registry.onRouteFailure({ conversationId: 'assistant', channelId: 'body:home', reason: 'server "body" answered the publish with an error: Rate limited', textLen: 30, outcome: 'unknown' }); + const messages = framework.getAgent('assistant')!.getContextManager().getAllMessages() as Array<{ + content: Array<{ type: string; text?: string }>; + metadata?: Record; + }>; + const markers = messages.filter((m) => m.metadata?.kind === 'discord-send-failed'); + assert.equal(markers.length, 3); + // An error response may follow a partial post: never claim it wasn't posted. + assert.equal(markers[0]!.content[0]!.text, + '[discord-send-failed] Delivery of your previous reply (12 chars) to the channel was not confirmed (server "body" answered the publish with an error: Channel not permitted). It may or may not have been posted, so sending it again could post it twice. It is saved in your archive.'); + assert.equal(markers[0]!.metadata?.outcome, 'unknown'); + assert.equal(markers[1]!.content[0]!.text, + '[discord-send-failed] Your previous reply (7 chars) could not be delivered to the channel (server "body" reported delivered:false for "body:home"). It was saved to your archive but the human did not receive it.'); + assert.equal(markers[1]!.metadata?.outcome, 'failed'); + assert.equal(markers[2]!.content[0]!.text, + '[discord-send-failed] Delivery of your previous reply (30 chars) to #Body · home (body:home) was not confirmed (server "body" answered the publish with an error: Rate limited). It may or may not have been posted, so sending it again could post it twice. It is saved in your archive.'); + assert.equal(markers[2]!.metadata?.channelId, 'body:home'); + await framework.stop(); + }); + + it('explicit: a refusal at delivery ends a destination that names the refused channel even when the pin has moved off it', async () => { + // Each half ends while it still names the refused channel, on its own. + // Here another delivery (as a replaced stream's queued one can) moves the + // pin to #cafe during #alpha's delivery, while the destination still + // names #alpha: the pin stays, and the destination ends. membrane.pushResponse(createMockResponse([ { type: 'text', text: '>>#alpha first' }, { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, ] as ContentBlock[], 'tool_use')); membrane.pushResponse(createMockResponse([ - { type: 'text', text: '>>#ears ! for the room' }, + { type: 'text', text: 'and more' }, ] as ContentBlock[])); const framework = await createFramework('explicit'); const routed = stubRegistry(framework); - const stream = captureStream(framework); - feedTurn(framework, 2, [{ type: 'text', text: 'and more for the room' }] as ContentBlock[]); - feedTurn(framework, 3, [{ type: 'text', text: '>>skip_reply' }] as ContentBlock[]); - feedTurn(framework, 4, [{ type: 'text', text: '>>skip_reply' }] as ContentBlock[]); + const registry = (framework as unknown as { channelRegistry: Record }).channelRegistry; + const declared = new Set(); + registry.inboundOnlyServer = (channelId: string) => (declared.has(channelId) ? 'body' : undefined); + let turn: { pin?: string; destination: string | null } | undefined; + let atFailure: { pin?: string; destination: string | null } | undefined; + registry.routeSpeech = async (_agent: string, text: string, locus?: string | null) => { + turn ??= (framework as unknown as { turnRouting: Map }).turnRouting.get('assistant'); + routed.push({ text, locus: locus ?? null }); + if (locus === 'chan-alpha') { + turn!.pin = 'discord:g:cafe'; + atFailure = { pin: turn!.pin, destination: turn!.destination }; + declared.add('chan-alpha'); + return null; + } + return { delivered: true, channelId: locus ?? '' }; + }; + feedTurn(framework, 2, [{ type: 'text', text: '>>skip_reply' }] as ContentBlock[]); + trigger(framework); + await framework.runUntilIdle(); + assert.deepEqual(atFailure, { pin: 'discord:g:cafe', destination: 'chan-alpha' }, 'the state the clear met'); + assert.equal(turn?.pin, 'discord:g:cafe', 'the moved pin stays'); + assert.equal(turn?.destination, null, 'the destination that named the refused channel ends'); + assert.deepEqual(routed, [{ text: 'first', locus: 'chan-alpha' }, { text: 'and more', locus: 'discord:g:cafe' }]); + await framework.stop(); + }); + + it("explicit: a delivery refused after a later round named another channel leaves that round's destination", async () => { + // Greta-2425's probe. Deliveries queue behind each other, and the turn + // goes on without waiting, so round 2's >>#cafe is admitted, setting the + // destination, before round 1's delivery to #alpha fails. The clear ends + // only a destination that still names #alpha. + membrane.pushResponse(createMockResponse([ + { type: 'text', text: '>>#alpha first' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: '>>#cafe second' }, + { type: 'tool_use', id: 'c2', name: 'robot--move', input: { dir: 'south' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'and more' }, + ] as ContentBlock[])); + const framework = await createFramework('explicit'); + const routed = stubRegistry(framework); + const registry = (framework as unknown as { channelRegistry: Record }).channelRegistry; + const declared = new Set(); + registry.inboundOnlyServer = (channelId: string) => (declared.has(channelId) ? 'body' : undefined); + const turnOf = () => (framework as unknown as { turnRouting: Map }).turnRouting.get('assistant'); + let turn: { pin?: string; destination: string | null } | undefined; + let destinationAtFailure: unknown = 'never reached'; + registry.routeSpeech = async (_agent: string, text: string, locus?: string | null) => { + turn ??= turnOf(); + routed.push({ text, locus: locus ?? null }); + if (locus === 'chan-alpha') { + // Hold #alpha's delivery until round 2 is admitted, then refuse it: + // its server has declared it inbound-only by now. + const deadline = Date.now() + 3000; + while (turn?.destination !== 'discord:g:cafe' && Date.now() < deadline) await new Promise((r) => setTimeout(r, 5)); + destinationAtFailure = turn?.destination ?? null; + declared.add('chan-alpha'); + return null; + } + return { delivered: true, channelId: locus ?? '' }; + }; + feedTurn(framework, 2, [{ type: 'text', text: '>>skip_reply' }] as ContentBlock[]); + trigger(framework); + await framework.runUntilIdle(); + assert.equal(destinationAtFailure, 'discord:g:cafe', 'round 2 was admitted before #alpha refused'); + assert.deepEqual(routed, [ + { text: 'first', locus: 'chan-alpha' }, + { text: 'second', locus: 'discord:g:cafe' }, + { text: 'and more', locus: 'discord:g:cafe' }, + ]); + // Delivery and a later stream of the turn agree on #cafe. + assert.equal(turn?.pin, 'discord:g:cafe'); + assert.equal(turn?.destination, 'discord:g:cafe'); + await framework.stop(); + }); + + for (const [final, kept] of [['>>#ears ! for the room', false], ['>>#cafe ! for the room', true]] as const) { + it(`explicit: a turn's final text (${kept ? 'its target kept' : 'its target refused'}) reaches nothing of its ' !' continuation, which starts a turn of its own`, async () => { + // The turn's final text is delivered after the turn settles, so neither + // its pin nor the destination a later stream would start at carries + // into the continuation: that's a new turn, with no destination. + membrane.pushResponse(createMockResponse([ + { type: 'text', text: '>>#alpha first' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: final }, + ] as ContentBlock[])); + const framework = await createFramework('explicit'); + const routed = stubRegistry(framework); + const stream = captureStream(framework); + feedTurn(framework, 2, [{ type: 'text', text: 'and more for the room' }] as ContentBlock[]); + feedTurn(framework, 3, [{ type: 'text', text: '>>skip_reply' }] as ContentBlock[]); + feedTurn(framework, 4, [{ type: 'text', text: '>>skip_reply' }] as ContentBlock[]); + trigger(framework); + await framework.runUntilIdle(); + // The first turn makes two calls, its tool round and its final text. + assert.ok(membrane.calls.length >= 3, `the continuation ran (${membrane.calls.length} calls)`); + assert.deepEqual(routed, kept + ? [{ text: 'first', locus: 'chan-alpha' }, { text: 'for the room', locus: 'discord:g:cafe' }] + : [{ text: 'first', locus: 'chan-alpha' }]); + const continuation = stream.chunks.filter((c) => c.delta.includes('and more')).map((c) => c.channelId); + assert.deepEqual(continuation, [], 'the continuation streamed nothing anywhere'); + assert.equal(stream.completes.some((c) => c.text.includes('and more')), false); + const bounces = context(framework).texts.filter((t) => t.startsWith('[prose-routing]') && t.includes('not delivered')); + assert.ok(bounces.some((t) => t.includes('this turn has no destination now')), `the continuation's text bounced: ${bounces.join(' | ')}`); + await framework.stop(); + }); + } + + it('hybrid: a target whose publish routeSpeech refuses bounces as unconfirmed, and holds later text', async () => { + // routeSpeech returns null for a server's refusal, after writing the + // send-failure marker with the server's words (channel-registry-routing). + // The unprefixed text in the next round is held, not sent to the locus. + membrane.pushResponse(createMockResponse([ + { type: 'text', text: '>>>#alpha refused here' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'and after it' }, + ] as ContentBlock[])); + const framework = await createFramework('hybrid'); + const routed = stubRegistry(framework); + const registry = (framework as unknown as { channelRegistry: Record }).channelRegistry; + registry.routeSpeech = async (_agent: string, text: string, locus?: string | null) => { + routed.push({ text, locus: locus ?? null }); + return null; + }; + feedTurn(framework, 2, [{ type: 'text', text: '>>>skip_reply' }] as ContentBlock[]); trigger(framework); await framework.runUntilIdle(); - assert.ok(membrane.calls.length >= 2, `the continuation ran (${membrane.calls.length} calls)`); - assert.deepEqual(routed, [{ text: 'first', locus: 'chan-alpha' }]); - const toAlpha = stream.chunks.filter((c) => c.channelId === 'chan-alpha').map((c) => c.delta).join(''); - assert.equal(toAlpha.includes('and more'), false, `the continuation streamed nothing to #alpha: ${toAlpha}`); - assert.equal(stream.completes.some((c) => c.text.includes('and more')), false); + assert.ok(membrane.calls.length >= 2, `the second round ran (${membrane.calls.length} calls)`); + assert.deepEqual(routed, [{ text: 'refused here', locus: 'chan-alpha' }], 'the later text went nowhere'); const bounces = context(framework).texts.filter((t) => t.startsWith('[prose-routing]') && t.includes('not delivered')); - assert.ok(bounces.some((t) => t.includes('this turn has no destination now')), `the continuation's text bounced: ${bounces.join(' | ')}`); + assert.ok(bounces.some((t) => t.includes('delivery to chan-alpha was not confirmed')), `the bounce: ${bounces.join(' | ')}`); await framework.stop(); }); From 39a3535f2c14a560d7e5f7d751f301f50b0b1bc5 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Sat, 17 Oct 2026 14:14:07 -0400 Subject: [PATCH 06/14] fix(channels): an open answered after a close leaves the channel closed, and the stream keeps nothing for an inbound-only channel Answers Greptile's review of this pull request at 4e5b4a45. - An open's answer now records the channel open only while its desired state is still on reconcile's open side, open or tuned-out, as anima-research/agent-framework#330 does for the same race. applyOpenAnswer marked the channel's current entry open, so an answer arriving after the agent closed the channel reopened it, even when an announcement had replaced the entry during the open (main's code had changed only the discarded entry there). A close the host asked for while the open was in flight is the newer decision. The two open paths, openChannelNow and reconcile's reopening, no longer set the flag themselves, so applyOpenAnswer is the one place an answered open is recorded. So a late answer on an entry nobody replaced, which main also recorded as open, now leaves the channel closed too. The answer's inbound-only declaration is still applied either way. - The outgoing stream keeps nothing for an inbound-only channel. ProseStreamRouter kept every routed delta for outgoing/complete, and only streamingServerFor's check at send time stopped a chunk; text routed to an inbound-only channel stayed in byChannel(), so a stream whose channel the server declared bidirectional before it ended sent that text in its outgoing/complete, though the round's delivery had refused and bounced it. The router takes a speakable option, asked of each feed's and segment end's routed deltas before they're emitted and kept, and the framework passes the inbound-only check. Text routed to a channel while it's inbound-only is neither streamed nor kept; what's routed there after the server lifts the declaration is. Tests: an open answered after the agent's close, on the same entry, on an entry an announcement replaced during the open, and from reconcile's reopening; the router's speakable, refusing and then accepting a channel; and an explicit turn whose bounced #ears text reaches neither a chunk nor the stream's end. --- src/framework.ts | 4 ++ src/mcpl/channel-registry.ts | 13 ++++-- src/mcpl/prose-stream-router.ts | 25 ++++++++-- test/channel-registry-routing.test.ts | 66 +++++++++++++++++++++++++++ test/explicit-prose-routing.test.ts | 53 +++++++++++++++++++++ 5 files changed, 154 insertions(+), 7 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 3ff7835ea..7da67701b 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -12355,6 +12355,10 @@ export class AgentFramework { const r = this.channelRegistry!.resolveProseTarget(spec); return 'channelId' in r ? r.channelId : null; }, + // A stream is a copy of speech, and nothing is published into a + // channel its server declared inbound-only: text routed there while + // it is one is never streamed or kept for the stream's end. + speakable: (channelId) => !this.channelRegistry!.inboundOnlyServer(channelId), }) : null; const emitOutgoing = (deltas: { channelId: string; delta: string }[]): void => { diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index a3c83e4ec..5e1e19f02 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -2316,7 +2316,6 @@ export class ChannelRegistry { type: channel.type, address: channel.address, }); - if (entry) entry.open = true; this.applyOpenAnswer(serverId, channel.id, answer); } catch (err) { if (entry) entry.open = false; @@ -2505,7 +2504,6 @@ export class ChannelRegistry { address: entry.descriptor.address, ...(history ? { history } : {}), }); - entry.open = true; this.applyOpenAnswer(entry.serverId, entry.descriptor.id, result); return result; } @@ -2518,6 +2516,14 @@ export class ChannelRegistry { * entry, which a register or `channels/changed` during the open may have * replaced. * + * The open is recorded only while the channel's desired state is still + * on reconcile's open side, open or tuned-out (a tune-out keeps the + * transport open): a close the host asked for while the open was in + * flight is the newer decision, and its answer can come first, so an open + * answered after it leaves the channel closed. This is the one place an + * answered open is recorded, for both ways the host opens. The + * declaration is applied either way. + * * Only an inbound-only declaration is applied. The answer isn't always the * server's latest word: a `channels/changed` that arrives in the same read * as the answer is handled before the open's caller resumes, so the answer @@ -2529,7 +2535,8 @@ export class ChannelRegistry { private applyOpenAnswer(serverId: string, channelId: string, answer: unknown): void { const current = this.channels.get(`${serverId}:${channelId}`); if (!current) return; - current.open = true; + const desired = this.getDesiredState(serverId, channelId); + if (desired === 'open' || desired === 'tuned-out') current.open = true; const declared = (answer as { channel?: { id?: unknown; direction?: unknown } } | undefined)?.channel; if (declared?.id === channelId && declared.direction === 'inbound') { current.descriptor = { ...current.descriptor, direction: 'inbound' }; diff --git a/src/mcpl/prose-stream-router.ts b/src/mcpl/prose-stream-router.ts index 308f554ff..9c9e66baa 100644 --- a/src/mcpl/prose-stream-router.ts +++ b/src/mcpl/prose-stream-router.ts @@ -17,7 +17,8 @@ * Suppression is fail-closed: text whose destination is unknown (explicit * mode before the first `>>`, unresolvable/ambiguous targets, `>>skip_reply`) * emits NOTHING — the delivery path will bounce/retain it; a stream consumer - * must never see text that delivery would refuse to send. + * must never see text that delivery would refuse to send. Text for a channel + * the `speakable` option refuses emits nothing either. * * Three modes, mirroring proseRouting: * - explicit: parse `>>` prefixes. A fresh turn's stream starts suppressed; @@ -58,6 +59,12 @@ export class ProseStreamRouter { initialTarget: string | null; /** Resolve a `>>spec` to a channelId, or null (unresolved/ambiguous). */ resolve: (spec: string) => string | null; + /** Whether speech may be published to this channel now. Text for a + * channel it refuses is neither emitted nor kept for outgoing/complete, + * so the stream's end can't carry it there should that change before + * the stream ends. Asked as the text is routed. Every channel, when + * left out. */ + speakable?: (channelId: string) => boolean; }, ) { this.target = opts.initialTarget; @@ -100,7 +107,7 @@ export class ProseStreamRouter { feed(delta: string): RoutedDelta[] { const out: RoutedDelta[] = []; for (const ch of delta) this.step(ch, out); - return coalesce(out); + return this.keep(out); } /** @@ -127,7 +134,7 @@ export class ProseStreamRouter { this.held = ''; this.token = ''; if (this.opts.mode !== 'locus') this.state = 'line-start'; - return coalesce(out); + return this.keep(out); } /** End of stream: the last segment ends. */ @@ -253,7 +260,17 @@ export class ProseStreamRouter { private emit(text: string, out: RoutedDelta[]): void { if (this.target === null || !text) return; out.push({ channelId: this.target, delta: text }); - this.accumulated.set(this.target, (this.accumulated.get(this.target) ?? '') + text); + } + + /** + * What one feed or segment end routed, less any channel `speakable` + * refuses, kept for outgoing/complete. A feed runs without yielding, so + * one answer per channel holds for all of it. + */ + private keep(out: RoutedDelta[]): RoutedDelta[] { + const kept = coalesce(out).filter((d) => this.opts.speakable?.(d.channelId) ?? true); + for (const d of kept) this.accumulated.set(d.channelId, (this.accumulated.get(d.channelId) ?? '') + d.delta); + return kept; } } diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index 41161aff8..6ee4764a9 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -1262,3 +1262,69 @@ test('a channel announced again while the host opens it is recorded open once th release(); await announced; }); + +// An open answered after a close the host asked for during it: the close is +// the newer decision, so the channel stays closed, whichever way the host +// opened it and whether or not an announcement replaced its entry meanwhile. +async function closedWhileOpening(holdOpen: number) { + const made = makeRegistry({ delivered: true }); + const home = { id: 'body:home', type: 'chat', label: 'Body · home', direction: 'bidirectional' }; + await made.registry.handleRegister('body', { channels: [home] } as never); + let answer!: () => void; + const answered = new Promise((resolve) => { answer = resolve; }); + let sent!: () => void; + const held = new Promise((resolve) => { sent = resolve; }); + let opens = 0; + made.setOnOpen(async () => { + opens++; + if (opens === holdOpen) { sent(); await answered; } + }); + const agent = { kind: 'agent' as const, agentName: 'linn' }; + return { ...made, home, agent, answer, held, opens: () => opens }; +} + +test("an open answered after the agent closed the channel during it leaves the channel closed", async () => { + const { registry, closeCalls, agent, answer, held } = await closedWhileOpening(1); + const opening = registry.handleChannelToolCall('channel_open', { channelId: 'body:home' }, agent); + await held; + // Registering the channel already sent one close, its reconcile's. + const closesBefore = closeCalls.length; + const closed = await registry.handleChannelToolCall('channel_close', { channelId: 'body:home' }, agent); + assert.equal((closed as { success?: boolean }).success, true); + assert.equal(closeCalls.length, closesBefore + 1, "the agent's close was sent"); + answer(); + await opening; + assert.equal(registry.getDesiredState('body', 'body:home'), 'closed'); + assert.equal(registry.isChannelOpen('body:home'), false, 'the open answered after the close must not record the channel open'); +}); + +test("an open answered after the agent closed the channel leaves it closed, though an announcement replaced its entry during the open", async () => { + const { registry, home, agent, answer, held, opens } = await closedWhileOpening(1); + const opening = registry.handleChannelToolCall('channel_open', { channelId: 'body:home' }, agent); + await held; + // The server announces the channel again: its entry is replaced, and the + // announcement's reconcile opens it, answered at once. + await registry.handleChanged('body', { added: [home] } as never); + assert.equal(opens(), 2); + assert.equal(registry.isChannelOpen('body:home'), true, "the reconcile's answered open is recorded"); + await registry.handleChannelToolCall('channel_close', { channelId: 'body:home' }, agent); + assert.equal(registry.isChannelOpen('body:home'), false); + answer(); + await opening; + assert.equal(registry.isChannelOpen('body:home'), false, "the first open's late answer must not reopen the replacement"); +}); + +test("a reconcile's open answered after the agent closed the channel leaves it closed", async () => { + const { registry, home, agent, answer, held, opens } = await closedWhileOpening(2); + assert.equal((await registry.handleChannelToolCall('channel_open', { channelId: 'body:home' }, agent) as { success?: boolean }).success, true); + assert.equal(registry.isChannelOpen('body:home'), true); + // The server announces it again; the reconcile's open is the one held. + const announced = registry.handleChanged('body', { added: [home] } as never); + await held; + assert.equal(opens(), 2); + await registry.handleChannelToolCall('channel_close', { channelId: 'body:home' }, agent); + answer(); + await announced; + assert.equal(registry.getDesiredState('body', 'body:home'), 'closed'); + assert.equal(registry.isChannelOpen('body:home'), false, "the reconcile's late answer must not record the channel open"); +}); diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index 0b9fcc0cb..a9e8083ce 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -1334,6 +1334,28 @@ describe('explicit prose routing', () => { } }); + it('the stream neither sends nor keeps text for a channel `speakable` refuses, and keeps what it routes there once `speakable` accepts it', () => { + let earsSpeakable = false; + const router = new ProseStreamRouter({ + mode: 'explicit', + initialTarget: null, + resolve: (spec) => (spec === '#ears' ? 'body:ears' : spec === '#alpha' ? 'chan-alpha' : null), + speakable: (channelId) => channelId !== 'body:ears' || earsSpeakable, + }); + const streamed = [ + ...router.feed('>>#ears refused words'), ...router.endSegment(), + ...router.feed('>>#alpha for alpha'), ...router.endSegment(), + ]; + earsSpeakable = true; + streamed.push(...router.feed('>>#ears spoken words'), ...router.finish()); + assert.deepEqual(streamed, [ + { channelId: 'chan-alpha', delta: 'for alpha' }, + { channelId: 'body:ears', delta: 'spoken words' }, + ]); + assert.deepEqual([...router.byChannel()], [['chan-alpha', 'for alpha'], ['body:ears', 'spoken words']], + 'outgoing/complete keeps only what was streamed'); + }); + it('admission turns a resolver that throws into an envelope that bounces', async () => { const framework = await createFramework(); const routed = stubRegistry(framework); @@ -1535,6 +1557,37 @@ describe('explicit prose routing', () => { await framework.stop(); }); + it("explicit: text routed to a channel its server declared inbound-only isn't streamed there, or sent at the stream's end", async () => { + // The round's delivery refuses #ears and bounces its text. The stream + // took it as it was written, and its end would send it should the server + // lift the declaration before the stream ends. + membrane.pushResponse(createMockResponse([ + { type: 'text', text: '>>#ears for the room' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: '>>#alpha done' }, + ] as ContentBlock[])); + const framework = await createFramework('explicit'); + stubRegistry(framework); + const registry = (framework as unknown as { channelRegistry: Record }).channelRegistry; + registry.inboundOnlyServer = (channelId: string) => (channelId === 'body:ears' ? 'body' : undefined); + const chunks: Array<{ channelId: string; delta: string }> = []; + const completes: Array<{ channelId: string; text: string }> = []; + registry.sendOutgoingChunk = (channelId: string, _agent: string, _inference: string, _index: number, delta: string) => { + chunks.push({ channelId, delta }); + }; + registry.sendOutgoingComplete = (channelId: string, _agent: string, _inference: string, text: string) => { + completes.push({ channelId, text }); + }; + feedTurn(framework, 2, [{ type: 'text', text: '>>skip_reply' }] as ContentBlock[]); + trigger(framework); + await framework.runUntilIdle(); + assert.deepEqual(chunks.filter((c) => c.channelId === 'body:ears'), [], 'nothing is streamed to the inbound-only channel'); + assert.deepEqual(completes, [{ channelId: 'chan-alpha', text: 'done' }], "the stream's end sends only what the stream routed and kept"); + await framework.stop(); + }); + for (const refusal of ['inbound-only', 'inbound-only, rejected', 'another failure', 'another failure, rejected', 'delivered, then declared'] as const) { const inboundOnly = refusal === 'inbound-only' || refusal === 'inbound-only, rejected'; it(`explicit: a delivery routeSpeech ${refusal === 'delivered, then declared' ? 'delivers while the channel turns inbound-only' : `fails (${refusal})`} ${inboundOnly ? "clears the turn's destination, as a refusal at admission does" : 'keeps the destination, as before'}`, async () => { From 15a15fc939de556f501dee6a7cc2f82bcd709e33 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Sat, 17 Oct 2026 14:14:07 -0400 Subject: [PATCH 07/14] fix(channels): pin the tune-out arm and the late answer's declaration, and say where the open is recorded Answers the internal review of e7aeba49. - openChannelNow's doc says it hands the answer to applyOpenAnswer, which records the open under the desired-state rule, and that reconcile's reopening hands its own answer there too. It said the executor marked the live entry open, and that every open funnelled through it. - Tests: the same-entry case's late answer declares the channel inbound-only, and the declaration lands while the open isn't recorded, which also shows the answer reached the host; and a reconcile's open answered while the channel is tuned out is recorded. --- src/mcpl/channel-registry.ts | 10 ++++++---- test/channel-registry-routing.test.ts | 16 +++++++++++++++- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 5e1e19f02..8d8b68d14 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -2476,10 +2476,12 @@ export class ChannelRegistry { /** * The single open executor: record durable desired-open intent, tell the - * server to subscribe, mark the live entry open. Every path that opens a - * channel — the agent's channel_open tool, delivery into a closed locus, - * an explicit send into a closed channel — funnels here, so lifecycle - * events, desired state, and live state can never diverge by path. + * server to subscribe, and hand its answer to applyOpenAnswer, which + * records the live entry open unless the host has asked to close it since. + * Every open the agent or its speech asks for — the channel_open tool, + * delivery into a closed locus, an explicit send into a closed channel — + * funnels here; reconcile's reopening sends its own and hands its answer + * to applyOpenAnswer too. * Desired state is recorded BEFORE the server round-trip: intent sticks * even if the subscribe fails, and reconciliation retries later. */ diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index 6ee4764a9..95a9eb6aa 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -1284,7 +1284,10 @@ async function closedWhileOpening(holdOpen: number) { } test("an open answered after the agent closed the channel during it leaves the channel closed", async () => { - const { registry, closeCalls, agent, answer, held } = await closedWhileOpening(1); + const { registry, closeCalls, agent, answer, held, setOpenResult } = await closedWhileOpening(1); + // The answer declares the channel inbound-only, which lands either way: it + // shows the late answer reached the host. + setOpenResult({ channel: { id: 'body:home', type: 'chat', label: 'Body · home', direction: 'inbound' } }); const opening = registry.handleChannelToolCall('channel_open', { channelId: 'body:home' }, agent); await held; // Registering the channel already sent one close, its reconcile's. @@ -1295,9 +1298,20 @@ test("an open answered after the agent closed the channel during it leaves the c answer(); await opening; assert.equal(registry.getDesiredState('body', 'body:home'), 'closed'); + assert.equal(registry.inboundOnlyServer('body:home'), 'body', "the late answer's declaration was applied"); assert.equal(registry.isChannelOpen('body:home'), false, 'the open answered after the close must not record the channel open'); }); +test("a reconcile's open answered while the channel is tuned out is recorded: a tune-out keeps the transport open", async () => { + const { registry, home, opens } = await closedWhileOpening(0); + assert.equal(registry.isChannelOpen('body:home'), false); + (registry as unknown as { setDesiredState(s: string, c: string, state: string, source: string): void }) + .setDesiredState('body', 'body:home', 'tuned-out', 'agent-tool'); + await registry.handleChanged('body', { added: [home] } as never); + assert.equal(opens(), 1, "the announcement's reconcile opened it"); + assert.equal(registry.isChannelOpen('body:home'), true); +}); + test("an open answered after the agent closed the channel leaves it closed, though an announcement replaced its entry during the open", async () => { const { registry, home, agent, answer, held, opens } = await closedWhileOpening(1); const opening = registry.handleChannelToolCall('channel_open', { channelId: 'body:home' }, agent); From 661d55ec1ed83fcbee0b119287462280f5fcf44a Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Sat, 17 Oct 2026 14:16:07 -0400 Subject: [PATCH 08/14] docs(stream): speakable is asked as text is routed, and delivery decides when its round is admitted Text speakable refuses stays out of the stream even if the channel accepts speech again before the round is admitted, and the round then posts it: the stream's copy lacks those words. The option's doc says so. --- src/mcpl/prose-stream-router.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/mcpl/prose-stream-router.ts b/src/mcpl/prose-stream-router.ts index 9c9e66baa..fd5dc56fb 100644 --- a/src/mcpl/prose-stream-router.ts +++ b/src/mcpl/prose-stream-router.ts @@ -62,8 +62,10 @@ export class ProseStreamRouter { /** Whether speech may be published to this channel now. Text for a * channel it refuses is neither emitted nor kept for outgoing/complete, * so the stream's end can't carry it there should that change before - * the stream ends. Asked as the text is routed. Every channel, when - * left out. */ + * the stream ends. Asked as the text is routed, while delivery decides + * when its round is admitted: text refused here stays out of the + * stream even if the channel accepts speech again by then and the + * round posts it. Every channel, when left out. */ speakable?: (channelId: string) => boolean; }, ) { From 8e4cc49e9e848dcbff57696a29e04791bd2de9f3 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Sat, 17 Oct 2026 22:11:49 -0400 Subject: [PATCH 09/14] Answer the second review: speak_in_channel's routed trace without a messageId, and two text fixes R2-1: publishForAgent's mcpl:speech-routed trace no longer carries the surface's messageId, as on main. Nothing in agent-framework reads it. connectome-host's TTS relay keeps every routed trace without reading conversationId, and when an interruption of the resident matches no entry it edits the newest traced message in the channel, so an id there would let it trim the subconscious's post. The routed-trace test now has the server name a message and asserts that the trace carries none, with that reason as its message. The changelog says so, and so does mcpl:speech-routed's doc in types/trace.ts, which had said the field holds the surface's id whenever the server reports one. R2-2: the changelog's "for every other failure of plain speech" becomes "for every other failure that gets a marker": a timeout, a lost connection or a closed one gets neither a marker nor a trace. R2-3: prose_help's test asserts each of the help's three clauses on a destination, read across the text's line breaks: it lasts until another is named, a name that matches no channel or several leaves it as it was, and an inbound-only channel is refused and clears it. The test asserted only the word "destination" before, which main's wording also has. Co-Authored-By: Claude Opus 5.5 --- changelog.d/inbound-only-channels.fixed.md | 8 +++---- src/mcpl/channel-registry.ts | 6 +++-- src/types/trace.ts | 14 +++++++----- test/channel-registry-routing.test.ts | 9 +++++--- test/explicit-prose-routing.test.ts | 26 ++++++++++++++++++++-- 5 files changed, 46 insertions(+), 17 deletions(-) diff --git a/changelog.d/inbound-only-channels.fixed.md b/changelog.d/inbound-only-channels.fixed.md index 45a94e559..5d7a45ca9 100644 --- a/changelog.d/inbound-only-channels.fixed.md +++ b/changelog.d/inbound-only-channels.fixed.md @@ -31,7 +31,7 @@ It carries the server's own message, cut at 200 characters, or says it sent none, and an `mcpl:speech-route-failed` trace goes out with `outcome: "unknown"`. The marker's and the trace's `outcome` is - `"failed"` for every other failure of plain speech. Before, the error was + `"failed"` for every other failure that gets a marker. Before, the error was only logged, unless the text had a hybrid `>>>` target, which bounced it. Any other failed publish still rejects as before: one that times out or loses its connection before an answer, which the server may or may not @@ -55,8 +55,8 @@ - Speech into a channel that is removed while the host opens it is refused, and nothing is published to it. - `speak_in_channel`'s `mcpl:speech-routed` trace now follows a publish the - server accepted and didn't report undelivered, with the fields - `routeSpeech`'s trace has (`serverId`, `delivered`, `textLen` and the - surface's `messageId`). Any other outcome, as the tool's result reports + server accepted and didn't report undelivered, with `serverId`, + `delivered` and `textLen` as `routeSpeech`'s trace has them. It carries no + `messageId`, as before. Any other outcome, as the tool's result reports it, is traced as `mcpl:speech-route-failed`: a refusal, a publish the server reported undelivered, or one that failed, a timeout included. diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 8d8b68d14..b5c70dad8 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -2133,6 +2133,9 @@ export class ChannelRegistry { * as the channel_publish tool. Its traces carry the agent's name and follow * the outcome as routeSpeech's do: speech-routed after a publish the server * accepted and didn't report undelivered, and speech-route-failed otherwise. + * The routed trace carries no message id, as before: nothing here reads one, + * and a consumer that edits the newest traced message in a channel would + * have this post to edit when an interruption of the resident matches none. */ async publishForAgent( channelId: string, @@ -2144,7 +2147,7 @@ export class ChannelRegistry { const serverId = this.findChannelEntry(channelId)?.serverId ?? ''; const seen: { rejection?: 'error-response' | 'other' } = {}; const result = await this.handleToolPublish({ channelId, content: text }, seen); - const reply = result.data as { delivered?: boolean; messageId?: string } | undefined; + const reply = result.data as { delivered?: boolean } | undefined; if (result.success && reply?.delivered !== false) { this.emitTraceFn({ type: 'mcpl:speech-routed', @@ -2154,7 +2157,6 @@ export class ChannelRegistry { delivered: true, textLen: text.length, text, - ...(reply?.messageId !== undefined ? { messageId: reply.messageId } : {}), }); } else { // What the host knows of the text, as routeSpeech's trace says it: an diff --git a/src/types/trace.ts b/src/types/trace.ts index a21793ed6..196fcd37c 100644 --- a/src/types/trace.ts +++ b/src/types/trace.ts @@ -429,13 +429,14 @@ export type TraceEvent = // Host-owned speech routing (ChannelRegistry.routeSpeech). One trace per // delivered prose segment, and one per publish the subconscious's // speak_in_channel delivers (ChannelRegistry.publishForAgent, whose - // conversationId is the speaking agent's name). `text` is the segment verbatim — the same content + // conversationId is the speaking agent's name, and which carries no + // messageId). `text` is the segment verbatim — the same content // already on the bus chunk-by-chunk via inference:tokens, and mcpl:* traces // are 'ops'-scoped on the webui wire — and `messageId` is the surface's id - // for the posted message (when the MCPL server reports one). Together they - // give external taps the (channelId, messageId, text) tuple needed to edit - // a just-posted message, e.g. a TTS interruption truncating a reply to the - // words actually voiced. + // for the posted message (on routeSpeech's traces, when the MCPL server + // reports one). Together they give external taps the (channelId, + // messageId, text) tuple needed to edit a just-posted message, e.g. a TTS + // interruption truncating a reply to the words actually voiced. | (TraceEventBase & { type: 'mcpl:speech-routed'; /** Agent / conversation whose speech was routed. */ @@ -446,7 +447,8 @@ export type TraceEvent = textLen: number; /** The routed prose segment, verbatim. */ text: string; - /** Surface message id (e.g. Discord message id), when reported. */ + /** Surface message id (e.g. Discord message id), when reported, on + * routeSpeech's traces. speak_in_channel's carry none. */ messageId?: string; }) | (TraceEventBase & { diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index 95a9eb6aa..1ee42b2d0 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -774,11 +774,14 @@ test('publishForAgent into an inbound-only channel is refused and traced as fail const posted = await registry.publishForAgent('body:home', 'a thought', 'Subconscious'); assert.equal(posted.success, true); assert.deepEqual(publishCalls.map((c) => c.channelId), ['body:home']); - // Shaped as routeSpeech's trace is, with the surface's message id. + // Shaped as routeSpeech's trace is, but without the surface's message id, + // though the server named one ('m-77'). assert.deepEqual(routed(), [{ type: 'mcpl:speech-routed', conversationId: 'Subconscious', serverId: 'body', channelId: 'body:home', - delivered: true, textLen: 'a thought'.length, text: 'a thought', messageId: 'm-77', - }]); + delivered: true, textLen: 'a thought'.length, text: 'a thought', + }], "no messageId: connectome-host's TTS relay keeps every routed trace without reading conversationId, " + + 'and when an interruption of the resident matches no entry it edits the newest traced message in the ' + + "channel, so an id here would let it trim the subconscious's post"); }); test('publishForAgent traces a publish the server reports undelivered as failed, not routed', async () => { diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index a9e8083ce..d4ef0533d 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -1462,8 +1462,30 @@ describe('explicit prose routing', () => { const stream = membrane.lastStream!; assert.equal(stream.receivedToolResults.length, 1); - const result = JSON.stringify(stream.receivedToolResults[0]); - assert.ok(result.includes('destination'), 'help text served as tool result'); + const strings: string[] = []; + JSON.stringify(stream.receivedToolResults[0], (_key, value) => { + if (typeof value === 'string') strings.push(value); + return value; + }); + // The help is the agent's only full reference for a destination's rule, + // so each of its clauses is pinned, read across the text's line breaks. + // The result's content is the help as a JSON string, so it's decoded first. + const decoded = strings.map((s) => { + try { + const value: unknown = JSON.parse(s); + return typeof value === 'string' ? value : s; + } catch { + return s; + } + }); + const help = decoded.join(' ').replace(/\s+/g, ' '); + for (const clause of [ + 'A destination applies to the rest of that turn, until another is named.', + 'A name that matches no channel, or several, bounces and leaves it as it was.', + 'A channel its server declared inbound-only is refused, and clears it.', + ]) { + assert.ok(help.includes(clause), `the help says: ${clause}`); + } assert.deepEqual(routed, [{ text: 'got it', locus: 'chan-alpha' }]); await framework.stop(); From b67453cf52dfce0b707ade62cab54fca3530d40e Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Sun, 18 Oct 2026 07:24:48 -0400 Subject: [PATCH 10/14] Answer the merge's review: a held hybrid round refuses an inbound-only target, and text fixes From the internal review of the merge of main and the commits since the review before it, and from a colleague's read of the merge. - holdSilencedProse resolves a `>>>` target with forSpeech, as deliverHybridProse does. A hybrid round silenced by a send moves the router's state as delivery would, and delivery refuses a channel its server declared inbound-only: the pin is dropped and the hold fails. Without forSpeech the held round pinned that channel, and the turn's later unprefixed text was aimed at it, and publish() refused each. A test holds a `>>>#ears` round and checks no pin is set. - publishForAgent's failure trace carries the connector's detail, as deliverSpeech's does, and the speech-route-failed trace type declares the `detail` field deliverSpeech already emitted. The routing test's error response now carries data, and its trace and tool error are checked for it. - The serverMessage sentence moves out of mcpl-request-outcome's fragment, which ships in the release before this change, and into this change's own fragment. That fragment now also says a missing code reads `none`, and that channel_decline reports a failed acknowledgement in its acknowledgmentError rather than an error. - Comments: publish()'s doc says a send refused before the open opens nothing, beside the words refused after it; deliverSpeech's doc names the held failure that writes no marker; the mid-turn retarget's comment says a target refused for speech clears the destination; and the speech-routed trace's comment says which trace carries no messageId. Co-Authored-By: Claude Opus 5.5 --- changelog.d/inbound-only-channels.fixed.md | 22 +++++++++++++--------- changelog.d/mcpl-request-outcome.added.md | 3 +-- src/framework.ts | 7 +++++-- src/mcpl/channel-registry.ts | 17 ++++++++++------- src/types/trace.ts | 9 ++++++--- test/channel-registry-routing.test.ts | 10 ++++++---- test/explicit-prose-routing.test.ts | 18 ++++++++++++++++++ 7 files changed, 59 insertions(+), 27 deletions(-) diff --git a/changelog.d/inbound-only-channels.fixed.md b/changelog.d/inbound-only-channels.fixed.md index c5a71c7e8..4704fdbaa 100644 --- a/changelog.d/inbound-only-channels.fixed.md +++ b/changelog.d/inbound-only-channels.fixed.md @@ -27,17 +27,21 @@ declared that channel inbound-only. - A server's error response reaches the resident in the server's own words, naming the server: in the send-failure marker, in a draft's recorded - outcome, and in the errors of `channel_publish`, `speak_in_channel`, - `channel_open` and `channel_close`, and of `channel_decline` when its - acknowledgement request fails. A publish answered with an error still says - delivery wasn't confirmed. + outcome, in the errors of `channel_publish`, `speak_in_channel`, + `channel_open` and `channel_close`, and in `channel_decline`'s + `acknowledgmentError` when its acknowledgement request fails. A publish + answered with an error still says delivery wasn't confirmed. + `McplRequestError` carries those words as `serverMessage` on an + `error-response`, bounded as its message is. - A server's error response, to a request the host sends once the connection - is up, says what came whatever it holds: text sent in place of an error - object is still the server's words, a missing message says so, and a - message that isn't text, a code that is neither a number nor text, or an + is up, now says what came whatever it holds, in `serverMessage` and in the + error's message: text sent in place of an error object is still the + server's words, a missing message says so, a missing code reads `none`, and + a message that isn't text, a code that is neither a number nor text, or an error that is neither an object nor text, is shown as JSON, bounded. - `speak_in_channel`'s `mcpl:speech-routed` trace now follows a confirmed delivery, with `serverId`, `delivered` and `textLen` as plain speech's trace has them, and no `messageId`, as before. Any other result is traced as - `mcpl:speech-route-failed`, with `outcome: "unknown"` when the words may - have been posted and `"failed"` when nothing was, a refusal included. + `mcpl:speech-route-failed`, with the publish's reason, the connector's + detail when it sent some, and `outcome: "unknown"` when the words may have + been posted or `"failed"` when nothing was, a refusal included. diff --git a/changelog.d/mcpl-request-outcome.added.md b/changelog.d/mcpl-request-outcome.added.md index 4a365d55e..a49bfd7c0 100644 --- a/changelog.d/mcpl-request-outcome.added.md +++ b/changelog.d/mcpl-request-outcome.added.md @@ -3,8 +3,7 @@ whose `outcome` says which of three things happened: `not-sent` (refused before anything was written: the connection was already closed), `error-response` (the server answered with a JSON-RPC error; the error's - `data` is kept, its code is in the message, and `serverMessage` holds the - server's own words as the agent reads them), or `no-response` (the request was handed to the + `data` is kept, and its code is in the message), or `no-response` (the request was handed to the transport and no answer came back: a timeout, or the connection closed while awaiting; it may or may not have reached the server). Only `not-sent` proves the server never saw the request. Messages are unchanged, diff --git a/src/framework.ts b/src/framework.ts index a7b35ad8e..82e2d1ab1 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -12746,7 +12746,9 @@ export class AgentFramework { if (parsed.body.includes('{{unsent}}')) this.dismissLatestBounce(agent); privateCount++; } else if (parsed.kind === 'target') { - const resolved = this.channelRegistry?.resolveProseTarget(parsed.target!); + // As deliverHybridProse resolves it: a channel its server declared + // inbound-only is refused as a target for speech. + const resolved = this.channelRegistry?.resolveProseTarget(parsed.target!, { forSpeech: true }); if (resolved && !('error' in resolved)) { routing.pin = resolved.channelId; delete routing.hybridHold; @@ -15825,7 +15827,8 @@ export class AgentFramework { // unprefixed text: the turn's destination. The stream // followed this round's prefixes as it was written, but a // private round's, a >>skip_reply envelope's or a target - // that didn't resolve leave the destination as it was. + // that didn't resolve leave the destination as it was, + // and a target refused for speech clears it. proseStream?.retarget(streamRouting.destination); } else if (turnSilenced) { console.error( diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 816b76577..67bdf786d 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -2238,11 +2238,12 @@ export class ChannelRegistry { type: 'mcpl:speech-route-failed', conversationId: agentName, channelId, - // The publish's own reason, as deliverSpeech's trace gives it, or the - // refusal before any publish. + // The publish's own reason and the connector's detail, as + // deliverSpeech's trace gives them, or the refusal before any publish. reason: seen.outcome?.reason ?? result.error ?? 'the publish failed', textLen: text.length, outcome: seen.outcome?.status === 'unknown' ? 'unknown' : 'failed', + ...(seen.outcome?.detail !== undefined ? { detail: connectorText(seen.outcome.detail) } : {}), }); } return result; @@ -3336,7 +3337,8 @@ export class ChannelRegistry { * possible to send into a closed channel: a closed but registered * destination is opened first (`openSource` names why), once its server * and that server's channels.publish grant are known to take the send, so - * a refused send opens nothing; and a failed open sends nothing. + * a send refused before the open opens nothing; and a failed open sends + * nothing. * * A channel its server declared inbound-only (isInboundOnly) takes nothing * the host would post, whichever path the words took, a drafts resend @@ -3461,10 +3463,11 @@ export class ChannelRegistry { * Plain speech for one turn segment: publish `text` to the turn's frozen * locus and report the PublishOutcome. A failure (or an uncertain outcome) * is surfaced as a trace and through onRouteFailure, so the agent learns - * its words did not (or may not have) reached anyone. This is host-owned - * output routing (see forking-knowledge-miner LOCUS-ROUTING-DESIGN), which - * replaced the per-surface sticky auto-post that used to live in - * discord-mcpl. + * its words did not (or may not have) reached anyone, except a `failed` + * one whose caller holds its words with a notice of its own + * (`heldOnFailure`). This is host-owned output routing (see + * forking-knowledge-miner LOCUS-ROUTING-DESIGN), which replaced the + * per-surface sticky auto-post that used to live in discord-mcpl. */ async deliverSpeech( conversationId: string, diff --git a/src/types/trace.ts b/src/types/trace.ts index d8e0597fc..27ea5e5b1 100644 --- a/src/types/trace.ts +++ b/src/types/trace.ts @@ -443,9 +443,9 @@ export type TraceEvent = // Host-owned speech routing (ChannelRegistry.routeSpeech). One trace per // delivered prose segment, and one per publish the subconscious's - // speak_in_channel delivers (ChannelRegistry.publishForAgent, whose - // conversationId is the speaking agent's name, and which carries no - // messageId). `text` is the segment verbatim — the same content + // speak_in_channel delivers (ChannelRegistry.publishForAgent): that one's + // conversationId is the speaking agent's name, and it carries no + // messageId. `text` is the segment verbatim — the same content // already on the bus chunk-by-chunk via inference:tokens, and mcpl:* traces // are 'ops'-scoped on the webui wire — and `messageId` is the surface's id // for the posted message (on routeSpeech's traces, when the MCPL server @@ -477,6 +477,9 @@ export type TraceEvent = /** `failed`: nothing was posted; `unknown`: the request was dispatched * and no valid receipt came back, so it may have been. */ outcome?: 'failed' | 'unknown'; + /** The connector error's structured data, bounded (connectorText), + * when it sent some: it may say what a multi-part send posted. */ + detail?: string; }) // Admin puppet: an operator executed a tool AS an agent and stored the diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index 0f51aa4be..eba35fab3 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -823,12 +823,13 @@ test("publishForAgent traces what a rejected publish may have done: unknown afte // An error response may follow a partial post. setOnPublish(async () => { - throw new McplRequestError('MCPL server "body" returned error for channels/publish: [-32000] Rate limited after part 1', 'error-response', undefined, 'Rate limited after part 1'); + throw new McplRequestError('MCPL server "body" returned error for channels/publish: [-32000] Rate limited after part 1', 'error-response', { posted: ['m-1'] }, 'Rate limited after part 1'); }); const unconfirmed = await registry.publishForAgent('body:home', 'a long thought', 'Subconscious'); - assert.equal(unconfirmed.error, 'Publish to body:home wasn\'t confirmed: server "body" answered with an error: Rate limited after part 1 (delivery uncertain). It may or may not have been posted, so sending it again could post it twice.'); - assert.deepEqual(failed().map((t) => [t.reason, t.outcome]), - [['server "body" answered with an error: Rate limited after part 1 (delivery uncertain)', 'unknown']]); + assert.equal(unconfirmed.error, 'Publish to body:home wasn\'t confirmed: server "body" answered with an error: Rate limited after part 1 (delivery uncertain). It may or may not have been posted, so sending it again could post it twice. Connector detail: {"posted":["m-1"]}'); + assert.deepEqual(failed().map((t) => [t.reason, t.outcome, t.detail]), + [['server "body" answered with an error: Rate limited after part 1 (delivery uncertain)', 'unknown', '{"posted":["m-1"]}']], + "the trace carries the connector's detail, as plain speech's does"); // So may a request that went out and got no answer. setOnPublish(async () => { @@ -844,6 +845,7 @@ test("publishForAgent traces what a rejected publish may have done: unknown afte const notSent = await registry.publishForAgent('body:home', 'another', 'Subconscious'); assert.match(notSent.error ?? '', /^Publish to body:home failed: .*is closed\. Nothing was posted\./); assert.equal(failed()[2]!.outcome, 'failed'); + assert.equal('detail' in failed()[2]!, false, 'no detail when the connector sent none'); assert.equal(traces.filter((t) => t.type === 'mcpl:speech-routed').length, 0); }); diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index ae0274efc..b4c2b7aa1 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -1842,6 +1842,24 @@ describe('explicit prose routing', () => { } }); + it('hybrid: a hold beside a send refuses a >>> target its server declared inbound-only, as delivery does, and pins nothing', async () => { + const framework = await createFramework('hybrid'); + stubRegistry(framework); + const gateway = framework as unknown as { + holdSilencedProse(agent: unknown, segments: string[], cause: 'send', privateThink: boolean, round: { routing: RoutingState; notice: 'later' }): void; + }; + const agent = framework.getAgent('assistant')!; + try { + const routing: RoutingState = routingState(); + gateway.holdSilencedProse(agent, ['>>>#ears for the room', 'after it'], 'send', false, { routing, notice: 'later' }); + assert.equal(routing.pin, undefined, 'the inbound-only channel is never pinned'); + assert.equal(routing.hybridHold, 'failed', 'the envelope fails as a refused target does at delivery'); + assert.deepEqual(routing.drafts.map((d) => d.text), ['for the room', 'after it'], 'and the words are held as drafts'); + } finally { + await framework.stop(); + } + }); + it('hybrid: after >>>skip_reply, the next unprefixed envelope stays in the context and counts as kept private', async () => { const framework = await createFramework('hybrid'); const routed = stubRegistry(framework); From db91add9142789141fa11cea13baa28eab7cf5c7 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Sun, 18 Oct 2026 12:37:30 -0400 Subject: [PATCH 11/14] Answer the merge's review: the hybrid hold test starts from a pin, and publish()'s doc names a removal From the internal review of the merge of main and the round before it. - The hybrid test of a hold beside a send now starts with the turn pinned to #alpha by an earlier round. A refused >>> target must drop that pin as well as refuse the channel, so the test now fails if holdSilencedProse keeps the earlier pin on a refused target, as it does if the target is resolved without forSpeech. - publish()'s doc says that words refused after an open because the channel was declared inbound-only leave it open with the open's notice, and that a channel removed meanwhile is gone, with a failure that names the removal. It had said every refusal after an open left the channel open. Co-Authored-By: Claude Opus 5.5 --- src/mcpl/channel-registry.ts | 7 ++++--- test/explicit-prose-routing.test.ts | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index c34a9cf55..f03e3d302 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -3353,9 +3353,10 @@ export class ChannelRegistry { * server, and a channels/changed update, a re-register or the open's own * answer (applyOpenAnswer) may declare the channel inbound-only meanwhile, * or a removal take it away, so both are checked again just before the - * publish, after every await. Words refused after an open leave the - * channel open: the agent gets the notice that it's open, which says how - * to close it, and the failure for the words. + * publish, after every await. Words refused after an open for a + * declaration leave the channel open: the agent gets the notice that it's + * open, which says how to close it, and the failure for the words. A + * channel removed meanwhile is gone, and the failure names the removal. * * Only the connector's `delivered: true` confirms a post. `failed` means * nothing was posted: the attempt stopped before the request was written, diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index b4c2b7aa1..8ca49a025 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -1842,7 +1842,7 @@ describe('explicit prose routing', () => { } }); - it('hybrid: a hold beside a send refuses a >>> target its server declared inbound-only, as delivery does, and pins nothing', async () => { + it('hybrid: a hold beside a send refuses a >>> target its server declared inbound-only, as delivery does, and drops the pin before it', async () => { const framework = await createFramework('hybrid'); stubRegistry(framework); const gateway = framework as unknown as { @@ -1850,9 +1850,10 @@ describe('explicit prose routing', () => { }; const agent = framework.getAgent('assistant')!; try { - const routing: RoutingState = routingState(); + // An earlier round of the turn pinned #alpha. + const routing: RoutingState = routingState({ pin: 'chan-alpha' }); gateway.holdSilencedProse(agent, ['>>>#ears for the room', 'after it'], 'send', false, { routing, notice: 'later' }); - assert.equal(routing.pin, undefined, 'the inbound-only channel is never pinned'); + assert.equal(routing.pin, undefined, 'the inbound-only channel is never pinned, and the earlier pin is dropped'); assert.equal(routing.hybridHold, 'failed', 'the envelope fails as a refused target does at delivery'); assert.deepEqual(routing.drafts.map((d) => d.text), ['for the room', 'after it'], 'and the words are held as drafts'); } finally { From c12884ee9e61fe71c49ad87871df6fddf1f71829 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Sun, 18 Oct 2026 23:18:50 -0400 Subject: [PATCH 12/14] docs(mcpl): say exactly what publish()'s last check refuses, and that an inbound-only target clears explicit mode's target publish()'s check just before the publish reads the channel's registration as it is then, and refuses one that is gone or inbound-only. Its doc, its comment, the changelog fragment and the description had said that a change made while the opening is announced counts too. A registration that replaced the waited one after the last wait, as one announced again from the opening's notice does, is neither gone nor inbound-only, so the words go out into it, as they do on main, whose publish() checks nothing after the notice. The texts now say exactly what the check refuses. The explicit-routing guide and the stream router's retarget() doc said that a target that didn't resolve leaves the turn's target as it was. A target its server declared inbound-only is refused and clears it, so later unprefixed segments bounce until a new >> target, as prose_help already says. Both now say so. The router's contract, that a stream consumer never sees text that delivery would refuse, now says that speakable is asked as text is routed, so a channel declared inbound-only after its text was emitted is refused at delivery, though a consumer has seen the text. The substitution test in channel-lifecycle-order now also asserts the mcpl:channel-open-substituted trace for the channel, so it shows that the open reached the server and was answered for another channel. Co-Authored-By: Claude Opus 5.5 --- changelog.d/inbound-only-channels.fixed.md | 5 +++-- docs/explicit-prose-routing.md | 4 +++- src/mcpl/channel-registry.ts | 23 +++++++++++----------- src/mcpl/prose-stream-router.ts | 7 +++++-- test/channel-lifecycle-order.test.ts | 2 ++ 5 files changed, 25 insertions(+), 16 deletions(-) diff --git a/changelog.d/inbound-only-channels.fixed.md b/changelog.d/inbound-only-channels.fixed.md index d80263b8c..cf9ecb3bc 100644 --- a/changelog.d/inbound-only-channels.fixed.md +++ b/changelog.d/inbound-only-channels.fixed.md @@ -7,8 +7,9 @@ channel is opened or published to, a drafts resend and `{{unsent}}` included, with a reason that names the declaration and says to use that server's own tools. A channel declared inbound-only while the publish - waits, on the channel's lifecycle work, its open or anything else, or - removed meanwhile, is refused too, and nothing is published to it. + waits, on the channel's lifecycle work, its open or anything else, is + refused too, and so is one whose registration is gone by the time the + words would be published. Nothing is published to either. - A `>>` or `>>>` target naming the channel bounces with the same reason, and the closed-channel invitation offers no reply into it. In explicit mode the refused target also clears the turn's destination, so the turn's diff --git a/docs/explicit-prose-routing.md b/docs/explicit-prose-routing.md index 25ad17af9..44c3989b0 100644 --- a/docs/explicit-prose-routing.md +++ b/docs/explicit-prose-routing.md @@ -26,7 +26,9 @@ A prose segment's FIRST line may carry a routing prefix: unprefixed segments of the SAME turn, including those a later stream of it writes, such as a framework retry's, a budget restart's or the compile a provider-capacity hold defers the turn to. A round kept private (one that - calls `skip_reply`, or has a same-round private think) sets no target. + calls `skip_reply`, or has a same-round private think) sets no target. A + target its server declared inbound-only is refused and clears the turn's + target, so later unprefixed segments bounce until a new `>>` target. Cross-turn, nothing is implicit. - **Continuation modifier:** ` !` immediately after the target (`>>#ops !` / `>>skip_reply !`) requests an immediate re-wake after this turn diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 9553d9ad6..fb81df974 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -3700,13 +3700,14 @@ export class ChannelRegistry { * lifecycle work and the open await the server, and a channels/changed * update, a re-register or the open's own answer (applyOpenDeclaration) * may declare the channel inbound-only meanwhile, or a removal take it - * away, so both are checked again just before the publish, after every - * await. A removal during a wait already fails it (waitForOpenChannel); - * the check also catches one made after the last wait, as the opening's - * notice can. Words refused after an open for a declaration leave the - * channel open: the agent gets the notice that it's open, which says how - * to close it, and the failure for the words. A channel removed - * meanwhile is gone, and the failure names the removal. + * away. So the check just before the publish, after every await, reads + * the channel's registration as it is then, and refuses one that is gone + * or inbound-only. A removal during a wait already fails it there + * (waitForOpenChannel); the check also catches one made after the last + * wait, as the opening's notice can. Words refused after an open for a + * declaration leave the channel open: the agent gets the notice that it's + * open, which says how to close it, and the failure for the words. When + * the registration is gone, the failure says the channel was removed. * * Only the connector's `delivered: true` confirms a post. `failed` means * nothing was posted: the attempt stopped before the request was written, @@ -3828,10 +3829,10 @@ export class ChannelRegistry { } // Checked again here, after every await above, the lifecycle work's, the - // open's or any other: nothing is published to a channel removed or - // declared inbound-only meanwhile. The entry is read from the map rather - // than taken from the last wait: the opening's notice runs in between, - // and publish() resumes a microtask after the wait. + // open's or any other: a registration that is gone, or inbound-only, at + // this moment takes nothing. The entry is read from the map rather than + // taken from the last wait: the opening's notice runs in between, and + // publish() resumes a microtask after the wait. const current = this.channels.get(`${entry.serverId}:${entry.descriptor.id}`); if (!current) { return { status: 'failed', destination, reason: 'the channel was removed before the words were published', at: at() }; diff --git a/src/mcpl/prose-stream-router.ts b/src/mcpl/prose-stream-router.ts index fd5dc56fb..7429b219e 100644 --- a/src/mcpl/prose-stream-router.ts +++ b/src/mcpl/prose-stream-router.ts @@ -18,7 +18,9 @@ * mode before the first `>>`, unresolvable/ambiguous targets, `>>skip_reply`) * emits NOTHING — the delivery path will bounce/retain it; a stream consumer * must never see text that delivery would refuse to send. Text for a channel - * the `speakable` option refuses emits nothing either. + * the `speakable` option refuses emits nothing either. `speakable` is asked + * as the text is routed, so a channel declared inbound-only after its text + * was emitted is refused at delivery, though a consumer has seen the text. * * Three modes, mirroring proseRouting: * - explicit: parse `>>` prefixes. A fresh turn's stream starts suppressed; @@ -83,7 +85,8 @@ export class ProseStreamRouter { * this at each round's end, with the turn's destination: a private round's * prefixes, a `>>skip_reply` envelope and a target that didn't resolve all * leave that destination as it was, though the stream followed them while - * the round was written. + * the round was written. A target refused for its server's inbound-only + * declaration clears it. */ retarget(target: string | null): void { this.target = target; diff --git a/test/channel-lifecycle-order.test.ts b/test/channel-lifecycle-order.test.ts index e189a110c..3cd3b782a 100644 --- a/test/channel-lifecycle-order.test.ts +++ b/test/channel-lifecycle-order.test.ts @@ -407,6 +407,8 @@ test("an open answered for another channel applies nothing of its answer to the f.declares.set('original', 'inbound'); await f.registry.handleChanged('test', { added: [descriptor('x')] }); assert.equal((await f.tool('open')).success, false); + assert.ok(f.traces.some((e) => e.type === 'mcpl:channel-open-substituted' && e.channelId === 'x'), + 'the open reached the server and was answered for another channel'); assert.equal(f.registry.inboundOnlyServer('x', 'test'), undefined, "the other channel's declaration isn't applied"); }); From 96f536a6d48205c0a7d946ca875a1c3bd85a56dd Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Mon, 19 Oct 2026 02:20:56 -0400 Subject: [PATCH 13/14] docs(mcpl): say what the stream can't know of delivery, and which open gives its notice Comments only; no behaviour changes. - ProseStreamRouter's header said a stream consumer must never see text that delivery would refuse to send. The router can't promise that: `speakable` is asked as the text is routed, so a channel declared inbound-only after its text was emitted, or one whose open then fails, is refused at delivery after the text has streamed. The header now says so, and that which servers see what the router emits is the registry's (sendOutgoingChunk). - streamingServerFor's reason said a stream is a copy of speech the host would publish. This check makes that so only for inbound-only channels, so it now says just that: nothing is published into an inbound-only channel, so nothing is streamed there. - publish()'s doc said words refused after an open for a declaration bring the notice that the channel is open. Only an open that turned a closed channel open gives it. One that only reconfirmed the transport of a channel already decided open or tuned out (openChannelNow's `opened: false`, such as after an earlier open failed) changes no decision and gives none, while the words are still refused and the channel is left open. --- src/mcpl/channel-registry.ts | 13 ++++++++----- src/mcpl/prose-stream-router.ts | 12 +++++++----- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index fb81df974..aaf1ede5f 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -3604,8 +3604,8 @@ export class ChannelRegistry { // never-guess rule that governs delivery. const matches = [...this.channels.values()].filter((e) => e.descriptor.id === channelId); if (matches.length !== 1) return null; - // A stream is a copy of speech the host would publish, and nothing is - // published into an inbound-only channel. + // Nothing is published into an inbound-only channel, so nothing is + // streamed there either. if (isInboundOnly(matches[0]!)) return null; const server = this.serverRegistry.getServer(matches[0]!.serverId); // §5.4: the GRANT gates streaming, not the raw advertisement. The old @@ -3705,9 +3705,12 @@ export class ChannelRegistry { * or inbound-only. A removal during a wait already fails it there * (waitForOpenChannel); the check also catches one made after the last * wait, as the opening's notice can. Words refused after an open for a - * declaration leave the channel open: the agent gets the notice that it's - * open, which says how to close it, and the failure for the words. When - * the registration is gone, the failure says the channel was removed. + * declaration leave the channel open, and the agent gets the failure for + * the words. An open that turned a closed channel open also gives the + * notice that it's open, which says how to close it. One that only + * reconfirmed the transport of a channel already decided open or tuned out + * changes no decision, and gives none. When the registration is gone, the + * failure says the channel was removed. * * Only the connector's `delivered: true` confirms a post. `failed` means * nothing was posted: the attempt stopped before the request was written, diff --git a/src/mcpl/prose-stream-router.ts b/src/mcpl/prose-stream-router.ts index 7429b219e..e8337e7d7 100644 --- a/src/mcpl/prose-stream-router.ts +++ b/src/mcpl/prose-stream-router.ts @@ -16,11 +16,13 @@ * * Suppression is fail-closed: text whose destination is unknown (explicit * mode before the first `>>`, unresolvable/ambiguous targets, `>>skip_reply`) - * emits NOTHING — the delivery path will bounce/retain it; a stream consumer - * must never see text that delivery would refuse to send. Text for a channel - * the `speakable` option refuses emits nothing either. `speakable` is asked - * as the text is routed, so a channel declared inbound-only after its text - * was emitted is refused at delivery, though a consumer has seen the text. + * emits NOTHING — the delivery path will bounce/retain it. Text for a channel + * the `speakable` option refuses emits nothing either, and which servers see + * what is emitted is the registry's (sendOutgoingChunk). The stream can't + * know what delivery decides later: `speakable` is asked as the text is + * routed, so a channel declared inbound-only after its text was emitted, or + * one whose open then fails, is refused at delivery, though a consumer has + * seen the text. * * Three modes, mirroring proseRouting: * - explicit: parse `>>` prefixes. A fresh turn's stream starts suppressed; From 8aa16e4cb4fcc6360921e6c9b028254b6846b6d8 Mon Sep 17 00:00:00 2001 From: Petra-1012 Date: Mon, 19 Oct 2026 03:04:16 -0400 Subject: [PATCH 14/14] docs(mcpl): say when onRouteFailure and the open's notice fire, and what the stream can't promise Comments, the guide and the changelog fragment only; no behaviour changes. - onRouteFailure's doc gives its cases as examples of whatever reason deliverSpeech gives, a missing grant, a failed open, a channel that refuses speech, a timeout and a lost connection among them. It names both cases where the hook isn't called: an ephemeral run's speech with a null locus, and a failed delivery whose caller holds its words (heldOnFailure). - deliverSpeech's doc: a held failure gets the trace but not onRouteFailure, and an ephemeral run's speech with no locus surfaces nothing. The merge's wording had put both under one exception. - publish()'s doc: the notice comes from an open by delivery, the default openSource. - framework.ts: the streaming comment no longer says nothing streams that delivery would refuse, and the speakable comment says what it checks. - docs/explicit-prose-routing.md: a `>>` send into a channel decided closed opens it with the notice. - The changelog fragment says that a declaration coming with the channel's own opening leaves the channel open, with the failure for the words, and the notice if that open is what opened it. --- changelog.d/inbound-only-channels.fixed.md | 5 +++- docs/explicit-prose-routing.md | 2 +- src/framework.ts | 14 +++++---- src/mcpl/channel-registry.ts | 35 ++++++++++++---------- 4 files changed, 33 insertions(+), 23 deletions(-) diff --git a/changelog.d/inbound-only-channels.fixed.md b/changelog.d/inbound-only-channels.fixed.md index cf9ecb3bc..37283f069 100644 --- a/changelog.d/inbound-only-channels.fixed.md +++ b/changelog.d/inbound-only-channels.fixed.md @@ -9,7 +9,10 @@ server's own tools. A channel declared inbound-only while the publish waits, on the channel's lifecycle work, its open or anything else, is refused too, and so is one whose registration is gone by the time the - words would be published. Nothing is published to either. + words would be published. Nothing is published to either. A declaration + that comes with the channel's own opening leaves the channel open: the + agent gets the failure for its words, and the notice that the channel is + open if this open is what opened it. - A `>>` or `>>>` target naming the channel bounces with the same reason, and the closed-channel invitation offers no reply into it. In explicit mode the refused target also clears the turn's destination, so the turn's diff --git a/docs/explicit-prose-routing.md b/docs/explicit-prose-routing.md index 44c3989b0..8db5f6f99 100644 --- a/docs/explicit-prose-routing.md +++ b/docs/explicit-prose-routing.md @@ -83,7 +83,7 @@ depends on the route: Explicit send tools (`send_message` etc.) are unchanged. Channel lifecycle invariants (send-opens-channel, subscribed⇒open) are unchanged — a `>>` send -into a closed channel opens it, with the usual `[channels]` notice. +into a channel decided closed opens it, with the usual `[channels]` notice. ## Teaching the grammar (teach-by-bounce) diff --git a/src/framework.ts b/src/framework.ts index c7a6d5455..bc909dfa1 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -16314,9 +16314,11 @@ export class AgentFramework { // MCPL Spec 14.3 outgoing streaming: route text deltas to their // destination server AS THEY GENERATE (voice synthesis, live message // rendering). Pure observer surface — suppression is fail-closed (nothing - // streams that delivery would refuse), emission is capability-gated in - // the registry (`channels.streaming`), and delivery via deliverProse - // remains the sole authoritative send. + // streams whose destination delivery wouldn't choose, or to a channel + // the router's `speakable` refuses), emission is capability-gated in the + // registry (`channels.streaming`), and delivery via deliverProse remains + // the sole authoritative send. Delivery can still refuse text that has + // streamed, for what it learns later (ProseStreamRouter's header). const newOutgoingInferenceId = (): string => `inf_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; // NOT const: a membrane refusal retry abandons everything streamed so @@ -16371,9 +16373,9 @@ export class AgentFramework { const r = this.channelRegistry!.resolveProseTarget(spec); return 'channelId' in r ? r.channelId : null; }, - // A stream is a copy of speech, and nothing is published into a - // channel its server declared inbound-only: text routed there while - // it is one is never streamed or kept for the stream's end. + // Nothing is published into a channel its server declared + // inbound-only, so text routed there while it is one is never + // streamed or kept for the stream's end. speakable: (channelId) => !this.channelRegistry!.inboundOnlyServer(channelId), }) : null; diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index e83f16d63..4e558591f 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -610,15 +610,19 @@ interface ChannelRegistryOptions { /** Callback to determine whether an incoming message should trigger inference. */ shouldTriggerInference?: (content: string, metadata: Record) => boolean; /** - * Called when a text-only turn's speech could NOT be delivered to its - * conversational locus — no locus, an unregistered channel, a missing - * server, or the server reporting `delivered: false` — or the server - * answered its publish with an error, after which whether it was posted is - * unknown. Not called for an ephemeral run's speech with no locus, which is - * its caller's result (`takesGlobalFallback`). The host wires this to drop a - * `[discord-send-failed]` marker into chronicle so the failure is visible to - * the agent (and operator) rather than silently lost. Must not itself - * trigger inference (avoid wake loops). + * Called when a text-only turn's speech was not delivered to its + * conversational locus (`failed`), or may not have been (`unknown`), for + * whatever reason deliverSpeech gives, such as no locus, a channel that + * isn't registered or refuses speech, a missing server or grant, a failed + * open, the server reporting `delivered: false`, or a publish answered + * with an error, a timeout or a lost connection, after which whether it + * was posted is unknown. It isn't called for an ephemeral run's speech + * whose turn has no locus (a null locus), which is its caller's result + * (`takesGlobalFallback`), nor for a `failed` delivery whose caller holds + * its words with a notice of its own (deliverSpeech's `heldOnFailure`). + * The host wires this to drop a `[discord-send-failed]` marker into + * chronicle so the failure is visible to the agent (and operator) rather + * than silently lost. Must not itself trigger inference (avoid wake loops). */ onRouteFailure?: (info: { conversationId: string; @@ -3733,8 +3737,9 @@ export class ChannelRegistry { * (waitForOpenChannel); the check also catches one made after the last * wait, as the opening's notice can. Words refused after an open for a * declaration leave the channel open, and the agent gets the failure for - * the words. An open that turned a closed channel open also gives the - * notice that it's open, which says how to close it. One that only + * the words. An open by delivery (`openSource` 'opened-by-delivery', the + * default) that turned a closed channel open also gives the notice that + * it's open, which says how to close it. One that only * reconfirmed the transport of a channel already decided open or tuned out * changes no decision, and gives none. When the registration is gone, the * failure says the channel was removed. @@ -3888,10 +3893,10 @@ export class ChannelRegistry { * Plain speech for one turn segment: publish `text` to the turn's frozen * locus and report the PublishOutcome. A failure (or an uncertain outcome) * is surfaced as a trace and through onRouteFailure, so the agent learns - * its words did not (or may not have) reached anyone. There are two - * exceptions: a `failed` one whose caller holds its words with a notice of - * its own (`heldOnFailure`), and an ephemeral run's speech with no locus, - * which is its caller's result, so it returns as not delivered and surfaces + * its words did not (or may not have) reached anyone. A `failed` one whose + * caller holds its words with a notice of its own (`heldOnFailure`) gets + * the trace but not onRouteFailure. An ephemeral run's speech with no locus + * (null) is its caller's result: it returns as not delivered and surfaces * nothing. This is host-owned output routing (see forking-knowledge-miner * LOCUS-ROUTING-DESIGN), which replaced the per-surface sticky auto-post * that used to live in discord-mcpl.