From 2f1c302d854cdd375705b8622092159180ab408f Mon Sep 17 00:00:00 2001 From: William Valentin Date: Thu, 5 Feb 2026 00:34:25 -0800 Subject: [PATCH] feat: add SessionManager for multi-frontend session handling --- src/session/index.ts | 1 + src/session/manager.test.ts | 61 ++++++++++++++++++++++++++ src/session/manager.ts | 85 +++++++++++++++++++++++++++++++++++++ 3 files changed, 147 insertions(+) create mode 100644 src/session/manager.test.ts create mode 100644 src/session/manager.ts diff --git a/src/session/index.ts b/src/session/index.ts index 2038ffd..c836905 100644 --- a/src/session/index.ts +++ b/src/session/index.ts @@ -1 +1,2 @@ export { SessionStore } from './store.js'; +export { SessionManager, ManagedSession, type Session } from './manager.js'; diff --git a/src/session/manager.test.ts b/src/session/manager.test.ts new file mode 100644 index 0000000..f0296ea --- /dev/null +++ b/src/session/manager.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { SessionManager } from './manager.js'; +import { SessionStore } from './store.js'; +import { unlinkSync, existsSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; + +describe('SessionManager', () => { + const dbPath = join(tmpdir(), 'flynn-test-manager.db'); + let store: SessionStore; + let manager: SessionManager; + + beforeEach(() => { + store = new SessionStore(dbPath); + manager = new SessionManager(store); + }); + + afterEach(() => { + store.close(); + if (existsSync(dbPath)) { + unlinkSync(dbPath); + } + }); + + it('creates sessions for different frontends', () => { + const telegramSession = manager.getSession('telegram', 'user-123'); + const tuiSession = manager.getSession('tui', 'local'); + + expect(telegramSession.id).toBe('telegram:user-123'); + expect(tuiSession.id).toBe('tui:local'); + }); + + it('returns same session for same frontend and user', () => { + const session1 = manager.getSession('telegram', 'user-123'); + const session2 = manager.getSession('telegram', 'user-123'); + + expect(session1).toBe(session2); + }); + + it('transfers session history between frontends', () => { + const telegramSession = manager.getSession('telegram', 'user-123'); + telegramSession.addMessage({ role: 'user', content: 'Hello from Telegram' }); + telegramSession.addMessage({ role: 'assistant', content: 'Hi!' }); + + const tuiSession = manager.getSession('tui', 'local'); + manager.transferSession('telegram', 'user-123', 'tui', 'local'); + + const tuiMessages = tuiSession.getHistory(); + expect(tuiMessages).toHaveLength(2); + expect(tuiMessages[0].content).toBe('Hello from Telegram'); + }); + + it('lists active sessions', () => { + manager.getSession('telegram', 'user-123'); + manager.getSession('tui', 'local'); + + const sessions = manager.listSessions(); + expect(sessions).toContain('telegram:user-123'); + expect(sessions).toContain('tui:local'); + }); +}); diff --git a/src/session/manager.ts b/src/session/manager.ts new file mode 100644 index 0000000..e50d161 --- /dev/null +++ b/src/session/manager.ts @@ -0,0 +1,85 @@ +import type { Message } from '../models/types.js'; +import type { SessionStore } from './store.js'; + +export interface Session { + id: string; + addMessage(message: Message): void; + getHistory(): Message[]; + clear(): void; +} + +export class ManagedSession implements Session { + constructor( + public readonly id: string, + private store: SessionStore, + private history: Message[] = [] + ) {} + + addMessage(message: Message): void { + this.history.push(message); + this.store.addMessage(this.id, message); + } + + getHistory(): Message[] { + return [...this.history]; + } + + clear(): void { + this.history = []; + this.store.clearSession(this.id); + } + + setHistory(messages: Message[]): void { + this.history = [...messages]; + } +} + +export class SessionManager { + private sessions: Map = new Map(); + + constructor(private store: SessionStore) {} + + private makeSessionId(frontend: string, userId: string): string { + return `${frontend}:${userId}`; + } + + getSession(frontend: string, userId: string): ManagedSession { + const id = this.makeSessionId(frontend, userId); + + let session = this.sessions.get(id); + if (!session) { + const history = this.store.getMessages(id); + session = new ManagedSession(id, this.store, history); + this.sessions.set(id, session); + } + + return session; + } + + transferSession( + fromFrontend: string, + fromUserId: string, + toFrontend: string, + toUserId: string + ): void { + const fromSession = this.getSession(fromFrontend, fromUserId); + const toSession = this.getSession(toFrontend, toUserId); + + const history = fromSession.getHistory(); + + // Clear target and copy history + toSession.clear(); + for (const message of history) { + toSession.addMessage(message); + } + } + + listSessions(): string[] { + return Array.from(this.sessions.keys()); + } + + closeSession(frontend: string, userId: string): void { + const id = this.makeSessionId(frontend, userId); + this.sessions.delete(id); + } +}