feat: make /transfer bidirectional across telegram and tui
This commit is contained in:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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.' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -9,5 +9,6 @@ export {
|
||||
createCompactCommand,
|
||||
createResetCommand,
|
||||
createQueueCommand,
|
||||
createTransferCommand,
|
||||
registerBuiltinCommands,
|
||||
} from './builtin/index.js';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}`;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user