feat: add persistent session favorites
This commit is contained in:
@@ -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