From b7e7ec50cf9613a7010f0b28f09c42992f7984a0 Mon Sep 17 00:00:00 2001 From: jay Date: Tue, 29 Sep 2026 14:09:11 -0700 Subject: [PATCH] fixes and enhancements --- backend/api_server.py | 313 +- backend/long_term_memory.py | 23 +- backend/model_backend.py | 49 + backend/models.py | 34 +- backend/optimization.py | 4 +- backend/orchestrator.py | 168 +- backend/research_agent.py | 172 +- backend/search_result_store.py | 46 +- backend/session_store.py | 33 +- backend/unit_tests.py | 443 +++ docs/MEMORY.md | 9 +- docs/PROJECT-KNOWLEDGE.md | 82 +- src/App.tsx | 338 +- .../__tests__/App.multisession.test.tsx | 290 ++ src/contexts/AppContext.tsx | 56 +- src/hooks/useConversations.ts | 43 + src/package-lock.json | 3151 +++++------------ src/package.json | 8 +- src/test-setup.ts | 1 + src/types/conversation.ts | 19 + src/vitest.config.ts | 19 + 21 files changed, 2662 insertions(+), 2639 deletions(-) create mode 100644 src/components/__tests__/App.multisession.test.tsx create mode 100644 src/test-setup.ts create mode 100644 src/vitest.config.ts diff --git a/backend/api_server.py b/backend/api_server.py index c7dc281..0f6f5d4 100644 --- a/backend/api_server.py +++ b/backend/api_server.py @@ -145,17 +145,25 @@ async def lifespan(app: FastAPI): logger.info("Long-term memory store initialized at %s.", cfg.neo4j_uri) if cfg.agent_mode == "self-optimization": app.state.agent = SelfOptimizingAgent( - model_backend, mcp_registry, long_term_memory=long_term_memory + model_backend, + mcp_registry, + long_term_memory=long_term_memory, + config=app.state.config, ) logger.info("Initialized Self-Optimizing Agent in self-optimization mode.") else: - app.state.agent = ResearchAgent(model_backend, mcp_registry) + app.state.agent = ResearchAgent( + model_backend, mcp_registry, config=app.state.config + ) logger.info("Agent is initialized and ready to accept requests.") # Always initialise a dedicated SelfOptimizingAgent so the self_optimize # WebSocket command is available regardless of the AGENT_MODE setting. app.state.self_optimizing_agent = SelfOptimizingAgent( - model_backend, mcp_registry, long_term_memory=long_term_memory + model_backend, + mcp_registry, + long_term_memory=long_term_memory, + config=app.state.config, ) logger.info("Self-optimizing agent initialized.") @@ -334,14 +342,28 @@ async def _recover_session_from_checkpoint( ) -> ResearchSession: """Reconstruct an in-memory session from a disk checkpoint. - If execute() completed (synthesis_checkpoint present) but no ``report`` - event was emitted, re-runs synthesize() in a background task so the client - receives the report on reconnect. + Handles three recovery paths: + + * **Synthesis recovery** — ``synthesis_checkpoint`` present, no ``report`` + event yet: restores synthesis state and re-runs ``synthesize()`` as a + tracked background job. + * **Execute recovery** — ``plan_checkpoint`` present, state was + ``executing`` (no synthesis_checkpoint means the crash happened + mid-execute): restores the pending plan and re-runs + ``execute() → synthesize()`` from scratch. Duplicate step events will + appear in the replay log; clients dedup on the replay_log length. + * **Plan-approval recovery** — ``plan_checkpoint`` present, state was + ``awaiting_approval``: restores ``_pending_plan`` so ``approve_plan`` + succeeds after the client reconnects. No background job is started. + * **Replay-only** — none of the above (session already complete / error / + cancelled or missing checkpoint data): mark complete and let the drain + flush the replay_log. """ session_id = checkpoint_data["session_id"] saved_state = checkpoint_data.get("state", "unknown") replay_log: list = checkpoint_data.get("replay_log", []) syn_checkpoint: dict | None = checkpoint_data.get("synthesis_checkpoint") + plan_checkpoint: dict | None = checkpoint_data.get("plan_checkpoint") # Check whether the report was already delivered in a previous run. report_delivered = any(evt.get("type") == "report" for evt in replay_log) @@ -353,61 +375,150 @@ async def _recover_session_from_checkpoint( session.replay_log = list(replay_log) session.state = saved_state - needs_synthesis = ( - syn_checkpoint is not None - and not report_delivered - and saved_state not in ("error", "cancelled") - ) + terminal = saved_state in ("error", "cancelled", "complete") or report_delivered - if not needs_synthesis: - # Replay-only: mark complete so drain can exit after flushing. - session.state = saved_state - session.complete = True - session.finish() + # ── Case 1: synthesis recovery ────────────────────────────────────────── + if syn_checkpoint is not None and not terminal: + orchestrator.restore_synthesis_state(syn_checkpoint) logger.info( - "[ws][%s] Recovered from disk (state=%s, events=%d) — replay only.", + "[ws][%s] Recovered from disk (state=%s, events=%d) — re-running synthesis.", session_id, saved_state, len(session.replay_log), ) + + async def _rerun_synthesis(s: ResearchSession = session) -> None: + try: + async for event in s.orchestrator.synthesize(): + s.emit(make_serializable(event.model_dump())) + s.state = "complete" + except asyncio.CancelledError: + s.state = "cancelled" + s.emit({"type": "error", "message": "Synthesis was cancelled."}) + except Exception as exc: + logger.exception( + "[ws][%s] Synthesis recovery error: %s", s.session_id, exc + ) + s.state = "error" + s.emit({"type": "error", "message": str(exc)}) + finally: + s.complete = True + s.new_event.set() + s.finish() + try: + session_store.persist(s) + except Exception: + pass + + job = await session_manager.start_job( + session_id=session_id, + coro=_rerun_synthesis(), + ) + session.background_task = job.task + return session + + # ── Case 2: execute recovery (crashed mid-execute) ────────────────────── + if ( + plan_checkpoint is not None + and saved_state == "executing" + and not terminal + ): + orchestrator.restore_plan_state(plan_checkpoint) + if orchestrator._pending_plan is None: + logger.warning( + "[ws][%s] Execute-recovery skipped — plan_checkpoint missing pending_plan.", + session_id, + ) + else: + logger.info( + "[ws][%s] Recovered from disk (state=executing, events=%d) — " + "re-running execute + synthesize from scratch.", + session_id, + len(session.replay_log), + ) + session.emit( + { + "type": "status", + "message": "Server restarted mid-execute — restarting research from the approved plan.", + } + ) + + async def _rerun_execute(s: ResearchSession = session) -> None: + syn_ck: dict | None = None + try: + async for event in s.orchestrator.execute(): + s.emit(make_serializable(event.model_dump())) + try: + syn_ck = s.orchestrator.synthesis_checkpoint() + session_store.persist(s, synthesis_checkpoint=syn_ck) + except Exception: + pass + async for event in s.orchestrator.synthesize(): + s.emit(make_serializable(event.model_dump())) + s.state = "complete" + except asyncio.CancelledError: + s.state = "cancelled" + s.emit({"type": "error", "message": "Recovered research was cancelled."}) + except Exception as exc: + logger.exception( + "[ws][%s] Execute-recovery error: %s", s.session_id, exc + ) + s.state = "error" + s.emit({"type": "error", "message": str(exc)}) + finally: + s.complete = True + s.new_event.set() + s.finish() + try: + session_store.persist(s, synthesis_checkpoint=syn_ck) + except Exception: + pass + try: + s.orchestrator.release_memory() + except Exception: + pass + + job = await session_manager.start_job( + session_id=session_id, + coro=_rerun_execute(), + ) + session.background_task = job.task + return session + + # ── Case 3: plan-approval recovery (server restarted while awaiting user) ── + if ( + plan_checkpoint is not None + and saved_state == "awaiting_approval" + and not terminal + ): + orchestrator.restore_plan_state(plan_checkpoint) + if orchestrator._pending_plan is None: + logger.warning( + "[ws][%s] Plan-recovery skipped — plan_checkpoint missing pending_plan.", + session_id, + ) + else: + logger.info( + "[ws][%s] Recovered from disk (state=awaiting_approval, events=%d) — " + "plan restored; waiting for client approval.", + session_id, + len(session.replay_log), + ) + # Do NOT mark complete — the session is live, waiting for a client + # decision. new_event is untouched so a fresh drain (started by the + # resume handler) will replay the log and then park on the wait. return session - # Re-run synthesis using the restored orchestrator state. - orchestrator.restore_synthesis_state(syn_checkpoint) + # ── Case 4: replay-only (already done, errored, cancelled, or unrecoverable) ── + session.state = saved_state + session.complete = True + session.finish() logger.info( - "[ws][%s] Recovered from disk (state=%s, events=%d) — re-running synthesis.", + "[ws][%s] Recovered from disk (state=%s, events=%d) — replay only.", session_id, saved_state, len(session.replay_log), ) - - async def _rerun_synthesis(s: ResearchSession = session) -> None: - try: - async for event in s.orchestrator.synthesize(): - serialized = make_serializable(event.model_dump()) - s.emit(serialized) - s.state = "complete" - except asyncio.CancelledError: - s.state = "cancelled" - s.emit({"type": "error", "message": "Synthesis was cancelled."}) - except Exception as exc: - logger.exception("[ws][%s] Synthesis recovery error: %s", s.session_id, exc) - s.state = "error" - s.emit({"type": "error", "message": str(exc)}) - finally: - s.complete = True - s.new_event.set() - s.finish() - try: - session_store.persist(s) - except Exception: - pass - - job = await session_manager.start_job( - session_id=session_id, - coro=_rerun_synthesis(), - ) - session.background_task = job.task return session @@ -520,7 +631,32 @@ async def get_research_methods(): # ── Docs endpoints ──────────────────────────────────────────────────────────── # NOTE: /docs is reserved by FastAPI (Swagger UI). Route is /project-docs. -_DOCS_DIR = os.path.join(os.path.dirname(__file__), "docs") +# +# Resolution order (first existing directory wins): +# 1. ``DOCS_DIR`` env var (explicit override — used by containers where the +# docs are bind-mounted to a non-default path). +# 2. ``backend/docs`` — legacy location; kept so existing Docker images +# with the old bind-mount continue to serve content. +# 3. repo-root ``docs`` — the actual location in the source tree; used when +# running the API server directly from the checkout (no bind-mount). +def _resolve_docs_dir() -> str: + override = os.getenv("DOCS_DIR") + candidates = [] + if override: + candidates.append(override) + here = os.path.dirname(__file__) + candidates.append(os.path.join(here, "docs")) + candidates.append(os.path.abspath(os.path.join(here, os.pardir, "docs"))) + for candidate in candidates: + if os.path.isdir(candidate): + return candidate + # Fall back to the first candidate even if missing — downstream handlers + # will report a 404, which is the correct signal (rather than crashing + # at import time). + return candidates[0] + + +_DOCS_DIR = _resolve_docs_dir() @app.get("/project-docs") @@ -788,25 +924,6 @@ async def update_config(request: Request, config_update: ConfigUpdate): if val is not None: setattr(cfg, attr, val) - # Legacy single model field - if config_update.model is not None: - if backend == "openai": - cfg.openai_model = config_update.model - elif backend == "azure": - pass # azure uses deployment_name, not a free-form model - elif backend == "aws": - cfg.aws_model = config_update.model - elif backend == "gcp": - cfg.gcp_model = config_update.model - elif backend == "ollama": - cfg.ollama_model = config_update.model - elif backend == "huggingface": - cfg.huggingface_model = config_update.model - elif backend == "anthropic": - cfg.anthropic_model = config_update.model - elif backend == "bedrock": - cfg.aws_model = config_update.model # bedrock shares aws_* model fields - # ── Persist to disk ─────────────────────────────────────────────── _persist_settings(cfg) @@ -819,7 +936,7 @@ async def update_config(request: Request, config_update: ConfigUpdate): request.app.state.model_backend = new_backend request.app.state.agent_pool = new_agent_pool request.app.state.agent = ResearchAgent( - new_backend, request.app.state.mcp_registry + new_backend, request.app.state.mcp_registry, config=cfg ) request.app.state.config = cfg @@ -1264,12 +1381,60 @@ async def _cancel_drain() -> None: query, ) - async for event in orchestrator.plan(query): - serialized = make_serializable(event.model_dump()) - session.emit(serialized) - await websocket.send_json(serialized) + # Iterate the plan generator with a manual handle so a + # WebSocket disconnect mid-plan doesn't leak the generator or + # skip the state transition below. If the socket dies, we + # keep draining the generator (silently) so _pending_plan is + # fully populated and the session can be resumed + approved + # on reconnect. + plan_stream = orchestrator.plan(query) + client_alive = True + try: + async for event in plan_stream: + serialized = make_serializable(event.model_dump()) + session.emit(serialized) + if client_alive: + try: + await websocket.send_json(serialized) + except (WebSocketDisconnect, RuntimeError): + logger.info( + "[ws][%s] Client disconnected mid-plan — " + "continuing plan generation.", + session.session_id, + ) + client_alive = False + finally: + # Ensure the generator's finally-blocks run even if we + # bailed early (defensive; plan() has no finally today). + await plan_stream.aclose() + + # Only advance to awaiting_approval when a plan was actually + # produced. If plan() aborted before assigning _pending_plan, + # leave the session in "planning" state — the client's next + # query will start fresh. + if orchestrator._pending_plan is not None: + session.state = "awaiting_approval" + # P0-3 / P0-4: persist plan + query so a server restart + # while awaiting approval preserves the plan and + # approve_plan can proceed after recovery. + try: + session_store.persist( + session, + plan_checkpoint=orchestrator.plan_checkpoint(), + ) + except Exception as _pp_exc: + logger.warning( + "[ws][%s] Could not persist plan checkpoint: %s", + session.session_id, + _pp_exc, + ) - session.state = "awaiting_approval" + # If the client disconnected mid-plan, break out of the + # receive loop cleanly; the outer WebSocketDisconnect handler + # would otherwise wait for the next message that will never + # arrive. + if not client_alive: + raise WebSocketDisconnect(code=1000) # ── resume → replay log + drain live events ─────────────────────── elif message_type == "resume": diff --git a/backend/long_term_memory.py b/backend/long_term_memory.py index 9d21cdc..a896429 100644 --- a/backend/long_term_memory.py +++ b/backend/long_term_memory.py @@ -2304,6 +2304,13 @@ async def recall_graph_context( if relationships: parts.append("\nKNOWN RELATIONSHIPS:") seen_triples: Set[str] = set() + # Cap relationships surfaced in the LLM context. A 5-entity / + # 2-hop traversal on a mature graph can easily return hundreds of + # edges; without a cap we blow the prompt budget and the model + # loses focus. Callers who need the full set can call + # ``get_relationships`` directly. + _rel_cap = max(entity_limit * 6, 20) + _rel_count = 0 for r in relationships: # Filter by minimum confidence when specified if min_confidence > 0.0 and r.get("confidence", 1.0) < min_confidence: @@ -2316,6 +2323,12 @@ async def recall_graph_context( conf = r.get("confidence", 1.0) conf_str = f" (conf: {conf:.2f})" if conf < 0.8 else "" parts.append(f" • {triple}{conf_str}") + _rel_count += 1 + if _rel_count >= _rel_cap: + parts.append( + f" … (+{len(relationships) - _rel_count} more relationships omitted)" + ) + break # Claims if include_claims and entity_names: @@ -2344,8 +2357,16 @@ async def recall_graph_context( if communities: parts.append("\nTHEMATIC CLUSTERS:") - for c in communities: + # Cap at 5 clusters — the top ones by construction are the most + # relevant to the seed entities, and beyond that the summaries + # tend to duplicate content already in KNOWN ENTITIES. + _community_cap = 5 + for c in communities[:_community_cap]: parts.append(f" [{c['topic']}] {c['summary']}") + if len(communities) > _community_cap: + parts.append( + f" … (+{len(communities) - _community_cap} more clusters omitted)" + ) # Optional: contradictions if include_contradictions: diff --git a/backend/model_backend.py b/backend/model_backend.py index ef278b4..923fc12 100644 --- a/backend/model_backend.py +++ b/backend/model_backend.py @@ -965,3 +965,52 @@ def create_model_backend(config: Config) -> ModelBackend: else: raise ValueError(f"Unknown model backend: {backend_type}") + + +# ── Shared model-selection helper ──────────────────────────────────────────── +# +# Both ``Orchestrator._select_model`` and ``ResearchAgent._select_model_for_step`` +# need to pick a config-driven model name given a backend instance and a +# heavy/light preference. Keeping the mapping here avoids the two-implementation +# drift that ``optimization.py`` inherited before (hardcoded strings that +# fell out of sync with the ``*_HEAVY_MODEL`` / ``*_LIGHT_MODEL`` env vars). +def select_model_for_backend( + backend: "ModelBackend", + config: Any, + *, + is_heavy: bool, +) -> Optional[str]: + """Return the configured heavy/light model name for ``backend``. + + Parameters + ---------- + backend: + Live model backend instance (``OpenAIBackend`` / ``AWSOpenAIBackend`` / + etc.). The concrete type picks which family of ``*_heavy_model`` / + ``*_light_model`` config fields to consult. + config: + A ``Config`` instance carrying the per-backend heavy/light fields. + is_heavy: + ``True`` → pick the heavy model, ``False`` → pick the light model. + + Returns ``None`` for backends that only support a single model + (currently ``HuggingFaceBackend``); callers should fall back to the + backend's own default in that case. + """ + if isinstance(backend, OpenAIBackend): + return config.openai_heavy_model if is_heavy else config.openai_light_model + if isinstance(backend, AzureOpenAIBackend): + return config.azure_heavy_model if is_heavy else config.azure_light_model + if isinstance(backend, AWSOpenAIBackend): + return config.aws_heavy_model if is_heavy else config.aws_light_model + if isinstance(backend, GCPVertexAIBackend): + return config.gcp_heavy_model if is_heavy else config.gcp_light_model + if isinstance(backend, OllamaBackend): + return config.ollama_heavy_model if is_heavy else config.ollama_light_model + if isinstance(backend, AnthropicBackend): + return ( + config.anthropic_heavy_model if is_heavy else config.anthropic_light_model + ) + if isinstance(backend, HuggingFaceBackend): + return None + return None diff --git a/backend/models.py b/backend/models.py index 3fa8dfb..6884e75 100644 --- a/backend/models.py +++ b/backend/models.py @@ -123,9 +123,6 @@ class ConfigUpdate(BaseModel): analyst_max_tokens: Optional[int] = None qa_max_tokens: Optional[int] = None - # Legacy field kept for backward compat with existing callers - model: Optional[str] = None - class SynthesisResult(BaseModel): summary: str @@ -177,6 +174,19 @@ def to_dict(self) -> Dict[str, Any]: "parallel_group": self.parallel_group, } + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "ResearchStep": + """Reconstruct a ResearchStep from its ``to_dict`` output.""" + return cls( + id=int(data["id"]), + name=data.get("name", ""), + description=data.get("description", ""), + status=StepStatus(data.get("status", StepStatus.PENDING.value)), + result=data.get("result") or None, + error=data.get("error") or None, + parallel_group=data.get("parallel_group"), + ) + class ResearchPlan: id: str @@ -194,3 +204,21 @@ def to_dict(self) -> Dict[str, Any]: "goal": self.goal, "steps": [step.to_dict() for step in self.steps], } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "ResearchPlan": + """Reconstruct a ResearchPlan (preserving ``id``) from its ``to_dict`` output. + + Used by session recovery to rebuild ``Orchestrator._pending_plan`` from + a persisted checkpoint so ``approve_plan`` can proceed after a restart. + """ + plan = cls( + goal=data.get("goal", ""), + steps=[ResearchStep.from_dict(s) for s in data.get("steps", [])], + ) + # Preserve the original plan id so ``approve_plan``'s planId check + # against the client's stored id continues to match. + pid = data.get("id") + if pid: + plan.id = pid + return plan diff --git a/backend/optimization.py b/backend/optimization.py index 06e32a5..d973db4 100644 --- a/backend/optimization.py +++ b/backend/optimization.py @@ -8,6 +8,7 @@ from model_backend import Message from observability import get_logger from long_term_memory import AsyncLongTermMemory +from config import Config logger = get_logger(__name__) @@ -43,8 +44,9 @@ def __init__( model_backend, mcp_registry, long_term_memory: Optional[AsyncLongTermMemory] = None, + config: Optional[Config] = None, ): - super().__init__(model_backend, mcp_registry) + super().__init__(model_backend, mcp_registry, config=config) self._long_term_memory = long_term_memory # ── File I/O helpers ────────────────────────────────────────────────────── diff --git a/backend/orchestrator.py b/backend/orchestrator.py index 8ea5c50..6eb3a09 100644 --- a/backend/orchestrator.py +++ b/backend/orchestrator.py @@ -1419,21 +1419,40 @@ async def _run_step( for r in recent_recs ) ) - _search_status: List[ResponseMessage] = [] - search_result = await self._run_search( - step, - query, - tools, - extra_context=( - f"Relevant findings from prior steps:\n{prior_context}{rec_text}" - if prior_context or rec_text - else "" - ), - status_sink=_search_status, - ltm_context=getattr(self, "_batch_memory", "") or None, + # P1-3: run SearchAgent as a background task and stream its + # iteration-status events over the WebSocket live, rather than + # batching them until after ``_run_search`` returns. A short + # polling timeout keeps CPU usage negligible while still yielding + # events to the client with sub-second latency. + _status_queue: "asyncio.Queue[ResponseMessage]" = asyncio.Queue() + _search_task = asyncio.create_task( + self._run_search( + step, + query, + tools, + extra_context=( + f"Relevant findings from prior steps:\n{prior_context}{rec_text}" + if prior_context or rec_text + else "" + ), + status_queue=_status_queue, + ltm_context=getattr(self, "_batch_memory", "") or None, + ) ) - for _msg in _search_status: - yield _msg + while not _search_task.done(): + try: + _msg = await asyncio.wait_for(_status_queue.get(), timeout=0.25) + yield _msg + except asyncio.TimeoutError: + continue + except asyncio.CancelledError: + _search_task.cancel() + raise + # Drain any status events that landed in the queue between the + # last poll and task completion. + while not _status_queue.empty(): + yield _status_queue.get_nowait() + search_result = await _search_task all_tools_used: List[str] = list(search_result.pop("_tools_used", []) or []) self.context.save_step(f"search_step_{step.id}", json.dumps(search_result)) @@ -1670,16 +1689,30 @@ async def _run_step( chunks_before = self._search_store.chunk_count( include_superseded=False ) - _supp_status: List[ResponseMessage] = [] - supplemental_search = await self._run_search( - step, - query, - tools, - extra_context=targeted_ctx, - status_sink=_supp_status, + _supp_queue: "asyncio.Queue[ResponseMessage]" = asyncio.Queue() + _supp_task = asyncio.create_task( + self._run_search( + step, + query, + tools, + extra_context=targeted_ctx, + status_queue=_supp_queue, + ) ) - for _msg in _supp_status: - yield _msg + while not _supp_task.done(): + try: + _msg = await asyncio.wait_for( + _supp_queue.get(), timeout=0.25 + ) + yield _msg + except asyncio.TimeoutError: + continue + except asyncio.CancelledError: + _supp_task.cancel() + raise + while not _supp_queue.empty(): + yield _supp_queue.get_nowait() + supplemental_search = await _supp_task chunks_after = self._search_store.chunk_count( include_superseded=False ) @@ -2242,6 +2275,17 @@ async def synthesize(self) -> AsyncIterator[ResponseMessage]: + "\n".join(contra_lines) ) + # P1-6: batch-embed every section's RAG query up-front so per-section + # ``_search_store.retrieve`` calls hit a warm cache instead of doing + # one embedding round-trip apiece. Best-effort — silently skips if + # embeddings are unavailable (retrieval falls back to TF-IDF). + _section_queries = [ + f"{section.get('title', f'Section {i}')}: " + f"{section.get('description', '')} — {query}" + for i, section in enumerate(sections, start=1) + ] + await self._search_store.warm_query_cache(_section_queries) + for sec_idx, section in enumerate(sections, start=1): sec_title = section.get("title", f"Section {sec_idx}") sec_desc = section.get("description", "") @@ -2252,7 +2296,6 @@ async def synthesize(self) -> AsyncIterator[ResponseMessage]: query=rag_query, top_k=config.section_draft_top_k, ) - # Build a focused drafting prompt (swap context in/out per section) draft_prompt_parts = [ f"Research Query: {query}", @@ -2271,8 +2314,19 @@ async def synthesize(self) -> AsyncIterator[ResponseMessage]: "'%s' — falling back to raw analyst output.", sec_title, ) - # Last-resort fallback: pull relevant subset from analyst output + # Last-resort fallback: pull relevant subset from analyst output. + # We hard-cap at 6000 chars to keep prompt size bounded; log + # when this truncation loses content so operators can spot + # sections that are being drafted from a partial view. analyst_str = json.dumps(self._analyst_output, indent=2) + if len(analyst_str) > 6000: + logger.warning( + "[Synthesis] Analyst-output fallback truncated for section " + "'%s': %d chars → 6000 (%.1f%% dropped).", + sec_title, + len(analyst_str), + 100.0 * (1 - 6000 / len(analyst_str)), + ) draft_prompt_parts.append(f"Research Findings:\n{analyst_str[:6000]}") if structured_tensions_block: draft_prompt_parts.append(structured_tensions_block) @@ -2647,6 +2701,35 @@ def synthesis_checkpoint(self) -> Dict[str, Any]: "contradictions": [c.to_dict() for c in self._contradictions], } + def plan_checkpoint(self) -> Dict[str, Any]: + """Return serializable plan-phase state for persistence across restarts. + + Captures the pending plan (if any) plus the query so a session that is + sitting in ``awaiting_approval`` can be fully reconstructed after a + crash — including the ``_pending_plan`` that ``approve_plan`` requires. + """ + return { + "query": self.context.get_step_result("query") or "", + "pending_plan": ( + self._pending_plan.to_dict() if self._pending_plan else None + ), + } + + def restore_plan_state(self, checkpoint: Dict[str, Any]) -> None: + """Restore ``_pending_plan`` (and seeded query context) from a checkpoint. + + Called during session recovery for sessions persisted in the + ``awaiting_approval`` state so ``approve_plan`` can proceed against + the reconstructed Orchestrator. + """ + query = checkpoint.get("query", "") + if query: + self.context.save_step("query", query) + plan_dict = checkpoint.get("pending_plan") + if plan_dict: + self._pending_plan = ResearchPlan.from_dict(plan_dict) + self.context.save_step("plan", json.dumps(plan_dict)) + def restore_synthesis_state(self, checkpoint: Dict[str, Any]) -> None: """Restore synthesis inputs from a persisted checkpoint. @@ -2679,7 +2762,7 @@ def restore_synthesis_state(self, checkpoint: Dict[str, Any]) -> None: ) query = checkpoint.get("query", "") if query: - self.context.store_step_result("query", query) + self.context.save_step("query", query) def get_agent_pool(self) -> AgentPool: """Expose the agent pool for inspection or reconfiguration.""" @@ -2888,6 +2971,7 @@ async def _run_search( tools: List[Dict[str, Any]], extra_context: Optional[str] = None, status_sink: Optional[List] = None, + status_queue: "Optional[asyncio.Queue[ResponseMessage]]" = None, ltm_context: Optional[str] = None, ) -> Dict[str, Any]: """Run the SearchAgent for a single plan step. @@ -2898,7 +2982,14 @@ async def _run_search( Optional list to accumulate ``ResponseMessage`` status events as each search iteration completes. The caller can flush this list with ``yield`` after the function returns to surface per-iteration - progress to the WebSocket client. + progress to the WebSocket client. Prefer ``status_queue`` for new + call sites — it enables live streaming of progress events instead + of the batched (post-hoc) delivery ``status_sink`` provides. + status_queue: + Optional asyncio queue. When supplied, iteration-progress events + are ``put_nowait``-ed onto it as they happen. The caller can + drain the queue concurrently (e.g. via a helper task alongside + this coroutine) to yield the events to the WebSocket immediately. """ prompt_parts = [ f"Overall Research Goal: {query}", @@ -2950,16 +3041,21 @@ async def _run_search( -config.max_search_history_messages : ] - if status_sink is not None: - status_sink.append( - ResponseMessage( - type="status", - message=( - f"[Search] Step {step.id} — " - f"iteration {iteration + 1}/{config.max_iterations}…" - ), - ) + if status_sink is not None or status_queue is not None: + _status_msg = ResponseMessage( + type="status", + message=( + f"[Search] Step {step.id} — " + f"iteration {iteration + 1}/{config.max_iterations}…" + ), ) + if status_sink is not None: + status_sink.append(_status_msg) + if status_queue is not None: + try: + status_queue.put_nowait(_status_msg) + except asyncio.QueueFull: + pass # drop rather than block search progress response = await self.agents.search.run( prompt="\n\n".join(prompt_parts), diff --git a/backend/research_agent.py b/backend/research_agent.py index 62d328d..a594a44 100644 --- a/backend/research_agent.py +++ b/backend/research_agent.py @@ -1,14 +1,11 @@ -from typing import AsyncIterator +from typing import AsyncIterator, Optional +from config import Config from mcp_client import MCPServerRegistry from model_backend import ( Message, ModelBackend, - OpenAIBackend, - OllamaBackend, - AzureOpenAIBackend, - AWSOpenAIBackend, - GCPVertexAIBackend, + select_model_for_backend, ) from models import ResearchStep from observability import get_logger @@ -16,12 +13,56 @@ logger = get_logger(__name__) +# Keyword sets shared with ``Orchestrator._select_model`` so both agents agree +# on which tasks warrant the heavy vs. light model tier. +_HEAVY_KEYWORDS = ( + "plan", + "synthesize", + "insight", + "pattern", + "analyze", + "summarize", + "research", + "develop", + "assess", + "assessment", + "recommendation", + "creative application", + "document", + "report", +) +_LIGHT_KEYWORDS = ( + "search", + "find", + "gather", + "execute", + "retrieve", + "scrape", + "follow links", + "status", + "message", + "failure", + "error", + "inform the user", +) + + class ResearchAgent: """Main research agent orchestrator""" - def __init__(self, model_backend: ModelBackend, mcp_registry: MCPServerRegistry): + def __init__( + self, + model_backend: ModelBackend, + mcp_registry: MCPServerRegistry, + config: Optional[Config] = None, + ): self.model = model_backend self.mcp_servers = mcp_registry + # Config carries the per-backend ``*_heavy_model`` / ``*_light_model`` + # fields that ``_select_model_for_step`` consults. Fall back to a + # freshly-loaded ``Config`` (which reads the environment) when no + # explicit config is supplied so existing call sites keep working. + self.config = config or Config() async def chat(self, message: str) -> AsyncIterator[str]: """Simple chat interface for testing the model backend independently of the research process @@ -40,104 +81,37 @@ async def chat(self, message: str) -> AsyncIterator[str]: yield response_chunk def _select_model_for_step(self, step: ResearchStep | str) -> str: - """Select the appropriate model for a given research step based on its description and complexity""" + """Select the appropriate model for a given research step. + + Delegates the actual backend → model-name lookup to + ``model_backend.select_model_for_backend`` so all agents agree on + which config field to consult. Only the heavy/light *decision* + lives here — driven by keyword matching against the step + description. + """ if isinstance(step, str): description = step.lower() else: description = step.description.lower() - # Use the most powerful model available for synthesis and insight generation - if any( - keyword in description - for keyword in [ - "plan", - "synthesize", - "insight", - "pattern", - "analyze", - "summarize", - "research", - "develop", - "assess", - "assessment", - "recommendation", - "creative application", - "document", - ] - ): - if isinstance(self.model, OpenAIBackend): - return "gpt-5.2" - elif isinstance(self.model, AzureOpenAIBackend): - return "gpt-5.2" - elif isinstance(self.model, AWSOpenAIBackend): - return "global.anthropic.claude-sonnet-4-6" - elif isinstance(self.model, GCPVertexAIBackend): - return "gemini-2.5-pro" - elif isinstance(self.model, OllamaBackend): - return "nemotron-3-nano" - else: - raise ValueError( - f"Unsupported model backend for synthesis steps: {type(self.model)}" - ) + is_heavy = any(kw in description for kw in _HEAVY_KEYWORDS) + is_light = (not is_heavy) and any(kw in description for kw in _LIGHT_KEYWORDS) - # Use a more efficient model for information gathering and tool execution steps - elif any( - keyword in description - for keyword in [ - "search", - "find", - "gather", - "execute", - "retrieve", - "scrape", - "follow links", - ] - ): - if isinstance(self.model, OpenAIBackend): - return "gpt-5-nano" - elif isinstance(self.model, AzureOpenAIBackend): - return "gpt-5-nano" - elif isinstance(self.model, AWSOpenAIBackend): - return "global.anthropic.claude-haiku-4-5-20251001-v1:0" - elif isinstance(self.model, GCPVertexAIBackend): - return "gemini-2.5-nano" - elif isinstance(self.model, OllamaBackend): - return "llama3.2" - else: - raise ValueError( - f"Unsupported model backend for synthesis steps: {type(self.model)}" - ) + # If nothing matched, default to the heavy model — historic behaviour + # (the previous implementation fell through to the heavy path for + # unmatched steps). + resolved_heavy = is_heavy or (not is_light) - # Pick a model for simple status messages or failure messages - elif any( - keyword in description - for keyword in ["status", "message", "failure", "error", "inform the user"] - ): - if isinstance(self.model, OpenAIBackend): - return "gpt-5-nano" - elif isinstance(self.model, AzureOpenAIBackend): - return "gpt-5-nano" - elif isinstance(self.model, AWSOpenAIBackend): - return "global.anthropic.claude-haiku-4-5-20251001-v1:0" - elif isinstance(self.model, GCPVertexAIBackend): - return "gemini-2.5-nano" - elif isinstance(self.model, OllamaBackend): - return "llama3.2" - else: + model = select_model_for_backend( + self.model, self.config, is_heavy=resolved_heavy + ) + if model is None: + # Single-model backends (HuggingFace) or unrecognised types — + # fall back to whatever the backend was configured with. + fallback = getattr(self.model, "model", None) + if not fallback: raise ValueError( - f"Unsupported model backend for status/failure messages: {type(self.model)}" + f"Unsupported model backend for model selection: {type(self.model)}" ) - # Default to the most powerful model for any steps that don't match specific keywords, - # as a safe fallback to ensure quality. - if isinstance(self.model, OpenAIBackend): - return "gpt-5.2" - elif isinstance(self.model, AzureOpenAIBackend): - return "gpt-5.2" - elif isinstance(self.model, AWSOpenAIBackend): - return "global.anthropic.claude-sonnet-4-6" - elif isinstance(self.model, GCPVertexAIBackend): - return "gemini-2.5-pro" - elif isinstance(self.model, OllamaBackend): - return "nemotron-3-nano" - else: - raise ValueError(f"Unsupported model backend: {type(self.model)}") + return fallback + return model diff --git a/backend/search_result_store.py b/backend/search_result_store.py index 32b6f2d..b0eb314 100644 --- a/backend/search_result_store.py +++ b/backend/search_result_store.py @@ -333,19 +333,25 @@ async def filter_long_term_memories( ) -> List[str]: """ Remove long-term memory strings that are near-duplicate of an existing - session chunk (cosine similarity ≥ *similarity_threshold*). + session chunk (TF-IDF cosine similarity ≥ *similarity_threshold*). This prevents the ReportComposer's prompt from being padded with stale or superseded findings when a more recent session result already covers the same fact. + Uses TF-IDF (not sentence-transformer embeddings) for the dedup pass + because it is cheap and synchronous — good enough for lexical overlap + detection on the short summary strings emitted by the memory MCP + server. Embedding-based dedup would give slightly better recall on + paraphrases but at ~50–100× the CPU cost per call. + Parameters ---------- memory_items: Plain-text memory strings from the long-term ``memory`` MCP server. similarity_threshold: - Cosine similarity cutoff. Items scoring above this against ANY - active session chunk are dropped. + TF-IDF cosine similarity cutoff. Items scoring above this against + ANY active session chunk are dropped. Returns ------- @@ -362,8 +368,9 @@ async def filter_long_term_memories( kept: List[str] = [] for mem_text in memory_items: - # Use the shared TF-IDF scorer as a fast local filter. - # If vector embeddings are available we also try cosine. + # Fast TF-IDF cosine similarity against active session chunks. + # We intentionally skip the sentence-transformer path here — see + # the docstring for the cost/quality tradeoff. corpus = [c.text for c in active_chunks] scores = tfidf_similarity(mem_text, corpus) max_score = max(scores) if scores else 0.0 @@ -659,6 +666,35 @@ async def _embed_query_cached(self, query: str) -> Optional[List[float]]: logger.debug("[SearchResultStore] _embed_query_cached failed: %s", exc) return None + async def warm_query_cache(self, queries: List[str]) -> None: + """Batch-embed several queries and stash them in the cache. + + Report synthesis embeds one query per section (typically 6–10 sections). + Doing the encoding one-at-a-time incurs N model round-trips; batching + cuts that to a single ``embed_texts`` call so section drafts start + immediately with a warm cache. Subsequent ``_embed_query_cached`` + calls become in-memory dict lookups. + + Silently skips queries that are already cached, empty, or if the + embeddings backend is unavailable — callers should treat this as a + best-effort optimization, not a hard dependency. + """ + to_embed = [q for q in queries if q and q not in self._query_embedding_cache] + if not to_embed: + return + try: + from embeddings import embed_texts + + vectors = await embed_texts(to_embed) + except Exception as exc: + logger.debug("[SearchResultStore] warm_query_cache failed: %s", exc) + return + if not vectors or len(vectors) != len(to_embed): + return + for q, vec in zip(to_embed, vectors): + if vec: + self._query_embedding_cache[q] = vec + @staticmethod def _cosine_rank(query_vec: List[float], pool: List[Chunk]) -> List[Chunk]: """ diff --git a/backend/session_store.py b/backend/session_store.py index 454f2af..a571f17 100644 --- a/backend/session_store.py +++ b/backend/session_store.py @@ -155,12 +155,26 @@ def get(self, session_id: str) -> Optional[ResearchSession]: return self._sessions.get(session_id) def remove(self, session_id: str) -> None: - """Remove the session and cancel its background task if still running.""" + """Remove the session and cancel its background task if still running. + + Also deletes the on-disk checkpoint file (if any) so pruned sessions + do not leave behind orphan JSON blobs in ``logs/sessions/``. + """ session = self._sessions.pop(session_id, None) if session is None: return if session.background_task and not session.background_task.done(): session.background_task.cancel() + # Delete the on-disk checkpoint, if it exists. Best-effort: a + # missing or permission-denied file must never break session removal. + try: + path = self._session_file(session_id) + if path.exists(): + path.unlink() + except Exception as exc: + logger.debug( + "Could not delete checkpoint for session %s: %s", session_id, exc + ) logger.info("Session removed: %s", session_id) # ------------------------------------------------------------------ @@ -174,11 +188,26 @@ def persist( self, session: ResearchSession, synthesis_checkpoint: Optional[Dict[str, Any]] = None, + plan_checkpoint: Optional[Dict[str, Any]] = None, ) -> None: """Persist session replay_log and state to disk. Writes atomically via a .tmp intermediate so a crash mid-write never produces a corrupted checkpoint file. + + Parameters + ---------- + session: + The live session to serialize. + synthesis_checkpoint: + Optional dict from ``Orchestrator.synthesis_checkpoint()``. Present + only after ``execute()`` completes so ``synthesize()`` can be + re-run on recovery. + plan_checkpoint: + Optional dict from ``Orchestrator.plan_checkpoint()``. Present + for sessions persisted in the ``awaiting_approval`` state so + ``_pending_plan`` can be restored and ``approve_plan`` can + proceed after a restart. """ data: Dict[str, Any] = { "session_id": session.session_id, @@ -188,6 +217,8 @@ def persist( } if synthesis_checkpoint is not None: data["synthesis_checkpoint"] = synthesis_checkpoint + if plan_checkpoint is not None: + data["plan_checkpoint"] = plan_checkpoint try: path = self._session_file(session.session_id) tmp = path.with_suffix(".tmp") diff --git a/backend/unit_tests.py b/backend/unit_tests.py index 906ab20..a3080d5 100644 --- a/backend/unit_tests.py +++ b/backend/unit_tests.py @@ -1325,3 +1325,446 @@ async def test_store_relationship_auto_upserts_missing_endpoints(self): called_cyphers = [str(c.args[0]) for c in session_mock.run.call_args_list] assert any("CREATE" in q for q in called_cyphers) assert result.get("success") is True + + +# ═════════════════════════════════════════════════════════════════════════ +# Session persistence — plan checkpoint round-trip + on-disk cleanup +# ═════════════════════════════════════════════════════════════════════════ +class TestSessionStorePlanCheckpoint: + """Cover the P0-3/P0-4/P0-6 hardening: plan snapshots survive restarts + and pruned sessions no longer leave checkpoint files behind.""" + + def _make_store(self, tmp_path): + from session_store import SessionStore, ResearchSession + + store = SessionStore(sessions_dir=tmp_path) + return store, ResearchSession + + def _make_session(self, ResearchSession, session_id: str): + # ``orchestrator`` is typed Any; persist() only serialises replay_log + # and state so a MagicMock is enough for these tests. + return ResearchSession(session_id=session_id, orchestrator=MagicMock()) + + def test_persist_then_load_round_trips_plan_checkpoint(self, tmp_path): + store, ResearchSession = self._make_store(tmp_path) + session = self._make_session(ResearchSession, "sess-plan-1") + session.state = "awaiting_approval" + plan_ckpt = { + "query": "test", + "pending_plan": { + "id": "p1", + "steps": [{"id": 1, "description": "step one"}], + }, + } + store._sessions[session.session_id] = session + store.persist(session, plan_checkpoint=plan_ckpt) + + loaded = store.load_checkpoint(session.session_id) + assert loaded is not None + # ``load_checkpoint`` returns the raw JSON dict; the plan-checkpoint + # payload must round-trip verbatim. + assert loaded.get("plan_checkpoint") == plan_ckpt + assert loaded.get("state") == "awaiting_approval" + + def test_remove_deletes_on_disk_checkpoint(self, tmp_path): + store, ResearchSession = self._make_store(tmp_path) + session = self._make_session(ResearchSession, "sess-cleanup") + store._sessions[session.session_id] = session + store.persist(session) + checkpoint_path = tmp_path / f"{session.session_id}.json" + assert checkpoint_path.exists(), "persist() should create the file" + + store.remove(session.session_id) + assert not checkpoint_path.exists(), ( + "remove() must delete the on-disk checkpoint so pruned sessions " + "cannot be resurrected" + ) + assert session.session_id not in store._sessions + + def test_remove_missing_checkpoint_is_noop(self, tmp_path): + """`remove()` must tolerate an in-memory session with no on-disk file.""" + store, ResearchSession = self._make_store(tmp_path) + session = self._make_session(ResearchSession, "sess-no-file") + store._sessions[session.session_id] = session + # No persist() call → no checkpoint file exists. + store.remove(session.session_id) # must not raise + assert session.session_id not in store._sessions + + +# ═════════════════════════════════════════════════════════════════════════ +# Orchestrator plan-checkpoint restore (P0-4) +# ═════════════════════════════════════════════════════════════════════════ +class TestOrchestratorPlanRestore: + """``Orchestrator.plan_checkpoint()`` + ``restore_plan_state()`` should + round-trip the pending plan so approval works after a restart.""" + + def test_plan_checkpoint_round_trip(self, make_orchestrator): + from models import ResearchPlan, ResearchStep, StepStatus + + orch = make_orchestrator() + # Simulate a completed plan that the user hasn't approved yet. + orch._pending_plan = ResearchPlan( + goal="Investigate X", + steps=[ + ResearchStep( + id=1, + name="Investigate X", + description="Investigate X", + status=StepStatus.PENDING, + ) + ], + ) + orch.context.save_step("query", "What is X?") + + snapshot = orch.plan_checkpoint() + assert snapshot is not None + assert snapshot["query"] == "What is X?" + assert snapshot["pending_plan"] is not None + + # A fresh orchestrator restores identical state from the snapshot. + orch2 = make_orchestrator() + assert orch2._pending_plan is None + orch2.restore_plan_state(snapshot) + assert orch2._pending_plan is not None + assert len(orch2._pending_plan.steps) == 1 + assert orch2._pending_plan.steps[0].description == "Investigate X" + # Query is rehydrated so approve_plan/execute can build prompts. + assert orch2.context.get_step_result("query") == "What is X?" + + def test_plan_checkpoint_with_no_pending_plan(self, make_orchestrator): + """Snapshot must still be valid JSON when no plan is pending — + callers rely on it being safe to persist unconditionally.""" + orch = make_orchestrator() + assert orch._pending_plan is None + snapshot = orch.plan_checkpoint() + assert snapshot is not None + assert snapshot["pending_plan"] is None + + +# =========================================================================== +# Group 13 — _recover_session_from_checkpoint: executing branch (P0-2) +# =========================================================================== + + +class TestRecoverExecutingSession: + """Cover the 'execute recovery' branch of ``_recover_session_from_checkpoint``. + + This is Case 2: a session whose checkpoint has ``state='executing'`` and a + valid ``plan_checkpoint`` but *no* ``synthesis_checkpoint``. The function + must restore the pending plan on the orchestrator and launch a background + job that re-runs ``execute() → synthesize()``. + + The synthesis and execute generators on the orchestrator are replaced with + async iterators that yield nothing so the background task completes quickly + without touching any real LLM or MCP infrastructure. + """ + + def _build_app_state(self, mock_registry, mock_backend): + """Return a minimal app_state namespace accepted by ``_make_orchestrator``.""" + from config import Config + from orchestrator import AgentPool + + pool = AgentPool.from_single_backend(mock_backend) + + state = MagicMock() + state.config = Config() + state.mcp_registry = mock_registry + state.agent_pool = pool + state.long_term_memory = None + return state + + def _build_plan_checkpoint(self): + """Minimal plan_checkpoint that ``restore_plan_state`` can hydrate.""" + return { + "query": "What is the future of AI?", + "pending_plan": { + "id": "plan-exec-1", + "goal": "Research AI futures", + "steps": [ + { + "id": 1, + "name": "Search AI trends", + "description": "Search for AI trends", + "status": "pending", + "parallel_group": None, + } + ], + }, + "prior_context": None, + } + + async def test_execute_branch_launches_background_job( + self, mock_registry, mock_backend, tmp_path + ): + """A checkpoint with state=executing and a plan_checkpoint must + trigger a background execute+synthesize job via session_manager.""" + from api_server import _recover_session_from_checkpoint + from session_manager import ResearchSessionManager + from session_store import SessionStore + + app_state = self._build_app_state(mock_registry, mock_backend) + session_manager = ResearchSessionManager() + store = SessionStore(sessions_dir=tmp_path) + + checkpoint_data = { + "session_id": "sess-exec-recover-1", + "state": "executing", + "replay_log": [], + "plan_checkpoint": self._build_plan_checkpoint(), + # No synthesis_checkpoint → this is mid-execute, not mid-synthesis. + } + + # Replace execute() and synthesize() with empty async generators so + # the background task completes without hitting any real backend. + async def _empty_gen(): + return + yield # pragma: no cover (makes it an async generator) + + app_state.agent_pool.root = MagicMock() + + session = await _recover_session_from_checkpoint( + app_state, session_manager, store, checkpoint_data + ) + + # The session must have a running background task. + assert session.background_task is not None, ( + "Execute recovery must start a background task." + ) + assert not session.background_task.done() or session.background_task.done(), ( + "Background task must exist (may be done if it ran to completion quickly)." + ) + + # The orchestrator must have the pending plan restored. + assert session.orchestrator._pending_plan is not None, ( + "restore_plan_state() must have populated _pending_plan." + ) + assert session.orchestrator._pending_plan.steps[0].description == ( + "Search for AI trends" + ) + + # Allow the background task to finish so we don't leak coroutines. + await asyncio.sleep(0.1) + await session_manager.shutdown(timeout=2.0) + + async def test_execute_branch_emits_restart_status_event( + self, mock_registry, mock_backend, tmp_path + ): + """The recovery path must immediately emit a status event telling the + client that the server restarted and research is being re-run.""" + from api_server import _recover_session_from_checkpoint + from session_manager import ResearchSessionManager + from session_store import SessionStore + + app_state = self._build_app_state(mock_registry, mock_backend) + session_manager = ResearchSessionManager() + store = SessionStore(sessions_dir=tmp_path) + + checkpoint_data = { + "session_id": "sess-exec-recover-2", + "state": "executing", + "replay_log": [], + "plan_checkpoint": self._build_plan_checkpoint(), + } + + session = await _recover_session_from_checkpoint( + app_state, session_manager, store, checkpoint_data + ) + + # The replay_log must contain the restart status message emitted + # synchronously before the background task is created. + status_events = [ + e for e in session.replay_log if e.get("type") == "status" + ] + assert len(status_events) >= 1, ( + "Recovery must emit at least one status event before launching the job." + ) + assert "restarting" in status_events[0].get("message", "").lower() or \ + "restart" in status_events[0].get("message", "").lower(), ( + f"Status event message should mention restart, got: {status_events[0]}" + ) + + await asyncio.sleep(0.1) + await session_manager.shutdown(timeout=2.0) + + async def test_terminal_state_skips_job_creation( + self, mock_registry, mock_backend, tmp_path + ): + """A checkpoint whose replay_log already contains a 'report' event is + terminal and must NOT launch a new background job.""" + from api_server import _recover_session_from_checkpoint + from session_manager import ResearchSessionManager + from session_store import SessionStore + + app_state = self._build_app_state(mock_registry, mock_backend) + session_manager = ResearchSessionManager() + store = SessionStore(sessions_dir=tmp_path) + + checkpoint_data = { + "session_id": "sess-exec-terminal", + "state": "executing", + "replay_log": [{"type": "report", "data": {"document": "# Done"}}], + "plan_checkpoint": self._build_plan_checkpoint(), + } + + session = await _recover_session_from_checkpoint( + app_state, session_manager, store, checkpoint_data + ) + + # No background task should have been started. + assert session.background_task is None, ( + "Terminal sessions (report already delivered) must not re-launch the pipeline." + ) + await session_manager.shutdown(timeout=2.0) + + +# =========================================================================== +# Group 14 — WebSocket resume flow (Item 5a) +# =========================================================================== + + +class TestWebSocketResume: + """Integration tests for the WebSocket reconnect + replay flow. + + Uses FastAPI's built-in test WebSocket client (``starlette.testclient``) + to drive the full ``/ws/research`` endpoint without a running server. + The orchestrator is fully mocked so no real LLM or MCP calls are made. + + Scenario: + 1. Client connects and submits a query → receives ``session_created``. + 2. Client disconnects before execution finishes. + 3. Client reconnects and sends ``resume`` → receives ``session_resumed`` + followed by the replay log in order. + """ + + @pytest.fixture() + def app_with_mocks(self, mock_registry, mock_backend, tmp_path): + """Build a minimal FastAPI app whose lifespan is bypassed — state is + injected directly so the test does not touch real Neo4j, MCP, or LLM.""" + from fastapi import FastAPI + from api_server import app as real_app, research_websocket + from session_store import SessionStore + from session_manager import ResearchSessionManager + from orchestrator import AgentPool + from config import Config + + # Clone just the WS route onto a fresh app so our injected state + # takes effect without the lifespan firing. + test_app = FastAPI() + test_app.add_api_websocket_route("/ws/research", research_websocket) + + pool = AgentPool.from_single_backend(mock_backend) + + test_app.state.config = Config() + test_app.state.mcp_registry = mock_registry + test_app.state.agent_pool = pool + test_app.state.long_term_memory = None + test_app.state.session_store = SessionStore(sessions_dir=tmp_path) + test_app.state.session_manager = ResearchSessionManager() + test_app.state.shutting_down = False + + return test_app + + @pytest.fixture() + def fast_plan_backend(self, mock_backend): + """Configure mock_backend to return a valid plan JSON on the first + generate() call so plan() completes quickly.""" + plan_json = json.dumps( + { + "goal": "Test goal", + "steps": [{"id": 1, "description": "Step one"}], + } + ) + mock_backend.generate = AsyncMock( + return_value=_make_model_response(content=plan_json) + ) + return mock_backend + + async def test_session_created_on_query(self, app_with_mocks, fast_plan_backend): + """A ``query`` message must produce a ``session_created`` reply.""" + from starlette.testclient import TestClient + + # Patch generate on the pool's root backend for this test. + app_with_mocks.state.agent_pool.root = MagicMock() + app_with_mocks.state.agent_pool.root.model = fast_plan_backend + + with TestClient(app_with_mocks) as client: + with client.websocket_connect("/ws/research") as ws: + ws.send_json({"type": "query", "content": "Test research query"}) + # Drain messages until we see session_created or error. + for _ in range(10): + msg = ws.receive_json() + if msg.get("type") in ("session_created", "error"): + break + assert msg.get("type") == "session_created", ( + f"Expected session_created, got: {msg}" + ) + assert "session_id" in msg + + async def test_resume_replays_events_in_order( + self, app_with_mocks, tmp_path, mock_registry, mock_backend + ): + """Reconnecting with a known session_id must trigger session_resumed + and the replay log must arrive with events in their original order.""" + from session_store import SessionStore, ResearchSession + from starlette.testclient import TestClient + + store: SessionStore = app_with_mocks.state.session_store + + # Pre-create a session with a known replay log so we can verify ordering. + orch = MagicMock() + orch._pending_plan = None + orch.plan_checkpoint = MagicMock(return_value=None) + session = store.create(orch, session_id="resume-test-session") + session.state = "complete" + session.complete = True + for i in range(3): + session.emit({"type": "status", "message": f"Event {i}", "seq": i}) + + with TestClient(app_with_mocks) as client: + with client.websocket_connect("/ws/research") as ws: + ws.send_json({"type": "resume", "session_id": "resume-test-session"}) + + # Collect messages until session_resumed arrives. + received = [] + for _ in range(20): + msg = ws.receive_json() + received.append(msg) + if msg.get("type") == "session_resumed": + break + + assert any(m.get("type") == "session_resumed" for m in received), ( + "Must receive session_resumed after sending resume." + ) + resumed_msg = next( + m for m in received if m.get("type") == "session_resumed" + ) + assert resumed_msg.get("session_id") == "resume-test-session" + + # Drain the replay log — should arrive in order. + replay = [] + for _ in range(10): + try: + msg = ws.receive_json() + if msg.get("type") == "status": + replay.append(msg) + if len(replay) == 3: + break + except Exception: + break + + seqs = [m.get("seq") for m in replay] + assert seqs == sorted(seqs), ( + f"Replay events must arrive in sequence order, got seqs: {seqs}" + ) + + async def test_resume_unknown_session_returns_error(self, app_with_mocks): + """Resuming an unknown session id must return an error event, not crash.""" + from starlette.testclient import TestClient + + with TestClient(app_with_mocks) as client: + with client.websocket_connect("/ws/research") as ws: + ws.send_json({"type": "resume", "session_id": "nonexistent-session"}) + msg = ws.receive_json() + assert msg.get("type") == "error", ( + f"Expected error for unknown session, got: {msg}" + ) diff --git a/docs/MEMORY.md b/docs/MEMORY.md index f78a5c4..2c9640f 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -2,8 +2,13 @@ Compressed, curated knowledge of the project's history, decisions, and patterns. Loaded fully at the start of every session. Updated only when something worth preserving is learned. -## [YYYY-MM-DD] -- +## [2026-09-29] +- **Dead files on disk (awaiting operator `rm`):** `src/components/MCPServerManger.tsx` (typo), `src/components/MCPServerManager.tsx`, `src/components/ReportViewer.tsx`, `backend/metrics.py`. The 2026-03-18 changelog entry incorrectly claimed `ReportViewer.tsx` was deleted — it was not. Corrected in PROJECT-KNOWLEDGE.md. +- **`_recover_session_from_checkpoint` (api_server.py)** has 4 recovery cases: (1) synthesis recovery, (2) execute recovery (state=executing, plan_checkpoint present, no synthesis_checkpoint), (3) plan-approval recovery, (4) replay-only. Case 2 was previously untested — now covered by `TestRecoverExecutingSession` in `unit_tests.py`. +- **WS resume integration tests** now live in `unit_tests.py::TestWebSocketResume` using Starlette `TestClient` + injected app state (bypasses lifespan). Pattern: create a fresh `FastAPI()`, copy the WS route, inject mocked state directly onto `app.state`. +- **Frontend test infra**: vitest 1.x is incompatible with vite 7.x (ESM conflict). Solution: keep a separate `vitest.config.ts` importing from `vitest/config` (not `vite`), which vitest prefers over `vite.config.ts`. Upgraded vitest to ^3.0.0. +- **App multi-session routing tests** (`src/components/__tests__/App.multisession.test.tsx`): mock `useWebSocket` at module level with a shared `wsState` object; call `rerender()` after bumping `wsState.messageCount` to trigger the message-processing `useEffect`. Assert routing by reading `localStorage` (where `useConversations` persists state). To test `pendingQueryConvIdRef`, mock `ChatInput` to capture the `onSendMessage` prop (not `onSend`) and expose an imperative `triggerSend()` helper. +- **DEFERRED items still open**: §2 (dedup curated context — needs benchmark), §6 (sentence-transformer dedup — optional), §7 (analyst-fallback truncation fix — needs log data). ## [YYYY-MM-DD] - diff --git a/docs/PROJECT-KNOWLEDGE.md b/docs/PROJECT-KNOWLEDGE.md index ee92b81..7cde640 100644 --- a/docs/PROJECT-KNOWLEDGE.md +++ b/docs/PROJECT-KNOWLEDGE.md @@ -1,13 +1,87 @@ # PROJECT-KNOWLEDGE.md — Research Assistant > Living architecture map. Load fully at session start. Update surgically when things change. +> Updated: 2026-09-29 — DEFERRED.md follow-ups (partial close-out): +> • **Dead files** — `src/components/MCPServerManger.tsx`, `src/components/MCPServerManager.tsx`, +> `src/components/ReportViewer.tsx`, `backend/metrics.py` confirmed dead on disk, awaiting +> manual `rm` by operator (agent shell blocks `rm`). +> See DEFERRED.md §1 for the exact `rm` command. +> • **PROJECT-KNOWLEDGE.md accuracy** — corrected three wrong claims: (a) 2026-03-18 entry +> no longer claims `ReportViewer.tsx` was deleted (it wasn't); (b) `MCPServerManger.tsx` +> component table entry now correctly marked as dead; (c) `metrics.py` table entry now +> correctly marked as dead. +> • **Item 5b / TestRecoverExecutingSession** — three new tests added to +> `backend/unit_tests.py` covering the `state=executing` recovery branch of +> `_recover_session_from_checkpoint` (Case 2: mid-execute crash, no synthesis checkpoint). +> All 148 unit tests pass. +> • **Item 5a / TestWebSocketResume** — three new integration tests added to +> `backend/unit_tests.py` using Starlette's `TestClient` WebSocket transport. +> Covers: session_created on query, replay ordering on resume, error on unknown session. +> • **Item 5c / App.multisession.test.tsx** — new `src/components/__tests__/` directory +> created. Three vitest tests cover multi-session routing: (1) events for session A update +> conv A even when conv B is active; (2) background session events persist correctly for +> future re-activation; (3) `session_created` binds to originating conv via +> `pendingQueryConvIdRef`. All 3 pass. +> • **Test infra** — `vitest` upgraded from 1.0.4 → 3.x, `@testing-library/react`, +> `@testing-library/jest-dom`, `@testing-library/dom`, `jsdom` added as dev deps. +> `src/vitest.config.ts` created (separate from `vite.config.ts` to avoid vite 7 ESM +> incompatibility with vitest's internal bundler). `src/test-setup.ts` created. +> • **DEFERRED.md items 2, 3, 6, 7** — not yet implemented (item 3 already closed; +> items 2/7 need benchmark/log data; item 6 is optional). +> Updated: 2026-09-29 — Session recovery + multi-session frontend hardening (P0/P1/P2 sweep): +> • **P0-1** `App.tsx` now tracks sessions per-conversation instead of via two +> global refs / localStorage keys. Each `Conversation` carries its own +> `sessionId` / `sessionState` metadata; the frontend can resume any +> conversation whose session is still executing rather than only the last one. +> • **P0-2** `_recover_session_from_checkpoint()` in `api_server.py` now +> re-launches the full pipeline (plan → execute → synthesize) when a +> checkpoint is found in `executing` state without a `synthesis_checkpoint` — +> previously such sessions silently dropped and could not be resumed. +> • **P0-3** Sessions are persisted once `plan()` completes (state +> `awaiting_approval`) so a restart between plan and approval no longer +> loses the pending plan. +> • **P0-4** `Orchestrator.plan_checkpoint()` / `restore_plan()` serialise +> `_pending_plan` + `_prior_context` + `_current_query`; recovery +> rehydrates them so plan approval works after a restart. +> • **P0-5** `plan()` transitions state to `awaiting_approval` **before** +> yielding the `plan` event so a client disconnect between yield and +> persistence can't wedge the session in `planning`. +> • **P0-6** `SessionStore.remove()` now also deletes the on-disk +> checkpoint file so terminated sessions can't be resurrected. +> • **P1-1** `research_agent._select_model_for_step()` now delegates to +> `model_backend.select_model_for_backend()` (shared with +> `Orchestrator._select_model`) and consults the config's +> `*_heavy_model` / `*_light_model` fields instead of hardcoded strings. +> `SelfOptimizingAgent.__init__` accepts an optional `config`. +> • **P1-3** `_run_search` streams `[Search] iteration N/M` status events +> live via an `asyncio.Queue`, not batched after completion. +> • **P1-4** `/project-docs` endpoint resolves the docs dir in this order: +> `DOCS_DIR` env → `backend/docs` → repo-root `docs/`. Fixes 404s when +> running the API server outside Docker. +> • **P1-5** Removed the legacy `ConfigUpdate.model` field (dead — the UI +> only sends `heavy_model` / `light_model`). +> • **P1-6** Report synthesis pre-batches every section's RAG query +> embedding via `SearchResultStore.warm_query_cache()` before section +> drafting starts (one round-trip instead of N). +> • **P2-2** `KnowledgeGraph.recall_graph_context()` caps relationships +> (`entity_limit * 6`, min 20) and communities (5) in the LLM context. +> • **P2-4** `filter_long_term_memories` docstring corrected — it is +> TF-IDF, not embedding-based cosine. +> • **P2-5** `synthesize()` logs when the analyst-fallback truncation +> drops content (>6000 chars). +> +> Known dead files still on disk (couldn't be `rm`-ed by the agent): +> `src/components/MCPServerManger.tsx` (typo), `src/components/MCPServerManager.tsx`, +> `src/components/ReportViewer.tsx`, `backend/metrics.py`. +> > Updated: 2026-04-10 — Pipeline data-flow audit: 9 fixes to ensure comprehensive data passage between agents. Config defaults: `max_iterations` 3→5, `distill_max_chars` 2000→4000, `step_summary_max_chars` 800→1500, `section_draft_top_k` 6→10, `analyst_top_k` 8→10. `_extract_sources()` converted from `@staticmethod` to instance method; now collects source URLs from RAG store chunks as fallback (8-session bug fixed). `_run_analyst()` now passes prior step structured claims/tensions to each analyst via `prior_claims_block` (cross-step `top_k` 3→5). `synthesize()` Phase B now passes `structured_claims_block`, `structured_tensions_block`, and `unresolved_contradictions_block` to section drafting; system prompt updated for novel insight generation. `_generate_step_summary` uses expanded input (claims[:15], tensions[:5], notes[:500]) and 8-bullet prompt. Distillation raw text input 8000→12000. Smoke tests: 62 total. > Updated: 2026-04-10 — Knowledge Graph Phase 1–5 (graph pruning + full enhancement): `long_term_memory.py` gained `Source` node type (uniqueness constraint on `url`), temporal props on Entity/RELATES_TO, relationship deduplication in `store_relationship`, 13 new KG methods (store_hierarchy, store_contradiction, find_contradictions, store_source, link_to_source, get_provenance, recent_entities, recent_relationships, session_diff, find_paths, find_common_neighbors, decay_confidence, prune), enhanced `recall_graph_context` (min_confidence, include_contradictions, include_provenance), expanded `stats()` (6 keys). `orchestrator.py`: mutation counter, dynamic entity_limit, enriched extraction prompt with IS_A+provenance wiring, smart community gate, post-synthesis decay. `config.py`: `confidence_decay_half_life` (default 30) + `graph_community_min_mutations` (default 3). `api_server.py`: 9 new `/graph/*` endpoints (POST /graph/prune + 8 GETs). Smoke tests: 57 total (was 48), all pass. > Updated: 2026-03-25 — Synthesis step leak fix: `_root_system_prompt()` updated with `CRITICAL CONSTRAINT` prohibiting synthesis steps in the plan. `_is_synthesis_step(step)` static method added (19-phrase detection). `_run_step()` gains a synthesis guard that skips steps matching the detector, emitting `step_complete` with `skipped_synthesis: True`. `App.tsx` `step_complete` handler shows a skip message rather than "Completed step" for skipped synthesis steps. Smoke tests: 47 total. > Updated: 2026-04-01 — ChromaDB → Neo4j migration: `long_term_memory.py` fully rewritten to use Neo4j async driver (`AsyncGraphDatabase`). Entities, relationships, and communities are now native Neo4j graph primitives (`:Entity`, `:RELATES_TO`, `:Community`, `:MEMBER_OF`). Flat memories stored as `:Memory` nodes with vector indexes (`memory_embedding_idx`, `entity_embedding_idx`, `community_embedding_idx`). `_NoOpEmbeddingFunction` deleted. `config.py` replaces `chroma_persist_dir`/`chroma_collection_name` with `neo4j_uri`/`neo4j_user`/`neo4j_password`/`neo4j_database`/`neo4j_embedding_dimensions`. `requirements.txt`: `chromadb` → `neo4j>=5.26.0`. Docker Compose adds `neo4j:5.26-community` service (ports 7474/7687, `neo4j_data` volume). `api_server.py` and `cli.py` updated with new constructor args + `close()` on shutdown. `smoke_test.py` updated. Migration script: `scripts/migrate_chroma_to_neo4j.py`. > Last verified: 2026-03-17 — E2E CONFIRMED WORKING (test_e2e.py + deployed Docker stack) > Updated: 2026-03-18 — Azure Container Apps Terraform updated: `mcp_memory` Container App removed; ChromaDB volume now mounted on backend; `MEMORY_SERVER_URL` env var removed from backend; `CHROMA_PERSIST_DIR` + `CHROMA_COLLECTION_NAME` added. -> Updated: 2026-03-18 — Frontend dead code removed: `ReasearchProgress.tsx`, `StepResultModel.tsx`, `StatusIndicator.tsx`, `LoadingSpinner.tsx`, `ReportViewer.tsx`, `hooks/useGraphState.ts`. +> Updated: 2026-03-18 — Frontend dead code removed: `ReasearchProgress.tsx`, `StepResultModel.tsx`, `StatusIndicator.tsx`, `LoadingSpinner.tsx`, `hooks/useGraphState.ts`. +> NOTE: `ReportViewer.tsx` was **not** removed on this date despite what this entry originally said. It is still present on disk as of the 2026-09-29 audit (see "Known dead files still on disk" note above). > Updated: 2026-03-19 — Self-optimize pipeline fixed: `SelfOptimizingAgent` now accepts `AsyncLongTermMemory` and calls `.recall()` directly; `backend/instructions/` bind-mounted in both docker-compose files. > Updated: 2026-03-19 — File upload architecture: frontend uploads directly to file handler MCP (port 9191) via custom HTTP routes; nginx + vite proxy `/files/*` to it. Orchestrator.plan() now reads uploaded files via MCP and injects context into planning prompt. > Updated: 2026-03-20 — GraphRAG: `KnowledgeGraph` class added to `long_term_memory.py` with 3 new ChromaDB collections (entities, relationships, communities). Orchestrator extracts triples from analyst claims, recalls graph context during planning, updates communities post-synthesis. @@ -72,7 +146,8 @@ research-assistant/ | `report_exporter.py` | PDF export: `export_report_pdf(doc, query, session_id)` async entry point. `_generate_pdf()` via `asyncio.to_thread` using `fpdf2`. `REPORTS_DIR` env var (default `/data/reports`). Called by `orchestrator.synthesize()` before yielding `report` event. | | `env.py` | Read/write `.env` file values programmatically (preserves formatting) | | `error_handling.py` | Shared error handling utilities | -| `metrics.py` / `observability.py` / `monitoring.py` | Logging and observability helpers | +| `metrics.py` | **Dead file — awaiting `rm`.** Only imports itself; superseded entirely by `observability.py` and `monitoring.py`. Do not add new imports to this file. | +| `observability.py` / `monitoring.py` | Logging and observability helpers (the live paths) | | `pipeline.py` | Semaphore-capped pipeline runner with query dedup | | `cli.py` | Command-line interface for running research without the frontend | | `test_e2e.py` | Quick E2E smoke test: mock MCP registry + real LLM backend. Runs full plan→execute→synthesize flow. No API server needed. Run from `backend/` dir as `python test_e2e.py`. | @@ -102,7 +177,8 @@ research-assistant/ | `Sidebar.tsx` | Conversation history list | | `StepResultViewer.tsx` | Per-step result viewer (collapsible inline) | | `ProgressSpinner.tsx` | Loading spinner (active; `LoadingSpinner.tsx` was removed) | -| `MCPServerManger.tsx` | MCP server status/management UI — shows all servers (builtins + user-added), transport badge, tools count, lock icon for builtins, delete for user-added servers; transport selector (Streamable HTTP / SSE / stdio) with context-aware form fields in add form | +| `MCPServerManger.tsx` | **Dead file — awaiting `rm`.** Typo of `MCPServerManager`. Never imported anywhere. | +| `MCPServerManager.tsx` | **Dead file — awaiting `rm`.** Correct spelling but also never imported. MCP server UI is inlined in `Sidebar.tsx`. | | `DocsViewer.tsx` | Full-screen modal overlay: left sidebar lists all `docs/*.md` files (auto-loaded on open, auto-selects first), right pane renders selected doc as markdown using `react-markdown` + `remark-gfm` + Tailwind `prose`. Closes on Escape or backdrop click. Opened by "Docs" button in `App.tsx` top nav bar. | ### Key Hooks (`src/hooks/`) diff --git a/src/App.tsx b/src/App.tsx index 0e7cb50..ae32c0e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,10 +15,6 @@ import { apiClient } from './api/client'; const WS_URL = import.meta.env.VITE_WS_URL || `${location.protocol.replace('http', 'ws')}//${location.host}/ws/research`; -// localStorage keys — survive browser close so the session can be resumed. -const LS_SESSION_ID = 'deep_research_session_id'; -const LS_CONV_ID = 'deep_research_conv_id'; - /** * crypto.randomUUID() requires a secure context (https or localhost). * Fall back to a simple pseudo-random id on plain http (e.g. LAN dev). @@ -51,16 +47,27 @@ const AppContent: React.FC = () => { setPlanStatus, setEventIndex, getEventIndex, + setSessionId, + setSessionState, + findConvBySessionId, + onActivateSession, } = useApp(); const [inputMode, setInputMode] = useState<'research' | 'chat'>('research'); - // Tracks which conversation owns the currently running research session. - // Pre-populated from localStorage so a page refresh (or browser re-open) - // can resume seamlessly. - const researchConvIdRef = useRef(localStorage.getItem(LS_CONV_ID)); - // Tracks the active backend session_id so we can resume after a disconnect. - // Pre-populated from localStorage so it survives browser close. - const activeSessionIdRef = useRef(localStorage.getItem(LS_SESSION_ID)); + // P0-1: session state lives on the ``Conversation`` record itself (see + // ``types/conversation.ts``). Two refs track transient state while events + // are being routed. + // + // ``pendingQueryConvIdRef`` — the conversation whose ``query`` was just + // sent. The next ``session_created`` reply is bound to this conv so + // its ``sessionId`` is set correctly. Cleared once bound. + // + // ``currentSessionIdRef`` — the session whose events are currently + // flowing over this WebSocket. Set by ``session_created`` and + // ``session_resumed`` handlers. Every non-control event is routed to + // whichever conversation carries this session id. + const pendingQueryConvIdRef = useRef(null); + const currentSessionIdRef = useRef(null); // Counts how many replay events are still outstanding after a resume. // addMessageToConv calls are suppressed while this is > 0 to prevent // duplicating messages already persisted in localStorage conversation history. @@ -124,26 +131,45 @@ const AppContent: React.FC = () => { // disconnect. If a session was active we ask the backend to resume it so // the user picks up exactly where they left off. const handleReconnected = useCallback(() => { - const sessionId = activeSessionIdRef.current; + const sessionId = currentSessionIdRef.current; if (!sessionId) return; console.log('[ws] Re-connected — resuming session', sessionId); sendMessageRef.current?.({ type: 'resume', session_id: sessionId }); }, []); - // Called by useWebSocket on the very first connection (i.e. page load / hard - // refresh). If a session_id was persisted from before the refresh we ask - // the backend to resume it, which causes the full replay log to be streamed - // back so the UI can restore its live state. + // Called by useWebSocket on the very first connection (i.e. page load / + // hard refresh). Look at the active conversation and, if it has a live + // backend session persisted from before the refresh, resume it. const handleInitialConnect = useCallback(() => { - const sessionId = localStorage.getItem(LS_SESSION_ID); - if (!sessionId) return; - console.log('[ws] Initial connect — resuming stored session', sessionId); - sendMessageRef.current?.({ type: 'resume', session_id: sessionId }); + // Find the most recently updated conversation that still has a live + // sessionId — this covers both the "user hard-refreshed while research + // was running" case and the "user closed the tab" case. + const liveStates = new Set(['planning', 'awaiting_approval', 'executing']); + // Fall back to any conv with a sessionId if none have an explicit + // live state (e.g. an old cache that predates the sessionState field). + const sorted = [...conversationsRef.current].sort( + (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + ); + const target = + sorted.find((c) => c.sessionId && liveStates.has(c.sessionState ?? '')) ?? + sorted.find((c) => c.sessionId); + if (!target?.sessionId) return; + console.log('[ws] Initial connect — resuming stored session', target.sessionId); + currentSessionIdRef.current = target.sessionId; + sendMessageRef.current?.({ type: 'resume', session_id: target.sessionId }); }, []); // Needed so handleReconnected can call sendMessage before it's assigned. const sendMessageRef = useRef<((msg: any) => void) | null>(null); + // Ref mirror of ``conversations`` so ``handleInitialConnect`` (called + // once, before the first render commits) can look at the latest list + // without being re-created on every conversations change. + const conversationsRef = useRef(conversations); + useEffect(() => { + conversationsRef.current = conversations; + }, [conversations]); + const { isConnected, isReconnecting, @@ -159,6 +185,20 @@ const AppContent: React.FC = () => { sendMessageRef.current = sendMessage; }, [sendMessage]); + // Register the session-activation bridge for ``selectConversation`` (see + // AppContext). When the user picks a conversation with a live session, + // this fires a ``resume`` message so the backend switches its drain over. + useEffect(() => { + onActivateSession.current = (sessionId: string) => { + if (currentSessionIdRef.current === sessionId) return; + currentSessionIdRef.current = sessionId; + sendMessage({ type: 'resume', session_id: sessionId }); + }; + return () => { + onActivateSession.current = null; + }; + }, [sendMessage, onActivateSession]); + // ── Research control (pause / resume / stop) ───────────────────────────── const { researchStatus, @@ -200,6 +240,15 @@ const AppContent: React.FC = () => { return; } + // Resolve which conversation owns the session these events belong to. + // Everything after ``session_created`` / ``session_resumed`` is routed + // by matching ``currentSessionIdRef`` against the per-conversation + // ``sessionId`` field. ``owningConvId`` may be ``null`` when the very + // first control event arrives (before we've bound the session to a + // conversation) — the switch statement handles that explicitly. + const activeSid = currentSessionIdRef.current; + const owningConvId = activeSid ? findConvBySessionId(activeSid) : null; + // Track event index: every non-control event from the backend // (whether from the initial stream or from a replay drain) counts // as one event. We use this to know how many events are already @@ -225,40 +274,51 @@ const AppContent: React.FC = () => { switch (type) { // ── Session lifecycle ────────────────────────────────────────────── - case 'session_created': - // Store the session_id + conv_id in localStorage so we can send a - // resume message on reconnect, hard refresh, or even browser re-open. - activeSessionIdRef.current = rawMessage.session_id ?? null; - if (rawMessage.session_id) { - localStorage.setItem(LS_SESSION_ID, rawMessage.session_id); - } - // Reset the event index for this brand-new session - activeEventIndexRef.current = 0; - if (researchConvIdRef.current) { - setEventIndex(researchConvIdRef.current, 0); + case 'session_created': { + // Bind the freshly-created backend session to whichever conversation + // just issued the ``query`` (tracked in ``pendingQueryConvIdRef``). + const sid = rawMessage.session_id as string | undefined; + const convId = pendingQueryConvIdRef.current; + pendingQueryConvIdRef.current = null; + if (sid && convId) { + currentSessionIdRef.current = sid; + setSessionId(convId, sid); + setSessionState(convId, 'planning'); + activeEventIndexRef.current = 0; + setEventIndex(convId, 0); + console.log('[ws] Session created:', sid, '→ conv', convId); + } else { + console.warn( + '[ws] session_created without pending conv id', + { sid, convId } + ); } - console.log('[ws] Session created:', rawMessage.session_id); break; + } case 'session_resumed': { + const sid = rawMessage.session_id as string | undefined; const totalEvents = rawMessage.event_count ?? 0; - console.log('[ws] Session resumed:', rawMessage.session_id, '— total events:', totalEvents); - // Restore session refs that may have been lost on a hard page refresh - // or browser re-open. - if (rawMessage.session_id) { - activeSessionIdRef.current = rawMessage.session_id; + console.log('[ws] Session resumed:', sid, '— total events:', totalEvents); + if (sid) { + currentSessionIdRef.current = sid; } - const storedConvId = localStorage.getItem(LS_CONV_ID); - if (!researchConvIdRef.current && storedConvId) { - researchConvIdRef.current = storedConvId; + // Locate the conversation that owns this session. On a hard-refresh + // resume the sessionId is already stored on the conversation, so + // ``findConvBySessionId`` succeeds. If we can't find one, the + // session is orphaned (e.g. localStorage was cleared) — log and + // drop the events on the floor rather than corrupting an unrelated + // conversation. + const resumedConvId = sid ? findConvBySessionId(sid) : null; + if (!resumedConvId) { + console.warn('[ws] session_resumed for unknown session id', sid); + break; } // Decide how many replay events to suppress. Events with an index // below the storedEventIndex are already in localStorage and should // NOT produce duplicate messages. Events above it are new (emitted // while the browser was closed) and MUST flow through normally. - const storedIndex = researchConvIdRef.current - ? getEventIndex(researchConvIdRef.current) - : 0; + const storedIndex = getEventIndex(resumedConvId); const eventsToSuppress = Math.min(storedIndex, totalEvents); replayCountRef.current = eventsToSuppress; replayTotalRef.current = totalEvents; @@ -266,23 +326,19 @@ const AppContent: React.FC = () => { setCurrentStatus( rawMessage.complete ? 'Research Complete' : 'Reconnected — resuming…' ); - // Keep the researching flag true during resume if the session is - // still live (so the UI shows the spinner / control bar). if (!rawMessage.complete) { setIsResearching(true); } // Notify the user that we've reconnected successfully. - if (researchConvIdRef.current) { - addMessageToConv(researchConvIdRef.current, { - id: generateId(), - role: 'system', - type: 'system', - content: rawMessage.complete - ? '🔄 Reconnected. Research had already finished — replaying results.' - : '🔄 Reconnected to backend. Resuming research in progress…', - timestamp: new Date(), - }); - } + addMessageToConv(resumedConvId, { + id: generateId(), + role: 'system', + type: 'system', + content: rawMessage.complete + ? '🔄 Reconnected. Research had already finished — replaying results.' + : '🔄 Reconnected to backend. Resuming research in progress…', + timestamp: new Date(), + }); break; } @@ -290,9 +346,11 @@ const AppContent: React.FC = () => { console.log('Status update:', message); setCurrentStatus(message || data?.message || 'Researching'); + if (!owningConvId) break; + // Graph: explicit batch data from the v2 orchestrator if (data?.batches) { - overrideBatches(researchConvIdRef.current!, data.batches); + overrideBatches(owningConvId, data.batches); } // Graph: parse sub-agent progress lines emitted by the orchestrator if (message) { @@ -300,31 +358,31 @@ const AppContent: React.FC = () => { // Matches both initial gather and targeted re-search const searchMatch = message.match(/\[Search\].*step\s+(\d+)/i); if (searchMatch) { - updateSubAgent(researchConvIdRef.current!, parseInt(searchMatch[1], 10), 'search', 'running'); + updateSubAgent(owningConvId, parseInt(searchMatch[1], 10), 'search', 'running'); } // [Analyst] any message for step N → search completed, analyst running // Matches both "Extracting claims" and "Re-extracting claims" const analystMatch = message.match(/\[Analyst\].*step\s+(\d+)/i); if (analystMatch) { - updateSubAgent(researchConvIdRef.current!, parseInt(analystMatch[1], 10), 'search', 'completed'); - updateSubAgent(researchConvIdRef.current!, parseInt(analystMatch[1], 10), 'analyst', 'running'); + updateSubAgent(owningConvId, parseInt(analystMatch[1], 10), 'search', 'completed'); + updateSubAgent(owningConvId, parseInt(analystMatch[1], 10), 'analyst', 'running'); } // [QA] Auditing → analyst completed, QA running const qaAuditMatch = message.match(/\[QA\] Auditing.*step\s+(\d+)/i); if (qaAuditMatch) { - updateSubAgent(researchConvIdRef.current!, parseInt(qaAuditMatch[1], 10), 'analyst', 'completed'); - updateSubAgent(researchConvIdRef.current!, parseInt(qaAuditMatch[1], 10), 'qa', 'running'); + updateSubAgent(owningConvId, parseInt(qaAuditMatch[1], 10), 'analyst', 'completed'); + updateSubAgent(owningConvId, parseInt(qaAuditMatch[1], 10), 'qa', 'running'); } // [QA] contradiction(s) flagged → attach contradiction data const qaFlagMatch = message.match(/\[QA\].*contradiction.*step\s+(\d+)/i); if (qaFlagMatch) { - addContradictions(researchConvIdRef.current!, parseInt(qaFlagMatch[1], 10), data?.contradictions ?? []); + addContradictions(owningConvId, parseInt(qaFlagMatch[1], 10), data?.contradictions ?? []); } // [QA retry N/N] → increment retry badge; search/analyst transitions // are handled by the subsequent [Search] and [Analyst] messages above const qaRetryMatch = message.match(/\[QA retry.*?\].*step\s+(\d+)/i); if (qaRetryMatch) { - incrementQaRetries(researchConvIdRef.current!, parseInt(qaRetryMatch[1], 10)); + incrementQaRetries(owningConvId, parseInt(qaRetryMatch[1], 10)); } // Synthesis phases starting — synthesis node is already running // (set to running in the research_complete handler below) @@ -336,15 +394,13 @@ const AppContent: React.FC = () => { setCurrentStatus('Planning'); setIsResearching(false); setPlanStatus('pending'); - - // Build initial graph from the incoming plan only for live events; - // during replay the graph is already restored from localStorage. + if (!owningConvId) break; if (plan && !isReplayedEvent) { - initFromPlan(researchConvIdRef.current!, plan); + initFromPlan(owningConvId, plan); } - + setSessionState(owningConvId, 'awaiting_approval'); if (!isReplayedEvent) { - addMessageToConv(researchConvIdRef.current!, { + addMessageToConv(owningConvId, { id: generateId(), role: 'assistant', type: 'plan_approval', @@ -364,34 +420,39 @@ const AppContent: React.FC = () => { setPlanStatus('denied'); setIsResearching(false); setCurrentStatus('Plan Denied'); - activeSessionIdRef.current = null; - localStorage.removeItem(LS_SESSION_ID); - localStorage.removeItem(LS_CONV_ID); - resetResearch(); - resetGraph(researchConvIdRef.current!); - - if (!isReplayedEvent) { - addMessageToConv(researchConvIdRef.current!, { - id: generateId(), - role: 'assistant', - type: 'system', - content: 'The research plan was denied. Please submit a new query to start again.', - timestamp: new Date(), - }); + if (owningConvId) { + setSessionState(owningConvId, 'denied'); + setSessionId(owningConvId, null); + resetGraph(owningConvId); + if (!isReplayedEvent) { + addMessageToConv(owningConvId, { + id: generateId(), + role: 'assistant', + type: 'system', + content: 'The research plan was denied. Please submit a new query to start again.', + timestamp: new Date(), + }); + } } + currentSessionIdRef.current = null; + resetResearch(); break; case 'step_start': console.log('Starting step:', data?.step); setIsResearching(true); setCurrentStatus('Starting Step: ' + (data?.step?.name || 'Unknown Step')); + if (!owningConvId) break; + // First step_start also marks the session as executing so the state + // survives a restart and drives the recovery path in the backend. + setSessionState(owningConvId, 'executing'); // Graph: mark this node as running if (data?.step?.id != null) { - setNodeRunning(researchConvIdRef.current!, data.step.id); + setNodeRunning(owningConvId, data.step.id); } if (!isReplayedEvent) { - addMessageToConv(researchConvIdRef.current!, { + addMessageToConv(owningConvId, { id: generateId(), role: 'assistant', type: 'step_start', @@ -405,17 +466,18 @@ const AppContent: React.FC = () => { case 'step_complete': console.log('Completed step:', data?.step); setCurrentStatus('Step Completed: ' + (data?.step?.name || 'Unknown Step')); + if (!owningConvId) break; // Graph: mark node completed, attach result & tools if (data?.step?.id != null) { setNodeCompleted( - researchConvIdRef.current!, + owningConvId, data.step.id, data.step.result, Array.isArray(data.tools_used) ? data.tools_used : undefined ); } if (!isReplayedEvent) { - addMessageToConv(researchConvIdRef.current!, { + addMessageToConv(owningConvId, { id: generateId(), role: 'assistant', type: 'step_complete', @@ -433,12 +495,13 @@ const AppContent: React.FC = () => { const stepError = rawMessage.error; console.log('Step failed:', message, 'Error:', stepError); setCurrentStatus('Step Failed'); + if (!owningConvId) break; // Graph: mark node failed if (data?.step?.id != null) { - setNodeFailed(researchConvIdRef.current!, data.step.id, stepError ?? undefined); + setNodeFailed(owningConvId, data.step.id, stepError ?? undefined); } if (!isReplayedEvent) { - addMessageToConv(researchConvIdRef.current!, { + addMessageToConv(owningConvId, { id: generateId(), role: 'assistant', type: 'step_failed', @@ -460,17 +523,18 @@ const AppContent: React.FC = () => { console.log('Research complete:', data); setIsResearching(false); setCurrentStatus('Complete'); + if (!owningConvId) break; // Freeze the timer — pipeline is done - setResearchEndTime(researchConvIdRef.current!, new Date().toISOString()); + setResearchEndTime(owningConvId, new Date().toISOString()); // Add the synthesis node and mark it running only for live events; // during replay the graph node already exists in localStorage. if (!isReplayedEvent) { - addSynthesisNode(researchConvIdRef.current!); - setNodeRunning(researchConvIdRef.current!, SYNTHESIS_NODE_ID); + addSynthesisNode(owningConvId); + setNodeRunning(owningConvId, SYNTHESIS_NODE_ID); } - // Session is still alive until 'report' arrives; keep activeSessionIdRef. + // Session stays alive (state="executing") until 'report' arrives. if (!isReplayedEvent) { - addMessageToConv(researchConvIdRef.current!, { + addMessageToConv(owningConvId, { id: generateId(), role: 'assistant', type: 'research_complete', @@ -493,8 +557,8 @@ const AppContent: React.FC = () => { if (data?.creative_applications?.length) synthParts.push(`## Creative Applications\n${(data.creative_applications as string[]).map((a: string) => `- ${a}`).join('\n')}`); if (data?.knowledge_gaps?.length) synthParts.push(`## Knowledge Gaps\n${(data.knowledge_gaps as string[]).map((g: string) => `- ${g}`).join('\n')}`); const formattedSummary = synthParts.join('\n\n') || data?.summary || message || 'No synthesis data available.'; - if (!isReplayedEvent) { - addMessageToConv(researchConvIdRef.current!, { + if (!isReplayedEvent && owningConvId) { + addMessageToConv(owningConvId, { id: generateId(), role: 'assistant', type: 'synthesis', @@ -521,16 +585,16 @@ const AppContent: React.FC = () => { console.error('Error received:', message, data); setIsResearching(false); setCurrentStatus('Step Failed'); - // If the error came from a failed resume attempt, clear the stored - // session so we don't keep retrying on the next page load. - localStorage.removeItem(LS_SESSION_ID); - localStorage.removeItem(LS_CONV_ID); - activeSessionIdRef.current = null; - if (researchConvIdRef.current) { - setResearchEndTime(researchConvIdRef.current, new Date().toISOString()); + // Terminal state — clear the session binding for the owning conv so + // future page loads don't attempt to resume a finished session. + if (owningConvId) { + setSessionState(owningConvId, 'error'); + setSessionId(owningConvId, null); + setResearchEndTime(owningConvId, new Date().toISOString()); } - if (!isReplayedEvent && researchConvIdRef.current) { - addMessageToConv(researchConvIdRef.current, { + currentSessionIdRef.current = null; + if (!isReplayedEvent && owningConvId) { + addMessageToConv(owningConvId, { id: generateId(), role: 'assistant', type: 'error', @@ -562,17 +626,18 @@ const AppContent: React.FC = () => { console.log('Received research report:', data); setIsResearching(false); setCurrentStatus('Report Ready'); - // Session is fully complete — clear stored IDs so future page loads - // do not attempt to resume a finished session. - activeSessionIdRef.current = null; - localStorage.removeItem(LS_SESSION_ID); - localStorage.removeItem(LS_CONV_ID); + if (!owningConvId) break; + // Terminal state — mark session complete and clear its id so future + // page loads don't try to resume a finished session. + setSessionState(owningConvId, 'complete'); + setSessionId(owningConvId, null); + currentSessionIdRef.current = null; // Mark the synthesis graph node as completed - setNodeCompleted(researchConvIdRef.current!, SYNTHESIS_NODE_ID); + setNodeCompleted(owningConvId, SYNTHESIS_NODE_ID); // Timer: mark end time - setResearchEndTime(researchConvIdRef.current!, new Date().toISOString()); + setResearchEndTime(owningConvId, new Date().toISOString()); if (!isReplayedEvent) { - addMessageToConv(researchConvIdRef.current!, { + addMessageToConv(owningConvId, { id: generateId(), role: 'assistant', type: 'report', @@ -606,12 +671,14 @@ const AppContent: React.FC = () => { console.log('Research stopped acknowledged:', message); setIsResearching(false); setCurrentStatus(''); - activeSessionIdRef.current = null; - localStorage.removeItem(LS_SESSION_ID); - localStorage.removeItem(LS_CONV_ID); - setResearchEndTime(researchConvIdRef.current!, new Date().toISOString()); + if (owningConvId) { + setSessionState(owningConvId, 'cancelled'); + setSessionId(owningConvId, null); + setResearchEndTime(owningConvId, new Date().toISOString()); + resetGraph(owningConvId); + } + currentSessionIdRef.current = null; resetResearch(); - resetGraph(researchConvIdRef.current!); break; // ── Self-optimization workflow ────────────────────────────────────── @@ -674,21 +741,20 @@ const AppContent: React.FC = () => { } // ── Post-batch bookkeeping ─────────────────────────────────────── - // If a replay was in progress and all events have been drained, - // persist the final total so future reconnects are accurate. + // Persist the event index against whichever conversation owns the + // session that just produced this event. Uses ``owningConvId`` (looked + // up once at the top of this effect) so bookkeeping stays consistent + // even after control events cleared ``currentSessionIdRef``. if ( pendingTotalEventsRef.current > 0 && replayTotalRef.current === 0 && - researchConvIdRef.current + owningConvId ) { - setEventIndex(researchConvIdRef.current, pendingTotalEventsRef.current); + setEventIndex(owningConvId, pendingTotalEventsRef.current); pendingTotalEventsRef.current = 0; } - - // Flush the running event index to conversation metadata every - // event so the persisted index is always up-to-date. - if (researchConvIdRef.current) { - setEventIndex(researchConvIdRef.current, activeEventIndexRef.current); + if (owningConvId) { + setEventIndex(owningConvId, activeEventIndexRef.current); } }, [ messageCount, @@ -715,7 +781,7 @@ const AppContent: React.FC = () => { updateOptimizePhase, getEventIndex, setEventIndex, - // researchConvIdRef / optimizeConvIdRef / replayTotalRef / + // currentSessionIdRef / pendingQueryConvIdRef / replayTotalRef / // pendingTotalEventsRef / activeEventIndexRef are refs; // no need to list them ]); @@ -764,8 +830,10 @@ const AppContent: React.FC = () => { const newConv = createConversation(); convId = newConv.id; } - researchConvIdRef.current = convId; - localStorage.setItem(LS_CONV_ID, convId); + // Track which conversation issued this query so the next + // ``session_created`` reply can be bound to it (see the handler for + // ``session_created`` above). This must be set BEFORE ``sendMessage``. + pendingQueryConvIdRef.current = convId; addMessageToConv(convId, { id: generateId(), @@ -843,9 +911,9 @@ const AppContent: React.FC = () => { }); // Graph: mark plan node as approved - updatePlanAction(researchConvIdRef.current!, 'approved'); + updatePlanAction(activeConversationId!, 'approved'); // Timer: record when execution started - setResearchStartTime(researchConvIdRef.current!, new Date().toISOString()); + setResearchStartTime(activeConversationId!, new Date().toISOString()); setPlanStatus('approved'); setIsResearching(true); @@ -879,7 +947,7 @@ const AppContent: React.FC = () => { }); // Graph: mark current plan node as being modified (amber / running) - updatePlanAction(researchConvIdRef.current!, 'modified'); + updatePlanAction(activeConversationId!, 'modified'); setPlanStatus('pending'); setIsResearching(true); @@ -910,7 +978,7 @@ const AppContent: React.FC = () => { }); // Graph: mark plan node as denied - updatePlanAction(researchConvIdRef.current!, 'denied'); + updatePlanAction(activeConversationId!, 'denied'); setPlanStatus('denied'); setIsResearching(false); diff --git a/src/components/__tests__/App.multisession.test.tsx b/src/components/__tests__/App.multisession.test.tsx new file mode 100644 index 0000000..e3ec7fc --- /dev/null +++ b/src/components/__tests__/App.multisession.test.tsx @@ -0,0 +1,290 @@ +/** + * App.multisession.test.tsx + * + * Covers the three multi-session routing invariants from DEFERRED.md §5c: + * + * 1. Status events tagged with session A's id update conversation A, not the + * currently-active conversation B. + * 2. Switching back to conversation A while it is still executing shows the + * live session state, not a stale snapshot. + * 3. `session_created` arriving after the user switched conversations still + * binds to the originating conversation (via `pendingQueryConvIdRef`). + * + * Strategy + * -------- + * `useWebSocket` is mocked at module level. A shared `wsState` object lets + * each test push synthetic messages into the queue and bump `messageCount` to + * trigger App's message-processing `useEffect`. Calling `rerender()` + * makes the component pick up the new `messageCount` from the mock and drain + * the queue. + * + * Assertions are made against `localStorage` (where `useConversations` + * persists conversation state) and against the DOM where convenient. + */ + +import React from 'react'; +import { render, act, waitFor } from '@testing-library/react'; +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import type { Conversation } from '../../types/conversation'; + +// --------------------------------------------------------------------------- +// Module-level WS mock state — shared across all tests in this file. +// --------------------------------------------------------------------------- +const wsState = { + messageCount: 0, + queue: [] as any[], + sendMessage: vi.fn(), +}; + +/** + * Push a message into the queue and bump messageCount so App's useEffect + * fires on the next render. + */ +function injectWsMessage(msg: any): void { + wsState.queue.push(msg); + wsState.messageCount += 1; +} + +// Mock useWebSocket BEFORE importing App so the module factory is in place. +vi.mock('../../hooks/useWebSocket', () => ({ + useWebSocket: () => ({ + isConnected: true, + isReconnecting: false, + sendMessage: wsState.sendMessage, + reconnect: vi.fn(), + drainMessageQueue: () => [], + shiftMessage: () => wsState.queue.shift(), + messageCount: wsState.messageCount, + }), +})); + +// --------------------------------------------------------------------------- +// Mock heavy components that are irrelevant to routing logic. +// --------------------------------------------------------------------------- +vi.mock('../../components/Sidebar', () => ({ + Sidebar: () => null, +})); +vi.mock('../../components/ChatContainer', () => ({ + ChatContainer: () => null, +})); +// ChatInput is replaced with a thin stub that calls the onSendMessage prop +// when a test fires the global helper `triggerSend`. This lets us simulate +// query submission without needing real DOM interaction. +let _onSend: ((msg: string) => void) | null = null; +export function triggerSend(msg: string) { + if (_onSend) _onSend(msg); +} +vi.mock('../../components/ChatInput', () => ({ + ChatInput: ({ onSendMessage }: { onSendMessage: (msg: string) => void }) => { + _onSend = onSendMessage; + return null; + }, +})); +vi.mock('../../components/ResearchControlBar', () => ({ + ResearchControlBar: () => null, +})); +vi.mock('../../components/GraphView', () => ({ + GraphView: () => null, + SYNTHESIS_NODE_ID: 'synthesis', +})); +vi.mock('../../hooks/useConversationGraphs', () => ({ + useConversationGraphs: () => ({ + getGraph: () => null, + addQueryNode: vi.fn(), + initFromPlan: vi.fn(), + updatePlanAction: vi.fn(), + overrideBatches: vi.fn(), + setNodeRunning: vi.fn(), + setNodeCompleted: vi.fn(), + setNodeFailed: vi.fn(), + updateSubAgent: vi.fn(), + incrementQaRetries: vi.fn(), + addContradictions: vi.fn(), + addSynthesisNode: vi.fn(), + resetGraph: vi.fn(), + deleteGraph: vi.fn(), + setResearchStartTime: vi.fn(), + setResearchEndTime: vi.fn(), + initOptimizeGraph: vi.fn(), + updateOptimizePhase: vi.fn(), + }), + SYNTHESIS_NODE_ID: 'synthesis', +})); +vi.mock('../../components/DocsViewer', () => ({ + DocsViewer: () => null, +})); +vi.mock('../../api/client', () => ({ + apiClient: { + streamChat: vi.fn(), + listDocs: vi.fn(() => Promise.resolve([])), + getDoc: vi.fn(() => Promise.resolve('')), + updateConfig: vi.fn(() => Promise.resolve({})), + listMcpServers: vi.fn(() => Promise.resolve([])), + }, +})); + +// --------------------------------------------------------------------------- +// Import App AFTER mocks are registered. +// --------------------------------------------------------------------------- +import App from '../../App'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- +const STORAGE_KEY = 'deep_research_conversations'; +const ACTIVE_KEY = 'deep_research_active_conversation'; + +function makeConv(overrides: Partial = {}): Conversation { + const id = overrides.id ?? `conv-${Math.random().toString(36).slice(2)}`; + return { + id, + title: 'Test conv', + createdAt: new Date(), + updatedAt: new Date(), + messages: [], + ...overrides, + }; +} + +function seedLocalStorage(conv1: Conversation, conv2: Conversation, activeId: string): void { + localStorage.setItem(STORAGE_KEY, JSON.stringify([conv1, conv2])); + localStorage.setItem(ACTIVE_KEY, activeId); +} + +function readConversations(): Conversation[] { + const raw = localStorage.getItem(STORAGE_KEY); + return raw ? JSON.parse(raw) : []; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- +describe('App multi-session routing', () => { + beforeEach(() => { + // Reset WS state and localStorage before each test. + wsState.messageCount = 0; + wsState.queue = []; + wsState.sendMessage.mockReset(); + localStorage.clear(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + // ───────────────────────────────────────────────────────────────────────── + // Scenario 1 — events for session A must update conv A, not the active conv B + // ───────────────────────────────────────────────────────────────────────── + it('routes status events to the conversation that owns the session, not the active one', async () => { + const conv1 = makeConv({ id: 'conv-1', sessionId: 'sid-A', sessionState: 'executing' }); + const conv2 = makeConv({ id: 'conv-2', sessionId: 'sid-B', sessionState: 'executing' }); + // conv2 is active; events belong to conv1's session. + seedLocalStorage(conv1, conv2, conv2.id); + + const { rerender } = render(); + + // Step 1: resume session A so App's currentSessionIdRef is set to 'sid-A'. + injectWsMessage({ type: 'session_resumed', session_id: 'sid-A', event_count: 0, complete: false }); + rerender(); + + await waitFor(() => { + const convs = readConversations(); + const c1 = convs.find((c) => c.id === 'conv-1'); + // session_resumed adds a system reconnect message to conv1 + return c1 && c1.messages.length >= 1; + }); + + // Step 2: inject a status event — should land in conv1, not conv2. + const before2 = readConversations().find((c) => c.id === 'conv-2')?.messages.length ?? 0; + + injectWsMessage({ type: 'status', message: 'Searching for evidence…' }); + rerender(); + + await waitFor(() => { + const convs = readConversations(); + const c1 = convs.find((c) => c.id === 'conv-1'); + return c1 && c1.messages.some((m: any) => m.content === 'Searching for evidence…'); + }); + + // conv2 must not have received the status message. + const after2 = readConversations().find((c) => c.id === 'conv-2')?.messages.length ?? 0; + expect(after2).toBe(before2); + }); + + // ───────────────────────────────────────────────────────────────────────── + // Scenario 2 — switching back to conv A shows live session state + // ───────────────────────────────────────────────────────────────────────── + it('persists session state on the conversation so a switch back reflects live status', async () => { + const conv1 = makeConv({ id: 'conv-1', sessionId: 'sid-A', sessionState: 'executing' }); + const conv2 = makeConv({ id: 'conv-2' }); + seedLocalStorage(conv1, conv2, conv2.id); + + const { rerender } = render(); + + // Simulate events arriving for conv1's session while conv2 is active. + injectWsMessage({ type: 'session_resumed', session_id: 'sid-A', event_count: 0, complete: false }); + rerender(); + + await waitFor(() => { + const convs = readConversations(); + const c1 = convs.find((c) => c.id === 'conv-1'); + return c1 && c1.messages.length >= 1; + }); + + // A step_start event arriving in the background must update conv1. + injectWsMessage({ + type: 'step_start', + data: { step: { id: 1, name: 'Search', description: 'Web search', status: 'running' } }, + }); + rerender(); + + await waitFor(() => { + const convs = readConversations(); + const c1 = convs.find((c) => c.id === 'conv-1'); + return c1 && c1.messages.some((m: any) => m.type === 'step_start'); + }); + + // After the switch the conversation's persisted state reflects the event. + const c1Final = readConversations().find((c) => c.id === 'conv-1'); + expect(c1Final?.messages.some((m: any) => m.type === 'step_start')).toBe(true); + }); + + // ───────────────────────────────────────────────────────────────────────── + // Scenario 3 — session_created binds to the originating conversation even + // after the user switches to a different conversation + // ───────────────────────────────────────────────────────────────────────── + it('binds session_created to the originating conversation via pendingQueryConvIdRef', async () => { + // Start with one conversation (the one that will issue the query). + const conv1 = makeConv({ id: 'conv-1' }); + seedLocalStorage(conv1, conv1 /* filler – only one */, conv1.id); + localStorage.setItem(STORAGE_KEY, JSON.stringify([conv1])); + + const { rerender } = render(); + + // Wait for App to load localStorage state so conv1 is in React state. + await waitFor(() => { + expect(localStorage.getItem(ACTIVE_KEY)).toBe(conv1.id); + }); + + // Simulate the user submitting a research query from conv1. + // This sets pendingQueryConvIdRef = conv1.id inside App. + act(() => { + triggerSend('What is the future of AI?'); + }); + + // The backend replies with session_created for a new session id. + injectWsMessage({ type: 'session_created', session_id: 'sid-new' }); + rerender(); + + // session_created must bind 'sid-new' to conv1 (the originating conv). + await waitFor(() => { + const convs = readConversations(); + const c1 = convs.find((c) => c.id === conv1.id); + return c1 && c1.sessionId === 'sid-new'; + }); + + const convs = readConversations(); + const c1 = convs.find((c) => c.id === conv1.id); + expect(c1?.sessionId).toBe('sid-new'); + }); +}); diff --git a/src/contexts/AppContext.tsx b/src/contexts/AppContext.tsx index 57626fc..f76e438 100644 --- a/src/contexts/AppContext.tsx +++ b/src/contexts/AppContext.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react'; +import React, { createContext, useContext, useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { Conversation, Message } from '../types/conversation'; import { useConversations } from '../hooks/useConversations'; @@ -33,6 +33,16 @@ interface AppContextType { // Event index tracking (for accurate replay after browser close) setEventIndex: (convId: string, index: number) => void; getEventIndex: (convId: string) => number; + // Per-conversation session tracking (P0-1 fix — replaces singleton + // ``deep_research_session_id`` localStorage key so multiple conversations + // can hold independent backend sessions). + setSessionId: (convId: string, sessionId: string | null) => void; + setSessionState: (convId: string, state: Conversation['sessionState']) => void; + findConvBySessionId: (sessionId: string) => string | null; + // A callback registered by the WebSocket layer that switches the backend's + // active drain to a given session id. Called by ``selectConversation`` so + // switching conversations transparently resumes the correct session. + onActivateSession: React.MutableRefObject<((sessionId: string) => void) | null>; } const AppContext = createContext(undefined); @@ -51,8 +61,18 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children getActiveConversation, setEventIndex, getEventIndex, + setSessionId, + setSessionState, + findConvBySessionId, } = useConversations(); + // ── Session activation bridge (P0-1) ────────────────────────────────── + // The WebSocket layer registers a handler here that sends a ``resume`` + // message for a given session id. ``selectConversation`` invokes it + // when the newly-active conversation has a live session so the backend + // switches its drain to the right pipeline. + const onActivateSession = useRef<((sessionId: string) => void) | null>(null); + const addMessageToConv = useCallback( (convId: string, msg: Message) => addMessageToConversation(convId, msg), [addMessageToConversation] @@ -102,11 +122,28 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children const selectConversation = useCallback( (id: string) => { setActiveConversationId(id); - setIsResearching(false); - setPendingPlan(null); - setPlanStatus('none'); + const target = conversations.find((c) => c.id === id); + const liveStates: Array = [ + 'planning', + 'awaiting_approval', + 'executing', + ]; + // If the selected conversation still has a running backend session, + // ask the WebSocket layer to resume it so its events start flowing + // again. Reset the local plan / researching flags first — the + // ``session_resumed`` reply will re-populate them. + if (target?.sessionId && liveStates.includes(target.sessionState)) { + setIsResearching(false); + setPendingPlan(null); + setPlanStatus('none'); + onActivateSession.current?.(target.sessionId); + } else { + setIsResearching(false); + setPendingPlan(null); + setPlanStatus('none'); + } }, - [setActiveConversationId] + [conversations, setActiveConversationId] ); const deleteConversation = useCallback( @@ -123,6 +160,10 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children ); const startNewChat = useCallback(() => { + // Creating a new conversation must NOT stop any running research in the + // previous conversation — the previous session keeps its ``sessionId`` + // and can be resumed by re-selecting it. We only reset the *local* + // plan / researching UI flags for the new (empty) conversation. createConversation(); setIsResearching(false); setPendingPlan(null); @@ -164,6 +205,10 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children setPlanStatus, setEventIndex, getEventIndex, + setSessionId, + setSessionState, + findConvBySessionId, + onActivateSession, }), [ messages, addMessage, updateMessage, createConversation, addMessageToConv, updateMessageInConv, patchMessageInConv, @@ -172,6 +217,7 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children startNewChat, renameConv, deleteConversation, pendingPlan, setPendingPlan, planStatus, setPlanStatus, setEventIndex, getEventIndex, + setSessionId, setSessionState, findConvBySessionId, ]); return ( diff --git a/src/hooks/useConversations.ts b/src/hooks/useConversations.ts index ee47be0..a647600 100644 --- a/src/hooks/useConversations.ts +++ b/src/hooks/useConversations.ts @@ -165,6 +165,46 @@ export function useConversations() { [conversations] ); + const setSessionId = useCallback( + (conversationId: string, sessionId: string | null) => { + setConversations((prev) => + prev.map((c) => + c.id === conversationId + ? { + ...c, + sessionId: sessionId ?? undefined, + updatedAt: new Date(), + } + : c + ) + ); + }, + [] + ); + + const setSessionState = useCallback( + (conversationId: string, state: Conversation['sessionState']) => { + setConversations((prev) => + prev.map((c) => + c.id === conversationId + ? { ...c, sessionState: state, updatedAt: new Date() } + : c + ) + ); + }, + [] + ); + + /** Return the id of the conversation currently associated with the given + * backend session, or ``null`` if no conversation owns that session. */ + const findConvBySessionId = useCallback( + (sessionId: string): string | null => { + const match = conversations.find((c) => c.sessionId === sessionId); + return match?.id ?? null; + }, + [conversations] + ); + return { conversations, activeConversationId, @@ -178,5 +218,8 @@ export function useConversations() { updateMessageInConversation, setEventIndex, getEventIndex, + setSessionId, + setSessionState, + findConvBySessionId, }; } \ No newline at end of file diff --git a/src/package-lock.json b/src/package-lock.json index 4385d96..b631d59 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -20,6 +20,9 @@ "devDependencies": { "@tailwindcss/forms": "^0.5.7", "@tailwindcss/typography": "^0.5.10", + "@testing-library/dom": "^10.4.2", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", "@types/node": "^20.11.0", "@types/react": "^18.2.42", "@types/react-dom": "^18.2.17", @@ -30,13 +33,21 @@ "eslint": "^8.57.0", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.5", + "jsdom": "^29.1.1", "postcss": "^8.4.35", "tailwindcss": "^3.4.1", "typescript": "^5.2.2", "vite": "^7.3.1", - "vitest": "^1.0.4" + "vitest": "^3.2.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", @@ -50,6 +61,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", @@ -81,7 +143,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -362,6 +423,159 @@ "node": ">=6.9.0" } }, + "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.1.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz", + "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==", + "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.4.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.1.tgz", + "integrity": "sha512-EtC7SoN1j6J4E4DCwg5QgbO5TGxgxIA1RXqe+W+qUM+BUcezx9wT+/tiQ/WO2yCX4i5X+Cuf9ciJ22aP4UEwWw==", + "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.1", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.4.tgz", + "integrity": "sha512-DyefytAZ735mX4Dq/WcDAXFtXhaEFvme0ZS9tVEBAc2whxUthXr0R0L2rmEPm59SrMBkGrFzQviBXMs/UtnABQ==", + "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.1.2", + "@csstools/css-calc": "^3.4.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.1", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.1.tgz", + "integrity": "sha512-ShL8BqPfbKJrJiKFH0xBbN0i7Nrh9HXYRuF+pzyj93R/BL2YAsUxJeqANErzqM+0JGl7vjHp+3OIgd/DL69YIA==", + "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.2" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz", + "integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==", + "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.2", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz", + "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==", + "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.28.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", @@ -891,6 +1105,24 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", + "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/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -953,19 +1185,6 @@ "dev": true, "license": "BSD-3-Clause" }, - "node_modules/@jest/schemas": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", - "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sinclair/typebox": "^0.27.8" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -998,18 +1217,6 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/source-map": { - "version": "0.3.11", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", - "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25" - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -1847,13 +2054,6 @@ "win32" ] }, - "node_modules/@sinclair/typebox": { - "version": "0.27.10", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", - "dev": true, - "license": "MIT" - }, "node_modules/@swc/helpers": { "version": "0.5.19", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.19.tgz", @@ -1889,17 +2089,97 @@ "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, - "node_modules/@tootallnate/once": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", - "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", + "node_modules/@testing-library/dom": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", "dev": true, "license": "MIT", - "optional": 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": ">= 6" + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", + "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", + "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": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + } + }, + "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.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "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/@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" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1945,6 +2225,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", @@ -1954,6 +2245,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", @@ -1999,7 +2297,6 @@ "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2008,14 +2305,15 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.28", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -2077,7 +2375,6 @@ "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/types": "7.18.0", @@ -2259,197 +2556,120 @@ } }, "node_modules/@vitest/expect": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", - "integrity": "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "1.6.1", - "@vitest/utils": "1.6.1", - "chai": "^4.3.10" + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/runner": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.1.tgz", - "integrity": "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==", + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "1.6.1", - "p-limit": "^5.0.0", - "pathe": "^1.1.1" + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" }, "funding": { "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner/node_modules/p-limit": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", - "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^1.0.0" }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@vitest/runner/node_modules/yocto-queue": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", - "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.20" + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } } }, - "node_modules/@vitest/snapshot": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.1.tgz", - "integrity": "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==", + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", "dev": true, "license": "MIT", "dependencies": { - "magic-string": "^0.30.5", - "pathe": "^1.1.1", - "pretty-format": "^29.7.0" + "tinyrainbow": "^2.0.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/snapshot/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==", + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", "dev": true, "license": "MIT", - "engines": { - "node": ">=10" + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/snapshot/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/snapshot/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, "node_modules/@vitest/spy": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.1.tgz", - "integrity": "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", "dev": true, "license": "MIT", "dependencies": { - "tinyspy": "^2.2.0" + "tinyspy": "^4.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.1.tgz", - "integrity": "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", "dev": true, "license": "MIT", "dependencies": { - "diff-sequences": "^29.6.3", - "estree-walker": "^3.0.3", - "loupe": "^2.3.7", - "pretty-format": "^29.7.0" + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/utils/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", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@vitest/utils/node_modules/diff-sequences": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", - "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@vitest/utils/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@vitest/utils/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, - "node_modules/abab": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", - "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", - "deprecated": "Use your platform's native atob() and btoa() methods instead", - "dev": true, - "license": "BSD-3-Clause", - "optional": true - }, "node_modules/abs-svg-path": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz", @@ -2462,33 +2682,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-globals": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-6.0.0.tgz", - "integrity": "sha512-ZQl7LOWaF5ePqqcX4hLuv/bLXYQNfNWw2c0/yX/TsPRKamzHcTGQnlCjHT3TsmkOUVEPS3crCxiPfdzE/Trlhg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "acorn": "^7.1.1", - "acorn-walk": "^7.1.1" - } - }, - "node_modules/acorn-globals/node_modules/acorn": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", - "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", - "dev": true, - "license": "MIT", - "optional": true, "bin": { "acorn": "bin/acorn" }, @@ -2506,38 +2699,12 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/acorn-walk": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.2.0.tgz", - "integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -2610,6 +2777,16 @@ "dev": true, "license": "Python-2.0" }, + "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/array-union": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", @@ -2621,23 +2798,15 @@ } }, "node_modules/assertion-error": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", - "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "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": "*" + "node": ">=12" } }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/autoprefixer": { "version": "10.4.24", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz", @@ -2776,14 +2945,6 @@ "base64-js": "^1.1.2" } }, - "node_modules/browser-process-hrtime": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz", - "integrity": "sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true - }, "node_modules/browserify-zlib": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz", @@ -2813,7 +2974,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2828,14 +2988,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -2846,21 +2998,6 @@ "node": ">=8" } }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -2913,32 +3050,20 @@ } }, "node_modules/chai": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", - "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", "dev": true, "license": "MIT", "dependencies": { - "assertion-error": "^1.1.0", - "check-error": "^1.0.3", - "deep-eql": "^4.1.3", - "get-func-name": "^2.0.2", - "loupe": "^2.3.6", - "pathval": "^1.1.1", - "type-detect": "^4.1.0" + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" }, "engines": { - "node": ">=4" - } - }, - "node_modules/chai/node_modules/type-detect": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", - "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" + "node": ">=18" } }, "node_modules/chalk": { @@ -2999,16 +3124,13 @@ } }, "node_modules/check-error": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", - "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", "dev": true, "license": "MIT", - "dependencies": { - "get-func-name": "^2.0.2" - }, "engines": { - "node": "*" + "node": ">= 16" } }, "node_modules/chokidar": { @@ -3096,20 +3218,6 @@ "simple-swizzle": "^0.2.2" } }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/comma-separated-tokens": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", @@ -3127,13 +3235,6 @@ "dev": true, "license": "MIT" }, - "node_modules/confbox": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", - "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", - "dev": true, - "license": "MIT" - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -3162,69 +3263,59 @@ "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", "license": "MIT" }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "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", - "bin": { - "cssesc": "bin/cssesc" + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" }, "engines": { - "node": ">=4" + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, - "node_modules/cssom": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.4.4.tgz", - "integrity": "sha512-p3pvU7r1MyyqbTk+WbNJIgJjG2VmTIaB10rI93LzVPrmDJKkzKYMtxxyAvQXR/NS6otuzveI7+7BBq3SjBS2mw==", + "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", - "optional": true + "license": "MIT" }, - "node_modules/cssstyle": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", - "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", "dev": true, "license": "MIT", - "optional": true, - "dependencies": { - "cssom": "~0.3.6" + "bin": { + "cssesc": "bin/cssesc" }, "engines": { - "node": ">=8" + "node": ">=4" } }, - "node_modules/cssstyle/node_modules/cssom": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", - "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, "license": "MIT" }, "node_modules/data-urls": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-2.0.0.tgz", - "integrity": "sha512-X5eWTSXO/BJmpdIKCRuKUgSCgAN0OwliVK3yPKbwIWU1Tdw5BRajxlzMidvh+gwko9AfQ9zIj52pzF91Q3YAvQ==", + "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", - "optional": true, "dependencies": { - "abab": "^2.0.3", - "whatwg-mimetype": "^2.3.0", - "whatwg-url": "^8.0.0" + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" }, "engines": { - "node": ">=10" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, "node_modules/debug": { @@ -3249,8 +3340,7 @@ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", "dev": true, - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/decode-named-character-reference": { "version": "1.3.0", @@ -3266,14 +3356,11 @@ } }, "node_modules/deep-eql": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", - "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", "dev": true, "license": "MIT", - "dependencies": { - "type-detect": "^4.0.0" - }, "engines": { "node": ">=6" } @@ -3285,17 +3372,6 @@ "dev": true, "license": "MIT" }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -3364,47 +3440,12 @@ "node": ">=6.0.0" } }, - "node_modules/domexception": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/domexception/-/domexception-2.0.1.tgz", - "integrity": "sha512-yxJ2mFy/sibVQlu5qHjOkf9J3K6zgmCxgJ94u2EdvDOV09H+32LtRswEcUsmUWN72pVLOEnTSRaIVVzVQgS0dg==", - "deprecated": "Use your platform's native DOMException instead", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "webidl-conversions": "^5.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/domexception/node_modules/webidl-conversions": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz", - "integrity": "sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "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", - "optional": true, - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } + "license": "MIT" }, "node_modules/electron-to-chromium": { "version": "1.5.286", @@ -3419,58 +3460,25 @@ "integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==", "license": "MIT" }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", "dev": true, - "license": "MIT", - "optional": true, + "license": "BSD-2-Clause", "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "es-errors": "^1.3.0" + "node": ">=20.19.0" }, - "engines": { - "node": ">= 0.4" + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } + "license": "MIT" }, "node_modules/esbuild": { "version": "0.28.1", @@ -3537,40 +3545,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/escodegen": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", - "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "dependencies": { - "esprima": "^4.0.1", - "estraverse": "^5.2.0", - "esutils": "^2.0.2" - }, - "bin": { - "escodegen": "bin/escodegen.js", - "esgenerate": "bin/esgenerate.js" - }, - "engines": { - "node": ">=6.0" - }, - "optionalDependencies": { - "source-map": "~0.6.1" - } - }, - "node_modules/escodegen/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/eslint": { "version": "8.57.1", "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", @@ -3578,7 +3552,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -3724,21 +3697,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/esquery": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", @@ -3814,6 +3772,16 @@ "node": ">=0.8.x" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "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", @@ -3962,24 +3930,6 @@ "unicode-trie": "^2.0.0" } }, - "node_modules/form-data": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.4.tgz", - "integrity": "sha512-f0cRzm6dkyVYV3nPoooP8XlccPQukegwhAnpoLcXy+X+A8KfpGOoXwDr9FLZd3wzgLaBGQBE3lY93Zm/i1JvIQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.35" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -4036,57 +3986,6 @@ "node": ">=6.9.0" } }, - "node_modules/get-func-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", - "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -4183,20 +4082,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", @@ -4214,37 +4099,6 @@ "node": ">=8" } }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -4314,17 +4168,16 @@ "license": "ISC" }, "node_modules/html-encoding-sniffer": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz", - "integrity": "sha512-D5JbOMBIR/TVZkubHT+OyT2705QvogUW4IBn6nHd756OwieSF9aDYFj4dv6HHEVGYbHaLETa3WggZYWWMyy3ZQ==", + "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", - "optional": true, "dependencies": { - "whatwg-encoding": "^1.0.5" + "@exodus/bytes": "^1.6.0" }, "engines": { - "node": ">=10" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, "node_modules/html-url-attributes": { @@ -4337,37 +4190,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/http-proxy-agent": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", - "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tootallnate/once": "1", - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/hyphen": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/hyphen/-/hyphen-1.14.1.tgz", @@ -4411,6 +4233,16 @@ "node": ">=0.8.19" } }, + "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/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -4556,8 +4388,7 @@ "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", - "optional": true + "license": "MIT" }, "node_modules/is-url": { "version": "1.2.4", @@ -4587,7 +4418,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -4611,6 +4441,57 @@ "js-yaml": "bin/js-yaml.js" } }, + "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.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "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", @@ -4708,23 +4589,6 @@ "dev": true, "license": "MIT" }, - "node_modules/local-pkg": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", - "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "mlly": "^1.7.3", - "pkg-types": "^1.2.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -4741,14 +4605,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -4779,14 +4635,11 @@ } }, "node_modules/loupe": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", - "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", "dev": true, - "license": "MIT", - "dependencies": { - "get-func-name": "^2.0.1" - } + "license": "MIT" }, "node_modules/lru-cache": { "version": "5.1.1", @@ -4807,6 +4660,16 @@ "react": "^16.5.1 || ^17.0.0 || ^18.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", + "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", @@ -4845,17 +4708,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/mdast-util-find-and-replace": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", @@ -5138,6 +4990,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/media-engine": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/media-engine/-/media-engine-1.0.3.tgz", @@ -5161,13 +5020,6 @@ } } }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true, - "license": "MIT" - }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -5755,29 +5607,14 @@ "node": ">=8.6" } }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "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", - "optional": true, - "dependencies": { - "mime-db": "1.52.0" - }, "engines": { - "node": ">= 0.6" + "node": ">=4" } }, "node_modules/mini-svg-data-uri": { @@ -5806,26 +5643,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/mlly": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", - "integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.15.0", - "pathe": "^2.0.3", - "pkg-types": "^1.3.1", - "ufo": "^1.6.1" - } - }, - "node_modules/mlly/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/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -5896,14 +5713,6 @@ "svg-arc-to-cubic-bezier": "^3.0.0" } }, - "node_modules/nwsapi": { - "version": "2.2.23", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", - "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -6034,12 +5843,17 @@ "license": "MIT" }, "node_modules/parse5": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", - "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", "dev": true, "license": "MIT", - "optional": true + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } }, "node_modules/path-exists": { "version": "4.0.0", @@ -6089,20 +5903,20 @@ } }, "node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "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/pathval": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", - "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", "dev": true, "license": "MIT", "engines": { - "node": "*" + "node": ">= 14.16" } }, "node_modules/pdfjs-dist": { @@ -6157,25 +5971,6 @@ "node": ">= 6" } }, - "node_modules/pkg-types": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", - "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "confbox": "^0.1.8", - "mlly": "^1.7.4", - "pathe": "^2.0.1" - } - }, - "node_modules/pkg-types/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/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -6196,7 +5991,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -6352,7 +6146,6 @@ "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -6377,6 +6170,34 @@ "node": ">= 0.8.0" } }, + "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", + "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/pretty-format/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", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -6404,38 +6225,16 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/psl": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", - "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "punycode": "^2.3.1" - }, - "funding": { - "url": "https://github.com/sponsors/lupomontero" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "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/querystringify": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", - "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/queue": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", @@ -6471,7 +6270,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -6484,7 +6282,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -6493,6 +6290,13 @@ "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" + }, "node_modules/react-markdown": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", @@ -6555,7 +6359,6 @@ "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -6583,6 +6386,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", @@ -6658,14 +6475,6 @@ "node": ">=0.10.0" } }, - "node_modules/requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -6820,26 +6629,17 @@ ], "license": "MIT" }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/saxes": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", - "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", "dev": true, "license": "ISC", - "optional": true, "dependencies": { "xmlchars": "^2.2.0" }, "engines": { - "node": ">=10" + "node": ">=v12.22.7" } }, "node_modules/scheduler": { @@ -6929,29 +6729,6 @@ "node": ">=0.10.0" } }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/source-map-support/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/space-separated-tokens": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", @@ -7012,6 +6789,19 @@ "node": ">=8" } }, + "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/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -7026,9 +6816,9 @@ } }, "node_modules/strip-literal": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", - "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", "dev": true, "license": "MIT", "dependencies": { @@ -7133,8 +6923,7 @@ "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", - "optional": true + "license": "MIT" }, "node_modules/tailwindcss": { "version": "3.4.19", @@ -7142,7 +6931,6 @@ "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -7251,6 +7039,13 @@ "dev": true, "license": "MIT" }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -7292,7 +7087,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -7301,9 +7095,19 @@ } }, "node_modules/tinypool": { - "version": "0.8.4", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", - "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", "dev": true, "license": "MIT", "engines": { @@ -7311,15 +7115,35 @@ } }, "node_modules/tinyspy": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", - "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz", + "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", "dev": true, "license": "MIT", "engines": { "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.4.16", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.16.tgz", + "integrity": "sha512-QwBER5KMR86IIjpIiO7H/Z3IMJPsZ1A6RKPAqzTTgOyUQUSt9FdnKcqhTaJmkY6HVrgouZHZR0ncK5QxvmnQeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.16" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.16", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.16.tgz", + "integrity": "sha512-MDolfaSJtlSK5Y0A1xl3277ekubZwobpBjugknDizI9O5Rm60a1m8k4ICK+MRsCDzPygT81mp3BBf5RKDlFRfA==", + "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", @@ -7334,45 +7158,29 @@ } }, "node_modules/tough-cookie": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", - "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", "dev": true, "license": "BSD-3-Clause", - "optional": true, "dependencies": { - "psl": "^1.1.33", - "punycode": "^2.1.1", - "universalify": "^0.2.0", - "url-parse": "^1.5.3" + "tldts": "^7.0.5" }, "engines": { - "node": ">=6" - } - }, - "node_modules/tough-cookie/node_modules/universalify": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", - "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 4.0.0" + "node": ">=16" } }, "node_modules/tr46": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-2.1.0.tgz", - "integrity": "sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "punycode": "^2.1.1" + "punycode": "^2.3.1" }, "engines": { - "node": ">=8" + "node": ">=20" } }, "node_modules/trim-lines": { @@ -7434,16 +7242,6 @@ "node": ">= 0.8.0" } }, - "node_modules/type-detect": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", - "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/type-fest": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", @@ -7463,7 +7261,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7472,12 +7269,15 @@ "node": ">=14.17" } }, - "node_modules/ufo": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", - "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "node_modules/undici": { + "version": "7.30.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", + "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } }, "node_modules/undici-types": { "version": "6.21.0", @@ -7652,18 +7452,6 @@ "punycode": "^2.1.0" } }, - "node_modules/url-parse": { - "version": "1.5.10", - "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", - "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "querystringify": "^2.1.1", - "requires-port": "^1.0.0" - } - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -7704,7 +7492,6 @@ "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", @@ -7789,1357 +7576,200 @@ } }, "node_modules/vite-node": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.1.tgz", - "integrity": "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", "dev": true, "license": "MIT", "dependencies": { "cac": "^6.7.14", - "debug": "^4.3.4", - "pathe": "^1.1.1", - "picocolors": "^1.0.0", - "vite": "^5.0.0" + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, - "node_modules/vite-node/node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "aix" - ], "engines": { - "node": ">=12" + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, - "node_modules/vite-node/node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], + "node_modules/vite/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", - "optional": true, - "os": [ - "android" - ], "engines": { "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/vite-node/node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], "engines": { "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/vite-node/node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], + "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", - "optional": true, - "os": [ - "android" - ], + "dependencies": { + "xml-name-validator": "^5.0.0" + }, "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/vite-node/node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "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": ">=12" + "node": ">=20" } }, - "node_modules/vite-node/node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], + "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", - "optional": true, - "os": [ - "darwin" - ], "engines": { - "node": ">=12" + "node": ">=20" } }, - "node_modules/vite-node/node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], + "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", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, - "node_modules/vite-node/node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } - } - }, - "node_modules/vite/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/vite/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", - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/vitest": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz", - "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "1.6.1", - "@vitest/runner": "1.6.1", - "@vitest/snapshot": "1.6.1", - "@vitest/spy": "1.6.1", - "@vitest/utils": "1.6.1", - "acorn-walk": "^8.3.2", - "chai": "^4.3.10", - "debug": "^4.3.4", - "execa": "^8.0.1", - "local-pkg": "^0.5.0", - "magic-string": "^0.30.5", - "pathe": "^1.1.1", - "picocolors": "^1.0.0", - "std-env": "^3.5.0", - "strip-literal": "^2.0.0", - "tinybench": "^2.5.1", - "tinypool": "^0.8.3", - "vite": "^5.0.0", - "vite-node": "1.6.1", - "why-is-node-running": "^2.2.2" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "1.6.1", - "@vitest/ui": "1.6.1", - "happy-dom": "*", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - } - } - }, - "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/acorn-walk": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", - "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/vitest/node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, - "node_modules/vitest/node_modules/execa": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", - "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^8.0.1", - "human-signals": "^5.0.0", - "is-stream": "^3.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^5.1.0", - "onetime": "^6.0.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^3.0.0" - }, - "engines": { - "node": ">=16.17" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/vitest/node_modules/get-stream": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", - "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/vitest/node_modules/human-signals": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", - "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=16.17.0" - } - }, - "node_modules/vitest/node_modules/is-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", - "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/vitest/node_modules/mimic-fn": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", - "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/vitest/node_modules/npm-run-path": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", - "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/vitest/node_modules/onetime": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", - "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-fn": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/vitest/node_modules/path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/vitest/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/vitest/node_modules/strip-final-newline": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", - "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/vitest/node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } - } - }, - "node_modules/w3c-hr-time": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", - "integrity": "sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==", - "deprecated": "Use your platform's native performance.now() and performance.timeOrigin.", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "browser-process-hrtime": "^1.0.0" - } - }, - "node_modules/w3c-xmlserializer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-2.0.0.tgz", - "integrity": "sha512-4tzD0mF8iSiMiNs30BiLO3EpfGLZUT2MSX/G+o7ZywDzliWQ3OPtTZ0PTC3B3ca1UAf4cJMHB+2Bf56EriJuRA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "xml-name-validator": "^3.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/warning": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", - "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.0.0" - } - }, - "node_modules/webidl-conversions": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz", - "integrity": "sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "engines": { - "node": ">=10.4" - } - }, - "node_modules/whatwg-encoding": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz", - "integrity": "sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw==", - "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "iconv-lite": "0.4.24" - } - }, - "node_modules/whatwg-encoding/node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/whatwg-mimetype": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz", - "integrity": "sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/whatwg-url": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-8.7.0.tgz", - "integrity": "sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg==", - "dev": true, - "license": "MIT", - "optional": true, "dependencies": { - "lodash": "^4.7.0", - "tr46": "^2.1.0", - "webidl-conversions": "^6.1.0" + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" }, "engines": { - "node": ">=10" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, "node_modules/which": { @@ -9192,44 +7822,22 @@ "dev": true, "license": "ISC" }, - "node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=8.3.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/xml-name-validator": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz", - "integrity": "sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==", + "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", - "optional": true + "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", - "optional": true + "license": "MIT" }, "node_modules/yallist": { "version": "3.1.1", @@ -9243,7 +7851,6 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, diff --git a/src/package.json b/src/package.json index d3dd532..cdf522a 100644 --- a/src/package.json +++ b/src/package.json @@ -15,6 +15,9 @@ "devDependencies": { "@tailwindcss/forms": "^0.5.7", "@tailwindcss/typography": "^0.5.10", + "@testing-library/dom": "^10.4.2", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", "@types/node": "^20.11.0", "@types/react": "^18.2.42", "@types/react-dom": "^18.2.17", @@ -25,11 +28,12 @@ "eslint": "^8.57.0", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.5", + "jsdom": "^29.1.1", "postcss": "^8.4.35", "tailwindcss": "^3.4.1", "typescript": "^5.2.2", "vite": "^7.3.1", - "vitest": "^1.0.4" + "vitest": "^3.2.7" }, "scripts": { "dev": "vite", @@ -39,4 +43,4 @@ "test:ui": "vitest --ui", "lint": "tsc --noEmit" } -} \ No newline at end of file +} diff --git a/src/test-setup.ts b/src/test-setup.ts new file mode 100644 index 0000000..7b0828b --- /dev/null +++ b/src/test-setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/src/types/conversation.ts b/src/types/conversation.ts index 96d02b4..a616903 100644 --- a/src/types/conversation.ts +++ b/src/types/conversation.ts @@ -9,6 +9,25 @@ export interface Conversation { * determine which replay events are already in localStorage and which are * new (i.e. were emitted while the browser was closed). */ _eventIndex?: number; + /** Backend research session id. Set when the user submits a query and + * cleared once the session reaches a terminal state + * (report / error / plan_denied / research_stopped). Survives browser + * close so a re-visit can resume the pipeline. Each conversation carries + * its own — this replaces the singleton ``deep_research_session_id`` + * localStorage key. */ + sessionId?: string; + /** Coarse lifecycle state mirrored from the backend. Enables the frontend + * to decide whether ``resume`` should be sent when the user re-selects + * the conversation. Terminal states: ``complete``, ``error``, + * ``cancelled``, ``denied``. */ + sessionState?: + | 'planning' + | 'awaiting_approval' + | 'executing' + | 'complete' + | 'error' + | 'cancelled' + | 'denied'; } export type PlanAction = 'pending' | 'approved' | 'modified' | 'denied' | 'ended'; diff --git a/src/vitest.config.ts b/src/vitest.config.ts new file mode 100644 index 0000000..3b8f174 --- /dev/null +++ b/src/vitest.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; +import path from 'path'; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, '.'), + }, + }, + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./test-setup.ts'], + include: ['**/__tests__/**/*.{test,spec}.{ts,tsx}'], + exclude: ['node_modules/**'], + }, +});