From 60ba5e57a890245ac12b03b0a3f2bbdd2de34dfb Mon Sep 17 00:00:00 2001 From: Aster Date: Sat, 17 Oct 2026 00:13:13 -0700 Subject: [PATCH 1/2] fix(mcpl): a channel marked speech: none is never the automatic locus of an agent's prose routeSpeech publishes a text-only turn to the turn's locus, the channel that woke the agent, and opens that channel when it was closed. A terminal completion is such a wake: its locus is the session's channel, so the agent's ordinary reply ("The build passed") was published there and the channel stayed open afterwards, so every progress digest and screen of that session flowed into context from then on. The server records such speech as a note and never types it, so nothing ran; the opened channel and the note were still a surprise. A server can mark a channel as not a conversation with `metadata.speech: 'none'` on its descriptor (terminal-sessions does so for every session channel). resolveLocus and buildChannelContext now skip such a channel when choosing where bare prose goes: the turn's locus is the next candidate (a fork's home, the triggering channel, the default publish channel), the agent is told that channel, and the marked channel is not opened by delivery. With no other candidate the turn has no locus, as any such turn does today, and the prose stays in the archive. An explicit send to the channel still goes: routeSpeech takes a pinned target as given, and the server's note is then a deliberate act. Tests: a wake from a speech-none channel routes to the default channel and does not open the terminal; with nothing else to speak into the turn has no locus; a speech-none home yields to the next candidate, and an explicit send still delivers. All three fail on main's code. Co-Authored-By: Claude Fable 5.1 --- changelog.d/speech-none-not-a-locus.fixed.md | 5 ++ src/mcpl/channel-registry.ts | 31 ++++++++- src/mcpl/types.ts | 7 +- test/channel-registry-routing.test.ts | 70 ++++++++++++++++++++ 4 files changed, 109 insertions(+), 4 deletions(-) create mode 100644 changelog.d/speech-none-not-a-locus.fixed.md diff --git a/changelog.d/speech-none-not-a-locus.fixed.md b/changelog.d/speech-none-not-a-locus.fixed.md new file mode 100644 index 000000000..e341a1f7e --- /dev/null +++ b/changelog.d/speech-none-not-a-locus.fixed.md @@ -0,0 +1,5 @@ +- A channel whose descriptor carries `metadata.speech: 'none'` (terminal-sessions marks + its session channels so) is never the automatic destination of an agent's prose: a turn + that such a channel woke routes bare prose to the agent's home or default channel + instead, the agent is told that channel, and the terminal channel is not opened by + delivery. Explicit sends to it still work. diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 082e6ed9b..04766e2cc 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -1462,7 +1462,7 @@ export class ChannelRegistry { // agent was told the wrong channel under concurrency. const home = agentName ? this.homeChannelResolver?.(agentName) : undefined; const active = agentName ? this.activeChannelResolver?.(agentName) : undefined; - const outgoing = home ?? active ?? this.defaultPublishChannel; + const outgoing = this.firstSpeakable(home, active, this.defaultPublishChannel); if (openChannels.length === 0 && !outgoing) { return undefined; @@ -3052,8 +3052,33 @@ export class ChannelRegistry { } resolveLocus(conversationId: string): string | null { - const home = this.homeChannelResolver?.(conversationId); - return home ?? this.activeChannelResolver?.(conversationId) ?? this.defaultPublishChannel ?? null; + return this.firstSpeakable( + this.homeChannelResolver?.(conversationId), + this.activeChannelResolver?.(conversationId), + this.defaultPublishChannel, + ); + } + + /** + * The first candidate locus, in precedence order, that may carry an + * agent's ordinary prose. A channel whose descriptor carries + * `metadata.speech: 'none'` is not a conversation: a server registers one + * for something it reports on (terminal-sessions marks every session + * channel so). A wake from it is a notification, not an invitation to + * reply there, so bare prose routes past it to the next candidate, and the + * channel is not opened by delivery. An explicit send to it still goes + * (routeSpeech takes the pinned target as given); only the automatic + * destination skips it. + */ + private firstSpeakable(...candidates: Array): string | null { + for (const candidate of candidates) { + if (candidate && !this.refusesSpeech(candidate)) return candidate; + } + return null; + } + + private refusesSpeech(channelId: string): boolean { + return this.findChannelEntry(channelId)?.descriptor.metadata?.speech === 'none'; } async routeSpeech( diff --git a/src/mcpl/types.ts b/src/mcpl/types.ts index 8c2cef42f..03a263bbf 100644 --- a/src/mcpl/types.ts +++ b/src/mcpl/types.ts @@ -967,7 +967,12 @@ export interface ChannelDescriptor { /** Platform-specific address (e.g., { guild: "acme", channel: "#general" }) */ address?: Record; - /** Arbitrary metadata */ + /** + * Arbitrary metadata. One key the host reads: `speech: 'none'` marks a + * channel that is not a conversation (a terminal session, a feed), so an + * agent's ordinary prose is never routed to it automatically and a wake + * from it does not make it the turn's locus; explicit sends still reach it. + */ metadata?: Record; /** diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index 80a825658..c324a1e09 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -590,3 +590,73 @@ 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); }); + +/** Mark a seeded channel as not a conversation, as terminal-sessions does. */ +function refuseSpeech(lookup: (id: string) => { descriptor: { metadata?: Record } } | undefined, channelId: string): void { + lookup(channelId)!.descriptor.metadata = { ...lookup(channelId)!.descriptor.metadata, speech: 'none' }; +} + +test('a wake from a speech-none channel (a terminal session) routes bare prose to the default channel, and does not open the terminal', async () => { + // The trunk agent's turn was triggered by a terminal completion: the active + // channel is terminal:build. Its last human message came in chanA. + const active: Record = { sol: 'terminal:build' }; + const { registry, publishCalls, openCalls, lookup } = makeRegistry( + { delivered: true }, + () => undefined, + (n) => active[n], + ); + seedRegistered(registry, 'discord', 'chanA'); + registry.handleIncoming('discord', incoming('chanA', 'kick off the build?')); + seedRegistered(registry, 'terminal', 'terminal:build'); + refuseSpeech(lookup, 'terminal:build'); + lookup('terminal:build')!.open = false; + + const locus = registry.resolveLocus('sol'); + assert.equal(locus, 'chanA', 'the terminal channel is skipped; the default (last inbound) channel is the locus'); + assert.equal(registry.buildChannelContext('sol')?.defaultOutgoing?.channelId, 'chanA', 'the agent is told the same channel'); + + const res = await registry.routeSpeech('sol', 'The build passed.', locus); + assert.deepEqual(res, { delivered: true, channelId: 'chanA' }); + assert.equal(publishCalls.at(-1)?.channelId, 'chanA'); + assert.equal(openCalls.length, 0, 'the terminal channel was not opened by delivery'); + assert.equal(lookup('terminal:build')!.open, false); + assert.equal(registry.getDesiredState('terminal', 'terminal:build'), undefined, 'no durable desire to keep it open'); +}); + +test('a wake from a speech-none channel with nothing else to speak into leaves the turn without a locus, and the terminal closed', async () => { + const active: Record = { sol: 'terminal:build' }; + const { registry, failures, openCalls, lookup } = makeRegistry({ delivered: true }, () => undefined, (n) => active[n]); + seedRegistered(registry, 'terminal', 'terminal:build'); + refuseSpeech(lookup, 'terminal:build'); + lookup('terminal:build')!.open = false; + + assert.equal(registry.resolveLocus('sol'), null); + assert.equal(registry.buildChannelContext('sol')?.defaultOutgoing, undefined); + const res = await registry.routeSpeech('sol', 'The build passed.', null); + assert.equal(res, null, 'prose stays in the archive, as for any turn without a locus'); + assert.equal(failures.length, 1); + assert.match(failures[0]!.reason, /no locus/); + assert.equal(openCalls.length, 0); +}); + +test('a speech-none home channel is skipped too, and an explicit send to a speech-none channel still delivers', async () => { + const { registry, publishCalls, openCalls, lookup } = makeRegistry( + { delivered: true }, + (n) => (n === 'fork-1' ? 'terminal:build' : undefined), + ); + seedRegistered(registry, 'discord', 'chanA'); + registry.handleIncoming('discord', incoming('chanA', 'hello')); + seedRegistered(registry, 'terminal', 'terminal:build'); + refuseSpeech(lookup, 'terminal:build'); + lookup('terminal:build')!.open = false; + + assert.equal(registry.resolveLocus('fork-1'), 'chanA', 'a home that refuses speech yields to the next candidate'); + + // Addressed on purpose (an explicit target), the channel takes the text: the + // server records it as a note for observers. That is a deliberate act, and + // it opens the channel as any delivery does. + const res = await registry.routeSpeech('fork-1', 'leaving a note on the build', 'terminal:build'); + assert.deepEqual(res, { delivered: true, channelId: 'terminal:build' }); + assert.equal(publishCalls.at(-1)?.channelId, 'terminal:build'); + assert.equal(openCalls.length, 1); +}); From ef1d0249b88dbc0743fb0ac75395709601d0e785 Mon Sep 17 00:00:00 2001 From: Pippa-2571 Date: Sat, 17 Oct 2026 04:46:14 -0400 Subject: [PATCH 2/2] fix(mcpl): a speech-none channel never becomes the locus mid-turn, by channel_open, or as the default The mark kept a channel out of the locus a turn freezes at its start, but the automatic destination is chosen or moved at more places, and each could still land an agent's prose in a marked channel: - The mid-turn re-pin (repinChannel): a message from a marked channel tagged chat:addressed, or a follow-up in a marked channel the turn sent into, moved the locus onto it, and the agent's later prose landed there. repinChannel now answers nothing for a marked channel, so neither a tool boundary's injections nor an arrival no boundary weighed moves the locus there. The message itself still reaches the agent. - channel_open: opening a marked channel moved the turn's trigger channel and its locus into it, and told the agent "Your plain speech now lands in this channel". Opening one is for listening, so the open still opens it, and the trigger channel, the locus, the announce baseline and the tool result stay as they were. - The default publish channel: an open terminal session's own traffic arrives as channels/incoming, which made the terminal the default publish channel. A wake from it then had no fallback while the session was open, but fell back to the conversation while it was closed, when its events arrive as pushes. And channel_publish without a channelId posted into the terminal. A marked channel's traffic no longer moves the default, so the fallback and the reply context servers receive stay with the conversation. channel_publish without a channelId also skips a default marked after its traffic set it, and its description says the default skips a channel that isn't a conversation. refusesSpeech, now public, is the one predicate, read at every site. It looks the channel up by id, as delivery resolves a channel id, so where two servers register one id, the mark read is the one on the channel prose would reach; the default publish channel is asked the same way. The mark is read from the descriptor a server registered, so a channel the host knows only from a push that named it, before its server registers it, isn't marked. A turn whose only candidates refuse speech has no locus, as a turn with no candidates had. Its delivery-failure reason now names that case: "no home, trigger or default channel that takes plain speech". The comments that called the default the most recent inbound channel, and the locus precedence that left out the skip, say what holds now. The explicit-send test no longer asserts that delivery opens the channel: that's routeSpeech's rule for every channel, not this mark's, and a change that stops speech from opening channels would break it. Tests: an open terminal session's traffic, registered with the mark through channels/register, leaves the default publish channel, the fallback, what the agent is told, the reply context and channel_publish without a channelId with the conversation; a channel marked after its traffic made it the default is skipped as the default, so a wake has no fallback locus and channel_publish without a channelId refuses; where a marked and an unmarked server register one id, traffic on it doesn't make it the default; the no-locus reason names a channel that takes plain speech; a marked home is skipped in what the agent is told too; an addressed mid-turn message from a marked channel, and a follow-up from one the turn sent into, leave the locus and announce nothing; channel_open of a marked channel opens it and moves neither the trigger channel, the locus nor the announce baseline, with no routing note. Two channel_open tests that build their own registry now give it refusesSpeech. --- changelog.d/speech-none-not-a-locus.fixed.md | 7 +- src/framework.ts | 22 ++- src/mcpl/channel-registry.ts | 77 ++++++---- src/mcpl/types.ts | 6 +- test/channel-registry-routing.test.ts | 92 +++++++++++- test/present-while-acting.test.ts | 146 +++++++++++++++++++ 6 files changed, 311 insertions(+), 39 deletions(-) diff --git a/changelog.d/speech-none-not-a-locus.fixed.md b/changelog.d/speech-none-not-a-locus.fixed.md index e341a1f7e..1c4f8ed8b 100644 --- a/changelog.d/speech-none-not-a-locus.fixed.md +++ b/changelog.d/speech-none-not-a-locus.fixed.md @@ -2,4 +2,9 @@ its session channels so) is never the automatic destination of an agent's prose: a turn that such a channel woke routes bare prose to the agent's home or default channel instead, the agent is told that channel, and the terminal channel is not opened by - delivery. Explicit sends to it still work. + delivery. Mid-turn, a message from such a channel doesn't move where the agent's + prose lands, and neither does the agent's `channel_open` of one, which still opens + it. Its traffic doesn't make it the default publish channel, so `channel_publish` + without a `channelId`, whose description now says so, and the reply context servers + receive (the channel context's `incoming`) stay with the conversation. Explicit sends + to it still work. diff --git a/src/framework.ts b/src/framework.ts index 81b0c2d40..e7714ac6e 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -571,8 +571,10 @@ interface TurnRouting { * system markers can't move it (2026-07-21 Cairn lounge misroute). Since * 2026-07-31 a re-pin moves it (repinTurnLocus): an addressed message, or a * follow-up in a channel the turn engaged. The agent's own channel_open - * moves it too. Unset in explicit and disabled mode, and for a turn with - * nowhere to speak. + * moves it too. None of these sets or moves it to a channel that refuses + * speech (ChannelRegistry.refusesSpeech), as of when it's set: a channel + * marked later keeps a locus it already holds until the turn ends. Unset + * in explicit and disabled mode, and for a turn with nowhere to speak. */ locus?: string; /** @@ -10918,13 +10920,16 @@ export class AgentFramework { * re-pin rule (2026-07-31, n=6 and n=7): a conversational message (not a * reaction or a system marker) that addressed the agent, or that follows * up in a channel the turn sent into (TurnRouting.engaged). Undefined for - * any other message. A tool boundary weighs the messages it injects by it, - * and noteArrival the ones no boundary of the turn weighs. + * any other message, and for one from a channel that refuses speech + * (ChannelRegistry.refusesSpeech), which names no conversation. A tool + * boundary weighs the messages it injects by it, and noteArrival the ones + * no boundary of the turn weighs. */ private repinChannel(metadata: MessageMetadata | undefined, routing: TurnRouting): string | undefined { if (!isConversationalInjection(metadata)) return undefined; const m = metadata as Record | undefined; if (typeof m?.channelId !== 'string') return undefined; + if (this.channelRegistry?.refusesSpeech(m.channelId)) return undefined; if (isAddressedMessage(m.tags as string[] | undefined, m)) return m.channelId; return routing.engaged.has(m.channelId) ? m.channelId : undefined; } @@ -11815,7 +11820,8 @@ export class AgentFramework { // a previous turn's channel. routing.triggerChannel = trigger?.channelId; // FREEZE the turn's outbound locus now (turn-frozen routing): the home - // channel for forks, else the trigger channel, else the global default. + // channel for forks, else the trigger channel, else the global default, + // skipping a channel that refuses speech (ChannelRegistry.refusesSpeech). // Mid-turn injections must not redirect the agent's plain prose // (ambient chatter and reactions hijacked it before, 2026-07-21), and // the eager snapshot protects heartbeat turns from a moving @@ -18394,7 +18400,11 @@ export class AgentFramework { const opened = (result.data as { channelId?: string } | undefined)?.channelId ?? (call.input as { channelId?: string } | undefined)?.channelId; - if (opened) { + // A channel that refuses speech (ChannelRegistry.refusesSpeech) + // names no conversation: opening it is for listening, so it moves + // neither the turn's trigger channel nor its locus, and nothing is + // announced. + if (opened && !this.channelRegistry?.refusesSpeech(opened)) { callingTurn.triggerChannel = opened; const openerAgent = this.agents.get(agentName); // The move is one this result tells the agent, so it needs the diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 04766e2cc..92540a6fe 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -390,11 +390,11 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [ }, { name: 'channel_publish', - description: 'Publish a message to a channel. If channelId is omitted, publishes to the most recent incoming channel.', + description: 'Publish a message to a channel. If channelId is omitted, publishes to the most recent incoming channel, skipping any marked as not a conversation, such as a terminal session.', inputSchema: { type: 'object' as const, properties: { - channelId: { type: 'string', description: 'ID of the channel to publish to (defaults to the most recent incoming channel)' }, + channelId: { type: 'string', description: 'ID of the channel to publish to (defaults to the most recent incoming channel, skipping any marked as not a conversation)' }, content: { type: 'string', description: 'Text content to publish' }, text: { type: 'string', description: 'Alias for content' }, }, @@ -521,7 +521,7 @@ interface ChannelRegistryOptions { * forks are spawned bound to a single channel (the framework tracks this in * `conversationAgentHomes` / `ConversationRouter.channelForAgent`); their * plain-text speech must route THERE. The process-global `defaultPublishChannel` - * tracks only the most-recent inbound across ALL channels, so with one fork per + * tracks only the most-recent inbound across every channel that takes speech, so with one fork per * channel running concurrently it misroutes a fork's reply to whichever channel * last spoke (item 3). Returns undefined for the trunk/primary agent, which has * no home and correctly falls back to the global locus (heartbeats, etc.). @@ -534,7 +534,7 @@ interface ChannelRegistryOptions { * connectome-host runs — it never exposes conversation forks). A trunk agent * has no fork home, so without this its plain-text speech falls back to the * process-global `defaultPublishChannel`, which tracks the most-recent inbound - * across ALL channels and misroutes a reply to whichever channel last spoke + * across every channel that takes speech and misroutes a reply to whichever channel last spoke * under concurrency (item-3 redux). The framework keeps both on the live * turn's routing state and exposes them here. For a heartbeat / no-trigger * turn it resolves to undefined until the turn's locus is resolved, so that @@ -649,10 +649,10 @@ export class ChannelRegistry { */ private dmClassified = new Map(); - /** Most recent incoming channel ID — used for speech routing / default publish. */ + /** Most recent incoming channel ID, from a channel that takes speech (refusesSpeech): the fallback locus, and channel_publish's channel when the call names none. */ private defaultPublishChannel: string | null = null; - /** Most recent incoming message metadata, used for buildChannelContext. */ + /** That incoming message's id and thread, used for buildChannelContext. */ private defaultPublishMessageId: string | null = null; private defaultPublishThreadId: string | undefined = undefined; @@ -1044,9 +1044,15 @@ export class ChannelRegistry { // A coalesced item is "accepted" only once the coalescer admits it, so // for those this runs after the hook (below). const markAccepted = () => { - this.defaultPublishChannel = message.channelId; - this.defaultPublishMessageId = message.messageId; - this.defaultPublishThreadId = message.threadId; + // A channel marked `speech: 'none'` names no conversation, so its + // traffic doesn't make it the default publish channel: the fallback + // for a wake that names none, and channel_publish's channel when the + // call names none. Asked by id, as delivery resolves the default. + if (!this.refusesSpeech(message.channelId)) { + this.defaultPublishChannel = message.channelId; + this.defaultPublishMessageId = message.messageId; + this.defaultPublishThreadId = message.threadId; + } // A server sending channels/incoming is authoritative evidence that // the transport is actually open. This repairs transient status only; // durable desired state still changes exclusively through lifecycle @@ -1318,7 +1324,8 @@ export class ChannelRegistry { // ========================================================================== /** - * Get the default publish channel ID (most recent incoming channel). + * Get the default publish channel ID: the most recent incoming channel that + * took speech when its traffic was accepted. */ getDefaultPublishChannel(): string | null { return this.defaultPublishChannel; @@ -1470,7 +1477,8 @@ export class ChannelRegistry { const context: ChannelContext = {}; - // Incoming: the most-recent inbound message (what the agent is replying to). + // Incoming: the most-recent inbound message from a channel that takes + // speech (what the agent is replying to). // Left process-global — per-channel inbound tracking (the right messageId for // a fork's own channel) is a separate concern from the outbound routing fix. if (this.defaultPublishChannel && this.defaultPublishMessageId) { @@ -2984,7 +2992,9 @@ 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). + * (the turn's locus, which resolveLocus resolves from a fork's home, the + * turn's trigger channel or the default publish channel, skipping a channel + * that refuses speech). * 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 @@ -3061,14 +3071,12 @@ export class ChannelRegistry { /** * The first candidate locus, in precedence order, that may carry an - * agent's ordinary prose. A channel whose descriptor carries - * `metadata.speech: 'none'` is not a conversation: a server registers one - * for something it reports on (terminal-sessions marks every session - * channel so). A wake from it is a notification, not an invitation to - * reply there, so bare prose routes past it to the next candidate, and the - * channel is not opened by delivery. An explicit send to it still goes - * (routeSpeech takes the pinned target as given); only the automatic - * destination skips it. + * agent's ordinary prose, skipping a channel that refuses speech + * (refusesSpeech). A wake from such a channel is a notification, not an + * invitation to reply there, so bare prose routes past it to the next + * candidate, and the channel is not opened by delivery. An explicit send + * to it still goes (routeSpeech takes the pinned target as given); only + * the automatic destination skips it. */ private firstSpeakable(...candidates: Array): string | null { for (const candidate of candidates) { @@ -3077,7 +3085,22 @@ export class ChannelRegistry { return null; } - private refusesSpeech(channelId: string): boolean { + /** + * Whether a channel's descriptor carries `metadata.speech: 'none'`: the + * channel is not a conversation, but something its server reports on + * (terminal-sessions marks every session channel so). It is never where an + * agent's ordinary prose lands on its own: not the locus a wake from it + * resolves to (firstSpeakable), not where a message from it re-pins the + * turn or the agent's channel_open of it moves it (the framework asks this + * at both), and not channel_publish's channel when the call names none + * (its traffic doesn't set the default, and a default marked later is + * skipped). A send that names it still reaches it. The channel is looked up + * by id, as delivery looks it up, so the mark read is the one on the + * channel prose would reach. The mark is read from the descriptor its + * server registered: a channel the host knows only from a push that named + * it, before its server registers it, isn't marked. + */ + refusesSpeech(channelId: string): boolean { return this.findChannelEntry(channelId)?.descriptor.metadata?.speech === 'none'; } @@ -3118,9 +3141,9 @@ export class ChannelRegistry { const channelId = locusChannelId; if (!channelId) { // The turn froze with no locus (no home, no triggering channel, no - // global inbound ever seen) — the agent was told its prose stays in - // the archive; honor that. - return fail(null, 'turn has no locus (no home/trigger channel; nothing to deliver into)'); + // global inbound ever seen, or each of them refuses speech) — the agent + // was told its prose stays in the archive; honor that. + return fail(null, 'turn has no locus (no home, trigger or default channel that takes plain speech; nothing to deliver into)'); } const entry = this.findChannelEntry(channelId); @@ -3217,8 +3240,10 @@ export class ChannelRegistry { }; } - // Resolve channelId: default to most recent incoming channel - const channelId = input.channelId ?? this.defaultPublishChannel; + // Resolve channelId: default to the most recent incoming channel, which + // is no default once it refuses speech (a mark that arrived after its + // traffic made it the default). + const channelId = input.channelId ?? this.firstSpeakable(this.defaultPublishChannel); if (!channelId) { return { success: false, diff --git a/src/mcpl/types.ts b/src/mcpl/types.ts index 03a263bbf..9ec617701 100644 --- a/src/mcpl/types.ts +++ b/src/mcpl/types.ts @@ -970,8 +970,10 @@ export interface ChannelDescriptor { /** * Arbitrary metadata. One key the host reads: `speech: 'none'` marks a * channel that is not a conversation (a terminal session, a feed), so an - * agent's ordinary prose is never routed to it automatically and a wake - * from it does not make it the turn's locus; explicit sends still reach it. + * agent's ordinary prose is never routed to it automatically. A wake from + * it, a message from it, and the agent's own channel_open of it don't make + * it the turn's locus, and a publish that names no channel doesn't go + * there. A send that names it still reaches it. */ metadata?: Record; diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index c324a1e09..8de21b6f2 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -636,11 +636,14 @@ test('a wake from a speech-none channel with nothing else to speak into leaves t assert.equal(res, null, 'prose stays in the archive, as for any turn without a locus'); assert.equal(failures.length, 1); assert.match(failures[0]!.reason, /no locus/); + // The reason reaches the agent in its send-failure marker, so it names the + // case truly: the trigger channel exists, and refuses speech. + assert.match(failures[0]!.reason, /no home, trigger or default channel that takes plain speech/); assert.equal(openCalls.length, 0); }); test('a speech-none home channel is skipped too, and an explicit send to a speech-none channel still delivers', async () => { - const { registry, publishCalls, openCalls, lookup } = makeRegistry( + const { registry, publishCalls, lookup } = makeRegistry( { delivered: true }, (n) => (n === 'fork-1' ? 'terminal:build' : undefined), ); @@ -651,12 +654,93 @@ test('a speech-none home channel is skipped too, and an explicit send to a speec lookup('terminal:build')!.open = false; assert.equal(registry.resolveLocus('fork-1'), 'chanA', 'a home that refuses speech yields to the next candidate'); + assert.equal(registry.buildChannelContext('fork-1')?.defaultOutgoing?.channelId, 'chanA', 'and the fork is told that channel'); // Addressed on purpose (an explicit target), the channel takes the text: the - // server records it as a note for observers. That is a deliberate act, and - // it opens the channel as any delivery does. + // server records it as a note for observers. Whether delivery also opens a + // closed channel is routeSpeech's rule for every channel, not this mark's, + // so it isn't asserted here. const res = await registry.routeSpeech('fork-1', 'leaving a note on the build', 'terminal:build'); assert.deepEqual(res, { delivered: true, channelId: 'terminal:build' }); assert.equal(publishCalls.at(-1)?.channelId, 'terminal:build'); - assert.equal(openCalls.length, 1); +}); + +/** A terminal session's descriptor, as terminal-sessions registers it: marked as not a conversation. */ +function terminalDescriptor(id: string, marked = true) { + return { + id, type: 'terminal', label: id, direction: 'bidirectional' as const, initiallyOpen: false, + metadata: { node: 'mbp', ...(marked ? { speech: 'none' } : {}) }, + }; +} + +/** A channels/incoming message with its own message id, so the reply context can tell messages apart. */ +function incomingWithId(channelId: string, messageId: string, text: string) { + const msg = incoming(channelId, text); + msg.messages[0]!.messageId = messageId; + return msg; +} + +test("a speech-none channel's own traffic never makes it the default publish channel: the fallback, the reply context and channel_publish without a channelId stay with the conversation", async () => { + // An open terminal session sends its digests as channels/incoming, as + // terminal-sessions does while a session channel is open. Before the mark + // was read here, that traffic made the terminal the default publish + // channel, so a wake from it had no fallback while the session was open. + const active: Record = { sol: 'terminal:build' }; + const { registry, publishCalls, lookup } = makeRegistry({ delivered: true }, () => undefined, (n) => active[n]); + seedRegistered(registry, 'discord', 'chanA'); + registry.handleIncoming('discord', incomingWithId('chanA', 'm-chan', 'kick off the build?')); + // The session's descriptor arrives as a server registers it, mark included. + await registry.handleRegister('terminal', { channels: [terminalDescriptor('terminal:build')] }); + registry.handleIncoming('terminal', incomingWithId('terminal:build', 'm-term', 'build: 40%, 12 MB written')); + assert.equal(lookup('terminal:build')!.open, true, 'its traffic was accepted, so the transport counts as open'); + + assert.equal(registry.getDefaultPublishChannel(), 'chanA', 'the default stays where the conversation was'); + assert.equal(registry.resolveLocus('sol'), 'chanA', 'the wake falls back to the conversation with the session open too'); + const context = registry.buildChannelContext('sol'); + assert.equal(context?.defaultOutgoing?.channelId, 'chanA', 'the agent is told the same channel'); + assert.deepEqual(context?.incoming, { channelId: 'chanA', messageId: 'm-chan', threadId: undefined }, + "the reply context stays on the conversation's message, its id and thread with it"); + + const res = await registry.handleChannelToolCall('channel_publish', { text: 'status: the build is running' }, { kind: 'agent', agentName: 'sol' }); + assert.equal(res.success, true, JSON.stringify(res)); + assert.equal(publishCalls.at(-1)?.channelId, 'chanA', 'channel_publish with no channelId posts to the conversation, not the terminal'); +}); + +test('a channel marked after its traffic made it the default is skipped as the default: no fallback locus, and channel_publish without a channelId refuses', async () => { + const { registry, publishCalls } = makeRegistry({ delivered: true }, () => undefined, () => undefined); + seedRegistered(registry, 'discord', 'chanA'); + registry.handleIncoming('discord', incomingWithId('chanA', 'm-chan', 'hello')); + // Unmarked when its traffic arrives, so it becomes the default... + await registry.handleRegister('terminal', { channels: [terminalDescriptor('terminal:build', false)] }); + registry.handleIncoming('terminal', incomingWithId('terminal:build', 'm-term', 'build: started')); + assert.equal(registry.getDefaultPublishChannel(), 'terminal:build'); + // ...and marked by a later registration of the same channel. + await registry.handleRegister('terminal', { channels: [terminalDescriptor('terminal:build')] }); + + assert.equal(registry.getDefaultPublishChannel(), 'terminal:build', 'the stored default is the one its traffic set'); + assert.equal(registry.resolveLocus('sol'), null, 'a heartbeat-like wake skips the marked default'); + assert.equal(registry.buildChannelContext('sol')?.defaultOutgoing, undefined, 'and is told of no channel'); + const before = publishCalls.length; + const res = await registry.handleChannelToolCall('channel_publish', { text: 'note' }, { kind: 'agent', agentName: 'sol' }); + assert.equal(res.success, false, 'no default to publish to'); + assert.match(String(res.error), /no default channel available/); + assert.equal(publishCalls.length, before, 'nothing was published'); +}); + +test("where two servers register one channel id, a message on it is read as delivery reads the id: the marked channel registered first keeps it from becoming the default", async () => { + const { registry, publishCalls } = makeRegistry({ delivered: true }, () => undefined, () => undefined); + seedRegistered(registry, 'discord', 'chanA'); + registry.handleIncoming('discord', incomingWithId('chanA', 'm-chan', 'hello')); + // terminal-sessions registers shared:1 first, marked; another server then + // registers the same id, unmarked, and sends traffic on it. + await registry.handleRegister('terminal', { channels: [terminalDescriptor('shared:1')] }); + seedRegistered(registry, 'other', 'shared:1'); + registry.handleIncoming('other', incomingWithId('shared:1', 'm-other', 'hi')); + + // A publish to shared:1 would reach the first registrant, the marked one, + // so the message doesn't make shared:1 the default. + assert.equal(registry.getDefaultPublishChannel(), 'chanA'); + const res = await registry.handleChannelToolCall('channel_publish', { text: 'hello again' }, { kind: 'agent', agentName: 'sol' }); + assert.equal(res.success, true, JSON.stringify(res)); + assert.equal(publishCalls.at(-1)?.channelId, 'chanA', "channel_publish with no channelId doesn't reach the marked channel"); }); diff --git a/test/present-while-acting.test.ts b/test/present-while-acting.test.ts index 0d965d9e6..cf2bf4c06 100644 --- a/test/present-while-acting.test.ts +++ b/test/present-while-acting.test.ts @@ -1538,6 +1538,7 @@ describe('present while acting', () => { framework.agents = new Map([['assistant', { proseRouting: 'locus' }]]); framework.channelRegistry = { handleChannelToolCall: () => new Promise((resolve) => { resolveOpen = resolve; }), + refusesSpeech: () => false, }; framework.turnRouting = new Map([['assistant', calling]]); framework.openLogical = new Map([['assistant', calling]]); @@ -1577,6 +1578,7 @@ describe('present while acting', () => { framework.agents = new Map([['assistant', { proseRouting: 'locus' }]]); framework.channelRegistry = { handleChannelToolCall: async () => ({ success: true, data: { channelId: 'discord:guild:observatory' } }), + refusesSpeech: () => false, }; framework.turnRouting = new Map([['assistant', calling]]); if (!silent) framework.openLogical = new Map([['assistant', calling]]); @@ -1597,6 +1599,33 @@ describe('present while acting', () => { }); } + it('a channel_open of a channel marked speech none moves neither the trigger channel, the locus nor the announce baseline, and says nothing', async () => { + const calling: { engaged: Set; triggerChannel?: string; locus?: string } = { engaged: new Set(), locus: 'chan-before' }; + const framework = bareFramework(); + framework.agents = new Map([['assistant', { proseRouting: 'locus' }]]); + framework.channelRegistry = { + handleChannelToolCall: async () => ({ success: true, data: { channelId: 'terminal:build' } }), + refusesSpeech: (channelId: string) => channelId === 'terminal:build', + }; + framework.turnRouting = new Map([['assistant', calling]]); + framework.openLogical = new Map([['assistant', calling]]); + (framework.activeTurnTriggers as Map).set('assistant', {}); + const results: Array<{ result: { success?: boolean; data?: { routing?: string } } }> = []; + framework.pushEvent = (event: { type: string }) => { + if (event.type === 'tool-result') results.push(event as unknown as { result: { success?: boolean; data?: { routing?: string } } }); + }; + + framework.dispatchChannelToolCall('assistant', { + id: 'open-1', name: 'channel_open', input: { channelId: 'terminal:build' }, + }); + await until(() => results.length > 0, 'the tool result'); + + assert.equal(results[0]!.result.success, true, 'the open itself succeeded'); + assert.deepEqual(calling, { engaged: new Set(), locus: 'chan-before' }, 'neither the trigger channel nor the locus moved'); + assert.equal((framework.lastAnnouncedLocus as Map).get('assistant'), undefined, 'the announce baseline stayed as it was'); + assert.equal(results[0]!.result.data?.routing, undefined, 'and the result tells the agent of no move'); + }); + it('a prose turn ends with a [delivered] receipt naming where the prose landed', async () => { // Explicit sends receipt themselves via tool_result; auto-routed prose // previously vanished with no in-window trace — the agent could never @@ -1759,6 +1788,123 @@ describe('present while acting', () => { await framework.stop(); }); + it('an addressed mid-turn message from a speech-none channel does not re-pin the locus', async () => { + // A channel marked speech: 'none' (a terminal session) names no + // conversation, so even a message from it that addresses the agent + // leaves the agent's prose where the turn's conversation is. + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Starting the build.' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'The build is still running.' }, + ] as ContentBlock[])); + + const framework = await createFramework(); + const routed = stubChannelRegistry(framework); + (framework as unknown as { channelRegistry: { refusesSpeech?: unknown } }).channelRegistry.refusesSpeech = + (channelId: string) => channelId === 'terminal:build'; + module.toolDelayMs = 25; + module.interjection = 'build: needs input'; + module.interjectionMetadata = { channelId: 'terminal:build', tags: ['chat:addressed'] }; + + const notices: string[] = []; + framework.onTrace((event) => { + const e = event as { type: string; source?: string }; + if (e.type === 'message:added' && e.source === 'routing-notice') notices.push(e.source!); + }); + + trigger(framework); + await framework.runUntilIdle(); + + assert.deepEqual(routed, [ + { text: 'Starting the build.', locus: 'chan-live-1' }, + { text: 'The build is still running.', locus: 'chan-live-1' }, + ], 'trailing prose stays at the frozen locus'); + assert.equal(notices.length, 1, 'only the boot baseline is announced: no re-pin notice'); + const wired = membrane.lastStream!.receivedToolResultOptions + .flatMap((o) => o?.injectedMessages ?? []) + .map((m) => JSON.stringify(m.content)); + assert.ok(wired.some((t) => t.includes('build: needs input')), 'the message itself still reached the agent'); + + await framework.stop(); + }); + + it('a follow-up from a speech-none channel the turn sent into does not re-pin the locus either', async () => { + // The engaged-channel leg of the re-pin: the agent sent into the + // terminal this turn, and the session's next output follows up there. + // A marked channel names no conversation, so the prose stays put. + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'The build is still running.' }, + ] as ContentBlock[])); + + const framework = await createFramework(); + const routed = stubChannelRegistry(framework); + (framework as unknown as { channelRegistry: { refusesSpeech?: unknown } }).channelRegistry.refusesSpeech = + (channelId: string) => channelId === 'terminal:build'; + module.toolDelayMs = 25; + module.interjection = 'build: 60%'; + module.interjectionMetadata = { channelId: 'terminal:build', tags: ['terminal:output'] }; + // As in the engaged-channel test above: seed the turn's engaged set with + // the terminal, as a send into it this turn would. + const origHandle = module.handleToolCall.bind(module); + module.handleToolCall = async (call) => { + (framework as unknown as { + turnRouting: Map }>; + }).turnRouting.get('assistant')!.engaged.add('terminal:build'); + return origHandle(call); + }; + + trigger(framework); + await framework.runUntilIdle(); + + assert.deepEqual(routed, [ + { text: 'The build is still running.', locus: 'chan-live-1' }, + ], 'trailing prose stays at the frozen locus'); + + await framework.stop(); + }); + + it('channel_open of a speech-none channel opens it without moving or announcing the locus', async () => { + // Opening a terminal session's channel is for listening to it. The + // agent's prose stays where the turn's conversation is, and the tool + // result carries no routing note. + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'c1', name: 'channel_open', input: { channelId: 'terminal:build' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Watching the build now.' }, + ] as ContentBlock[])); + + const framework = await createFramework(); + const routed = stubChannelRegistry(framework); + const registry = (framework as unknown as { channelRegistry: Record }).channelRegistry; + let opens = 0; + (registry as { handleChannelToolCall?: unknown }).handleChannelToolCall = async () => { + opens++; + return { success: true, data: { channelId: 'terminal:build', opened: true } }; + }; + (registry as { refusesSpeech?: unknown }).refusesSpeech = (channelId: string) => channelId === 'terminal:build'; + + trigger(framework); + await framework.runUntilIdle(); + + assert.equal(opens, 1, 'the open itself went to the registry'); + assert.deepEqual(routed, [ + { text: 'Watching the build now.', locus: 'chan-live-1' }, + ], 'prose after the open stays at the frozen locus'); + const results = membrane.lastStream!.receivedToolResults.flat() as Array<{ content?: unknown }>; + assert.ok( + !JSON.stringify(results).includes('plain speech now lands in this channel'), + 'no routing note in the channel_open tool result', + ); + + await framework.stop(); + }); + it('channel_open moves the pin mid-turn and announces in its own tool result', async () => { // The agent's own deliberate open is the strongest "my next words go // here" signal — stronger than any injection. The original 2026-07-21