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:
Aamer Akhter
2026-07-17 16:31:09 -04:00
parent b27a0e9188
commit 5ec71ace5a
4 changed files with 245 additions and 23 deletions
+118 -5
View File
@@ -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));