diff --git a/src/services/unified-session-service.ts b/src/services/unified-session-service.ts index 5d4fa134..6eaf5545 100644 --- a/src/services/unified-session-service.ts +++ b/src/services/unified-session-service.ts @@ -226,6 +226,16 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte // 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). + // + // The workingDir guess is a last resort and MUST be skipped for any item that + // already has its own 'history' entry (step 1 above already gave it a real, + // direct scan of its own transcript). Without this guard, a history row whose + // OWN extraction genuinely failed (oversized first message, etc.) silently + // inherited the newest OTHER session's opening line from the same directory — + // not a blank, but actively wrong: old sessions displayed today's conversation + // as if it were their own. A row with no 'history' source at all (its + // transcript hasn't been linked/scanned under its own id yet) has no such + // direct attempt to prefer, so the guess remains a reasonable stand-in there. const firstPromptByUuid = new Map(); const firstPromptByWorkingDir = new Map(); // COD-145: lastPrompt rides the same backfill (build parallel indexes; never overwrite). @@ -254,12 +264,13 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte } } for (const item of map.values()) { + const hasOwnHistoryEntry = item.sources.includes('history'); if (!item.firstPrompt) { // never overwrite an existing non-empty prompt const byUuid = item.claudeSessionId ? firstPromptByUuid.get(item.claudeSessionId) : undefined; if (byUuid) { item.firstPrompt = byUuid; - } else if (item.workingDir) { + } else if (item.workingDir && !hasOwnHistoryEntry) { const byDir = firstPromptByWorkingDir.get(item.workingDir); if (byDir) item.firstPrompt = byDir.prompt; } @@ -268,7 +279,7 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte const byUuid = item.claudeSessionId ? lastPromptByUuid.get(item.claudeSessionId) : undefined; if (byUuid) { item.lastPrompt = byUuid; - } else if (item.workingDir) { + } else if (item.workingDir && !hasOwnHistoryEntry) { const byDir = lastPromptByWorkingDir.get(item.workingDir); if (byDir) item.lastPrompt = byDir.prompt; } diff --git a/test/services/unified-session-service.test.ts b/test/services/unified-session-service.test.ts index f25faff7..2d6e1db8 100644 --- a/test/services/unified-session-service.test.ts +++ b/test/services/unified-session-service.test.ts @@ -270,6 +270,39 @@ describe('mergeUnifiedSessions', () => { expect(live!.firstPrompt).toBeUndefined(); }); + it('does NOT borrow a sibling transcript for a history-only row whose own extraction failed (no cross-contamination)', () => { + // A pure history row already got its own real scan (step 1 keys it under its + // OWN sessionId) — if that extraction genuinely failed (oversized first + // message, noise-filtered, etc.), the workingDir guess must not paper over + // it with an unrelated session's opening line. Regression: an old session + // in a shared workingDir was displaying TODAY's live session's firstPrompt + // as its own, because the guess didn't check whether this row already had + // its own (failed) attempt. + const merged = mergeUnifiedSessions({ + history: [ + // This session's own transcript scan found no usable prompt. + { + sessionId: 'old-uuid', + workingDir: '/shared', + sizeBytes: 5000, + lastModified: '2026-01-01T00:00:00.000Z', + firstPrompt: undefined, + }, + // A much newer, unrelated session in the same directory. + { + sessionId: 'newer-uuid', + workingDir: '/shared', + sizeBytes: 6000, + lastModified: '2026-06-01T00:00:00.000Z', + firstPrompt: "today's real prompt", + }, + ], + }); + const old = merged.find((m) => m.sessionId === 'old-uuid'); + expect(old).toBeDefined(); + expect(old!.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)', () => {