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
@@ -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');
});
});
});