wip: [01-stabilize] paused at task 1/1 - OCR Hallucination Immune logic via Semantic delta window and fret-isolation

This commit is contained in:
2026-03-29 22:08:40 +09:00
parent aca7bf592a
commit 2507de45d3
4289 changed files with 732689 additions and 28672 deletions

View File

@@ -0,0 +1,199 @@
/**
* Data integrity tests for TRIAGE-03
* Tests: content-hash deduplication, project name collision, empty project guard, stuck isProcessing
*/
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { ClaudeMemDatabase } from '../../src/services/sqlite/Database.js';
import {
storeObservation,
computeObservationContentHash,
findDuplicateObservation,
} from '../../src/services/sqlite/observations/store.js';
import {
createSDKSession,
updateMemorySessionId,
} from '../../src/services/sqlite/Sessions.js';
import { storeObservations } from '../../src/services/sqlite/transactions.js';
import { PendingMessageStore } from '../../src/services/sqlite/PendingMessageStore.js';
import type { ObservationInput } from '../../src/services/sqlite/observations/types.js';
import type { Database } from 'bun:sqlite';
function createObservationInput(overrides: Partial<ObservationInput> = {}): ObservationInput {
return {
type: 'discovery',
title: 'Test Observation',
subtitle: 'Test Subtitle',
facts: ['fact1', 'fact2'],
narrative: 'Test narrative content',
concepts: ['concept1', 'concept2'],
files_read: ['/path/to/file1.ts'],
files_modified: ['/path/to/file2.ts'],
...overrides,
};
}
function createSessionWithMemoryId(db: Database, contentSessionId: string, memorySessionId: string, project: string = 'test-project'): string {
const sessionId = createSDKSession(db, contentSessionId, project, 'initial prompt');
updateMemorySessionId(db, sessionId, memorySessionId);
return memorySessionId;
}
describe('TRIAGE-03: Data Integrity', () => {
let db: Database;
beforeEach(() => {
db = new ClaudeMemDatabase(':memory:').db;
});
afterEach(() => {
db.close();
});
describe('Content-hash deduplication', () => {
it('computeObservationContentHash produces consistent hashes', () => {
const hash1 = computeObservationContentHash('session-1', 'Title A', 'Narrative A');
const hash2 = computeObservationContentHash('session-1', 'Title A', 'Narrative A');
expect(hash1).toBe(hash2);
expect(hash1.length).toBe(16);
});
it('computeObservationContentHash produces different hashes for different content', () => {
const hash1 = computeObservationContentHash('session-1', 'Title A', 'Narrative A');
const hash2 = computeObservationContentHash('session-1', 'Title B', 'Narrative B');
expect(hash1).not.toBe(hash2);
});
it('computeObservationContentHash handles nulls', () => {
const hash = computeObservationContentHash('session-1', null, null);
expect(hash.length).toBe(16);
});
it('storeObservation deduplicates identical observations within 30s window', () => {
const memId = createSessionWithMemoryId(db, 'content-dedup-1', 'mem-dedup-1');
const obs = createObservationInput({ title: 'Same Title', narrative: 'Same Narrative' });
const now = Date.now();
const result1 = storeObservation(db, memId, 'test-project', obs, 1, 0, now);
const result2 = storeObservation(db, memId, 'test-project', obs, 1, 0, now + 1000);
// Second call should return the same id as the first (deduped)
expect(result2.id).toBe(result1.id);
});
it('storeObservation allows same content after dedup window expires', () => {
const memId = createSessionWithMemoryId(db, 'content-dedup-2', 'mem-dedup-2');
const obs = createObservationInput({ title: 'Same Title', narrative: 'Same Narrative' });
const now = Date.now();
const result1 = storeObservation(db, memId, 'test-project', obs, 1, 0, now);
// 31 seconds later — outside the 30s window
const result2 = storeObservation(db, memId, 'test-project', obs, 1, 0, now + 31_000);
expect(result2.id).not.toBe(result1.id);
});
it('storeObservation allows different content at same time', () => {
const memId = createSessionWithMemoryId(db, 'content-dedup-3', 'mem-dedup-3');
const obs1 = createObservationInput({ title: 'Title A', narrative: 'Narrative A' });
const obs2 = createObservationInput({ title: 'Title B', narrative: 'Narrative B' });
const now = Date.now();
const result1 = storeObservation(db, memId, 'test-project', obs1, 1, 0, now);
const result2 = storeObservation(db, memId, 'test-project', obs2, 1, 0, now);
expect(result2.id).not.toBe(result1.id);
});
it('content_hash column is populated on new observations', () => {
const memId = createSessionWithMemoryId(db, 'content-hash-col', 'mem-hash-col');
const obs = createObservationInput();
storeObservation(db, memId, 'test-project', obs);
const row = db.prepare('SELECT content_hash FROM observations LIMIT 1').get() as { content_hash: string };
expect(row.content_hash).toBeTruthy();
expect(row.content_hash.length).toBe(16);
});
});
describe('Transaction-level deduplication', () => {
it('storeObservations deduplicates within a batch', () => {
const memId = createSessionWithMemoryId(db, 'content-tx-1', 'mem-tx-1');
const obs = createObservationInput({ title: 'Duplicate', narrative: 'Same content' });
const result = storeObservations(db, memId, 'test-project', [obs, obs, obs], null);
// First is inserted, second and third are deduped to the first
expect(result.observationIds.length).toBe(3);
expect(result.observationIds[1]).toBe(result.observationIds[0]);
expect(result.observationIds[2]).toBe(result.observationIds[0]);
// Only 1 row in the database
const count = db.prepare('SELECT COUNT(*) as count FROM observations').get() as { count: number };
expect(count.count).toBe(1);
});
});
describe('Empty project string guard', () => {
it('storeObservation replaces empty project with cwd-derived name', () => {
const memId = createSessionWithMemoryId(db, 'content-empty-proj', 'mem-empty-proj');
const obs = createObservationInput();
const result = storeObservation(db, memId, '', obs);
const row = db.prepare('SELECT project FROM observations WHERE id = ?').get(result.id) as { project: string };
// Should not be empty — will be derived from cwd
expect(row.project).toBeTruthy();
expect(row.project.length).toBeGreaterThan(0);
});
});
describe('Stuck isProcessing flag', () => {
it('hasAnyPendingWork resets stuck processing messages older than 5 minutes', () => {
// Create a pending_messages table entry that's stuck in 'processing'
const sessionId = createSDKSession(db, 'content-stuck', 'stuck-project', 'test');
// Insert a processing message stuck for 6 minutes
const sixMinutesAgo = Date.now() - (6 * 60 * 1000);
db.prepare(`
INSERT INTO pending_messages (session_db_id, content_session_id, message_type, status, retry_count, created_at_epoch, started_processing_at_epoch)
VALUES (?, 'content-stuck', 'observation', 'processing', 0, ?, ?)
`).run(sessionId, sixMinutesAgo, sixMinutesAgo);
const pendingStore = new PendingMessageStore(db);
// hasAnyPendingWork should reset the stuck message and still return true (it's now pending again)
const hasPending = pendingStore.hasAnyPendingWork();
expect(hasPending).toBe(true);
// Verify the message was reset to 'pending'
const msg = db.prepare('SELECT status FROM pending_messages WHERE content_session_id = ?').get('content-stuck') as { status: string };
expect(msg.status).toBe('pending');
});
it('hasAnyPendingWork does NOT reset recently-started processing messages', () => {
const sessionId = createSDKSession(db, 'content-recent', 'recent-project', 'test');
// Insert a processing message started 1 minute ago (well within 5-minute threshold)
const oneMinuteAgo = Date.now() - (1 * 60 * 1000);
db.prepare(`
INSERT INTO pending_messages (session_db_id, content_session_id, message_type, status, retry_count, created_at_epoch, started_processing_at_epoch)
VALUES (?, 'content-recent', 'observation', 'processing', 0, ?, ?)
`).run(sessionId, oneMinuteAgo, oneMinuteAgo);
const pendingStore = new PendingMessageStore(db);
const hasPending = pendingStore.hasAnyPendingWork();
expect(hasPending).toBe(true);
// Verify the message is still 'processing' (not reset)
const msg = db.prepare('SELECT status FROM pending_messages WHERE content_session_id = ?').get('content-recent') as { status: string };
expect(msg.status).toBe('processing');
});
it('hasAnyPendingWork returns false when no pending or processing messages exist', () => {
const pendingStore = new PendingMessageStore(db);
expect(pendingStore.hasAnyPendingWork()).toBe(false);
});
});
});

