diff --git a/docs/api/PROTOCOL.md b/docs/api/PROTOCOL.md index c1a3f38..3be99bd 100644 --- a/docs/api/PROTOCOL.md +++ b/docs/api/PROTOCOL.md @@ -417,13 +417,19 @@ Useful for proactive compaction monitoring and operator dashboards. #### `sessions.list` -List all sessions. +List sessions with optional persisted inclusion, frontend filtering, and paging. **Request:** ```json { "id": 3, - "method": "sessions.list" + "method": "sessions.list", + "params": { + "includePersisted": true, + "frontend": "ws", + "limit": 50, + "offset": 0 + } } ``` @@ -435,12 +441,27 @@ List all sessions. "sessions": [ { "id": "telegram:123456", - "createdAt": "2025-02-13T10:00:00Z", - "lastActiveAt": "2025-02-13T12:00:00Z", + "frontend": "telegram", + "userId": "123456", "messageCount": 42, - "connectionCount": 1 + "lastMessageAt": 1739448000000, + "config": { + "modelTier": "fast", + "queue": { + "mode": "followup", + "overflow": "drop_old", + "cap": 8, + "debounceMs": 250, + "summarizeOverflow": true + }, + "elevation": { + "active": false, + "untilMs": 1739451600000 + } + } } - ] + ], + "total": 1 } } ``` diff --git a/docs/plans/state.json b/docs/plans/state.json index adecbda..4b627ab 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -5060,10 +5060,26 @@ "docs/plans/state.json" ], "test_status": "pnpm test:run src/tools/builtin/agent-delegate.test.ts + pnpm typecheck passing" + }, + "post-parity-ops-ux-reliability-capability-sessions-surface": { + "status": "completed", + "date": "2026-02-18", + "updated": "2026-02-18", + "summary": "Delivered a post-parity 3-track slice in one sessions-focused release: operator UX polish (dashboard Sessions page now shows frontend/model/queue/last-activity with frontend filter + inactive toggle), reliability hardening (`sessions.list`/`sessions.history` now validate and bound pagination/filter params), and capability expansion (`sessions.list` now supports persisted inclusion, frontend filtering, total counts, and per-session config snapshot metadata for model/queue/elevation visibility).", + "files_modified": [ + "src/session/manager.ts", + "src/session/manager.test.ts", + "src/gateway/handlers/sessions.ts", + "src/gateway/handlers/handlers.test.ts", + "src/gateway/ui/pages/sessions.js", + "docs/api/PROTOCOL.md", + "docs/plans/state.json" + ], + "test_status": "pnpm test:run src/session/manager.test.ts src/gateway/handlers/handlers.test.ts + pnpm typecheck passing" } }, "overall_progress": { - "total_test_count": 1882, + "total_test_count": 1887, "all_tests_passing": true, "p0_completion": "3/3 (100%)", "p1_completion": "4/4 (100%)", @@ -5083,7 +5099,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": "Define and execute post-parity roadmap slices beyond OpenClaw gap closure (operator UX polish, reliability hardening, and new capability expansion)" + "next_up": "Monitor production feedback for the expanded sessions operator surface and prioritize next post-parity slice from reliability and capability roadmap" }, "soul_md_and_cron_create": { "date": "2026-02-11", diff --git a/src/gateway/handlers/handlers.test.ts b/src/gateway/handlers/handlers.test.ts index dbe946b..cd4adbd 100644 --- a/src/gateway/handlers/handlers.test.ts +++ b/src/gateway/handlers/handlers.test.ts @@ -465,6 +465,7 @@ describe('session handlers', () => { const mockSessionManager = { listSessions: vi.fn(() => ['ws:test']), getSession: vi.fn(() => mockSession), + getSessionConfig: vi.fn((_frontend: string, _userId: string, _key: string) => undefined), transferSession: vi.fn(), closeSession: vi.fn(), }; @@ -480,15 +481,49 @@ describe('session handlers', () => { mockSession.getHistory.mockReturnValue(mockHistory); }); - it('sessions.list returns session list with message counts', async () => { + it('sessions.list returns session list with message counts and metadata', async () => { const req: GatewayRequest = { id: 1, method: 'sessions.list' }; const result = await handlers['sessions.list'](req) as GatewayResponse; expect(result.id).toBe(1); - const r = result.result as { sessions: Array<{ id: string; messageCount: number }> }; + const r = result.result as { sessions: Array<{ id: string; messageCount: number; frontend: string; userId: string }>; total: number }; expect(r.sessions).toHaveLength(1); expect(r.sessions[0].id).toBe('ws:test'); + expect(r.sessions[0].frontend).toBe('ws'); + expect(r.sessions[0].userId).toBe('test'); expect(r.sessions[0].messageCount).toBe(2); + expect(r.total).toBe(1); + }); + + it('sessions.list supports persisted inclusion, frontend filter, and paging', async () => { + mockSessionManager.listSessions.mockReturnValue(['ws:a', 'ws:b', 'telegram:c']); + const req: GatewayRequest = { + id: 10, + method: 'sessions.list', + params: { includePersisted: true, frontend: 'ws', limit: 1, offset: 1 }, + }; + const result = await handlers['sessions.list'](req) as GatewayResponse; + const payload = result.result as { sessions: Array<{ id: string }>; total: number }; + expect(mockSessionManager.listSessions).toHaveBeenCalledWith({ includePersisted: true, frontend: 'ws' }); + expect(payload.total).toBe(3); + expect(payload.sessions).toHaveLength(1); + expect(payload.sessions[0].id).toBe('ws:b'); + }); + + it('sessions.list rejects invalid pagination and filters', async () => { + const badLimit = await handlers['sessions.list']({ + id: 11, + method: 'sessions.list', + params: { limit: -1 }, + }) as GatewayError; + expect(badLimit.error.code).toBe(ErrorCode.InvalidRequest); + + const badFrontend = await handlers['sessions.list']({ + id: 12, + method: 'sessions.list', + params: { frontend: '' }, + }) as GatewayError; + expect(badFrontend.error.code).toBe(ErrorCode.InvalidRequest); }); it('sessions.history returns messages with pagination', async () => { @@ -507,6 +542,15 @@ describe('session handlers', () => { expect(result.error.code).toBe(ErrorCode.InvalidRequest); }); + it('sessions.history rejects invalid pagination values', async () => { + const badOffset = await handlers['sessions.history']({ + id: 13, + method: 'sessions.history', + params: { sessionId: 'ws:test', offset: -1 }, + }) as GatewayError; + expect(badOffset.error.code).toBe(ErrorCode.InvalidRequest); + }); + it('sessions.create creates a new session', async () => { const req: GatewayRequest = { id: 4, method: 'sessions.create', params: { sessionId: 'ws:new' } }; const result = await handlers['sessions.create'](req) as GatewayResponse; diff --git a/src/gateway/handlers/sessions.ts b/src/gateway/handlers/sessions.ts index 2e1b98e..46fca9e 100644 --- a/src/gateway/handlers/sessions.ts +++ b/src/gateway/handlers/sessions.ts @@ -8,18 +8,106 @@ export interface SessionHandlerDeps { sessionBridge?: SessionBridge; } +const MAX_SESSION_LIST_LIMIT = 500; +const MAX_SESSION_HISTORY_LIMIT = 500; + +function parsePositiveInt(value: unknown, field: string, max: number): { value: number } | { error: string } { + if (value === undefined) { + return { value: 0 }; + } + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || !Number.isInteger(value)) { + return { error: `${field} must be a non-negative integer` }; + } + if (value > max) { + return { error: `${field} must be <= ${max}` }; + } + return { value }; +} + export function createSessionHandlers(deps: SessionHandlerDeps) { return { 'sessions.list': async (request: GatewayRequest): Promise => { - const sessionIds = deps.sessionManager.listSessions(); - const sessions = sessionIds.map(id => ({ - id, - messageCount: deps.sessionManager.getSession( - id.split(':')[0], - id.split(':').slice(1).join(':'), - ).getHistory().length, - })); - return makeResponse(request.id, { sessions }); + const params = request.params as { + includePersisted?: boolean; + frontend?: string; + limit?: number; + offset?: number; + } | undefined; + + const parsedLimit = parsePositiveInt(params?.limit, 'limit', MAX_SESSION_LIST_LIMIT); + if ('error' in parsedLimit) { + return makeError(request.id, ErrorCode.InvalidRequest, parsedLimit.error); + } + const parsedOffset = parsePositiveInt(params?.offset, 'offset', Number.MAX_SAFE_INTEGER); + if ('error' in parsedOffset) { + return makeError(request.id, ErrorCode.InvalidRequest, parsedOffset.error); + } + if (params?.frontend !== undefined && (typeof params.frontend !== 'string' || !params.frontend.trim())) { + return makeError(request.id, ErrorCode.InvalidRequest, 'frontend must be a non-empty string'); + } + + const sessionIds = deps.sessionManager.listSessions({ + includePersisted: params?.includePersisted === true, + frontend: params?.frontend, + }); + + const offset = parsedOffset.value; + const end = parsedLimit.value > 0 ? offset + parsedLimit.value : undefined; + const pagedSessionIds = sessionIds.slice(offset, end); + + const sessions = pagedSessionIds.map((id) => { + const parts = id.split(':'); + const frontend = parts[0]; + const userId = parts.slice(1).join(':'); + const session = deps.sessionManager.getSession(frontend, userId); + const history = session.getHistory(); + const lastMessageAt = history.length > 0 ? (history[history.length - 1].timestamp ?? undefined) : undefined; + + const modelTier = deps.sessionManager.getSessionConfig(frontend, userId, 'modelTier'); + const queueMode = deps.sessionManager.getSessionConfig(frontend, userId, 'queue.mode'); + const queueOverflow = deps.sessionManager.getSessionConfig(frontend, userId, 'queue.overflow'); + const queueCapRaw = deps.sessionManager.getSessionConfig(frontend, userId, 'queue.cap'); + const queueDebounceRaw = deps.sessionManager.getSessionConfig(frontend, userId, 'queue.debounce_ms'); + const queueSummarizeOverflowRaw = deps.sessionManager.getSessionConfig(frontend, userId, 'queue.summarize_overflow'); + const elevationUntilRaw = deps.sessionManager.getSessionConfig(frontend, userId, 'elevation.until_ms'); + const elevationReason = deps.sessionManager.getSessionConfig(frontend, userId, 'elevation.reason'); + + const queueCap = queueCapRaw ? Number.parseInt(queueCapRaw, 10) : undefined; + const queueDebounceMs = queueDebounceRaw ? Number.parseInt(queueDebounceRaw, 10) : undefined; + const elevationUntilMs = elevationUntilRaw ? Number.parseInt(elevationUntilRaw, 10) : undefined; + const queueSummarizeOverflow = queueSummarizeOverflowRaw === 'true' + ? true + : queueSummarizeOverflowRaw === 'false' + ? false + : undefined; + + return { + id, + frontend, + userId, + messageCount: history.length, + lastMessageAt, + config: { + modelTier, + queue: { + mode: queueMode, + overflow: queueOverflow, + cap: Number.isFinite(queueCap) ? queueCap : undefined, + debounceMs: Number.isFinite(queueDebounceMs) ? queueDebounceMs : undefined, + summarizeOverflow: queueSummarizeOverflow, + }, + elevation: { + active: Number.isFinite(elevationUntilMs) ? Number(elevationUntilMs) > Date.now() : false, + untilMs: Number.isFinite(elevationUntilMs) ? elevationUntilMs : undefined, + reason: elevationReason || undefined, + }, + }, + }; + }); + return makeResponse(request.id, { + sessions, + total: sessionIds.length, + }); }, 'sessions.history': async (request: GatewayRequest): Promise => { @@ -28,15 +116,24 @@ export function createSessionHandlers(deps: SessionHandlerDeps) { return makeError(request.id, ErrorCode.InvalidRequest, 'sessionId is required'); } - const { sessionId, limit, offset } = params; + const parsedLimit = parsePositiveInt(params.limit, 'limit', MAX_SESSION_HISTORY_LIMIT); + if ('error' in parsedLimit) { + return makeError(request.id, ErrorCode.InvalidRequest, parsedLimit.error); + } + const parsedOffset = parsePositiveInt(params.offset, 'offset', Number.MAX_SAFE_INTEGER); + if ('error' in parsedOffset) { + return makeError(request.id, ErrorCode.InvalidRequest, parsedOffset.error); + } + + const { sessionId } = params; const parts = sessionId.split(':'); const frontend = parts[0]; const userId = parts.slice(1).join(':'); const session = deps.sessionManager.getSession(frontend, userId); const allMessages = session.getHistory(); - const start = offset ?? 0; - const end = limit ? start + limit : allMessages.length; + const start = parsedOffset.value; + const end = parsedLimit.value > 0 ? start + parsedLimit.value : allMessages.length; const messages = allMessages.slice(start, end); return makeResponse(request.id, { diff --git a/src/gateway/ui/pages/sessions.js b/src/gateway/ui/pages/sessions.js index 72f74f2..a002784 100644 --- a/src/gateway/ui/pages/sessions.js +++ b/src/gateway/ui/pages/sessions.js @@ -12,6 +12,30 @@ function escapeHtml(text) { let _client = null; let _el = null; +let _frontendFilter = ''; +let _includeInactive = true; + +function formatTime(ts) { + if (!ts || !Number.isFinite(ts)) {return '—';} + try { + return new Date(ts).toLocaleString(); + } catch { + return '—'; + } +} + +function formatQueue(config) { + const queue = config?.queue; + if (!queue?.mode) {return 'default';} + const parts = [queue.mode]; + if (typeof queue.debounceMs === 'number') { + parts.push(`${queue.debounceMs}ms`); + } + if (typeof queue.cap === 'number') { + parts.push(`cap:${queue.cap}`); + } + return parts.join(' · '); +} async function loadSessionList() { if (!_client || !_el) {return;} @@ -21,7 +45,11 @@ async function loadSessionList() { if (detailContainer) {detailContainer.innerHTML = '';} try { - const result = await _client.call('sessions.list'); + const params = { + includePersisted: _includeInactive, + frontend: _frontendFilter || undefined, + }; + const result = await _client.call('sessions.list', params); const sessions = result.sessions ?? []; if (sessions.length === 0) { @@ -34,7 +62,11 @@ async function loadSessionList() { Session ID + Frontend Messages + Model + Queue + Last Activity Actions @@ -45,7 +77,11 @@ async function loadSessionList() { html += ` ${escapeHtml(s.id)} + ${escapeHtml(s.frontend ?? (String(s.id).split(':')[0] || 'unknown'))} ${s.messageCount ?? 0} + ${escapeHtml(s.config?.modelTier ?? 'default')} + ${escapeHtml(formatQueue(s.config))} + ${escapeHtml(formatTime(s.lastMessageAt))} @@ -132,10 +168,53 @@ export const SessionsPage = { el.innerHTML = `

