diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b47837ca..da7df824 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -115,6 +115,8 @@ jobs: - name: Publish to npmjs if: steps.check.outputs.exists == 'false' working-directory: packages/opentypebb - run: npm publish --registry=https://registry.npmjs.org + run: | + echo "//registry.npmjs.org/:_authToken=\${NODE_AUTH_TOKEN}" > .npmrc + npm publish --registry=https://registry.npmjs.org env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/package.json b/package.json index 3db2f6b1..f69f4e93 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-alice", - "version": "0.9.0-beta.2", + "version": "0.9.0-beta.3", "description": "File-based trading agent engine", "type": "module", "scripts": { diff --git a/packages/opentypebb/package.json b/packages/opentypebb/package.json index 85d6804f..298068f9 100644 --- a/packages/opentypebb/package.json +++ b/packages/opentypebb/package.json @@ -1,6 +1,6 @@ { "name": "@traderalice/opentypebb", - "version": "0.1.0", + "version": "0.1.1", "description": "TypeScript port of OpenBB Platform — financial data infrastructure", "type": "module", "exports": { diff --git a/packages/opentypebb/src/core/api/rest-api.ts b/packages/opentypebb/src/core/api/rest-api.ts index c5628562..065095ad 100644 --- a/packages/opentypebb/src/core/api/rest-api.ts +++ b/packages/opentypebb/src/core/api/rest-api.ts @@ -1,12 +1,13 @@ /** * REST API setup using Hono. - * Maps to: openbb_core/api/rest_api.py + * Maps to: openbb_core/api/rest_api.py + platform_api/main.py * * Creates the Hono app with: * - CORS middleware * - Default credential injection middleware * - Error handling * - Health check endpoint + * - /widgets.json endpoint (for OpenBB Workspace frontend) */ import { Hono } from 'hono' @@ -14,6 +15,8 @@ import { cors } from 'hono/cors' import { serve } from '@hono/node-server' import type { Credentials } from '../app/model/credentials.js' +const OBB_HEADERS = { 'X-Backend-Type': 'OpenBB Platform' } + /** * Create the Hono app with middleware configured. * Maps to: the FastAPI app creation in rest_api.py @@ -35,6 +38,26 @@ export function createApp( return app } +/** + * Mount the /widgets.json endpoint on the app. + * Maps to: @app.get("/widgets.json") in platform_api/main.py + * + * The widgets config is generated once at startup and cached. + * This is the endpoint that the OpenBB Workspace frontend fetches + * to discover available data widgets. + * + * @param app - The Hono app + * @param widgetsJson - Pre-built widgets configuration + */ +export function mountWidgetsEndpoint( + app: Hono, + widgetsJson: Record, +): void { + app.get('/widgets.json', (c) => { + return c.json(widgetsJson, 200, OBB_HEADERS) + }) +} + /** * Start the HTTP server. * Maps to: uvicorn.run() in rest_api.py diff --git a/packages/opentypebb/src/core/api/schema-registry.ts b/packages/opentypebb/src/core/api/schema-registry.ts new file mode 100644 index 00000000..56aa836b --- /dev/null +++ b/packages/opentypebb/src/core/api/schema-registry.ts @@ -0,0 +1,295 @@ +/** + * Schema Registry — maps model names to their standard-model Zod schemas. + * + * In Python OpenBB, FastAPI auto-generates OpenAPI from Pydantic models. + * Here we maintain an explicit registry so buildWidgetsJson() can introspect + * Zod schemas for query params (→ widget form fields) and data (→ table columns). + * + * Models not in this registry will still get basic widget configs, just without + * detailed param/column definitions. + */ + +import type { ZodObject } from 'zod' + +import { EquityHistoricalQueryParamsSchema, EquityHistoricalDataSchema } from '../../standard-models/equity-historical.js' +import { EquityInfoQueryParamsSchema, EquityInfoDataSchema } from '../../standard-models/equity-info.js' +import { EquityQuoteQueryParamsSchema, EquityQuoteDataSchema } from '../../standard-models/equity-quote.js' +import { CompanyNewsQueryParamsSchema, CompanyNewsDataSchema } from '../../standard-models/company-news.js' +import { WorldNewsQueryParamsSchema, WorldNewsDataSchema } from '../../standard-models/world-news.js' +import { CryptoHistoricalQueryParamsSchema, CryptoHistoricalDataSchema } from '../../standard-models/crypto-historical.js' +import { CurrencyHistoricalQueryParamsSchema, CurrencyHistoricalDataSchema } from '../../standard-models/currency-historical.js' +import { BalanceSheetQueryParamsSchema, BalanceSheetDataSchema } from '../../standard-models/balance-sheet.js' +import { IncomeStatementQueryParamsSchema, IncomeStatementDataSchema } from '../../standard-models/income-statement.js' +import { CashFlowStatementQueryParamsSchema, CashFlowStatementDataSchema } from '../../standard-models/cash-flow.js' +import { FinancialRatiosQueryParamsSchema, FinancialRatiosDataSchema } from '../../standard-models/financial-ratios.js' +import { KeyMetricsQueryParamsSchema, KeyMetricsDataSchema } from '../../standard-models/key-metrics.js' +import { InsiderTradingQueryParamsSchema, InsiderTradingDataSchema } from '../../standard-models/insider-trading.js' +import { CalendarEarningsQueryParamsSchema, CalendarEarningsDataSchema } from '../../standard-models/calendar-earnings.js' +import { EquityDiscoveryQueryParamsSchema, EquityDiscoveryDataSchema } from '../../standard-models/equity-discovery.js' +import { PriceTargetConsensusQueryParamsSchema, PriceTargetConsensusDataSchema } from '../../standard-models/price-target-consensus.js' +import { CryptoSearchQueryParamsSchema, CryptoSearchDataSchema } from '../../standard-models/crypto-search.js' +import { CurrencyPairsQueryParamsSchema, CurrencyPairsDataSchema } from '../../standard-models/currency-pairs.js' +import { EquityPerformanceQueryParamsSchema, EquityPerformanceDataSchema } from '../../standard-models/equity-performance.js' +import { BalanceSheetGrowthQueryParamsSchema, BalanceSheetGrowthDataSchema } from '../../standard-models/balance-sheet-growth.js' +import { IncomeStatementGrowthQueryParamsSchema, IncomeStatementGrowthDataSchema } from '../../standard-models/income-statement-growth.js' +import { CashFlowStatementGrowthQueryParamsSchema, CashFlowStatementGrowthDataSchema } from '../../standard-models/cash-flow-growth.js' +import { CalendarDividendQueryParamsSchema, CalendarDividendDataSchema } from '../../standard-models/calendar-dividend.js' +import { CalendarSplitsQueryParamsSchema, CalendarSplitsDataSchema } from '../../standard-models/calendar-splits.js' +import { CalendarIpoQueryParamsSchema, CalendarIpoDataSchema } from '../../standard-models/calendar-ipo.js' +import { EconomicCalendarQueryParamsSchema, EconomicCalendarDataSchema } from '../../standard-models/economic-calendar.js' +import { AnalystEstimatesQueryParamsSchema, AnalystEstimatesDataSchema } from '../../standard-models/analyst-estimates.js' +import { ForwardEpsEstimatesQueryParamsSchema, ForwardEpsEstimatesDataSchema } from '../../standard-models/forward-eps-estimates.js' +import { ForwardEbitdaEstimatesQueryParamsSchema, ForwardEbitdaEstimatesDataSchema } from '../../standard-models/forward-ebitda-estimates.js' +import { PriceTargetQueryParamsSchema, PriceTargetDataSchema } from '../../standard-models/price-target.js' +import { EtfInfoQueryParamsSchema, EtfInfoDataSchema } from '../../standard-models/etf-info.js' +import { EtfHoldingsQueryParamsSchema, EtfHoldingsDataSchema } from '../../standard-models/etf-holdings.js' +import { EtfSectorsQueryParamsSchema, EtfSectorsDataSchema } from '../../standard-models/etf-sectors.js' +import { EtfCountriesQueryParamsSchema, EtfCountriesDataSchema } from '../../standard-models/etf-countries.js' +import { EtfEquityExposureQueryParamsSchema, EtfEquityExposureDataSchema } from '../../standard-models/etf-equity-exposure.js' +import { EtfSearchQueryParamsSchema, EtfSearchDataSchema } from '../../standard-models/etf-search.js' +import { KeyExecutivesQueryParamsSchema, KeyExecutivesDataSchema } from '../../standard-models/key-executives.js' +import { ExecutiveCompensationQueryParamsSchema, ExecutiveCompensationDataSchema } from '../../standard-models/executive-compensation.js' +import { GovernmentTradesQueryParamsSchema, GovernmentTradesDataSchema } from '../../standard-models/government-trades.js' +import { InstitutionalOwnershipQueryParamsSchema, InstitutionalOwnershipDataSchema } from '../../standard-models/institutional-ownership.js' +import { HistoricalDividendsQueryParamsSchema, HistoricalDividendsDataSchema } from '../../standard-models/historical-dividends.js' +import { HistoricalSplitsQueryParamsSchema, HistoricalSplitsDataSchema } from '../../standard-models/historical-splits.js' +import { HistoricalEpsQueryParamsSchema, HistoricalEpsDataSchema } from '../../standard-models/historical-eps.js' +import { HistoricalEmployeesQueryParamsSchema, HistoricalEmployeesDataSchema } from '../../standard-models/historical-employees.js' +import { ShareStatisticsQueryParamsSchema, ShareStatisticsDataSchema } from '../../standard-models/share-statistics.js' +import { EquityPeersQueryParamsSchema, EquityPeersDataSchema } from '../../standard-models/equity-peers.js' +import { EquityScreenerQueryParamsSchema, EquityScreenerDataSchema } from '../../standard-models/equity-screener.js' +import { CompanyFilingsQueryParamsSchema, CompanyFilingsDataSchema } from '../../standard-models/company-filings.js' +import { MarketSnapshotsQueryParamsSchema, MarketSnapshotsDataSchema } from '../../standard-models/market-snapshots.js' +import { CurrencySnapshotsQueryParamsSchema, CurrencySnapshotsDataSchema } from '../../standard-models/currency-snapshots.js' +import { AvailableIndicesQueryParamsSchema, AvailableIndicesDataSchema } from '../../standard-models/available-indices.js' +import { IndexConstituentsQueryParamsSchema, IndexConstituentsDataSchema } from '../../standard-models/index-constituents.js' +import { IndexHistoricalQueryParamsSchema, IndexHistoricalDataSchema } from '../../standard-models/index-historical.js' +import { RiskPremiumQueryParamsSchema, RiskPremiumDataSchema } from '../../standard-models/risk-premium.js' +import { TreasuryRatesQueryParamsSchema, TreasuryRatesDataSchema } from '../../standard-models/treasury-rates.js' +import { RevenueBusinessLineQueryParamsSchema, RevenueBusinessLineDataSchema } from '../../standard-models/revenue-business-line.js' +import { RevenueGeographicQueryParamsSchema, RevenueGeographicDataSchema } from '../../standard-models/revenue-geographic.js' +import { EarningsCallTranscriptQueryParamsSchema, EarningsCallTranscriptDataSchema } from '../../standard-models/earnings-call-transcript.js' +import { DiscoveryFilingsQueryParamsSchema, DiscoveryFilingsDataSchema } from '../../standard-models/discovery-filings.js' +import { HistoricalMarketCapQueryParamsSchema, HistoricalMarketCapDataSchema } from '../../standard-models/historical-market-cap.js' +import { EsgScoreQueryParamsSchema, EsgScoreDataSchema } from '../../standard-models/esg-score.js' +import { FuturesHistoricalQueryParamsSchema, FuturesHistoricalDataSchema } from '../../standard-models/futures-historical.js' +import { FuturesCurveQueryParamsSchema, FuturesCurveDataSchema } from '../../standard-models/futures-curve.js' +import { FuturesInfoQueryParamsSchema, FuturesInfoDataSchema } from '../../standard-models/futures-info.js' +import { FuturesInstrumentsQueryParamsSchema, FuturesInstrumentsDataSchema } from '../../standard-models/futures-instruments.js' +import { OptionsChainsQueryParamsSchema, OptionsChainsDataSchema } from '../../standard-models/options-chains.js' +import { OptionsSnapshotsQueryParamsSchema, OptionsSnapshotsDataSchema } from '../../standard-models/options-snapshots.js' +import { OptionsUnusualQueryParamsSchema, OptionsUnusualDataSchema } from '../../standard-models/options-unusual.js' +import { IndexSearchQueryParamsSchema, IndexSearchDataSchema } from '../../standard-models/index-search.js' +import { IndexSectorsQueryParamsSchema, IndexSectorsDataSchema } from '../../standard-models/index-sectors.js' +import { SP500MultiplesQueryParamsSchema, SP500MultiplesDataSchema } from '../../standard-models/sp500-multiples.js' +import { AvailableIndicatorsQueryParamsSchema, AvailableIndicatorsDataSchema } from '../../standard-models/available-indicators.js' +import { ConsumerPriceIndexQueryParamsSchema, ConsumerPriceIndexDataSchema } from '../../standard-models/consumer-price-index.js' +import { CompositeLeadingIndicatorQueryParamsSchema, CompositeLeadingIndicatorDataSchema } from '../../standard-models/composite-leading-indicator.js' +import { CountryInterestRatesQueryParamsSchema, CountryInterestRatesDataSchema } from '../../standard-models/country-interest-rates.js' +import { BalanceOfPaymentsQueryParamsSchema, BalanceOfPaymentsDataSchema } from '../../standard-models/balance-of-payments.js' +import { CentralBankHoldingsQueryParamsSchema, CentralBankHoldingsDataSchema } from '../../standard-models/central-bank-holdings.js' +import { CountryProfileQueryParamsSchema, CountryProfileDataSchema } from '../../standard-models/country-profile.js' +import { DirectionOfTradeQueryParamsSchema, DirectionOfTradeDataSchema } from '../../standard-models/direction-of-trade.js' +import { ExportDestinationsQueryParamsSchema, ExportDestinationsDataSchema } from '../../standard-models/export-destinations.js' +import { EconomicIndicatorsQueryParamsSchema, EconomicIndicatorsDataSchema } from '../../standard-models/economic-indicators.js' +import { FredSearchQueryParamsSchema, FredSearchDataSchema } from '../../standard-models/fred-search.js' +import { FredSeriesQueryParamsSchema, FredSeriesDataSchema } from '../../standard-models/fred-series.js' +import { FredReleaseTableQueryParamsSchema, FredReleaseTableDataSchema } from '../../standard-models/fred-release-table.js' +import { FredRegionalQueryParamsSchema, FredRegionalDataSchema } from '../../standard-models/fred-regional.js' +import { UnemploymentQueryParamsSchema, UnemploymentDataSchema } from '../../standard-models/unemployment.js' +import { MoneyMeasuresQueryParamsSchema, MoneyMeasuresDataSchema } from '../../standard-models/money-measures.js' +import { PersonalConsumptionExpendituresQueryParamsSchema, PersonalConsumptionExpendituresDataSchema } from '../../standard-models/pce.js' +import { TotalFactorProductivityQueryParamsSchema, TotalFactorProductivityDataSchema } from '../../standard-models/total-factor-productivity.js' +import { FomcDocumentsQueryParamsSchema, FomcDocumentsDataSchema } from '../../standard-models/fomc-documents.js' +import { PrimaryDealerPositioningQueryParamsSchema, PrimaryDealerPositioningDataSchema } from '../../standard-models/primary-dealer-positioning.js' +import { PrimaryDealerFailsQueryParamsSchema, PrimaryDealerFailsDataSchema } from '../../standard-models/primary-dealer-fails.js' +import { NonfarmPayrollsQueryParamsSchema, NonfarmPayrollsDataSchema } from '../../standard-models/nonfarm-payrolls.js' +import { InflationExpectationsQueryParamsSchema, InflationExpectationsDataSchema } from '../../standard-models/inflation-expectations.js' +import { SloosQueryParamsSchema, SloosDataSchema } from '../../standard-models/sloos.js' +import { UniversityOfMichiganQueryParamsSchema, UniversityOfMichiganDataSchema } from '../../standard-models/university-of-michigan.js' +import { EconomicConditionsChicagoQueryParamsSchema, EconomicConditionsChicagoDataSchema } from '../../standard-models/economic-conditions-chicago.js' +import { ManufacturingOutlookNYQueryParamsSchema, ManufacturingOutlookNYDataSchema } from '../../standard-models/manufacturing-outlook-ny.js' +import { ManufacturingOutlookTexasQueryParamsSchema, ManufacturingOutlookTexasDataSchema } from '../../standard-models/manufacturing-outlook-texas.js' +import { GdpForecastQueryParamsSchema, GdpForecastDataSchema } from '../../standard-models/gdp-forecast.js' +import { GdpNominalQueryParamsSchema, GdpNominalDataSchema } from '../../standard-models/gdp-nominal.js' +import { GdpRealQueryParamsSchema, GdpRealDataSchema } from '../../standard-models/gdp-real.js' +import { SharePriceIndexQueryParamsSchema, SharePriceIndexDataSchema } from '../../standard-models/share-price-index.js' +import { HousePriceIndexQueryParamsSchema, HousePriceIndexDataSchema } from '../../standard-models/house-price-index.js' +import { RetailPricesQueryParamsSchema, RetailPricesDataSchema } from '../../standard-models/retail-prices.js' +import { BlsSeriesQueryParamsSchema, BlsSeriesDataSchema } from '../../standard-models/bls-series.js' +import { BlsSearchQueryParamsSchema, BlsSearchDataSchema } from '../../standard-models/bls-search.js' +import { CommoditySpotPriceQueryParamsSchema, CommoditySpotPriceDataSchema } from '../../standard-models/commodity-spot-price.js' +import { PetroleumStatusReportQueryParamsSchema, PetroleumStatusReportDataSchema } from '../../standard-models/petroleum-status-report.js' +import { ShortTermEnergyOutlookQueryParamsSchema, ShortTermEnergyOutlookDataSchema } from '../../standard-models/short-term-energy-outlook.js' +import { PortInfoQueryParamsSchema, PortInfoDataSchema } from '../../standard-models/port-info.js' +import { PortVolumeQueryParamsSchema, PortVolumeDataSchema } from '../../standard-models/port-volume.js' +import { ChokepointInfoQueryParamsSchema, ChokepointInfoDataSchema } from '../../standard-models/chokepoint-info.js' +import { ChokepointVolumeQueryParamsSchema, ChokepointVolumeDataSchema } from '../../standard-models/chokepoint-volume.js' + +export interface ModelSchemas { + queryParams: ZodObject + data: ZodObject +} + +/** + * Registry mapping model names (as used in Router commands) to their + * standard-model Zod schemas for query params and response data. + */ +export const SCHEMA_REGISTRY: Record = { + // --- Equity --- + EquityHistorical: { queryParams: EquityHistoricalQueryParamsSchema, data: EquityHistoricalDataSchema }, + EquityInfo: { queryParams: EquityInfoQueryParamsSchema, data: EquityInfoDataSchema }, + EquityQuote: { queryParams: EquityQuoteQueryParamsSchema, data: EquityQuoteDataSchema }, + EquityScreener: { queryParams: EquityScreenerQueryParamsSchema, data: EquityScreenerDataSchema }, + EquityPeers: { queryParams: EquityPeersQueryParamsSchema, data: EquityPeersDataSchema }, + MarketSnapshots: { queryParams: MarketSnapshotsQueryParamsSchema, data: MarketSnapshotsDataSchema }, + HistoricalMarketCap: { queryParams: HistoricalMarketCapQueryParamsSchema, data: HistoricalMarketCapDataSchema }, + PricePerformance: { queryParams: EquityPerformanceQueryParamsSchema, data: EquityPerformanceDataSchema }, + + // Equity Discovery (all use the same EquityDiscovery schema) + EquityGainers: { queryParams: EquityDiscoveryQueryParamsSchema, data: EquityDiscoveryDataSchema }, + EquityLosers: { queryParams: EquityDiscoveryQueryParamsSchema, data: EquityDiscoveryDataSchema }, + EquityActive: { queryParams: EquityDiscoveryQueryParamsSchema, data: EquityDiscoveryDataSchema }, + + // Equity Fundamental + BalanceSheet: { queryParams: BalanceSheetQueryParamsSchema, data: BalanceSheetDataSchema }, + BalanceSheetGrowth: { queryParams: BalanceSheetGrowthQueryParamsSchema, data: BalanceSheetGrowthDataSchema }, + IncomeStatement: { queryParams: IncomeStatementQueryParamsSchema, data: IncomeStatementDataSchema }, + IncomeStatementGrowth: { queryParams: IncomeStatementGrowthQueryParamsSchema, data: IncomeStatementGrowthDataSchema }, + CashFlowStatement: { queryParams: CashFlowStatementQueryParamsSchema, data: CashFlowStatementDataSchema }, + CashFlowStatementGrowth: { queryParams: CashFlowStatementGrowthQueryParamsSchema, data: CashFlowStatementGrowthDataSchema }, + FinancialRatios: { queryParams: FinancialRatiosQueryParamsSchema, data: FinancialRatiosDataSchema }, + KeyMetrics: { queryParams: KeyMetricsQueryParamsSchema, data: KeyMetricsDataSchema }, + KeyExecutives: { queryParams: KeyExecutivesQueryParamsSchema, data: KeyExecutivesDataSchema }, + ExecutiveCompensation: { queryParams: ExecutiveCompensationQueryParamsSchema, data: ExecutiveCompensationDataSchema }, + HistoricalDividends: { queryParams: HistoricalDividendsQueryParamsSchema, data: HistoricalDividendsDataSchema }, + HistoricalSplits: { queryParams: HistoricalSplitsQueryParamsSchema, data: HistoricalSplitsDataSchema }, + HistoricalEps: { queryParams: HistoricalEpsQueryParamsSchema, data: HistoricalEpsDataSchema }, + HistoricalEmployees: { queryParams: HistoricalEmployeesQueryParamsSchema, data: HistoricalEmployeesDataSchema }, + CompanyFilings: { queryParams: CompanyFilingsQueryParamsSchema, data: CompanyFilingsDataSchema }, + RevenueGeographic: { queryParams: RevenueGeographicQueryParamsSchema, data: RevenueGeographicDataSchema }, + RevenueBusinessLine: { queryParams: RevenueBusinessLineQueryParamsSchema, data: RevenueBusinessLineDataSchema }, + EarningsCallTranscript: { queryParams: EarningsCallTranscriptQueryParamsSchema, data: EarningsCallTranscriptDataSchema }, + EsgScore: { queryParams: EsgScoreQueryParamsSchema, data: EsgScoreDataSchema }, + ShareStatistics: { queryParams: ShareStatisticsQueryParamsSchema, data: ShareStatisticsDataSchema }, + + // Equity Ownership + InsiderTrading: { queryParams: InsiderTradingQueryParamsSchema, data: InsiderTradingDataSchema }, + InstitutionalOwnership: { queryParams: InstitutionalOwnershipQueryParamsSchema, data: InstitutionalOwnershipDataSchema }, + GovernmentTrades: { queryParams: GovernmentTradesQueryParamsSchema, data: GovernmentTradesDataSchema }, + + // Equity Calendar + CalendarEarnings: { queryParams: CalendarEarningsQueryParamsSchema, data: CalendarEarningsDataSchema }, + CalendarDividend: { queryParams: CalendarDividendQueryParamsSchema, data: CalendarDividendDataSchema }, + CalendarSplits: { queryParams: CalendarSplitsQueryParamsSchema, data: CalendarSplitsDataSchema }, + CalendarIpo: { queryParams: CalendarIpoQueryParamsSchema, data: CalendarIpoDataSchema }, + + // Equity Estimates + PriceTarget: { queryParams: PriceTargetQueryParamsSchema, data: PriceTargetDataSchema }, + PriceTargetConsensus: { queryParams: PriceTargetConsensusQueryParamsSchema, data: PriceTargetConsensusDataSchema }, + AnalystEstimates: { queryParams: AnalystEstimatesQueryParamsSchema, data: AnalystEstimatesDataSchema }, + ForwardEpsEstimates: { queryParams: ForwardEpsEstimatesQueryParamsSchema, data: ForwardEpsEstimatesDataSchema }, + ForwardEbitdaEstimates: { queryParams: ForwardEbitdaEstimatesQueryParamsSchema, data: ForwardEbitdaEstimatesDataSchema }, + + // --- News --- + CompanyNews: { queryParams: CompanyNewsQueryParamsSchema, data: CompanyNewsDataSchema }, + WorldNews: { queryParams: WorldNewsQueryParamsSchema, data: WorldNewsDataSchema }, + + // --- Crypto --- + CryptoHistorical: { queryParams: CryptoHistoricalQueryParamsSchema, data: CryptoHistoricalDataSchema }, + CryptoSearch: { queryParams: CryptoSearchQueryParamsSchema, data: CryptoSearchDataSchema }, + + // --- Currency --- + CurrencyHistorical: { queryParams: CurrencyHistoricalQueryParamsSchema, data: CurrencyHistoricalDataSchema }, + CurrencyPairs: { queryParams: CurrencyPairsQueryParamsSchema, data: CurrencyPairsDataSchema }, + CurrencySnapshots: { queryParams: CurrencySnapshotsQueryParamsSchema, data: CurrencySnapshotsDataSchema }, + + // --- ETF --- + EtfInfo: { queryParams: EtfInfoQueryParamsSchema, data: EtfInfoDataSchema }, + EtfHoldings: { queryParams: EtfHoldingsQueryParamsSchema, data: EtfHoldingsDataSchema }, + EtfSectors: { queryParams: EtfSectorsQueryParamsSchema, data: EtfSectorsDataSchema }, + EtfCountries: { queryParams: EtfCountriesQueryParamsSchema, data: EtfCountriesDataSchema }, + EtfEquityExposure: { queryParams: EtfEquityExposureQueryParamsSchema, data: EtfEquityExposureDataSchema }, + EtfSearch: { queryParams: EtfSearchQueryParamsSchema, data: EtfSearchDataSchema }, + EtfHistorical: { queryParams: EquityHistoricalQueryParamsSchema, data: EquityHistoricalDataSchema }, + + // --- Index --- + AvailableIndices: { queryParams: AvailableIndicesQueryParamsSchema, data: AvailableIndicesDataSchema }, + IndexConstituents: { queryParams: IndexConstituentsQueryParamsSchema, data: IndexConstituentsDataSchema }, + IndexHistorical: { queryParams: IndexHistoricalQueryParamsSchema, data: IndexHistoricalDataSchema }, + RiskPremium: { queryParams: RiskPremiumQueryParamsSchema, data: RiskPremiumDataSchema }, + IndexSearch: { queryParams: IndexSearchQueryParamsSchema, data: IndexSearchDataSchema }, + IndexSectors: { queryParams: IndexSectorsQueryParamsSchema, data: IndexSectorsDataSchema }, + SP500Multiples: { queryParams: SP500MultiplesQueryParamsSchema, data: SP500MultiplesDataSchema }, + + // --- Derivatives --- + FuturesHistorical: { queryParams: FuturesHistoricalQueryParamsSchema, data: FuturesHistoricalDataSchema }, + FuturesCurve: { queryParams: FuturesCurveQueryParamsSchema, data: FuturesCurveDataSchema }, + FuturesInfo: { queryParams: FuturesInfoQueryParamsSchema, data: FuturesInfoDataSchema }, + FuturesInstruments: { queryParams: FuturesInstrumentsQueryParamsSchema, data: FuturesInstrumentsDataSchema }, + OptionsChains: { queryParams: OptionsChainsQueryParamsSchema, data: OptionsChainsDataSchema }, + OptionsSnapshots: { queryParams: OptionsSnapshotsQueryParamsSchema, data: OptionsSnapshotsDataSchema }, + OptionsUnusual: { queryParams: OptionsUnusualQueryParamsSchema, data: OptionsUnusualDataSchema }, + + // --- Economy --- + EconomicCalendar: { queryParams: EconomicCalendarQueryParamsSchema, data: EconomicCalendarDataSchema }, + TreasuryRates: { queryParams: TreasuryRatesQueryParamsSchema, data: TreasuryRatesDataSchema }, + DiscoveryFilings: { queryParams: DiscoveryFilingsQueryParamsSchema, data: DiscoveryFilingsDataSchema }, + AvailableIndicators: { queryParams: AvailableIndicatorsQueryParamsSchema, data: AvailableIndicatorsDataSchema }, + ConsumerPriceIndex: { queryParams: ConsumerPriceIndexQueryParamsSchema, data: ConsumerPriceIndexDataSchema }, + CompositeLeadingIndicator: { queryParams: CompositeLeadingIndicatorQueryParamsSchema, data: CompositeLeadingIndicatorDataSchema }, + CountryInterestRates: { queryParams: CountryInterestRatesQueryParamsSchema, data: CountryInterestRatesDataSchema }, + BalanceOfPayments: { queryParams: BalanceOfPaymentsQueryParamsSchema, data: BalanceOfPaymentsDataSchema }, + CentralBankHoldings: { queryParams: CentralBankHoldingsQueryParamsSchema, data: CentralBankHoldingsDataSchema }, + CountryProfile: { queryParams: CountryProfileQueryParamsSchema, data: CountryProfileDataSchema }, + DirectionOfTrade: { queryParams: DirectionOfTradeQueryParamsSchema, data: DirectionOfTradeDataSchema }, + ExportDestinations: { queryParams: ExportDestinationsQueryParamsSchema, data: ExportDestinationsDataSchema }, + EconomicIndicators: { queryParams: EconomicIndicatorsQueryParamsSchema, data: EconomicIndicatorsDataSchema }, + + // Economy — FRED + FredSearch: { queryParams: FredSearchQueryParamsSchema, data: FredSearchDataSchema }, + FredSeries: { queryParams: FredSeriesQueryParamsSchema, data: FredSeriesDataSchema }, + FredReleaseTable: { queryParams: FredReleaseTableQueryParamsSchema, data: FredReleaseTableDataSchema }, + FredRegional: { queryParams: FredRegionalQueryParamsSchema, data: FredRegionalDataSchema }, + + // Economy — Macro + Unemployment: { queryParams: UnemploymentQueryParamsSchema, data: UnemploymentDataSchema }, + MoneyMeasures: { queryParams: MoneyMeasuresQueryParamsSchema, data: MoneyMeasuresDataSchema }, + PersonalConsumptionExpenditures: { queryParams: PersonalConsumptionExpendituresQueryParamsSchema, data: PersonalConsumptionExpendituresDataSchema }, + TotalFactorProductivity: { queryParams: TotalFactorProductivityQueryParamsSchema, data: TotalFactorProductivityDataSchema }, + FomcDocuments: { queryParams: FomcDocumentsQueryParamsSchema, data: FomcDocumentsDataSchema }, + PrimaryDealerPositioning: { queryParams: PrimaryDealerPositioningQueryParamsSchema, data: PrimaryDealerPositioningDataSchema }, + PrimaryDealerFails: { queryParams: PrimaryDealerFailsQueryParamsSchema, data: PrimaryDealerFailsDataSchema }, + + // Economy — Survey + NonfarmPayrolls: { queryParams: NonfarmPayrollsQueryParamsSchema, data: NonfarmPayrollsDataSchema }, + InflationExpectations: { queryParams: InflationExpectationsQueryParamsSchema, data: InflationExpectationsDataSchema }, + Sloos: { queryParams: SloosQueryParamsSchema, data: SloosDataSchema }, + UniversityOfMichigan: { queryParams: UniversityOfMichiganQueryParamsSchema, data: UniversityOfMichiganDataSchema }, + EconomicConditionsChicago: { queryParams: EconomicConditionsChicagoQueryParamsSchema, data: EconomicConditionsChicagoDataSchema }, + ManufacturingOutlookTexas: { queryParams: ManufacturingOutlookTexasQueryParamsSchema, data: ManufacturingOutlookTexasDataSchema }, + ManufacturingOutlookNY: { queryParams: ManufacturingOutlookNYQueryParamsSchema, data: ManufacturingOutlookNYDataSchema }, + BlsSeries: { queryParams: BlsSeriesQueryParamsSchema, data: BlsSeriesDataSchema }, + BlsSearch: { queryParams: BlsSearchQueryParamsSchema, data: BlsSearchDataSchema }, + + // Economy — GDP + GdpForecast: { queryParams: GdpForecastQueryParamsSchema, data: GdpForecastDataSchema }, + GdpNominal: { queryParams: GdpNominalQueryParamsSchema, data: GdpNominalDataSchema }, + GdpReal: { queryParams: GdpRealQueryParamsSchema, data: GdpRealDataSchema }, + + // Economy — OECD + SharePriceIndex: { queryParams: SharePriceIndexQueryParamsSchema, data: SharePriceIndexDataSchema }, + HousePriceIndex: { queryParams: HousePriceIndexQueryParamsSchema, data: HousePriceIndexDataSchema }, + RetailPrices: { queryParams: RetailPricesQueryParamsSchema, data: RetailPricesDataSchema }, + + // --- Commodity --- + CommoditySpotPrice: { queryParams: CommoditySpotPriceQueryParamsSchema, data: CommoditySpotPriceDataSchema }, + PetroleumStatusReport: { queryParams: PetroleumStatusReportQueryParamsSchema, data: PetroleumStatusReportDataSchema }, + ShortTermEnergyOutlook: { queryParams: ShortTermEnergyOutlookQueryParamsSchema, data: ShortTermEnergyOutlookDataSchema }, + + // --- Shipping --- + PortInfo: { queryParams: PortInfoQueryParamsSchema, data: PortInfoDataSchema }, + PortVolume: { queryParams: PortVolumeQueryParamsSchema, data: PortVolumeDataSchema }, + ChokepointInfo: { queryParams: ChokepointInfoQueryParamsSchema, data: ChokepointInfoDataSchema }, + ChokepointVolume: { queryParams: ChokepointVolumeQueryParamsSchema, data: ChokepointVolumeDataSchema }, +} diff --git a/packages/opentypebb/src/core/api/widgets.ts b/packages/opentypebb/src/core/api/widgets.ts new file mode 100644 index 00000000..af6482d8 --- /dev/null +++ b/packages/opentypebb/src/core/api/widgets.ts @@ -0,0 +1,209 @@ +/** + * Widget Builder — generates widgets.json for the OpenBB Workspace frontend. + * + * Maps to: openbb_platform/extensions/platform_api/openbb_platform_api/utils/widgets.py + * + * The Python version parses the OpenAPI spec (auto-generated from Pydantic models). + * In TypeScript we skip OpenAPI and directly walk: + * - Router command map → routes, model names, descriptions + * - Registry → which providers support each model + * - Schema registry → Zod schemas for query params and data columns + */ + +import type { Router } from '../app/router.js' +import type { Registry } from '../provider/registry.js' +import { SCHEMA_REGISTRY } from './schema-registry.js' +import { zodSchemaToWidgetParams, zodSchemaToColumnDefs } from './zod-to-widget.js' +import type { WidgetParam } from './zod-to-widget.js' + +/** Provider name display mapping (matches Python's provider_map in widgets.py). */ +const PROVIDER_DISPLAY: Record = { + fmp: 'FMP', + yfinance: 'yFinance', + fred: 'FRED', + sec: 'SEC', + tmx: 'TMX', + ecb: 'ECB', + econdb: 'EconDB', + eia: 'EIA', + oecd: 'OECD', + finra: 'FINRA', + imf: 'IMF', + bls: 'BLS', + cftc: 'CFTC', + wsj: 'WSJ', + deribit: 'Deribit', + cboe: 'CBOE', + multpl: 'Multpl', + intrinio: 'Intrinio', + federal_reserve: 'Federal Reserve', + stub: 'Stub', +} + +// Strings that should always be uppercased in widget names +const TO_CAPS = new Set([ + 'pe', 'pb', 'ps', 'eps', 'ebitda', 'ebit', 'gdp', 'cpi', 'ipo', + 'etf', 'sec', 'fred', 'oecd', 'imf', 'ecb', 'bls', 'eia', + 'sp', 'ny', 'us', 'uk', 'esg', 'sloos', 'fomc', 'pce', 'nonfarm', +]) + +/** + * Build the widgets.json configuration from registered routes and providers. + * + * @param router - The root Router with all commands registered + * @param registry - The provider Registry + * @param apiPrefix - The API prefix (default: "/api/v1") + * @returns Record of widgetId → widget configuration + */ +export function buildWidgetsJson( + router: Router, + registry: Registry, + apiPrefix = '/api/v1', +): Record { + const widgets: Record = {} + const commands = router.getCommandMap(apiPrefix) + + // Build reverse index: modelName → provider names + const modelToProviders = new Map() + for (const [providerName, provider] of registry.providers) { + for (const modelName of Object.keys(provider.fetcherDict)) { + const list = modelToProviders.get(modelName) ?? [] + list.push(providerName) + modelToProviders.set(modelName, list) + } + } + + for (const [routePath, cmd] of commands) { + const providers = modelToProviders.get(cmd.model) ?? ['custom'] + + // Derive widget_id from route path (strip apiPrefix, convert / to _) + const routeWithoutPrefix = routePath.replace(apiPrefix, '') + const baseWidgetId = routeWithoutPrefix.startsWith('/') + ? routeWithoutPrefix.slice(1).replace(/\//g, '_') + : routeWithoutPrefix.replace(/\//g, '_') + + // Derive category and subcategory from route segments + const segments = routeWithoutPrefix + .split('/') + .filter((s) => s.length > 0) + const category = segments[0] ? toTitle(segments[0]) : '' + const subCategory = segments.length > 2 + ? toTitle(segments[1]) + : segments.length > 1 + ? toTitle(segments[1]) + : undefined + + // Derive widget name from route (strip category/subcategory, humanize) + const name = deriveWidgetName(baseWidgetId, category, subCategory) + + // Look up Zod schemas for this model + const schemas = SCHEMA_REGISTRY[cmd.model] + + for (const provider of providers) { + const widgetId = provider === 'custom' + ? `${baseWidgetId}_obb` + : `${baseWidgetId}_${provider}_obb` + + // Build params from Zod query schema + let params: WidgetParam[] = [] + if (schemas) { + params = zodSchemaToWidgetParams(schemas.queryParams) + } + + // Add hidden provider param (matches Python behavior) + if (provider !== 'custom') { + params.push({ + paramName: 'provider', + label: 'Provider', + description: 'Data source provider.', + type: 'text', + value: provider, + optional: false, + show: false, + }) + } + + // Build column definitions from Zod data schema + let columnsDefs: unknown[] = [] + if (schemas) { + columnsDefs = zodSchemaToColumnDefs(schemas.data) + } + + const providerDisplayName = PROVIDER_DISPLAY[provider] ?? toTitle(provider) + + const widgetConfig: Record = { + name, + description: cmd.description, + category: category.replace('Fixedincome', 'Fixed Income'), + type: 'table', + searchCategory: category.replace('Fixedincome', 'Fixed Income'), + widgetId, + mcp_tool: { + mcp_server: 'Open Data Platform', + tool_id: baseWidgetId, + }, + params, + endpoint: routePath, + runButton: false, + gridData: { w: 40, h: 15 }, + data: { + dataKey: 'results', + table: { + showAll: true, + enableAdvanced: true, + ...(columnsDefs.length > 0 ? { columnsDefs } : {}), + }, + }, + source: [providerDisplayName], + } + + if (subCategory && segments.length > 2) { + widgetConfig.subCategory = subCategory + } + + widgets[widgetId] = widgetConfig + } + } + + return widgets +} + +/** Convert a snake_case segment to Title Case, uppercasing known acronyms. */ +function toTitle(s: string): string { + return s + .replace(/_/g, ' ') + .split(' ') + .map((w) => { + const lower = w.toLowerCase() + if (TO_CAPS.has(lower)) return lower.toUpperCase() + return lower.charAt(0).toUpperCase() + lower.slice(1) + }) + .join(' ') +} + +/** Derive a human-readable widget name from the base widget ID. */ +function deriveWidgetName(widgetId: string, category: string, subCategory?: string): string { + let name = widgetId + .replace(/_/g, ' ') + .split(' ') + .map((w) => { + const lower = w.toLowerCase() + if (TO_CAPS.has(lower)) return lower.toUpperCase() + return lower.charAt(0).toUpperCase() + lower.slice(1) + }) + .join(' ') + + // Remove category and subcategory from name to avoid duplication + if (category) { + name = name.replace(new RegExp(`^${escapeRegex(category)}\\s*`, 'i'), '') + } + if (subCategory) { + name = name.replace(new RegExp(`^${escapeRegex(subCategory)}\\s*`, 'i'), '') + } + + return name.trim() || widgetId +} + +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} diff --git a/packages/opentypebb/src/core/api/zod-to-widget.ts b/packages/opentypebb/src/core/api/zod-to-widget.ts new file mode 100644 index 00000000..0ebfb84c --- /dev/null +++ b/packages/opentypebb/src/core/api/zod-to-widget.ts @@ -0,0 +1,220 @@ +/** + * Zod-to-Widget — introspect Zod schemas to generate OpenBB Workspace widget params and column defs. + * + * The Python version parses OpenAPI specs (auto-generated from Pydantic models). + * In TypeScript we skip OpenAPI and read Zod schemas directly via `.shape`. + */ + +import { type ZodTypeAny, ZodObject, ZodOptional, ZodDefault, ZodNullable, ZodEnum, ZodNativeEnum } from 'zod' + +// Strings that should always be uppercased in labels +const TO_CAPS = new Set([ + 'pe', 'pb', 'ps', 'pcf', 'peg', 'eps', 'ebitda', 'ebitdar', 'ebit', + 'roa', 'roe', 'roi', 'roic', 'wacc', 'cik', 'lei', 'cusip', 'isin', + 'sedol', 'ip', 'gdp', 'cpi', 'ppi', 'pce', 'ipo', 'etf', 'sec', + 'fred', 'oecd', 'imf', 'ecb', 'bls', 'eia', 'url', 'sp', 'ny', + 'us', 'uk', 'id', 'sic', 'irs', 'esg', +]) + +/** Widget parameter definition (matches OpenBB Workspace widget param format). */ +export interface WidgetParam { + paramName: string + label: string + description: string + type: string + value: unknown + optional: boolean + show: boolean + options?: Array<{ label: string; value: string }> +} + +/** Widget column definition (matches OpenBB Workspace columnsDefs format). */ +export interface WidgetColumnDef { + field: string + headerName: string + cellDataType?: string + formatterFn?: string +} + +/** + * Convert a snake_case or camelCase field name to a human-readable label. + * e.g. "start_date" → "Start Date", "eps" → "EPS" + */ +function fieldNameToLabel(name: string): string { + const words = name + .replace(/([a-z])([A-Z])/g, '$1_$2') + .split(/[_\s]+/) + + return words + .map((w) => { + const lower = w.toLowerCase() + if (TO_CAPS.has(lower)) return lower.toUpperCase() + return lower.charAt(0).toUpperCase() + lower.slice(1) + }) + .join(' ') +} + +/** + * Unwrap ZodOptional / ZodDefault / ZodNullable to get the inner type. + * Returns { inner, isOptional, defaultValue }. + */ +function unwrapZodType(zodType: ZodTypeAny): { + inner: ZodTypeAny + isOptional: boolean + defaultValue: unknown +} { + let current = zodType + let isOptional = false + let defaultValue: unknown = undefined + + // Peel off wrappers in any order + let changed = true + while (changed) { + changed = false + + if (current instanceof ZodOptional) { + isOptional = true + current = current.unwrap() + changed = true + } + + if (current instanceof ZodDefault) { + defaultValue = current._def.defaultValue() + current = current._def.innerType + changed = true + } + + if (current instanceof ZodNullable) { + isOptional = true + current = current.unwrap() + changed = true + } + } + + return { inner: current, isOptional, defaultValue } +} + +/** Map Zod type names to widget param types. */ +function zodTypeToWidgetType(inner: ZodTypeAny, fieldName: string): string { + const typeName = inner._def.typeName as string | undefined + + // Date-like field names get "date" type + if (fieldName.includes('date') || fieldName.includes('_date')) { + return 'date' + } + + switch (typeName) { + case 'ZodString': + return 'text' + case 'ZodNumber': + case 'ZodBigInt': + return 'number' + case 'ZodBoolean': + return 'boolean' + case 'ZodEnum': + case 'ZodNativeEnum': + return 'text' // will have options + default: + return 'text' + } +} + +/** Map Zod type names to AG-Grid cell data types. */ +function zodTypeToColumnType(inner: ZodTypeAny, fieldName: string): string | undefined { + const typeName = inner._def.typeName as string | undefined + + if (fieldName.includes('date')) return 'dateString' + + switch (typeName) { + case 'ZodNumber': + case 'ZodBigInt': + return 'number' + case 'ZodBoolean': + return 'boolean' + default: + return undefined + } +} + +/** Extract enum options from ZodEnum or ZodNativeEnum. */ +function extractOptions(inner: ZodTypeAny): Array<{ label: string; value: string }> | undefined { + if (inner instanceof ZodEnum) { + const values = inner._def.values as string[] + return values.map((v) => ({ label: v, value: v })) + } + if (inner instanceof ZodNativeEnum) { + const enumObj = inner._def.values as Record + return Object.entries(enumObj) + .filter(([, v]) => typeof v === 'string') + .map(([, v]) => ({ label: String(v), value: String(v) })) + } + return undefined +} + +/** + * Extract widget params from a Zod query params schema. + * + * @param schema - ZodObject representing query parameters + * @returns Array of widget param definitions + */ +export function zodSchemaToWidgetParams(schema: ZodObject): WidgetParam[] { + const shape = schema.shape + const params: WidgetParam[] = [] + + for (const [fieldName, zodType] of Object.entries(shape) as [string, ZodTypeAny][]) { + // Skip "provider" — it's added separately per-provider + if (fieldName === 'provider') continue + + const { inner, isOptional, defaultValue } = unwrapZodType(zodType) + const description = zodType.description ?? inner.description ?? '' + const widgetType = zodTypeToWidgetType(inner, fieldName) + const options = extractOptions(inner) + + params.push({ + paramName: fieldName, + label: fieldNameToLabel(fieldName), + description, + type: widgetType, + value: defaultValue !== undefined ? defaultValue : null, + optional: isOptional, + show: true, + ...(options ? { options } : {}), + }) + } + + return params +} + +/** + * Extract column definitions from a Zod data schema. + * + * @param schema - ZodObject representing response data + * @returns Array of column definitions for AG-Grid tables + */ +export function zodSchemaToColumnDefs(schema: ZodObject): WidgetColumnDef[] { + const shape = schema.shape + const columns: WidgetColumnDef[] = [] + + for (const [fieldName, zodType] of Object.entries(shape) as [string, ZodTypeAny][]) { + const { inner } = unwrapZodType(zodType) + const cellDataType = zodTypeToColumnType(inner, fieldName) + + const col: WidgetColumnDef = { + field: fieldName, + headerName: fieldNameToLabel(fieldName), + } + + if (cellDataType) { + col.cellDataType = cellDataType + } + + // Number columns get a formatter + if (cellDataType === 'number') { + col.formatterFn = 'int' + } + + columns.push(col) + } + + return columns +} diff --git a/packages/opentypebb/src/server.ts b/packages/opentypebb/src/server.ts index 9f36d151..0f5a4749 100644 --- a/packages/opentypebb/src/server.ts +++ b/packages/opentypebb/src/server.ts @@ -15,8 +15,9 @@ */ import { setupProxy } from './core/utils/proxy.js' -import { createApp, startServer } from './core/api/rest-api.js' -import { createExecutor, loadAllRouters } from './core/api/app-loader.js' +import { createApp, startServer, mountWidgetsEndpoint } from './core/api/rest-api.js' +import { createExecutor, createRegistry, loadAllRouters } from './core/api/app-loader.js' +import { buildWidgetsJson } from './core/api/widgets.js' // Must be called before any fetch() calls setupProxy() @@ -27,14 +28,22 @@ if (process.env.FMP_API_KEY) { defaultCredentials.fmp_api_key = process.env.FMP_API_KEY } -// Create executor with all providers loaded +// Create registry and executor with all providers loaded +const registry = createRegistry() const executor = createExecutor() // Create Hono app const app = createApp(defaultCredentials) -// Load and mount all extension routers +// Load all extension routers const rootRouter = loadAllRouters() + +// Build widgets.json from router commands + provider registry + Zod schemas +const widgetsJson = buildWidgetsJson(rootRouter, registry) +mountWidgetsEndpoint(app, widgetsJson) +console.log(`Built widgets.json with ${Object.keys(widgetsJson).length} widgets`) + +// Mount all extension routers as API endpoints rootRouter.mountToHono(app, executor) // Start server diff --git a/src/ai-providers/types.ts b/src/ai-providers/types.ts index c18bc9d8..39e66d88 100644 --- a/src/ai-providers/types.ts +++ b/src/ai-providers/types.ts @@ -1,4 +1,4 @@ -import type { SessionStore, SDKModelMessage } from '../core/session.js' +import type { ISessionStore, SDKModelMessage } from '../core/session.js' import type { CompactionConfig, CompactionResult } from '../core/compaction.js' import type { MediaAttachment } from '../core/types.js' @@ -60,5 +60,5 @@ export interface GenerateProvider { * Use case: providers with native server-side compaction (e.g. Anthropic API * compact-2026-01-12) can bypass the local JSONL-based summarization. */ - compact?(session: SessionStore, config: CompactionConfig): Promise + compact?(session: ISessionStore, config: CompactionConfig): Promise } diff --git a/src/connectors/mock.ts b/src/connectors/mock.ts new file mode 100644 index 00000000..be187b41 --- /dev/null +++ b/src/connectors/mock.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/delivery.spec.ts b/src/core/__tests__/pipeline/delivery.spec.ts index 3d686350..1fb029fc 100644 --- a/src/core/__tests__/pipeline/delivery.spec.ts +++ b/src/core/__tests__/pipeline/delivery.spec.ts @@ -4,13 +4,13 @@ * Verifies notify/notifyStream/broadcast routing, sendStream delegation, * fallback to send, interaction tracking, and error resilience. */ -import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest' +import { describe, it, expect, vi, beforeEach } from 'vitest' import { StreamableResult, type ProviderEvent } from '../../ai-provider.js' import { ConnectorCenter } from '../../connector-center.js' import { createEventLog } from '../../event-log.js' import type { MediaAttachment } from '../../types.js' import { - makeCapturingConnector, + MockConnector, textEvent, doneEvent, } from './helpers.js' @@ -43,7 +43,7 @@ describe('ConnectorCenter — delivery', () => { it('C1: notify() sends text with default kind=notification', async () => { const cc = new ConnectorCenter() - const connector = makeCapturingConnector({ channel: 'web' }) + const connector = new MockConnector({ channel: 'web' }) cc.register(connector) await cc.notify('hello') @@ -60,7 +60,7 @@ describe('ConnectorCenter — delivery', () => { it('C2: notify() with media passes MediaAttachment[] through', async () => { const cc = new ConnectorCenter() - const connector = makeCapturingConnector({ channel: 'web' }) + const connector = new MockConnector({ channel: 'web' }) cc.register(connector) const media: MediaAttachment[] = [{ type: 'image', path: '/tmp/chart.png' }] @@ -79,7 +79,7 @@ describe('ConnectorCenter — delivery', () => { it('C4: notifyStream() delegates to sendStream when available', async () => { const cc = new ConnectorCenter() - const connector = makeCapturingConnector({ channel: 'web', hasSendStream: true }) + const connector = new MockConnector({ channel: 'web' }) cc.register(connector) async function* gen(): AsyncGenerator { @@ -93,12 +93,13 @@ describe('ConnectorCenter — delivery', () => { expect(connector.calls).toHaveLength(1) expect(connector.calls[0].method).toBe('sendStream') expect(connector.calls[0].meta).toEqual({ kind: 'notification', source: 'cron' }) - expect((connector.send as Mock).mock.calls).toHaveLength(0) + // No send calls — only sendStream + expect(connector.calls.filter(c => c.method === 'send')).toHaveLength(0) }) it('C5: notifyStream() drains and falls back to send when no sendStream', async () => { const cc = new ConnectorCenter() - const connector = makeCapturingConnector({ channel: 'telegram', hasSendStream: false }) + const connector = new MockConnector({ channel: 'telegram', sendStream: false }) cc.register(connector) async function* gen(): AsyncGenerator { @@ -117,8 +118,8 @@ describe('ConnectorCenter — delivery', () => { it('C6: broadcast() only sends to push-capable connectors', async () => { const cc = new ConnectorCenter() - const pushable = makeCapturingConnector({ channel: 'web', push: true }) - const pullOnly = makeCapturingConnector({ channel: 'mcp', push: false }) + const pushable = new MockConnector({ channel: 'web', push: true }) + const pullOnly = new MockConnector({ channel: 'mcp', push: false }) cc.register(pushable) cc.register(pullOnly) @@ -131,10 +132,17 @@ describe('ConnectorCenter — delivery', () => { it('C7: broadcast() continues despite individual send failures', async () => { const cc = new ConnectorCenter() - const failing = makeCapturingConnector({ channel: 'telegram', push: true }) - ;(failing.send as Mock).mockRejectedValueOnce(new Error('network error')) + const failing = new MockConnector({ channel: 'telegram', push: true }) + // Override send to simulate a network error + const originalSend = failing.send.bind(failing) + let callCount = 0 + failing.send = async (payload) => { + callCount++ + if (callCount === 1) throw new Error('network error') + return originalSend(payload) + } - const working = makeCapturingConnector({ channel: 'web', push: true }) + const working = new MockConnector({ channel: 'web', push: true }) cc.register(failing) cc.register(working) @@ -149,8 +157,8 @@ describe('ConnectorCenter — delivery', () => { it('C8: resolveTarget defaults to first registered when no interaction', async () => { const cc = new ConnectorCenter() - const web = makeCapturingConnector({ channel: 'web' }) - const telegram = makeCapturingConnector({ channel: 'telegram' }) + const web = new MockConnector({ channel: 'web' }) + const telegram = new MockConnector({ channel: 'telegram' }) cc.register(web) cc.register(telegram) @@ -164,8 +172,8 @@ describe('ConnectorCenter — delivery', () => { try { const cc = new ConnectorCenter(eventLog) - const web = makeCapturingConnector({ channel: 'web' }) - const telegram = makeCapturingConnector({ channel: 'telegram' }) + const web = new MockConnector({ channel: 'web' }) + const telegram = new MockConnector({ channel: 'telegram' }) cc.register(web) cc.register(telegram) @@ -201,7 +209,7 @@ describe('ConnectorCenter — delivery', () => { it('C11: notify with explicit kind=message overrides default', async () => { const cc = new ConnectorCenter() - const connector = makeCapturingConnector({ channel: 'web' }) + const connector = new MockConnector({ channel: 'web' }) cc.register(connector) await cc.notify('user message', { kind: 'message', source: 'manual' }) @@ -212,7 +220,7 @@ describe('ConnectorCenter — delivery', () => { it('C12: unregister callback removes connector', async () => { const cc = new ConnectorCenter() - const connector = makeCapturingConnector({ channel: 'web' }) + const connector = new MockConnector({ channel: 'web' }) const unregister = cc.register(connector) await cc.notify('before') @@ -227,8 +235,8 @@ describe('ConnectorCenter — delivery', () => { it('C13: re-register replaces existing connector for same channel', async () => { const cc = new ConnectorCenter() - const old = makeCapturingConnector({ channel: 'web' }) - const replacement = makeCapturingConnector({ channel: 'web' }) + const old = new MockConnector({ channel: 'web' }) + const replacement = new MockConnector({ channel: 'web' }) cc.register(old) cc.register(replacement) @@ -241,8 +249,8 @@ describe('ConnectorCenter — delivery', () => { it('C14: broadcast with media and source passes all fields correctly', async () => { const cc = new ConnectorCenter() - const web = makeCapturingConnector({ channel: 'web', push: true }) - const telegram = makeCapturingConnector({ channel: 'telegram', push: true }) + const web = new MockConnector({ channel: 'web', push: true }) + const telegram = new MockConnector({ channel: 'telegram', push: true }) cc.register(web) cc.register(telegram) @@ -264,7 +272,7 @@ describe('ConnectorCenter — delivery', () => { it('C15: notifyStream with kind=message passes through to sendStream meta', async () => { const cc = new ConnectorCenter() - const connector = makeCapturingConnector({ channel: 'web', hasSendStream: true }) + const connector = new MockConnector({ channel: 'web' }) cc.register(connector) async function* gen(): AsyncGenerator { diff --git a/src/core/__tests__/pipeline/e2e.spec.ts b/src/core/__tests__/pipeline/e2e.spec.ts index 99cd60ac..d1955452 100644 --- a/src/core/__tests__/pipeline/e2e.spec.ts +++ b/src/core/__tests__/pipeline/e2e.spec.ts @@ -7,10 +7,11 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest' import { ConnectorCenter } from '../../connector-center.js' +import type { SessionEntry } from '../../session.js' import { FakeProvider, - makeCapturingSession, - makeCapturingConnector, + MemorySessionStore, + MockConnector, makeAgentCenter, collectEvents, textEvent, @@ -38,6 +39,16 @@ vi.mock('../../../ai-providers/log-tool-call.js', () => ({ logToolCall: vi.fn(), })) +// ==================== Helpers ==================== + +function userEntries(entries: SessionEntry[]): SessionEntry[] { + return entries.filter(e => e.type === 'user') +} + +function assistantEntries(entries: SessionEntry[]): SessionEntry[] { + return entries.filter(e => e.type === 'assistant') +} + // ==================== Tests ==================== describe('End-to-end flows', () => { @@ -53,7 +64,7 @@ describe('End-to-end flows', () => { doneEvent('AAPL is at $185'), ]) const ac = makeAgentCenter(provider) - const session = makeCapturingSession() + const session = new MemorySessionStore() const stream = ac.askWithSession('what is AAPL?', session) const events = await collectEvents(stream) @@ -66,15 +77,16 @@ describe('End-to-end flows', () => { expect(result.text).toBe('AAPL is at $185') - const userWrites = session.writes.filter(w => w.method === 'appendUser') - const assistantWrites = session.writes.filter(w => w.method === 'appendAssistant') + const all = await session.readAll() + const users = userEntries(all) + const assistants = assistantEntries(all) - expect(userWrites.length).toBeGreaterThanOrEqual(2) - expect(assistantWrites.length).toBeGreaterThanOrEqual(2) + expect(users.length).toBeGreaterThanOrEqual(2) + expect(assistants.length).toBeGreaterThanOrEqual(2) - const finalWrite = assistantWrites[assistantWrites.length - 1] - expect(finalWrite.content).toEqual([{ type: 'text', text: 'AAPL is at $185' }]) - expect(finalWrite.provider).toBe('vercel-ai') + const finalAssistant = assistants[assistants.length - 1] + expect(finalAssistant.message.content).toEqual([{ type: 'text', text: 'AAPL is at $185' }]) + expect(finalAssistant.provider).toBe('vercel-ai') }) it('D2: notification path — agent result delivered via connector.send', async () => { @@ -83,12 +95,12 @@ describe('End-to-end flows', () => { doneEvent('market alert: AAPL up 5%'), ]) const ac = makeAgentCenter(provider) - const heartbeatSession = makeCapturingSession() + const heartbeatSession = new MemorySessionStore() const result = await ac.askWithSession('check market', heartbeatSession) const cc = new ConnectorCenter() - const webConnector = makeCapturingConnector({ channel: 'web' }) + const webConnector = new MockConnector({ channel: 'web' }) cc.register(webConnector) await cc.notify(result.text, { media: result.media, source: 'heartbeat' }) @@ -98,8 +110,9 @@ describe('End-to-end flows', () => { expect(webConnector.calls[0].payload!.kind).toBe('notification') expect(webConnector.calls[0].payload!.source).toBe('heartbeat') - const hbAssistant = heartbeatSession.writes.filter(w => w.method === 'appendAssistant') - expect(hbAssistant.length).toBeGreaterThanOrEqual(1) + const all = await heartbeatSession.readAll() + const hbAssistants = assistantEntries(all) + expect(hbAssistants.length).toBeGreaterThanOrEqual(1) }) it('D3: streaming notification path — askWithSession result streamed via connector.sendStream', async () => { @@ -108,12 +121,12 @@ describe('End-to-end flows', () => { doneEvent('streaming notification'), ]) const ac = makeAgentCenter(provider) - const cronSession = makeCapturingSession() + const cronSession = new MemorySessionStore() const stream = ac.askWithSession('run cron task', cronSession) const cc = new ConnectorCenter() - const webConnector = makeCapturingConnector({ channel: 'web', hasSendStream: true }) + const webConnector = new MockConnector({ channel: 'web' }) cc.register(webConnector) await cc.notifyStream(stream, { source: 'cron' }) @@ -122,9 +135,10 @@ describe('End-to-end flows', () => { expect(webConnector.calls[0].method).toBe('sendStream') expect(webConnector.calls[0].meta).toEqual({ kind: 'notification', source: 'cron' }) - const cronAssistant = cronSession.writes.filter(w => w.method === 'appendAssistant') - const finalWrite = cronAssistant[cronAssistant.length - 1] - expect(finalWrite.content).toEqual([{ type: 'text', text: 'streaming notification' }]) + const all = await cronSession.readAll() + const cronAssistants = assistantEntries(all) + const finalAssistant = cronAssistants[cronAssistants.length - 1] + expect(finalAssistant.message.content).toEqual([{ type: 'text', text: 'streaming notification' }]) }) it('D4: media flows end-to-end from provider through AgentCenter to connector', async () => { @@ -133,13 +147,14 @@ describe('End-to-end flows', () => { doneEvent('chart ready', [{ type: 'image', path: '/tmp/chart.png' }]), ]) const ac = makeAgentCenter(provider) - const session = makeCapturingSession() + const session = new MemorySessionStore() const result = await ac.askWithSession('make chart', session) - const assistantWrites = session.writes.filter(w => w.method === 'appendAssistant') - const finalWrite = assistantWrites[assistantWrites.length - 1] - expect(finalWrite.content).toEqual([ + const all = await session.readAll() + const assistants = assistantEntries(all) + const finalAssistant = assistants[assistants.length - 1] + expect(finalAssistant.message.content).toEqual([ { type: 'text', text: 'chart ready' }, { type: 'image', url: '/api/media/2026-03-13/ace-aim-air.png' }, ]) @@ -147,7 +162,7 @@ describe('End-to-end flows', () => { expect(result.mediaUrls).toEqual(['/api/media/2026-03-13/ace-aim-air.png']) const cc = new ConnectorCenter() - const connector = makeCapturingConnector({ channel: 'web' }) + const connector = new MockConnector({ channel: 'web' }) cc.register(connector) await cc.notify(result.text, { media: result.media, source: 'heartbeat' }) diff --git a/src/core/__tests__/pipeline/helpers.ts b/src/core/__tests__/pipeline/helpers.ts index 2d503457..1634d607 100644 --- a/src/core/__tests__/pipeline/helpers.ts +++ b/src/core/__tests__/pipeline/helpers.ts @@ -1,11 +1,11 @@ /** * Shared test infrastructure for message pipeline integration tests. * - * FakeProvider, CapturingSession, CapturingConnector, event builders, - * and helpers used across the pipeline-*.spec.ts files. + * 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 { vi } from 'vitest' import { AgentCenter } from '../../agent-center.js' import { GenerateRouter, @@ -16,11 +16,16 @@ import { type GenerateInput, type GenerateOpts, } from '../../ai-provider.js' -import { type Connector, type SendPayload, type SendResult } from '../../connector-center.js' import { DEFAULT_COMPACTION_CONFIG } from '../../compaction.js' -import type { SessionStore, SessionEntry, ContentBlock } from '../../session.js' +import type { ContentBlock } from '../../session.js' import type { MediaAttachment } from '../../types.js' +// Re-export test doubles for convenience +export { MemorySessionStore } from '../../session.js' +export type { SessionEntry, ContentBlock } from '../../session.js' +export { MockConnector } from '../../../connectors/mock.js' +export type { MockConnectorCall } from '../../../connectors/mock.js' + // ==================== FakeProvider ==================== /** A FakeProvider that yields a configurable sequence of ProviderEvents. */ @@ -45,111 +50,6 @@ export class FakeProvider implements GenerateProvider { } } -// ==================== CapturingSession ==================== - -/** Recorded session write operation. */ -export interface SessionWrite { - method: 'appendUser' | 'appendAssistant' - content: string | ContentBlock[] - provider?: string - metadata?: Record -} - -/** In-memory SessionStore that captures all writes. */ -export function makeCapturingSession(): SessionStore & { writes: SessionWrite[] } { - const writes: SessionWrite[] = [] - const entries: SessionEntry[] = [] - - const session = { - id: 'test-session', - writes, - appendUser: vi.fn(async (content: string | ContentBlock[], provider?: string) => { - writes.push({ method: 'appendUser', content, provider }) - const e: SessionEntry = { - type: 'user', - message: { role: 'user', content }, - uuid: `u-${entries.length}`, - parentUuid: null, - sessionId: 'test-session', - timestamp: new Date().toISOString(), - provider: provider as SessionEntry['provider'], - } - entries.push(e) - return e - }), - appendAssistant: vi.fn(async (content: string | ContentBlock[], provider?: string, metadata?: Record) => { - writes.push({ method: 'appendAssistant', content, provider, metadata }) - const e: SessionEntry = { - type: 'assistant', - message: { role: 'assistant', content }, - uuid: `a-${entries.length}`, - parentUuid: null, - sessionId: 'test-session', - timestamp: new Date().toISOString(), - provider: provider as SessionEntry['provider'], - metadata, - } - entries.push(e) - return e - }), - appendRaw: vi.fn(async () => {}), - readAll: vi.fn(async () => [...entries]), - readActive: vi.fn(async () => [...entries]), - restore: vi.fn(async () => {}), - exists: vi.fn(async () => true), - } as unknown as SessionStore & { writes: SessionWrite[] } - - return session -} - -// ==================== CapturingConnector ==================== - -/** Captured connector call. */ -export interface ConnectorCall { - method: 'send' | 'sendStream' - payload?: SendPayload - stream?: StreamableResult - meta?: Pick -} - -/** Create a capturing Connector mock. */ -export function makeCapturingConnector(opts?: { - channel?: string - push?: boolean - media?: boolean - hasSendStream?: boolean - sendResult?: SendResult -}): Connector & { calls: ConnectorCall[] } { - const calls: ConnectorCall[] = [] - const result = opts?.sendResult ?? { delivered: true } - - const connector: Connector & { calls: ConnectorCall[] } = { - channel: opts?.channel ?? 'test', - to: 'default', - capabilities: { - push: opts?.push ?? true, - media: opts?.media ?? true, - }, - calls, - send: vi.fn(async (payload: SendPayload) => { - calls.push({ method: 'send', payload }) - return result - }), - } - - if (opts?.hasSendStream !== false) { - connector.sendStream = vi.fn(async (stream: StreamableResult, meta?: Pick) => { - calls.push({ method: 'sendStream', stream, meta }) - // Drain the stream so it doesn't hang - for await (const _e of stream) { /* drain */ } - await stream - return result - }) - } - - return connector -} - // ==================== Event Builders ==================== export function textEvent(text: string): ProviderEvent { diff --git a/src/core/__tests__/pipeline/persistence.spec.ts b/src/core/__tests__/pipeline/persistence.spec.ts index 396473c3..43b5326d 100644 --- a/src/core/__tests__/pipeline/persistence.spec.ts +++ b/src/core/__tests__/pipeline/persistence.spec.ts @@ -4,12 +4,15 @@ * Verifies that all event types (text, tool_use, tool_result, media) * are correctly persisted to the session store with proper providerTag, * ContentBlock[] format, and media handling. + * + * Uses MemorySessionStore so assertions verify actual stored state + * (via readAll()), not just API call recordings. */ import { describe, it, expect, vi, beforeEach } from 'vitest' -import type { ContentBlock } from '../../session.js' +import type { ContentBlock, SessionEntry } from '../../session.js' import { FakeProvider, - makeCapturingSession, + MemorySessionStore, makeAgentCenter, textEvent, toolUseEvent, @@ -36,6 +39,20 @@ vi.mock('../../../ai-providers/log-tool-call.js', () => ({ logToolCall: vi.fn(), })) +// ==================== Helpers ==================== + +function userEntries(entries: SessionEntry[]) { + return entries.filter(e => e.type === 'user') +} + +function assistantEntries(entries: SessionEntry[]) { + return entries.filter(e => e.type === 'assistant') +} + +function blocksOf(entry: SessionEntry): ContentBlock[] { + return entry.message.content as ContentBlock[] +} + // ==================== Tests ==================== describe('AgentCenter — session persistence', () => { @@ -49,21 +66,21 @@ describe('AgentCenter — session persistence', () => { doneEvent('hello'), ]) const ac = makeAgentCenter(provider) - const session = makeCapturingSession() + const session = new MemorySessionStore() - const stream = ac.askWithSession('prompt', session) - await stream // drain + await ac.askWithSession('prompt', session) - const userWrites = session.writes.filter(w => w.method === 'appendUser') - const assistantWrites = session.writes.filter(w => w.method === 'appendAssistant') + const entries = await session.readAll() + const users = userEntries(entries) + const assistants = assistantEntries(entries) - expect(userWrites.length).toBeGreaterThanOrEqual(1) - expect(userWrites[0].content).toBe('prompt') - expect(userWrites[0].provider).toBe('human') + expect(users).toHaveLength(1) + expect(users[0].message.content).toBe('prompt') + expect(users[0].provider).toBe('human') - const finalWrite = assistantWrites[assistantWrites.length - 1] - expect(finalWrite.content).toEqual([{ type: 'text', text: 'hello' }]) - expect(finalWrite.provider).toBe('vercel-ai') + expect(assistants).toHaveLength(1) + expect(blocksOf(assistants[0])).toEqual([{ type: 'text', text: 'hello' }]) + expect(assistants[0].provider).toBe('vercel-ai') }) it('A2: tool loop persists intermediate tool_use/tool_result + final text', async () => { @@ -74,41 +91,42 @@ describe('AgentCenter — session persistence', () => { doneEvent('The weather is 72°F'), ]) const ac = makeAgentCenter(provider) - const session = makeCapturingSession() + const session = new MemorySessionStore() await ac.askWithSession('weather?', session) - const assistantWrites = session.writes.filter(w => w.method === 'appendAssistant') - const userWrites = session.writes.filter(w => w.method === 'appendUser') + const entries = await session.readAll() + const users = userEntries(entries) + const assistants = assistantEntries(entries) - expect(userWrites[0].content).toBe('weather?') - expect(userWrites[0].provider).toBe('human') + // User prompt + tool_result = 2 user entries + expect(users[0].message.content).toBe('weather?') + expect(users[0].provider).toBe('human') - const intermediateAssistant = assistantWrites.find(w => { - const content = w.content - return Array.isArray(content) && content.some((b: ContentBlock) => b.type === 'tool_use') + const toolResultEntry = users.find(u => + Array.isArray(u.message.content) && (u.message.content as ContentBlock[]).some(b => b.type === 'tool_result'), + ) + expect(toolResultEntry).toBeDefined() + expect(blocksOf(toolResultEntry!)[0]).toEqual({ + type: 'tool_result', + tool_use_id: 't1', + content: '72°F', }) - expect(intermediateAssistant).toBeDefined() - expect((intermediateAssistant!.content as ContentBlock[])[0]).toEqual({ + + // Intermediate tool_use + final text = 2 assistant entries + const toolUseEntry = assistants.find(a => + blocksOf(a).some(b => b.type === 'tool_use'), + ) + expect(toolUseEntry).toBeDefined() + expect(blocksOf(toolUseEntry!)[0]).toEqual({ type: 'tool_use', id: 't1', name: 'get_weather', input: { city: 'Tokyo' }, }) - const toolResultWrite = userWrites.find(w => { - const content = w.content - return Array.isArray(content) && (content as ContentBlock[]).some((b: ContentBlock) => b.type === 'tool_result') - }) - expect(toolResultWrite).toBeDefined() - expect((toolResultWrite!.content as ContentBlock[])[0]).toEqual({ - type: 'tool_result', - tool_use_id: 't1', - content: '72°F', - }) - - const finalAssistant = assistantWrites[assistantWrites.length - 1] - expect(finalAssistant.content).toEqual([{ type: 'text', text: 'The weather is 72°F' }]) + const finalEntry = assistants[assistants.length - 1] + expect(blocksOf(finalEntry)).toEqual([{ type: 'text', text: 'The weather is 72°F' }]) }) it('A3: multi-turn tools produce correct flush ordering', async () => { @@ -121,23 +139,23 @@ describe('AgentCenter — session persistence', () => { doneEvent('combined answer'), ]) const ac = makeAgentCenter(provider) - const session = makeCapturingSession() + const session = new MemorySessionStore() await ac.askWithSession('search both', session) - const providerWrites = session.writes.filter(w => w.provider !== 'human') + const entries = await session.readAll() - const toolUseWrites = providerWrites.filter(w => - Array.isArray(w.content) && (w.content as ContentBlock[]).some(b => b.type === 'tool_use'), + const toolUseEntries = entries.filter(e => + e.type === 'assistant' && blocksOf(e).some(b => b.type === 'tool_use'), ) - expect(toolUseWrites).toHaveLength(2) - expect(((toolUseWrites[0].content as ContentBlock[])[0] as { name: string }).name).toBe('search') - expect(((toolUseWrites[1].content as ContentBlock[])[0] as { name: string }).name).toBe('search') + expect(toolUseEntries).toHaveLength(2) + expect((blocksOf(toolUseEntries[0])[0] as { name: string }).name).toBe('search') + expect((blocksOf(toolUseEntries[1])[0] as { name: string }).name).toBe('search') - const toolResultWrites = providerWrites.filter(w => - Array.isArray(w.content) && (w.content as ContentBlock[]).some(b => b.type === 'tool_result'), + const toolResultEntries = entries.filter(e => + e.type === 'user' && Array.isArray(e.message.content) && (e.message.content as ContentBlock[]).some(b => b.type === 'tool_result'), ) - expect(toolResultWrites).toHaveLength(2) + expect(toolResultEntries).toHaveLength(2) }) it('A4: media in done event persists image blocks in final write', async () => { @@ -146,15 +164,14 @@ describe('AgentCenter — session persistence', () => { doneEvent('chart generated', [{ type: 'image', path: '/tmp/chart.png' }]), ]) const ac = makeAgentCenter(provider) - const session = makeCapturingSession() + const session = new MemorySessionStore() await ac.askWithSession('make a chart', session) - const assistantWrites = session.writes.filter(w => w.method === 'appendAssistant') - const finalWrite = assistantWrites[assistantWrites.length - 1] - const blocks = finalWrite.content as ContentBlock[] + const assistants = assistantEntries(await session.readAll()) + const finalEntry = assistants[assistants.length - 1] - expect(blocks).toEqual([ + expect(blocksOf(finalEntry)).toEqual([ { type: 'text', text: 'chart generated' }, { type: 'image', url: '/api/media/2026-03-13/ace-aim-air.png' }, ]) @@ -172,16 +189,15 @@ describe('AgentCenter — session persistence', () => { doneEvent('screenshot taken'), ]) const ac = makeAgentCenter(provider) - const session = makeCapturingSession() + const session = new MemorySessionStore() await ac.askWithSession('take screenshot', session) - const assistantWrites = session.writes.filter(w => w.method === 'appendAssistant') - const finalWrite = assistantWrites[assistantWrites.length - 1] - const blocks = finalWrite.content as ContentBlock[] + const assistants = assistantEntries(await session.readAll()) + const finalBlocks = blocksOf(assistants[assistants.length - 1]) - expect(blocks).toContainEqual({ type: 'text', text: 'screenshot taken' }) - expect(blocks).toContainEqual({ type: 'image', url: '/api/media/2026-03-13/ace-aim-air.png' }) + expect(finalBlocks).toContainEqual({ type: 'text', text: 'screenshot taken' }) + expect(finalBlocks).toContainEqual({ type: 'image', url: '/api/media/2026-03-13/ace-aim-air.png' }) }) it('A6: providerTag correctly propagates for each provider type', async () => { @@ -192,13 +208,13 @@ describe('AgentCenter — session persistence', () => { { providerTag: tag }, ) const ac = makeAgentCenter(provider) - const session = makeCapturingSession() + const session = new MemorySessionStore() await ac.askWithSession('test', session) - const assistantWrites = session.writes.filter(w => w.method === 'appendAssistant') - const finalWrite = assistantWrites[assistantWrites.length - 1] - expect(finalWrite.provider).toBe(tag) + const assistants = assistantEntries(await session.readAll()) + const finalEntry = assistants[assistants.length - 1] + expect(finalEntry.provider).toBe(tag) } }) @@ -211,15 +227,14 @@ describe('AgentCenter — session persistence', () => { doneEvent('image gone', [{ type: 'image', path: '/tmp/deleted.png' }]), ]) const ac = makeAgentCenter(provider) - const session = makeCapturingSession() + const session = new MemorySessionStore() await ac.askWithSession('generate', session) - const assistantWrites = session.writes.filter(w => w.method === 'appendAssistant') - const finalWrite = assistantWrites[assistantWrites.length - 1] - const blocks = finalWrite.content as ContentBlock[] + const assistants = assistantEntries(await session.readAll()) + const finalBlocks = blocksOf(assistants[assistants.length - 1]) - expect(blocks).toEqual([{ type: 'text', text: 'image gone' }]) + expect(finalBlocks).toEqual([{ type: 'text', text: 'image gone' }]) }) it('A8: multiple media from tool_result + done event both appear in final', async () => { @@ -238,15 +253,14 @@ describe('AgentCenter — session persistence', () => { doneEvent('done', [{ type: 'image', path: '/tmp/chart.png' }]), ]) const ac = makeAgentCenter(provider) - const session = makeCapturingSession() + const session = new MemorySessionStore() await ac.askWithSession('browse and chart', session) - const assistantWrites = session.writes.filter(w => w.method === 'appendAssistant') - const finalWrite = assistantWrites[assistantWrites.length - 1] - const blocks = finalWrite.content as ContentBlock[] + const assistants = assistantEntries(await session.readAll()) + const finalBlocks = blocksOf(assistants[assistants.length - 1]) - expect(blocks).toEqual([ + expect(finalBlocks).toEqual([ { type: 'text', text: 'done' }, { type: 'image', url: '/api/media/2026-03-13/tool-media-one.png' }, { type: 'image', url: '/api/media/2026-03-13/done-media-two.png' }, @@ -266,17 +280,17 @@ describe('AgentCenter — session persistence', () => { doneEvent('ok'), ]) const ac = makeAgentCenter(provider) - const session = makeCapturingSession() + const session = new MemorySessionStore() await ac.askWithSession('screenshot', session) - const toolResultWrite = session.writes.find(w => { - const content = w.content - return Array.isArray(content) && (content as ContentBlock[]).some(b => b.type === 'tool_result') - }) - expect(toolResultWrite).toBeDefined() + const users = userEntries(await session.readAll()) + const toolResultEntry = users.find(u => + Array.isArray(u.message.content) && (u.message.content as ContentBlock[]).some(b => b.type === 'tool_result'), + ) + expect(toolResultEntry).toBeDefined() - const toolResultBlock = (toolResultWrite!.content as ContentBlock[]).find(b => b.type === 'tool_result')! + const toolResultBlock = blocksOf(toolResultEntry!).find(b => b.type === 'tool_result')! const parsed = JSON.parse((toolResultBlock as { content: string }).content) expect(parsed[0]).toEqual({ type: 'text', text: '[Image saved to disk — use Read tool to view the file]' }) expect(parsed[1]).toEqual({ type: 'text', text: 'Screenshot captured' }) @@ -287,14 +301,13 @@ describe('AgentCenter — session persistence', () => { doneEvent(''), ]) const ac = makeAgentCenter(provider) - const session = makeCapturingSession() + const session = new MemorySessionStore() const result = await ac.askWithSession('silent', session) expect(result.text).toBe('') - const assistantWrites = session.writes.filter(w => w.method === 'appendAssistant') - const finalWrite = assistantWrites[assistantWrites.length - 1] - expect(finalWrite.content).toEqual([{ type: 'text', text: '' }]) + const assistants = assistantEntries(await session.readAll()) + expect(blocksOf(assistants[assistants.length - 1])).toEqual([{ type: 'text', text: '' }]) }) it('A11: provider stream without done event throws', async () => { @@ -302,7 +315,7 @@ describe('AgentCenter — session persistence', () => { textEvent('cut off mid-'), ]) const ac = makeAgentCenter(provider) - const session = makeCapturingSession() + const session = new MemorySessionStore() await expect(ac.askWithSession('test', session)).rejects.toThrow( 'provider stream ended without done event', @@ -317,15 +330,15 @@ describe('AgentCenter — session persistence', () => { doneEvent('first second third'), ]) const ac = makeAgentCenter(provider) - const session = makeCapturingSession() + const session = new MemorySessionStore() await ac.askWithSession('multi-text', session) - const assistantWrites = session.writes.filter(w => w.method === 'appendAssistant') + const assistants = assistantEntries(await session.readAll()) - // Only one assistant write — the authoritative final text (no duplicate intermediate flush) - expect(assistantWrites).toHaveLength(1) - expect(assistantWrites[0].content).toEqual([{ type: 'text', text: 'first second third' }]) + // Only one assistant entry — the authoritative final text (no duplicate intermediate flush) + expect(assistants).toHaveLength(1) + expect(blocksOf(assistants[0])).toEqual([{ type: 'text', text: 'first second third' }]) }) it('A13: tool_use with complex nested input preserves structure', async () => { @@ -344,16 +357,14 @@ describe('AgentCenter — session persistence', () => { doneEvent('Orders submitted'), ]) const ac = makeAgentCenter(provider) - const session = makeCapturingSession() + const session = new MemorySessionStore() await ac.askWithSession('submit orders', session) - const assistantWrites = session.writes.filter(w => w.method === 'appendAssistant') - const toolUseWrite = assistantWrites.find(w => - Array.isArray(w.content) && (w.content as ContentBlock[]).some(b => b.type === 'tool_use'), - ) - expect(toolUseWrite).toBeDefined() - const toolUseBlock = (toolUseWrite!.content as ContentBlock[]).find(b => b.type === 'tool_use')! + const assistants = assistantEntries(await session.readAll()) + const toolUseEntry = assistants.find(a => blocksOf(a).some(b => b.type === 'tool_use')) + expect(toolUseEntry).toBeDefined() + const toolUseBlock = blocksOf(toolUseEntry!).find(b => b.type === 'tool_use')! expect((toolUseBlock as { input: unknown }).input).toEqual(complexInput) }) @@ -367,17 +378,17 @@ describe('AgentCenter — session persistence', () => { doneEvent('Based on the result: everything looks good.'), ]) const ac = makeAgentCenter(provider) - const session = makeCapturingSession() + const session = new MemorySessionStore() await ac.askWithSession('check', session) - const assistantWrites = session.writes.filter(w => w.method === 'appendAssistant') - const firstFlush = assistantWrites.find(w => { - const content = w.content as ContentBlock[] - return Array.isArray(content) && content.some(b => b.type === 'tool_use') && content.some(b => b.type === 'text') + const assistants = assistantEntries(await session.readAll()) + const firstFlush = assistants.find(a => { + const blocks = blocksOf(a) + return blocks.some(b => b.type === 'tool_use') && blocks.some(b => b.type === 'text') }) expect(firstFlush).toBeDefined() - const blocks = firstFlush!.content as ContentBlock[] + const blocks = blocksOf(firstFlush!) expect(blocks[0]).toEqual({ type: 'text', text: 'Let me check...' }) expect(blocks[1]).toMatchObject({ type: 'tool_use', name: 'lookup' }) }) @@ -393,20 +404,21 @@ describe('AgentCenter — session persistence', () => { { providerTag: 'agent-sdk' }, ) const ac = makeAgentCenter(provider) - const session = makeCapturingSession() + const session = new MemorySessionStore() await ac.askWithSession('calc', session) - const assistantWrites = session.writes.filter(w => w.method === 'appendAssistant') - for (const w of assistantWrites) { - expect(w.provider).toBe('agent-sdk') + const entries = await session.readAll() + const assistants = assistantEntries(entries) + for (const a of assistants) { + expect(a.provider).toBe('agent-sdk') } - const toolResultUserWrites = session.writes.filter(w => - w.method === 'appendUser' && Array.isArray(w.content), + const toolResultUsers = userEntries(entries).filter(u => + Array.isArray(u.message.content) && (u.message.content as ContentBlock[]).some(b => b.type === 'tool_result'), ) - for (const w of toolResultUserWrites) { - expect(w.provider).toBe('agent-sdk') + for (const u of toolResultUsers) { + expect(u.provider).toBe('agent-sdk') } }) }) diff --git a/src/core/__tests__/pipeline/streaming.spec.ts b/src/core/__tests__/pipeline/streaming.spec.ts index e9f0de9a..83fb0b27 100644 --- a/src/core/__tests__/pipeline/streaming.spec.ts +++ b/src/core/__tests__/pipeline/streaming.spec.ts @@ -8,7 +8,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { StreamableResult, type ProviderEvent } from '../../ai-provider.js' import { FakeProvider, - makeCapturingSession, + MemorySessionStore, makeAgentCenter, collectEvents, textEvent, @@ -52,7 +52,7 @@ describe('AgentCenter — streaming output', () => { doneEvent('The answer is 2'), ]) const ac = makeAgentCenter(provider) - const session = makeCapturingSession() + const session = new MemorySessionStore() const stream = ac.askWithSession('calculate', session) const events = await collectEvents(stream) @@ -70,7 +70,7 @@ describe('AgentCenter — streaming output', () => { doneEvent('image ready', [{ type: 'image', path: '/tmp/img.png' }]), ]) const ac = makeAgentCenter(provider) - const session = makeCapturingSession() + const session = new MemorySessionStore() const stream = ac.askWithSession('gen image', session) const events = await collectEvents(stream) @@ -88,7 +88,7 @@ describe('AgentCenter — streaming output', () => { doneEvent('result', [{ type: 'image', path: '/tmp/x.png' }]), ]) const ac = makeAgentCenter(provider) - const session = makeCapturingSession() + const session = new MemorySessionStore() const stream = ac.askWithSession('go', session) const result = await stream @@ -133,7 +133,7 @@ describe('AgentCenter — streaming output', () => { doneEvent('hello'), ]) const ac = makeAgentCenter(provider) - const session = makeCapturingSession() + const session = new MemorySessionStore() const stream = ac.askWithSession('test', session) @@ -177,7 +177,7 @@ describe('AgentCenter — streaming output', () => { doneEvent('The price is $100.00'), ]) const ac = makeAgentCenter(provider) - const session = makeCapturingSession() + const session = new MemorySessionStore() const stream = ac.askWithSession('get price', session) const events = await collectEvents(stream) @@ -196,7 +196,7 @@ describe('AgentCenter — streaming output', () => { doneEvent('no media'), ]) const ac = makeAgentCenter(provider) - const session = makeCapturingSession() + const session = new MemorySessionStore() const stream = ac.askWithSession('plain', session) const events = await collectEvents(stream) diff --git a/src/core/agent-center.spec.ts b/src/core/agent-center.spec.ts index 7fb28870..585fe961 100644 --- a/src/core/agent-center.spec.ts +++ b/src/core/agent-center.spec.ts @@ -6,7 +6,7 @@ import { GenerateRouter } from './ai-provider.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' -import type { SessionStore, SessionEntry } from './session.js' +import { MemorySessionStore, type SessionEntry } from './session.js' // ==================== Helpers ==================== @@ -49,43 +49,6 @@ function makeAgentCenter(overrides: MakeAgentCenterOpts = {}): AgentCenter { return new AgentCenter({ router, compaction }) } -/** In-memory SessionStore mock (no filesystem). */ -function makeSessionMock(entries: SessionEntry[] = []): SessionStore { - const store: SessionEntry[] = [...entries] - return { - id: 'test-session', - appendUser: vi.fn(async (content: string) => { - const e: SessionEntry = { - type: 'user', - message: { role: 'user', content }, - uuid: `u-${store.length}`, - parentUuid: null, - sessionId: 'test-session', - timestamp: new Date().toISOString(), - } - store.push(e) - return e - }), - appendAssistant: vi.fn(async (content: string | import('./session.js').ContentBlock[]) => { - const e: SessionEntry = { - type: 'assistant', - message: { role: 'assistant', content }, - uuid: `a-${store.length}`, - parentUuid: null, - sessionId: 'test-session', - timestamp: new Date().toISOString(), - } - store.push(e) - return e - }), - appendRaw: vi.fn(async () => {}), - readAll: vi.fn(async () => [...store]), - readActive: vi.fn(async () => [...store]), - restore: vi.fn(async () => {}), - exists: vi.fn(async () => store.length > 0), - } as unknown as SessionStore -} - // ==================== Mock model-factory ==================== vi.mock('../ai-providers/vercel-ai-sdk/model-factory.js', () => ({ @@ -163,21 +126,23 @@ describe('AgentCenter', () => { it('appends user message to session before generating', async () => { const model = makeMockModel('session response') const agentCenter = makeAgentCenter({ model }) - const session = makeSessionMock() + const session = new MemorySessionStore() + const spy = vi.spyOn(session, 'appendUser') await agentCenter.askWithSession('user prompt', session) - expect(session.appendUser).toHaveBeenCalledWith('user prompt', 'human') + expect(spy).toHaveBeenCalledWith('user prompt', 'human') }) it('appends assistant response to session after generating', async () => { const model = makeMockModel('assistant reply') const agentCenter = makeAgentCenter({ model }) - const session = makeSessionMock() + const session = new MemorySessionStore() + const spy = vi.spyOn(session, 'appendAssistant') await agentCenter.askWithSession('hello', session) - expect(session.appendAssistant).toHaveBeenCalledWith( + expect(spy).toHaveBeenCalledWith( [{ type: 'text', text: 'assistant reply' }], 'vercel-ai', ) @@ -186,7 +151,7 @@ describe('AgentCenter', () => { it('returns the generated text and empty media', async () => { const model = makeMockModel('generated text') const agentCenter = makeAgentCenter({ model }) - const session = makeSessionMock() + const session = new MemorySessionStore() const result = await agentCenter.askWithSession('prompt', session) expect(result.text).toBe('generated text') @@ -203,7 +168,7 @@ describe('AgentCenter', () => { microcompactKeepRecent: 2, } const agentCenter = makeAgentCenter({ model, compaction }) - const session = makeSessionMock() + const session = new MemorySessionStore() await agentCenter.askWithSession('test', session) @@ -232,12 +197,13 @@ describe('AgentCenter', () => { const model = makeMockModel('from compacted') const agentCenter = makeAgentCenter({ model }) - const session = makeSessionMock() + const session = new MemorySessionStore() + const spy = vi.spyOn(session, 'readActive') const result = await agentCenter.askWithSession('test', session) expect(result.text).toBe('from compacted') // readActive should NOT be called when activeEntries is provided - expect(session.readActive).not.toHaveBeenCalled() + expect(spy).not.toHaveBeenCalled() }) it('falls back to session.readActive when no activeEntries', async () => { @@ -249,10 +215,11 @@ describe('AgentCenter', () => { const model = makeMockModel('from readActive') const agentCenter = makeAgentCenter({ model }) - const session = makeSessionMock() + const session = new MemorySessionStore() + const spy = vi.spyOn(session, 'readActive') await agentCenter.askWithSession('test', session) - expect(session.readActive).toHaveBeenCalled() + expect(spy).toHaveBeenCalled() }) }) diff --git a/src/core/agent-center.ts b/src/core/agent-center.ts index 5f696fdc..b6599218 100644 --- a/src/core/agent-center.ts +++ b/src/core/agent-center.ts @@ -13,7 +13,7 @@ import type { AskOptions, ProviderResult, ProviderEvent, GenerateOpts } from './ai-provider.js' import { GenerateRouter, StreamableResult } from './ai-provider.js' -import type { SessionStore, ContentBlock } from './session.js' +import type { ISessionStore, ContentBlock } from './session.js' import { toTextHistory, toModelMessages } from './session.js' import type { CompactionConfig } from './compaction.js' import { compactIfNeeded } from './compaction.js' @@ -55,7 +55,7 @@ export class AgentCenter { } /** Prompt with session history — full orchestration pipeline. */ - askWithSession(prompt: string, session: SessionStore, opts?: AskOptions): StreamableResult { + askWithSession(prompt: string, session: ISessionStore, opts?: AskOptions): StreamableResult { return new StreamableResult(this._generate(prompt, session, opts)) } @@ -63,7 +63,7 @@ export class AgentCenter { private async *_generate( prompt: string, - session: SessionStore, + session: ISessionStore, opts?: AskOptions, ): AsyncGenerator { const maxHistory = opts?.maxHistoryEntries ?? this.defaultMaxHistory diff --git a/src/core/compaction.ts b/src/core/compaction.ts index 59cc3c18..1a6ae200 100644 --- a/src/core/compaction.ts +++ b/src/core/compaction.ts @@ -10,7 +10,7 @@ import { randomUUID } from 'node:crypto' import type { SessionEntry, ContentBlock } from './session.js' -import type { SessionStore } from './session.js' +import type { ISessionStore } from './session.js' // ==================== Configuration ==================== @@ -239,7 +239,7 @@ export interface CompactionResult { * - If full compact needed → writes boundary + summary to JSONL, future readActive() will pick them up */ export async function compactIfNeeded( - session: SessionStore, + session: ISessionStore, config: CompactionConfig, summarize: (prompt: string) => Promise, ): Promise { @@ -283,7 +283,7 @@ export async function compactIfNeeded( * Returns token count before compaction, or null if session was empty. */ export async function forceCompact( - session: SessionStore, + session: ISessionStore, summarize: (prompt: string) => Promise, ): Promise<{ preTokens: number } | null> { const allEntries = await session.readAll() diff --git a/src/core/session.ts b/src/core/session.ts index fdcc18c2..688bd5d4 100644 --- a/src/core/session.ts +++ b/src/core/session.ts @@ -55,11 +55,35 @@ export type ContentBlock = | { type: 'tool_use'; id: string; name: string; input: unknown } | { type: 'tool_result'; tool_use_id: string; content: string } -// ==================== Session Store ==================== +// ==================== Session Store Interface ==================== + +/** + * Session store contract. + * + * Implementations persist conversation entries (user, assistant, system) + * and support reading them back. AgentCenter and other consumers depend + * on this interface, not on any specific storage backend. + * + * Two implementations: + * - SessionStore — JSONL file persistence (production) + * - MemorySessionStore — in-memory array (testing, no filesystem) + */ +export interface ISessionStore { + readonly id: string + appendUser(content: string | ContentBlock[], provider?: SessionEntry['provider']): Promise + appendAssistant(content: string | ContentBlock[], provider?: SessionEntry['provider'], metadata?: Record): Promise + appendRaw(entry: SessionEntry): Promise + readAll(): Promise + readActive(): Promise + restore(): Promise + exists(): Promise +} + +// ==================== JSONL Session Store ==================== const SESSIONS_DIR = join(process.cwd(), 'data', 'sessions') -export class SessionStore { +export class SessionStore implements ISessionStore { private sessionId: string private lastUuid: string | null = null @@ -164,6 +188,89 @@ export class SessionStore { } } +// ==================== In-Memory Session Store ==================== + +/** + * In-memory session store for testing. + * + * Behaves identically to the JSONL-backed SessionStore but stores entries + * in a plain array. Use this in tests to verify persistence behavior + * without touching the filesystem — readAll() returns what was actually + * written, so duplicate writes surface immediately. + */ +export class MemorySessionStore implements ISessionStore { + private sessionId: string + private lastUuid: string | null = null + private entries: SessionEntry[] = [] + + constructor(sessionId?: string) { + this.sessionId = sessionId ?? randomUUID() + } + + get id(): string { + return this.sessionId + } + + async appendUser(content: string | ContentBlock[], provider: SessionEntry['provider'] = 'human'): Promise { + return this.append({ + type: 'user', + message: { role: 'user', content }, + provider, + }) + } + + async appendAssistant( + content: string | ContentBlock[], + provider: SessionEntry['provider'] = 'vercel-ai', + metadata?: Record, + ): Promise { + return this.append({ + type: 'assistant', + message: { role: 'assistant', content }, + provider, + ...(metadata ? { metadata } : {}), + }) + } + + async appendRaw(entry: SessionEntry): Promise { + this.entries.push(entry) + this.lastUuid = entry.uuid + } + + async readAll(): Promise { + return this.entries.filter( + (e) => e.type === 'user' || e.type === 'assistant' || e.type === 'system', + ) + } + + async readActive(): Promise { + return getActiveEntries(await this.readAll()) + } + + async restore(): Promise { + if (this.entries.length > 0) { + this.lastUuid = this.entries[this.entries.length - 1].uuid + } + } + + async exists(): Promise { + return this.entries.length > 0 + } + + private append(partial: Omit): SessionEntry { + const entry: SessionEntry = { + ...partial, + uuid: randomUUID(), + parentUuid: this.lastUuid, + sessionId: this.sessionId, + timestamp: new Date().toISOString(), + } + this.entries.push(entry) + this.lastUuid = entry.uuid + return entry + } +} + // ==================== Format Conversion ==================== /**