Phase 2 reactions v2 priority and cooldown
This commit is contained in:
@@ -2484,6 +2484,160 @@ describe('daemon reactions routing integration', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('skips reactions for command messages', async () => {
|
||||
const processSpy = vi.spyOn(AgentOrchestrator.prototype, 'process').mockResolvedValue('ok');
|
||||
|
||||
const session = {
|
||||
id: 'gmail:reaction-command',
|
||||
addMessage: vi.fn(),
|
||||
getHistory: vi.fn(() => []),
|
||||
clear: vi.fn(),
|
||||
replaceHistory: vi.fn(),
|
||||
getConfig: vi.fn(() => undefined),
|
||||
setConfig: vi.fn(),
|
||||
deleteConfig: vi.fn(),
|
||||
};
|
||||
|
||||
const router = createMessageRouter({
|
||||
sessionManager: { getSession: vi.fn(() => session) } as unknown as MessageRouterDeps['sessionManager'],
|
||||
modelRouter: {
|
||||
getAvailableTiers: () => ['default'],
|
||||
getAllLabels: () => ({ default: 'default' }),
|
||||
getLabel: (tier: string) => tier,
|
||||
} as unknown as MessageRouterDeps['modelRouter'],
|
||||
systemPrompt: 'test prompt',
|
||||
toolRegistry: { clone() { return this; }, register: vi.fn() } as unknown as MessageRouterDeps['toolRegistry'],
|
||||
toolExecutor: {} as unknown as MessageRouterDeps['toolExecutor'],
|
||||
config: {
|
||||
agents: {
|
||||
primary_tier: 'default',
|
||||
delegation: {
|
||||
compaction: 'default',
|
||||
memory_extraction: 'default',
|
||||
classification: 'default',
|
||||
tool_summarisation: 'default',
|
||||
complex_reasoning: 'default',
|
||||
},
|
||||
max_delegation_depth: 1,
|
||||
max_iterations: 3,
|
||||
},
|
||||
automation: {
|
||||
reactions: [{
|
||||
name: 'boss-email',
|
||||
enabled: true,
|
||||
on: ['gmail'],
|
||||
filter: { contains: 'boss@company.com' },
|
||||
run: 'Summarize: {{text}}',
|
||||
}],
|
||||
},
|
||||
compaction: { enabled: false },
|
||||
models: { default: { provider: 'anthropic', model: 'claude' } },
|
||||
} as unknown as MessageRouterDeps['config'],
|
||||
});
|
||||
|
||||
await router.handler({
|
||||
id: 'r3',
|
||||
channel: 'gmail',
|
||||
senderId: 'reaction-command',
|
||||
text: '/status',
|
||||
timestamp: Date.now(),
|
||||
metadata: { isCommand: true, command: 'status' },
|
||||
} as MessageRouterInput, vi.fn(async (_message: OutboundMessage) => {}));
|
||||
|
||||
expect(processSpy).toHaveBeenCalledTimes(1);
|
||||
const [prompt] = processSpy.mock.calls[0] ?? [];
|
||||
expect(prompt).toBe('/status');
|
||||
expect(mockAuditLogger.reactionMatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('suppresses reactions during cooldown windows', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.setSystemTime(new Date('2026-02-25T00:00:00Z'));
|
||||
|
||||
const processSpy = vi.spyOn(AgentOrchestrator.prototype, 'process').mockResolvedValue('ok');
|
||||
|
||||
const session = {
|
||||
id: 'gmail:reaction-cooldown',
|
||||
addMessage: vi.fn(),
|
||||
getHistory: vi.fn(() => []),
|
||||
clear: vi.fn(),
|
||||
replaceHistory: vi.fn(),
|
||||
getConfig: vi.fn(() => undefined),
|
||||
setConfig: vi.fn(),
|
||||
deleteConfig: vi.fn(),
|
||||
};
|
||||
|
||||
const router = createMessageRouter({
|
||||
sessionManager: { getSession: vi.fn(() => session) } as unknown as MessageRouterDeps['sessionManager'],
|
||||
modelRouter: {
|
||||
getAvailableTiers: () => ['default'],
|
||||
getAllLabels: () => ({ default: 'default' }),
|
||||
getLabel: (tier: string) => tier,
|
||||
} as unknown as MessageRouterDeps['modelRouter'],
|
||||
systemPrompt: 'test prompt',
|
||||
toolRegistry: { clone() { return this; }, register: vi.fn() } as unknown as MessageRouterDeps['toolRegistry'],
|
||||
toolExecutor: {} as unknown as MessageRouterDeps['toolExecutor'],
|
||||
config: {
|
||||
agents: {
|
||||
primary_tier: 'default',
|
||||
delegation: {
|
||||
compaction: 'default',
|
||||
memory_extraction: 'default',
|
||||
classification: 'default',
|
||||
tool_summarisation: 'default',
|
||||
complex_reasoning: 'default',
|
||||
},
|
||||
max_delegation_depth: 1,
|
||||
max_iterations: 3,
|
||||
},
|
||||
automation: {
|
||||
reactions: [{
|
||||
name: 'boss-email',
|
||||
enabled: true,
|
||||
on: ['gmail'],
|
||||
filter: { contains: 'boss@company.com' },
|
||||
run: 'Summarize: {{text}}',
|
||||
cooldown_ms: 60000,
|
||||
}],
|
||||
},
|
||||
compaction: { enabled: false },
|
||||
models: { default: { provider: 'anthropic', model: 'claude' } },
|
||||
} as unknown as MessageRouterDeps['config'],
|
||||
});
|
||||
|
||||
const reply = vi.fn(async (_message: OutboundMessage) => {});
|
||||
await router.handler({
|
||||
id: 'r4',
|
||||
channel: 'gmail',
|
||||
senderId: 'reaction-cooldown',
|
||||
text: 'boss@company.com update',
|
||||
timestamp: Date.now(),
|
||||
} as MessageRouterInput, reply);
|
||||
|
||||
vi.setSystemTime(new Date('2026-02-25T00:00:30Z'));
|
||||
await router.handler({
|
||||
id: 'r5',
|
||||
channel: 'gmail',
|
||||
senderId: 'reaction-cooldown',
|
||||
text: 'boss@company.com update again',
|
||||
timestamp: Date.now(),
|
||||
} as MessageRouterInput, reply);
|
||||
|
||||
expect(processSpy).toHaveBeenCalledTimes(2);
|
||||
const secondPrompt = processSpy.mock.calls[1]?.[0];
|
||||
expect(secondPrompt).toBe('boss@company.com update again');
|
||||
expect(mockAuditLogger.reactionSkip).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
session_id: 'gmail:reaction-cooldown',
|
||||
reason: 'cooldown',
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('daemon auto-escalate integration', () => {
|
||||
|
||||
+27
-11
@@ -27,7 +27,7 @@ import type { ComponentRegistry } from '../intents/index.js';
|
||||
import type { RoutingPolicy } from '../routing/index.js';
|
||||
import type { HookEngine } from '../hooks/index.js';
|
||||
import { createClientFromConfig } from './models.js';
|
||||
import { matchReactionPrompt } from '../automation/reactions.js';
|
||||
import { resolveReactionDecision } from '../automation/reactions.js';
|
||||
import { loadSkillRegistryCatalog } from '../skills/index.js';
|
||||
import type { SkillInstaller, SkillRegistry, SkillRegistryEntry, SkillRegistrySource } from '../skills/index.js';
|
||||
import { auditLogger } from '../audit/index.js';
|
||||
@@ -388,6 +388,7 @@ export function createMessageRouter(deps: {
|
||||
const agents = new Map<string, { orchestrator: AgentOrchestrator; collector: OutboundAttachmentCollector }>();
|
||||
const talkModeUntil = new Map<string, number>();
|
||||
const activeRuns = new Map<string, AgentOrchestrator>();
|
||||
const reactionCooldowns = new Map<string, number>();
|
||||
|
||||
function getBackendMode(): BackendRuntimeMode {
|
||||
return deps.getBackendMode?.() ?? 'config_default';
|
||||
@@ -837,7 +838,17 @@ export function createMessageRouter(deps: {
|
||||
|
||||
const automationReactions = deps.config.automation?.reactions ?? [];
|
||||
if (!msg.metadata?.isCommand) {
|
||||
if (automationReactions.length === 0) {
|
||||
if (msg.metadata?.automationReaction) {
|
||||
auditLogger?.reactionSkip?.({
|
||||
session_id: sessionIdForRun,
|
||||
channel: msg.channel,
|
||||
sender: msg.senderId,
|
||||
source: 'channel',
|
||||
reason: 'recursion_guard',
|
||||
candidate_count: automationReactions.length,
|
||||
});
|
||||
deps.metrics?.recordReactionDecision({ matched: false, reason: 'recursion_guard' });
|
||||
} else if (automationReactions.length === 0) {
|
||||
auditLogger?.reactionSkip?.({
|
||||
session_id: sessionIdForRun,
|
||||
channel: msg.channel,
|
||||
@@ -848,36 +859,41 @@ export function createMessageRouter(deps: {
|
||||
});
|
||||
deps.metrics?.recordReactionDecision({ matched: false, reason: 'no_rules' });
|
||||
} else {
|
||||
const reactionMatch = matchReactionPrompt(automationReactions, {
|
||||
const decision = resolveReactionDecision(automationReactions, {
|
||||
channel: msg.channel,
|
||||
senderId: msg.senderId,
|
||||
text: incomingText,
|
||||
metadata: msg.metadata,
|
||||
}, {
|
||||
cooldownStore: reactionCooldowns,
|
||||
cooldownScope: sessionIdForRun,
|
||||
now: Date.now(),
|
||||
});
|
||||
if (reactionMatch) {
|
||||
matchedReactionName = reactionMatch.name;
|
||||
incomingText = reactionMatch.prompt;
|
||||
const matchedRule = automationReactions.find((rule) => rule.name === reactionMatch.name);
|
||||
|
||||
if (decision.match) {
|
||||
matchedReactionName = decision.match.name;
|
||||
incomingText = decision.match.prompt;
|
||||
auditLogger?.reactionMatch?.({
|
||||
session_id: sessionIdForRun,
|
||||
channel: msg.channel,
|
||||
sender: msg.senderId,
|
||||
source: 'channel',
|
||||
rule_name: reactionMatch.name,
|
||||
rule_name: decision.match.name,
|
||||
candidate_count: automationReactions.length,
|
||||
filter_summary: buildReactionFilterSummary(matchedRule),
|
||||
filter_summary: buildReactionFilterSummary(decision.matchedRule),
|
||||
});
|
||||
deps.metrics?.recordReactionDecision({ matched: true });
|
||||
} else {
|
||||
const reason = decision.cooldownSkipped > 0 ? 'cooldown' : 'no_match';
|
||||
auditLogger?.reactionSkip?.({
|
||||
session_id: sessionIdForRun,
|
||||
channel: msg.channel,
|
||||
sender: msg.senderId,
|
||||
source: 'channel',
|
||||
reason: 'no_match',
|
||||
reason,
|
||||
candidate_count: automationReactions.length,
|
||||
});
|
||||
deps.metrics?.recordReactionDecision({ matched: false, reason: 'no_match' });
|
||||
deps.metrics?.recordReactionDecision({ matched: false, reason });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user