feat(chat): add /stop cancellation command across gateway and telegram

This commit is contained in:
William Valentin
2026-02-19 09:52:57 -08:00
parent 027f7ad283
commit cd74b1e78a
9 changed files with 203 additions and 11 deletions
+42 -2
View File
@@ -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 () => {
+22 -4
View File
@@ -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();
+29 -1
View File
@@ -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();
+18
View File
@@ -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());
+1
View File
@@ -8,6 +8,7 @@ export {
createModelCommand,
createCompactCommand,
createResetCommand,
createStopCommand,
createQueueCommand,
createTransferCommand,
createApprovalsCommand,
+1
View File
@@ -34,6 +34,7 @@ export interface CommandServices {
getQueue?: () => Promise<string> | string;
setQueue?: (input: string) => Promise<string> | string;
resetQueue?: () => Promise<string> | string;
cancelRun?: () => Promise<string> | string;
transferSession?: (target: string) => Promise<string> | string;
getApprovals?: () => Promise<string> | string;
approvePending?: (input: string) => Promise<string> | string;
+21
View File
@@ -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));
+6
View File
@@ -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.';
+63 -4
View File
@@ -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 = '<svg class="w-3.5 h-3.5 fill-current shrink-0" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M8.5 3a5.5 5.5 0 0 1 4.38 8.82l4.15 4.15a.75.75 0 0 1-1.06 1.06l-4.15-4.15A5.5 5.5 0 1 1 8.5 3zm0 1.5a4 4 0 1 0 0 8 4 4 0 0 0 0-8z" fill="currentColor"/></svg>';
@@ -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 = '<div class="text-center py-12 px-6 text-zinc-500 text-sm">Select a session or create a new one to start chatting</div>';
}
updateSendButton();
},
teardown() {
_currentSession = null;
_sending = false;
_cancelling = false;
_searchMode = false;
_slashPopupIndex = -1;
_sessionSort = 'recent';