diff --git a/apps/finance-ai-agent-demo/backend/agent/sprawl_tools.py b/apps/finance-ai-agent-demo/backend/agent/sprawl_tools.py
index faebcb77..6496a71e 100644
--- a/apps/finance-ai-agent-demo/backend/agent/sprawl_tools.py
+++ b/apps/finance-ai-agent-demo/backend/agent/sprawl_tools.py
@@ -581,3 +581,135 @@ def convergent_search_sprawl(
return f"No results found for account '{account_id}'"
return json.dumps(all_results, default=str)
+
+
+# ---------------------------------------------------------------------------
+# suggest_portfolio_hedge (PostgreSQL sprawl mode)
+# ---------------------------------------------------------------------------
+
+
+def suggest_portfolio_hedge_sprawl(pg_conn, args, query_logger):
+ """Hedge recommendation tool for sprawl/PostgreSQL mode.
+
+ Mirrors _suggest_portfolio_hedge in tools.py but uses PostgreSQL syntax
+ (%(param)s placeholders, JSONB operators, window functions).
+ """
+ import json as _json
+
+ account_id = args.get("account_id", "")
+ risk_focus = args.get("risk_focus", "all")
+
+ sql = """
+ WITH holdings AS (
+ SELECT
+ ph.holding_id,
+ ph.asset_class,
+ ph.instrument_name,
+ ph.ticker,
+ ph.sector,
+ ph.region,
+ ph.risk_rating,
+ ph.current_value,
+ SUM(ph.current_value) OVER () AS total_value,
+ ROUND(
+ ph.current_value / NULLIF(SUM(ph.current_value) OVER (), 0) * 100, 2
+ ) AS pct_of_portfolio,
+ ca.risk_profile,
+ ca.metadata->>'esg_mandate' AS esg_mandate,
+ ca.metadata->>'max_single_position' AS max_position,
+ ca.metadata->'excluded_sectors' AS excluded_sectors
+ FROM portfolio_holdings ph
+ JOIN client_accounts ca ON ca.account_id = ph.account_id
+ WHERE ph.account_id = %(account_id)s
+ ),
+ sector_exposure AS (
+ SELECT sector,
+ ROUND(SUM(pct_of_portfolio)::numeric, 2) AS sector_pct
+ FROM holdings
+ GROUP BY sector
+ ORDER BY sector_pct DESC
+ ),
+ region_exposure AS (
+ SELECT region,
+ ROUND(SUM(pct_of_portfolio)::numeric, 2) AS region_pct
+ FROM holdings
+ GROUP BY region
+ ORDER BY region_pct DESC
+ ),
+ asset_class_exposure AS (
+ SELECT asset_class,
+ ROUND(SUM(pct_of_portfolio)::numeric, 2) AS asset_class_pct
+ FROM holdings
+ GROUP BY asset_class
+ ORDER BY asset_class_pct DESC
+ ),
+ high_risk_positions AS (
+ SELECT holding_id, instrument_name, ticker, sector, region,
+ risk_rating, pct_of_portfolio
+ FROM holdings
+ WHERE risk_rating >= 7
+ ORDER BY pct_of_portfolio DESC
+ )
+ SELECT 'HOLDING' AS row_type,
+ h.holding_id AS id,
+ h.instrument_name AS label,
+ h.ticker AS ticker,
+ h.sector AS sector,
+ h.region AS region,
+ h.asset_class AS asset_class,
+ h.risk_rating AS risk_rating,
+ h.pct_of_portfolio AS pct,
+ h.risk_profile AS risk_profile,
+ h.esg_mandate AS esg_mandate,
+ h.max_position AS max_position,
+ h.excluded_sectors::text AS excluded_sectors
+ FROM holdings h
+ UNION ALL
+ SELECT 'SECTOR', sector, sector, NULL, NULL, NULL, NULL,
+ NULL, sector_pct, NULL, NULL, NULL, NULL
+ FROM sector_exposure
+ UNION ALL
+ SELECT 'REGION', region, region, NULL, NULL, NULL, NULL,
+ NULL, region_pct, NULL, NULL, NULL, NULL
+ FROM region_exposure
+ UNION ALL
+ SELECT 'ASSET_CLASS', asset_class, asset_class, NULL, NULL, NULL, NULL,
+ NULL, asset_class_pct, NULL, NULL, NULL, NULL
+ FROM asset_class_exposure
+ UNION ALL
+ SELECT 'HIGH_RISK', holding_id, instrument_name, ticker, sector, region,
+ NULL, risk_rating, pct_of_portfolio, NULL, NULL, NULL, NULL
+ FROM high_risk_positions
+ """
+
+ columns = [
+ "ROW_TYPE",
+ "ID",
+ "LABEL",
+ "TICKER",
+ "SECTOR",
+ "REGION",
+ "ASSET_CLASS",
+ "RISK_RATING",
+ "PCT",
+ "RISK_PROFILE",
+ "ESG_MANDATE",
+ "MAX_POSITION",
+ "EXCLUDED_SECTORS",
+ ]
+
+ rows, _ = execute_query(
+ pg_conn,
+ sql,
+ {"account_id": account_id},
+ query_logger,
+ description=f"Hedge analysis (PostgreSQL): {account_id}",
+ )
+
+ if not rows:
+ return f"No holdings found for account '{account_id}'"
+
+ from agent.tools import _build_hedge_recommendations
+
+ results = [dict(zip(columns, row, strict=False)) for row in rows]
+ return _json.dumps(_build_hedge_recommendations(results, account_id, risk_focus), default=str)
diff --git a/apps/finance-ai-agent-demo/backend/agent/tools.py b/apps/finance-ai-agent-demo/backend/agent/tools.py
index 9fef65b6..d34eefb5 100644
--- a/apps/finance-ai-agent-demo/backend/agent/tools.py
+++ b/apps/finance-ai-agent-demo/backend/agent/tools.py
@@ -235,6 +235,39 @@
},
},
},
+ {
+ "type": "function",
+ "function": {
+ "name": "suggest_portfolio_hedge",
+ "description": (
+ "Analyze a portfolio's risk factors — sector concentration, regional exposure, "
+ "asset class distribution, and individual position risk ratings — then recommend "
+ "specific hedging instruments (inverse ETFs, commodities, bonds, defensive equities, "
+ "options strategies) with clear reasoning tied to the actual holdings. "
+ "Use this when a client asks how to protect their portfolio, reduce risk, or hedge "
+ "against market downturns, sector-specific events, or geopolitical risks."
+ ),
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "account_id": {
+ "type": "string",
+ "description": "The account ID to analyze and generate hedge recommendations for",
+ },
+ "risk_focus": {
+ "type": "string",
+ "enum": ["market", "sector", "regional", "currency", "all"],
+ "description": (
+ "The type of risk to hedge against: 'market' for broad market downturns, "
+ "'sector' for sector-specific concentration risk, 'regional' for geographic "
+ "exposure, 'currency' for FX risk, 'all' for a comprehensive hedge plan"
+ ),
+ },
+ },
+ "required": ["account_id"],
+ },
+ },
+ },
]
# Preloaded tools: always available to the LLM regardless of query or DB state.
@@ -302,6 +335,10 @@ def execute_tool(tool_name, tool_args):
return convergent_search_sprawl(
pg_conn, neo4j_driver, qdrant_client, embedding_model, tool_args, query_logger
)
+ elif tool_name == "suggest_portfolio_hedge":
+ from agent.sprawl_tools import suggest_portfolio_hedge_sprawl
+
+ return suggest_portfolio_hedge_sprawl(pg_conn, tool_args, query_logger)
else:
return f"Error: Unknown tool '{tool_name}'"
else:
@@ -324,6 +361,8 @@ def execute_tool(tool_name, tool_args):
return _find_nearby_clients(conn, tool_args, query_logger)
elif tool_name == "convergent_search":
return _convergent_search(conn, embedding_model, tool_args, query_logger)
+ elif tool_name == "suggest_portfolio_hedge":
+ return _suggest_portfolio_hedge(conn, tool_args, query_logger)
else:
return f"Error: Unknown tool '{tool_name}'"
@@ -634,6 +673,437 @@ def _find_nearby_clients(conn, args, query_logger):
return json.dumps(results, default=str)
+def _suggest_portfolio_hedge(conn, args, query_logger):
+ """Analyze portfolio risk factors and recommend hedging instruments (Oracle mode).
+
+ Uses a single SQL statement (Relational + JSON + Analytics) to compute:
+ - Sector concentration and dominant sectors
+ - Regional exposure breakdown
+ - Asset class distribution
+ - High-risk positions (risk_rating >= 7)
+ - Account-level risk profile and ESG mandate from JSON metadata
+ """
+ account_id = args.get("account_id", "")
+ risk_focus = args.get("risk_focus", "all")
+
+ # Single query: Relational + JSON + window-function analytics
+ sql = """
+ WITH holdings AS (
+ SELECT
+ ph.holding_id,
+ ph.asset_class,
+ ph.instrument_name,
+ ph.ticker,
+ ph.sector,
+ ph.region,
+ ph.risk_rating,
+ ph.current_value,
+ SUM(ph.current_value) OVER () AS total_value,
+ ROUND(ph.current_value / NULLIF(SUM(ph.current_value) OVER (), 0) * 100, 2) AS pct_of_portfolio,
+ ca.risk_profile,
+ JSON_VALUE(ca.metadata, '$.investment_preferences.esg_mandate') AS esg_mandate,
+ JSON_VALUE(ca.metadata, '$.investment_preferences.max_single_position') AS max_position,
+ JSON_QUERY(ca.metadata, '$.investment_preferences.excluded_sectors') AS excluded_sectors
+ FROM portfolio_holdings ph
+ JOIN client_accounts ca ON ca.account_id = ph.account_id
+ WHERE ph.account_id = :account_id
+ ),
+ sector_exposure AS (
+ SELECT sector,
+ ROUND(SUM(pct_of_portfolio), 2) AS sector_pct
+ FROM holdings
+ GROUP BY sector
+ ORDER BY sector_pct DESC
+ ),
+ region_exposure AS (
+ SELECT region,
+ ROUND(SUM(pct_of_portfolio), 2) AS region_pct
+ FROM holdings
+ GROUP BY region
+ ORDER BY region_pct DESC
+ ),
+ asset_class_exposure AS (
+ SELECT asset_class,
+ ROUND(SUM(pct_of_portfolio), 2) AS asset_class_pct
+ FROM holdings
+ GROUP BY asset_class
+ ORDER BY asset_class_pct DESC
+ ),
+ high_risk_positions AS (
+ SELECT holding_id, instrument_name, ticker, sector, region,
+ risk_rating, pct_of_portfolio
+ FROM holdings
+ WHERE risk_rating >= 7
+ ORDER BY pct_of_portfolio DESC
+ )
+ SELECT
+ 'HOLDING' AS row_type,
+ h.holding_id AS id,
+ h.instrument_name AS label,
+ h.ticker AS ticker,
+ h.sector AS sector,
+ h.region AS region,
+ h.asset_class AS asset_class,
+ h.risk_rating AS risk_rating,
+ h.pct_of_portfolio AS pct,
+ h.risk_profile AS risk_profile,
+ h.esg_mandate AS esg_mandate,
+ h.max_position AS max_position,
+ h.excluded_sectors AS excluded_sectors
+ FROM holdings h
+ UNION ALL
+ SELECT 'SECTOR', sector, sector, NULL, NULL, NULL, NULL,
+ NULL, sector_pct, NULL, NULL, NULL, NULL
+ FROM sector_exposure
+ UNION ALL
+ SELECT 'REGION', region, region, NULL, NULL, NULL, NULL,
+ NULL, region_pct, NULL, NULL, NULL, NULL
+ FROM region_exposure
+ UNION ALL
+ SELECT 'ASSET_CLASS', asset_class, asset_class, NULL, NULL, NULL, NULL,
+ NULL, asset_class_pct, NULL, NULL, NULL, NULL
+ FROM asset_class_exposure
+ UNION ALL
+ SELECT 'HIGH_RISK', holding_id, instrument_name, ticker, sector, region,
+ NULL, risk_rating, pct_of_portfolio, NULL, NULL, NULL, NULL
+ FROM high_risk_positions
+ """
+
+ columns = [
+ "ROW_TYPE",
+ "ID",
+ "LABEL",
+ "TICKER",
+ "SECTOR",
+ "REGION",
+ "ASSET_CLASS",
+ "RISK_RATING",
+ "PCT",
+ "RISK_PROFILE",
+ "ESG_MANDATE",
+ "MAX_POSITION",
+ "EXCLUDED_SECTORS",
+ ]
+
+ rows, _ = execute_query(
+ conn,
+ sql,
+ {"account_id": account_id},
+ query_logger,
+ description=f"Hedge analysis (Relational + JSON + Analytics): {account_id}",
+ )
+
+ if not rows:
+ return f"No holdings found for account '{account_id}'"
+
+ results = [dict(zip(columns, row, strict=False)) for row in rows]
+ return json.dumps(_build_hedge_recommendations(results, account_id, risk_focus), default=str)
+
+
+# ---------------------------------------------------------------------------
+# Hedge recommendation engine — pure Python, no DB calls
+# ---------------------------------------------------------------------------
+
+# Catalogue of hedge instruments keyed by risk dimension
+_HEDGE_CATALOGUE = {
+ "market": [
+ {
+ "ticker": "SH",
+ "name": "ProShares Short S&P500 ETF",
+ "type": "Inverse ETF",
+ "rationale": "Broad market hedge; gains when S&P 500 declines.",
+ "risk_level": "medium",
+ },
+ {
+ "ticker": "SQQQ",
+ "name": "ProShares UltraPro Short QQQ",
+ "type": "Leveraged Inverse ETF",
+ "rationale": "Aggressive tech/growth hedge (3× inverse NASDAQ-100). Suitable only for high-risk-tolerance accounts.",
+ "risk_level": "high",
+ },
+ {
+ "ticker": "GLD",
+ "name": "SPDR Gold Shares",
+ "type": "Commodity ETF",
+ "rationale": "Safe-haven asset; historically negatively correlated with equities during downturns.",
+ "risk_level": "low",
+ },
+ {
+ "ticker": "TLT",
+ "name": "iShares 20+ Year Treasury Bond ETF",
+ "type": "Bond ETF",
+ "rationale": "Long-duration Treasuries typically rise during risk-off equity sell-offs.",
+ "risk_level": "low",
+ },
+ ],
+ "sector": {
+ "Technology": [
+ {
+ "ticker": "REK",
+ "name": "ProShares Short Real Estate / rotate to Value",
+ "type": "Sector Rotation",
+ "rationale": "Rotate overweight tech into value/defensive sectors to reduce concentration.",
+ "risk_level": "low",
+ },
+ {
+ "ticker": "PUT",
+ "name": "Put options on QQQ",
+ "type": "Options Strategy",
+ "rationale": "Buy QQQ put options as targeted downside protection for tech-heavy portfolios.",
+ "risk_level": "medium",
+ },
+ ],
+ "Energy": [
+ {
+ "ticker": "ERY",
+ "name": "Direxion Daily Energy Bear 2× ETF",
+ "type": "Inverse ETF",
+ "rationale": "Inverse energy exposure; hedges oil/gas concentration risk.",
+ "risk_level": "high",
+ },
+ {
+ "ticker": "XLU",
+ "name": "Utilities Select Sector SPDR",
+ "type": "Defensive Equity ETF",
+ "rationale": "Utilities are low-beta defensives that offset cyclical energy holdings.",
+ "risk_level": "low",
+ },
+ ],
+ "Financials": [
+ {
+ "ticker": "SKF",
+ "name": "ProShares Ultra Short Financials",
+ "type": "Inverse ETF",
+ "rationale": "Inverse financials ETF; hedges bank and credit exposure.",
+ "risk_level": "high",
+ },
+ ],
+ "Healthcare": [
+ {
+ "ticker": "RXD",
+ "name": "ProShares Ultra Short Health Care",
+ "type": "Inverse ETF",
+ "rationale": "Hedges overweight healthcare/biotech concentration.",
+ "risk_level": "high",
+ },
+ ],
+ "DEFAULT": [
+ {
+ "ticker": "VXUS",
+ "name": "Vanguard Total International Stock ETF",
+ "type": "Diversification",
+ "rationale": "Broad international diversification reduces domestic sector concentration.",
+ "risk_level": "low",
+ },
+ ],
+ },
+ "regional": {
+ "North America": [
+ {
+ "ticker": "EFA",
+ "name": "iShares MSCI EAFE ETF",
+ "type": "Geographic Diversification",
+ "rationale": "Developed-market international exposure offsets US-heavy concentration.",
+ "risk_level": "low",
+ },
+ ],
+ "Europe": [
+ {
+ "ticker": "EPV",
+ "name": "ProShares Ultra Short FTSE Europe",
+ "type": "Inverse ETF",
+ "rationale": "Inverse European equities ETF; hedges European macro/political risk.",
+ "risk_level": "high",
+ },
+ ],
+ "Asia": [
+ {
+ "ticker": "EWJ",
+ "name": "iShares MSCI Japan ETF + short futures",
+ "type": "Hedged Exposure",
+ "rationale": "Currency-hedged Japan ETF reduces yen FX risk on Asian holdings.",
+ "risk_level": "medium",
+ },
+ ],
+ "DEFAULT": [
+ {
+ "ticker": "ACWX",
+ "name": "iShares MSCI ACWI ex US ETF",
+ "type": "Geographic Diversification",
+ "rationale": "Broad ex-US exposure balances home-country bias.",
+ "risk_level": "low",
+ },
+ ],
+ },
+ "currency": [
+ {
+ "ticker": "UUP",
+ "name": "Invesco DB US Dollar Index Bullish Fund",
+ "type": "Currency Hedge",
+ "rationale": "Long USD hedge against foreign-currency denominated international holdings.",
+ "risk_level": "medium",
+ },
+ {
+ "ticker": "FXE",
+ "name": "Invesco CurrencyShares Euro Trust",
+ "type": "Currency Hedge",
+ "rationale": "Direct EUR exposure hedge for portfolios with significant European assets.",
+ "risk_level": "medium",
+ },
+ ],
+}
+
+
+def _build_hedge_recommendations(rows, account_id, risk_focus):
+ """Derive risk factors from query rows and map to hedge recommendations."""
+ holdings = [r for r in rows if r["ROW_TYPE"] == "HOLDING"]
+ sectors = [r for r in rows if r["ROW_TYPE"] == "SECTOR"]
+ regions = [r for r in rows if r["ROW_TYPE"] == "REGION"]
+ asset_classes = [r for r in rows if r["ROW_TYPE"] == "ASSET_CLASS"]
+ high_risk = [r for r in rows if r["ROW_TYPE"] == "HIGH_RISK"]
+
+ # Pull account-level metadata from first holding row
+ meta = holdings[0] if holdings else {}
+ risk_profile = meta.get("RISK_PROFILE", "unknown")
+ esg_mandate = (meta.get("ESG_MANDATE") or "no").lower() == "yes"
+ excluded_sectors = []
+ try:
+ raw = meta.get("EXCLUDED_SECTORS") or "[]"
+ excluded_sectors = json.loads(raw) if isinstance(raw, str) else raw
+ except Exception:
+ excluded_sectors = []
+
+ # Build risk factor summary
+ risk_factors = []
+ recommendations = []
+
+ # --- Market risk ---
+ if risk_focus in ("market", "all"):
+ equity_pct = sum(
+ float(r["PCT"] or 0) for r in asset_classes if "equity" in (r["ID"] or "").lower()
+ )
+ if equity_pct > 60:
+ risk_factors.append(
+ f"High equity concentration: {equity_pct:.1f}% of portfolio is in equities."
+ )
+ for h in _HEDGE_CATALOGUE["market"]:
+ if esg_mandate and h["ticker"] in ("SQQQ",):
+ continue # Skip leveraged products for ESG/conservative accounts
+ recommendations.append(
+ {**h, "hedge_dimension": "market", "trigger": f"equity_pct={equity_pct:.1f}%"}
+ )
+
+ if high_risk:
+ high_risk_pct = sum(float(r["PCT"] or 0) for r in high_risk)
+ risk_factors.append(
+ f"{len(high_risk)} high-risk positions (risk_rating ≥ 7) totalling {high_risk_pct:.1f}% of portfolio."
+ )
+ recommendations.append(
+ {
+ "ticker": "GLD",
+ "name": "SPDR Gold Shares",
+ "type": "Safe Haven",
+ "rationale": f"Gold allocation offsets tail risk from {len(high_risk)} high-rated positions.",
+ "risk_level": "low",
+ "hedge_dimension": "market",
+ "trigger": f"high_risk_positions={len(high_risk)}",
+ }
+ )
+
+ # --- Sector risk ---
+ if risk_focus in ("sector", "all") and sectors:
+ top_sector = sectors[0]
+ top_sector_name = top_sector["ID"] or "Unknown"
+ top_sector_pct = float(top_sector["PCT"] or 0)
+ if top_sector_pct > 30:
+ risk_factors.append(
+ f"Sector concentration: {top_sector_name} represents {top_sector_pct:.1f}% of portfolio."
+ )
+ sector_hedges = _HEDGE_CATALOGUE["sector"].get(
+ top_sector_name, _HEDGE_CATALOGUE["sector"]["DEFAULT"]
+ )
+ for h in sector_hedges:
+ if esg_mandate and h["risk_level"] == "high":
+ continue
+ if top_sector_name in excluded_sectors:
+ continue
+ recommendations.append(
+ {
+ **h,
+ "hedge_dimension": "sector",
+ "trigger": f"{top_sector_name}={top_sector_pct:.1f}%",
+ }
+ )
+
+ # --- Regional risk ---
+ if risk_focus in ("regional", "all") and regions:
+ top_region = regions[0]
+ top_region_name = top_region["ID"] or "Unknown"
+ top_region_pct = float(top_region["PCT"] or 0)
+ if top_region_pct > 50:
+ risk_factors.append(
+ f"Regional concentration: {top_region_name} represents {top_region_pct:.1f}% of portfolio."
+ )
+ region_hedges = _HEDGE_CATALOGUE["regional"].get(
+ top_region_name, _HEDGE_CATALOGUE["regional"]["DEFAULT"]
+ )
+ for h in region_hedges:
+ recommendations.append(
+ {
+ **h,
+ "hedge_dimension": "regional",
+ "trigger": f"{top_region_name}={top_region_pct:.1f}%",
+ }
+ )
+
+ # --- Currency risk ---
+ if risk_focus in ("currency", "all"):
+ intl_pct = sum(
+ float(r["PCT"] or 0)
+ for r in regions
+ if (r["ID"] or "").lower() not in ("north america", "usa", "us")
+ )
+ if intl_pct > 25:
+ risk_factors.append(
+ f"International exposure: {intl_pct:.1f}% of portfolio is outside North America, creating FX risk."
+ )
+ for h in _HEDGE_CATALOGUE["currency"]:
+ recommendations.append(
+ {**h, "hedge_dimension": "currency", "trigger": f"intl_pct={intl_pct:.1f}%"}
+ )
+
+ # De-duplicate by ticker, keeping first occurrence
+ seen_tickers = set()
+ unique_recommendations = []
+ for r in recommendations:
+ if r["ticker"] not in seen_tickers:
+ seen_tickers.add(r["ticker"])
+ unique_recommendations.append(r)
+
+ return {
+ "account_id": account_id,
+ "risk_profile": risk_profile,
+ "esg_mandate": esg_mandate,
+ "risk_focus": risk_focus,
+ "portfolio_summary": {
+ "total_holdings": len(holdings),
+ "high_risk_positions": len(high_risk),
+ "sector_breakdown": [{"sector": r["ID"], "pct": r["PCT"]} for r in sectors],
+ "region_breakdown": [{"region": r["ID"], "pct": r["PCT"]} for r in regions],
+ "asset_class_breakdown": [
+ {"asset_class": r["ID"], "pct": r["PCT"]} for r in asset_classes
+ ],
+ },
+ "risk_factors_identified": risk_factors,
+ "hedge_recommendations": unique_recommendations,
+ "disclaimer": (
+ "These recommendations are generated algorithmically for illustrative purposes only. "
+ "All hedging decisions should be reviewed by a qualified financial advisor and assessed "
+ "for suitability against the client's investment policy statement."
+ ),
+ }
+
+
def _convergent_search(conn, embedding_model, args, query_logger):
"""Convergent query: Relational + Graph + Vector + Spatial in a single SQL statement.
diff --git a/apps/finance-ai-agent-demo/backend/pytest.ini b/apps/finance-ai-agent-demo/backend/pytest.ini
new file mode 100644
index 00000000..9855d94e
--- /dev/null
+++ b/apps/finance-ai-agent-demo/backend/pytest.ini
@@ -0,0 +1,6 @@
+[pytest]
+testpaths = tests
+python_files = test_*.py
+python_classes = Test*
+python_functions = test_*
+addopts = -v --tb=short
diff --git a/apps/finance-ai-agent-demo/backend/requirements.txt b/apps/finance-ai-agent-demo/backend/requirements.txt
index 19ef71c5..dbfdf3b5 100644
--- a/apps/finance-ai-agent-demo/backend/requirements.txt
+++ b/apps/finance-ai-agent-demo/backend/requirements.txt
@@ -24,3 +24,5 @@ pandas>=2.0
pydantic>=2.0
python-dotenv>=1.0
tiktoken>=0.7
+pytest>=8.0
+pytest-mock>=3.12
diff --git a/apps/finance-ai-agent-demo/backend/tests/__init__.py b/apps/finance-ai-agent-demo/backend/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/apps/finance-ai-agent-demo/backend/tests/conftest.py b/apps/finance-ai-agent-demo/backend/tests/conftest.py
new file mode 100644
index 00000000..9d60e49e
--- /dev/null
+++ b/apps/finance-ai-agent-demo/backend/tests/conftest.py
@@ -0,0 +1,270 @@
+"""Shared pytest fixtures for the finance-ai-agent backend test suite."""
+
+import pytest
+
+# ---------------------------------------------------------------------------
+# Minimal portfolio rows as returned by _suggest_portfolio_hedge's SQL query
+# ---------------------------------------------------------------------------
+
+
+def _holding(
+ holding_id,
+ instrument_name,
+ ticker,
+ sector,
+ region,
+ asset_class,
+ risk_rating,
+ pct,
+ risk_profile="moderate",
+ esg_mandate=None,
+ max_position="0.10",
+ excluded_sectors=None,
+):
+ return {
+ "ROW_TYPE": "HOLDING",
+ "ID": holding_id,
+ "LABEL": instrument_name,
+ "TICKER": ticker,
+ "SECTOR": sector,
+ "REGION": region,
+ "ASSET_CLASS": asset_class,
+ "RISK_RATING": risk_rating,
+ "PCT": pct,
+ "RISK_PROFILE": risk_profile,
+ "ESG_MANDATE": esg_mandate,
+ "MAX_POSITION": max_position,
+ "EXCLUDED_SECTORS": excluded_sectors or "[]",
+ }
+
+
+def _sector(name, pct):
+ return {
+ "ROW_TYPE": "SECTOR",
+ "ID": name,
+ "LABEL": name,
+ "TICKER": None,
+ "SECTOR": None,
+ "REGION": None,
+ "ASSET_CLASS": None,
+ "RISK_RATING": None,
+ "PCT": pct,
+ "RISK_PROFILE": None,
+ "ESG_MANDATE": None,
+ "MAX_POSITION": None,
+ "EXCLUDED_SECTORS": None,
+ }
+
+
+def _region(name, pct):
+ return {
+ "ROW_TYPE": "REGION",
+ "ID": name,
+ "LABEL": name,
+ "TICKER": None,
+ "SECTOR": None,
+ "REGION": None,
+ "ASSET_CLASS": None,
+ "RISK_RATING": None,
+ "PCT": pct,
+ "RISK_PROFILE": None,
+ "ESG_MANDATE": None,
+ "MAX_POSITION": None,
+ "EXCLUDED_SECTORS": None,
+ }
+
+
+def _asset_class(name, pct):
+ return {
+ "ROW_TYPE": "ASSET_CLASS",
+ "ID": name,
+ "LABEL": name,
+ "TICKER": None,
+ "SECTOR": None,
+ "REGION": None,
+ "ASSET_CLASS": None,
+ "RISK_RATING": None,
+ "PCT": pct,
+ "RISK_PROFILE": None,
+ "ESG_MANDATE": None,
+ "MAX_POSITION": None,
+ "EXCLUDED_SECTORS": None,
+ }
+
+
+def _high_risk(holding_id, instrument_name, ticker, sector, region, risk_rating, pct):
+ return {
+ "ROW_TYPE": "HIGH_RISK",
+ "ID": holding_id,
+ "LABEL": instrument_name,
+ "TICKER": ticker,
+ "SECTOR": sector,
+ "REGION": region,
+ "ASSET_CLASS": None,
+ "RISK_RATING": risk_rating,
+ "PCT": pct,
+ "RISK_PROFILE": None,
+ "ESG_MANDATE": None,
+ "MAX_POSITION": None,
+ "EXCLUDED_SECTORS": None,
+ }
+
+
+@pytest.fixture
+def tech_heavy_rows():
+ """Portfolio that is tech-heavy (40% sector) and equity-heavy (80% equities),
+ located primarily in North America (70%), with two high-risk positions."""
+ return [
+ _holding(
+ "H001",
+ "Apple Inc",
+ "AAPL",
+ "Technology",
+ "North America",
+ "Equity",
+ 6,
+ 25.0,
+ risk_profile="aggressive",
+ ),
+ _holding(
+ "H002",
+ "Microsoft Corp",
+ "MSFT",
+ "Technology",
+ "North America",
+ "Equity",
+ 5,
+ 15.0,
+ risk_profile="aggressive",
+ ),
+ _holding(
+ "H003",
+ "ExxonMobil",
+ "XOM",
+ "Energy",
+ "North America",
+ "Equity",
+ 4,
+ 20.0,
+ risk_profile="aggressive",
+ ),
+ _holding(
+ "H004",
+ "Riskco Ltd",
+ "RSK",
+ "Technology",
+ "Europe",
+ "Equity",
+ 8,
+ 10.0,
+ risk_profile="aggressive",
+ ),
+ _holding(
+ "H005",
+ "DangerFund",
+ "DNG",
+ "Financials",
+ "Asia",
+ "Equity",
+ 9,
+ 10.0,
+ risk_profile="aggressive",
+ ),
+ _holding(
+ "H006",
+ "Safe Bond A",
+ "SBA",
+ "Government",
+ "North America",
+ "Bond",
+ 2,
+ 20.0,
+ risk_profile="aggressive",
+ ),
+ _sector("Technology", 40.0),
+ _sector("Energy", 20.0),
+ _sector("Government", 20.0),
+ _sector("Financials", 10.0),
+ _region("North America", 65.0),
+ _region("Europe", 20.0),
+ _region("Asia", 15.0),
+ _asset_class("Equity", 80.0),
+ _asset_class("Bond", 20.0),
+ _high_risk("H004", "Riskco Ltd", "RSK", "Technology", "Europe", 8, 10.0),
+ _high_risk("H005", "DangerFund", "DNG", "Financials", "Asia", 9, 10.0),
+ ]
+
+
+@pytest.fixture
+def esg_rows():
+ """Portfolio with ESG mandate set — leveraged/high-risk instruments should be filtered."""
+ return [
+ _holding(
+ "H001",
+ "Green Energy ETF",
+ "GRN",
+ "Energy",
+ "North America",
+ "Equity",
+ 3,
+ 70.0,
+ risk_profile="conservative",
+ esg_mandate="yes",
+ ),
+ _holding(
+ "H002",
+ "Clean Tech Fund",
+ "CLT",
+ "Technology",
+ "North America",
+ "Equity",
+ 4,
+ 30.0,
+ risk_profile="conservative",
+ esg_mandate="yes",
+ ),
+ _sector("Energy", 70.0),
+ _sector("Technology", 30.0),
+ _region("North America", 100.0),
+ _asset_class("Equity", 100.0),
+ ]
+
+
+@pytest.fixture
+def balanced_rows():
+ """Well-diversified portfolio — should trigger few or no risk factors."""
+ return [
+ _holding("H001", "US Equity Fund", "USQ", "Financials", "North America", "Equity", 3, 20.0),
+ _holding("H002", "EU Bond Fund", "EUB", "Government", "Europe", "Bond", 2, 20.0),
+ _holding("H003", "Asia Growth", "ASG", "Technology", "Asia", "Equity", 4, 20.0),
+ _holding("H004", "Gold ETF", "GLD", "Commodities", "North America", "Commodity", 2, 20.0),
+ _holding("H005", "EM Equity", "EMQ", "Financials", "Asia", "Equity", 5, 20.0),
+ _sector("Financials", 25.0),
+ _sector("Government", 20.0),
+ _sector("Technology", 20.0),
+ _sector("Commodities", 20.0),
+ _region("North America", 40.0),
+ _region("Europe", 20.0),
+ _region("Asia", 40.0),
+ _asset_class("Equity", 60.0),
+ _asset_class("Bond", 20.0),
+ _asset_class("Commodity", 20.0),
+ ]
+
+
+@pytest.fixture
+def intl_heavy_rows():
+ """Portfolio with 60% outside North America — should trigger currency hedge."""
+ return [
+ _holding("H001", "EU Stocks", "EUS", "Financials", "Europe", "Equity", 4, 35.0),
+ _holding("H002", "Japan Growth", "JPG", "Technology", "Asia", "Equity", 5, 25.0),
+ _holding("H003", "US Bonds", "USB", "Government", "North America", "Bond", 2, 40.0),
+ _sector("Financials", 35.0),
+ _sector("Technology", 25.0),
+ _sector("Government", 40.0),
+ _region("Europe", 35.0),
+ _region("Asia", 25.0),
+ _region("North America", 40.0),
+ _asset_class("Equity", 60.0),
+ _asset_class("Bond", 40.0),
+ ]
diff --git a/apps/finance-ai-agent-demo/backend/tests/test_hedge_recommendations.py b/apps/finance-ai-agent-demo/backend/tests/test_hedge_recommendations.py
new file mode 100644
index 00000000..b5c144c2
--- /dev/null
+++ b/apps/finance-ai-agent-demo/backend/tests/test_hedge_recommendations.py
@@ -0,0 +1,389 @@
+"""Unit tests for the hedge recommendation engine (_build_hedge_recommendations).
+
+All tests run without a database connection — they call the pure-Python
+recommendation logic directly with pre-built row fixtures.
+"""
+
+import json
+import os
+import sys
+
+# Ensure the backend package root is on the path so imports resolve
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
+
+from agent.tools import _build_hedge_recommendations
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def tickers(recommendations):
+ return [r["ticker"] for r in recommendations]
+
+
+def dimensions(recommendations):
+ return {r["hedge_dimension"] for r in recommendations}
+
+
+# ---------------------------------------------------------------------------
+# Output structure
+# ---------------------------------------------------------------------------
+
+
+class TestOutputStructure:
+ def test_returns_dict_with_required_keys(self, tech_heavy_rows):
+ result = _build_hedge_recommendations(tech_heavy_rows, "ACC-001", "all")
+ for key in (
+ "account_id",
+ "risk_profile",
+ "esg_mandate",
+ "risk_focus",
+ "portfolio_summary",
+ "risk_factors_identified",
+ "hedge_recommendations",
+ "disclaimer",
+ ):
+ assert key in result, f"Missing key: {key}"
+
+ def test_account_id_preserved(self, tech_heavy_rows):
+ result = _build_hedge_recommendations(tech_heavy_rows, "ACC-042", "all")
+ assert result["account_id"] == "ACC-042"
+
+ def test_risk_focus_preserved(self, tech_heavy_rows):
+ result = _build_hedge_recommendations(tech_heavy_rows, "ACC-001", "sector")
+ assert result["risk_focus"] == "sector"
+
+ def test_portfolio_summary_contains_breakdowns(self, tech_heavy_rows):
+ result = _build_hedge_recommendations(tech_heavy_rows, "ACC-001", "all")
+ summary = result["portfolio_summary"]
+ assert isinstance(summary["sector_breakdown"], list)
+ assert isinstance(summary["region_breakdown"], list)
+ assert isinstance(summary["asset_class_breakdown"], list)
+ assert summary["total_holdings"] == 6
+ assert summary["high_risk_positions"] == 2
+
+ def test_disclaimer_present_and_non_empty(self, tech_heavy_rows):
+ result = _build_hedge_recommendations(tech_heavy_rows, "ACC-001", "all")
+ assert len(result["disclaimer"]) > 20
+
+ def test_recommendations_are_list(self, tech_heavy_rows):
+ result = _build_hedge_recommendations(tech_heavy_rows, "ACC-001", "all")
+ assert isinstance(result["hedge_recommendations"], list)
+
+ def test_each_recommendation_has_required_fields(self, tech_heavy_rows):
+ result = _build_hedge_recommendations(tech_heavy_rows, "ACC-001", "all")
+ for rec in result["hedge_recommendations"]:
+ for field in (
+ "ticker",
+ "name",
+ "type",
+ "rationale",
+ "risk_level",
+ "hedge_dimension",
+ "trigger",
+ ):
+ assert field in rec, f"Recommendation missing field: {field}"
+
+ def test_no_duplicate_tickers(self, tech_heavy_rows):
+ result = _build_hedge_recommendations(tech_heavy_rows, "ACC-001", "all")
+ seen = [r["ticker"] for r in result["hedge_recommendations"]]
+ assert len(seen) == len(set(seen)), f"Duplicate tickers found: {seen}"
+
+ def test_json_serialisable(self, tech_heavy_rows):
+ result = _build_hedge_recommendations(tech_heavy_rows, "ACC-001", "all")
+ # Should not raise
+ json.dumps(result)
+
+
+# ---------------------------------------------------------------------------
+# Market risk detection
+# ---------------------------------------------------------------------------
+
+
+class TestMarketRiskDetection:
+ def test_equity_heavy_triggers_market_risk_factor(self, tech_heavy_rows):
+ result = _build_hedge_recommendations(tech_heavy_rows, "ACC-001", "market")
+ assert any("equity" in f.lower() for f in result["risk_factors_identified"])
+
+ def test_market_hedge_instruments_included_for_equity_heavy(self, tech_heavy_rows):
+ result = _build_hedge_recommendations(tech_heavy_rows, "ACC-001", "market")
+ t = tickers(result["hedge_recommendations"])
+ # At least one of the core market hedges must appear
+ assert any(tk in t for tk in ("SH", "GLD", "TLT")), f"No market hedge found: {t}"
+
+ def test_high_risk_positions_trigger_market_risk_factor(self, tech_heavy_rows):
+ result = _build_hedge_recommendations(tech_heavy_rows, "ACC-001", "market")
+ assert any("high-risk" in f.lower() for f in result["risk_factors_identified"])
+
+ def test_high_risk_positions_trigger_gold_recommendation(self, tech_heavy_rows):
+ result = _build_hedge_recommendations(tech_heavy_rows, "ACC-001", "market")
+ assert "GLD" in tickers(result["hedge_recommendations"])
+
+ def test_balanced_portfolio_no_market_risk_factor(self, balanced_rows):
+ result = _build_hedge_recommendations(balanced_rows, "ACC-001", "market")
+ # Equity is only 60% — below the 60% threshold, no market risk factor
+ assert not any("equity" in f.lower() for f in result["risk_factors_identified"])
+
+ def test_market_focus_only_returns_market_dimension(self, tech_heavy_rows):
+ result = _build_hedge_recommendations(tech_heavy_rows, "ACC-001", "market")
+ dims = dimensions(result["hedge_recommendations"])
+ assert dims <= {"market"}, f"Unexpected dimensions: {dims}"
+
+
+# ---------------------------------------------------------------------------
+# Sector risk detection
+# ---------------------------------------------------------------------------
+
+
+class TestSectorRiskDetection:
+ def test_tech_overweight_triggers_sector_risk_factor(self, tech_heavy_rows):
+ result = _build_hedge_recommendations(tech_heavy_rows, "ACC-001", "sector")
+ assert any("technology" in f.lower() for f in result["risk_factors_identified"])
+
+ def test_tech_overweight_triggers_tech_hedges(self, tech_heavy_rows):
+ result = _build_hedge_recommendations(tech_heavy_rows, "ACC-001", "sector")
+ # Tech hedges include PUT (QQQ puts) or REK
+ t = tickers(result["hedge_recommendations"])
+ assert any(tk in t for tk in ("PUT", "REK", "VXUS")), f"No sector hedge found: {t}"
+
+ def test_balanced_portfolio_no_sector_risk_factor(self, balanced_rows):
+ result = _build_hedge_recommendations(balanced_rows, "ACC-001", "sector")
+ # No sector > 30%, so no sector risk factor expected
+ assert not any("sector" in f.lower() or "%" in f for f in result["risk_factors_identified"])
+
+ def test_sector_focus_only_returns_sector_dimension(self, tech_heavy_rows):
+ result = _build_hedge_recommendations(tech_heavy_rows, "ACC-001", "sector")
+ dims = dimensions(result["hedge_recommendations"])
+ assert dims <= {"sector"}, f"Unexpected dimensions: {dims}"
+
+
+# ---------------------------------------------------------------------------
+# Regional risk detection
+# ---------------------------------------------------------------------------
+
+
+class TestRegionalRiskDetection:
+ def test_na_heavy_triggers_regional_risk_factor(self, tech_heavy_rows):
+ # North America is 65% — above the 50% threshold
+ result = _build_hedge_recommendations(tech_heavy_rows, "ACC-001", "regional")
+ assert any("north america" in f.lower() for f in result["risk_factors_identified"])
+
+ def test_na_heavy_triggers_geographic_diversification_hedge(self, tech_heavy_rows):
+ result = _build_hedge_recommendations(tech_heavy_rows, "ACC-001", "regional")
+ t = tickers(result["hedge_recommendations"])
+ assert any(tk in t for tk in ("EFA", "ACWX")), f"No regional hedge found: {t}"
+
+ def test_balanced_no_regional_risk_factor(self, balanced_rows):
+ result = _build_hedge_recommendations(balanced_rows, "ACC-001", "regional")
+ # No region > 50% in balanced_rows
+ assert not result["risk_factors_identified"]
+
+ def test_regional_focus_only_returns_regional_dimension(self, tech_heavy_rows):
+ result = _build_hedge_recommendations(tech_heavy_rows, "ACC-001", "regional")
+ dims = dimensions(result["hedge_recommendations"])
+ assert dims <= {"regional"}, f"Unexpected dimensions: {dims}"
+
+
+# ---------------------------------------------------------------------------
+# Currency risk detection
+# ---------------------------------------------------------------------------
+
+
+class TestCurrencyRiskDetection:
+ def test_intl_heavy_triggers_currency_risk_factor(self, intl_heavy_rows):
+ result = _build_hedge_recommendations(intl_heavy_rows, "ACC-001", "currency")
+ assert any(
+ "fx" in f.lower() or "international" in f.lower()
+ for f in result["risk_factors_identified"]
+ )
+
+ def test_intl_heavy_includes_currency_hedge_instruments(self, intl_heavy_rows):
+ result = _build_hedge_recommendations(intl_heavy_rows, "ACC-001", "currency")
+ t = tickers(result["hedge_recommendations"])
+ assert any(tk in t for tk in ("UUP", "FXE")), f"No currency hedge found: {t}"
+
+ def test_na_only_no_currency_risk(self, esg_rows):
+ # esg_rows is 100% North America
+ result = _build_hedge_recommendations(esg_rows, "ACC-001", "currency")
+ assert not result["risk_factors_identified"]
+
+ def test_currency_focus_only_returns_currency_dimension(self, intl_heavy_rows):
+ result = _build_hedge_recommendations(intl_heavy_rows, "ACC-001", "currency")
+ dims = dimensions(result["hedge_recommendations"])
+ assert dims <= {"currency"}, f"Unexpected dimensions: {dims}"
+
+
+# ---------------------------------------------------------------------------
+# ESG mandate filtering
+# ---------------------------------------------------------------------------
+
+
+class TestEsgMandateFiltering:
+ def test_esg_mandate_detected_from_rows(self, esg_rows):
+ result = _build_hedge_recommendations(esg_rows, "ACC-001", "all")
+ assert result["esg_mandate"] is True
+
+ def test_no_esg_mandate_when_field_absent(self, tech_heavy_rows):
+ # tech_heavy_rows have esg_mandate=None
+ result = _build_hedge_recommendations(tech_heavy_rows, "ACC-001", "all")
+ assert result["esg_mandate"] is False
+
+ def test_esg_filters_out_sqqq(self, esg_rows):
+ # Energy is 70% — triggers market risk; SQQQ should be absent for ESG accounts
+ result = _build_hedge_recommendations(esg_rows, "ACC-001", "market")
+ assert "SQQQ" not in tickers(result["hedge_recommendations"])
+
+ def test_esg_filters_high_risk_level_sector_hedges(self, esg_rows):
+ # Sector hedges with risk_level='high' should be excluded for ESG accounts
+ result = _build_hedge_recommendations(esg_rows, "ACC-001", "sector")
+ for rec in result["hedge_recommendations"]:
+ assert rec["risk_level"] != "high", (
+ f"High-risk instrument {rec['ticker']} slipped through ESG filter"
+ )
+
+
+# ---------------------------------------------------------------------------
+# risk_focus scoping
+# ---------------------------------------------------------------------------
+
+
+class TestRiskFocusScoping:
+ def test_all_can_return_multiple_dimensions(self, tech_heavy_rows):
+ result = _build_hedge_recommendations(tech_heavy_rows, "ACC-001", "all")
+ dims = dimensions(result["hedge_recommendations"])
+ # With tech_heavy_rows, at minimum market + sector + regional should fire
+ assert len(dims) >= 2, f"Expected multiple dimensions, got: {dims}"
+
+ def test_market_focus_excludes_sector_dimension(self, tech_heavy_rows):
+ result = _build_hedge_recommendations(tech_heavy_rows, "ACC-001", "market")
+ assert "sector" not in dimensions(result["hedge_recommendations"])
+
+ def test_sector_focus_excludes_market_dimension(self, tech_heavy_rows):
+ result = _build_hedge_recommendations(tech_heavy_rows, "ACC-001", "sector")
+ assert "market" not in dimensions(result["hedge_recommendations"])
+
+ def test_regional_focus_excludes_market_dimension(self, tech_heavy_rows):
+ result = _build_hedge_recommendations(tech_heavy_rows, "ACC-001", "regional")
+ assert "market" not in dimensions(result["hedge_recommendations"])
+
+ def test_currency_focus_excludes_sector_dimension(self, intl_heavy_rows):
+ result = _build_hedge_recommendations(intl_heavy_rows, "ACC-001", "currency")
+ assert "sector" not in dimensions(result["hedge_recommendations"])
+
+
+# ---------------------------------------------------------------------------
+# Empty / edge cases
+# ---------------------------------------------------------------------------
+
+
+class TestEdgeCases:
+ def test_empty_rows_returns_no_risk_factors_or_recommendations(self):
+ # No holdings at all — _suggest_portfolio_hedge would catch this upstream,
+ # but _build_hedge_recommendations should handle gracefully
+ result = _build_hedge_recommendations([], "ACC-999", "all")
+ assert result["risk_factors_identified"] == []
+ assert result["hedge_recommendations"] == []
+
+ def test_none_pct_values_do_not_crash(self):
+ rows = [
+ {
+ "ROW_TYPE": "HOLDING",
+ "ID": "H001",
+ "LABEL": "Foo",
+ "TICKER": "FOO",
+ "SECTOR": "Technology",
+ "REGION": "North America",
+ "ASSET_CLASS": "Equity",
+ "RISK_RATING": 5,
+ "PCT": None,
+ "RISK_PROFILE": "moderate",
+ "ESG_MANDATE": None,
+ "MAX_POSITION": "0.10",
+ "EXCLUDED_SECTORS": "[]",
+ },
+ {
+ "ROW_TYPE": "ASSET_CLASS",
+ "ID": "Equity",
+ "LABEL": "Equity",
+ "TICKER": None,
+ "SECTOR": None,
+ "REGION": None,
+ "ASSET_CLASS": None,
+ "RISK_RATING": None,
+ "PCT": None,
+ "RISK_PROFILE": None,
+ "ESG_MANDATE": None,
+ "MAX_POSITION": None,
+ "EXCLUDED_SECTORS": None,
+ },
+ ]
+ # Should not raise even with None PCT values
+ result = _build_hedge_recommendations(rows, "ACC-999", "all")
+ assert isinstance(result, dict)
+
+ def test_malformed_excluded_sectors_does_not_crash(self):
+ rows = [
+ {
+ "ROW_TYPE": "HOLDING",
+ "ID": "H001",
+ "LABEL": "Foo",
+ "TICKER": "FOO",
+ "SECTOR": "Technology",
+ "REGION": "North America",
+ "ASSET_CLASS": "Equity",
+ "RISK_RATING": 5,
+ "PCT": 50.0,
+ "RISK_PROFILE": "moderate",
+ "ESG_MANDATE": None,
+ "MAX_POSITION": "0.10",
+ "EXCLUDED_SECTORS": "NOT_VALID_JSON",
+ },
+ {
+ "ROW_TYPE": "SECTOR",
+ "ID": "Technology",
+ "LABEL": "Technology",
+ "TICKER": None,
+ "SECTOR": None,
+ "REGION": None,
+ "ASSET_CLASS": None,
+ "RISK_RATING": None,
+ "PCT": 50.0,
+ "RISK_PROFILE": None,
+ "ESG_MANDATE": None,
+ "MAX_POSITION": None,
+ "EXCLUDED_SECTORS": None,
+ },
+ {
+ "ROW_TYPE": "REGION",
+ "ID": "North America",
+ "LABEL": "North America",
+ "TICKER": None,
+ "SECTOR": None,
+ "REGION": None,
+ "ASSET_CLASS": None,
+ "RISK_RATING": None,
+ "PCT": 100.0,
+ "RISK_PROFILE": None,
+ "ESG_MANDATE": None,
+ "MAX_POSITION": None,
+ "EXCLUDED_SECTORS": None,
+ },
+ {
+ "ROW_TYPE": "ASSET_CLASS",
+ "ID": "Equity",
+ "LABEL": "Equity",
+ "TICKER": None,
+ "SECTOR": None,
+ "REGION": None,
+ "ASSET_CLASS": None,
+ "RISK_RATING": None,
+ "PCT": 100.0,
+ "RISK_PROFILE": None,
+ "ESG_MANDATE": None,
+ "MAX_POSITION": None,
+ "EXCLUDED_SECTORS": None,
+ },
+ ]
+ result = _build_hedge_recommendations(rows, "ACC-999", "all")
+ assert isinstance(result, dict)
diff --git a/apps/finance-ai-agent-demo/backend/tests/test_suggest_portfolio_hedge_integration.py b/apps/finance-ai-agent-demo/backend/tests/test_suggest_portfolio_hedge_integration.py
new file mode 100644
index 00000000..345ce7ab
--- /dev/null
+++ b/apps/finance-ai-agent-demo/backend/tests/test_suggest_portfolio_hedge_integration.py
@@ -0,0 +1,209 @@
+"""Integration-style tests for _suggest_portfolio_hedge.
+
+These tests call _suggest_portfolio_hedge directly with a mock DB connection
+that returns controlled row data — no real Oracle instance required.
+The goal is to verify the full pipeline: SQL execution → row parsing →
+_build_hedge_recommendations → JSON output.
+"""
+
+import json
+import os
+import sys
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
+
+
+# ---------------------------------------------------------------------------
+# Minimal mock connection and execute_query that return fixture rows
+# ---------------------------------------------------------------------------
+
+
+class MockQueryLogger:
+ def __init__(self):
+ self.calls = []
+
+ def log(self, *args, **kwargs):
+ self.calls.append((args, kwargs))
+
+
+def make_execute_query_stub(rows):
+ """Return a drop-in for execute_query that yields `rows` with column names."""
+ columns = [
+ "ROW_TYPE",
+ "ID",
+ "LABEL",
+ "TICKER",
+ "SECTOR",
+ "REGION",
+ "ASSET_CLASS",
+ "RISK_RATING",
+ "PCT",
+ "RISK_PROFILE",
+ "ESG_MANDATE",
+ "MAX_POSITION",
+ "EXCLUDED_SECTORS",
+ ]
+
+ def _execute_query(conn, sql, params, query_logger, description=""):
+ # Convert dict rows to tuples matching column order
+ tuple_rows = [tuple(r.get(c) for c in columns) for r in rows]
+ return tuple_rows, columns
+
+ return _execute_query
+
+
+# ---------------------------------------------------------------------------
+# Tests
+# ---------------------------------------------------------------------------
+
+
+class TestSuggestPortfolioHedgeOutput:
+ def test_returns_valid_json_string(self, tech_heavy_rows, monkeypatch):
+ import agent.tools as tools_module
+
+ monkeypatch.setattr("agent.tools.execute_query", make_execute_query_stub(tech_heavy_rows))
+
+ result_str = tools_module._suggest_portfolio_hedge(
+ conn=object(),
+ args={"account_id": "ACC-001", "risk_focus": "all"},
+ query_logger=MockQueryLogger(),
+ )
+ result = json.loads(result_str)
+ assert isinstance(result, dict)
+
+ def test_result_contains_hedge_recommendations(self, tech_heavy_rows, monkeypatch):
+ import agent.tools as tools_module
+
+ monkeypatch.setattr("agent.tools.execute_query", make_execute_query_stub(tech_heavy_rows))
+
+ result = json.loads(
+ tools_module._suggest_portfolio_hedge(
+ conn=object(),
+ args={"account_id": "ACC-001", "risk_focus": "all"},
+ query_logger=MockQueryLogger(),
+ )
+ )
+ assert len(result["hedge_recommendations"]) > 0
+
+ def test_no_holdings_returns_error_message(self, monkeypatch):
+ import agent.tools as tools_module
+
+ monkeypatch.setattr("agent.tools.execute_query", make_execute_query_stub([]))
+
+ result = tools_module._suggest_portfolio_hedge(
+ conn=object(),
+ args={"account_id": "ACC-999"},
+ query_logger=MockQueryLogger(),
+ )
+ assert "No holdings found" in result
+ assert "ACC-999" in result
+
+ def test_default_risk_focus_is_all(self, tech_heavy_rows, monkeypatch):
+ import agent.tools as tools_module
+
+ monkeypatch.setattr("agent.tools.execute_query", make_execute_query_stub(tech_heavy_rows))
+
+ # Omit risk_focus — should default to 'all'
+ result = json.loads(
+ tools_module._suggest_portfolio_hedge(
+ conn=object(),
+ args={"account_id": "ACC-001"},
+ query_logger=MockQueryLogger(),
+ )
+ )
+ assert result["risk_focus"] == "all"
+
+ def test_query_logger_is_called(self, tech_heavy_rows, monkeypatch):
+ import agent.tools as tools_module
+
+ logger = MockQueryLogger()
+ called = []
+
+ def _stub(conn, sql, params, query_logger, description=""):
+ called.append(description)
+ columns = [
+ "ROW_TYPE",
+ "ID",
+ "LABEL",
+ "TICKER",
+ "SECTOR",
+ "REGION",
+ "ASSET_CLASS",
+ "RISK_RATING",
+ "PCT",
+ "RISK_PROFILE",
+ "ESG_MANDATE",
+ "MAX_POSITION",
+ "EXCLUDED_SECTORS",
+ ]
+ return [tuple(r.get(c) for c in columns) for r in tech_heavy_rows], columns
+
+ monkeypatch.setattr("agent.tools.execute_query", _stub)
+ tools_module._suggest_portfolio_hedge(
+ conn=object(),
+ args={"account_id": "ACC-001"},
+ query_logger=logger,
+ )
+ assert any("ACC-001" in d for d in called)
+
+ def test_esg_portfolio_excludes_leveraged_products(self, esg_rows, monkeypatch):
+ import agent.tools as tools_module
+
+ monkeypatch.setattr("agent.tools.execute_query", make_execute_query_stub(esg_rows))
+
+ result = json.loads(
+ tools_module._suggest_portfolio_hedge(
+ conn=object(),
+ args={"account_id": "ACC-ESG", "risk_focus": "all"},
+ query_logger=MockQueryLogger(),
+ )
+ )
+ rec_tickers = [r["ticker"] for r in result["hedge_recommendations"]]
+ assert "SQQQ" not in rec_tickers
+
+ def test_market_focus_filters_non_market_dimensions(self, tech_heavy_rows, monkeypatch):
+ import agent.tools as tools_module
+
+ monkeypatch.setattr("agent.tools.execute_query", make_execute_query_stub(tech_heavy_rows))
+
+ result = json.loads(
+ tools_module._suggest_portfolio_hedge(
+ conn=object(),
+ args={"account_id": "ACC-001", "risk_focus": "market"},
+ query_logger=MockQueryLogger(),
+ )
+ )
+ dims = {r["hedge_dimension"] for r in result["hedge_recommendations"]}
+ assert dims <= {"market"}
+
+ def test_account_id_in_params_passed_to_sql(self, tech_heavy_rows, monkeypatch):
+ import agent.tools as tools_module
+
+ received_params = {}
+
+ def _stub(conn, sql, params, query_logger, description=""):
+ received_params.update(params)
+ columns = [
+ "ROW_TYPE",
+ "ID",
+ "LABEL",
+ "TICKER",
+ "SECTOR",
+ "REGION",
+ "ASSET_CLASS",
+ "RISK_RATING",
+ "PCT",
+ "RISK_PROFILE",
+ "ESG_MANDATE",
+ "MAX_POSITION",
+ "EXCLUDED_SECTORS",
+ ]
+ return [tuple(r.get(c) for c in columns) for r in tech_heavy_rows], columns
+
+ monkeypatch.setattr("agent.tools.execute_query", _stub)
+ tools_module._suggest_portfolio_hedge(
+ conn=object(),
+ args={"account_id": "ACC-007"},
+ query_logger=MockQueryLogger(),
+ )
+ assert received_params.get("account_id") == "ACC-007"
diff --git a/apps/finance-ai-agent-demo/backend/tests/test_tool_schema.py b/apps/finance-ai-agent-demo/backend/tests/test_tool_schema.py
new file mode 100644
index 00000000..abea0677
--- /dev/null
+++ b/apps/finance-ai-agent-demo/backend/tests/test_tool_schema.py
@@ -0,0 +1,124 @@
+"""Tests for the suggest_portfolio_hedge tool schema registration.
+
+Verifies the tool is correctly declared in TOOL_SCHEMAS and PRELOADED_TOOLS
+so the LLM will always have access to it.
+"""
+
+import os
+import sys
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
+
+from agent.tools import PRELOADED_TOOLS, TOOL_SCHEMAS
+
+
+def _find_tool(tools, name):
+ return next((t for t in tools if t["function"]["name"] == name), None)
+
+
+class TestToolSchemaRegistration:
+ def test_tool_present_in_tool_schemas(self):
+ tool = _find_tool(TOOL_SCHEMAS, "suggest_portfolio_hedge")
+ assert tool is not None, "suggest_portfolio_hedge missing from TOOL_SCHEMAS"
+
+ def test_tool_present_in_preloaded_tools(self):
+ tool = _find_tool(PRELOADED_TOOLS, "suggest_portfolio_hedge")
+ assert tool is not None, "suggest_portfolio_hedge missing from PRELOADED_TOOLS"
+
+ def test_tool_has_correct_type(self):
+ tool = _find_tool(TOOL_SCHEMAS, "suggest_portfolio_hedge")
+ assert tool["type"] == "function"
+
+ def test_account_id_is_required_parameter(self):
+ tool = _find_tool(TOOL_SCHEMAS, "suggest_portfolio_hedge")
+ params = tool["function"]["parameters"]
+ assert "account_id" in params["required"]
+ assert "account_id" in params["properties"]
+
+ def test_risk_focus_is_optional(self):
+ tool = _find_tool(TOOL_SCHEMAS, "suggest_portfolio_hedge")
+ params = tool["function"]["parameters"]
+ assert "risk_focus" in params["properties"]
+ assert "risk_focus" not in params.get("required", [])
+
+ def test_risk_focus_enum_values(self):
+ tool = _find_tool(TOOL_SCHEMAS, "suggest_portfolio_hedge")
+ enum_vals = tool["function"]["parameters"]["properties"]["risk_focus"]["enum"]
+ assert set(enum_vals) == {"market", "sector", "regional", "currency", "all"}
+
+ def test_description_is_non_empty(self):
+ tool = _find_tool(TOOL_SCHEMAS, "suggest_portfolio_hedge")
+ assert len(tool["function"]["description"]) > 20
+
+ def test_account_id_has_description(self):
+ tool = _find_tool(TOOL_SCHEMAS, "suggest_portfolio_hedge")
+ desc = tool["function"]["parameters"]["properties"]["account_id"].get("description", "")
+ assert len(desc) > 5
+
+ def test_preloaded_tools_is_superset_of_tool_schemas(self):
+ schema_names = {t["function"]["name"] for t in TOOL_SCHEMAS}
+ preloaded_names = {t["function"]["name"] for t in PRELOADED_TOOLS}
+ assert schema_names == preloaded_names
+
+
+class TestToolDispatcherRouting:
+ """Verify that the dispatcher routes suggest_portfolio_hedge without error.
+
+ We mock the DB connection and query_helper so no real Oracle instance is needed.
+ """
+
+ def test_oracle_mode_routes_to_correct_handler(self, monkeypatch):
+ import agent.tools as tools_module
+
+ # Patch execute_query to return empty rows (no DB needed)
+ monkeypatch.setattr(
+ "agent.tools.execute_query",
+ lambda conn, sql, params, query_logger, description="": ([], []),
+ )
+ # Patch ARCH_MODE to converged
+ monkeypatch.setattr(
+ "agent.tools.os.getenv", lambda k, d="": "converged" if k == "ARCH_MODE" else d
+ )
+
+ import types
+
+ fake_config = types.ModuleType("config")
+ fake_config.ARCH_MODE = "converged"
+ monkeypatch.setitem(sys.modules, "config", fake_config)
+
+ execute_tool = tools_module.create_tool_executor(
+ conn=object(),
+ embedding_model=None,
+ memory_manager=None,
+ llm_client=None,
+ query_logger=None,
+ )
+
+ result = execute_tool("suggest_portfolio_hedge", {"account_id": "ACC-001"})
+ # Empty rows → "No holdings found" message
+ assert "No holdings found" in result or isinstance(result, str)
+
+ def test_unknown_tool_returns_error_string(self, monkeypatch):
+ import types
+
+ fake_config = types.ModuleType("config")
+ fake_config.ARCH_MODE = "converged"
+ monkeypatch.setitem(sys.modules, "config", fake_config)
+
+ import agent.tools as tools_module
+
+ monkeypatch.setattr(
+ "agent.tools.execute_query",
+ lambda *a, **kw: ([], []),
+ )
+
+ execute_tool = tools_module.create_tool_executor(
+ conn=object(),
+ embedding_model=None,
+ memory_manager=None,
+ llm_client=None,
+ query_logger=None,
+ )
+
+ result = execute_tool("nonexistent_tool_xyz", {})
+ assert "Error" in result or "Unknown" in result
diff --git a/apps/finance-ai-agent-demo/frontend/package-lock.json b/apps/finance-ai-agent-demo/frontend/package-lock.json
index d6ad4e96..b6bc4576 100644
--- a/apps/finance-ai-agent-demo/frontend/package-lock.json
+++ b/apps/finance-ai-agent-demo/frontend/package-lock.json
@@ -16,15 +16,28 @@
"socket.io-client": "^4.8.3"
},
"devDependencies": {
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.1",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
+ "@vitest/coverage-v8": "^4.1.7",
"autoprefixer": "^10.4.19",
+ "jsdom": "^29.1.1",
"postcss": "^8.4.39",
"tailwindcss": "^3.4.6",
- "vite": "^6.4.2"
+ "vite": "^6.4.2",
+ "vitest": "^4.1.7"
}
},
+ "node_modules/@adobe/css-tools": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz",
+ "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@alloc/quick-lru": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
@@ -38,6 +51,57 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "5.1.11",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
+ "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/generational-cache": "^1.0.1",
+ "@csstools/css-calc": "^3.2.0",
+ "@csstools/css-color-parser": "^4.1.0",
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
+ "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/generational-cache": "^1.0.1",
+ "@asamuzakjp/nwsapi": "^2.3.9",
+ "bidi-js": "^1.0.3",
+ "css-tree": "^3.2.1",
+ "is-potential-custom-element-name": "^1.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/generational-cache": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
+ "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/nwsapi": {
+ "version": "2.3.9",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
+ "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -181,9 +245,9 @@
}
},
"node_modules/@babel/helper-string-parser": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
- "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -191,9 +255,9 @@
}
},
"node_modules/@babel/helper-validator-identifier": {
- "version": "7.28.5",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
- "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -225,13 +289,13 @@
}
},
"node_modules/@babel/parser": {
- "version": "7.29.0",
- "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
- "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
+ "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/types": "^7.29.0"
+ "@babel/types": "^7.29.7"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -272,6 +336,16 @@
"@babel/core": "^7.0.0-0"
}
},
+ "node_modules/@babel/runtime": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
+ "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -307,19 +381,182 @@
}
},
"node_modules/@babel/types": {
- "version": "7.29.0",
- "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
- "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
+ "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-string-parser": "^7.27.1",
- "@babel/helper-validator-identifier": "^7.28.5"
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
+ "node_modules/@bcoe/v8-coverage": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
+ "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@bramus/specificity": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
+ "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "css-tree": "^3.0.0"
+ },
+ "bin": {
+ "specificity": "bin/cli.js"
+ }
+ },
+ "node_modules/@csstools/color-helpers": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
+ "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz",
+ "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz",
+ "integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^6.0.2",
+ "@csstools/css-calc": "^3.2.1"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
+ "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-syntax-patches-for-csstree": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.4.tgz",
+ "integrity": "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "peerDependencies": {
+ "css-tree": "^3.2.1"
+ },
+ "peerDependenciesMeta": {
+ "css-tree": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
+ "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@@ -762,6 +999,24 @@
"node": ">=18"
}
},
+ "node_modules/@exodus/bytes": {
+ "version": "1.15.1",
+ "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
+ "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "@noble/hashes": "^1.8.0 || ^2.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@noble/hashes": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1213,6 +1468,111 @@
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@testing-library/dom": {
+ "version": "10.4.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
+ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.10.4",
+ "@babel/runtime": "^7.12.5",
+ "@types/aria-query": "^5.0.1",
+ "aria-query": "5.3.0",
+ "dom-accessibility-api": "^0.5.9",
+ "lz-string": "^1.5.0",
+ "picocolors": "1.1.1",
+ "pretty-format": "^27.0.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@testing-library/jest-dom": {
+ "version": "6.9.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
+ "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@adobe/css-tools": "^4.4.0",
+ "aria-query": "^5.0.0",
+ "css.escape": "^1.5.1",
+ "dom-accessibility-api": "^0.6.3",
+ "picocolors": "^1.1.1",
+ "redent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=14",
+ "npm": ">=6",
+ "yarn": ">=1"
+ }
+ },
+ "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
+ "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@testing-library/react": {
+ "version": "16.3.2",
+ "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
+ "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": "^10.0.0",
+ "@types/react": "^18.0.0 || ^19.0.0",
+ "@types/react-dom": "^18.0.0 || ^19.0.0",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@testing-library/user-event": {
+ "version": "14.6.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
+ "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=7.21.4"
+ }
+ },
+ "node_modules/@types/aria-query": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
+ "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1258,6 +1618,17 @@
"@babel/types": "^7.28.2"
}
},
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
"node_modules/@types/debug": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
@@ -1267,6 +1638,13 @@
"@types/ms": "*"
}
},
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1365,6 +1743,175 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
+ "node_modules/@vitest/coverage-v8": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.7.tgz",
+ "integrity": "sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@bcoe/v8-coverage": "^1.0.2",
+ "@vitest/utils": "4.1.7",
+ "ast-v8-to-istanbul": "^1.0.0",
+ "istanbul-lib-coverage": "^3.2.2",
+ "istanbul-lib-report": "^3.0.1",
+ "istanbul-reports": "^3.2.0",
+ "magicast": "^0.5.2",
+ "obug": "^2.1.1",
+ "std-env": "^4.0.0-rc.1",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@vitest/browser": "4.1.7",
+ "vitest": "4.1.7"
+ },
+ "peerDependenciesMeta": {
+ "@vitest/browser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz",
+ "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.1.7",
+ "@vitest/utils": "4.1.7",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz",
+ "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.1.7",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz",
+ "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz",
+ "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.1.7",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz",
+ "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.7",
+ "@vitest/utils": "4.1.7",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz",
+ "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz",
+ "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.7",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
"node_modules/any-promise": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
@@ -1393,6 +1940,45 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/aria-query": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
+ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "dequal": "^2.0.3"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/ast-v8-to-istanbul": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.2.tgz",
+ "integrity": "sha512-dKmJxJsGItLmc5CYZKuEjuG6GnBs6PG4gohMhyFOWKaNQoYCuRZJDECaBlHmcG0lv2wc2E0uU8lESmBEumC3DQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.31",
+ "estree-walker": "^3.0.3",
+ "js-tokens": "^10.0.0"
+ }
+ },
+ "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
+ "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/autoprefixer": {
"version": "10.4.27",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz",
@@ -1453,6 +2039,16 @@
"node": ">=6.0.0"
}
},
+ "node_modules/bidi-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
+ "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "require-from-string": "^2.0.2"
+ }
+ },
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -1554,6 +2150,16 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/character-entities": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz",
@@ -1659,6 +2265,27 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/css-tree": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
+ "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mdn-data": "2.27.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+ }
+ },
+ "node_modules/css.escape": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
+ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -1678,6 +2305,20 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
+ "node_modules/data-urls": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
+ "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1695,6 +2336,13 @@
}
}
},
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/decode-named-character-reference": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
@@ -1744,6 +2392,14 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/dom-accessibility-api": {
+ "version": "0.5.16",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
+ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
"node_modules/electron-to-chromium": {
"version": "1.5.302",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz",
@@ -1773,6 +2429,26 @@
"node": ">=10.0.0"
}
},
+ "node_modules/entities": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
+ "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
+ "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
@@ -1847,6 +2523,26 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
+ "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
@@ -1968,6 +2664,16 @@
"node": ">=10.13.0"
}
},
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -2021,6 +2727,26 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/html-encoding-sniffer": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
+ "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.6.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/html-escaper": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
+ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/html-url-attributes": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
@@ -2031,6 +2757,16 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/indent-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/inline-style-parser": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
@@ -2155,6 +2891,52 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/istanbul-lib-coverage": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
+ "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/istanbul-lib-report": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
+ "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "istanbul-lib-coverage": "^3.0.0",
+ "make-dir": "^4.0.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-reports": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
+ "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "html-escaper": "^2.0.0",
+ "istanbul-lib-report": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/jiti": {
"version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
@@ -2171,6 +2953,57 @@
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT"
},
+ "node_modules/jsdom": {
+ "version": "29.1.1",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
+ "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^5.1.11",
+ "@asamuzakjp/dom-selector": "^7.1.1",
+ "@bramus/specificity": "^2.4.2",
+ "@csstools/css-syntax-patches-for-csstree": "^1.1.3",
+ "@exodus/bytes": "^1.15.0",
+ "css-tree": "^3.2.1",
+ "data-urls": "^7.0.0",
+ "decimal.js": "^10.6.0",
+ "html-encoding-sniffer": "^6.0.0",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.3.5",
+ "parse5": "^8.0.1",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^6.0.1",
+ "undici": "^7.25.0",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^8.0.1",
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.1",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "canvas": "^3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/jsdom/node_modules/lru-cache": {
+ "version": "11.5.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
+ "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -2249,13 +3082,75 @@
"yallist": "^3.0.2"
}
},
- "node_modules/lucide-react": {
- "version": "0.400.0",
- "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.400.0.tgz",
- "integrity": "sha512-rpp7pFHh3Xd93KHixNgB0SqThMHpYNzsGUu69UaQbSZ75Q/J3m5t6EhKyMT3m4w2WOxmJ2mY0tD3vebnXqQryQ==",
+ "node_modules/lucide-react": {
+ "version": "0.400.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.400.0.tgz",
+ "integrity": "sha512-rpp7pFHh3Xd93KHixNgB0SqThMHpYNzsGUu69UaQbSZ75Q/J3m5t6EhKyMT3m4w2WOxmJ2mY0tD3vebnXqQryQ==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/lz-string": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "bin": {
+ "lz-string": "bin/bin.js"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/magicast": {
+ "version": "0.5.3",
+ "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz",
+ "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.3",
+ "@babel/types": "^7.29.0",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/make-dir": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
+ "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/make-dir/node_modules/semver": {
+ "version": "7.8.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
+ "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
+ "dev": true,
"license": "ISC",
- "peerDependencies": {
- "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
}
},
"node_modules/markdown-table": {
@@ -2538,6 +3433,13 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/mdn-data": {
+ "version": "2.27.1",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
+ "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
+ "dev": true,
+ "license": "CC0-1.0"
+ },
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -3125,6 +4027,16 @@
"node": ">=8.6"
}
},
+ "node_modules/min-indent": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
+ "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -3199,6 +4111,17 @@
"node": ">= 6"
}
},
+ "node_modules/obug": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
+ "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT"
+ },
"node_modules/parse-entities": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
@@ -3224,6 +4147,19 @@
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
"license": "MIT"
},
+ "node_modules/parse5": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
+ "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^8.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
@@ -3231,6 +4167,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -3434,6 +4377,22 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/pretty-format": {
+ "version": "27.5.1",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
+ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "ansi-regex": "^5.0.1",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^17.0.1"
+ },
+ "engines": {
+ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+ }
+ },
"node_modules/property-information": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
@@ -3444,6 +4403,16 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -3490,6 +4459,14 @@
"react": "^18.3.1"
}
},
+ "node_modules/react-is": {
+ "version": "17.0.2",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
"node_modules/react-markdown": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.1.0.tgz",
@@ -3550,6 +4527,20 @@
"node": ">=8.10.0"
}
},
+ "node_modules/redent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
+ "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "indent-string": "^4.0.0",
+ "strip-indent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/remark-gfm": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
@@ -3616,6 +4607,16 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/resolve": {
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -3717,6 +4718,19 @@
"queue-microtask": "^1.2.2"
}
},
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
@@ -3736,6 +4750,13 @@
"semver": "bin/semver.js"
}
},
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/socket.io-client": {
"version": "4.8.3",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
@@ -3784,6 +4805,20 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
+ "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/stringify-entities": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
@@ -3798,6 +4833,19 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/strip-indent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
+ "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "min-indent": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/style-to-js": {
"version": "1.1.21",
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
@@ -3839,6 +4887,19 @@
"node": ">=16 || 14 >=14.17"
}
},
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
@@ -3852,6 +4913,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/symbol-tree": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/tailwindcss": {
"version": "3.4.19",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
@@ -3913,6 +4981,23 @@
"node": ">=0.8"
}
},
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.2.tgz",
+ "integrity": "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -3961,6 +5046,36 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/tinyrainbow": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
+ "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tldts": {
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.0.tgz",
+ "integrity": "sha512-yHBe+zVfzNZ3QfTPW/Z6KK1G2t340gFjMHqI/4KKSt/abzYydzuCnpqdaF5gCCABby+9Yfbj59oR5F2Fd5CBzg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^7.4.0"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "7.4.1",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.1.tgz",
+ "integrity": "sha512-sc2nGvGbixlJRHwTh/qQdPXTxJU1UDJboGPQm4d/01YUJ9r/u6aeIulQvEaxUlvKDN7hb1qCLjax+jhVAPLa/g==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -3974,6 +5089,32 @@
"node": ">=8.0"
}
},
+ "node_modules/tough-cookie": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
+ "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^7.0.5"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
+ "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/trim-lines": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
@@ -4001,6 +5142,16 @@
"dev": true,
"license": "Apache-2.0"
},
+ "node_modules/undici": {
+ "version": "7.26.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.26.0.tgz",
+ "integrity": "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
"node_modules/unified": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
@@ -4260,6 +5411,174 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/vitest": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz",
+ "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.1.7",
+ "@vitest/mocker": "4.1.7",
+ "@vitest/pretty-format": "4.1.7",
+ "@vitest/runner": "4.1.7",
+ "@vitest/snapshot": "4.1.7",
+ "@vitest/spy": "4.1.7",
+ "@vitest/utils": "4.1.7",
+ "es-module-lexer": "^2.0.0",
+ "expect-type": "^1.3.0",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^4.0.0-rc.1",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.1.7",
+ "@vitest/browser-preview": "4.1.7",
+ "@vitest/browser-webdriverio": "4.1.7",
+ "@vitest/coverage-istanbul": "4.1.7",
+ "@vitest/coverage-v8": "4.1.7",
+ "@vitest/ui": "4.1.7",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/vitest/node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/w3c-xmlserializer": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
+ "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
+ "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "16.0.1",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
+ "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.11.0",
+ "tr46": "^6.0.0",
+ "webidl-conversions": "^8.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
@@ -4281,6 +5600,23 @@
}
}
},
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
diff --git a/apps/finance-ai-agent-demo/frontend/package.json b/apps/finance-ai-agent-demo/frontend/package.json
index a75d4c48..ed846051 100644
--- a/apps/finance-ai-agent-demo/frontend/package.json
+++ b/apps/finance-ai-agent-demo/frontend/package.json
@@ -6,7 +6,10 @@
"scripts": {
"dev": "vite",
"build": "vite build",
- "preview": "vite preview"
+ "preview": "vite preview",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "test:coverage": "vitest run --coverage"
},
"dependencies": {
"lucide-react": "^0.400.0",
@@ -17,12 +20,18 @@
"socket.io-client": "^4.8.3"
},
"devDependencies": {
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.1",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
+ "@vitest/coverage-v8": "^4.1.7",
"autoprefixer": "^10.4.19",
+ "jsdom": "^29.1.1",
"postcss": "^8.4.39",
"tailwindcss": "^3.4.6",
- "vite": "^6.4.2"
+ "vite": "^6.4.2",
+ "vitest": "^4.1.7"
}
}
diff --git a/apps/finance-ai-agent-demo/frontend/src/test/ToolCallBubble.test.jsx b/apps/finance-ai-agent-demo/frontend/src/test/ToolCallBubble.test.jsx
new file mode 100644
index 00000000..84cc6d6f
--- /dev/null
+++ b/apps/finance-ai-agent-demo/frontend/src/test/ToolCallBubble.test.jsx
@@ -0,0 +1,178 @@
+/**
+ * Tests for ToolCallBubble rendering the suggest_portfolio_hedge tool call.
+ *
+ * Covers:
+ * - Running state (spinner, no output toggle)
+ * - Success state (tick, elapsed time, expandable output)
+ * - Error state (cross icon, output visible)
+ * - Output expand/collapse toggle
+ * - Pretty-printed JSON output for hedge results
+ * - Args rendered correctly in the header
+ */
+
+import { describe, it, expect } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+import ToolCallBubble from "../components/ToolCallBubble";
+import { makeToolCall, makeRunningToolCall, makeErrorToolCall, hedgeOutput } from "./hedgeFixtures";
+
+// ---------------------------------------------------------------------------
+// Running state
+// ---------------------------------------------------------------------------
+
+describe("ToolCallBubble — running state", () => {
+ it("shows tool name and account_id arg in header", () => {
+ render(
block should contain hedge_recommendations
+ const pre = container.querySelector("pre");
+ expect(pre).toBeInTheDocument();
+ expect(pre.textContent).toContain("hedge_recommendations");
+ });
+
+ it("shows 'Hide output' after expanding", () => {
+ render( );
+ fireEvent.click(screen.getByText(/show output/i));
+ expect(screen.getByText(/hide output/i)).toBeInTheDocument();
+ });
+
+ it("collapses output on second click", () => {
+ const { container } = render( );
+ fireEvent.click(screen.getByText(/show output/i));
+ fireEvent.click(screen.getByText(/hide output/i));
+ // The block should be gone after collapsing
+ expect(container.querySelector("pre")).not.toBeInTheDocument();
+ expect(screen.getByText(/show output/i)).toBeInTheDocument();
+ });
+
+ it("pretty-prints hedge recommendation JSON when expanded", () => {
+ render( );
+ fireEvent.click(screen.getByText(/show output/i));
+ expect(screen.getByText(/hedge_recommendations/)).toBeInTheDocument();
+ expect(screen.getByText(/risk_factors_identified/)).toBeInTheDocument();
+ });
+
+ it("shows disclaimer text when expanded", () => {
+ render( );
+ fireEvent.click(screen.getByText(/show output/i));
+ expect(screen.getByText(/algorithmically/i)).toBeInTheDocument();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Error state
+// ---------------------------------------------------------------------------
+
+describe("ToolCallBubble — error state", () => {
+ it("shows error cross (✗) icon", () => {
+ render( );
+ expect(screen.getByText("✗")).toBeInTheDocument();
+ });
+
+ it("shows elapsed time on error", () => {
+ render( );
+ expect(screen.getByText(/55ms/)).toBeInTheDocument();
+ });
+
+ it("shows error output when expanded", () => {
+ render( );
+ fireEvent.click(screen.getByText(/show output/i));
+ expect(screen.getByText(/Database connection failed/)).toBeInTheDocument();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Args rendering edge cases
+// ---------------------------------------------------------------------------
+
+describe("ToolCallBubble — args rendering", () => {
+ it("renders only account_id when risk_focus is omitted", () => {
+ const toolCall = makeToolCall({ args: { account_id: "ACC-007" } });
+ render( );
+ expect(screen.getByText(/account_id="ACC-007"/)).toBeInTheDocument();
+ });
+
+ it("renders both args when risk_focus is provided", () => {
+ const toolCall = makeToolCall({
+ args: { account_id: "ACC-003", risk_focus: "sector" },
+ });
+ render( );
+ expect(screen.getByText(/risk_focus="sector"/)).toBeInTheDocument();
+ });
+
+ it("handles empty args object gracefully", () => {
+ const toolCall = makeToolCall({ args: {} });
+ render( );
+ // Should render tool name with empty parens, no crash
+ expect(screen.getByText(/suggest_portfolio_hedge/)).toBeInTheDocument();
+ });
+
+ it("handles null args gracefully", () => {
+ const toolCall = makeToolCall({ args: null });
+ render( );
+ expect(screen.getByText(/suggest_portfolio_hedge/)).toBeInTheDocument();
+ });
+});
diff --git a/apps/finance-ai-agent-demo/frontend/src/test/chatReducer.test.js b/apps/finance-ai-agent-demo/frontend/src/test/chatReducer.test.js
new file mode 100644
index 00000000..2f0142d5
--- /dev/null
+++ b/apps/finance-ai-agent-demo/frontend/src/test/chatReducer.test.js
@@ -0,0 +1,345 @@
+/**
+ * Tests for the chatReducer handling suggest_portfolio_hedge tool call events.
+ *
+ * Exercises the ADD_TOOL_CALL_START → UPDATE_TOOL_CALL → AGENT_COMPLETE
+ * lifecycle that the ToolCallBubble ultimately renders.
+ */
+
+import { describe, it, expect } from "vitest";
+import { makeToolCall, makeRunningToolCall, hedgeOutput } from "./hedgeFixtures";
+
+// Import the reducer directly — it is not exported from useChat.js, so we
+// replicate just the relevant cases here to keep tests self-contained and fast.
+// If the reducer is ever extracted, these tests can import it directly.
+
+// ---------------------------------------------------------------------------
+// Inline reducer (mirrors useChat.js chatReducer exactly for the relevant cases)
+// ---------------------------------------------------------------------------
+
+function chatReducer(state, action) {
+ switch (action.type) {
+ case "ADD_TOOL_CALL_START":
+ return {
+ ...state,
+ toolCalls: [
+ ...state.toolCalls,
+ {
+ id: action.payload.tool_call_id,
+ name: action.payload.tool_name,
+ args: action.payload.tool_args,
+ status: "running",
+ output: null,
+ elapsed_ms: null,
+ },
+ ],
+ };
+
+ case "UPDATE_TOOL_CALL":
+ return {
+ ...state,
+ toolCalls: state.toolCalls.map((tc) =>
+ tc.id === action.payload.tool_call_id
+ ? {
+ ...tc,
+ status: action.payload.status,
+ output: action.payload.output,
+ elapsed_ms: action.payload.elapsed_ms,
+ }
+ : tc
+ ),
+ };
+
+ case "AGENT_COMPLETE": {
+ const streamingExists = state.messages.find((m) => m.id === action.payload.message_id);
+ if (streamingExists) {
+ return {
+ ...state,
+ messages: state.messages.map((m) =>
+ m.id === action.payload.message_id
+ ? {
+ ...m,
+ content: action.payload.response || m.content,
+ isStreaming: false,
+ toolCalls: [...state.toolCalls],
+ }
+ : m
+ ),
+ isLoading: false,
+ toolCalls: [],
+ };
+ }
+ return {
+ ...state,
+ messages: [
+ ...state.messages,
+ {
+ id: action.payload.message_id,
+ role: "assistant",
+ content: action.payload.response,
+ timestamp: new Date().toISOString(),
+ toolCalls: [...state.toolCalls],
+ },
+ ],
+ isLoading: false,
+ toolCalls: [],
+ };
+ }
+
+ default:
+ return state;
+ }
+}
+
+const emptyState = { messages: [], toolCalls: [], isLoading: true };
+
+// ---------------------------------------------------------------------------
+// ADD_TOOL_CALL_START
+// ---------------------------------------------------------------------------
+
+describe("chatReducer — ADD_TOOL_CALL_START for suggest_portfolio_hedge", () => {
+ const startPayload = {
+ tool_call_id: "tc-001",
+ tool_name: "suggest_portfolio_hedge",
+ tool_args: { account_id: "ACC-001", risk_focus: "all" },
+ };
+
+ it("adds tool call to toolCalls list", () => {
+ const state = chatReducer(emptyState, { type: "ADD_TOOL_CALL_START", payload: startPayload });
+ expect(state.toolCalls).toHaveLength(1);
+ });
+
+ it("sets correct tool name", () => {
+ const state = chatReducer(emptyState, { type: "ADD_TOOL_CALL_START", payload: startPayload });
+ expect(state.toolCalls[0].name).toBe("suggest_portfolio_hedge");
+ });
+
+ it("sets status to running", () => {
+ const state = chatReducer(emptyState, { type: "ADD_TOOL_CALL_START", payload: startPayload });
+ expect(state.toolCalls[0].status).toBe("running");
+ });
+
+ it("sets output and elapsed_ms to null initially", () => {
+ const state = chatReducer(emptyState, { type: "ADD_TOOL_CALL_START", payload: startPayload });
+ expect(state.toolCalls[0].output).toBeNull();
+ expect(state.toolCalls[0].elapsed_ms).toBeNull();
+ });
+
+ it("preserves args on the tool call", () => {
+ const state = chatReducer(emptyState, { type: "ADD_TOOL_CALL_START", payload: startPayload });
+ expect(state.toolCalls[0].args).toEqual({ account_id: "ACC-001", risk_focus: "all" });
+ });
+
+ it("does not mutate existing toolCalls", () => {
+ const existingCall = {
+ id: "tc-existing",
+ name: "get_account_details",
+ args: {},
+ status: "success",
+ output: "{}",
+ elapsed_ms: 50,
+ };
+ const stateWithExisting = { ...emptyState, toolCalls: [existingCall] };
+ const next = chatReducer(stateWithExisting, {
+ type: "ADD_TOOL_CALL_START",
+ payload: startPayload,
+ });
+ expect(next.toolCalls).toHaveLength(2);
+ expect(next.toolCalls[0]).toEqual(existingCall);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// UPDATE_TOOL_CALL
+// ---------------------------------------------------------------------------
+
+describe("chatReducer — UPDATE_TOOL_CALL for suggest_portfolio_hedge", () => {
+ const stateWithRunning = chatReducer(emptyState, {
+ type: "ADD_TOOL_CALL_START",
+ payload: {
+ tool_call_id: "tc-001",
+ tool_name: "suggest_portfolio_hedge",
+ tool_args: { account_id: "ACC-001" },
+ },
+ });
+
+ const completePayload = {
+ tool_call_id: "tc-001",
+ status: "success",
+ output: JSON.stringify(hedgeOutput),
+ elapsed_ms: 142,
+ };
+
+ it("updates status to success", () => {
+ const state = chatReducer(stateWithRunning, {
+ type: "UPDATE_TOOL_CALL",
+ payload: completePayload,
+ });
+ expect(state.toolCalls[0].status).toBe("success");
+ });
+
+ it("sets output on the correct tool call", () => {
+ const state = chatReducer(stateWithRunning, {
+ type: "UPDATE_TOOL_CALL",
+ payload: completePayload,
+ });
+ const output = JSON.parse(state.toolCalls[0].output);
+ expect(output.account_id).toBe("ACC-001");
+ expect(output.hedge_recommendations).toBeInstanceOf(Array);
+ });
+
+ it("sets elapsed_ms", () => {
+ const state = chatReducer(stateWithRunning, {
+ type: "UPDATE_TOOL_CALL",
+ payload: completePayload,
+ });
+ expect(state.toolCalls[0].elapsed_ms).toBe(142);
+ });
+
+ it("does not affect other tool calls", () => {
+ const stateTwo = chatReducer(stateWithRunning, {
+ type: "ADD_TOOL_CALL_START",
+ payload: { tool_call_id: "tc-002", tool_name: "get_account_details", tool_args: {} },
+ });
+ const updated = chatReducer(stateTwo, { type: "UPDATE_TOOL_CALL", payload: completePayload });
+ expect(updated.toolCalls[1].status).toBe("running"); // tc-002 unchanged
+ });
+
+ it("handles error status correctly", () => {
+ const errorPayload = {
+ tool_call_id: "tc-001",
+ status: "error",
+ output: "DB error",
+ elapsed_ms: 10,
+ };
+ const state = chatReducer(stateWithRunning, {
+ type: "UPDATE_TOOL_CALL",
+ payload: errorPayload,
+ });
+ expect(state.toolCalls[0].status).toBe("error");
+ expect(state.toolCalls[0].output).toBe("DB error");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// AGENT_COMPLETE — tool calls attached to assistant message
+// ---------------------------------------------------------------------------
+
+describe("chatReducer — AGENT_COMPLETE attaches hedge tool calls to message", () => {
+ // Build state: start hedge tool call, complete it, then agent_complete
+ let state = emptyState;
+ state = chatReducer(state, {
+ type: "ADD_TOOL_CALL_START",
+ payload: {
+ tool_call_id: "tc-001",
+ tool_name: "suggest_portfolio_hedge",
+ tool_args: { account_id: "ACC-001" },
+ },
+ });
+ state = chatReducer(state, {
+ type: "UPDATE_TOOL_CALL",
+ payload: {
+ tool_call_id: "tc-001",
+ status: "success",
+ output: JSON.stringify(hedgeOutput),
+ elapsed_ms: 142,
+ },
+ });
+
+ const completeAction = {
+ type: "AGENT_COMPLETE",
+ payload: {
+ message_id: "msg-001",
+ response: "Here are hedge recommendations for ACC-001...",
+ query_summary: null,
+ },
+ };
+
+ it("clears toolCalls from live state after agent_complete", () => {
+ const next = chatReducer(state, completeAction);
+ expect(next.toolCalls).toHaveLength(0);
+ });
+
+ it("attaches tool calls to the new assistant message", () => {
+ const next = chatReducer(state, completeAction);
+ const assistantMsg = next.messages.find((m) => m.role === "assistant");
+ expect(assistantMsg).toBeDefined();
+ expect(assistantMsg.toolCalls).toHaveLength(1);
+ expect(assistantMsg.toolCalls[0].name).toBe("suggest_portfolio_hedge");
+ });
+
+ it("assistant message contains hedge output in its tool call", () => {
+ const next = chatReducer(state, completeAction);
+ const assistantMsg = next.messages.find((m) => m.role === "assistant");
+ const output = JSON.parse(assistantMsg.toolCalls[0].output);
+ expect(output.hedge_recommendations.length).toBeGreaterThan(0);
+ });
+
+ it("sets isLoading to false", () => {
+ const next = chatReducer(state, completeAction);
+ expect(next.isLoading).toBe(false);
+ });
+
+ it("agent_complete on a streaming message updates it in-place", () => {
+ // Simulate a streaming message already existing
+ const streamingState = {
+ ...state,
+ messages: [
+ {
+ id: "msg-001",
+ role: "assistant",
+ content: "partial...",
+ isStreaming: true,
+ toolCalls: [],
+ },
+ ],
+ };
+ const next = chatReducer(streamingState, completeAction);
+ const assistantMsg = next.messages.find((m) => m.id === "msg-001");
+ expect(assistantMsg.isStreaming).toBe(false);
+ expect(assistantMsg.content).toBe("Here are hedge recommendations for ACC-001...");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Full lifecycle simulation
+// ---------------------------------------------------------------------------
+
+describe("chatReducer — full suggest_portfolio_hedge lifecycle", () => {
+ it("running → success → attached to message without errors", () => {
+ let s = emptyState;
+
+ // 1. Tool call starts
+ s = chatReducer(s, {
+ type: "ADD_TOOL_CALL_START",
+ payload: {
+ tool_call_id: "tc-hedge",
+ tool_name: "suggest_portfolio_hedge",
+ tool_args: { account_id: "ACC-005", risk_focus: "sector" },
+ },
+ });
+ expect(s.toolCalls[0].status).toBe("running");
+
+ // 2. Tool call completes
+ s = chatReducer(s, {
+ type: "UPDATE_TOOL_CALL",
+ payload: {
+ tool_call_id: "tc-hedge",
+ status: "success",
+ output: JSON.stringify(hedgeOutput),
+ elapsed_ms: 200,
+ },
+ });
+ expect(s.toolCalls[0].status).toBe("success");
+
+ // 3. Agent completes
+ s = chatReducer(s, {
+ type: "AGENT_COMPLETE",
+ payload: { message_id: "msg-final", response: "Done.", query_summary: null },
+ });
+ expect(s.toolCalls).toHaveLength(0);
+
+ const msg = s.messages.find((m) => m.id === "msg-final");
+ expect(msg.toolCalls[0].name).toBe("suggest_portfolio_hedge");
+ expect(msg.toolCalls[0].elapsed_ms).toBe(200);
+ });
+});
diff --git a/apps/finance-ai-agent-demo/frontend/src/test/hedgeFixtures.js b/apps/finance-ai-agent-demo/frontend/src/test/hedgeFixtures.js
new file mode 100644
index 00000000..4995d793
--- /dev/null
+++ b/apps/finance-ai-agent-demo/frontend/src/test/hedgeFixtures.js
@@ -0,0 +1,97 @@
+/**
+ * Shared test fixtures for suggest_portfolio_hedge frontend tests.
+ */
+
+export const hedgeOutput = {
+ account_id: "ACC-001",
+ risk_profile: "aggressive",
+ esg_mandate: false,
+ risk_focus: "all",
+ portfolio_summary: {
+ total_holdings: 6,
+ high_risk_positions: 2,
+ sector_breakdown: [
+ { sector: "Technology", pct: 40.0 },
+ { sector: "Energy", pct: 20.0 },
+ ],
+ region_breakdown: [
+ { region: "North America", pct: 65.0 },
+ { region: "Europe", pct: 20.0 },
+ ],
+ asset_class_breakdown: [
+ { asset_class: "Equity", pct: 80.0 },
+ { asset_class: "Bond", pct: 20.0 },
+ ],
+ },
+ risk_factors_identified: [
+ "High equity concentration: 80.0% of portfolio is in equities.",
+ "2 high-risk positions (risk_rating ≥ 7) totalling 20.0% of portfolio.",
+ "Sector concentration: Technology represents 40.0% of portfolio.",
+ "Regional concentration: North America represents 65.0% of portfolio.",
+ ],
+ hedge_recommendations: [
+ {
+ ticker: "SH",
+ name: "ProShares Short S&P500 ETF",
+ type: "Inverse ETF",
+ rationale: "Broad market hedge; gains when S&P 500 declines.",
+ risk_level: "medium",
+ hedge_dimension: "market",
+ trigger: "equity_pct=80.0%",
+ },
+ {
+ ticker: "GLD",
+ name: "SPDR Gold Shares",
+ type: "Safe Haven",
+ rationale: "Gold allocation offsets tail risk from 2 high-rated positions.",
+ risk_level: "low",
+ hedge_dimension: "market",
+ trigger: "high_risk_positions=2",
+ },
+ {
+ ticker: "PUT",
+ name: "Put options on QQQ",
+ type: "Options Strategy",
+ rationale: "Buy QQQ put options as targeted downside protection for tech-heavy portfolios.",
+ risk_level: "medium",
+ hedge_dimension: "sector",
+ trigger: "Technology=40.0%",
+ },
+ {
+ ticker: "EFA",
+ name: "iShares MSCI EAFE ETF",
+ type: "Geographic Diversification",
+ rationale: "Developed-market international exposure offsets US-heavy concentration.",
+ risk_level: "low",
+ hedge_dimension: "regional",
+ trigger: "North America=65.0%",
+ },
+ ],
+ disclaimer:
+ "These recommendations are generated algorithmically for illustrative purposes only. " +
+ "All hedging decisions should be reviewed by a qualified financial advisor.",
+};
+
+export function makeToolCall({
+ id = "tc-hedge-001",
+ name = "suggest_portfolio_hedge",
+ args = { account_id: "ACC-001", risk_focus: "all" },
+ status = "success",
+ output = JSON.stringify(hedgeOutput),
+ elapsed_ms = 142,
+} = {}) {
+ return { id, name, args, status, output, elapsed_ms };
+}
+
+export function makeRunningToolCall(overrides = {}) {
+ return makeToolCall({ status: "running", output: null, elapsed_ms: null, ...overrides });
+}
+
+export function makeErrorToolCall(overrides = {}) {
+ return makeToolCall({
+ status: "error",
+ output: "Database connection failed",
+ elapsed_ms: 55,
+ ...overrides,
+ });
+}
diff --git a/apps/finance-ai-agent-demo/frontend/src/test/setup.js b/apps/finance-ai-agent-demo/frontend/src/test/setup.js
new file mode 100644
index 00000000..d0de870d
--- /dev/null
+++ b/apps/finance-ai-agent-demo/frontend/src/test/setup.js
@@ -0,0 +1 @@
+import "@testing-library/jest-dom";
diff --git a/apps/finance-ai-agent-demo/frontend/vite.config.js b/apps/finance-ai-agent-demo/frontend/vite.config.js
index 94c0fa01..212e5277 100644
--- a/apps/finance-ai-agent-demo/frontend/vite.config.js
+++ b/apps/finance-ai-agent-demo/frontend/vite.config.js
@@ -3,6 +3,15 @@ import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
+ test: {
+ environment: "jsdom",
+ globals: true,
+ setupFiles: "./src/test/setup.js",
+ coverage: {
+ reporter: ["text", "lcov"],
+ include: ["src/components/**", "src/hooks/**"],
+ },
+ },
server: {
port: 3000,
proxy: {