Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/transport-context-only.added.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 5 additions & 2 deletions src/mcpl/channel-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Comment on lines +888 to +889

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Module wakes bypass suppression When a module requests inference for a context-only channel message, this check sets triggerInference to false, but the framework still dispatches the event to the module and queues its wake request without checking that flag. The same applies to suppressed push events. The agent can therefore wake despite the transport's context-only hint.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/mcpl/channel-registry.ts
Line: 888-889

Comment:
**Module wakes bypass suppression** When a module requests inference for a context-only channel message, this check sets `triggerInference` to false, but the framework still dispatches the event to the module and queues its wake request without checking that flag. The same applies to suppressed push events. The agent can therefore wake despite the transport's context-only hint.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

const textContent = message.content
.filter((b): b is { type: 'text'; text: string } => b.type === 'text')
.map((b) => b.text)
Expand Down
6 changes: 4 additions & 2 deletions src/mcpl/push-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Suppressed events receive inference IDs When origin.suppressWake is true, this code disables inference, but the push handler still returns an inferenceId in its success response. The response contract says that field is present only when inference was triggered, so the transport receives an identifier for a turn that will not occur.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/mcpl/push-handler.ts
Line: 239

Comment:
**Suppressed events receive inference IDs** When `origin.suppressWake` is true, this code disables inference, but the push handler still returns an `inferenceId` in its success response. The response contract says that field is present only when inference was triggered, so the transport receives an identifier for a turn that will not occur.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

if (triggerInference && this.shouldTriggerInference) {
const textContent = content
.filter((b): b is ContentBlock & { type: 'text' } => b.type === 'text')
.map((b) => b.text)
Expand Down
52 changes: 52 additions & 0 deletions test/mcpl-gate-roundtrip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string, unknown> };
assert.strictEqual(event.triggerInference, false);
assert.strictEqual(event.metadata?.suppressWake, true);
});
});

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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<string, unknown>) => {
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);
});
});
Loading