refactor: decompose monolith into modular architecture with comprehensive test suite (#538)

* fix: prevent memory_session_id from equaling content_session_id

The bug: memory_session_id was initialized to contentSessionId as a
"placeholder for FK purposes". This caused the SDK resume logic to
inject memory agent messages into the USER's Claude Code transcript,
corrupting their conversation history.

Root cause:
- SessionStore.createSDKSession initialized memory_session_id = contentSessionId
- SDKAgent checked memorySessionId !== contentSessionId but this check
  only worked if the session was fetched fresh from DB

The fix:
- SessionStore: Initialize memory_session_id as NULL, not contentSessionId
- SDKAgent: Simple truthy check !!session.memorySessionId (NULL = fresh start)
- Database migration: Ran UPDATE to set memory_session_id = NULL for 1807
  existing sessions that had the bug

Also adds [ALIGNMENT] logging across the session lifecycle to help debug
session continuity issues:
- Hook entry: contentSessionId + promptNumber
- DB lookup: contentSessionId → memorySessionId mapping proof
- Resume decision: shows which memorySessionId will be used for resume
- Capture: logs when memorySessionId is captured from first SDK response

UI: Added "Alignment" quick filter button in LogsModal to show only
alignment logs for debugging session continuity.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: improve error handling in worker-service.ts

- Fix GENERIC_CATCH anti-patterns by logging full error objects instead of just messages
- Add [ANTI-PATTERN IGNORED] markers for legitimate cases (cleanup, hot paths)
- Simplify error handling comments to be more concise
- Improve httpShutdown() error discrimination for ECONNREFUSED
- Reduce LARGE_TRY_BLOCK issues in initialization code

Part of anti-pattern cleanup plan (132 total issues)

* refactor: improve error logging in SearchManager.ts

- Pass full error objects to logger instead of just error.message
- Fixes PARTIAL_ERROR_LOGGING anti-patterns (10 instances)
- Better debugging visibility when Chroma queries fail

Part of anti-pattern cleanup (133 remaining)

* refactor: improve error logging across SessionStore and mcp-server

- SessionStore.ts: Fix error logging in column rename utility
- mcp-server.ts: Log full error objects instead of just error.message
- Improve error handling in Worker API calls and tool execution

Part of anti-pattern cleanup (133 remaining)

* Refactor hooks to streamline error handling and loading states

- Simplified error handling in useContextPreview by removing try-catch and directly checking response status.
- Refactored usePagination to eliminate try-catch, improving readability and maintaining error handling through response checks.
- Cleaned up useSSE by removing unnecessary try-catch around JSON parsing, ensuring clarity in message handling.
- Enhanced useSettings by streamlining the saving process, removing try-catch, and directly checking the result for success.

* refactor: add error handling back to SearchManager Chroma calls

- Wrap queryChroma calls in try-catch to prevent generator crashes
- Log Chroma errors as warnings and fall back gracefully
- Fixes generator failures when Chroma has issues
- Part of anti-pattern cleanup recovery

* feat: Add generator failure investigation report and observation duplication regression report

- Created a comprehensive investigation report detailing the root cause of generator failures during anti-pattern cleanup, including the impact, investigation process, and implemented fixes.
- Documented the critical regression causing observation duplication due to race conditions in the SDK agent, outlining symptoms, root cause analysis, and proposed fixes.

* fix: address PR #528 review comments - atomic cleanup and detector improvements

This commit addresses critical review feedback from PR #528:

## 1. Atomic Message Cleanup (Fix Race Condition)

**Problem**: SessionRoutes.ts generator error handler had race condition
- Queried messages then marked failed in loop
- If crash during loop → partial marking → inconsistent state

**Solution**:
- Added `markSessionMessagesFailed()` to PendingMessageStore.ts
- Single atomic UPDATE statement replaces loop
- Follows existing pattern from `resetProcessingToPending()`

**Files**:
- src/services/sqlite/PendingMessageStore.ts (new method)
- src/services/worker/http/routes/SessionRoutes.ts (use new method)

## 2. Anti-Pattern Detector Improvements

**Problem**: Detector didn't recognize logger.failure() method
- Lines 212 & 335 already included "failure"
- Lines 112-113 (PARTIAL_ERROR_LOGGING detection) did not

**Solution**: Updated regex patterns to include "failure" for consistency

**Files**:
- scripts/anti-pattern-test/detect-error-handling-antipatterns.ts

## 3. Documentation

**PR Comment**: Added clarification on memory_session_id fix location
- Points to SessionStore.ts:1155
- Explains why NULL initialization prevents message injection bug

## Review Response

Addresses "Must Address Before Merge" items from review:
 Clarified memory_session_id bug fix location (via PR comment)
 Made generator error handler message cleanup atomic
 Deferred comprehensive test suite to follow-up PR (keeps PR focused)

## Testing

- Build passes with no errors
- Anti-pattern detector runs successfully
- Atomic cleanup follows proven pattern from existing methods

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: FOREIGN KEY constraint and missing failed_at_epoch column

Two critical bugs fixed:

1. Missing failed_at_epoch column in pending_messages table
   - Added migration 20 to create the column
   - Fixes error when trying to mark messages as failed

2. FOREIGN KEY constraint failed when storing observations
   - All three agents (SDK, Gemini, OpenRouter) were passing
     session.contentSessionId instead of session.memorySessionId
   - storeObservationsAndMarkComplete expects memorySessionId
   - Added null check and clear error message

However, observations still not saving - see investigation report.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Refactor hook input parsing to improve error handling

- Added a nested try-catch block in new-hook.ts, save-hook.ts, and summary-hook.ts to handle JSON parsing errors more gracefully.
- Replaced direct error throwing with logging of the error details using logger.error.
- Ensured that the process exits cleanly after handling input in all three hooks.

* docs: add monolith refactor report with system breakdown

Comprehensive analysis of codebase identifying:
- 14 files over 500 lines requiring refactoring
- 3 critical monoliths (SessionStore, SearchManager, worker-service)
- 80% code duplication across agent files
- 5-phase refactoring roadmap with domain-based architecture

* docs: update monolith report post session-logging merge

- SessionStore grew to 2,011 lines (49 methods) - highest priority
- SearchManager reduced to 1,778 lines (improved)
- Agent files reduced by ~45 lines combined
- Added trend indicators and post-merge observations
- Core refactoring proposal remains valid

* refactor(sqlite): decompose SessionStore into modular architecture

Extract the 2011-line SessionStore.ts monolith into focused, single-responsibility
modules following grep-optimized progressive disclosure pattern:

New module structure:
- sessions/ - Session creation and retrieval (create.ts, get.ts, types.ts)
- observations/ - Observation storage and queries (store.ts, get.ts, recent.ts, files.ts, types.ts)
- summaries/ - Summary storage and queries (store.ts, get.ts, recent.ts, types.ts)
- prompts/ - User prompt management (store.ts, get.ts, types.ts)
- timeline/ - Cross-entity timeline queries (queries.ts)
- import/ - Bulk import operations (bulk.ts)
- migrations/ - Database migrations (runner.ts)

New coordinator files:
- Database.ts - ClaudeMemDatabase class with re-exports
- transactions.ts - Atomic cross-entity transactions
- Named re-export facades (Sessions.ts, Observations.ts, etc.)

Key design decisions:
- All functions take `db: Database` as first parameter (functional style)
- Named re-exports instead of index.ts for grep-friendliness
- SessionStore retained as backward-compatible wrapper
- Target file size: 50-150 lines (60% compliance)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor(agents): extract shared logic into modular architecture

Consolidate duplicate code across SDKAgent, GeminiAgent, and OpenRouterAgent
into focused utility modules. Total reduction: 500 lines (29%).

New modules in src/services/worker/agents/:
- ResponseProcessor.ts: Atomic DB transactions, Chroma sync, SSE broadcast
- ObservationBroadcaster.ts: SSE event formatting and dispatch
- SessionCleanupHelper.ts: Session state cleanup and stuck message reset
- FallbackErrorHandler.ts: Provider error detection for fallback logic
- types.ts: Shared interfaces (WorkerRef, SSE payloads, StorageResult)

Bug fix: SDKAgent was incorrectly using obs.files instead of obs.files_read
and hardcoding files_modified to empty array.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor(search): extract search strategies into modular architecture

Decompose SearchManager into focused strategy pattern with:
- SearchOrchestrator: Coordinates strategy selection and fallback
- ChromaSearchStrategy: Vector semantic search via ChromaDB
- SQLiteSearchStrategy: Filter-only queries for date/project/type
- HybridSearchStrategy: Metadata filtering + semantic ranking
- ResultFormatter: Markdown table formatting for results
- TimelineBuilder: Chronological timeline construction
- Filter modules: DateFilter, ProjectFilter, TypeFilter

SearchManager now delegates to new infrastructure while maintaining
full backward compatibility with existing public API.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor(context): decompose context-generator into modular architecture

Extract 660-line monolith into focused components:
- ContextBuilder: Main orchestrator (~160 lines)
- ContextConfigLoader: Configuration loading
- TokenCalculator: Token budget calculations
- ObservationCompiler: Data retrieval and query building
- MarkdownFormatter/ColorFormatter: Output formatting
- Section renderers: Header, Timeline, Summary, Footer

Maintains full backward compatibility - context-generator.ts now
delegates to new ContextBuilder while preserving public API.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor(worker): decompose worker-service into modular infrastructure

Split 2000+ line monolith into focused modules:

Infrastructure:
- ProcessManager: PID files, signal handlers, child process cleanup
- HealthMonitor: Port checks, health polling, version matching
- GracefulShutdown: Coordinated cleanup on exit

Server:
- Server: Express app setup, core routes, route registration
- Middleware: Re-exports from existing middleware
- ErrorHandler: Centralized error handling with AppError class

Integrations:
- CursorHooksInstaller: Full Cursor IDE integration (registry, hooks, MCP)

WorkerService now acts as thin coordinator wiring all components together.
Maintains full backward compatibility with existing public API.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Refactor session queue processing and database interactions

- Implement claim-and-delete pattern in SessionQueueProcessor to simplify message handling and eliminate duplicate processing.
- Update PendingMessageStore to support atomic claim-and-delete operations, removing the need for intermediate processing states.
- Introduce storeObservations method in SessionStore for simplified observation and summary storage without message tracking.
- Remove deprecated methods and clean up session state management in worker agents.
- Adjust response processing to accommodate new storage patterns, ensuring atomic transactions for observations and summaries.
- Remove unnecessary reset logic for stuck messages due to the new queue handling approach.

* Add duplicate observation cleanup script

Script to clean up duplicate observations created by the batching bug
where observations were stored once per message ID instead of once per
observation. Includes safety checks to always keep at least one copy.

Usage:
  bun scripts/cleanup-duplicates.ts           # Dry run
  bun scripts/cleanup-duplicates.ts --execute # Delete duplicates
  bun scripts/cleanup-duplicates.ts --aggressive # Ignore time window

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test(sqlite): add comprehensive test suite for SQLite repositories

Add 44 tests across 5 test files covering:
- Sessions: CRUD operations and schema validation
- Observations: creation, retrieval, filtering, and ordering
- Prompts: persistence and association with observations
- Summaries: generation tracking and session linkage
- Transactions: context management and rollback behavior

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test(worker): add comprehensive test suites for worker agent modules

Add test coverage for response-processor, observation-broadcaster,
session-cleanup-helper, and fallback-error-handler agents. Fix type
import issues across search module (use `import type` for type-only
imports) and update worker-service main module detection for ESM/CJS
compatibility.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test(search): add comprehensive test suites for search module

Add test coverage for the refactored search architecture:
- SearchOrchestrator: query coordination and caching
- ResultFormatter: pagination, sorting, and field mapping
- SQLiteSearchStrategy: database search operations
- ChromaSearchStrategy: vector similarity search
- HybridSearchStrategy: combined search with score fusion

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test(context): add comprehensive test suites for context-generator modules

Add test coverage for the modular context-generator architecture:
- context-builder.test.ts: Tests for context building and result assembly
- observation-compiler.test.ts: Tests for observation compilation with privacy tags
- token-calculator.test.ts: Tests for token budget calculations
- formatters/markdown-formatter.test.ts: Tests for markdown output formatting

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test(infrastructure): add comprehensive test suites for worker infrastructure modules

Add test coverage for graceful-shutdown, health-monitor, and process-manager
modules extracted during the worker-service refactoring. All 32 tests pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test(server): add comprehensive test suites for server modules

Add test coverage for Express server infrastructure:
- error-handler.test.ts: Tests error handling middleware including
  validation errors, database errors, and async error handling
- server.test.ts: Tests server initialization, middleware configuration,
  and route mounting for all API endpoints

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore(package): add test scripts for modular test suites

Add npm run scripts to simplify running tests:
- test: run all tests
- test:sqlite, test:agents, test:search, test:context, test:infra, test:server

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* build assets

* feat(tests): add detailed failure analysis reports for session ID refactor, validation, and store tests

- Created reports for session ID refactor test failures, highlighting 8 failures due to design mismatches.
- Added session ID usage validation report detailing 10 failures caused by outdated assumptions in tests.
- Documented session store test failures, focusing on foreign key constraint violations in 2 tests.
- Compiled a comprehensive test suite report summarizing overall test results, including 28 failing tests across various categories.

* fix(tests): align session ID tests with NULL-based initialization

Update test expectations to match implementation where memory_session_id
starts as NULL (not equal to contentSessionId) per architecture decision
that memory_session_id must NEVER equal contentSessionId.

Changes:
- session_id_refactor.test.ts: expect NULL initial state, add updateMemorySessionId() calls
- session_id_usage_validation.test.ts: update placeholder detection to check !== null
- session_store.test.ts: add updateMemorySessionId() before storeObservation/storeSummary

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(tests): update GeminiAgent tests with correct field names and mocks

- Rename deprecated fields: claudeSessionId → contentSessionId,
  sdkSessionId → memorySessionId, pendingProcessingIds → pendingMessages
- Add missing required ActiveSession fields
- Add storeObservations mock (plural) for ResponseProcessor compatibility
- Fix settings mock to use correct CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED key
- Add await to rejects.toThrow assertion

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(tests): add logger imports and fix coverage test exclusions

Phase 3 of test suite fixes:
- Add logger imports to 34 high-priority source files (SQLite, worker, context)
- Exclude CLI-facing files from console.log check (worker-service.ts,
  integrations/*Installer.ts) as they use console.log intentionally for
  interactive user output

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* docs: update SESSION_ID_ARCHITECTURE for NULL-based initialization

Update documentation to reflect that memory_session_id starts as NULL,
not as a placeholder equal to contentSessionId. This matches the
implementation decision that memory_session_id must NEVER equal
contentSessionId to prevent injecting memory messages into user transcripts.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore(deps): update esbuild and MCP SDK

- esbuild: 0.25.12 → 0.27.2 (fixes minifyIdentifiers issue)
- @modelcontextprotocol/sdk: 1.20.1 → 1.25.1

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* build assets and updates

* chore: remove bun.lock and add to gitignore

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Alex Newman
2026-01-03 23:58:41 -05:00
committed by GitHub
parent 2f900b681f
commit f21ea97c39
83 changed files with 8939 additions and 367 deletions
+340
View File
@@ -0,0 +1,340 @@
import { describe, it, expect, mock, beforeEach, afterEach } from 'bun:test';
// Create mock functions that can be accessed
const mockPrepare = mock(() => ({
all: mock(() => []),
run: mock(() => {}),
}));
const mockClose = mock(() => {});
// Mock SessionStore before importing ContextBuilder
mock.module('../../src/services/sqlite/SessionStore.js', () => ({
SessionStore: class MockSessionStore {
db = {
prepare: mockPrepare,
};
close = mockClose;
},
}));
// Mock the logger
mock.module('../../src/utils/logger.js', () => ({
logger: {
debug: mock(() => {}),
failure: mock(() => {}),
error: mock(() => {}),
info: mock(() => {}),
},
}));
// Mock project-name utility
mock.module('../../src/utils/project-name.js', () => ({
getProjectName: mock((cwd: string) => cwd.split('/').pop() || 'unknown'),
}));
// Mock SettingsDefaultsManager
mock.module('../../src/shared/SettingsDefaultsManager.js', () => ({
SettingsDefaultsManager: {
loadFromFile: mock(() => ({
CLAUDE_MEM_MODE: 'code',
CLAUDE_MEM_CONTEXT_OBSERVATIONS: '50',
CLAUDE_MEM_CONTEXT_FULL_COUNT: '5',
CLAUDE_MEM_CONTEXT_SESSION_COUNT: '3',
CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS: 'true',
CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS: 'true',
CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_AMOUNT: 'true',
CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_PERCENT: 'true',
CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES: 'discovery,decision,bugfix',
CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS: 'architecture,testing',
CLAUDE_MEM_CONTEXT_FULL_FIELD: 'narrative',
CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY: 'true',
CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE: 'false',
})),
},
}));
// Mock ModeManager
mock.module('../../src/services/domain/ModeManager.js', () => ({
ModeManager: {
getInstance: () => ({
getActiveMode: () => ({
name: 'code',
prompts: {},
observation_types: [
{ id: 'decision', emoji: 'D' },
{ id: 'bugfix', emoji: 'B' },
{ id: 'discovery', emoji: 'I' },
],
observation_concepts: [
{ id: 'architecture' },
{ id: 'testing' },
],
}),
getTypeIcon: (type: string) => {
const icons: Record<string, string> = { decision: 'D', bugfix: 'B', discovery: 'I' };
return icons[type] || '?';
},
getWorkEmoji: () => 'W',
}),
},
}));
import { generateContext, loadContextConfig } from '../../src/services/context/index.js';
import type { ContextConfig } from '../../src/services/context/types.js';
describe('ContextBuilder', () => {
beforeEach(() => {
mockPrepare.mockClear();
mockClose.mockClear();
});
describe('loadContextConfig', () => {
it('should return valid ContextConfig object', () => {
const config = loadContextConfig();
expect(config).toBeDefined();
expect(typeof config.totalObservationCount).toBe('number');
expect(typeof config.fullObservationCount).toBe('number');
expect(typeof config.sessionCount).toBe('number');
});
it('should parse observation count as number', () => {
const config = loadContextConfig();
expect(config.totalObservationCount).toBe(50);
});
it('should parse full observation count as number', () => {
const config = loadContextConfig();
expect(config.fullObservationCount).toBe(5);
});
it('should parse session count as number', () => {
const config = loadContextConfig();
expect(config.sessionCount).toBe(3);
});
it('should parse boolean flags correctly', () => {
const config = loadContextConfig();
expect(config.showReadTokens).toBe(true);
expect(config.showWorkTokens).toBe(true);
expect(config.showSavingsAmount).toBe(true);
expect(config.showSavingsPercent).toBe(true);
});
it('should parse observation types into Set', () => {
const config = loadContextConfig();
expect(config.observationTypes instanceof Set).toBe(true);
expect(config.observationTypes.has('discovery')).toBe(true);
expect(config.observationTypes.has('decision')).toBe(true);
expect(config.observationTypes.has('bugfix')).toBe(true);
});
it('should parse observation concepts into Set', () => {
const config = loadContextConfig();
expect(config.observationConcepts instanceof Set).toBe(true);
expect(config.observationConcepts.has('architecture')).toBe(true);
expect(config.observationConcepts.has('testing')).toBe(true);
});
it('should set fullObservationField', () => {
const config = loadContextConfig();
expect(config.fullObservationField).toBe('narrative');
});
it('should parse showLastSummary and showLastMessage', () => {
const config = loadContextConfig();
expect(config.showLastSummary).toBe(true);
expect(config.showLastMessage).toBe(false);
});
});
describe('generateContext', () => {
it('should produce non-empty output when data exists', async () => {
// Setup mock to return some observations
mockPrepare.mockImplementation((sql: string) => ({
all: mock((...args: any[]) => {
if (sql.includes('FROM observations')) {
return [{
id: 1,
memory_session_id: 'session-1',
type: 'discovery',
title: 'Test Discovery',
subtitle: null,
narrative: 'Found something interesting',
facts: '["fact1"]',
concepts: '["architecture"]',
files_read: null,
files_modified: null,
discovery_tokens: 100,
created_at: '2025-01-01T12:00:00.000Z',
created_at_epoch: 1735732800000,
}];
}
return [];
}),
}));
const result = await generateContext({ cwd: '/test/project' }, false);
expect(result.length).toBeGreaterThan(0);
});
it('should return empty state message when no data', async () => {
// Setup mock to return empty arrays
mockPrepare.mockImplementation(() => ({
all: mock(() => []),
}));
const result = await generateContext({ cwd: '/test/my-project' }, false);
expect(result).toContain('recent context');
expect(result).toContain('No previous sessions');
});
it('should contain project name in output', async () => {
mockPrepare.mockImplementation((sql: string) => ({
all: mock(() => {
if (sql.includes('FROM observations')) {
return [{
id: 1,
memory_session_id: 'session-1',
type: 'discovery',
title: 'Test',
subtitle: null,
narrative: 'Narrative',
facts: '[]',
concepts: '["architecture"]',
files_read: null,
files_modified: null,
discovery_tokens: 50,
created_at: '2025-01-01T12:00:00.000Z',
created_at_epoch: 1735732800000,
}];
}
return [];
}),
}));
const result = await generateContext({ cwd: '/path/to/awesome-project' }, false);
expect(result).toContain('awesome-project');
});
it('should close database after completion', async () => {
mockPrepare.mockImplementation(() => ({
all: mock(() => []),
}));
await generateContext({ cwd: '/test/project' }, false);
expect(mockClose).toHaveBeenCalled();
});
it('should contain expected markdown sections', async () => {
mockPrepare.mockImplementation((sql: string) => ({
all: mock(() => {
if (sql.includes('FROM observations')) {
return [{
id: 1,
memory_session_id: 'session-1',
type: 'discovery',
title: 'Interesting Finding',
subtitle: null,
narrative: 'Description here',
facts: '["fact"]',
concepts: '["architecture"]',
files_read: null,
files_modified: null,
discovery_tokens: 200,
created_at: '2025-01-01T10:00:00.000Z',
created_at_epoch: 1735725600000,
}];
}
if (sql.includes('FROM session_summaries')) {
return [{
id: 1,
memory_session_id: 'session-1',
request: 'Build feature',
investigated: 'Code review',
learned: 'Best practices',
completed: 'Initial implementation',
next_steps: 'Add tests',
created_at: '2025-01-01T11:00:00.000Z',
created_at_epoch: 1735729200000,
}];
}
return [];
}),
}));
const result = await generateContext({ cwd: '/test/project' }, false);
// Should contain header
expect(result).toContain('recent context');
// Should contain observation data
expect(result).toContain('Interesting Finding');
});
it('should use cwd from input when provided', async () => {
mockPrepare.mockImplementation(() => ({
all: mock(() => []),
}));
const result = await generateContext({ cwd: '/custom/path/special-project' }, false);
expect(result).toContain('special-project');
});
it('should handle undefined input gracefully', async () => {
mockPrepare.mockImplementation(() => ({
all: mock(() => []),
}));
// Should not throw
const result = await generateContext(undefined, false);
expect(typeof result).toBe('string');
});
it('should produce markdown format when useColors is false', async () => {
mockPrepare.mockImplementation((sql: string) => ({
all: mock(() => {
if (sql.includes('FROM observations')) {
return [{
id: 1,
memory_session_id: 'session-1',
type: 'discovery',
title: 'Test',
subtitle: null,
narrative: 'Text',
facts: '[]',
concepts: '["testing"]',
files_read: null,
files_modified: null,
discovery_tokens: 10,
created_at: '2025-01-01T12:00:00.000Z',
created_at_epoch: 1735732800000,
}];
}
return [];
}),
}));
const result = await generateContext({ cwd: '/test/project' }, false);
// Markdown format uses # for headers
expect(result).toContain('#');
// Should not contain ANSI escape codes
expect(result).not.toContain('\x1b[');
});
});
});
@@ -0,0 +1,528 @@
import { describe, it, expect, mock, beforeEach } from 'bun:test';
// Mock the ModeManager before importing the formatter
mock.module('../../../src/services/domain/ModeManager.js', () => ({
ModeManager: {
getInstance: () => ({
getActiveMode: () => ({
name: 'code',
prompts: {},
observation_types: [
{ id: 'decision', emoji: 'D' },
{ id: 'bugfix', emoji: 'B' },
{ id: 'discovery', emoji: 'I' },
],
observation_concepts: [],
}),
getTypeIcon: (type: string) => {
const icons: Record<string, string> = {
decision: 'D',
bugfix: 'B',
discovery: 'I',
};
return icons[type] || '?';
},
getWorkEmoji: () => 'W',
}),
},
}));
import {
renderMarkdownHeader,
renderMarkdownLegend,
renderMarkdownColumnKey,
renderMarkdownContextIndex,
renderMarkdownContextEconomics,
renderMarkdownDayHeader,
renderMarkdownFileHeader,
renderMarkdownTableRow,
renderMarkdownFullObservation,
renderMarkdownSummaryItem,
renderMarkdownSummaryField,
renderMarkdownPreviouslySection,
renderMarkdownFooter,
renderMarkdownEmptyState,
} from '../../../src/services/context/formatters/MarkdownFormatter.js';
import type { Observation, TokenEconomics, ContextConfig, PriorMessages } from '../../../src/services/context/types.js';
// Helper to create a minimal observation
function createTestObservation(overrides: Partial<Observation> = {}): Observation {
return {
id: 1,
memory_session_id: 'session-123',
type: 'discovery',
title: 'Test Observation',
subtitle: null,
narrative: 'A test narrative',
facts: '["fact1"]',
concepts: '["concept1"]',
files_read: null,
files_modified: null,
discovery_tokens: 100,
created_at: '2025-01-01T12:00:00.000Z',
created_at_epoch: 1735732800000,
...overrides,
};
}
// Helper to create token economics
function createTestEconomics(overrides: Partial<TokenEconomics> = {}): TokenEconomics {
return {
totalObservations: 10,
totalReadTokens: 500,
totalDiscoveryTokens: 5000,
savings: 4500,
savingsPercent: 90,
...overrides,
};
}
// Helper to create context config
function createTestConfig(overrides: Partial<ContextConfig> = {}): ContextConfig {
return {
totalObservationCount: 50,
fullObservationCount: 5,
sessionCount: 3,
showReadTokens: true,
showWorkTokens: true,
showSavingsAmount: true,
showSavingsPercent: true,
observationTypes: new Set(['discovery', 'decision', 'bugfix']),
observationConcepts: new Set(['concept1', 'concept2']),
fullObservationField: 'narrative',
showLastSummary: true,
showLastMessage: true,
...overrides,
};
}
describe('MarkdownFormatter', () => {
describe('renderMarkdownHeader', () => {
it('should produce valid markdown header with project name', () => {
const result = renderMarkdownHeader('my-project');
expect(result).toHaveLength(2);
expect(result[0]).toBe('# [my-project] recent context');
expect(result[1]).toBe('');
});
it('should handle special characters in project name', () => {
const result = renderMarkdownHeader('project-with-special_chars.v2');
expect(result[0]).toContain('project-with-special_chars.v2');
});
it('should handle empty project name', () => {
const result = renderMarkdownHeader('');
expect(result[0]).toBe('# [] recent context');
});
});
describe('renderMarkdownLegend', () => {
it('should produce legend with type items', () => {
const result = renderMarkdownLegend();
expect(result).toHaveLength(2);
expect(result[0]).toContain('**Legend:**');
expect(result[1]).toBe('');
});
it('should include session-request in legend', () => {
const result = renderMarkdownLegend();
expect(result[0]).toContain('session-request');
});
});
describe('renderMarkdownColumnKey', () => {
it('should produce column key explanation', () => {
const result = renderMarkdownColumnKey();
expect(result.length).toBeGreaterThan(0);
expect(result[0]).toContain('**Column Key**');
});
it('should explain Read column', () => {
const result = renderMarkdownColumnKey();
const joined = result.join('\n');
expect(joined).toContain('Read');
expect(joined).toContain('Tokens to read');
});
it('should explain Work column', () => {
const result = renderMarkdownColumnKey();
const joined = result.join('\n');
expect(joined).toContain('Work');
expect(joined).toContain('Tokens spent');
});
});
describe('renderMarkdownContextIndex', () => {
it('should produce context index instructions', () => {
const result = renderMarkdownContextIndex();
expect(result.length).toBeGreaterThan(0);
expect(result[0]).toContain('**Context Index:**');
});
it('should mention mem-search skill', () => {
const result = renderMarkdownContextIndex();
const joined = result.join('\n');
expect(joined).toContain('mem-search');
});
});
describe('renderMarkdownContextEconomics', () => {
it('should include observation count', () => {
const economics = createTestEconomics({ totalObservations: 25 });
const config = createTestConfig();
const result = renderMarkdownContextEconomics(economics, config);
const joined = result.join('\n');
expect(joined).toContain('25 observations');
});
it('should include read tokens', () => {
const economics = createTestEconomics({ totalReadTokens: 1500 });
const config = createTestConfig();
const result = renderMarkdownContextEconomics(economics, config);
const joined = result.join('\n');
expect(joined).toContain('1,500 tokens');
});
it('should include work investment', () => {
const economics = createTestEconomics({ totalDiscoveryTokens: 10000 });
const config = createTestConfig();
const result = renderMarkdownContextEconomics(economics, config);
const joined = result.join('\n');
expect(joined).toContain('10,000 tokens');
});
it('should show savings when config has showSavingsAmount', () => {
const economics = createTestEconomics({ savings: 4500, savingsPercent: 90, totalDiscoveryTokens: 5000 });
const config = createTestConfig({ showSavingsAmount: true, showSavingsPercent: false });
const result = renderMarkdownContextEconomics(economics, config);
const joined = result.join('\n');
expect(joined).toContain('savings');
expect(joined).toContain('4,500 tokens');
});
it('should show savings percent when config has showSavingsPercent', () => {
const economics = createTestEconomics({ savingsPercent: 85, totalDiscoveryTokens: 1000 });
const config = createTestConfig({ showSavingsAmount: false, showSavingsPercent: true });
const result = renderMarkdownContextEconomics(economics, config);
const joined = result.join('\n');
expect(joined).toContain('85%');
});
it('should not show savings when discovery tokens is 0', () => {
const economics = createTestEconomics({ totalDiscoveryTokens: 0, savings: 0, savingsPercent: 0 });
const config = createTestConfig({ showSavingsAmount: true, showSavingsPercent: true });
const result = renderMarkdownContextEconomics(economics, config);
const joined = result.join('\n');
expect(joined).not.toContain('Your savings');
});
});
describe('renderMarkdownDayHeader', () => {
it('should render day as h3 heading', () => {
const result = renderMarkdownDayHeader('2025-01-01');
expect(result).toHaveLength(2);
expect(result[0]).toBe('### 2025-01-01');
expect(result[1]).toBe('');
});
});
describe('renderMarkdownFileHeader', () => {
it('should render file name in bold', () => {
const result = renderMarkdownFileHeader('src/index.ts');
expect(result[0]).toBe('**src/index.ts**');
});
it('should include table headers', () => {
const result = renderMarkdownFileHeader('test.ts');
const joined = result.join('\n');
expect(joined).toContain('| ID |');
expect(joined).toContain('| Time |');
expect(joined).toContain('| T |');
expect(joined).toContain('| Title |');
expect(joined).toContain('| Read |');
expect(joined).toContain('| Work |');
});
it('should include separator row', () => {
const result = renderMarkdownFileHeader('test.ts');
expect(result[2]).toContain('|----');
});
});
describe('renderMarkdownTableRow', () => {
it('should include observation ID with hash prefix', () => {
const obs = createTestObservation({ id: 42 });
const config = createTestConfig();
const result = renderMarkdownTableRow(obs, '10:30', config);
expect(result).toContain('#42');
});
it('should include time display', () => {
const obs = createTestObservation();
const config = createTestConfig();
const result = renderMarkdownTableRow(obs, '14:30', config);
expect(result).toContain('14:30');
});
it('should include title', () => {
const obs = createTestObservation({ title: 'Important Discovery' });
const config = createTestConfig();
const result = renderMarkdownTableRow(obs, '10:00', config);
expect(result).toContain('Important Discovery');
});
it('should use "Untitled" when title is null', () => {
const obs = createTestObservation({ title: null });
const config = createTestConfig();
const result = renderMarkdownTableRow(obs, '10:00', config);
expect(result).toContain('Untitled');
});
it('should show read tokens when config enabled', () => {
const obs = createTestObservation();
const config = createTestConfig({ showReadTokens: true });
const result = renderMarkdownTableRow(obs, '10:00', config);
expect(result).toContain('~');
});
it('should hide read tokens when config disabled', () => {
const obs = createTestObservation();
const config = createTestConfig({ showReadTokens: false });
const result = renderMarkdownTableRow(obs, '10:00', config);
// Row should have empty read column
const columns = result.split('|');
// Find the Read column (5th column, index 5)
expect(columns[5].trim()).toBe('');
});
it('should use quote mark for repeated time', () => {
const obs = createTestObservation();
const config = createTestConfig();
// Empty string timeDisplay means "same as previous"
const result = renderMarkdownTableRow(obs, '', config);
expect(result).toContain('"');
});
});
describe('renderMarkdownFullObservation', () => {
it('should include observation ID and title', () => {
const obs = createTestObservation({ id: 7, title: 'Full Observation' });
const config = createTestConfig();
const result = renderMarkdownFullObservation(obs, '10:00', 'Detail content', config);
const joined = result.join('\n');
expect(joined).toContain('**#7**');
expect(joined).toContain('**Full Observation**');
});
it('should include detail field when provided', () => {
const obs = createTestObservation();
const config = createTestConfig();
const result = renderMarkdownFullObservation(obs, '10:00', 'The detailed narrative here', config);
const joined = result.join('\n');
expect(joined).toContain('The detailed narrative here');
});
it('should not include detail field when null', () => {
const obs = createTestObservation();
const config = createTestConfig();
const result = renderMarkdownFullObservation(obs, '10:00', null, config);
// Should not have an extra content block
expect(result.length).toBeLessThan(5);
});
it('should include token info when enabled', () => {
const obs = createTestObservation({ discovery_tokens: 250 });
const config = createTestConfig({ showReadTokens: true, showWorkTokens: true });
const result = renderMarkdownFullObservation(obs, '10:00', null, config);
const joined = result.join('\n');
expect(joined).toContain('Read:');
expect(joined).toContain('Work:');
});
});
describe('renderMarkdownSummaryItem', () => {
it('should include session ID with S prefix', () => {
const summary = { id: 5, request: 'Implement feature' };
const result = renderMarkdownSummaryItem(summary, '2025-01-01 10:00');
const joined = result.join('\n');
expect(joined).toContain('**#S5**');
});
it('should include request text', () => {
const summary = { id: 1, request: 'Build authentication' };
const result = renderMarkdownSummaryItem(summary, '10:00');
const joined = result.join('\n');
expect(joined).toContain('Build authentication');
});
it('should use "Session started" when request is null', () => {
const summary = { id: 1, request: null };
const result = renderMarkdownSummaryItem(summary, '10:00');
const joined = result.join('\n');
expect(joined).toContain('Session started');
});
});
describe('renderMarkdownSummaryField', () => {
it('should render label and value in bold', () => {
const result = renderMarkdownSummaryField('Learned', 'How to test');
expect(result).toHaveLength(2);
expect(result[0]).toBe('**Learned**: How to test');
expect(result[1]).toBe('');
});
it('should return empty array when value is null', () => {
const result = renderMarkdownSummaryField('Learned', null);
expect(result).toHaveLength(0);
});
it('should return empty array when value is empty string', () => {
const result = renderMarkdownSummaryField('Learned', '');
// Empty string is falsy, so should return empty array
expect(result).toHaveLength(0);
});
});
describe('renderMarkdownPreviouslySection', () => {
it('should render section when assistantMessage exists', () => {
const priorMessages: PriorMessages = {
userMessage: '',
assistantMessage: 'I completed the task successfully.',
};
const result = renderMarkdownPreviouslySection(priorMessages);
const joined = result.join('\n');
expect(joined).toContain('**Previously**');
expect(joined).toContain('A: I completed the task successfully.');
});
it('should return empty when assistantMessage is empty', () => {
const priorMessages: PriorMessages = {
userMessage: '',
assistantMessage: '',
};
const result = renderMarkdownPreviouslySection(priorMessages);
expect(result).toHaveLength(0);
});
it('should include separator', () => {
const priorMessages: PriorMessages = {
userMessage: '',
assistantMessage: 'Some message',
};
const result = renderMarkdownPreviouslySection(priorMessages);
const joined = result.join('\n');
expect(joined).toContain('---');
});
});
describe('renderMarkdownFooter', () => {
it('should include token amounts', () => {
const result = renderMarkdownFooter(10000, 500);
const joined = result.join('\n');
expect(joined).toContain('10k');
expect(joined).toContain('500');
});
it('should mention mem-search skill', () => {
const result = renderMarkdownFooter(5000, 100);
const joined = result.join('\n');
expect(joined).toContain('mem-search');
});
it('should round work tokens to nearest thousand', () => {
const result = renderMarkdownFooter(15500, 100);
const joined = result.join('\n');
// 15500 / 1000 = 15.5 -> rounds to 16
expect(joined).toContain('16k');
});
});
describe('renderMarkdownEmptyState', () => {
it('should return helpful message with project name', () => {
const result = renderMarkdownEmptyState('my-project');
expect(result).toContain('# [my-project] recent context');
expect(result).toContain('No previous sessions found');
});
it('should be valid markdown', () => {
const result = renderMarkdownEmptyState('test');
// Should start with h1
expect(result.startsWith('#')).toBe(true);
});
it('should handle empty project name', () => {
const result = renderMarkdownEmptyState('');
expect(result).toContain('# [] recent context');
});
});
});
+333
View File
@@ -0,0 +1,333 @@
import { describe, it, expect, mock, beforeEach } from 'bun:test';
// Mock the logger before importing modules that use it
mock.module('../../src/utils/logger.js', () => ({
logger: {
debug: mock(() => {}),
failure: mock(() => {}),
error: mock(() => {}),
},
}));
import {
queryObservations,
querySummaries,
buildTimeline,
getPriorSessionMessages,
} from '../../src/services/context/index.js';
import type { Observation, SessionSummary, SummaryTimelineItem, ContextConfig } from '../../src/services/context/types.js';
// Helper to create a minimal observation
function createTestObservation(overrides: Partial<Observation> = {}): Observation {
return {
id: 1,
memory_session_id: 'session-123',
type: 'discovery',
title: 'Test Observation',
subtitle: null,
narrative: 'A test narrative',
facts: '["fact1"]',
concepts: '["concept1"]',
files_read: null,
files_modified: null,
discovery_tokens: 100,
created_at: '2025-01-01T12:00:00.000Z',
created_at_epoch: 1735732800000,
...overrides,
};
}
// Helper to create a summary timeline item
function createTestSummaryTimelineItem(overrides: Partial<SummaryTimelineItem> = {}): SummaryTimelineItem {
return {
id: 1,
memory_session_id: 'session-123',
request: 'Test Request',
investigated: 'Investigated things',
learned: 'Learned things',
completed: 'Completed things',
next_steps: 'Next steps',
created_at: '2025-01-01T12:00:00.000Z',
created_at_epoch: 1735732800000,
displayEpoch: 1735732800000,
displayTime: '2025-01-01T12:00:00.000Z',
shouldShowLink: false,
...overrides,
};
}
// Helper to create a minimal ContextConfig
function createTestConfig(overrides: Partial<ContextConfig> = {}): ContextConfig {
return {
totalObservationCount: 50,
fullObservationCount: 5,
sessionCount: 3,
showReadTokens: true,
showWorkTokens: true,
showSavingsAmount: true,
showSavingsPercent: true,
observationTypes: new Set(['discovery', 'decision', 'bugfix']),
observationConcepts: new Set(['concept1', 'concept2']),
fullObservationField: 'narrative',
showLastSummary: true,
showLastMessage: false,
...overrides,
};
}
// Mock database that returns specified data
function createMockDb(observations: Observation[] = [], summaries: SessionSummary[] = []) {
return {
db: {
prepare: mock((sql: string) => ({
all: mock((...args: any[]) => {
// Check if query is for observations or summaries
if (sql.includes('FROM observations')) {
return observations;
} else if (sql.includes('FROM session_summaries')) {
return summaries;
}
return [];
}),
})),
},
};
}
describe('ObservationCompiler', () => {
describe('queryObservations', () => {
it('should query observations with correct SQL pattern', () => {
const mockObs = [createTestObservation()];
const mockDb = createMockDb(mockObs);
const config = createTestConfig();
const result = queryObservations(mockDb as any, 'test-project', config);
expect(result).toEqual(mockObs);
expect(mockDb.db.prepare).toHaveBeenCalled();
});
it('should pass observation types from config to query', () => {
const mockDb = createMockDb([]);
const config = createTestConfig({
observationTypes: new Set(['decision', 'bugfix']),
});
queryObservations(mockDb as any, 'test-project', config);
expect(mockDb.db.prepare).toHaveBeenCalled();
});
it('should respect totalObservationCount limit from config', () => {
const mockDb = createMockDb([]);
const config = createTestConfig({ totalObservationCount: 100 });
queryObservations(mockDb as any, 'test-project', config);
expect(mockDb.db.prepare).toHaveBeenCalled();
});
it('should return empty array when no observations match', () => {
const mockDb = createMockDb([]);
const config = createTestConfig();
const result = queryObservations(mockDb as any, 'test-project', config);
expect(result).toEqual([]);
});
it('should handle multiple observation types', () => {
const mockObs = [
createTestObservation({ id: 1, type: 'discovery' }),
createTestObservation({ id: 2, type: 'decision' }),
createTestObservation({ id: 3, type: 'bugfix' }),
];
const mockDb = createMockDb(mockObs);
const config = createTestConfig({
observationTypes: new Set(['discovery', 'decision', 'bugfix']),
});
const result = queryObservations(mockDb as any, 'test-project', config);
expect(result).toHaveLength(3);
});
});
describe('querySummaries', () => {
it('should query summaries with session count from config', () => {
const mockSummaries: SessionSummary[] = [
{
id: 1,
memory_session_id: 'session-1',
request: 'Request 1',
investigated: null,
learned: null,
completed: null,
next_steps: null,
created_at: '2025-01-01T12:00:00.000Z',
created_at_epoch: 1735732800000,
},
];
const mockDb = createMockDb([], mockSummaries);
const config = createTestConfig({ sessionCount: 5 });
const result = querySummaries(mockDb as any, 'test-project', config);
expect(result).toEqual(mockSummaries);
});
it('should return empty array when no summaries exist', () => {
const mockDb = createMockDb([], []);
const config = createTestConfig();
const result = querySummaries(mockDb as any, 'test-project', config);
expect(result).toEqual([]);
});
});
describe('buildTimeline', () => {
it('should combine observations and summaries into timeline', () => {
const observations = [
createTestObservation({ id: 1, created_at_epoch: 1000 }),
];
const summaries = [
createTestSummaryTimelineItem({ id: 1, displayEpoch: 2000 }),
];
const timeline = buildTimeline(observations, summaries);
expect(timeline).toHaveLength(2);
});
it('should sort timeline items chronologically by epoch', () => {
const observations = [
createTestObservation({ id: 1, created_at_epoch: 3000 }),
createTestObservation({ id: 2, created_at_epoch: 1000 }),
];
const summaries = [
createTestSummaryTimelineItem({ id: 1, displayEpoch: 2000 }),
];
const timeline = buildTimeline(observations, summaries);
// Should be sorted: obs2 (1000), summary (2000), obs1 (3000)
expect(timeline).toHaveLength(3);
expect(timeline[0].type).toBe('observation');
expect((timeline[0].data as Observation).id).toBe(2);
expect(timeline[1].type).toBe('summary');
expect(timeline[2].type).toBe('observation');
expect((timeline[2].data as Observation).id).toBe(1);
});
it('should handle empty observations array', () => {
const summaries = [
createTestSummaryTimelineItem({ id: 1, displayEpoch: 1000 }),
];
const timeline = buildTimeline([], summaries);
expect(timeline).toHaveLength(1);
expect(timeline[0].type).toBe('summary');
});
it('should handle empty summaries array', () => {
const observations = [
createTestObservation({ id: 1, created_at_epoch: 1000 }),
];
const timeline = buildTimeline(observations, []);
expect(timeline).toHaveLength(1);
expect(timeline[0].type).toBe('observation');
});
it('should handle both empty arrays', () => {
const timeline = buildTimeline([], []);
expect(timeline).toHaveLength(0);
});
it('should correctly tag items with their type', () => {
const observations = [createTestObservation()];
const summaries = [createTestSummaryTimelineItem()];
const timeline = buildTimeline(observations, summaries);
const observationItem = timeline.find(item => item.type === 'observation');
const summaryItem = timeline.find(item => item.type === 'summary');
expect(observationItem).toBeDefined();
expect(summaryItem).toBeDefined();
expect(observationItem!.data).toHaveProperty('narrative');
expect(summaryItem!.data).toHaveProperty('request');
});
it('should use displayEpoch for summary sorting, not created_at_epoch', () => {
const observations = [
createTestObservation({ id: 1, created_at_epoch: 2000 }),
];
const summaries = [
createTestSummaryTimelineItem({
id: 1,
created_at_epoch: 3000, // Created later
displayEpoch: 1000, // But displayed earlier
}),
];
const timeline = buildTimeline(observations, summaries);
// Summary should come first because its displayEpoch is earlier
expect(timeline[0].type).toBe('summary');
expect(timeline[1].type).toBe('observation');
});
});
describe('getPriorSessionMessages', () => {
it('should return empty messages when showLastMessage is false', () => {
const observations = [createTestObservation()];
const config = createTestConfig({ showLastMessage: false });
const result = getPriorSessionMessages(observations, config, 'current-session', '/test/cwd');
expect(result.userMessage).toBe('');
expect(result.assistantMessage).toBe('');
});
it('should return empty messages when observations array is empty', () => {
const config = createTestConfig({ showLastMessage: true });
const result = getPriorSessionMessages([], config, 'current-session', '/test/cwd');
expect(result.userMessage).toBe('');
expect(result.assistantMessage).toBe('');
});
it('should return empty messages when no prior session found', () => {
// All observations have same session ID as current
const observations = [
createTestObservation({ memory_session_id: 'current-session' }),
];
const config = createTestConfig({ showLastMessage: true });
const result = getPriorSessionMessages(observations, config, 'current-session', '/test/cwd');
expect(result.userMessage).toBe('');
expect(result.assistantMessage).toBe('');
});
it('should look for prior session when current session differs', () => {
// Has observation from a different session
const observations = [
createTestObservation({ memory_session_id: 'prior-session' }),
];
const config = createTestConfig({ showLastMessage: true });
// Transcript file won't exist, so should return empty strings
const result = getPriorSessionMessages(observations, config, 'current-session', '/nonexistent/path');
expect(result.userMessage).toBe('');
expect(result.assistantMessage).toBe('');
});
});
});
+262
View File
@@ -0,0 +1,262 @@
import { describe, it, expect } from 'bun:test';
import {
calculateObservationTokens,
calculateTokenEconomics,
} from '../../src/services/context/index.js';
import type { Observation } from '../../src/services/context/types.js';
import { CHARS_PER_TOKEN_ESTIMATE } from '../../src/services/context/types.js';
// Helper to create a minimal observation for testing
function createTestObservation(overrides: Partial<Observation> = {}): Observation {
return {
id: 1,
memory_session_id: 'session-123',
type: 'discovery',
title: null,
subtitle: null,
narrative: null,
facts: null,
concepts: null,
files_read: null,
files_modified: null,
discovery_tokens: null,
created_at: '2025-01-01T12:00:00.000Z',
created_at_epoch: 1735732800000,
...overrides,
};
}
describe('TokenCalculator', () => {
describe('CHARS_PER_TOKEN_ESTIMATE constant', () => {
it('should be 4 characters per token', () => {
expect(CHARS_PER_TOKEN_ESTIMATE).toBe(4);
});
});
describe('calculateObservationTokens', () => {
it('should return 0 for an observation with no content', () => {
const obs = createTestObservation();
const tokens = calculateObservationTokens(obs);
// Even empty observations have facts as "[]" when stringified
// null facts becomes '[]' = 2 chars / 4 = 0.5 -> ceil = 1
expect(tokens).toBe(1);
});
it('should estimate tokens based on title length', () => {
const title = 'A'.repeat(40); // 40 chars = 10 tokens
const obs = createTestObservation({ title });
const tokens = calculateObservationTokens(obs);
// title (40) + facts stringified (null -> '[]' = 2) = 42 / 4 = 10.5 -> 11
expect(tokens).toBe(11);
});
it('should estimate tokens based on subtitle length', () => {
const subtitle = 'B'.repeat(20); // 20 chars = 5 tokens
const obs = createTestObservation({ subtitle });
const tokens = calculateObservationTokens(obs);
// subtitle (20) + facts (2) = 22 / 4 = 5.5 -> 6
expect(tokens).toBe(6);
});
it('should estimate tokens based on narrative length', () => {
const narrative = 'C'.repeat(80); // 80 chars = 20 tokens
const obs = createTestObservation({ narrative });
const tokens = calculateObservationTokens(obs);
// narrative (80) + facts (2) = 82 / 4 = 20.5 -> 21
expect(tokens).toBe(21);
});
it('should estimate tokens based on facts JSON length', () => {
// When facts is a string, JSON.stringify adds quotes around it
// '["fact"]' as string becomes '"[\\"fact\\"]"' when stringified
// But in practice, obs.facts is a string that gets stringified
const facts = '["fact one", "fact two", "fact three"]'; // 38 chars
const obs = createTestObservation({ facts });
const tokens = calculateObservationTokens(obs);
// JSON.stringify of string adds quotes: 38 + 2 = 40, plus escaping
// Actually becomes: '"[\"fact one\", \"fact two\", \"fact three\"]"' = 46 chars
// 46 / 4 = 11.5 -> 12
expect(tokens).toBe(12);
});
it('should combine all fields for total token estimate', () => {
const obs = createTestObservation({
title: 'A'.repeat(20), // 20 chars
subtitle: 'B'.repeat(20), // 20 chars
narrative: 'C'.repeat(40), // 40 chars
facts: '["test"]', // 8 chars, but JSON.stringify adds quotes = 10 chars
});
const tokens = calculateObservationTokens(obs);
// 20 + 20 + 40 + 10 (stringified) = 90 / 4 = 22.5 -> 23
expect(tokens).toBe(23);
});
it('should handle large observations correctly', () => {
const largeNarrative = 'X'.repeat(4000); // 4000 chars = 1000 tokens
const obs = createTestObservation({ narrative: largeNarrative });
const tokens = calculateObservationTokens(obs);
// 4000 + 2 (null facts) = 4002 / 4 = 1000.5 -> 1001
expect(tokens).toBe(1001);
});
it('should round up fractional tokens using ceil', () => {
// 9 chars / 4 = 2.25 -> should be 3
const obs = createTestObservation({ title: 'ABCDEFGHI' }); // 9 chars
const tokens = calculateObservationTokens(obs);
// 9 + 2 = 11 / 4 = 2.75 -> 3
expect(tokens).toBe(3);
});
});
describe('calculateTokenEconomics', () => {
it('should return zeros for empty observations array', () => {
const economics = calculateTokenEconomics([]);
expect(economics.totalObservations).toBe(0);
expect(economics.totalReadTokens).toBe(0);
expect(economics.totalDiscoveryTokens).toBe(0);
expect(economics.savings).toBe(0);
expect(economics.savingsPercent).toBe(0);
});
it('should count total observations', () => {
const observations = [
createTestObservation({ id: 1 }),
createTestObservation({ id: 2 }),
createTestObservation({ id: 3 }),
];
const economics = calculateTokenEconomics(observations);
expect(economics.totalObservations).toBe(3);
});
it('should sum read tokens from all observations', () => {
const observations = [
createTestObservation({ title: 'A'.repeat(40) }), // ~11 tokens
createTestObservation({ title: 'B'.repeat(40) }), // ~11 tokens
];
const economics = calculateTokenEconomics(observations);
expect(economics.totalReadTokens).toBe(22);
});
it('should sum discovery tokens from all observations', () => {
const observations = [
createTestObservation({ discovery_tokens: 100 }),
createTestObservation({ discovery_tokens: 200 }),
createTestObservation({ discovery_tokens: 300 }),
];
const economics = calculateTokenEconomics(observations);
expect(economics.totalDiscoveryTokens).toBe(600);
});
it('should handle null discovery_tokens as 0', () => {
const observations = [
createTestObservation({ discovery_tokens: 100 }),
createTestObservation({ discovery_tokens: null }),
createTestObservation({ discovery_tokens: 50 }),
];
const economics = calculateTokenEconomics(observations);
expect(economics.totalDiscoveryTokens).toBe(150);
});
it('should calculate savings as discovery minus read tokens', () => {
const observations = [
createTestObservation({
title: 'A'.repeat(40), // ~11 read tokens
discovery_tokens: 500,
}),
];
const economics = calculateTokenEconomics(observations);
expect(economics.savings).toBe(500 - 11);
expect(economics.savings).toBe(489);
});
it('should calculate savings percent correctly', () => {
// If discovery = 1000 and read = 100, savings = 900, percent = 90%
const observations = [
createTestObservation({
title: 'A'.repeat(396), // 396 + 2 = 398 / 4 = 99.5 -> 100 read tokens
discovery_tokens: 1000,
}),
];
const economics = calculateTokenEconomics(observations);
expect(economics.totalReadTokens).toBe(100);
expect(economics.totalDiscoveryTokens).toBe(1000);
expect(economics.savings).toBe(900);
expect(economics.savingsPercent).toBe(90);
});
it('should return 0% savings when discovery tokens is 0', () => {
const observations = [
createTestObservation({ discovery_tokens: 0 }),
createTestObservation({ discovery_tokens: null }),
];
const economics = calculateTokenEconomics(observations);
expect(economics.savingsPercent).toBe(0);
});
it('should handle negative savings correctly', () => {
// When read tokens > discovery tokens, savings is negative
const observations = [
createTestObservation({
narrative: 'X'.repeat(400), // ~101 read tokens
discovery_tokens: 50,
}),
];
const economics = calculateTokenEconomics(observations);
expect(economics.savings).toBeLessThan(0);
});
it('should round savings percent to nearest integer', () => {
// Create a scenario where savings percent is fractional
// discovery = 100, read = 33, savings = 67, percent = 67%
const observations = [
createTestObservation({
title: 'A'.repeat(130), // 130 + 2 = 132 / 4 = 33 read tokens
discovery_tokens: 100,
}),
];
const economics = calculateTokenEconomics(observations);
expect(economics.totalReadTokens).toBe(33);
expect(economics.savingsPercent).toBe(67);
});
it('should aggregate correctly with multiple observations', () => {
const observations = [
createTestObservation({
id: 1,
title: 'A'.repeat(20),
narrative: 'X'.repeat(60),
discovery_tokens: 500,
}),
createTestObservation({
id: 2,
title: 'B'.repeat(40),
subtitle: 'Y'.repeat(40),
discovery_tokens: 300,
}),
createTestObservation({
id: 3,
narrative: 'Z'.repeat(100),
facts: '["fact1", "fact2"]',
discovery_tokens: 200,
}),
];
const economics = calculateTokenEconomics(observations);
expect(economics.totalObservations).toBe(3);
expect(economics.totalDiscoveryTokens).toBe(1000);
expect(economics.totalReadTokens).toBeGreaterThan(0);
expect(economics.savings).toBe(economics.totalDiscoveryTokens - economics.totalReadTokens);
});
});
});
+74 -28
View File
@@ -5,18 +5,26 @@ import { SessionManager } from '../src/services/worker/SessionManager';
import { ModeManager } from '../src/services/worker/domain/ModeManager';
import { SettingsDefaultsManager } from '../src/shared/SettingsDefaultsManager';
let billingEnabled = 'true';
// Track rate limiting setting (controls Gemini RPM throttling)
// Set to 'false' to disable rate limiting for faster tests
let rateLimitingEnabled = 'false';
// Mock SettingsDefaultsManager
// Mock SettingsDefaultsManager - must return complete settings object
mock.module('../src/shared/SettingsDefaultsManager', () => ({
SettingsDefaultsManager: {
loadFromFile: () => ({
CLAUDE_MEM_GEMINI_API_KEY: 'test-api-key',
CLAUDE_MEM_GEMINI_MODEL: 'gemini-2.5-flash-lite',
CLAUDE_MEM_GEMINI_BILLING_ENABLED: billingEnabled
CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED: rateLimitingEnabled, // This is what GeminiAgent actually checks
CLAUDE_MEM_LOG_LEVEL: 'INFO',
CLAUDE_MEM_DATA_DIR: '/tmp/claude-mem-test'
}),
get: (key: string) => {
if (key === 'CLAUDE_MEM_LOG_LEVEL') return 'INFO';
if (key === 'CLAUDE_MEM_DATA_DIR') return '/tmp/claude-mem-test';
if (key === 'CLAUDE_MEM_GEMINI_API_KEY') return 'test-api-key';
if (key === 'CLAUDE_MEM_GEMINI_MODEL') return 'gemini-2.5-flash-lite';
if (key === 'CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED') return rateLimitingEnabled;
return '';
}
}
@@ -48,6 +56,7 @@ describe('GeminiAgent', () => {
// Mocks
let mockStoreObservation: any;
let mockStoreObservations: any; // Plural - atomic transaction method used by ResponseProcessor
let mockStoreSummary: any;
let mockMarkSessionCompleted: any;
let mockSyncObservation: any;
@@ -59,8 +68,8 @@ describe('GeminiAgent', () => {
let mockSessionManager: SessionManager;
beforeEach(() => {
// Reset billing for each test default
billingEnabled = 'true';
// Reset rate limiting to disabled by default (speeds up tests)
rateLimitingEnabled = 'false';
// Initialize mocks
mockStoreObservation = mock(() => ({ id: 1, createdAtEpoch: Date.now() }));
@@ -72,8 +81,16 @@ describe('GeminiAgent', () => {
mockCleanupProcessed = mock(() => 0);
mockResetStuckMessages = mock(() => 0);
// Mock for storeObservations (plural) - the atomic transaction method called by ResponseProcessor
mockStoreObservations = mock(() => ({
observationIds: [1],
summaryId: 1,
createdAtEpoch: Date.now()
}));
const mockSessionStore = {
storeObservation: mockStoreObservation,
storeObservations: mockStoreObservations, // Required by ResponseProcessor.ts
storeSummary: mockStoreSummary,
markSessionCompleted: mockMarkSessionCompleted
};
@@ -111,15 +128,19 @@ describe('GeminiAgent', () => {
it('should initialize with correct config', async () => {
const session = {
sessionDbId: 1,
claudeSessionId: 'test-session',
sdkSessionId: 'test-sdk',
contentSessionId: 'test-session',
memorySessionId: 'mem-session-123',
project: 'test-project',
userPrompt: 'test prompt',
conversationHistory: [],
lastPromptNumber: 1,
cumulativeInputTokens: 0,
cumulativeOutputTokens: 0,
pendingProcessingIds: new Set(),
pendingMessages: [],
abortController: new AbortController(),
generatorPromise: null,
earliestPendingTimestamp: null,
currentProvider: null,
startTime: Date.now()
} as any;
@@ -143,15 +164,19 @@ describe('GeminiAgent', () => {
it('should handle multi-turn conversation', async () => {
const session = {
sessionDbId: 1,
claudeSessionId: 'test-session',
sdkSessionId: 'test-sdk',
contentSessionId: 'test-session',
memorySessionId: 'mem-session-123',
project: 'test-project',
userPrompt: 'test prompt',
conversationHistory: [{ role: 'user', content: 'prev context' }, { role: 'assistant', content: 'prev response' }],
lastPromptNumber: 2,
cumulativeInputTokens: 0,
cumulativeOutputTokens: 0,
pendingProcessingIds: new Set(),
pendingMessages: [],
abortController: new AbortController(),
generatorPromise: null,
earliestPendingTimestamp: null,
currentProvider: null,
startTime: Date.now()
} as any;
@@ -171,15 +196,19 @@ describe('GeminiAgent', () => {
it('should process observations and store them', async () => {
const session = {
sessionDbId: 1,
claudeSessionId: 'test-session',
sdkSessionId: 'test-sdk',
contentSessionId: 'test-session',
memorySessionId: 'mem-session-123',
project: 'test-project',
userPrompt: 'test prompt',
conversationHistory: [],
lastPromptNumber: 1,
cumulativeInputTokens: 0,
cumulativeOutputTokens: 0,
pendingProcessingIds: new Set(),
pendingMessages: [],
abortController: new AbortController(),
generatorPromise: null,
earliestPendingTimestamp: null,
currentProvider: null,
startTime: Date.now()
} as any;
@@ -203,7 +232,8 @@ describe('GeminiAgent', () => {
await agent.startSession(session);
expect(mockStoreObservation).toHaveBeenCalled();
// ResponseProcessor uses storeObservations (plural) for atomic transactions
expect(mockStoreObservations).toHaveBeenCalled();
expect(mockSyncObservation).toHaveBeenCalled();
expect(session.cumulativeInputTokens).toBeGreaterThan(0);
});
@@ -211,15 +241,19 @@ describe('GeminiAgent', () => {
it('should fallback to Claude on rate limit error', async () => {
const session = {
sessionDbId: 1,
claudeSessionId: 'test-session',
sdkSessionId: 'test-sdk',
contentSessionId: 'test-session',
memorySessionId: 'mem-session-123',
project: 'test-project',
userPrompt: 'test prompt',
conversationHistory: [],
lastPromptNumber: 1,
cumulativeInputTokens: 0,
cumulativeOutputTokens: 0,
pendingProcessingIds: new Set(),
pendingMessages: [],
abortController: new AbortController(),
generatorPromise: null,
earliestPendingTimestamp: null,
currentProvider: null,
startTime: Date.now()
} as any;
@@ -232,22 +266,27 @@ describe('GeminiAgent', () => {
await agent.startSession(session);
// Verify fallback to Claude was triggered
expect(fallbackAgent.startSession).toHaveBeenCalledWith(session, undefined);
expect(mockResetStuckMessages).toHaveBeenCalled();
// Note: resetStuckMessages is called by worker-service.ts, not by GeminiAgent
});
it('should NOT fallback on other errors', async () => {
const session = {
sessionDbId: 1,
claudeSessionId: 'test-session',
sdkSessionId: 'test-sdk',
contentSessionId: 'test-session',
memorySessionId: 'mem-session-123',
project: 'test-project',
userPrompt: 'test prompt',
conversationHistory: [],
lastPromptNumber: 1,
cumulativeInputTokens: 0,
cumulativeOutputTokens: 0,
pendingProcessingIds: new Set(),
pendingMessages: [],
abortController: new AbortController(),
generatorPromise: null,
earliestPendingTimestamp: null,
currentProvider: null,
startTime: Date.now()
} as any;
@@ -258,12 +297,15 @@ describe('GeminiAgent', () => {
};
agent.setFallbackAgent(fallbackAgent);
expect(agent.startSession(session)).rejects.toThrow('Gemini API error: 400 - Invalid argument');
await expect(agent.startSession(session)).rejects.toThrow('Gemini API error: 400 - Invalid argument');
expect(fallbackAgent.startSession).not.toHaveBeenCalled();
});
it('should respect rate limits when billing disabled', async () => {
billingEnabled = 'false';
it('should respect rate limits when rate limiting enabled', async () => {
// Enable rate limiting - this means requests will be throttled
// Note: CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED !== 'false' means enabled
rateLimitingEnabled = 'true';
const originalSetTimeout = global.setTimeout;
const mockSetTimeout = mock((cb: any) => cb());
global.setTimeout = mockSetTimeout as any;
@@ -271,15 +313,19 @@ describe('GeminiAgent', () => {
try {
const session = {
sessionDbId: 1,
claudeSessionId: 'test-session',
sdkSessionId: 'test-sdk',
contentSessionId: 'test-session',
memorySessionId: 'mem-session-123',
project: 'test-project',
userPrompt: 'test prompt',
conversationHistory: [],
lastPromptNumber: 1,
cumulativeInputTokens: 0,
cumulativeOutputTokens: 0,
pendingProcessingIds: new Set(),
pendingMessages: [],
abortController: new AbortController(),
generatorPromise: null,
earliestPendingTimestamp: null,
currentProvider: null,
startTime: Date.now()
} as any;
@@ -0,0 +1,238 @@
import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test';
import { existsSync, readFileSync } from 'fs';
import { homedir } from 'os';
import path from 'path';
import http from 'http';
import {
performGracefulShutdown,
writePidFile,
readPidFile,
removePidFile,
type GracefulShutdownConfig,
type ShutdownableService,
type CloseableClient,
type CloseableDatabase,
type PidInfo
} from '../../src/services/infrastructure/index.js';
const DATA_DIR = path.join(homedir(), '.claude-mem');
const PID_FILE = path.join(DATA_DIR, 'worker.pid');
describe('GracefulShutdown', () => {
// Store original PID file content if it exists
let originalPidContent: string | null = null;
const originalPlatform = process.platform;
beforeEach(() => {
// Backup existing PID file if present
if (existsSync(PID_FILE)) {
originalPidContent = readFileSync(PID_FILE, 'utf-8');
}
// Ensure we're testing on non-Windows to avoid child process enumeration
Object.defineProperty(process, 'platform', {
value: 'darwin',
writable: true,
configurable: true
});
});
afterEach(() => {
// Restore original PID file or remove test one
if (originalPidContent !== null) {
const { writeFileSync } = require('fs');
writeFileSync(PID_FILE, originalPidContent);
originalPidContent = null;
} else {
removePidFile();
}
// Restore platform
Object.defineProperty(process, 'platform', {
value: originalPlatform,
writable: true,
configurable: true
});
});
describe('performGracefulShutdown', () => {
it('should call shutdown steps in correct order', async () => {
const callOrder: string[] = [];
const mockServer = {
closeAllConnections: mock(() => {
callOrder.push('closeAllConnections');
}),
close: mock((cb: (err?: Error) => void) => {
callOrder.push('serverClose');
cb();
})
} as unknown as http.Server;
const mockSessionManager: ShutdownableService = {
shutdownAll: mock(async () => {
callOrder.push('sessionManager.shutdownAll');
})
};
const mockMcpClient: CloseableClient = {
close: mock(async () => {
callOrder.push('mcpClient.close');
})
};
const mockDbManager: CloseableDatabase = {
close: mock(async () => {
callOrder.push('dbManager.close');
})
};
// Create a PID file so we can verify it's removed
writePidFile({ pid: 12345, port: 37777, startedAt: new Date().toISOString() });
expect(existsSync(PID_FILE)).toBe(true);
const config: GracefulShutdownConfig = {
server: mockServer,
sessionManager: mockSessionManager,
mcpClient: mockMcpClient,
dbManager: mockDbManager
};
await performGracefulShutdown(config);
// Verify order: PID removal happens first (synchronous), then server, then session, then MCP, then DB
expect(callOrder).toContain('closeAllConnections');
expect(callOrder).toContain('serverClose');
expect(callOrder).toContain('sessionManager.shutdownAll');
expect(callOrder).toContain('mcpClient.close');
expect(callOrder).toContain('dbManager.close');
// Verify server closes before session manager
expect(callOrder.indexOf('serverClose')).toBeLessThan(callOrder.indexOf('sessionManager.shutdownAll'));
// Verify session manager shuts down before MCP client
expect(callOrder.indexOf('sessionManager.shutdownAll')).toBeLessThan(callOrder.indexOf('mcpClient.close'));
// Verify MCP closes before database
expect(callOrder.indexOf('mcpClient.close')).toBeLessThan(callOrder.indexOf('dbManager.close'));
});
it('should remove PID file during shutdown', async () => {
const mockSessionManager: ShutdownableService = {
shutdownAll: mock(async () => {})
};
// Create PID file
writePidFile({ pid: 99999, port: 37777, startedAt: new Date().toISOString() });
expect(existsSync(PID_FILE)).toBe(true);
const config: GracefulShutdownConfig = {
server: null,
sessionManager: mockSessionManager
};
await performGracefulShutdown(config);
// PID file should be removed
expect(existsSync(PID_FILE)).toBe(false);
});
it('should handle missing optional services gracefully', async () => {
const mockSessionManager: ShutdownableService = {
shutdownAll: mock(async () => {})
};
const config: GracefulShutdownConfig = {
server: null,
sessionManager: mockSessionManager
// mcpClient and dbManager are undefined
};
// Should not throw
await expect(performGracefulShutdown(config)).resolves.toBeUndefined();
// Session manager should still be called
expect(mockSessionManager.shutdownAll).toHaveBeenCalled();
});
it('should handle null server gracefully', async () => {
const mockSessionManager: ShutdownableService = {
shutdownAll: mock(async () => {})
};
const config: GracefulShutdownConfig = {
server: null,
sessionManager: mockSessionManager
};
// Should not throw
await expect(performGracefulShutdown(config)).resolves.toBeUndefined();
});
it('should call sessionManager.shutdownAll even without server', async () => {
const mockSessionManager: ShutdownableService = {
shutdownAll: mock(async () => {})
};
const config: GracefulShutdownConfig = {
server: null,
sessionManager: mockSessionManager
};
await performGracefulShutdown(config);
expect(mockSessionManager.shutdownAll).toHaveBeenCalledTimes(1);
});
it('should close database after MCP client', async () => {
const callOrder: string[] = [];
const mockSessionManager: ShutdownableService = {
shutdownAll: mock(async () => {
callOrder.push('sessionManager');
})
};
const mockMcpClient: CloseableClient = {
close: mock(async () => {
callOrder.push('mcpClient');
})
};
const mockDbManager: CloseableDatabase = {
close: mock(async () => {
callOrder.push('dbManager');
})
};
const config: GracefulShutdownConfig = {
server: null,
sessionManager: mockSessionManager,
mcpClient: mockMcpClient,
dbManager: mockDbManager
};
await performGracefulShutdown(config);
expect(callOrder).toEqual(['sessionManager', 'mcpClient', 'dbManager']);
});
it('should handle shutdown when PID file does not exist', async () => {
// Ensure PID file doesn't exist
removePidFile();
expect(existsSync(PID_FILE)).toBe(false);
const mockSessionManager: ShutdownableService = {
shutdownAll: mock(async () => {})
};
const config: GracefulShutdownConfig = {
server: null,
sessionManager: mockSessionManager
};
// Should not throw
await expect(performGracefulShutdown(config)).resolves.toBeUndefined();
});
});
});
+175
View File
@@ -0,0 +1,175 @@
import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test';
import {
isPortInUse,
waitForHealth,
waitForPortFree
} from '../../src/services/infrastructure/index.js';
describe('HealthMonitor', () => {
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
});
describe('isPortInUse', () => {
it('should return true for occupied port (health check succeeds)', async () => {
global.fetch = mock(() => Promise.resolve({ ok: true } as Response));
const result = await isPortInUse(37777);
expect(result).toBe(true);
expect(global.fetch).toHaveBeenCalledWith('http://127.0.0.1:37777/api/health');
});
it('should return false for free port (connection refused)', async () => {
global.fetch = mock(() => Promise.reject(new Error('ECONNREFUSED')));
const result = await isPortInUse(39999);
expect(result).toBe(false);
});
it('should return false when health check returns non-ok', async () => {
global.fetch = mock(() => Promise.resolve({ ok: false, status: 503 } as Response));
const result = await isPortInUse(37777);
expect(result).toBe(false);
});
it('should return false on network timeout', async () => {
global.fetch = mock(() => Promise.reject(new Error('ETIMEDOUT')));
const result = await isPortInUse(37777);
expect(result).toBe(false);
});
it('should return false on fetch failed error', async () => {
global.fetch = mock(() => Promise.reject(new Error('fetch failed')));
const result = await isPortInUse(37777);
expect(result).toBe(false);
});
});
describe('waitForHealth', () => {
it('should succeed immediately when server responds', async () => {
global.fetch = mock(() => Promise.resolve({ ok: true } as Response));
const start = Date.now();
const result = await waitForHealth(37777, 5000);
const elapsed = Date.now() - start;
expect(result).toBe(true);
// Should return quickly (within first poll cycle)
expect(elapsed).toBeLessThan(1000);
});
it('should timeout when no server responds', async () => {
global.fetch = mock(() => Promise.reject(new Error('ECONNREFUSED')));
const start = Date.now();
const result = await waitForHealth(39999, 1500);
const elapsed = Date.now() - start;
expect(result).toBe(false);
// Should take close to timeout duration
expect(elapsed).toBeGreaterThanOrEqual(1400);
expect(elapsed).toBeLessThan(2500);
});
it('should succeed after server becomes available', async () => {
let callCount = 0;
global.fetch = mock(() => {
callCount++;
// Fail first 2 calls, succeed on third
if (callCount < 3) {
return Promise.reject(new Error('ECONNREFUSED'));
}
return Promise.resolve({ ok: true } as Response);
});
const result = await waitForHealth(37777, 5000);
expect(result).toBe(true);
expect(callCount).toBeGreaterThanOrEqual(3);
});
it('should check readiness endpoint not health endpoint', async () => {
const fetchMock = mock(() => Promise.resolve({ ok: true } as Response));
global.fetch = fetchMock;
await waitForHealth(37777, 1000);
// waitForHealth uses /api/readiness, not /api/health
const calls = fetchMock.mock.calls;
expect(calls.length).toBeGreaterThan(0);
expect(calls[0][0]).toBe('http://127.0.0.1:37777/api/readiness');
});
it('should use default timeout when not specified', async () => {
global.fetch = mock(() => Promise.resolve({ ok: true } as Response));
// Just verify it doesn't throw and returns quickly
const result = await waitForHealth(37777);
expect(result).toBe(true);
});
});
describe('waitForPortFree', () => {
it('should return true immediately when port is already free', async () => {
global.fetch = mock(() => Promise.reject(new Error('ECONNREFUSED')));
const start = Date.now();
const result = await waitForPortFree(39999, 5000);
const elapsed = Date.now() - start;
expect(result).toBe(true);
// Should return quickly
expect(elapsed).toBeLessThan(1000);
});
it('should timeout when port remains occupied', async () => {
global.fetch = mock(() => Promise.resolve({ ok: true } as Response));
const start = Date.now();
const result = await waitForPortFree(37777, 1500);
const elapsed = Date.now() - start;
expect(result).toBe(false);
// Should take close to timeout duration
expect(elapsed).toBeGreaterThanOrEqual(1400);
expect(elapsed).toBeLessThan(2500);
});
it('should succeed when port becomes free', async () => {
let callCount = 0;
global.fetch = mock(() => {
callCount++;
// Port occupied for first 2 checks, then free
if (callCount < 3) {
return Promise.resolve({ ok: true } as Response);
}
return Promise.reject(new Error('ECONNREFUSED'));
});
const result = await waitForPortFree(37777, 5000);
expect(result).toBe(true);
expect(callCount).toBeGreaterThanOrEqual(3);
});
it('should use default timeout when not specified', async () => {
global.fetch = mock(() => Promise.reject(new Error('ECONNREFUSED')));
// Just verify it doesn't throw and returns quickly
const result = await waitForPortFree(39999);
expect(result).toBe(true);
});
});
});
@@ -0,0 +1,197 @@
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { existsSync, readFileSync } from 'fs';
import { homedir } from 'os';
import path from 'path';
import {
writePidFile,
readPidFile,
removePidFile,
getPlatformTimeout,
type PidInfo
} from '../../src/services/infrastructure/index.js';
const DATA_DIR = path.join(homedir(), '.claude-mem');
const PID_FILE = path.join(DATA_DIR, 'worker.pid');
describe('ProcessManager', () => {
// Store original PID file content if it exists
let originalPidContent: string | null = null;
beforeEach(() => {
// Backup existing PID file if present
if (existsSync(PID_FILE)) {
originalPidContent = readFileSync(PID_FILE, 'utf-8');
}
});
afterEach(() => {
// Restore original PID file or remove test one
if (originalPidContent !== null) {
const { writeFileSync } = require('fs');
writeFileSync(PID_FILE, originalPidContent);
originalPidContent = null;
} else {
removePidFile();
}
});
describe('writePidFile', () => {
it('should create file with PID info', () => {
const testInfo: PidInfo = {
pid: 12345,
port: 37777,
startedAt: new Date().toISOString()
};
writePidFile(testInfo);
expect(existsSync(PID_FILE)).toBe(true);
const content = JSON.parse(readFileSync(PID_FILE, 'utf-8'));
expect(content.pid).toBe(12345);
expect(content.port).toBe(37777);
expect(content.startedAt).toBe(testInfo.startedAt);
});
it('should overwrite existing PID file', () => {
const firstInfo: PidInfo = {
pid: 11111,
port: 37777,
startedAt: '2024-01-01T00:00:00.000Z'
};
const secondInfo: PidInfo = {
pid: 22222,
port: 37888,
startedAt: '2024-01-02T00:00:00.000Z'
};
writePidFile(firstInfo);
writePidFile(secondInfo);
const content = JSON.parse(readFileSync(PID_FILE, 'utf-8'));
expect(content.pid).toBe(22222);
expect(content.port).toBe(37888);
});
});
describe('readPidFile', () => {
it('should return PidInfo object for valid file', () => {
const testInfo: PidInfo = {
pid: 54321,
port: 37999,
startedAt: '2024-06-15T12:00:00.000Z'
};
writePidFile(testInfo);
const result = readPidFile();
expect(result).not.toBeNull();
expect(result!.pid).toBe(54321);
expect(result!.port).toBe(37999);
expect(result!.startedAt).toBe('2024-06-15T12:00:00.000Z');
});
it('should return null for missing file', () => {
// Ensure file doesn't exist
removePidFile();
const result = readPidFile();
expect(result).toBeNull();
});
it('should return null for corrupted JSON', () => {
const { writeFileSync } = require('fs');
writeFileSync(PID_FILE, 'not valid json {{{');
const result = readPidFile();
expect(result).toBeNull();
});
});
describe('removePidFile', () => {
it('should delete existing file', () => {
const testInfo: PidInfo = {
pid: 99999,
port: 37777,
startedAt: new Date().toISOString()
};
writePidFile(testInfo);
expect(existsSync(PID_FILE)).toBe(true);
removePidFile();
expect(existsSync(PID_FILE)).toBe(false);
});
it('should not throw for missing file', () => {
// Ensure file doesn't exist
removePidFile();
expect(existsSync(PID_FILE)).toBe(false);
// Should not throw
expect(() => removePidFile()).not.toThrow();
});
});
describe('getPlatformTimeout', () => {
const originalPlatform = process.platform;
afterEach(() => {
Object.defineProperty(process, 'platform', {
value: originalPlatform,
writable: true,
configurable: true
});
});
it('should return same value on non-Windows platforms', () => {
Object.defineProperty(process, 'platform', {
value: 'darwin',
writable: true,
configurable: true
});
const result = getPlatformTimeout(1000);
expect(result).toBe(1000);
});
it('should return doubled value on Windows', () => {
Object.defineProperty(process, 'platform', {
value: 'win32',
writable: true,
configurable: true
});
const result = getPlatformTimeout(1000);
expect(result).toBe(2000);
});
it('should apply 2.0x multiplier consistently on Windows', () => {
Object.defineProperty(process, 'platform', {
value: 'win32',
writable: true,
configurable: true
});
expect(getPlatformTimeout(500)).toBe(1000);
expect(getPlatformTimeout(5000)).toBe(10000);
expect(getPlatformTimeout(100)).toBe(200);
});
it('should round Windows timeout values', () => {
Object.defineProperty(process, 'platform', {
value: 'win32',
writable: true,
configurable: true
});
// 2.0x of 333 = 666 (rounds to 666)
const result = getPlatformTimeout(333);
expect(result).toBe(666);
});
});
});
+2
View File
@@ -30,6 +30,8 @@ const EXCLUDED_PATTERNS = [
/paths\.ts$/, // Path utilities
/bun-path\.ts$/, // Path utilities
/migrations\.ts$/, // Database migrations (console.log for migration output)
/worker-service\.ts$/, // CLI entry point with interactive setup wizard (console.log for user prompts)
/integrations\/.*Installer\.ts$/, // CLI installer commands (console.log for interactive installation output)
];
// Files that should always use logger (core business logic)
+314
View File
@@ -0,0 +1,314 @@
import { describe, it, expect, mock, beforeEach, afterEach } from 'bun:test';
import type { Request, Response, NextFunction } from 'express';
// Mock logger to prevent console output during tests
mock.module('../../src/utils/logger.js', () => ({
logger: {
info: () => {},
debug: () => {},
warn: () => {},
error: () => {},
},
}));
// Import after mocks
import {
AppError,
createErrorResponse,
errorHandler,
notFoundHandler,
} from '../../src/services/server/ErrorHandler.js';
describe('ErrorHandler', () => {
afterEach(() => {
mock.restore();
});
describe('AppError', () => {
it('should extend Error', () => {
const error = new AppError('Test error');
expect(error).toBeInstanceOf(Error);
expect(error).toBeInstanceOf(AppError);
});
it('should set default statusCode to 500', () => {
const error = new AppError('Test error');
expect(error.statusCode).toBe(500);
});
it('should set custom statusCode', () => {
const error = new AppError('Not found', 404);
expect(error.statusCode).toBe(404);
});
it('should set error code when provided', () => {
const error = new AppError('Invalid input', 400, 'INVALID_INPUT');
expect(error.code).toBe('INVALID_INPUT');
});
it('should set details when provided', () => {
const details = { field: 'email', reason: 'invalid format' };
const error = new AppError('Validation failed', 400, 'VALIDATION_ERROR', details);
expect(error.details).toEqual(details);
});
it('should set message correctly', () => {
const error = new AppError('Something went wrong');
expect(error.message).toBe('Something went wrong');
});
it('should set name to AppError', () => {
const error = new AppError('Test error');
expect(error.name).toBe('AppError');
});
it('should handle all parameters together', () => {
const details = { userId: 123 };
const error = new AppError('User not found', 404, 'USER_NOT_FOUND', details);
expect(error.message).toBe('User not found');
expect(error.statusCode).toBe(404);
expect(error.code).toBe('USER_NOT_FOUND');
expect(error.details).toEqual(details);
expect(error.name).toBe('AppError');
});
});
describe('createErrorResponse', () => {
it('should create basic error response with error and message', () => {
const response = createErrorResponse('Error', 'Something went wrong');
expect(response.error).toBe('Error');
expect(response.message).toBe('Something went wrong');
expect(response.code).toBeUndefined();
expect(response.details).toBeUndefined();
});
it('should include code when provided', () => {
const response = createErrorResponse('ValidationError', 'Invalid input', 'INVALID_INPUT');
expect(response.error).toBe('ValidationError');
expect(response.message).toBe('Invalid input');
expect(response.code).toBe('INVALID_INPUT');
expect(response.details).toBeUndefined();
});
it('should include details when provided', () => {
const details = { fields: ['email', 'password'] };
const response = createErrorResponse('ValidationError', 'Multiple errors', 'VALIDATION_ERROR', details);
expect(response.error).toBe('ValidationError');
expect(response.message).toBe('Multiple errors');
expect(response.code).toBe('VALIDATION_ERROR');
expect(response.details).toEqual(details);
});
it('should not include code or details keys when not provided', () => {
const response = createErrorResponse('Error', 'Basic error');
expect(Object.keys(response)).toEqual(['error', 'message']);
});
it('should handle empty string code as falsy and exclude it', () => {
const response = createErrorResponse('Error', 'Test', '');
// Empty string is falsy, so code should not be set
expect(response.code).toBeUndefined();
});
});
describe('errorHandler middleware', () => {
let mockRequest: Partial<Request>;
let mockResponse: Partial<Response>;
let mockNext: NextFunction;
let statusSpy: ReturnType<typeof mock>;
let jsonSpy: ReturnType<typeof mock>;
beforeEach(() => {
statusSpy = mock(() => mockResponse);
jsonSpy = mock(() => mockResponse);
mockRequest = {
method: 'GET',
path: '/api/test',
};
mockResponse = {
status: statusSpy as unknown as Response['status'],
json: jsonSpy as unknown as Response['json'],
};
mockNext = mock(() => {});
});
it('should handle AppError with custom status code', () => {
const error = new AppError('Not found', 404, 'NOT_FOUND');
errorHandler(
error,
mockRequest as Request,
mockResponse as Response,
mockNext
);
expect(statusSpy).toHaveBeenCalledWith(404);
expect(jsonSpy).toHaveBeenCalled();
const responseBody = jsonSpy.mock.calls[0][0];
expect(responseBody.error).toBe('AppError');
expect(responseBody.message).toBe('Not found');
expect(responseBody.code).toBe('NOT_FOUND');
});
it('should handle AppError with details', () => {
const details = { resourceId: 'abc123' };
const error = new AppError('Resource not found', 404, 'RESOURCE_NOT_FOUND', details);
errorHandler(
error,
mockRequest as Request,
mockResponse as Response,
mockNext
);
const responseBody = jsonSpy.mock.calls[0][0];
expect(responseBody.details).toEqual(details);
});
it('should handle generic Error with 500 status code', () => {
const error = new Error('Something went wrong');
errorHandler(
error,
mockRequest as Request,
mockResponse as Response,
mockNext
);
expect(statusSpy).toHaveBeenCalledWith(500);
const responseBody = jsonSpy.mock.calls[0][0];
expect(responseBody.error).toBe('Error');
expect(responseBody.message).toBe('Something went wrong');
expect(responseBody.code).toBeUndefined();
expect(responseBody.details).toBeUndefined();
});
it('should not call next after handling error', () => {
const error = new AppError('Test error', 400);
errorHandler(
error,
mockRequest as Request,
mockResponse as Response,
mockNext
);
expect(mockNext).not.toHaveBeenCalled();
});
it('should use error name in response', () => {
const error = new TypeError('Invalid type');
errorHandler(
error,
mockRequest as Request,
mockResponse as Response,
mockNext
);
const responseBody = jsonSpy.mock.calls[0][0];
expect(responseBody.error).toBe('TypeError');
});
it('should handle AppError with default 500 status', () => {
const error = new AppError('Server error');
errorHandler(
error,
mockRequest as Request,
mockResponse as Response,
mockNext
);
expect(statusSpy).toHaveBeenCalledWith(500);
});
});
describe('notFoundHandler', () => {
let mockRequest: Partial<Request>;
let mockResponse: Partial<Response>;
let statusSpy: ReturnType<typeof mock>;
let jsonSpy: ReturnType<typeof mock>;
beforeEach(() => {
statusSpy = mock(() => mockResponse);
jsonSpy = mock(() => mockResponse);
mockResponse = {
status: statusSpy as unknown as Response['status'],
json: jsonSpy as unknown as Response['json'],
};
});
it('should return 404 status', () => {
mockRequest = {
method: 'GET',
path: '/api/unknown',
};
notFoundHandler(mockRequest as Request, mockResponse as Response);
expect(statusSpy).toHaveBeenCalledWith(404);
});
it('should include method and path in message', () => {
mockRequest = {
method: 'POST',
path: '/api/users',
};
notFoundHandler(mockRequest as Request, mockResponse as Response);
const responseBody = jsonSpy.mock.calls[0][0];
expect(responseBody.error).toBe('NotFound');
expect(responseBody.message).toBe('Cannot POST /api/users');
});
it('should handle DELETE method', () => {
mockRequest = {
method: 'DELETE',
path: '/api/items/123',
};
notFoundHandler(mockRequest as Request, mockResponse as Response);
const responseBody = jsonSpy.mock.calls[0][0];
expect(responseBody.message).toBe('Cannot DELETE /api/items/123');
});
it('should handle PUT method', () => {
mockRequest = {
method: 'PUT',
path: '/api/config',
};
notFoundHandler(mockRequest as Request, mockResponse as Response);
const responseBody = jsonSpy.mock.calls[0][0];
expect(responseBody.message).toBe('Cannot PUT /api/config');
});
it('should return structured error response', () => {
mockRequest = {
method: 'GET',
path: '/missing',
};
notFoundHandler(mockRequest as Request, mockResponse as Response);
const responseBody = jsonSpy.mock.calls[0][0];
expect(Object.keys(responseBody)).toEqual(['error', 'message']);
});
});
});
+377
View File
@@ -0,0 +1,377 @@
import { describe, it, expect, mock, beforeEach, afterEach } from 'bun:test';
// Mock logger to prevent console output during tests
mock.module('../../src/utils/logger.js', () => ({
logger: {
info: () => {},
debug: () => {},
warn: () => {},
error: () => {},
},
}));
// Mock middleware to avoid complex dependencies
mock.module('../../src/services/worker/http/middleware.js', () => ({
createMiddleware: () => [],
requireLocalhost: (_req: any, _res: any, next: any) => next(),
summarizeRequestBody: () => 'test body',
}));
// Import after mocks
import { Server } from '../../src/services/server/Server.js';
import type { RouteHandler, ServerOptions } from '../../src/services/server/Server.js';
describe('Server', () => {
let server: Server;
let mockOptions: ServerOptions;
beforeEach(() => {
mockOptions = {
getInitializationComplete: () => true,
getMcpReady: () => true,
onShutdown: mock(() => Promise.resolve()),
onRestart: mock(() => Promise.resolve()),
};
});
afterEach(async () => {
// Clean up server if created and still has an active http server
if (server && server.getHttpServer()) {
try {
await server.close();
} catch {
// Ignore errors on cleanup
}
}
mock.restore();
});
describe('constructor', () => {
it('should create Express app', () => {
server = new Server(mockOptions);
expect(server.app).toBeDefined();
expect(typeof server.app.get).toBe('function');
expect(typeof server.app.post).toBe('function');
expect(typeof server.app.use).toBe('function');
});
it('should expose app as readonly property', () => {
server = new Server(mockOptions);
// App should be accessible
expect(server.app).toBeDefined();
// App should be an Express application
expect(typeof server.app.listen).toBe('function');
});
});
describe('listen', () => {
it('should start server on specified port', async () => {
server = new Server(mockOptions);
// Use a random high port to avoid conflicts
const testPort = 40000 + Math.floor(Math.random() * 10000);
await server.listen(testPort, '127.0.0.1');
// Server should now be listening
const httpServer = server.getHttpServer();
expect(httpServer).not.toBeNull();
expect(httpServer!.listening).toBe(true);
});
it('should reject if port is already in use', async () => {
server = new Server(mockOptions);
const server2 = new Server(mockOptions);
const testPort = 40000 + Math.floor(Math.random() * 10000);
// Start first server
await server.listen(testPort, '127.0.0.1');
// Second server should fail on same port
await expect(server2.listen(testPort, '127.0.0.1')).rejects.toThrow();
// The server object was created but not successfully listening
const httpServer = server2.getHttpServer();
if (httpServer) {
expect(httpServer.listening).toBe(false);
}
});
});
describe('close', () => {
it('should stop server from listening after close', async () => {
server = new Server(mockOptions);
const testPort = 40000 + Math.floor(Math.random() * 10000);
await server.listen(testPort, '127.0.0.1');
// Server should exist and be listening
const httpServerBefore = server.getHttpServer();
expect(httpServerBefore).not.toBeNull();
expect(httpServerBefore!.listening).toBe(true);
// Close the server - may throw ERR_SERVER_NOT_RUNNING on some platforms
// because closeAllConnections() might immediately close the server
try {
await server.close();
} catch (e: any) {
// ERR_SERVER_NOT_RUNNING is acceptable - closeAllConnections() already closed it
if (e.code !== 'ERR_SERVER_NOT_RUNNING') {
throw e;
}
}
// The server should no longer be listening (even if ref is not null due to early throw)
const httpServerAfter = server.getHttpServer();
if (httpServerAfter) {
expect(httpServerAfter.listening).toBe(false);
}
});
it('should handle close when server not started', async () => {
server = new Server(mockOptions);
// Should not throw when closing unstarted server
await expect(server.close()).resolves.toBeUndefined();
});
it('should allow starting a new server on same port after close', async () => {
server = new Server(mockOptions);
const testPort = 40000 + Math.floor(Math.random() * 10000);
await server.listen(testPort, '127.0.0.1');
// Close the server
try {
await server.close();
} catch (e: any) {
// ERR_SERVER_NOT_RUNNING is acceptable
if (e.code !== 'ERR_SERVER_NOT_RUNNING') {
throw e;
}
}
// Small delay to ensure port is released
await new Promise(resolve => setTimeout(resolve, 100));
// Should be able to listen again on same port with a new server
const server2 = new Server(mockOptions);
await server2.listen(testPort, '127.0.0.1');
expect(server2.getHttpServer()!.listening).toBe(true);
// Clean up server2
try {
await server2.close();
} catch {
// Ignore cleanup errors
}
});
});
describe('getHttpServer', () => {
it('should return null before listen', () => {
server = new Server(mockOptions);
expect(server.getHttpServer()).toBeNull();
});
it('should return http.Server after listen', async () => {
server = new Server(mockOptions);
const testPort = 40000 + Math.floor(Math.random() * 10000);
await server.listen(testPort, '127.0.0.1');
const httpServer = server.getHttpServer();
expect(httpServer).not.toBeNull();
expect(httpServer!.listening).toBe(true);
});
});
describe('registerRoutes', () => {
it('should call setupRoutes on route handler', () => {
server = new Server(mockOptions);
const setupRoutesMock = mock(() => {});
const mockRouteHandler: RouteHandler = {
setupRoutes: setupRoutesMock,
};
server.registerRoutes(mockRouteHandler);
expect(setupRoutesMock).toHaveBeenCalledTimes(1);
expect(setupRoutesMock).toHaveBeenCalledWith(server.app);
});
it('should register multiple route handlers', () => {
server = new Server(mockOptions);
const handler1Mock = mock(() => {});
const handler2Mock = mock(() => {});
const handler1: RouteHandler = { setupRoutes: handler1Mock };
const handler2: RouteHandler = { setupRoutes: handler2Mock };
server.registerRoutes(handler1);
server.registerRoutes(handler2);
expect(handler1Mock).toHaveBeenCalledTimes(1);
expect(handler2Mock).toHaveBeenCalledTimes(1);
});
});
describe('finalizeRoutes', () => {
it('should not throw when called', () => {
server = new Server(mockOptions);
expect(() => server.finalizeRoutes()).not.toThrow();
});
});
describe('health endpoint', () => {
it('should return 200 with status ok', async () => {
server = new Server(mockOptions);
const testPort = 40000 + Math.floor(Math.random() * 10000);
await server.listen(testPort, '127.0.0.1');
const response = await fetch(`http://127.0.0.1:${testPort}/api/health`);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.status).toBe('ok');
});
it('should include initialization status', async () => {
server = new Server(mockOptions);
const testPort = 40000 + Math.floor(Math.random() * 10000);
await server.listen(testPort, '127.0.0.1');
const response = await fetch(`http://127.0.0.1:${testPort}/api/health`);
const body = await response.json();
expect(body.initialized).toBe(true);
expect(body.mcpReady).toBe(true);
});
it('should reflect initialization state changes', async () => {
let isInitialized = false;
const dynamicOptions: ServerOptions = {
getInitializationComplete: () => isInitialized,
getMcpReady: () => true,
onShutdown: mock(() => Promise.resolve()),
onRestart: mock(() => Promise.resolve()),
};
server = new Server(dynamicOptions);
const testPort = 40000 + Math.floor(Math.random() * 10000);
await server.listen(testPort, '127.0.0.1');
// Check when not initialized
let response = await fetch(`http://127.0.0.1:${testPort}/api/health`);
let body = await response.json();
expect(body.initialized).toBe(false);
// Change state
isInitialized = true;
// Check when initialized
response = await fetch(`http://127.0.0.1:${testPort}/api/health`);
body = await response.json();
expect(body.initialized).toBe(true);
});
it('should include platform and pid', async () => {
server = new Server(mockOptions);
const testPort = 40000 + Math.floor(Math.random() * 10000);
await server.listen(testPort, '127.0.0.1');
const response = await fetch(`http://127.0.0.1:${testPort}/api/health`);
const body = await response.json();
expect(body.platform).toBeDefined();
expect(body.pid).toBeDefined();
expect(typeof body.pid).toBe('number');
});
});
describe('readiness endpoint', () => {
it('should return 200 when initialized', async () => {
server = new Server(mockOptions);
const testPort = 40000 + Math.floor(Math.random() * 10000);
await server.listen(testPort, '127.0.0.1');
const response = await fetch(`http://127.0.0.1:${testPort}/api/readiness`);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.status).toBe('ready');
});
it('should return 503 when not initialized', async () => {
const uninitializedOptions: ServerOptions = {
getInitializationComplete: () => false,
getMcpReady: () => false,
onShutdown: mock(() => Promise.resolve()),
onRestart: mock(() => Promise.resolve()),
};
server = new Server(uninitializedOptions);
const testPort = 40000 + Math.floor(Math.random() * 10000);
await server.listen(testPort, '127.0.0.1');
const response = await fetch(`http://127.0.0.1:${testPort}/api/readiness`);
expect(response.status).toBe(503);
const body = await response.json();
expect(body.status).toBe('initializing');
expect(body.message).toBeDefined();
});
});
describe('version endpoint', () => {
it('should return 200 with version', async () => {
server = new Server(mockOptions);
const testPort = 40000 + Math.floor(Math.random() * 10000);
await server.listen(testPort, '127.0.0.1');
const response = await fetch(`http://127.0.0.1:${testPort}/api/version`);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.version).toBeDefined();
expect(typeof body.version).toBe('string');
});
});
describe('404 handling', () => {
it('should return 404 for unknown routes after finalizeRoutes', async () => {
server = new Server(mockOptions);
server.finalizeRoutes();
const testPort = 40000 + Math.floor(Math.random() * 10000);
await server.listen(testPort, '127.0.0.1');
const response = await fetch(`http://127.0.0.1:${testPort}/api/nonexistent`);
expect(response.status).toBe(404);
const body = await response.json();
expect(body.error).toBe('NotFound');
});
});
});
+39 -28
View File
@@ -92,16 +92,17 @@ describe('Session ID Refactor', () => {
expect(session.content_session_id).toBe(contentSessionId);
});
it('should create session with memory_session_id initially equal to content_session_id', () => {
it('should create session with memory_session_id initially NULL', () => {
const contentSessionId = 'user-session-456';
const sessionDbId = store.createSDKSession(contentSessionId, 'test-project', 'Test prompt');
const session = store.db.prepare(
'SELECT content_session_id, memory_session_id FROM sdk_sessions WHERE id = ?'
).get(sessionDbId) as { content_session_id: string; memory_session_id: string };
).get(sessionDbId) as { content_session_id: string; memory_session_id: string | null };
// Initially they're the same - memory_session_id gets updated when SDK responds
expect(session.memory_session_id).toBe(contentSessionId);
// CRITICAL: memory_session_id starts as NULL - it must NEVER equal contentSessionId
// because that would inject memory messages into the user's transcript!
expect(session.memory_session_id).toBeNull();
});
it('should be idempotent - return same ID for same content_session_id', () => {
@@ -129,11 +130,11 @@ describe('Session ID Refactor', () => {
const sessionDbId = store.createSDKSession(contentSessionId, 'test-project', 'Test');
// Initially memory_session_id equals content_session_id
// Initially memory_session_id is NULL
const beforeUpdate = store.db.prepare(
'SELECT memory_session_id FROM sdk_sessions WHERE id = ?'
).get(sessionDbId) as { memory_session_id: string };
expect(beforeUpdate.memory_session_id).toBe(contentSessionId);
).get(sessionDbId) as { memory_session_id: string | null };
expect(beforeUpdate.memory_session_id).toBeNull();
// Update with SDK-captured memory session ID
store.updateMemorySessionId(sessionDbId, memorySessionId);
@@ -175,21 +176,23 @@ describe('Session ID Refactor', () => {
expect(session?.memory_session_id).toBe(memorySessionId);
});
it('should initialize memory_session_id to content_session_id before SDK capture', () => {
it('should initialize memory_session_id to NULL before SDK capture', () => {
const contentSessionId = 'never-captured-session';
const sessionDbId = store.createSDKSession(contentSessionId, 'test-project', 'Test');
// createSDKSession sets memory_session_id = content_session_id initially
// The memory_session_id gets updated when SDK responds with its session ID
// createSDKSession sets memory_session_id = NULL initially
// The memory_session_id gets set when SDK responds with its session ID
const session = store.getSessionById(sessionDbId);
expect(session?.memory_session_id).toBe(contentSessionId);
expect(session?.memory_session_id).toBeNull();
});
});
describe('storeObservation - Memory Session ID Reference', () => {
it('should store observation with memory_session_id as foreign key', () => {
const contentSessionId = 'obs-test-session';
store.createSDKSession(contentSessionId, 'test-project', 'Test');
const memorySessionId = 'memory-obs-test-session';
const sessionDbId = store.createSDKSession(contentSessionId, 'test-project', 'Test');
store.updateMemorySessionId(sessionDbId, memorySessionId);
const obs = {
type: 'discovery',
@@ -202,19 +205,21 @@ describe('Session ID Refactor', () => {
files_modified: []
};
const result = store.storeObservation(contentSessionId, 'test-project', obs, 1);
const result = store.storeObservation(memorySessionId, 'test-project', obs, 1);
// Verify the observation was stored with memory_session_id
const stored = store.db.prepare(
'SELECT memory_session_id FROM observations WHERE id = ?'
).get(result.id) as { memory_session_id: string };
expect(stored.memory_session_id).toBe(contentSessionId);
expect(stored.memory_session_id).toBe(memorySessionId);
});
it('should be retrievable by getObservationsForSession using memory_session_id', () => {
const contentSessionId = 'obs-retrieval-session';
store.createSDKSession(contentSessionId, 'test-project', 'Test');
const memorySessionId = 'memory-retrieval-session';
const sessionDbId = store.createSDKSession(contentSessionId, 'test-project', 'Test');
store.updateMemorySessionId(sessionDbId, memorySessionId);
const obs = {
type: 'feature',
@@ -227,9 +232,9 @@ describe('Session ID Refactor', () => {
files_modified: ['file2.ts']
};
store.storeObservation(contentSessionId, 'test-project', obs, 1);
store.storeObservation(memorySessionId, 'test-project', obs, 1);
const observations = store.getObservationsForSession(contentSessionId);
const observations = store.getObservationsForSession(memorySessionId);
expect(observations.length).toBe(1);
expect(observations[0].title).toBe('New Feature');
@@ -239,7 +244,9 @@ describe('Session ID Refactor', () => {
describe('storeSummary - Memory Session ID Reference', () => {
it('should store summary with memory_session_id as foreign key', () => {
const contentSessionId = 'summary-test-session';
store.createSDKSession(contentSessionId, 'test-project', 'Test');
const memorySessionId = 'memory-summary-test-session';
const sessionDbId = store.createSDKSession(contentSessionId, 'test-project', 'Test');
store.updateMemorySessionId(sessionDbId, memorySessionId);
const summary = {
request: 'Test request',
@@ -250,19 +257,21 @@ describe('Session ID Refactor', () => {
notes: null
};
const result = store.storeSummary(contentSessionId, 'test-project', summary, 1);
const result = store.storeSummary(memorySessionId, 'test-project', summary, 1);
// Verify the summary was stored with memory_session_id
const stored = store.db.prepare(
'SELECT memory_session_id FROM session_summaries WHERE id = ?'
).get(result.id) as { memory_session_id: string };
expect(stored.memory_session_id).toBe(contentSessionId);
expect(stored.memory_session_id).toBe(memorySessionId);
});
it('should be retrievable by getSummaryForSession using memory_session_id', () => {
const contentSessionId = 'summary-retrieval-session';
store.createSDKSession(contentSessionId, 'test-project', 'Test');
const memorySessionId = 'memory-summary-retrieval-session';
const sessionDbId = store.createSDKSession(contentSessionId, 'test-project', 'Test');
store.updateMemorySessionId(sessionDbId, memorySessionId);
const summary = {
request: 'My request',
@@ -273,9 +282,9 @@ describe('Session ID Refactor', () => {
notes: 'Some notes'
};
store.storeSummary(contentSessionId, 'test-project', summary, 1);
store.storeSummary(memorySessionId, 'test-project', summary, 1);
const retrieved = store.getSummaryForSession(contentSessionId);
const retrieved = store.getSummaryForSession(memorySessionId);
expect(retrieved).not.toBeNull();
expect(retrieved?.request).toBe('My request');
@@ -374,11 +383,13 @@ describe('Session ID Refactor', () => {
it('should support multiple observations linked to same memory_session_id', () => {
const contentSessionId = 'multi-obs-session';
store.createSDKSession(contentSessionId, 'test-project', 'Test');
const memorySessionId = 'memory-multi-obs-session';
const sessionDbId = store.createSDKSession(contentSessionId, 'test-project', 'Test');
store.updateMemorySessionId(sessionDbId, memorySessionId);
// Store multiple observations
for (let i = 1; i <= 5; i++) {
store.storeObservation(contentSessionId, 'test-project', {
store.storeObservation(memorySessionId, 'test-project', {
type: 'discovery',
title: `Observation ${i}`,
subtitle: null,
@@ -390,16 +401,16 @@ describe('Session ID Refactor', () => {
}, i);
}
const observations = store.getObservationsForSession(contentSessionId);
const observations = store.getObservationsForSession(memorySessionId);
expect(observations.length).toBe(5);
// All should have the same memory_session_id
const directQuery = store.db.prepare(
'SELECT DISTINCT memory_session_id FROM observations WHERE memory_session_id = ?'
).all(contentSessionId) as Array<{ memory_session_id: string }>;
).all(memorySessionId) as Array<{ memory_session_id: string }>;
expect(directQuery.length).toBe(1);
expect(directQuery[0].memory_session_id).toBe(contentSessionId);
expect(directQuery[0].memory_session_id).toBe(memorySessionId);
});
});
});
+80 -72
View File
@@ -11,11 +11,11 @@ import { SessionStore } from '../src/services/sqlite/SessionStore.js';
* - 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
* 1. memorySessionId starts as NULL (NEVER equals contentSessionId - that would inject memory into user transcript!)
* 2. Resume MUST NOT be used when memorySessionId is NULL
* 3. Resume MUST ONLY be used when hasRealMemorySessionId === true (memorySessionId is non-null)
* 4. Observations are stored with memorySessionId (after updateMemorySessionId has been called)
* 5. updateMemorySessionId() is required before storeObservation() or storeSummary() can work
*/
describe('Session ID Usage Validation', () => {
let store: SessionStore;
@@ -29,17 +29,18 @@ describe('Session ID Usage Validation', () => {
});
describe('Placeholder Detection - hasRealMemorySessionId Logic', () => {
it('should identify placeholder when memorySessionId equals contentSessionId', () => {
it('should identify placeholder when memorySessionId is NULL', () => {
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);
// Initially, memory_session_id is NULL (placeholder state)
// CRITICAL: memory_session_id must NEVER equal contentSessionId - that would inject memory into user transcript!
expect(session?.memory_session_id).toBeNull();
// hasRealMemorySessionId would be FALSE
const hasRealMemorySessionId = session?.memory_session_id !== session?.content_session_id;
// hasRealMemorySessionId would be FALSE (NULL is falsy)
const hasRealMemorySessionId = session?.memory_session_id !== null;
expect(hasRealMemorySessionId).toBe(false);
});
@@ -52,11 +53,11 @@ describe('Session ID Usage Validation', () => {
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);
// After capture, memory_session_id is set (non-NULL)
expect(session?.memory_session_id).toBe(capturedMemoryId);
// hasRealMemorySessionId would be TRUE
const hasRealMemorySessionId = session?.memory_session_id !== session?.content_session_id;
const hasRealMemorySessionId = session?.memory_session_id !== null;
expect(hasRealMemorySessionId).toBe(true);
});
@@ -65,9 +66,9 @@ describe('Session ID Usage Validation', () => {
const sessionDbId = store.createSDKSession(contentSessionId, 'test-project', 'Test');
const session = store.getSessionById(sessionDbId);
const hasRealMemorySessionId = session?.memory_session_id !== session?.content_session_id;
const hasRealMemorySessionId = session?.memory_session_id !== null;
// CRITICAL: This check prevents resuming the USER'S session instead of memory session
// CRITICAL: This check prevents resuming when memory_session_id is not captured
if (hasRealMemorySessionId) {
// Safe to use for resume
const resumeParam = session?.memory_session_id;
@@ -80,10 +81,12 @@ describe('Session ID Usage Validation', () => {
});
});
describe('Observation Storage - ContentSessionId Usage', () => {
it('should store observations with contentSessionId in memory_session_id column', () => {
describe('Observation Storage - MemorySessionId Usage', () => {
it('should store observations with memorySessionId in memory_session_id column', () => {
const contentSessionId = 'obs-content-session-123';
store.createSDKSession(contentSessionId, 'test-project', 'Test');
const memorySessionId = 'obs-memory-session-123';
const sessionDbId = store.createSDKSession(contentSessionId, 'test-project', 'Test');
store.updateMemorySessionId(sessionDbId, memorySessionId);
const obs = {
type: 'discovery',
@@ -96,24 +99,26 @@ describe('Session ID Usage Validation', () => {
files_modified: []
};
// SDKAgent.ts line 332 passes session.contentSessionId here
const result = store.storeObservation(contentSessionId, 'test-project', obs, 1);
// storeObservation takes memorySessionId (after updateMemorySessionId has been called)
const result = store.storeObservation(memorySessionId, 'test-project', obs, 1);
// Verify it's stored in the memory_session_id column with contentSessionId value
// Verify it's stored in the memory_session_id column with memorySessionId 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);
// memory_session_id column contains the captured SDK session ID
expect(stored.memory_session_id).toBe(memorySessionId);
});
it('should be retrievable using contentSessionId (observations use contentSessionId)', () => {
it('should be retrievable using memorySessionId', () => {
const contentSessionId = 'retrieval-test-session';
const memorySessionId = 'retrieval-memory-session';
store.createSDKSession(contentSessionId, 'test-project', 'Test');
const sessionDbId = store.createSDKSession(contentSessionId, 'test-project', 'Test');
store.updateMemorySessionId(sessionDbId, memorySessionId);
// Store observation with contentSessionId
// Store observation with memorySessionId
const obs = {
type: 'feature',
title: 'Observation',
@@ -124,28 +129,26 @@ describe('Session ID Usage Validation', () => {
files_read: [],
files_modified: []
};
store.storeObservation(contentSessionId, 'test-project', obs, 1);
store.storeObservation(memorySessionId, '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);
// Observations are retrievable by memorySessionId
const observations = store.getObservationsForSession(memorySessionId);
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', () => {
it('should prevent resume with NULL 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;
// Simulate hasRealMemorySessionId check - memory_session_id must be non-null
const hasRealMemorySessionId = session?.memory_session_id !== null;
// MUST be false in placeholder state
// MUST be false in placeholder state (memory_session_id is NULL)
expect(hasRealMemorySessionId).toBe(false);
// Resume parameter should NOT be set
@@ -161,10 +164,9 @@ describe('Session ID Usage Validation', () => {
const sessionDbId = store.createSDKSession(contentSessionId, 'test-project', 'Test');
// Before capture - no resume
// Before capture - no resume (memory_session_id is NULL)
let session = store.getSessionById(sessionDbId);
let hasRealMemorySessionId = session?.memory_session_id &&
session.memory_session_id !== session.content_session_id;
let hasRealMemorySessionId = session?.memory_session_id !== null;
expect(hasRealMemorySessionId).toBe(false);
// Capture memory session ID
@@ -172,8 +174,7 @@ describe('Session ID Usage Validation', () => {
// After capture - resume allowed
session = store.getSessionById(sessionDbId);
hasRealMemorySessionId = session?.memory_session_id &&
session.memory_session_id !== session.content_session_id;
hasRealMemorySessionId = session?.memory_session_id !== null;
expect(hasRealMemorySessionId).toBe(true);
// Resume parameter should be the captured ID
@@ -185,14 +186,18 @@ describe('Session ID Usage Validation', () => {
describe('Cross-Contamination Prevention', () => {
it('should never mix observations from different content sessions', () => {
const session1 = 'user-session-A';
const session2 = 'user-session-B';
const content1 = 'user-session-A';
const content2 = 'user-session-B';
const memory1 = 'memory-session-A';
const memory2 = 'memory-session-B';
store.createSDKSession(session1, 'project-a', 'Prompt A');
store.createSDKSession(session2, 'project-b', 'Prompt B');
const id1 = store.createSDKSession(content1, 'project-a', 'Prompt A');
const id2 = store.createSDKSession(content2, 'project-b', 'Prompt B');
store.updateMemorySessionId(id1, memory1);
store.updateMemorySessionId(id2, memory2);
// Store observations in each session
store.storeObservation(session1, 'project-a', {
// Store observations in each session using memorySessionId
store.storeObservation(memory1, 'project-a', {
type: 'discovery',
title: 'Observation A',
subtitle: null,
@@ -203,7 +208,7 @@ describe('Session ID Usage Validation', () => {
files_modified: []
}, 1);
store.storeObservation(session2, 'project-b', {
store.storeObservation(memory2, 'project-b', {
type: 'discovery',
title: 'Observation B',
subtitle: null,
@@ -215,8 +220,8 @@ describe('Session ID Usage Validation', () => {
}, 1);
// Verify isolation
const obsA = store.getObservationsForSession(session1);
const obsB = store.getObservationsForSession(session2);
const obsA = store.getObservationsForSession(memory1);
const obsB = store.getObservationsForSession(memory2);
expect(obsA.length).toBe(1);
expect(obsB.length).toBe(1);
@@ -249,7 +254,9 @@ describe('Session ID Usage Validation', () => {
describe('Foreign Key Integrity', () => {
it('should cascade delete observations when session is deleted', () => {
const contentSessionId = 'cascade-test-session';
const memorySessionId = 'cascade-memory-session';
const sessionDbId = store.createSDKSession(contentSessionId, 'test-project', 'Test');
store.updateMemorySessionId(sessionDbId, memorySessionId);
// Store observation
const obs = {
@@ -262,27 +269,29 @@ describe('Session ID Usage Validation', () => {
files_read: [],
files_modified: []
};
store.storeObservation(contentSessionId, 'test-project', obs, 1);
store.storeObservation(memorySessionId, 'test-project', obs, 1);
// Verify observation exists
let observations = store.getObservationsForSession(contentSessionId);
let observations = store.getObservationsForSession(memorySessionId);
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);
observations = store.getObservationsForSession(memorySessionId);
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');
const memorySessionId = 'fk-memory-session';
const sessionDbId = store.createSDKSession(contentSessionId, 'test-project', 'Test');
store.updateMemorySessionId(sessionDbId, memorySessionId);
// This should succeed (FK exists)
expect(() => {
store.storeObservation(contentSessionId, 'test-project', {
store.storeObservation(memorySessionId, 'test-project', {
type: 'discovery',
title: 'Valid FK',
subtitle: null,
@@ -314,10 +323,10 @@ describe('Session ID Usage Validation', () => {
it('should follow correct lifecycle: create → capture → resume', () => {
const contentSessionId = 'lifecycle-session';
// STEP 1: Hook creates session (memory_session_id = content_session_id)
// STEP 1: Hook creates session (memory_session_id = NULL)
const sessionDbId = store.createSDKSession(contentSessionId, 'test-project', 'First prompt');
let session = store.getSessionById(sessionDbId);
expect(session?.memory_session_id).toBe(contentSessionId); // Placeholder
expect(session?.memory_session_id).toBeNull(); // NULL - not captured yet
// STEP 2: First SDK message arrives with real session ID
const realMemoryId = 'sdk-generated-session-xyz';
@@ -326,7 +335,7 @@ describe('Session ID Usage Validation', () => {
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;
const hasRealMemorySessionId = session?.memory_session_id !== null;
expect(hasRealMemorySessionId).toBe(true);
// Resume parameter is safe to use
@@ -350,7 +359,7 @@ describe('Session ID Usage Validation', () => {
expect(session?.memory_session_id).toBe(capturedMemoryId);
// Resume can work immediately
const hasRealMemorySessionId = session?.memory_session_id !== session?.content_session_id;
const hasRealMemorySessionId = session?.memory_session_id !== null;
expect(hasRealMemorySessionId).toBe(true);
});
});
@@ -417,8 +426,8 @@ describe('Session ID Usage Validation', () => {
let sessionDbId = store.createSDKSession(contentSessionId, 'test-project', 'Prompt 1');
let session = store.getSessionById(sessionDbId);
// Initially placeholder
expect(session?.memory_session_id).toBe(contentSessionId);
// Initially NULL
expect(session?.memory_session_id).toBeNull();
// Prompt 1: Capture real memory ID
store.updateMemorySessionId(sessionDbId, realMemoryId);
@@ -438,7 +447,7 @@ describe('Session ID Usage Validation', () => {
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;
const hasRealMemorySessionId = session?.memory_session_id !== null;
expect(hasRealMemorySessionId).toBe(true);
});
@@ -470,6 +479,7 @@ describe('Session ID Usage Validation', () => {
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
// This shouldn't happen in practice, but we test it anyway
const contentSessionId = 'same-id-123';
const sessionDbId = store.createSDKSession(contentSessionId, 'test-project', 'Test');
@@ -477,26 +487,24 @@ describe('Session ID Usage Validation', () => {
store.updateMemorySessionId(sessionDbId, contentSessionId);
const session = store.getSessionById(sessionDbId);
const hasRealMemorySessionId = session?.memory_session_id !== session?.content_session_id;
// Now checking for non-null instead of comparing to content_session_id
const hasRealMemorySessionId = session?.memory_session_id !== null;
// 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);
// Would be TRUE since we set a value (even if same as content)
// In practice, the SDK should never return the same ID as contentSessionId
expect(hasRealMemorySessionId).toBe(true);
});
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);
// memory_session_id is already NULL from createSDKSession
const session = store.getSessionById(sessionDbId);
const hasRealMemorySessionId = session?.memory_session_id &&
session.memory_session_id !== session.content_session_id;
const hasRealMemorySessionId = session?.memory_session_id !== null;
// Should be falsy (NULL is falsy)
expect(hasRealMemorySessionId).toBeFalsy();
// Should be false (NULL means not captured yet)
expect(hasRealMemorySessionId).toBe(false);
});
});
});
+16 -11
View File
@@ -35,12 +35,13 @@ describe('SessionStore', () => {
it('should store observation with timestamp override', () => {
const claudeId = 'claude-sess-obs';
const memoryId = 'memory-sess-obs';
const sdkId = store.createSDKSession(claudeId, 'test-project', 'initial prompt');
// Get the memory_session_id string (createSDKSession returns number ID, need string for FK)
// createSDKSession inserts using memory_session_id = content_session_id in the current implementation
// "VALUES (?, ?, ?, ?, ?, ?, 'active')" -> contentSessionId, contentSessionId, ...
// Set the memory_session_id before storing observations
// createSDKSession now initializes memory_session_id = NULL
store.updateMemorySessionId(sdkId, memoryId);
const obs = {
type: 'discovery',
title: 'Test Obs',
@@ -53,9 +54,9 @@ describe('SessionStore', () => {
};
const pastTimestamp = 1600000000000; // Some time in the past
const result = store.storeObservation(
claudeId, // sdkSessionId is same as claudeSessionId in createSDKSession
memoryId, // Use memorySessionId for FK reference
'test-project',
obs,
1,
@@ -68,14 +69,18 @@ describe('SessionStore', () => {
const stored = store.getObservationById(result.id);
expect(stored).not.toBeNull();
expect(stored?.created_at_epoch).toBe(pastTimestamp);
// Verify ISO string matches
expect(new Date(stored!.created_at).getTime()).toBe(pastTimestamp);
});
it('should store summary with timestamp override', () => {
const claudeId = 'claude-sess-sum';
store.createSDKSession(claudeId, 'test-project', 'initial prompt');
const memoryId = 'memory-sess-sum';
const sdkId = store.createSDKSession(claudeId, 'test-project', 'initial prompt');
// Set the memory_session_id before storing summaries
store.updateMemorySessionId(sdkId, memoryId);
const summary = {
request: 'Do something',
@@ -89,7 +94,7 @@ describe('SessionStore', () => {
const pastTimestamp = 1650000000000;
const result = store.storeSummary(
claudeId,
memoryId, // Use memorySessionId for FK reference
'test-project',
summary,
1,
@@ -99,7 +104,7 @@ describe('SessionStore', () => {
expect(result.createdAtEpoch).toBe(pastTimestamp);
const stored = store.getSummaryForSession(claudeId);
const stored = store.getSummaryForSession(memoryId);
expect(stored).not.toBeNull();
expect(stored?.created_at_epoch).toBe(pastTimestamp);
});
+231
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([]);
});
});
});
+129
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);
});
});
});
+120
View File
@@ -0,0 +1,120 @@
/**
* 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('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');
});
});
});
+214
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');
});
});
});
+309
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();
});
});
});
@@ -0,0 +1,147 @@
import { describe, it, expect } from 'bun:test';
// Import directly from specific files to avoid worker-service import chain
import { shouldFallbackToClaude, isAbortError } from '../../../src/services/worker/agents/FallbackErrorHandler.js';
import { FALLBACK_ERROR_PATTERNS } from '../../../src/services/worker/agents/types.js';
describe('FallbackErrorHandler', () => {
describe('FALLBACK_ERROR_PATTERNS', () => {
it('should contain all 7 expected patterns', () => {
expect(FALLBACK_ERROR_PATTERNS).toHaveLength(7);
expect(FALLBACK_ERROR_PATTERNS).toContain('429');
expect(FALLBACK_ERROR_PATTERNS).toContain('500');
expect(FALLBACK_ERROR_PATTERNS).toContain('502');
expect(FALLBACK_ERROR_PATTERNS).toContain('503');
expect(FALLBACK_ERROR_PATTERNS).toContain('ECONNREFUSED');
expect(FALLBACK_ERROR_PATTERNS).toContain('ETIMEDOUT');
expect(FALLBACK_ERROR_PATTERNS).toContain('fetch failed');
});
});
describe('shouldFallbackToClaude', () => {
describe('returns true for fallback patterns', () => {
it('should return true for 429 rate limit errors', () => {
expect(shouldFallbackToClaude('Rate limit exceeded: 429')).toBe(true);
expect(shouldFallbackToClaude(new Error('429 Too Many Requests'))).toBe(true);
});
it('should return true for 500 internal server errors', () => {
expect(shouldFallbackToClaude('500 Internal Server Error')).toBe(true);
expect(shouldFallbackToClaude(new Error('Server returned 500'))).toBe(true);
});
it('should return true for 502 bad gateway errors', () => {
expect(shouldFallbackToClaude('502 Bad Gateway')).toBe(true);
expect(shouldFallbackToClaude(new Error('Upstream returned 502'))).toBe(true);
});
it('should return true for 503 service unavailable errors', () => {
expect(shouldFallbackToClaude('503 Service Unavailable')).toBe(true);
expect(shouldFallbackToClaude(new Error('Server is 503'))).toBe(true);
});
it('should return true for ECONNREFUSED errors', () => {
expect(shouldFallbackToClaude('connect ECONNREFUSED 127.0.0.1:8080')).toBe(true);
expect(shouldFallbackToClaude(new Error('ECONNREFUSED'))).toBe(true);
});
it('should return true for ETIMEDOUT errors', () => {
expect(shouldFallbackToClaude('connect ETIMEDOUT')).toBe(true);
expect(shouldFallbackToClaude(new Error('Request ETIMEDOUT'))).toBe(true);
});
it('should return true for fetch failed errors', () => {
expect(shouldFallbackToClaude('fetch failed')).toBe(true);
expect(shouldFallbackToClaude(new Error('fetch failed: network error'))).toBe(true);
});
});
describe('returns false for non-fallback errors', () => {
it('should return false for 400 Bad Request', () => {
expect(shouldFallbackToClaude('400 Bad Request')).toBe(false);
expect(shouldFallbackToClaude(new Error('400 Invalid argument'))).toBe(false);
});
it('should return false for 401 Unauthorized', () => {
expect(shouldFallbackToClaude('401 Unauthorized')).toBe(false);
});
it('should return false for 403 Forbidden', () => {
expect(shouldFallbackToClaude('403 Forbidden')).toBe(false);
});
it('should return false for 404 Not Found', () => {
expect(shouldFallbackToClaude('404 Not Found')).toBe(false);
});
it('should return false for generic errors', () => {
expect(shouldFallbackToClaude('Something went wrong')).toBe(false);
expect(shouldFallbackToClaude(new Error('Unknown error'))).toBe(false);
});
});
describe('handles various error types', () => {
it('should handle string errors', () => {
expect(shouldFallbackToClaude('429 rate limited')).toBe(true);
expect(shouldFallbackToClaude('invalid input')).toBe(false);
});
it('should handle Error objects', () => {
expect(shouldFallbackToClaude(new Error('429 Too Many Requests'))).toBe(true);
expect(shouldFallbackToClaude(new Error('Bad Request'))).toBe(false);
});
it('should handle objects with message property', () => {
expect(shouldFallbackToClaude({ message: '503 unavailable' })).toBe(true);
expect(shouldFallbackToClaude({ message: 'ok' })).toBe(false);
});
it('should handle null and undefined', () => {
expect(shouldFallbackToClaude(null)).toBe(false);
expect(shouldFallbackToClaude(undefined)).toBe(false);
});
it('should handle non-error objects by stringifying', () => {
expect(shouldFallbackToClaude({ code: 429 })).toBe(false); // toString won't include 429
expect(shouldFallbackToClaude(429)).toBe(true); // number 429 stringifies to "429"
});
});
});
describe('isAbortError', () => {
it('should return true for Error with name "AbortError"', () => {
const abortError = new Error('The operation was aborted');
abortError.name = 'AbortError';
expect(isAbortError(abortError)).toBe(true);
});
it('should return true for objects with name "AbortError"', () => {
expect(isAbortError({ name: 'AbortError', message: 'aborted' })).toBe(true);
});
it('should return false for regular Error objects', () => {
expect(isAbortError(new Error('Some error'))).toBe(false);
expect(isAbortError(new TypeError('Type error'))).toBe(false);
});
it('should return false for errors with other names', () => {
const error = new Error('timeout');
error.name = 'TimeoutError';
expect(isAbortError(error)).toBe(false);
});
it('should return false for null and undefined', () => {
expect(isAbortError(null)).toBe(false);
expect(isAbortError(undefined)).toBe(false);
});
it('should return false for strings', () => {
expect(isAbortError('AbortError')).toBe(false);
});
it('should return false for objects without name property', () => {
expect(isAbortError({ message: 'error' })).toBe(false);
expect(isAbortError({})).toBe(false);
});
});
});
@@ -0,0 +1,236 @@
import { describe, it, expect, mock } from 'bun:test';
// Import directly from specific files to avoid worker-service import chain
import {
broadcastObservation,
broadcastSummary,
} from '../../../src/services/worker/agents/ObservationBroadcaster.js';
import type {
WorkerRef,
ObservationSSEPayload,
SummarySSEPayload,
} from '../../../src/services/worker/agents/types.js';
describe('ObservationBroadcaster', () => {
// Helper to create mock worker with broadcaster
function createMockWorker() {
const broadcastMock = mock(() => {});
const worker: WorkerRef = {
sseBroadcaster: {
broadcast: broadcastMock,
},
broadcastProcessingStatus: mock(() => {}),
};
return { worker, broadcastMock };
}
// Helper to create test observation payload
function createTestObservationPayload(): ObservationSSEPayload {
return {
id: 1,
memory_session_id: 'mem-session-123',
session_id: 'content-session-456',
type: 'discovery',
title: 'Found important pattern',
subtitle: 'In auth module',
text: null,
narrative: 'Discovered a reusable authentication pattern.',
facts: JSON.stringify(['Pattern uses JWT', 'Supports refresh tokens']),
concepts: JSON.stringify(['authentication', 'JWT']),
files_read: JSON.stringify(['src/auth.ts']),
files_modified: JSON.stringify([]),
project: 'test-project',
prompt_number: 5,
created_at_epoch: Date.now(),
};
}
// Helper to create test summary payload
function createTestSummaryPayload(): SummarySSEPayload {
return {
id: 1,
session_id: 'content-session-456',
request: 'Implement user authentication',
investigated: 'Reviewed existing auth patterns',
learned: 'JWT with refresh tokens is best',
completed: 'Basic auth flow implemented',
next_steps: 'Add rate limiting',
notes: null,
project: 'test-project',
prompt_number: 5,
created_at_epoch: Date.now(),
};
}
describe('broadcastObservation', () => {
it('should call worker.sseBroadcaster.broadcast with correct payload', () => {
const { worker, broadcastMock } = createMockWorker();
const payload = createTestObservationPayload();
broadcastObservation(worker, payload);
expect(broadcastMock).toHaveBeenCalledTimes(1);
expect(broadcastMock).toHaveBeenCalledWith({
type: 'new_observation',
observation: payload,
});
});
it('should handle undefined worker gracefully (no crash)', () => {
const payload = createTestObservationPayload();
// Should not throw
expect(() => {
broadcastObservation(undefined, payload);
}).not.toThrow();
});
it('should handle missing sseBroadcaster gracefully', () => {
const worker: WorkerRef = {};
const payload = createTestObservationPayload();
// Should not throw
expect(() => {
broadcastObservation(worker, payload);
}).not.toThrow();
});
it('should handle worker with undefined sseBroadcaster', () => {
const worker: WorkerRef = {
sseBroadcaster: undefined,
broadcastProcessingStatus: mock(() => {}),
};
const payload = createTestObservationPayload();
// Should not throw
expect(() => {
broadcastObservation(worker, payload);
}).not.toThrow();
});
it('should broadcast observation with all fields correctly', () => {
const { worker, broadcastMock } = createMockWorker();
const payload: ObservationSSEPayload = {
id: 42,
memory_session_id: null, // Test null case
session_id: 'session-xyz',
type: 'bugfix',
title: 'Fixed null pointer',
subtitle: null,
text: null,
narrative: 'Resolved NPE in user service.',
facts: JSON.stringify(['Added null check']),
concepts: JSON.stringify(['error-handling']),
files_read: JSON.stringify(['src/user.ts']),
files_modified: JSON.stringify(['src/user.ts']),
project: 'my-app',
prompt_number: 10,
created_at_epoch: 1700000000000,
};
broadcastObservation(worker, payload);
const call = broadcastMock.mock.calls[0][0];
expect(call.type).toBe('new_observation');
expect(call.observation.id).toBe(42);
expect(call.observation.memory_session_id).toBeNull();
expect(call.observation.type).toBe('bugfix');
expect(call.observation.title).toBe('Fixed null pointer');
});
});
describe('broadcastSummary', () => {
it('should call worker.sseBroadcaster.broadcast with correct payload', () => {
const { worker, broadcastMock } = createMockWorker();
const payload = createTestSummaryPayload();
broadcastSummary(worker, payload);
expect(broadcastMock).toHaveBeenCalledTimes(1);
expect(broadcastMock).toHaveBeenCalledWith({
type: 'new_summary',
summary: payload,
});
});
it('should handle undefined worker gracefully (no crash)', () => {
const payload = createTestSummaryPayload();
// Should not throw
expect(() => {
broadcastSummary(undefined, payload);
}).not.toThrow();
});
it('should handle missing sseBroadcaster gracefully', () => {
const worker: WorkerRef = {};
const payload = createTestSummaryPayload();
// Should not throw
expect(() => {
broadcastSummary(worker, payload);
}).not.toThrow();
});
it('should handle worker with undefined sseBroadcaster', () => {
const worker: WorkerRef = {
sseBroadcaster: undefined,
};
const payload = createTestSummaryPayload();
// Should not throw
expect(() => {
broadcastSummary(worker, payload);
}).not.toThrow();
});
it('should broadcast summary with all fields correctly', () => {
const { worker, broadcastMock } = createMockWorker();
const payload: SummarySSEPayload = {
id: 99,
session_id: 'session-abc',
request: 'Build login form',
investigated: 'Looked at existing forms',
learned: 'React Hook Form is good',
completed: 'Form is ready',
next_steps: 'Add validation',
notes: 'Some additional notes here',
project: 'frontend-app',
prompt_number: 3,
created_at_epoch: 1700000001000,
};
broadcastSummary(worker, payload);
const call = broadcastMock.mock.calls[0][0];
expect(call.type).toBe('new_summary');
expect(call.summary.id).toBe(99);
expect(call.summary.request).toBe('Build login form');
expect(call.summary.notes).toBe('Some additional notes here');
});
it('should broadcast summary with null optional fields', () => {
const { worker, broadcastMock } = createMockWorker();
const payload: SummarySSEPayload = {
id: 50,
session_id: 'session-def',
request: null,
investigated: null,
learned: null,
completed: null,
next_steps: null,
notes: null,
project: 'empty-project',
prompt_number: 1,
created_at_epoch: 1700000002000,
};
broadcastSummary(worker, payload);
const call = broadcastMock.mock.calls[0][0];
expect(call.type).toBe('new_summary');
expect(call.summary.request).toBeNull();
expect(call.summary.notes).toBeNull();
});
});
});
@@ -0,0 +1,635 @@
import { describe, it, expect, mock, beforeEach, afterEach } from 'bun:test';
// Mock modules that cause import chain issues - MUST be before imports
// Use full paths from test file location
mock.module('../../../src/services/worker-service.js', () => ({
updateCursorContextForProject: () => Promise.resolve(),
}));
mock.module('../../../src/shared/worker-utils.js', () => ({
getWorkerPort: () => 37777,
}));
// Mock the ModeManager
mock.module('../../../src/services/domain/ModeManager.js', () => ({
ModeManager: {
getInstance: () => ({
getActiveMode: () => ({
name: 'code',
prompts: {
init: 'init prompt',
observation: 'obs prompt',
summary: 'summary prompt',
},
observation_types: [{ id: 'discovery' }, { id: 'bugfix' }, { id: 'refactor' }],
observation_concepts: [],
}),
}),
},
}));
// Mock logger
mock.module('../../../src/utils/logger.js', () => ({
logger: {
info: () => {},
debug: () => {},
warn: () => {},
error: () => {},
},
}));
// Import after mocks
import { processAgentResponse } from '../../../src/services/worker/agents/ResponseProcessor.js';
import type { WorkerRef, StorageResult } from '../../../src/services/worker/agents/types.js';
import type { ActiveSession } from '../../../src/services/worker-types.js';
import type { DatabaseManager } from '../../../src/services/worker/DatabaseManager.js';
import type { SessionManager } from '../../../src/services/worker/SessionManager.js';
describe('ResponseProcessor', () => {
// Mocks
let mockStoreObservations: ReturnType<typeof mock>;
let mockChromaSyncObservation: ReturnType<typeof mock>;
let mockChromaSyncSummary: ReturnType<typeof mock>;
let mockBroadcast: ReturnType<typeof mock>;
let mockBroadcastProcessingStatus: ReturnType<typeof mock>;
let mockDbManager: DatabaseManager;
let mockSessionManager: SessionManager;
let mockWorker: WorkerRef;
beforeEach(() => {
// Create fresh mocks for each test
mockStoreObservations = mock(() => ({
observationIds: [1, 2],
summaryId: 1,
createdAtEpoch: 1700000000000,
} as StorageResult));
mockChromaSyncObservation = mock(() => Promise.resolve());
mockChromaSyncSummary = mock(() => Promise.resolve());
mockDbManager = {
getSessionStore: () => ({
storeObservations: mockStoreObservations,
}),
getChromaSync: () => ({
syncObservation: mockChromaSyncObservation,
syncSummary: mockChromaSyncSummary,
}),
} as unknown as DatabaseManager;
mockSessionManager = {
getMessageIterator: async function* () {
yield* [];
},
getPendingMessageStore: () => ({
markProcessed: mock(() => {}),
cleanupProcessed: mock(() => 0),
resetStuckMessages: mock(() => 0),
}),
} as unknown as SessionManager;
mockBroadcast = mock(() => {});
mockBroadcastProcessingStatus = mock(() => {});
mockWorker = {
sseBroadcaster: {
broadcast: mockBroadcast,
},
broadcastProcessingStatus: mockBroadcastProcessingStatus,
};
});
afterEach(() => {
mock.restore();
});
// Helper to create mock session
function createMockSession(
overrides: Partial<ActiveSession> = {}
): ActiveSession {
return {
sessionDbId: 1,
contentSessionId: 'content-session-123',
memorySessionId: 'memory-session-456',
project: 'test-project',
userPrompt: 'Test prompt',
pendingMessages: [],
abortController: new AbortController(),
generatorPromise: null,
lastPromptNumber: 5,
startTime: Date.now(),
cumulativeInputTokens: 100,
cumulativeOutputTokens: 50,
earliestPendingTimestamp: Date.now() - 10000,
conversationHistory: [],
currentProvider: 'claude',
...overrides,
};
}
describe('parsing observations from XML response', () => {
it('should parse single observation from response', async () => {
const session = createMockSession();
const responseText = `
<observation>
<type>discovery</type>
<title>Found important pattern</title>
<subtitle>In auth module</subtitle>
<narrative>Discovered reusable authentication pattern.</narrative>
<facts><fact>Uses JWT</fact></facts>
<concepts><concept>authentication</concept></concepts>
<files_read><file>src/auth.ts</file></files_read>
<files_modified></files_modified>
</observation>
`;
await processAgentResponse(
responseText,
session,
mockDbManager,
mockSessionManager,
mockWorker,
100,
null,
'TestAgent'
);
expect(mockStoreObservations).toHaveBeenCalledTimes(1);
const [memorySessionId, project, observations, summary] =
mockStoreObservations.mock.calls[0];
expect(memorySessionId).toBe('memory-session-456');
expect(project).toBe('test-project');
expect(observations).toHaveLength(1);
expect(observations[0].type).toBe('discovery');
expect(observations[0].title).toBe('Found important pattern');
});
it('should parse multiple observations from response', async () => {
const session = createMockSession();
const responseText = `
<observation>
<type>discovery</type>
<title>First discovery</title>
<narrative>First narrative</narrative>
<facts></facts>
<concepts></concepts>
<files_read></files_read>
<files_modified></files_modified>
</observation>
<observation>
<type>bugfix</type>
<title>Fixed null pointer</title>
<narrative>Second narrative</narrative>
<facts></facts>
<concepts></concepts>
<files_read></files_read>
<files_modified></files_modified>
</observation>
`;
await processAgentResponse(
responseText,
session,
mockDbManager,
mockSessionManager,
mockWorker,
100,
null,
'TestAgent'
);
const [, , observations] = mockStoreObservations.mock.calls[0];
expect(observations).toHaveLength(2);
expect(observations[0].type).toBe('discovery');
expect(observations[1].type).toBe('bugfix');
});
});
describe('parsing summary from XML response', () => {
it('should parse summary from response', async () => {
const session = createMockSession();
const responseText = `
<observation>
<type>discovery</type>
<title>Test</title>
<facts></facts>
<concepts></concepts>
<files_read></files_read>
<files_modified></files_modified>
</observation>
<summary>
<request>Build login form</request>
<investigated>Reviewed existing forms</investigated>
<learned>React Hook Form works well</learned>
<completed>Form skeleton created</completed>
<next_steps>Add validation</next_steps>
<notes>Some notes</notes>
</summary>
`;
await processAgentResponse(
responseText,
session,
mockDbManager,
mockSessionManager,
mockWorker,
100,
null,
'TestAgent'
);
const [, , , summary] = mockStoreObservations.mock.calls[0];
expect(summary).not.toBeNull();
expect(summary.request).toBe('Build login form');
expect(summary.investigated).toBe('Reviewed existing forms');
expect(summary.learned).toBe('React Hook Form works well');
});
it('should handle response without summary', async () => {
const session = createMockSession();
const responseText = `
<observation>
<type>discovery</type>
<title>Test</title>
<facts></facts>
<concepts></concepts>
<files_read></files_read>
<files_modified></files_modified>
</observation>
`;
// Mock to return result without summary
mockStoreObservations = mock(() => ({
observationIds: [1],
summaryId: null,
createdAtEpoch: 1700000000000,
}));
(mockDbManager.getSessionStore as any) = () => ({
storeObservations: mockStoreObservations,
});
await processAgentResponse(
responseText,
session,
mockDbManager,
mockSessionManager,
mockWorker,
100,
null,
'TestAgent'
);
const [, , , summary] = mockStoreObservations.mock.calls[0];
expect(summary).toBeNull();
});
});
describe('atomic database transactions', () => {
it('should call storeObservations atomically', async () => {
const session = createMockSession();
const responseText = `
<observation>
<type>discovery</type>
<title>Test</title>
<facts></facts>
<concepts></concepts>
<files_read></files_read>
<files_modified></files_modified>
</observation>
<summary>
<request>Test request</request>
<investigated>Test investigated</investigated>
<learned>Test learned</learned>
<completed>Test completed</completed>
<next_steps>Test next steps</next_steps>
</summary>
`;
await processAgentResponse(
responseText,
session,
mockDbManager,
mockSessionManager,
mockWorker,
100,
1700000000000,
'TestAgent'
);
// Verify storeObservations was called exactly once (atomic)
expect(mockStoreObservations).toHaveBeenCalledTimes(1);
// Verify all parameters passed correctly
const [
memorySessionId,
project,
observations,
summary,
promptNumber,
tokens,
timestamp,
] = mockStoreObservations.mock.calls[0];
expect(memorySessionId).toBe('memory-session-456');
expect(project).toBe('test-project');
expect(observations).toHaveLength(1);
expect(summary).not.toBeNull();
expect(promptNumber).toBe(5);
expect(tokens).toBe(100);
expect(timestamp).toBe(1700000000000);
});
});
describe('SSE broadcasting', () => {
it('should broadcast observations via SSE', async () => {
const session = createMockSession();
const responseText = `
<observation>
<type>discovery</type>
<title>Broadcast Test</title>
<subtitle>Testing broadcast</subtitle>
<narrative>Testing SSE broadcast</narrative>
<facts><fact>Fact 1</fact></facts>
<concepts><concept>testing</concept></concepts>
<files_read><file>test.ts</file></files_read>
<files_modified></files_modified>
</observation>
`;
// Mock returning single observation ID
mockStoreObservations = mock(() => ({
observationIds: [42],
summaryId: null,
createdAtEpoch: 1700000000000,
}));
(mockDbManager.getSessionStore as any) = () => ({
storeObservations: mockStoreObservations,
});
await processAgentResponse(
responseText,
session,
mockDbManager,
mockSessionManager,
mockWorker,
100,
null,
'TestAgent'
);
// Should broadcast observation
expect(mockBroadcast).toHaveBeenCalled();
// Find the observation broadcast call
const observationCall = mockBroadcast.mock.calls.find(
(call: any[]) => call[0].type === 'new_observation'
);
expect(observationCall).toBeDefined();
expect(observationCall[0].observation.id).toBe(42);
expect(observationCall[0].observation.title).toBe('Broadcast Test');
expect(observationCall[0].observation.type).toBe('discovery');
});
it('should broadcast summary via SSE', async () => {
const session = createMockSession();
const responseText = `
<observation>
<type>discovery</type>
<title>Test</title>
<facts></facts>
<concepts></concepts>
<files_read></files_read>
<files_modified></files_modified>
</observation>
<summary>
<request>Build feature</request>
<investigated>Reviewed code</investigated>
<learned>Found patterns</learned>
<completed>Feature built</completed>
<next_steps>Add tests</next_steps>
</summary>
`;
await processAgentResponse(
responseText,
session,
mockDbManager,
mockSessionManager,
mockWorker,
100,
null,
'TestAgent'
);
// Find the summary broadcast call
const summaryCall = mockBroadcast.mock.calls.find(
(call: any[]) => call[0].type === 'new_summary'
);
expect(summaryCall).toBeDefined();
expect(summaryCall[0].summary.request).toBe('Build feature');
});
});
describe('handling empty response', () => {
it('should handle empty response gracefully', async () => {
const session = createMockSession();
const responseText = '';
// Mock to handle empty observations
mockStoreObservations = mock(() => ({
observationIds: [],
summaryId: null,
createdAtEpoch: 1700000000000,
}));
(mockDbManager.getSessionStore as any) = () => ({
storeObservations: mockStoreObservations,
});
await processAgentResponse(
responseText,
session,
mockDbManager,
mockSessionManager,
mockWorker,
100,
null,
'TestAgent'
);
// Should still call storeObservations with empty arrays
expect(mockStoreObservations).toHaveBeenCalledTimes(1);
const [, , observations, summary] = mockStoreObservations.mock.calls[0];
expect(observations).toHaveLength(0);
expect(summary).toBeNull();
});
it('should handle response with only text (no XML)', async () => {
const session = createMockSession();
const responseText = 'This is just plain text without any XML tags.';
mockStoreObservations = mock(() => ({
observationIds: [],
summaryId: null,
createdAtEpoch: 1700000000000,
}));
(mockDbManager.getSessionStore as any) = () => ({
storeObservations: mockStoreObservations,
});
await processAgentResponse(
responseText,
session,
mockDbManager,
mockSessionManager,
mockWorker,
100,
null,
'TestAgent'
);
expect(mockStoreObservations).toHaveBeenCalledTimes(1);
const [, , observations] = mockStoreObservations.mock.calls[0];
expect(observations).toHaveLength(0);
});
});
describe('session cleanup', () => {
it('should reset earliestPendingTimestamp after processing', async () => {
const session = createMockSession({
earliestPendingTimestamp: 1700000000000,
});
const responseText = `
<observation>
<type>discovery</type>
<title>Test</title>
<facts></facts>
<concepts></concepts>
<files_read></files_read>
<files_modified></files_modified>
</observation>
`;
mockStoreObservations = mock(() => ({
observationIds: [1],
summaryId: null,
createdAtEpoch: 1700000000000,
}));
(mockDbManager.getSessionStore as any) = () => ({
storeObservations: mockStoreObservations,
});
await processAgentResponse(
responseText,
session,
mockDbManager,
mockSessionManager,
mockWorker,
100,
null,
'TestAgent'
);
expect(session.earliestPendingTimestamp).toBeNull();
});
it('should call broadcastProcessingStatus after processing', async () => {
const session = createMockSession();
const responseText = `
<observation>
<type>discovery</type>
<title>Test</title>
<facts></facts>
<concepts></concepts>
<files_read></files_read>
<files_modified></files_modified>
</observation>
`;
mockStoreObservations = mock(() => ({
observationIds: [1],
summaryId: null,
createdAtEpoch: 1700000000000,
}));
(mockDbManager.getSessionStore as any) = () => ({
storeObservations: mockStoreObservations,
});
await processAgentResponse(
responseText,
session,
mockDbManager,
mockSessionManager,
mockWorker,
100,
null,
'TestAgent'
);
expect(mockBroadcastProcessingStatus).toHaveBeenCalled();
});
});
describe('conversation history', () => {
it('should add assistant response to conversation history', async () => {
const session = createMockSession({
conversationHistory: [],
});
const responseText = `
<observation>
<type>discovery</type>
<title>Test</title>
<facts></facts>
<concepts></concepts>
<files_read></files_read>
<files_modified></files_modified>
</observation>
`;
mockStoreObservations = mock(() => ({
observationIds: [1],
summaryId: null,
createdAtEpoch: 1700000000000,
}));
(mockDbManager.getSessionStore as any) = () => ({
storeObservations: mockStoreObservations,
});
await processAgentResponse(
responseText,
session,
mockDbManager,
mockSessionManager,
mockWorker,
100,
null,
'TestAgent'
);
expect(session.conversationHistory).toHaveLength(1);
expect(session.conversationHistory[0].role).toBe('assistant');
expect(session.conversationHistory[0].content).toBe(responseText);
});
});
describe('error handling', () => {
it('should throw error if memorySessionId is missing', async () => {
const session = createMockSession({
memorySessionId: null, // Missing memory session ID
});
const responseText = '<observation><type>discovery</type></observation>';
await expect(
processAgentResponse(
responseText,
session,
mockDbManager,
mockSessionManager,
mockWorker,
100,
null,
'TestAgent'
)
).rejects.toThrow('Cannot store observations: memorySessionId not yet captured');
});
});
});
@@ -0,0 +1,165 @@
import { describe, it, expect, mock } from 'bun:test';
// Import directly from specific files to avoid worker-service import chain
import { cleanupProcessedMessages } from '../../../src/services/worker/agents/SessionCleanupHelper.js';
import type { WorkerRef } from '../../../src/services/worker/agents/types.js';
import type { ActiveSession } from '../../../src/services/worker-types.js';
describe('SessionCleanupHelper', () => {
// Helper to create a minimal mock session
function createMockSession(
overrides: Partial<ActiveSession> = {}
): ActiveSession {
return {
sessionDbId: 1,
contentSessionId: 'content-session-123',
memorySessionId: 'memory-session-456',
project: 'test-project',
userPrompt: 'Test prompt',
pendingMessages: [],
abortController: new AbortController(),
generatorPromise: null,
lastPromptNumber: 5,
startTime: Date.now(),
cumulativeInputTokens: 100,
cumulativeOutputTokens: 50,
earliestPendingTimestamp: Date.now() - 10000, // 10 seconds ago
conversationHistory: [],
currentProvider: 'claude',
...overrides,
};
}
// Helper to create mock worker
function createMockWorker() {
const broadcastProcessingStatusMock = mock(() => {});
const worker: WorkerRef = {
sseBroadcaster: {
broadcast: mock(() => {}),
},
broadcastProcessingStatus: broadcastProcessingStatusMock,
};
return { worker, broadcastProcessingStatusMock };
}
describe('cleanupProcessedMessages', () => {
it('should reset session.earliestPendingTimestamp to null', () => {
const session = createMockSession({
earliestPendingTimestamp: 1700000000000,
});
const { worker } = createMockWorker();
expect(session.earliestPendingTimestamp).toBe(1700000000000);
cleanupProcessedMessages(session, worker);
expect(session.earliestPendingTimestamp).toBeNull();
});
it('should reset earliestPendingTimestamp even when already null', () => {
const session = createMockSession({
earliestPendingTimestamp: null,
});
const { worker } = createMockWorker();
cleanupProcessedMessages(session, worker);
expect(session.earliestPendingTimestamp).toBeNull();
});
it('should call worker.broadcastProcessingStatus() if available', () => {
const session = createMockSession();
const { worker, broadcastProcessingStatusMock } = createMockWorker();
cleanupProcessedMessages(session, worker);
expect(broadcastProcessingStatusMock).toHaveBeenCalledTimes(1);
});
it('should handle missing worker gracefully (no crash)', () => {
const session = createMockSession({
earliestPendingTimestamp: 1700000000000,
});
// Should not throw
expect(() => {
cleanupProcessedMessages(session, undefined);
}).not.toThrow();
// Should still reset timestamp
expect(session.earliestPendingTimestamp).toBeNull();
});
it('should handle worker without broadcastProcessingStatus', () => {
const session = createMockSession({
earliestPendingTimestamp: 1700000000000,
});
const worker: WorkerRef = {
sseBroadcaster: {
broadcast: mock(() => {}),
},
// No broadcastProcessingStatus
};
// Should not throw
expect(() => {
cleanupProcessedMessages(session, worker);
}).not.toThrow();
// Should still reset timestamp
expect(session.earliestPendingTimestamp).toBeNull();
});
it('should handle empty worker object', () => {
const session = createMockSession({
earliestPendingTimestamp: 1700000000000,
});
const worker: WorkerRef = {};
// Should not throw
expect(() => {
cleanupProcessedMessages(session, worker);
}).not.toThrow();
// Should still reset timestamp
expect(session.earliestPendingTimestamp).toBeNull();
});
it('should handle worker with null broadcastProcessingStatus', () => {
const session = createMockSession({
earliestPendingTimestamp: 1700000000000,
});
const worker: WorkerRef = {
broadcastProcessingStatus: undefined,
};
// Should not throw
expect(() => {
cleanupProcessedMessages(session, worker);
}).not.toThrow();
// Should still reset timestamp
expect(session.earliestPendingTimestamp).toBeNull();
});
it('should not modify other session properties', () => {
const session = createMockSession({
earliestPendingTimestamp: 1700000000000,
lastPromptNumber: 10,
cumulativeInputTokens: 500,
cumulativeOutputTokens: 250,
project: 'my-project',
});
const { worker } = createMockWorker();
cleanupProcessedMessages(session, worker);
// Only earliestPendingTimestamp should change
expect(session.earliestPendingTimestamp).toBeNull();
expect(session.lastPromptNumber).toBe(10);
expect(session.cumulativeInputTokens).toBe(500);
expect(session.cumulativeOutputTokens).toBe(250);
expect(session.project).toBe('my-project');
});
});
});
@@ -0,0 +1,396 @@
import { describe, it, expect, beforeEach, mock } from 'bun:test';
// Mock the ModeManager before imports
mock.module('../../../src/services/domain/ModeManager.js', () => ({
ModeManager: {
getInstance: () => ({
getActiveMode: () => ({
name: 'code',
prompts: {},
observation_types: [
{ id: 'decision', icon: 'D' },
{ id: 'bugfix', icon: 'B' },
{ id: 'feature', icon: 'F' },
{ id: 'refactor', icon: 'R' },
{ id: 'discovery', icon: 'I' },
{ id: 'change', icon: 'C' }
],
observation_concepts: [],
}),
getObservationTypes: () => [
{ id: 'decision', icon: 'D' },
{ id: 'bugfix', icon: 'B' },
{ id: 'feature', icon: 'F' },
{ id: 'refactor', icon: 'R' },
{ id: 'discovery', icon: 'I' },
{ id: 'change', icon: 'C' }
],
getTypeIcon: (type: string) => {
const icons: Record<string, string> = {
decision: 'D',
bugfix: 'B',
feature: 'F',
refactor: 'R',
discovery: 'I',
change: 'C'
};
return icons[type] || '?';
},
getWorkEmoji: () => 'W',
}),
},
}));
import { ResultFormatter } from '../../../src/services/worker/search/ResultFormatter.js';
import type { ObservationSearchResult, SessionSummarySearchResult, UserPromptSearchResult, SearchResults } from '../../../src/services/worker/search/types.js';
// Mock data
const mockObservation: ObservationSearchResult = {
id: 1,
memory_session_id: 'session-123',
project: 'test-project',
text: 'Test observation text',
type: 'decision',
title: 'Test Decision Title',
subtitle: 'A descriptive subtitle',
facts: '["fact1", "fact2"]',
narrative: 'This is the narrative description',
concepts: '["concept1", "concept2"]',
files_read: '["src/file1.ts"]',
files_modified: '["src/file2.ts"]',
prompt_number: 1,
discovery_tokens: 100,
created_at: '2025-01-01T12:00:00.000Z',
created_at_epoch: 1735732800000
};
const mockSession: SessionSummarySearchResult = {
id: 1,
memory_session_id: 'session-123',
project: 'test-project',
request: 'Implement feature X',
investigated: 'Looked at code structure',
learned: 'Learned about the architecture',
completed: 'Added new feature',
next_steps: 'Write tests',
files_read: '["src/index.ts"]',
files_edited: '["src/feature.ts"]',
notes: 'Additional notes',
prompt_number: 1,
discovery_tokens: 500,
created_at: '2025-01-01T12:00:00.000Z',
created_at_epoch: 1735732800000
};
const mockPrompt: UserPromptSearchResult = {
id: 1,
content_session_id: 'content-123',
prompt_number: 1,
prompt_text: 'Can you help me implement feature X?',
created_at: '2025-01-01T12:00:00.000Z',
created_at_epoch: 1735732800000
};
describe('ResultFormatter', () => {
let formatter: ResultFormatter;
beforeEach(() => {
formatter = new ResultFormatter();
});
describe('formatSearchResults', () => {
it('should format observations as markdown', () => {
const results: SearchResults = {
observations: [mockObservation],
sessions: [],
prompts: []
};
const formatted = formatter.formatSearchResults(results, 'test query');
expect(formatted).toContain('test query');
expect(formatted).toContain('1 result');
expect(formatted).toContain('1 obs');
expect(formatted).toContain('#1'); // ID
expect(formatted).toContain('Test Decision Title');
});
it('should format sessions as markdown', () => {
const results: SearchResults = {
observations: [],
sessions: [mockSession],
prompts: []
};
const formatted = formatter.formatSearchResults(results, 'session query');
expect(formatted).toContain('1 session');
expect(formatted).toContain('#S1'); // Session ID format
expect(formatted).toContain('Implement feature X');
});
it('should format prompts as markdown', () => {
const results: SearchResults = {
observations: [],
sessions: [],
prompts: [mockPrompt]
};
const formatted = formatter.formatSearchResults(results, 'prompt query');
expect(formatted).toContain('1 prompt');
expect(formatted).toContain('#P1'); // Prompt ID format
expect(formatted).toContain('Can you help me implement');
});
it('should handle empty results', () => {
const results: SearchResults = {
observations: [],
sessions: [],
prompts: []
};
const formatted = formatter.formatSearchResults(results, 'no matches');
expect(formatted).toContain('No results found');
expect(formatted).toContain('no matches');
});
it('should show combined count for multiple types', () => {
const results: SearchResults = {
observations: [mockObservation],
sessions: [mockSession],
prompts: [mockPrompt]
};
const formatted = formatter.formatSearchResults(results, 'mixed query');
expect(formatted).toContain('3 result(s)');
expect(formatted).toContain('1 obs');
expect(formatted).toContain('1 sessions');
expect(formatted).toContain('1 prompts');
});
it('should escape special characters in query', () => {
const results: SearchResults = {
observations: [mockObservation],
sessions: [],
prompts: []
};
const formatted = formatter.formatSearchResults(results, 'query with "quotes"');
expect(formatted).toContain('query with "quotes"');
});
it('should include table headers', () => {
const results: SearchResults = {
observations: [mockObservation],
sessions: [],
prompts: []
};
const formatted = formatter.formatSearchResults(results, 'test');
expect(formatted).toContain('| ID |');
expect(formatted).toContain('| Time |');
expect(formatted).toContain('| T |');
expect(formatted).toContain('| Title |');
});
it('should indicate Chroma failure when chromaFailed is true', () => {
const results: SearchResults = {
observations: [],
sessions: [],
prompts: []
};
const formatted = formatter.formatSearchResults(results, 'test', true);
expect(formatted).toContain('Vector search failed');
expect(formatted).toContain('semantic search unavailable');
});
});
describe('combineResults', () => {
it('should combine all result types into unified format', () => {
const results: SearchResults = {
observations: [mockObservation],
sessions: [mockSession],
prompts: [mockPrompt]
};
const combined = formatter.combineResults(results);
expect(combined).toHaveLength(3);
expect(combined.some(r => r.type === 'observation')).toBe(true);
expect(combined.some(r => r.type === 'session')).toBe(true);
expect(combined.some(r => r.type === 'prompt')).toBe(true);
});
it('should include epoch for sorting', () => {
const results: SearchResults = {
observations: [mockObservation],
sessions: [],
prompts: []
};
const combined = formatter.combineResults(results);
expect(combined[0].epoch).toBe(mockObservation.created_at_epoch);
});
it('should include created_at for display', () => {
const results: SearchResults = {
observations: [mockObservation],
sessions: [],
prompts: []
};
const combined = formatter.combineResults(results);
expect(combined[0].created_at).toBe(mockObservation.created_at);
});
});
describe('formatTableHeader', () => {
it('should include Work column', () => {
const header = formatter.formatTableHeader();
expect(header).toContain('| Work |');
expect(header).toContain('| ID |');
expect(header).toContain('| Time |');
});
});
describe('formatSearchTableHeader', () => {
it('should not include Work column', () => {
const header = formatter.formatSearchTableHeader();
expect(header).not.toContain('| Work |');
expect(header).toContain('| Read |');
});
});
describe('formatObservationSearchRow', () => {
it('should format observation as table row', () => {
const result = formatter.formatObservationSearchRow(mockObservation, '');
expect(result.row).toContain('#1');
expect(result.row).toContain('Test Decision Title');
expect(result.row).toContain('~'); // Token estimate
});
it('should use quote mark for repeated time', () => {
// First get the actual time format for this observation
const firstResult = formatter.formatObservationSearchRow(mockObservation, '');
// Now pass that same time as lastTime
const result = formatter.formatObservationSearchRow(mockObservation, firstResult.time);
// When time matches lastTime, the row should show quote mark
expect(result.row).toContain('"');
expect(result.time).toBe(firstResult.time);
});
it('should return the time for tracking', () => {
const result = formatter.formatObservationSearchRow(mockObservation, '');
expect(typeof result.time).toBe('string');
});
});
describe('formatSessionSearchRow', () => {
it('should format session as table row', () => {
const result = formatter.formatSessionSearchRow(mockSession, '');
expect(result.row).toContain('#S1');
expect(result.row).toContain('Implement feature X');
});
it('should fallback to session ID prefix when no request', () => {
const sessionNoRequest = { ...mockSession, request: null };
const result = formatter.formatSessionSearchRow(sessionNoRequest, '');
expect(result.row).toContain('Session session-');
});
});
describe('formatPromptSearchRow', () => {
it('should format prompt as table row', () => {
const result = formatter.formatPromptSearchRow(mockPrompt, '');
expect(result.row).toContain('#P1');
expect(result.row).toContain('Can you help me implement');
});
it('should truncate long prompts', () => {
const longPrompt = {
...mockPrompt,
prompt_text: 'A'.repeat(100)
};
const result = formatter.formatPromptSearchRow(longPrompt, '');
expect(result.row).toContain('...');
expect(result.row.length).toBeLessThan(longPrompt.prompt_text.length + 50);
});
});
describe('formatObservationIndex', () => {
it('should include Work column in index format', () => {
const row = formatter.formatObservationIndex(mockObservation, 0);
expect(row).toContain('#1');
// Should have more columns than search row
expect(row.split('|').length).toBeGreaterThan(5);
});
it('should show discovery tokens as work', () => {
const obsWithTokens = { ...mockObservation, discovery_tokens: 250 };
const row = formatter.formatObservationIndex(obsWithTokens, 0);
expect(row).toContain('250');
});
it('should show dash when no discovery tokens', () => {
const obsNoTokens = { ...mockObservation, discovery_tokens: 0 };
const row = formatter.formatObservationIndex(obsNoTokens, 0);
expect(row).toContain('-');
});
});
describe('formatSessionIndex', () => {
it('should include session ID prefix', () => {
const row = formatter.formatSessionIndex(mockSession, 0);
expect(row).toContain('#S1');
});
});
describe('formatPromptIndex', () => {
it('should include prompt ID prefix', () => {
const row = formatter.formatPromptIndex(mockPrompt, 0);
expect(row).toContain('#P1');
});
});
describe('formatSearchTips', () => {
it('should include search strategy tips', () => {
const tips = formatter.formatSearchTips();
expect(tips).toContain('Search Strategy');
expect(tips).toContain('timeline');
expect(tips).toContain('get_observations');
});
it('should include filter examples', () => {
const tips = formatter.formatSearchTips();
expect(tips).toContain('obs_type');
expect(tips).toContain('dateStart');
expect(tips).toContain('orderBy');
});
});
});
@@ -0,0 +1,401 @@
import { describe, it, expect, mock, beforeEach } from 'bun:test';
// Mock the ModeManager before imports
mock.module('../../../src/services/domain/ModeManager.js', () => ({
ModeManager: {
getInstance: () => ({
getActiveMode: () => ({
name: 'code',
prompts: {},
observation_types: [
{ id: 'decision', icon: 'D' },
{ id: 'bugfix', icon: 'B' },
{ id: 'feature', icon: 'F' },
{ id: 'refactor', icon: 'R' },
{ id: 'discovery', icon: 'I' },
{ id: 'change', icon: 'C' }
],
observation_concepts: [],
}),
getObservationTypes: () => [
{ id: 'decision', icon: 'D' },
{ id: 'bugfix', icon: 'B' },
{ id: 'feature', icon: 'F' },
{ id: 'refactor', icon: 'R' },
{ id: 'discovery', icon: 'I' },
{ id: 'change', icon: 'C' }
],
getTypeIcon: (type: string) => {
const icons: Record<string, string> = {
decision: 'D',
bugfix: 'B',
feature: 'F',
refactor: 'R',
discovery: 'I',
change: 'C'
};
return icons[type] || '?';
},
getWorkEmoji: () => 'W',
}),
},
}));
import { SearchOrchestrator } from '../../../src/services/worker/search/SearchOrchestrator.js';
import type { ObservationSearchResult, SessionSummarySearchResult, UserPromptSearchResult } from '../../../src/services/worker/search/types.js';
// Mock data
const mockObservation: ObservationSearchResult = {
id: 1,
memory_session_id: 'session-123',
project: 'test-project',
text: 'Test observation',
type: 'decision',
title: 'Test Decision',
subtitle: 'Subtitle',
facts: '["fact1"]',
narrative: 'Narrative',
concepts: '["concept1"]',
files_read: '["file1.ts"]',
files_modified: '["file2.ts"]',
prompt_number: 1,
discovery_tokens: 100,
created_at: '2025-01-01T12:00:00.000Z',
created_at_epoch: Date.now() - 1000 * 60 * 60 * 24
};
const mockSession: SessionSummarySearchResult = {
id: 1,
memory_session_id: 'session-123',
project: 'test-project',
request: 'Test request',
investigated: 'Investigated',
learned: 'Learned',
completed: 'Completed',
next_steps: 'Next steps',
files_read: '["file1.ts"]',
files_edited: '["file2.ts"]',
notes: 'Notes',
prompt_number: 1,
discovery_tokens: 500,
created_at: '2025-01-01T12:00:00.000Z',
created_at_epoch: Date.now() - 1000 * 60 * 60 * 24
};
const mockPrompt: UserPromptSearchResult = {
id: 1,
content_session_id: 'content-123',
prompt_number: 1,
prompt_text: 'Test prompt',
created_at: '2025-01-01T12:00:00.000Z',
created_at_epoch: Date.now() - 1000 * 60 * 60 * 24
};
describe('SearchOrchestrator', () => {
let orchestrator: SearchOrchestrator;
let mockSessionSearch: any;
let mockSessionStore: any;
let mockChromaSync: any;
beforeEach(() => {
mockSessionSearch = {
searchObservations: mock(() => [mockObservation]),
searchSessions: mock(() => [mockSession]),
searchUserPrompts: mock(() => [mockPrompt]),
findByConcept: mock(() => [mockObservation]),
findByType: mock(() => [mockObservation]),
findByFile: mock(() => ({ observations: [mockObservation], sessions: [mockSession] }))
};
mockSessionStore = {
getObservationsByIds: mock(() => [mockObservation]),
getSessionSummariesByIds: mock(() => [mockSession]),
getUserPromptsByIds: mock(() => [mockPrompt])
};
mockChromaSync = {
queryChroma: mock(() => Promise.resolve({
ids: [1],
distances: [0.1],
metadatas: [{ sqlite_id: 1, doc_type: 'observation', created_at_epoch: Date.now() - 1000 }]
}))
};
});
describe('with Chroma available', () => {
beforeEach(() => {
orchestrator = new SearchOrchestrator(mockSessionSearch, mockSessionStore, mockChromaSync);
});
describe('search', () => {
it('should select SQLite strategy for filter-only queries (no query text)', async () => {
const result = await orchestrator.search({
project: 'test-project',
limit: 10
});
expect(result.strategy).toBe('sqlite');
expect(result.usedChroma).toBe(false);
expect(mockSessionSearch.searchObservations).toHaveBeenCalled();
expect(mockChromaSync.queryChroma).not.toHaveBeenCalled();
});
it('should select Chroma strategy for query-only', async () => {
const result = await orchestrator.search({
query: 'semantic search query'
});
expect(result.strategy).toBe('chroma');
expect(result.usedChroma).toBe(true);
expect(mockChromaSync.queryChroma).toHaveBeenCalled();
});
it('should fall back to SQLite when Chroma fails', async () => {
mockChromaSync.queryChroma = mock(() => Promise.reject(new Error('Chroma unavailable')));
const result = await orchestrator.search({
query: 'test query'
});
// Chroma failed, should have fallen back
expect(result.fellBack).toBe(true);
expect(result.usedChroma).toBe(false);
});
it('should normalize comma-separated concepts', async () => {
await orchestrator.search({
concepts: 'concept1, concept2, concept3',
limit: 10
});
// Should be parsed into array internally
const callArgs = mockSessionSearch.searchObservations.mock.calls[0];
expect(callArgs[1].concepts).toEqual(['concept1', 'concept2', 'concept3']);
});
it('should normalize comma-separated files', async () => {
await orchestrator.search({
files: 'file1.ts, file2.ts',
limit: 10
});
const callArgs = mockSessionSearch.searchObservations.mock.calls[0];
expect(callArgs[1].files).toEqual(['file1.ts', 'file2.ts']);
});
it('should normalize dateStart/dateEnd into dateRange object', async () => {
await orchestrator.search({
dateStart: '2025-01-01',
dateEnd: '2025-01-31'
});
const callArgs = mockSessionSearch.searchObservations.mock.calls[0];
expect(callArgs[1].dateRange).toEqual({
start: '2025-01-01',
end: '2025-01-31'
});
});
it('should map type to searchType for observations/sessions/prompts', async () => {
await orchestrator.search({
type: 'observations'
});
// Should search only observations
expect(mockSessionSearch.searchObservations).toHaveBeenCalled();
expect(mockSessionSearch.searchSessions).not.toHaveBeenCalled();
expect(mockSessionSearch.searchUserPrompts).not.toHaveBeenCalled();
});
});
describe('findByConcept', () => {
it('should use hybrid strategy when Chroma available', async () => {
const result = await orchestrator.findByConcept('test-concept', {
limit: 10
});
// Hybrid strategy should be used
expect(mockSessionSearch.findByConcept).toHaveBeenCalled();
expect(mockChromaSync.queryChroma).toHaveBeenCalled();
});
it('should return observations matching concept', async () => {
const result = await orchestrator.findByConcept('test-concept', {});
expect(result.results.observations.length).toBeGreaterThanOrEqual(0);
});
});
describe('findByType', () => {
it('should use hybrid strategy', async () => {
const result = await orchestrator.findByType('decision', {});
expect(mockSessionSearch.findByType).toHaveBeenCalled();
});
it('should handle array of types', async () => {
await orchestrator.findByType(['decision', 'bugfix'], {});
expect(mockSessionSearch.findByType).toHaveBeenCalledWith(['decision', 'bugfix'], expect.any(Object));
});
});
describe('findByFile', () => {
it('should return observations and sessions for file', async () => {
const result = await orchestrator.findByFile('/path/to/file.ts', {});
expect(result.observations.length).toBeGreaterThanOrEqual(0);
expect(mockSessionSearch.findByFile).toHaveBeenCalled();
});
it('should include usedChroma in result', async () => {
const result = await orchestrator.findByFile('/path/to/file.ts', {});
expect(typeof result.usedChroma).toBe('boolean');
});
});
describe('isChromaAvailable', () => {
it('should return true when Chroma is available', () => {
expect(orchestrator.isChromaAvailable()).toBe(true);
});
});
describe('formatSearchResults', () => {
it('should format results as markdown', () => {
const results = {
observations: [mockObservation],
sessions: [mockSession],
prompts: [mockPrompt]
};
const formatted = orchestrator.formatSearchResults(results, 'test query');
expect(formatted).toContain('test query');
expect(formatted).toContain('result');
});
it('should handle empty results', () => {
const results = {
observations: [],
sessions: [],
prompts: []
};
const formatted = orchestrator.formatSearchResults(results, 'no matches');
expect(formatted).toContain('No results found');
});
it('should indicate Chroma failure when chromaFailed is true', () => {
const results = {
observations: [],
sessions: [],
prompts: []
};
const formatted = orchestrator.formatSearchResults(results, 'test', true);
expect(formatted).toContain('Vector search failed');
});
});
});
describe('without Chroma (null)', () => {
beforeEach(() => {
orchestrator = new SearchOrchestrator(mockSessionSearch, mockSessionStore, null);
});
describe('isChromaAvailable', () => {
it('should return false when Chroma is null', () => {
expect(orchestrator.isChromaAvailable()).toBe(false);
});
});
describe('search', () => {
it('should return empty results for query search without Chroma', async () => {
const result = await orchestrator.search({
query: 'semantic query'
});
// No Chroma available, can't do semantic search
expect(result.results.observations).toHaveLength(0);
expect(result.usedChroma).toBe(false);
});
it('should still work for filter-only queries', async () => {
const result = await orchestrator.search({
project: 'test-project'
});
expect(result.strategy).toBe('sqlite');
expect(result.results.observations).toHaveLength(1);
});
});
describe('findByConcept', () => {
it('should fall back to SQLite-only', async () => {
const result = await orchestrator.findByConcept('test-concept', {});
expect(result.usedChroma).toBe(false);
expect(result.strategy).toBe('sqlite');
expect(mockSessionSearch.findByConcept).toHaveBeenCalled();
});
});
describe('findByType', () => {
it('should fall back to SQLite-only', async () => {
const result = await orchestrator.findByType('decision', {});
expect(result.usedChroma).toBe(false);
expect(result.strategy).toBe('sqlite');
});
});
describe('findByFile', () => {
it('should fall back to SQLite-only', async () => {
const result = await orchestrator.findByFile('/path/to/file.ts', {});
expect(result.usedChroma).toBe(false);
expect(mockSessionSearch.findByFile).toHaveBeenCalled();
});
});
});
describe('parameter normalization', () => {
beforeEach(() => {
orchestrator = new SearchOrchestrator(mockSessionSearch, mockSessionStore, null);
});
it('should parse obs_type into obsType array', async () => {
await orchestrator.search({
obs_type: 'decision, bugfix'
});
const callArgs = mockSessionSearch.searchObservations.mock.calls[0];
expect(callArgs[1].type).toEqual(['decision', 'bugfix']);
});
it('should handle already-array concepts', async () => {
await orchestrator.search({
concepts: ['concept1', 'concept2']
});
const callArgs = mockSessionSearch.searchObservations.mock.calls[0];
expect(callArgs[1].concepts).toEqual(['concept1', 'concept2']);
});
it('should handle empty string filters', async () => {
await orchestrator.search({
concepts: '',
files: ''
});
const callArgs = mockSessionSearch.searchObservations.mock.calls[0];
// Empty strings are falsy, so the normalization doesn't process them
// They stay as empty strings (the underlying search functions handle this)
expect(callArgs[1].concepts).toEqual('');
expect(callArgs[1].files).toEqual('');
});
});
});
@@ -0,0 +1,305 @@
import { describe, it, expect, mock, beforeEach } from 'bun:test';
import { ChromaSearchStrategy } from '../../../../src/services/worker/search/strategies/ChromaSearchStrategy.js';
import type { StrategySearchOptions, ObservationSearchResult, SessionSummarySearchResult, UserPromptSearchResult } from '../../../../src/services/worker/search/types.js';
// Mock observation data
const mockObservation: ObservationSearchResult = {
id: 1,
memory_session_id: 'session-123',
project: 'test-project',
text: 'Test observation text',
type: 'decision',
title: 'Test Decision',
subtitle: 'A test subtitle',
facts: '["fact1", "fact2"]',
narrative: 'Test narrative',
concepts: '["concept1", "concept2"]',
files_read: '["file1.ts"]',
files_modified: '["file2.ts"]',
prompt_number: 1,
discovery_tokens: 100,
created_at: '2025-01-01T12:00:00.000Z',
created_at_epoch: Date.now() - 1000 * 60 * 60 * 24 // 1 day ago
};
const mockSession: SessionSummarySearchResult = {
id: 2,
memory_session_id: 'session-123',
project: 'test-project',
request: 'Test request',
investigated: 'Test investigated',
learned: 'Test learned',
completed: 'Test completed',
next_steps: 'Test next steps',
files_read: '["file1.ts"]',
files_edited: '["file2.ts"]',
notes: 'Test notes',
prompt_number: 1,
discovery_tokens: 500,
created_at: '2025-01-01T12:00:00.000Z',
created_at_epoch: Date.now() - 1000 * 60 * 60 * 24
};
const mockPrompt: UserPromptSearchResult = {
id: 3,
content_session_id: 'content-session-123',
prompt_number: 1,
prompt_text: 'Test prompt text',
created_at: '2025-01-01T12:00:00.000Z',
created_at_epoch: Date.now() - 1000 * 60 * 60 * 24
};
describe('ChromaSearchStrategy', () => {
let strategy: ChromaSearchStrategy;
let mockChromaSync: any;
let mockSessionStore: any;
beforeEach(() => {
const recentEpoch = Date.now() - 1000 * 60 * 60 * 24; // 1 day ago (within 90-day window)
mockChromaSync = {
queryChroma: mock(() => Promise.resolve({
ids: [1, 2, 3],
distances: [0.1, 0.2, 0.3],
metadatas: [
{ sqlite_id: 1, doc_type: 'observation', created_at_epoch: recentEpoch },
{ sqlite_id: 2, doc_type: 'session_summary', created_at_epoch: recentEpoch },
{ sqlite_id: 3, doc_type: 'user_prompt', created_at_epoch: recentEpoch }
]
}))
};
mockSessionStore = {
getObservationsByIds: mock(() => [mockObservation]),
getSessionSummariesByIds: mock(() => [mockSession]),
getUserPromptsByIds: mock(() => [mockPrompt])
};
strategy = new ChromaSearchStrategy(mockChromaSync, mockSessionStore);
});
describe('canHandle', () => {
it('should return true when query text is present', () => {
const options: StrategySearchOptions = {
query: 'semantic search query'
};
expect(strategy.canHandle(options)).toBe(true);
});
it('should return false for filter-only (no query)', () => {
const options: StrategySearchOptions = {
project: 'test-project'
};
expect(strategy.canHandle(options)).toBe(false);
});
it('should return false when query is empty string', () => {
const options: StrategySearchOptions = {
query: ''
};
expect(strategy.canHandle(options)).toBe(false);
});
it('should return false when query is undefined', () => {
const options: StrategySearchOptions = {};
expect(strategy.canHandle(options)).toBe(false);
});
});
describe('search', () => {
it('should call Chroma with query text', async () => {
const options: StrategySearchOptions = {
query: 'test query',
limit: 10
};
await strategy.search(options);
expect(mockChromaSync.queryChroma).toHaveBeenCalledWith(
'test query',
100, // CHROMA_BATCH_SIZE
undefined // no where filter for 'all'
);
});
it('should return usedChroma: true on success', async () => {
const options: StrategySearchOptions = {
query: 'test query'
};
const result = await strategy.search(options);
expect(result.usedChroma).toBe(true);
expect(result.fellBack).toBe(false);
expect(result.strategy).toBe('chroma');
});
it('should hydrate observations from SQLite', async () => {
const options: StrategySearchOptions = {
query: 'test query',
searchType: 'observations'
};
const result = await strategy.search(options);
expect(mockSessionStore.getObservationsByIds).toHaveBeenCalled();
expect(result.results.observations).toHaveLength(1);
});
it('should hydrate sessions from SQLite', async () => {
const options: StrategySearchOptions = {
query: 'test query',
searchType: 'sessions'
};
await strategy.search(options);
expect(mockSessionStore.getSessionSummariesByIds).toHaveBeenCalled();
});
it('should hydrate prompts from SQLite', async () => {
const options: StrategySearchOptions = {
query: 'test query',
searchType: 'prompts'
};
await strategy.search(options);
expect(mockSessionStore.getUserPromptsByIds).toHaveBeenCalled();
});
it('should filter by doc_type when searchType is observations', async () => {
const options: StrategySearchOptions = {
query: 'test query',
searchType: 'observations'
};
await strategy.search(options);
expect(mockChromaSync.queryChroma).toHaveBeenCalledWith(
'test query',
100,
{ doc_type: 'observation' }
);
});
it('should filter by doc_type when searchType is sessions', async () => {
const options: StrategySearchOptions = {
query: 'test query',
searchType: 'sessions'
};
await strategy.search(options);
expect(mockChromaSync.queryChroma).toHaveBeenCalledWith(
'test query',
100,
{ doc_type: 'session_summary' }
);
});
it('should filter by doc_type when searchType is prompts', async () => {
const options: StrategySearchOptions = {
query: 'test query',
searchType: 'prompts'
};
await strategy.search(options);
expect(mockChromaSync.queryChroma).toHaveBeenCalledWith(
'test query',
100,
{ doc_type: 'user_prompt' }
);
});
it('should return empty result when no query provided', async () => {
const options: StrategySearchOptions = {
query: undefined
};
const result = await strategy.search(options);
expect(result.results.observations).toHaveLength(0);
expect(result.results.sessions).toHaveLength(0);
expect(result.results.prompts).toHaveLength(0);
expect(mockChromaSync.queryChroma).not.toHaveBeenCalled();
});
it('should return empty result when Chroma returns no matches', async () => {
mockChromaSync.queryChroma = mock(() => Promise.resolve({
ids: [],
distances: [],
metadatas: []
}));
const options: StrategySearchOptions = {
query: 'no matches query'
};
const result = await strategy.search(options);
expect(result.results.observations).toHaveLength(0);
expect(result.usedChroma).toBe(true); // Still used Chroma, just no results
});
it('should filter out old results (beyond 90-day window)', async () => {
const oldEpoch = Date.now() - 1000 * 60 * 60 * 24 * 100; // 100 days ago
mockChromaSync.queryChroma = mock(() => Promise.resolve({
ids: [1],
distances: [0.1],
metadatas: [
{ sqlite_id: 1, doc_type: 'observation', created_at_epoch: oldEpoch }
]
}));
const options: StrategySearchOptions = {
query: 'old data query'
};
const result = await strategy.search(options);
// Old results should be filtered out
expect(mockSessionStore.getObservationsByIds).not.toHaveBeenCalled();
});
it('should handle Chroma errors gracefully (returns usedChroma: false)', async () => {
mockChromaSync.queryChroma = mock(() => Promise.reject(new Error('Chroma connection failed')));
const options: StrategySearchOptions = {
query: 'test query'
};
const result = await strategy.search(options);
expect(result.usedChroma).toBe(false);
expect(result.fellBack).toBe(false);
expect(result.results.observations).toHaveLength(0);
expect(result.results.sessions).toHaveLength(0);
expect(result.results.prompts).toHaveLength(0);
});
it('should handle SQLite hydration errors gracefully', async () => {
mockSessionStore.getObservationsByIds = mock(() => {
throw new Error('SQLite error');
});
const options: StrategySearchOptions = {
query: 'test query',
searchType: 'observations'
};
const result = await strategy.search(options);
expect(result.usedChroma).toBe(false); // Error occurred
expect(result.results.observations).toHaveLength(0);
});
});
describe('strategy name', () => {
it('should have name "chroma"', () => {
expect(strategy.name).toBe('chroma');
});
});
});
@@ -0,0 +1,417 @@
import { describe, it, expect, mock, beforeEach } from 'bun:test';
import { HybridSearchStrategy } from '../../../../src/services/worker/search/strategies/HybridSearchStrategy.js';
import type { StrategySearchOptions, ObservationSearchResult, SessionSummarySearchResult } from '../../../../src/services/worker/search/types.js';
// Mock observation data
const mockObservation1: ObservationSearchResult = {
id: 1,
memory_session_id: 'session-123',
project: 'test-project',
text: 'Test observation 1',
type: 'decision',
title: 'First Decision',
subtitle: 'Subtitle 1',
facts: '["fact1"]',
narrative: 'Narrative 1',
concepts: '["concept1"]',
files_read: '["file1.ts"]',
files_modified: '["file2.ts"]',
prompt_number: 1,
discovery_tokens: 100,
created_at: '2025-01-01T12:00:00.000Z',
created_at_epoch: Date.now() - 1000 * 60 * 60 * 24
};
const mockObservation2: ObservationSearchResult = {
id: 2,
memory_session_id: 'session-123',
project: 'test-project',
text: 'Test observation 2',
type: 'bugfix',
title: 'Second Bugfix',
subtitle: 'Subtitle 2',
facts: '["fact2"]',
narrative: 'Narrative 2',
concepts: '["concept2"]',
files_read: '["file3.ts"]',
files_modified: '["file4.ts"]',
prompt_number: 2,
discovery_tokens: 150,
created_at: '2025-01-02T12:00:00.000Z',
created_at_epoch: Date.now() - 1000 * 60 * 60 * 24 * 2
};
const mockObservation3: ObservationSearchResult = {
id: 3,
memory_session_id: 'session-456',
project: 'test-project',
text: 'Test observation 3',
type: 'feature',
title: 'Third Feature',
subtitle: 'Subtitle 3',
facts: '["fact3"]',
narrative: 'Narrative 3',
concepts: '["concept3"]',
files_read: '["file5.ts"]',
files_modified: '["file6.ts"]',
prompt_number: 3,
discovery_tokens: 200,
created_at: '2025-01-03T12:00:00.000Z',
created_at_epoch: Date.now() - 1000 * 60 * 60 * 24 * 3
};
const mockSession: SessionSummarySearchResult = {
id: 1,
memory_session_id: 'session-123',
project: 'test-project',
request: 'Test request',
investigated: 'Test investigated',
learned: 'Test learned',
completed: 'Test completed',
next_steps: 'Test next steps',
files_read: '["file1.ts"]',
files_edited: '["file2.ts"]',
notes: 'Test notes',
prompt_number: 1,
discovery_tokens: 500,
created_at: '2025-01-01T12:00:00.000Z',
created_at_epoch: Date.now() - 1000 * 60 * 60 * 24
};
describe('HybridSearchStrategy', () => {
let strategy: HybridSearchStrategy;
let mockChromaSync: any;
let mockSessionStore: any;
let mockSessionSearch: any;
beforeEach(() => {
mockChromaSync = {
queryChroma: mock(() => Promise.resolve({
ids: [2, 1, 3], // Chroma returns in semantic relevance order
distances: [0.1, 0.2, 0.3],
metadatas: []
}))
};
mockSessionStore = {
getObservationsByIds: mock((ids: number[]) => {
// Return in the order we stored them (not Chroma order)
const allObs = [mockObservation1, mockObservation2, mockObservation3];
return allObs.filter(obs => ids.includes(obs.id));
}),
getSessionSummariesByIds: mock(() => [mockSession]),
getUserPromptsByIds: mock(() => [])
};
mockSessionSearch = {
findByConcept: mock(() => [mockObservation1, mockObservation2, mockObservation3]),
findByType: mock(() => [mockObservation1, mockObservation2]),
findByFile: mock(() => ({
observations: [mockObservation1, mockObservation2],
sessions: [mockSession]
}))
};
strategy = new HybridSearchStrategy(mockChromaSync, mockSessionStore, mockSessionSearch);
});
describe('canHandle', () => {
it('should return true when concepts filter is present', () => {
const options: StrategySearchOptions = {
concepts: ['test-concept']
};
expect(strategy.canHandle(options)).toBe(true);
});
it('should return true when files filter is present', () => {
const options: StrategySearchOptions = {
files: ['/path/to/file.ts']
};
expect(strategy.canHandle(options)).toBe(true);
});
it('should return true when type and query are present', () => {
const options: StrategySearchOptions = {
type: 'decision',
query: 'semantic query'
};
expect(strategy.canHandle(options)).toBe(true);
});
it('should return true when strategyHint is hybrid', () => {
const options: StrategySearchOptions = {
strategyHint: 'hybrid'
};
expect(strategy.canHandle(options)).toBe(true);
});
it('should return false for query-only (no filters)', () => {
const options: StrategySearchOptions = {
query: 'semantic query'
};
expect(strategy.canHandle(options)).toBe(false);
});
it('should return false for filter-only without Chroma', () => {
// Create strategy without Chroma
const strategyNoChroma = new HybridSearchStrategy(null as any, mockSessionStore, mockSessionSearch);
const options: StrategySearchOptions = {
concepts: ['test-concept']
};
expect(strategyNoChroma.canHandle(options)).toBe(false);
});
});
describe('search', () => {
it('should return empty result for generic hybrid search without query', async () => {
const options: StrategySearchOptions = {
concepts: ['test-concept']
};
const result = await strategy.search(options);
expect(result.results.observations).toHaveLength(0);
expect(result.strategy).toBe('hybrid');
});
it('should return empty result for generic hybrid search (use specific methods)', async () => {
const options: StrategySearchOptions = {
query: 'test query'
};
const result = await strategy.search(options);
// Generic search returns empty - use findByConcept/findByType/findByFile instead
expect(result.results.observations).toHaveLength(0);
});
});
describe('findByConcept', () => {
it('should combine metadata + semantic results', async () => {
const options: StrategySearchOptions = {
limit: 10
};
const result = await strategy.findByConcept('test-concept', options);
expect(mockSessionSearch.findByConcept).toHaveBeenCalledWith('test-concept', expect.any(Object));
expect(mockChromaSync.queryChroma).toHaveBeenCalledWith('test-concept', expect.any(Number));
expect(result.usedChroma).toBe(true);
expect(result.fellBack).toBe(false);
expect(result.strategy).toBe('hybrid');
});
it('should preserve semantic ranking order from Chroma', async () => {
// Chroma returns: [2, 1, 3] (obs 2 is most relevant)
// SQLite returns: [1, 2, 3] (by date or however)
// Result should be in Chroma order: [2, 1, 3]
const options: StrategySearchOptions = {
limit: 10
};
const result = await strategy.findByConcept('test-concept', options);
expect(result.results.observations.length).toBeGreaterThan(0);
// The first result should be id=2 (Chroma's top result)
expect(result.results.observations[0].id).toBe(2);
});
it('should only include observations that match both metadata and Chroma', async () => {
// Metadata returns ids [1, 2, 3]
// Chroma returns ids [2, 4, 5] (4 and 5 don't exist in metadata results)
mockChromaSync.queryChroma = mock(() => Promise.resolve({
ids: [2, 4, 5],
distances: [0.1, 0.2, 0.3],
metadatas: []
}));
const options: StrategySearchOptions = {
limit: 10
};
const result = await strategy.findByConcept('test-concept', options);
// Only id=2 should be in both sets
expect(result.results.observations).toHaveLength(1);
expect(result.results.observations[0].id).toBe(2);
});
it('should return empty when no metadata matches', async () => {
mockSessionSearch.findByConcept = mock(() => []);
const options: StrategySearchOptions = {
limit: 10
};
const result = await strategy.findByConcept('nonexistent-concept', options);
expect(result.results.observations).toHaveLength(0);
expect(mockChromaSync.queryChroma).not.toHaveBeenCalled(); // Should short-circuit
});
it('should fall back to metadata-only on Chroma error', async () => {
mockChromaSync.queryChroma = mock(() => Promise.reject(new Error('Chroma failed')));
const options: StrategySearchOptions = {
limit: 10
};
const result = await strategy.findByConcept('test-concept', options);
expect(result.usedChroma).toBe(false);
expect(result.fellBack).toBe(true);
expect(result.results.observations).toHaveLength(3); // All metadata results
});
});
describe('findByType', () => {
it('should find observations by type with semantic ranking', async () => {
const options: StrategySearchOptions = {
limit: 10
};
const result = await strategy.findByType('decision', options);
expect(mockSessionSearch.findByType).toHaveBeenCalledWith('decision', expect.any(Object));
expect(mockChromaSync.queryChroma).toHaveBeenCalled();
expect(result.usedChroma).toBe(true);
});
it('should handle array of types', async () => {
const options: StrategySearchOptions = {
limit: 10
};
await strategy.findByType(['decision', 'bugfix'], options);
expect(mockSessionSearch.findByType).toHaveBeenCalledWith(['decision', 'bugfix'], expect.any(Object));
// Chroma query should use joined type string
expect(mockChromaSync.queryChroma).toHaveBeenCalledWith('decision, bugfix', expect.any(Number));
});
it('should preserve Chroma ranking order for types', async () => {
mockChromaSync.queryChroma = mock(() => Promise.resolve({
ids: [2, 1], // Chroma order
distances: [0.1, 0.2],
metadatas: []
}));
const options: StrategySearchOptions = {
limit: 10
};
const result = await strategy.findByType('decision', options);
expect(result.results.observations[0].id).toBe(2);
});
it('should fall back on Chroma error', async () => {
mockChromaSync.queryChroma = mock(() => Promise.reject(new Error('Chroma unavailable')));
const options: StrategySearchOptions = {
limit: 10
};
const result = await strategy.findByType('bugfix', options);
expect(result.usedChroma).toBe(false);
expect(result.fellBack).toBe(true);
expect(result.results.observations.length).toBeGreaterThan(0);
});
it('should return empty when no metadata matches', async () => {
mockSessionSearch.findByType = mock(() => []);
const options: StrategySearchOptions = {
limit: 10
};
const result = await strategy.findByType('nonexistent', options);
expect(result.results.observations).toHaveLength(0);
});
});
describe('findByFile', () => {
it('should find observations and sessions by file path', async () => {
const options: StrategySearchOptions = {
limit: 10
};
const result = await strategy.findByFile('/path/to/file.ts', options);
expect(mockSessionSearch.findByFile).toHaveBeenCalledWith('/path/to/file.ts', expect.any(Object));
expect(result.observations.length).toBeGreaterThanOrEqual(0);
expect(result.sessions).toHaveLength(1);
});
it('should return sessions without semantic ranking', async () => {
// Sessions are already summarized, no need for semantic ranking
const options: StrategySearchOptions = {
limit: 10
};
const result = await strategy.findByFile('/path/to/file.ts', options);
// Sessions should come directly from metadata search
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0].id).toBe(1);
});
it('should apply semantic ranking only to observations', async () => {
mockChromaSync.queryChroma = mock(() => Promise.resolve({
ids: [2, 1], // Chroma ranking for observations
distances: [0.1, 0.2],
metadatas: []
}));
const options: StrategySearchOptions = {
limit: 10
};
const result = await strategy.findByFile('/path/to/file.ts', options);
// Observations should be in Chroma order
expect(result.observations[0].id).toBe(2);
expect(result.usedChroma).toBe(true);
});
it('should return usedChroma: false when no observations to rank', async () => {
mockSessionSearch.findByFile = mock(() => ({
observations: [],
sessions: [mockSession]
}));
const options: StrategySearchOptions = {
limit: 10
};
const result = await strategy.findByFile('/path/to/file.ts', options);
expect(result.usedChroma).toBe(false);
expect(result.sessions).toHaveLength(1);
});
it('should fall back on Chroma error', async () => {
mockChromaSync.queryChroma = mock(() => Promise.reject(new Error('Chroma down')));
const options: StrategySearchOptions = {
limit: 10
};
const result = await strategy.findByFile('/path/to/file.ts', options);
expect(result.usedChroma).toBe(false);
expect(result.observations.length).toBeGreaterThan(0);
expect(result.sessions).toHaveLength(1);
});
});
describe('strategy name', () => {
it('should have name "hybrid"', () => {
expect(strategy.name).toBe('hybrid');
});
});
});
@@ -0,0 +1,349 @@
import { describe, it, expect, mock, beforeEach } from 'bun:test';
import { SQLiteSearchStrategy } from '../../../../src/services/worker/search/strategies/SQLiteSearchStrategy.js';
import type { StrategySearchOptions, ObservationSearchResult, SessionSummarySearchResult, UserPromptSearchResult } from '../../../../src/services/worker/search/types.js';
// Mock observation data
const mockObservation: ObservationSearchResult = {
id: 1,
memory_session_id: 'session-123',
project: 'test-project',
text: 'Test observation text',
type: 'decision',
title: 'Test Decision',
subtitle: 'A test subtitle',
facts: '["fact1", "fact2"]',
narrative: 'Test narrative',
concepts: '["concept1", "concept2"]',
files_read: '["file1.ts"]',
files_modified: '["file2.ts"]',
prompt_number: 1,
discovery_tokens: 100,
created_at: '2025-01-01T12:00:00.000Z',
created_at_epoch: 1735732800000
};
const mockSession: SessionSummarySearchResult = {
id: 1,
memory_session_id: 'session-123',
project: 'test-project',
request: 'Test request',
investigated: 'Test investigated',
learned: 'Test learned',
completed: 'Test completed',
next_steps: 'Test next steps',
files_read: '["file1.ts"]',
files_edited: '["file2.ts"]',
notes: 'Test notes',
prompt_number: 1,
discovery_tokens: 500,
created_at: '2025-01-01T12:00:00.000Z',
created_at_epoch: 1735732800000
};
const mockPrompt: UserPromptSearchResult = {
id: 1,
content_session_id: 'content-session-123',
prompt_number: 1,
prompt_text: 'Test prompt text',
created_at: '2025-01-01T12:00:00.000Z',
created_at_epoch: 1735732800000
};
describe('SQLiteSearchStrategy', () => {
let strategy: SQLiteSearchStrategy;
let mockSessionSearch: any;
beforeEach(() => {
mockSessionSearch = {
searchObservations: mock(() => [mockObservation]),
searchSessions: mock(() => [mockSession]),
searchUserPrompts: mock(() => [mockPrompt]),
findByConcept: mock(() => [mockObservation]),
findByType: mock(() => [mockObservation]),
findByFile: mock(() => ({ observations: [mockObservation], sessions: [mockSession] }))
};
strategy = new SQLiteSearchStrategy(mockSessionSearch);
});
describe('canHandle', () => {
it('should return true when no query text (filter-only)', () => {
const options: StrategySearchOptions = {
project: 'test-project'
};
expect(strategy.canHandle(options)).toBe(true);
});
it('should return true when query is empty string', () => {
const options: StrategySearchOptions = {
query: '',
project: 'test-project'
};
expect(strategy.canHandle(options)).toBe(true);
});
it('should return false when query text is present', () => {
const options: StrategySearchOptions = {
query: 'semantic search query'
};
expect(strategy.canHandle(options)).toBe(false);
});
it('should return true when strategyHint is sqlite (even with query)', () => {
const options: StrategySearchOptions = {
query: 'semantic search query',
strategyHint: 'sqlite'
};
expect(strategy.canHandle(options)).toBe(true);
});
it('should return true for date range filter only', () => {
const options: StrategySearchOptions = {
dateRange: {
start: '2025-01-01',
end: '2025-01-31'
}
};
expect(strategy.canHandle(options)).toBe(true);
});
});
describe('search', () => {
it('should search all types by default', async () => {
const options: StrategySearchOptions = {
limit: 10
};
const result = await strategy.search(options);
expect(result.usedChroma).toBe(false);
expect(result.fellBack).toBe(false);
expect(result.strategy).toBe('sqlite');
expect(result.results.observations).toHaveLength(1);
expect(result.results.sessions).toHaveLength(1);
expect(result.results.prompts).toHaveLength(1);
expect(mockSessionSearch.searchObservations).toHaveBeenCalled();
expect(mockSessionSearch.searchSessions).toHaveBeenCalled();
expect(mockSessionSearch.searchUserPrompts).toHaveBeenCalled();
});
it('should search only observations when searchType is observations', async () => {
const options: StrategySearchOptions = {
searchType: 'observations',
limit: 10
};
const result = await strategy.search(options);
expect(result.results.observations).toHaveLength(1);
expect(result.results.sessions).toHaveLength(0);
expect(result.results.prompts).toHaveLength(0);
expect(mockSessionSearch.searchObservations).toHaveBeenCalled();
expect(mockSessionSearch.searchSessions).not.toHaveBeenCalled();
expect(mockSessionSearch.searchUserPrompts).not.toHaveBeenCalled();
});
it('should search only sessions when searchType is sessions', async () => {
const options: StrategySearchOptions = {
searchType: 'sessions',
limit: 10
};
const result = await strategy.search(options);
expect(result.results.observations).toHaveLength(0);
expect(result.results.sessions).toHaveLength(1);
expect(result.results.prompts).toHaveLength(0);
});
it('should search only prompts when searchType is prompts', async () => {
const options: StrategySearchOptions = {
searchType: 'prompts',
limit: 10
};
const result = await strategy.search(options);
expect(result.results.observations).toHaveLength(0);
expect(result.results.sessions).toHaveLength(0);
expect(result.results.prompts).toHaveLength(1);
});
it('should pass date range filter to search methods', async () => {
const options: StrategySearchOptions = {
dateRange: {
start: '2025-01-01',
end: '2025-01-31'
},
limit: 10
};
await strategy.search(options);
const callArgs = mockSessionSearch.searchObservations.mock.calls[0];
expect(callArgs[1].dateRange).toEqual({
start: '2025-01-01',
end: '2025-01-31'
});
});
it('should pass project filter to search methods', async () => {
const options: StrategySearchOptions = {
project: 'my-project',
limit: 10
};
await strategy.search(options);
const callArgs = mockSessionSearch.searchObservations.mock.calls[0];
expect(callArgs[1].project).toBe('my-project');
});
it('should pass orderBy to search methods', async () => {
const options: StrategySearchOptions = {
orderBy: 'date_asc',
limit: 10
};
await strategy.search(options);
const callArgs = mockSessionSearch.searchObservations.mock.calls[0];
expect(callArgs[1].orderBy).toBe('date_asc');
});
it('should handle search errors gracefully', async () => {
mockSessionSearch.searchObservations = mock(() => {
throw new Error('Database error');
});
const options: StrategySearchOptions = {
limit: 10
};
const result = await strategy.search(options);
expect(result.results.observations).toHaveLength(0);
expect(result.results.sessions).toHaveLength(0);
expect(result.results.prompts).toHaveLength(0);
expect(result.usedChroma).toBe(false);
});
});
describe('findByConcept', () => {
it('should return matching observations (sync)', () => {
const options: StrategySearchOptions = {
limit: 10
};
const results = strategy.findByConcept('test-concept', options);
expect(results).toHaveLength(1);
expect(results[0].id).toBe(1);
expect(mockSessionSearch.findByConcept).toHaveBeenCalledWith('test-concept', expect.any(Object));
});
it('should pass all filter options to findByConcept', () => {
const options: StrategySearchOptions = {
limit: 20,
project: 'my-project',
dateRange: { start: '2025-01-01' },
orderBy: 'date_desc'
};
strategy.findByConcept('test-concept', options);
expect(mockSessionSearch.findByConcept).toHaveBeenCalledWith('test-concept', {
limit: 20,
project: 'my-project',
dateRange: { start: '2025-01-01' },
orderBy: 'date_desc'
});
});
it('should use default limit when not specified', () => {
const options: StrategySearchOptions = {};
strategy.findByConcept('test-concept', options);
const callArgs = mockSessionSearch.findByConcept.mock.calls[0];
expect(callArgs[1].limit).toBe(20); // SEARCH_CONSTANTS.DEFAULT_LIMIT
});
});
describe('findByType', () => {
it('should return typed observations (sync)', () => {
const options: StrategySearchOptions = {
limit: 10
};
const results = strategy.findByType('decision', options);
expect(results).toHaveLength(1);
expect(results[0].type).toBe('decision');
expect(mockSessionSearch.findByType).toHaveBeenCalledWith('decision', expect.any(Object));
});
it('should handle array of types', () => {
const options: StrategySearchOptions = {
limit: 10
};
strategy.findByType(['decision', 'bugfix'], options);
expect(mockSessionSearch.findByType).toHaveBeenCalledWith(['decision', 'bugfix'], expect.any(Object));
});
it('should pass filter options to findByType', () => {
const options: StrategySearchOptions = {
limit: 15,
project: 'test-project',
orderBy: 'date_asc'
};
strategy.findByType('feature', options);
expect(mockSessionSearch.findByType).toHaveBeenCalledWith('feature', {
limit: 15,
project: 'test-project',
orderBy: 'date_asc'
});
});
});
describe('findByFile', () => {
it('should return observations and sessions for file path', () => {
const options: StrategySearchOptions = {
limit: 10
};
const result = strategy.findByFile('/path/to/file.ts', options);
expect(result.observations).toHaveLength(1);
expect(result.sessions).toHaveLength(1);
expect(mockSessionSearch.findByFile).toHaveBeenCalledWith('/path/to/file.ts', expect.any(Object));
});
it('should pass filter options to findByFile', () => {
const options: StrategySearchOptions = {
limit: 25,
project: 'file-project',
dateRange: { end: '2025-12-31' },
orderBy: 'date_desc'
};
strategy.findByFile('/src/index.ts', options);
expect(mockSessionSearch.findByFile).toHaveBeenCalledWith('/src/index.ts', {
limit: 25,
project: 'file-project',
dateRange: { end: '2025-12-31' },
orderBy: 'date_desc'
});
});
});
describe('strategy name', () => {
it('should have name "sqlite"', () => {
expect(strategy.name).toBe('sqlite');
});
});
});