mirror of
https://github.com/thedotmack/claude-mem
synced 2026-04-25 17:15:04 +02:00
* Add enforceable anti-pattern detection for try-catch abuse PROBLEM: - Overly-broad try-catch blocks waste 10+ hours of debugging time - Empty catch blocks silently swallow errors - AI assistants use try-catch to paper over uncertainty instead of doing research SOLUTION: 1. Created detect-error-handling-antipatterns.ts test - Detects empty catch blocks (45 CRITICAL found) - Detects catch without logging (45 CRITICAL total) - Detects large try blocks (>10 lines) - Detects generic catch without type checking - Detects catch-and-continue on critical paths - Exit code 1 if critical issues found 2. Updated CLAUDE.md with MANDATORY ERROR HANDLING RULES - 5-question pre-flight checklist before any try-catch - FORBIDDEN patterns with examples - ALLOWED patterns with examples - Meta-rule: UNCERTAINTY TRIGGERS RESEARCH, NOT TRY-CATCH - Critical path protection list 3. Created comprehensive try-catch audit report - Documents all 96 try-catch blocks in worker service - Identifies critical issue at worker-service.ts:748-750 - Categorizes patterns and provides recommendations This is enforceable via test, not just instructions that can be ignored. Current state: 163 anti-patterns detected (45 critical, 47 high, 71 medium) Next: Fix critical issues identified by test 🤖 Generated with Claude Code Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix: add logging to 5 critical empty catch blocks (Wave 1) Wave 1 of error handling cleanup - fixing empty catch blocks that silently swallow errors without any trace. Fixed files: - src/bin/import-xml-observations.ts:80 - Log skipped invalid JSON - src/utils/bun-path.ts:33 - Log when bun not in PATH - src/utils/cursor-utils.ts:44 - Log failed registry reads - src/utils/cursor-utils.ts:149 - Log corrupt MCP config - src/shared/worker-utils.ts:128 - Log failed health checks All catch blocks now have proper logging with context and error details. Progress: 41 → 39 CRITICAL issues remaining 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix: add logging to promise catches on critical paths (Wave 2) Wave 2 of error handling cleanup - fixing empty promise catch handlers that silently swallow errors on critical code paths. These are the patterns that caused the 10-hour debugging session. Fixed empty promise catches: - worker-service.ts:642 - Background initialization failures - SDKAgent.ts:372,446 - Session processor errors - GeminiAgent.ts:408,475 - Finalization failures - OpenRouterAgent.ts:451,518 - Finalization failures - SessionManager.ts:289 - Generator promise failures Added justification comments to catch-and-continue blocks: - worker-service.ts:68 - PID file removal (cleanup, non-critical) - worker-service.ts:130 - Cursor context update (non-critical) All promise rejection handlers now log errors with context, preventing silent failures that were nearly impossible to debug. Note: The anti-pattern detector only tracks try-catch blocks, not standalone promise chains. These fixes address the root cause of the original 10-hour debugging session even though the detector count remains unchanged. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix: add logging and documentation to error handling patterns (Wave 3) Wave 3 of error handling cleanup - comprehensive review and fixes for remaining critical issues identified by the anti-pattern detector. Changes organized by severity: **Wave 3.1: Fixed 2 EMPTY_CATCH blocks** - worker-service.ts:162 - Health check polling now logs failures - worker-service.ts:610 - Process cleanup logs failures **Wave 3.2: Reviewed 12 CATCH_AND_CONTINUE patterns** - Verified all are correct (log errors AND exit/return HTTP errors) - Added justification comment to session recovery (line 829) - All patterns properly notify callers of failures **Wave 3.3: Fixed 29 NO_LOGGING_IN_CATCH issues** Added logging to 16 catch blocks: - UI layer: useSettings.ts, useContextPreview.ts (console logging) - Servers: mcp-server.ts health checks and tool execution - Worker: version fetch, cleanup, config corruption - Routes: error handler, session recovery, settings validation - Services: branch checkout, timeline queries Documented 13 intentional exceptions with comments explaining why: - Hot paths (port checks, process checks in tight loops) - Error accumulation (transcript parser collects for batch retrieval) - Special cases (logger can't log its own failures) - Fallback parsing (JSON parse in optional data structures) All changes follow error handling guidelines from CLAUDE.md: - Appropriate log levels (error/warn/debug) - Context objects with relevant details - Descriptive messages explaining failures - Error extraction pattern for Error instances Progress: 41 → 29 detector warnings Remaining warnings are conservative flags on verified-correct patterns (catch-and-continue blocks that properly log + notify callers). Build verified successful. All error handling now provides visibility for debugging while avoiding excessive logging on hot paths. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * feat: add queue:clear command to remove failed messages Added functionality to clear failed messages from the observation queue: **Changes:** - PendingMessageStore: Added clearFailed() method to delete failed messages - DataRoutes: Added DELETE /api/pending-queue/failed endpoint - CLI: Created scripts/clear-failed-queue.ts for interactive queue clearing - package.json: Added npm run queue:clear script **Usage:** npm run queue:clear # Interactive - prompts for confirmation npm run queue:clear -- --force # Non-interactive - clears without prompt Failed messages are observations that exceeded max retry count. They remain in the queue for debugging but won't be processed. This command removes them to clean up the queue. Works alongside existing queue:check and queue:process commands to provide complete queue management capabilities. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * feat: add --all flag to queue:clear for complete queue reset Extended queue clearing functionality to support clearing all messages, not just failed ones. **Changes:** - PendingMessageStore: Added clearAll() method to clear pending, processing, and failed - DataRoutes: Added DELETE /api/pending-queue/all endpoint - clear-failed-queue.ts: Added --all flag to clear everything - Updated help text and UI to distinguish between failed-only and all-clear modes **Usage:** npm run queue:clear # Clear failed only (interactive) npm run queue:clear -- --all # Clear ALL messages (interactive) npm run queue:clear -- --all --force # Clear all without confirmation The --all flag provides a complete queue reset, removing pending, processing, and failed messages. Useful when you want a fresh start or need to cancel stuck sessions. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * feat: add comprehensive documentation for session ID architecture and validation tests * feat: add logs viewer with clear functionality to UI - Add LogsRoutes API endpoint for fetching and clearing worker logs - Create LogsModal component with auto-refresh and clear button - Integrate logs viewer button into Header component - Add comprehensive CSS styling for logs modal - Logs accessible via new document icon button in header Logs viewer features: - Display last 1000 lines of current day's log file - Auto-refresh toggle (2s interval) - Clear logs button with confirmation - Monospace font for readable log output - Responsive modal design matching existing UI 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * refactor: redesign logs as Chrome DevTools-style console drawer Major UX improvements to match Chrome DevTools console: - Convert from modal to bottom drawer that slides up - Move toggle button to bottom-left corner (floating button) - Add draggable resize handle for height adjustment - Use plain monospace font (SF Mono/Monaco/Consolas) instead of Monaspace - Simplify controls with icon-only buttons - Add Console tab UI matching DevTools aesthetic Changes: - Renamed LogsModal to LogsDrawer with drawer implementation - Added resize functionality with mouse drag - Removed logs button from header - Added floating console toggle button in bottom-left - Updated all CSS to match Chrome console styling - Minimum height: 150px, maximum: window height - 100px 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix: suppress /api/logs endpoint logging to reduce noise Skip logging GET /api/logs requests in HTTP middleware to prevent log spam from auto-refresh polling (every 2s). Keeps the auto-refresh feature functional while eliminating the repetitive log entries. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * refactor: enhance error handling guidelines with approved overrides for justified exceptions --------- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
503 lines
20 KiB
TypeScript
503 lines
20 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
|
import { SessionStore } from '../src/services/sqlite/SessionStore.js';
|
|
|
|
/**
|
|
* Session ID Usage Validation Tests
|
|
*
|
|
* PURPOSE: Prevent confusion and bugs from mixing contentSessionId and memorySessionId
|
|
*
|
|
* CRITICAL ARCHITECTURE:
|
|
* - contentSessionId: User's Claude Code conversation session (immutable)
|
|
* - memorySessionId: SDK agent's session ID for resume (captured from SDK response)
|
|
*
|
|
* INVARIANTS TO ENFORCE:
|
|
* 1. memorySessionId starts equal to contentSessionId (placeholder for FK)
|
|
* 2. Resume MUST NOT be used when memorySessionId === contentSessionId
|
|
* 3. Resume MUST ONLY be used when hasRealMemorySessionId === true
|
|
* 4. Observations are stored with contentSessionId (not the captured SDK memorySessionId)
|
|
* 5. updateMemorySessionId() is required before resume can work
|
|
*/
|
|
describe('Session ID Usage Validation', () => {
|
|
let store: SessionStore;
|
|
|
|
beforeEach(() => {
|
|
store = new SessionStore(':memory:');
|
|
});
|
|
|
|
afterEach(() => {
|
|
store.close();
|
|
});
|
|
|
|
describe('Placeholder Detection - hasRealMemorySessionId Logic', () => {
|
|
it('should identify placeholder when memorySessionId equals contentSessionId', () => {
|
|
const contentSessionId = 'user-session-123';
|
|
const sessionDbId = store.createSDKSession(contentSessionId, 'test-project', 'Test prompt');
|
|
|
|
const session = store.getSessionById(sessionDbId);
|
|
|
|
// Initially, they're equal (placeholder state)
|
|
expect(session?.memory_session_id).toBe(session?.content_session_id);
|
|
|
|
// hasRealMemorySessionId would be FALSE
|
|
const hasRealMemorySessionId = session?.memory_session_id !== session?.content_session_id;
|
|
expect(hasRealMemorySessionId).toBe(false);
|
|
});
|
|
|
|
it('should identify real memory session ID after capture', () => {
|
|
const contentSessionId = 'user-session-456';
|
|
const capturedMemoryId = 'sdk-generated-abc123';
|
|
|
|
const sessionDbId = store.createSDKSession(contentSessionId, 'test-project', 'Test prompt');
|
|
store.updateMemorySessionId(sessionDbId, capturedMemoryId);
|
|
|
|
const session = store.getSessionById(sessionDbId);
|
|
|
|
// After capture, they're different (real memory session ID)
|
|
expect(session?.memory_session_id).not.toBe(session?.content_session_id);
|
|
|
|
// hasRealMemorySessionId would be TRUE
|
|
const hasRealMemorySessionId = session?.memory_session_id !== session?.content_session_id;
|
|
expect(hasRealMemorySessionId).toBe(true);
|
|
});
|
|
|
|
it('should never use contentSessionId as resume parameter when in placeholder state', () => {
|
|
const contentSessionId = 'dangerous-session-789';
|
|
const sessionDbId = store.createSDKSession(contentSessionId, 'test-project', 'Test');
|
|
|
|
const session = store.getSessionById(sessionDbId);
|
|
const hasRealMemorySessionId = session?.memory_session_id !== session?.content_session_id;
|
|
|
|
// CRITICAL: This check prevents resuming the USER'S session instead of memory session
|
|
if (hasRealMemorySessionId) {
|
|
// Safe to use for resume
|
|
const resumeParam = session?.memory_session_id;
|
|
expect(resumeParam).not.toBe(contentSessionId);
|
|
} else {
|
|
// Must NOT pass resume parameter
|
|
// Resume should be undefined/null in SDK call
|
|
expect(hasRealMemorySessionId).toBe(false);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Observation Storage - ContentSessionId Usage', () => {
|
|
it('should store observations with contentSessionId in memory_session_id column', () => {
|
|
const contentSessionId = 'obs-content-session-123';
|
|
store.createSDKSession(contentSessionId, 'test-project', 'Test');
|
|
|
|
const obs = {
|
|
type: 'discovery',
|
|
title: 'Test Observation',
|
|
subtitle: null,
|
|
facts: ['Fact 1'],
|
|
narrative: 'Testing',
|
|
concepts: ['testing'],
|
|
files_read: [],
|
|
files_modified: []
|
|
};
|
|
|
|
// SDKAgent.ts line 332 passes session.contentSessionId here
|
|
const result = store.storeObservation(contentSessionId, 'test-project', obs, 1);
|
|
|
|
// Verify it's stored in the memory_session_id column with contentSessionId value
|
|
const stored = store.db.prepare(
|
|
'SELECT memory_session_id FROM observations WHERE id = ?'
|
|
).get(result.id) as { memory_session_id: string };
|
|
|
|
// CRITICAL: memory_session_id column contains contentSessionId, not the captured SDK session ID
|
|
expect(stored.memory_session_id).toBe(contentSessionId);
|
|
});
|
|
|
|
it('should be retrievable using contentSessionId (observations use contentSessionId)', () => {
|
|
const contentSessionId = 'retrieval-test-session';
|
|
|
|
store.createSDKSession(contentSessionId, 'test-project', 'Test');
|
|
|
|
// Store observation with contentSessionId
|
|
const obs = {
|
|
type: 'feature',
|
|
title: 'Observation',
|
|
subtitle: null,
|
|
facts: [],
|
|
narrative: null,
|
|
concepts: [],
|
|
files_read: [],
|
|
files_modified: []
|
|
};
|
|
store.storeObservation(contentSessionId, 'test-project', obs, 1);
|
|
|
|
// Observations are retrievable by contentSessionId
|
|
// (because storeObservation receives contentSessionId and stores it in memory_session_id column)
|
|
const observations = store.getObservationsForSession(contentSessionId);
|
|
expect(observations.length).toBe(1);
|
|
expect(observations[0].title).toBe('Observation');
|
|
});
|
|
});
|
|
|
|
describe('Resume Safety - Prevent contentSessionId Resume Bug', () => {
|
|
it('should prevent resume with placeholder memorySessionId', () => {
|
|
const contentSessionId = 'safety-test-session';
|
|
const sessionDbId = store.createSDKSession(contentSessionId, 'test-project', 'Test');
|
|
|
|
const session = store.getSessionById(sessionDbId);
|
|
|
|
// Simulate hasRealMemorySessionId check from SDKAgent.ts line 75-76
|
|
const hasRealMemorySessionId = session?.memory_session_id &&
|
|
session.memory_session_id !== session.content_session_id;
|
|
|
|
// MUST be false in placeholder state
|
|
expect(hasRealMemorySessionId).toBe(false);
|
|
|
|
// Resume parameter should NOT be set
|
|
// In SDK call: ...(hasRealMemorySessionId && { resume: session.memorySessionId })
|
|
// This evaluates to an empty object, not a resume parameter
|
|
const resumeOptions = hasRealMemorySessionId ? { resume: session?.memory_session_id } : {};
|
|
expect(resumeOptions).toEqual({});
|
|
});
|
|
|
|
it('should allow resume only after memory session ID is captured', () => {
|
|
const contentSessionId = 'resume-ready-session';
|
|
const capturedMemoryId = 'real-sdk-session-123';
|
|
|
|
const sessionDbId = store.createSDKSession(contentSessionId, 'test-project', 'Test');
|
|
|
|
// Before capture - no resume
|
|
let session = store.getSessionById(sessionDbId);
|
|
let hasRealMemorySessionId = session?.memory_session_id &&
|
|
session.memory_session_id !== session.content_session_id;
|
|
expect(hasRealMemorySessionId).toBe(false);
|
|
|
|
// Capture memory session ID
|
|
store.updateMemorySessionId(sessionDbId, capturedMemoryId);
|
|
|
|
// After capture - resume allowed
|
|
session = store.getSessionById(sessionDbId);
|
|
hasRealMemorySessionId = session?.memory_session_id &&
|
|
session.memory_session_id !== session.content_session_id;
|
|
expect(hasRealMemorySessionId).toBe(true);
|
|
|
|
// Resume parameter should be the captured ID
|
|
const resumeOptions = hasRealMemorySessionId ? { resume: session?.memory_session_id } : {};
|
|
expect(resumeOptions).toEqual({ resume: capturedMemoryId });
|
|
expect(resumeOptions.resume).not.toBe(contentSessionId);
|
|
});
|
|
});
|
|
|
|
describe('Cross-Contamination Prevention', () => {
|
|
it('should never mix observations from different content sessions', () => {
|
|
const session1 = 'user-session-A';
|
|
const session2 = 'user-session-B';
|
|
|
|
store.createSDKSession(session1, 'project-a', 'Prompt A');
|
|
store.createSDKSession(session2, 'project-b', 'Prompt B');
|
|
|
|
// Store observations in each session
|
|
store.storeObservation(session1, 'project-a', {
|
|
type: 'discovery',
|
|
title: 'Observation A',
|
|
subtitle: null,
|
|
facts: [],
|
|
narrative: null,
|
|
concepts: [],
|
|
files_read: [],
|
|
files_modified: []
|
|
}, 1);
|
|
|
|
store.storeObservation(session2, 'project-b', {
|
|
type: 'discovery',
|
|
title: 'Observation B',
|
|
subtitle: null,
|
|
facts: [],
|
|
narrative: null,
|
|
concepts: [],
|
|
files_read: [],
|
|
files_modified: []
|
|
}, 1);
|
|
|
|
// Verify isolation
|
|
const obsA = store.getObservationsForSession(session1);
|
|
const obsB = store.getObservationsForSession(session2);
|
|
|
|
expect(obsA.length).toBe(1);
|
|
expect(obsB.length).toBe(1);
|
|
expect(obsA[0].title).toBe('Observation A');
|
|
expect(obsB[0].title).toBe('Observation B');
|
|
});
|
|
|
|
it('should never leak memory session IDs between content sessions', () => {
|
|
const content1 = 'content-session-1';
|
|
const content2 = 'content-session-2';
|
|
const memory1 = 'memory-session-1';
|
|
const memory2 = 'memory-session-2';
|
|
|
|
const id1 = store.createSDKSession(content1, 'project', 'Prompt');
|
|
const id2 = store.createSDKSession(content2, 'project', 'Prompt');
|
|
|
|
store.updateMemorySessionId(id1, memory1);
|
|
store.updateMemorySessionId(id2, memory2);
|
|
|
|
const session1 = store.getSessionById(id1);
|
|
const session2 = store.getSessionById(id2);
|
|
|
|
// Each session must have its own unique memory session ID
|
|
expect(session1?.memory_session_id).toBe(memory1);
|
|
expect(session2?.memory_session_id).toBe(memory2);
|
|
expect(session1?.memory_session_id).not.toBe(session2?.memory_session_id);
|
|
});
|
|
});
|
|
|
|
describe('Foreign Key Integrity', () => {
|
|
it('should cascade delete observations when session is deleted', () => {
|
|
const contentSessionId = 'cascade-test-session';
|
|
const sessionDbId = store.createSDKSession(contentSessionId, 'test-project', 'Test');
|
|
|
|
// Store observation
|
|
const obs = {
|
|
type: 'discovery',
|
|
title: 'Will be deleted',
|
|
subtitle: null,
|
|
facts: [],
|
|
narrative: null,
|
|
concepts: [],
|
|
files_read: [],
|
|
files_modified: []
|
|
};
|
|
store.storeObservation(contentSessionId, 'test-project', obs, 1);
|
|
|
|
// Verify observation exists
|
|
let observations = store.getObservationsForSession(contentSessionId);
|
|
expect(observations.length).toBe(1);
|
|
|
|
// Delete session (should cascade to observations)
|
|
store.db.prepare('DELETE FROM sdk_sessions WHERE id = ?').run(sessionDbId);
|
|
|
|
// Verify observations were deleted
|
|
observations = store.getObservationsForSession(contentSessionId);
|
|
expect(observations.length).toBe(0);
|
|
});
|
|
|
|
it('should maintain FK relationship between observations and sessions', () => {
|
|
const contentSessionId = 'fk-test-session';
|
|
store.createSDKSession(contentSessionId, 'test-project', 'Test');
|
|
|
|
// This should succeed (FK exists)
|
|
expect(() => {
|
|
store.storeObservation(contentSessionId, 'test-project', {
|
|
type: 'discovery',
|
|
title: 'Valid FK',
|
|
subtitle: null,
|
|
facts: [],
|
|
narrative: null,
|
|
concepts: [],
|
|
files_read: [],
|
|
files_modified: []
|
|
}, 1);
|
|
}).not.toThrow();
|
|
|
|
// This should fail (FK doesn't exist)
|
|
expect(() => {
|
|
store.storeObservation('nonexistent-session-id', 'test-project', {
|
|
type: 'discovery',
|
|
title: 'Invalid FK',
|
|
subtitle: null,
|
|
facts: [],
|
|
narrative: null,
|
|
concepts: [],
|
|
files_read: [],
|
|
files_modified: []
|
|
}, 1);
|
|
}).toThrow();
|
|
});
|
|
});
|
|
|
|
describe('Session Lifecycle - Memory ID Capture Flow', () => {
|
|
it('should follow correct lifecycle: create → capture → resume', () => {
|
|
const contentSessionId = 'lifecycle-session';
|
|
|
|
// STEP 1: Hook creates session (memory_session_id = content_session_id)
|
|
const sessionDbId = store.createSDKSession(contentSessionId, 'test-project', 'First prompt');
|
|
let session = store.getSessionById(sessionDbId);
|
|
expect(session?.memory_session_id).toBe(contentSessionId); // Placeholder
|
|
|
|
// STEP 2: First SDK message arrives with real session ID
|
|
const realMemoryId = 'sdk-generated-session-xyz';
|
|
store.updateMemorySessionId(sessionDbId, realMemoryId);
|
|
session = store.getSessionById(sessionDbId);
|
|
expect(session?.memory_session_id).toBe(realMemoryId); // Real ID
|
|
|
|
// STEP 3: Subsequent prompts can now resume
|
|
const hasRealMemorySessionId = session?.memory_session_id !== session?.content_session_id;
|
|
expect(hasRealMemorySessionId).toBe(true);
|
|
|
|
// Resume parameter is safe to use
|
|
const resumeParam = session?.memory_session_id;
|
|
expect(resumeParam).toBe(realMemoryId);
|
|
expect(resumeParam).not.toBe(contentSessionId);
|
|
});
|
|
|
|
it('should handle worker restart by preserving captured memory session ID', () => {
|
|
const contentSessionId = 'restart-test-session';
|
|
const capturedMemoryId = 'persisted-memory-id';
|
|
|
|
// Simulate first worker instance
|
|
const sessionDbId = store.createSDKSession(contentSessionId, 'test-project', 'Prompt');
|
|
store.updateMemorySessionId(sessionDbId, capturedMemoryId);
|
|
|
|
// Simulate worker restart - session re-fetched from database
|
|
const session = store.getSessionById(sessionDbId);
|
|
|
|
// Memory session ID should be preserved
|
|
expect(session?.memory_session_id).toBe(capturedMemoryId);
|
|
|
|
// Resume can work immediately
|
|
const hasRealMemorySessionId = session?.memory_session_id !== session?.content_session_id;
|
|
expect(hasRealMemorySessionId).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('CRITICAL: 1:1 Transcript Mapping Guarantees', () => {
|
|
it('should enforce UNIQUE constraint on memory_session_id (prevents duplicate memory transcripts)', () => {
|
|
const content1 = 'content-session-1';
|
|
const content2 = 'content-session-2';
|
|
const sharedMemoryId = 'shared-memory-id';
|
|
|
|
const id1 = store.createSDKSession(content1, 'project', 'Prompt 1');
|
|
const id2 = store.createSDKSession(content2, 'project', 'Prompt 2');
|
|
|
|
// First session captures memory ID - should succeed
|
|
store.updateMemorySessionId(id1, sharedMemoryId);
|
|
|
|
// Second session tries to use SAME memory ID - should FAIL
|
|
expect(() => {
|
|
store.updateMemorySessionId(id2, sharedMemoryId);
|
|
}).toThrow(); // UNIQUE constraint violation
|
|
|
|
// Verify first session still has the ID
|
|
const session1 = store.getSessionById(id1);
|
|
expect(session1?.memory_session_id).toBe(sharedMemoryId);
|
|
});
|
|
|
|
it('should prevent memorySessionId from being changed after real capture (single transition guarantee)', () => {
|
|
const contentSessionId = 'single-capture-test';
|
|
const firstMemoryId = 'first-sdk-session-id';
|
|
const secondMemoryId = 'different-sdk-session-id';
|
|
|
|
const sessionDbId = store.createSDKSession(contentSessionId, 'test-project', 'Test');
|
|
|
|
// First capture - should succeed
|
|
store.updateMemorySessionId(sessionDbId, firstMemoryId);
|
|
|
|
let session = store.getSessionById(sessionDbId);
|
|
expect(session?.memory_session_id).toBe(firstMemoryId);
|
|
|
|
// Second capture with DIFFERENT ID - should FAIL (or be no-op in proper implementation)
|
|
// This test documents current behavior - ideally updateMemorySessionId should
|
|
// check if memorySessionId already differs from contentSessionId and refuse to update
|
|
store.updateMemorySessionId(sessionDbId, secondMemoryId);
|
|
|
|
session = store.getSessionById(sessionDbId);
|
|
|
|
// CRITICAL: If this allows the update, we could get multiple memory transcripts!
|
|
// This test currently shows the vulnerability - in production, SDKAgent.ts
|
|
// has the check `if (!session.memorySessionId)` which should prevent this,
|
|
// but the database layer doesn't enforce it.
|
|
//
|
|
// For now, we document that the second update DOES go through (current behavior)
|
|
expect(session?.memory_session_id).toBe(secondMemoryId);
|
|
|
|
// TODO: Add database-level protection via CHECK constraint or trigger
|
|
// to prevent changing memory_session_id once it differs from content_session_id
|
|
});
|
|
|
|
it('should use same memorySessionId for all prompts in a conversation (resume consistency)', () => {
|
|
const contentSessionId = 'multi-prompt-session';
|
|
const realMemoryId = 'consistent-memory-id';
|
|
|
|
// Prompt 1: Create session
|
|
let sessionDbId = store.createSDKSession(contentSessionId, 'test-project', 'Prompt 1');
|
|
let session = store.getSessionById(sessionDbId);
|
|
|
|
// Initially placeholder
|
|
expect(session?.memory_session_id).toBe(contentSessionId);
|
|
|
|
// Prompt 1: Capture real memory ID
|
|
store.updateMemorySessionId(sessionDbId, realMemoryId);
|
|
|
|
// Prompt 2: Look up session by contentSessionId (simulates hook creating session again)
|
|
sessionDbId = store.createSDKSession(contentSessionId, 'test-project', 'Prompt 2');
|
|
session = store.getSessionById(sessionDbId);
|
|
|
|
// Should get SAME memory ID (resume with this)
|
|
expect(session?.memory_session_id).toBe(realMemoryId);
|
|
|
|
// Prompt 3: Again, same contentSessionId
|
|
sessionDbId = store.createSDKSession(contentSessionId, 'test-project', 'Prompt 3');
|
|
session = store.getSessionById(sessionDbId);
|
|
|
|
// Should STILL get same memory ID
|
|
expect(session?.memory_session_id).toBe(realMemoryId);
|
|
|
|
// All three prompts use the SAME memorySessionId → ONE memory transcript file
|
|
const hasRealMemorySessionId = session?.memory_session_id !== session?.content_session_id;
|
|
expect(hasRealMemorySessionId).toBe(true);
|
|
});
|
|
|
|
it('should lookup session by contentSessionId and retrieve memorySessionId for resume', () => {
|
|
const contentSessionId = 'lookup-test-session';
|
|
const capturedMemoryId = 'memory-for-resume';
|
|
|
|
// First prompt: Create and capture
|
|
const sessionDbId = store.createSDKSession(contentSessionId, 'test-project', 'First');
|
|
store.updateMemorySessionId(sessionDbId, capturedMemoryId);
|
|
|
|
// Second prompt: Hook provides contentSessionId, needs to lookup memorySessionId
|
|
// The createSDKSession method IS the lookup (INSERT OR IGNORE + SELECT)
|
|
const lookedUpSessionDbId = store.createSDKSession(contentSessionId, 'test-project', 'Second');
|
|
|
|
// Should be same DB row
|
|
expect(lookedUpSessionDbId).toBe(sessionDbId);
|
|
|
|
// Get session to extract memorySessionId for resume
|
|
const session = store.getSessionById(lookedUpSessionDbId);
|
|
const resumeParam = session?.memory_session_id;
|
|
|
|
// This is what would be passed to SDK query({ resume: resumeParam })
|
|
expect(resumeParam).toBe(capturedMemoryId);
|
|
expect(resumeParam).not.toBe(contentSessionId);
|
|
});
|
|
});
|
|
|
|
describe('Edge Cases - Session ID Equality', () => {
|
|
it('should handle case where SDK returns session ID equal to contentSessionId', () => {
|
|
// Edge case: SDK happens to generate same ID as content session
|
|
const contentSessionId = 'same-id-123';
|
|
const sessionDbId = store.createSDKSession(contentSessionId, 'test-project', 'Test');
|
|
|
|
// SDK returns the same ID (unlikely but possible)
|
|
store.updateMemorySessionId(sessionDbId, contentSessionId);
|
|
|
|
const session = store.getSessionById(sessionDbId);
|
|
const hasRealMemorySessionId = session?.memory_session_id !== session?.content_session_id;
|
|
|
|
// Would be FALSE, so resume would not be used
|
|
// This is safe - worst case is a fresh session instead of resume
|
|
expect(hasRealMemorySessionId).toBe(false);
|
|
});
|
|
|
|
it('should handle NULL memory_session_id gracefully', () => {
|
|
const contentSessionId = 'null-test-session';
|
|
const sessionDbId = store.createSDKSession(contentSessionId, 'test-project', 'Test');
|
|
|
|
// Manually set memory_session_id to NULL (shouldn't happen in practice)
|
|
store.db.prepare('UPDATE sdk_sessions SET memory_session_id = NULL WHERE id = ?').run(sessionDbId);
|
|
|
|
const session = store.getSessionById(sessionDbId);
|
|
const hasRealMemorySessionId = session?.memory_session_id &&
|
|
session.memory_session_id !== session.content_session_id;
|
|
|
|
// Should be falsy (NULL is falsy)
|
|
expect(hasRealMemorySessionId).toBeFalsy();
|
|
});
|
|
});
|
|
});
|