feat: add persistent session favorites
This commit is contained in:
+71
-48
@@ -7,9 +7,10 @@ import { existsSync } from 'fs';
|
||||
import { refreshEngines, listSessions, getSession, getSessionMessages, getToolUsage, resolvePrefix, getAllHealth, getEngine } from './engine/index.js';
|
||||
import { getCodexHome, isDbReady } from './db.js';
|
||||
import { getCacheInfo } from './sessions.js';
|
||||
import { initMetadataDb, setCustomName, deleteCustomName, getCustomNames, isMetadataReady } from './metadata-db.js';
|
||||
import { initMetadataDb, setCustomName, deleteCustomName, getCustomNames, isMetadataReady, setFavorite, deleteFavorite, getFavorites, deleteFavoriteBySession } from './metadata-db.js';
|
||||
import { generateTitle } from './title-generator.js';
|
||||
import { matchesSessionQuery, normalizeSearchText } from './search.js';
|
||||
import { getSessionList, getFavoriteEnrichedSession } from './session-list.js';
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
const app = express();
|
||||
@@ -48,55 +49,25 @@ app.get('/api/health', (req, res) => {
|
||||
// ─── Sessions List (unified) ───────────────────────────────────────
|
||||
app.get('/api/sessions', (req, res) => {
|
||||
try {
|
||||
const { query, cwd, source, model, from, to, limit, offset, engine, status } = req.query;
|
||||
const { query, cwd, source, model, from, to, limit, offset, engine, status, favorite } = req.query;
|
||||
const eng = engine || 'codex';
|
||||
|
||||
const IS_STATUS_KEYWORD = source && ['success','failed','active','interrupted'].includes(source);
|
||||
const statusFilter = status || (IS_STATUS_KEYWORD ? source : null);
|
||||
const sourceParam = IS_STATUS_KEYWORD ? null : source;
|
||||
|
||||
// Fetch all sessions from the registry, no filtering
|
||||
const all = listSessions({
|
||||
// Use the centralized favorite-enriched listing
|
||||
const result = getSessionList({
|
||||
engine: eng,
|
||||
limit: null,
|
||||
offset: 0,
|
||||
query,
|
||||
cwd,
|
||||
model,
|
||||
source,
|
||||
status,
|
||||
from,
|
||||
to,
|
||||
favorite,
|
||||
limit: Number(limit) || 50,
|
||||
offset: Number(offset) || 0,
|
||||
});
|
||||
|
||||
let list = all.sessions;
|
||||
|
||||
// Merge custom names into ALL results before filtering
|
||||
const customNames = getCustomNames(list);
|
||||
list = list.map(s => ({
|
||||
...s,
|
||||
custom_name: customNames[`${s.engine}:${s.id}`] || null,
|
||||
}));
|
||||
|
||||
// Apply filters on the merged data
|
||||
if (query) {
|
||||
list = list.filter(s => matchesSessionQuery(s, query, { includeCustomName: true }));
|
||||
}
|
||||
if (cwd) list = list.filter(s => normalizeSearchText(s.cwd).includes(normalizeSearchText(cwd)));
|
||||
if (model) list = list.filter(s => normalizeSearchText(s.model).includes(normalizeSearchText(model)));
|
||||
if (sourceParam) list = list.filter(s => normalizeSearchText(s.source) === normalizeSearchText(sourceParam));
|
||||
if (statusFilter && statusFilter !== 'all') {
|
||||
if (statusFilter === 'failed') {
|
||||
list = list.filter(s => s.status === 'error' || s.status === 'interrupted');
|
||||
} else {
|
||||
list = list.filter(s => s.status === statusFilter);
|
||||
}
|
||||
}
|
||||
if (from) list = list.filter(s => (s.created_at || 0) >= Number(from));
|
||||
if (to) list = list.filter(s => (s.created_at || 0) <= Number(to));
|
||||
|
||||
// Sort by created_at desc
|
||||
list.sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
|
||||
|
||||
const total = list.length;
|
||||
const off = Number(offset) || 0;
|
||||
const lim = Math.min(Number(limit) || 50, 200);
|
||||
const page = list.slice(off, off + lim);
|
||||
|
||||
res.json({ sessions: page, total });
|
||||
res.json({ sessions: result.sessions, total: result.total });
|
||||
} catch (err) {
|
||||
console.error('[API] /api/sessions error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
@@ -124,12 +95,12 @@ app.get('/api/sessions/:engine/:id', async (req, res) => {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const customName = isMetadataReady() ? getCustomNames([{ engine, id }])[`${engine}:${id}`] : null;
|
||||
// Enrich with favorite and custom name metadata
|
||||
const enriched = getFavoriteEnrichedSession(engine, id);
|
||||
|
||||
res.json({
|
||||
metadata: {
|
||||
...(metadata || { id, engine, error: 'Session not found' }),
|
||||
custom_name: customName || null,
|
||||
...(enriched || { id, engine, error: 'Session not found' }),
|
||||
},
|
||||
summary: {
|
||||
tool_call_count: metadata?.tool_call_count || 0,
|
||||
@@ -393,6 +364,7 @@ app.delete('/api/sessions/:engine/:id', async (req, res) => {
|
||||
// Clean up custom name metadata
|
||||
try {
|
||||
deleteCustomName(engine, id);
|
||||
deleteFavoriteBySession(engine, id);
|
||||
} catch {}
|
||||
|
||||
// Trigger refresh
|
||||
@@ -405,6 +377,57 @@ app.delete('/api/sessions/:engine/:id', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Session Favorites ─────────────────────────────────────────────
|
||||
app.put('/api/sessions/:engine/:id/favorite', (req, res) => {
|
||||
try {
|
||||
const { engine, id } = req.params;
|
||||
if (!VALID_ENGINES.includes(engine)) {
|
||||
return res.status(400).json({ error: `Invalid engine: ${engine}. Must be one of: ${VALID_ENGINES.join(', ')}` });
|
||||
}
|
||||
|
||||
// Verify session exists
|
||||
const e = getEngine(engine);
|
||||
const session = e ? e.getSession(id) : null;
|
||||
if (!session) {
|
||||
return res.status(404).json({ error: 'Session not found' });
|
||||
}
|
||||
|
||||
const favoritedAt = setFavorite(engine, id);
|
||||
if (favoritedAt === null) {
|
||||
return res.status(500).json({ error: '元数据库不可用,无法收藏' });
|
||||
}
|
||||
res.json({ status: 'ok', is_favorite: true, favorited_at: favoritedAt });
|
||||
} catch (err) {
|
||||
console.error('[API] favorite error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/sessions/:engine/:id/favorite', (req, res) => {
|
||||
try {
|
||||
const { engine, id } = req.params;
|
||||
if (!VALID_ENGINES.includes(engine)) {
|
||||
return res.status(400).json({ error: `Invalid engine: ${engine}. Must be one of: ${VALID_ENGINES.join(', ')}` });
|
||||
}
|
||||
|
||||
// Verify session exists
|
||||
const e = getEngine(engine);
|
||||
const session = e ? e.getSession(id) : null;
|
||||
if (!session) {
|
||||
return res.status(404).json({ error: 'Session not found' });
|
||||
}
|
||||
|
||||
const favResult = deleteFavorite(engine, id);
|
||||
if (!favResult) {
|
||||
return res.status(500).json({ error: '元数据库不可用,无法取消收藏' });
|
||||
}
|
||||
res.json({ status: 'ok', is_favorite: false, favorited_at: null });
|
||||
} catch (err) {
|
||||
console.error('[API] unfavorite error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── SPA fallback ──────────────────────────────────────────────────
|
||||
if (existsSync(frontendDist)) {
|
||||
app.get('*', (req, res) => {
|
||||
|
||||
@@ -29,6 +29,14 @@ export function initMetadataDb() {
|
||||
PRIMARY KEY (engine, session_id)
|
||||
)
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS session_favorites (
|
||||
engine TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
favorited_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||
PRIMARY KEY (engine, session_id)
|
||||
)
|
||||
`);
|
||||
ready = true;
|
||||
console.log('[MetadataDB] Connected to', DB_PATH);
|
||||
} catch (err) {
|
||||
@@ -120,3 +128,78 @@ export function getCustomNames(sessions) {
|
||||
export function isMetadataReady() {
|
||||
return ready;
|
||||
}
|
||||
|
||||
// ─── Session Favorites ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Favorite a session (idempotent). Retains original favorited_at on repeat.
|
||||
* @param {string} engine
|
||||
* @param {string} sessionId
|
||||
* @returns {number|null} favorited_at unix timestamp (original if already favorited), or null on failure
|
||||
*/
|
||||
export function setFavorite(engine, sessionId) {
|
||||
if (!ready || !db) return null;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
// INSERT OR IGNORE preserves the original favorited_at on conflict
|
||||
db.prepare(`
|
||||
INSERT OR IGNORE INTO session_favorites (engine, session_id, favorited_at)
|
||||
VALUES (?, ?, ?)
|
||||
`).run(engine, sessionId, now);
|
||||
// Return the actual stored value (whether just inserted or pre-existing)
|
||||
const row = db.prepare('SELECT favorited_at FROM session_favorites WHERE engine = ? AND session_id = ?').get(engine, sessionId);
|
||||
return row ? row.favorited_at : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unfavorite a session (idempotent).
|
||||
* @param {string} engine
|
||||
* @param {string} sessionId
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function deleteFavorite(engine, sessionId) {
|
||||
if (!ready || !db) return false;
|
||||
db.prepare('DELETE FROM session_favorites WHERE engine = ? AND session_id = ?').run(engine, sessionId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get favorite info for multiple sessions, keyed by "engine:id".
|
||||
* @param {Array<{engine: string, id: string}>} sessions
|
||||
* @returns {Object<string, {favorited_at: number}>}
|
||||
*/
|
||||
export function getFavorites(sessions) {
|
||||
if (!ready || !db || !sessions.length) return {};
|
||||
const result = {};
|
||||
|
||||
const pairs = [];
|
||||
const seen = new Set();
|
||||
for (const s of sessions) {
|
||||
const key = `${s.engine}:${s.id}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
pairs.push([s.engine, s.id]);
|
||||
}
|
||||
|
||||
if (!pairs.length) return result;
|
||||
|
||||
const stmt = db.prepare('SELECT favorited_at FROM session_favorites WHERE engine = ? AND session_id = ?');
|
||||
for (const [engine, id] of pairs) {
|
||||
const row = stmt.get(engine, id);
|
||||
if (row) {
|
||||
result[`${engine}:${id}`] = { favorited_at: row.favorited_at };
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all favorites for a specific session (used when deleting a session).
|
||||
* @param {string} engine
|
||||
* @param {string} sessionId
|
||||
*/
|
||||
export function deleteFavoriteBySession(engine, sessionId) {
|
||||
if (!ready || !db) return false;
|
||||
db.prepare('DELETE FROM session_favorites WHERE engine = ? AND session_id = ?').run(engine, sessionId);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { listSessions, getSession } from './engine/index.js';
|
||||
import { getCustomNames, getFavorites, isMetadataReady } from './metadata-db.js';
|
||||
import { matchesSessionQuery, normalizeSearchText } from './search.js';
|
||||
|
||||
/**
|
||||
* Pure filter/sort/paginate pipeline for already-enriched sessions.
|
||||
* Accepts sessions with is_favorite / favorited_at already set.
|
||||
* Does NOT fetch from engine or metadata DB — suitable for deterministic tests.
|
||||
*
|
||||
* @param {Array} list - sessions enriched with custom_name, is_favorite, favorited_at
|
||||
* @param {Object} opts
|
||||
* @param {string} [opts.query]
|
||||
* @param {string} [opts.cwd]
|
||||
* @param {string} [opts.model]
|
||||
* @param {string} [opts.source]
|
||||
* @param {string} [opts.status]
|
||||
* @param {string} [opts.from]
|
||||
* @param {string} [opts.to]
|
||||
* @param {string} [opts.favorite] - '1' to show only favorites
|
||||
* @param {number} [opts.limit=50]
|
||||
* @param {number} [opts.offset=0]
|
||||
* @returns {{ sessions: Array, total: number }}
|
||||
*/
|
||||
export function processSessionList(list, opts = {}) {
|
||||
const {
|
||||
query,
|
||||
cwd,
|
||||
model,
|
||||
source,
|
||||
status,
|
||||
from,
|
||||
to,
|
||||
favorite,
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
} = opts;
|
||||
|
||||
// 1. Apply filters (mirroring existing index.js logic)
|
||||
const IS_STATUS_KEYWORD = source && ['success', 'failed', 'active', 'interrupted'].includes(source);
|
||||
const statusFilter = status || (IS_STATUS_KEYWORD ? source : null);
|
||||
const sourceParam = IS_STATUS_KEYWORD ? null : source;
|
||||
|
||||
if (query) {
|
||||
list = list.filter(s => matchesSessionQuery(s, query, { includeCustomName: true }));
|
||||
}
|
||||
if (cwd) list = list.filter(s => normalizeSearchText(s.cwd).includes(normalizeSearchText(cwd)));
|
||||
if (model) list = list.filter(s => normalizeSearchText(s.model).includes(normalizeSearchText(model)));
|
||||
if (sourceParam) list = list.filter(s => normalizeSearchText(s.source) === normalizeSearchText(sourceParam));
|
||||
if (statusFilter && statusFilter !== 'all') {
|
||||
if (statusFilter === 'failed') {
|
||||
list = list.filter(s => s.status === 'error' || s.status === 'interrupted');
|
||||
} else {
|
||||
list = list.filter(s => s.status === statusFilter);
|
||||
}
|
||||
}
|
||||
if (from) list = list.filter(s => (s.created_at || 0) >= Number(from));
|
||||
if (to) list = list.filter(s => (s.created_at || 0) <= Number(to));
|
||||
|
||||
// 2. Sort: favorited first (by favorited_at desc), then by created_at desc
|
||||
// Equal favorited_at values fall through to created_at desc as tie-breaker
|
||||
list.sort((a, b) => {
|
||||
const aFav = a.is_favorite ? 1 : 0;
|
||||
const bFav = b.is_favorite ? 1 : 0;
|
||||
if (aFav !== bFav) return bFav - aFav; // favorited first
|
||||
if (a.is_favorite && b.is_favorite) {
|
||||
const favDiff = (b.favorited_at || 0) - (a.favorited_at || 0);
|
||||
if (favDiff !== 0) return favDiff; // by favorited_at desc
|
||||
// Equal favorited_at: fall through to created_at desc
|
||||
}
|
||||
return (b.created_at || 0) - (a.created_at || 0); // by created_at desc for all others
|
||||
});
|
||||
|
||||
// 3. Favorite-only filter (applied AFTER sort so sorting reflects all items)
|
||||
if (favorite === '1') {
|
||||
list = list.filter(s => s.is_favorite);
|
||||
}
|
||||
|
||||
// 4. Paginate
|
||||
const total = list.length;
|
||||
const off = Number(offset) || 0;
|
||||
const lim = Math.min(Number(limit) || 50, 200);
|
||||
const page = list.slice(off, off + lim);
|
||||
|
||||
return { sessions: page, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a favorite-enriched session list.
|
||||
*
|
||||
* 1. Fetch all sessions (unfiltered) from the engine registry
|
||||
* 2. Merge custom names and favorite metadata
|
||||
* 3. Delegate to processSessionList for filter/sort/paginate
|
||||
*
|
||||
* @param {Object} opts
|
||||
* @param {string} [opts.engine='codex']
|
||||
* @param {string} [opts.query]
|
||||
* @param {string} [opts.cwd]
|
||||
* @param {string} [opts.model]
|
||||
* @param {string} [opts.source]
|
||||
* @param {string} [opts.status]
|
||||
* @param {string} [opts.from]
|
||||
* @param {string} [opts.to]
|
||||
* @param {string} [opts.favorite] - '1' to show only favorites
|
||||
* @param {number} [opts.limit=50]
|
||||
* @param {number} [opts.offset=0]
|
||||
* @returns {{ sessions: Array, total: number }}
|
||||
*/
|
||||
export function getSessionList(opts = {}) {
|
||||
const {
|
||||
engine = 'codex',
|
||||
query,
|
||||
cwd,
|
||||
model,
|
||||
source,
|
||||
status,
|
||||
from,
|
||||
to,
|
||||
favorite,
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
} = opts;
|
||||
|
||||
// 1. Fetch all sessions from the registry
|
||||
const all = listSessions({
|
||||
engine,
|
||||
limit: null,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
let list = all.sessions;
|
||||
|
||||
// 2. Merge custom names and favorites
|
||||
const metadataReady = isMetadataReady();
|
||||
const customNames = metadataReady ? getCustomNames(list) : {};
|
||||
const favorites = metadataReady ? getFavorites(list) : {};
|
||||
|
||||
list = list.map(s => {
|
||||
const key = `${s.engine}:${s.id}`;
|
||||
const fav = favorites[key];
|
||||
return {
|
||||
...s,
|
||||
custom_name: customNames[key] || null,
|
||||
is_favorite: !!fav,
|
||||
favorited_at: fav ? fav.favorited_at : null,
|
||||
};
|
||||
});
|
||||
|
||||
// 3. Delegate to pure pipeline
|
||||
return processSessionList(list, { query, cwd, model, source, status, from, to, favorite, limit, offset });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single session with favorite metadata.
|
||||
* @param {string} engine
|
||||
* @param {string} id
|
||||
* @returns {Object|null} session metadata with is_favorite / favorited_at, or null
|
||||
*/
|
||||
export function getFavoriteEnrichedSession(engine, id) {
|
||||
const metadata = getSession(engine, id);
|
||||
if (!metadata) return null;
|
||||
|
||||
const key = `${engine}:${id}`;
|
||||
const customNames = isMetadataReady() ? getCustomNames([{ engine, id }]) : {};
|
||||
const favorites = isMetadataReady() ? getFavorites([{ engine, id }]) : {};
|
||||
const fav = favorites[key];
|
||||
|
||||
return {
|
||||
...metadata,
|
||||
custom_name: customNames[key] || null,
|
||||
is_favorite: !!fav,
|
||||
favorited_at: fav ? fav.favorited_at : null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, it, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
let tmpDir;
|
||||
let origDataDir;
|
||||
|
||||
describe('Session Favorites (metadata-db)', () => {
|
||||
let db;
|
||||
|
||||
before(async () => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'meta-db-test-'));
|
||||
origDataDir = process.env.AGENT_DATA_DIR;
|
||||
process.env.AGENT_DATA_DIR = tmpDir;
|
||||
// Import and init
|
||||
db = await import('../src/metadata-db.js');
|
||||
db.initMetadataDb();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
if (db) db.closeMetadataDb();
|
||||
if (origDataDir !== undefined) {
|
||||
process.env.AGENT_DATA_DIR = origDataDir;
|
||||
} else {
|
||||
delete process.env.AGENT_DATA_DIR;
|
||||
}
|
||||
if (tmpDir) {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should persist a favorite and return the favorited_at timestamp', () => {
|
||||
const ts = db.setFavorite('codex', 'session-1');
|
||||
assert.notEqual(ts, null);
|
||||
assert.equal(typeof ts, 'number');
|
||||
assert(ts > 0);
|
||||
|
||||
// Verify via getFavorites
|
||||
const favs = db.getFavorites([{ engine: 'codex', id: 'session-1' }]);
|
||||
assert.deepEqual(favs, { 'codex:session-1': { favorited_at: ts } });
|
||||
});
|
||||
|
||||
it('should return isMetadataReady() === true after init', () => {
|
||||
assert.equal(db.isMetadataReady(), true);
|
||||
});
|
||||
|
||||
it('should enforce engine + session_id isolation', () => {
|
||||
db.setFavorite('codex', 'session-a');
|
||||
db.setFavorite('claude', 'session-b');
|
||||
|
||||
const favs = db.getFavorites([
|
||||
{ engine: 'codex', id: 'session-a' },
|
||||
{ engine: 'claude', id: 'session-b' },
|
||||
{ engine: 'codex', id: 'session-b' },
|
||||
]);
|
||||
// codex:session-a and claude:session-b should be favorited, codex:session-b should not
|
||||
assert.equal(favs['codex:session-a'] !== undefined, true);
|
||||
assert.equal(favs['claude:session-b'] !== undefined, true);
|
||||
assert.equal(favs['codex:session-b'], undefined);
|
||||
});
|
||||
|
||||
it('should be idempotent: repeat favorite returns original favorited_at across different timestamps', async () => {
|
||||
const first = db.setFavorite('codex', 'idempotent-ts');
|
||||
// Wait past a Unix-second boundary to prove the original timestamp is preserved
|
||||
await new Promise(r => setTimeout(r, 1100));
|
||||
const second = db.setFavorite('codex', 'idempotent-ts');
|
||||
// Both calls must return the same timestamp (the original one)
|
||||
assert.equal(first, second);
|
||||
});
|
||||
|
||||
it('should unfavorite a session (idempotent)', () => {
|
||||
db.setFavorite('codex', 'to-delete');
|
||||
assert.equal(db.getFavorites([{ engine: 'codex', id: 'to-delete' }])['codex:to-delete'] !== undefined, true);
|
||||
|
||||
const result = db.deleteFavorite('codex', 'to-delete');
|
||||
assert.equal(result, true);
|
||||
|
||||
const afterDel = db.getFavorites([{ engine: 'codex', id: 'to-delete' }]);
|
||||
assert.equal(afterDel['codex:to-delete'], undefined);
|
||||
|
||||
// Deleting again should be OK (idempotent)
|
||||
const result2 = db.deleteFavorite('codex', 'to-delete');
|
||||
assert.equal(result2, true);
|
||||
});
|
||||
|
||||
it('should delete favorites by session (deleteFavoriteBySession)', () => {
|
||||
db.setFavorite('codex', 'delete-by-session');
|
||||
db.deleteFavoriteBySession('codex', 'delete-by-session');
|
||||
const after = db.getFavorites([{ engine: 'codex', id: 'delete-by-session' }]);
|
||||
assert.equal(after['codex:delete-by-session'], undefined);
|
||||
});
|
||||
|
||||
it('should return empty results for empty input', () => {
|
||||
assert.deepEqual(db.getFavorites([]), {});
|
||||
});
|
||||
|
||||
it('should handle many sessions efficiently', () => {
|
||||
const sessions = [];
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const engine = i % 2 === 0 ? 'codex' : 'claude';
|
||||
const id = `mass-test-${i}`;
|
||||
db.setFavorite(engine, id);
|
||||
sessions.push({ engine, id });
|
||||
}
|
||||
const favs = db.getFavorites(sessions);
|
||||
assert.equal(Object.keys(favs).length, 50);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { processSessionList } from '../src/session-list.js';
|
||||
|
||||
/**
|
||||
* Deterministic fixture builder for processSessionList.
|
||||
* Returns already-enriched sessions (is_favorite, favorited_at, custom_name set).
|
||||
*/
|
||||
function makeSession(overrides = {}) {
|
||||
return {
|
||||
id: overrides.id || 'sess-1',
|
||||
engine: overrides.engine || 'codex',
|
||||
created_at: overrides.created_at || 1000,
|
||||
status: overrides.status || 'success',
|
||||
summary: overrides.summary || 'Test session',
|
||||
model: overrides.model || 'gpt-4',
|
||||
cwd: overrides.cwd || '/home/test',
|
||||
source: overrides.source || 'chat',
|
||||
custom_name: overrides.custom_name || null,
|
||||
is_favorite: overrides.is_favorite || false,
|
||||
favorited_at: overrides.favorited_at || null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('Session list pipeline (processSessionList)', () => {
|
||||
|
||||
// ── Sorting ──────────────────────────────────────────────────────
|
||||
|
||||
it('should place favorited sessions first, ordered by favorited_at desc', () => {
|
||||
const list = [
|
||||
makeSession({ id: 'old-fav', is_favorite: true, favorited_at: 100, created_at: 500 }),
|
||||
makeSession({ id: 'recent-fav', is_favorite: true, favorited_at: 200, created_at: 400 }),
|
||||
makeSession({ id: 'normal', is_favorite: false, created_at: 900 }),
|
||||
];
|
||||
const result = processSessionList(list, { limit: 10 });
|
||||
assert.equal(result.sessions[0].id, 'recent-fav');
|
||||
assert.equal(result.sessions[1].id, 'old-fav');
|
||||
assert.equal(result.sessions[2].id, 'normal');
|
||||
});
|
||||
|
||||
it('should apply created_at desc tie-break for equal favorited_at values', () => {
|
||||
const list = [
|
||||
makeSession({ id: 'older', is_favorite: true, favorited_at: 100, created_at: 1000 }),
|
||||
makeSession({ id: 'newer', is_favorite: true, favorited_at: 100, created_at: 2000 }),
|
||||
];
|
||||
const result = processSessionList(list, { limit: 10 });
|
||||
// Equal favorited_at → created_at desc
|
||||
assert.equal(result.sessions[0].id, 'newer');
|
||||
assert.equal(result.sessions[1].id, 'older');
|
||||
});
|
||||
|
||||
it('should order non-favorite sessions by created_at desc', () => {
|
||||
const list = [
|
||||
makeSession({ id: 'old', created_at: 100 }),
|
||||
makeSession({ id: 'mid', created_at: 200 }),
|
||||
makeSession({ id: 'new', created_at: 300 }),
|
||||
];
|
||||
const result = processSessionList(list, { limit: 10 });
|
||||
assert.equal(result.sessions[0].id, 'new');
|
||||
assert.equal(result.sessions[1].id, 'mid');
|
||||
assert.equal(result.sessions[2].id, 'old');
|
||||
});
|
||||
|
||||
it('should sort before pagination (sort covers all items, pagination slices)', () => {
|
||||
const list = [];
|
||||
// 5 favorites at timestamps favoring higher i → higher favorited_at
|
||||
for (let i = 0; i < 5; i++) {
|
||||
list.push(makeSession({ id: `fav-${i}`, is_favorite: true, favorited_at: 500 - i, created_at: 1000 + i }));
|
||||
}
|
||||
// 5 non-favorites
|
||||
for (let i = 0; i < 5; i++) {
|
||||
list.push(makeSession({ id: `normal-${i}`, created_at: 100 + i }));
|
||||
}
|
||||
// fav-0 has favorited_at=500 (highest), fav-4 has 496 (lowest among favs)
|
||||
const result = processSessionList(list, { limit: 3, offset: 0 });
|
||||
assert.equal(result.total, 10);
|
||||
assert.equal(result.sessions.length, 3);
|
||||
assert.equal(result.sessions[0].id, 'fav-0');
|
||||
assert.equal(result.sessions[1].id, 'fav-1');
|
||||
assert.equal(result.sessions[2].id, 'fav-2');
|
||||
});
|
||||
|
||||
// ── Filtering ────────────────────────────────────────────────────
|
||||
|
||||
it('should filter by favorite-only when opts.favorite="1"', () => {
|
||||
const list = [
|
||||
makeSession({ id: 'fav', is_favorite: true, favorited_at: 100 }),
|
||||
makeSession({ id: 'not-fav', is_favorite: false }),
|
||||
];
|
||||
const result = processSessionList(list, { favorite: '1', limit: 10 });
|
||||
assert.equal(result.sessions.length, 1);
|
||||
assert.equal(result.sessions[0].id, 'fav');
|
||||
});
|
||||
|
||||
it('should combine favorite-only with status filter', () => {
|
||||
const list = [
|
||||
makeSession({ id: 'fav-ok', is_favorite: true, favorited_at: 200, status: 'success' }),
|
||||
makeSession({ id: 'fav-err', is_favorite: true, favorited_at: 100, status: 'error' }),
|
||||
makeSession({ id: 'normal-ok', status: 'success' }),
|
||||
];
|
||||
// Only favorited AND status=error
|
||||
const result = processSessionList(list, { favorite: '1', status: 'failed', limit: 10 });
|
||||
assert.equal(result.sessions.length, 1);
|
||||
assert.equal(result.sessions[0].id, 'fav-err');
|
||||
});
|
||||
|
||||
it('should be deterministic when favorited_at and created_at both equal', () => {
|
||||
const list = [
|
||||
makeSession({ id: 'a', is_favorite: true, favorited_at: 100, created_at: 1000 }),
|
||||
makeSession({ id: 'b', is_favorite: true, favorited_at: 100, created_at: 1000 }),
|
||||
];
|
||||
// When both are equal, sort is stable (preserves original order)
|
||||
const result = processSessionList(list, { limit: 10 });
|
||||
assert.equal(result.sessions[0].id, 'a');
|
||||
assert.equal(result.sessions[1].id, 'b');
|
||||
});
|
||||
|
||||
it('should combine favorite-only with query and status filter (composition test)', () => {
|
||||
const list = [
|
||||
// Favorited, matches query, has error status
|
||||
makeSession({ id: 'match-all', is_favorite: true, favorited_at: 300, status: 'error', summary: 'OAuth fix', custom_name: null }),
|
||||
// Favorited, does NOT match query
|
||||
makeSession({ id: 'fav-no-query', is_favorite: true, favorited_at: 200, status: 'error', summary: 'Other thing' }),
|
||||
// Favorited, matches query but wrong status
|
||||
makeSession({ id: 'fav-ok-status', is_favorite: true, favorited_at: 100, status: 'success', summary: 'OAuth callback' }),
|
||||
// Not favorited, matches query and status
|
||||
makeSession({ id: 'not-fav', is_favorite: false, status: 'error', summary: 'OAuth failure' }),
|
||||
];
|
||||
// Only favorited + query matching "OAuth" + status failed
|
||||
const result = processSessionList(list, { favorite: '1', query: 'OAuth', status: 'failed', limit: 10 });
|
||||
assert.equal(result.sessions.length, 1);
|
||||
assert.equal(result.sessions[0].id, 'match-all');
|
||||
});
|
||||
|
||||
// ── Edge cases ───────────────────────────────────────────────────
|
||||
|
||||
it('should return empty array for empty input', () => {
|
||||
const result = processSessionList([], { limit: 10 });
|
||||
assert.deepEqual(result, { sessions: [], total: 0 });
|
||||
});
|
||||
|
||||
it('should respect limit and offset', () => {
|
||||
const list = [];
|
||||
for (let i = 0; i < 20; i++) {
|
||||
list.push(makeSession({ id: `s-${i}`, created_at: 2000 - i }));
|
||||
}
|
||||
// s-0 has created_at=2000 (highest → first after sort desc)
|
||||
const page1 = processSessionList(list, { limit: 5, offset: 0 });
|
||||
assert.equal(page1.sessions.length, 5);
|
||||
assert.equal(page1.sessions[0].id, 's-0');
|
||||
assert.equal(page1.total, 20);
|
||||
|
||||
const page2 = processSessionList(list, { limit: 5, offset: 5 });
|
||||
assert.equal(page2.sessions.length, 5);
|
||||
assert.equal(page2.sessions[0].id, 's-5');
|
||||
});
|
||||
|
||||
it('should cap limit at 200', () => {
|
||||
const list = Array.from({ length: 300 }, (_, i) =>
|
||||
makeSession({ id: `s-${i}`, created_at: 300 - i })
|
||||
);
|
||||
const result = processSessionList(list, { limit: 999 });
|
||||
assert.equal(result.sessions.length, 200);
|
||||
assert.equal(result.total, 300);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user