View File

@@ -0,0 +1,231 @@
/**
* Observations module tests
* Tests modular observation functions with in-memory database
*
* Sources:
* - API patterns from src/services/sqlite/observations/store.ts
* - API patterns from src/services/sqlite/observations/get.ts
* - API patterns from src/services/sqlite/observations/recent.ts
* - Type definitions from src/services/sqlite/observations/types.ts
*/
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { ClaudeMemDatabase } from '../../src/services/sqlite/Database.js';
import {
storeObservation,
getObservationById,
getRecentObservations,
} from '../../src/services/sqlite/Observations.js';
import {
createSDKSession,
updateMemorySessionId,
} from '../../src/services/sqlite/Sessions.js';
import type { ObservationInput } from '../../src/services/sqlite/observations/types.js';
import type { Database } from 'bun:sqlite';
describe('Observations Module', () => {
let db: Database;
beforeEach(() => {
db = new ClaudeMemDatabase(':memory:').db;
});
afterEach(() => {
db.close();
});
// Helper to create a valid observation input
function createObservationInput(overrides: Partial<ObservationInput> = {}): ObservationInput {
return {
type: 'discovery',
title: 'Test Observation',
subtitle: 'Test Subtitle',
facts: ['fact1', 'fact2'],
narrative: 'Test narrative content',
concepts: ['concept1', 'concept2'],
files_read: ['/path/to/file1.ts'],
files_modified: ['/path/to/file2.ts'],
...overrides,
};
}
// Helper to create a session and return memory_session_id for FK constraints
function createSessionWithMemoryId(contentSessionId: string, memorySessionId: string, project: string = 'test-project'): string {
const sessionId = createSDKSession(db, contentSessionId, project, 'initial prompt');
updateMemorySessionId(db, sessionId, memorySessionId);
return memorySessionId;
}
describe('storeObservation', () => {
it('should store observation and return id and createdAtEpoch', () => {
const memorySessionId = createSessionWithMemoryId('content-123', 'mem-session-123');
const project = 'test-project';
const observation = createObservationInput();
const result = storeObservation(db, memorySessionId, project, observation);
expect(typeof result.id).toBe('number');
expect(result.id).toBeGreaterThan(0);
expect(typeof result.createdAtEpoch).toBe('number');
expect(result.createdAtEpoch).toBeGreaterThan(0);
});
it('should store all observation fields correctly', () => {
const memorySessionId = createSessionWithMemoryId('content-456', 'mem-session-456');
const project = 'test-project';
const observation = createObservationInput({
type: 'bugfix',
title: 'Fixed critical bug',
subtitle: 'Memory leak',
facts: ['leak found', 'patched'],
narrative: 'Fixed memory leak in parser',
concepts: ['memory', 'gc'],
files_read: ['/src/parser.ts'],
files_modified: ['/src/parser.ts', '/tests/parser.test.ts'],
});
const result = storeObservation(db, memorySessionId, project, observation, 1, 100);
const stored = getObservationById(db, result.id);
expect(stored).not.toBeNull();
expect(stored?.type).toBe('bugfix');
expect(stored?.title).toBe('Fixed critical bug');
expect(stored?.memory_session_id).toBe(memorySessionId);
expect(stored?.project).toBe(project);
});
it('should respect overrideTimestampEpoch', () => {
const memorySessionId = createSessionWithMemoryId('content-789', 'mem-session-789');
const project = 'test-project';
const observation = createObservationInput();
const pastTimestamp = 1600000000000; // Sep 13, 2020
const result = storeObservation(
db,
memorySessionId,
project,
observation,
1,
0,
pastTimestamp
);
expect(result.createdAtEpoch).toBe(pastTimestamp);
const stored = getObservationById(db, result.id);
expect(stored?.created_at_epoch).toBe(pastTimestamp);
// Verify ISO string matches epoch
expect(new Date(stored!.created_at).getTime()).toBe(pastTimestamp);
});
it('should use current time when overrideTimestampEpoch not provided', () => {
const memorySessionId = createSessionWithMemoryId('content-now', 'session-now');
const before = Date.now();
const result = storeObservation(
db,
memorySessionId,
'project',
createObservationInput()
);
const after = Date.now();
expect(result.createdAtEpoch).toBeGreaterThanOrEqual(before);
expect(result.createdAtEpoch).toBeLessThanOrEqual(after);
});
it('should handle null subtitle and narrative', () => {
const memorySessionId = createSessionWithMemoryId('content-null', 'session-null');
const observation = createObservationInput({
subtitle: null,
narrative: null,
});
const result = storeObservation(db, memorySessionId, 'project', observation);
const stored = getObservationById(db, result.id);
expect(stored).not.toBeNull();
expect(stored?.id).toBe(result.id);
});
});
describe('getObservationById', () => {
it('should retrieve observation by ID', () => {
const memorySessionId = createSessionWithMemoryId('content-get', 'session-get');
const observation = createObservationInput({ title: 'Unique Title' });
const result = storeObservation(db, memorySessionId, 'project', observation);
const retrieved = getObservationById(db, result.id);
expect(retrieved).not.toBeNull();
expect(retrieved?.id).toBe(result.id);
expect(retrieved?.title).toBe('Unique Title');
});
it('should return null for non-existent observation', () => {
const retrieved = getObservationById(db, 99999);
expect(retrieved).toBeNull();
});
});
describe('getRecentObservations', () => {
it('should return observations ordered by date DESC', () => {
const project = 'test-project';
// Create sessions and store observations with different timestamps (oldest first)
const mem1 = createSessionWithMemoryId('content-1', 'session1', project);
const mem2 = createSessionWithMemoryId('content-2', 'session2', project);
const mem3 = createSessionWithMemoryId('content-3', 'session3', project);
storeObservation(db, mem1, project, createObservationInput(), 1, 0, 1000000000000);
storeObservation(db, mem2, project, createObservationInput(), 2, 0, 2000000000000);
storeObservation(db, mem3, project, createObservationInput(), 3, 0, 3000000000000);
const recent = getRecentObservations(db, project, 10);
expect(recent.length).toBe(3);
// Most recent first (DESC order)
expect(recent[0].prompt_number).toBe(3);
expect(recent[1].prompt_number).toBe(2);
expect(recent[2].prompt_number).toBe(1);
});
it('should respect limit parameter', () => {
const project = 'test-project';
const mem1 = createSessionWithMemoryId('content-lim1', 'session-lim1', project);
const mem2 = createSessionWithMemoryId('content-lim2', 'session-lim2', project);
const mem3 = createSessionWithMemoryId('content-lim3', 'session-lim3', project);
storeObservation(db, mem1, project, createObservationInput(), 1, 0, 1000000000000);
storeObservation(db, mem2, project, createObservationInput(), 2, 0, 2000000000000);
storeObservation(db, mem3, project, createObservationInput(), 3, 0, 3000000000000);
const recent = getRecentObservations(db, project, 2);
expect(recent.length).toBe(2);
});
it('should filter by project', () => {
const memA1 = createSessionWithMemoryId('content-a1', 'session-a1', 'project-a');
const memB1 = createSessionWithMemoryId('content-b1', 'session-b1', 'project-b');
const memA2 = createSessionWithMemoryId('content-a2', 'session-a2', 'project-a');
storeObservation(db, memA1, 'project-a', createObservationInput());
storeObservation(db, memB1, 'project-b', createObservationInput());
storeObservation(db, memA2, 'project-a', createObservationInput());
const recentA = getRecentObservations(db, 'project-a', 10);
const recentB = getRecentObservations(db, 'project-b', 10);
expect(recentA.length).toBe(2);
expect(recentB.length).toBe(1);
});
it('should return empty array for project with no observations', () => {
const recent = getRecentObservations(db, 'nonexistent-project', 10);
expect(recent).toEqual([]);
});
});
});

