feat: make /transfer bidirectional across telegram and tui

This commit is contained in:
William Valentin
2026-02-18 07:55:08 -08:00
parent d48adbe0b0
commit 16af5e75fd
13 changed files with 262 additions and 17 deletions
+2 -2
View File
@@ -486,7 +486,7 @@ pnpm tui:fs
| `/verbose` | Toggle verbose output mode |
| `/pair` | Generate/list/revoke DM pairing codes |
| `/fullscreen` | Switch to fullscreen mode |
| `/transfer <dest>` | Transfer session to another frontend |
| `/transfer <dest>` | Transfer session to another frontend (`telegram` or `tui`) |
| `/quit` | Exit |
#### Runtime Model Switching
@@ -1476,7 +1476,7 @@ Exit code is `1` if any check fails, `0` otherwise. Checks that depend on a vali
- Sessions persist in `~/.local/share/flynn/sessions.db`
- Session ID format: `{frontend}:{userId}` (e.g., `telegram:123456789`)
- History survives restarts
- Transfer sessions between frontends with `/transfer`
- Transfer sessions between frontends with `/transfer <telegram|tui>`
## Architecture
+24 -2
View File
@@ -5107,10 +5107,32 @@
"docs/plans/state.json"
],
"test_status": "pnpm test:run src/backends/native/agent.test.ts passing"
},
"bidirectional-frontend-transfer-command": {
"status": "completed",
"date": "2026-02-18",
"updated": "2026-02-18",
"summary": "Promoted `/transfer` to a first-class command-registry command and implemented bidirectional `telegram <-> tui` session transfer semantics across channel and TUI paths. Added Telegram `/transfer` command ingestion (including `/transfer@bot ...` parsing), transfer fast-path handling in daemon routing, improved TUI transfer usage/no-op messaging, and expanded regression coverage.",
"files_modified": [
"src/commands/types.ts",
"src/commands/index.ts",
"src/commands/builtin/index.ts",
"src/commands/builtin/index.test.ts",
"src/daemon/routing.ts",
"src/daemon/routing.test.ts",
"src/channels/telegram/adapter.ts",
"src/channels/telegram/adapter.test.ts",
"src/frontends/tui/commands.ts",
"src/frontends/tui/commands.test.ts",
"src/cli/tui.ts",
"README.md",
"docs/plans/state.json"
],
"test_status": "pnpm test:run src/commands/builtin/index.test.ts src/daemon/routing.test.ts src/channels/telegram/adapter.test.ts src/frontends/tui/commands.test.ts passing + pnpm typecheck passing"
}
},
"overall_progress": {
"total_test_count": 1895,
"total_test_count": 1900,
"all_tests_passing": true,
"p0_completion": "3/3 (100%)",
"p1_completion": "4/4 (100%)",
@@ -5130,7 +5152,7 @@
"gmail_auth_cli": "flynn gmail-auth command implemented with OAuth2 flow, doctor check, config routed to Telegram",
"native_audio_support": "completed — smart routing for native audio (Gemini/OpenAI/GitHub) vs Whisper transcription fallback",
"remaining_phases_completion": "Phase 1: 3/3 (100%) — context levels, command registry, memory structure. Phase 2: 3/3 (100%) — component registry, confidence routing, history index. Phase 3: 2/2 (100%) — adaptive memory/compaction, truthfulness/autonomy hardening",
"next_up": "Monitor production feedback for the expanded sessions operator surface and prioritize next post-parity slice from reliability and capability roadmap"
"next_up": "Monitor production feedback for bidirectional session-transfer command behavior across Telegram/TUI and prioritize the next post-parity reliability/capability slice"
},
"soul_md_and_cron_create": {
"date": "2026-02-11",
+31 -2
View File
@@ -78,13 +78,14 @@ describe('TelegramAdapter', () => {
// .use() for auth middleware
expect(mockUse).toHaveBeenCalledTimes(1);
// .command() for /start, /reset, /model, /local, /cloud
expect(mockCommand).toHaveBeenCalledTimes(5);
// .command() for /start, /reset, /model, /local, /cloud, /transfer
expect(mockCommand).toHaveBeenCalledTimes(6);
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');
// .on('message:text', ...) for text handler
expect(mockOn).toHaveBeenCalledWith('message:text', expect.any(Function));
// .start() to begin long polling
@@ -269,6 +270,34 @@ describe('TelegramAdapter', () => {
});
});
it('/transfer command strips @bot suffix in groups', async () => {
const handler = vi.fn();
adapter.onMessage(handler);
await adapter.connect();
const transferCall = mockCommand.mock.calls.find((call) => call[0] === 'transfer');
expect(transferCall).toBeDefined();
const transferHandler = getCommandHandler('transfer');
const ctx = {
message: { message_id: 124, text: '/transfer@flynn_bot tui' },
chat: { id: 100 },
from: { first_name: 'Will' },
};
await transferHandler(ctx);
expect(handler).toHaveBeenCalledTimes(1);
const msg: InboundMessage = handler.mock.calls[0][0];
expect(msg.text).toBe('/transfer tui');
expect(msg.metadata).toEqual({
isCommand: true,
command: 'transfer',
commandArgs: 'tui',
});
});
// ── Auth middleware ───────────────────────────────────────────
it('auth middleware blocks unauthorized chat IDs', async () => {
+22
View File
@@ -210,6 +210,28 @@ export class TelegramAdapter implements ChannelAdapter {
});
});
this.bot.command('transfer', async (ctx) => {
if (!this.messageHandler) {return;}
// Telegram can deliver group commands in the form: /transfer@bot_username ...
// Strip optional @mention for consistent parsing across contexts.
const args = ctx.message?.text?.replace(/^\/transfer(?:@\S+)?\s*/i, '').trim() ?? '';
this.messageHandler({
id: String(ctx.message?.message_id ?? Date.now()),
channel: 'telegram',
senderId: String(ctx.chat.id),
senderName: ctx.from?.first_name,
text: `/transfer ${args}`.trim(),
timestamp: Date.now(),
metadata: {
isCommand: true,
command: 'transfer',
commandArgs: args || undefined,
},
});
});
// ── Text message handler ──
this.bot.on('message:text', async (ctx) => {
+17 -7
View File
@@ -238,15 +238,25 @@ export function registerTuiCommand(program: Command): void {
});
const transferSessionToTarget = (target: string): string => {
if (target !== 'telegram') {
return `Unknown transfer target: ${target}`;
const normalizedTarget = target.trim().toLowerCase();
if (!normalizedTarget) {
return 'Usage: /transfer <tui|telegram>';
}
if (!config.telegram || config.telegram.allowed_chat_ids.length === 0) {
return 'Telegram not configured';
if (normalizedTarget === 'tui') {
return 'Session is already active on TUI (local)';
}
const telegramUserId = String(config.telegram.allowed_chat_ids[0]);
sessionManager.transferSession('tui', 'local', 'telegram', telegramUserId);
return `Session transferred to Telegram (${telegramUserId})`;
if (normalizedTarget === 'telegram') {
if (!config.telegram || config.telegram.allowed_chat_ids.length === 0) {
return 'Telegram not configured';
}
const telegramUserId = String(config.telegram.allowed_chat_ids[0]);
sessionManager.transferSession('tui', 'local', 'telegram', telegramUserId);
return `Session transferred to Telegram (${telegramUserId})`;
}
return `Unknown transfer target: ${target}. Supported targets: tui, telegram`;
};
if (opts.fullscreen) {
+29 -1
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, vi } from 'vitest';
import { createContextCommand, createElevateCommand, createModelCommand, createQueueCommand, createResearchCommand } from './index.js';
import { createContextCommand, createElevateCommand, createModelCommand, createQueueCommand, createResearchCommand, createTransferCommand } from './index.js';
describe('builtin /model command', () => {
it('passes through the full argument string', async () => {
@@ -169,3 +169,31 @@ describe('builtin /context command', () => {
expect(result).toEqual({ handled: true, text: 'Context command is not available in this session.' });
});
});
describe('builtin /transfer command', () => {
it('passes through the full target argument string', async () => {
const cmd = createTransferCommand();
const transferSession = vi.fn(() => 'Session transferred');
const result = await cmd.execute(['telegram'], {
channel: 'test',
senderId: 'user',
sessionId: 's1',
rawInput: '/transfer telegram',
services: { transferSession },
});
expect(transferSession).toHaveBeenCalledWith('telegram');
expect(result).toEqual({ handled: true, text: 'Session transferred' });
});
it('returns not-available when service is missing', async () => {
const cmd = createTransferCommand();
const result = await cmd.execute(['tui'], {
channel: 'test',
senderId: 'user',
sessionId: 's1',
rawInput: '/transfer tui',
services: {},
});
expect(result).toEqual({ handled: true, text: 'Transfer command is not available in this session.' });
});
});
+19
View File
@@ -218,6 +218,24 @@ export function createResearchCommand(): CommandDefinition {
};
}
export function createTransferCommand(): CommandDefinition {
return {
name: 'transfer',
description: 'Transfer session to another frontend',
execute: async (args, ctx) => {
if (!ctx.services?.transferSession) {
return notAvailable('Transfer command');
}
const target = args.join(' ').trim();
return {
handled: true,
text: await ctx.services.transferSession(target),
};
},
};
}
export function registerBuiltinCommands(registry: CommandRegistry): void {
registry.register(createHelpCommand(registry));
registry.register(createStatusCommand());
@@ -229,4 +247,5 @@ export function registerBuiltinCommands(registry: CommandRegistry): void {
registry.register(createResetCommand());
registry.register(createElevateCommand());
registry.register(createQueueCommand());
registry.register(createTransferCommand());
}
+1
View File
@@ -9,5 +9,6 @@ export {
createCompactCommand,
createResetCommand,
createQueueCommand,
createTransferCommand,
registerBuiltinCommands,
} from './builtin/index.js';
+1
View File
@@ -34,4 +34,5 @@ export interface CommandServices {
getQueue?: () => Promise<string> | string;
setQueue?: (input: string) => Promise<string> | string;
resetQueue?: () => Promise<string> | string;
transferSession?: (target: string) => Promise<string> | string;
}
+70
View File
@@ -213,6 +213,76 @@ describe('daemon command fast-path integration', () => {
expect(session.deleteConfig).toHaveBeenCalledWith('modelTier');
});
it('handles /transfer command via command fast-path and copies session to TUI', async () => {
const processSpy = vi.spyOn(AgentOrchestrator.prototype, 'process');
const transferSpy = vi.fn();
const session = {
id: 'telegram:user-1',
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),
transferSession: transferSpy,
} 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,
},
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: 'm-transfer',
channel: 'telegram',
senderId: 'user-1',
text: '/transfer tui',
timestamp: Date.now(),
metadata: { isCommand: true, command: 'transfer', commandArgs: 'tui' },
} as MessageRouterInput, reply);
expect(processSpy).not.toHaveBeenCalled();
expect(transferSpy).toHaveBeenCalledWith('telegram', 'user-1', 'tui', 'local');
expect(reply).toHaveBeenCalledWith({
text: 'Session transferred to TUI (local)',
replyTo: 'm-transfer',
});
});
it('emits user.action audit events for channel messages', async () => {
const mockAuditLogger = {
userAction: vi.fn(),
+40 -1
View File
@@ -274,7 +274,7 @@ export function createMessageRouter(deps: {
tier: effectiveTier,
autonomyLevel: deps.config.agents.autonomy_level ?? 'standard',
sensitiveMode: deps.config.agents.sensitive_mode,
immutableDenylist: deps.config.agents.immutable_denylist.map((rule) => ({
immutableDenylist: (deps.config.agents.immutable_denylist ?? []).map((rule) => ({
tool: rule.tool,
argsPattern: rule.args_pattern,
reason: rule.reason,
@@ -838,6 +838,45 @@ export function createMessageRouter(deps: {
session.deleteConfig('queue.summarize_overflow');
return 'Reset session queue overrides.';
},
transferSession: (targetRaw: string) => {
const target = targetRaw.trim().toLowerCase();
if (!target) {
return 'Usage: /transfer <tui|telegram>';
}
let toFrontend: string;
let toUserId: string;
let destinationLabel: string;
if (target === 'tui') {
toFrontend = 'tui';
toUserId = 'local';
destinationLabel = 'TUI (local)';
} else if (target === 'telegram') {
if (msg.channel === 'telegram') {
toFrontend = 'telegram';
toUserId = msg.senderId;
} else {
const chatId = deps.config.telegram?.allowed_chat_ids?.[0];
if (chatId === undefined) {
return 'Telegram not configured';
}
toFrontend = 'telegram';
toUserId = String(chatId);
}
destinationLabel = `Telegram (${toUserId})`;
} else {
return `Unknown transfer target: ${target}. Supported targets: tui, telegram`;
}
if (msg.channel === toFrontend && msg.senderId === toUserId) {
return `Session is already active on ${destinationLabel}`;
}
deps.sessionManager.transferSession(msg.channel, msg.senderId, toFrontend, toUserId);
return `Session transferred to ${destinationLabel}`;
},
},
});
+1
View File
@@ -89,6 +89,7 @@ describe('parseCommand', () => {
it('parses /transfer command', () => {
expect(parseCommand('/transfer telegram')).toEqual({ type: 'transfer', target: 'telegram' });
expect(parseCommand('/transfer')).toEqual({ type: 'transfer', target: '' });
});
it('parses /queue commands', () => {
+5 -2
View File
@@ -94,6 +94,9 @@ export function parseCommand(input: string): Command | null {
}
// Transfer
if (trimmed === '/transfer') {
return { type: 'transfer', target: '' };
}
if (trimmed.startsWith('/transfer ')) {
const target = trimmed.slice('/transfer '.length).trim();
return { type: 'transfer', target };
@@ -172,7 +175,7 @@ Commands:
/verbose Toggle verbose mode (show raw streaming and tool output)
/status Show session info and token usage
/fullscreen, /fs Switch to fullscreen mode
/transfer <dest> Transfer session to another frontend
/transfer <dest> Transfer session to another frontend (telegram|tui)
/quit, /exit Exit TUI
`.trim();
}
@@ -224,7 +227,7 @@ export const COMMAND_TOOLTIPS: Record<string, string> = {
'/pair': 'Generate/list/revoke DM pairing codes',
'/queue': 'Show or update per-session queue policy',
'/elevate': 'Show or manage elevated mode',
'/transfer': 'Transfer session to another frontend',
'/transfer': 'Transfer session to another frontend (telegram|tui)',
'/quit': 'Exit TUI',
'/exit': 'Exit TUI',
};