fix(channels): wire accumulate mode through the bridge

The router + session DB were already fully plumbed for
ignored_message_policy='accumulate' — fan-out in routeInbound calls
deliverToAgent(wake=false) for non-engaging agents on accumulate wirings,
writeSessionMessage writes trigger=0, countDueMessages filters trigger=1,
container formatter includes all messages regardless of trigger. But the
Chat SDK bridge dropped non-engaging messages before the router ever saw
them, so accumulate was dead on arrival for every adapter that goes
through the bridge.

Expose ignored_message_policy on ConversationConfig, project it in
buildConversationConfigs, and widen shouldEngage's "forward" decision to
"engage OR accumulate" with the union taken across all wirings on a
conversation. stickySubscribe stays gated on a real engage — subscribing
a thread we'd only silently accumulate on would misrepresent the bot's
presence.

shouldEngage return shape is now { forward, stickySubscribe } — engage
was an internal concept the caller never needed, and conflating it with
forward was the source of this bug.

7 new tests cover: non-engaging messages forwarding under accumulate,
mixed drop/accumulate wirings taking the union, accumulate not
triggering sticky subscribe, unknown-conversation drop precedence over
accumulate, and drop policy preserving existing behavior on engaging
messages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-04-20 11:18:43 +03:00
parent ce25e1e97c
commit c38e5b11a8
4 changed files with 133 additions and 48 deletions

View File