View File

@@ -0,0 +1,129 @@
/**
* Prompts module tests
* Tests modular prompt functions with in-memory database
*
* Sources:
* - API patterns from src/services/sqlite/prompts/store.ts
* - API patterns from src/services/sqlite/prompts/get.ts
* - Test pattern from tests/session_store.test.ts
*/
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { ClaudeMemDatabase } from '../../src/services/sqlite/Database.js';
import {
saveUserPrompt,
getPromptNumberFromUserPrompts,
} from '../../src/services/sqlite/Prompts.js';
import { createSDKSession } from '../../src/services/sqlite/Sessions.js';
import type { Database } from 'bun:sqlite';
describe('Prompts Module', () => {
let db: Database;
beforeEach(() => {
db = new ClaudeMemDatabase(':memory:').db;
});
afterEach(() => {
db.close();
});
// Helper to create a session (for FK constraint on user_prompts.content_session_id)
function createSession(contentSessionId: string, project: string = 'test-project'): string {
createSDKSession(db, contentSessionId, project, 'initial prompt');
return contentSessionId;
}
describe('saveUserPrompt', () => {
it('should store prompt and return numeric ID', () => {
const contentSessionId = createSession('content-session-prompt-1');
const promptNumber = 1;
const promptText = 'First user prompt';
const id = saveUserPrompt(db, contentSessionId, promptNumber, promptText);
expect(typeof id).toBe('number');
expect(id).toBeGreaterThan(0);
});
it('should store multiple prompts with incrementing IDs', () => {
const contentSessionId = createSession('content-session-prompt-2');
const id1 = saveUserPrompt(db, contentSessionId, 1, 'First prompt');
const id2 = saveUserPrompt(db, contentSessionId, 2, 'Second prompt');
const id3 = saveUserPrompt(db, contentSessionId, 3, 'Third prompt');
expect(id1).toBeGreaterThan(0);
expect(id2).toBeGreaterThan(id1);
expect(id3).toBeGreaterThan(id2);
});
it('should allow prompts from different sessions', () => {
const sessionA = createSession('session-a');
const sessionB = createSession('session-b');
const id1 = saveUserPrompt(db, sessionA, 1, 'Prompt A1');
const id2 = saveUserPrompt(db, sessionB, 1, 'Prompt B1');
expect(id1).not.toBe(id2);
});
});
describe('getPromptNumberFromUserPrompts', () => {
it('should return 0 when no prompts exist', () => {
const count = getPromptNumberFromUserPrompts(db, 'nonexistent-session');
expect(count).toBe(0);
});
it('should return count of prompts for session', () => {
const contentSessionId = createSession('count-test-session');
expect(getPromptNumberFromUserPrompts(db, contentSessionId)).toBe(0);
saveUserPrompt(db, contentSessionId, 1, 'First prompt');
expect(getPromptNumberFromUserPrompts(db, contentSessionId)).toBe(1);
saveUserPrompt(db, contentSessionId, 2, 'Second prompt');
expect(getPromptNumberFromUserPrompts(db, contentSessionId)).toBe(2);
saveUserPrompt(db, contentSessionId, 3, 'Third prompt');
expect(getPromptNumberFromUserPrompts(db, contentSessionId)).toBe(3);
});
it('should maintain session isolation', () => {
const sessionA = createSession('isolation-session-a');
const sessionB = createSession('isolation-session-b');
// Add prompts to session A
saveUserPrompt(db, sessionA, 1, 'A1');
saveUserPrompt(db, sessionA, 2, 'A2');
// Add prompts to session B
saveUserPrompt(db, sessionB, 1, 'B1');
// Session A should have 2 prompts
expect(getPromptNumberFromUserPrompts(db, sessionA)).toBe(2);
// Session B should have 1 prompt
expect(getPromptNumberFromUserPrompts(db, sessionB)).toBe(1);
// Adding to session B shouldn't affect session A
saveUserPrompt(db, sessionB, 2, 'B2');
saveUserPrompt(db, sessionB, 3, 'B3');
expect(getPromptNumberFromUserPrompts(db, sessionA)).toBe(2);
expect(getPromptNumberFromUserPrompts(db, sessionB)).toBe(3);
});
it('should handle edge case of many prompts', () => {
const contentSessionId = createSession('many-prompts-session');
for (let i = 1; i <= 100; i++) {
saveUserPrompt(db, contentSessionId, i, `Prompt ${i}`);
}
expect(getPromptNumberFromUserPrompts(db, contentSessionId)).toBe(100);
});
});
});

