/** * Tests for Hook Platform Adapters * * @module @agentkits/memory/hooks/__tests__/adapters.test */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { ClaudeCodeAdapter } from '../adapters/claude-code-adapter.js'; import { OpenCodeAdapter } from '../adapters/opencode-adapter.js'; import { GenericAdapter } from '../adapters/generic-adapter.js'; import { resolveAdapter } from '../adapters/platform-adapter.js'; import type { HookResult } from '../types.js'; describe('Platform Adapters', () => { const originalEnv = { ...process.env }; afterEach(() => { process.env = { ...originalEnv }; vi.restoreAllMocks(); }); // ===== resolveAdapter ===== describe('resolveAdapter', () => { beforeEach(() => { delete process.env.AGENTKITS_PLATFORM; }); it('should default to claude-code when no env var', () => { const adapter = resolveAdapter(); expect(adapter.name).toBe('claude-code'); }); it('should resolve opencode from env var', () => { process.env.AGENTKITS_PLATFORM = 'opencode'; const adapter = resolveAdapter(); expect(adapter.name).toBe('opencode'); }); it('should resolve generic from env var', () => { process.env.AGENTKITS_PLATFORM = 'generic'; const adapter = resolveAdapter(); expect(adapter.name).toBe('generic'); }); it('should resolve claude-code from env var', () => { process.env.AGENTKITS_PLATFORM = 'claude-code'; const adapter = resolveAdapter(); expect(adapter.name).toBe('claude-code'); }); it('should fall back to claude-code for unknown platform', () => { process.env.AGENTKITS_PLATFORM = 'unknown-platform'; const adapter = resolveAdapter(); expect(adapter.name).toBe('claude-code'); }); }); // ===== ClaudeCodeAdapter ===== describe('ClaudeCodeAdapter', () => { const adapter = new ClaudeCodeAdapter(); it('should have name claude-code', () => { expect(adapter.name).toBe('claude-code'); }); it('should support all 5 events', () => { expect(adapter.supportedEvents).toContain('context'); expect(adapter.supportedEvents).toContain('session-init'); expect(adapter.supportedEvents).toContain('observation'); expect(adapter.supportedEvents).toContain('summarize'); expect(adapter.supportedEvents).toContain('user-message'); }); describe('parseInput', () => { it('should parse valid Claude Code JSON', () => { const input = JSON.stringify({ session_id: 'test-session', cwd: '/test/project', prompt: 'Hello world', tool_name: 'Edit', tool_input: { file_path: '/test/file.ts' }, tool_result: 'success', transcript_path: '/test/transcript.jsonl', stop_reason: 'user', }); const result = adapter.parseInput(input); expect(result.sessionId).toBe('test-session'); expect(result.cwd).toBe('/test/project'); expect(result.project).toBe('project'); expect(result.prompt).toBe('Hello world'); expect(result.toolName).toBe('Edit'); expect(result.toolInput).toEqual({ file_path: '/test/file.ts' }); expect(result.toolResponse).toBe('success'); expect(result.transcriptPath).toBe('/test/transcript.jsonl'); expect(result.stopReason).toBe('user'); expect(result.timestamp).toBeGreaterThan(0); }); it('should generate session ID when missing', () => { const result = adapter.parseInput(JSON.stringify({ cwd: '/test' })); expect(result.sessionId).toMatch(/^session_/); }); it('should use process.cwd() when cwd missing', () => { const result = adapter.parseInput(JSON.stringify({ session_id: 'x' })); expect(result.cwd).toBe(process.cwd()); }); it('should handle invalid JSON gracefully', () => { const result = adapter.parseInput('not json'); expect(result.sessionId).toMatch(/^session_/); expect(result.cwd).toBe(process.cwd()); }); it('should handle empty string', () => { const result = adapter.parseInput(''); expect(result.sessionId).toMatch(/^session_/); }); }); describe('formatOutput', () => { it('should format result with additionalContext as SessionStart', () => { const result: HookResult = { continue: true, suppressOutput: false, additionalContext: 'Memory context here', }; const output = JSON.parse(adapter.formatOutput(result)); expect(output.hookSpecificOutput).toBeDefined(); expect(output.hookSpecificOutput.hookEventName).toBe('SessionStart'); expect(output.hookSpecificOutput.additionalContext).toBe('Memory context here'); }); it('should format standard response without context', () => { const result: HookResult = { continue: true, suppressOutput: true, }; const output = JSON.parse(adapter.formatOutput(result)); expect(output.continue).toBe(true); expect(output.suppressOutput).toBe(true); expect(output.hookSpecificOutput).toBeUndefined(); }); }); }); // ===== OpenCodeAdapter ===== describe('OpenCodeAdapter', () => { const adapter = new OpenCodeAdapter(); it('should have name opencode', () => { expect(adapter.name).toBe('opencode'); }); it('should support 4 events (no user-message)', () => { expect(adapter.supportedEvents).toContain('context'); expect(adapter.supportedEvents).toContain('session-init'); expect(adapter.supportedEvents).toContain('observation'); expect(adapter.supportedEvents).toContain('summarize'); expect(adapter.supportedEvents).not.toContain('user-message'); }); describe('parseInput', () => { it('should parse same format as Claude Code', () => { const input = JSON.stringify({ session_id: 'oc-session', cwd: '/test/oc', prompt: 'Test prompt', tool_name: 'Bash', }); const result = adapter.parseInput(input); expect(result.sessionId).toBe('oc-session'); expect(result.cwd).toBe('/test/oc'); expect(result.prompt).toBe('Test prompt'); expect(result.toolName).toBe('Bash'); }); it('should handle invalid JSON', () => { const result = adapter.parseInput('broken'); expect(result.sessionId).toMatch(/^session_/); }); }); describe('formatOutput', () => { it('should include additionalContext at top level', () => { const result: HookResult = { continue: true, suppressOutput: false, additionalContext: 'context text', }; const output = JSON.parse(adapter.formatOutput(result)); expect(output.continue).toBe(true); expect(output.additionalContext).toBe('context text'); // OpenCode doesn't use hookSpecificOutput expect(output.hookSpecificOutput).toBeUndefined(); }); it('should include error when present', () => { const result: HookResult = { continue: true, suppressOutput: true, error: 'Something went wrong', }; const output = JSON.parse(adapter.formatOutput(result)); expect(output.error).toBe('Something went wrong'); }); it('should output minimal JSON without context or error', () => { const result: HookResult = { continue: true, suppressOutput: true, }; const output = JSON.parse(adapter.formatOutput(result)); expect(output.continue).toBe(true); expect(output.additionalContext).toBeUndefined(); expect(output.error).toBeUndefined(); }); }); }); // ===== GenericAdapter ===== describe('GenericAdapter', () => { const adapter = new GenericAdapter(); it('should have name generic', () => { expect(adapter.name).toBe('generic'); }); describe('parseInput', () => { it('should parse camelCase fields', () => { const input = JSON.stringify({ sessionId: 'gen-session', cwd: '/test/gen', prompt: 'Test', toolName: 'Read', toolInput: { file_path: '/x' }, toolResponse: 'content', transcriptPath: '/test/t.jsonl', stopReason: 'done', }); const result = adapter.parseInput(input); expect(result.sessionId).toBe('gen-session'); expect(result.cwd).toBe('/test/gen'); expect(result.prompt).toBe('Test'); expect(result.toolName).toBe('Read'); expect(result.toolInput).toEqual({ file_path: '/x' }); expect(result.toolResponse).toBe('content'); expect(result.transcriptPath).toBe('/test/t.jsonl'); expect(result.stopReason).toBe('done'); }); it('should also accept snake_case fields as fallback', () => { const input = JSON.stringify({ session_id: 'snake-session', cwd: '/test', tool_name: 'Bash', tool_input: { command: 'ls' }, tool_result: 'files', transcript_path: '/t.jsonl', stop_reason: 'end', }); const result = adapter.parseInput(input); expect(result.sessionId).toBe('snake-session'); expect(result.toolName).toBe('Bash'); expect(result.toolInput).toEqual({ command: 'ls' }); expect(result.toolResponse).toBe('files'); expect(result.transcriptPath).toBe('/t.jsonl'); expect(result.stopReason).toBe('end'); }); it('should prefer camelCase over snake_case', () => { const input = JSON.stringify({ sessionId: 'camel', session_id: 'snake', cwd: '/test', }); const result = adapter.parseInput(input); expect(result.sessionId).toBe('camel'); }); it('should accept project field', () => { const input = JSON.stringify({ cwd: '/test/dir', project: 'my-custom-project', }); const result = adapter.parseInput(input); expect(result.project).toBe('my-custom-project'); }); it('should derive project from cwd when not provided', () => { const input = JSON.stringify({ cwd: '/test/my-project' }); const result = adapter.parseInput(input); expect(result.project).toBe('my-project'); }); it('should handle invalid JSON', () => { const result = adapter.parseInput('{}{}'); expect(result.sessionId).toMatch(/^session_/); }); }); describe('formatOutput', () => { it('should format with additionalContext', () => { const result: HookResult = { continue: true, suppressOutput: false, additionalContext: 'some context', }; const output = JSON.parse(adapter.formatOutput(result)); expect(output.continue).toBe(true); expect(output.additionalContext).toBe('some context'); }); it('should format with error', () => { const result: HookResult = { continue: true, suppressOutput: true, error: 'fail', }; const output = JSON.parse(adapter.formatOutput(result)); expect(output.error).toBe('fail'); }); it('should format minimal response', () => { const result: HookResult = { continue: true, suppressOutput: true, }; const output = JSON.parse(adapter.formatOutput(result)); expect(Object.keys(output)).toEqual(['continue']); }); }); }); // ===== Cross-adapter consistency ===== describe('Cross-adapter consistency', () => { const adapters = [ new ClaudeCodeAdapter(), new OpenCodeAdapter(), new GenericAdapter(), ]; it('all adapters should handle empty JSON', () => { for (const adapter of adapters) { const result = adapter.parseInput('{}'); expect(result.sessionId).toBeTruthy(); expect(result.cwd).toBeTruthy(); expect(result.project).toBeTruthy(); expect(result.timestamp).toBeGreaterThan(0); } }); it('all adapters should produce valid JSON output', () => { const hookResult: HookResult = { continue: true, suppressOutput: false, additionalContext: 'test', }; for (const adapter of adapters) { const output = adapter.formatOutput(hookResult); expect(() => JSON.parse(output)).not.toThrow(); } }); it('all adapters should have a name', () => { for (const adapter of adapters) { expect(adapter.name).toBeTruthy(); expect(typeof adapter.name).toBe('string'); } }); it('all adapters should list supported events', () => { for (const adapter of adapters) { expect(adapter.supportedEvents.length).toBeGreaterThan(0); expect(adapter.supportedEvents).toContain('context'); } }); }); });