diff --git a/changelog.d/transport-context-only.added.md b/changelog.d/transport-context-only.added.md new file mode 100644 index 0000000..71faf9e --- /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. diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 636e032..4ad36c5 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 a9cde38..69fc4a3 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 3ede04d..5784bd2 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'; @@ -83,6 +84,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); + }); }); // --------------------------------------------------------------------------- @@ -345,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); + }); +});