fix(tui): add /queue command support across tui and routing
This commit is contained in:
@@ -210,6 +210,79 @@ describe('daemon command fast-path integration', () => {
|
||||
expect(session.setConfig).toHaveBeenCalledWith('modelTier', 'fast');
|
||||
});
|
||||
|
||||
it('handles queue command via fast-path and persists queue override', async () => {
|
||||
const processSpy = vi.spyOn(AgentOrchestrator.prototype, 'process');
|
||||
const session = {
|
||||
id: 'telegram:user-queue',
|
||||
addMessage: vi.fn(),
|
||||
getHistory: vi.fn(() => []),
|
||||
clear: vi.fn(),
|
||||
replaceHistory: vi.fn(),
|
||||
getConfig: vi.fn(() => undefined),
|
||||
setConfig: vi.fn(),
|
||||
deleteConfig: vi.fn(),
|
||||
};
|
||||
|
||||
const commandRegistry = new CommandRegistry();
|
||||
registerBuiltinCommands(commandRegistry);
|
||||
|
||||
const router = createMessageRouter({
|
||||
sessionManager: {
|
||||
getSession: vi.fn(() => session),
|
||||
} as unknown as MessageRouterDeps['sessionManager'],
|
||||
modelRouter: {
|
||||
getAvailableTiers: () => ['fast', 'default', 'complex', 'local'],
|
||||
getAllLabels: () => ({ fast: 'fast', default: 'default', complex: 'complex', local: 'local' }),
|
||||
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: 'fast',
|
||||
memory_extraction: 'fast',
|
||||
classification: 'fast',
|
||||
tool_summarisation: 'fast',
|
||||
complex_reasoning: 'complex',
|
||||
},
|
||||
max_delegation_depth: 3,
|
||||
max_iterations: 10,
|
||||
},
|
||||
server: {
|
||||
queue: {
|
||||
mode: 'collect',
|
||||
cap: 50,
|
||||
overflow: 'drop_old',
|
||||
debounce_ms: 0,
|
||||
summarize_overflow: true,
|
||||
},
|
||||
},
|
||||
compaction: { enabled: false },
|
||||
models: { default: { provider: 'anthropic', model: 'claude' } },
|
||||
} as unknown as MessageRouterDeps['config'],
|
||||
commandRegistry,
|
||||
});
|
||||
|
||||
const reply = vi.fn(async (_message: OutboundMessage) => {});
|
||||
await router.handler({
|
||||
id: 'q1',
|
||||
channel: 'telegram',
|
||||
senderId: 'user-queue',
|
||||
text: '/queue set mode followup',
|
||||
timestamp: Date.now(),
|
||||
metadata: { isCommand: true, command: 'queue', commandArgs: 'set mode followup' },
|
||||
} as MessageRouterInput, reply);
|
||||
|
||||
expect(processSpy).not.toHaveBeenCalled();
|
||||
expect(session.setConfig).toHaveBeenCalledWith('queue.mode', 'followup');
|
||||
});
|
||||
|
||||
it('uses intent match to override agent target', async () => {
|
||||
const session = {
|
||||
id: 'telegram:user-2',
|
||||
|
||||
@@ -616,6 +616,87 @@ export function createMessageRouter(deps: {
|
||||
|
||||
return `Elevated mode: on until ${new Date(untilMs).toISOString()}`;
|
||||
},
|
||||
|
||||
getQueue: () => {
|
||||
const mode = session.getConfig('queue.mode') ?? deps.config.server.queue.mode;
|
||||
const cap = session.getConfig('queue.cap') ?? String(deps.config.server.queue.cap);
|
||||
const overflow = session.getConfig('queue.overflow') ?? deps.config.server.queue.overflow;
|
||||
const debounceMs = session.getConfig('queue.debounce_ms') ?? String(deps.config.server.queue.debounce_ms);
|
||||
const summarizeOverflow = session.getConfig('queue.summarize_overflow') ?? String(deps.config.server.queue.summarize_overflow);
|
||||
const source = session.getConfig('queue.mode')
|
||||
|| session.getConfig('queue.cap')
|
||||
|| session.getConfig('queue.overflow')
|
||||
|| session.getConfig('queue.debounce_ms')
|
||||
|| session.getConfig('queue.summarize_overflow')
|
||||
? 'session override'
|
||||
: 'default config';
|
||||
return [
|
||||
'**Queue policy**',
|
||||
`mode: ${mode}`,
|
||||
`cap: ${cap}`,
|
||||
`overflow: ${overflow}`,
|
||||
`debounce_ms: ${debounceMs}`,
|
||||
`summarize_overflow: ${summarizeOverflow}`,
|
||||
`source: ${source}`,
|
||||
].join('\n');
|
||||
},
|
||||
|
||||
setQueue: (input: string) => {
|
||||
const [rawKey, ...rest] = input.trim().split(/\s+/);
|
||||
const value = rest.join(' ').trim();
|
||||
if (!rawKey || !value) {
|
||||
return 'Usage: /queue <mode|cap|overflow|debounce_ms|summarize_overflow> <value>';
|
||||
}
|
||||
const key = rawKey.toLowerCase();
|
||||
if (key === 'mode') {
|
||||
if (!['collect', 'followup', 'steer', 'steer_backlog', 'interrupt'].includes(value)) {
|
||||
return 'Invalid mode. Use one of: collect, followup, steer, steer_backlog, interrupt';
|
||||
}
|
||||
session.setConfig('queue.mode', value);
|
||||
return `Set queue.mode=${value} for this session`;
|
||||
}
|
||||
if (key === 'cap') {
|
||||
const cap = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(cap) || cap < 1 || cap > 1000) {
|
||||
return 'Invalid cap. Use an integer between 1 and 1000';
|
||||
}
|
||||
session.setConfig('queue.cap', String(cap));
|
||||
return `Set queue.cap=${cap} for this session`;
|
||||
}
|
||||
if (key === 'overflow') {
|
||||
if (value !== 'drop_old' && value !== 'drop_new') {
|
||||
return 'Invalid overflow. Use drop_old or drop_new';
|
||||
}
|
||||
session.setConfig('queue.overflow', value);
|
||||
return `Set queue.overflow=${value} for this session`;
|
||||
}
|
||||
if (key === 'debounce_ms') {
|
||||
const debounceMs = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(debounceMs) || debounceMs < 0 || debounceMs > 60_000) {
|
||||
return 'Invalid debounce_ms. Use an integer between 0 and 60000';
|
||||
}
|
||||
session.setConfig('queue.debounce_ms', String(debounceMs));
|
||||
return `Set queue.debounce_ms=${debounceMs} for this session`;
|
||||
}
|
||||
if (key === 'summarize_overflow') {
|
||||
const normalized = value.toLowerCase();
|
||||
if (normalized !== 'true' && normalized !== 'false') {
|
||||
return 'Invalid summarize_overflow. Use true or false';
|
||||
}
|
||||
session.setConfig('queue.summarize_overflow', normalized);
|
||||
return `Set queue.summarize_overflow=${normalized} for this session`;
|
||||
}
|
||||
return 'Unknown queue key. Use one of: mode, cap, overflow, debounce_ms, summarize_overflow';
|
||||
},
|
||||
|
||||
resetQueue: () => {
|
||||
session.deleteConfig('queue.mode');
|
||||
session.deleteConfig('queue.cap');
|
||||
session.deleteConfig('queue.overflow');
|
||||
session.deleteConfig('queue.debounce_ms');
|
||||
session.deleteConfig('queue.summarize_overflow');
|
||||
return 'Reset session queue overrides.';
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -87,6 +87,13 @@ describe('parseCommand', () => {
|
||||
expect(parseCommand('/transfer telegram')).toEqual({ type: 'transfer', target: 'telegram' });
|
||||
});
|
||||
|
||||
it('parses /queue commands', () => {
|
||||
expect(parseCommand('/queue')).toEqual({ type: 'queue', action: 'show' });
|
||||
expect(parseCommand('/queue show')).toEqual({ type: 'queue', action: 'show' });
|
||||
expect(parseCommand('/queue reset')).toEqual({ type: 'queue', action: 'reset' });
|
||||
expect(parseCommand('/queue set mode followup')).toEqual({ type: 'queue', action: 'set', args: 'mode followup' });
|
||||
});
|
||||
|
||||
it('parses regular message', () => {
|
||||
expect(parseCommand('Hello Flynn')).toEqual({ type: 'message', content: 'Hello Flynn' });
|
||||
});
|
||||
@@ -106,6 +113,7 @@ describe('getHelpText', () => {
|
||||
expect(help).toContain('/compact');
|
||||
expect(help).toContain('/usage');
|
||||
expect(help).toContain('/verbose');
|
||||
expect(help).toContain('/queue');
|
||||
expect(help).toContain('/quit');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ export type Command =
|
||||
| { type: 'login'; provider?: string }
|
||||
| { type: 'transfer'; target: string }
|
||||
| { type: 'pair'; action?: 'generate' | 'list' | 'revoke'; args?: string }
|
||||
| { type: 'queue'; action?: 'show' | 'set' | 'reset'; args?: string }
|
||||
| { type: 'message'; content: string };
|
||||
|
||||
export function parseCommand(input: string): Command | null {
|
||||
@@ -113,6 +114,22 @@ export function parseCommand(input: string): Command | null {
|
||||
return { type: 'pair', action: 'revoke', args };
|
||||
}
|
||||
|
||||
// Queue
|
||||
if (trimmed === '/queue' || trimmed === '/queue show') {
|
||||
return { type: 'queue', action: 'show' };
|
||||
}
|
||||
if (trimmed === '/queue reset') {
|
||||
return { type: 'queue', action: 'reset' };
|
||||
}
|
||||
if (trimmed.startsWith('/queue set ')) {
|
||||
const args = trimmed.slice('/queue set '.length).trim();
|
||||
return { type: 'queue', action: 'set', args };
|
||||
}
|
||||
if (trimmed.startsWith('/queue ')) {
|
||||
const args = trimmed.slice('/queue '.length).trim();
|
||||
return { type: 'queue', action: 'set', args };
|
||||
}
|
||||
|
||||
// Regular message
|
||||
return { type: 'message', content: trimmed };
|
||||
}
|
||||
@@ -128,6 +145,9 @@ Commands:
|
||||
/pair List pending pairing codes and approved senders
|
||||
/pair generate [label] Generate a new DM pairing code
|
||||
/pair revoke <ch> <id> Revoke an approved sender
|
||||
/queue Show queue policy for this session
|
||||
/queue set <k> <v> Set queue override (mode/cap/overflow/debounce_ms/summarize_overflow)
|
||||
/queue reset Clear queue overrides for this session
|
||||
/reset, /clear, /new Clear conversation history
|
||||
/compact Compact conversation history
|
||||
/usage Show token usage and estimated cost
|
||||
@@ -159,6 +179,7 @@ export const SLASH_COMMANDS = [
|
||||
'/fs',
|
||||
'/login',
|
||||
'/pair',
|
||||
'/queue',
|
||||
'/transfer',
|
||||
'/quit',
|
||||
'/exit',
|
||||
@@ -180,6 +201,7 @@ export const COMMAND_TOOLTIPS: Record<string, string> = {
|
||||
'/fs': 'Switch to fullscreen mode',
|
||||
'/login': 'Authenticate with GitHub/OpenAI/Anthropic (OAuth/token or API key) or Z.AI (API key store)',
|
||||
'/pair': 'Generate/list/revoke DM pairing codes',
|
||||
'/queue': 'Show or update per-session queue policy',
|
||||
'/transfer': 'Transfer session to another frontend',
|
||||
'/quit': 'Exit TUI',
|
||||
'/exit': 'Exit TUI',
|
||||
|
||||
@@ -326,6 +326,101 @@ export function App({
|
||||
setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: 'Transfer not supported in fullscreen mode.' })]);
|
||||
return;
|
||||
|
||||
case 'queue': {
|
||||
if (!command.action || command.action === 'show') {
|
||||
const mode = session.getConfig('queue.mode') ?? 'collect';
|
||||
const cap = session.getConfig('queue.cap') ?? '50';
|
||||
const overflow = session.getConfig('queue.overflow') ?? 'drop_old';
|
||||
const debounceMs = session.getConfig('queue.debounce_ms') ?? '0';
|
||||
const summarizeOverflow = session.getConfig('queue.summarize_overflow') ?? 'true';
|
||||
const text = [
|
||||
'Queue policy:',
|
||||
`mode: ${mode}`,
|
||||
`cap: ${cap}`,
|
||||
`overflow: ${overflow}`,
|
||||
`debounce_ms: ${debounceMs}`,
|
||||
`summarize_overflow: ${summarizeOverflow}`,
|
||||
].join('\n');
|
||||
setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: text })]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command.action === 'reset') {
|
||||
session.deleteConfig('queue.mode');
|
||||
session.deleteConfig('queue.cap');
|
||||
session.deleteConfig('queue.overflow');
|
||||
session.deleteConfig('queue.debounce_ms');
|
||||
session.deleteConfig('queue.summarize_overflow');
|
||||
setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: 'Reset session queue overrides.' })]);
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = (command.args ?? '').trim();
|
||||
const [rawKey, ...rest] = raw.split(/\s+/);
|
||||
const value = rest.join(' ').trim();
|
||||
if (!rawKey || !value) {
|
||||
setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: 'Usage: /queue set <mode|cap|overflow|debounce_ms|summarize_overflow> <value>' })]);
|
||||
return;
|
||||
}
|
||||
|
||||
const key = rawKey.toLowerCase();
|
||||
if (key === 'mode') {
|
||||
if (!['collect', 'followup', 'steer', 'steer_backlog', 'interrupt'].includes(value)) {
|
||||
setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: 'Invalid mode. Use: collect, followup, steer, steer_backlog, interrupt' })]);
|
||||
return;
|
||||
}
|
||||
session.setConfig('queue.mode', value);
|
||||
setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: `Set queue.mode=${value}` })]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'cap') {
|
||||
const cap = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(cap) || cap < 1 || cap > 1000) {
|
||||
setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: 'Invalid cap. Use an integer between 1 and 1000.' })]);
|
||||
return;
|
||||
}
|
||||
session.setConfig('queue.cap', String(cap));
|
||||
setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: `Set queue.cap=${cap}` })]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'overflow') {
|
||||
if (value !== 'drop_old' && value !== 'drop_new') {
|
||||
setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: 'Invalid overflow. Use drop_old or drop_new.' })]);
|
||||
return;
|
||||
}
|
||||
session.setConfig('queue.overflow', value);
|
||||
setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: `Set queue.overflow=${value}` })]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'debounce_ms') {
|
||||
const debounceMs = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(debounceMs) || debounceMs < 0 || debounceMs > 60_000) {
|
||||
setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: 'Invalid debounce_ms. Use an integer between 0 and 60000.' })]);
|
||||
return;
|
||||
}
|
||||
session.setConfig('queue.debounce_ms', String(debounceMs));
|
||||
setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: `Set queue.debounce_ms=${debounceMs}` })]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'summarize_overflow') {
|
||||
const normalized = value.toLowerCase();
|
||||
if (normalized !== 'true' && normalized !== 'false') {
|
||||
setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: 'Invalid summarize_overflow. Use true or false.' })]);
|
||||
return;
|
||||
}
|
||||
session.setConfig('queue.summarize_overflow', normalized);
|
||||
setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: `Set queue.summarize_overflow=${normalized}` })]);
|
||||
return;
|
||||
}
|
||||
|
||||
setMessages(prev => [...prev, session.addMessage({ role: 'assistant', content: 'Unknown queue key. Use mode, cap, overflow, debounce_ms, summarize_overflow.' })]);
|
||||
return;
|
||||
}
|
||||
|
||||
case 'message':
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -338,6 +338,10 @@ export class MinimalTui {
|
||||
this.handlePairCommand(command.action, command.args);
|
||||
break;
|
||||
|
||||
case 'queue':
|
||||
this.handleQueueCommand(command.action, command.args);
|
||||
break;
|
||||
|
||||
case 'transfer':
|
||||
this.config.onTransfer?.(command.target);
|
||||
break;
|
||||
@@ -348,6 +352,105 @@ export class MinimalTui {
|
||||
}
|
||||
}
|
||||
|
||||
private handleQueueCommand(action?: 'show' | 'set' | 'reset', args?: string): void {
|
||||
if (!action || action === 'show') {
|
||||
const mode = this.config.session.getConfig('queue.mode') ?? 'collect';
|
||||
const cap = this.config.session.getConfig('queue.cap') ?? '50';
|
||||
const overflow = this.config.session.getConfig('queue.overflow') ?? 'drop_old';
|
||||
const debounceMs = this.config.session.getConfig('queue.debounce_ms') ?? '0';
|
||||
const summarizeOverflow = this.config.session.getConfig('queue.summarize_overflow') ?? 'true';
|
||||
const hasSessionOverride = Boolean(
|
||||
this.config.session.getConfig('queue.mode')
|
||||
|| this.config.session.getConfig('queue.cap')
|
||||
|| this.config.session.getConfig('queue.overflow')
|
||||
|| this.config.session.getConfig('queue.debounce_ms')
|
||||
|| this.config.session.getConfig('queue.summarize_overflow'),
|
||||
);
|
||||
console.log(`${colors.gray}Queue policy:${colors.reset}`);
|
||||
console.log(` mode: ${mode}`);
|
||||
console.log(` cap: ${cap}`);
|
||||
console.log(` overflow: ${overflow}`);
|
||||
console.log(` debounce_ms: ${debounceMs}`);
|
||||
console.log(` summarize_overflow: ${summarizeOverflow}`);
|
||||
console.log(` source: ${hasSessionOverride ? 'session override' : 'defaults'}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'reset') {
|
||||
this.config.session.deleteConfig('queue.mode');
|
||||
this.config.session.deleteConfig('queue.cap');
|
||||
this.config.session.deleteConfig('queue.overflow');
|
||||
this.config.session.deleteConfig('queue.debounce_ms');
|
||||
this.config.session.deleteConfig('queue.summarize_overflow');
|
||||
console.log(`${colors.gray}Reset session queue overrides.${colors.reset}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = (args ?? '').trim();
|
||||
const [rawKey, ...rest] = raw.split(/\s+/);
|
||||
const value = rest.join(' ').trim();
|
||||
if (!rawKey || !value) {
|
||||
console.log(`${colors.gray}Usage: /queue set <mode|cap|overflow|debounce_ms|summarize_overflow> <value>${colors.reset}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
const key = rawKey.toLowerCase();
|
||||
if (key === 'mode') {
|
||||
if (!['collect', 'followup', 'steer', 'steer_backlog', 'interrupt'].includes(value)) {
|
||||
console.log(`${colors.gray}Invalid mode. Use: collect, followup, steer, steer_backlog, interrupt${colors.reset}\n`);
|
||||
return;
|
||||
}
|
||||
this.config.session.setConfig('queue.mode', value);
|
||||
console.log(`${colors.gray}Set queue.mode=${value}.${colors.reset}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'cap') {
|
||||
const cap = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(cap) || cap < 1 || cap > 1000) {
|
||||
console.log(`${colors.gray}Invalid cap. Use an integer between 1 and 1000.${colors.reset}\n`);
|
||||
return;
|
||||
}
|
||||
this.config.session.setConfig('queue.cap', String(cap));
|
||||
console.log(`${colors.gray}Set queue.cap=${cap}.${colors.reset}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'overflow') {
|
||||
if (value !== 'drop_old' && value !== 'drop_new') {
|
||||
console.log(`${colors.gray}Invalid overflow. Use drop_old or drop_new.${colors.reset}\n`);
|
||||
return;
|
||||
}
|
||||
this.config.session.setConfig('queue.overflow', value);
|
||||
console.log(`${colors.gray}Set queue.overflow=${value}.${colors.reset}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'debounce_ms') {
|
||||
const debounceMs = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(debounceMs) || debounceMs < 0 || debounceMs > 60_000) {
|
||||
console.log(`${colors.gray}Invalid debounce_ms. Use an integer between 0 and 60000.${colors.reset}\n`);
|
||||
return;
|
||||
}
|
||||
this.config.session.setConfig('queue.debounce_ms', String(debounceMs));
|
||||
console.log(`${colors.gray}Set queue.debounce_ms=${debounceMs}.${colors.reset}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'summarize_overflow') {
|
||||
const normalized = value.toLowerCase();
|
||||
if (normalized !== 'true' && normalized !== 'false') {
|
||||
console.log(`${colors.gray}Invalid summarize_overflow. Use true or false.${colors.reset}\n`);
|
||||
return;
|
||||
}
|
||||
this.config.session.setConfig('queue.summarize_overflow', normalized);
|
||||
console.log(`${colors.gray}Set queue.summarize_overflow=${normalized}.${colors.reset}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`${colors.gray}Unknown queue key. Use mode, cap, overflow, debounce_ms, summarize_overflow.${colors.reset}\n`);
|
||||
}
|
||||
|
||||
private handleModelCommand(name?: string, providerModel?: string): void {
|
||||
const router = this.config.modelRouter;
|
||||
if (!router) {
|
||||
|
||||
Reference in New Issue
Block a user