diff --git a/src/daemon/routing.test.ts b/src/daemon/routing.test.ts index 6aff2a3..fea70ed 100644 --- a/src/daemon/routing.test.ts +++ b/src/daemon/routing.test.ts @@ -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', diff --git a/src/daemon/routing.ts b/src/daemon/routing.ts index cfef711..39605cd 100644 --- a/src/daemon/routing.ts +++ b/src/daemon/routing.ts @@ -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 '; + } + 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.'; + }, }, }); diff --git a/src/frontends/tui/commands.test.ts b/src/frontends/tui/commands.test.ts index 036a52b..9535856 100644 --- a/src/frontends/tui/commands.test.ts +++ b/src/frontends/tui/commands.test.ts @@ -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'); }); }); diff --git a/src/frontends/tui/commands.ts b/src/frontends/tui/commands.ts index 1ee5bc4..22874f2 100644 --- a/src/frontends/tui/commands.ts +++ b/src/frontends/tui/commands.ts @@ -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 Revoke an approved sender + /queue Show queue policy for this session + /queue set 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 = { '/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', diff --git a/src/frontends/tui/components/App.tsx b/src/frontends/tui/components/App.tsx index 394c902..fd672e6 100644 --- a/src/frontends/tui/components/App.tsx +++ b/src/frontends/tui/components/App.tsx @@ -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 ' })]); + 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; } diff --git a/src/frontends/tui/minimal.ts b/src/frontends/tui/minimal.ts index a8c8a7e..4805d05 100644 --- a/src/frontends/tui/minimal.ts +++ b/src/frontends/tui/minimal.ts @@ -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 ${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) {