Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
346 changes: 279 additions & 67 deletions tests/unit/application/handlers/PromptSubmitHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>): IPromptSubmitEvent {
return {
Expand Down Expand Up @@ -36,9 +39,13 @@ function createMemory(overrides?: Partial<IMemoryEntity>): IMemoryEntity {
};
}

function createMockLoader(result: IMemoryContextResult): IMemoryContextLoader {
function createMockLoader(
result: IMemoryContextResult,
queryResult?: IMemoryContextResult,
): IMemoryContextLoader {
return {
load: () => result,
loadWithQuery: () => queryResult ?? result,
};
}

Expand All @@ -48,91 +55,296 @@ function createMockFormatter(output: string): IContextFormatter {
};
}

function createMockConfigLoader(overrides?: Partial<IHookConfig['hooks']['promptSubmit']>): 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');
});
Comment on lines +326 to +348
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider adding test for empty/missing prompt with intent extraction enabled.

The handler at line 62 checks event.prompt truthiness before extraction. When extractIntent: true and intentExtractor is present but event.prompt is empty/undefined, it falls back to load. This path isn't explicitly tested.

📝 Suggested test case
it('should use load when prompt is empty even with extractIntent enabled', 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: true });
  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({ prompt: '' }));

  assert.ok(loadCalled, 'load should be called');
  assert.ok(!extractCalled, 'extract should not be called when prompt is empty');
});
🤖 Prompt for AI Agents
In `@tests/unit/application/handlers/PromptSubmitHandler.test.ts` around lines 326
- 348, Add a unit test covering the branch where configLoader.extractIntent is
true but event.prompt is empty: instantiate PromptSubmitHandler with a mock
loader (track loadCalled), a mock intentExtractor (track extractCalled), and
configLoader with extractIntent: true, then call handler.handle(createEvent({
prompt: '' })) and assert that loader.load was called and
intentExtractor.extract was not; this verifies the handler checks event.prompt
truthiness before calling intentExtractor.extract.

});
});
Loading