feat(gateway): expand sessions surface with operator metadata and paging hardening

This commit is contained in:
William Valentin
2026-02-17 16:14:06 -08:00
parent 47187aa878
commit 9c9ab92e9d
7 changed files with 309 additions and 25 deletions
+46 -2
View File
@@ -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;
+109 -12
View File
@@ -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<OutboundMessage> => {
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<OutboundMessage> => {
@@ -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, {
+80 -1
View File
@@ -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() {
<thead>
<tr>
<th>Session ID</th>
<th>Frontend</th>
<th>Messages</th>
<th>Model</th>
<th>Queue</th>
<th>Last Activity</th>
<th>Actions</th>
</tr>
</thead>
@@ -45,7 +77,11 @@ async function loadSessionList() {
html += `
<tr>
<td><a href="#" class="session-view-link" data-id="${escapeHtml(s.id)}">${escapeHtml(s.id)}</a></td>
<td>${escapeHtml(s.frontend ?? (String(s.id).split(':')[0] || 'unknown'))}</td>
<td>${s.messageCount ?? 0}</td>
<td>${escapeHtml(s.config?.modelTier ?? 'default')}</td>
<td>${escapeHtml(formatQueue(s.config))}</td>
<td>${escapeHtml(formatTime(s.lastMessageAt))}</td>
<td class="session-actions">
<button class="btn btn-secondary session-view-btn" data-id="${escapeHtml(s.id)}">View</button>
<button class="btn btn-danger session-delete-btn" data-id="${escapeHtml(s.id)}">Delete</button>
@@ -132,10 +168,53 @@ export const SessionsPage = {
el.innerHTML = `
<h1 class="page-title">Sessions</h1>
<div class="status-row" style="margin-bottom: 0.75rem; gap: 0.75rem; flex-wrap: wrap;">
<label class="text-sm text-muted">Frontend
<select id="sessions-frontend-filter" style="margin-left: 0.35rem;">
<option value="">All</option>
<option value="ws">ws</option>
<option value="tui">tui</option>
<option value="telegram">telegram</option>
<option value="slack">slack</option>
<option value="discord">discord</option>
<option value="mattermost">mattermost</option>
</select>
</label>
<label class="text-sm text-muted">
<input id="sessions-include-inactive" type="checkbox" checked />
Include inactive/persisted
</label>
<button id="sessions-refresh-btn" class="btn btn-secondary">Refresh</button>
</div>
<div id="sessions-list"></div>
<div id="session-detail"></div>
`;
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();
},
+13
View File
@@ -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,
+16 -2
View File
@@ -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<string>(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 {