feat(gateway): expand sessions surface with operator metadata and paging hardening
This commit is contained in:
+27
-6
@@ -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
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
+18
-2
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user