From 1e902e123baf1672754fb2c236721ed7d6146ad7 Mon Sep 17 00:00:00 2001 From: Ian de Marcellus <50731233+ian-de-marcellus@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:20:47 +0200 Subject: [PATCH 1/3] feat(mcpl): transport-declared context-only messages (suppressWake) A transport can mark an incoming message or push event as context-only with metadata/origin suppressWake: true. It is checked before the optional inference gate, so a permissive gate cannot turn it into a wake. discord-mcpl uses it for continuation chunks and for non-tail messages of a batched room. Ported from local uncommitted work onto current main. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/mcpl/channel-registry.ts | 7 +++++-- src/mcpl/push-handler.ts | 6 ++++-- test/mcpl-gate-roundtrip.test.ts | 13 +++++++++++++ 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 636e032d..4ad36c52 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -882,8 +882,11 @@ export class ChannelRegistry { } // Determine whether to trigger inference - let triggerInference = true; - if (this.shouldTriggerInference) { + // A transport may explicitly mark a message as context-only. This is a + // hard suppression, evaluated before the optional gate, so a permissive + // gate cannot accidentally turn a continuation chunk into a wake. + let triggerInference = message.metadata?.suppressWake !== true; + if (triggerInference && this.shouldTriggerInference) { const textContent = message.content .filter((b): b is { type: 'text'; text: string } => b.type === 'text') .map((b) => b.text) diff --git a/src/mcpl/push-handler.ts b/src/mcpl/push-handler.ts index a9cde38b..69fc4a3b 100644 --- a/src/mcpl/push-handler.ts +++ b/src/mcpl/push-handler.ts @@ -234,8 +234,10 @@ export class PushHandler { const content: ContentBlock[] = params.payload.content.map(convertBlock); // 4. Check shouldTriggerInference callback - let triggerInference = true; - if (this.shouldTriggerInference) { + // Same context-only contract as channels/incoming: a transport may mark + // an event (e.g. a continuation chunk) as context-only. + let triggerInference = params.origin?.suppressWake !== true; + if (triggerInference && this.shouldTriggerInference) { const textContent = content .filter((b): b is ContentBlock & { type: 'text' } => b.type === 'text') .map((b) => b.text) diff --git a/test/mcpl-gate-roundtrip.test.ts b/test/mcpl-gate-roundtrip.test.ts index 3ede04df..9a949fcb 100644 --- a/test/mcpl-gate-roundtrip.test.ts +++ b/test/mcpl-gate-roundtrip.test.ts @@ -83,6 +83,19 @@ describe('ChannelRegistry โ†’ shouldTriggerInference contract', () => { assert.strictEqual(seen[0].serverId, 'zulip'); assert.strictEqual(seen[0].channelId, 'zulip:tracker-miner-f'); }); + + it('honors transport context-only suppression before a permissive gate', () => { + const { registry, pushed } = makeRegistry(() => true); + const params = incomingParams('zulip:tracker-miner-f', '๐Ÿงต first half'); + params.messages[0].metadata = { suppressWake: true }; + + registry.handleIncoming('zulip', params); + + assert.strictEqual(pushed.length, 1); + const event = pushed[0] as { triggerInference?: boolean; metadata?: Record }; + assert.strictEqual(event.triggerInference, false); + assert.strictEqual(event.metadata?.suppressWake, true); + }); }); // --------------------------------------------------------------------------- From 27c086382f5a351131b522ccf87c28c8f562711c Mon Sep 17 00:00:00 2001 From: Ian de Marcellus <50731233+ian-de-marcellus@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:17:21 +0200 Subject: [PATCH 2/3] chore(changelog): add fragment Co-Authored-By: Claude Opus 5.5 (1M context) --- changelog.d/transport-context-only.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/transport-context-only.added.md diff --git a/changelog.d/transport-context-only.added.md b/changelog.d/transport-context-only.added.md new file mode 100644 index 00000000..71faf9e1 --- /dev/null +++ b/changelog.d/transport-context-only.added.md @@ -0,0 +1 @@ +- A transport can declare an incoming message or push event **context-only** with `metadata.suppressWake: true` (channels/incoming) or `origin.suppressWake: true` (push events): it is stored and routed as usual but never triggers inference. The check runs before the optional EventGate, so a permissive gate (small setups that wake on every message) cannot turn a continuation chunk, a batch's non-final message, or a replay into a wake. It only ever removes a wake; transports that never set it are unaffected. From f8941caefc10dd24f31f632cb1ed75a3766fbc7c Mon Sep 17 00:00:00 2001 From: Ian de Marcellus <50731233+ian-de-marcellus@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:20:39 +0200 Subject: [PATCH 3/3] test(mcpl): push-event lane honors context-only suppression before the gate Co-Authored-By: Claude Opus 5.5 (1M context) --- test/mcpl-gate-roundtrip.test.ts | 39 ++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/test/mcpl-gate-roundtrip.test.ts b/test/mcpl-gate-roundtrip.test.ts index 9a949fcb..5784bd21 100644 --- a/test/mcpl-gate-roundtrip.test.ts +++ b/test/mcpl-gate-roundtrip.test.ts @@ -6,6 +6,7 @@ import { join } from 'node:path'; import { EventGate } from '../src/gate/event-gate.js'; import { ChannelRegistry } from '../src/mcpl/channel-registry.js'; +import { PushHandler } from '../src/mcpl/push-handler.js'; import type { GateConfig } from '../src/gate/types.js'; import type { ChannelsIncomingParams } from '../src/mcpl/types.js'; import type { ProcessEvent } from '../src/types/index.js'; @@ -358,3 +359,41 @@ describe('ChannelRegistry durable lifecycle', () => { }; } }); + +// --------------------------------------------------------------------------- +// Push-event lane: same context-only contract (origin.suppressWake) +// --------------------------------------------------------------------------- + +describe('PushHandler โ†’ context-only suppression', () => { + const run = (origin: Record) => { + const pushed: Array<{ triggerInference?: boolean }> = []; + let gateCalls = 0; + const handler = new PushHandler( + { validateInbound: () => {} } as never, + (event) => { pushed.push(event as { triggerInference?: boolean }); }, + () => {}, + () => { gateCalls++; return true; }, // permissive gate: wake on everything + ); + handler.handlePushEvent('discord', { + featureSet: 'discord.messaging', + eventId: `e-${Math.random()}`, + timestamp: new Date().toISOString(), + origin, + payload: { content: [{ type: 'text', text: 'replayed message' }] }, + } as never); + return { pushed, gateCalls }; + }; + + it('a context-only push event is stored without inference, before the gate is consulted', () => { + const { pushed, gateCalls } = run({ source: 'discord', suppressWake: true }); + assert.strictEqual(pushed.length, 1); + assert.strictEqual(pushed[0].triggerInference, false); + assert.strictEqual(gateCalls, 0); + }); + + it('without the flag the same event still wakes under a permissive gate', () => { + const { pushed, gateCalls } = run({ source: 'discord' }); + assert.strictEqual(pushed[0].triggerInference, true); + assert.strictEqual(gateCalls, 1); + }); +});