From 02edb4e719577c71e347c86ee33c7b20d9316bbe Mon Sep 17 00:00:00 2001 From: Aspirational Date: Mon, 5 Oct 2026 21:43:57 +0300 Subject: [PATCH 1/5] feat(typing): show typing in disabled prose-routing mode An agent with proseRouting 'disabled' answers only through send tools, and its turns showed no typing indicator, so it looked idle while it worked. Presence now works as in explicit mode: typing shows on the channel the trigger came from, and a batched gate wake, which names no channelId, uses its wakeChannelId. Silent wakes (suppressProse), which also run as disabled, still show none. Explicit and locus modes are unchanged. Both typing sites (turn start and driveStream) pick the same channel. test/disabled-prose-typing.test.ts covers trigger channel, gate wake, silent wake, no channel, and explicit mode unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UN6d9TeXZDWNMxQ3dT5uR8 --- .../feat-disabled-mode-typing.changed.md | 7 ++ src/framework.ts | 14 +++- test/disabled-prose-typing.test.ts | 77 +++++++++++++++++++ 3 files changed, 94 insertions(+), 4 deletions(-) create mode 100644 changelog.d/feat-disabled-mode-typing.changed.md create mode 100644 test/disabled-prose-typing.test.ts diff --git a/changelog.d/feat-disabled-mode-typing.changed.md b/changelog.d/feat-disabled-mode-typing.changed.md new file mode 100644 index 000000000..e94482bb1 --- /dev/null +++ b/changelog.d/feat-disabled-mode-typing.changed.md @@ -0,0 +1,7 @@ +- Agents with `proseRouting: 'disabled'` now show the typing indicator during + a turn, on the channel the trigger came from, the same as explicit mode. A + batched gate wake names no `channelId`, so its `wakeChannelId` is used + instead. Before, disabled-mode turns showed no indicator at all, so a + resident that answers only through send tools looked idle while it worked. + Silent wakes (`suppressProse`, such as a silent heartbeat) still show none. + Explicit and locus modes are unchanged. diff --git a/src/framework.ts b/src/framework.ts index 25cdb419b..05b789308 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -9050,9 +9050,11 @@ export class AgentFramework { // idempotent per channel, and owns the 7s refresh); the catch below stops // it on the no-driveStream failure paths (e.g. a compile refusal). const earlyTypingChannel = - turnProseRouting === 'explicit' - ? trigger?.channelId ?? null - : this.turnLocusPins.get(agent.name) ?? null; + turnProseRouting === 'disabled' + ? (trigger?.suppressProse ? null : trigger?.channelId ?? trigger?.wakeChannelId ?? null) + : turnProseRouting === 'explicit' + ? trigger?.channelId ?? null + : this.turnLocusPins.get(agent.name) ?? null; if (earlyTypingChannel) this.channelRegistry?.startTyping(earlyTypingChannel); try { @@ -9318,9 +9320,13 @@ export class AgentFramework { // this doesn't violate never-guess: it says "attending to what you // sent here", which is true regardless of where the reply goes. // Heartbeat/no-trigger explicit turns show no indicator. + // - disabled mode: like explicit, the trigger channel; a batched gate + // wake names no channelId, only where it came from (wakeChannelId), + // so typing goes there. A silent wake (suppressProse, which also runs + // as disabled) stays private and shows no indicator. const typingChannel = turnProseRouting === 'disabled' - ? null + ? (trigger?.suppressProse ? null : trigger?.channelId ?? trigger?.wakeChannelId ?? null) : turnProseRouting === 'explicit' ? trigger?.channelId ?? null : resolveTurnLocus(); diff --git a/test/disabled-prose-typing.test.ts b/test/disabled-prose-typing.test.ts new file mode 100644 index 000000000..d38b55e56 --- /dev/null +++ b/test/disabled-prose-typing.test.ts @@ -0,0 +1,77 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { AgentFramework } from '../src/index.js'; +import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; +import type { ContentBlock } from '@animalabs/membrane'; + +async function make(proseRouting: 'disabled' | 'explicit') { + const dir = mkdtempSync(join(tmpdir(), 'disabled-typing-af-')); + const membrane = new MockMembrane(); + const framework = await AgentFramework.create({ + storePath: join(dir, 'store'), membrane: membrane.asMembrane(), + agents: [{ name: 'assistant', model: 'test', systemPrompt: 'test', proseRouting }], + modules: [], + }); + const typing: string[] = []; + (framework as any).channelRegistry = new Proxy({ + resolveLocus: () => 'world:commons', getDefaultPublishChannel: () => 'world:commons', + routeSpeech: async () => ({ delivered: true, channelId: 'world:commons' }), + startTyping: (channel: string) => { typing.push(channel); }, + stopTyping: () => {}, + getChannelTools: () => [], getDescriptor: () => undefined, + }, { get: (t, p: string) => p in t ? (t as any)[p] : () => undefined }); + // In explicit mode, prose with no `>>` target keeps this run from going idle + // (the mock answers every further call with prose); a turn with no prose ends. + membrane.pushResponse(createMockResponse( + (proseRouting === 'explicit' ? [] : [{ type: 'text', text: 'working on it' }]) as ContentBlock[])); + return { dir, framework, typed: () => [...new Set(typing)] }; +} + +async function wake(proseRouting: 'disabled' | 'explicit', request: Record | 'silent') { + const x = await make(proseRouting); + try { + if (request === 'silent') { + (x.framework as any).handleMcplPushEvent({ + type: 'mcpl:push-event', serverId: 'heartbeat', featureSet: 'heartbeat', + eventId: 'hb-1', content: [], timestamp: new Date().toISOString(), + origin: { source: 'heartbeat', reason: 'schedule', silent: true, scheduledAt: '2026-10-05 12:00:00 PT' }, + inferenceId: 'inf-1', triggerInference: true, + }); + } else { + (x.framework as any).pendingRequests.push({ agentName: 'assistant', timestamp: Date.now(), ...request }); + } + await x.framework.runUntilIdle(); + return x.typed(); + } finally { await x.framework.stop(); rmSync(x.dir, { recursive: true, force: true }); } +} + +describe('typing with proseRouting disabled', () => { + it('shows typing on the channel the trigger came from', async () => { + const typed = await wake('disabled', { reason: 'channel-message', source: 'zulip', channelId: 'zulip:support', addressed: true }); + assert.deepEqual(typed, ['zulip:support']); + }); + + it('shows typing where a batched gate wake came from (wakeChannelId)', async () => { + const typed = await wake('disabled', { reason: 'gate', source: 'gate', wakeChannelId: 'zulip:support' }); + assert.deepEqual(typed, ['zulip:support']); + }); + + it('keeps a silent wake private: no typing', async () => { + assert.deepEqual(await wake('disabled', 'silent'), []); + }); + + it('shows no typing for a wake that names no channel', async () => { + assert.deepEqual(await wake('disabled', { reason: 'heartbeat', source: 'heartbeat' }), []); + }); + + it('leaves explicit mode unchanged: a batched gate wake shows no typing', async () => { + assert.deepEqual(await wake('explicit', { reason: 'gate', source: 'gate', wakeChannelId: 'zulip:support' }), []); + assert.deepEqual( + await wake('explicit', { reason: 'channel-message', source: 'zulip', channelId: 'zulip:support', addressed: true }), + ['zulip:support'], + ); + }); +}); From 336c7cf9cafd7fae6e91c936e0c03be3b9065bca Mon Sep 17 00:00:00 2001 From: Aspirational Date: Mon, 5 Oct 2026 21:58:01 +0300 Subject: [PATCH 2/5] fix(typing): no disabled-mode typing in a tuned-out channel With a debounce policy, a tuned-out channel's ambient traffic reaches the EventGate before tune-out diverts it, and the batched wake keeps that channel as wakeChannelId. Disabled-mode typing then showed attendance in a channel the agent had turned off. disabledTypingChannel now skips a tuned-out wakeChannelId; both typing sites use it. Found in review (GPT-6.1 Sol, xhigh). Tests through the real ChannelRegistry, EventGate and TuneOutCoordinator: a debounced ambient wake types and stops; a tuned-out channel shows none (fails without the check). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UN6d9TeXZDWNMxQ3dT5uR8 --- .../feat-disabled-mode-typing.changed.md | 9 ++- src/framework.ts | 28 +++++-- test/disabled-prose-typing.test.ts | 73 ++++++++++++++++++- 3 files changed, 99 insertions(+), 11 deletions(-) diff --git a/changelog.d/feat-disabled-mode-typing.changed.md b/changelog.d/feat-disabled-mode-typing.changed.md index e94482bb1..6b72c91b5 100644 --- a/changelog.d/feat-disabled-mode-typing.changed.md +++ b/changelog.d/feat-disabled-mode-typing.changed.md @@ -1,7 +1,8 @@ - Agents with `proseRouting: 'disabled'` now show the typing indicator during a turn, on the channel the trigger came from, the same as explicit mode. A batched gate wake names no `channelId`, so its `wakeChannelId` is used - instead. Before, disabled-mode turns showed no indicator at all, so a - resident that answers only through send tools looked idle while it worked. - Silent wakes (`suppressProse`, such as a silent heartbeat) still show none. - Explicit and locus modes are unchanged. + instead, unless that channel is tuned out (its ambient traffic can reach + the gate before tune-out diverts it). Before, disabled-mode turns showed no + indicator at all, so a resident that answers only through send tools looked + idle while it worked. Silent wakes (`suppressProse`, such as a silent + heartbeat) still show none. Explicit and locus modes are unchanged. diff --git a/src/framework.ts b/src/framework.ts index 05b789308..ca99c312b 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -8867,6 +8867,25 @@ export class AgentFramework { } } + /** + * Where a `proseRouting: 'disabled'` turn shows typing: the trigger + * channel, as in explicit mode, else where a batched gate wake came from + * (`wakeChannelId`; the gate deliberately names no `channelId`). Not a + * tuned-out channel: its ambient traffic can reach the gate before tune-out + * diverts it, and typing there would show attendance the agent turned + * off. A silent wake (`suppressProse`, which also runs as disabled) shows + * none. + */ + private disabledTypingChannel(trigger: InferenceRequest | undefined): string | null { + if (!trigger || trigger.suppressProse) return null; + if (trigger.channelId) return trigger.channelId; + const wake = trigger.wakeChannelId; + if (!wake) return null; + const serverId = this.channelRegistry?.getChannelServerId(wake); + if (serverId && this.channelRegistry?.getTuneOutState(serverId, wake)) return null; + return wake; + } + /** * Body of startAgentStream, split out so the caller's try/finally owns the * turn token unconditionally. Returns true iff the token was handed off to @@ -9051,7 +9070,7 @@ export class AgentFramework { // it on the no-driveStream failure paths (e.g. a compile refusal). const earlyTypingChannel = turnProseRouting === 'disabled' - ? (trigger?.suppressProse ? null : trigger?.channelId ?? trigger?.wakeChannelId ?? null) + ? this.disabledTypingChannel(trigger) : turnProseRouting === 'explicit' ? trigger?.channelId ?? null : this.turnLocusPins.get(agent.name) ?? null; @@ -9320,13 +9339,10 @@ export class AgentFramework { // this doesn't violate never-guess: it says "attending to what you // sent here", which is true regardless of where the reply goes. // Heartbeat/no-trigger explicit turns show no indicator. - // - disabled mode: like explicit, the trigger channel; a batched gate - // wake names no channelId, only where it came from (wakeChannelId), - // so typing goes there. A silent wake (suppressProse, which also runs - // as disabled) stays private and shows no indicator. + // - disabled mode: see disabledTypingChannel. const typingChannel = turnProseRouting === 'disabled' - ? (trigger?.suppressProse ? null : trigger?.channelId ?? trigger?.wakeChannelId ?? null) + ? this.disabledTypingChannel(trigger) : turnProseRouting === 'explicit' ? trigger?.channelId ?? null : resolveTurnLocus(); diff --git a/test/disabled-prose-typing.test.ts b/test/disabled-prose-typing.test.ts index d38b55e56..543b443d2 100644 --- a/test/disabled-prose-typing.test.ts +++ b/test/disabled-prose-typing.test.ts @@ -4,7 +4,10 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { AgentFramework } from '../src/index.js'; -import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; +import { ChannelRegistry } from '../src/mcpl/channel-registry.js'; +import { CapabilityGrant } from '../src/mcpl/capability-grant.js'; +import { TuneOutCoordinator } from '../src/tune-out/coordinator.js'; +import { MockMembrane, MockYieldingStream, createMockResponse } from './helpers/mock-membrane.js'; import type { ContentBlock } from '@animalabs/membrane'; async function make(proseRouting: 'disabled' | 'explicit') { @@ -75,3 +78,71 @@ describe('typing with proseRouting disabled', () => { ); }); }); + +/** + * The real path: an incoming channel message goes through the ChannelRegistry, + * a debounced EventGate wake (which carries only wakeChannelId), and the + * tune-out coordinator; typing is recorded at the registry's sendTypingFn. + */ +async function makeRealPath(tunedOut: boolean) { + const dir = mkdtempSync(join(tmpdir(), 'disabled-typing-real-')); + const membrane = { streamYielding: () => new MockYieldingStream([createMockResponse([])]) }; + const framework = await AgentFramework.create({ + storePath: join(dir, 'store'), membrane: membrane as any, + agents: [{ name: 'assistant', model: 'test', systemPrompt: 'test', proseRouting: 'disabled' }], + modules: [], + gate: { config: { default: 'always', policies: [{ name: 'batch', match: { scope: ['mcpl:channel-incoming'] }, behavior: { debounce: 50 } }] } }, + } as any); + const typing: { channelId: string; op: string }[] = []; + const server = { grant: new CapabilityGrant(new Set(['channels.typing']), []) }; + const servers = { getServer: () => server } as any; + const f = framework as any; + const registry = new ChannelRegistry(servers, {} as any, (e: any) => f.pushEvent(e), () => {}, { + store: f.store, + shouldTriggerInference: f.eventGate.asShouldTriggerCallback(), + sendTypingFn: (_s: string, channelId: string, _m: unknown, op?: string) => { typing.push({ channelId, op: op ?? 'start' }); }, + } as any); + (registry as any).channels.set('zulip:zulip:support', { + serverId: 'zulip', open: true, + descriptor: { id: 'zulip:support', type: 'zulip-stream', address: { streamId: 1 }, label: 'support' }, + }); + f.channelRegistry = registry; + f.tuneOutCoordinator = new TuneOutCoordinator(registry, servers, { + addMessage: (p: any, c: any, m: any, a: any) => f.addMessage(p, c, m, a ? { forAgent: a } : undefined), + requestInference: (agentName: string, reason: string, source: string) => f.pendingRequests.push({ agentName, reason, source, timestamp: Date.now() }), + subconsciousName: () => 'Subconscious', primaryName: () => 'assistant', + getStoredMessages: () => f.getAgent('assistant').getContextManager().getAllMessages(), + currentSequence: () => f.store.currentSequence(), setSubconsciousAnchor() {}, + isForkBound: () => false, isPrivilegedAuthor: () => false, allowChannelSpeech: () => false, + emitTrace: () => {}, + } as any); + if (tunedOut) { + registry.enterTuneOut('zulip', 'zulip:support', { + epochId: 'epoch-1', cadenceSeconds: 3600, backlogCap: 20, maxWakes: 5, startedAtSequence: f.store.currentSequence(), + } as any, 'agent-tool' as any); + } + registry.handleIncoming('zulip', { messages: [{ + channelId: 'zulip:support', messageId: 'm1', author: { id: 'u1', name: 'Human' }, + timestamp: new Date().toISOString(), content: [{ type: 'text', text: 'ambient traffic' }], + tags: ['chat:ambient', 'chat:from-human'], + }] } as any); + await framework.runUntilIdle(); + await new Promise((r) => setTimeout(r, 120)); // let the debounce fire + await framework.runUntilIdle(); + const result = { typing, intervals: (registry as any).typingIntervals.size as number }; + await framework.stop(); rmSync(dir, { recursive: true, force: true }); + return result; +} + +describe('typing with proseRouting disabled, through the real gate and registry', () => { + it('a debounced ambient wake types where it came from, then stops', async () => { + const { typing, intervals } = await makeRealPath(false); + assert.deepEqual(typing, [{ channelId: 'zulip:support', op: 'start' }, { channelId: 'zulip:support', op: 'stop' }]); + assert.equal(intervals, 0); + }); + + it('a tuned-out channel shows no typing, even when its traffic reaches the gate', async () => { + const { typing } = await makeRealPath(true); + assert.deepEqual(typing, []); + }); +}); From 41c846b4dc30157f599d2e4349d7eeb8bd9e139b Mon Sep 17 00:00:00 2001 From: Aspirational Date: Mon, 5 Oct 2026 22:28:13 +0300 Subject: [PATCH 3/5] fix(typing): no presence in a tuned-out channel from any trigger or mid-turn Round-2 review findings on the disabled-mode typing change: - trigger.channelId does not mean addressed: ambient pushes and coalesced push wakes set it too, and tune-out diverts even addressed traffic away from the resident. disabledTypingChannel now applies the tune-out check to whichever channel it picks (channelId, else wakeChannelId). - A typing run started before tune-out kept its 7s refresh until the turn ended. ChannelRegistry.enterTuneOut now stops typing on that channel (all prose-routing modes). Tests through the real registry, gate and tune-out coordinator: valid 100 ms debounce with a turn-ran assertion, ambient and addressed pushes into a tuned-out channel, tune-out entered mid-turn, and a silent wake that names channels. Each fix fails its test when removed. Found in review (GPT-6 Astra, xhigh). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UN6d9TeXZDWNMxQ3dT5uR8 --- .../feat-disabled-mode-typing.changed.md | 14 ++- src/framework.ts | 22 ++-- src/mcpl/channel-registry.ts | 3 + test/disabled-prose-typing.test.ts | 113 +++++++++++++----- 4 files changed, 107 insertions(+), 45 deletions(-) diff --git a/changelog.d/feat-disabled-mode-typing.changed.md b/changelog.d/feat-disabled-mode-typing.changed.md index 6b72c91b5..c63053974 100644 --- a/changelog.d/feat-disabled-mode-typing.changed.md +++ b/changelog.d/feat-disabled-mode-typing.changed.md @@ -1,8 +1,12 @@ - Agents with `proseRouting: 'disabled'` now show the typing indicator during a turn, on the channel the trigger came from, the same as explicit mode. A batched gate wake names no `channelId`, so its `wakeChannelId` is used - instead, unless that channel is tuned out (its ambient traffic can reach - the gate before tune-out diverts it). Before, disabled-mode turns showed no - indicator at all, so a resident that answers only through send tools looked - idle while it worked. Silent wakes (`suppressProse`, such as a silent - heartbeat) still show none. Explicit and locus modes are unchanged. + instead. Before, disabled-mode turns showed no indicator at all, so a + resident that answers only through send tools looked idle while it worked. + Never on a tuned-out channel: tune-out diverts even addressed traffic away + from the resident, but an ambient push or a gate wake can still name the + channel. Silent wakes (`suppressProse`, such as a silent heartbeat) still + show none. Explicit and locus modes pick their typing channel as before. +- Entering tune-out on a channel now stops a typing indicator already running + there, in every prose-routing mode. Before, its 7 s refresh continued until + the turn ended. diff --git a/src/framework.ts b/src/framework.ts index ca99c312b..f29cd42aa 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -8870,20 +8870,20 @@ export class AgentFramework { /** * Where a `proseRouting: 'disabled'` turn shows typing: the trigger * channel, as in explicit mode, else where a batched gate wake came from - * (`wakeChannelId`; the gate deliberately names no `channelId`). Not a - * tuned-out channel: its ambient traffic can reach the gate before tune-out - * diverts it, and typing there would show attendance the agent turned - * off. A silent wake (`suppressProse`, which also runs as disabled) shows - * none. + * (`wakeChannelId`; the gate deliberately names no `channelId`). Never a + * tuned-out channel: tune-out diverts even addressed traffic away from the + * resident, yet an ambient push or a gate wake can still name the channel, + * and typing there would show attendance the agent turned off + * (`enterTuneOut` also stops typing already running there). A silent wake + * (`suppressProse`, which also runs as disabled) shows none. */ private disabledTypingChannel(trigger: InferenceRequest | undefined): string | null { if (!trigger || trigger.suppressProse) return null; - if (trigger.channelId) return trigger.channelId; - const wake = trigger.wakeChannelId; - if (!wake) return null; - const serverId = this.channelRegistry?.getChannelServerId(wake); - if (serverId && this.channelRegistry?.getTuneOutState(serverId, wake)) return null; - return wake; + const channelId = trigger.channelId ?? trigger.wakeChannelId; + if (!channelId) return null; + const serverId = this.channelRegistry?.getChannelServerId(channelId); + if (serverId && this.channelRegistry?.getTuneOutState(serverId, channelId)) return null; + return channelId; } /** diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 9e31ef7e1..77cf55ec6 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -1863,6 +1863,9 @@ export class ChannelRegistry { tuneOut: params, wakeCount: 0, }); + // A tuned-out channel shows no attendance: stop a typing run already + // going there, or its 7s refresh would continue until the turn ends. + this.stopTyping(channelId); this.appendLifecycleEvent({ kind: 'desired-state', serverId, diff --git a/test/disabled-prose-typing.test.ts b/test/disabled-prose-typing.test.ts index 543b443d2..cea9fe970 100644 --- a/test/disabled-prose-typing.test.ts +++ b/test/disabled-prose-typing.test.ts @@ -80,24 +80,27 @@ describe('typing with proseRouting disabled', () => { }); /** - * The real path: an incoming channel message goes through the ChannelRegistry, - * a debounced EventGate wake (which carries only wakeChannelId), and the - * tune-out coordinator; typing is recorded at the registry's sendTypingFn. + * The real path: typing is recorded at the ChannelRegistry's sendTypingFn, + * with the real EventGate (debounce) and TuneOutCoordinator in place. */ -async function makeRealPath(tunedOut: boolean) { +async function makeRealPath(opts: { tunedOut?: boolean; onStream?: (registry: ChannelRegistry) => void } = {}) { const dir = mkdtempSync(join(tmpdir(), 'disabled-typing-real-')); - const membrane = { streamYielding: () => new MockYieldingStream([createMockResponse([])]) }; + let streams = 0; + let registry!: ChannelRegistry; + const membrane = { + streamYielding: () => { streams++; opts.onStream?.(registry); return new MockYieldingStream([createMockResponse([])]); }, + }; const framework = await AgentFramework.create({ storePath: join(dir, 'store'), membrane: membrane as any, agents: [{ name: 'assistant', model: 'test', systemPrompt: 'test', proseRouting: 'disabled' }], modules: [], - gate: { config: { default: 'always', policies: [{ name: 'batch', match: { scope: ['mcpl:channel-incoming'] }, behavior: { debounce: 50 } }] } }, + gate: { config: { default: 'always', policies: [{ name: 'batch', match: { scope: ['mcpl:channel-incoming'] }, behavior: { debounce: 100 } }] } }, } as any); const typing: { channelId: string; op: string }[] = []; const server = { grant: new CapabilityGrant(new Set(['channels.typing']), []) }; const servers = { getServer: () => server } as any; const f = framework as any; - const registry = new ChannelRegistry(servers, {} as any, (e: any) => f.pushEvent(e), () => {}, { + registry = new ChannelRegistry(servers, {} as any, (e: any) => f.pushEvent(e), () => {}, { store: f.store, shouldTriggerInference: f.eventGate.asShouldTriggerCallback(), sendTypingFn: (_s: string, channelId: string, _m: unknown, op?: string) => { typing.push({ channelId, op: op ?? 'start' }); }, @@ -116,33 +119,85 @@ async function makeRealPath(tunedOut: boolean) { isForkBound: () => false, isPrivilegedAuthor: () => false, allowChannelSpeech: () => false, emitTrace: () => {}, } as any); - if (tunedOut) { - registry.enterTuneOut('zulip', 'zulip:support', { - epochId: 'epoch-1', cadenceSeconds: 3600, backlogCap: 20, maxWakes: 5, startedAtSequence: f.store.currentSequence(), - } as any, 'agent-tool' as any); - } - registry.handleIncoming('zulip', { messages: [{ - channelId: 'zulip:support', messageId: 'm1', author: { id: 'u1', name: 'Human' }, - timestamp: new Date().toISOString(), content: [{ type: 'text', text: 'ambient traffic' }], - tags: ['chat:ambient', 'chat:from-human'], - }] } as any); - await framework.runUntilIdle(); - await new Promise((r) => setTimeout(r, 120)); // let the debounce fire - await framework.runUntilIdle(); - const result = { typing, intervals: (registry as any).typingIntervals.size as number }; - await framework.stop(); rmSync(dir, { recursive: true, force: true }); - return result; + const tuneOut = () => registry.enterTuneOut('zulip', 'zulip:support', { + epochId: 'epoch-1', cadenceSeconds: 3600, backlogCap: 20, maxWakes: 5, startedAtSequence: f.store.currentSequence(), + } as any, 'agent-tool' as any); + if (opts.tunedOut) tuneOut(); + return { + framework, registry, typing, tuneOut, + streams: () => streams, + starts: () => typing.filter((t) => t.op === 'start').map((t) => t.channelId), + async ambient() { + registry.handleIncoming('zulip', { messages: [{ + channelId: 'zulip:support', messageId: 'm1', author: { id: 'u1', name: 'Human' }, + timestamp: new Date().toISOString(), content: [{ type: 'text', text: 'ambient traffic' }], + tags: ['chat:ambient', 'chat:from-human'], + }] } as any); + await framework.runUntilIdle(); + await new Promise((r) => setTimeout(r, 300)); // past the 100 ms debounce + await framework.runUntilIdle(); + }, + async request(r: Record) { + f.pendingRequests.push({ agentName: 'assistant', timestamp: Date.now(), ...r }); + await framework.runUntilIdle(); + }, + async close() { await framework.stop(); rmSync(dir, { recursive: true, force: true }); }, + }; } describe('typing with proseRouting disabled, through the real gate and registry', () => { it('a debounced ambient wake types where it came from, then stops', async () => { - const { typing, intervals } = await makeRealPath(false); - assert.deepEqual(typing, [{ channelId: 'zulip:support', op: 'start' }, { channelId: 'zulip:support', op: 'stop' }]); - assert.equal(intervals, 0); + const x = await makeRealPath(); + try { + await x.ambient(); + assert.equal(x.streams(), 1, 'the gate wake ran a turn'); + assert.deepEqual(x.typing, [{ channelId: 'zulip:support', op: 'start' }, { channelId: 'zulip:support', op: 'stop' }]); + assert.equal((x.registry as any).typingIntervals.size, 0); + } finally { await x.close(); } }); - it('a tuned-out channel shows no typing, even when its traffic reaches the gate', async () => { - const { typing } = await makeRealPath(true); - assert.deepEqual(typing, []); + it('a tuned-out channel shows no typing when its traffic still reaches the gate', async () => { + const x = await makeRealPath({ tunedOut: true }); + try { + await x.ambient(); + assert.equal(x.streams(), 1, 'the gate wake ran a turn'); + assert.deepEqual(x.starts(), []); + } finally { await x.close(); } + }); + + it('a tuned-out channel shows no typing for a push that names it, ambient or addressed', async () => { + for (const addressed of [false, true]) { + const x = await makeRealPath({ tunedOut: true }); + try { + await x.request({ reason: 'mcpl:push-event', source: 'zulip', channelId: 'zulip:support', addressed }); + assert.equal(x.streams(), 1, 'the push ran a turn'); + assert.deepEqual(x.starts(), [], `addressed=${addressed}`); + } finally { await x.close(); } + } + }); + + it('tune-out entered mid-turn stops the typing already running there', async () => { + let intervalsAfterTuneOut = -1; + const x = await makeRealPath({ + onStream: (registry) => { + x.tuneOut(); + intervalsAfterTuneOut = (registry as any).typingIntervals.size; + }, + }); + try { + await x.request({ reason: 'mcpl:push-event', source: 'zulip', channelId: 'zulip:support', addressed: true }); + assert.equal(x.streams(), 1); + assert.equal(intervalsAfterTuneOut, 0, 'no refresh keeps running after tune-out'); + assert.deepEqual(x.typing, [{ channelId: 'zulip:support', op: 'start' }, { channelId: 'zulip:support', op: 'stop' }]); + } finally { await x.close(); } + }); + + it('a silent wake that names channels still shows no typing', async () => { + const x = await makeRealPath(); + try { + await x.request({ reason: 'heartbeat', source: 'heartbeat', suppressProse: true, channelId: 'zulip:support', wakeChannelId: 'zulip:support' }); + assert.equal(x.streams(), 1); + assert.deepEqual(x.starts(), []); + } finally { await x.close(); } }); }); From 1c2d35373154a42d8bb4ca259c7dd93e08848f68 Mon Sep 17 00:00:00 2001 From: Aspirational Date: Mon, 5 Oct 2026 22:42:58 +0300 Subject: [PATCH 4/5] fix(typing): round-3 follow-ups: changelog scope, durable-first tune-out - enterTuneOut writes the lifecycle record before stopping typing (durable record first; no behavior change). - Changelog: explicit and locus turns can still restart typing on a tuned-out channel at stream start or on a retry; only disabled mode checks tune-out. Comment and wording no longer claim that tune-out diverts pushes. - The mid-turn test says what it covers: tune-out before stream start. Found in review (Claude Fable 5.1, high). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UN6d9TeXZDWNMxQ3dT5uR8 --- changelog.d/feat-disabled-mode-typing.changed.md | 13 +++++++------ src/framework.ts | 6 +++--- src/mcpl/channel-registry.ts | 6 +++--- test/disabled-prose-typing.test.ts | 2 +- 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/changelog.d/feat-disabled-mode-typing.changed.md b/changelog.d/feat-disabled-mode-typing.changed.md index c63053974..d871359e4 100644 --- a/changelog.d/feat-disabled-mode-typing.changed.md +++ b/changelog.d/feat-disabled-mode-typing.changed.md @@ -3,10 +3,11 @@ batched gate wake names no `channelId`, so its `wakeChannelId` is used instead. Before, disabled-mode turns showed no indicator at all, so a resident that answers only through send tools looked idle while it worked. - Never on a tuned-out channel: tune-out diverts even addressed traffic away - from the resident, but an ambient push or a gate wake can still name the - channel. Silent wakes (`suppressProse`, such as a silent heartbeat) still - show none. Explicit and locus modes pick their typing channel as before. + Never on a tuned-out channel: the agent turned its attention off there, yet + an ambient push or a gate wake can still name the channel. Silent wakes + (`suppressProse`, such as a silent heartbeat) still show none. Explicit and + locus modes pick their typing channel as before. - Entering tune-out on a channel now stops a typing indicator already running - there, in every prose-routing mode. Before, its 7 s refresh continued until - the turn ended. + there. Before, its 7 s refresh continued until the turn ended. A + disabled-mode turn does not restart it; an explicit or locus turn can still + restart it at stream start or on a retry, as before. diff --git a/src/framework.ts b/src/framework.ts index f29cd42aa..8a8a03f43 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -8871,9 +8871,9 @@ export class AgentFramework { * Where a `proseRouting: 'disabled'` turn shows typing: the trigger * channel, as in explicit mode, else where a batched gate wake came from * (`wakeChannelId`; the gate deliberately names no `channelId`). Never a - * tuned-out channel: tune-out diverts even addressed traffic away from the - * resident, yet an ambient push or a gate wake can still name the channel, - * and typing there would show attendance the agent turned off + * tuned-out channel: its incoming traffic, addressed included, is diverted + * away from the resident, yet an ambient push or a gate wake can still name + * the channel, and typing there would show attendance the agent turned off * (`enterTuneOut` also stops typing already running there). A silent wake * (`suppressProse`, which also runs as disabled) shows none. */ diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 77cf55ec6..d53171d5c 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -1863,9 +1863,6 @@ export class ChannelRegistry { tuneOut: params, wakeCount: 0, }); - // A tuned-out channel shows no attendance: stop a typing run already - // going there, or its 7s refresh would continue until the turn ends. - this.stopTyping(channelId); this.appendLifecycleEvent({ kind: 'desired-state', serverId, @@ -1875,6 +1872,9 @@ export class ChannelRegistry { source, timestamp: new Date().toISOString(), }); + // A tuned-out channel shows no attendance: stop a typing run already + // going there, or its 7s refresh would continue until the turn ends. + this.stopTyping(channelId); } /** diff --git a/test/disabled-prose-typing.test.ts b/test/disabled-prose-typing.test.ts index cea9fe970..19f512bef 100644 --- a/test/disabled-prose-typing.test.ts +++ b/test/disabled-prose-typing.test.ts @@ -176,7 +176,7 @@ describe('typing with proseRouting disabled, through the real gate and registry' } }); - it('tune-out entered mid-turn stops the typing already running there', async () => { + it('tune-out entered mid-turn, before the stream starts, stops the typing already running there', async () => { let intervalsAfterTuneOut = -1; const x = await makeRealPath({ onStream: (registry) => { From 7166b0b287fd85da82fcd534d7af0fa7d47dd8f8 Mon Sep 17 00:00:00 2001 From: Aspirational Date: Mon, 5 Oct 2026 23:16:00 +0300 Subject: [PATCH 5/5] fix(typing): Greptile review: fork-bound gate wakes, mixed silent batches, ungranted stop - disabledTypingChannel: with conversation routing the gate wakes every agent, but a channel's messages reach only the fork bound to it. The wakeChannelId fallback now applies only where the channel reaches the agent: a fork on its home channel, no other agent on a fork-bound one. - A silent request batched with ordinary requests no longer supplies the turn's channelId or wake provenance (all prose modes); a silent-only batch is unchanged. - ChannelRegistry: typing stops go through sendTypingStop, which applies the same channels.typing grant check as the start, in both stopTyping branches. Each fix has a test that fails without it. Full suite 1286 tests, 1281 pass, 0 fail (mcpl-runtime-lifecycle file timeout as on main). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UN6d9TeXZDWNMxQ3dT5uR8 --- .../feat-disabled-mode-typing.changed.md | 8 +++++ src/framework.ts | 33 +++++++++++++------ src/mcpl/channel-registry.ts | 22 ++++++++----- test/channel-registry-typing.test.ts | 21 ++++++++++++ test/disabled-prose-typing.test.ts | 31 +++++++++++++++-- 5 files changed, 95 insertions(+), 20 deletions(-) diff --git a/changelog.d/feat-disabled-mode-typing.changed.md b/changelog.d/feat-disabled-mode-typing.changed.md index d871359e4..11b65b9c3 100644 --- a/changelog.d/feat-disabled-mode-typing.changed.md +++ b/changelog.d/feat-disabled-mode-typing.changed.md @@ -11,3 +11,11 @@ there. Before, its 7 s refresh continued until the turn ended. A disabled-mode turn does not restart it; an explicit or locus turn can still restart it at stream start or on a retry, as before. +- With conversation routing, a disabled-mode gate wake shows typing only + where the channel's messages reach the agent: a fork on its home channel, + any other agent never on a fork-bound channel. +- A silent request batched with ordinary ones no longer picks the turn's + channel or wake provenance; the ordinary requests decide, in every + prose-routing mode. A batch of only silent requests is unchanged. +- A typing `stop` now honors the server's `channels.typing` grant, like the + start: a server without the grant no longer receives an unpaired stop. diff --git a/src/framework.ts b/src/framework.ts index 8a8a03f43..a367ca6ed 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -8289,9 +8289,14 @@ export class AgentFramework { // and counterparty must come from the same message, or a batch of // "ambient from A, then addressed from B" would report B's channel // with A's author (review finding on the provenance change). + // A silent tick never suppresses a genuine coalesced wake. If any + // ordinary request shares this batch, the ordinary turn semantics win, + // including where the turn is: a silent request names no channel then. + const silentOnly = requests.every((r) => r.suppressProse === true); + const routable = silentOnly ? requests : requests.filter((r) => r.suppressProse !== true); let ambientReq: InferenceRequest | undefined; let addressedReq: InferenceRequest | undefined; - for (const r of requests) { + for (const r of routable) { if (!r.channelId) continue; ambientReq = r; if (r.addressed) addressedReq = r; @@ -8303,14 +8308,11 @@ export class AgentFramework { // ordered by the event's own time (wakeAt) rather than flush order. let provReq: InferenceRequest | undefined = channelReq?.counterparty || channelReq?.wakeChannelId ? channelReq : undefined; if (!provReq) { - for (const r of requests) { + for (const r of routable) { if (!r.counterparty && !r.wakeChannelId) continue; if (!provReq || (r.wakeAt ?? r.timestamp) >= (provReq.wakeAt ?? provReq.timestamp)) provReq = r; } } - // A silent tick never suppresses a genuine coalesced wake. If any - // ordinary request shares this batch, the ordinary turn semantics win. - const silentOnly = requests.every((r) => r.suppressProse === true); await this.startAgentStream(agent, { ...trigger, suppressProse: silentOnly ? trigger?.suppressProse : undefined, @@ -8870,16 +8872,27 @@ export class AgentFramework { /** * Where a `proseRouting: 'disabled'` turn shows typing: the trigger * channel, as in explicit mode, else where a batched gate wake came from - * (`wakeChannelId`; the gate deliberately names no `channelId`). Never a + * (`wakeChannelId`; the gate deliberately names no `channelId`) when that + * channel's messages reach this agent. Never a * tuned-out channel: its incoming traffic, addressed included, is diverted * away from the resident, yet an ambient push or a gate wake can still name * the channel, and typing there would show attendance the agent turned off * (`enterTuneOut` also stops typing already running there). A silent wake * (`suppressProse`, which also runs as disabled) shows none. */ - private disabledTypingChannel(trigger: InferenceRequest | undefined): string | null { + private disabledTypingChannel(agentName: string, trigger: InferenceRequest | undefined): string | null { if (!trigger || trigger.suppressProse) return null; - const channelId = trigger.channelId ?? trigger.wakeChannelId; + let channelId = trigger.channelId; + if (!channelId && trigger.wakeChannelId) { + // The gate wakes every agent, but with conversation routing a channel's + // messages reach only the fork bound to it: a fork types only on its + // home channel, and no other agent types on a fork-bound channel. + const home = this.conversationAgentHomes.get(agentName); + const reachesAgent = home !== undefined + ? trigger.wakeChannelId === home + : ![...this.conversationAgentHomes.values()].includes(trigger.wakeChannelId); + if (reachesAgent) channelId = trigger.wakeChannelId; + } if (!channelId) return null; const serverId = this.channelRegistry?.getChannelServerId(channelId); if (serverId && this.channelRegistry?.getTuneOutState(serverId, channelId)) return null; @@ -9070,7 +9083,7 @@ export class AgentFramework { // it on the no-driveStream failure paths (e.g. a compile refusal). const earlyTypingChannel = turnProseRouting === 'disabled' - ? this.disabledTypingChannel(trigger) + ? this.disabledTypingChannel(agent.name, trigger) : turnProseRouting === 'explicit' ? trigger?.channelId ?? null : this.turnLocusPins.get(agent.name) ?? null; @@ -9342,7 +9355,7 @@ export class AgentFramework { // - disabled mode: see disabledTypingChannel. const typingChannel = turnProseRouting === 'disabled' - ? this.disabledTypingChannel(trigger) + ? this.disabledTypingChannel(agent.name, trigger) : turnProseRouting === 'explicit' ? trigger?.channelId ?? null : resolveTurnLocus(); diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index d53171d5c..4ab5dd316 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -1176,9 +1176,7 @@ export class ChannelRegistry { // global-clear branch's semantics, and keeps defensive stopTyping(ch) // calls from spamming stops at a server that never saw a start. const entry = this.findChannelEntry(channelId); - if (entry && this.sendTypingFn) { - this.sendTypingFn(entry.serverId, channelId, this.typingMetadata.get(channelId), 'stop'); - } + if (entry) this.sendTypingStop(entry.serverId, channelId); } this.typingMetadata.delete(channelId); } else { @@ -1188,11 +1186,9 @@ export class ChannelRegistry { clearInterval(interval); } this.typingIntervals.clear(); - if (this.sendTypingFn) { - for (const id of channels) { - const entry = this.findChannelEntry(id); - if (entry) this.sendTypingFn(entry.serverId, id, this.typingMetadata.get(id), 'stop'); - } + for (const id of channels) { + const entry = this.findChannelEntry(id); + if (entry) this.sendTypingStop(entry.serverId, id); } this.typingMetadata.clear(); } @@ -2158,6 +2154,16 @@ export class ChannelRegistry { // Private: Typing notification // ========================================================================== + /** + * Send a typing 'stop'. The same ยง14.1 grant gate as the start: a timer can + * exist for a server whose start was skipped for lack of `channels.typing`, + * and it must not receive an unpaired stop either. + */ + private sendTypingStop(serverId: string, channelId: string): void { + if (!CapabilityGrant.of(this.serverRegistry.getServer(serverId)).has('channels.typing')) return; + this.sendTypingFn?.(serverId, channelId, this.typingMetadata.get(channelId), 'stop'); + } + /** * Send a typing notification for a channel. * diff --git a/test/channel-registry-typing.test.ts b/test/channel-registry-typing.test.ts index 1d49067f0..4cd18f737 100644 --- a/test/channel-registry-typing.test.ts +++ b/test/channel-registry-typing.test.ts @@ -140,3 +140,24 @@ test('stopTyping() global branch only dispatches stops for channels with interva assert.equal(stops.length, 1); assert.equal(stops[0].channelId, 'ch1'); }); + +test('a server without the channels.typing grant gets no stop, from either stopTyping branch', () => { + const calls: TypingCall[] = []; + const registry = new ChannelRegistry( + { getServer: () => ({ grant: new CapabilityGrant(new Set(), []) }) } as unknown as McplServerRegistry, + {} as FeatureSetManager, + () => {}, + () => {}, + { sendTypingFn: (serverId, channelId, metadata, op) => { calls.push({ serverId, channelId, metadata, op }); } }, + ); + (registry as unknown as { + channels: Map; + }).channels.set('srv:ch1', { serverId: 'srv', descriptor: { id: 'ch1' }, open: true }); + + registry.startTyping('ch1'); // the timer exists; the start was skipped for lack of the grant + registry.stopTyping('ch1'); + registry.startTyping('ch1'); + registry.stopTyping(); + + assert.deepEqual(calls, [], 'an ungranted server receives neither start nor stop'); +}); diff --git a/test/disabled-prose-typing.test.ts b/test/disabled-prose-typing.test.ts index 19f512bef..1a35eefcf 100644 --- a/test/disabled-prose-typing.test.ts +++ b/test/disabled-prose-typing.test.ts @@ -33,8 +33,13 @@ async function make(proseRouting: 'disabled' | 'explicit') { return { dir, framework, typed: () => [...new Set(typing)] }; } -async function wake(proseRouting: 'disabled' | 'explicit', request: Record | 'silent') { +async function wake( + proseRouting: 'disabled' | 'explicit', + request: Record | Record[] | 'silent', + homes: Record = {}, +) { const x = await make(proseRouting); + for (const [agent, home] of Object.entries(homes)) (x.framework as any).conversationAgentHomes.set(agent, home); try { if (request === 'silent') { (x.framework as any).handleMcplPushEvent({ @@ -44,7 +49,10 @@ async function wake(proseRouting: 'disabled' | 'explicit', request: Record { assert.deepEqual(await wake('disabled', { reason: 'heartbeat', source: 'heartbeat' }), []); }); + it('a silent request batched with ordinary ones does not pick the turn\'s channel', async () => { + const silent = { reason: 'heartbeat', source: 'heartbeat', suppressProse: true, channelId: 'zulip:private', wakeChannelId: 'zulip:private' }; + assert.deepEqual(await wake('disabled', [silent, { reason: 'heartbeat', source: 'heartbeat' }]), []); + assert.deepEqual( + await wake('disabled', [silent, { reason: 'channel-message', source: 'zulip', channelId: 'zulip:support' }]), + ['zulip:support'], + ); + }); + + it('a gate wake types only where the channel\'s messages reach this agent', async () => { + const gate = (ch: string) => ({ reason: 'gate', source: 'gate', wakeChannelId: ch }); + // This agent is the fork bound to zulip:b: only its home channel. + assert.deepEqual(await wake('disabled', gate('zulip:a'), { assistant: 'zulip:b' }), []); + assert.deepEqual(await wake('disabled', gate('zulip:b'), { assistant: 'zulip:b' }), ['zulip:b']); + // Another fork owns zulip:a: not this agent's channel. + assert.deepEqual(await wake('disabled', gate('zulip:a'), { 'fork-1': 'zulip:a' }), []); + assert.deepEqual(await wake('disabled', gate('zulip:c'), { 'fork-1': 'zulip:a' }), ['zulip:c']); + }); + it('leaves explicit mode unchanged: a batched gate wake shows no typing', async () => { assert.deepEqual(await wake('explicit', { reason: 'gate', source: 'gate', wakeChannelId: 'zulip:support' }), []); assert.deepEqual(