diff --git a/README.md b/README.md index d9014e7..e23c54a 100644 --- a/README.md +++ b/README.md @@ -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 ` | Transfer session to another frontend | +| `/transfer ` | 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 ` ## Architecture diff --git a/docs/plans/state.json b/docs/plans/state.json index ceefe79..25ac5b4 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -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", diff --git a/src/channels/telegram/adapter.test.ts b/src/channels/telegram/adapter.test.ts index 74d73c5..b967f3d 100644 --- a/src/channels/telegram/adapter.test.ts +++ b/src/channels/telegram/adapter.test.ts @@ -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 () => { diff --git a/src/channels/telegram/adapter.ts b/src/channels/telegram/adapter.ts index f877339..3f2ae9a 100644 --- a/src/channels/telegram/adapter.ts +++ b/src/channels/telegram/adapter.ts @@ -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) => { diff --git a/src/cli/tui.ts b/src/cli/tui.ts index 70fc8f8..5d653da 100644 --- a/src/cli/tui.ts +++ b/src/cli/tui.ts @@ -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 '; } - 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) { diff --git a/src/commands/builtin/index.test.ts b/src/commands/builtin/index.test.ts index f5d4cda..549519d 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 { 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.' }); + }); +}); diff --git a/src/commands/builtin/index.ts b/src/commands/builtin/index.ts index e351e48..7ed8b3d 100644 --- a/src/commands/builtin/index.ts +++ b/src/commands/builtin/index.ts @@ -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()); } diff --git a/src/commands/index.ts b/src/commands/index.ts index ac9979b..d0ad650 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -9,5 +9,6 @@ export { createCompactCommand, createResetCommand, createQueueCommand, + createTransferCommand, registerBuiltinCommands, } from './builtin/index.js'; diff --git a/src/commands/types.ts b/src/commands/types.ts index c506e52..43fdfb4 100644 --- a/src/commands/types.ts +++ b/src/commands/types.ts @@ -34,4 +34,5 @@ export interface CommandServices { getQueue?: () => Promise | string; setQueue?: (input: string) => Promise | string; resetQueue?: () => Promise | string; + transferSession?: (target: string) => Promise | string; } diff --git a/src/daemon/routing.test.ts b/src/daemon/routing.test.ts index 907cb3c..a2d808f 100644 --- a/src/daemon/routing.test.ts +++ b/src/daemon/routing.test.ts @@ -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(), diff --git a/src/daemon/routing.ts b/src/daemon/routing.ts index 3f8c0b0..35d1ee3 100644 --- a/src/daemon/routing.ts +++ b/src/daemon/routing.ts @@ -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 '; + } + + 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}`; + }, }, }); diff --git a/src/frontends/tui/commands.test.ts b/src/frontends/tui/commands.test.ts index 81314a0..6c52c4c 100644 --- a/src/frontends/tui/commands.test.ts +++ b/src/frontends/tui/commands.test.ts @@ -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', () => { diff --git a/src/frontends/tui/commands.ts b/src/frontends/tui/commands.ts index 09e6ade..02ac643 100644 --- a/src/frontends/tui/commands.ts +++ b/src/frontends/tui/commands.ts @@ -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 Transfer session to another frontend + /transfer Transfer session to another frontend (telegram|tui) /quit, /exit Exit TUI `.trim(); } @@ -224,7 +227,7 @@ export const COMMAND_TOOLTIPS: Record = { '/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', };