feat: Add message queue system with interruption handling (#4179)
Co-authored-by: Zane Staggs <zane@squareup.com> Co-authored-by: Michael Neale <michael.neale@gmail.com>
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
detectInterruption,
|
||||
isInterruptionCommand,
|
||||
getInterruptionMessage,
|
||||
INTERRUPTION_KEYWORDS,
|
||||
InterruptionMatch,
|
||||
} from './interruptionDetector';
|
||||
|
||||
describe('interruptionDetector', () => {
|
||||
describe('detectInterruption', () => {
|
||||
describe('exact matches (confidence: 1.0)', () => {
|
||||
it('detects exact stop variations', () => {
|
||||
const result = detectInterruption('stop');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.confidence).toBe(1.0);
|
||||
expect(result?.keyword.action).toBe('stop');
|
||||
expect(result?.shouldInterrupt).toBe(true);
|
||||
});
|
||||
|
||||
it('detects exact wait variations', () => {
|
||||
const result = detectInterruption('pause');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.confidence).toBe(1.0);
|
||||
expect(result?.keyword.action).toBe('pause');
|
||||
expect(result?.shouldInterrupt).toBe(true);
|
||||
});
|
||||
|
||||
it('detects exact redirect variations', () => {
|
||||
const result = detectInterruption('actually');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.confidence).toBe(1.0);
|
||||
expect(result?.keyword.action).toBe('redirect');
|
||||
expect(result?.shouldInterrupt).toBe(true);
|
||||
});
|
||||
|
||||
it('is case insensitive', () => {
|
||||
expect(detectInterruption('STOP')?.confidence).toBe(1.0);
|
||||
expect(detectInterruption('Stop')?.confidence).toBe(1.0);
|
||||
expect(detectInterruption('sToP')?.confidence).toBe(1.0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('beginning matches (confidence: 0.9)', () => {
|
||||
it('detects variations at start with space', () => {
|
||||
const result = detectInterruption('stop doing that');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.confidence).toBe(0.9);
|
||||
expect(result?.shouldInterrupt).toBe(true);
|
||||
});
|
||||
|
||||
it('detects variations at start with comma', () => {
|
||||
const result = detectInterruption('wait, I need to think');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.confidence).toBe(0.9);
|
||||
expect(result?.shouldInterrupt).toBe(true);
|
||||
});
|
||||
|
||||
it('detects "never mind" at the beginning', () => {
|
||||
const result = detectInterruption('never mind, forget it');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.confidence).toBe(0.9);
|
||||
expect(result?.keyword.action).toBe('stop');
|
||||
});
|
||||
});
|
||||
|
||||
describe('contained matches in short inputs (confidence: 0.7)', () => {
|
||||
it('detects keywords in short messages', () => {
|
||||
const result = detectInterruption('oh wait please');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.confidence).toBe(0.7);
|
||||
expect(result?.matchedText).toBe('wait');
|
||||
});
|
||||
|
||||
it('only interrupts high priority keywords when confidence is 0.7', () => {
|
||||
// High priority - should interrupt
|
||||
const highPriority = detectInterruption('oh stop please');
|
||||
expect(highPriority?.shouldInterrupt).toBe(true);
|
||||
|
||||
// Medium priority - should not interrupt at 0.7 confidence
|
||||
// Note: "oh actually wait" will match "wait" (high priority) first, so let's use a different example
|
||||
const mediumPriority = detectInterruption('oh actually');
|
||||
expect(mediumPriority?.confidence).toBe(0.7);
|
||||
expect(mediumPriority?.shouldInterrupt).toBe(false);
|
||||
});
|
||||
|
||||
it('detects contained keywords in short inputs', () => {
|
||||
// The implementation actually DOES match keywords contained in short inputs
|
||||
// This is by design - it uses .includes() for short messages
|
||||
const result = detectInterruption('unstoppable');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.matchedText).toBe('stop');
|
||||
expect(result?.confidence).toBe(0.7);
|
||||
|
||||
// Words that don't contain any keyword variations should return null
|
||||
expect(detectInterruption('continuing')).toBeNull();
|
||||
expect(detectInterruption('proceeding')).toBeNull();
|
||||
|
||||
// Long messages should not match even if they contain keywords
|
||||
expect(
|
||||
detectInterruption('this is a very long message with stop in it somewhere')
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores keywords in long messages', () => {
|
||||
const longMessage =
|
||||
'This is a very long message that contains stop but should not be detected';
|
||||
expect(detectInterruption(longMessage)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('detects contained keywords in short inputs', () => {
|
||||
// The implementation actually DOES match keywords contained in short inputs
|
||||
// This is by design - it uses .includes() for short messages
|
||||
const result = detectInterruption('unstoppable');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.matchedText).toBe('stop');
|
||||
expect(result?.confidence).toBe(0.7);
|
||||
|
||||
// Words that don't contain any keyword variations should return null
|
||||
expect(detectInterruption('continuing')).toBeNull();
|
||||
expect(detectInterruption('proceeding')).toBeNull();
|
||||
|
||||
// Long messages should not match even if they contain keywords
|
||||
expect(
|
||||
detectInterruption('this is a very long message with stop in it somewhere')
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('handles multiple keyword matches by returning first match', () => {
|
||||
const result = detectInterruption('stop wait');
|
||||
expect(result?.matchedText).toBe('stop');
|
||||
expect(result?.confidence).toBe(0.9);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isInterruptionCommand', () => {
|
||||
it('returns true for interruption commands', () => {
|
||||
expect(isInterruptionCommand('stop')).toBe(true);
|
||||
expect(isInterruptionCommand('wait')).toBe(true);
|
||||
expect(isInterruptionCommand('halt now')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-interruption text', () => {
|
||||
expect(isInterruptionCommand('continue')).toBe(false);
|
||||
expect(isInterruptionCommand('hello')).toBe(false);
|
||||
expect(isInterruptionCommand('')).toBe(false);
|
||||
});
|
||||
|
||||
it('respects shouldInterrupt flag', () => {
|
||||
// Medium priority keyword in short text - shouldInterrupt is false
|
||||
expect(isInterruptionCommand('oh actually')).toBe(false);
|
||||
|
||||
// High priority keyword in short text - shouldInterrupt is true
|
||||
expect(isInterruptionCommand('oh stop')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInterruptionMessage', () => {
|
||||
it('returns correct message for stop action', () => {
|
||||
const match: InterruptionMatch = {
|
||||
keyword: INTERRUPTION_KEYWORDS.find((k) => k.action === 'stop')!,
|
||||
matchedText: 'stop',
|
||||
confidence: 1.0,
|
||||
shouldInterrupt: true,
|
||||
};
|
||||
expect(getInterruptionMessage(match)).toBe('Stopped processing. You said "stop".');
|
||||
});
|
||||
|
||||
it('returns correct message for pause action', () => {
|
||||
const match: InterruptionMatch = {
|
||||
keyword: INTERRUPTION_KEYWORDS.find((k) => k.action === 'pause')!,
|
||||
matchedText: 'wait',
|
||||
confidence: 1.0,
|
||||
shouldInterrupt: true,
|
||||
};
|
||||
expect(getInterruptionMessage(match)).toBe('Paused processing. You said "wait".');
|
||||
});
|
||||
|
||||
it('returns correct message for redirect action', () => {
|
||||
const match: InterruptionMatch = {
|
||||
keyword: INTERRUPTION_KEYWORDS.find((k) => k.action === 'redirect')!,
|
||||
matchedText: 'actually',
|
||||
confidence: 1.0,
|
||||
shouldInterrupt: true,
|
||||
};
|
||||
expect(getInterruptionMessage(match)).toBe('Stopping to redirect. You said "actually".');
|
||||
});
|
||||
|
||||
it('returns default message for unknown action', () => {
|
||||
const match: InterruptionMatch = {
|
||||
keyword: {
|
||||
keyword: 'test',
|
||||
variations: ['test'],
|
||||
priority: 'low',
|
||||
action: 'unknown' as 'stop',
|
||||
},
|
||||
matchedText: 'test',
|
||||
confidence: 1.0,
|
||||
shouldInterrupt: true,
|
||||
};
|
||||
expect(getInterruptionMessage(match)).toBe('Interrupted processing. You said "test".');
|
||||
});
|
||||
});
|
||||
|
||||
describe('INTERRUPTION_KEYWORDS', () => {
|
||||
it('has valid structure for all keywords', () => {
|
||||
INTERRUPTION_KEYWORDS.forEach((keyword) => {
|
||||
expect(keyword.keyword).toBeTruthy();
|
||||
expect(keyword.variations).toBeInstanceOf(Array);
|
||||
expect(keyword.variations.length).toBeGreaterThan(0);
|
||||
expect(['high', 'medium', 'low']).toContain(keyword.priority);
|
||||
expect(['stop', 'pause', 'redirect']).toContain(keyword.action);
|
||||
});
|
||||
});
|
||||
|
||||
it('includes the main keyword in variations', () => {
|
||||
INTERRUPTION_KEYWORDS.forEach((keyword) => {
|
||||
expect(keyword.variations).toContain(keyword.keyword);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Utility for detecting interruption keywords in user input
|
||||
*/
|
||||
|
||||
export interface InterruptionKeyword {
|
||||
keyword: string;
|
||||
variations: string[];
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
action: 'stop' | 'pause' | 'redirect';
|
||||
}
|
||||
|
||||
// Define interruption keywords and their variations
|
||||
export const INTERRUPTION_KEYWORDS: InterruptionKeyword[] = [
|
||||
{
|
||||
keyword: 'stop',
|
||||
variations: ['stop', 'halt', 'cease', 'quit', 'end', 'abort', 'cancel'],
|
||||
priority: 'high',
|
||||
action: 'stop'
|
||||
},
|
||||
{
|
||||
keyword: 'wait',
|
||||
variations: ['wait', 'hold', 'pause', 'hold on', 'wait up', 'hold up'],
|
||||
priority: 'high',
|
||||
action: 'pause'
|
||||
},
|
||||
{
|
||||
keyword: 'no',
|
||||
variations: ['no', 'nope', 'nah', 'wrong', 'incorrect', 'not right'],
|
||||
priority: 'medium',
|
||||
action: 'stop'
|
||||
},
|
||||
{
|
||||
keyword: 'actually',
|
||||
variations: ['actually', 'instead', 'rather', 'better idea', 'change of plans'],
|
||||
priority: 'medium',
|
||||
action: 'redirect'
|
||||
},
|
||||
{
|
||||
keyword: 'nevermind',
|
||||
variations: ['nevermind', 'never mind', 'forget it', 'ignore that', 'disregard'],
|
||||
priority: 'medium',
|
||||
action: 'stop'
|
||||
}
|
||||
];
|
||||
|
||||
export interface InterruptionMatch {
|
||||
keyword: InterruptionKeyword;
|
||||
matchedText: string;
|
||||
confidence: number;
|
||||
shouldInterrupt: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyzes input text for interruption keywords
|
||||
*/
|
||||
export function detectInterruption(input: string): InterruptionMatch | null {
|
||||
if (!input || input.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedInput = input.toLowerCase().trim();
|
||||
|
||||
// Check for exact matches first (highest confidence)
|
||||
for (const keyword of INTERRUPTION_KEYWORDS) {
|
||||
for (const variation of keyword.variations) {
|
||||
if (normalizedInput === variation) {
|
||||
return {
|
||||
keyword,
|
||||
matchedText: variation,
|
||||
confidence: 1.0,
|
||||
shouldInterrupt: true
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for matches at the beginning of input (high confidence)
|
||||
for (const keyword of INTERRUPTION_KEYWORDS) {
|
||||
for (const variation of keyword.variations) {
|
||||
if (normalizedInput.startsWith(variation + ' ') || normalizedInput.startsWith(variation + ',')) {
|
||||
return {
|
||||
keyword,
|
||||
matchedText: variation,
|
||||
confidence: 0.9,
|
||||
shouldInterrupt: true
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for matches anywhere in short inputs (medium confidence)
|
||||
if (normalizedInput.length <= 20) {
|
||||
for (const keyword of INTERRUPTION_KEYWORDS) {
|
||||
for (const variation of keyword.variations) {
|
||||
if (normalizedInput.includes(variation)) {
|
||||
return {
|
||||
keyword,
|
||||
matchedText: variation,
|
||||
confidence: 0.7,
|
||||
shouldInterrupt: keyword.priority === 'high'
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if input is likely an interruption command
|
||||
*/
|
||||
export function isInterruptionCommand(input: string): boolean {
|
||||
const match = detectInterruption(input);
|
||||
return match?.shouldInterrupt ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a user-friendly message for the interruption action
|
||||
*/
|
||||
export function getInterruptionMessage(match: InterruptionMatch): string {
|
||||
switch (match.keyword.action) {
|
||||
case 'stop':
|
||||
return `Stopped processing. You said "${match.matchedText}".`;
|
||||
case 'pause':
|
||||
return `Paused processing. You said "${match.matchedText}".`;
|
||||
case 'redirect':
|
||||
return `Stopping to redirect. You said "${match.matchedText}".`;
|
||||
default:
|
||||
return `Interrupted processing. You said "${match.matchedText}".`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user