mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
COD-140 backfill firstPrompt for sessions whose id != transcript UUID
The unified session list only set firstPrompt from the transcript-history view, keyed by the Claude transcript file's UUID. A live/persisted row keyed by its Codeman id only inherited a prompt when that id happened to equal an on-disk transcript filename; when it didn't (stale/wrong claudeSessionId, post-/clear new uuid, resumed/attached/worktree session), the session manager showed "(no prompt captured)" even though a real transcript for that working dir existed under a different UUID. Add a pure firstPrompt backfill pass in mergeUnifiedSessions (after the merge loops, using the already-passed history source): for any row with no firstPrompt, join by claudeSessionId first, then fall back to the newest transcript in the same workingDir. Never overwrites a non-empty prompt, so rows keyed to their own transcript are untouched; rows with genuinely no transcript still show the placeholder. Pure, unit-tested (+5). (cherry picked from commit 1f9f53ec64a61c9fa7f77d29efcbdd1d2794ec38)
This commit is contained in:
@@ -212,6 +212,44 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
|
||||
if (m.remote !== undefined) item.remote = m.remote;
|
||||
}
|
||||
|
||||
// firstPrompt backfill (COD-140): the only source that sets firstPrompt is the
|
||||
// transcript-history view, keyed by the Claude transcript file's UUID. A live/persisted
|
||||
// row keyed by its Codeman id only inherits firstPrompt when that id happens to equal an
|
||||
// on-disk transcript UUID. When it doesn't (stale/wrong claudeSessionId, post-/clear new
|
||||
// uuid, resumed/attached/worktree session, transcript not yet flushed), the row shows
|
||||
// "(no prompt captured)" even though a real transcript for that working dir exists under a
|
||||
// different UUID. Backfill from the already-passed history: first try the claudeSessionId
|
||||
// join, then the newest transcript in the same workingDir. Never overwrite a non-empty
|
||||
// firstPrompt (so rows keyed to their own transcript are untouched).
|
||||
const firstPromptByUuid = new Map<string, string>();
|
||||
const firstPromptByWorkingDir = new Map<string, { prompt: string; ms: number }>();
|
||||
for (const h of sources.history ?? []) {
|
||||
if (!h.firstPrompt) continue;
|
||||
firstPromptByUuid.set(h.sessionId, h.firstPrompt);
|
||||
if (h.workingDir) {
|
||||
const ms = Date.parse(h.lastModified);
|
||||
const ts = Number.isNaN(ms) ? -Infinity : ms;
|
||||
const existing = firstPromptByWorkingDir.get(h.workingDir);
|
||||
if (!existing || ts > existing.ms) {
|
||||
firstPromptByWorkingDir.set(h.workingDir, { prompt: h.firstPrompt, ms: ts });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const item of map.values()) {
|
||||
if (item.firstPrompt) continue; // never overwrite an existing non-empty prompt
|
||||
if (item.claudeSessionId) {
|
||||
const byUuid = firstPromptByUuid.get(item.claudeSessionId);
|
||||
if (byUuid) {
|
||||
item.firstPrompt = byUuid;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (item.workingDir) {
|
||||
const byDir = firstPromptByWorkingDir.get(item.workingDir);
|
||||
if (byDir) item.firstPrompt = byDir.prompt;
|
||||
}
|
||||
}
|
||||
|
||||
// Meaningfulness floor: keep real rows, drop bare lifecycle/mux-only noise.
|
||||
const kept: UnifiedSessionItem[] = [];
|
||||
for (const item of map.values()) {
|
||||
|
||||
@@ -163,6 +163,112 @@ describe('mergeUnifiedSessions', () => {
|
||||
expect(merged).toHaveLength(1);
|
||||
expect(merged[0].projectKey).toBe('-repo-alpha');
|
||||
});
|
||||
|
||||
// COD-140: firstPrompt backfill — live sessions whose Codeman id does not match an
|
||||
// on-disk transcript UUID still surface a first prompt (by claudeSessionId join, then
|
||||
// by newest transcript in the same workingDir).
|
||||
it('backfills firstPrompt onto a live session by claudeSessionId join (uuid-join)', () => {
|
||||
const merged = mergeUnifiedSessions({
|
||||
live: [{ id: 'codeman-1', status: 'working', claudeSessionId: 'uuid-A', workingDir: '/w' }],
|
||||
history: [
|
||||
{
|
||||
sessionId: 'uuid-A',
|
||||
workingDir: '/w',
|
||||
sizeBytes: 5000,
|
||||
lastModified: '2026-01-01T00:00:00.000Z',
|
||||
firstPrompt: 'fix the bug',
|
||||
},
|
||||
],
|
||||
});
|
||||
const live = merged.find((m) => m.sessionId === 'codeman-1');
|
||||
expect(live).toBeDefined();
|
||||
expect(live!.firstPrompt).toBe('fix the bug');
|
||||
// The upstream unified-service alias map (COD-160/161) folds a history row keyed
|
||||
// by the Claude conversation UUID into the owning live session (claudeSessionId
|
||||
// join), so it does NOT surface as a separate item — the firstPrompt reaches the
|
||||
// live row above rather than a duplicate uuid-A entry.
|
||||
const hist = merged.find((m) => m.sessionId === 'uuid-A');
|
||||
expect(hist).toBeUndefined();
|
||||
});
|
||||
|
||||
it('falls back to the workingDir transcript when no uuid join exists (workingDir fallback)', () => {
|
||||
const merged = mergeUnifiedSessions({
|
||||
live: [{ id: 'codeman-2', status: 'working', claudeSessionId: 'uuid-missing', workingDir: '/w2' }],
|
||||
history: [
|
||||
{
|
||||
sessionId: 'uuid-other',
|
||||
workingDir: '/w2',
|
||||
sizeBytes: 5000,
|
||||
lastModified: '2026-01-01T00:00:00.000Z',
|
||||
firstPrompt: 'borrowed prompt',
|
||||
},
|
||||
],
|
||||
});
|
||||
const live = merged.find((m) => m.sessionId === 'codeman-2');
|
||||
expect(live).toBeDefined();
|
||||
expect(live!.firstPrompt).toBe('borrowed prompt');
|
||||
});
|
||||
|
||||
it('uses the newest transcript per workingDir for the fallback (newest-wins)', () => {
|
||||
const merged = mergeUnifiedSessions({
|
||||
live: [{ id: 'codeman-3', status: 'working', claudeSessionId: 'uuid-missing', workingDir: '/w3' }],
|
||||
history: [
|
||||
{
|
||||
sessionId: 'uuid-old',
|
||||
workingDir: '/w3',
|
||||
sizeBytes: 5000,
|
||||
lastModified: '2026-01-01T00:00:00.000Z',
|
||||
firstPrompt: 'older prompt',
|
||||
},
|
||||
{
|
||||
sessionId: 'uuid-new',
|
||||
workingDir: '/w3',
|
||||
sizeBytes: 6000,
|
||||
lastModified: '2026-02-01T00:00:00.000Z',
|
||||
firstPrompt: 'newer prompt',
|
||||
},
|
||||
],
|
||||
});
|
||||
const live = merged.find((m) => m.sessionId === 'codeman-3');
|
||||
expect(live).toBeDefined();
|
||||
expect(live!.firstPrompt).toBe('newer prompt');
|
||||
});
|
||||
|
||||
it('never overwrites a firstPrompt that already merged from the session own transcript (no overwrite)', () => {
|
||||
const merged = mergeUnifiedSessions({
|
||||
live: [{ id: 'self-uuid', status: 'working', claudeSessionId: 'self-uuid', workingDir: '/w4' }],
|
||||
history: [
|
||||
// the session's own transcript (keyed by its id) — provides the real prompt
|
||||
{
|
||||
sessionId: 'self-uuid',
|
||||
workingDir: '/w4',
|
||||
sizeBytes: 5000,
|
||||
lastModified: '2026-01-01T00:00:00.000Z',
|
||||
firstPrompt: 'own prompt',
|
||||
},
|
||||
// a newer sibling transcript in the same dir that must NOT clobber it
|
||||
{
|
||||
sessionId: 'sibling-uuid',
|
||||
workingDir: '/w4',
|
||||
sizeBytes: 6000,
|
||||
lastModified: '2026-03-01T00:00:00.000Z',
|
||||
firstPrompt: 'sibling prompt',
|
||||
},
|
||||
],
|
||||
});
|
||||
const self = merged.find((m) => m.sessionId === 'self-uuid');
|
||||
expect(self).toBeDefined();
|
||||
expect(self!.firstPrompt).toBe('own prompt');
|
||||
});
|
||||
|
||||
it('leaves firstPrompt undefined when there is no transcript at all (no transcript)', () => {
|
||||
const merged = mergeUnifiedSessions({
|
||||
live: [{ id: 'codeman-5', status: 'working', claudeSessionId: 'uuid-none', workingDir: '/empty' }],
|
||||
});
|
||||
const live = merged.find((m) => m.sessionId === 'codeman-5');
|
||||
expect(live).toBeDefined();
|
||||
expect(live!.firstPrompt).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterAndPaginate', () => {
|
||||
|
||||
Reference in New Issue
Block a user