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..1c4f8ed8b --- /dev/null +++ b/changelog.d/speech-none-not-a-locus.fixed.md @@ -0,0 +1,10 @@ +- 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. 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 082e6ed9b..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; @@ -1462,7 +1469,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; @@ -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 @@ -3052,8 +3062,46 @@ 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, 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) { + if (candidate && !this.refusesSpeech(candidate)) return candidate; + } + return null; + } + + /** + * 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'; } async routeSpeech( @@ -3093,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); @@ -3192,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 8c2cef42f..9ec617701 100644 --- a/src/mcpl/types.ts +++ b/src/mcpl/types.ts @@ -967,7 +967,14 @@ 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. 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 80a825658..8de21b6f2 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -590,3 +590,157 @@ 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/); + // 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, 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'); + 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. 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'); +}); + +/** 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