View File

@@ -0,0 +1,166 @@
/**
* Session module tests
* Tests modular session functions with in-memory database
*
* Sources:
* - API patterns from src/services/sqlite/sessions/create.ts
* - API patterns from src/services/sqlite/sessions/get.ts
* - Test pattern from tests/session_store.test.ts
*/
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { ClaudeMemDatabase } from '../../src/services/sqlite/Database.js';
import {
createSDKSession,
getSessionById,
updateMemorySessionId,
} from '../../src/services/sqlite/Sessions.js';
import type { Database } from 'bun:sqlite';
describe('Sessions Module', () => {
let db: Database;
beforeEach(() => {
db = new ClaudeMemDatabase(':memory:').db;
});
afterEach(() => {
db.close();
});
describe('createSDKSession', () => {
it('should create a new session and return numeric ID', () => {
const contentSessionId = 'content-session-123';
const project = 'test-project';
const userPrompt = 'Initial user prompt';
const sessionId = createSDKSession(db, contentSessionId, project, userPrompt);
expect(typeof sessionId).toBe('number');
expect(sessionId).toBeGreaterThan(0);
});
it('should be idempotent - return same ID for same content_session_id', () => {
const contentSessionId = 'content-session-456';
const project = 'test-project';
const userPrompt = 'Initial user prompt';
const sessionId1 = createSDKSession(db, contentSessionId, project, userPrompt);
const sessionId2 = createSDKSession(db, contentSessionId, project, 'Different prompt');
expect(sessionId1).toBe(sessionId2);
});
it('should create different sessions for different content_session_ids', () => {
const sessionId1 = createSDKSession(db, 'session-a', 'project', 'prompt');
const sessionId2 = createSDKSession(db, 'session-b', 'project', 'prompt');
expect(sessionId1).not.toBe(sessionId2);
});
});
describe('getSessionById', () => {
it('should retrieve session by ID', () => {
const contentSessionId = 'content-session-get';
const project = 'test-project';
const userPrompt = 'Test prompt';
const sessionId = createSDKSession(db, contentSessionId, project, userPrompt);
const session = getSessionById(db, sessionId);
expect(session).not.toBeNull();
expect(session?.id).toBe(sessionId);
expect(session?.content_session_id).toBe(contentSessionId);
expect(session?.project).toBe(project);
expect(session?.user_prompt).toBe(userPrompt);
// memory_session_id should be null initially (set via updateMemorySessionId)
expect(session?.memory_session_id).toBeNull();
});
it('should return null for non-existent session', () => {
const session = getSessionById(db, 99999);
expect(session).toBeNull();
});
});
describe('custom_title', () => {
it('should store custom_title when provided at creation', () => {
const sessionId = createSDKSession(db, 'session-title-1', 'project', 'prompt', 'My Agent');
const session = getSessionById(db, sessionId);
expect(session?.custom_title).toBe('My Agent');
});
it('should default custom_title to null when not provided', () => {
const sessionId = createSDKSession(db, 'session-title-2', 'project', 'prompt');
const session = getSessionById(db, sessionId);
expect(session?.custom_title).toBeNull();
});
it('should backfill custom_title on idempotent call if not already set', () => {
const sessionId = createSDKSession(db, 'session-title-3', 'project', 'prompt');
let session = getSessionById(db, sessionId);
expect(session?.custom_title).toBeNull();
// Second call with custom_title should backfill
createSDKSession(db, 'session-title-3', 'project', 'prompt', 'Backfilled Title');
session = getSessionById(db, sessionId);
expect(session?.custom_title).toBe('Backfilled Title');
});
it('should not overwrite existing custom_title on idempotent call', () => {
const sessionId = createSDKSession(db, 'session-title-4', 'project', 'prompt', 'Original');
let session = getSessionById(db, sessionId);
expect(session?.custom_title).toBe('Original');
// Second call should NOT overwrite
createSDKSession(db, 'session-title-4', 'project', 'prompt', 'Attempted Override');
session = getSessionById(db, sessionId);
expect(session?.custom_title).toBe('Original');
});
it('should handle empty string custom_title as no title', () => {
const sessionId = createSDKSession(db, 'session-title-5', 'project', 'prompt', '');
const session = getSessionById(db, sessionId);
// Empty string becomes null via the || null conversion
expect(session?.custom_title).toBeNull();
});
});
describe('updateMemorySessionId', () => {
it('should update memory_session_id for existing session', () => {
const contentSessionId = 'content-session-update';
const project = 'test-project';
const userPrompt = 'Test prompt';
const memorySessionId = 'memory-session-abc123';
const sessionId = createSDKSession(db, contentSessionId, project, userPrompt);
// Verify memory_session_id is null initially
let session = getSessionById(db, sessionId);
expect(session?.memory_session_id).toBeNull();
// Update memory session ID
updateMemorySessionId(db, sessionId, memorySessionId);
// Verify update
session = getSessionById(db, sessionId);
expect(session?.memory_session_id).toBe(memorySessionId);
});
it('should allow updating to different memory_session_id', () => {
const sessionId = createSDKSession(db, 'session-x', 'project', 'prompt');
updateMemorySessionId(db, sessionId, 'memory-1');
let session = getSessionById(db, sessionId);
expect(session?.memory_session_id).toBe('memory-1');
updateMemorySessionId(db, sessionId, 'memory-2');
session = getSessionById(db, sessionId);
expect(session?.memory_session_id).toBe('memory-2');
});
});
});

