diff --git a/package.json b/package.json index 583d69ae..7d8e6f43 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,9 @@ "predev": "node scripts/bootstrap-pilotdeck-config.mjs", "dev": "node scripts/dev-launcher.mjs", "test": "npm run build && node --test --test-force-exit --test-timeout 60000 \"dist/tests/**/*.test.js\"", + "test:vitest": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", "e2e:real-agent-lifecycle-hooks": "npm run build && PILOTDECK_RUN_REAL_AGENT_LIFECYCLE_E2E=1 node dist/tests/agent/e2e/run-real-agent-lifecycle-hooks.js" }, "devDependencies": { @@ -26,7 +29,8 @@ "@types/ws": "^8.18.1", "ink-testing-library": "^4.0.0", "tsx": "^4.21.0", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^4.1.7" }, "dependencies": { "@larksuiteoapi/node-sdk": "^1.65.0", diff --git a/tests/adapters/channel/ChannelAdapter.test.ts b/tests/adapters/channel/ChannelAdapter.test.ts new file mode 100644 index 00000000..df1ab14b --- /dev/null +++ b/tests/adapters/channel/ChannelAdapter.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest'; +import type { ChannelAdapter, ChannelStartDeps } from '../../../src/adapters/channel/protocol/ChannelAdapter.js'; +import type { GatewayChannelKey } from '../../../src/gateway/index.js'; + +describe('ChannelAdapter interface contract', () => { + it('defines valid channelKey property', () => { + // ChannelAdapter is an interface; verify the type contract + // by checking the GatewayChannelKey type is a string + const adapter: ChannelAdapter = { + channelKey: 'test' as GatewayChannelKey, + start: async (_deps: ChannelStartDeps) => ({ + stop: async (_reason?: string) => {}, + }), + }; + + expect(adapter.channelKey).toBe('test'); + expect(typeof adapter.start).toBe('function'); + }); + + it('ChannelStartDeps config is optional', () => { + const adapter: ChannelAdapter = { + channelKey: 'cli' as GatewayChannelKey, + start: async (deps: ChannelStartDeps) => ({ + stop: async () => {}, + }), + }; + + expect(adapter.start).toBeInstanceOf(Function); + }); +}); + +describe('ChannelHandle contract', () => { + it('stop can be called with or without reason', async () => { + const adapter: ChannelAdapter = { + channelKey: 'test' as GatewayChannelKey, + start: async (_deps: ChannelStartDeps) => { + const handle = { + stop: async (reason?: string) => { + if (reason) { + // cleanup with reason + } + }, + }; + return handle; + }, + }; + + const handle = await adapter.start({ + gateway: {} as any, + }); + + await handle.stop(); + await handle.stop('shutdown'); + }); +}); diff --git a/tests/adapters/channel/WebhookSessionMapper.test.ts b/tests/adapters/channel/WebhookSessionMapper.test.ts new file mode 100644 index 00000000..c232be49 --- /dev/null +++ b/tests/adapters/channel/WebhookSessionMapper.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from 'vitest'; +import { WebhookSessionMapper } from '../../../src/adapters/channel/webhook/WebhookSessionMapper.js'; + +describe('WebhookSessionMapper', () => { + it('creates a general session for first-time chats', () => { + const mapper = new WebhookSessionMapper( + { activeByChatId: {} }, + () => 'fixed-uuid', + ); + const result = mapper.resolve({ + chatId: 'chat_001', + text: 'Hello', + }); + expect(result.sessionKey).toBe('webhook:chat=chat_001:general'); + expect(result.message).toBe('Hello'); + expect(result.command).toBeUndefined(); + }); + + it('creates a new session on /new command', () => { + const mapper = new WebhookSessionMapper( + { activeByChatId: {} }, + () => 'new-session-uuid', + ); + const result = mapper.resolve({ + chatId: 'chat_002', + text: '/new', + }); + expect(result.sessionKey).toBe('webhook:chat=chat_002:s_new-session-uuid'); + expect(result.command).toBe('new'); + expect(result.message).toBe(''); + }); + + it('creates a new session with message on /new ', () => { + const mapper = new WebhookSessionMapper( + { activeByChatId: {} }, + () => 'session-abc', + ); + const result = mapper.resolve({ + chatId: 'chat_003', + text: '/new initialize project', + }); + expect(result.sessionKey).toBe('webhook:chat=chat_003:s_session-abc'); + expect(result.command).toBe('new'); + expect(result.message).toBe('initialize project'); + }); + + it('reuses an existing active session', () => { + const mapper = new WebhookSessionMapper( + { activeByChatId: { chat_004: 'existing-session-key' } }, + ); + const result = mapper.resolve({ + chatId: 'chat_004', + text: 'Second message', + }); + expect(result.sessionKey).toBe('existing-session-key'); + expect(result.message).toBe('Second message'); + }); + + it('trims whitespace from messages', () => { + const mapper = new WebhookSessionMapper(); + const result = mapper.resolve({ + chatId: 'chat_005', + text: ' Hello World ', + }); + expect(result.message).toBe('Hello World'); + }); + + it('snapshot returns copy of state', () => { + const mapper = new WebhookSessionMapper( + { activeByChatId: { chat_a: 'session_a' } }, + ); + const snapshot = mapper.snapshot(); + expect(snapshot.activeByChatId).toEqual({ chat_a: 'session_a' }); + // Mutating snapshot should not affect original + snapshot.activeByChatId.chat_b = 'session_b'; + expect(mapper.snapshot().activeByChatId.chat_b).toBeUndefined(); + }); +}); diff --git a/tests/agent/loop/collectToolCalls.test.ts b/tests/agent/loop/collectToolCalls.test.ts new file mode 100644 index 00000000..7a7ca3a3 --- /dev/null +++ b/tests/agent/loop/collectToolCalls.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from 'vitest'; +import { collectToolCalls } from '../../../src/agent/loop/collectToolCalls.js'; +import type { CanonicalMessage } from '../../../src/model/index.js'; + +describe('collectToolCalls', () => { + it('extracts tool call blocks from an assistant message', () => { + const message: CanonicalMessage = { + role: 'assistant', + content: [ + { type: 'text', text: 'Let me search for that.' }, + { + type: 'tool_call', + id: 'call_0001', + name: 'web_search', + input: { query: 'weather in Tokyo' }, + }, + { + type: 'tool_call', + id: 'call_0002', + name: 'read_file', + input: { path: '/tmp/test.txt' }, + }, + ], + }; + + const calls = collectToolCalls(message); + expect(calls).toHaveLength(2); + expect(calls[0]!.id).toBe('call_0001'); + expect(calls[0]!.name).toBe('web_search'); + expect(calls[1]!.id).toBe('call_0002'); + expect(calls[1]!.name).toBe('read_file'); + }); + + it('returns empty array for messages with no tool calls', () => { + const message: CanonicalMessage = { + role: 'assistant', + content: [ + { type: 'text', text: 'Hello!' }, + ], + }; + + const calls = collectToolCalls(message); + expect(calls).toHaveLength(0); + }); + + it('returns empty array for empty content', () => { + const message: CanonicalMessage = { + role: 'assistant', + content: [], + }; + + const calls = collectToolCalls(message); + expect(calls).toHaveLength(0); + }); + + it('handles tool calls with complex input objects', () => { + const message: CanonicalMessage = { + role: 'assistant', + content: [ + { + type: 'tool_call', + id: 'call_0003', + name: 'edit_file', + input: { + path: '/tmp/file.txt', + operations: [ + { type: 'insert', position: 5, text: 'new content' }, + ], + }, + }, + ], + }; + + const calls = collectToolCalls(message); + expect(calls).toHaveLength(1); + expect(calls[0]!.name).toBe('edit_file'); + expect(calls[0]!.input).toEqual({ + path: '/tmp/file.txt', + operations: [{ type: 'insert', position: 5, text: 'new content' }], + }); + }); +}); diff --git a/tests/agent/loop/ensureToolResultPairing.test.ts b/tests/agent/loop/ensureToolResultPairing.test.ts new file mode 100644 index 00000000..c266a5ac --- /dev/null +++ b/tests/agent/loop/ensureToolResultPairing.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from 'vitest'; +import { + ensureToolResultPairing, + createMissingToolResult, +} from '../../../src/agent/loop/ensureToolResultPairing.js'; +import type { CanonicalToolCall } from '../../../src/model/index.js'; + +describe('ensureToolResultPairing', () => { + const makeToolCall = (overrides: Partial = {}): CanonicalToolCall => ({ + id: 'call_1234', + name: 'test_tool', + input: { query: 'hello' }, + ...overrides, + }); + + it('pairs successful tool calls with results', () => { + const calls = [makeToolCall({ id: 'call_0001' })]; + const results = [{ + type: 'success' as const, + toolCallId: 'call_0001', + toolName: 'test_tool', + data: 'hello world', + }]; + + const paired = ensureToolResultPairing(calls, results, () => new Date()); + expect(paired).toHaveLength(1); + expect(paired[0]!.type).toBe('success'); + expect(paired[0]!.toolCallId).toBe('call_0001'); + }); + + it('pairs error results with tool calls', () => { + const calls = [makeToolCall({ id: 'call_0002' })]; + const results = [{ + type: 'error' as const, + toolCallId: 'call_0002', + toolName: 'test_tool', + error: { code: 'runtime_error', message: 'Something went wrong' }, + isError: true, + }]; + + const paired = ensureToolResultPairing(calls, results, () => new Date()); + expect(paired).toHaveLength(1); + expect(paired[0]!.type).toBe('error'); + expect(paired[0]!.toolCallId).toBe('call_0002'); + }); + + it('creates missing results for calls without corresponding results', () => { + const calls = [ + makeToolCall({ id: 'call_0003' }), + makeToolCall({ id: 'call_0004' }), + ]; + const results = [{ + type: 'success' as const, + toolCallId: 'call_0003', + toolName: 'test_tool', + data: 'result', + }]; + + const paired = ensureToolResultPairing(calls, results, () => new Date()); + expect(paired).toHaveLength(2); + expect(paired[0]!.toolCallId).toBe('call_0003'); + expect(paired[1]!.toolCallId).toBe('call_0004'); + expect(paired[1]!.type).toBe('error'); + }); +}); + +describe('createMissingToolResult', () => { + it('creates an error result for a missing tool call', () => { + const toolCall: CanonicalToolCall = { + id: 'call_0001', + name: 'test_tool', + input: { query: 'test' }, + }; + const now = () => new Date('2025-01-01T00:00:00Z'); + const result = createMissingToolResult(toolCall, now, 'Execution failed'); + expect(result.type).toBe('error'); + expect(result.toolCallId).toBe('call_0001'); + expect(result.toolName).toBe('test_tool'); + expect(result.error.code).toBe('runtime_error'); + expect(result.error.message).toBe('Execution failed'); + }); +}); diff --git a/tests/agent/protocol/errors.test.ts b/tests/agent/protocol/errors.test.ts new file mode 100644 index 00000000..01da0a74 --- /dev/null +++ b/tests/agent/protocol/errors.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from 'vitest'; +import { + agentError, + AgentRuntimeError, + normalizeAgentError, +} from '../../../src/agent/protocol/errors.js'; + +describe('Agent Protocol Errors', () => { + describe('agentError', () => { + it('creates an AgentError with code and message', () => { + const err = agentError('agent_max_turns_reached', 'Reached max turns'); + expect(err.code).toBe('agent_max_turns_reached'); + expect(err.message).toBe('Reached max turns'); + }); + + it('includes optional details', () => { + const details = { turns: 10 }; + const err = agentError('agent_invalid_state', 'Invalid state', details); + expect(err.details).toBe(details); + }); + + it('handles all known error codes', () => { + const codes = [ + 'agent_aborted', + 'agent_max_turns_reached', + 'agent_model_error', + 'agent_model_capability_error', + 'agent_prompt_too_long', + 'agent_context_recovery_failed', + 'agent_tool_result_pairing_failed', + 'agent_transcript_error', + 'agent_invalid_state', + 'agent_unsupported_feature', + 'agent_tool_error_loop', + ] as const; + + for (const code of codes) { + const err = agentError(code, `Test ${code}`); + expect(err.code).toBe(code); + expect(typeof err.message).toBe('string'); + } + }); + }); + + describe('AgentRuntimeError', () => { + it('extends Error with code and details', () => { + const err = new AgentRuntimeError( + 'agent_aborted', + 'Session was aborted', + { reason: 'user_cancelled' }, + ); + expect(err).toBeInstanceOf(Error); + expect(err).toBeInstanceOf(AgentRuntimeError); + expect(err.name).toBe('AgentRuntimeError'); + expect(err.code).toBe('agent_aborted'); + expect(err.message).toBe('Session was aborted'); + expect(err.details).toEqual({ reason: 'user_cancelled' }); + }); + }); + + describe('normalizeAgentError', () => { + it('preserves AgentRuntimeError codes', () => { + const original = new AgentRuntimeError('agent_max_turns_reached', 'Too many turns'); + const normalized = normalizeAgentError(original); + expect(normalized.code).toBe('agent_max_turns_reached'); + expect(normalized.message).toBe('Too many turns'); + }); + + it('wraps generic Error as agent_invalid_state', () => { + const normalized = normalizeAgentError(new Error('Something broke')); + expect(normalized.code).toBe('agent_invalid_state'); + expect(normalized.message).toBe('Something broke'); + }); + + it('converts non-Error values to agent_invalid_state string', () => { + const normalized = normalizeAgentError('just a string'); + expect(normalized.code).toBe('agent_invalid_state'); + expect(normalized.message).toBe('just a string'); + }); + + it('converts null to agent_invalid_state', () => { + const normalized = normalizeAgentError(null); + expect(normalized.code).toBe('agent_invalid_state'); + expect(normalized.message).toBe('null'); + }); + }); +}); diff --git a/tests/agent/turn/TurnInputProcessor.test.ts b/tests/agent/turn/TurnInputProcessor.test.ts new file mode 100644 index 00000000..36d3ef6c --- /dev/null +++ b/tests/agent/turn/TurnInputProcessor.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import { TurnInputProcessor } from '../../../src/agent/turn/TurnInputProcessor.js'; + +describe('TurnInputProcessor', () => { + const processor = new TurnInputProcessor(); + + it('processes text input', () => { + const result = processor.accept({ + type: 'text', + text: 'Hello agent!', + }); + expect(result.shouldCallModel).toBe(true); + expect(result.messages).toHaveLength(1); + expect(result.messages[0]!.role).toBe('user'); + expect(result.messages[0]!.content).toEqual([ + { type: 'text', text: 'Hello agent!' }, + ]); + }); + + it('processes content input', () => { + const result = processor.accept({ + type: 'content', + content: [ + { type: 'text', text: 'Analyze this file' }, + ], + }); + expect(result.shouldCallModel).toBe(true); + expect(result.messages).toHaveLength(1); + expect(result.messages[0]!.content).toHaveLength(1); + }); + + it('processes content input with multiple blocks', () => { + const result = processor.accept({ + type: 'content', + content: [ + { type: 'text', text: 'What is in this image?' }, + { type: 'image', source: 'base64', data: 'AAAA', mimeType: 'image/png' }, + ], + }); + expect(result.shouldCallModel).toBe(true); + expect(result.messages[0]!.content).toHaveLength(2); + expect(result.messages[0]!.content[1]!.type).toBe('image'); + }); +}); diff --git a/tests/model/errors/normalizeModelError.test.ts b/tests/model/errors/normalizeModelError.test.ts new file mode 100644 index 00000000..b78d4de0 --- /dev/null +++ b/tests/model/errors/normalizeModelError.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect } from 'vitest'; +import { normalizeModelError } from '../../../src/model/errors/normalizeModelError.js'; + +describe('normalizeModelError', () => { + describe('semantic error classification', () => { + it('classifies Anthropic "prompt is too long" as prompt_too_long', () => { + const result = normalizeModelError('anthropic', 'anthropic', { + message: 'This prompt is too long. Please shorten it.', + }); + expect(result.code).toBe('prompt_too_long'); + expect(result.retryable).toBe(false); + expect(result.recoverableViaCompact).toBe(true); + }); + + it('classifies OpenAI "input length and max_tokens exceed context limit" as prompt_too_long', () => { + const result = normalizeModelError('openai', 'openai', { + message: "This model's maximum context length is 128000 tokens. Your input length and max_tokens exceed context limit.", + }); + expect(result.code).toBe('prompt_too_long'); + expect(result.recoverableViaCompact).toBe(true); + }); + + it('classifies "request too large" errors', () => { + const result = normalizeModelError('openai', 'openai', { + message: 'Request too large: the total request size exceeds the limit.', + }); + expect(result.code).toBe('request_too_large'); + }); + + it('classifies max output reached errors', () => { + const result = normalizeModelError('anthropic', 'anthropic', { + message: 'Maximum output tokens exceeded.', + }); + expect(result.code).toBe('max_output_reached'); + }); + + it('classifies max completion tokens reached errors', () => { + const result = normalizeModelError('openai', 'openai', { + message: 'The max completion tokens have been reached.', + }); + expect(result.code).toBe('max_output_reached'); + }); + }); + + describe('status code classification', () => { + it('classifies 401 as auth_error', () => { + const result = normalizeModelError('openai', 'openai', {}, 401); + expect(result.code).toBe('auth_error'); + expect(result.retryable).toBe(false); + }); + + it('classifies 403 as auth_error', () => { + const result = normalizeModelError('anthropic', 'anthropic', {}, 403); + expect(result.code).toBe('auth_error'); + }); + + it('classifies 429 as rate_limit_error (retryable)', () => { + const result = normalizeModelError('openai', 'openai', {}, 429); + expect(result.code).toBe('rate_limit_error'); + expect(result.retryable).toBe(true); + }); + + it('classifies 408 as retryable', () => { + const result = normalizeModelError('anthropic', 'anthropic', {}, 408); + expect(result.code).toBe('provider_error'); + expect(result.retryable).toBe(true); + }); + + it('classifies 500+ as server_error (retryable)', () => { + const result = normalizeModelError('openai', 'openai', {}, 502); + expect(result.code).toBe('server_error'); + expect(result.retryable).toBe(true); + }); + + it('classifies 413 as request_too_large', () => { + const result = normalizeModelError('anthropic', 'anthropic', {}, 413); + expect(result.code).toBe('request_too_large'); + }); + }); + + describe('error message extraction', () => { + it('extracts message from Error instance', () => { + const result = normalizeModelError('openai', 'openai', new Error('Connection timeout')); + expect(result.message).toBe('Connection timeout'); + }); + + it('extracts nested error.message', () => { + const result = normalizeModelError('openai', 'openai', { + error: { message: 'Rate limit exceeded' }, + }); + expect(result.message).toBe('Rate limit exceeded'); + }); + + it('falls back to default message when no message found', () => { + const result = normalizeModelError('openai', 'openai', 42); + expect(result.message).toBe('Model provider request failed.'); + }); + + it('extracts code from nested error', () => { + const result = normalizeModelError('openai', 'openai', { + error: { code: 'rate_limit_error', message: 'Too fast' }, + }); + expect(result.code).toBe('rate_limit_error'); + }); + + it('extracts type as fallback code', () => { + const result = normalizeModelError('anthropic', 'anthropic', { + type: 'overloaded_error', + message: 'Server overloaded', + }); + expect(result.code).toBe('overloaded_error'); + }); + }); + + describe('retryable error via code', () => { + it('marks rate_limit_error as retryable', () => { + const result = normalizeModelError('openai', 'openai', { + error: { code: 'rate_limit_error', message: 'Too many requests' }, + }); + expect(result.retryable).toBe(true); + }); + + it('marks overloaded_error as retryable', () => { + const result = normalizeModelError('anthropic', 'anthropic', { + error: { code: 'overloaded_error', message: 'Overloaded' }, + }); + expect(result.retryable).toBe(true); + }); + + it('marks timeout as retryable', () => { + const result = normalizeModelError('openai', 'openai', { + error: { code: 'timeout', message: 'Request timed out' }, + }); + expect(result.retryable).toBe(true); + }); + + it('marks server_error as retryable', () => { + const result = normalizeModelError('openai', 'openai', { + error: { code: 'server_error', message: 'Internal error' }, + }); + expect(result.retryable).toBe(true); + }); + }); + + describe('multimodal processor recovery', () => { + it('marks multimodal processor errors as recoverable via image strip', () => { + const result = normalizeModelError('openai', 'openai', { + message: 'Failed to apply multimodal processor to image data', + }); + expect(result.recoverableViaImageStrip).toBe(true); + }); + }); + + describe('protocol and provider passthrough', () => { + it('preserves provider and protocol fields', () => { + const result = normalizeModelError('my-provider', 'openai', { + message: 'Something went wrong', + }, 500); + expect(result.provider).toBe('my-provider'); + expect(result.protocol).toBe('openai'); + expect(result.status).toBe(500); + }); + }); +}); diff --git a/vitest.config.js b/vitest.config.js index c4a303e9..5fae86e9 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -15,6 +15,15 @@ export default defineConfig({ }, }, test: { + globals: true, + environment: 'node', + include: ['tests/**/*.test.ts', 'tests/**/*.test.tsx'], setupFiles: [resolve(rootDir, 'vitest.setup.ts')], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + include: ['src/**/*.ts', 'src/**/*.tsx'], + exclude: ['src/**/*.d.ts', 'src/context/memory/edgeclaw-memory-core/**'], + }, }, });