@@ -17,6 +17,7 @@ function cfg(
agentGroupId: partial.agentGroupId ?? 'ag-1',
engageMode: partial.engageMode,
engagePattern: partial.engagePattern ?? null,
ignoredMessagePolicy: partial.ignoredMessagePolicy ?? 'drop',
sessionMode: partial.sessionMode ?? 'shared',
};
}
@@ -66,26 +67,26 @@ describe('shouldEngage', () => {
const sources: EngageSource[] = ['subscribed', 'mention', 'dm'];
for (const source of sources) {
it(`forwards for source='${source}' (may be a not-yet-wired group)`, () => {
expect(shouldEngage(empty, 'C1', source, '')).toEqual({ engage: true, stickySubscribe: false });
expect(shouldEngage(empty, 'C1', source, '')).toEqual({ forward: true, stickySubscribe: false });
});
}
it("DROPS for source='new-message' (would flood from unwired channels)", () => {
expect(shouldEngage(empty, 'C1', 'new-message', 'hello')).toEqual({
engage: false,
forward: false,
stickySubscribe: false,
});
});
});
describe("engageMode='mention'", () => {
describe("engageMode='mention' + ignoredMessagePolicy='drop' (default)", () => {
const conv = mapFor(cfg({ engageMode: 'mention' }));
it('engages on mention + dm', () => {
expect(shouldEngage(conv, 'C1', 'mention', '').engage).toBe(true);
expect(shouldEngage(conv, 'C1', 'dm', '').engage).toBe(true);
it('forwards on mention + dm', () => {
expect(shouldEngage(conv, 'C1', 'mention', '').forward).toBe(true);
expect(shouldEngage(conv, 'C1', 'dm', '').forward).toBe(true);
});
it('does NOT engage on subscribed or new-message (prevents keep-firing + flooding)', () => {
expect(shouldEngage(conv, 'C1', 'subscribed', '').engage).toBe(false);
expect(shouldEngage(conv, 'C1', 'new-message', '').engage).toBe(false);
it('does NOT forward on subscribed or new-message (prevents keep-firing + flooding)', () => {
expect(shouldEngage(conv, 'C1', 'subscribed', '').forward).toBe(false);
expect(shouldEngage(conv, 'C1', 'new-message', '').forward).toBe(false);
});
it('never asks to subscribe', () => {
for (const s of ['subscribed', 'mention', 'dm', 'new-message'] as EngageSource[]) {
@@ -96,37 +97,37 @@ describe('shouldEngage', () => {
describe("engageMode='mention-sticky'", () => {
const conv = mapFor(cfg({ engageMode: 'mention-sticky' }));
it('engages on mention + dm with stickySubscribe=true', () => {
expect(shouldEngage(conv, 'C1', 'mention', '')).toEqual({ engage: true, stickySubscribe: true });
expect(shouldEngage(conv, 'C1', 'dm', '')).toEqual({ engage: true, stickySubscribe: true });
it('forwards on mention + dm with stickySubscribe=true', () => {
expect(shouldEngage(conv, 'C1', 'mention', '')).toEqual({ forward: true, stickySubscribe: true });
expect(shouldEngage(conv, 'C1', 'dm', '')).toEqual({ forward: true, stickySubscribe: true });
});
it('engages on subscribed follow-ups without re-subscribing', () => {
expect(shouldEngage(conv, 'C1', 'subscribed', '')).toEqual({ engage: true, stickySubscribe: false });
it('forwards subscribed follow-ups without re-subscribing', () => {
expect(shouldEngage(conv, 'C1', 'subscribed', '')).toEqual({ forward: true, stickySubscribe: false });
});
it('does NOT engage on new-message (explicit mention required to start)', () => {
expect(shouldEngage(conv, 'C1', 'new-message', '').engage).toBe(false);
it('does NOT forward on new-message (explicit mention required to start)', () => {
expect(shouldEngage(conv, 'C1', 'new-message', '').forward).toBe(false);
});
});
describe("engageMode='pattern'", () => {
it('pattern="." engages on every source except new-message-with-unknown', () => {
it('pattern="." forwards on every source (when conversation is known)', () => {
const conv = mapFor(cfg({ engageMode: 'pattern', engagePattern: '.' }));
for (const s of ['subscribed', 'mention', 'dm', 'new-message'] as EngageSource[]) {
expect(shouldEngage(conv, 'C1', s, 'anything').engage).toBe(true);
expect(shouldEngage(conv, 'C1', s, 'anything').forward).toBe(true);
}
});
it('tests regex against text on new-message (the main bug fix)', () => {
const conv = mapFor(cfg({ engageMode: 'pattern', engagePattern: '^!report' }));
expect(shouldEngage(conv, 'C1', 'new-message', '!report now').engage).toBe(true);
expect(shouldEngage(conv, 'C1', 'new-message', 'nothing to see').engage).toBe(false);
expect(shouldEngage(conv, 'C1', 'new-message', '!report now').forward).toBe(true);
expect(shouldEngage(conv, 'C1', 'new-message', 'nothing to see').forward).toBe(false);
});
it('pattern regex applies on every source (symmetry)', () => {
const conv = mapFor(cfg({ engageMode: 'pattern', engagePattern: 'deploy' }));
for (const s of ['subscribed', 'mention', 'dm', 'new-message'] as EngageSource[]) {
expect(shouldEngage(conv, 'C1', s, 'time to deploy').engage).toBe(true);
expect(shouldEngage(conv, 'C1', s, 'weather today').engage).toBe(false);
expect(shouldEngage(conv, 'C1', s, 'time to deploy').forward).toBe(true);
expect(shouldEngage(conv, 'C1', s, 'weather today').forward).toBe(false);
}
});
@@ -139,7 +140,50 @@ describe('shouldEngage', () => {
it('invalid regex fails open (admin sees something rather than silent drop)', () => {
const conv = mapFor(cfg({ engageMode: 'pattern', engagePattern: '[unclosed' }));
expect(shouldEngage(conv, 'C1', 'new-message', 'x').engage).toBe(true);
expect(shouldEngage(conv, 'C1', 'new-message', 'x').forward).toBe(true);
});
});
describe("ignoredMessagePolicy='accumulate'", () => {
it('forwards non-engaging new-message so the router can store it as context (trigger=0)', () => {
const conv = mapFor(cfg({ engageMode: 'mention', ignoredMessagePolicy: 'accumulate' }));
// Plain message in unsubscribed group — mention rule says no engage,
// but accumulate says forward anyway.
expect(shouldEngage(conv, 'C1', 'new-message', 'chit chat')).toEqual({
forward: true,
stickySubscribe: false,
});
});
it('forwards non-engaging subscribed messages for accumulation', () => {
// mention wiring in a subscribed thread: the mention handler already
// fired once, thread is now subscribed, follow-ups route here. The
// base 'mention' rule wouldn't engage without an @-mention, but
// accumulate says capture the context.
const conv = mapFor(cfg({ engageMode: 'mention', ignoredMessagePolicy: 'accumulate' }));
expect(shouldEngage(conv, 'C1', 'subscribed', 'follow up talk').forward).toBe(true);
});
it('does NOT set stickySubscribe purely from accumulate (avoid misleading bot presence)', () => {
const conv = mapFor(cfg({ engageMode: 'mention-sticky', ignoredMessagePolicy: 'accumulate' }));
expect(shouldEngage(conv, 'C1', 'new-message', 'plain').stickySubscribe).toBe(false);
});
it("accumulate doesn't override the 'unknown conversation → drop new-message' rule", () => {
// Unknown conversation (not in map): accumulate can't be read because
// there's no config to read from. We still drop.
const empty = new Map<string, ConversationConfig[]>();
expect(shouldEngage(empty, 'C-unknown', 'new-message', 'x').forward).toBe(false);
});
it("drop policy + non-engaging message → doesn't forward", () => {
const conv = mapFor(cfg({ engageMode: 'mention', ignoredMessagePolicy: 'drop' }));
expect(shouldEngage(conv, 'C1', 'new-message', 'plain').forward).toBe(false);
});
it('engaging message with drop policy still forwards (engage wins regardless)', () => {
const conv = mapFor(cfg({ engageMode: 'mention', ignoredMessagePolicy: 'drop' }));
expect(shouldEngage(conv, 'C1', 'mention', '@bot hi').forward).toBe(true);
});
});
@@ -152,8 +196,17 @@ describe('shouldEngage', () => {
cfg({ agentGroupId: 'ag-a', engageMode: 'mention' }),
cfg({ agentGroupId: 'ag-b', engageMode: 'pattern', engagePattern: '^hi' }),
);
expect(shouldEngage(conv, 'C1', 'new-message', 'hi there').engage).toBe(true);
expect(shouldEngage(conv, 'C1', 'new-message', 'something else').engage).toBe(false);
expect(shouldEngage(conv, 'C1', 'new-message', 'hi there').forward).toBe(true);
expect(shouldEngage(conv, 'C1', 'new-message', 'something else').forward).toBe(false);
});
it('any accumulate wiring causes forward even if all others would drop', () => {
const conv = mapFor(
cfg({ agentGroupId: 'ag-a', engageMode: 'mention', ignoredMessagePolicy: 'drop' }),
cfg({ agentGroupId: 'ag-b', engageMode: 'mention', ignoredMessagePolicy: 'accumulate' }),
);
// Plain message: ag-a would drop, ag-b would accumulate → forward.
expect(shouldEngage(conv, 'C1', 'new-message', 'plain').forward).toBe(true);
});
it('stickySubscribe from any mention-sticky wiring wins', () => {
@@ -161,7 +214,7 @@ describe('shouldEngage', () => {
cfg({ agentGroupId: 'ag-a', engageMode: 'mention' }),
cfg({ agentGroupId: 'ag-b', engageMode: 'mention-sticky' }),
);
expect(shouldEngage(conv, 'C1', 'mention', '')).toEqual({ engage: true, stickySubscribe: true });
expect(shouldEngage(conv, 'C1', 'mention', '')).toEqual({ forward: true, stickySubscribe: true });
});
});
});