View File

@@ -0,0 +1,214 @@
/**
* Summaries module tests
* Tests modular summary functions with in-memory database
*
* Sources:
* - API patterns from src/services/sqlite/summaries/store.ts
* - API patterns from src/services/sqlite/summaries/get.ts
* - Type definitions from src/services/sqlite/summaries/types.ts
*/
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { ClaudeMemDatabase } from '../../src/services/sqlite/Database.js';
import {
storeSummary,
getSummaryForSession,
} from '../../src/services/sqlite/Summaries.js';
import {
createSDKSession,
updateMemorySessionId,
} from '../../src/services/sqlite/Sessions.js';
import type { SummaryInput } from '../../src/services/sqlite/summaries/types.js';
import type { Database } from 'bun:sqlite';
describe('Summaries Module', () => {
let db: Database;
beforeEach(() => {
db = new ClaudeMemDatabase(':memory:').db;
});
afterEach(() => {
db.close();
});
// Helper to create a valid summary input
function createSummaryInput(overrides: Partial<SummaryInput> = {}): SummaryInput {
return {
request: 'User requested feature X',
investigated: 'Explored the codebase',
learned: 'Discovered pattern Y',
completed: 'Implemented feature X',
next_steps: 'Add tests and documentation',
notes: 'Consider edge case Z',
...overrides,
};
}
// Helper to create a session and return memory_session_id for FK constraints
function createSessionWithMemoryId(contentSessionId: string, memorySessionId: string, project: string = 'test-project'): string {
const sessionId = createSDKSession(db, contentSessionId, project, 'initial prompt');
updateMemorySessionId(db, sessionId, memorySessionId);
return memorySessionId;
}
describe('storeSummary', () => {
it('should store summary and return id and createdAtEpoch', () => {
const memorySessionId = createSessionWithMemoryId('content-sum-123', 'mem-session-sum-123');
const project = 'test-project';
const summary = createSummaryInput();
const result = storeSummary(db, memorySessionId, project, summary);
expect(typeof result.id).toBe('number');
expect(result.id).toBeGreaterThan(0);
expect(typeof result.createdAtEpoch).toBe('number');
expect(result.createdAtEpoch).toBeGreaterThan(0);
});
it('should store all summary fields correctly', () => {
const memorySessionId = createSessionWithMemoryId('content-sum-456', 'mem-session-sum-456');
const project = 'test-project';
const summary = createSummaryInput({
request: 'Refactor the database layer',
investigated: 'Analyzed current schema',
learned: 'Found N+1 query issues',
completed: 'Optimized queries',
next_steps: 'Monitor performance',
notes: 'May need caching',
});
const result = storeSummary(db, memorySessionId, project, summary, 1, 500);
const stored = getSummaryForSession(db, memorySessionId);
expect(stored).not.toBeNull();
expect(stored?.request).toBe('Refactor the database layer');
expect(stored?.investigated).toBe('Analyzed current schema');
expect(stored?.learned).toBe('Found N+1 query issues');
expect(stored?.completed).toBe('Optimized queries');
expect(stored?.next_steps).toBe('Monitor performance');
expect(stored?.notes).toBe('May need caching');
expect(stored?.prompt_number).toBe(1);
});
it('should respect overrideTimestampEpoch', () => {
const memorySessionId = createSessionWithMemoryId('content-sum-789', 'mem-session-sum-789');
const project = 'test-project';
const summary = createSummaryInput();
const pastTimestamp = 1650000000000; // Apr 15, 2022
const result = storeSummary(
db,
memorySessionId,
project,
summary,
1,
0,
pastTimestamp
);
expect(result.createdAtEpoch).toBe(pastTimestamp);
const stored = getSummaryForSession(db, memorySessionId);
expect(stored?.created_at_epoch).toBe(pastTimestamp);
});
it('should use current time when overrideTimestampEpoch not provided', () => {
const memorySessionId = createSessionWithMemoryId('content-sum-now', 'session-sum-now');
const before = Date.now();
const result = storeSummary(
db,
memorySessionId,
'project',
createSummaryInput()
);
const after = Date.now();
expect(result.createdAtEpoch).toBeGreaterThanOrEqual(before);
expect(result.createdAtEpoch).toBeLessThanOrEqual(after);
});
it('should handle null notes', () => {
const memorySessionId = createSessionWithMemoryId('content-sum-null', 'session-sum-null');
const summary = createSummaryInput({ notes: null });
const result = storeSummary(db, memorySessionId, 'project', summary);
const stored = getSummaryForSession(db, memorySessionId);
expect(stored).not.toBeNull();
expect(stored?.notes).toBeNull();
});
});
describe('getSummaryForSession', () => {
it('should retrieve summary by memory_session_id', () => {
const memorySessionId = createSessionWithMemoryId('content-unique', 'unique-mem-session');
const summary = createSummaryInput({ request: 'Unique request' });
storeSummary(db, memorySessionId, 'project', summary);
const retrieved = getSummaryForSession(db, memorySessionId);
expect(retrieved).not.toBeNull();
expect(retrieved?.request).toBe('Unique request');
});
it('should return null for session with no summary', () => {
const retrieved = getSummaryForSession(db, 'nonexistent-session');
expect(retrieved).toBeNull();
});
it('should return most recent summary when multiple exist', () => {
const memorySessionId = createSessionWithMemoryId('content-multi', 'multi-summary-session');
// Store older summary
storeSummary(
db,
memorySessionId,
'project',
createSummaryInput({ request: 'First request' }),
1,
0,
1000000000000
);
// Store newer summary
storeSummary(
db,
memorySessionId,
'project',
createSummaryInput({ request: 'Second request' }),
2,
0,
2000000000000
);
const retrieved = getSummaryForSession(db, memorySessionId);
expect(retrieved).not.toBeNull();
expect(retrieved?.request).toBe('Second request');
expect(retrieved?.prompt_number).toBe(2);
});
it('should return summary with all expected fields', () => {
const memorySessionId = createSessionWithMemoryId('content-fields', 'fields-check-session');
const summary = createSummaryInput();
storeSummary(db, memorySessionId, 'project', summary, 1, 100, 1500000000000);
const retrieved = getSummaryForSession(db, memorySessionId);
expect(retrieved).not.toBeNull();
expect(retrieved).toHaveProperty('request');
expect(retrieved).toHaveProperty('investigated');
expect(retrieved).toHaveProperty('learned');
expect(retrieved).toHaveProperty('completed');
expect(retrieved).toHaveProperty('next_steps');
expect(retrieved).toHaveProperty('notes');
expect(retrieved).toHaveProperty('prompt_number');
expect(retrieved).toHaveProperty('created_at');
expect(retrieved).toHaveProperty('created_at_epoch');
});
});
});

