feat(chat): add /stop cancellation command across gateway and telegram
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -8,6 +8,7 @@ export {
|
||||
createModelCommand,
|
||||
createCompactCommand,
|
||||
createResetCommand,
|
||||
createStopCommand,
|
||||
createQueueCommand,
|
||||
createTransferCommand,
|
||||
createApprovalsCommand,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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.';
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user