diff --git a/src/channels/telegram/adapter.test.ts b/src/channels/telegram/adapter.test.ts index 0e8c4c0..be15b83 100644 --- a/src/channels/telegram/adapter.test.ts +++ b/src/channels/telegram/adapter.test.ts @@ -81,14 +81,16 @@ describe('TelegramAdapter', () => { // .use() for auth middleware expect(mockUse).toHaveBeenCalledTimes(1); expect(mockCatch).toHaveBeenCalledTimes(1); - // .command() for /start, /reset, /model, /local, /cloud, /transfer - expect(mockCommand).toHaveBeenCalledTimes(6); + // .command() for /start, /reset, /model, /local, /cloud, /transfer, /stop, /cancel + expect(mockCommand).toHaveBeenCalledTimes(8); expect(mockCommand.mock.calls[0][0]).toBe('start'); expect(mockCommand.mock.calls[1][0]).toBe('reset'); expect(mockCommand.mock.calls[2][0]).toBe('model'); expect(mockCommand.mock.calls[3][0]).toBe('local'); expect(mockCommand.mock.calls[4][0]).toBe('cloud'); expect(mockCommand.mock.calls[5][0]).toBe('transfer'); + expect(mockCommand.mock.calls[6][0]).toBe('stop'); + expect(mockCommand.mock.calls[7][0]).toBe('cancel'); // .on('message:text', ...) for text handler expect(mockOn).toHaveBeenCalledWith('message:text', expect.any(Function)); // .start() to begin long polling @@ -301,6 +303,44 @@ describe('TelegramAdapter', () => { }); }); + it('/stop command forwards a stop command message', async () => { + const handler = vi.fn(); + adapter.onMessage(handler); + await adapter.connect(); + + const stopHandler = getCommandHandler('stop'); + const ctx = { + message: { message_id: 200 }, + chat: { id: 100 }, + from: { first_name: 'Will' }, + }; + await stopHandler(ctx); + + expect(handler).toHaveBeenCalledTimes(1); + const msg: InboundMessage = handler.mock.calls[0][0]; + expect(msg.text).toBe('/stop'); + expect(msg.metadata).toEqual({ isCommand: true, command: 'stop' }); + }); + + it('/cancel command forwards a cancel command message', async () => { + const handler = vi.fn(); + adapter.onMessage(handler); + await adapter.connect(); + + const cancelHandler = getCommandHandler('cancel'); + const ctx = { + message: { message_id: 201 }, + chat: { id: 100 }, + from: { first_name: 'Will' }, + }; + await cancelHandler(ctx); + + expect(handler).toHaveBeenCalledTimes(1); + const msg: InboundMessage = handler.mock.calls[0][0]; + expect(msg.text).toBe('/stop'); + expect(msg.metadata).toEqual({ isCommand: true, command: 'cancel' }); + }); + // ── Auth middleware ─────────────────────────────────────────── it('auth middleware blocks unauthorized chat IDs', async () => { diff --git a/src/channels/telegram/adapter.ts b/src/channels/telegram/adapter.ts index 02c8ee7..5088386 100644 --- a/src/channels/telegram/adapter.ts +++ b/src/channels/telegram/adapter.ts @@ -261,6 +261,22 @@ export class TelegramAdapter implements ChannelAdapter { }); }); + const handleStopCommand = (command: 'stop' | 'cancel') => async (ctx: { message?: { message_id?: number }; chat: { id: number }; from?: { first_name?: string } }) => { + if (!this.messageHandler) {return;} + this.messageHandler({ + id: String(ctx.message?.message_id ?? Date.now()), + channel: 'telegram', + senderId: String(ctx.chat.id), + senderName: ctx.from?.first_name, + text: '/stop', + timestamp: Date.now(), + metadata: { isCommand: true, command }, + }); + }; + + bot.command('stop', handleStopCommand('stop')); + bot.command('cancel', handleStopCommand('cancel')); + // ── Text message handler ── bot.on('message:text', async (ctx) => { @@ -465,7 +481,7 @@ export class TelegramAdapter implements ChannelAdapter { // bot.start() is a long-running method that resolves only when the bot stops. // Do NOT await it — fire-and-forget so connect() resolves immediately. - bot.start({ + const startResult = bot.start({ onStart: (botInfo) => { console.log(`Telegram bot started: @${botInfo.username}`); this.botInfo = { id: botInfo.id, username: botInfo.username }; @@ -474,7 +490,8 @@ export class TelegramAdapter implements ChannelAdapter { this._lastError = undefined; this._lastErrorAt = undefined; }, - }).catch((error) => { + }); + Promise.resolve(startResult).catch((error) => { const description = error instanceof Error ? error.message : String(error); this.recordAdapterError(`Telegram connect failed: ${description}`); this.scheduleReconnect(); @@ -633,7 +650,7 @@ export class TelegramAdapter implements ChannelAdapter { this._status = 'connecting'; await bot.stop(); - bot.start({ + const startResult = bot.start({ onStart: (botInfo) => { console.log(`Telegram bot reconnected: @${botInfo.username}`); this.botInfo = { id: botInfo.id, username: botInfo.username }; @@ -642,7 +659,8 @@ export class TelegramAdapter implements ChannelAdapter { this._lastError = undefined; this._lastErrorAt = undefined; }, - }).catch((error) => { + }); + Promise.resolve(startResult).catch((error) => { const description = error instanceof Error ? error.message : String(error); this.recordAdapterError(`Telegram reconnect polling error: ${description}`); this.scheduleReconnect(); diff --git a/src/commands/builtin/index.test.ts b/src/commands/builtin/index.test.ts index 369fde8..5e0407d 100644 --- a/src/commands/builtin/index.test.ts +++ b/src/commands/builtin/index.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; -import { createApproveCommand, createApprovalsCommand, createContextCommand, createDenyCommand, createElevateCommand, createModelCommand, createQueueCommand, createResearchCommand, createSkillCommand, createTransferCommand } from './index.js'; +import { createApproveCommand, createApprovalsCommand, createContextCommand, createDenyCommand, createElevateCommand, createModelCommand, createQueueCommand, createResearchCommand, createSkillCommand, createStopCommand, createTransferCommand } from './index.js'; describe('builtin /model command', () => { it('passes through the full argument string', async () => { @@ -198,6 +198,34 @@ describe('builtin /transfer command', () => { }); }); +describe('builtin /stop command', () => { + it('calls cancelRun service', async () => { + const cmd = createStopCommand(); + const cancelRun = vi.fn(() => 'Cancellation requested.'); + const result = await cmd.execute([], { + channel: 'test', + senderId: 'user', + sessionId: 's1', + rawInput: '/stop', + services: { cancelRun }, + }); + expect(cancelRun).toHaveBeenCalledOnce(); + expect(result).toEqual({ handled: true, text: 'Cancellation requested.' }); + }); + + it('returns not-available when service is missing', async () => { + const cmd = createStopCommand(); + const result = await cmd.execute([], { + channel: 'test', + senderId: 'user', + sessionId: 's1', + rawInput: '/stop', + services: {}, + }); + expect(result).toEqual({ handled: true, text: 'Stop command is not available in this session.' }); + }); +}); + describe('builtin approval commands', () => { it('calls getApprovals for /approvals', async () => { const cmd = createApprovalsCommand(); diff --git a/src/commands/builtin/index.ts b/src/commands/builtin/index.ts index aef39b9..1870b69 100644 --- a/src/commands/builtin/index.ts +++ b/src/commands/builtin/index.ts @@ -144,6 +144,23 @@ export function createResetCommand(): CommandDefinition { }; } +export function createStopCommand(): CommandDefinition { + return { + name: 'stop', + aliases: ['cancel'], + description: 'Stop the current in-flight run', + execute: async (_args, ctx) => { + if (!ctx.services?.cancelRun) { + return notAvailable('Stop command'); + } + return { + handled: true, + text: await ctx.services.cancelRun(), + }; + }, + }; +} + export function createElevateCommand(): CommandDefinition { return { name: 'elevate', @@ -309,6 +326,7 @@ export function registerBuiltinCommands(registry: CommandRegistry): void { registry.register(createModelCommand()); registry.register(createCompactCommand()); registry.register(createResetCommand()); + registry.register(createStopCommand()); registry.register(createElevateCommand()); registry.register(createQueueCommand()); registry.register(createTransferCommand()); diff --git a/src/commands/index.ts b/src/commands/index.ts index 5131e9d..bb89560 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -8,6 +8,7 @@ export { createModelCommand, createCompactCommand, createResetCommand, + createStopCommand, createQueueCommand, createTransferCommand, createApprovalsCommand, diff --git a/src/commands/types.ts b/src/commands/types.ts index 0f88065..dc81509 100644 --- a/src/commands/types.ts +++ b/src/commands/types.ts @@ -34,6 +34,7 @@ export interface CommandServices { getQueue?: () => Promise | string; setQueue?: (input: string) => Promise | string; resetQueue?: () => Promise | string; + cancelRun?: () => Promise | string; transferSession?: (target: string) => Promise | string; getApprovals?: () => Promise | string; approvePending?: (input: string) => Promise | string; diff --git a/src/gateway/handlers/agent.test.ts b/src/gateway/handlers/agent.test.ts index 3274adc..7d5d01c 100644 --- a/src/gateway/handlers/agent.test.ts +++ b/src/gateway/handlers/agent.test.ts @@ -36,6 +36,7 @@ describe('createAgentHandlers command fast-path', () => { setBusy: vi.fn(), setOnToolUse: vi.fn(), isBusy: vi.fn(() => false), + cancel: vi.fn(() => true), }; const sessionManager = { @@ -219,6 +220,26 @@ describe('createAgentHandlers command fast-path', () => { expect(((sent[0] as GatewayEvent).data as { content: string }).content).toContain('Reset default to: anthropic/claude-sonnet-4'); }); + it('handles /stop command via fast-path and requests cancellation', async () => { + const sent: OutboundMessage[] = []; + const send = vi.fn((msg: OutboundMessage) => sent.push(msg)); + const req: GatewayRequest = { + id: 12, + method: 'agent.send', + params: { + message: '/stop', + connectionId: 'conn-1', + metadata: { isCommand: true, command: 'stop' }, + }, + }; + + await handlers['agent.send'](req, send); + + expect(sessionBridge.cancel).toHaveBeenCalledWith('conn-1'); + expect(mockAgent.process).not.toHaveBeenCalled(); + expect(((sent[0] as GatewayEvent).data as { content: string }).content).toContain('Cancellation requested'); + }); + it('falls through to agent.process for unknown commands', async () => { const sent: OutboundMessage[] = []; const send = vi.fn((msg: OutboundMessage) => sent.push(msg)); diff --git a/src/gateway/handlers/agent.ts b/src/gateway/handlers/agent.ts index 2d4f63b..adec783 100644 --- a/src/gateway/handlers/agent.ts +++ b/src/gateway/handlers/agent.ts @@ -294,6 +294,12 @@ export function createAgentHandlers(deps: AgentHandlerDeps) { } return 'Session reset.'; }, + cancelRun: () => { + const cancelled = deps.sessionBridge.cancel(connectionId); + return cancelled + ? 'Cancellation requested. The active operation will stop at the next safe point.' + : 'No active operation to cancel.'; + }, getApprovals: () => { if (!deps.hookEngine) { return 'Approval gates are not enabled in this runtime.'; diff --git a/src/gateway/ui/pages/chat.js b/src/gateway/ui/pages/chat.js index 70d5256..55265d8 100644 --- a/src/gateway/ui/pages/chat.js +++ b/src/gateway/ui/pages/chat.js @@ -10,6 +10,7 @@ import { renderSafeMarkdown } from '../lib/markdown.js'; let _currentSession = null; let _sending = false; +let _cancelling = false; let _searchMode = false; let _slashPopupIndex = -1; let _elements = {}; @@ -25,6 +26,8 @@ const SLASH_COMMANDS = [ { name: '/usage', desc: 'Show token usage' }, { name: '/status', desc: 'Show system health' }, { name: '/model', desc: 'Show current model' }, + { name: '/stop', desc: 'Stop active response' }, + { name: '/cancel', desc: 'Alias for /stop' }, { name: '/approvals', desc: 'List pending guarded actions' }, { name: '/approve', desc: 'Approve latest or by id' }, { name: '/deny', desc: 'Deny latest or by id' }, @@ -462,6 +465,8 @@ function parseSlashCommand(text) { case '/usage': return { type: 'usage' }; case '/status': return { type: 'status' }; case '/model': return { type: 'model', args }; + case '/stop': return { type: 'stop' }; + case '/cancel': return { type: 'cancel' }; case '/approvals': return { type: 'approvals' }; case '/approve': return { type: 'approve', args }; case '/deny': return { type: 'deny', args }; @@ -502,6 +507,8 @@ async function handleSlashCommand(cmd, client) { '| `/usage` | Show token usage stats |', '| `/status` | Show system health |', '| `/model [tier|provider]` | Show or set model tier/provider |', + '| `/stop` | Stop active response |', + '| `/cancel` | Alias for `/stop` |', '| `/approvals` | List pending guarded actions |', '| `/approve [id]` | Approve latest pending or specific id |', '| `/deny [id] [reason]` | Deny latest pending or specific id |', @@ -562,6 +569,16 @@ async function handleSlashCommand(cmd, client) { } return true; } + case 'stop': + case 'cancel': { + try { + const result = await executeAgentSlashCommand(client, 'stop'); + showSystemMessage(result || 'Cancellation requested.'); + } catch (err) { + showSystemMessage(`Failed to stop: ${err.message}`); + } + return true; + } case 'approvals': { try { @@ -680,7 +697,8 @@ async function sendMessage(client, overrideText) { } _sending = true; - _elements.sendBtn.disabled = true; + _cancelling = false; + updateSendButton(); if (!overrideText && input) {input.value = '';} // Apply search mode prefix @@ -750,12 +768,41 @@ async function sendMessage(client, overrideText) { placeholder.replaceWith(errorMessage); } finally { _sending = false; - if (_elements.sendBtn) {_elements.sendBtn.disabled = false;} + _cancelling = false; + updateSendButton(); clearPendingAttachments(); scrollToBottom(); } } +function updateSendButton() { + if (!_elements.sendBtn) { + return; + } + if (_sending) { + _elements.sendBtn.disabled = _cancelling; + _elements.sendBtn.textContent = _cancelling ? 'Stopping...' : 'Stop'; + return; + } + _elements.sendBtn.disabled = false; + _elements.sendBtn.textContent = 'Send'; +} + +async function cancelActiveRun(client) { + if (!_sending || _cancelling) { + return; + } + _cancelling = true; + updateSendButton(); + try { + await client.call('agent.cancel', {}); + } catch (err) { + showSystemMessage(`Cancel failed: ${err.message}`); + _cancelling = false; + updateSendButton(); + } +} + // ── Search SVG Icon ───────────────────────────────────────── const SEARCH_ICON = ''; @@ -884,7 +931,13 @@ export const ChatPage = { }); // Event: send message - _elements.sendBtn.addEventListener('click', () => sendMessage(client)); + _elements.sendBtn.addEventListener('click', () => { + if (_sending) { + void cancelActiveRun(client); + return; + } + void sendMessage(client); + }); // Event: keyboard in textarea _elements.input.addEventListener('keydown', (e) => { @@ -924,7 +977,11 @@ export const ChatPage = { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); hideSlashPopup(); - sendMessage(client); + if (_sending) { + void cancelActiveRun(client); + return; + } + void sendMessage(client); } }); @@ -950,11 +1007,13 @@ export const ChatPage = { if (!_currentSession) { _elements.messages.innerHTML = '
Select a session or create a new one to start chatting
'; } + updateSendButton(); }, teardown() { _currentSession = null; _sending = false; + _cancelling = false; _searchMode = false; _slashPopupIndex = -1; _sessionSort = 'recent';