View File

@@ -0,0 +1,309 @@
/**
* Transactions module tests
* Tests atomic transaction functions with in-memory database
*
* Sources:
* - API patterns from src/services/sqlite/transactions.ts
* - Type definitions from src/services/sqlite/transactions.ts
*/
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { ClaudeMemDatabase } from '../../src/services/sqlite/Database.js';
import {
storeObservations,
storeObservationsAndMarkComplete,
} from '../../src/services/sqlite/transactions.js';
import { getObservationById } from '../../src/services/sqlite/Observations.js';
import { getSummaryForSession } from '../../src/services/sqlite/Summaries.js';
import {
createSDKSession,
updateMemorySessionId,
} from '../../src/services/sqlite/Sessions.js';
import type { ObservationInput } from '../../src/services/sqlite/observations/types.js';
import type { SummaryInput } from '../../src/services/sqlite/summaries/types.js';
import type { Database } from 'bun:sqlite';
describe('Transactions Module', () => {
let db: Database;
beforeEach(() => {
db = new ClaudeMemDatabase(':memory:').db;
});
afterEach(() => {
db.close();
});
// Helper to create a valid observation input
function createObservationInput(overrides: Partial<ObservationInput> = {}): ObservationInput {
return {
type: 'discovery',
title: 'Test Observation',
subtitle: 'Test Subtitle',
facts: ['fact1', 'fact2'],
narrative: 'Test narrative content',
concepts: ['concept1', 'concept2'],
files_read: ['/path/to/file1.ts'],
files_modified: ['/path/to/file2.ts'],
...overrides,
};
}
// Helper to create a valid summary input
function createSummaryInput(overrides: Partial<SummaryInput> = {}): SummaryInput {
return {
request: 'User requested feature X',
investigated: 'Explored the codebase',
learned: 'Discovered pattern Y',
completed: 'Implemented feature X',
next_steps: 'Add tests and documentation',
notes: 'Consider edge case Z',
...overrides,
};
}
// Helper to create a session and return memory_session_id for FK constraints
function createSessionWithMemoryId(contentSessionId: string, memorySessionId: string, project: string = 'test-project'): { memorySessionId: string; sessionDbId: number } {
const sessionDbId = createSDKSession(db, contentSessionId, project, 'initial prompt');
updateMemorySessionId(db, sessionDbId, memorySessionId);
return { memorySessionId, sessionDbId };
}
describe('storeObservations', () => {
it('should store multiple observations atomically and return result', () => {
const { memorySessionId } = createSessionWithMemoryId('content-atomic-123', 'atomic-session-123');
const project = 'test-project';
const observations = [
createObservationInput({ title: 'Obs 1' }),
createObservationInput({ title: 'Obs 2' }),
createObservationInput({ title: 'Obs 3' }),
];
const result = storeObservations(db, memorySessionId, project, observations, null);
expect(result.observationIds).toHaveLength(3);
expect(result.observationIds.every((id) => typeof id === 'number')).toBe(true);
expect(result.summaryId).toBeNull();
expect(typeof result.createdAtEpoch).toBe('number');
});
it('should store all observations with same timestamp', () => {
const { memorySessionId } = createSessionWithMemoryId('content-ts', 'timestamp-session');
const project = 'test-project';
const observations = [
createObservationInput({ title: 'Obs A' }),
createObservationInput({ title: 'Obs B' }),
];
const fixedTimestamp = 1600000000000;
const result = storeObservations(
db,
memorySessionId,
project,
observations,
null,
1,
0,
fixedTimestamp
);
expect(result.createdAtEpoch).toBe(fixedTimestamp);
// Verify each observation has the same timestamp
for (const id of result.observationIds) {
const obs = getObservationById(db, id);
expect(obs?.created_at_epoch).toBe(fixedTimestamp);
}
});
it('should store observations with summary', () => {
const { memorySessionId } = createSessionWithMemoryId('content-with-sum', 'with-summary-session');
const project = 'test-project';
const observations = [createObservationInput({ title: 'Main Obs' })];
const summary = createSummaryInput({ request: 'Test request' });
const result = storeObservations(db, memorySessionId, project, observations, summary);
expect(result.observationIds).toHaveLength(1);
expect(result.summaryId).not.toBeNull();
expect(typeof result.summaryId).toBe('number');
// Verify summary was stored
const storedSummary = getSummaryForSession(db, memorySessionId);
expect(storedSummary).not.toBeNull();
expect(storedSummary?.request).toBe('Test request');
});
it('should handle empty observations array', () => {
const { memorySessionId } = createSessionWithMemoryId('content-empty', 'empty-obs-session');
const project = 'test-project';
const observations: ObservationInput[] = [];
const result = storeObservations(db, memorySessionId, project, observations, null);
expect(result.observationIds).toHaveLength(0);
expect(result.summaryId).toBeNull();
});
it('should handle summary-only (no observations)', () => {
const { memorySessionId } = createSessionWithMemoryId('content-sum-only', 'summary-only-session');
const project = 'test-project';
const summary = createSummaryInput({ request: 'Summary-only request' });
const result = storeObservations(db, memorySessionId, project, [], summary);
expect(result.observationIds).toHaveLength(0);
expect(result.summaryId).not.toBeNull();
const storedSummary = getSummaryForSession(db, memorySessionId);
expect(storedSummary?.request).toBe('Summary-only request');
});
it('should return correct createdAtEpoch', () => {
const { memorySessionId } = createSessionWithMemoryId('content-epoch', 'session-epoch');
const before = Date.now();
const result = storeObservations(
db,
memorySessionId,
'project',
[createObservationInput()],
null
);
const after = Date.now();
expect(result.createdAtEpoch).toBeGreaterThanOrEqual(before);
expect(result.createdAtEpoch).toBeLessThanOrEqual(after);
});
it('should apply promptNumber to all observations', () => {
const { memorySessionId } = createSessionWithMemoryId('content-pn', 'prompt-num-session');
const project = 'test-project';
const observations = [
createObservationInput({ title: 'Obs 1' }),
createObservationInput({ title: 'Obs 2' }),
];
const promptNumber = 5;
const result = storeObservations(
db,
memorySessionId,
project,
observations,
null,
promptNumber
);
for (const id of result.observationIds) {
const obs = getObservationById(db, id);
expect(obs?.prompt_number).toBe(promptNumber);
}
});
});
describe('storeObservationsAndMarkComplete', () => {
// Note: This function also marks a pending message as processed.
// For testing, we need a pending_messages row to exist first.
it('should store observations, summary, and mark message complete', () => {
const { memorySessionId, sessionDbId } = createSessionWithMemoryId('content-complete', 'complete-session');
const project = 'test-project';
const observations = [createObservationInput({ title: 'Complete Obs' })];
const summary = createSummaryInput({ request: 'Complete request' });
// First, insert a pending message to mark as complete
const insertStmt = db.prepare(`
INSERT INTO pending_messages
(session_db_id, content_session_id, message_type, created_at_epoch, status)
VALUES (?, ?, 'observation', ?, 'processing')
`);
const msgResult = insertStmt.run(sessionDbId, 'content-complete', Date.now());
const messageId = Number(msgResult.lastInsertRowid);
const result = storeObservationsAndMarkComplete(
db,
memorySessionId,
project,
observations,
summary,
messageId
);
expect(result.observationIds).toHaveLength(1);
expect(result.summaryId).not.toBeNull();
// Verify message was marked as processed
const msgStmt = db.prepare('SELECT status FROM pending_messages WHERE id = ?');
const msg = msgStmt.get(messageId) as { status: string } | undefined;
expect(msg?.status).toBe('processed');
});
it('should maintain atomicity - all operations share same timestamp', () => {
const { memorySessionId, sessionDbId } = createSessionWithMemoryId('content-atomic-ts', 'atomic-timestamp-session');
const project = 'test-project';
const observations = [
createObservationInput({ title: 'Obs 1' }),
createObservationInput({ title: 'Obs 2' }),
];
const summary = createSummaryInput();
const fixedTimestamp = 1700000000000;
// Create pending message
db.prepare(`
INSERT INTO pending_messages
(session_db_id, content_session_id, message_type, created_at_epoch, status)
VALUES (?, ?, 'observation', ?, 'processing')
`).run(sessionDbId, 'content-atomic-ts', Date.now());
const messageId = db.prepare('SELECT last_insert_rowid() as id').get() as { id: number };
const result = storeObservationsAndMarkComplete(
db,
memorySessionId,
project,
observations,
summary,
messageId.id,
1,
0,
fixedTimestamp
);
expect(result.createdAtEpoch).toBe(fixedTimestamp);
// All observations should have same timestamp
for (const id of result.observationIds) {
const obs = getObservationById(db, id);
expect(obs?.created_at_epoch).toBe(fixedTimestamp);
}
// Summary should have same timestamp
const storedSummary = getSummaryForSession(db, memorySessionId);
expect(storedSummary?.created_at_epoch).toBe(fixedTimestamp);
});
it('should handle null summary', () => {
const { memorySessionId, sessionDbId } = createSessionWithMemoryId('content-no-sum', 'no-summary-session');
const project = 'test-project';
const observations = [createObservationInput({ title: 'Only Obs' })];
// Create pending message
db.prepare(`
INSERT INTO pending_messages
(session_db_id, content_session_id, message_type, created_at_epoch, status)
VALUES (?, ?, 'observation', ?, 'processing')
`).run(sessionDbId, 'content-no-sum', Date.now());
const messageId = db.prepare('SELECT last_insert_rowid() as id').get() as { id: number };
const result = storeObservationsAndMarkComplete(
db,
memorySessionId,
project,
observations,
null,
messageId.id
);
expect(result.observationIds).toHaveLength(1);
expect(result.summaryId).toBeNull();
});
});
});