From f1ff6064255baefc2ce2d0931cb3fe8984f764d8 Mon Sep 17 00:00:00 2001 From: Ame Date: Sat, 14 Mar 2026 16:18:14 +0800 Subject: [PATCH 1/8] fix(pipeline): remove double media extraction from text-kind providers ClaudeCodeProvider and AgentSdkProvider were calling extractMediaFromToolResultContent inside onToolResult, then forwarding the same content string via tool_result events. AgentCenter's unified pipeline would extract again, causing persistMedia to be called twice for the same file and duplicate image blocks in the session. Fix: remove extraction from both providers; done.media is now always []. AgentCenter remains the single extraction point for tool_result content, consistent with the "shared logic lives in AgentCenter" design. Add A17 regression test that asserts persistMedia is called exactly once when tool_result content contains a MEDIA marker and provider done.media is empty. Co-Authored-By: Claude Sonnet 4.6 --- .../agent-sdk/agent-sdk-provider.ts | 5 +-- .../claude-code/claude-code-provider.ts | 5 +-- .../__tests__/pipeline/persistence.spec.ts | 33 +++++++++++++++++++ 3 files changed, 35 insertions(+), 8 deletions(-) diff --git a/src/ai-providers/agent-sdk/agent-sdk-provider.ts b/src/ai-providers/agent-sdk/agent-sdk-provider.ts index 689c3539..1fffb93b 100644 --- a/src/ai-providers/agent-sdk/agent-sdk-provider.ts +++ b/src/ai-providers/agent-sdk/agent-sdk-provider.ts @@ -13,7 +13,6 @@ import type { Tool } from 'ai' import type { ProviderResult, ProviderEvent, GenerateProvider, 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' @@ -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/claude-code/claude-code-provider.ts b/src/ai-providers/claude-code/claude-code-provider.ts index 5ce5482b..4de5919d 100644 --- a/src/ai-providers/claude-code/claude-code-provider.ts +++ b/src/ai-providers/claude-code/claude-code-provider.ts @@ -12,7 +12,6 @@ import { resolve } from 'node:path' import type { ProviderResult, ProviderEvent, GenerateProvider, 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' @@ -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/core/__tests__/pipeline/persistence.spec.ts b/src/core/__tests__/pipeline/persistence.spec.ts index e79da4ee..b6749ae8 100644 --- a/src/core/__tests__/pipeline/persistence.spec.ts +++ b/src/core/__tests__/pipeline/persistence.spec.ts @@ -426,6 +426,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( [ From b186f1b816eca0ecd532c08e5dd757b97dcb10f6 Mon Sep 17 00:00:00 2001 From: Ame Date: Sat, 14 Mar 2026 16:27:43 +0800 Subject: [PATCH 2/8] =?UTF-8?q?refactor(ai-providers):=20rename=20Generate?= =?UTF-8?q?Provider=20=E2=86=92=20AIProvider=20+=20add=20MockAIProvider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GenerateProvider used a verb as identifier, making it harder to read at a glance. Renamed to AIProvider to match the Connector pattern in connectors/types.ts (noun-as-identity, no prefix). Add src/ai-providers/mock.ts with MockAIProvider (analogous to connectors/mock.ts / MockConnector): captures generateCalls and askCalls for test assertions, configurable inputKind/providerTag/askResult. Also exports event builder helpers (textEvent, toolUseEvent, etc.) previously buried in __tests__/pipeline/helpers.ts. Simplify helpers.ts to re-export MockAIProvider as FakeProvider for backward compatibility with existing pipeline tests. Co-Authored-By: Claude Sonnet 4.6 --- .../agent-sdk/agent-sdk-provider.ts | 4 +- .../claude-code/claude-code-provider.ts | 4 +- src/ai-providers/mock.ts | 85 +++++++++++++++++++ src/ai-providers/types.ts | 2 +- .../vercel-ai-sdk/vercel-provider.ts | 4 +- src/core/__tests__/pipeline/helpers.ts | 68 +++------------ src/core/ai-provider.spec.ts | 4 +- src/core/ai-provider.ts | 16 ++-- 8 files changed, 113 insertions(+), 74 deletions(-) create mode 100644 src/ai-providers/mock.ts diff --git a/src/ai-providers/agent-sdk/agent-sdk-provider.ts b/src/ai-providers/agent-sdk/agent-sdk-provider.ts index 1fffb93b..7cc1aa53 100644 --- a/src/ai-providers/agent-sdk/agent-sdk-provider.ts +++ b/src/ai-providers/agent-sdk/agent-sdk-provider.ts @@ -10,14 +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 { 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 diff --git a/src/ai-providers/claude-code/claude-code-provider.ts b/src/ai-providers/claude-code/claude-code-provider.ts index 4de5919d..d53baf46 100644 --- a/src/ai-providers/claude-code/claude-code-provider.ts +++ b/src/ai-providers/claude-code/claude-code-provider.ts @@ -9,13 +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 { 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 diff --git a/src/ai-providers/mock.ts b/src/ai-providers/mock.ts new file mode 100644 index 00000000..75c3dc7e --- /dev/null +++ b/src/ai-providers/mock.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/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/core/__tests__/pipeline/helpers.ts b/src/core/__tests__/pipeline/helpers.ts index 1634d607..7d443c92 100644 --- a/src/core/__tests__/pipeline/helpers.ts +++ b/src/core/__tests__/pipeline/helpers.ts @@ -1,24 +1,14 @@ /** * 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.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' @@ -26,52 +16,16 @@ export type { SessionEntry, ContentBlock } from '../../session.js' export { MockConnector } from '../../../connectors/mock.js' export type { MockConnectorCall } from '../../../connectors/mock.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.js' +export { textEvent, toolUseEvent, toolResultEvent, doneEvent } from '../../../ai-providers/mock.js' // ==================== Helpers ==================== -/** Create an AgentCenter wired to a FakeProvider. */ -export function makeAgentCenter(provider: FakeProvider): AgentCenter { +import type { MockAIProvider } from '../../../ai-providers/mock.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/ai-provider.spec.ts b/src/core/ai-provider.spec.ts index 9222f503..c768fdcb 100644 --- a/src/core/ai-provider.spec.ts +++ b/src/core/ai-provider.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.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.ts index b8f69911..3f897095 100644 --- a/src/core/ai-provider.ts +++ b/src/core/ai-provider.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' @@ -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 From 54b6de9e6ca13b40637d5ce3f467dd0b1fa1b2e3 Mon Sep 17 00:00:00 2001 From: Ame Date: Sat, 14 Mar 2026 16:30:15 +0800 Subject: [PATCH 3/8] refactor: move mock.ts files into mock/ directories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ai-providers/mock.ts → ai-providers/mock/index.ts connectors/mock.ts → connectors/mock/index.ts Mirrors the directory-per-concern pattern used by all other modules in these packages (vercel-ai-sdk/, claude-code/, agent-sdk/, web/, telegram/, etc.), and gives each mock room to grow without a single flat file. Co-Authored-By: Claude Sonnet 4.6 --- src/ai-providers/mock/index.ts | 85 ++++++++++++++++++++++++++ src/connectors/mock/index.ts | 65 ++++++++++++++++++++ src/core/__tests__/pipeline/helpers.ts | 10 +-- 3 files changed, 155 insertions(+), 5 deletions(-) create mode 100644 src/ai-providers/mock/index.ts create mode 100644 src/connectors/mock/index.ts 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/connectors/mock/index.ts b/src/connectors/mock/index.ts new file mode 100644 index 00000000..be187b41 --- /dev/null +++ b/src/connectors/mock/index.ts @@ -0,0 +1,65 @@ +/** + * Mock connector for testing. + * + * Implements the full Connector interface with configurable capabilities. + * Captures all send/sendStream calls for test assertions while maintaining + * correct behavioral semantics (drains streams, returns delivered: true). + * + * Usage: + * const conn = new MockConnector({ channel: 'test' }) + * centerOrPlugin.register(conn) + * // ... exercise code ... + * expect(conn.calls).toHaveLength(1) + * expect(conn.calls[0].payload.text).toBe('hello') + */ + +import type { Connector, ConnectorCapabilities, SendPayload, SendResult } from './types.js' +import type { StreamableResult } from '../core/ai-provider.js' + +export interface MockConnectorCall { + method: 'send' | 'sendStream' + payload?: SendPayload + stream?: StreamableResult + meta?: Pick +} + +export interface MockConnectorOpts { + channel?: string + to?: string + push?: boolean + media?: boolean + /** Set to false to remove sendStream, forcing ConnectorCenter to fall back to send. */ + sendStream?: boolean +} + +export class MockConnector implements Connector { + readonly channel: string + readonly to: string + readonly capabilities: ConnectorCapabilities + readonly calls: MockConnectorCall[] = [] + + constructor(opts?: MockConnectorOpts) { + this.channel = opts?.channel ?? 'mock' + this.to = opts?.to ?? 'default' + this.capabilities = { + push: opts?.push ?? true, + media: opts?.media ?? false, + } + if (opts?.sendStream === false) { + // Shadow prototype method with undefined so ConnectorCenter falls back to send + ;(this as any).sendStream = undefined + } + } + + async send(payload: SendPayload): Promise { + this.calls.push({ method: 'send', payload }) + return { delivered: true } + } + + async sendStream(stream: StreamableResult, meta?: Pick): Promise { + // Drain the stream to prevent hanging generators + for await (const _e of stream) { /* drain */ } + this.calls.push({ method: 'sendStream', stream, meta }) + return { delivered: true } + } +} diff --git a/src/core/__tests__/pipeline/helpers.ts b/src/core/__tests__/pipeline/helpers.ts index 7d443c92..9560a34c 100644 --- a/src/core/__tests__/pipeline/helpers.ts +++ b/src/core/__tests__/pipeline/helpers.ts @@ -13,16 +13,16 @@ import { DEFAULT_COMPACTION_CONFIG } from '../../compaction.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' // Re-export MockAIProvider as FakeProvider for backward compatibility with existing tests -export { MockAIProvider as FakeProvider } from '../../../ai-providers/mock.js' -export { textEvent, toolUseEvent, toolResultEvent, doneEvent } from '../../../ai-providers/mock.js' +export { MockAIProvider as FakeProvider } from '../../../ai-providers/mock/index.js' +export { textEvent, toolUseEvent, toolResultEvent, doneEvent } from '../../../ai-providers/mock/index.js' // ==================== Helpers ==================== -import type { MockAIProvider } from '../../../ai-providers/mock.js' +import type { MockAIProvider } from '../../../ai-providers/mock/index.js' /** Create an AgentCenter wired to a MockAIProvider. */ export function makeAgentCenter(provider: MockAIProvider): AgentCenter { From c75db34697296bbfa78d13414c2a6f7533b62312 Mon Sep 17 00:00:00 2001 From: Ame Date: Sat, 14 Mar 2026 16:51:02 +0800 Subject: [PATCH 4/8] refactor: consolidate provider utils into ai-providers/utils.ts + add vitest config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add vitest.config.ts with @/ alias (→ src/) so tests can use absolute imports - Merge src/core/provider-utils.ts + src/ai-providers/log-tool-call.ts into src/ai-providers/utils.ts — all shared provider utilities now in one place - Move provider-utils.spec.ts → ai-providers/utils.spec.ts - Update imports in agent-center.ts and all three provider implementations - Fix vi.mock in 5 test files: switch to @/ path + importOriginal to preserve real stripImageData/buildChatHistoryPrompt implementations Co-Authored-By: Claude Sonnet 4.6 --- src/ai-providers/agent-sdk/query.ts | 2 +- src/ai-providers/claude-code/provider.ts | 2 +- src/ai-providers/log-tool-call.ts | 4 - src/ai-providers/mock.ts | 85 ------------------- .../utils.spec.ts} | 2 +- .../utils.ts} | 13 ++- src/ai-providers/vercel-ai-sdk/agent.ts | 2 +- src/connectors/mock.ts | 65 -------------- .../web/__tests__/chat-streaming.spec.ts | 3 +- src/core/__tests__/pipeline/delivery.spec.ts | 3 +- src/core/__tests__/pipeline/e2e.spec.ts | 3 +- .../__tests__/pipeline/persistence.spec.ts | 3 +- src/core/__tests__/pipeline/streaming.spec.ts | 3 +- src/core/agent-center.ts | 3 +- vitest.config.ts | 13 +++ 15 files changed, 37 insertions(+), 169 deletions(-) delete mode 100644 src/ai-providers/log-tool-call.ts delete mode 100644 src/ai-providers/mock.ts rename src/{core/provider-utils.spec.ts => ai-providers/utils.spec.ts} (99%) rename src/{core/provider-utils.ts => ai-providers/utils.ts} (90%) delete mode 100644 src/connectors/mock.ts create mode 100644 vitest.config.ts 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/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.ts b/src/ai-providers/mock.ts deleted file mode 100644 index 75c3dc7e..00000000 --- a/src/ai-providers/mock.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * 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/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/connectors/mock.ts b/src/connectors/mock.ts deleted file mode 100644 index be187b41..00000000 --- a/src/connectors/mock.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Mock connector for testing. - * - * Implements the full Connector interface with configurable capabilities. - * Captures all send/sendStream calls for test assertions while maintaining - * correct behavioral semantics (drains streams, returns delivered: true). - * - * Usage: - * const conn = new MockConnector({ channel: 'test' }) - * centerOrPlugin.register(conn) - * // ... exercise code ... - * expect(conn.calls).toHaveLength(1) - * expect(conn.calls[0].payload.text).toBe('hello') - */ - -import type { Connector, ConnectorCapabilities, SendPayload, SendResult } from './types.js' -import type { StreamableResult } from '../core/ai-provider.js' - -export interface MockConnectorCall { - method: 'send' | 'sendStream' - payload?: SendPayload - stream?: StreamableResult - meta?: Pick -} - -export interface MockConnectorOpts { - channel?: string - to?: string - push?: boolean - media?: boolean - /** Set to false to remove sendStream, forcing ConnectorCenter to fall back to send. */ - sendStream?: boolean -} - -export class MockConnector implements Connector { - readonly channel: string - readonly to: string - readonly capabilities: ConnectorCapabilities - readonly calls: MockConnectorCall[] = [] - - constructor(opts?: MockConnectorOpts) { - this.channel = opts?.channel ?? 'mock' - this.to = opts?.to ?? 'default' - this.capabilities = { - push: opts?.push ?? true, - media: opts?.media ?? false, - } - if (opts?.sendStream === false) { - // Shadow prototype method with undefined so ConnectorCenter falls back to send - ;(this as any).sendStream = undefined - } - } - - async send(payload: SendPayload): Promise { - this.calls.push({ method: 'send', payload }) - return { delivered: true } - } - - async sendStream(stream: StreamableResult, meta?: Pick): Promise { - // Drain the stream to prevent hanging generators - for await (const _e of stream) { /* drain */ } - this.calls.push({ method: 'sendStream', stream, meta }) - return { delivered: true } - } -} diff --git a/src/connectors/web/__tests__/chat-streaming.spec.ts b/src/connectors/web/__tests__/chat-streaming.spec.ts index 125f019e..ed2512d7 100644 --- a/src/connectors/web/__tests__/chat-streaming.spec.ts +++ b/src/connectors/web/__tests__/chat-streaming.spec.ts @@ -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/core/__tests__/pipeline/delivery.spec.ts b/src/core/__tests__/pipeline/delivery.spec.ts index 1fb029fc..5520c320 100644 --- a/src/core/__tests__/pipeline/delivery.spec.ts +++ b/src/core/__tests__/pipeline/delivery.spec.ts @@ -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/persistence.spec.ts b/src/core/__tests__/pipeline/persistence.spec.ts index b6749ae8..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(), })) diff --git a/src/core/__tests__/pipeline/streaming.spec.ts b/src/core/__tests__/pipeline/streaming.spec.ts index 83fb0b27..5d113207 100644 --- a/src/core/__tests__/pipeline/streaming.spec.ts +++ b/src/core/__tests__/pipeline/streaming.spec.ts @@ -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.ts b/src/core/agent-center.ts index 92b98b07..ed59b342 100644 --- a/src/core/agent-center.ts +++ b/src/core/agent-center.ts @@ -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/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'), + }, + }, +}) From bc244c50d557cc1e005202e856ac1751c428da9b Mon Sep 17 00:00:00 2001 From: Ame Date: Sat, 14 Mar 2026 16:54:31 +0800 Subject: [PATCH 5/8] =?UTF-8?q?refactor:=20rename=20ai-provider.ts=20?= =?UTF-8?q?=E2=86=92=20ai-provider-manager.ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches the project's XxxManager naming convention for files that orchestrate multiple implementations of a standard interface (AgentCenter, ConnectorCenter, etc.) rather than being one themselves. Co-Authored-By: Claude Sonnet 4.6 --- package.json | 3 +- pnpm-lock.yaml | 93 +++++++++++++++++++ .../vercel-ai-sdk/model-factory.ts | 2 +- src/connectors/mock/index.ts | 2 +- src/connectors/types.ts | 2 +- .../web/__tests__/chat-streaming.spec.ts | 2 +- src/connectors/web/routes/chat.ts | 2 +- src/connectors/web/web-connector.ts | 2 +- src/core/__tests__/pipeline/delivery.spec.ts | 2 +- src/core/__tests__/pipeline/helpers.ts | 2 +- src/core/__tests__/pipeline/streaming.spec.ts | 2 +- src/core/agent-center.spec.ts | 2 +- src/core/agent-center.ts | 4 +- ...er.spec.ts => ai-provider-manager.spec.ts} | 2 +- ...{ai-provider.ts => ai-provider-manager.ts} | 2 +- src/core/config.ts | 16 ++-- src/core/connector-center.ts | 2 +- src/main.ts | 2 +- 18 files changed, 119 insertions(+), 25 deletions(-) rename src/core/{ai-provider.spec.ts => ai-provider-manager.spec.ts} (99%) rename src/core/{ai-provider.ts => ai-provider-manager.ts} (99%) 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/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/connectors/mock/index.ts b/src/connectors/mock/index.ts index be187b41..58597ae9 100644 --- a/src/connectors/mock/index.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 ed2512d7..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, 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 5520c320..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' diff --git a/src/core/__tests__/pipeline/helpers.ts b/src/core/__tests__/pipeline/helpers.ts index 9560a34c..cb1806bb 100644 --- a/src/core/__tests__/pipeline/helpers.ts +++ b/src/core/__tests__/pipeline/helpers.ts @@ -7,7 +7,7 @@ */ import { AgentCenter } from '../../agent-center.js' -import { GenerateRouter, StreamableResult, type ProviderEvent } from '../../ai-provider.js' +import { GenerateRouter, StreamableResult, type ProviderEvent } from '../../ai-provider-manager.js' import { DEFAULT_COMPACTION_CONFIG } from '../../compaction.js' // Re-export test doubles for convenience diff --git a/src/core/__tests__/pipeline/streaming.spec.ts b/src/core/__tests__/pipeline/streaming.spec.ts index 5d113207..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, 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 ed59b342..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' diff --git a/src/core/ai-provider.spec.ts b/src/core/ai-provider-manager.spec.ts similarity index 99% rename from src/core/ai-provider.spec.ts rename to src/core/ai-provider-manager.spec.ts index c768fdcb..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 AIProvider } 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 ==================== diff --git a/src/core/ai-provider.ts b/src/core/ai-provider-manager.ts similarity index 99% rename from src/core/ai-provider.ts rename to src/core/ai-provider-manager.ts index 3f897095..fe6e63fe 100644 --- a/src/core/ai-provider.ts +++ b/src/core/ai-provider-manager.ts @@ -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' 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/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' From 571e972ff96a2da510743d1209ff842dfa2793c6 Mon Sep 17 00:00:00 2001 From: Ame Date: Sat, 14 Mar 2026 16:56:34 +0800 Subject: [PATCH 6/8] chore: track CLAUDE.md in git + note it is public Remove CLAUDE.md from .gitignore so project context is shared across contributors and machines. Add a note warning not to put sensitive information in it. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 - CLAUDE.md | 102 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 CLAUDE.md 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 From 696a6ae0a8b33bbfbfba6e77e92db2a587f4e984 Mon Sep 17 00:00:00 2001 From: Ame Date: Sat, 14 Mar 2026 17:16:41 +0800 Subject: [PATCH 7/8] test: expand unit test coverage from 62% to 74% statements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add and extend spec files across core and extension modules: - core/config.spec.ts (new): loadJsonFile, parseAndSeed, writeConfigSection, readAIBackend/writeAIBackend, loadTradingConfig (new-format + legacy migration) - core/compaction.spec.ts: compactIfNeeded (none/microcompact/full paths), forceCompact - core/session.spec.ts: MemorySessionStore/SessionStore UUID chaining, readActive, restore(), filesystem I/O - core/tool-center.spec.ts: getVercelTools/getMcpTools disabled-list filtering - ai-providers/vercel-ai-sdk/vercel-provider.spec.ts (new): agent cache invalidation, disabledTools filtering, modelOverride bypass, generate() error path - extension/news-collector/rss-parser.spec.ts: fetchAndParseFeed network retry paths - extension/trading/adapter.spec.ts (new): resolveAccounts/resolveOne, searchContracts aggregation, getPortfolio filtering - extension/trading/factory.spec.ts: createAlpacaFromConfig/createCcxtFromConfig - extension/trading/providers/alpaca/AlpacaAccount.spec.ts: init retries, placeOrder, getPositions - extension/trading/providers/ccxt/CcxtAccount.spec.ts (new): searchContracts sorting and filtering, cancelOrder cache, placeOrder notional conversion Overall: 714 tests passing, statements 61.98% → 73.93%, branches 51.35% → 61.80% Co-Authored-By: Claude Sonnet 4.6 --- .../vercel-ai-sdk/vercel-provider.spec.ts | 168 ++++++++ src/core/compaction.spec.ts | 108 +++++ src/core/config.spec.ts | 392 ++++++++++++++++++ src/core/session.spec.ts | 156 ++++++- src/core/tool-center.spec.ts | 76 +++- .../news-collector/rss-parser.spec.ts | 70 +++- src/extension/trading/adapter.spec.ts | 191 +++++++++ src/extension/trading/factory.spec.ts | 68 ++- .../providers/alpaca/AlpacaAccount.spec.ts | 196 ++++++++- .../providers/ccxt/CcxtAccount.spec.ts | 239 +++++++++++ 10 files changed, 1655 insertions(+), 9 deletions(-) create mode 100644 src/ai-providers/vercel-ai-sdk/vercel-provider.spec.ts create mode 100644 src/core/config.spec.ts create mode 100644 src/extension/trading/adapter.spec.ts create mode 100644 src/extension/trading/providers/ccxt/CcxtAccount.spec.ts 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/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/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') + }) +}) From 3fc71f556769d73824bc45b1e7aa9ccfde3ebfae Mon Sep 17 00:00:00 2001 From: Ame Date: Sat, 14 Mar 2026 17:19:07 +0800 Subject: [PATCH 8/8] fix: restore preview image in README Co-Authored-By: Claude Sonnet 4.6 --- docs/images/preview.png | Bin 0 -> 60120 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/images/preview.png diff --git a/docs/images/preview.png b/docs/images/preview.png new file mode 100644 index 0000000000000000000000000000000000000000..2df539659fb95b7ba6a6c0fddeb72ae3cc1c12e1 GIT binary patch literal 60120 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
$Uf}CZh`E+l!*0s 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 literal 0 HcmV?d00001