Sessions

+
+ + + +
`; + const frontendSelect = el.querySelector('#sessions-frontend-filter'); + if (frontendSelect) { + frontendSelect.value = _frontendFilter; + frontendSelect.addEventListener('change', async () => { + _frontendFilter = frontendSelect.value; + await loadSessionList(); + }); + } + + const inactiveToggle = el.querySelector('#sessions-include-inactive'); + if (inactiveToggle) { + inactiveToggle.checked = _includeInactive; + inactiveToggle.addEventListener('change', async () => { + _includeInactive = inactiveToggle.checked; + await loadSessionList(); + }); + } + + const refreshBtn = el.querySelector('#sessions-refresh-btn'); + if (refreshBtn) { + refreshBtn.addEventListener('click', async () => { + await loadSessionList(); + }); + } + await loadSessionList(); }, diff --git a/src/session/manager.test.ts b/src/session/manager.test.ts index d4351f1..3019695 100644 --- a/src/session/manager.test.ts +++ b/src/session/manager.test.ts @@ -59,6 +59,19 @@ describe('SessionManager', () => { expect(sessions).toContain('tui:local'); }); + it('can include persisted sessions and frontend filters', () => { + const persisted = manager.getSession('telegram', 'persisted-user'); + persisted.addMessage({ role: 'user', content: 'persist me' }); + + // Simulate daemon restart with empty in-memory map. + manager.evictSessions(['telegram:persisted-user']); + manager.getSession('ws', 'active-user'); + + expect(manager.listSessions()).toEqual(['ws:active-user']); + expect(manager.listSessions({ includePersisted: true })).toEqual(['telegram:persisted-user', 'ws:active-user']); + expect(manager.listSessions({ includePersisted: true, frontend: 'telegram' })).toEqual(['telegram:persisted-user']); + }); + it('indexes and searches history when enabled', () => { manager = new SessionManager(store, { enabled: true, diff --git a/src/session/manager.ts b/src/session/manager.ts index cbb7727..6967b13 100644 --- a/src/session/manager.ts +++ b/src/session/manager.ts @@ -154,8 +154,22 @@ export class SessionManager { auditLogger?.sessionTransfer(fromSession.id, toSession.id, history.length); } - listSessions(): string[] { - return Array.from(this.sessions.keys()); + listSessions(opts?: { includePersisted?: boolean; frontend?: string }): string[] { + const ids = new Set(this.sessions.keys()); + if (opts?.includePersisted) { + for (const id of this.store.listSessions()) { + ids.add(id); + } + } + + let sessions = Array.from(ids.values()); + if (opts?.frontend) { + const prefix = `${opts.frontend}:`; + sessions = sessions.filter((id) => id.startsWith(prefix)); + } + + sessions.sort((a, b) => a.localeCompare(b)); + return sessions; } closeSession(frontend: string, userId: string): void {