diff --git a/.gitignore b/.gitignore index d4a62060..91cfcaea 100644 --- a/.gitignore +++ b/.gitignore @@ -27,5 +27,4 @@ Thumbs.db coverage/ # Claude Code -CLAUDE.md .claude/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..a68d5617 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,102 @@ +# Open Alice + +File-driven AI trading agent. All state (sessions, config, logs) stored as files — no database. + +## Quick Start + +```bash +pnpm install +pnpm dev # Dev mode (tsx watch, port 3002) +pnpm build # Production build (backend + UI) +pnpm test # Vitest +``` + +## Project Structure + +``` +src/ +├── main.ts # Composition root +├── core/ +│ ├── agent-center.ts # Top-level AI orchestration, owns GenerateRouter +│ ├── ai-provider-manager.ts # GenerateRouter + StreamableResult + AskOptions +│ ├── tool-center.ts # Centralized tool registry (Vercel + MCP export) +│ ├── session.ts # JSONL session store +│ ├── compaction.ts # Auto-summarize long context windows +│ ├── config.ts # Zod-validated config loader +│ ├── ai-config.ts # Runtime AI provider selection +│ ├── event-log.ts # Append-only JSONL event log +│ ├── connector-center.ts # ConnectorCenter — push delivery + last-interacted tracking +│ ├── async-channel.ts # AsyncChannel for streaming provider events to SSE +│ ├── model-factory.ts # Model instance factory for Vercel AI SDK +│ ├── media.ts # MediaAttachment extraction +│ ├── media-store.ts # Media file persistence +│ └── types.ts # Plugin, EngineContext interfaces +├── ai-providers/ +│ ├── claude-code/ # Claude Code CLI subprocess +│ ├── vercel-ai-sdk/ # Vercel AI SDK ToolLoopAgent +│ └── agent-sdk/ # Agent SDK (@anthropic-ai/claude-agent-sdk) +├── extension/ +│ ├── analysis-kit/ # Indicators, market data tools, sandbox +│ ├── equity/ # Equity fundamentals +│ ├── market/ # Unified symbol search +│ ├── news/ # OpenBB news tools +│ ├── news-collector/ # RSS collector + archive search +│ ├── trading/ # Unified multi-account trading, guard pipeline, git-like commits +│ ├── thinking-kit/ # Reasoning and calculation tools +│ ├── brain/ # Cognitive state (memory, emotion) +│ └── browser/ # Browser automation bridge (OpenClaw) +├── openbb/ # In-process data SDK (equity, crypto, currency, commodity, economy, news) +├── connectors/ +│ ├── web/ # Web UI (Hono, SSE streaming, sub-channels) +│ ├── telegram/ # Telegram bot (grammY) +│ └── mcp-ask/ # MCP Ask connector +├── plugins/ +│ └── mcp.ts # MCP protocol server +├── task/ +│ ├── cron/ # Cron scheduling +│ └── heartbeat/ # Periodic heartbeat +├── skills/ # Agent skill definitions +└── openclaw/ # ⚠️ Frozen — DO NOT MODIFY +``` + +## Key Architecture + +### AgentCenter → GenerateRouter → GenerateProvider + +Two layers (Engine was removed): + +1. **AgentCenter** (`core/agent-center.ts`) — top-level orchestration. Manages sessions, compaction, and routes calls through GenerateRouter. Exposes `ask()` (stateless) and `askWithSession()` (with history). + +2. **GenerateRouter** (`core/ai-provider-manager.ts`) — reads `ai-provider.json` on each call, resolves to active provider. Three backends: + - Claude Code CLI (`inputKind: 'text'`) + - Vercel AI SDK (`inputKind: 'messages'`) + - Agent SDK (`inputKind: 'text'`) + +**AIProvider interface**: `ask(prompt)` for one-shot, `generate(input, opts)` for streaming `ProviderEvent` (tool_use / tool_result / text / done). Optional `compact()` for provider-native compaction. + +**StreamableResult**: dual interface — `PromiseLike` (await for result) + `AsyncIterable` (for-await for streaming). Multiple consumers each get independent cursors. + +Per-request provider and model overrides via `AskOptions.provider` and `AskOptions.vercelAiSdk` / `AskOptions.agentSdk`. + +### ConnectorCenter + +`connector-center.ts` manages push channels (Web, Telegram, MCP Ask). Tracks last-interacted channel for delivery routing. + +### ToolCenter + +Centralized registry. Extensions register tools, exports in Vercel and MCP formats. Decoupled from AgentCenter. + +## Conventions + +- ESM only (`.js` extensions in imports), path alias `@/*` → `./src/*` +- Strict TypeScript, ES2023 target +- Zod for config, TypeBox for tool parameter schemas +- `decimal.js` for financial math +- Pino logger → `logs/engine.log` + +## Git Workflow + +- `origin` = `TraderAlice/OpenAlice` (production) +- `dev` branch for all development, `master` only via PR +- **Never** force push master, **never** push `archive/dev` (contains old API keys) +- CLAUDE.md is **committed to the repo and publicly visible** — never put API keys, personal paths, or sensitive information in it diff --git a/docs/images/preview.png b/docs/images/preview.png new file mode 100644 index 00000000..2df53965 Binary files /dev/null and b/docs/images/preview.png differ diff --git a/package.json b/package.json index 5746c051..9935638c 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@hono/node-server": "^1.19.11", "@modelcontextprotocol/sdk": "^1.26.0", "@sinclair/typebox": "0.34.48", + "@traderalice/opentypebb": "workspace:*", "ai": "^6.0.86", "ajv": "^8.18.0", "ccxt": "^4.5.38", @@ -49,7 +50,6 @@ "grammy": "^1.40.0", "hono": "^4.12.7", "json5": "^2.2.3", - "@traderalice/opentypebb": "workspace:*", "pino": "^10.3.1", "playwright-core": "1.58.2", "sharp": "^0.34.5", @@ -63,6 +63,7 @@ "@types/express": "^5.0.6", "@types/node": "^25.2.3", "@types/ws": "^8.18.1", + "@vitest/coverage-v8": "^4.0.18", "tsup": "^8.5.1", "tsx": "^4.21.0", "typescript": "^5.9.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a12de960..2c338828 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -102,6 +102,9 @@ importers: '@types/ws': specifier: ^8.18.1 version: 8.18.1 + '@vitest/coverage-v8': + specifier: ^4.0.18 + version: 4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) tsup: specifier: ^8.5.1 version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) @@ -324,6 +327,10 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@borewit/text-codec@0.2.1': resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==} @@ -1221,6 +1228,15 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/coverage-v8@4.0.18': + resolution: {integrity: sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==} + peerDependencies: + '@vitest/browser': 4.0.18 + vitest: 4.0.18 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} @@ -1335,6 +1351,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-v8-to-istanbul@0.3.12: + resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==} + atomic-sleep@1.0.0: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} @@ -1807,6 +1826,9 @@ packages: resolution: {integrity: sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==} engines: {node: '>=16.9.0'} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -1871,6 +1893,18 @@ packages: resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} engines: {node: '>=18'} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -1882,6 +1916,9 @@ packages: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2034,6 +2071,13 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.5.2: + resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + marked-highlight@2.2.3: resolution: {integrity: sha512-FCfZRxW/msZAiasCML4isYpxyQWKEEx44vOgdn5Kloae+Qc3q4XR7WjpKKf8oMLk7JP9ZCRd2vhtclJFdwxlWQ==} peerDependencies: @@ -3079,6 +3123,8 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@bcoe/v8-coverage@1.0.2': {} + '@borewit/text-codec@0.2.1': {} '@deno/shim-deno-test@0.5.0': {} @@ -3714,6 +3760,20 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.0.18 + ast-v8-to-istanbul: 0.3.12 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.2 + obug: 2.1.1 + std-env: 3.10.0 + tinyrainbow: 3.0.3 + vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0) + '@vitest/expect@3.2.4': dependencies: '@types/chai': 5.2.3 @@ -3848,6 +3908,12 @@ snapshots: assertion-error@2.0.1: {} + ast-v8-to-istanbul@0.3.12: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + atomic-sleep@1.0.0: {} axios@0.21.4: @@ -4396,6 +4462,8 @@ snapshots: hono@4.12.7: {} + html-escaper@2.0.2: {} + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -4448,12 +4516,27 @@ snapshots: isexe@3.1.5: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + jiti@2.6.1: {} jose@6.1.3: {} joycon@3.1.1: {} + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} js-tokens@9.0.1: {} @@ -4562,6 +4645,16 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.5.2: + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.4 + marked-highlight@2.2.3(marked@15.0.12): dependencies: marked: 15.0.12 diff --git a/src/ai-providers/agent-sdk/agent-sdk-provider.ts b/src/ai-providers/agent-sdk/agent-sdk-provider.ts index 689c3539..7cc1aa53 100644 --- a/src/ai-providers/agent-sdk/agent-sdk-provider.ts +++ b/src/ai-providers/agent-sdk/agent-sdk-provider.ts @@ -10,15 +10,14 @@ import { resolve } from 'node:path' import type { Tool } from 'ai' -import type { ProviderResult, ProviderEvent, GenerateProvider, GenerateInput, GenerateOpts } from '../types.js' +import type { ProviderResult, ProviderEvent, AIProvider, GenerateInput, GenerateOpts } from '../types.js' import type { AgentSdkConfig, AgentSdkOverride } from './query.js' import { readAgentConfig } from '../../core/config.js' -import { extractMediaFromToolResultContent } from '../../core/media.js' import { createChannel } from '../../core/async-channel.js' import { askAgentSdk } from './query.js' import { buildAgentSdkMcpServer } from './tool-bridge.js' -export class AgentSdkProvider implements GenerateProvider { +export class AgentSdkProvider implements AIProvider { readonly inputKind = 'text' as const readonly providerTag = 'agent-sdk' as const @@ -66,7 +65,6 @@ export class AgentSdkProvider implements GenerateProvider { const mcpServer = await this.buildMcpServer(opts?.disabledTools) const channel = createChannel() - const media: import('../../core/types.js').MediaAttachment[] = [] const resultPromise = askAgentSdk( input.prompt, @@ -76,7 +74,6 @@ export class AgentSdkProvider implements GenerateProvider { channel.push({ type: 'tool_use', id, name, input: toolInput }) }, onToolResult: ({ toolUseId, content }) => { - media.push(...extractMediaFromToolResultContent(content)) channel.push({ type: 'tool_result', tool_use_id: toolUseId, content }) }, onText: (text) => { @@ -92,7 +89,7 @@ export class AgentSdkProvider implements GenerateProvider { const result = await resultPromise const prefix = result.ok ? '' : '[error] ' - yield { type: 'done', result: { text: prefix + result.text, media } } + yield { type: 'done', result: { text: prefix + result.text, media: [] } } } } diff --git a/src/ai-providers/agent-sdk/query.ts b/src/ai-providers/agent-sdk/query.ts index 8f94bc83..1e919448 100644 --- a/src/ai-providers/agent-sdk/query.ts +++ b/src/ai-providers/agent-sdk/query.ts @@ -10,7 +10,7 @@ import type { McpSdkServerConfigWithInstance } from '@anthropic-ai/claude-agent- import { pino } from 'pino' import type { ContentBlock } from '../../core/session.js' import { readAIProviderConfig } from '../../core/config.js' -import { logToolCall } from '../log-tool-call.js' +import { logToolCall } from '../utils.js' const logger = pino({ transport: { target: 'pino/file', options: { destination: 'logs/agent-sdk.log', mkdir: true } }, diff --git a/src/ai-providers/claude-code/claude-code-provider.ts b/src/ai-providers/claude-code/claude-code-provider.ts index 5ce5482b..d53baf46 100644 --- a/src/ai-providers/claude-code/claude-code-provider.ts +++ b/src/ai-providers/claude-code/claude-code-provider.ts @@ -9,14 +9,13 @@ */ import { resolve } from 'node:path' -import type { ProviderResult, ProviderEvent, GenerateProvider, GenerateInput, GenerateOpts } from '../types.js' +import type { ProviderResult, ProviderEvent, AIProvider, GenerateInput, GenerateOpts } from '../types.js' import type { ClaudeCodeConfig } from './types.js' import { readAgentConfig } from '../../core/config.js' -import { extractMediaFromToolResultContent } from '../../core/media.js' import { createChannel } from '../../core/async-channel.js' import { askClaudeCode } from './provider.js' -export class ClaudeCodeProvider implements GenerateProvider { +export class ClaudeCodeProvider implements AIProvider { readonly inputKind = 'text' as const readonly providerTag = 'claude-code' as const @@ -53,7 +52,6 @@ export class ClaudeCodeProvider implements GenerateProvider { } const channel = createChannel() - const media: import('../../core/types.js').MediaAttachment[] = [] const resultPromise = askClaudeCode(input.prompt, { ...claudeCode, @@ -61,7 +59,6 @@ export class ClaudeCodeProvider implements GenerateProvider { channel.push({ type: 'tool_use', id, name, input: toolInput }) }, onToolResult: ({ toolUseId, content }) => { - media.push(...extractMediaFromToolResultContent(content)) channel.push({ type: 'tool_result', tool_use_id: toolUseId, content }) }, onText: (text) => { @@ -74,7 +71,7 @@ export class ClaudeCodeProvider implements GenerateProvider { const result = await resultPromise const prefix = result.ok ? '' : '[error] ' - yield { type: 'done', result: { text: prefix + result.text, media } } + yield { type: 'done', result: { text: prefix + result.text, media: [] } } } } diff --git a/src/ai-providers/claude-code/provider.ts b/src/ai-providers/claude-code/provider.ts index ae730afe..184ab52b 100644 --- a/src/ai-providers/claude-code/provider.ts +++ b/src/ai-providers/claude-code/provider.ts @@ -2,7 +2,7 @@ import { spawn } from 'node:child_process' import { pino } from 'pino' import type { ClaudeCodeConfig, ClaudeCodeResult, ClaudeCodeMessage } from './types.js' import type { ContentBlock } from '../../core/session.js' -import { logToolCall } from '../log-tool-call.js' +import { logToolCall } from '../utils.js' const logger = pino({ transport: { target: 'pino/file', options: { destination: 'logs/claude-code.log', mkdir: true } }, diff --git a/src/ai-providers/log-tool-call.ts b/src/ai-providers/log-tool-call.ts deleted file mode 100644 index a630c411..00000000 --- a/src/ai-providers/log-tool-call.ts +++ /dev/null @@ -1,4 +0,0 @@ -export function logToolCall(name: string, input: unknown) { - const preview = JSON.stringify(input).slice(0, 120) - console.log(` ↳ ${name}(${preview})`) -} diff --git a/src/ai-providers/mock/index.ts b/src/ai-providers/mock/index.ts new file mode 100644 index 00000000..75c3dc7e --- /dev/null +++ b/src/ai-providers/mock/index.ts @@ -0,0 +1,85 @@ +/** + * MockAIProvider for testing. + * + * Implements the full AIProvider interface with configurable event sequences. + * Captures all generate() and ask() calls for test assertions. + * + * Also exports event builder helpers for constructing ProviderEvent sequences. + * + * Usage: + * const provider = new MockAIProvider([ + * toolUseEvent('t1', 'get_price', { symbol: 'AAPL' }), + * toolResultEvent('t1', '185'), + * textEvent('AAPL is at $185'), + * doneEvent('AAPL is at $185'), + * ]) + * // ... exercise code ... + * expect(provider.generateCalls).toHaveLength(1) + * expect(provider.askCalls).toHaveLength(0) + */ + +import type { AIProvider, ProviderEvent, ProviderResult, GenerateInput, GenerateOpts } from './types.js' +import type { MediaAttachment } from '../core/types.js' + +// ==================== Call Records ==================== + +export interface MockAIProviderCall { + input: GenerateInput + opts?: GenerateOpts +} + +// ==================== Options ==================== + +export interface MockAIProviderOpts { + inputKind?: 'text' | 'messages' + providerTag?: 'vercel-ai' | 'claude-code' | 'agent-sdk' + /** Text returned by ask(). Default: 'mock-ask-result'. */ + askResult?: string +} + +// ==================== MockAIProvider ==================== + +export class MockAIProvider implements AIProvider { + readonly inputKind: 'text' | 'messages' + readonly providerTag: 'vercel-ai' | 'claude-code' | 'agent-sdk' + readonly generateCalls: MockAIProviderCall[] = [] + readonly askCalls: string[] = [] + private _askResult: string + + constructor( + private events: ProviderEvent[], + opts?: MockAIProviderOpts, + ) { + this.inputKind = opts?.inputKind ?? 'messages' + this.providerTag = opts?.providerTag ?? 'vercel-ai' + this._askResult = opts?.askResult ?? 'mock-ask-result' + } + + async ask(prompt: string): Promise { + this.askCalls.push(prompt) + return { text: this._askResult, media: [] } + } + + async *generate(input: GenerateInput, opts?: GenerateOpts): AsyncIterable { + this.generateCalls.push({ input, opts }) + for (const e of this.events) yield e + } +} + +// ==================== Event Builders ==================== + +export function textEvent(text: string): ProviderEvent { + return { type: 'text', text } +} + +export function toolUseEvent(id: string, name: string, input: unknown): ProviderEvent { + return { type: 'tool_use', id, name, input } +} + +export function toolResultEvent(toolUseId: string, content: string): ProviderEvent { + return { type: 'tool_result', tool_use_id: toolUseId, content } +} + +export function doneEvent(text: string, media: MediaAttachment[] = []): ProviderEvent { + return { type: 'done', result: { text, media } } +} diff --git a/src/ai-providers/types.ts b/src/ai-providers/types.ts index 39e66d88..f5d24a82 100644 --- a/src/ai-providers/types.ts +++ b/src/ai-providers/types.ts @@ -44,7 +44,7 @@ export interface GenerateOpts { * Does NOT touch session management. AgentCenter prepares the input, * the provider calls the backend and yields ProviderEvents. */ -export interface GenerateProvider { +export interface AIProvider { /** Which input format this provider expects. */ readonly inputKind: 'text' | 'messages' /** Session log provenance tag. */ diff --git a/src/core/provider-utils.spec.ts b/src/ai-providers/utils.spec.ts similarity index 99% rename from src/core/provider-utils.spec.ts rename to src/ai-providers/utils.spec.ts index c00514b7..cc637b0f 100644 --- a/src/core/provider-utils.spec.ts +++ b/src/ai-providers/utils.spec.ts @@ -8,7 +8,7 @@ import { NORMAL_EXTRA_DISALLOWED, EVOLUTION_EXTRA_DISALLOWED, DEFAULT_MAX_HISTORY, -} from './provider-utils.js' +} from './utils.js' // ==================== stripImageData ==================== diff --git a/src/core/provider-utils.ts b/src/ai-providers/utils.ts similarity index 90% rename from src/core/provider-utils.ts rename to src/ai-providers/utils.ts index b40c8aea..9f189dff 100644 --- a/src/core/provider-utils.ts +++ b/src/ai-providers/utils.ts @@ -1,8 +1,5 @@ /** - * Shared utilities extracted from claude-code/provider.ts and agent-sdk/query.ts. - * - * These were previously copy-pasted across multiple providers. - * Now centralized here for single-source-of-truth usage. + * Shared utilities used across AI providers and AgentCenter. */ // ==================== Strip Image Data ==================== @@ -98,3 +95,11 @@ export function buildChatHistoryPrompt( /** Default max history entries for text-based providers. */ export const DEFAULT_MAX_HISTORY = 50 + +// ==================== Tool Call Logging ==================== + +/** Log a tool call with a short input preview. */ +export function logToolCall(name: string, input: unknown) { + const preview = JSON.stringify(input).slice(0, 120) + console.log(` ↳ ${name}(${preview})`) +} diff --git a/src/ai-providers/vercel-ai-sdk/agent.ts b/src/ai-providers/vercel-ai-sdk/agent.ts index f3ba3994..4db79ce9 100644 --- a/src/ai-providers/vercel-ai-sdk/agent.ts +++ b/src/ai-providers/vercel-ai-sdk/agent.ts @@ -1,6 +1,6 @@ import { ToolLoopAgent, stepCountIs } from 'ai' import type { LanguageModel, Tool } from 'ai' -import { logToolCall } from '../log-tool-call.js' +import { logToolCall } from '../utils.js' /** * Create a generic ToolLoopAgent with externally-provided tools. diff --git a/src/ai-providers/vercel-ai-sdk/model-factory.ts b/src/ai-providers/vercel-ai-sdk/model-factory.ts index 447cc0eb..0e42c3f2 100644 --- a/src/ai-providers/vercel-ai-sdk/model-factory.ts +++ b/src/ai-providers/vercel-ai-sdk/model-factory.ts @@ -1,7 +1,7 @@ /** * Model factory — creates Vercel AI SDK LanguageModel instances from config. * - * Reads ai-provider.json from disk on each call so that model + * Reads ai-provider-manager.json from disk on each call so that model * changes take effect without a restart. Uses dynamic imports so unused * provider packages don't prevent startup. */ diff --git a/src/ai-providers/vercel-ai-sdk/vercel-provider.spec.ts b/src/ai-providers/vercel-ai-sdk/vercel-provider.spec.ts new file mode 100644 index 00000000..ad97fced --- /dev/null +++ b/src/ai-providers/vercel-ai-sdk/vercel-provider.spec.ts @@ -0,0 +1,168 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { VercelAIProvider } from './vercel-provider.js' + +vi.mock('./model-factory.js', () => ({ + createModelFromConfig: vi.fn(), +})) + +vi.mock('./agent.js', () => ({ + createAgent: vi.fn(), +})) + +vi.mock('../../core/media.js', () => ({ + extractMediaFromToolOutput: vi.fn().mockReturnValue([]), +})) + +import { createModelFromConfig } from './model-factory.js' +import { createAgent } from './agent.js' + +const mockCreateModelFromConfig = vi.mocked(createModelFromConfig) +const mockCreateAgent = vi.mocked(createAgent) + +// ==================== Helpers ==================== + +function makeAgent(text = 'ok', steps: any[] = []) { + return { + generate: vi.fn().mockResolvedValue({ text, steps }), + } +} + +function makeProvider(overrides?: { getTools?: () => Promise> }) { + const getTools = overrides?.getTools ?? (async () => ({ toolA: {}, toolB: {} })) + return new VercelAIProvider(getTools as any, 'You are a trading assistant.', 10) +} + +// ==================== resolveAgent caching ==================== + +describe('VercelAIProvider — agent caching', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCreateModelFromConfig.mockResolvedValue({ model: {} as any, key: 'gpt-4o' }) + mockCreateAgent.mockReturnValue(makeAgent() as any) + }) + + it('creates agent on first ask()', async () => { + const provider = makeProvider() + await provider.ask('hello') + expect(mockCreateAgent).toHaveBeenCalledOnce() + }) + + it('reuses cached agent on second ask() when nothing changes', async () => { + const provider = makeProvider() + await provider.ask('first') + await provider.ask('second') + expect(mockCreateAgent).toHaveBeenCalledOnce() + }) + + it('recreates agent when config key changes', async () => { + const provider = makeProvider() + await provider.ask('first') + // Simulate config key change + mockCreateModelFromConfig.mockResolvedValue({ model: {} as any, key: 'claude-3-5-sonnet' }) + await provider.ask('second') + expect(mockCreateAgent).toHaveBeenCalledTimes(2) + }) + + it('recreates agent when tool count changes', async () => { + let toolSet: Record = { toolA: {}, toolB: {} } + const provider = new VercelAIProvider(async () => toolSet as any, 'prompt', 5) + await provider.ask('first') + toolSet = { toolA: {}, toolB: {}, toolC: {} } + await provider.ask('second') + expect(mockCreateAgent).toHaveBeenCalledTimes(2) + }) +}) + +// ==================== per-request overrides ==================== + +describe('VercelAIProvider — per-request overrides', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCreateModelFromConfig.mockResolvedValue({ model: {} as any, key: 'gpt-4o' }) + mockCreateAgent.mockReturnValue(makeAgent() as any) + }) + + it('skips cache and uses filtered tools when disabledTools provided', async () => { + const getTools = async () => ({ toolA: {} as any, toolB: {} as any, toolC: {} as any }) + const provider = new VercelAIProvider(getTools, 'prompt', 5) + // warm cache + await provider.ask('warm') + vi.clearAllMocks() + mockCreateModelFromConfig.mockResolvedValue({ model: {} as any, key: 'gpt-4o' }) + mockCreateAgent.mockReturnValue(makeAgent() as any) + + // generate with disabledTools + const input = { kind: 'messages' as const, messages: [] } + const events = [] + for await (const e of provider.generate(input, { disabledTools: ['toolB'] })) { + events.push(e) + } + + expect(mockCreateAgent).toHaveBeenCalledOnce() + // The tools passed to createAgent should exclude toolB + const toolsArg = mockCreateAgent.mock.calls[0][1] + expect(Object.keys(toolsArg)).toContain('toolA') + expect(Object.keys(toolsArg)).not.toContain('toolB') + expect(Object.keys(toolsArg)).toContain('toolC') + }) + + it('skips cache when modelOverride is provided', async () => { + const provider = makeProvider() + await provider.ask('warm') + vi.clearAllMocks() + mockCreateModelFromConfig.mockResolvedValue({ model: {} as any, key: 'gpt-4o' }) + mockCreateAgent.mockReturnValue(makeAgent() as any) + + const input = { kind: 'messages' as const, messages: [] } + for await (const _ of provider.generate(input, { vercelAiSdk: { modelId: 'claude-3-7' } as any })) { + // drain + } + + expect(mockCreateAgent).toHaveBeenCalledOnce() + }) +}) + +// ==================== generate() input validation ==================== + +describe('VercelAIProvider — generate() input', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCreateModelFromConfig.mockResolvedValue({ model: {} as any, key: 'gpt-4o' }) + mockCreateAgent.mockReturnValue(makeAgent() as any) + }) + + it('throws when input.kind is not "messages"', async () => { + const provider = makeProvider() + const input = { kind: 'text' as any, text: 'hello' } + const gen = provider.generate(input) + await expect(gen.next()).rejects.toThrow('expects messages input') + }) + + it('yields done event with text from result', async () => { + const provider = makeProvider() + const agent = makeAgent('final answer') + mockCreateAgent.mockReturnValue(agent as any) + + const input = { kind: 'messages' as const, messages: [] } + const events = [] + for await (const e of provider.generate(input)) { + events.push(e) + } + + const done = events.find((e) => e.type === 'done') + expect(done?.result.text).toBe('final answer') + }) + + it('propagates agent error through channel', async () => { + const agent = { generate: vi.fn().mockRejectedValue(new Error('model error')) } + mockCreateAgent.mockReturnValue(agent as any) + const provider = makeProvider() + + const input = { kind: 'messages' as const, messages: [] } + await expect(async () => { + for await (const _ of provider.generate(input)) { + // drain + } + }).rejects.toThrow('model error') + }) +}) diff --git a/src/ai-providers/vercel-ai-sdk/vercel-provider.ts b/src/ai-providers/vercel-ai-sdk/vercel-provider.ts index 31e09190..aaaf1df1 100644 --- a/src/ai-providers/vercel-ai-sdk/vercel-provider.ts +++ b/src/ai-providers/vercel-ai-sdk/vercel-provider.ts @@ -7,7 +7,7 @@ */ import type { ModelMessage, Tool } from 'ai' -import type { ProviderResult, ProviderEvent, GenerateProvider, GenerateInput, GenerateOpts } from '../types.js' +import type { ProviderResult, ProviderEvent, AIProvider, GenerateInput, GenerateOpts } from '../types.js' import type { Agent } from './agent.js' import type { MediaAttachment } from '../../core/types.js' import { extractMediaFromToolOutput } from '../../core/media.js' @@ -15,7 +15,7 @@ import { createModelFromConfig, type ModelOverride } from './model-factory.js' import { createAgent } from './agent.js' import { createChannel } from '../../core/async-channel.js' -export class VercelAIProvider implements GenerateProvider { +export class VercelAIProvider implements AIProvider { readonly inputKind = 'messages' as const readonly providerTag = 'vercel-ai' as const private cachedKey: string | null = null diff --git a/src/connectors/mock.ts b/src/connectors/mock/index.ts similarity index 96% rename from src/connectors/mock.ts rename to src/connectors/mock/index.ts index be187b41..58597ae9 100644 --- a/src/connectors/mock.ts +++ b/src/connectors/mock/index.ts @@ -14,7 +14,7 @@ */ import type { Connector, ConnectorCapabilities, SendPayload, SendResult } from './types.js' -import type { StreamableResult } from '../core/ai-provider.js' +import type { StreamableResult } from '../core/ai-provider-manager.js' export interface MockConnectorCall { method: 'send' | 'sendStream' diff --git a/src/connectors/types.ts b/src/connectors/types.ts index 9482902a..0fd940a5 100644 --- a/src/connectors/types.ts +++ b/src/connectors/types.ts @@ -1,5 +1,5 @@ import type { MediaAttachment } from '../core/types.js' -import type { StreamableResult } from '../core/ai-provider.js' +import type { StreamableResult } from '../core/ai-provider-manager.js' // ==================== Send Types ==================== diff --git a/src/connectors/web/__tests__/chat-streaming.spec.ts b/src/connectors/web/__tests__/chat-streaming.spec.ts index 125f019e..9ed4c180 100644 --- a/src/connectors/web/__tests__/chat-streaming.spec.ts +++ b/src/connectors/web/__tests__/chat-streaming.spec.ts @@ -11,7 +11,7 @@ import { createChannel } from '../../../core/async-channel.js' import { StreamableResult, type ProviderEvent, -} from '../../../core/ai-provider.js' +} from '../../../core/ai-provider-manager.js' import { FakeProvider, MemorySessionStore, @@ -38,7 +38,8 @@ vi.mock('../../../core/media-store.js', () => ({ resolveMediaPath: vi.fn((name: string) => `/mock/media/${name}`), })) -vi.mock('../../../ai-providers/log-tool-call.js', () => ({ +vi.mock('@/ai-providers/utils.js', async (importOriginal) => ({ + ...(await importOriginal()), logToolCall: vi.fn(), })) diff --git a/src/connectors/web/routes/chat.ts b/src/connectors/web/routes/chat.ts index 8cd59d71..db40bc98 100644 --- a/src/connectors/web/routes/chat.ts +++ b/src/connectors/web/routes/chat.ts @@ -4,7 +4,7 @@ import { readFile } from 'node:fs/promises' import { randomUUID } from 'node:crypto' import { extname, join } from 'node:path' import type { EngineContext } from '../../../core/types.js' -import type { AskOptions } from '../../../core/ai-provider.js' +import type { AskOptions } from '../../../core/ai-provider-manager.js' import { SessionStore, toChatHistory } from '../../../core/session.js' import { readWebSubchannels } from '../../../core/config.js' import { resolveMediaPath } from '../../../core/media-store.js' diff --git a/src/connectors/web/web-connector.ts b/src/connectors/web/web-connector.ts index 1d6d702a..135f081c 100644 --- a/src/connectors/web/web-connector.ts +++ b/src/connectors/web/web-connector.ts @@ -10,7 +10,7 @@ */ import type { Connector, ConnectorCapabilities, SendPayload, SendResult } from '../types.js' -import type { StreamableResult } from '../../core/ai-provider.js' +import type { StreamableResult } from '../../core/ai-provider-manager.js' import type { SSEClient } from './routes/chat.js' import { SessionStore, type ContentBlock } from '../../core/session.js' import { persistMedia } from '../../core/media-store.js' diff --git a/src/core/__tests__/pipeline/delivery.spec.ts b/src/core/__tests__/pipeline/delivery.spec.ts index 1fb029fc..9a1cf83d 100644 --- a/src/core/__tests__/pipeline/delivery.spec.ts +++ b/src/core/__tests__/pipeline/delivery.spec.ts @@ -5,7 +5,7 @@ * fallback to send, interaction tracking, and error resilience. */ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { StreamableResult, type ProviderEvent } from '../../ai-provider.js' +import { StreamableResult, type ProviderEvent } from '../../ai-provider-manager.js' import { ConnectorCenter } from '../../connector-center.js' import { createEventLog } from '../../event-log.js' import type { MediaAttachment } from '../../types.js' @@ -30,7 +30,8 @@ vi.mock('../../media-store.js', () => ({ resolveMediaPath: vi.fn((name: string) => `/mock/media/${name}`), })) -vi.mock('../../../ai-providers/log-tool-call.js', () => ({ +vi.mock('@/ai-providers/utils.js', async (importOriginal) => ({ + ...(await importOriginal()), logToolCall: vi.fn(), })) diff --git a/src/core/__tests__/pipeline/e2e.spec.ts b/src/core/__tests__/pipeline/e2e.spec.ts index d1955452..ff7fdc24 100644 --- a/src/core/__tests__/pipeline/e2e.spec.ts +++ b/src/core/__tests__/pipeline/e2e.spec.ts @@ -35,7 +35,8 @@ vi.mock('../../media-store.js', () => ({ resolveMediaPath: vi.fn((name: string) => `/mock/media/${name}`), })) -vi.mock('../../../ai-providers/log-tool-call.js', () => ({ +vi.mock('@/ai-providers/utils.js', async (importOriginal) => ({ + ...(await importOriginal()), logToolCall: vi.fn(), })) diff --git a/src/core/__tests__/pipeline/helpers.ts b/src/core/__tests__/pipeline/helpers.ts index 1634d607..cb1806bb 100644 --- a/src/core/__tests__/pipeline/helpers.ts +++ b/src/core/__tests__/pipeline/helpers.ts @@ -1,77 +1,31 @@ /** * Shared test infrastructure for message pipeline integration tests. * - * FakeProvider, event builders, and helpers used across the pipeline-*.spec.ts - * files. Session and connector test doubles are imported from their respective - * modules (MemorySessionStore, MockConnector). + * MockAIProvider (aliased as FakeProvider), event builders, and helpers used + * across the pipeline-*.spec.ts files. Session and connector test doubles are + * imported from their respective modules (MemorySessionStore, MockConnector). */ import { AgentCenter } from '../../agent-center.js' -import { - GenerateRouter, - StreamableResult, - type GenerateProvider, - type ProviderEvent, - type ProviderResult, - type GenerateInput, - type GenerateOpts, -} from '../../ai-provider.js' +import { GenerateRouter, StreamableResult, type ProviderEvent } from '../../ai-provider-manager.js' import { DEFAULT_COMPACTION_CONFIG } from '../../compaction.js' -import type { ContentBlock } from '../../session.js' -import type { MediaAttachment } from '../../types.js' // Re-export test doubles for convenience export { MemorySessionStore } from '../../session.js' export type { SessionEntry, ContentBlock } from '../../session.js' -export { MockConnector } from '../../../connectors/mock.js' -export type { MockConnectorCall } from '../../../connectors/mock.js' +export { MockConnector } from '../../../connectors/mock/index.js' +export type { MockConnectorCall } from '../../../connectors/mock/index.js' -// ==================== FakeProvider ==================== - -/** A FakeProvider that yields a configurable sequence of ProviderEvents. */ -export class FakeProvider implements GenerateProvider { - readonly inputKind: 'text' | 'messages' - readonly providerTag: 'vercel-ai' | 'claude-code' | 'agent-sdk' - - constructor( - private events: ProviderEvent[], - opts?: { inputKind?: 'text' | 'messages'; providerTag?: 'vercel-ai' | 'claude-code' | 'agent-sdk' }, - ) { - this.inputKind = opts?.inputKind ?? 'messages' - this.providerTag = opts?.providerTag ?? 'vercel-ai' - } - - async ask(_prompt: string): Promise { - return { text: 'fake-ask', media: [] } - } - - async *generate(_input: GenerateInput, _opts?: GenerateOpts): AsyncIterable { - for (const e of this.events) yield e - } -} - -// ==================== Event Builders ==================== - -export function textEvent(text: string): ProviderEvent { - return { type: 'text', text } -} - -export function toolUseEvent(id: string, name: string, input: unknown): ProviderEvent { - return { type: 'tool_use', id, name, input } -} - -export function toolResultEvent(toolUseId: string, content: string): ProviderEvent { - return { type: 'tool_result', tool_use_id: toolUseId, content } -} - -export function doneEvent(text: string, media: MediaAttachment[] = []): ProviderEvent { - return { type: 'done', result: { text, media } } -} +// Re-export MockAIProvider as FakeProvider for backward compatibility with existing tests +export { MockAIProvider as FakeProvider } from '../../../ai-providers/mock/index.js' +export { textEvent, toolUseEvent, toolResultEvent, doneEvent } from '../../../ai-providers/mock/index.js' // ==================== Helpers ==================== -/** Create an AgentCenter wired to a FakeProvider. */ -export function makeAgentCenter(provider: FakeProvider): AgentCenter { +import type { MockAIProvider } from '../../../ai-providers/mock/index.js' + +/** Create an AgentCenter wired to a MockAIProvider. */ +export function makeAgentCenter(provider: MockAIProvider): AgentCenter { const router = new GenerateRouter(provider, null) return new AgentCenter({ router, compaction: DEFAULT_COMPACTION_CONFIG }) } diff --git a/src/core/__tests__/pipeline/persistence.spec.ts b/src/core/__tests__/pipeline/persistence.spec.ts index e79da4ee..81323ee8 100644 --- a/src/core/__tests__/pipeline/persistence.spec.ts +++ b/src/core/__tests__/pipeline/persistence.spec.ts @@ -35,7 +35,8 @@ vi.mock('../../media-store.js', () => ({ resolveMediaPath: vi.fn((name: string) => `/mock/media/${name}`), })) -vi.mock('../../../ai-providers/log-tool-call.js', () => ({ +vi.mock('@/ai-providers/utils.js', async (importOriginal) => ({ + ...(await importOriginal()), logToolCall: vi.fn(), })) @@ -426,6 +427,39 @@ describe('AgentCenter — session persistence', () => { expect(resultBlocks.map(b => (b as { tool_use_id: string }).tool_use_id)).toEqual(['t1', 't2', 't3']) }) + it('A17: media in tool_result content is extracted exactly once by AgentCenter when provider done.media is empty', async () => { + const { persistMedia } = await import('../../media-store.js') + vi.mocked(persistMedia).mockResolvedValueOnce('2026-03-14/screenshot.png') + + const toolResultContent = JSON.stringify({ + content: [{ type: 'text', text: 'MEDIA:/tmp/screenshot.png' }], + }) + + // Simulates fixed ClaudeCode/AgentSdk provider behavior: + // - tool_result content contains MEDIA marker (raw content passed through) + // - done event carries empty media (provider does NOT extract from tool_result) + // AgentCenter is the sole extractor — must call persistMedia exactly once. + const provider = new FakeProvider([ + toolUseEvent('t1', 'browser', {}), + toolResultEvent('t1', toolResultContent), + textEvent('screenshot taken'), + doneEvent('screenshot taken'), + ]) + const ac = makeAgentCenter(provider) + const session = new MemorySessionStore() + + await ac.askWithSession('take screenshot', session) + + // persistMedia must be called exactly once — single extraction path + expect(vi.mocked(persistMedia)).toHaveBeenCalledTimes(1) + + const assistants = assistantEntries(await session.readAll()) + const finalBlocks = blocksOf(assistants[assistants.length - 1]) + const imageBlocks = finalBlocks.filter(b => b.type === 'image') + expect(imageBlocks).toHaveLength(1) + expect(imageBlocks[0]).toEqual({ type: 'image', url: '/api/media/2026-03-14/screenshot.png' }) + }) + it('A15: providerTag carries through to intermediate writes too', async () => { const provider = new FakeProvider( [ diff --git a/src/core/__tests__/pipeline/streaming.spec.ts b/src/core/__tests__/pipeline/streaming.spec.ts index 83fb0b27..60057764 100644 --- a/src/core/__tests__/pipeline/streaming.spec.ts +++ b/src/core/__tests__/pipeline/streaming.spec.ts @@ -5,7 +5,7 @@ * PromiseLike resolution, multi-consumer cursors, and error propagation. */ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { StreamableResult, type ProviderEvent } from '../../ai-provider.js' +import { StreamableResult, type ProviderEvent } from '../../ai-provider-manager.js' import { FakeProvider, MemorySessionStore, @@ -32,7 +32,8 @@ vi.mock('../../media-store.js', () => ({ resolveMediaPath: vi.fn((name: string) => `/mock/media/${name}`), })) -vi.mock('../../../ai-providers/log-tool-call.js', () => ({ +vi.mock('@/ai-providers/utils.js', async (importOriginal) => ({ + ...(await importOriginal()), logToolCall: vi.fn(), })) diff --git a/src/core/agent-center.spec.ts b/src/core/agent-center.spec.ts index 585fe961..0da85eec 100644 --- a/src/core/agent-center.spec.ts +++ b/src/core/agent-center.spec.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import type { LanguageModel, Tool } from 'ai' import { MockLanguageModelV3 } from 'ai/test' import { AgentCenter } from './agent-center.js' -import { GenerateRouter } from './ai-provider.js' +import { GenerateRouter } from './ai-provider-manager.js' import { DEFAULT_COMPACTION_CONFIG, type CompactionConfig } from './compaction.js' import { VercelAIProvider } from '../ai-providers/vercel-ai-sdk/vercel-provider.js' import { createModelFromConfig } from '../ai-providers/vercel-ai-sdk/model-factory.js' diff --git a/src/core/agent-center.ts b/src/core/agent-center.ts index 92b98b07..8cbc4bb2 100644 --- a/src/core/agent-center.ts +++ b/src/core/agent-center.ts @@ -11,8 +11,8 @@ * - Message persistence (intermediate tool messages + final response) */ -import type { AskOptions, ProviderResult, ProviderEvent, GenerateOpts } from './ai-provider.js' -import { GenerateRouter, StreamableResult } from './ai-provider.js' +import type { AskOptions, ProviderResult, ProviderEvent, GenerateOpts } from './ai-provider-manager.js' +import { GenerateRouter, StreamableResult } from './ai-provider-manager.js' import type { ISessionStore, ContentBlock } from './session.js' import { toTextHistory, toModelMessages } from './session.js' import type { CompactionConfig } from './compaction.js' @@ -20,8 +20,7 @@ import { compactIfNeeded } from './compaction.js' import type { MediaAttachment } from './types.js' import { extractMediaFromToolResultContent } from './media.js' import { persistMedia } from './media-store.js' -import { logToolCall } from '../ai-providers/log-tool-call.js' -import { stripImageData, buildChatHistoryPrompt, DEFAULT_MAX_HISTORY } from './provider-utils.js' +import { logToolCall, stripImageData, buildChatHistoryPrompt, DEFAULT_MAX_HISTORY } from '../ai-providers/utils.js' // ==================== Types ==================== diff --git a/src/core/ai-provider.spec.ts b/src/core/ai-provider-manager.spec.ts similarity index 97% rename from src/core/ai-provider.spec.ts rename to src/core/ai-provider-manager.spec.ts index 9222f503..94213cd2 100644 --- a/src/core/ai-provider.spec.ts +++ b/src/core/ai-provider-manager.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from 'vitest' -import { StreamableResult, type ProviderEvent, type ProviderResult, GenerateRouter, type GenerateProvider } from './ai-provider.js' +import { StreamableResult, type ProviderEvent, type ProviderResult, GenerateRouter, type AIProvider } from './ai-provider-manager.js' import { createChannel } from './async-channel.js' // ==================== Helpers ==================== @@ -130,7 +130,7 @@ describe('StreamableResult', () => { // ==================== GenerateRouter ==================== describe('GenerateRouter', () => { - function makeProvider(tag: GenerateProvider['providerTag']): GenerateProvider { + function makeProvider(tag: AIProvider['providerTag']): AIProvider { return { inputKind: tag === 'vercel-ai' ? 'messages' : 'text', providerTag: tag, diff --git a/src/core/ai-provider.ts b/src/core/ai-provider-manager.ts similarity index 90% rename from src/core/ai-provider.ts rename to src/core/ai-provider-manager.ts index b8f69911..fe6e63fe 100644 --- a/src/core/ai-provider.ts +++ b/src/core/ai-provider-manager.ts @@ -1,16 +1,16 @@ /** * AI Provider abstraction — StreamableResult + GenerateRouter. * - * Provider interface types (GenerateProvider, ProviderEvent, etc.) live in + * Provider interface types (AIProvider, ProviderEvent, etc.) live in * ai-providers/types.ts alongside the implementations. This file holds the * core infrastructure that orchestrates providers. */ import { readAIProviderConfig } from './config.js' -import type { ProviderEvent, ProviderResult, GenerateProvider } from '../ai-providers/types.js' +import type { ProviderEvent, ProviderResult, AIProvider } from '../ai-providers/types.js' export type { - ProviderEvent, ProviderResult, GenerateProvider, + ProviderEvent, ProviderResult, AIProvider, GenerateInput, GenerateOpts, } from '../ai-providers/types.js' @@ -106,7 +106,7 @@ export interface AskOptions { */ disabledTools?: string[] /** - * AI provider to use for this call, overriding the global ai-provider.json config. + * AI provider to use for this call, overriding the global ai-provider-manager.json config. * Falls back to global config if not specified. */ provider?: 'claude-code' | 'vercel-ai-sdk' | 'agent-sdk' @@ -133,16 +133,16 @@ export interface AskOptions { // ==================== GenerateRouter ==================== -/** Reads runtime AI config and resolves to the correct GenerateProvider. */ +/** Reads runtime AI config and resolves to the correct AIProvider. */ export class GenerateRouter { constructor( - private vercel: GenerateProvider, - private claudeCode: GenerateProvider | null, - private agentSdk: GenerateProvider | null = null, + private vercel: AIProvider, + private claudeCode: AIProvider | null, + private agentSdk: AIProvider | null = null, ) {} /** Resolve the active provider, optionally overridden per-request. */ - async resolve(override?: string): Promise { + async resolve(override?: string): Promise { if (override === 'agent-sdk' && this.agentSdk) return this.agentSdk if (override === 'claude-code' && this.claudeCode) return this.claudeCode if (override === 'vercel-ai-sdk') return this.vercel diff --git a/src/core/compaction.spec.ts b/src/core/compaction.spec.ts index 3b2650b4..68104f8f 100644 --- a/src/core/compaction.spec.ts +++ b/src/core/compaction.spec.ts @@ -10,9 +10,12 @@ import { buildSummarizationPrompt, createCompactBoundary, createSummaryEntry, + compactIfNeeded, + forceCompact, DEFAULT_COMPACTION_CONFIG, type CompactionConfig, } from './compaction.js' +import { MemorySessionStore } from './session.js' import type { SessionEntry, ContentBlock } from './session.js' // ==================== Helpers ==================== @@ -359,3 +362,108 @@ describe('createSummaryEntry', () => { expect(entry.parentUuid).toBe('parent-uuid') }) }) + +// ==================== compactIfNeeded ==================== + +// A small config so tests don't need giant strings for most cases +const SMALL_CONFIG: CompactionConfig = { + maxContextTokens: 1000, + maxOutputTokens: 100, + autoCompactBuffer: 100, + microcompactKeepRecent: 1, +} +// threshold = 1000 - 100 - 100 = 800 tokens ≈ 2800 chars + +describe('compactIfNeeded', () => { + it('returns { compacted: false } when session is below threshold', async () => { + const session = new MemorySessionStore('test-s1') + await session.appendUser('short message') + const summarize = vi.fn() + const result = await compactIfNeeded(session, SMALL_CONFIG, summarize) + expect(result).toEqual({ compacted: false, method: 'none' }) + expect(summarize).not.toHaveBeenCalled() + }) + + it('returns microcompact result when large old tool result saves enough tokens', async () => { + const session = new MemorySessionStore('test-s2') + // Two tool result entries — microcompactKeepRecent=1 truncates the old one + const hugeContent = 'x'.repeat(72_000) // ~20571 tokens, well above MIN_MICROCOMPACT_SAVINGS=20000 + await session.appendUser([{ type: 'tool_result', tool_use_id: 't0', content: hugeContent }]) + await session.appendUser([{ type: 'tool_result', tool_use_id: 't1', content: 'small result' }]) + await session.appendUser('follow-up') + const summarize = vi.fn() + const tinyConfig: CompactionConfig = { + maxContextTokens: 100, + maxOutputTokens: 5, + autoCompactBuffer: 5, + microcompactKeepRecent: 1, + } + // threshold = 90 tokens; session is ~20573 tokens >> threshold; after microcompact ~4 tokens << threshold + const result = await compactIfNeeded(session, tinyConfig, summarize) + expect(result.compacted).toBe(true) + expect(result.method).toBe('microcompact') + expect(result.activeEntries).toBeDefined() + expect(summarize).not.toHaveBeenCalled() + }) + + it('calls summarize and writes boundary+summary when microcompact is insufficient', async () => { + const session = new MemorySessionStore('test-s3') + // Fill with large user text that microcompact cannot truncate (no tool results) + const largeText = 'w'.repeat(3000) // ~857 tokens > threshold of 800 + await session.appendUser(largeText) + const summarize = vi.fn().mockResolvedValue('Here is the summary.') + const result = await compactIfNeeded(session, SMALL_CONFIG, summarize) + expect(result).toEqual({ compacted: true, method: 'full' }) + expect(summarize).toHaveBeenCalledOnce() + // Boundary + summary should have been appended + const all = await session.readAll() + const boundary = all.find((e) => e.subtype === 'compact_boundary') + const summary = all.find((e) => e.isCompactSummary === true) + expect(boundary).toBeDefined() + expect(summary).toBeDefined() + expect(summary?.message.content).toContain('Here is the summary.') + }) + + it('propagates error when summarize throws', async () => { + const session = new MemorySessionStore('test-s4') + const largeText = 'e'.repeat(3000) + await session.appendUser(largeText) + const summarize = vi.fn().mockRejectedValue(new Error('LLM unavailable')) + await expect(compactIfNeeded(session, SMALL_CONFIG, summarize)).rejects.toThrow('LLM unavailable') + }) +}) + +// ==================== forceCompact ==================== + +describe('forceCompact', () => { + it('returns null for an empty session', async () => { + const session = new MemorySessionStore('test-fc1') + const summarize = vi.fn() + const result = await forceCompact(session, summarize) + expect(result).toBeNull() + expect(summarize).not.toHaveBeenCalled() + }) + + it('compresses non-empty session and writes boundary+summary', async () => { + const session = new MemorySessionStore('test-fc2') + await session.appendUser('first message') + await session.appendAssistant('assistant reply') + const summarize = vi.fn().mockResolvedValue('Compact summary text.') + const result = await forceCompact(session, summarize) + expect(result).not.toBeNull() + expect(result!.preTokens).toBeGreaterThan(0) + expect(summarize).toHaveBeenCalledOnce() + const all = await session.readAll() + const boundary = all.find((e) => e.subtype === 'compact_boundary') + const summary = all.find((e) => e.isCompactSummary === true) + expect(boundary?.compactMetadata?.trigger).toBe('manual') + expect(summary?.message.content).toContain('Compact summary text.') + }) + + it('propagates error when summarize throws', async () => { + const session = new MemorySessionStore('test-fc3') + await session.appendUser('a message') + const summarize = vi.fn().mockRejectedValue(new Error('timeout')) + await expect(forceCompact(session, summarize)).rejects.toThrow('timeout') + }) +}) diff --git a/src/core/config.spec.ts b/src/core/config.spec.ts new file mode 100644 index 00000000..51832417 --- /dev/null +++ b/src/core/config.spec.ts @@ -0,0 +1,392 @@ +/** + * config.ts unit tests. + * + * fs/promises is mocked so no real disk I/O occurs. + * Tests cover: hot-read helpers, writeConfigSection, writeAIBackend, + * loadTradingConfig (both new-format and legacy-migration paths). + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Mock fs/promises BEFORE importing config +vi.mock('fs/promises', () => ({ + readFile: vi.fn(), + writeFile: vi.fn().mockResolvedValue(undefined), + mkdir: vi.fn().mockResolvedValue(undefined), + unlink: vi.fn().mockResolvedValue(undefined), +})) + +import { readFile, writeFile, mkdir } from 'fs/promises' +import { + readAIProviderConfig, + readAIBackend, + writeAIBackend, + readToolsConfig, + readAgentConfig, + readOpenbbConfig, + loadTradingConfig, + writeConfigSection, + readPlatformsConfig, + readAccountsConfig, + writePlatformsConfig, + writeAccountsConfig, + aiProviderSchema, +} from './config.js' + +const mockReadFile = vi.mocked(readFile) +const mockWriteFile = vi.mocked(writeFile) +const mockMkdir = vi.mocked(mkdir) + +/** Simulate a file read that returns JSON content. */ +function fileReturns(content: unknown) { + mockReadFile.mockResolvedValueOnce(JSON.stringify(content) as any) +} + +/** Simulate ENOENT (file not found). */ +function fileNotFound() { + const err = new Error('ENOENT: no such file') as NodeJS.ErrnoException + err.code = 'ENOENT' + mockReadFile.mockRejectedValueOnce(err) +} + +/** Simulate a non-ENOENT read error. */ +function fileReadError(message = 'Permission denied') { + mockReadFile.mockRejectedValueOnce(new Error(message)) +} + +beforeEach(() => { + vi.clearAllMocks() + mockWriteFile.mockResolvedValue(undefined as any) + mockMkdir.mockResolvedValue(undefined as any) +}) + +// ==================== readAIProviderConfig ==================== + +describe('readAIProviderConfig', () => { + it('returns schema defaults when file is missing', async () => { + fileNotFound() + const cfg = await readAIProviderConfig() + expect(cfg.backend).toBe('claude-code') + expect(cfg.provider).toBe('anthropic') + expect(cfg.model).toBe('claude-sonnet-4-6') + }) + + it('parses valid file content', async () => { + fileReturns({ backend: 'vercel-ai-sdk', provider: 'openai', model: 'gpt-4o' }) + const cfg = await readAIProviderConfig() + expect(cfg.backend).toBe('vercel-ai-sdk') + expect(cfg.provider).toBe('openai') + expect(cfg.model).toBe('gpt-4o') + }) + + it('returns defaults when file contains invalid JSON (parse error)', async () => { + fileReadError('Unexpected token') + const cfg = await readAIProviderConfig() + expect(cfg.backend).toBe('claude-code') + }) + + it('fills in missing fields with schema defaults', async () => { + fileReturns({ backend: 'agent-sdk' }) + const cfg = await readAIProviderConfig() + expect(cfg.backend).toBe('agent-sdk') + expect(cfg.provider).toBe('anthropic') // default + expect(cfg.model).toBe('claude-sonnet-4-6') // default + }) +}) + +// ==================== readAIBackend ==================== + +describe('readAIBackend', () => { + it('returns claude-code backend by default', async () => { + fileNotFound() + const { backend } = await readAIBackend() + expect(backend).toBe('claude-code') + }) + + it('returns the backend stored in file', async () => { + fileReturns({ backend: 'vercel-ai-sdk' }) + const { backend } = await readAIBackend() + expect(backend).toBe('vercel-ai-sdk') + }) +}) + +// ==================== writeAIBackend ==================== + +describe('writeAIBackend', () => { + it('reads current config and overwrites only the backend field', async () => { + // First read: return existing config with custom model + fileReturns({ backend: 'claude-code', provider: 'anthropic', model: 'my-custom-model' }) + + await writeAIBackend('vercel-ai-sdk') + + expect(mockMkdir).toHaveBeenCalled() + expect(mockWriteFile).toHaveBeenCalled() + + const written = JSON.parse((mockWriteFile.mock.calls[0][1] as string)) + expect(written.backend).toBe('vercel-ai-sdk') + expect(written.model).toBe('my-custom-model') // preserved + expect(written.provider).toBe('anthropic') // preserved + }) + + it('writes to ai-provider-manager.json', async () => { + fileReturns({ backend: 'agent-sdk' }) + await writeAIBackend('claude-code') + + const filePath = mockWriteFile.mock.calls[0][0] as string + expect(filePath).toMatch(/ai-provider-manager\.json$/) + }) +}) + +// ==================== readToolsConfig ==================== + +describe('readToolsConfig', () => { + it('returns empty disabled list when file is missing', async () => { + fileNotFound() + const cfg = await readToolsConfig() + expect(cfg.disabled).toEqual([]) + }) + + it('returns disabled tools from file', async () => { + fileReturns({ disabled: ['web_search', 'read_file'] }) + const cfg = await readToolsConfig() + expect(cfg.disabled).toEqual(['web_search', 'read_file']) + }) + + it('returns defaults on read error', async () => { + fileReadError() + const cfg = await readToolsConfig() + expect(cfg.disabled).toEqual([]) + }) +}) + +// ==================== readAgentConfig ==================== + +describe('readAgentConfig', () => { + it('returns defaults when file is missing', async () => { + fileNotFound() + const cfg = await readAgentConfig() + expect(cfg.maxSteps).toBe(20) + expect(cfg.evolutionMode).toBe(false) + }) + + it('parses maxSteps from file', async () => { + fileReturns({ maxSteps: 50 }) + const cfg = await readAgentConfig() + expect(cfg.maxSteps).toBe(50) + }) +}) + +// ==================== readOpenbbConfig ==================== + +describe('readOpenbbConfig', () => { + it('returns defaults when file is missing', async () => { + fileNotFound() + const cfg = await readOpenbbConfig() + expect(cfg.enabled).toBe(true) + expect(cfg.dataBackend).toBe('sdk') + }) + + it('parses enabled flag from file', async () => { + fileReturns({ enabled: false }) + const cfg = await readOpenbbConfig() + expect(cfg.enabled).toBe(false) + }) +}) + +// ==================== writeConfigSection ==================== + +describe('writeConfigSection', () => { + it('validates and writes a section to the correct file', async () => { + const result = await writeConfigSection('tools', { disabled: ['foo'] }) + + expect(mockWriteFile).toHaveBeenCalledOnce() + const filePath = mockWriteFile.mock.calls[0][0] as string + expect(filePath).toMatch(/tools\.json$/) + + const written = JSON.parse(mockWriteFile.mock.calls[0][1] as string) + expect(written.disabled).toEqual(['foo']) + expect(result).toMatchObject({ disabled: ['foo'] }) + }) + + it('applies schema defaults when partial data is provided', async () => { + const result = await writeConfigSection('tools', {}) as { disabled: string[] } + expect(result.disabled).toEqual([]) + }) + + it('throws ZodError for invalid data (does not write file)', async () => { + await expect( + writeConfigSection('aiProvider', { backend: 'invalid-backend-name' }) + ).rejects.toThrow() + // writeFile should not have been called + expect(mockWriteFile).not.toHaveBeenCalled() + }) + + it('writes connectors section to connectors.json', async () => { + await writeConfigSection('connectors', { web: { port: 3005 } }) + const filePath = mockWriteFile.mock.calls[0][0] as string + expect(filePath).toMatch(/connectors\.json$/) + }) +}) + +// ==================== readPlatformsConfig / writeAccountsConfig ==================== + +describe('readPlatformsConfig', () => { + it('returns empty array when file is missing', async () => { + const enoent = new Error('ENOENT') as NodeJS.ErrnoException + enoent.code = 'ENOENT' + mockReadFile.mockRejectedValueOnce(enoent) + const platforms = await readPlatformsConfig() + expect(platforms).toEqual([]) + }) + + it('parses platforms from file', async () => { + fileReturns([{ id: 'bybit-platform', type: 'ccxt', exchange: 'bybit' }]) + const platforms = await readPlatformsConfig() + expect(platforms).toHaveLength(1) + expect(platforms[0].type).toBe('ccxt') + expect((platforms[0] as any).exchange).toBe('bybit') + }) +}) + +describe('readAccountsConfig', () => { + it('returns empty array when file is missing', async () => { + const enoent = new Error('ENOENT') as NodeJS.ErrnoException + enoent.code = 'ENOENT' + mockReadFile.mockRejectedValueOnce(enoent) + const accounts = await readAccountsConfig() + expect(accounts).toEqual([]) + }) + + it('parses accounts from file', async () => { + fileReturns([{ id: 'bybit-main', platformId: 'bybit-platform', apiKey: 'key1', apiSecret: 'sec1' }]) + const accounts = await readAccountsConfig() + expect(accounts).toHaveLength(1) + expect(accounts[0].id).toBe('bybit-main') + expect(accounts[0].platformId).toBe('bybit-platform') + }) +}) + +describe('writePlatformsConfig', () => { + it('writes validated platforms to platforms.json', async () => { + await writePlatformsConfig([{ id: 'alpaca-platform', type: 'alpaca', paper: true }]) + const filePath = mockWriteFile.mock.calls[0][0] as string + expect(filePath).toMatch(/platforms\.json$/) + const written = JSON.parse(mockWriteFile.mock.calls[0][1] as string) + expect(written[0].type).toBe('alpaca') + }) + + it('throws ZodError for invalid platform type', async () => { + await expect( + writePlatformsConfig([{ id: 'bad', type: 'unknown-type' } as any]) + ).rejects.toThrow() + expect(mockWriteFile).not.toHaveBeenCalled() + }) +}) + +describe('writeAccountsConfig', () => { + it('writes validated accounts to accounts.json', async () => { + await writeAccountsConfig([{ id: 'acc-1', platformId: 'plat-1' }]) + const filePath = mockWriteFile.mock.calls[0][0] as string + expect(filePath).toMatch(/accounts\.json$/) + }) +}) + +// ==================== loadTradingConfig ==================== + +describe('loadTradingConfig', () => { + it('returns platforms + accounts directly when both files exist', async () => { + // platforms.json + fileReturns([{ id: 'bybit-p', type: 'ccxt', exchange: 'bybit' }]) + // accounts.json + fileReturns([{ id: 'bybit-main', platformId: 'bybit-p' }]) + + const { platforms, accounts } = await loadTradingConfig() + expect(platforms).toHaveLength(1) + expect(platforms[0].id).toBe('bybit-p') + expect(accounts).toHaveLength(1) + expect(accounts[0].id).toBe('bybit-main') + // No migration write should occur + expect(mockWriteFile).not.toHaveBeenCalled() + }) + + it('migrates from crypto.json + securities.json when platforms.json is missing', async () => { + // platforms.json → ENOENT + fileNotFound() + // accounts.json → ENOENT + fileNotFound() + // crypto.json (loaded inside migrateLegacyTradingConfig) + fileReturns({ + provider: { + type: 'ccxt', + exchange: 'binance', + apiKey: 'k1', + apiSecret: 's1', + sandbox: false, + demoTrading: false, + defaultMarketType: 'spot', + }, + guards: [], + }) + // securities.json + fileReturns({ + provider: { type: 'alpaca', paper: true, apiKey: 'alpk', secretKey: 'alps' }, + guards: [], + }) + + const { platforms, accounts } = await loadTradingConfig() + + expect(platforms.find(p => p.type === 'ccxt')).toBeDefined() + expect(platforms.find(p => p.type === 'alpaca')).toBeDefined() + expect(accounts.find(a => a.id === 'binance-main')).toBeDefined() + expect(accounts.find(a => a.id === 'alpaca-paper')).toBeDefined() + + // Should have written platforms.json and accounts.json + const writtenPaths = mockWriteFile.mock.calls.map(c => c[0] as string) + expect(writtenPaths.some(p => p.endsWith('platforms.json'))).toBe(true) + expect(writtenPaths.some(p => p.endsWith('accounts.json'))).toBe(true) + }) + + it('migrates from legacy with none providers → empty arrays', async () => { + fileNotFound() // platforms.json + fileNotFound() // accounts.json + fileReturns({ provider: { type: 'none' }, guards: [] }) // crypto.json + fileReturns({ provider: { type: 'none' }, guards: [] }) // securities.json + + const { platforms, accounts } = await loadTradingConfig() + expect(platforms).toHaveLength(0) + expect(accounts).toHaveLength(0) + }) + + it('falls back to defaults when legacy files are also missing', async () => { + fileNotFound() // platforms.json + fileNotFound() // accounts.json + fileNotFound() // crypto.json + fileNotFound() // securities.json + + const { platforms, accounts } = await loadTradingConfig() + // Default crypto is ccxt/binance, default securities is alpaca/paper + expect(platforms.find(p => p.type === 'ccxt')).toBeDefined() + expect(platforms.find(p => p.type === 'alpaca')).toBeDefined() + }) +}) + +// ==================== aiProviderSchema (Zod schema validation) ==================== + +describe('aiProviderSchema', () => { + it('accepts valid backends', () => { + for (const backend of ['claude-code', 'vercel-ai-sdk', 'agent-sdk'] as const) { + expect(() => aiProviderSchema.parse({ backend })).not.toThrow() + } + }) + + it('rejects unknown backend', () => { + expect(() => aiProviderSchema.parse({ backend: 'unknown-backend' })).toThrow() + }) + + it('uses defaults for missing fields', () => { + const result = aiProviderSchema.parse({}) + expect(result.backend).toBe('claude-code') + expect(result.provider).toBe('anthropic') + expect(result.model).toBe('claude-sonnet-4-6') + expect(result.apiKeys).toEqual({}) + }) +}) diff --git a/src/core/config.ts b/src/core/config.ts index 66bbb378..a7f51f1d 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -300,14 +300,14 @@ async function parseAndSeed(filename: string, schema: z.ZodType, raw: unkn } export async function loadConfig(): Promise { - const files = ['engine.json', 'agent.json', 'crypto.json', 'securities.json', 'openbb.json', 'compaction.json', 'ai-provider.json', 'heartbeat.json', 'connectors.json', 'news-collector.json', 'tools.json'] as const + const files = ['engine.json', 'agent.json', 'crypto.json', 'securities.json', 'openbb.json', 'compaction.json', 'ai-provider-manager.json', 'heartbeat.json', 'connectors.json', 'news-collector.json', 'tools.json'] as const const raws = await Promise.all(files.map((f) => loadJsonFile(f))) // TODO: remove all migration blocks before v1.0 — no stable release yet, breaking changes are fine // ---------- Migration: consolidate old ai-provider + model + api-keys → ai-provider ---------- const aiProviderRaw = raws[6] as Record | undefined if (aiProviderRaw && !('backend' in aiProviderRaw)) { - // Old format detected — merge model.json + api-keys.json into ai-provider.json + // Old format detected — merge model.json + api-keys.json into ai-provider-manager.json const oldModel = await loadJsonFile('model.json') as Record | undefined const oldKeys = await loadJsonFile('api-keys.json') as Record | undefined const migrated = { @@ -319,7 +319,7 @@ export async function loadConfig(): Promise { } raws[6] = migrated await mkdir(CONFIG_DIR, { recursive: true }) - await writeFile(resolve(CONFIG_DIR, 'ai-provider.json'), JSON.stringify(migrated, null, 2) + '\n') + await writeFile(resolve(CONFIG_DIR, 'ai-provider-manager.json'), JSON.stringify(migrated, null, 2) + '\n') await removeJsonFile('model.json') await removeJsonFile('api-keys.json') } @@ -491,7 +491,7 @@ export async function readAgentConfig() { /** Read AI provider config from disk (called per-request for hot-reload). */ export async function readAIProviderConfig() { try { - const raw = JSON.parse(await readFile(resolve(CONFIG_DIR, 'ai-provider.json'), 'utf-8')) + const raw = JSON.parse(await readFile(resolve(CONFIG_DIR, 'ai-provider-manager.json'), 'utf-8')) return aiProviderSchema.parse(raw) } catch { return aiProviderSchema.parse({}) @@ -522,18 +522,18 @@ export async function readToolsConfig() { export type AIBackend = 'claude-code' | 'vercel-ai-sdk' | 'agent-sdk' -/** Read the current AI backend from ai-provider.json. */ +/** Read the current AI backend from ai-provider-manager.json. */ export async function readAIBackend(): Promise<{ backend: AIBackend }> { const config = await readAIProviderConfig() return { backend: config.backend } } -/** Switch the AI backend in ai-provider.json (preserves other fields). */ +/** Switch the AI backend in ai-provider-manager.json (preserves other fields). */ export async function writeAIBackend(backend: AIBackend): Promise { const current = await readAIProviderConfig() const updated = { ...current, backend } await mkdir(CONFIG_DIR, { recursive: true }) - await writeFile(resolve(CONFIG_DIR, 'ai-provider.json'), JSON.stringify(updated, null, 2) + '\n') + await writeFile(resolve(CONFIG_DIR, 'ai-provider-manager.json'), JSON.stringify(updated, null, 2) + '\n') } // ==================== Writer ==================== @@ -561,7 +561,7 @@ const sectionFiles: Record = { securities: 'securities.json', openbb: 'openbb.json', compaction: 'compaction.json', - aiProvider: 'ai-provider.json', + aiProvider: 'ai-provider-manager.json', heartbeat: 'heartbeat.json', connectors: 'connectors.json', newsCollector: 'news-collector.json', diff --git a/src/core/connector-center.ts b/src/core/connector-center.ts index 241c7638..1c7ee00a 100644 --- a/src/core/connector-center.ts +++ b/src/core/connector-center.ts @@ -12,7 +12,7 @@ import type { EventLog } from './event-log.js' import type { MediaAttachment } from './types.js' -import type { StreamableResult } from './ai-provider.js' +import type { StreamableResult } from './ai-provider-manager.js' import type { Connector, SendPayload, SendResult } from '../connectors/types.js' export type { Connector, SendPayload, SendResult, ConnectorCapabilities } from '../connectors/types.js' diff --git a/src/core/session.spec.ts b/src/core/session.spec.ts index 6bfe5943..31cd33f6 100644 --- a/src/core/session.spec.ts +++ b/src/core/session.spec.ts @@ -1,5 +1,8 @@ -import { describe, it, expect } from 'vitest' -import { toModelMessages, toTextHistory, toChatHistory, type SessionEntry, type ContentBlock } from './session.js' +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { toModelMessages, toTextHistory, toChatHistory, SessionStore, MemorySessionStore, type SessionEntry, type ContentBlock } from './session.js' +import { mkdtemp, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' // ==================== Helpers ==================== @@ -306,3 +309,152 @@ describe('toChatHistory', () => { expect(items).toEqual([]) }) }) + +// ==================== MemorySessionStore ==================== + +describe('MemorySessionStore', () => { + it('should start empty: exists() returns false', async () => { + const store = new MemorySessionStore('m1') + expect(await store.exists()).toBe(false) + }) + + it('should return true from exists() after an append', async () => { + const store = new MemorySessionStore('m1') + await store.appendUser('hello') + expect(await store.exists()).toBe(true) + }) + + it('should persist user message and read it back', async () => { + const store = new MemorySessionStore('m1') + await store.appendUser('user says hi') + const all = await store.readAll() + expect(all).toHaveLength(1) + expect(all[0].type).toBe('user') + expect(all[0].message.content).toBe('user says hi') + }) + + it('should persist assistant message and read it back', async () => { + const store = new MemorySessionStore('m1') + await store.appendAssistant('assistant reply') + const all = await store.readAll() + expect(all).toHaveLength(1) + expect(all[0].type).toBe('assistant') + expect(all[0].provider).toBe('vercel-ai') + }) + + it('should chain UUIDs correctly across multiple appends', async () => { + const store = new MemorySessionStore('m1') + const e1 = await store.appendUser('first') + const e2 = await store.appendAssistant('second') + const e3 = await store.appendUser('third') + expect(e1.parentUuid).toBeNull() + expect(e2.parentUuid).toBe(e1.uuid) + expect(e3.parentUuid).toBe(e2.uuid) + }) + + it('should include correct sessionId on all entries', async () => { + const store = new MemorySessionStore('session-abc') + await store.appendUser('msg') + const all = await store.readAll() + expect(all[0].sessionId).toBe('session-abc') + }) + + it('readActive() should return only entries after last compact_boundary', async () => { + const store = new MemorySessionStore('m2') + await store.appendUser('before boundary') + const boundary: SessionEntry = { + type: 'system', + subtype: 'compact_boundary', + message: { role: 'system', content: 'compacted' }, + uuid: 'b1', + parentUuid: null, + sessionId: 'm2', + timestamp: new Date().toISOString(), + } + await store.appendRaw(boundary) + await store.appendUser('after boundary') + const active = await store.readActive() + // getActiveEntries returns from the boundary onward (inclusive) + expect(active.some(e => e.subtype === 'compact_boundary')).toBe(true) + expect(active.some(e => e.message.content === 'after boundary')).toBe(true) + expect(active.some(e => e.message.content === 'before boundary')).toBe(false) + }) + + it('restore() should set lastUuid so next append chains correctly', async () => { + const store = new MemorySessionStore('m3') + const e1 = await store.appendUser('existing') + // Create a new store instance simulating a fresh load from same data + const store2 = new MemorySessionStore('m3') + await store2.appendRaw(e1) + await store2.restore() + const e2 = await store2.appendUser('new') + expect(e2.parentUuid).toBe(e1.uuid) + }) + + it('appendAssistant() should attach custom metadata when provided', async () => { + const store = new MemorySessionStore('m4') + await store.appendAssistant('reply', 'claude-code', { kind: 'heartbeat' }) + const all = await store.readAll() + expect(all[0].metadata).toEqual({ kind: 'heartbeat' }) + }) +}) + +// ==================== SessionStore (filesystem) ==================== + +describe('SessionStore', () => { + let tmpDir: string + + // HACK: SessionStore uses SESSIONS_DIR = join(process.cwd(), 'data', 'sessions') which is + // a module-level constant, so we can't redirect it. We test using the default path + // by cleaning up after ourselves instead. + // To keep tests isolated we use a custom sessionId that won't clash. + + const testId = `test-session-${Date.now()}` + + afterEach(async () => { + // Clean up session file written during test + const { join: pathJoin } = await import('node:path') + const { rm: fsRm } = await import('node:fs/promises') + try { + await fsRm(pathJoin(process.cwd(), 'data', 'sessions', `${testId}.jsonl`), { force: true }) + } catch { /* ignore */ } + }) + + it('exists() returns false for a new session', async () => { + const store = new SessionStore(testId) + expect(await store.exists()).toBe(false) + }) + + it('appendUser() creates file and can be read back', async () => { + const store = new SessionStore(testId) + await store.appendUser('hello from disk') + expect(await store.exists()).toBe(true) + const all = await store.readAll() + expect(all).toHaveLength(1) + expect(all[0].message.content).toBe('hello from disk') + expect(all[0].sessionId).toBe(testId) + }) + + it('multiple appends chain UUIDs correctly', async () => { + const store = new SessionStore(testId) + const e1 = await store.appendUser('first') + const e2 = await store.appendAssistant('second') + expect(e1.parentUuid).toBeNull() + expect(e2.parentUuid).toBe(e1.uuid) + }) + + it('restore() reads lastUuid from file so next append chains correctly', async () => { + const store = new SessionStore(testId) + const e1 = await store.appendUser('original') + // New store instance (simulates process restart) + const store2 = new SessionStore(testId) + await store2.restore() + const e2 = await store2.appendUser('after restore') + expect(e2.parentUuid).toBe(e1.uuid) + }) + + it('readAll() returns [] for non-existent session (ENOENT)', async () => { + const store = new SessionStore('definitely-does-not-exist-' + Date.now()) + expect(await store.readAll()).toEqual([]) + }) +}) diff --git a/src/core/tool-center.spec.ts b/src/core/tool-center.spec.ts index 85202988..c9542979 100644 --- a/src/core/tool-center.spec.ts +++ b/src/core/tool-center.spec.ts @@ -1,7 +1,14 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi, beforeEach } from 'vitest' import { ToolCenter } from './tool-center.js' import type { Tool } from 'ai' +vi.mock('./config.js', () => ({ + readToolsConfig: vi.fn(), +})) + +import { readToolsConfig } from './config.js' +const mockReadToolsConfig = vi.mocked(readToolsConfig) + // ==================== Helpers ==================== function makeTool(description = 'A test tool'): Tool { @@ -68,12 +75,75 @@ describe('ToolCenter', () => { }) describe('getVercelTools', () => { - it('should return all tools when disabled list is empty (reads from disk)', async () => { - // readToolsConfig reads tools.json — if missing, returns { disabled: [] } + beforeEach(() => { + mockReadToolsConfig.mockResolvedValue({ disabled: [] }) + }) + + it('should return all tools when disabled list is empty', async () => { const tc = new ToolCenter() tc.register({ a: makeTool(), b: makeTool() }, 'g') const tools = await tc.getVercelTools() expect(Object.keys(tools).sort()).toEqual(['a', 'b']) }) + + it('should exclude disabled tools from the result', async () => { + mockReadToolsConfig.mockResolvedValue({ disabled: ['b'] }) + const tc = new ToolCenter() + tc.register({ a: makeTool(), b: makeTool(), c: makeTool() }, 'g') + const tools = await tc.getVercelTools() + expect(Object.keys(tools).sort()).toEqual(['a', 'c']) + }) + + it('should exclude all matching tools when multiple are disabled', async () => { + mockReadToolsConfig.mockResolvedValue({ disabled: ['a', 'c'] }) + const tc = new ToolCenter() + tc.register({ a: makeTool(), b: makeTool(), c: makeTool() }, 'g') + const tools = await tc.getVercelTools() + expect(Object.keys(tools)).toEqual(['b']) + }) + + it('should not error when disabled list contains unknown tool names', async () => { + mockReadToolsConfig.mockResolvedValue({ disabled: ['nonexistent'] }) + const tc = new ToolCenter() + tc.register({ a: makeTool() }, 'g') + const tools = await tc.getVercelTools() + expect(Object.keys(tools)).toEqual(['a']) + }) + + it('should return empty object when all tools are disabled', async () => { + mockReadToolsConfig.mockResolvedValue({ disabled: ['a', 'b'] }) + const tc = new ToolCenter() + tc.register({ a: makeTool(), b: makeTool() }, 'g') + const tools = await tc.getVercelTools() + expect(Object.keys(tools)).toEqual([]) + }) + + it('should return empty object when no tools are registered', async () => { + const tc = new ToolCenter() + const tools = await tc.getVercelTools() + expect(tools).toEqual({}) + }) + }) + + describe('getMcpTools', () => { + beforeEach(() => { + mockReadToolsConfig.mockResolvedValue({ disabled: [] }) + }) + + it('should return same results as getVercelTools when disabled list is empty', async () => { + const tc = new ToolCenter() + tc.register({ x: makeTool(), y: makeTool() }, 'g') + const vercel = await tc.getVercelTools() + const mcp = await tc.getMcpTools() + expect(Object.keys(mcp).sort()).toEqual(Object.keys(vercel).sort()) + }) + + it('should apply disabled list filtering same as getVercelTools', async () => { + mockReadToolsConfig.mockResolvedValue({ disabled: ['x'] }) + const tc = new ToolCenter() + tc.register({ x: makeTool(), y: makeTool() }, 'g') + const tools = await tc.getMcpTools() + expect(Object.keys(tools)).toEqual(['y']) + }) }) }) diff --git a/src/extension/news-collector/rss-parser.spec.ts b/src/extension/news-collector/rss-parser.spec.ts index f363b4c5..210e99f4 100644 --- a/src/extension/news-collector/rss-parser.spec.ts +++ b/src/extension/news-collector/rss-parser.spec.ts @@ -1,5 +1,5 @@ -import { describe, it, expect } from 'vitest' -import { parseRSSXml } from './rss-parser' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { parseRSSXml, fetchAndParseFeed } from './rss-parser' describe('parseRSSXml', () => { it('parses standard RSS 2.0 items', () => { @@ -128,3 +128,69 @@ describe('parseRSSXml', () => { expect(items[0].content).toBe('This is the full article text with much more detail.') }) }) + +// ==================== fetchAndParseFeed ==================== + +const MINIMAL_RSS = ` + TestBody +` + +function mockOkResponse(body: string): Response { + return { + ok: true, + status: 200, + statusText: 'OK', + text: () => Promise.resolve(body), + } as unknown as Response +} + +function mockErrorResponse(status: number, statusText: string): Response { + return { ok: false, status, statusText, text: () => Promise.resolve('') } as unknown as Response +} + +describe('fetchAndParseFeed', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('returns parsed items on successful fetch', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockOkResponse(MINIMAL_RSS)) + const items = await fetchAndParseFeed('https://example.com/feed') + expect(items).toHaveLength(1) + expect(items[0].title).toBe('Test') + }) + + it('retries once after a network failure and succeeds', async () => { + // Patch setTimeout to execute instantly so the test doesn't wait 2s + vi.spyOn(globalThis, 'setTimeout').mockImplementation((fn: any) => { fn(); return 0 as any }) + const fetchSpy = vi.spyOn(globalThis, 'fetch') + .mockRejectedValueOnce(new Error('network error')) + .mockResolvedValueOnce(mockOkResponse(MINIMAL_RSS)) + + const items = await fetchAndParseFeed('https://example.com/feed', 1) + expect(fetchSpy).toHaveBeenCalledTimes(2) + expect(items).toHaveLength(1) + }) + + it('throws after all retries are exhausted', async () => { + vi.spyOn(globalThis, 'setTimeout').mockImplementation((fn: any) => { fn(); return 0 as any }) + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('always fails')) + await expect(fetchAndParseFeed('https://example.com/feed', 1)).rejects.toThrow('always fails') + }) + + it('throws on HTTP error status without retrying if called with retries=0', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockErrorResponse(404, 'Not Found')) + await expect(fetchAndParseFeed('https://example.com/feed', 0)).rejects.toThrow('404') + }) + + it('retries on HTTP error response and succeeds on second attempt', async () => { + vi.spyOn(globalThis, 'setTimeout').mockImplementation((fn: any) => { fn(); return 0 as any }) + const fetchSpy = vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(mockErrorResponse(503, 'Service Unavailable')) + .mockResolvedValueOnce(mockOkResponse(MINIMAL_RSS)) + + const items = await fetchAndParseFeed('https://example.com/feed', 1) + expect(fetchSpy).toHaveBeenCalledTimes(2) + expect(items).toHaveLength(1) + }) +}) diff --git a/src/extension/trading/adapter.spec.ts b/src/extension/trading/adapter.spec.ts new file mode 100644 index 00000000..b1fe59d0 --- /dev/null +++ b/src/extension/trading/adapter.spec.ts @@ -0,0 +1,191 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { resolveAccounts, resolveOne, createTradingTools } from './adapter.js' +import { AccountManager } from './account-manager.js' +import { MockTradingAccount, makePosition, makeContract } from './__test__/mock-account.js' + +// ==================== Helpers ==================== + +function makeManager(...accounts: MockTradingAccount[]): AccountManager { + const mgr = new AccountManager() + for (const acc of accounts) mgr.addAccount(acc) + return mgr +} + +function makeResolver(mgr: AccountManager) { + return { + accountManager: mgr, + getGit: () => undefined, + getGitState: () => undefined, + } +} + +// ==================== resolveAccounts ==================== + +describe('resolveAccounts', () => { + let alpaca: MockTradingAccount + let ccxt: MockTradingAccount + let mgr: AccountManager + + beforeEach(() => { + alpaca = new MockTradingAccount({ id: 'alpaca-paper', provider: 'alpaca', label: 'Alpaca Paper' }) + ccxt = new MockTradingAccount({ id: 'bybit-main', provider: 'ccxt', label: 'Bybit Main' }) + mgr = makeManager(alpaca, ccxt) + }) + + it('returns all accounts when source is not provided', () => { + const results = resolveAccounts(mgr) + expect(results).toHaveLength(2) + expect(results.map((r) => r.id).sort()).toEqual(['alpaca-paper', 'bybit-main']) + }) + + it('returns single account by exact id', () => { + const results = resolveAccounts(mgr, 'alpaca-paper') + expect(results).toHaveLength(1) + expect(results[0].id).toBe('alpaca-paper') + expect(results[0].account).toBe(alpaca) + }) + + it('returns all accounts matching a provider name', () => { + const ccxt2 = new MockTradingAccount({ id: 'binance-main', provider: 'ccxt', label: 'Binance' }) + mgr.addAccount(ccxt2) + const results = resolveAccounts(mgr, 'ccxt') + expect(results).toHaveLength(2) + expect(results.map((r) => r.id).sort()).toEqual(['binance-main', 'bybit-main']) + }) + + it('returns empty array when source matches nothing', () => { + const results = resolveAccounts(mgr, 'nonexistent') + expect(results).toHaveLength(0) + }) + + it('prefers id match over provider match when source matches both', () => { + // Account id equals another account's provider name (edge case) + const special = new MockTradingAccount({ id: 'alpaca', provider: 'mock', label: 'Special' }) + mgr.addAccount(special) + const results = resolveAccounts(mgr, 'alpaca') + // id match returns immediately + expect(results).toHaveLength(1) + expect(results[0].id).toBe('alpaca') + }) +}) + +// ==================== resolveOne ==================== + +describe('resolveOne', () => { + let mgr: AccountManager + + beforeEach(() => { + mgr = makeManager( + new MockTradingAccount({ id: 'alpaca-paper', provider: 'alpaca' }), + new MockTradingAccount({ id: 'bybit-main', provider: 'ccxt' }), + ) + }) + + it('returns the single matching account', () => { + const result = resolveOne(mgr, 'alpaca-paper') + expect(result.id).toBe('alpaca-paper') + }) + + it('throws when no account matches', () => { + expect(() => resolveOne(mgr, 'unknown-id')).toThrow('No account found matching source "unknown-id"') + }) + + it('throws with disambiguation info when multiple accounts match provider', () => { + mgr.addAccount(new MockTradingAccount({ id: 'alpaca-live', provider: 'alpaca' })) + expect(() => resolveOne(mgr, 'alpaca')).toThrow(/Multiple accounts match source "alpaca"/) + }) +}) + +// ==================== createTradingTools: listAccounts ==================== + +describe('createTradingTools — listAccounts', () => { + it('returns summaries for all registered accounts', async () => { + const mgr = makeManager( + new MockTradingAccount({ id: 'acc1', provider: 'alpaca', label: 'Test' }), + ) + const tools = createTradingTools(makeResolver(mgr)) + const result = await (tools.listAccounts.execute as Function)({}) + expect(Array.isArray(result)).toBe(true) + expect(result[0].id).toBe('acc1') + expect(result[0].provider).toBe('alpaca') + }) +}) + +// ==================== createTradingTools: searchContracts ==================== + +describe('createTradingTools — searchContracts', () => { + it('aggregates results from all accounts', async () => { + const a1 = new MockTradingAccount({ id: 'acc1', provider: 'alpaca' }) + const a2 = new MockTradingAccount({ id: 'acc2', provider: 'ccxt' }) + a1.searchContracts.mockResolvedValue([{ contract: makeContract({ symbol: 'AAPL' }) }]) + a2.searchContracts.mockResolvedValue([{ contract: makeContract({ symbol: 'AAPL' }) }]) + const mgr = makeManager(a1, a2) + const tools = createTradingTools(makeResolver(mgr)) + const result = await (tools.searchContracts.execute as Function)({ pattern: 'AAPL' }) + expect(Array.isArray(result)).toBe(true) + expect(result).toHaveLength(2) + expect(result[0].source).toBe('acc1') + expect(result[1].source).toBe('acc2') + }) + + it('returns no-results message when no accounts found anything', async () => { + const a1 = new MockTradingAccount({ id: 'acc1' }) + a1.searchContracts.mockResolvedValue([]) + const mgr = makeManager(a1) + const tools = createTradingTools(makeResolver(mgr)) + const result = await (tools.searchContracts.execute as Function)({ pattern: 'ZZZZ' }) + expect(result.results).toEqual([]) + expect(result.message).toContain('No contracts found') + }) + + it('returns error when no accounts are registered', async () => { + const mgr = new AccountManager() + const tools = createTradingTools(makeResolver(mgr)) + const result = await (tools.searchContracts.execute as Function)({ pattern: 'AAPL' }) + expect(result.error).toBeTruthy() + }) + + it('skips accounts that throw during searchContracts', async () => { + const a1 = new MockTradingAccount({ id: 'acc1' }) + const a2 = new MockTradingAccount({ id: 'acc2' }) + a1.searchContracts.mockRejectedValue(new Error('connection error')) + a2.searchContracts.mockResolvedValue([{ contract: makeContract({ symbol: 'BTC' }) }]) + const mgr = makeManager(a1, a2) + const tools = createTradingTools(makeResolver(mgr)) + const result = await (tools.searchContracts.execute as Function)({ pattern: 'BTC' }) + expect(Array.isArray(result)).toBe(true) + expect(result).toHaveLength(1) + expect(result[0].source).toBe('acc2') + }) +}) + +// ==================== createTradingTools: getPortfolio ==================== + +describe('createTradingTools — getPortfolio', () => { + it('returns all positions when symbol is omitted', async () => { + const acc = new MockTradingAccount({ id: 'acc1' }) + acc.setPositions([ + makePosition({ contract: makeContract({ symbol: 'AAPL' }) }), + makePosition({ contract: makeContract({ symbol: 'TSLA' }) }), + ]) + const mgr = makeManager(acc) + const tools = createTradingTools(makeResolver(mgr)) + const result = await (tools.getPortfolio.execute as Function)({ source: 'acc1' }) + expect(Array.isArray(result)).toBe(true) + expect(result).toHaveLength(2) + }) + + it('filters to specific symbol when provided', async () => { + const acc = new MockTradingAccount({ id: 'acc1' }) + acc.setPositions([ + makePosition({ contract: makeContract({ symbol: 'AAPL' }) }), + makePosition({ contract: makeContract({ symbol: 'TSLA' }) }), + ]) + const mgr = makeManager(acc) + const tools = createTradingTools(makeResolver(mgr)) + const result = await (tools.getPortfolio.execute as Function)({ source: 'acc1', symbol: 'AAPL' }) + expect(Array.isArray(result)).toBe(true) + expect(result).toHaveLength(1) + expect(result[0].symbol).toBe('AAPL') + }) +}) diff --git a/src/extension/trading/factory.spec.ts b/src/extension/trading/factory.spec.ts index ab815896..45173f6d 100644 --- a/src/extension/trading/factory.spec.ts +++ b/src/extension/trading/factory.spec.ts @@ -1,7 +1,15 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { wireAccountTrading } from './factory.js' +import { wireAccountTrading, createAlpacaFromConfig, createCcxtFromConfig } from './factory.js' import { MockTradingAccount, makeOrderResult } from './__test__/mock-account.js' +vi.mock('./providers/alpaca/index.js', () => ({ + AlpacaAccount: vi.fn(function (this: any, cfg: unknown) { this._config = cfg; this.id = 'alpaca-mock'; this.provider = 'alpaca' }), +})) + +vi.mock('./providers/ccxt/index.js', () => ({ + CcxtAccount: vi.fn(function (this: any, cfg: unknown) { this._config = cfg; this.id = 'ccxt-mock'; this.provider = 'ccxt' }), +})) + describe('wireAccountTrading', () => { let account: MockTradingAccount @@ -98,3 +106,61 @@ describe('wireAccountTrading', () => { expect(account.getOrders).toHaveBeenCalled() }) }) + +// ==================== createAlpacaFromConfig ==================== + +describe('createAlpacaFromConfig', () => { + it('returns null when provider.type is none', () => { + const result = createAlpacaFromConfig({ provider: { type: 'none' } } as any) + expect(result).toBeNull() + }) + + it('returns AlpacaAccount instance when provider.type is alpaca', () => { + const result = createAlpacaFromConfig({ + provider: { type: 'alpaca', apiKey: 'key123', secretKey: 'secret456', paper: true }, + } as any) + expect(result).not.toBeNull() + expect((result as any)._config).toMatchObject({ apiKey: 'key123', secretKey: 'secret456', paper: true }) + }) + + it('passes empty strings for missing apiKey/secretKey', () => { + const result = createAlpacaFromConfig({ + provider: { type: 'alpaca', paper: false }, + } as any) + expect((result as any)._config.apiKey).toBe('') + expect((result as any)._config.secretKey).toBe('') + }) +}) + +// ==================== createCcxtFromConfig ==================== + +describe('createCcxtFromConfig', () => { + it('returns null when provider.type is none', () => { + const result = createCcxtFromConfig({ provider: { type: 'none' } } as any) + expect(result).toBeNull() + }) + + it('returns CcxtAccount instance with exchange config', () => { + const result = createCcxtFromConfig({ + provider: { + type: 'bybit', + exchange: 'bybit', + apiKey: 'k', + apiSecret: 's', + sandbox: true, + demoTrading: false, + defaultMarketType: 'swap', + }, + } as any) + expect(result).not.toBeNull() + expect((result as any)._config).toMatchObject({ exchange: 'bybit', sandbox: true }) + }) + + it('passes empty strings for missing apiKey/apiSecret', () => { + const result = createCcxtFromConfig({ + provider: { type: 'bybit', exchange: 'bybit' }, + } as any) + expect((result as any)._config.apiKey).toBe('') + expect((result as any)._config.apiSecret).toBe('') + }) +}) diff --git a/src/extension/trading/providers/alpaca/AlpacaAccount.spec.ts b/src/extension/trading/providers/alpaca/AlpacaAccount.spec.ts index 2969c107..20fb4acb 100644 --- a/src/extension/trading/providers/alpaca/AlpacaAccount.spec.ts +++ b/src/extension/trading/providers/alpaca/AlpacaAccount.spec.ts @@ -1,5 +1,24 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi, beforeEach } from 'vitest' import { computeRealizedPnL } from './alpaca-pnl.js' +import { AlpacaAccount } from './AlpacaAccount.js' + +// ==================== Alpaca SDK mock ==================== + +vi.mock('@alpacahq/alpaca-trade-api', () => { + const MockAlpaca = vi.fn(function (this: any) { + this.getAccount = vi.fn() + this.getPositions = vi.fn() + this.createOrder = vi.fn() + this.replaceOrder = vi.fn() + this.cancelOrder = vi.fn() + this.closePosition = vi.fn() + this.getOrders = vi.fn() + this.getSnapshot = vi.fn() + this.getClock = vi.fn() + this.getAccountActivities = vi.fn() + }) + return { default: MockAlpaca } +}) /** Helper to build a fill activity record. */ function fill(symbol: string, side: 'buy' | 'sell', qty: number, price: number, index = 0) { @@ -122,3 +141,178 @@ describe('computeRealizedPnL', () => { expect(computeRealizedPnL(fills)).toBe(1) }) }) + +// ==================== AlpacaAccount ==================== + +function makeClient(account: any) { + // After construction, Alpaca SDK is mocked — get the instance via the constructor mock + const Alpaca = require('@alpacahq/alpaca-trade-api').default + const instance = new Alpaca.mock.instances[Alpaca.mock.instances.length - 1] + // Actually, we get the client by inspecting mock calls more carefully below. + return account +} + +function getLastAlpacaInstance() { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const Alpaca = require('@alpacahq/alpaca-trade-api').default + return Alpaca.mock.instances[Alpaca.mock.instances.length - 1] +} + +describe('AlpacaAccount — init()', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('throws when no apiKey is configured', async () => { + const acc = new AlpacaAccount({ apiKey: '', secretKey: '' }) + await expect(acc.init()).rejects.toThrow('No API credentials') + }) + + it('throws when no secretKey is configured', async () => { + const acc = new AlpacaAccount({ apiKey: 'key', secretKey: '' }) + await expect(acc.init()).rejects.toThrow('No API credentials') + }) + + it('resolves on successful getAccount()', async () => { + const acc = new AlpacaAccount({ apiKey: 'key', secretKey: 'secret', paper: true }) + // Alpaca constructor is mocked — set up getAccount on the instance + // We need to patch BEFORE init() is called but AFTER AlpacaAccount constructor runs + // The AlpacaAccount constructor does NOT call new Alpaca yet — init() does. + // So we set up the mock inside the test before calling init(). + const { default: Alpaca } = await import('@alpacahq/alpaca-trade-api') + ;(Alpaca as any).mockImplementationOnce(function (this: any) { + this.getAccount = vi.fn().mockResolvedValue({ equity: '50000', paper: true }) + this.getPositions = vi.fn() + this.createOrder = vi.fn() + this.replaceOrder = vi.fn() + this.cancelOrder = vi.fn() + this.closePosition = vi.fn() + this.getOrders = vi.fn() + this.getSnapshot = vi.fn() + this.getClock = vi.fn() + this.getAccountActivities = vi.fn() + }) + await expect(acc.init()).resolves.toBeUndefined() + }) + + it('throws authentication error after MAX_AUTH_RETRIES on 401', async () => { + vi.spyOn(globalThis, 'setTimeout').mockImplementation((fn: any) => { fn(); return 0 as any }) + const acc = new AlpacaAccount({ apiKey: 'bad', secretKey: 'bad', paper: true }) + const { default: Alpaca } = await import('@alpacahq/alpaca-trade-api') + ;(Alpaca as any).mockImplementationOnce(function (this: any) { + this.getAccount = vi.fn().mockRejectedValue(new Error('401 Unauthorized')) + this.getPositions = vi.fn() + this.createOrder = vi.fn() + this.replaceOrder = vi.fn() + this.cancelOrder = vi.fn() + this.closePosition = vi.fn() + this.getOrders = vi.fn() + this.getSnapshot = vi.fn() + this.getClock = vi.fn() + this.getAccountActivities = vi.fn() + }) + await expect(acc.init()).rejects.toThrow('Authentication failed') + }) +}) + +describe('AlpacaAccount — searchContracts()', () => { + it('returns empty array for empty pattern', async () => { + const acc = new AlpacaAccount({ apiKey: 'k', secretKey: 's' }) + const results = await acc.searchContracts('') + expect(results).toEqual([]) + }) + + it('uppercases the pattern and returns a contract', async () => { + const acc = new AlpacaAccount({ apiKey: 'k', secretKey: 's' }) + const results = await acc.searchContracts('aapl') + expect(results).toHaveLength(1) + expect(results[0].contract.symbol).toBe('AAPL') + }) +}) + +describe('AlpacaAccount — placeOrder()', () => { + beforeEach(() => vi.clearAllMocks()) + + it('returns success with orderId on filled order', async () => { + const acc = new AlpacaAccount({ apiKey: 'k', secretKey: 's' }) + const { default: Alpaca } = await import('@alpacahq/alpaca-trade-api') + ;(Alpaca as any).mockImplementationOnce(function (this: any) { + this.getAccount = vi.fn() + this.getPositions = vi.fn() + this.createOrder = vi.fn().mockResolvedValue({ + id: 'ord-1', + status: 'filled', + filled_avg_price: '150.50', + filled_qty: '10', + }) + this.replaceOrder = vi.fn() + this.cancelOrder = vi.fn() + this.closePosition = vi.fn() + this.getOrders = vi.fn() + this.getSnapshot = vi.fn() + this.getClock = vi.fn() + this.getAccountActivities = vi.fn() + }) + await acc.init().catch(() => {}) // init to set up client (will fail without mock account but client is created) + // Directly inject client by triggering init on a partial mock + const { default: AlpacaClass } = await import('@alpacahq/alpaca-trade-api') + ;(acc as any).client = { + createOrder: vi.fn().mockResolvedValue({ + id: 'ord-1', status: 'filled', filled_avg_price: '150.50', filled_qty: '10', + }), + } + const result = await acc.placeOrder({ + contract: { aliceId: 'alpaca-AAPL', symbol: 'AAPL', secType: 'STK', exchange: 'NASDAQ', currency: 'USD' }, + side: 'buy', + type: 'market', + qty: 10, + }) + expect(result.success).toBe(true) + expect(result.orderId).toBe('ord-1') + expect(result.filledPrice).toBe(150.50) + expect(result.filledQty).toBe(10) + }) + + it('returns error when contract resolution fails', async () => { + const acc = new AlpacaAccount({ apiKey: 'k', secretKey: 's' }) + ;(acc as any).client = { createOrder: vi.fn() } + const result = await acc.placeOrder({ + contract: { aliceId: '', symbol: '', secType: 'STK', exchange: '', currency: '' }, + side: 'buy', + type: 'market', + qty: 1, + }) + expect(result.success).toBe(false) + expect(result.error).toContain('Cannot resolve') + }) +}) + +describe('AlpacaAccount — getPositions()', () => { + it('maps raw Alpaca positions to domain Position format', async () => { + const acc = new AlpacaAccount({ apiKey: 'k', secretKey: 's' }) + ;(acc as any).client = { + getPositions: vi.fn().mockResolvedValue([{ + symbol: 'AAPL', + side: 'long', + qty: '10', + avg_entry_price: '150.00', + current_price: '160.00', + market_value: '1600.00', + unrealized_pl: '100.00', + unrealized_plpc: '0.0667', + cost_basis: '1500.00', + }]), + } + const positions = await acc.getPositions() + expect(positions).toHaveLength(1) + expect(positions[0].symbol).toBeUndefined() // fields are on contract + expect(positions[0].contract.symbol).toBe('AAPL') + expect(positions[0].qty).toBe(10) + expect(positions[0].avgEntryPrice).toBe(150) + expect(positions[0].currentPrice).toBe(160) + expect(positions[0].marketValue).toBe(1600) + expect(positions[0].unrealizedPnL).toBe(100) + expect(Math.round(positions[0].unrealizedPnLPercent * 100) / 100).toBeCloseTo(6.67, 1) + expect(positions[0].side).toBe('long') + }) +}) diff --git a/src/extension/trading/providers/ccxt/CcxtAccount.spec.ts b/src/extension/trading/providers/ccxt/CcxtAccount.spec.ts new file mode 100644 index 00000000..7576c4d1 --- /dev/null +++ b/src/extension/trading/providers/ccxt/CcxtAccount.spec.ts @@ -0,0 +1,239 @@ +/** + * CcxtAccount unit tests. + * + * We mock the ccxt module so the constructor doesn't try to reach real exchanges. + * Tests focus on pure logic: searchContracts sorting/filtering, cancelOrder cache, + * placeOrder notional conversion, and the constructor error path. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Mock ccxt BEFORE importing CcxtAccount +vi.mock('ccxt', () => { + // Create a fake exchange class that can be used as a constructor + const MockExchange = vi.fn(function (this: any) { + this.markets = {} + this.options = { fetchMarkets: { types: ['spot', 'linear'] } } + this.setSandboxMode = vi.fn() + this.loadMarkets = vi.fn().mockResolvedValue({}) + this.fetchMarkets = vi.fn().mockResolvedValue([]) + this.fetchTicker = vi.fn() + this.fetchBalance = vi.fn() + this.fetchPositions = vi.fn() + this.fetchOpenOrders = vi.fn() + this.fetchClosedOrders = vi.fn() + this.createOrder = vi.fn() + this.cancelOrder = vi.fn() + this.editOrder = vi.fn() + this.fetchOrder = vi.fn() + this.fetchFundingRate = vi.fn() + this.fetchOrderBook = vi.fn() + }) + + return { + default: { + bybit: MockExchange, + binance: MockExchange, + }, + } +}) + +import { CcxtAccount } from './CcxtAccount.js' + +// ==================== Helpers ==================== + +function makeSpotMarket(base: string, quote: string, symbol?: string): any { + return { + id: symbol ?? `${base}${quote}`, + symbol: symbol ?? `${base}/${quote}`, + base: base.toUpperCase(), + quote: quote.toUpperCase(), + type: 'spot', + active: true, + precision: { price: 0.01 }, + limits: {}, + settle: undefined, + } +} + +function makeSwapMarket(base: string, quote: string, symbol?: string): any { + return { + id: symbol ?? `${base}${quote}`, + symbol: symbol ?? `${base}/${quote}:${quote}`, + base: base.toUpperCase(), + quote: quote.toUpperCase(), + type: 'swap', + active: true, + precision: { price: 0.01 }, + limits: {}, + settle: quote.toUpperCase(), + } +} + +function makeAccount(overrides?: Partial<{ apiKey: string; apiSecret: string }>) { + return new CcxtAccount({ + exchange: 'bybit', + apiKey: overrides?.apiKey ?? 'k', + apiSecret: overrides?.apiSecret ?? 's', + defaultMarketType: 'swap', + }) +} + +function setInitialized(acc: CcxtAccount, markets: Record) { + ;(acc as any).initialized = true + ;(acc as any).exchange.markets = markets +} + +// ==================== Constructor ==================== + +describe('CcxtAccount — constructor', () => { + it('throws for unknown exchange', () => { + expect(() => new CcxtAccount({ exchange: 'unknownxyz', apiKey: 'k', apiSecret: 's', defaultMarketType: 'spot' })).toThrow( + 'Unknown CCXT exchange', + ) + }) + + it('sets readOnly when no apiKey', () => { + const acc = new CcxtAccount({ exchange: 'bybit', apiKey: '', apiSecret: '', defaultMarketType: 'spot' }) + expect((acc as any).readOnly).toBe(true) + }) + + it('uses exchange name as provider', () => { + const acc = makeAccount() + expect(acc.provider).toBe('bybit') + }) + + it('defaults id to exchange-main', () => { + const acc = makeAccount() + expect(acc.id).toBe('bybit-main') + }) +}) + +// ==================== searchContracts ==================== + +describe('CcxtAccount — searchContracts', () => { + let acc: CcxtAccount + + beforeEach(() => { + acc = makeAccount() + setInitialized(acc, { + 'BTC/USDT': makeSpotMarket('BTC', 'USDT', 'BTC/USDT'), + 'BTC/USDT:USDT': makeSwapMarket('BTC', 'USDT', 'BTC/USDT:USDT'), + 'BTC/USD': makeSpotMarket('BTC', 'USD', 'BTC/USD'), + 'ETH/USDT': makeSpotMarket('ETH', 'USDT', 'ETH/USDT'), + }) + }) + + it('returns empty array for empty pattern', async () => { + expect(await acc.searchContracts('')).toEqual([]) + }) + + it('filters by base asset (case-insensitive)', async () => { + const results = await acc.searchContracts('btc') + const symbols = results.map((r) => r.contract.symbol) + expect(symbols.every((s) => s.startsWith('BTC'))).toBe(true) + expect(symbols).not.toContain('ETH/USDT') + }) + + it('only returns USDT/USD/USDC quoted markets', async () => { + ;(acc as any).exchange.markets['BTC/DOGE'] = { ...makeSpotMarket('BTC', 'DOGE'), id: 'BTCDOGE' } + const results = await acc.searchContracts('BTC') + const quotes = results.map((r) => r.contract.currency) + expect(quotes.every((q) => ['USDT', 'USD', 'USDC'].includes(q ?? ''))).toBe(true) + }) + + it('excludes inactive markets', async () => { + ;(acc as any).exchange.markets['BTC/USDC'] = { ...makeSpotMarket('BTC', 'USDC'), active: false } + const before = (await acc.searchContracts('BTC')).length + expect(before).toBe(3) // spot+swap USDT + spot USD (not inactive USDC) + }) + + it('sorts swap before spot when defaultMarketType is swap', async () => { + const results = await acc.searchContracts('BTC') + // USDT swap should come first (swap preference when defaultMarketType=swap) + const first = results[0] + expect((first.contract as any).secType ?? first.contract.symbol.includes(':') ? 'CRYPTO_PERP' : 'CRYPTO').toBeTruthy() + }) +}) + +// ==================== cancelOrder — cache miss ==================== + +describe('CcxtAccount — cancelOrder cache', () => { + it('calls exchange.cancelOrder with undefined symbol when orderId is not in cache', async () => { + const acc = makeAccount() + setInitialized(acc, {}) + ;(acc as any).exchange.cancelOrder = vi.fn().mockResolvedValue({}) + await acc.cancelOrder('order-not-cached') + expect((acc as any).exchange.cancelOrder).toHaveBeenCalledWith('order-not-cached', undefined) + }) + + it('returns false when exchange.cancelOrder throws (cache miss causes undefined symbol)', async () => { + const acc = makeAccount() + setInitialized(acc, {}) + ;(acc as any).exchange.cancelOrder = vi.fn().mockRejectedValue(new Error('symbol required')) + const result = await acc.cancelOrder('order-not-cached') + expect(result).toBe(false) + }) + + it('calls exchange.cancelOrder with correct symbol when orderId is cached', async () => { + const acc = makeAccount() + setInitialized(acc, {}) + ;(acc as any).orderSymbolCache.set('order-123', 'BTC/USDT:USDT') + ;(acc as any).exchange.cancelOrder = vi.fn().mockResolvedValue({}) + const result = await acc.cancelOrder('order-123') + expect(result).toBe(true) + expect((acc as any).exchange.cancelOrder).toHaveBeenCalledWith('order-123', 'BTC/USDT:USDT') + }) +}) + +// ==================== placeOrder — notional conversion ==================== + +describe('CcxtAccount — placeOrder notional', () => { + it('converts notional to size using ticker price when qty is not provided', async () => { + const acc = makeAccount() + setInitialized(acc, { + 'BTC/USDT:USDT': makeSwapMarket('BTC', 'USDT', 'BTC/USDT:USDT'), + }) + ;(acc as any).exchange.fetchTicker = vi.fn().mockResolvedValue({ last: 50_000 }) + ;(acc as any).exchange.createOrder = vi.fn().mockResolvedValue({ + id: 'ord-1', status: 'open', average: undefined, filled: undefined, + }) + + const result = await acc.placeOrder({ + contract: { + aliceId: 'bybit-BTC/USDT:USDT', + symbol: 'BTC/USDT:USDT', + secType: 'CRYPTO_PERP', + exchange: 'bybit', + currency: 'USDT', + }, + side: 'buy', + type: 'market', + notional: 500, // $500 worth of BTC + }) + + expect(result.success).toBe(true) + const createOrderCall = (acc as any).exchange.createOrder.mock.calls[0] + // size = 500 / 50000 = 0.01 BTC + expect(createOrderCall[3]).toBeCloseTo(0.01) + }) + + it('returns error when neither qty nor notional provided', async () => { + const acc = makeAccount() + setInitialized(acc, { + 'BTC/USDT:USDT': makeSwapMarket('BTC', 'USDT', 'BTC/USDT:USDT'), + }) + const result = await acc.placeOrder({ + contract: { + aliceId: 'bybit-BTC/USDT:USDT', + symbol: 'BTC/USDT:USDT', + secType: 'CRYPTO_PERP', + exchange: 'bybit', + currency: 'USDT', + }, + side: 'buy', + type: 'market', + }) + expect(result.success).toBe(false) + expect(result.error).toContain('qty or notional') + }) +}) diff --git a/src/main.ts b/src/main.ts index 01297e25..5135cdc9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -39,7 +39,7 @@ import { SessionStore } from './core/session.js' import { ConnectorCenter } from './core/connector-center.js' import { ToolCenter } from './core/tool-center.js' import { AgentCenter } from './core/agent-center.js' -import { GenerateRouter } from './core/ai-provider.js' +import { GenerateRouter } from './core/ai-provider-manager.js' import { VercelAIProvider } from './ai-providers/vercel-ai-sdk/vercel-provider.js' import { ClaudeCodeProvider } from './ai-providers/claude-code/claude-code-provider.js' import { AgentSdkProvider } from './ai-providers/agent-sdk/agent-sdk-provider.js' diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..06d3a1c4 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config' +import { fileURLToPath } from 'node:url' +import { resolve, dirname } from 'node:path' + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +export default defineConfig({ + resolve: { + alias: { + '@': resolve(__dirname, './src'), + }, + }, +})