mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
COD-145 show last (most recent) prompt alongside first in session manager
Building on COD-140's firstPrompt backfill, surface each session's most recent user prompt too, so a long-running session is identifiable by both where it started and where it is now. - session-routes: add extractLastUserPrompt() (mirrors extractFirstUserPrompt with last-match semantics + same noise/secret/slash-command filters + 120 cap); scanProjectDir computes lastPrompt from the file tail (reads a tail for large files; small files scan head); thread lastPrompt through HistorySession and the /api/sessions/unified history rows. - unified-session-service: add lastPrompt to UnifiedSessionItem + HistoryInput, set it from history in the merge, and extend the backfill with parallel by-uuid / newest-by-workingDir indexes (never overwrites); add lastPrompt to the filterAndPaginate search haystack. - terminal-ui: render a 'Last prompt' detail row, omitted when absent or equal to the first prompt (single-prompt sessions show one line). Tests: unified-session-service.test.ts +5 (uuid-join, workingDir fallback, newest-wins, no-overwrite, search). Beta-verified: /api/sessions/unified populated firstPrompt+lastPrompt on all 200 rows (12 distinct); Playwright on the session-manager modal rendered 12 'Last prompt' rows, 0 console errors. (cherry picked from commit 115f4d397e91decc1a6381b47a99d74922e9055b)
This commit is contained in:
@@ -269,6 +269,105 @@ describe('mergeUnifiedSessions', () => {
|
||||
expect(live).toBeDefined();
|
||||
expect(live!.firstPrompt).toBeUndefined();
|
||||
});
|
||||
|
||||
// COD-145: lastPrompt backfill — mirrors the COD-140 firstPrompt path so the
|
||||
// most-recent user prompt also reaches live rows whose id ≠ transcript UUID.
|
||||
it('backfills lastPrompt onto a live session by claudeSessionId join (uuid-join)', () => {
|
||||
const merged = mergeUnifiedSessions({
|
||||
live: [{ id: 'codeman-l1', status: 'working', claudeSessionId: 'uuid-LA', workingDir: '/wl' }],
|
||||
history: [
|
||||
{
|
||||
sessionId: 'uuid-LA',
|
||||
workingDir: '/wl',
|
||||
sizeBytes: 5000,
|
||||
lastModified: '2026-01-01T00:00:00.000Z',
|
||||
firstPrompt: 'fix the bug',
|
||||
lastPrompt: 'now ship it',
|
||||
},
|
||||
],
|
||||
});
|
||||
const live = merged.find((m) => m.sessionId === 'codeman-l1');
|
||||
expect(live).toBeDefined();
|
||||
expect(live!.lastPrompt).toBe('now ship it');
|
||||
// The upstream unified-service alias map (COD-160/161) folds the UUID-keyed
|
||||
// history row into the owning live session, so lastPrompt reaches the live row
|
||||
// above rather than surfacing as a separate uuid-LA entry.
|
||||
const hist = merged.find((m) => m.sessionId === 'uuid-LA');
|
||||
expect(hist).toBeUndefined();
|
||||
});
|
||||
|
||||
it('falls back to the workingDir transcript for lastPrompt when no uuid join exists (workingDir fallback)', () => {
|
||||
const merged = mergeUnifiedSessions({
|
||||
live: [{ id: 'codeman-l2', status: 'working', claudeSessionId: 'uuid-missing', workingDir: '/wl2' }],
|
||||
history: [
|
||||
{
|
||||
sessionId: 'uuid-other',
|
||||
workingDir: '/wl2',
|
||||
sizeBytes: 5000,
|
||||
lastModified: '2026-01-01T00:00:00.000Z',
|
||||
firstPrompt: 'borrowed first',
|
||||
lastPrompt: 'borrowed last',
|
||||
},
|
||||
],
|
||||
});
|
||||
const live = merged.find((m) => m.sessionId === 'codeman-l2');
|
||||
expect(live).toBeDefined();
|
||||
expect(live!.lastPrompt).toBe('borrowed last');
|
||||
});
|
||||
|
||||
it('uses the newest transcript per workingDir for the lastPrompt fallback (newest-wins)', () => {
|
||||
const merged = mergeUnifiedSessions({
|
||||
live: [{ id: 'codeman-l3', status: 'working', claudeSessionId: 'uuid-missing', workingDir: '/wl3' }],
|
||||
history: [
|
||||
{
|
||||
sessionId: 'uuid-old',
|
||||
workingDir: '/wl3',
|
||||
sizeBytes: 5000,
|
||||
lastModified: '2026-01-01T00:00:00.000Z',
|
||||
firstPrompt: 'older first',
|
||||
lastPrompt: 'older last',
|
||||
},
|
||||
{
|
||||
sessionId: 'uuid-new',
|
||||
workingDir: '/wl3',
|
||||
sizeBytes: 6000,
|
||||
lastModified: '2026-02-01T00:00:00.000Z',
|
||||
firstPrompt: 'newer first',
|
||||
lastPrompt: 'newer last',
|
||||
},
|
||||
],
|
||||
});
|
||||
const live = merged.find((m) => m.sessionId === 'codeman-l3');
|
||||
expect(live).toBeDefined();
|
||||
expect(live!.lastPrompt).toBe('newer last');
|
||||
});
|
||||
|
||||
it('never overwrites a lastPrompt that already merged from the session own transcript (no overwrite)', () => {
|
||||
const merged = mergeUnifiedSessions({
|
||||
live: [{ id: 'self-luuid', status: 'working', claudeSessionId: 'self-luuid', workingDir: '/wl4' }],
|
||||
history: [
|
||||
{
|
||||
sessionId: 'self-luuid',
|
||||
workingDir: '/wl4',
|
||||
sizeBytes: 5000,
|
||||
lastModified: '2026-01-01T00:00:00.000Z',
|
||||
firstPrompt: 'own first',
|
||||
lastPrompt: 'own last',
|
||||
},
|
||||
{
|
||||
sessionId: 'sibling-uuid',
|
||||
workingDir: '/wl4',
|
||||
sizeBytes: 6000,
|
||||
lastModified: '2026-03-01T00:00:00.000Z',
|
||||
firstPrompt: 'sibling first',
|
||||
lastPrompt: 'sibling last',
|
||||
},
|
||||
],
|
||||
});
|
||||
const self = merged.find((m) => m.sessionId === 'self-luuid');
|
||||
expect(self).toBeDefined();
|
||||
expect(self!.lastPrompt).toBe('own last');
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterAndPaginate', () => {
|
||||
@@ -276,6 +375,14 @@ describe('filterAndPaginate', () => {
|
||||
{ sessionId: 's1', name: 'Alpha build', sources: ['live'], workingDir: '/repo/alpha' },
|
||||
{ sessionId: 's2', name: 'Beta', firstPrompt: 'fix the login bug', sources: ['history'], workingDir: '/repo/beta' },
|
||||
{ sessionId: 's3', name: 'Gamma', sources: ['persisted'], workingDir: '/srv/gamma' },
|
||||
{
|
||||
sessionId: 's4',
|
||||
name: 'Delta',
|
||||
firstPrompt: 'start the migration',
|
||||
lastPrompt: 'roll back the migration',
|
||||
sources: ['history'],
|
||||
workingDir: '/repo/delta',
|
||||
},
|
||||
];
|
||||
|
||||
it('filters by name (case-insensitive)', () => {
|
||||
@@ -289,18 +396,24 @@ describe('filterAndPaginate', () => {
|
||||
expect(filterAndPaginate(items, { q: '/srv/' }).sessions[0].sessionId).toBe('s3');
|
||||
});
|
||||
|
||||
it('filters by lastPrompt (COD-145)', () => {
|
||||
const r = filterAndPaginate(items, { q: 'roll back' });
|
||||
expect(r.total).toBe(1);
|
||||
expect(r.sessions[0].sessionId).toBe('s4');
|
||||
});
|
||||
|
||||
it('reports total as the pre-page filtered count', () => {
|
||||
const r = filterAndPaginate(items, { q: 'repo', limit: 1 });
|
||||
// both s1 and s2 have /repo/ workingDir
|
||||
expect(r.total).toBe(2);
|
||||
// s1, s2, and s4 all have /repo/ workingDir
|
||||
expect(r.total).toBe(3);
|
||||
expect(r.sessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('clamps limit to a max of 500', () => {
|
||||
const r = filterAndPaginate(items, { limit: 99999 });
|
||||
expect(r.sessions).toHaveLength(items.length);
|
||||
// clamp does not throw and returns all 3 (< 500)
|
||||
expect(r.total).toBe(3);
|
||||
// clamp does not throw and returns all items (< 500)
|
||||
expect(r.total).toBe(items.length);
|
||||
});
|
||||
|
||||
it('clamps limit to a min of 1', () => {
|
||||
@@ -312,7 +425,7 @@ describe('filterAndPaginate', () => {
|
||||
const page1 = filterAndPaginate(items, { offset: 0, limit: 2 });
|
||||
const page2 = filterAndPaginate(items, { offset: 2, limit: 2 });
|
||||
expect(page1.sessions.map((s) => s.sessionId)).toEqual(['s1', 's2']);
|
||||
expect(page2.sessions.map((s) => s.sessionId)).toEqual(['s3']);
|
||||
expect(page2.sessions.map((s) => s.sessionId)).toEqual(['s3', 's4']);
|
||||
const overlap = page1.sessions
|
||||
.map((s) => s.sessionId)
|
||||
.filter((id) => page2.sessions.map((s2) => s2.sessionId).includes(id));
|
||||
|
||||
Reference in New Issue
Block a user