diff --git a/tests/unit/application/handlers/PromptSubmitHandler.test.ts b/tests/unit/application/handlers/PromptSubmitHandler.test.ts index f5fda992..f16801cd 100644 --- a/tests/unit/application/handlers/PromptSubmitHandler.test.ts +++ b/tests/unit/application/handlers/PromptSubmitHandler.test.ts @@ -9,6 +9,9 @@ import type { IMemoryContextLoader, IMemoryContextResult } from '../../../../src import type { IContextFormatter } from '../../../../src/domain/interfaces/IContextFormatter'; import type { IPromptSubmitEvent } from '../../../../src/domain/events/HookEvents'; import type { IMemoryEntity } from '../../../../src/domain/entities/IMemoryEntity'; +import type { IHookConfigLoader } from '../../../../src/domain/interfaces/IHookConfigLoader'; +import type { IHookConfig } from '../../../../src/domain/interfaces/IHookConfig'; +import type { IIntentExtractor, IIntentExtractorResult } from '../../../../src/domain/interfaces/IIntentExtractor'; function createEvent(overrides?: Partial): IPromptSubmitEvent { return { @@ -36,9 +39,13 @@ function createMemory(overrides?: Partial): IMemoryEntity { }; } -function createMockLoader(result: IMemoryContextResult): IMemoryContextLoader { +function createMockLoader( + result: IMemoryContextResult, + queryResult?: IMemoryContextResult, +): IMemoryContextLoader { return { load: () => result, + loadWithQuery: () => queryResult ?? result, }; } @@ -48,91 +55,296 @@ function createMockFormatter(output: string): IContextFormatter { }; } +function createMockConfigLoader(overrides?: Partial): IHookConfigLoader { + const defaultPromptSubmit = { + enabled: true, + recordPrompts: false, + surfaceContext: true, + extractIntent: false, + intentTimeout: 3000, + minWords: 5, + memoryLimit: 20, + ...overrides, + }; + + return { + loadConfig: (): IHookConfig => ({ + hooks: { + enabled: true, + sessionStart: { enabled: true, memoryLimit: 20 }, + sessionStop: { enabled: true, autoExtract: true, threshold: 3 }, + promptSubmit: defaultPromptSubmit, + postCommit: { enabled: true }, + commitMsg: { + enabled: true, + autoAnalyze: true, + inferTags: true, + requireType: false, + defaultLifecycle: 'project', + enrich: true, + enrichTimeout: 8000, + }, + }, + }), + }; +} + +function createMockIntentExtractor(result: IIntentExtractorResult): IIntentExtractor { + return { + extract: async () => result, + }; +} + describe('PromptSubmitHandler', () => { - it('should return success with formatted output when memories exist', async () => { - const memories = [createMemory()]; - const loader = createMockLoader({ memories, total: 1, filtered: 1 }); - const formatter = createMockFormatter('# Context'); - const handler = new PromptSubmitHandler(loader, formatter); + describe('basic functionality', () => { + it('should return success with formatted output when memories exist', async () => { + const memories = [createMemory()]; + const loader = createMockLoader({ memories, total: 1, filtered: 1 }); + const formatter = createMockFormatter('# Context'); + const handler = new PromptSubmitHandler(loader, formatter); - const result = await handler.handle(createEvent()); + const result = await handler.handle(createEvent()); - assert.equal(result.success, true); - assert.equal(result.handler, 'PromptSubmitHandler'); - assert.equal(result.output, '# Context'); - }); + assert.equal(result.success, true); + assert.equal(result.handler, 'PromptSubmitHandler'); + assert.equal(result.output, '# Context'); + }); - it('should return success with empty output when no memories', async () => { - const loader = createMockLoader({ memories: [], total: 0, filtered: 0 }); - let formatterCalled = false; - const formatter: IContextFormatter = { - format: () => { formatterCalled = true; return ''; }, - }; - const handler = new PromptSubmitHandler(loader, formatter); + it('should return success with empty output when no memories', async () => { + const loader = createMockLoader({ memories: [], total: 0, filtered: 0 }); + let formatterCalled = false; + const formatter: IContextFormatter = { + format: () => { formatterCalled = true; return ''; }, + }; + const handler = new PromptSubmitHandler(loader, formatter); - const result = await handler.handle(createEvent()); + const result = await handler.handle(createEvent()); - assert.equal(result.success, true); - assert.equal(result.output, ''); - assert.ok(!formatterCalled, 'formatter should not be called when no memories'); - }); + assert.equal(result.success, true); + assert.equal(result.output, ''); + assert.ok(!formatterCalled, 'formatter should not be called when no memories'); + }); - it('should handle non-Error throws', async () => { - const loader: IMemoryContextLoader = { - load: () => { throw 'string error'; }, - }; - const formatter = createMockFormatter(''); - const handler = new PromptSubmitHandler(loader, formatter); + it('should handle non-Error throws', async () => { + const loader: IMemoryContextLoader = { + load: () => { throw 'string error'; }, + loadWithQuery: () => { throw 'string error'; }, + }; + const formatter = createMockFormatter(''); + const handler = new PromptSubmitHandler(loader, formatter); - const result = await handler.handle(createEvent()); + const result = await handler.handle(createEvent()); - assert.equal(result.success, false); - assert.ok(result.error instanceof Error); - assert.equal(result.error!.message, 'string error'); - }); + assert.equal(result.success, false); + assert.ok(result.error instanceof Error); + assert.equal(result.error!.message, 'string error'); + }); - it('should pass event cwd to loader', async () => { - let capturedCwd: string | undefined; - const loader: IMemoryContextLoader = { - load: (options) => { - capturedCwd = options?.cwd; - return { memories: [], total: 0, filtered: 0 }; - }, - }; - const formatter = createMockFormatter(''); - const handler = new PromptSubmitHandler(loader, formatter); + it('should pass event cwd to loader', async () => { + let capturedCwd: string | undefined; + const loader: IMemoryContextLoader = { + load: (options) => { + capturedCwd = options?.cwd; + return { memories: [], total: 0, filtered: 0 }; + }, + loadWithQuery: () => ({ memories: [], total: 0, filtered: 0 }), + }; + const formatter = createMockFormatter(''); + const handler = new PromptSubmitHandler(loader, formatter); + + await handler.handle(createEvent({ cwd: '/my/repo' })); - await handler.handle(createEvent({ cwd: '/my/repo' })); + assert.equal(capturedCwd, '/my/repo'); + }); - assert.equal(capturedCwd, '/my/repo'); + it('should return failure result when loader throws', async () => { + const loader: IMemoryContextLoader = { + load: () => { throw new Error('load failed'); }, + loadWithQuery: () => { throw new Error('load failed'); }, + }; + const formatter = createMockFormatter(''); + const handler = new PromptSubmitHandler(loader, formatter); + + const result = await handler.handle(createEvent()); + + assert.equal(result.success, false); + assert.equal(result.handler, 'PromptSubmitHandler'); + assert.ok(result.error instanceof Error); + assert.equal(result.error!.message, 'load failed'); + }); + + it('should return failure result when formatter throws', async () => { + const memories = [createMemory()]; + const loader = createMockLoader({ memories, total: 1, filtered: 1 }); + const formatter: IContextFormatter = { + format: () => { throw new Error('format failed'); }, + }; + const handler = new PromptSubmitHandler(loader, formatter); + + const result = await handler.handle(createEvent()); + + assert.equal(result.success, false); + assert.equal(result.error!.message, 'format failed'); + }); }); - it('should return failure result when loader throws', async () => { - const loader: IMemoryContextLoader = { - load: () => { throw new Error('load failed'); }, - }; - const formatter = createMockFormatter(''); - const handler = new PromptSubmitHandler(loader, formatter); + describe('config handling', () => { + it('should return empty output when surfaceContext is disabled', async () => { + const memories = [createMemory()]; + const loader = createMockLoader({ memories, total: 1, filtered: 1 }); + const formatter = createMockFormatter('# Context'); + const configLoader = createMockConfigLoader({ surfaceContext: false }); + const handler = new PromptSubmitHandler(loader, formatter, undefined, configLoader); - const result = await handler.handle(createEvent()); + const result = await handler.handle(createEvent()); - assert.equal(result.success, false); - assert.equal(result.handler, 'PromptSubmitHandler'); - assert.ok(result.error instanceof Error); - assert.equal(result.error!.message, 'load failed'); + assert.equal(result.success, true); + assert.equal(result.output, ''); + }); + + it('should respect memoryLimit from config', async () => { + let capturedLimit: number | undefined; + const loader: IMemoryContextLoader = { + load: (options) => { + capturedLimit = options?.limit; + return { memories: [], total: 0, filtered: 0 }; + }, + loadWithQuery: () => ({ memories: [], total: 0, filtered: 0 }), + }; + const formatter = createMockFormatter(''); + const configLoader = createMockConfigLoader({ memoryLimit: 10 }); + const handler = new PromptSubmitHandler(loader, formatter, undefined, configLoader); + + await handler.handle(createEvent()); + + assert.equal(capturedLimit, 10); + }); }); - it('should return failure result when formatter throws', async () => { - const memories = [createMemory()]; - const loader = createMockLoader({ memories, total: 1, filtered: 1 }); - const formatter: IContextFormatter = { - format: () => { throw new Error('format failed'); }, - }; - const handler = new PromptSubmitHandler(loader, formatter); + describe('intent extraction', () => { + it('should use loadWithQuery when intent is extracted', async () => { + let loadWithQueryCalled = false; + let capturedQuery: string | undefined; + const memories = [createMemory({ content: 'Authentication decision' })]; + const loader: IMemoryContextLoader = { + load: () => ({ memories: [], total: 0, filtered: 0 }), + loadWithQuery: (query) => { + loadWithQueryCalled = true; + capturedQuery = query; + return { memories, total: 1, filtered: 1 }; + }, + }; + const formatter = createMockFormatter('# Context'); + const configLoader = createMockConfigLoader({ extractIntent: true }); + const intentExtractor = createMockIntentExtractor({ + intent: 'authentication, LoginHandler', + skipped: false, + }); + const handler = new PromptSubmitHandler( + loader, + formatter, + undefined, + configLoader, + intentExtractor, + ); + + const result = await handler.handle(createEvent({ + prompt: 'Fix the authentication bug in LoginHandler', + })); + + assert.equal(result.success, true); + assert.ok(loadWithQueryCalled, 'loadWithQuery should be called'); + assert.equal(capturedQuery, 'authentication, LoginHandler'); + }); + + it('should fall back to load when intent is skipped', async () => { + let loadCalled = false; + let loadWithQueryCalled = false; + const loader: IMemoryContextLoader = { + load: () => { + loadCalled = true; + return { memories: [], total: 0, filtered: 0 }; + }, + loadWithQuery: () => { + loadWithQueryCalled = true; + return { memories: [], total: 0, filtered: 0 }; + }, + }; + const formatter = createMockFormatter(''); + const configLoader = createMockConfigLoader({ extractIntent: true }); + const intentExtractor = createMockIntentExtractor({ + intent: null, + skipped: true, + reason: 'too_short', + }); + const handler = new PromptSubmitHandler( + loader, + formatter, + undefined, + configLoader, + intentExtractor, + ); + + await handler.handle(createEvent({ prompt: 'yes' })); + + assert.ok(loadCalled, 'load should be called'); + assert.ok(!loadWithQueryCalled, 'loadWithQuery should not be called'); + }); + + it('should use load when extractIntent is disabled', async () => { + let loadCalled = false; + let extractCalled = false; + const loader: IMemoryContextLoader = { + load: () => { + loadCalled = true; + return { memories: [], total: 0, filtered: 0 }; + }, + loadWithQuery: () => ({ memories: [], total: 0, filtered: 0 }), + }; + const formatter = createMockFormatter(''); + const configLoader = createMockConfigLoader({ extractIntent: false }); + const intentExtractor: IIntentExtractor = { + extract: async () => { + extractCalled = true; + return { intent: 'keywords', skipped: false }; + }, + }; + const handler = new PromptSubmitHandler( + loader, + formatter, + undefined, + configLoader, + intentExtractor, + ); + + await handler.handle(createEvent()); + + assert.ok(loadCalled, 'load should be called'); + assert.ok(!extractCalled, 'extract should not be called when disabled'); + }); + + it('should use load when intentExtractor is null', async () => { + let loadCalled = false; + const loader: IMemoryContextLoader = { + load: () => { + loadCalled = true; + return { memories: [], total: 0, filtered: 0 }; + }, + loadWithQuery: () => ({ memories: [], total: 0, filtered: 0 }), + }; + const formatter = createMockFormatter(''); + const configLoader = createMockConfigLoader({ extractIntent: true }); + const handler = new PromptSubmitHandler( + loader, + formatter, + undefined, + configLoader, + null, // No intent extractor available + ); - const result = await handler.handle(createEvent()); + await handler.handle(createEvent()); - assert.equal(result.success, false); - assert.equal(result.error!.message, 'format failed'); + assert.ok(loadCalled, 'load should be called'); + }); }); }); diff --git a/tests/unit/infrastructure/llm/IntentExtractor.test.ts b/tests/unit/infrastructure/llm/IntentExtractor.test.ts new file mode 100644 index 00000000..14ff3a7e --- /dev/null +++ b/tests/unit/infrastructure/llm/IntentExtractor.test.ts @@ -0,0 +1,140 @@ +/** + * IntentExtractor unit tests + * + * Tests for keyword extraction from user prompts. + * LLM calls are not mocked since we test filtering logic separately. + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +// Test the confirmation pattern matching logic directly +describe('IntentExtractor', () => { + // The CONFIRMATION_PATTERN from IntentExtractor.ts + const CONFIRMATION_PATTERN = /^(yes|no|ok|okay|go|sure|proceed|continue|done|y|n|yep|nope|thanks|thank you|\d+)$/i; + + describe('confirmation pattern', () => { + it('should match "yes"', () => { + assert.ok(CONFIRMATION_PATTERN.test('yes')); + }); + + it('should match "no"', () => { + assert.ok(CONFIRMATION_PATTERN.test('no')); + }); + + it('should match "ok"', () => { + assert.ok(CONFIRMATION_PATTERN.test('ok')); + }); + + it('should match "okay"', () => { + assert.ok(CONFIRMATION_PATTERN.test('okay')); + }); + + it('should match "go"', () => { + assert.ok(CONFIRMATION_PATTERN.test('go')); + }); + + it('should match "sure"', () => { + assert.ok(CONFIRMATION_PATTERN.test('sure')); + }); + + it('should match "proceed"', () => { + assert.ok(CONFIRMATION_PATTERN.test('proceed')); + }); + + it('should match "continue"', () => { + assert.ok(CONFIRMATION_PATTERN.test('continue')); + }); + + it('should match "done"', () => { + assert.ok(CONFIRMATION_PATTERN.test('done')); + }); + + it('should match "y"', () => { + assert.ok(CONFIRMATION_PATTERN.test('y')); + }); + + it('should match "n"', () => { + assert.ok(CONFIRMATION_PATTERN.test('n')); + }); + + it('should match "yep"', () => { + assert.ok(CONFIRMATION_PATTERN.test('yep')); + }); + + it('should match "nope"', () => { + assert.ok(CONFIRMATION_PATTERN.test('nope')); + }); + + it('should match "thanks"', () => { + assert.ok(CONFIRMATION_PATTERN.test('thanks')); + }); + + it('should match "thank you"', () => { + assert.ok(CONFIRMATION_PATTERN.test('thank you')); + }); + + it('should match single digit', () => { + assert.ok(CONFIRMATION_PATTERN.test('1')); + }); + + it('should match multi-digit number', () => { + assert.ok(CONFIRMATION_PATTERN.test('42')); + }); + + it('should be case-insensitive', () => { + assert.ok(CONFIRMATION_PATTERN.test('YES')); + assert.ok(CONFIRMATION_PATTERN.test('Yes')); + assert.ok(CONFIRMATION_PATTERN.test('NO')); + assert.ok(CONFIRMATION_PATTERN.test('OK')); + }); + + it('should NOT match substantive prompts', () => { + assert.ok(!CONFIRMATION_PATTERN.test('fix the bug')); + assert.ok(!CONFIRMATION_PATTERN.test('yes please fix it')); + assert.ok(!CONFIRMATION_PATTERN.test('start on GIT-95')); + assert.ok(!CONFIRMATION_PATTERN.test('implement authentication')); + }); + + it('should NOT match partial matches', () => { + assert.ok(!CONFIRMATION_PATTERN.test('yes please')); + assert.ok(!CONFIRMATION_PATTERN.test('okay then')); + assert.ok(!CONFIRMATION_PATTERN.test('continue with')); + }); + }); + + describe('word count logic', () => { + function countWords(prompt: string): number { + return prompt.trim().split(/\s+/).filter(w => w.length > 0).length; + } + + it('should count words correctly', () => { + assert.equal(countWords('hello world'), 2); + assert.equal(countWords('fix the authentication bug'), 4); + assert.equal(countWords('implement user authentication for the API'), 6); + }); + + it('should handle multiple spaces', () => { + assert.equal(countWords('hello world'), 2); + assert.equal(countWords(' fix the bug '), 3); + }); + + it('should handle empty string', () => { + assert.equal(countWords(''), 0); + assert.equal(countWords(' '), 0); + }); + + it('should skip prompts with fewer than 5 words', () => { + const minWords = 5; + assert.ok(countWords('yes') < minWords); + assert.ok(countWords('fix the bug') < minWords); + assert.ok(countWords('implement authentication') < minWords); + }); + + it('should process prompts with 5 or more words', () => { + const minWords = 5; + assert.ok(countWords('fix the authentication bug please') >= minWords); + assert.ok(countWords('implement user authentication for the API') >= minWords); + }); + }); +});