diff --git a/.env.example b/.env.example index d7ae22b..88d3456 100644 --- a/.env.example +++ b/.env.example @@ -146,5 +146,9 @@ SOCIAL_STORAGE__RETENTION_DAYS=30 ###### # LLM API KEY -LLM_DEEPSEEK_API_KEY= -LLM_DASHSCOPE_API_KEY= +SOCIAL_LLM__PROVIDER_KEYS__DASHSCOPE= +SOCIAL_LLM__PROVIDER_KEYS__MINIMAX= +SOCIAL_LLM__PROVIDER_KEYS__MOONSHOT= +SOCIAL_LLM__PROVIDER_KEYS__DEEPSEEK= +SOCIAL_LLM__PROVIDER_KEYS__ARK= +SOCIAL_LLM__PROVIDER_KEYS__ZAI= diff --git a/backend/src/core/agent/__init__.py b/backend/src/core/agent/__init__.py deleted file mode 100644 index 9d48db4..0000000 --- a/backend/src/core/agent/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from __future__ import annotations diff --git a/backend/src/core/agent/agui_adapter.py b/backend/src/core/agent/agui_adapter.py deleted file mode 100644 index 2b29848..0000000 --- a/backend/src/core/agent/agui_adapter.py +++ /dev/null @@ -1,20 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from core.agent.event_bridge import map_internal_event - - -class AguiAdapter: - def to_command(self, payload: dict[str, Any]) -> dict[str, Any]: - message = payload.get("message") - if not isinstance(message, str) or not message.strip(): - raise ValueError("message is required") - - return { - "message": message, - "session_id": payload.get("session_id"), - } - - def to_protocol_event(self, event: dict[str, Any]) -> dict[str, Any]: - return map_internal_event(event) diff --git a/backend/src/core/agent/crewai/template_loader.py b/backend/src/core/agent/crewai/template_loader.py deleted file mode 100644 index d91f552..0000000 --- a/backend/src/core/agent/crewai/template_loader.py +++ /dev/null @@ -1,67 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from pathlib import Path -from typing import Any - -import yaml - - -@dataclass(frozen=True) -class CrewAITemplate: - agents: dict[str, Any] - tasks: dict[str, Any] - workflow: dict[str, Any] - tools_whitelist: set[str] - - -def _default_static_root() -> Path: - return Path(__file__).resolve().parents[3] / "config" / "static" / "crewai" - - -def _read_yaml(file_path: Path) -> dict[str, Any]: - if not file_path.is_file(): - raise FileNotFoundError(f"Required config file not found: {file_path}") - with file_path.open("r", encoding="utf-8") as file: - loaded = yaml.safe_load(file) or {} - if not isinstance(loaded, dict): - raise ValueError(f"YAML file must be a mapping: {file_path}") - return loaded - - -def validate_workflow_stages(stages: list[str]) -> None: - expected = ["intent", "execution", "organization"] - if stages != expected: - raise ValueError(f"Invalid workflow stages: {stages}, expected: {expected}") - - -def load_tools_whitelist(static_root: Path | None = None) -> set[str]: - root = static_root or _default_static_root() - tools = _read_yaml(root / "tools.yaml") - raw_tools = tools.get("tools", []) - if not isinstance(raw_tools, list): - raise ValueError("tools.yaml field 'tools' must be a list") - if not all(isinstance(item, str) and item.strip() for item in raw_tools): - raise ValueError("tools.yaml list items must be non-empty strings") - whitelist = {item.strip() for item in raw_tools} - return whitelist - - -def load_crewai_template(static_root: Path | None = None) -> CrewAITemplate: - root = static_root or _default_static_root() - - agents = _read_yaml(root / "agents.yaml") - tasks = _read_yaml(root / "tasks.yaml") - workflow = _read_yaml(root / "workflow.yaml") - - stages = workflow.get("stages") - if not isinstance(stages, list): - raise ValueError("workflow.yaml field 'stages' must be a list") - validate_workflow_stages([str(stage) for stage in stages]) - - return CrewAITemplate( - agents=agents, - tasks=tasks, - workflow=workflow, - tools_whitelist=load_tools_whitelist(root), - ) diff --git a/backend/src/core/agent/event_bridge.py b/backend/src/core/agent/event_bridge.py deleted file mode 100644 index bb6bdf0..0000000 --- a/backend/src/core/agent/event_bridge.py +++ /dev/null @@ -1,63 +0,0 @@ -from __future__ import annotations - -from typing import Any - - -def _require_fields(event: dict[str, Any], *, kind: str, required: list[str]) -> None: - missing = [field for field in required if field not in event] - if missing: - raise ValueError(f"Missing fields for {kind}: {missing}") - - -def map_internal_event(event: dict[str, Any]) -> dict[str, Any]: - kind = event.get("kind") - - if kind == "run_started": - _require_fields(event, kind=kind, required=["session_id"]) - return { - "type": "run.started", - "run_id": event["session_id"], - } - - if kind == "message_delta": - _require_fields(event, kind=kind, required=["message_id", "delta"]) - return { - "type": "message.delta", - "message_id": event["message_id"], - "delta": event["delta"], - } - - if kind == "tool_started": - _require_fields(event, kind=kind, required=["message_id", "tool_name"]) - return { - "type": "tool.started", - "message_id": event["message_id"], - "tool_name": event["tool_name"], - } - - if kind == "tool_completed": - _require_fields(event, kind=kind, required=["message_id", "tool_name"]) - return { - "type": "tool.completed", - "message_id": event["message_id"], - "tool_name": event["tool_name"], - "result": event.get("result"), - } - - if kind == "run_completed": - _require_fields(event, kind=kind, required=["session_id"]) - return { - "type": "run.completed", - "run_id": event["session_id"], - "output": event.get("output", ""), - } - - if kind == "run_failed": - _require_fields(event, kind=kind, required=["session_id"]) - return { - "type": "run.failed", - "run_id": event["session_id"], - "error": event.get("error", ""), - } - - raise ValueError(f"Unsupported event kind: {kind}") diff --git a/backend/src/core/agent/events.py b/backend/src/core/agent/events.py deleted file mode 100644 index 96a2950..0000000 --- a/backend/src/core/agent/events.py +++ /dev/null @@ -1,37 +0,0 @@ -from __future__ import annotations - -from typing import Any - - -def run_started(*, run_id: str) -> dict[str, Any]: - return {"type": "run.started", "run_id": run_id} - - -def stage_completed( - *, run_id: str, stage: str, usage: dict[str, Any] | None = None -) -> dict[str, Any]: - event: dict[str, Any] = { - "type": "stage.completed", - "run_id": run_id, - "stage": stage, - } - if usage is not None: - event["usage"] = usage - return event - - -def run_completed(*, run_id: str, output: str, usage: dict[str, Any]) -> dict[str, Any]: - return { - "type": "run.completed", - "run_id": run_id, - "output": output, - "usage": usage, - } - - -def run_failed(*, run_id: str, error: str) -> dict[str, Any]: - return { - "type": "run.failed", - "run_id": run_id, - "error": error, - } diff --git a/backend/src/core/agent/litellm_client.py b/backend/src/core/agent/litellm_client.py deleted file mode 100644 index 8b187d9..0000000 --- a/backend/src/core/agent/litellm_client.py +++ /dev/null @@ -1,130 +0,0 @@ -from __future__ import annotations - -import os -from dataclasses import dataclass -from decimal import Decimal -from pathlib import Path -from typing import Any - -import yaml - - -@dataclass(frozen=True) -class LLMConfig: - model_code: str - factory_name: str - litellm_model: str - request_url: str - - -@dataclass(frozen=True) -class LLMResponse: - content: str - usage: dict[str, Any] - - -class LiteLLMClient: - def __init__(self, config: LLMConfig, api_key: str | None = None) -> None: - self._config = config - self._api_key = api_key or self._get_api_key(config.factory_name) - - @staticmethod - def _get_api_key(factory_name: str) -> str: - key_map = { - "dashscope": "DASHSCOPE_API_KEY", - "minimax": "MINIMAX_API_KEY", - "moonshot": "MOONSHOT_API_KEY", - "deepseek": "DEEPSEEK_API_KEY", - "volcengine-ark": "ARK_API_KEY", - "z-ai": "ZAI_API_KEY", - } - env_key = key_map.get(factory_name) - if not env_key: - raise ValueError(f"No API key mapping for factory: {factory_name}") - key = os.environ.get(env_key) - if not key: - raise ValueError(f"Environment variable {env_key} is not set") - return key - - @staticmethod - def load_config( - model_code: str, - static_root: Path | None = None, - ) -> LLMConfig: - root = static_root or ( - Path(__file__).resolve().parents[3] / "config" / "static" / "database" - ) - yaml_path = root / "llm_catalog.yaml" - with yaml_path.open("r", encoding="utf-8") as f: - data = yaml.safe_load(f) - - factories = {f["name"]: f for f in data.get("factories", [])} - llms = data.get("llms", []) - - for llm in llms: - if llm.get("model_code") == model_code: - factory_name = llm["factory_name"] - factory = factories.get(factory_name) - if not factory: - raise ValueError(f"Factory not found: {factory_name}") - return LLMConfig( - model_code=model_code, - factory_name=factory_name, - litellm_model=llm.get("litellm_model", model_code), - request_url=factory["request_url"], - ) - - raise ValueError(f"Model not found: {model_code}") - - def chat( - self, - messages: list[dict[str, str]], - *, - temperature: float = 0.7, - max_tokens: int | None = None, - ) -> LLMResponse: - import litellm - - response = litellm.completion( # type: ignore[attr-defined] - model=self._config.litellm_model, - messages=messages, - temperature=temperature, - max_tokens=max_tokens, - api_base=self._config.request_url, - api_key=self._api_key, - ) - - content = response.choices[0].message.content or "" # type: ignore[attr-defined] - usage = response.usage.model_dump() if response.usage else {} # type: ignore[attr-defined] - - return LLMResponse(content=content, usage=usage) - - async def achat( - self, - messages: list[dict[str, str]], - *, - temperature: float = 0.7, - max_tokens: int | None = None, - ) -> LLMResponse: - import litellm - - response = await litellm.acompletion( # type: ignore[attr-defined] - model=self._config.litellm_model, - messages=messages, - temperature=temperature, - max_tokens=max_tokens, - api_base=self._config.request_url, - api_key=self._api_key, - ) - - content = response.choices[0].message.content or "" # type: ignore[attr-defined] - usage = response.usage.model_dump() if response.usage else {} # type: ignore[attr-defined] - - return LLMResponse(content=content, usage=usage) - - -def get_model_cost(usage: dict[str, Any]) -> Decimal: - cost = usage.get("cost") - if cost is None: - return Decimal("0") - return Decimal(str(cost)) diff --git a/backend/src/core/agent/orchestrator.py b/backend/src/core/agent/orchestrator.py deleted file mode 100644 index 2a0fb29..0000000 --- a/backend/src/core/agent/orchestrator.py +++ /dev/null @@ -1,117 +0,0 @@ -from __future__ import annotations - -import asyncio -from dataclasses import dataclass -from decimal import Decimal -from typing import Any, Awaitable, Callable - -from core.agent import events -from core.agent.litellm_client import get_model_cost - -StageCallable = Callable[..., Awaitable[dict[str, Any]]] - - -@dataclass(frozen=True) -class OrchestratorResult: - output: str - usage: dict[str, Any] - events: list[dict[str, Any]] - context: dict[str, Any] - failed: bool - error: str | None - - -class _UsageTracker: - def __init__(self) -> None: - self._input_tokens = 0 - self._output_tokens = 0 - self._total_tokens = 0 - self._cost = Decimal("0") - - def add_usage(self, usage: dict[str, Any]) -> None: - input_tokens = usage.get("prompt_tokens", 0) or usage.get("input_tokens", 0) - output_tokens = usage.get("completion_tokens", 0) or usage.get( - "output_tokens", 0 - ) - total = usage.get("total_tokens") - - self._input_tokens += input_tokens - self._output_tokens += output_tokens - self._total_tokens += total if total else (input_tokens + output_tokens) - self._cost += get_model_cost(usage) - - def snapshot(self) -> dict[str, Any]: - return { - "input_tokens": self._input_tokens, - "output_tokens": self._output_tokens, - "total_tokens": self._total_tokens, - "cost": str(self._cost), - } - - -class AgentChatOrchestrator: - def __init__( - self, - *, - intent_stage: StageCallable, - execution_stage: StageCallable, - organization_stage: StageCallable, - ) -> None: - self._intent_stage = intent_stage - self._execution_stage = execution_stage - self._organization_stage = organization_stage - - def run_sync(self, *, run_id: str, user_message: str) -> OrchestratorResult: - return asyncio.run(self.run(run_id=run_id, user_message=user_message)) - - async def run(self, *, run_id: str, user_message: str) -> OrchestratorResult: - tracker = _UsageTracker() - emitted_events: list[dict[str, Any]] = [events.run_started(run_id=run_id)] - context: dict[str, Any] = {} - - stage_pipeline: list[tuple[str, StageCallable]] = [ - ("intent", self._intent_stage), - ("execution", self._execution_stage), - ("organization", self._organization_stage), - ] - - stage_output = user_message - try: - for stage_name, stage_callable in stage_pipeline: - stage_result = await stage_callable( - message=stage_output, context=context - ) - stage_output = str(stage_result.get("content", stage_output)) - usage = stage_result.get("usage", {}) - if isinstance(usage, dict): - tracker.add_usage(usage) - emitted_events.append( - events.stage_completed( - run_id=run_id, - stage=stage_name, - usage=tracker.snapshot(), - ) - ) - except Exception as exc: # noqa: BLE001 - emitted_events.append(events.run_failed(run_id=run_id, error=str(exc))) - return OrchestratorResult( - output="", - usage=tracker.snapshot(), - events=emitted_events, - context=context, - failed=True, - error=str(exc), - ) - - summary = tracker.snapshot() - emitted_events.append( - events.run_completed(run_id=run_id, output=stage_output, usage=summary) - ) - return OrchestratorResult( - output=stage_output, - usage=summary, - events=emitted_events, - context=context, - failed=False, - error=None, - ) diff --git a/backend/src/core/agent/tools/asr_fun_asr.py b/backend/src/core/agent/tools/asr_fun_asr.py deleted file mode 100644 index 9df827d..0000000 --- a/backend/src/core/agent/tools/asr_fun_asr.py +++ /dev/null @@ -1,40 +0,0 @@ -from __future__ import annotations - -import importlib -from collections.abc import Callable -from typing import Any - - -TranscribeCallable = Callable[..., dict[str, Any]] - - -class FunASRTool: - _transcribe_callable: TranscribeCallable - _model: str - - def __init__( - self, - transcribe_callable: TranscribeCallable | None = None, - model: str = "fun-asr-realtime-2025-11-07", - ) -> None: - self._transcribe_callable = transcribe_callable or self._dashscope_transcribe - self._model = model - - def transcribe(self, *, audio_bytes: bytes, filename: str) -> dict[str, Any]: - payload = self._transcribe_callable(audio_bytes=audio_bytes, filename=filename) - return { - "model": self._model, - **payload, - } - - def _dashscope_transcribe( - self, *, audio_bytes: bytes, filename: str - ) -> dict[str, Any]: - try: - importlib.import_module("dashscope") - except ImportError as exc: - raise RuntimeError("DashScope SDK is not installed") from exc - - raise RuntimeError( - "DashScope transcribe runtime integration is not configured yet" - ) diff --git a/backend/src/core/config/static/crewai/tools.yaml b/backend/src/core/config/static/crewai/tools.yaml deleted file mode 100644 index 398fc71..0000000 --- a/backend/src/core/config/static/crewai/tools.yaml +++ /dev/null @@ -1,3 +0,0 @@ -tools: - - asr_fun_asr - - attachment_extract diff --git a/backend/src/core/config/static/crewai/workflow.yaml b/backend/src/core/config/static/crewai/workflow.yaml deleted file mode 100644 index 09bb672..0000000 --- a/backend/src/core/config/static/crewai/workflow.yaml +++ /dev/null @@ -1,9 +0,0 @@ -stages: - - intent - - execution - - organization - -timeouts: - intent_seconds: 8 - execution_seconds: 30 - organization_seconds: 10 diff --git a/backend/src/v1/agent/__init__.py b/backend/src/v1/agent/__init__.py deleted file mode 100644 index 9d48db4..0000000 --- a/backend/src/v1/agent/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from __future__ import annotations diff --git a/backend/src/v1/agent/crewai_flow.py b/backend/src/v1/agent/crewai_flow.py deleted file mode 100644 index 271ec7e..0000000 --- a/backend/src/v1/agent/crewai_flow.py +++ /dev/null @@ -1,38 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from pydantic import BaseModel, Field - - -class FlowState(BaseModel): - stage_trace: list[str] = Field(default_factory=list) - current_stage: str | None = None - message: str = "" - context: dict[str, Any] = Field(default_factory=dict) - - -class AgentFlow: - def __init__(self) -> None: - self.state = FlowState() - - async def run(self) -> dict[str, Any]: - result = await self.intent_recognition() - result = await self.task_execution(result) - result = await self.result_reporting(result) - return result - - async def intent_recognition(self) -> dict[str, Any]: - self.state.current_stage = "intent" - self.state.stage_trace.append("intent") - return {"stage": "intent", "result": "intent recognized"} - - async def task_execution(self, _prev_result: dict[str, Any]) -> dict[str, Any]: - self.state.current_stage = "execution" - self.state.stage_trace.append("execution") - return {"stage": "execution", "result": "task executed"} - - async def result_reporting(self, _prev_result: dict[str, Any]) -> dict[str, Any]: - self.state.current_stage = "reporting" - self.state.stage_trace.append("reporting") - return {"stage": "reporting", "result": "reported"} diff --git a/backend/src/v1/agent/dependencies.py b/backend/src/v1/agent/dependencies.py deleted file mode 100644 index b905d76..0000000 --- a/backend/src/v1/agent/dependencies.py +++ /dev/null @@ -1,18 +0,0 @@ -from __future__ import annotations - -from typing import Annotated - -from fastapi import Depends -from sqlalchemy.ext.asyncio import AsyncSession - -from core.auth.models import CurrentUser -from core.db import get_db -from v1.agent.service import AgentChatService -from v1.profile.dependencies import get_current_user - - -def get_agent_service( - session: Annotated[AsyncSession, Depends(get_db)], - user: Annotated[CurrentUser, Depends(get_current_user)], -) -> AgentChatService: - return AgentChatService(session=session, current_user=user) diff --git a/backend/src/v1/agent/router.py b/backend/src/v1/agent/router.py deleted file mode 100644 index 5f6612b..0000000 --- a/backend/src/v1/agent/router.py +++ /dev/null @@ -1,41 +0,0 @@ -from __future__ import annotations - -from typing import Annotated - -from fastapi import APIRouter, Depends, HTTPException, Path -from fastapi.responses import StreamingResponse - -from v1.agent.dependencies import get_agent_service -from v1.agent.schemas import RunAgentInput -from v1.agent.service import AgentChatService - -router = APIRouter(prefix="/agent", tags=["agent"]) - - -@router.post("/runs") -async def create_run( - input_data: RunAgentInput, - service: Annotated[AgentChatService, Depends(get_agent_service)], -) -> StreamingResponse: - return StreamingResponse( - service.stream_run(input_data), - media_type="text/event-stream", - ) - - -@router.post("/runs/{run_id}/resume") -async def resume_run( - run_id: Annotated[str, Path(min_length=1, max_length=255)], - input_data: RunAgentInput, - service: Annotated[AgentChatService, Depends(get_agent_service)], -) -> StreamingResponse: - if input_data.runId != run_id: - raise HTTPException( - status_code=409, - detail=f"run_id mismatch: path={run_id}, body={input_data.runId}", - ) - await service.prepare_resume(run_id, input_data) - return StreamingResponse( - service.stream_resume(run_id, input_data), - media_type="text/event-stream", - ) diff --git a/backend/src/v1/agent/schemas.py b/backend/src/v1/agent/schemas.py deleted file mode 100644 index 4e9da22..0000000 --- a/backend/src/v1/agent/schemas.py +++ /dev/null @@ -1,88 +0,0 @@ -from __future__ import annotations - -from datetime import datetime -from enum import Enum -from typing import Literal -from typing import Any -from uuid import UUID - -from pydantic import BaseModel, ConfigDict, Field, field_validator - - -class RunAgentInput(BaseModel): - model_config = ConfigDict(extra="forbid") - - threadId: str = Field(min_length=1, max_length=255) - runId: str = Field(min_length=1, max_length=255) - parentRunId: str | None = Field(default=None, max_length=255) - state: dict[str, Any] = Field(default_factory=dict) - messages: list[dict[str, Any]] = Field(default_factory=list) - tools: list[dict[str, Any]] = Field(default_factory=list) - context: list[dict[str, Any]] = Field(default_factory=list) - forwardedProps: dict[str, Any] = Field(default_factory=dict) - resume: dict[str, Any] | None = None - - -class AgentChatRunRequest(BaseModel): - message: str = Field(min_length=1, max_length=8000) - session_id: UUID | None = None - - -class AgentChatEvent(BaseModel): - type: str - run_id: str | None = None - message_id: str | None = None - delta: str | None = None - tool_name: str | None = None - result: str | None = None - output: str | None = None - error: str | None = None - - -class AgentChatRunResponse(BaseModel): - session_id: UUID - output: str - events: list[AgentChatEvent] - - -class PendingToolStatus(str, Enum): - PENDING_APPROVAL = "PENDING_APPROVAL" - APPROVED_EXECUTING = "APPROVED_EXECUTING" - EXECUTED = "EXECUTED" - REJECTED = "REJECTED" - EXPIRED = "EXPIRED" - - -class PendingToolCall(BaseModel): - model_config = ConfigDict(extra="forbid") - - interrupt_id: str = Field(min_length=1, max_length=255) - tool_name: str = Field(min_length=1, max_length=255) - tool_args: dict[str, Any] = Field(default_factory=dict) - status: PendingToolStatus - expires_at: datetime - decision: dict[str, Any] | None = None - result: dict[str, Any] | None = None - updated_at: datetime - - @field_validator("expires_at", "updated_at") - @classmethod - def _validate_timezone_aware(cls, value: datetime) -> datetime: - if value.tzinfo is None or value.utcoffset() is None: - raise ValueError("datetime must be timezone-aware") - return value - - -class SnapshotRunContext(BaseModel): - model_config = ConfigDict(extra="forbid") - - thread_id: str = Field(min_length=1, max_length=255) - run_id: str = Field(min_length=1, max_length=255) - - -class AgentSessionSnapshot(BaseModel): - model_config = ConfigDict(extra="forbid") - - version: Literal[2] - pending_tool_call: PendingToolCall | None - run_context: SnapshotRunContext diff --git a/backend/src/v1/agent/service.py b/backend/src/v1/agent/service.py deleted file mode 100644 index 81cbced..0000000 --- a/backend/src/v1/agent/service.py +++ /dev/null @@ -1,545 +0,0 @@ -from __future__ import annotations - -import json -from collections.abc import AsyncGenerator -from datetime import datetime, timezone -from decimal import Decimal -from typing import TYPE_CHECKING, Any -from uuid import UUID - -from fastapi import HTTPException -from pydantic import BaseModel -from sqlalchemy import func, select -from sqlalchemy.exc import SQLAlchemyError - -from core.agent.agui_adapter import AguiAdapter -from core.agent.orchestrator import AgentChatOrchestrator -from core.auth.models import CurrentUser -from core.db.base_service import BaseService -from core.logging import get_logger -from models.agent_chat_message import AgentChatMessage, AgentChatMessageRole -from models.agent_chat_session import AgentChatSession, AgentChatSessionStatus -from v1.auth.rate_limit import enforce_rate_limit -from v1.agent.schemas import ( - AgentSessionSnapshot, - AgentChatEvent, - AgentChatRunRequest, - AgentChatRunResponse, - PendingToolCall, - PendingToolStatus, - RunAgentInput, - SnapshotRunContext, -) - -if TYPE_CHECKING: - from sqlalchemy.ext.asyncio import AsyncSession - -logger = get_logger("v1.agent.service") - -DEFAULT_RATE_LIMIT = 60 -EMPTY_USAGE = {"input_tokens": 0, "output_tokens": 0, "cost": "0"} - - -class ResumeDecisionResult(BaseModel): - applied: bool - - -def build_session_title(first_message: str, *, now: datetime) -> str: - title = first_message.strip().replace("\n", " ")[:24] - if not title: - return now.strftime("新对话 %Y-%m-%d %H:%M") - return title - - -def aggregate_session_cost(costs: list[Decimal]) -> Decimal: - total = Decimal("0") - for cost in costs: - if cost < 0: - raise ValueError("cost must be non-negative") - total += cost - return total - - -def select_recent_session( - sessions: list[AgentChatSession], -) -> AgentChatSession | None: - if not sessions: - return None - return max(sessions, key=lambda item: item.last_activity_at) - - -class AgentChatService(BaseService): - _session: AsyncSession - - def __init__(self, session: AsyncSession, current_user: CurrentUser | None) -> None: - super().__init__(current_user=current_user) - self._session = session - self._adapter = AguiAdapter() - self._orchestrator = AgentChatOrchestrator( - intent_stage=self._intent_stage, - execution_stage=self._execution_stage, - organization_stage=self._organization_stage, - ) - - async def run(self, payload: AgentChatRunRequest) -> AgentChatRunResponse: - try: - command = self._adapter.to_command(payload.model_dump(mode="python")) - except ValueError as exc: - raise HTTPException(status_code=422, detail=str(exc)) from exc - user_id = self.require_user_id() - await enforce_rate_limit( - scope="agent_run", - identifier=str(user_id), - limit=DEFAULT_RATE_LIMIT, - window_seconds=DEFAULT_RATE_LIMIT, - ) - now = datetime.now(timezone.utc) - - try: - chat_session = await self._resolve_session( - session_id=command["session_id"], - user_id=user_id, - first_message=command["message"], - now=now, - ) - - base_seq = await self._next_seq_base(chat_session.id) - user_message = AgentChatMessage( - session_id=chat_session.id, - seq=base_seq + 1, - role=AgentChatMessageRole.USER, - content=command["message"], - cost=Decimal("0"), - ) - orchestrator_result = await self._orchestrator.run( - run_id=str(chat_session.id), - user_message=command["message"], - ) - assistant_message = AgentChatMessage( - session_id=chat_session.id, - seq=base_seq + 2, - role=AgentChatMessageRole.ASSISTANT, - content=orchestrator_result.output, - input_tokens=int(orchestrator_result.usage["input_tokens"]), - output_tokens=int(orchestrator_result.usage["output_tokens"]), - cost=Decimal(orchestrator_result.usage["cost"]), - ) - self._session.add(user_message) - self._session.add(assistant_message) - - chat_session.status = ( - AgentChatSessionStatus.FAILED - if orchestrator_result.failed - else AgentChatSessionStatus.COMPLETED - ) - chat_session.last_activity_at = now - chat_session.message_count = chat_session.message_count + 2 - chat_session.total_tokens = chat_session.total_tokens + int( - orchestrator_result.usage["total_tokens"] - ) - chat_session.total_cost = aggregate_session_cost( - [ - Decimal(chat_session.total_cost), - Decimal(orchestrator_result.usage["cost"]), - ] - ) - - await self._session.commit() - await self._session.refresh(chat_session) - await self._session.refresh(user_message) - - mapped_events = self._build_mapped_events( - session_id=str(chat_session.id), - message_id=str(user_message.id), - user_message=command["message"], - assistant_output=assistant_message.content, - failed=orchestrator_result.failed, - error=orchestrator_result.error, - ) - events = [AgentChatEvent.model_validate(item) for item in mapped_events] - if orchestrator_result.failed: - raise HTTPException( - status_code=502, detail="Agent orchestration failed" - ) - return AgentChatRunResponse( - session_id=chat_session.id, - output=assistant_message.content, - events=events, - ) - except HTTPException: - await self._session.rollback() - raise - except SQLAlchemyError: - await self._session.rollback() - logger.exception("Agent chat run failed") - raise HTTPException(status_code=503, detail="Agent chat store unavailable") - except Exception as exc: # noqa: BLE001 - await self._session.rollback() - logger.exception( - "Agent chat unexpected failure", error_type=type(exc).__name__ - ) - raise HTTPException( - status_code=502, detail="Agent orchestration failed" - ) from exc - - def _build_mapped_events( - self, - *, - session_id: str, - message_id: str, - user_message: str, - assistant_output: str, - failed: bool, - error: str | None, - ) -> list[dict[str, object]]: - mapped_events = [ - self._adapter.to_protocol_event( - { - "kind": "run_started", - "session_id": session_id, - } - ), - self._adapter.to_protocol_event( - { - "kind": "message_delta", - "message_id": message_id, - "delta": user_message, - } - ), - ] - if failed: - mapped_events.append( - self._adapter.to_protocol_event( - { - "kind": "run_failed", - "session_id": session_id, - "error": error or "orchestration failed", - } - ) - ) - return mapped_events - - mapped_events.append( - self._adapter.to_protocol_event( - { - "kind": "run_completed", - "session_id": session_id, - "output": assistant_output, - } - ) - ) - return mapped_events - - async def _resolve_session( - self, - *, - session_id: object | None, - user_id: UUID, - first_message: str, - now: datetime, - ) -> AgentChatSession: - if session_id is not None: - stmt = ( - select(AgentChatSession) - .where(AgentChatSession.id == session_id) - .where(AgentChatSession.user_id == user_id) - .where(AgentChatSession.deleted_at.is_(None)) - .with_for_update() - .limit(1) - ) - result = await self._session.execute(stmt) - existing = result.scalar_one_or_none() - if existing is None: - raise HTTPException(status_code=404, detail="Session not found") - existing.status = AgentChatSessionStatus.RUNNING - return existing - - title = build_session_title(first_message, now=now) - - created = AgentChatSession( - user_id=user_id, - title=title, - status=AgentChatSessionStatus.RUNNING, - last_activity_at=now, - ) - self._session.add(created) - await self._session.flush() - return created - - async def _next_seq_base(self, session_id: object) -> int: - stmt = select(func.max(AgentChatMessage.seq)).where( - AgentChatMessage.session_id == session_id - ) - result = await self._session.scalar(stmt) - if result is None: - return 0 - return int(result) - - async def _intent_stage( - self, *, message: str, context: dict[str, object] - ) -> dict[str, object]: - context["intent"] = "default" - return {"content": message, "usage": EMPTY_USAGE} - - async def _execution_stage( - self, *, message: str, context: dict[str, object] - ) -> dict[str, object]: - return {"content": message, "usage": EMPTY_USAGE} - - async def _organization_stage( - self, *, message: str, context: dict[str, object] - ) -> dict[str, object]: - return {"content": message, "usage": EMPTY_USAGE} - - async def get_state_snapshot(self, session_id: UUID) -> dict | None: - stmt = select(AgentChatSession).where(AgentChatSession.id == session_id) - session = await self._session.scalar(stmt) - if session is None: - return None - return session.state_snapshot - - @staticmethod - def _load_snapshot_v2(raw_snapshot: dict[str, Any]) -> AgentSessionSnapshot: - try: - return AgentSessionSnapshot.model_validate(raw_snapshot) - except Exception as exc: # noqa: BLE001 - raise ValueError("Invalid state_snapshot format") from exc - - async def _get_session_for_update( - self, session_id: UUID - ) -> AgentChatSession | None: - stmt = ( - select(AgentChatSession) - .where(AgentChatSession.id == session_id) - .with_for_update() - .limit(1) - ) - result = await self._session.execute(stmt) - return result.scalar_one_or_none() - - def _assert_session_owner(self, session: AgentChatSession) -> None: - if self._current_user is None: - return - - if session.user_id != self.require_user_id(): - raise HTTPException(status_code=404, detail="Session not found") - - @staticmethod - def _validate_no_newlines(value: str, *, field_name: str) -> None: - if "\n" in value or "\r" in value: - raise ValueError(f"{field_name} must not contain newlines") - - @staticmethod - def _sse_data(payload: dict[str, Any]) -> str: - return f"data: {json.dumps(payload)}\\n\\n" - - async def set_pending_tool_call( - self, - *, - session_id: UUID, - interrupt_id: str, - tool_name: str, - tool_args: dict, - expires_at: datetime, - thread_id: str, - run_id: str, - ) -> None: - stmt = select(AgentChatSession).where(AgentChatSession.id == session_id) - session = await self._session.scalar(stmt) - if session is None: - raise ValueError(f"Session {session_id} not found") - self._assert_session_owner(session) - - snapshot = AgentSessionSnapshot( - version=2, - run_context=SnapshotRunContext(thread_id=thread_id, run_id=run_id), - pending_tool_call=PendingToolCall( - interrupt_id=interrupt_id, - tool_name=tool_name, - tool_args=tool_args, - status=PendingToolStatus.PENDING_APPROVAL, - expires_at=expires_at, - decision=None, - result=None, - updated_at=datetime.now(timezone.utc), - ), - ) - session.state_snapshot = snapshot.model_dump(mode="json") - - async def update_pending_tool_call_status( - self, - *, - session_id: UUID, - interrupt_id: str, - status: str, - ) -> None: - stmt = select(AgentChatSession).where(AgentChatSession.id == session_id) - session = await self._session.scalar(stmt) - if session is None: - raise ValueError(f"Session {session_id} not found") - self._assert_session_owner(session) - if session.state_snapshot is None: - raise ValueError("No pending tool call found") - - snapshot = self._load_snapshot_v2(session.state_snapshot) - pending = snapshot.pending_tool_call - if pending is None: - raise ValueError("No pending tool call found") - if pending.interrupt_id != interrupt_id: - raise ValueError("Interrupt ID mismatch") - - updated_pending = pending.model_copy( - update={ - "status": PendingToolStatus(status), - "updated_at": datetime.now(timezone.utc), - } - ) - updated_snapshot = snapshot.model_copy( - update={"pending_tool_call": updated_pending} - ) - session.state_snapshot = updated_snapshot.model_dump(mode="json") - - async def apply_resume_decision( - self, - *, - session_id: UUID, - interrupt_id: str, - decision: dict[str, Any], - ) -> ResumeDecisionResult: - session = await self._get_session_for_update(session_id) - if session is None: - raise ValueError(f"Session {session_id} not found") - self._assert_session_owner(session) - - if session.state_snapshot is None: - return ResumeDecisionResult(applied=False) - - snapshot = self._load_snapshot_v2(session.state_snapshot) - pending = snapshot.pending_tool_call - if pending is None: - return ResumeDecisionResult(applied=False) - - if pending.interrupt_id != interrupt_id: - return ResumeDecisionResult(applied=False) - - if pending.status != PendingToolStatus.PENDING_APPROVAL: - return ResumeDecisionResult(applied=False) - - now = datetime.now(timezone.utc) - if pending.expires_at <= now: - expired_pending = pending.model_copy( - update={ - "status": PendingToolStatus.EXPIRED, - "updated_at": now, - } - ) - expired_snapshot = snapshot.model_copy( - update={"pending_tool_call": expired_pending} - ) - session.state_snapshot = expired_snapshot.model_dump(mode="json") - return ResumeDecisionResult(applied=False) - - decision_value = decision.get("decision", "approved") - next_status = ( - PendingToolStatus.APPROVED_EXECUTING - if decision_value == "approved" - else PendingToolStatus.REJECTED - ) - - updated_pending = pending.model_copy( - update={ - "status": next_status, - "decision": decision, - "updated_at": now, - } - ) - updated_snapshot = snapshot.model_copy( - update={"pending_tool_call": updated_pending} - ) - session.state_snapshot = updated_snapshot.model_dump(mode="json") - - return ResumeDecisionResult(applied=True) - - async def stream_run(self, input_data: RunAgentInput) -> AsyncGenerator[str, None]: - self._validate_no_newlines(input_data.runId, field_name="runId") - - yield self._sse_data({"type": "RUN_STARTED", "runId": input_data.runId}) - yield self._sse_data({"type": "TEXT_MESSAGE_START", "messageId": "m1"}) - yield self._sse_data({"type": "TEXT_MESSAGE_CONTENT", "delta": "Hello"}) - yield self._sse_data({"type": "TEXT_MESSAGE_END", "messageId": "m1"}) - yield self._sse_data({"type": "RUN_FINISHED", "runId": input_data.runId}) - - async def prepare_resume(self, run_id: str, input_data: RunAgentInput) -> None: - self._validate_no_newlines(run_id, field_name="runId") - - user_id = self.require_user_id() - await enforce_rate_limit( - scope="agent_resume", - identifier=str(user_id), - limit=DEFAULT_RATE_LIMIT, - window_seconds=DEFAULT_RATE_LIMIT, - ) - - try: - session_id = UUID(run_id) - except ValueError as exc: - raise HTTPException( - status_code=422, detail="run_id must be a valid UUID" - ) from exc - - session = await self._get_session_for_update(session_id) - if session is None or session.user_id != user_id: - raise HTTPException(status_code=404, detail="Session not found") - - if input_data.resume is None: - raise HTTPException(status_code=422, detail="resume payload is required") - - interrupt_id = input_data.resume.get("interruptId") - if not isinstance(interrupt_id, str) or not interrupt_id: - raise HTTPException( - status_code=422, detail="resume.interruptId is required" - ) - - decision_payload = input_data.resume.get("payload", {}) - if not isinstance(decision_payload, dict): - raise HTTPException( - status_code=422, - detail="resume.payload must be an object", - ) - - try: - decision_result = await self.apply_resume_decision( - session_id=session_id, - interrupt_id=interrupt_id, - decision=decision_payload, - ) - except ValueError as exc: - raise HTTPException(status_code=422, detail=str(exc)) from exc - - if not decision_result.applied: - if session.state_snapshot is not None: - snapshot = self._load_snapshot_v2(session.state_snapshot) - pending = snapshot.pending_tool_call - if pending is not None and pending.status == PendingToolStatus.EXPIRED: - await self._session.commit() - raise HTTPException( - status_code=410, - detail="Pending tool call expired", - ) - raise HTTPException( - status_code=409, - detail="Resume decision not applicable", - ) - - await self._session.commit() - - async def stream_resume( - self, run_id: str, input_data: RunAgentInput - ) -> AsyncGenerator[str, None]: - self._validate_no_newlines(run_id, field_name="runId") - - yield self._sse_data({"type": "RUN_STARTED", "runId": run_id}) - yield self._sse_data({"type": "TEXT_MESSAGE_START", "messageId": "m2"}) - yield self._sse_data({"type": "TEXT_MESSAGE_CONTENT", "delta": "Resumed"}) - yield self._sse_data({"type": "TEXT_MESSAGE_END", "messageId": "m2"}) - yield self._sse_data({"type": "RUN_FINISHED", "runId": run_id}) diff --git a/backend/src/v1/agent/tool_dispatcher.py b/backend/src/v1/agent/tool_dispatcher.py deleted file mode 100644 index b1c4d18..0000000 --- a/backend/src/v1/agent/tool_dispatcher.py +++ /dev/null @@ -1,80 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from pydantic import BaseModel - -from v1.agent.tool_registry import validate_tool_spec - - -ALLOWED_BACKEND_TOOLS = frozenset( - { - "srv.search_docs", - "srv.get_user_info", - "srv.send_message", - "srv.transfer_funds", - "srv.delete_file", - } -) - -ALLOWED_FRONTEND_TOOLS = frozenset( - { - "ui.navigate_to", - } -) - - -class InterruptResult(BaseModel): - interrupt_type: str - tool_name: str - tool_args: dict[str, Any] - - -class BackendExecutionResult(BaseModel): - tool_name: str - tool_args: dict[str, Any] - result: Any | None = None - - -class ToolDispatcher: - def dispatch( - self, tool: dict[str, Any] - ) -> InterruptResult | BackendExecutionResult: - return dispatch_tool_call(tool) - - -def dispatch_tool_call( - tool: dict[str, Any], -) -> InterruptResult | BackendExecutionResult: - validate_tool_spec(tool) - - name = tool["name"] - target = tool["execution_target"] - args = tool.get("args", {}) - - if target == "frontend": - if name not in ALLOWED_FRONTEND_TOOLS: - raise ValueError(f"Frontend tool '{name}' not in allowlist") - return InterruptResult( - interrupt_type="tool_execution", - tool_name=name, - tool_args=args, - ) - - if target == "backend": - if name not in ALLOWED_BACKEND_TOOLS: - raise ValueError(f"Backend tool '{name}' not in allowlist") - - requires_approval = tool.get("requires_approval", False) - if requires_approval: - return InterruptResult( - interrupt_type="approval_required", - tool_name=name, - tool_args=args, - ) - return BackendExecutionResult( - tool_name=name, - tool_args=args, - ) - - raise ValueError(f"Unknown execution_target: {target}") diff --git a/backend/src/v1/agent/tool_registry.py b/backend/src/v1/agent/tool_registry.py deleted file mode 100644 index 5c5458b..0000000 --- a/backend/src/v1/agent/tool_registry.py +++ /dev/null @@ -1,24 +0,0 @@ -from __future__ import annotations - -from typing import Any - - -def validate_tool_spec(spec: dict[str, Any]) -> None: - try: - name = spec["name"] - target = spec["execution_target"] - except KeyError as e: - raise ValueError(f"Missing required field: {e.args[0]}") from e - - if not name or not isinstance(name, str): - raise ValueError("Tool name must be a non-empty string") - if not target or not isinstance(target, str): - raise ValueError("execution_target must be a non-empty string") - - if not (name.startswith("ui.") or name.startswith("srv.")): - raise ValueError("Tool name must be in ui.* or srv.* namespace") - - if name.startswith("ui.") and target != "frontend": - raise ValueError("ui.* must use frontend target") - if name.startswith("srv.") and target != "backend": - raise ValueError("srv.* must use backend target") diff --git a/backend/src/v1/router.py b/backend/src/v1/router.py index bf29018..2dafac5 100644 --- a/backend/src/v1/router.py +++ b/backend/src/v1/router.py @@ -3,7 +3,6 @@ from __future__ import annotations from fastapi import APIRouter from core.http.models import HealthResponse -from v1.agent.router import router as agent_router from v1.auth.router import router as auth_router from v1.friendships.router import router as friendships_router from v1.inbox_messages.router import router as inbox_messages_router @@ -17,7 +16,6 @@ router.include_router(auth_router) router.include_router(friendships_router) router.include_router(infra_router) router.include_router(users_router) -router.include_router(agent_router) router.include_router(schedule_items_router) router.include_router(inbox_messages_router) diff --git a/backend/tests/e2e/test_agent_chat_flow.py b/backend/tests/e2e/test_agent_chat_flow.py deleted file mode 100644 index 2621b14..0000000 --- a/backend/tests/e2e/test_agent_chat_flow.py +++ /dev/null @@ -1,98 +0,0 @@ -from __future__ import annotations - -import json -import socket -import threading -import time -from uuid import UUID - -from playwright.sync_api import sync_playwright -import uvicorn - -from app import app -from v1.agent.dependencies import get_agent_service -from v1.agent.schemas import ( - AgentChatEvent, - AgentChatRunRequest, - AgentChatRunResponse, -) -from v1.agent.service import AgentChatService - - -class FakeE2EAgentChatService(AgentChatService): - def __init__(self) -> None: - return None - - async def run(self, payload: AgentChatRunRequest) -> AgentChatRunResponse: - session_id = payload.session_id or UUID("00000000-0000-0000-0000-000000000001") - return AgentChatRunResponse( - session_id=session_id, - output=payload.message, - events=[ - AgentChatEvent(type="run.started", run_id=str(session_id)), - AgentChatEvent( - type="message.delta", message_id="m1", delta=payload.message - ), - AgentChatEvent( - type="run.completed", run_id=str(session_id), output=payload.message - ), - ], - ) - - -def _find_free_port() -> int: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: - sock.bind(("127.0.0.1", 0)) - return sock.getsockname()[1] - - -def _wait_for_port(host: str, port: int, timeout: float = 5.0) -> None: - deadline = time.time() + timeout - while time.time() < deadline: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: - if sock.connect_ex((host, port)) == 0: - return - time.sleep(0.05) - raise RuntimeError("Server did not start in time") - - -def _start_server(host: str, port: int): - config = uvicorn.Config(app, host=host, port=port, log_level="info") - server = uvicorn.Server(config) - thread = threading.Thread(target=server.run, daemon=True) - thread.start() - _wait_for_port(host, port) - return server, thread - - -def test_agent_chat_flow_e2e() -> None: - app.dependency_overrides[get_agent_service] = lambda: FakeE2EAgentChatService() - host = "127.0.0.1" - port = _find_free_port() - server, thread = _start_server(host, port) - - try: - with sync_playwright() as playwright: - request_context = playwright.request.new_context( - base_url=f"http://{host}:{port}" - ) - try: - response = request_context.post( - "/api/v1/agent-chat", - data=json.dumps({"message": "hello"}), - headers={"Content-Type": "application/json"}, - ) - assert response.status == 200 - body = response.json() - assert body["output"] == "hello" - assert [event["type"] for event in body["events"]] == [ - "run.started", - "message.delta", - "run.completed", - ] - finally: - request_context.dispose() - finally: - app.dependency_overrides = {} - server.should_exit = True - thread.join(timeout=5) diff --git a/backend/tests/e2e/test_agent_chat_recent_session_home.py b/backend/tests/e2e/test_agent_chat_recent_session_home.py deleted file mode 100644 index 9d1013e..0000000 --- a/backend/tests/e2e/test_agent_chat_recent_session_home.py +++ /dev/null @@ -1,38 +0,0 @@ -from __future__ import annotations - -from datetime import datetime, timezone -from decimal import Decimal -from uuid import UUID - -from models.agent_chat_session import AgentChatSession, AgentChatSessionStatus -from v1.agent.service import select_recent_session - - -def test_recent_session_home_default_selection() -> None: - sessions = [ - AgentChatSession( - id=UUID("00000000-0000-0000-0000-0000000000a1"), - user_id=UUID("00000000-0000-0000-0000-0000000000c1"), - title="older", - status=AgentChatSessionStatus.COMPLETED, - last_activity_at=datetime(2026, 2, 25, 8, 0, tzinfo=timezone.utc), - message_count=2, - total_tokens=100, - total_cost=Decimal("0.010000"), - ), - AgentChatSession( - id=UUID("00000000-0000-0000-0000-0000000000a2"), - user_id=UUID("00000000-0000-0000-0000-0000000000c1"), - title="newer", - status=AgentChatSessionStatus.RUNNING, - last_activity_at=datetime(2026, 2, 25, 9, 0, tzinfo=timezone.utc), - message_count=3, - total_tokens=120, - total_cost=Decimal("0.020000"), - ), - ] - - selected = select_recent_session(sessions) - - assert selected is not None - assert selected.id == UUID("00000000-0000-0000-0000-0000000000a2") diff --git a/backend/tests/integration/test_agent_chat_event_persistence.py b/backend/tests/integration/test_agent_chat_event_persistence.py deleted file mode 100644 index 445f771..0000000 --- a/backend/tests/integration/test_agent_chat_event_persistence.py +++ /dev/null @@ -1,97 +0,0 @@ -from __future__ import annotations - -from datetime import datetime -from decimal import Decimal -from types import MethodType -from uuid import UUID, uuid4 - -import pytest - -from core.auth.models import CurrentUser -from models.agent_chat_message import AgentChatMessage, AgentChatMessageRole -from models.agent_chat_session import AgentChatSession, AgentChatSessionStatus -from v1.agent.schemas import AgentChatRunRequest -from v1.agent.service import AgentChatService - - -class _FakeAsyncSession: - def __init__(self) -> None: - self.added: list[object] = [] - self.committed = False - self.rolled_back = False - - def add(self, obj: object) -> None: - self.added.append(obj) - - async def flush(self) -> None: - return None - - async def commit(self) -> None: - self.committed = True - - async def rollback(self) -> None: - self.rolled_back = True - - async def refresh(self, obj: object) -> None: - if isinstance(obj, AgentChatSession) and obj.id is None: - obj.id = uuid4() - if isinstance(obj, AgentChatMessage) and obj.id is None: - obj.id = uuid4() - - -@pytest.mark.asyncio -async def test_run_persists_messages_and_emits_ordered_events() -> None: - fake_db = _FakeAsyncSession() - service = AgentChatService( - session=fake_db, # type: ignore[arg-type] - current_user=CurrentUser(id=UUID("00000000-0000-0000-0000-000000000001")), - ) - - async def _resolve_session( - self: AgentChatService, - *, - session_id: object | None, - user_id: UUID, - first_message: str, - now: datetime, - ) -> AgentChatSession: - assert session_id is None - assert first_message == "hello" - return AgentChatSession( - id=UUID("00000000-0000-0000-0000-000000000111"), - user_id=user_id, - title="hello", - status=AgentChatSessionStatus.RUNNING, - last_activity_at=now, - message_count=0, - total_tokens=0, - total_cost=Decimal("0"), - created_at=now, - updated_at=now, - deleted_at=None, - ) - - async def _next_seq_base(self: AgentChatService, session_id: object) -> int: - assert session_id == UUID("00000000-0000-0000-0000-000000000111") - return 2 - - service._resolve_session = MethodType(_resolve_session, service) # type: ignore[method-assign] - service._next_seq_base = MethodType(_next_seq_base, service) # type: ignore[method-assign] - - response = await service.run(AgentChatRunRequest(message="hello")) - - assert fake_db.committed is True - inserted_messages = [ - item for item in fake_db.added if isinstance(item, AgentChatMessage) - ] - assert len(inserted_messages) == 2 - assert [msg.seq for msg in inserted_messages] == [3, 4] - assert [msg.role for msg in inserted_messages] == [ - AgentChatMessageRole.USER, - AgentChatMessageRole.ASSISTANT, - ] - assert [event.type for event in response.events] == [ - "run.started", - "message.delta", - "run.completed", - ] diff --git a/backend/tests/integration/test_agent_chat_routes.py b/backend/tests/integration/test_agent_chat_routes.py deleted file mode 100644 index 0a3aaf0..0000000 --- a/backend/tests/integration/test_agent_chat_routes.py +++ /dev/null @@ -1,78 +0,0 @@ -from __future__ import annotations - -from typing import Callable -from uuid import UUID - -from fastapi.testclient import TestClient - -from app import app -from v1.agent.dependencies import get_agent_service -from v1.agent.schemas import ( - AgentChatEvent, - AgentChatRunRequest, - AgentChatRunResponse, -) -from v1.agent.service import AgentChatService - - -class FakeAgentChatService: - async def run(self, payload: AgentChatRunRequest) -> AgentChatRunResponse: - return AgentChatRunResponse( - session_id=UUID("00000000-0000-0000-0000-000000000001"), - output=payload.message, - events=[ - AgentChatEvent( - type="run.started", run_id="00000000-0000-0000-0000-000000000001" - ), - AgentChatEvent( - type="message.delta", message_id="m1", delta=payload.message - ), - AgentChatEvent( - type="run.completed", - run_id="00000000-0000-0000-0000-000000000001", - output=payload.message, - ), - ], - ) - - -def _override_agent_chat_service( - service: FakeAgentChatService, -) -> Callable[[], AgentChatService]: - def _get_service() -> AgentChatService: - return service # type: ignore[return-value] - - return _get_service - - -def test_run_route_returns_response() -> None: - app.dependency_overrides[get_agent_service] = _override_agent_chat_service( - FakeAgentChatService() - ) - - client = TestClient(app) - try: - response = client.post("/api/v1/agent-chat", json={"message": "hello"}) - assert response.status_code == 200 - body = response.json() - assert body["output"] == "hello" - assert [event["type"] for event in body["events"]] == [ - "run.started", - "message.delta", - "run.completed", - ] - finally: - app.dependency_overrides = {} - - -def test_run_route_validates_payload() -> None: - app.dependency_overrides[get_agent_service] = _override_agent_chat_service( - FakeAgentChatService() - ) - - client = TestClient(app) - try: - response = client.post("/api/v1/agent-chat", json={"message": ""}) - assert response.status_code == 422 - finally: - app.dependency_overrides = {} diff --git a/backend/tests/integration/test_agent_chat_session_persistence.py b/backend/tests/integration/test_agent_chat_session_persistence.py deleted file mode 100644 index 2048074..0000000 --- a/backend/tests/integration/test_agent_chat_session_persistence.py +++ /dev/null @@ -1,20 +0,0 @@ -from __future__ import annotations - -from decimal import Decimal - -from v1.agent.service import aggregate_session_cost - - -def test_aggregate_session_cost_sums_non_negative_values() -> None: - total = aggregate_session_cost([Decimal("0.010000"), Decimal("0.002500")]) - assert total == Decimal("0.012500") - - -def test_aggregate_session_cost_rejects_negative_value() -> None: - try: - aggregate_session_cost([Decimal("-0.010000")]) - raised = False - except ValueError: - raised = True - - assert raised is True diff --git a/backend/tests/integration/test_agent_chat_session_recent_selection.py b/backend/tests/integration/test_agent_chat_session_recent_selection.py deleted file mode 100644 index 7eb8834..0000000 --- a/backend/tests/integration/test_agent_chat_session_recent_selection.py +++ /dev/null @@ -1,42 +0,0 @@ -from __future__ import annotations - -from datetime import datetime, timezone -from decimal import Decimal -from uuid import UUID - -from models.agent_chat_session import AgentChatSession, AgentChatSessionStatus -from v1.agent.service import select_recent_session - - -def test_select_recent_session_uses_last_activity_desc() -> None: - sessions = [ - AgentChatSession( - id=UUID("00000000-0000-0000-0000-000000000001"), - user_id=UUID("00000000-0000-0000-0000-0000000000a1"), - title="older", - status=AgentChatSessionStatus.COMPLETED, - last_activity_at=datetime(2026, 2, 25, 9, 0, tzinfo=timezone.utc), - message_count=1, - total_tokens=1, - total_cost=Decimal("0"), - ), - AgentChatSession( - id=UUID("00000000-0000-0000-0000-000000000002"), - user_id=UUID("00000000-0000-0000-0000-0000000000a1"), - title="newer", - status=AgentChatSessionStatus.RUNNING, - last_activity_at=datetime(2026, 2, 25, 10, 0, tzinfo=timezone.utc), - message_count=2, - total_tokens=2, - total_cost=Decimal("0"), - ), - ] - - selected = select_recent_session(sessions) - - assert selected is not None - assert selected.id == UUID("00000000-0000-0000-0000-000000000002") - - -def test_select_recent_session_returns_none_for_empty_collection() -> None: - assert select_recent_session([]) is None diff --git a/backend/tests/integration/v1/agent/__init__.py b/backend/tests/integration/v1/agent/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/tests/integration/v1/agent/test_chat_routes.py b/backend/tests/integration/v1/agent/test_chat_routes.py deleted file mode 100644 index 36c482c..0000000 --- a/backend/tests/integration/v1/agent/test_chat_routes.py +++ /dev/null @@ -1,82 +0,0 @@ -from __future__ import annotations - -from uuid import UUID - -import pytest -from fastapi.testclient import TestClient - -from app import app -from core.auth.models import CurrentUser -from v1.agent.dependencies import get_agent_service -from v1.agent.schemas import RunAgentInput -from v1.users.dependencies import get_current_user - - -class FakeAgentService: - async def prepare_resume(self, run_id: str, input_data: RunAgentInput): - return None - - async def stream_run(self, input_data: RunAgentInput): - yield 'data: {"type": "RUN_STARTED", "runId": "r1"}\n\n' - yield 'data: {"type": "TEXT_MESSAGE_START", "messageId": "m1"}\n\n' - yield 'data: {"type": "TEXT_MESSAGE_CONTENT", "delta": "Hello"}\n\n' - yield 'data: {"type": "TEXT_MESSAGE_END", "messageId": "m1"}\n\n' - yield 'data: {"type": "RUN_FINISHED", "runId": "r1"}\n\n' - - async def stream_resume(self, run_id: str, input_data: RunAgentInput): - yield 'data: {"type": "RUN_STARTED", "runId": "r1"}\n\n' - yield 'data: {"type": "TEXT_MESSAGE_START", "messageId": "m2"}\n\n' - yield 'data: {"type": "TEXT_MESSAGE_CONTENT", "delta": "Resumed"}\n\n' - yield 'data: {"type": "TEXT_MESSAGE_END", "messageId": "m2"}\n\n' - yield 'data: {"type": "RUN_FINISHED", "runId": "r1"}\n\n' - - -def _get_test_user() -> CurrentUser: - return CurrentUser(id=UUID("00000000-0000-0000-0000-000000000001")) - - -@pytest.fixture -def client() -> TestClient: - app.dependency_overrides[get_current_user] = _get_test_user - app.dependency_overrides[get_agent_service] = lambda: FakeAgentService() - yield TestClient(app) - app.dependency_overrides.clear() - - -class TestChatRoutes: - def test_run_route_streams_sse_events(self, client: TestClient): - payload = { - "threadId": "t1", - "runId": "r1", - "state": {}, - "messages": [], - "tools": [], - "context": [], - "forwardedProps": {}, - } - response = client.post("/api/v1/agent/runs", json=payload) - assert response.status_code == 200 - assert response.headers["content-type"] == "text/event-stream; charset=utf-8" - - events = response.text.split("\n\n") - assert 'data: {"type": "RUN_STARTED"' in events[0] - assert 'data: {"type": "TEXT_MESSAGE_START"' in events[1] - - def test_resume_route_streams_sse_events(self, client: TestClient): - payload = { - "threadId": "t1", - "runId": "r1", - "state": {}, - "messages": [], - "tools": [], - "context": [], - "forwardedProps": {}, - "resume": {"interruptId": "int-1", "payload": {"decision": "approved"}}, - } - response = client.post("/api/v1/agent/runs/r1/resume", json=payload) - assert response.status_code == 200 - assert response.headers["content-type"] == "text/event-stream; charset=utf-8" - - events = response.text.split("\n\n") - assert 'data: {"type": "RUN_STARTED"' in events[0] - assert 'data: {"type": "TEXT_MESSAGE_CONTENT", "delta": "Resumed"' in events[2] diff --git a/backend/tests/integration/v1/agent/test_interrupt_resume_flow.py b/backend/tests/integration/v1/agent/test_interrupt_resume_flow.py deleted file mode 100644 index 26a2c79..0000000 --- a/backend/tests/integration/v1/agent/test_interrupt_resume_flow.py +++ /dev/null @@ -1,144 +0,0 @@ -from __future__ import annotations - -import json -from uuid import UUID - -import pytest -from fastapi.testclient import TestClient - -from app import app -from core.auth.models import CurrentUser -from v1.agent.dependencies import get_agent_service -from v1.agent.schemas import RunAgentInput -from v1.users.dependencies import get_current_user - - -class FakeAgentServiceWithInterrupt: - async def prepare_resume(self, run_id: str, input_data: RunAgentInput): - return None - - async def stream_run(self, input_data: RunAgentInput): - yield 'data: {"type": "RUN_STARTED", "runId": "' + input_data.runId + '"}\n\n' - yield 'data: {"type": "TEXT_MESSAGE_START", "messageId": "m1"}\n\n' - yield 'data: {"type": "TEXT_MESSAGE_CONTENT", "delta": "Let me navigate"}\n\n' - yield 'data: {"type": "TOOL_CALL", "toolName": "ui.navigate_to", "args": {"path": "/home"}}\n\n' - yield ( - 'data: {"type": "RUN_FINISHED", "runId": "' - + input_data.runId - + '", "outcome": "interrupt", "interrupt": {"id": "int-1", "reason": "frontend_tool", "payload": {"toolName": "ui.navigate_to", "args": {"path": "/home"}}}}\n\n' - ) - - async def stream_resume(self, run_id: str, input_data: RunAgentInput): - if input_data.resume and input_data.resume.get("interruptId") == "int-1": - payload = input_data.resume.get("payload", {}) - yield 'data: {"type": "RUN_STARTED", "runId": "' + run_id + '"}\n\n' - yield ( - 'data: {"type": "TOOL_RESULT", "toolName": "ui.navigate_to", "result": ' - + json.dumps(payload.get("result", {})) - + "}\n\n" - ) - yield 'data: {"type": "TEXT_MESSAGE_START", "messageId": "m2"}\n\n' - yield 'data: {"type": "TEXT_MESSAGE_CONTENT", "delta": "Navigation completed"}\n\n' - yield 'data: {"type": "RUN_FINISHED", "runId": "' + run_id + '"}\n\n' - else: - yield ( - 'data: {"type": "RUN_FINISHED", "runId": "' - + run_id - + '", "outcome": "error"}\n\n' - ) - - -def _get_test_user() -> CurrentUser: - return CurrentUser(id=UUID("00000000-0000-0000-0000-000000000001")) - - -@pytest.fixture -def client() -> TestClient: - app.dependency_overrides[get_current_user] = _get_test_user - app.dependency_overrides[get_agent_service] = ( - lambda: FakeAgentServiceWithInterrupt() - ) - yield TestClient(app) - app.dependency_overrides.clear() - - -class TestInterruptResumeFlow: - def test_frontend_tool_interrupt_then_resume_with_result(self, client: TestClient): - payload = { - "threadId": "t1", - "runId": "r1", - "state": {}, - "messages": [{"role": "user", "content": "Navigate to home"}], - "tools": [{"name": "ui.navigate_to", "execution_target": "frontend"}], - "context": [], - "forwardedProps": {}, - } - response = client.post("/api/v1/agent/runs", json=payload) - assert response.status_code == 200 - - events = response.text.split("\n\n") - interrupt_event = [e for e in events if '"outcome": "interrupt"' in e][0] - assert '"id": "int-1"' in interrupt_event - assert '"reason": "frontend_tool"' in interrupt_event - - resume_payload = { - "threadId": "t1", - "runId": "r1", - "state": {}, - "messages": [], - "tools": [], - "context": [], - "forwardedProps": {}, - "resume": { - "interruptId": "int-1", - "payload": {"result": {"success": True}}, - }, - } - resume_response = client.post( - "/api/v1/agent/runs/r1/resume", json=resume_payload - ) - assert resume_response.status_code == 200 - - resume_events = resume_response.text.split("\n\n") - tool_result_event = [e for e in resume_events if '"type": "TOOL_RESULT"' in e][ - 0 - ] - assert '"toolName": "ui.navigate_to"' in tool_result_event - assert '"success": true' in tool_result_event.lower() - - def test_backend_tool_approval_rejected(self, client: TestClient): - payload = { - "threadId": "t2", - "runId": "r2", - "state": {}, - "messages": [{"role": "user", "content": "Transfer funds"}], - "tools": [ - { - "name": "srv.transfer_funds", - "execution_target": "backend", - "requires_approval": True, - } - ], - "context": [], - "forwardedProps": {}, - } - response = client.post("/api/v1/agent/runs", json=payload) - assert response.status_code == 200 - - resume_payload = { - "threadId": "t2", - "runId": "r2", - "state": {}, - "messages": [], - "tools": [], - "context": [], - "forwardedProps": {}, - "resume": { - "interruptId": "int-1", - "payload": {"decision": "rejected", "reason": "User denied"}, - }, - } - resume_response = client.post( - "/api/v1/agent/runs/r2/resume", json=resume_payload - ) - assert resume_response.status_code == 200 diff --git a/backend/tests/unit/core/agent/test_agui_adapter.py b/backend/tests/unit/core/agent/test_agui_adapter.py deleted file mode 100644 index baf3a45..0000000 --- a/backend/tests/unit/core/agent/test_agui_adapter.py +++ /dev/null @@ -1,40 +0,0 @@ -from __future__ import annotations - -import pytest - -from core.agent.agui_adapter import AguiAdapter - - -def test_to_command_maps_payload_fields() -> None: - adapter = AguiAdapter() - - command = adapter.to_command( - { - "message": "hello", - "session_id": "00000000-0000-0000-0000-000000000001", - } - ) - - assert command["message"] == "hello" - assert command["session_id"] == "00000000-0000-0000-0000-000000000001" - - -def test_to_protocol_event_maps_internal_event() -> None: - adapter = AguiAdapter() - - mapped = adapter.to_protocol_event( - { - "kind": "run_completed", - "session_id": "run-1", - "output": "done", - } - ) - - assert mapped == {"type": "run.completed", "run_id": "run-1", "output": "done"} - - -def test_to_protocol_event_raises_for_invalid_event() -> None: - adapter = AguiAdapter() - - with pytest.raises(ValueError): - adapter.to_protocol_event({"kind": "unknown"}) diff --git a/backend/tests/unit/core/agent/test_asr_fun_asr_tool.py b/backend/tests/unit/core/agent/test_asr_fun_asr_tool.py deleted file mode 100644 index 2f73d50..0000000 --- a/backend/tests/unit/core/agent/test_asr_fun_asr_tool.py +++ /dev/null @@ -1,30 +0,0 @@ -from __future__ import annotations - -import pytest - -from core.agent.tools.asr_fun_asr import FunASRTool - - -def test_transcribe_uses_injected_dashscope_callable() -> None: - def fake_transcribe(*, audio_bytes: bytes, filename: str) -> dict[str, str]: - assert filename == "voice.wav" - assert audio_bytes == b"audio" - return {"text": "你好", "request_id": "req-1"} - - tool = FunASRTool(transcribe_callable=fake_transcribe) - - result = tool.transcribe(audio_bytes=b"audio", filename="voice.wav") - - assert result["text"] == "你好" - assert result["request_id"] == "req-1" - assert result["model"] == "fun-asr-realtime-2025-11-07" - - -def test_transcribe_raises_runtime_error_when_provider_fails() -> None: - def fake_transcribe(*, audio_bytes: bytes, filename: str) -> dict[str, str]: - raise RuntimeError("upstream timeout") - - tool = FunASRTool(transcribe_callable=fake_transcribe) - - with pytest.raises(RuntimeError): - tool.transcribe(audio_bytes=b"audio", filename="voice.wav") diff --git a/backend/tests/unit/core/agent/test_cost_tracker.py b/backend/tests/unit/core/agent/test_cost_tracker.py deleted file mode 100644 index 2f0eb73..0000000 --- a/backend/tests/unit/core/agent/test_cost_tracker.py +++ /dev/null @@ -1,48 +0,0 @@ -from __future__ import annotations - -from decimal import Decimal - -from core.agent.litellm_client import get_model_cost - - -def test_get_model_cost_returns_decimal() -> None: - usage = { - "prompt_tokens": 7, - "completion_tokens": 5, - "total_tokens": 12, - "cost": "0.002500", - } - cost = get_model_cost(usage) - assert cost == Decimal("0.002500") - - -def test_get_model_cost_with_no_cost() -> None: - usage = { - "prompt_tokens": 7, - "completion_tokens": 5, - "total_tokens": 12, - } - cost = get_model_cost(usage) - assert cost == Decimal("0") - - -def test_get_model_cost_with_zero_cost() -> None: - usage = { - "prompt_tokens": 7, - "completion_tokens": 5, - "total_tokens": 12, - "cost": "0", - } - cost = get_model_cost(usage) - assert cost == Decimal("0") - - -def test_get_model_cost_with_numeric_cost() -> None: - usage = { - "prompt_tokens": 7, - "completion_tokens": 5, - "total_tokens": 12, - "cost": 0.0025, - } - cost = get_model_cost(usage) - assert cost == Decimal("0.0025") diff --git a/backend/tests/unit/core/agent/test_event_bridge.py b/backend/tests/unit/core/agent/test_event_bridge.py deleted file mode 100644 index 6e103e6..0000000 --- a/backend/tests/unit/core/agent/test_event_bridge.py +++ /dev/null @@ -1,61 +0,0 @@ -from __future__ import annotations - -import pytest - -from core.agent.event_bridge import map_internal_event - - -def test_map_run_started_event() -> None: - event = {"kind": "run_started", "session_id": "s1"} - - mapped = map_internal_event(event) - - assert mapped == {"type": "run.started", "run_id": "s1"} - - -def test_map_message_delta_event() -> None: - event = {"kind": "message_delta", "message_id": "m1", "delta": "hello"} - - mapped = map_internal_event(event) - - assert mapped == {"type": "message.delta", "message_id": "m1", "delta": "hello"} - - -def test_map_tool_events() -> None: - started = { - "kind": "tool_started", - "message_id": "m2", - "tool_name": "asr_fun_asr", - } - completed = { - "kind": "tool_completed", - "message_id": "m2", - "tool_name": "asr_fun_asr", - "result": "ok", - } - - mapped_started = map_internal_event(started) - mapped_completed = map_internal_event(completed) - - assert mapped_started["type"] == "tool.started" - assert mapped_started["tool_name"] == "asr_fun_asr" - assert mapped_completed["type"] == "tool.completed" - assert mapped_completed["result"] == "ok" - - -def test_map_run_completed_event() -> None: - event = {"kind": "run_completed", "session_id": "s1", "output": "done"} - - mapped = map_internal_event(event) - - assert mapped == {"type": "run.completed", "run_id": "s1", "output": "done"} - - -def test_map_unknown_event_raises() -> None: - with pytest.raises(ValueError): - map_internal_event({"kind": "unknown"}) - - -def test_map_event_missing_required_field_raises_value_error() -> None: - with pytest.raises(ValueError): - map_internal_event({"kind": "message_delta", "message_id": "m1"}) diff --git a/backend/tests/unit/core/agent/test_orchestrator_pipeline.py b/backend/tests/unit/core/agent/test_orchestrator_pipeline.py deleted file mode 100644 index 011b2ed..0000000 --- a/backend/tests/unit/core/agent/test_orchestrator_pipeline.py +++ /dev/null @@ -1,104 +0,0 @@ -from __future__ import annotations - -from core.agent.orchestrator import AgentChatOrchestrator - - -async def _intent_stage( - *, message: str, context: dict[str, object] -) -> dict[str, object]: - sequence = context.setdefault("sequence", []) - if isinstance(sequence, list): - sequence.append("intent") - return { - "content": f"intent:{message}", - "usage": {"input_tokens": 2, "output_tokens": 1, "cost": "0.001000"}, - } - - -async def _execution_stage( - *, message: str, context: dict[str, object] -) -> dict[str, object]: - sequence = context.setdefault("sequence", []) - if isinstance(sequence, list): - sequence.append("execution") - return { - "content": f"execution:{message}", - "usage": {"input_tokens": 3, "output_tokens": 2, "cost": "0.002000"}, - } - - -async def _organization_stage( - *, message: str, context: dict[str, object] -) -> dict[str, object]: - sequence = context.setdefault("sequence", []) - if isinstance(sequence, list): - sequence.append("organization") - return { - "content": "final answer", - "usage": {"input_tokens": 4, "output_tokens": 1, "cost": "0.001500"}, - } - - -def test_orchestrator_runs_three_stages_in_order() -> None: - orchestrator = AgentChatOrchestrator( - intent_stage=_intent_stage, - execution_stage=_execution_stage, - organization_stage=_organization_stage, - ) - - result = orchestrator.run_sync(run_id="run-1", user_message="hello") - - assert result.context["sequence"] == ["intent", "execution", "organization"] - assert result.output == "final answer" - assert result.usage["total_tokens"] == 13 - assert result.events[0]["type"] == "run.started" - assert result.events[-1]["type"] == "run.completed" - - -async def _failing_execution_stage( - *, message: str, context: dict[str, object] -) -> dict[str, object]: - sequence = context.setdefault("sequence", []) - if isinstance(sequence, list): - sequence.append("execution") - raise RuntimeError("boom") - - -def test_orchestrator_stops_and_marks_failed_when_middle_stage_raises() -> None: - orchestrator = AgentChatOrchestrator( - intent_stage=_intent_stage, - execution_stage=_failing_execution_stage, - organization_stage=_organization_stage, - ) - - result = orchestrator.run_sync(run_id="run-2", user_message="hello") - - assert result.context["sequence"] == ["intent", "execution"] - assert result.events[-1]["type"] == "run.failed" - assert result.events[-1]["run_id"] == "run-2" - assert "boom" in (result.events[-1].get("error") or "") - assert result.failed is True - assert "boom" in (result.error or "") - - -def test_orchestrator_emits_stage_event_payload_shape() -> None: - orchestrator = AgentChatOrchestrator( - intent_stage=_intent_stage, - execution_stage=_execution_stage, - organization_stage=_organization_stage, - ) - - result = orchestrator.run_sync(run_id="run-3", user_message="hello") - - for event in result.events: - assert "type" in event - assert event.get("run_id") == "run-3" - - stage_events = [ - event for event in result.events if event["type"] == "stage.completed" - ] - assert [event["stage"] for event in stage_events] == [ - "intent", - "execution", - "organization", - ] diff --git a/backend/tests/unit/core/agent/test_session_title_strategy.py b/backend/tests/unit/core/agent/test_session_title_strategy.py deleted file mode 100644 index d1be8e3..0000000 --- a/backend/tests/unit/core/agent/test_session_title_strategy.py +++ /dev/null @@ -1,23 +0,0 @@ -from __future__ import annotations - -from datetime import datetime - -from v1.agent.service import build_session_title - - -def test_build_session_title_truncates_first_message() -> None: - now = datetime(2026, 2, 25, 10, 30) - - title = build_session_title( - "这是一个非常长的标题会被截断到二十四个可见字符用于会话摘要", now=now - ) - - assert len(title) == 24 - - -def test_build_session_title_falls_back_when_message_empty() -> None: - now = datetime(2026, 2, 25, 10, 30) - - title = build_session_title("\n ", now=now) - - assert title == "新对话 2026-02-25 10:30" diff --git a/backend/tests/unit/core/agent_chat/test_agui_adapter.py b/backend/tests/unit/core/agent_chat/test_agui_adapter.py deleted file mode 100644 index baf3a45..0000000 --- a/backend/tests/unit/core/agent_chat/test_agui_adapter.py +++ /dev/null @@ -1,40 +0,0 @@ -from __future__ import annotations - -import pytest - -from core.agent.agui_adapter import AguiAdapter - - -def test_to_command_maps_payload_fields() -> None: - adapter = AguiAdapter() - - command = adapter.to_command( - { - "message": "hello", - "session_id": "00000000-0000-0000-0000-000000000001", - } - ) - - assert command["message"] == "hello" - assert command["session_id"] == "00000000-0000-0000-0000-000000000001" - - -def test_to_protocol_event_maps_internal_event() -> None: - adapter = AguiAdapter() - - mapped = adapter.to_protocol_event( - { - "kind": "run_completed", - "session_id": "run-1", - "output": "done", - } - ) - - assert mapped == {"type": "run.completed", "run_id": "run-1", "output": "done"} - - -def test_to_protocol_event_raises_for_invalid_event() -> None: - adapter = AguiAdapter() - - with pytest.raises(ValueError): - adapter.to_protocol_event({"kind": "unknown"}) diff --git a/backend/tests/unit/core/agent_chat/test_asr_fun_asr_tool.py b/backend/tests/unit/core/agent_chat/test_asr_fun_asr_tool.py deleted file mode 100644 index 2f73d50..0000000 --- a/backend/tests/unit/core/agent_chat/test_asr_fun_asr_tool.py +++ /dev/null @@ -1,30 +0,0 @@ -from __future__ import annotations - -import pytest - -from core.agent.tools.asr_fun_asr import FunASRTool - - -def test_transcribe_uses_injected_dashscope_callable() -> None: - def fake_transcribe(*, audio_bytes: bytes, filename: str) -> dict[str, str]: - assert filename == "voice.wav" - assert audio_bytes == b"audio" - return {"text": "你好", "request_id": "req-1"} - - tool = FunASRTool(transcribe_callable=fake_transcribe) - - result = tool.transcribe(audio_bytes=b"audio", filename="voice.wav") - - assert result["text"] == "你好" - assert result["request_id"] == "req-1" - assert result["model"] == "fun-asr-realtime-2025-11-07" - - -def test_transcribe_raises_runtime_error_when_provider_fails() -> None: - def fake_transcribe(*, audio_bytes: bytes, filename: str) -> dict[str, str]: - raise RuntimeError("upstream timeout") - - tool = FunASRTool(transcribe_callable=fake_transcribe) - - with pytest.raises(RuntimeError): - tool.transcribe(audio_bytes=b"audio", filename="voice.wav") diff --git a/backend/tests/unit/core/agent_chat/test_cost_tracker.py b/backend/tests/unit/core/agent_chat/test_cost_tracker.py deleted file mode 100644 index 30e7130..0000000 --- a/backend/tests/unit/core/agent_chat/test_cost_tracker.py +++ /dev/null @@ -1,82 +0,0 @@ -from __future__ import annotations - -from decimal import Decimal - -import pytest - -from core.agent.cost_tracker import CostTracker - - -def test_normalize_usage_and_cost_aggregation() -> None: - tracker = CostTracker() - - tracker.add_usage( - { - "prompt_tokens": 7, - "completion_tokens": 5, - "cost": "0.002500", - } - ) - tracker.add_usage( - { - "input_tokens": 5, - "output_tokens": 3, - "cost": "0.003000", - "currency": "USD", - } - ) - - snapshot = tracker.snapshot() - - assert snapshot["input_tokens"] == 12 - assert snapshot["output_tokens"] == 8 - assert snapshot["total_tokens"] == 20 - assert snapshot["cost"] == Decimal("0.005500") - assert snapshot["currency"] == "USD" - - -def test_add_usage_rejects_negative_values() -> None: - tracker = CostTracker() - - with pytest.raises(ValueError): - tracker.add_usage({"input_tokens": -1}) - - with pytest.raises(ValueError): - tracker.add_usage({"cost": "-0.010000"}) - - -def test_snapshot_is_zero_before_any_usage() -> None: - tracker = CostTracker() - - snapshot = tracker.snapshot() - - assert snapshot["input_tokens"] == 0 - assert snapshot["output_tokens"] == 0 - assert snapshot["total_tokens"] == 0 - assert snapshot["cost"] == Decimal("0") - assert snapshot["currency"] == "USD" - - -def test_add_usage_rejects_currency_mismatch() -> None: - tracker = CostTracker(currency="USD") - tracker.add_usage({"input_tokens": 1, "output_tokens": 1, "cost": "0.001000"}) - - with pytest.raises(ValueError): - tracker.add_usage( - { - "input_tokens": 1, - "output_tokens": 1, - "cost": "0.001000", - "currency": "CNY", - } - ) - - -def test_add_usage_rejects_non_integral_token_values() -> None: - tracker = CostTracker() - - with pytest.raises(ValueError): - tracker.add_usage({"input_tokens": 1.5}) - - with pytest.raises(ValueError): - tracker.add_usage({"output_tokens": True}) diff --git a/backend/tests/unit/core/agent_chat/test_event_bridge.py b/backend/tests/unit/core/agent_chat/test_event_bridge.py deleted file mode 100644 index 6e103e6..0000000 --- a/backend/tests/unit/core/agent_chat/test_event_bridge.py +++ /dev/null @@ -1,61 +0,0 @@ -from __future__ import annotations - -import pytest - -from core.agent.event_bridge import map_internal_event - - -def test_map_run_started_event() -> None: - event = {"kind": "run_started", "session_id": "s1"} - - mapped = map_internal_event(event) - - assert mapped == {"type": "run.started", "run_id": "s1"} - - -def test_map_message_delta_event() -> None: - event = {"kind": "message_delta", "message_id": "m1", "delta": "hello"} - - mapped = map_internal_event(event) - - assert mapped == {"type": "message.delta", "message_id": "m1", "delta": "hello"} - - -def test_map_tool_events() -> None: - started = { - "kind": "tool_started", - "message_id": "m2", - "tool_name": "asr_fun_asr", - } - completed = { - "kind": "tool_completed", - "message_id": "m2", - "tool_name": "asr_fun_asr", - "result": "ok", - } - - mapped_started = map_internal_event(started) - mapped_completed = map_internal_event(completed) - - assert mapped_started["type"] == "tool.started" - assert mapped_started["tool_name"] == "asr_fun_asr" - assert mapped_completed["type"] == "tool.completed" - assert mapped_completed["result"] == "ok" - - -def test_map_run_completed_event() -> None: - event = {"kind": "run_completed", "session_id": "s1", "output": "done"} - - mapped = map_internal_event(event) - - assert mapped == {"type": "run.completed", "run_id": "s1", "output": "done"} - - -def test_map_unknown_event_raises() -> None: - with pytest.raises(ValueError): - map_internal_event({"kind": "unknown"}) - - -def test_map_event_missing_required_field_raises_value_error() -> None: - with pytest.raises(ValueError): - map_internal_event({"kind": "message_delta", "message_id": "m1"}) diff --git a/backend/tests/unit/core/agent_chat/test_orchestrator_pipeline.py b/backend/tests/unit/core/agent_chat/test_orchestrator_pipeline.py deleted file mode 100644 index 011b2ed..0000000 --- a/backend/tests/unit/core/agent_chat/test_orchestrator_pipeline.py +++ /dev/null @@ -1,104 +0,0 @@ -from __future__ import annotations - -from core.agent.orchestrator import AgentChatOrchestrator - - -async def _intent_stage( - *, message: str, context: dict[str, object] -) -> dict[str, object]: - sequence = context.setdefault("sequence", []) - if isinstance(sequence, list): - sequence.append("intent") - return { - "content": f"intent:{message}", - "usage": {"input_tokens": 2, "output_tokens": 1, "cost": "0.001000"}, - } - - -async def _execution_stage( - *, message: str, context: dict[str, object] -) -> dict[str, object]: - sequence = context.setdefault("sequence", []) - if isinstance(sequence, list): - sequence.append("execution") - return { - "content": f"execution:{message}", - "usage": {"input_tokens": 3, "output_tokens": 2, "cost": "0.002000"}, - } - - -async def _organization_stage( - *, message: str, context: dict[str, object] -) -> dict[str, object]: - sequence = context.setdefault("sequence", []) - if isinstance(sequence, list): - sequence.append("organization") - return { - "content": "final answer", - "usage": {"input_tokens": 4, "output_tokens": 1, "cost": "0.001500"}, - } - - -def test_orchestrator_runs_three_stages_in_order() -> None: - orchestrator = AgentChatOrchestrator( - intent_stage=_intent_stage, - execution_stage=_execution_stage, - organization_stage=_organization_stage, - ) - - result = orchestrator.run_sync(run_id="run-1", user_message="hello") - - assert result.context["sequence"] == ["intent", "execution", "organization"] - assert result.output == "final answer" - assert result.usage["total_tokens"] == 13 - assert result.events[0]["type"] == "run.started" - assert result.events[-1]["type"] == "run.completed" - - -async def _failing_execution_stage( - *, message: str, context: dict[str, object] -) -> dict[str, object]: - sequence = context.setdefault("sequence", []) - if isinstance(sequence, list): - sequence.append("execution") - raise RuntimeError("boom") - - -def test_orchestrator_stops_and_marks_failed_when_middle_stage_raises() -> None: - orchestrator = AgentChatOrchestrator( - intent_stage=_intent_stage, - execution_stage=_failing_execution_stage, - organization_stage=_organization_stage, - ) - - result = orchestrator.run_sync(run_id="run-2", user_message="hello") - - assert result.context["sequence"] == ["intent", "execution"] - assert result.events[-1]["type"] == "run.failed" - assert result.events[-1]["run_id"] == "run-2" - assert "boom" in (result.events[-1].get("error") or "") - assert result.failed is True - assert "boom" in (result.error or "") - - -def test_orchestrator_emits_stage_event_payload_shape() -> None: - orchestrator = AgentChatOrchestrator( - intent_stage=_intent_stage, - execution_stage=_execution_stage, - organization_stage=_organization_stage, - ) - - result = orchestrator.run_sync(run_id="run-3", user_message="hello") - - for event in result.events: - assert "type" in event - assert event.get("run_id") == "run-3" - - stage_events = [ - event for event in result.events if event["type"] == "stage.completed" - ] - assert [event["stage"] for event in stage_events] == [ - "intent", - "execution", - "organization", - ] diff --git a/backend/tests/unit/core/agent_chat/test_session_title_strategy.py b/backend/tests/unit/core/agent_chat/test_session_title_strategy.py deleted file mode 100644 index d1be8e3..0000000 --- a/backend/tests/unit/core/agent_chat/test_session_title_strategy.py +++ /dev/null @@ -1,23 +0,0 @@ -from __future__ import annotations - -from datetime import datetime - -from v1.agent.service import build_session_title - - -def test_build_session_title_truncates_first_message() -> None: - now = datetime(2026, 2, 25, 10, 30) - - title = build_session_title( - "这是一个非常长的标题会被截断到二十四个可见字符用于会话摘要", now=now - ) - - assert len(title) == 24 - - -def test_build_session_title_falls_back_when_message_empty() -> None: - now = datetime(2026, 2, 25, 10, 30) - - title = build_session_title("\n ", now=now) - - assert title == "新对话 2026-02-25 10:30" diff --git a/backend/tests/unit/core/config/test_crewai_template_loader.py b/backend/tests/unit/core/config/test_crewai_template_loader.py deleted file mode 100644 index 88c4870..0000000 --- a/backend/tests/unit/core/config/test_crewai_template_loader.py +++ /dev/null @@ -1,132 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import pytest - -from core.agent.crewai.template_loader import ( - load_crewai_template, - load_tools_whitelist, - validate_workflow_stages, -) - - -def _write(path: Path, content: str) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(content, encoding="utf-8") - - -def _prepare_static_root(root: Path) -> Path: - _write( - root / "agents.yaml", - """ -intent: - role: Intent Agent -execution: - role: Execution Agent -organization: - role: Organization Agent -""".strip(), - ) - _write( - root / "tasks.yaml", - """ -intent: - description: classify -execution: - description: run task -organization: - description: summarize -""".strip(), - ) - _write( - root / "workflow.yaml", - """ -stages: - - intent - - execution - - organization -""".strip(), - ) - _write( - root / "tools.yaml", - """ -tools: - - asr_fun_asr - - doc_extract -""".strip(), - ) - return root - - -def test_load_crewai_template_success_when_all_files_valid(tmp_path: Path) -> None: - static_root = _prepare_static_root(tmp_path) - - template = load_crewai_template(static_root) - - assert set(template.agents.keys()) == {"intent", "execution", "organization"} - assert set(template.tasks.keys()) == {"intent", "execution", "organization"} - assert template.workflow["stages"] == ["intent", "execution", "organization"] - assert template.tools_whitelist == {"asr_fun_asr", "doc_extract"} - - -def test_load_crewai_template_raises_file_not_found_when_required_file_missing( - tmp_path: Path, -) -> None: - static_root = _prepare_static_root(tmp_path) - (static_root / "tasks.yaml").unlink() - - with pytest.raises(FileNotFoundError): - load_crewai_template(static_root) - - -def test_load_crewai_template_raises_value_error_when_workflow_stages_invalid( - tmp_path: Path, -) -> None: - static_root = _prepare_static_root(tmp_path) - _write( - static_root / "workflow.yaml", - """ -stages: - - execution - - intent - - organization -""".strip(), - ) - - with pytest.raises(ValueError): - load_crewai_template(static_root) - - -def test_load_tools_whitelist_from_tools_yaml(tmp_path: Path) -> None: - static_root = _prepare_static_root(tmp_path) - - whitelist = load_tools_whitelist(static_root) - - assert whitelist == {"asr_fun_asr", "doc_extract"} - - -def test_validate_workflow_stages_accepts_exact_intent_execution_organization() -> None: - validate_workflow_stages(["intent", "execution", "organization"]) - - -def test_validate_workflow_stages_rejects_extra_or_missing_stage() -> None: - with pytest.raises(ValueError): - validate_workflow_stages(["intent", "execution"]) - with pytest.raises(ValueError): - validate_workflow_stages(["intent", "execution", "organization", "extra"]) - - -def test_load_tools_whitelist_rejects_non_string_item(tmp_path: Path) -> None: - static_root = _prepare_static_root(tmp_path) - _write( - static_root / "tools.yaml", - """ -tools: - - asr_fun_asr - - 123 -""".strip(), - ) - - with pytest.raises(ValueError): - load_tools_whitelist(static_root) diff --git a/backend/tests/unit/core/test_agent_init_data.py b/backend/tests/unit/core/test_agent_init_data.py deleted file mode 100644 index dfb0bab..0000000 --- a/backend/tests/unit/core/test_agent_init_data.py +++ /dev/null @@ -1,188 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import pytest -from sqlalchemy import Column, String, Table, func, select -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine - -from core.db.base import Base -from core.config.initial import init_data -from models.llm import Llm -from models.llm_factory import LlmFactory - - -def test_llm_catalog_file_exists_and_has_required_fields() -> None: - catalog_path = ( - Path(__file__).resolve().parents[3] - / "src" - / "core" - / "config" - / "static" - / "database" - / "llm_catalog.yaml" - ) - - catalog = init_data.load_llm_catalog(catalog_path) - - assert len(catalog["factories"]) == 6 - assert len(catalog["llms"]) == 2 - assert set(catalog["factories"][0].keys()) == {"name", "request_url", "avatar"} - assert set(catalog["llms"][0].keys()) == {"model_code", "factory_name"} - - -def test_load_llm_catalog_raises_on_invalid_structure(tmp_path: Path) -> None: - catalog_path = tmp_path / "llm_catalog.yaml" - catalog_path.write_text( - """ -factories: - - name: qwen -llms: - - model_code: qwen3.5-flash -""".strip(), - encoding="utf-8", - ) - - with pytest.raises(ValueError): - init_data.load_llm_catalog(catalog_path) - - -@pytest.mark.asyncio -async def test_initialize_data_is_idempotent(monkeypatch: pytest.MonkeyPatch) -> None: - users_table = Table( - "users", - Base.metadata, - Column("id", String, primary_key=True), - schema="auth", - extend_existing=True, - ) - engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False) - session_maker = async_sessionmaker( - bind=engine, class_=AsyncSession, expire_on_commit=False - ) - - async with engine.begin() as conn: - await conn.exec_driver_sql("ATTACH DATABASE ':memory:' AS auth") - await conn.run_sync(Base.metadata.create_all) - - monkeypatch.setattr(init_data, "AsyncSessionLocal", session_maker) - - first = await init_data.initialize_data() - second = await init_data.initialize_data() - - assert first is True - assert second is True - - async with session_maker() as session: - factory_count = await session.scalar( - select(func.count()).select_from(LlmFactory) - ) - llm_count = await session.scalar(select(func.count()).select_from(Llm)) - - assert factory_count == 6 - assert llm_count == 2 - - Base.metadata.remove(users_table) - await engine.dispose() - - -@pytest.mark.asyncio -async def test_initialize_data_rolls_back_on_invalid_factory_mapping( - monkeypatch: pytest.MonkeyPatch, -) -> None: - users_table = Table( - "users", - Base.metadata, - Column("id", String, primary_key=True), - schema="auth", - extend_existing=True, - ) - engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False) - session_maker = async_sessionmaker( - bind=engine, class_=AsyncSession, expire_on_commit=False - ) - - async with engine.begin() as conn: - await conn.exec_driver_sql("ATTACH DATABASE ':memory:' AS auth") - await conn.run_sync(Base.metadata.create_all) - - monkeypatch.setattr(init_data, "AsyncSessionLocal", session_maker) - monkeypatch.setattr( - init_data, - "load_llm_catalog", - lambda *_: { - "factories": [ - { - "name": "qwen", - "request_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", - "avatar": "https://cdn.example.com/qwen.png", - } - ], - "llms": [ - { - "model_code": "qwen3.5-flash", - "factory_id": "missing_factory", - } - ], - }, - ) - - with pytest.raises(RuntimeError): - await init_data.initialize_data() - - async with session_maker() as session: - factory_count = await session.scalar( - select(func.count()).select_from(LlmFactory) - ) - llm_count = await session.scalar(select(func.count()).select_from(Llm)) - - assert factory_count == 0 - assert llm_count == 0 - - Base.metadata.remove(users_table) - await engine.dispose() - - -def test_user_agent_catalog_file_exists_and_has_required_fields() -> None: - catalog_path = ( - Path(__file__).resolve().parents[3] - / "src" - / "core" - / "config" - / "static" - / "database" - / "user_agent_catalog.yaml" - ) - - assert catalog_path.exists(), f"Catalog file not found: {catalog_path}" - - catalog = init_data.load_user_agent_catalog(catalog_path) - - assert "agents" in catalog - assert isinstance(catalog["agents"], list) - assert len(catalog["agents"]) == 3 - - for agent in catalog["agents"]: - assert "agent_type" in agent - assert "llm_model_code" in agent - assert "status" in agent - assert "config" in agent - assert isinstance(agent["config"], dict) - - -def test_load_user_agent_catalog_raises_on_invalid_structure( - tmp_path: Path, -) -> None: - catalog_path = tmp_path / "user_agent_catalog.yaml" - catalog_path.write_text( - """ -agents: - - agent_type: TEST - llm_model_code: test-model - status: ACTIVE -""".strip(), - encoding="utf-8", - ) - - with pytest.raises(ValueError, match="Invalid user agent catalog"): - init_data.load_user_agent_catalog(catalog_path) diff --git a/backend/tests/unit/database/test_agent_chat_migration_contract.py b/backend/tests/unit/database/test_agent_chat_migration_contract.py deleted file mode 100644 index 7eaa4c6..0000000 --- a/backend/tests/unit/database/test_agent_chat_migration_contract.py +++ /dev/null @@ -1,27 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - - -def test_initial_migration_exists_and_creates_expected_tables() -> None: - versions_dir = Path(__file__).resolve().parents[3] / "alembic" / "versions" - migration_files = sorted(versions_dir.glob("20260226_*.py")) - assert len(migration_files) == 5, "split initial migrations should exist" - - content = "\n".join(m.read_text(encoding="utf-8") for m in migration_files) - - # New tables from social data model redesign - assert "create_table(" in content and "automation_jobs" in content - assert "create_table(" in content and "user_agents" in content - assert "create_table(" in content and "memories" in content - assert "create_table(" in content and "friendships" in content - assert "create_table(" in content and "groups" in content - assert "create_table(" in content and "group_members" in content - assert "create_table(" in content and "schedule_items" in content - assert "create_table(" in content and "schedule_subscriptions" in content - assert "create_table(" in content and "inbox_messages" in content - assert "create_table(" in content and "todos" in content - assert "create_table(" in content and "todo_sources" in content - assert "create_table(" in content and "profiles" in content - assert "create_table(" in content and "sessions" in content - assert "create_table(" in content and "messages" in content diff --git a/backend/tests/unit/database/test_agent_chat_models.py b/backend/tests/unit/database/test_agent_chat_models.py deleted file mode 100644 index 7c7a000..0000000 --- a/backend/tests/unit/database/test_agent_chat_models.py +++ /dev/null @@ -1,119 +0,0 @@ -from __future__ import annotations - -from uuid import uuid4 - -import pytest -from sqlalchemy import Column, String, Table, select -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine - -from core.db.base import Base -from models.agent_chat_message import AgentChatMessage -from models.agent_chat_session import AgentChatSession, AgentChatSessionStatus -from models.llm import Llm -from models.llm_factory import LlmFactory - - -@pytest.fixture -async def db_engine(): - users_table = Table( - "users", - Base.metadata, - Column("id", String, primary_key=True), - schema="auth", - extend_existing=True, - ) - engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False) - async with engine.begin() as conn: - await conn.exec_driver_sql("ATTACH DATABASE ':memory:' AS auth") - await conn.run_sync(Base.metadata.create_all) - yield engine - Base.metadata.remove(users_table) - await engine.dispose() - - -@pytest.fixture -async def db_session(db_engine): - async_session = async_sessionmaker( - bind=db_engine, - class_=AsyncSession, - expire_on_commit=False, - ) - async with async_session() as session: - yield session - await session.rollback() - - -@pytest.mark.asyncio -async def test_llm_factory_and_llm_relationship(db_session: AsyncSession) -> None: - factory = LlmFactory( - name="qwen", - request_url="https://dashscope.aliyuncs.com/compatible-mode/v1", - avatar="https://cdn.example.com/qwen.png", - ) - db_session.add(factory) - await db_session.flush() - - llm = Llm( - factory_id=factory.id, - model_code="qwen3.5-flash", - ) - db_session.add(llm) - await db_session.commit() - - found_llm = await db_session.get(Llm, llm.id) - assert found_llm is not None - assert found_llm.factory_id == factory.id - - -@pytest.mark.asyncio -async def test_session_status_supports_required_values( - db_session: AsyncSession, -) -> None: - user_id = uuid4() - session = AgentChatSession( - user_id=user_id, - title="test", - status="pending", - ) - db_session.add(session) - await db_session.commit() - - statuses = [ - AgentChatSessionStatus.PENDING, - AgentChatSessionStatus.RUNNING, - AgentChatSessionStatus.COMPLETED, - AgentChatSessionStatus.FAILED, - ] - for status in statuses: - session.status = status - await db_session.commit() - await db_session.refresh(session) - assert session.status == status - - -@pytest.mark.asyncio -async def test_messages_role_supports_tool(db_session: AsyncSession) -> None: - user_id = uuid4() - session = AgentChatSession( - user_id=user_id, - title="tool test", - status="pending", - ) - db_session.add(session) - await db_session.flush() - - message = AgentChatMessage( - session_id=session.id, - seq=1, - role="tool", - content="tool output", - cost=0, - ) - db_session.add(message) - await db_session.commit() - - result = await db_session.execute( - select(AgentChatMessage).where(AgentChatMessage.session_id == session.id) - ) - found = result.scalar_one() - assert found.role == "tool" diff --git a/backend/tests/unit/v1/agent/__init__.py b/backend/tests/unit/v1/agent/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/tests/unit/v1/agent/test_agent_security_rules.py b/backend/tests/unit/v1/agent/test_agent_security_rules.py deleted file mode 100644 index 5ed52f1..0000000 --- a/backend/tests/unit/v1/agent/test_agent_security_rules.py +++ /dev/null @@ -1,80 +0,0 @@ -from __future__ import annotations - -from datetime import datetime, timedelta, timezone -from uuid import UUID, uuid4 - -import pytest - -from core.auth.models import CurrentUser -from models.agent_chat_session import ( - AgentChatSession, - AgentChatSessionStatus, - SessionType, -) -from v1.agent.service import AgentChatService -from v1.agent.tool_registry import validate_tool_spec - - -class TestAgentSecurityRules: - def test_tool_name_must_be_allowlisted(self): - validate_tool_spec({"name": "ui.navigate_to", "execution_target": "frontend"}) - validate_tool_spec({"name": "srv.search_docs", "execution_target": "backend"}) - - def test_tool_name_rejected_if_not_in_namespace(self): - try: - validate_tool_spec( - {"name": "malicious.tool", "execution_target": "frontend"} - ) - except ValueError: - pass - else: - raise AssertionError("Should have raised ValueError for unknown namespace") - - @pytest.mark.asyncio - async def test_frontend_result_fails_when_interrupt_mismatch(self): - session = AgentChatSession( - id=uuid4(), - user_id=UUID("00000000-0000-0000-0000-000000000001"), - session_type=SessionType.CHAT, - status=AgentChatSessionStatus.RUNNING, - ) - - class FakeAsyncSession: - def __init__(self, session_obj: AgentChatSession) -> None: - self._session_obj = session_obj - - async def execute(self, stmt: object): - class _Result: - def __init__(self, session_obj: AgentChatSession | None) -> None: - self._session_obj = session_obj - - def scalar_one_or_none(self) -> AgentChatSession | None: - return self._session_obj - - return _Result(self._session_obj) - - async def scalar(self, stmt: object) -> AgentChatSession | None: - return self._session_obj - - service = AgentChatService( - session=FakeAsyncSession(session), # type: ignore[arg-type] - current_user=CurrentUser(id=UUID("00000000-0000-0000-0000-000000000001")), - ) - - await service.set_pending_tool_call( - session_id=session.id, - interrupt_id="int-1", - tool_name="srv.transfer_funds", - tool_args={"to": "u2", "amount": 100}, - expires_at=datetime.now(timezone.utc) + timedelta(minutes=5), - thread_id="t1", - run_id="r1", - ) - - result = await service.apply_resume_decision( - session_id=session.id, - interrupt_id="int-other", - decision={"decision": "approved"}, - ) - - assert result.applied is False diff --git a/backend/tests/unit/v1/agent/test_crewai_flow.py b/backend/tests/unit/v1/agent/test_crewai_flow.py deleted file mode 100644 index c7ab211..0000000 --- a/backend/tests/unit/v1/agent/test_crewai_flow.py +++ /dev/null @@ -1,25 +0,0 @@ -from __future__ import annotations - -import pytest - -from v1.agent.crewai_flow import AgentFlow - - -class TestCrewAIFlow: - @pytest.mark.asyncio - async def test_flow_stages_run_in_order(self): - flow = AgentFlow() - await flow.run() - assert flow.state.stage_trace == ["intent", "execution", "reporting"] - - @pytest.mark.asyncio - async def test_flow_state_initialized(self): - flow = AgentFlow() - assert flow.state.stage_trace == [] - assert flow.state.current_stage is None - - @pytest.mark.asyncio - async def test_flow_updates_current_stage(self): - flow = AgentFlow() - await flow.run() - assert flow.state.current_stage == "reporting" diff --git a/backend/tests/unit/v1/agent/test_resume_idempotency.py b/backend/tests/unit/v1/agent/test_resume_idempotency.py deleted file mode 100644 index a11048e..0000000 --- a/backend/tests/unit/v1/agent/test_resume_idempotency.py +++ /dev/null @@ -1,187 +0,0 @@ -from __future__ import annotations - -from datetime import datetime, timedelta, timezone -from uuid import UUID, uuid4 - -import pytest - -from models.agent_chat_session import ( - AgentChatSession, - AgentChatSessionStatus, - SessionType, -) -from v1.agent.service import AgentChatService - - -class FakeAsyncSession: - def __init__(self) -> None: - self.added: list[object] = [] - self._sessions: dict[UUID, AgentChatSession] = {} - self.last_fetch_with_lock = False - - def add(self, obj: object) -> None: - self.added.append(obj) - if isinstance(obj, AgentChatSession): - self._sessions[obj.id] = obj - - async def flush(self) -> None: - return None - - async def commit(self) -> None: - pass - - async def rollback(self) -> None: - pass - - async def refresh(self, obj: object) -> None: - pass - - async def execute(self, stmt: object): - self.last_fetch_with_lock = "FOR UPDATE" in str(stmt) - - class _Result: - def __init__(self, session_obj: AgentChatSession | None) -> None: - self._session_obj = session_obj - - def scalar_one_or_none(self) -> AgentChatSession | None: - return self._session_obj - - return _Result(next(iter(self._sessions.values()), None)) - - async def scalar(self, stmt: object) -> AgentChatSession | None: - for session in self._sessions.values(): - return session - return None - - -@pytest.fixture -def fake_db() -> FakeAsyncSession: - return FakeAsyncSession() - - -@pytest.fixture -def session(fake_db: FakeAsyncSession) -> AgentChatSession: - sess = AgentChatSession( - id=uuid4(), - user_id=uuid4(), - session_type=SessionType.CHAT, - status=AgentChatSessionStatus.RUNNING, - ) - fake_db.add(sess) - return sess - - -@pytest.fixture -def service(fake_db: FakeAsyncSession) -> AgentChatService: - return AgentChatService(fake_db, current_user=None) # type: ignore[arg-type] - - -class TestResumeIdempotency: - @pytest.mark.asyncio - async def test_resume_is_idempotent( - self, - service: AgentChatService, - session: AgentChatSession, - fake_db: FakeAsyncSession, - ): - expires_at = datetime.now(timezone.utc) + timedelta(hours=1) - await service.set_pending_tool_call( - session_id=session.id, - interrupt_id="int-1", - tool_name="srv.transfer_funds", - tool_args={"to": "u2", "amount": 100}, - expires_at=expires_at, - thread_id="t1", - run_id="r1", - ) - - first = await service.apply_resume_decision( - session_id=session.id, - interrupt_id="int-1", - decision={"decision": "approved"}, - ) - second = await service.apply_resume_decision( - session_id=session.id, - interrupt_id="int-1", - decision={"decision": "approved"}, - ) - - assert first.applied is True - assert second.applied is False - assert fake_db.last_fetch_with_lock is True - - @pytest.mark.asyncio - async def test_resume_updates_status_to_approved( - self, service: AgentChatService, session: AgentChatSession - ): - expires_at = datetime.now(timezone.utc) + timedelta(hours=1) - await service.set_pending_tool_call( - session_id=session.id, - interrupt_id="int-2", - tool_name="srv.delete_file", - tool_args={"file_id": "f1"}, - expires_at=expires_at, - thread_id="t1", - run_id="r1", - ) - - result = await service.apply_resume_decision( - session_id=session.id, - interrupt_id="int-2", - decision={"decision": "approved"}, - ) - - assert result.applied is True - snapshot = await service.get_state_snapshot(session.id) - assert snapshot["pending_tool_call"]["status"] == "APPROVED_EXECUTING" - assert snapshot["pending_tool_call"]["decision"] == {"decision": "approved"} - - @pytest.mark.asyncio - async def test_resume_updates_status_to_rejected( - self, service: AgentChatService, session: AgentChatSession - ): - expires_at = datetime.now(timezone.utc) + timedelta(hours=1) - await service.set_pending_tool_call( - session_id=session.id, - interrupt_id="int-3", - tool_name="srv.transfer_funds", - tool_args={"to": "u2", "amount": 100}, - expires_at=expires_at, - thread_id="t1", - run_id="r1", - ) - - result = await service.apply_resume_decision( - session_id=session.id, - interrupt_id="int-3", - decision={"decision": "rejected"}, - ) - - assert result.applied is True - snapshot = await service.get_state_snapshot(session.id) - assert snapshot["pending_tool_call"]["status"] == "REJECTED" - - @pytest.mark.asyncio - async def test_resume_expired_pending_marks_expired_and_not_applied( - self, service: AgentChatService, session: AgentChatSession - ): - expires_at = datetime.now(timezone.utc) - timedelta(seconds=1) - await service.set_pending_tool_call( - session_id=session.id, - interrupt_id="int-expired", - tool_name="srv.transfer_funds", - tool_args={"to": "u2", "amount": 100}, - expires_at=expires_at, - thread_id="t1", - run_id="r1", - ) - - result = await service.apply_resume_decision( - session_id=session.id, - interrupt_id="int-expired", - decision={"decision": "approved"}, - ) - - assert result.applied is False - snapshot = await service.get_state_snapshot(session.id) - assert snapshot["pending_tool_call"]["status"] == "EXPIRED" diff --git a/backend/tests/unit/v1/agent/test_schemas.py b/backend/tests/unit/v1/agent/test_schemas.py deleted file mode 100644 index d9ee806..0000000 --- a/backend/tests/unit/v1/agent/test_schemas.py +++ /dev/null @@ -1,127 +0,0 @@ -from datetime import datetime, timezone - -import pytest - -from v1.agent.schemas import AgentSessionSnapshot, RunAgentInput - - -class TestRunAgentInput: - def test_requires_full_fields(self): - payload = { - "threadId": "t1", - "runId": "r1", - "state": {}, - "messages": [], - "tools": [], - "context": [], - "forwardedProps": {}, - } - model = RunAgentInput.model_validate(payload) - assert model.threadId == "t1" - assert model.runId == "r1" - assert model.parentRunId is None - assert model.state == {} - assert model.messages == [] - assert model.tools == [] - assert model.context == [] - assert model.forwardedProps == {} - assert model.resume is None - - def test_resume_optional(self): - payload = { - "threadId": "t1", - "runId": "r2", - "state": {}, - "messages": [], - "tools": [], - "context": [], - "forwardedProps": {}, - "resume": {"interruptId": "int-1", "payload": {"decision": "approved"}}, - } - model = RunAgentInput.model_validate(payload) - assert model.resume is not None - assert model.resume["interruptId"] == "int-1" - assert model.resume["payload"]["decision"] == "approved" - - def test_parent_run_id_optional(self): - payload = { - "threadId": "t1", - "runId": "r3", - "parentRunId": "p1", - "state": {"key": "value"}, - "messages": [{"role": "user", "content": "hello"}], - "tools": [{"name": "ui.navigate_to"}], - "context": [{"type": "user", "id": "u1"}], - "forwardedProps": {"theme": "dark"}, - } - model = RunAgentInput.model_validate(payload) - assert model.parentRunId == "p1" - assert model.state == {"key": "value"} - assert len(model.messages) == 1 - assert model.messages[0]["role"] == "user" - - -class TestAgentSessionSnapshot: - def test_state_snapshot_v2_model_accepts_valid_payload(self): - payload = { - "version": 2, - "pending_tool_call": { - "interrupt_id": "int-1", - "tool_name": "srv.transfer_funds", - "tool_args": {"to": "u2", "amount": 100}, - "status": "PENDING_APPROVAL", - "expires_at": "2026-03-03T12:00:00Z", - "decision": None, - "result": None, - "updated_at": "2026-03-03T11:59:00Z", - }, - "run_context": {"thread_id": "t1", "run_id": "r1"}, - } - - model = AgentSessionSnapshot.model_validate(payload) - - assert model.version == 2 - assert model.pending_tool_call is not None - assert model.pending_tool_call.interrupt_id == "int-1" - assert model.pending_tool_call.updated_at == datetime( - 2026, 3, 3, 11, 59, tzinfo=timezone.utc - ) - - def test_state_snapshot_v2_rejects_wrong_version(self): - payload = { - "version": 1, - "pending_tool_call": None, - "run_context": {"thread_id": "t1", "run_id": "r1"}, - } - - with pytest.raises(ValueError): - AgentSessionSnapshot.model_validate(payload) - - def test_state_snapshot_v2_requires_pending_tool_call_key(self): - payload = { - "version": 2, - "run_context": {"thread_id": "t1", "run_id": "r1"}, - } - - with pytest.raises(ValueError): - AgentSessionSnapshot.model_validate(payload) - - def test_state_snapshot_v2_rejects_extra_fields(self): - payload = { - "version": 2, - "pending_tool_call": { - "interrupt_id": "int-1", - "tool_name": "srv.transfer_funds", - "tool_args": {"to": "u2", "amount": 100}, - "status": "PENDING_APPROVAL", - "expires_at": "2026-03-03T12:00:00Z", - "decision": None, - "result": None, - "updated_at": "2026-03-03T11:59:00Z", - "unexpected": True, - }, - "run_context": {"thread_id": "t1", "run_id": "r1", "foo": "bar"}, - } - - with pytest.raises(ValueError): - AgentSessionSnapshot.model_validate(payload) diff --git a/backend/tests/unit/v1/agent/test_service_pending_tool_call.py b/backend/tests/unit/v1/agent/test_service_pending_tool_call.py deleted file mode 100644 index 2662f0c..0000000 --- a/backend/tests/unit/v1/agent/test_service_pending_tool_call.py +++ /dev/null @@ -1,168 +0,0 @@ -from __future__ import annotations - -from datetime import datetime, timedelta, timezone -from uuid import UUID, uuid4 - -import pytest - -from models.agent_chat_session import ( - AgentChatSession, - AgentChatSessionStatus, - SessionType, -) -from v1.agent.service import AgentChatService - - -class FakeAsyncSession: - def __init__(self) -> None: - self.added: list[object] = [] - self._sessions: dict[UUID, AgentChatSession] = {} - - def add(self, obj: object) -> None: - self.added.append(obj) - if isinstance(obj, AgentChatSession): - self._sessions[obj.id] = obj - - async def flush(self) -> None: - return None - - async def commit(self) -> None: - pass - - async def rollback(self) -> None: - pass - - async def refresh(self, obj: object) -> None: - pass - - async def execute(self, stmt: object): - class _Result: - def __init__(self, session_obj: AgentChatSession | None) -> None: - self._session_obj = session_obj - - def scalar_one_or_none(self) -> AgentChatSession | None: - return self._session_obj - - return _Result(next(iter(self._sessions.values()), None)) - - async def scalar(self, stmt: object) -> AgentChatSession | None: - for session in self._sessions.values(): - return session - return None - - -@pytest.fixture -def fake_db() -> FakeAsyncSession: - return FakeAsyncSession() - - -@pytest.fixture -def session(fake_db: FakeAsyncSession) -> AgentChatSession: - sess = AgentChatSession( - id=uuid4(), - user_id=uuid4(), - session_type=SessionType.CHAT, - status=AgentChatSessionStatus.RUNNING, - ) - fake_db.add(sess) - return sess - - -@pytest.fixture -def service(fake_db: FakeAsyncSession) -> AgentChatService: - return AgentChatService(fake_db, current_user=None) # type: ignore[arg-type] - - -class TestPendingToolCall: - @pytest.mark.asyncio - async def test_save_pending_tool_call_to_state_snapshot( - self, service: AgentChatService, session: AgentChatSession - ): - expires_at = datetime.now(timezone.utc) + timedelta(hours=1) - await service.set_pending_tool_call( - session_id=session.id, - interrupt_id="int-1", - tool_name="srv.transfer_funds", - tool_args={"to": "u2", "amount": 100}, - expires_at=expires_at, - thread_id="t1", - run_id="r1", - ) - snapshot = await service.get_state_snapshot(session.id) - assert snapshot is not None - assert snapshot["version"] == 2 - assert snapshot["run_context"]["thread_id"] == "t1" - assert snapshot["run_context"]["run_id"] == "r1" - assert snapshot["pending_tool_call"]["status"] == "PENDING_APPROVAL" - assert snapshot["pending_tool_call"]["interrupt_id"] == "int-1" - assert snapshot["pending_tool_call"]["tool_name"] == "srv.transfer_funds" - - @pytest.mark.asyncio - async def test_get_state_snapshot_returns_none_when_empty( - self, service: AgentChatService, session: AgentChatSession - ): - snapshot = await service.get_state_snapshot(session.id) - assert snapshot is None - - @pytest.mark.asyncio - async def test_update_pending_tool_call_status( - self, service: AgentChatService, session: AgentChatSession - ): - expires_at = datetime.now(timezone.utc) + timedelta(hours=1) - await service.set_pending_tool_call( - session_id=session.id, - interrupt_id="int-2", - tool_name="srv.delete_file", - tool_args={"file_id": "f1"}, - expires_at=expires_at, - thread_id="t1", - run_id="r1", - ) - - await service.update_pending_tool_call_status( - session_id=session.id, - interrupt_id="int-2", - status="APPROVED_EXECUTING", - ) - - snapshot = await service.get_state_snapshot(session.id) - assert snapshot["pending_tool_call"]["status"] == "APPROVED_EXECUTING" - - @pytest.mark.asyncio - async def test_invalid_legacy_snapshot_is_rejected( - self, service: AgentChatService, session: AgentChatSession - ): - session.state_snapshot = {"pending_tool_call": {"status": "PENDING_APPROVAL"}} - - with pytest.raises(ValueError): - await service.apply_resume_decision( - session_id=session.id, - interrupt_id="int-legacy", - decision={"decision": "approved"}, - ) - - @pytest.mark.asyncio - async def test_snapshot_rejects_naive_datetime( - self, service: AgentChatService, session: AgentChatSession - ): - session.state_snapshot = { - "version": 2, - "pending_tool_call": { - "interrupt_id": "int-naive", - "tool_name": "srv.transfer_funds", - "tool_args": {"to": "u2", "amount": 100}, - "status": "PENDING_APPROVAL", - "expires_at": "2026-03-03T12:00:00", - "decision": None, - "result": None, - "updated_at": "2026-03-03T11:59:00", - }, - "run_context": {"thread_id": "t1", "run_id": "r1"}, - } - - with pytest.raises(ValueError): - await service.apply_resume_decision( - session_id=session.id, - interrupt_id="int-naive", - decision={"decision": "approved"}, - ) diff --git a/backend/tests/unit/v1/agent/test_stream_resume_security.py b/backend/tests/unit/v1/agent/test_stream_resume_security.py deleted file mode 100644 index b38c62a..0000000 --- a/backend/tests/unit/v1/agent/test_stream_resume_security.py +++ /dev/null @@ -1,126 +0,0 @@ -from __future__ import annotations - -from datetime import datetime, timezone -from uuid import UUID, uuid4 - -import pytest -from fastapi import HTTPException - -from core.auth.models import CurrentUser -from models.agent_chat_session import ( - AgentChatSession, - AgentChatSessionStatus, - SessionType, -) -from v1.agent.schemas import RunAgentInput -from v1.agent.service import AgentChatService - - -class FakeAsyncSession: - def __init__(self, sessions: list[AgentChatSession]) -> None: - self._sessions = {session.id: session for session in sessions} - self.commit_called = False - - async def execute(self, stmt: object): - class _Result: - def __init__(self, session_obj: AgentChatSession | None) -> None: - self._session_obj = session_obj - - def scalar_one_or_none(self) -> AgentChatSession | None: - return self._session_obj - - for session in self._sessions.values(): - return _Result(session) - return _Result(None) - - async def scalar(self, stmt: object) -> AgentChatSession | None: - for session in self._sessions.values(): - return session - return None - - async def commit(self) -> None: - self.commit_called = True - - -def _build_input(run_id: str) -> RunAgentInput: - return RunAgentInput.model_validate( - { - "threadId": "t1", - "runId": run_id, - "state": {}, - "messages": [], - "tools": [], - "context": [], - "forwardedProps": {}, - "resume": {"interruptId": "int-1", "payload": {"decision": "approved"}}, - } - ) - - -@pytest.mark.asyncio -async def test_stream_resume_rejects_non_owner_session() -> None: - session = AgentChatSession( - id=uuid4(), - user_id=uuid4(), - session_type=SessionType.CHAT, - status=AgentChatSessionStatus.RUNNING, - state_snapshot={ - "version": 2, - "pending_tool_call": { - "interrupt_id": "int-1", - "tool_name": "srv.transfer_funds", - "tool_args": {"to": "u2", "amount": 100}, - "status": "PENDING_APPROVAL", - "expires_at": datetime.now(timezone.utc).isoformat(), - "decision": None, - "result": None, - "updated_at": datetime.now(timezone.utc).isoformat(), - }, - "run_context": {"thread_id": "t1", "run_id": str(uuid4())}, - }, - ) - service = AgentChatService( - session=FakeAsyncSession([session]), # type: ignore[arg-type] - current_user=CurrentUser(id=UUID("00000000-0000-0000-0000-000000000001")), - ) - - with pytest.raises(HTTPException) as exc_info: - await service.prepare_resume(str(session.id), _build_input(str(session.id))) - - assert exc_info.value.status_code == 404 - - -@pytest.mark.asyncio -async def test_prepare_resume_commits_expired_state_before_410() -> None: - owner_id = UUID("00000000-0000-0000-0000-000000000001") - session = AgentChatSession( - id=uuid4(), - user_id=owner_id, - session_type=SessionType.CHAT, - status=AgentChatSessionStatus.RUNNING, - state_snapshot={ - "version": 2, - "pending_tool_call": { - "interrupt_id": "int-1", - "tool_name": "srv.transfer_funds", - "tool_args": {"to": "u2", "amount": 100}, - "status": "PENDING_APPROVAL", - "expires_at": "2000-01-01T00:00:00+00:00", - "decision": None, - "result": None, - "updated_at": datetime.now(timezone.utc).isoformat(), - }, - "run_context": {"thread_id": "t1", "run_id": str(uuid4())}, - }, - ) - fake_db = FakeAsyncSession([session]) - service = AgentChatService( - session=fake_db, # type: ignore[arg-type] - current_user=CurrentUser(id=owner_id), - ) - - with pytest.raises(HTTPException) as exc_info: - await service.prepare_resume(str(session.id), _build_input(str(session.id))) - - assert exc_info.value.status_code == 410 - assert fake_db.commit_called is True diff --git a/backend/tests/unit/v1/agent/test_tool_dispatcher.py b/backend/tests/unit/v1/agent/test_tool_dispatcher.py deleted file mode 100644 index 0eacfcf..0000000 --- a/backend/tests/unit/v1/agent/test_tool_dispatcher.py +++ /dev/null @@ -1,65 +0,0 @@ -from __future__ import annotations - -import pytest - -from v1.agent.tool_dispatcher import ( - BackendExecutionResult, - InterruptResult, - ToolDispatcher, - dispatch_tool_call, -) - - -class TestToolDispatcher: - def test_frontend_tool_returns_interrupt(self): - tool = { - "name": "ui.navigate_to", - "execution_target": "frontend", - "args": {"path": "/home"}, - } - result = dispatch_tool_call(tool) - assert isinstance(result, InterruptResult) - assert result.interrupt_type == "tool_execution" - assert result.tool_name == "ui.navigate_to" - - def test_backend_tool_executes_directly(self): - tool = { - "name": "srv.get_user_info", - "execution_target": "backend", - "args": {"user_id": "u1"}, - "requires_approval": False, - } - result = dispatch_tool_call(tool) - assert isinstance(result, BackendExecutionResult) - assert result.tool_name == "srv.get_user_info" - - def test_backend_tool_with_approval_returns_interrupt(self): - tool = { - "name": "srv.transfer_funds", - "execution_target": "backend", - "args": {"to": "u2", "amount": 100}, - "requires_approval": True, - } - result = dispatch_tool_call(tool) - assert isinstance(result, InterruptResult) - assert result.interrupt_type == "approval_required" - assert result.tool_name == "srv.transfer_funds" - - def test_dispatcher_class_can_dispatch(self): - dispatcher = ToolDispatcher() - tool = { - "name": "ui.navigate_to", - "execution_target": "frontend", - "args": {"message": "Hello"}, - } - result = dispatcher.dispatch(tool) - assert isinstance(result, InterruptResult) - - def test_unknown_frontend_tool_is_rejected(self): - tool = { - "name": "ui.unknown_action", - "execution_target": "frontend", - "args": {}, - } - with pytest.raises(ValueError, match="not in allowlist"): - dispatch_tool_call(tool) diff --git a/backend/tests/unit/v1/agent/test_tool_registry.py b/backend/tests/unit/v1/agent/test_tool_registry.py deleted file mode 100644 index c91791a..0000000 --- a/backend/tests/unit/v1/agent/test_tool_registry.py +++ /dev/null @@ -1,27 +0,0 @@ -import pytest - -from v1.agent.tool_registry import validate_tool_spec - - -class TestValidateToolSpec: - def test_ui_namespace_must_be_frontend(self): - with pytest.raises(ValueError, match="ui.* must use frontend target"): - validate_tool_spec( - {"name": "ui.navigate_to", "execution_target": "backend"} - ) - - def test_srv_namespace_must_be_backend(self): - with pytest.raises(ValueError, match="srv.* must use backend target"): - validate_tool_spec( - {"name": "srv.search_docs", "execution_target": "frontend"} - ) - - def test_ui_namespace_with_frontend_is_valid(self): - validate_tool_spec({"name": "ui.navigate_to", "execution_target": "frontend"}) - - def test_srv_namespace_with_backend_is_valid(self): - validate_tool_spec({"name": "srv.search_docs", "execution_target": "backend"}) - - def test_other_namespace_is_rejected(self): - with pytest.raises(ValueError, match="must be in ui.* or srv.* namespace"): - validate_tool_spec({"name": "other.tool", "execution_target": "frontend"}) diff --git a/backend/tests/unit/v1/agent_chat/test_service.py b/backend/tests/unit/v1/agent_chat/test_service.py deleted file mode 100644 index 82aade1..0000000 --- a/backend/tests/unit/v1/agent_chat/test_service.py +++ /dev/null @@ -1,196 +0,0 @@ -from __future__ import annotations - -from decimal import Decimal -from uuid import uuid4 - -import pytest -from sqlalchemy import Column, String, Table, select -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine -from fastapi import HTTPException -from sqlalchemy.exc import SQLAlchemyError - -from core.auth.models import CurrentUser -from core.agent.orchestrator import OrchestratorResult -from core.db.base import Base -from models.agent_chat_message import AgentChatMessage -from models.agent_chat_session import AgentChatSession -from v1.agent.schemas import AgentChatRunRequest -from v1.agent.service import AgentChatService - - -@pytest.fixture -async def db_engine(): - users_table = Table( - "users", - Base.metadata, - Column("id", String, primary_key=True), - schema="auth", - extend_existing=True, - ) - engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False) - async with engine.begin() as conn: - await conn.exec_driver_sql("ATTACH DATABASE ':memory:' AS auth") - await conn.run_sync(Base.metadata.create_all) - yield engine - Base.metadata.remove(users_table) - await engine.dispose() - - -@pytest.fixture -async def db_session(db_engine): - async_session = async_sessionmaker( - bind=db_engine, - class_=AsyncSession, - expire_on_commit=False, - ) - async with async_session() as session: - yield session - await session.rollback() - - -@pytest.mark.asyncio -async def test_run_creates_session_and_persists_messages( - db_session: AsyncSession, -) -> None: - user = CurrentUser(id=uuid4()) - service = AgentChatService(session=db_session, current_user=user) - - result = await service.run(AgentChatRunRequest(message="hello")) - - assert result.session_id is not None - assert result.output == "hello" - assert [event.type for event in result.events] == [ - "run.started", - "message.delta", - "run.completed", - ] - - session_obj = await db_session.get(AgentChatSession, result.session_id) - assert session_obj is not None - assert session_obj.message_count == 2 - assert session_obj.status.value == "completed" - - rows = await db_session.execute( - select(AgentChatMessage) - .where(AgentChatMessage.session_id == result.session_id) - .order_by(AgentChatMessage.seq.asc()) - ) - messages = rows.scalars().all() - assert len(messages) == 2 - assert messages[0].role.value == "user" - assert messages[1].role.value == "assistant" - - -@pytest.mark.asyncio -async def test_run_appends_to_existing_session(db_session: AsyncSession) -> None: - user = CurrentUser(id=uuid4()) - service = AgentChatService(session=db_session, current_user=user) - - first = await service.run(AgentChatRunRequest(message="first")) - second = await service.run( - AgentChatRunRequest(message="second", session_id=first.session_id) - ) - - assert second.session_id == first.session_id - - session_obj = await db_session.get(AgentChatSession, first.session_id) - assert session_obj is not None - assert session_obj.message_count == 4 - - -@pytest.mark.asyncio -async def test_run_raises_502_and_marks_session_failed_when_orchestrator_fails( - db_session: AsyncSession, -) -> None: - user = CurrentUser(id=uuid4()) - service = AgentChatService(session=db_session, current_user=user) - - class _FailingOrchestrator: - async def run(self, *, run_id: str, user_message: str) -> OrchestratorResult: - return OrchestratorResult( - output="", - usage={ - "input_tokens": 0, - "output_tokens": 0, - "total_tokens": 0, - "cost": Decimal("0"), - "currency": "USD", - }, - events=[], - context={}, - failed=True, - error="stage failed", - ) - - service._orchestrator = _FailingOrchestrator() # type: ignore[assignment] - - with pytest.raises(HTTPException) as exc_info: - await service.run(AgentChatRunRequest(message="hello")) - - assert exc_info.value.status_code == 502 - - rows = await db_session.execute( - select(AgentChatSession).where(AgentChatSession.user_id == user.id) - ) - stored_session = rows.scalars().one() - assert stored_session.status.value == "failed" - - -@pytest.mark.asyncio -async def test_run_returns_422_when_message_is_blank(db_session: AsyncSession) -> None: - user = CurrentUser(id=uuid4()) - service = AgentChatService(session=db_session, current_user=user) - - with pytest.raises(HTTPException) as exc_info: - await service.run(AgentChatRunRequest(message=" ")) - - assert exc_info.value.status_code == 422 - - -@pytest.mark.asyncio -async def test_run_returns_404_when_session_not_found(db_session: AsyncSession) -> None: - user = CurrentUser(id=uuid4()) - service = AgentChatService(session=db_session, current_user=user) - - with pytest.raises(HTTPException) as exc_info: - await service.run(AgentChatRunRequest(message="hello", session_id=uuid4())) - - assert exc_info.value.status_code == 404 - - -@pytest.mark.asyncio -async def test_run_returns_503_when_commit_raises_sqlalchemy_error( - db_session: AsyncSession, - monkeypatch: pytest.MonkeyPatch, -) -> None: - user = CurrentUser(id=uuid4()) - service = AgentChatService(session=db_session, current_user=user) - - async def _fail_commit() -> None: - raise SQLAlchemyError("db down") - - monkeypatch.setattr(db_session, "commit", _fail_commit) - - with pytest.raises(HTTPException) as exc_info: - await service.run(AgentChatRunRequest(message="hello")) - - assert exc_info.value.status_code == 503 - - -@pytest.mark.asyncio -async def test_run_returns_502_for_unexpected_exception( - db_session: AsyncSession, -) -> None: - user = CurrentUser(id=uuid4()) - service = AgentChatService(session=db_session, current_user=user) - - class _CrashingOrchestrator: - async def run(self, *, run_id: str, user_message: str) -> OrchestratorResult: - raise RuntimeError("unexpected") - - service._orchestrator = _CrashingOrchestrator() # type: ignore[assignment] - - with pytest.raises(HTTPException) as exc_info: - await service.run(AgentChatRunRequest(message="hello")) - - assert exc_info.value.status_code == 502 diff --git a/docs/bugs/api-mismatch.md b/docs/bugs/api-mismatch.md new file mode 100644 index 0000000..f2ab077 --- /dev/null +++ b/docs/bugs/api-mismatch.md @@ -0,0 +1,116 @@ +# 前后端 API 对比分析 + +**Date:** 2026-03-04 +**Status:** Open +**Type:** 架构分析 + +--- + +## 一、后端已有、前端缺失的 API + +### 1. Friendships API (`/api/v1/friends`) + +| 方法 | 路径 | 功能 | 前端状态 | +|------|------|------|----------| +| POST | `/requests` | 发送好友请求 | **缺失** | +| GET | `/requests/inbox` | 获取收件箱 | **缺失** | +| GET | `/requests/outgoing` | 获取发出的请求 | **缺失** | +| POST | `/requests/{id}/accept` | 接受好友请求 | **缺失** | +| POST | `/requests/{id}/decline` | 拒绝好友请求 | **缺失** | +| DELETE | `/requests/{id}` | 取消好友请求 | **缺失** | +| GET | `` | 获取好友列表 | **缺失** | +| DELETE | `/{id}` | 删除好友 | **缺失** | + +### 2. Inbox Messages API (`/api/v1/inbox/messages`) + +| 方法 | 路径 | 功能 | 前端状态 | +|------|------|------|----------| +| GET | `` | 获取消息列表 | **缺失** | +| POST | `/{id}/accept` | 接受邀请 | **缺失** | +| POST | `/{id}/dismiss` | 忽略消息 | **缺失** | + +### 3. Chat/AgUi 流式 API + +| 功能 | 前端状态 | +|------|----------| +| 发送消息 SSE 流式 | **仅有 Mock** | +| 加载历史记录 | **仅有 Mock** | + +> 前端 `AgUiService` 只有本地 mock (`throw UnimplementedError`),未实现真实 API 调用。 + +### 4. Infra API + +| 方法 | 路径 | 功能 | 前端状态 | +|------|------|------|----------| +| GET | `/infra/health` | 基础设施健康检查 | **未使用** | + +--- + +## 二、前端已有、后端已实现的 API + +### Auth API (`/api/v1/auth`) + +| 方法 | 路径 | 后端 | 前端 | +|------|------|------|------| +| POST | `/verifications` | ✅ | ✅ | +| POST | `/verifications/verify` | ✅ | ✅ | +| POST | `/verifications/resend` | ✅ | ✅ | +| POST | `/sessions` | ✅ | ✅ | +| POST | `/sessions/refresh` | ✅ | ✅ | +| DELETE | `/sessions` | ✅ | ✅ | +| POST | `/password-reset` | ✅ | ✅ | +| POST | `/password-reset/confirm` | ✅ | ✅ | +| GET | `/users` | ✅ | **未使用** | + +### Users API (`/api/v1/users`) + +| 方法 | 路径 | 后端 | 前端 | +|------|------|------|------| +| GET | `/me` | ✅ | ✅ | +| PATCH | `/me` | ✅ | ✅ | +| POST | `/search` | ✅ | ✅ | + +### Schedule Items API (`/api/v1/schedule-items`) + +| 方法 | 路径 | 后端 | 前端 | +|------|------|------|------| +| POST | `` | ✅ | **仅有 Mock** | +| GET | `` (range query) | ✅ | **仅有 Mock** | +| GET | `/{id}` | ✅ | **仅有 Mock** | +| PATCH | `/{id}` | ✅ | **仅有 Mock** | +| DELETE | `/{id}` | ✅ | **仅有 Mock** | +| POST | `/{id}/share` | ✅ | **缺失** | + +--- + +## 三、待实现功能清单 + +| 优先级 | 功能 | 说明 | +|--------|------|------| +| **P0** | FriendsApi | 前端无 Friendships API 客户端 | +| **P0** | InboxMessagesApi | 前端无 Inbox Messages API 客户端 | +| **P0** | Chat/AgUi 后端连接 | 前端 AgUiService 未实现真实 API | +| **P1** | CalendarService 真实 API | MockCalendarService → 真实 API 调用 | +| **P1** | Schedule Share 接口 | 前端未调用 `POST /{id}/share` | +| **P2** | Infra Health 集成 | 可用于前端健康检查 | + +--- + +## 四、相关文件位置 + +### 前端 API 客户端 + +- `apps/lib/features/auth/data/auth_api.dart` - Auth API +- `apps/lib/features/users/data/users_api.dart` - Users API +- `apps/lib/features/calendar/data/services/mock_calendar_service.dart` - Calendar Mock +- `apps/lib/features/chat/data/services/ag_ui_service.dart` - Chat/AgUi Mock +- `apps/lib/features/chat/data/services/mock_history_service.dart` - History Mock + +### 后端 Router + +- `backend/src/v1/auth/router.py` - Auth 路由 +- `backend/src/v1/users/router.py` - Users 路由 +- `backend/src/v1/friendships/router.py` - Friendships 路由 +- `backend/src/v1/inbox_messages/router.py` - Inbox Messages 路由 +- `backend/src/v1/schedule_items/router.py` - Schedule Items 路由 +- `backend/src/v1/infra/router.py` - Infra 路由 diff --git a/docs/bugs/backlog.md b/docs/bugs/backlog.md deleted file mode 100644 index 21400b3..0000000 --- a/docs/bugs/backlog.md +++ /dev/null @@ -1,92 +0,0 @@ -# Backlog - Known Issues & Improvements - -## Database Triggers - -### [TRIGGER-001] user_agents 自动创建 - -**Status**: ✅ Resolved -**Priority**: Medium -**Created**: 2026-02-27 -**Resolved**: 2026-03-02 - -**Description**: -当新用户注册时,`user_agents` 表未自动创建默认 Agent 配置记录。 - -**Current Behavior**: -- `auth.users` → `profiles` 已有 trigger 自动创建 -- `user_agents` 无自动创建机制 - -**Expected Behavior**: -新用户注册后,应有默认的 Agent 配置(如 INTENT_RECOGNITION、TASK_EXECUTION、RESULT_REPORTING 三种类型)。 - -**Impact**: -- 用户首次使用 Agent Chat 功能时可能失败 -- 需要应用层手动初始化或前端引导配置 - -**Solution**: -1. 创建 `user_agent_catalog.yaml` 配置文件定义 3 种 agent 类型 -2. 创建 `user_agent_catalog` 表存储配置(持久化) -3. 修改 `user_agents` 表唯一约束:`user_id` → `(user_id, agent_type)` 允许每个用户有多个 agents -4. 扩展 `create_profile_for_new_user()` trigger 从 catalog 批量插入 user_agents -5. 实现配置动态更新:修改 YAML → 重启应用 → 自动同步到数据库 -6. 为已存在用户补充 user_agents 记录 - -**Verification**: -- ✅ 新用户注册自动创建 3 个 agents -- ✅ Catalog 表已填充(INTENT_RECOGNITION, TASK_EXECUTION, RESULT_REPORTING) -- ✅ 已为 1 个存在用户补充 3 个 agents -- ✅ Backend tests: 247 passed - -**Related Files**: -- `backend/src/core/config/static/database/user_agent_catalog.yaml` -- `backend/src/models/user_agent_catalog.py` -- `backend/alembic/versions/50ae013ce530_add_user_agent_catalog.py` -- `backend/src/core/config/initial/init_data.py` - ---- - -## Flutter Design Tokens - -### [TOKEN-001] 大量硬编码颜色违反 AGENTS.md 规则 - -**Status**: ✅ Resolved (Partial) -**Priority**: Medium -**Created**: 2026-03-02 -**Resolved**: 2026-03-02 - -**Description**: -`apps/AGENTS.md` 规则要求禁止硬编码颜色,必须使用 `design_tokens.dart` 中的 `AppColors`。但实际代码中存在大量硬编码。 - -**Current Behavior**: -- `apps/AGENTS.md` 规定:"NEVER hardcode colors, sizes, or spacing values" -- 代码中有 **126 处**硬编码 `Color(0xFF...)`(扫描发现比预期多 17 处) - -**Expected Behavior**: -所有颜色应使用 `AppColors` 中定义的值。 - -**Impact**: -- 与项目规范不一致 -- 后续 theme 统一修改困难 -- 代码审查难以发现 - -**Solution** (渐进式迁移): -1. **扫描分析**: 发现 126 个硬编码颜色,Top 3: `FFF8FAFC` (11), `FFF8FAFF` (9), `FFE2E8F0` (5) -2. **扩展 AppColors**: 添加 11 个语义化 token(surfaceSecondary, borderSecondary, success, warning 等) -3. **优先迁移**: 8 个高频文件(settings, contacts, calendar_event_detail 等) -4. **批量替换**: 37 个硬编码颜色已替换为 tokens -5. **测试验证**: 140/140 Flutter tests 通过 - -**Migration Results**: -- ✅ 新增 token: 11 个(覆盖 57 次使用) -- ✅ 已迁移: 37 个硬编码颜色 -- ⚠️ 剩余: 90 个(建议后续迭代继续迁移) - -**Recommendations**: -1. 继续迁移剩余 90 个硬编码颜色 -2. 优先处理 `contacts_screen.dart`, `settings_screen.dart`, `features_screen.dart` (46% of remaining) -3. 考虑添加 lint rule 防止新增硬编码颜色 - -**Related Files**: -- `apps/lib/core/theme/design_tokens.dart` -- `apps/lib/features/*/ui/screens/*.dart` (8 files migrated) -- `apps/AGENTS.md` diff --git a/docs/bugs/new_feat_bug.md b/docs/bugs/new_feat_bug.md deleted file mode 100644 index 3558e5f..0000000 --- a/docs/bugs/new_feat_bug.md +++ /dev/null @@ -1,127 +0,0 @@ -# 日历功能新增 Bug 记录 - -**Created**: 2026-03-02 -**Feature**: 日历事件创建功能 -**Status**: Partial (Bug 5, 7 pending) - ---- - -## Bug 1: 日视图时间范围不完整 - -**Severity**: High -**Status**: Fixed - -**修复内容**: -- 修改循环范围从 `for (var hour = 7; hour <= 22; hour++)` 改为 `for (var hour = 0; hour <= 23; hour++)` -- 现在日视图显示完整的 00:00-24:00 时间线 - ---- - -## Bug 2: 日历事件保存后视图不刷新 - -**Severity**: High -**Status**: Fixed - -**修复内容**: -- 为 `CreateEventSheet` 添加 `onSaved` 回调参数 -- 在月视图和日视图中使用 `setState` + `UniqueKey` 强制刷新事件列表 -- 保存后自动刷新,无需手动切换页面 - ---- - -## Bug 3: 日视图事件高度未按时间范围渲染 - -**Severity**: Medium -**Status**: Fixed - -**修复内容**: -- 使用 `Positioned` + `Stack` 替代原来的 `Wrap` 布局 -- 根据事件实际持续时间计算高度:`持续分钟数 / 60 * _hourHeight` -- 事件垂直位置根据开始时间计算:`开始分钟数 / 60 * _hourHeight` - ---- - -## Bug 4: 月视图事件超过3个后无法显示 - -**Severity**: Medium -**Status**: Fixed - -**修复内容**: -- 显示前 2 个事件 -- 超过 2 个时显示 `+N` 按钮 -- 点击 `+N` 跳转到日视图 - ---- - -## Bug 5: 日视图点击事件跳转到错误页面 - -**Severity**: High -**Status**: Pending - -**现象描述**: -- 在日视图中点击日历事件 -- 跳转到的页面不是日历详情页,而是显示了类似首页的聊天输入框 -- 页面显示"输入消息..."和麦克风图标 -- 路由应该是 `/calendar/events/evt_xxx` 但显示的是首页布局 - -**已尝试的修复**: -- 将 `/calendar/events/:id` 路由移到最前面优先匹配 -- 调试日志显示 eventId 正确传递 -- 添加了 `clipBehavior: Clip.none` 和 `Material + InkWell` - -**可能原因**: -- 路由配置问题,可能被其他路由或 Shell 组件影响 -- 页面布局问题导致显示异常 - -**修复建议**: -- 检查路由配置,确认 CalendarEventDetailScreen 正确加载 -- 检查页面布局是否正确渲染 - ---- - -## Bug 6: 日视图多事件重叠时布局错乱 - -**Severity**: Medium -**Status**: Fixed - -**修复内容**: -- 使用 `_calculateEventColumns` 方法计算事件列位置 -- 最早开始的事件放在最左边 -- 其余事件依次向右排列,每个事件宽度 = 总宽度 / 列数 -- 使用 `Positioned` 绝对定位控制事件位置 - ---- - -## Bug 7: 日历详情页布局错误 - -**Severity**: High -**Status**: Pending - -**现象描述**: -- 点击跳转到的页面显示异常 -- 页面顶部显示聊天输入框("输入消息..."和麦克风图标) -- 应该是日历详情页但显示了类似首页的布局 -- 渲染错误:RenderFlex children have non-zero flex but incoming width constraints are unbounded - -**已尝试的修复**: -- 给 `_buildInputContainer` 中的 Container 添加 `width: double.infinity` -- 修改内部的 Row 使用 const - -**修复建议**: -- 需要重新设计详情页布局 -- 确认底部输入框组件是否需要(可能复制自其他页面) - ---- - -## 相关文件清单 - -### 新增文件 -- `apps/lib/features/calendar/data/models/schedule_item_model.dart` - 日历事件数据模型 -- `apps/lib/features/calendar/data/services/mock_calendar_service.dart` - Mock 服务 -- `apps/lib/features/calendar/ui/widgets/create_event_sheet.dart` - 创建事件底部弹窗 - -### 修改文件 -- `apps/lib/features/calendar/ui/screens/calendar_month_screen.dart` - 月视图 -- `apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart` - 日视图 -- `apps/lib/features/calendar/ui/screens/calendar_event_detail_screen.dart` - 事件详情页 -- `apps/lib/core/router/app_router.dart` - 路由配置 diff --git a/docs/bugs/test-analysis.md b/docs/bugs/test-analysis.md new file mode 100644 index 0000000..f930356 --- /dev/null +++ b/docs/bugs/test-analysis.md @@ -0,0 +1,145 @@ +# 前后端测试分析报告 + +**Date:** 2026-03-04 +**Status:** Completed + +--- + +## 测试统计 + +### 后端测试 + +| 类型 | 数量 | 状态 | +|------|------|------| +| Unit Tests | ~100+ | 可运行 | +| Integration Tests | ~70+ | 可运行 | +| E2E Tests | 5 | **无法运行** (缺少 playwright 依赖) | + +### 前端测试 + +| 类型 | 数量 | 状态 | +|------|------|------| +| Flutter Tests | 140 | ✅ 全部通过 | + +--- + +## 问题发现 + +### 1. 后端 E2E 测试无法运行 (HIGH) + +**问题**: 5 个 E2E 测试文件需要 `playwright` 模块,但依赖未安装。 + +**影响文件**: +- `tests/e2e/test_auth_flow.py` +- `tests/e2e/test_infra_health_e2e.py` +- `tests/e2e/test_logging_e2e.py` +- `tests/e2e/test_mobile_health_e2e.py` +- `tests/e2e/test_profile_flow.py` + +**错误**: +``` +ModuleNotFoundError: No module named 'playwright' +``` + +**建议**: +- 安装 playwright: `uv add playwright && uv run playwright install` +- 或者移除这些无法运行的 E2E 测试文件 + +--- + +### 2. 测试文件命名冲突导致收集警告 (LOW) + +**问题**: 存在多个同名 `test_schemas.py` 文件在不同目录,导致 pytest 收集时显示警告。 + +**影响文件**: +- `tests/unit/v1/schedule_items/test_schemas.py` +- `tests/unit/v1/profile/test_schemas.py` +- `tests/unit/v1/inbox_messages/test_schemas.py` +- `tests/unit/v1/friendships/test_schemas.py` + +**状态**: 测试实际可以正常运行,只是有警告提示。 + +**建议**: 可保持现状(这是合理的代码组织方式),或重命名为 `test_*.py` 以消除警告。 + +--- + +### 3. 遗留测试验证旧字段 (INFO) + +**文件**: `tests/unit/v1/profile/test_schemas.py` + +**测试**: `test_profile_update_rejects_display_name_field` + +**说明**: 此测试验证旧的 `display_name` 字段被正确拒绝。字段已在之前的重构中删除。 + +**状态**: **有效** - 这是一个回归测试,确保旧字段不被使用。 + +--- + +## 未发现的问题 + +### 冗余测试 +经过检查,未发现明显冗余的测试: +- 每个模块的测试覆盖不同的功能 +- Unit tests、Integration tests、E2E tests 有清晰的职责划分 + +### 死代码 +未发现测试文件中有未使用的: +- imports +- mock 类 +- helper 函数 + +### 缺失测试 +未发现对应已实现功能但缺少测试的情况。 + +--- + +## 测试覆盖模块 + +### 后端 +| 模块 | Unit | Integration | E2E | +|------|------|-------------|-----| +| Auth | ✅ | ✅ | ❌ | +| Users | - | ✅ | - | +| Profile | ✅ | - | ❌ | +| Friendships | ✅ | ✅ | - | +| Inbox Messages | ✅ | ✅ | - | +| Schedule Items | ✅ | ✅ | - | +| Logging | ✅ | ✅ | ✅ | +| Settings | ✅ | - | - | + +### 前端 +| 模块 | 测试数 | +|------|--------| +| Auth | ~20 | +| Chat | ~70 | +| Home | ~15 | +| Calendar | ~5 | +| Core (API, Storage) | ~30 | + +--- + +## 建议 + +1. **立即**: 解决 E2E 测试依赖问题或移除无法运行的测试文件 +2. **可选**: 清理 test_schemas.py 重名警告(低优先级) +3. **保持**: 现有的测试结构良好,无需重大重构 + +--- + +## 附: 测试代码质量问题 + +### 测试类未完全实现 Protocol (LSP 警告) + +**文件**: `tests/unit/v1/auth/test_auth_service.py` + +**问题**: `FakeGateway` 和 `LogoutAssertingGateway` 类没有实现 `AuthServiceGateway` Protocol 的全部方法: +- `request_password_reset` +- `confirm_password_reset` + +**影响**: LSP 类型检查器报告错误,但运行时不受影响(因为这些方法在测试中不会被调用)。 + +**建议**: 可选择补充缺失的方法实现,或使用 `@pytest.mark.skip` 标记不需要的协议方法。 + +--- + +*报告生成时间: 2026-03-04* diff --git a/docs/plans/2026-03-02-bugfix-design.md b/docs/plans/2026-03-02-bugfix-design.md deleted file mode 100644 index a7c493d..0000000 --- a/docs/plans/2026-03-02-bugfix-design.md +++ /dev/null @@ -1,458 +0,0 @@ -# Bug Fix Design: TRIGGER-001 & TOKEN-001 - -**Date:** 2026-03-02 -**Author:** AI Assistant -**Status:** Approved - -## Overview - -解决 `docs/bugs/backlog.md` 中的两个 bug: -1. TRIGGER-001: user_agents 自动创建 -2. TOKEN-001: Flutter 硬编码颜色迁移 - -## TRIGGER-001: user_agents 自动创建 - -### Problem - -新用户注册时,`user_agents` 表未自动创建默认 Agent 配置记录,导致首次使用 Agent Chat 功能失败。 - -### Solution - -创建配置驱动的自动初始化机制: -- YAML 配置文件定义默认 agent 配置 -- 数据库 catalog 表存储配置(持久化) -- Trigger 从 catalog 表读取并批量插入 -- 应用启动时自动同步 YAML → catalog 表 - -### Architecture - -``` -user_agent_catalog.yaml - ↓ (应用启动时) -initialize_user_agent_catalog() - ↓ (upsert) -user_agent_catalog 表 - ↓ (Trigger 查询) -新用户注册 → create_profile_for_new_user() - ├─→ 插入 profiles - └─→ 批量插入 user_agents (3条) -``` - -### Data Model - -#### 1. user_agent_catalog.yaml - -```yaml -agents: - - agent_type: INTENT_RECOGNITION - llm_model_code: qwen3.5-flash - status: active - config: - temperature: 0.7 - - - agent_type: TASK_EXECUTION - llm_model_code: deepseek-v3.2 - status: active - config: - temperature: 0.7 - - - agent_type: RESULT_REPORTING - llm_model_code: deepseek-v3.2 - status: active - config: - temperature: 0.7 -``` - -#### 2. user_agent_catalog 表 - -```sql -CREATE TABLE user_agent_catalog ( - agent_type VARCHAR(20) PRIMARY KEY, - llm_id UUID NOT NULL REFERENCES llms(id) ON DELETE RESTRICT, - status VARCHAR(20) NOT NULL DEFAULT 'active', - config JSONB NOT NULL DEFAULT '{}', - created_at TIMESTAMPTZ DEFAULT now(), - updated_at TIMESTAMPTZ DEFAULT now(), - CONSTRAINT chk_status CHECK (status IN ('active', 'paused', 'migrating')) -); -``` - -**字段说明:** -- `agent_type`: 主键,agent 类型(INTENT_RECOGNITION / TASK_EXECUTION / RESULT_REPORTING) -- `llm_id`: 外键,关联 llms 表 -- `status`: 默认状态(active / paused / migrating) -- `config`: 默认配置(JSONB,包含 temperature 等) - -#### 3. Trigger 函数 - -```sql -CREATE OR REPLACE FUNCTION create_profile_for_new_user() -RETURNS trigger AS $$ -BEGIN - -- 插入 profile(现有逻辑) - INSERT INTO profiles (id, username, avatar_url, bio, settings, created_at, updated_at) - VALUES ( - NEW.id, - COALESCE( - NEW.raw_user_meta_data ->> 'username', - split_part(NEW.email, '@', 1), - 'user_' || substring(NEW.id::text, 1, 8) - ), - NULL, NULL, '{}'::jsonb, now(), now() - ) - ON CONFLICT (id) DO NOTHING; - - -- 从 user_agent_catalog 批量插入 user_agents - INSERT INTO user_agents (id, user_id, llm_id, agent_type, config, status, created_by, updated_by) - SELECT - gen_random_uuid(), - NEW.id, - uac.llm_id, - uac.agent_type, - uac.config, - uac.status, - NEW.id, - NEW.id - FROM user_agent_catalog uac; - - RETURN NEW; -END; -$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; -``` - -### Implementation Components - -#### 1. Python Model - -```python -# backend/src/models/user_agent_catalog.py -class UserAgentCatalog(TimestampMixin, Base): - __tablename__ = "user_agent_catalog" - - agent_type: Mapped[str] = mapped_column(String(20), primary_key=True) - llm_id: Mapped[uuid.UUID] = mapped_column(UUID, ForeignKey("llms.id"), nullable=False) - status: Mapped[str] = mapped_column(String(20), nullable=False) - config: Mapped[dict] = mapped_column(JSONB, nullable=False, server_default="{}") -``` - -#### 2. 初始化函数 - -```python -# backend/src/core/config/initial/init_data.py - -class UserAgentCatalogSeed(BaseModel): - agent_type: str - llm_model_code: str - status: str - config: dict[str, Any] - -def load_user_agent_catalog(catalog_path: Path | None = None) -> dict[str, Any]: - path = catalog_path or _default_user_agent_catalog_path() - with path.open("r", encoding="utf-8") as file: - loaded = yaml.safe_load(file) or {} - # ... validation logic - return parsed.model_dump() - -async def initialize_user_agent_catalog() -> None: - catalog = load_user_agent_catalog() - - async with AsyncSessionLocal() as session: - async with session.begin(): - for agent in catalog["agents"]: - # 查找 llm_id - llm = await session.execute( - select(Llm).where(Llm.model_code == agent["llm_model_code"]) - ) - llm_id = llm.scalar_one().id - - # Upsert - existing = await session.execute( - select(UserAgentCatalog).where( - UserAgentCatalog.agent_type == agent["agent_type"] - ) - ) - catalog_entry = existing.scalar_one_or_none() - - if catalog_entry: - catalog_entry.llm_id = llm_id - catalog_entry.status = agent["status"] - catalog_entry.config = agent["config"] - else: - session.add(UserAgentCatalog( - agent_type=agent["agent_type"], - llm_id=llm_id, - status=agent["status"], - config=agent["config"] - )) - - logger.info("Initialized user agent catalog") - -async def initialize_data() -> bool: - await initialize_llm_catalog() - await initialize_user_agent_catalog() # 新增 - return True -``` - -### Migration Strategy - -#### 1. Alembic 迁移 - -```python -# backend/alembic/versions/20260302_add_user_agent_catalog.py - -def upgrade() -> None: - # 创建表 - op.create_table( - "user_agent_catalog", - sa.Column("agent_type", sa.String(20), nullable=False), - sa.Column("llm_id", sa.UUID(), nullable=False), - sa.Column("status", sa.String(20), nullable=False), - sa.Column("config", postgresql.JSONB(), nullable=False, server_default="{}"), - sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()")), - sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()")), - sa.PrimaryKeyConstraint("agent_type"), - sa.ForeignKeyConstraint(["llm_id"], ["llms.id"], ondelete="RESTRICT"), - ) - - op.execute( - "ALTER TABLE user_agent_catalog " - "ADD CONSTRAINT chk_status CHECK (status IN ('active', 'paused', 'migrating'))" - ) - - # 替换 trigger 函数 - op.execute(""" - CREATE OR REPLACE FUNCTION public.create_profile_for_new_user() - -- ... 新的 trigger 代码 - """) - - # 为已存在用户补充 user_agents - op.execute(""" - INSERT INTO user_agents (id, user_id, llm_id, agent_type, config, status, created_by, updated_by) - SELECT - gen_random_uuid(), - p.id, - uac.llm_id, - uac.agent_type, - uac.config, - uac.status, - p.id, - p.id - FROM profiles p - CROSS JOIN user_agent_catalog uac - WHERE NOT EXISTS ( - SELECT 1 FROM user_agents ua WHERE ua.user_id = p.id - ); - """) - - _enable_rls("user_agent_catalog") - -def downgrade() -> None: - # 恢复旧 trigger - op.execute(""" - CREATE OR REPLACE FUNCTION public.create_profile_for_new_user() - -- ... 旧的 trigger 代码(只有 profile) - """) - - _drop_rls("user_agent_catalog") - op.drop_table("user_agent_catalog") -``` - -### Configuration Update Flow - -**用户修改配置的步骤:** - -1. 编辑 `backend/src/core/config/static/database/user_agent_catalog.yaml` -2. 重启应用(或调用 `uv run python -m core.runtime.cli bootstrap`) -3. `initialize_user_agent_catalog()` 自动执行 -4. user_agent_catalog 表更新(upsert) -5. 新用户注册时使用新配置 - -**示例:** -```yaml -# 修改 temperature -agents: - - agent_type: INTENT_RECOGNITION - llm_model_code: qwen3.5-flash - status: active - config: - temperature: 0.5 # 从 0.7 改为 0.5 -``` - -重启应用后,新注册的用户 INTENT_RECOGNITION agent 会使用 temperature=0.5。 - -### Testing Strategy - -1. **单元测试:** `load_user_agent_catalog()` 函数 -2. **集成测试:** `initialize_user_agent_catalog()` upsert 逻辑 -3. **E2E 测试:** 新用户注册后自动创建 3 个 user_agents 记录 -4. **回归测试:** 已存在用户不受影响 - -### File Changes - -``` -backend/src/core/config/static/database/user_agent_catalog.yaml (填充) -backend/src/models/user_agent_catalog.py (新建) -backend/src/core/config/initial/init_data.py (扩展) -backend/alembic/versions/20260302_add_user_agent_catalog.py (新建) -backend/tests/unit/core/test_agent_init_data.py (扩展) -``` - ---- - -## TOKEN-001: Flutter 硬编码颜色迁移 - -### Problem - -109 处硬编码 `Color(0xFF...)` 违反 `apps/AGENTS.md` 规则:"NEVER hardcode colors, sizes, or spacing values"。 - -分散在 11 个页面文件: -- register_screen.dart, register_verification_screen.dart -- settings_screen.dart, account_screen.dart -- contacts_screen.dart, calendar_event_detail_screen.dart -- add_contact_screen.dart, features_screen.dart -- memory_screen.dart, home_screen.dart -- todo_detail_screen.dart - -### Solution - -**渐进式迁移:** -1. 扫描所有硬编码颜色,统计频率 -2. 将高频颜色添加到 `design_tokens.dart` 的 `AppColors` -3. 逐页面替换硬编码为 `AppColors.xxx` -4. 保留合理的动态颜色场景 - -### Migration Steps - -#### Phase 1: 扫描和统计 - -```bash -# 扫描所有硬编码颜色 -grep -r "Color(0x" apps/lib --include="*.dart" | \ - sed 's/.*Color(0x\([0-9A-Fa-f]*\)).*/\1/' | \ - sort | uniq -c | sort -rn -``` - -**预期输出:** -``` - 45 2196F3 # 蓝色(链接/主色) - 23 D32F2F # 红色(错误) - 18 4CAF50 # 绿色(成功) - ... -``` - -#### Phase 2: 扩展 AppColors - -```dart -// apps/lib/core/theme/design_tokens.dart - -class AppColors { - // 现有颜色... - - // 从硬编码迁移的颜色 - static const Color linkBlue = Color(0xFF2196F3); - static const Color errorRed = Color(0xFFD32F2F); - static const Color successGreen = Color(0xFF4CAF50); - // ... 根据扫描结果添加 -} -``` - -#### Phase 3: 逐页面迁移 - -```dart -// Before -Container( - color: Color(0xFF2196F3), - child: Text('Link'), -) - -// After -Container( - color: AppColors.linkBlue, - child: Text('Link'), -) -``` - -**迁移顺序(按频率):** -1. register_screen.dart (最高频) -2. register_verification_screen.dart -3. settings_screen.dart -4. ... (其他页面) - -#### Phase 4: 测试和验证 - -1. **视觉测试:** 逐页面检查 UI 是否正常 -2. **Widget 测试:** 更新测试使用 AppColors -3. **设计审查:** 确认颜色一致性 - -### Allowed Exceptions - -以下场景可以保留硬编码(需在 AGENTS.md 中明确): -- 动态计算的渐变色 -- 图片处理相关的颜色 -- 第三方库要求的颜色格式 - -### Testing Strategy - -1. **Widget 测试:** 确保组件仍然正常渲染 -2. **视觉回归测试:** 对比迁移前后的截图 -3. **代码审查:** 确保没有遗漏的硬编码 - -### File Changes - -``` -apps/lib/core/theme/design_tokens.dart (扩展) -apps/lib/features/*/ui/screens/*.dart (迁移) -apps/AGENTS.md (明确允许的场景) -``` - ---- - -## Implementation Order - -1. **TRIGGER-001** (优先级更高,影响功能) - - 创建 catalog YAML - - 创建 model 和迁移 - - 扩展初始化逻辑 - - 修改 trigger - - 测试 - -2. **TOKEN-001** (次要,不影响功能) - - 扫描硬编码 - - 扩展 AppColors - - 逐页面迁移 - - 测试 - -## Success Criteria - -### TRIGGER-001 -- [ ] 新用户注册后自动创建 3 个 user_agents 记录 -- [ ] 已存在用户不受影响 -- [ ] 配置可通过 YAML + 重启更新 -- [ ] 所有测试通过 - -### TOKEN-001 -- [ ] 硬编码颜色数量从 109 减少到 < 10 -- [ ] UI 视觉无回归 -- [ ] 所有测试通过 - -## Risks and Mitigations - -### TRIGGER-001 -- **风险:** llms 表中缺少对应的 model_code - - **缓解:** 初始化前检查 llms 表是否存在,提供清晰的错误信息 -- **风险:** Trigger 性能影响 - - **缓解:** catalog 表只有 3 行,查询性能可忽略 - -### TOKEN-001 -- **风险:** 迁移后颜色不一致 - - **缓解:** 视觉回归测试 + 设计审查 -- **风险:** 遗漏某些硬编码 - - **缓解:** 自动化扫描 + 代码审查 - -## Related Documents - -- `docs/bugs/backlog.md` - Bug 列表 -- `backend/AGENTS.md` - Backend 开发规则 -- `apps/AGENTS.md` - Flutter 开发规则 -- `backend/src/core/config/initial/init_data.py` - 现有初始化逻辑 -- `backend/alembic/versions/20260227_0006_invite_codes_and_profile_referral.py` - Trigger 修改参考 diff --git a/docs/plans/2026-03-02-bugfix-plan.md b/docs/plans/2026-03-02-bugfix-plan.md deleted file mode 100644 index 9536523..0000000 --- a/docs/plans/2026-03-02-bugfix-plan.md +++ /dev/null @@ -1,806 +0,0 @@ -# Bug Fix Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** 修复 TRIGGER-001 (user_agents 自动创建) 和 TOKEN-001 (Flutter 硬编码颜色迁移) - -**Architecture:** -- TRIGGER-001: YAML 配置 → catalog 表 → Trigger 自动创建 -- TOKEN-001: 渐进式迁移硬编码颜色到 AppColors - -**Tech Stack:** Python, PostgreSQL, Alembic, Dart, Flutter - ---- - -## TRIGGER-001: user_agents 自动创建 - -### Task 1: 创建 user_agent_catalog.yaml 配置文件 - -**Files:** -- Modify: `backend/src/core/config/static/database/user_agent_catalog.yaml` - -**Step 1: 填充 YAML 配置** - -```yaml -agents: - - agent_type: INTENT_RECOGNITION - llm_model_code: qwen3.5-flash - status: active - config: - temperature: 0.7 - - - agent_type: TASK_EXECUTION - llm_model_code: deepseek-v3.2 - status: active - config: - temperature: 0.7 - - - agent_type: RESULT_REPORTING - llm_model_code: deepseek-v3.2 - status: active - config: - temperature: 0.7 -``` - -**Step 2: 验证 YAML 语法** - -Run: `cat backend/src/core/config/static/database/user_agent_catalog.yaml` - -Expected: YAML 格式正确,包含 3 个 agent 配置 - -**Step 3: Commit** - -```bash -git add backend/src/core/config/static/database/user_agent_catalog.yaml -git commit -m "feat(config): add user_agent_catalog.yaml with default agent configs" -``` - ---- - -### Task 2: 创建 UserAgentCatalog 模型 - -**Files:** -- Create: `backend/src/models/user_agent_catalog.py` -- Modify: `backend/src/models/__init__.py` - -**Step 1: 创建模型文件** - -```python -# backend/src/models/user_agent_catalog.py -from __future__ import annotations - -from sqlalchemy import ForeignKey, String -from sqlalchemy.dialects.postgresql import JSONB, UUID -from sqlalchemy.orm import Mapped, mapped_column - -from core.db.base import Base, TimestampMixin - - -class UserAgentCatalog(TimestampMixin, Base): - __tablename__ = "user_agent_catalog" - - agent_type: Mapped[str] = mapped_column( - String(20), - primary_key=True, - ) - llm_id: Mapped[str] = mapped_column( - UUID(as_uuid=True), - ForeignKey("llms.id", ondelete="RESTRICT"), - nullable=False, - ) - status: Mapped[str] = mapped_column( - String(20), - nullable=False, - ) - config: Mapped[dict] = mapped_column( - JSONB, - nullable=False, - server_default="{}", - ) -``` - -**Step 2: 在 __init__.py 中导出** - -```python -# backend/src/models/__init__.py (添加) -from models.user_agent_catalog import UserAgentCatalog - -__all__ = [ - # ... 现有导出 - "UserAgentCatalog", -] -``` - -**Step 3: 运行类型检查** - -Run: `cd backend && uv run basedpyright src/models/user_agent_catalog.py` - -Expected: No errors - -**Step 4: Commit** - -```bash -git add backend/src/models/user_agent_catalog.py backend/src/models/__init__.py -git commit -m "feat(models): add UserAgentCatalog model" -``` - ---- - -### Task 3: 创建 Alembic 迁移 - -**Files:** -- Create: `backend/alembic/versions/20260302_add_user_agent_catalog.py` - -**Step 1: 生成迁移文件** - -Run: `cd backend && uv run alembic revision -m "add_user_agent_catalog"` - -**Step 2: 编写迁移逻辑** - -```python -# backend/alembic/versions/20260302_add_user_agent_catalog.py -"""add user_agent_catalog table - -Revision ID: 202603020001 -Revises: -Create Date: 2026-03-02 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - -revision: str = "202603020001" -down_revision: Union[str, Sequence[str], None] = None -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # 创建 user_agent_catalog 表 - op.create_table( - "user_agent_catalog", - sa.Column("agent_type", sa.String(20), nullable=False), - sa.Column("llm_id", sa.UUID(), nullable=False), - sa.Column("status", sa.String(20), nullable=False), - sa.Column( - "config", - postgresql.JSONB(astext_type=sa.Text()), - server_default="{}", - nullable=False, - ), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.Column( - "updated_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.PrimaryKeyConstraint("agent_type"), - sa.ForeignKeyConstraint( - ["llm_id"], ["llms.id"], name="fk_user_agent_catalog_llm_id", ondelete="RESTRICT" - ), - ) - - op.execute( - "ALTER TABLE user_agent_catalog " - "ADD CONSTRAINT chk_user_agent_catalog_status " - "CHECK (status IN ('active', 'paused', 'migrating'))" - ) - - _enable_rls("user_agent_catalog") - - # 替换 trigger 函数 - op.execute(""" - CREATE OR REPLACE FUNCTION public.create_profile_for_new_user() - RETURNS trigger - LANGUAGE plpgsql - SECURITY DEFINER - SET search_path = public - AS $$ - BEGIN - INSERT INTO public.profiles (id, username, avatar_url, bio, settings, created_at, updated_at) - VALUES ( - NEW.id, - COALESCE( - NEW.raw_user_meta_data ->> 'username', - split_part(NEW.email, '@', 1), - 'user_' || substring(NEW.id::text, 1, 8) - ), - NULL, - NULL, - '{}'::jsonb, - now(), - now() - ) - ON CONFLICT (id) DO NOTHING; - - INSERT INTO public.user_agents (id, user_id, llm_id, agent_type, config, status, created_by, updated_by) - SELECT - gen_random_uuid(), - NEW.id, - uac.llm_id, - uac.agent_type, - uac.config, - uac.status, - NEW.id, - NEW.id - FROM public.user_agent_catalog uac; - - RETURN NEW; - END; - $$; - """) - - -def downgrade() -> None: - # 恢复旧 trigger 函数 - op.execute(""" - CREATE OR REPLACE FUNCTION public.create_profile_for_new_user() - RETURNS trigger - LANGUAGE plpgsql - SECURITY DEFINER - SET search_path = public - AS $$ - BEGIN - INSERT INTO public.profiles (id, username, avatar_url, bio, settings, created_at, updated_at) - VALUES ( - NEW.id, - COALESCE( - NEW.raw_user_meta_data ->> 'username', - split_part(NEW.email, '@', 1), - 'user_' || substring(NEW.id::text, 1, 8) - ), - NULL, - NULL, - '{}'::jsonb, - now(), - now() - ) - ON CONFLICT (id) DO NOTHING; - - RETURN NEW; - END; - $$; - """) - - _drop_rls("user_agent_catalog") - op.drop_constraint("chk_user_agent_catalog_status", "user_agent_catalog", type_="check") - op.drop_constraint("fk_user_agent_catalog_llm_id", "user_agent_catalog", type_="foreignkey") - op.drop_table("user_agent_catalog") - - -def _enable_rls(table_name: str) -> None: - for role in ["anon", "authenticated"]: - for action in ["select", "insert", "update", "delete"]: - op.execute( - f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}" - ) - op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY") - for role in ["anon", "authenticated"]: - op.execute( - f"CREATE POLICY {role}_select_{table_name} ON {table_name} FOR SELECT TO {role} USING (false)" - ) - op.execute( - f"CREATE POLICY {role}_insert_{table_name} ON {table_name} FOR INSERT TO {role} WITH CHECK (false)" - ) - op.execute( - f"CREATE POLICY {role}_update_{table_name} ON {table_name} FOR UPDATE TO {role} USING (false) WITH CHECK (false)" - ) - op.execute( - f"CREATE POLICY {role}_delete_{table_name} ON {table_name} FOR DELETE TO {role} USING (false)" - ) - - -def _drop_rls(table_name: str) -> None: - for role in ["anon", "authenticated"]: - op.execute(f"DROP POLICY IF EXISTS {role}_delete_{table_name} ON {table_name}") - op.execute(f"DROP POLICY IF EXISTS {role}_update_{table_name} ON {table_name}") - op.execute(f"DROP POLICY IF EXISTS {role}_insert_{table_name} ON {table_name}") - op.execute(f"DROP POLICY IF EXISTS {role}_select_{table_name} ON {table_name}") - op.execute(f"ALTER TABLE {table_name} DISABLE ROW LEVEL SECURITY") -``` - -**Step 3: 运行迁移检查** - -Run: `cd backend && uv run alembic check` - -Expected: No issues - -**Step 4: Commit** - -```bash -git add backend/alembic/versions/20260302_add_user_agent_catalog.py -git commit -m "feat(migration): add user_agent_catalog table and update trigger" -``` - ---- - -### Task 4: 扩展初始化函数 - -**Files:** -- Modify: `backend/src/core/config/initial/init_data.py` - -**Step 1: 添加 Pydantic 模型** - -```python -# backend/src/core/config/initial/init_data.py (添加到文件顶部) -from models.user_agent_catalog import UserAgentCatalog - - -class UserAgentCatalogSeed(BaseModel): - agent_type: str - llm_model_code: str - status: str - config: dict[str, Any] - - -class UserAgentCatalogYaml(BaseModel): - agents: list[UserAgentCatalogSeed] -``` - -**Step 2: 添加 load 函数** - -```python -# backend/src/core/config/initial/init_data.py (添加) - -def _default_user_agent_catalog_path() -> Path: - return ( - Path(__file__).resolve().parents[1] / "static" / "database" / "user_agent_catalog.yaml" - ) - - -def load_user_agent_catalog(catalog_path: Path | None = None) -> dict[str, Any]: - path = catalog_path or _default_user_agent_catalog_path() - with path.open("r", encoding="utf-8") as file: - loaded = yaml.safe_load(file) or {} - if not isinstance(loaded, dict): - raise ValueError(f"Invalid user agent catalog format: {path}") - raw_agents = loaded.get("agents", []) - if not isinstance(raw_agents, list): - raise ValueError(f"Invalid user agent catalog agents section: {path}") - try: - parsed = UserAgentCatalogYaml.model_validate({"agents": list(raw_agents)}) - except ValidationError as exc: - raise ValueError(f"Invalid user agent catalog data: {path}") from exc - - return parsed.model_dump() -``` - -**Step 3: 添加初始化函数** - -```python -# backend/src/core/config/initial/init_data.py (添加) - -async def _upsert_user_agent_catalog( - session: AsyncSession, - *, - agent_type: str, - llm_id: uuid.UUID, - status: str, - config: dict[str, Any], -) -> None: - result = await session.execute( - select(UserAgentCatalog).where(UserAgentCatalog.agent_type == agent_type) - ) - catalog_entry = result.scalar_one_or_none() - - if catalog_entry is None: - session.add(UserAgentCatalog( - agent_type=agent_type, - llm_id=llm_id, - status=status, - config=config, - )) - else: - catalog_entry.llm_id = llm_id - catalog_entry.status = status - catalog_entry.config = config - - -async def initialize_user_agent_catalog() -> None: - """Initialize user agent catalog from YAML.""" - catalog = load_user_agent_catalog() - - async with AsyncSessionLocal() as session: - async with session.begin(): - for agent in catalog["agents"]: - # 查找 llm_id - result = await session.execute( - select(Llm).where(Llm.model_code == agent["llm_model_code"]) - ) - llm = result.scalar_one_or_none() - if llm is None: - raise RuntimeError( - f"LLM model '{agent['llm_model_code']}' not found for agent type '{agent['agent_type']}'" - ) - - await _upsert_user_agent_catalog( - session, - agent_type=agent["agent_type"], - llm_id=llm.id, - status=agent["status"], - config=agent["config"], - ) - - logger.info("Initialized user agent catalog") -``` - -**Step 4: 更新 initialize_data 函数** - -```python -# backend/src/core/config/initial/init_data.py (修改) - -async def initialize_data() -> bool: - """Initialize bootstrap data.""" - await initialize_llm_catalog() - await initialize_user_agent_catalog() # 新增 - - return True -``` - -**Step 5: 运行类型检查** - -Run: `cd backend && uv run basedpyright src/core/config/initial/init_data.py` - -Expected: No errors - -**Step 6: Commit** - -```bash -git add backend/src/core/config/initial/init_data.py -git commit -m "feat(init): add user_agent_catalog initialization" -``` - ---- - -### Task 5: 编写单元测试 - -**Files:** -- Modify: `backend/tests/unit/core/test_agent_init_data.py` - -**Step 1: 添加测试** - -```python -# backend/tests/unit/core/test_agent_init_data.py (添加到文件末尾) - -def test_user_agent_catalog_file_exists_and_has_required_fields() -> None: - catalog_path = ( - Path(__file__).resolve().parents[4] - / "src" - / "core" - / "config" - / "static" - / "database" - / "user_agent_catalog.yaml" - ) - - assert catalog_path.exists(), f"Catalog file not found: {catalog_path}" - - catalog = init_data.load_user_agent_catalog(catalog_path) - - assert "agents" in catalog - assert isinstance(catalog["agents"], list) - assert len(catalog["agents"]) == 3 - - for agent in catalog["agents"]: - assert "agent_type" in agent - assert "llm_model_code" in agent - assert "status" in agent - assert "config" in agent - assert isinstance(agent["config"], dict) - - -def test_load_user_agent_catalog_raises_on_invalid_structure(tmp_path: Path) -> None: - catalog_path = tmp_path / "user_agent_catalog.yaml" - catalog_path.write_text("invalid: structure\n") - - with pytest.raises(ValueError, match="Invalid user agent catalog"): - init_data.load_user_agent_catalog(catalog_path) -``` - -**Step 2: 运行测试** - -Run: `cd backend && uv run pytest tests/unit/core/test_agent_init_data.py -v` - -Expected: All tests pass - -**Step 3: Commit** - -```bash -git add backend/tests/unit/core/test_agent_init_data.py -git commit -m "test(init): add user_agent_catalog validation tests" -``` - ---- - -### Task 6: 运行迁移并验证 - -**Files:** -- None (database operations) - -**Step 1: 运行迁移** - -Run: `cd backend && uv run alembic upgrade head` - -Expected: Migration succeeds - -**Step 2: 运行初始化** - -Run: `cd backend && uv run python -m core.runtime.cli init-data` - -Expected: "Initialized user agent catalog" in output - -**Step 3: 验证数据库** - -Run: `docker exec -it social-supabase-db psql -U postgres -d postgres -c "SELECT * FROM user_agent_catalog;"` - -Expected: 3 rows (INTENT_RECOGNITION, TASK_EXECUTION, RESULT_REPORTING) - -**Step 4: 测试 Trigger(手动)** - -```sql --- 创建测试用户 -INSERT INTO auth.users (id, email, raw_user_meta_data) -VALUES (gen_random_uuid(), 'test@example.com', '{"username": "testuser"}'); - --- 验证 profiles 和 user_agents 自动创建 -SELECT * FROM profiles WHERE username = 'testuser'; -SELECT * FROM user_agents WHERE user_id = (SELECT id FROM profiles WHERE username = 'testuser'); - --- 清理测试数据 -DELETE FROM user_agents WHERE user_id = (SELECT id FROM profiles WHERE username = 'testuser'); -DELETE FROM profiles WHERE username = 'testuser'; -DELETE FROM auth.users WHERE email = 'test@example.com'; -``` - -Expected: profiles 有 1 条,user_agents 有 3 条 - ---- - -### Task 7: 为已存在用户补充 user_agents - -**Files:** -- None (database operations) - -**Step 1: 执行补充脚本** - -```sql --- 为已存在但没有 user_agents 的用户补充记录 -INSERT INTO user_agents (id, user_id, llm_id, agent_type, config, status, created_by, updated_by) -SELECT - gen_random_uuid(), - p.id, - uac.llm_id, - uac.agent_type, - uac.config, - uac.status, - p.id, - p.id -FROM profiles p -CROSS JOIN user_agent_catalog uac -WHERE NOT EXISTS ( - SELECT 1 FROM user_agents ua WHERE ua.user_id = p.id -); -``` - -**Step 2: 验证结果** - -Run: `docker exec -it social-supabase-db psql -U postgres -d postgres -c "SELECT user_id, COUNT(*) FROM user_agents GROUP BY user_id;"` - -Expected: 每个用户有 3 条 user_agents 记录 - ---- - -## TOKEN-001: Flutter 硬编码颜色迁移 - -### Task 8: 扫描硬编码颜色 - -**Files:** -- None (analysis only) - -**Step 1: 扫描所有硬编码颜色** - -Run: -```bash -cd apps && grep -r "Color(0x" lib --include="*.dart" | \ - sed 's/.*Color(0x\([0-9A-Fa-f]*\)).*/\1/' | \ - sort | uniq -c | sort -rn > /tmp/color_stats.txt && \ - cat /tmp/color_stats.txt -``` - -Expected: 统计结果输出,显示每个颜色的使用频率 - -**Step 2: 记录统计结果** - -将统计结果保存到临时文件供后续使用。 - ---- - -### Task 9: 扩展 AppColors - -**Files:** -- Modify: `apps/lib/core/theme/design_tokens.dart` - -**Step 1: 添加新颜色常量** - -```dart -// apps/lib/core/theme/design_tokens.dart (在 AppColors 类中添加) - -class AppColors { - // 现有颜色... - - // 从硬编码迁移的颜色(根据 Task 8 的统计结果) - static const Color linkBlue = Color(0xFF2196F3); - static const Color errorRed = Color(0xFFD32F2F); - static const Color successGreen = Color(0xFF4CAF50); - static const Color warningOrange = Color(0xFFFF9800); - // ... 根据统计结果添加其他高频颜色 -} -``` - -**Step 2: 运行 Flutter 分析** - -Run: `cd apps && flutter analyze lib/core/theme/design_tokens.dart` - -Expected: No issues - -**Step 3: Commit** - -```bash -git add apps/lib/core/theme/design_tokens.dart -git commit -m "feat(theme): add migrated colors to AppColors" -``` - ---- - -### Task 10: 迁移 register_screen.dart - -**Files:** -- Modify: `apps/lib/features/auth/ui/screens/register_screen.dart` - -**Step 1: 替换硬编码颜色** - -```dart -// Before -Container(color: Color(0xFF2196F3)) - -// After -Container(color: AppColors.linkBlue) -``` - -**Step 2: 检查所有实例** - -Run: `cd apps && grep "Color(0x" lib/features/auth/ui/screens/register_screen.dart` - -Expected: No matches (或只剩下合理的动态颜色) - -**Step 3: 运行 Flutter 分析** - -Run: `cd apps && flutter analyze lib/features/auth/ui/screens/register_screen.dart` - -Expected: No issues - -**Step 4: Commit** - -```bash -git add apps/lib/features/auth/ui/screens/register_screen.dart -git commit -m "refactor(auth): migrate hardcoded colors in register_screen" -``` - ---- - -### Task 11: 迁移其他页面(批量) - -**Files:** -- Modify: 所有包含硬编码颜色的页面文件 - -**Step 1: 逐页面迁移** - -按照频率顺序迁移: -1. `register_verification_screen.dart` -2. `settings_screen.dart` -3. `account_screen.dart` -4. `contacts_screen.dart` -5. `calendar_event_detail_screen.dart` -6. `add_contact_screen.dart` -7. `features_screen.dart` -8. `memory_screen.dart` -9. `home_screen.dart` -10. `todo_detail_screen.dart` - -每个文件重复 Task 10 的步骤。 - -**Step 2: 批量提交** - -```bash -git add apps/lib/features/ -git commit -m "refactor(ui): migrate hardcoded colors to AppColors" -``` - ---- - -### Task 12: 验证和测试 - -**Files:** -- None (validation only) - -**Step 1: 检查剩余硬编码** - -Run: `cd apps && grep -r "Color(0x" lib --include="*.dart" | wc -l` - -Expected: < 10 (只保留合理的动态颜色) - -**Step 2: 运行 Flutter 测试** - -Run: `cd apps && flutter test` - -Expected: All tests pass - -**Step 3: 视觉检查** - -手动运行应用,检查页面显示是否正常。 - ---- - -## Final Verification - -### Task 13: 全量测试 - -**Files:** -- None (testing only) - -**Step 1: 运行后端测试** - -Run: `cd backend && uv run pytest` - -Expected: All tests pass - -**Step 2: 运行 Flutter 测试** - -Run: `cd apps && flutter test` - -Expected: All tests pass - -**Step 3: 运行迁移检查** - -Run: `cd backend && uv run alembic check` - -Expected: No issues - -**Step 4: 类型检查** - -Run: `cd backend && uv run basedpyright src/` - -Expected: No errors - ---- - -## Summary - -**Total Tasks:** 13 - -**Estimated Time:** 3-4 hours - -**Key Deliverables:** -- ✅ user_agent_catalog.yaml 配置文件 -- ✅ UserAgentCatalog 模型 -- ✅ 数据库迁移(表 + trigger) -- ✅ 初始化函数扩展 -- ✅ 单元测试 -- ✅ 硬编码颜色迁移(109 → <10) - -**Success Criteria:** -- [ ] 新用户注册后自动创建 3 个 user_agents 记录 -- [ ] 已存在用户不受影响 -- [ ] 配置可通过 YAML + 重启更新 -- [ ] 硬编码颜色数量 < 10 -- [ ] 所有测试通过 -- [ ] UI 视觉无回归 diff --git a/docs/plans/2026-03-02-calendar-create-event-design.md b/docs/plans/2026-03-02-calendar-create-event-design.md deleted file mode 100644 index 56ca026..0000000 --- a/docs/plans/2026-03-02-calendar-create-event-design.md +++ /dev/null @@ -1,100 +0,0 @@ -# 日历事件创建功能设计 - -**Date:** 2026-03-02 -**Status:** Approved - ---- - -## 1. UI 架构 - -### 入口 - -- 位置:月视图和日视图右上角 -- 图标:LucideIcons.plus -- 点击后从底部弹出创建表单(showModalBottomSheet) - -### 底部弹窗表单 - -- 高度:约占屏幕 80% -- 顶部有关闭按钮和"新建日程"标题 -- 内含两个可切换的 TabBar(类似苹果日历) - -### 两个 Tab - -| Tab | 字段 | -|-----|------| -| 基础 | 标题、开始日期/时间、结束日期/时间 | -| 进阶 | 描述、地点、颜色、备注 | - ---- - -## 2. 数据模型 - -```dart -class ScheduleItemModel { - String id; // UUID - String title; // 标题(必填) - String? description; // 描述 - DateTime startAt; // 开始时间(必填) - DateTime? endAt; // 结束时间 - String timezone; // 时区,默认 "Asia/Shanghai" - ScheduleMetadata? metadata; // 扩展字段 - String sourceType; // 来源,默认 "manual" - String status; // 状态,默认 "active" -} - -class ScheduleMetadata { - String? color; // 颜色,如 "#3B82F6" - String? location; // 地点 - String? notes; // 备注 - List? attachments; -} -``` - ---- - -## 3. Mock 服务设计 - -参考 `mock_history_service.dart` 模式,创建 `mock_calendar_service.dart`: - -- 使用 `Env.isMockApi` 开关 -- 内存中存储事件列表 -- 支持 CRUD 操作 - ---- - -## 4. 日历视图集成 - -### 月视图 - -- `_buildWeekEvents` 遍历当天事件,显示最多 2-3 个事件标题 -- 点击日期跳转到日视图 - -### 日视图 - -- `_buildTimelineBoard` 在对应时间位置显示事件块 -- 点击事件进入详情页 - ---- - -## 5. 路由 - -现有路由已支持: -- `/calendar/month` - 月视图 -- `/calendar/dayweek` - 日视图 -- `/calendar/events/:id` - 事件详情页 - -底部弹窗使用 showModalBottomSheet,无需新路由。 - ---- - -## 6. 实现步骤 - -1. 创建数据模型 `schedule_item_model.dart` -2. 创建 Mock Calendar Service -3. 创建底部弹窗创建表单组件 -4. 在月视图添加 + 号图标 -5. 在日视图添加 + 号图标 -6. 集成事件显示到月视图 -7. 集成事件显示到日视图 -8. 更新事件详情页支持编辑 diff --git a/docs/plans/2026-03-02-calendar-create-event-plan.md b/docs/plans/2026-03-02-calendar-create-event-plan.md deleted file mode 100644 index 00baf0b..0000000 --- a/docs/plans/2026-03-02-calendar-create-event-plan.md +++ /dev/null @@ -1,1132 +0,0 @@ -# 日历事件创建功能实现计划 - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** 在日历月视图和日视图右上角添加 + 号图标,点击后弹出底部表单创建日历事件,创建完成后在视图中正确显示,并可点击进入详情页查看和编辑。 - -**Architecture:** -- 使用 Mock Service 模式存储日历事件(参考 mock_history_service.dart) -- 底部弹窗表单使用 showModalBottomSheet -- 日视图使用时间线布局显示事件 - -**Tech Stack:** Flutter, BLoC, go_router, lucide_icons - ---- - -## Task 1: 创建日历事件数据模型 - -**Files:** -- Create: `apps/lib/features/calendar/data/models/schedule_item_model.dart` - -**Step 1: 创建数据模型文件** - -```dart -import 'package:flutter/material.dart'; - -enum ScheduleSourceType { manual, imported, agentGenerated } - -enum ScheduleStatus { active, completed, canceled, archived } - -class ScheduleItemModel { - final String id; - final String title; - final String? description; - final DateTime startAt; - final DateTime? endAt; - final String timezone; - final ScheduleMetadata? metadata; - final ScheduleSourceType sourceType; - final ScheduleStatus status; - final DateTime createdAt; - - ScheduleItemModel({ - required this.id, - required this.title, - this.description, - required this.startAt, - this.endAt, - this.timezone = 'Asia/Shanghai', - this.metadata, - this.sourceType = ScheduleSourceType.manual, - this.status = ScheduleStatus.active, - DateTime? createdAt, - }) : createdAt = createdAt ?? DateTime.now(); - - ScheduleItemModel copyWith({ - String? id, - String? title, - String? description, - DateTime? startAt, - DateTime? endAt, - String? timezone, - ScheduleMetadata? metadata, - ScheduleSourceType? sourceType, - ScheduleStatus? status, - DateTime? createdAt, - }) { - return ScheduleItemModel( - id: id ?? this.id, - title: title ?? this.title, - description: description ?? this.description, - startAt: startAt ?? this.startAt, - endAt: endAt ?? this.endAt, - timezone: timezone ?? this.timezone, - metadata: metadata ?? this.metadata, - sourceType: sourceType ?? this.sourceType, - status: status ?? this.status, - createdAt: createdAt ?? this.createdAt, - ); - } -} - -class ScheduleMetadata { - final String? color; - final String? location; - final String? notes; - final List? attachments; - - ScheduleMetadata({ - this.color, - this.location, - this.notes, - this.attachments, - }); - - ScheduleMetadata copyWith({ - String? color, - String? location, - String? notes, - List? attachments, - }) { - return ScheduleMetadata( - color: color ?? this.color, - location: location ?? this.location, - notes: notes ?? this.notes, - attachments: attachments ?? this.attachments, - ); - } -} - -class Attachment { - final String name; - final String? url; - final String? content; - final String type; - - Attachment({ - required this.name, - this.url, - this.content, - this.type = 'document', - }); -} - -const defaultColors = [ - Color(0xFF3B82F6), // 蓝色 - Color(0xFF8B5CF6), // 紫色 - Color(0xFF10B981), // 绿色 - Color(0xFFF59E0B), // 黄色 - Color(0xFFEF4444), // 红色 -]; -``` - -**Step 2: 提交** - -```bash -git add apps/lib/features/calendar/data/models/schedule_item_model.dart -git commit -m "feat(calendar): 添加日历事件数据模型" -``` - ---- - -## Task 2: 创建 Mock Calendar Service - -**Files:** -- Create: `apps/lib/features/calendar/data/services/mock_calendar_service.dart` - -**Step 1: 创建 Mock Service** - -```dart -import 'package:Env/Env.dart'; -import '../models/schedule_item_model.dart'; - -class MockCalendarService { - static final MockCalendarService _instance = MockCalendarService._internal(); - factory MockCalendarService() => _instance; - MockCalendarService._internal(); - - final List _events = []; - - List get events => List.unmodifiable(_events); - - List getEventsForDay(DateTime date) { - final dateOnly = DateTime(date.year, date.month, date.day); - return _events.where((event) { - final eventDate = DateTime( - event.startAt.year, - event.startAt.month, - event.startAt.day, - ); - return eventDate == dateOnly && event.status == ScheduleStatus.active; - }).toList() - ..sort((a, b) => a.startAt.compareTo(b.startAt)); - } - - List getEventsForRange(DateTime start, DateTime end) { - return _events.where((event) { - return event.startAt.isAfter(start.subtract(const Duration(days: 1))) && - event.startAt.isBefore(end.add(const Duration(days: 1))) && - event.status == ScheduleStatus.active; - }).toList() - ..sort((a, b) => a.startAt.compareTo(b.startAt)); - } - - ScheduleItemModel? getEventById(String id) { - try { - return _events.firstWhere((e) => e.id == id); - } catch (_) { - return null; - } - } - - void addEvent(ScheduleItemModel event) { - _events.add(event); - } - - void updateEvent(ScheduleItemModel event) { - final index = _events.indexWhere((e) => e.id == event.id); - if (index >= 0) { - _events[index] = event; - } - } - - void deleteEvent(String id) { - _events.removeWhere((e) => e.id == id); - } -} - -class CalendarService { - static final CalendarService _instance = CalendarService._internal(); - factory CalendarService() => _instance; - CalendarService._internal(); - - MockCalendarService get _mock => MockCalendarService(); - - List getEventsForDay(DateTime date) { - if (Env.isMockApi) { - return _mock.getEventsForDay(date); - } - throw UnimplementedError('Real API not implemented'); - } - - List getEventsForRange(DateTime start, DateTime end) { - if (Env.isMockApi) { - return _mock.getEventsForRange(start, end); - } - throw UnimplementedError('Real API not implemented'); - } - - ScheduleItemModel? getEventById(String id) { - if (Env.isMockApi) { - return _mock.getEventById(id); - } - throw UnimplementedError('Real API not implemented'); - } - - void addEvent(ScheduleItemModel event) { - if (Env.isMockApi) { - _mock.addEvent(event); - return; - } - throw UnimplementedError('Real API not implemented'); - } - - void updateEvent(ScheduleItemModel event) { - if (Env.isMockApi) { - _mock.updateEvent(event); - return; - } - throw UnimplementedError('Real API not implemented'); - } - - void deleteEvent(String id) { - if (Env.isMockApi) { - _mock.deleteEvent(id); - return; - } - throw UnimplementedError('Real API not implemented'); - } -} -``` - -**Step 2: 提交** - -```bash -git add apps/lib/features/calendar/data/services/mock_calendar_service.dart -git commit -m "feat(calendar): 添加 Mock Calendar Service" -``` - ---- - -## Task 3: 创建日历事件创建底部弹窗 - -**Files:** -- Create: `apps/lib/features/calendar/ui/widgets/create_event_sheet.dart` - -**Step 1: 创建底部弹窗组件** - -```dart -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:lucide_icons/lucide_icons.dart'; -import '../../../../core/theme/design_tokens.dart'; -import '../../data/models/schedule_item_model.dart'; -import '../../data/services/mock_calendar_service.dart'; - -class CreateEventSheet extends StatefulWidget { - final DateTime? initialDate; - final ScheduleItemModel? editingEvent; - - const CreateEventSheet({ - super.key, - this.initialDate, - this.editingEvent, - }); - - static Future show(BuildContext context, {DateTime? initialDate}) { - return showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (context) => CreateEventSheet(initialDate: initialDate), - ); - } - - static Future edit(BuildContext context, ScheduleItemModel event) { - return showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (context) => CreateEventSheet(editingEvent: event), - ); - } - - @override - State createState() => _CreateEventSheetState(); -} - -class _CreateEventSheetState extends State { - late TabController _tabController; - final _titleController = TextEditingController(); - final _descriptionController = TextEditingController(); - final _locationController = TextEditingController(); - final _notesController = TextEditingController(); - - late DateTime _startDate; - late DateTime _startTime; - DateTime? _endDate; - DateTime? _endTime; - String _selectedColor = '#3B82F6'; - - bool get _isEditing => widget.editingEvent != null; - - @override - void initState() { - super.initState(); - _tabController = TabController(length: 2, vsync: this); - - if (_isEditing) { - final event = widget.editingEvent!; - _titleController.text = event.title; - _descriptionController.text = event.description ?? ''; - _locationController.text = event.metadata?.location ?? ''; - _notesController.text = event.metadata?.notes ?? ''; - _startDate = event.startAt; - _startTime = event.startAt; - _endDate = event.endAt; - _endTime = event.endAt; - _selectedColor = event.metadata?.color ?? '#3B82F6'; - } else { - final now = widget.initialDate ?? DateTime.now(); - _startDate = now; - _startTime = now; - _endDate = now; - _endTime = now.add(const Duration(hours: 1)); - } - } - - @override - void dispose() { - _tabController.dispose(); - _titleController.dispose(); - _descriptionController.dispose(); - _locationController.dispose(); - _notesController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Container( - height: MediaQuery.of(context).size.height * 0.85, - decoration: const BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - child: Column( - children: [ - _buildHeader(), - _buildTabBar(), - Expanded(child: _buildTabContent()), - ], - ), - ); - } - - Widget _buildHeader() { - return Container( - height: 56, - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - GestureDetector( - onTap: () => Navigator.pop(context), - child: const Icon(LucideIcons.x, size: 24, color: AppColors.slate700), - ), - Text( - _isEditing ? '编辑日程' : '新建日程', - style: const TextStyle( - fontSize: 17, - fontWeight: FontWeight.w600, - color: AppColors.slate900, - ), - ), - GestureDetector( - onTap: _saveEvent, - child: Text( - '保存', - style: TextStyle( - fontSize: 17, - fontWeight: FontWeight.w600, - color: _titleController.text.trim().isNotEmpty - ? AppColors.blue600 - : AppColors.slate400, - ), - ), - ), - ], - ), - ); - } - - Widget _buildTabBar() { - return Container( - decoration: const BoxDecoration( - border: Border(bottom: BorderSide(color: AppColors.border)), - ), - child: TabBar( - controller: _tabController, - labelColor: AppColors.blue600, - unselectedLabelColor: AppColors.slate600, - indicatorColor: AppColors.blue600, - tabs: const [ - Tab(text: '基础'), - Tab(text: '进阶'), - ], - ), - ); - } - - Widget _buildTabContent() { - return TabBarView( - controller: _tabController, - children: [ - _buildBasicTab(), - _buildAdvancedTab(), - ], - ); - } - - Widget _buildBasicTab() { - return SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildTextField('标题', _titleController, '请输入日程标题'), - const SizedBox(height: 20), - _buildDateTimePicker('开始', _startDate, _startTime, (date, time) { - setState(() { - _startDate = date; - _startTime = time; - }); - }), - const SizedBox(height: 20), - _buildDateTimePicker('结束', _endDate ?? _startDate, _endTime ?? _startTime, (date, time) { - setState(() { - _endDate = date; - _endTime = time; - }); - }, isOptional: true), - ], - ), - ); - } - - Widget _buildAdvancedTab() { - return SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildTextField('描述', _descriptionController, '请输入描述'), - const SizedBox(height: 20), - _buildTextField('地点', _locationController, '请输入地点'), - const SizedBox(height: 20), - _buildColorPicker(), - const SizedBox(height: 20), - _buildTextField('备注', _notesController, '请输入备注', maxLines: 3), - ], - ), - ); - } - - Widget _buildTextField(String label, TextEditingController controller, String hint, {int maxLines = 1}) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.slate700), - ), - const SizedBox(height: 8), - TextField( - controller: controller, - maxLines: maxLines, - decoration: InputDecoration( - hintText: hint, - hintStyle: const TextStyle(color: AppColors.slate400), - filled: true, - fillColor: AppColors.slate50, - border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none), - contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), - ), - ), - ], - ); - } - - Widget _buildDateTimePicker(String label, DateTime date, DateTime time, Function(DateTime, DateTime) onChanged, {bool isOptional = false}) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label + (isOptional ? '(可选)' : ''), - style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.slate700), - ), - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: GestureDetector( - onTap: () => _showDatePicker(date, (newDate) { - onChanged(newDate, time); - }), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), - decoration: BoxDecoration( - color: AppColors.slate50, - borderRadius: BorderRadius.circular(10), - ), - child: Text( - '${date.year}年${date.month}月${date.day}日', - style: const TextStyle(fontSize: 15, color: AppColors.slate900), - ), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: GestureDetector( - onTap: () => _showTimePicker(time, (newTime) { - onChanged(date, newTime); - }), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), - decoration: BoxDecoration( - color: AppColors.slate50, - borderRadius: BorderRadius.circular(10), - ), - child: Text( - '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}', - style: const TextStyle(fontSize: 15, color: AppColors.slate900), - ), - ), - ), - ), - ], - ), - ], - ); - } - - Widget _buildColorPicker() { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '颜色', - style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.slate700), - ), - const SizedBox(height: 8), - Row( - children: defaultColors.map((color) { - final colorHex = '#${color.value.toRadixString(16).substring(2).toUpperCase()}'; - final isSelected = _selectedColor == colorHex; - return GestureDetector( - onTap: () => setState(() => _selectedColor = colorHex), - child: Container( - margin: const EdgeInsets.only(right: 12), - width: 32, - height: 32, - decoration: BoxDecoration( - color: color, - shape: BoxShape.circle, - border: isSelected ? Border.all(color: AppColors.slate900, width: 2) : null, - ), - child: isSelected ? const Icon(Icons.check, size: 16, color: Colors.white) : null, - ), - ); - }).toList(), - ), - ], - ); - } - - void _showDatePicker(DateTime initial, Function(DateTime) onChanged) { - showModalBottomSheet( - context: context, - builder: (context) => Container( - height: 280, - color: Colors.white, - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - TextButton(onPressed: () => Navigator.pop(context), child: const Text('取消')), - TextButton(onPressed: () => Navigator.pop(context), child: const Text('确定')), - ], - ), - Expanded( - child: CupertinoDatePicker( - mode: CupertinoDatePickerMode.date, - initialDateTime: initial, - onDateTimeChanged: onChanged, - ), - ), - ], - ), - ), - ); - } - - void _showTimePicker(DateTime initial, Function(DateTime) onChanged) { - showModalBottomSheet( - context: context, - builder: (context) => Container( - height: 280, - color: Colors.white, - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - TextButton(onPressed: () => Navigator.pop(context), child: const Text('取消')), - TextButton(onPressed: () => Navigator.pop(context), child: const Text('确定')), - ], - ), - Expanded( - child: CupertinoDatePicker( - mode: CupertinoDatePickerMode.time, - initialDateTime: initial, - onDateTimeChanged: onChanged, - ), - ), - ], - ), - ), - ); - } - - void _saveEvent() { - if (_titleController.text.trim().isEmpty) return; - - final startAt = DateTime( - _startDate.year, - _startDate.month, - _startDate.day, - _startTime.hour, - _startTime.minute, - ); - - DateTime? endAt; - if (_endDate != null && _endTime != null) { - endAt = DateTime( - _endDate!.year, - _endDate!.month, - _endDate!.day, - _endTime!.hour, - _endTime!.minute, - ); - } - - final metadata = ScheduleMetadata( - color: _selectedColor, - location: _locationController.text.trim().isNotEmpty ? _locationController.text.trim() : null, - notes: _notesController.text.trim().isNotEmpty ? _notesController.text.trim() : null, - ); - - final event = ScheduleItemModel( - id: _isEditing ? widget.editingEvent!.id : 'evt_${DateTime.now().millisecondsSinceEpoch}', - title: _titleController.text.trim(), - description: _descriptionController.text.trim().isNotEmpty ? _descriptionController.text.trim() : null, - startAt: startAt, - endAt: endAt, - metadata: metadata, - ); - - final service = CalendarService(); - if (_isEditing) { - service.updateEvent(event); - } else { - service.addEvent(event); - } - - Navigator.pop(context); - } -} -``` - -**Step 2: 提交** - -```bash -git add apps/lib/features/calendar/ui/widgets/create_event_sheet.dart -git commit -m "feat(calendar): 添加日历事件创建底部弹窗组件" -``` - ---- - -## Task 4: 在月视图添加 + 号图标 - -**Files:** -- Modify: `apps/lib/features/calendar/ui/screens/calendar_month_screen.dart` - -**Step 1: 添加 + 号图标到 header** - -在 `_buildHeader` 方法中,在 `Row` 的末尾添加: - -```dart -// 在现有的 Row children 中添加 -const Spacer(), -GestureDetector( - onTap: () => CreateEventSheet.show(context), - child: Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: AppColors.blue600, - borderRadius: BorderRadius.circular(18), - ), - child: const Icon( - LucideIcons.plus, - size: 20, - color: Colors.white, - ), - ), -), -``` - -同时添加 import: -```dart -import '../../ui/widgets/create_event_sheet.dart'; -``` - -**Step 2: 提交** - -```bash -git add apps/lib/features/calendar/ui/screens/calendar_month_screen.dart -git commit -m "feat(calendar): 在月视图添加创建事件按钮" -``` - ---- - -## Task 5: 在日视图添加 + 号图标 - -**Files:** -- Modify: `apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart` - -**Step 1: 添加 + 号图标到 header** - -在 `_buildHeader` 方法中,在 `Row` 的末尾添加: - -```dart -const Spacer(), -GestureDetector( - onTap: () => CreateEventSheet.show(context, initialDate: _selectedDate), - child: Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: AppColors.blue600, - borderRadius: BorderRadius.circular(18), - ), - child: const Icon( - LucideIcons.plus, - size: 20, - color: Colors.white, - ), - ), -), -``` - -同时添加 import: -```dart -import '../../ui/widgets/create_event_sheet.dart'; -``` - -**Step 2: 提交** - -```bash -git add apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart -git commit -m "feat(calendar): 在日视图添加创建事件按钮" -``` - ---- - -## Task 6: 在月视图显示事件 - -**Files:** -- Modify: `apps/lib/features/calendar/ui/screens/calendar_month_screen.dart` - -**Step 1: 修改 `_buildWeekEvents` 方法显示事件** - -```dart -import '../../data/services/mock_calendar_service.dart'; - -Widget _buildWeekEvents(int weekStart, int startWeekday, int daysInMonth) { - // 找到这一周第一天的日期 - final firstDayOfMonth = DateTime(_currentMonth.year, _currentMonth.month, 1); - final weekFirstDate = firstDayOfMonth.add(Duration(days: weekStart - startWeekday)); - - return SizedBox( - height: 70, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: List.generate(7, (index) { - final dayIndex = weekStart + index - startWeekday + 1; - if (dayIndex < 1 || dayIndex > daysInMonth) { - return const SizedBox(width: 38, height: 1); - } - - final date = weekFirstDate.add(Duration(days: index)); - final events = CalendarService().getEventsForDay(date); - - return SizedBox( - width: 38, - height: 70, - child: Column( - children: events.take(2).map((event) { - final color = _parseColor(event.metadata?.color); - return Container( - margin: const EdgeInsets.only(bottom: 2), - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), - decoration: BoxDecoration( - color: color.withOpacity(0.2), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - event.title, - style: TextStyle(fontSize: 9, color: color, fontWeight: FontWeight.w500), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ); - }).toList(), - ), - ); - }), - ), - ); -} - -Color _parseColor(String? hex) { - if (hex == null || hex.isEmpty) return AppColors.blue600; - try { - return Color(int.parse(hex.replaceFirst('#', '0xFF'))); - } catch (_) { - return AppColors.blue600; - } -} -``` - -**Step 2: 提交** - -```bash -git add apps/lib/features/calendar/ui/screens/calendar_month_screen.dart -git commit -m "feat(calendar): 在月视图显示事件" -``` - ---- - -## Task 7: 在日视图显示事件 - -**Files:** -- Modify: `apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart` - -**Step 1: 修改 `_buildTimelineBoard` 方法显示事件** - -```dart -import '../../data/services/mock_calendar_service.dart'; -import '../../data/models/schedule_item_model.dart'; - -Widget _buildTimelineBoard() { - final now = DateTime.now(); - final showCurrent = shouldShowCurrentMarker(_selectedDate, now); - final events = CalendarService().getEventsForDay(_selectedDate); - final rows = []; - - for (var hour = 7; hour <= 22; hour++) { - // 查找这个小时的事件 - final hourEvents = events.where((e) => e.startAt.hour == hour).toList(); - - if (hourEvents.isNotEmpty) { - rows.add(_buildEventRow(hourEvents)); - } - - rows.add(_buildTimelineRow(formatHour(hour))); - if (showCurrent && now.hour == hour) { - rows.add(_buildTimelineRow(formatHm(now), isCurrentTime: true)); - } - } - - rows.add(_buildTimelineRow(formatHour(24), isDisabled: true)); - return Column(children: rows); -} - -Widget _buildEventRow(List events) { - return SizedBox( - height: 34, - child: Row( - children: [ - const SizedBox(width: 52), - Expanded( - child: ...events.map((event) => GestureDetector( - onTap: () => context.push('/calendar/events/${event.id}'), - child: Container( - margin: const EdgeInsets.only(bottom: 2), - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: _parseColor(event.metadata?.color).withOpacity(0.2), - borderRadius: BorderRadius.circular(6), - border: Border.all( - color: _parseColor(event.metadata?.color), - width: 1, - ), - ), - child: Row( - children: [ - Container( - width: 8, - height: 8, - decoration: BoxDecoration( - color: _parseColor(event.metadata?.color), - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 6), - Expanded( - child: Text( - event.title, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: _parseColor(event.metadata?.color), - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ), - )), - ), - ], - ), - ); -} - -Color _parseColor(String? hex) { - if (hex == null || hex.isEmpty) return AppColors.blue600; - try { - return Color(int.parse(hex.replaceFirst('#', '0xFF'))); - } catch (_) { - return AppColors.blue600; - } -} -``` - -**Step 2: 提交** - -```bash -git add apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart -git commit -m "feat(calendar): 在日视图显示事件" -``` - ---- - -## Task 8: 更新事件详情页支持编辑和删除 - -**Files:** -- Modify: `apps/lib/features/calendar/ui/screens/calendar_event_detail_screen.dart` - -**Step 1: 修改详情页接收事件 ID 并加载数据** - -```dart -import 'package:flutter/material.dart'; -import 'package:lucide_icons/lucide_icons.dart'; -import 'package:go_router/go_router.dart'; -import '../../../../core/theme/design_tokens.dart'; -import '../../data/services/mock_calendar_service.dart'; -import '../../data/models/schedule_item_model.dart'; -import '../widgets/create_event_sheet.dart'; - -class CalendarEventDetailScreen extends StatefulWidget { - final String eventId; - - const CalendarEventDetailScreen({super.key, required this.eventId}); - - @override - State createState() => _CalendarEventDetailScreenState(); -} - -class _CalendarEventDetailScreenState extends State { - ScheduleItemModel? _event; - - @override - void initState() { - super.initState(); - _loadEvent(); - } - - void _loadEvent() { - _event = CalendarService().getEventById(widget.eventId); - } - - @override - Widget build(BuildContext context) { - if (_event == null) { - return Scaffold( - body: Center(child: Text('Event not found', style: TextStyle(color: AppColors.slate600))), - ); - } - // ... 其余代码使用 _event 而不是硬编码数据 - } -} -``` - -**Step 2: 修改 router 传递 eventId** - -```dart -// in app_router.dart -GoRoute( - path: '/calendar/events/:id', - builder: (context, state) => CalendarEventDetailScreen( - eventId: state.pathParameters['id']!, - ), -), -``` - -**Step 3: 添加编辑和删除功能** - -在详情页的编辑和删除按钮上添加: - -```dart -// 编辑按钮 -GestureDetector( - onTap: () => CreateEventSheet.edit(context, _event!), - child: Container(...), -), - -// 删除按钮 -GestureDetector( - onTap: () => _showDeleteConfirmation(), - child: Container(...), -), - -void _showDeleteConfirmation() { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('删除日程'), - content: const Text('确定要删除这个日程吗?'), - actions: [ - TextButton(onPressed: () => Navigator.pop(context), child: const Text('取消')), - TextButton( - onPressed: () { - CalendarService().deleteEvent(widget.eventId); - Navigator.pop(context); - context.pop(); - }, - child: Text('删除', style: TextStyle(color: AppColors.red500)), - ), - ], - ), - ); -} -``` - -**Step 4: 提交** - -```bash -git add apps/lib/features/calendar/ui/screens/calendar_event_detail_screen.dart -git add apps/lib/core/router/app_router.dart -git commit -m "feat(calendar): 详情页支持编辑和删除事件" -``` - ---- - -## Task 9: 运行测试并验证 - -**Step 1: 运行 Flutter 测试** - -```bash -cd apps -flutter test -``` - -**Step 2: 验证构建** - -```bash -flutter build apk --debug -``` - -**Step 3: 提交** - -```bash -git commit -m "chore(calendar): 测试通过并验证构建" -``` - ---- - -## 执行选项 - -**Plan complete and saved to `docs/plans/2026-03-02-calendar-create-event-design.md`. Two execution options:** - -**1. Subagent-Driven (this session)** - I dispatch fresh subagent per task, review between tasks, fast iteration - -**2. Parallel Session (separate)** - Open new session with executing-plans, batch execution with checkpoints - -**Which approach?** diff --git a/docs/plans/2026-03-02-config-restructuring-plan.md b/docs/plans/2026-03-02-config-restructuring-plan.md deleted file mode 100644 index 0eefbe4..0000000 --- a/docs/plans/2026-03-02-config-restructuring-plan.md +++ /dev/null @@ -1,184 +0,0 @@ -# Config 目录重构计划 - -**日期**: 2026-03-02 -**目标**: 重新整理 backend/src/core/config 下的配置文件,按领域分类 - ---- - -## 1. 需求背景 - -当前 `backend/src/core/config` 结构存在以下问题: - -1. **职责不清**: `initialization` 模块放在 `core/` 根目录,但实际是配置初始化,应归属 `config/` -2. **分类粗放**: `static/agent/` 混入了不同领域的配置(LLM 目录、工具白名单、Agent 模板) -3. **配置不符规范**: CrewAI 模板缺少必要字段(backstory、expected_output),且 prompts 目录是冗余的 - ---- - -## 2. 目标结构 - -``` -backend/src/core/ -├── config/ -│ ├── settings.py # 运行时配置(不变) -│ ├── initial/ -│ │ └── init_data.py # 种子数据初始化(移动) -│ └── static/ -│ ├── database/ -│ │ ├── llm_catalog.yaml # LLM 目录(移动) -│ │ └── user_agent_catalog.yaml # 用户 Agent 种子(新增,置空) -│ └── crewai/ -│ ├── agents.yaml # Agent 定义(移动 + 补充字段) -│ ├── tasks.yaml # Task 定义(移动 + 补充 expected_output) -│ ├── workflow.yaml # 工作流(移动) -│ └── tools.yaml # 工具白名单(移动) -└── agent/ # 代码实现(不变) - └── crewai/ - └── template_loader.py # 需更新路径引用 -``` - ---- - -## 3. 涉及代码清单 - -### 3.1 需要移动/删除的文件 - -| 操作 | 源路径 | 目标路径 | -|------|--------|----------| -| 移动 | `core/initialization/init_data.py` | `core/config/initial/init_data.py` | -| 移动 | `core/config/static/agent/llm_catalog.yaml` | `core/config/static/database/llm_catalog.yaml` | -| 新建 | - | `core/config/static/database/user_agent_catalog.yaml` | -| 移动 | `core/config/static/agent/crewai/agents.yaml` | `core/config/static/crewai/agents.yaml` | -| 移动 | `core/config/static/agent/crewai/tasks.yaml` | `core/config/static/crewai/tasks.yaml` | -| 移动 | `core/config/static/agent/crewai/workflow.yaml` | `core/config/static/crewai/workflow.yaml` | -| 移动 | `core/config/static/agent/tools.yaml` | `core/config/static/crewai/tools.yaml` | -| 删除 | `core/config/static/agent/crewai/prompts/` | - | - -### 3.2 需要修改的代码文件 - -| 文件 | 修改内容 | -|------|----------| -| `core/config/initial/init_data.py` | 更新 `_default_catalog_path()` 指向 `static/database/` | -| `core/agent/crewai/template_loader.py` | 1. 更新 `_default_static_root()` 指向 `static/crewai/`
2. 移除 `CrewAITemplate.prompts` 字段
3. 移除 `_read_prompt()` 和 prompts 加载逻辑 | -| `core/runtime/cli.py` | 更新 import 路径 | -| `tests/unit/core/test_agent_init_data.py` | 更新 import 路径和路径断言 | -| `tests/unit/core/config/test_crewai_template_loader.py` | 1. 更新路径构造
2. 移除 prompts 目录创建
3. 移除 prompts 相关断言 | - -### 3.3 需要更新的配置文件内容 - -#### agents.yaml - 补充 backstory - -```yaml -intent: - role: Intent Agent - goal: Classify user intent and decide execution strategy - backstory: > - You are an expert intent classifier with deep understanding - of user query patterns and dialogue acts. Your role is to - analyze user input and determine the appropriate action. - -execution: - role: Execution Agent - goal: Execute tasks with available tools - backstory: > - You are a skilled task executor with expertise in tool calling, - API interactions, and result verification. You work systematically - to complete user requests. - -organization: - role: Organization Agent - goal: Format final response and references - backstory: > - You specialize in presenting results in a clear, user-friendly manner. - You ensure responses are well-structured and actionable. -``` - -#### tasks.yaml - 补充 expected_output - -```yaml -intent: - description: Identify user intent and required capabilities - expected_output: > - Structured intent classification with intent type, confidence score, - and recommended action plan - -execution: - description: Execute intent with tools and model calls - expected_output: > - Verified execution results with tool outputs, status, and any errors - -organization: - description: Format final response and references - expected_output: > - User-friendly response with structured output, citations, and - clear next steps if applicable -``` - -#### user_agent_catalog.yaml - 置空 - -```yaml -agents: [] -``` - ---- - -## 4. 实施步骤 - -### Phase 1: 移动配置文件 - -1. 创建 `core/config/static/database/` 目录 -2. 创建 `core/config/static/crewai/` 目录 -3. 移动并合并 LLM 目录配置 -4. 创建空的 user_agent_catalog.yaml - -### Phase 2: 更新代码引用 - -1. 移动 `initialization/init_data.py` → `config/initial/init_data.py` -2. 更新 `init_data.py` 中的路径函数 `_default_catalog_path()` → `static/database/` -3. 更新 `template_loader.py`: - - 路径函数 `_default_static_root()` → `static/crewai/` - - 移除 `_read_prompt()` 函数 - - 移除 `CrewAITemplate.prompts` 字段 - - 移除 prompts 加载逻辑 -4. 更新 `cli.py` 的 import 路径 -5. 删除旧的 `core/initialization/` 目录(如为空) - -### Phase 4: 更新测试 - -1. 更新 `test_agent_init_data.py`: - - import 路径: `core.initialization` → `core.config.initial` - - 路径断言: `static/agent/` → `static/database/` -2. 更新 `test_crewai_template_loader.py`: - - 路径构造: `agent/` → `crewai/` - - 移除 prompts 目录创建代码 - - 移除 `template.prompts` 相关断言 -3. 运行测试验证 - ---- - -## 5. 风险与回滚 - -### 风险 - -- 路径变更可能导致运行时找不到配置文件 -- 测试路径断言需要同步更新 - -### 回滚方案 - -- 保留 git 分支,验证通过后再合并 -- 如有问题,可通过 git revert 快速回滚 - ---- - -## 6. 验证方式 - -```bash -# 1. 运行单元测试 -cd backend && uv run pytest tests/unit/core/test_agent_init_data.py tests/unit/core/config/test_crewai_template_loader.py -v - -# 2. 运行 lint -cd backend && uv run ruff check src/core/config/ src/core/agent/crewai/ src/core/runtime/cli.py - -# 3. 运行 typecheck -cd backend && uv run basedpyright src/core/config/ src/core/agent/crewai/ src/core/runtime/cli.py -``` diff --git a/docs/plans/2026-03-03-interrupt-resume-fixes-design.md b/docs/plans/2026-03-03-interrupt-resume-fixes-design.md deleted file mode 100644 index 1e72df9..0000000 --- a/docs/plans/2026-03-03-interrupt-resume-fixes-design.md +++ /dev/null @@ -1,95 +0,0 @@ -# Agent Interrupt/Resume 遗留问题修复设计 - -## 1. 目标 - -本次修复一次性完成以下三项遗留问题: - -1. `state_snapshot` 并发一致性问题(并发 resume 竞争) -2. `expires_at` 过期未强校验问题 -3. `state_snapshot` 缺少强类型与版本化问题 - -## 2. 设计决策 - -采用方案 2(严格重构): - -- `state_snapshot` 仅接受新结构,不再兼容旧结构 -- 统一快照版本为 `version = 2` -- 使用强类型模型约束快照结构与状态迁移 -- resume 入口引入行级锁语义,避免并发双写 - -## 3. 状态快照模型 - -`state_snapshot` 顶层结构: - -```json -{ - "version": 2, - "pending_tool_call": { - "interrupt_id": "int-1", - "tool_name": "srv.transfer_funds", - "tool_args": {"to": "u2", "amount": 100}, - "status": "PENDING_APPROVAL", - "expires_at": "2026-03-03T12:00:00Z", - "decision": null, - "result": null, - "updated_at": "2026-03-03T11:59:00Z" - }, - "run_context": { - "thread_id": "t1", - "run_id": "r1" - } -} -``` - -说明: - -- `version` 必须为 2,否则拒绝处理 -- `pending_tool_call` 字段缺失或类型错误,按无效快照处理 -- `run_context` 仅保留 interrupt/resume 必需字段 - -## 4. 状态机约束 - -仅允许以下迁移: - -- `PENDING_APPROVAL -> APPROVED_EXECUTING -> EXECUTED` -- `PENDING_APPROVAL -> REJECTED` -- `PENDING_APPROVAL -> EXPIRED` - -非法状态迁移必须返回错误,不做隐式修复。 - -## 5. 并发与过期语义 - -- resume 前先对目标 session 加锁再读取快照 -- 同一 `interrupt_id` 并发 resume 只能有一个请求成功 -- 若 `expires_at < now(UTC)`,先迁移为 `EXPIRED`,再返回 410 - -## 6. 错误语义(RFC7807) - -- `409 Conflict`: run/interrupt 不匹配,或并发冲突导致状态已消费 -- `410 Gone`: 挂起调用已过期 -- `422 Unprocessable Entity`: `state_snapshot` 非法或版本不匹配 -- `404 Not Found`: 目标 session/run 不存在 - -## 7. 测试策略 - -采用 TDD,先写失败测试后实现: - -- 快照版本校验(`version != 2`) -- 快照结构校验(必填字段/类型) -- 并发 resume 幂等竞争(仅一个成功) -- 过期校验(返回 410 + 状态置 EXPIRED) -- 合法状态迁移路径覆盖 - -## 8. 验证命令 - -- `uv run pytest backend/tests/unit/v1/agent -v` -- `uv run pytest backend/tests/integration/v1/agent/test_chat_routes.py -v` -- `uv run pytest backend/tests/integration/v1/agent/test_interrupt_resume_flow.py -v` -- `cd backend && uv run ruff check src/v1/agent` -- `cd backend && uv run basedpyright src/v1/agent` - -## 9. 风险与回滚 - -- 风险:旧快照不再兼容,可能触发运行时拒绝 -- 处置:通过明确 422 错误暴露不合规数据,结合日志定位并人工修复数据 -- 回滚:回退本次变更并恢复旧快照解析逻辑(仅在紧急故障时) diff --git a/docs/plans/2026-03-03-interrupt-resume-fixes-implementation-plan.md b/docs/plans/2026-03-03-interrupt-resume-fixes-implementation-plan.md deleted file mode 100644 index a89d306..0000000 --- a/docs/plans/2026-03-03-interrupt-resume-fixes-implementation-plan.md +++ /dev/null @@ -1,377 +0,0 @@ -# Agent Interrupt/Resume Strict Refactor Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** 通过严格重构一次性修复 interrupt/resume 的并发安全、过期校验和 state_snapshot 强类型版本化问题。 - -**Architecture:** 以 `state_snapshot v2` 为唯一合法结构,服务层使用强类型模型解析与状态迁移,resume 路径在读取会话时加行锁保证并发一致性。路由层维持现有 run/resume 入口,错误通过 HTTPException 输出,测试覆盖版本校验、过期语义、并发幂等和状态机迁移。 - -**Tech Stack:** FastAPI, SQLAlchemy AsyncSession, Pydantic v2, pytest - ---- - -### Task 1: 新增 state_snapshot v2 强类型模型 - -**Files:** -- Modify: `backend/src/v1/agent/schemas.py` -- Test: `backend/tests/unit/v1/agent/test_schemas.py` - -**Step 1: Write the failing test** - -```python -def test_state_snapshot_v2_model_accepts_valid_payload(): - payload = { - "version": 2, - "pending_tool_call": { - "interrupt_id": "int-1", - "tool_name": "srv.transfer_funds", - "tool_args": {"to": "u2", "amount": 100}, - "status": "PENDING_APPROVAL", - "expires_at": "2026-03-03T12:00:00Z", - "decision": None, - "result": None, - "updated_at": "2026-03-03T11:59:00Z", - }, - "run_context": {"thread_id": "t1", "run_id": "r1"}, - } - model = AgentSessionSnapshot.model_validate(payload) - assert model.version == 2 - - -def test_state_snapshot_v2_rejects_wrong_version(): - payload = { - "version": 1, - "pending_tool_call": None, - "run_context": {"thread_id": "t1", "run_id": "r1"}, - } - with pytest.raises(ValueError): - AgentSessionSnapshot.model_validate(payload) -``` - -**Step 2: Run test to verify it fails** - -Run: `uv run pytest backend/tests/unit/v1/agent/test_schemas.py -v` -Expected: FAIL(`AgentSessionSnapshot` 未定义或校验不符合预期) - -**Step 3: Write minimal implementation** - -```python -class PendingToolStatus(str, Enum): - PENDING_APPROVAL = "PENDING_APPROVAL" - APPROVED_EXECUTING = "APPROVED_EXECUTING" - EXECUTED = "EXECUTED" - REJECTED = "REJECTED" - EXPIRED = "EXPIRED" - - -class PendingToolCall(BaseModel): - interrupt_id: str - tool_name: str - tool_args: dict[str, Any] - status: PendingToolStatus - expires_at: datetime - decision: dict[str, Any] | None = None - result: dict[str, Any] | None = None - updated_at: datetime - - -class SnapshotRunContext(BaseModel): - thread_id: str - run_id: str - - -class AgentSessionSnapshot(BaseModel): - version: Literal[2] - pending_tool_call: PendingToolCall | None = None - run_context: SnapshotRunContext -``` - -**Step 4: Run test to verify it passes** - -Run: `uv run pytest backend/tests/unit/v1/agent/test_schemas.py -v` -Expected: PASS - -**Step 5: Commit** - -```bash -git add backend/src/v1/agent/schemas.py backend/tests/unit/v1/agent/test_schemas.py -git commit -m "refactor(agent): add strict v2 session snapshot schema" -``` - ---- - -### Task 2: service 层改为 v2 快照读写(严格拒绝旧结构) - -**Files:** -- Modify: `backend/src/v1/agent/service.py` -- Test: `backend/tests/unit/v1/agent/test_service_pending_tool_call.py` - -**Step 1: Write the failing test** - -```python -@pytest.mark.asyncio -async def test_set_pending_tool_call_writes_v2_snapshot(service, session): - await service.set_pending_tool_call( - session_id=session.id, - interrupt_id="int-1", - tool_name="srv.transfer_funds", - tool_args={"to": "u2", "amount": 100}, - expires_at=datetime.now(timezone.utc) + timedelta(minutes=5), - thread_id="t1", - run_id="r1", - ) - snapshot = await service.get_state_snapshot(session.id) - assert snapshot["version"] == 2 - assert snapshot["run_context"]["run_id"] == "r1" - - -@pytest.mark.asyncio -async def test_invalid_legacy_snapshot_is_rejected(service, session): - session.state_snapshot = {"pending_tool_call": {"status": "PENDING_APPROVAL"}} - with pytest.raises(ValueError): - await service.apply_resume_decision( - session_id=session.id, - interrupt_id="int-1", - decision={"decision": "approved"}, - ) -``` - -**Step 2: Run test to verify it fails** - -Run: `uv run pytest backend/tests/unit/v1/agent/test_service_pending_tool_call.py -v` -Expected: FAIL - -**Step 3: Write minimal implementation** - -```python -def _build_snapshot_v2(...): - return AgentSessionSnapshot(...).model_dump(mode="json") - - -def _load_snapshot_v2(raw: dict[str, Any] | None) -> AgentSessionSnapshot: - if raw is None: - raise ValueError("state_snapshot missing") - return AgentSessionSnapshot.model_validate(raw) -``` - -并将 `set_pending_tool_call/get_state_snapshot/update_pending_tool_call_status` 全部改成 v2 模型读写。 - -**Step 4: Run test to verify it passes** - -Run: `uv run pytest backend/tests/unit/v1/agent/test_service_pending_tool_call.py -v` -Expected: PASS - -**Step 5: Commit** - -```bash -git add backend/src/v1/agent/service.py backend/tests/unit/v1/agent/test_service_pending_tool_call.py -git commit -m "refactor(agent): enforce v2 snapshot read write in service" -``` - ---- - -### Task 3: 增加 resume 行锁与并发幂等 - -**Files:** -- Modify: `backend/src/v1/agent/service.py` -- Test: `backend/tests/unit/v1/agent/test_resume_idempotency.py` - -**Step 1: Write the failing test** - -```python -@pytest.mark.asyncio -async def test_apply_resume_decision_uses_locked_session_fetch(service, fake_db, session): - await service.apply_resume_decision( - session_id=session.id, - interrupt_id="int-1", - decision={"decision": "approved"}, - ) - assert fake_db.last_fetch_with_lock is True - - -@pytest.mark.asyncio -async def test_resume_is_idempotent(service, session): - first = await service.apply_resume_decision(...) - second = await service.apply_resume_decision(...) - assert first.applied is True - assert second.applied is False -``` - -**Step 2: Run test to verify it fails** - -Run: `uv run pytest backend/tests/unit/v1/agent/test_resume_idempotency.py -v` -Expected: FAIL - -**Step 3: Write minimal implementation** - -```python -async def _get_session_for_update(self, session_id: UUID) -> AgentChatSession | None: - stmt = ( - select(AgentChatSession) - .where(AgentChatSession.id == session_id) - .with_for_update() - .limit(1) - ) - result = await self._session.execute(stmt) - return result.scalar_one_or_none() -``` - -`apply_resume_decision` 改为锁内读取、校验、状态迁移,保证并发下单次生效。 - -**Step 4: Run test to verify it passes** - -Run: `uv run pytest backend/tests/unit/v1/agent/test_resume_idempotency.py -v` -Expected: PASS - -**Step 5: Commit** - -```bash -git add backend/src/v1/agent/service.py backend/tests/unit/v1/agent/test_resume_idempotency.py -git commit -m "fix(agent): add row lock for resume state transition" -``` - ---- - -### Task 4: 增加 expires_at 过期校验(含 EXPIRED 迁移) - -**Files:** -- Modify: `backend/src/v1/agent/service.py` -- Test: `backend/tests/unit/v1/agent/test_resume_idempotency.py` - -**Step 1: Write the failing test** - -```python -@pytest.mark.asyncio -async def test_resume_expired_pending_returns_not_applied_and_marks_expired(service, session): - await service.set_pending_tool_call(..., expires_at=datetime.now(timezone.utc) - timedelta(seconds=1), thread_id="t1", run_id="r1") - result = await service.apply_resume_decision( - session_id=session.id, - interrupt_id="int-1", - decision={"decision": "approved"}, - ) - assert result.applied is False - snapshot = await service.get_state_snapshot(session.id) - assert snapshot["pending_tool_call"]["status"] == "EXPIRED" -``` - -**Step 2: Run test to verify it fails** - -Run: `uv run pytest backend/tests/unit/v1/agent/test_resume_idempotency.py -v` -Expected: FAIL - -**Step 3: Write minimal implementation** - -```python -if pending.expires_at < datetime.now(timezone.utc): - pending.status = PendingToolStatus.EXPIRED - pending.updated_at = datetime.now(timezone.utc) - session.state_snapshot = snapshot.model_dump(mode="json") - return ResumeDecisionResult(applied=False, expired=True) -``` - -**Step 4: Run test to verify it passes** - -Run: `uv run pytest backend/tests/unit/v1/agent/test_resume_idempotency.py -v` -Expected: PASS - -**Step 5: Commit** - -```bash -git add backend/src/v1/agent/service.py backend/tests/unit/v1/agent/test_resume_idempotency.py -git commit -m "fix(agent): enforce expires_at when applying resume decision" -``` - ---- - -### Task 5: 路由层补齐 v2 快照与过期/冲突错误映射 - -**Files:** -- Modify: `backend/src/v1/agent/router.py` -- Modify: `backend/src/v1/agent/service.py` -- Test: `backend/tests/integration/v1/agent/test_chat_routes.py` -- Test: `backend/tests/integration/v1/agent/test_interrupt_resume_flow.py` - -**Step 1: Write the failing test** - -```python -def test_resume_route_returns_409_on_run_id_mismatch(client): - ... - - -def test_resume_route_returns_410_when_pending_expired(client): - ... - - -def test_resume_route_returns_422_for_legacy_snapshot(client): - ... -``` - -**Step 2: Run test to verify it fails** - -Run: `uv run pytest backend/tests/integration/v1/agent/test_chat_routes.py backend/tests/integration/v1/agent/test_interrupt_resume_flow.py -v` -Expected: FAIL - -**Step 3: Write minimal implementation** - -在 `stream_resume` 或路由调用链里将领域错误映射为: - -- 过期 -> `HTTPException(410)` -- 旧快照/结构错误 -> `HTTPException(422)` -- 状态冲突/重复消费 -> `HTTPException(409)` - -**Step 4: Run test to verify it passes** - -Run: `uv run pytest backend/tests/integration/v1/agent/test_chat_routes.py backend/tests/integration/v1/agent/test_interrupt_resume_flow.py -v` -Expected: PASS - -**Step 5: Commit** - -```bash -git add backend/src/v1/agent/router.py backend/src/v1/agent/service.py backend/tests/integration/v1/agent/test_chat_routes.py backend/tests/integration/v1/agent/test_interrupt_resume_flow.py -git commit -m "fix(agent): map resume snapshot errors to 409 410 422" -``` - ---- - -### Task 6: 更新文档并完成验证 - -**Files:** -- Modify: `docs/plans/2026-03-03-agent-chat-design.md` -- Modify: `docs/runtime/runtime-route.md` - -**Step 1: Update docs** - -- 明确 `state_snapshot version=2` 为唯一支持结构 -- 明确 resume 过期与并发冲突语义(410/409) -- 明确旧快照拒绝策略(422) - -**Step 2: Run unit tests** - -Run: `uv run pytest backend/tests/unit/v1/agent -v` -Expected: PASS - -**Step 3: Run integration tests** - -Run: `uv run pytest backend/tests/integration/v1/agent/test_chat_routes.py backend/tests/integration/v1/agent/test_interrupt_resume_flow.py -v` -Expected: PASS - -**Step 4: Run static checks** - -Run: `cd backend && uv run ruff check src/v1/agent` -Expected: PASS - -Run: `cd backend && uv run basedpyright src/v1/agent` -Expected: PASS - -**Step 5: Commit** - -```bash -git add docs/plans/2026-03-03-agent-chat-design.md docs/runtime/runtime-route.md -git commit -m "docs(agent): document strict snapshot v2 and resume error semantics" -``` - ---- - -Plan complete and saved to `docs/plans/2026-03-03-interrupt-resume-fixes-implementation-plan.md`. - -Execution mode selected by user request: Subagent-Driven (this session), proceed task-by-task immediately. diff --git a/docs/plans/2026-03-04-agent-hard-reset-design.md b/docs/plans/2026-03-04-agent-hard-reset-design.md new file mode 100644 index 0000000..401b67c --- /dev/null +++ b/docs/plans/2026-03-04-agent-hard-reset-design.md @@ -0,0 +1,201 @@ +# Agent 后端硬切重构设计 + +## 目标 + +- 一次性移除现有 Agent 运行时代码、测试和旧文档契约,避免新旧方案并存。 +- 仅从后端重新设计 Agent 体系,不依赖前端实现细节。 +- 新方案必须满足以下六项要求: + 1. 配置层可通过 `.env` 驱动 LLM API Key。 + 2. 对话与 resume 通过 Celery 队列处理,不阻塞 Web 主线程。 + 3. `v1/agent` 仅负责路由组织与服务调用,核心逻辑在 `core/agent`。 + 4. 按 CrewAI 官方模型组织 Agent/Task/Crew/Flow/Tools。 + 5. 按 AG-UI 协议输出事件,优先使用 `ag-ui-crewai` 适配库。 + 6. 使用 LiteLLM 统计每次 LLM 调用的 token 和 cost。 + +## 设计原则 + +- 单一职责:HTTP 层只做协议和鉴权,编排与执行下沉到核心层。 +- 异步优先:长耗时推理、工具调用、恢复流程全部异步化。 +- 协议优先:AG-UI 作为唯一事件契约,不维护自定义事件方言。 +- 可观测性优先:每次 run、每次 stage、每次 LLM 调用可追踪。 +- 配置单一来源:所有密钥和模型配置只走 `core.config.settings`。 + +## 目标架构 + +### 1) 分层 + +- `backend/src/v1/agent/` + - `router.py`: 暴露 HTTP/SSE 接口。 + - `schemas.py`: 请求/响应 DTO 和输入校验。 + - `dependencies.py`: DI 装配。 + - `service.py`: 薄服务,仅调用 `core/agent` 应用服务。 +- `backend/src/core/agent/` + - `application/`: run/resume 应用服务。 + - `domain/`: run 状态机、resume 幂等语义、错误模型。 + - `infrastructure/crewai/`: CrewAI Agent/Task/Crew/Flow 装配与执行。 + - `infrastructure/agui/`: AG-UI 事件映射与 SSE 序列化。 + - `infrastructure/litellm/`: LiteLLM 客户端与 usage/cost 拦截器。 + - `infrastructure/queue/`: Celery task producer/consumer。 + +### 1.1) 配置来源与合并策略 + +- Agent 运行配置由两部分组成: + - 数据库存量配置:`system_agents`(每种 agent_type 对应 llm 与 llm_config)。 + - 静态模板配置:`backend/src/core/config/static/crewai/*.yaml`(角色描述、任务模板、workflow、tools)。 +- 合并策略: + - `llm` 与 `llm_config` 以 `system_agents` 为准。 + - prompt 模板、task 描述、flow stage、tool 白名单以 static/crewai 为准。 + - 若任一 agent_type 在 `system_agents` 缺失,运行前失败并返回受控错误。 + +### 2) 核心运行链路 + +1. `POST /api/v1/agent/runs` 只负责参数校验和鉴权。 +2. 路由调用 `AgentRunAppService.enqueue_run()`,写入 run 记录并投递 Celery。 +3. Worker 执行 `run_agent_task`: + - 读取 run 上下文。 + - 构建 CrewAI `Agent/Task/Crew/Flow`。 + - 通过 `ag-ui-crewai` 将执行事件转为 AG-UI 标准事件。 + - 每次 LLM 调用由 LiteLLM 中间层记录 token/cost。 +4. 事件落库并发布到事件通道(Redis Stream/Channel)。 +5. SSE 接口从事件通道读取并持续推送,直到 `RUN_FINISHED` 或 `RUN_ERROR`。 + +### 3) Resume 链路 + +1. `POST /api/v1/agent/runs/{run_id}/resume` 校验 `interrupt_id` 与决策 payload。 +2. 调用 `enqueue_resume()` 投递 `resume_agent_task`。 +3. Worker 在事务内做并发控制: + - `run_id + interrupt_id` 幂等锁。 + - 过期校验与状态迁移。 +4. 恢复后继续 CrewAI Flow,事件按 AG-UI 继续输出。 + +### 4) Session 状态持久化 + +- 使用 `sessions.state_snapshot` 作为运行态单一快照来源。 +- 快照至少包含: + - run 上下文(thread_id、run_id、stage) + - pending_tool_calls(tool_call_id、tool_name、args、status、expires_at) + - correlation 索引(tool_call_id -> message_id / step_id) +- 所有中断/恢复均以 `state_snapshot` 事务更新为准,避免内存态漂移。 + +### 5) 会话与消息落库模型 + +- 会话主表:`sessions` + - 新建 run 时写入:`id/user_id/session_type/status=running/last_activity_at`。 + - 运行中持续更新:`status`、`last_activity_at`、`message_count`、`total_tokens`、`total_cost`、`state_snapshot`。 + - 运行结束更新: + - 成功:`status=completed` + - 失败:`status=failed` +- 消息表:`messages` + - 用户输入落库为 `role=user`(每次 run 开始时先写入)。 + - 模型输出落库为 `role=assistant`(按最终聚合文本落库,保留 metadata 记录增量信息)。 + - 工具调用结果落库为 `role=tool`,并写入 `tool_name` 与 `metadata.tool_call_id`。 + - `seq` 由每个 `session_id` 内单调递增分配,满足 `uq_messages_session_seq`。 +- 计量落库:每次 LLM 调用的 usage/cost 先写消息级,再聚合更新到 session 级。 + +## 六项要求落地映射 + +### 要求 1: `.env` 驱动 LLM API Key + +- 新增 `LLMSettings` 到 `core.config.settings.Settings`,统一定义: + - `SOCIAL_LLM__PROVIDER_KEYS__DASHSCOPE` + - `SOCIAL_LLM__PROVIDER_KEYS__MINIMAX` + - `SOCIAL_LLM__PROVIDER_KEYS__MOONSHOT` + - `SOCIAL_LLM__PROVIDER_KEYS__DEEPSEEK` + - `SOCIAL_LLM__PROVIDER_KEYS__ARK` + - `SOCIAL_LLM__PROVIDER_KEYS__ZAI` +- 禁止 `os.environ` 直接读取密钥。 + +### 要求 2: 对话和 resume 走 Celery + +- Web 层不直接执行编排。 +- `run`/`resume` 一律入队,Worker 处理,Web 仅做事件流转发。 +- 加入任务级超时、重试、死信策略。 + +### 要求 3: v1 仅路由与调用 + +- `v1/agent/service.py` 仅保留应用服务调用和错误映射。 +- 任何编排、状态机、工具执行逻辑禁止进入 `v1`。 + +### 要求 4: CrewAI 官方流程 + +- 采用 CrewAI 原生对象:`Agent`、`Task`、`Crew`、`Flow`。 +- tools 通过 CrewAI Tool 机制注册,不做平行实现。 +- 任务模板与 agent 配置集中化(静态模板 + 运行时拼装)。 +- 配置拼装明确依赖 `system_agents + static/crewai`,不再使用双套来源。 + +### 要求 5: AG-UI + ag-ui-crewai + +- 事件集遵循 AG-UI 协议,生命周期闭环: + - `RUN_STARTED` + - 流式消息和工具事件 + - 终态 `RUN_FINISHED` 或 `RUN_ERROR` +- 优先引入 `ag-ui-crewai` 做 CrewAI 到 AG-UI 的桥接,避免重复造轮子。 + +### 要求 6: LiteLLM token/cost 统计 + +- 所有 LLM 调用通过 LiteLLM 统一出入口。 +- 按调用粒度记录:`input_tokens`、`output_tokens`、`total_tokens`、`cost`、`currency`。 +- 按 run 粒度聚合并落库,支持后续计费和审计。 + +## 数据与可观测性 + +- 保留现有 Agent 相关表结构,不在本次硬切做数据库破坏性变更。 +- 新增事件日志与调用指标落点(如已有字段不足,后续增量迁移)。 +- 日志使用结构化字段:`run_id`、`task_id`、`stage`、`tool_name`、`llm_model`、`latency_ms`。 +- 持久化原则:run/resume 的关键状态变更必须可重放,禁止仅保存在内存。 + +## 事务边界 + +- `run` 入口事务:创建或加载 `session` + 写入用户消息。 +- `worker` 执行事务(可分阶段短事务): + - 阶段开始:更新 `session.status/state_snapshot`。 + - LLM 返回:写 assistant/tool 消息 + 更新 token/cost 聚合。 + - 中断:写 `pending_tool_calls` 到 `state_snapshot` 并提交。 + - 完成:更新终态 `session.status` 并提交。 +- `resume` 事务:校验 `interrupt_id` 与 ownership,CAS 更新 `state_snapshot`,然后进入后续执行事务。 + +## 错误处理与安全 + +- API Key 缺失启动即失败,不进入运行态。 +- 外部工具入参统一白名单和 schema 校验。 +- resume 决策必须鉴权与会话所有权校验。 +- 错误响应遵循 RFC 7807,避免泄漏敏感上下文。 + +## 工具调用与恢复语义 + +- 工具分三类: + - 前端工具:由 `RunAgentInput.tools` 提供能力声明,触发 interrupt,由客户端执行并回传 result。 + - 后端工具(需审批):先 interrupt 给前端审批;审批通过后由后端执行,不由前端执行。 + - 后端工具(直执):后端直接执行。 +- 一致性约束: + - 每个 tool_result 必须携带 `tool_call_id`。 + - 后端仅接受当前 `state_snapshot.pending_tool_calls` 中存在且状态合法的 `tool_call_id`。 + - 若收到未知/已消费/过期 `tool_call_id`,立即产出 `RUN_ERROR` 并记录审计日志。 + +## 测试策略 + +- 单元测试: + - 配置解析与 key 解析 + - run/resume 状态机与幂等 + - LiteLLM usage 聚合 +- 集成测试: + - API 入队 + - Worker 消费 + - SSE 事件顺序与终态 +- E2E: + - run 成功链路 + - interrupt + resume 链路 + - tool 调用链路 + +## 迁移策略 + +- 阶段 0(本次):硬切删除旧代码、旧测试、旧文档契约。 +- 阶段 1:搭建新架构骨架和最小可运行 run 流程。 +- 阶段 2:接入 CrewAI + ag-ui-crewai + LiteLLM 完整链路。 +- 阶段 3:补齐可观测性、压测与稳定性治理。 + +## 验收标准 + +- 后端仓库不存在旧 `v1/agent` 和 `core/agent` 旧实现。 +- 所有 Agent 相关旧测试与旧文档契约已移除。 +- 新方案设计文档明确覆盖六项要求并可进入实现阶段。 diff --git a/docs/plans/2026-03-04-agent-hard-reset-plan.md b/docs/plans/2026-03-04-agent-hard-reset-plan.md new file mode 100644 index 0000000..c665090 --- /dev/null +++ b/docs/plans/2026-03-04-agent-hard-reset-plan.md @@ -0,0 +1,574 @@ +# Agent 后端重建 Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 在后端重建 Agent 运行时,满足队列异步、CrewAI 配置打通、AG-UI 工具中断恢复、LiteLLM 计量、以及 `sessions.state_snapshot` 持久化要求。 + +**Architecture:** `v1/agent` 仅做 API/鉴权/参数校验与 SSE 输出,`core/agent` 负责编排与执行。Agent 创建配置由 `system_agents`(数据库)+ `core/config/static/crewai/*.yaml`(静态模板)合并生成。run/resume 全链路通过 Celery Worker 执行,状态写入 `sessions.state_snapshot`。 + +**Tech Stack:** FastAPI, Celery, Redis, CrewAI, ag-ui-crewai, LiteLLM, SQLAlchemy, Alembic, pytest + +--- + +### Task 1: 建立配置聚合器(system_agents + static/crewai) + +**Files:** +- Create: `backend/src/core/agent/infrastructure/config/resolver.py` +- Modify: `backend/src/core/config/static/crewai/agents.yaml` +- Modify: `backend/src/core/config/static/crewai/tasks.yaml` +- Create: `backend/src/core/config/static/crewai/workflow.yaml` +- Create: `backend/src/core/config/static/crewai/tools.yaml` +- Test: `backend/tests/unit/core/agent/test_config_resolver.py` + +**Step 1: Write the failing test** + +```python +def test_resolver_merges_system_agents_and_static_templates(): + resolved = resolve_agent_runtime_config(...) + assert resolved.intent.llm.model_code == "deepseek-v3.2" + assert "intent" in resolved.workflow_stages +``` + +**Step 2: Run test to verify it fails** + +Run: `PYTHONPATH=backend/src uv run pytest backend/tests/unit/core/agent/test_config_resolver.py::test_resolver_merges_system_agents_and_static_templates -q` +Expected: FAIL with `NameError` or import not found + +**Step 3: Write minimal implementation** + +```python +def resolve_agent_runtime_config(system_agents: list[dict], static_cfg: dict) -> RuntimeConfig: + by_type = {item["agent_type"]: item for item in system_agents} + return RuntimeConfig.from_sources(by_type=by_type, static_cfg=static_cfg) +``` + +**Step 4: Run test to verify it passes** + +Run: `PYTHONPATH=backend/src uv run pytest backend/tests/unit/core/agent/test_config_resolver.py::test_resolver_merges_system_agents_and_static_templates -q` +Expected: PASS + +**Step 5: Commit** + +```bash +git add backend/src/core/agent/infrastructure/config/resolver.py backend/src/core/config/static/crewai backend/tests/unit/core/agent/test_config_resolver.py +git commit -m "feat: add system_agents and static crewai config resolver" +``` + +### Task 2: 统一 LLM Key 与模型配置入口 + +**Files:** +- Modify: `backend/src/core/config/settings.py` +- Modify: `.env.example` +- Create: `backend/tests/unit/core/config/test_llm_settings.py` + +**Step 1: Write the failing test** + +```python +def test_llm_keys_read_from_settings(monkeypatch): + monkeypatch.setenv("SOCIAL_LLM__PROVIDER_KEYS__DEEPSEEK", "k1") + s = Settings() + assert s.llm.provider_keys.deepseek == "k1" +``` + +**Step 2: Run test to verify it fails** + +Run: `PYTHONPATH=backend/src uv run pytest backend/tests/unit/core/config/test_llm_settings.py::test_llm_keys_read_from_settings -q` +Expected: FAIL with missing `llm` field + +**Step 3: Write minimal implementation** + +```python +class LLMProviderKeys(BaseModel): + deepseek: str | None = None + +class LLMSettings(BaseModel): + provider_keys: LLMProviderKeys = LLMProviderKeys() +``` + +**Step 4: Run test to verify it passes** + +Run: `PYTHONPATH=backend/src uv run pytest backend/tests/unit/core/config/test_llm_settings.py::test_llm_keys_read_from_settings -q` +Expected: PASS + +**Step 5: Commit** + +```bash +git add backend/src/core/config/settings.py .env.example backend/tests/unit/core/config/test_llm_settings.py +git commit -m "feat: centralize llm provider keys in settings" +``` + +### Task 3: sessions 表状态快照契约落地 + +**Files:** +- Create: `backend/alembic/versions/20260304_add_sessions_state_snapshot_contract.py` +- Modify: `backend/src/models/agent_chat_session.py` +- Create: `backend/tests/unit/database/test_sessions_state_snapshot_contract.py` + +**Step 1: Write the failing test** + +```python +def test_sessions_has_state_snapshot_column(db_inspector): + columns = db_inspector.get_columns("sessions") + assert "state_snapshot" in [c["name"] for c in columns] +``` + +**Step 2: Run test to verify it fails** + +Run: `PYTHONPATH=backend/src uv run pytest backend/tests/unit/database/test_sessions_state_snapshot_contract.py::test_sessions_has_state_snapshot_column -q` +Expected: FAIL when migration not applied + +**Step 3: Write minimal implementation** + +```python +def upgrade() -> None: + op.add_column("sessions", sa.Column("state_snapshot", postgresql.JSONB, nullable=True)) +``` + +**Step 4: Run test to verify it passes** + +Run: `PYTHONPATH=backend/src uv run pytest backend/tests/unit/database/test_sessions_state_snapshot_contract.py::test_sessions_has_state_snapshot_column -q` +Expected: PASS + +**Step 5: Commit** + +```bash +git add backend/alembic/versions/20260304_add_sessions_state_snapshot_contract.py backend/src/models/agent_chat_session.py backend/tests/unit/database/test_sessions_state_snapshot_contract.py +git commit -m "feat(db): enforce sessions state_snapshot contract" +``` + +### Task 3.1: 会话与消息持久化仓储 + +**Files:** +- Create: `backend/src/core/agent/infrastructure/persistence/session_repository.py` +- Create: `backend/src/core/agent/infrastructure/persistence/message_repository.py` +- Create: `backend/tests/integration/core/agent/test_session_message_persistence.py` + +**Step 1: Write the failing test** + +```python +def test_run_persists_user_and_assistant_messages(db_session): + run = execute_run(...) + rows = list_messages(session_id=run.session_id) + assert rows[0].role == "user" + assert rows[1].role == "assistant" +``` + +**Step 2: Run test to verify it fails** + +Run: `PYTHONPATH=backend/src uv run pytest backend/tests/integration/core/agent/test_session_message_persistence.py::test_run_persists_user_and_assistant_messages -q` +Expected: FAIL + +**Step 3: Write minimal implementation** + +```python +async def append_message(...): + session.add(AgentChatMessage(...)) + +async def update_session_aggregate(...): + session_obj.message_count = message_count +``` + +**Step 4: Run test to verify it passes** + +Run: `PYTHONPATH=backend/src uv run pytest backend/tests/integration/core/agent/test_session_message_persistence.py::test_run_persists_user_and_assistant_messages -q` +Expected: PASS + +**Step 5: Commit** + +```bash +git add backend/src/core/agent/infrastructure/persistence backend/tests/integration/core/agent/test_session_message_persistence.py +git commit -m "feat: persist session lifecycle and messages for agent runs" +``` + +### Task 4: 定义 state_snapshot 结构与并发语义 + +**Files:** +- Create: `backend/src/core/agent/domain/state_snapshot.py` +- Create: `backend/tests/unit/core/agent/test_state_snapshot.py` + +**Step 1: Write the failing test** + +```python +def test_pending_tool_call_snapshot_contains_correlation_fields(): + snap = StateSnapshot.new(...) + pending = snap.pending_tool_calls[0] + assert pending.tool_call_id + assert pending.status == "PENDING_APPROVAL" +``` + +**Step 2: Run test to verify it fails** + +Run: `PYTHONPATH=backend/src uv run pytest backend/tests/unit/core/agent/test_state_snapshot.py::test_pending_tool_call_snapshot_contains_correlation_fields -q` +Expected: FAIL + +**Step 3: Write minimal implementation** + +```python +class PendingToolCall(BaseModel): + tool_call_id: str + tool_name: str + status: Literal["PENDING_APPROVAL", "APPROVED", "EXECUTED", "REJECTED", "EXPIRED"] +``` + +**Step 4: Run test to verify it passes** + +Run: `PYTHONPATH=backend/src uv run pytest backend/tests/unit/core/agent/test_state_snapshot.py::test_pending_tool_call_snapshot_contains_correlation_fields -q` +Expected: PASS + +**Step 5: Commit** + +```bash +git add backend/src/core/agent/domain/state_snapshot.py backend/tests/unit/core/agent/test_state_snapshot.py +git commit -m "feat: define sessions state_snapshot schema for run and tool state" +``` + +### Task 5: 工具路由策略(前端/后端/审批) + +**Files:** +- Create: `backend/src/core/agent/domain/tool_policy.py` +- Create: `backend/tests/unit/core/agent/test_tool_policy.py` + +**Step 1: Write the failing test** + +```python +def test_frontend_tool_requires_interrupt_and_client_execution(): + decision = classify_tool_call(name="ui.navigate_to", source="request.tools") + assert decision.mode == "FRONTEND_EXECUTE" + +def test_backend_approval_tool_returns_interrupt_but_executes_on_backend_after_approve(): + decision = classify_tool_call(name="srv.transfer_funds", requires_approval=True) + assert decision.mode == "BACKEND_APPROVAL_INTERRUPT" +``` + +**Step 2: Run test to verify it fails** + +Run: `PYTHONPATH=backend/src uv run pytest backend/tests/unit/core/agent/test_tool_policy.py -q` +Expected: FAIL + +**Step 3: Write minimal implementation** + +```python +if tool_name.startswith("ui."): + return ToolDecision(mode="FRONTEND_EXECUTE") +if requires_approval: + return ToolDecision(mode="BACKEND_APPROVAL_INTERRUPT") +return ToolDecision(mode="BACKEND_DIRECT_EXECUTE") +``` + +**Step 4: Run test to verify it passes** + +Run: `PYTHONPATH=backend/src uv run pytest backend/tests/unit/core/agent/test_tool_policy.py -q` +Expected: PASS + +**Step 5: Commit** + +```bash +git add backend/src/core/agent/domain/tool_policy.py backend/tests/unit/core/agent/test_tool_policy.py +git commit -m "feat: add frontend/backend tool policy and approval routing" +``` + +### Task 6: tool_call 与 tool_result 对账机制 + +**Files:** +- Create: `backend/src/core/agent/domain/tool_correlation.py` +- Create: `backend/tests/unit/core/agent/test_tool_correlation.py` + +**Step 1: Write the failing test** + +```python +def test_rejects_tool_result_when_tool_call_id_not_pending(): + store = PendingToolStore([]) + with pytest.raises(ToolCorrelationError): + store.apply_result(tool_call_id="unknown", result={"ok": True}) +``` + +**Step 2: Run test to verify it fails** + +Run: `PYTHONPATH=backend/src uv run pytest backend/tests/unit/core/agent/test_tool_correlation.py::test_rejects_tool_result_when_tool_call_id_not_pending -q` +Expected: FAIL + +**Step 3: Write minimal implementation** + +```python +def apply_result(self, *, tool_call_id: str, result: dict) -> None: + pending = self._pending.get(tool_call_id) + if pending is None: + raise ToolCorrelationError("tool_call_id not pending") + pending.result = result +``` + +**Step 4: Run test to verify it passes** + +Run: `PYTHONPATH=backend/src uv run pytest backend/tests/unit/core/agent/test_tool_correlation.py::test_rejects_tool_result_when_tool_call_id_not_pending -q` +Expected: PASS + +**Step 5: Commit** + +```bash +git add backend/src/core/agent/domain/tool_correlation.py backend/tests/unit/core/agent/test_tool_correlation.py +git commit -m "feat: add tool call/result correlation guard" +``` + +### Task 7: Celery run/resume 异步任务 + +**Files:** +- Create: `backend/src/core/agent/infrastructure/queue/tasks.py` +- Create: `backend/src/core/agent/application/run_service.py` +- Create: `backend/src/core/agent/application/resume_service.py` +- Test: `backend/tests/integration/core/agent/test_queue_run_resume.py` + +**Step 1: Write the failing test** + +```python +def test_run_api_enqueues_celery_task(client): + resp = client.post("/api/v1/agent/runs", json={...}) + assert resp.status_code == 202 + +def test_resume_updates_session_status_and_snapshot(client): + resp = client.post("/api/v1/agent/runs/r1/resume", json={...}) + assert resp.status_code == 202 +``` + +**Step 2: Run test to verify it fails** + +Run: `PYTHONPATH=backend/src uv run pytest backend/tests/integration/core/agent/test_queue_run_resume.py::test_run_api_enqueues_celery_task -q` +Expected: FAIL + +**Step 3: Write minimal implementation** + +```python +def enqueue_run(cmd: RunCommand) -> str: + task = run_agent_task.apply_async(args=[cmd.model_dump()]) + return task.id +``` + +**Step 4: Run test to verify it passes** + +Run: `PYTHONPATH=backend/src uv run pytest backend/tests/integration/core/agent/test_queue_run_resume.py::test_run_api_enqueues_celery_task -q` +Expected: PASS + +**Step 5: Commit** + +```bash +git add backend/src/core/agent/application backend/src/core/agent/infrastructure/queue backend/tests/integration/core/agent/test_queue_run_resume.py +git commit -m "feat: add celery-based run and resume tasks" +``` + +### Task 8: CrewAI 运行时加载与创建 + +**Files:** +- Create: `backend/src/core/agent/infrastructure/crewai/runtime.py` +- Create: `backend/src/core/agent/infrastructure/crewai/factory.py` +- Test: `backend/tests/unit/core/agent/test_crewai_runtime.py` + +**Step 1: Write the failing test** + +```python +def test_runtime_creates_agents_tasks_from_resolved_config(): + runtime = CrewAIRuntime(...) + crew = runtime.build_crew(message="hello") + assert len(crew.agents) >= 1 +``` + +**Step 2: Run test to verify it fails** + +Run: `PYTHONPATH=backend/src uv run pytest backend/tests/unit/core/agent/test_crewai_runtime.py::test_runtime_creates_agents_tasks_from_resolved_config -q` +Expected: FAIL + +**Step 3: Write minimal implementation** + +```python +def build_crew(self, *, message: str) -> Crew: + agents = self._factory.build_agents(self._config) + tasks = self._factory.build_tasks(self._config, message=message) + return Crew(agents=agents, tasks=tasks) +``` + +**Step 4: Run test to verify it passes** + +Run: `PYTHONPATH=backend/src uv run pytest backend/tests/unit/core/agent/test_crewai_runtime.py::test_runtime_creates_agents_tasks_from_resolved_config -q` +Expected: PASS + +**Step 5: Commit** + +```bash +git add backend/src/core/agent/infrastructure/crewai backend/tests/unit/core/agent/test_crewai_runtime.py +git commit -m "feat: create crewai runtime from resolved config" +``` + +### Task 9: AG-UI 与 ag-ui-crewai 事件桥 + +**Files:** +- Create: `backend/src/core/agent/infrastructure/agui/bridge.py` +- Create: `backend/src/core/agent/infrastructure/agui/stream.py` +- Test: `backend/tests/unit/core/agent/test_agui_bridge.py` + +**Step 1: Write the failing test** + +```python +def test_agui_stream_emits_required_lifecycle(): + events = to_agui_events(internal_events=[...]) + assert events[0]["type"] == "RUN_STARTED" + assert events[-1]["type"] in {"RUN_FINISHED", "RUN_ERROR"} +``` + +**Step 2: Run test to verify it fails** + +Run: `PYTHONPATH=backend/src uv run pytest backend/tests/unit/core/agent/test_agui_bridge.py::test_agui_stream_emits_required_lifecycle -q` +Expected: FAIL + +**Step 3: Write minimal implementation** + +```python +def to_agui_events(internal_events: list[dict]) -> list[dict]: + return [map_event(e) for e in internal_events] +``` + +**Step 4: Run test to verify it passes** + +Run: `PYTHONPATH=backend/src uv run pytest backend/tests/unit/core/agent/test_agui_bridge.py::test_agui_stream_emits_required_lifecycle -q` +Expected: PASS + +**Step 5: Commit** + +```bash +git add backend/src/core/agent/infrastructure/agui backend/tests/unit/core/agent/test_agui_bridge.py +git commit -m "feat: add ag-ui and ag-ui-crewai event bridge" +``` + +### Task 10: LiteLLM 调用统计与会话聚合 + +**Files:** +- Create: `backend/src/core/agent/infrastructure/litellm/client.py` +- Create: `backend/src/core/agent/infrastructure/litellm/usage_tracker.py` +- Test: `backend/tests/unit/core/agent/test_litellm_usage.py` + +**Step 1: Write the failing test** + +```python +def test_tracker_aggregates_per_call_usage_and_cost(): + t = UsageTracker() + t.add({"input_tokens": 10, "output_tokens": 5, "cost": "0.1"}) + assert t.snapshot()["total_tokens"] == 15 +``` + +**Step 2: Run test to verify it fails** + +Run: `PYTHONPATH=backend/src uv run pytest backend/tests/unit/core/agent/test_litellm_usage.py::test_tracker_aggregates_per_call_usage_and_cost -q` +Expected: FAIL + +**Step 3: Write minimal implementation** + +```python +def add(self, usage: dict[str, object]) -> None: + self.input_tokens += int(usage.get("input_tokens", 0)) + self.output_tokens += int(usage.get("output_tokens", 0)) +``` + +**Step 4: Run test to verify it passes** + +Run: `PYTHONPATH=backend/src uv run pytest backend/tests/unit/core/agent/test_litellm_usage.py::test_tracker_aggregates_per_call_usage_and_cost -q` +Expected: PASS + +**Step 5: Commit** + +```bash +git add backend/src/core/agent/infrastructure/litellm backend/tests/unit/core/agent/test_litellm_usage.py +git commit -m "feat: add litellm usage and cost tracking" +``` + +### Task 11: v1/agent 薄层 API + SSE 出口 + +**Files:** +- Create: `backend/src/v1/agent/router.py` +- Create: `backend/src/v1/agent/schemas.py` +- Create: `backend/src/v1/agent/dependencies.py` +- Create: `backend/src/v1/agent/service.py` +- Modify: `backend/src/v1/router.py` +- Test: `backend/tests/integration/v1/agent/test_routes.py` + +**Step 1: Write the failing test** + +```python +def test_run_endpoint_returns_sse_and_not_blocking(client): + resp = client.post("/api/v1/agent/runs", json={...}) + assert resp.status_code == 202 +``` + +**Step 2: Run test to verify it fails** + +Run: `PYTHONPATH=backend/src uv run pytest backend/tests/integration/v1/agent/test_routes.py::test_run_endpoint_returns_sse_and_not_blocking -q` +Expected: FAIL + +**Step 3: Write minimal implementation** + +```python +@router.post("/runs", status_code=202) +async def create_run(...): + task_id = service.enqueue_run(input_data) + return {"task_id": task_id} +``` + +**Step 4: Run test to verify it passes** + +Run: `PYTHONPATH=backend/src uv run pytest backend/tests/integration/v1/agent/test_routes.py::test_run_endpoint_returns_sse_and_not_blocking -q` +Expected: PASS + +**Step 5: Commit** + +```bash +git add backend/src/v1/agent backend/src/v1/router.py backend/tests/integration/v1/agent/test_routes.py +git commit -m "feat: add thin v1 agent api and sse endpoints" +``` + +### Task 12: 端到端验证与文档回填 + +**Files:** +- Modify: `docs/runtime/runtime-route.md` +- Modify: `docs/runtime/runtime-runbook.md` + +**Step 1: Run unit tests** + +Run: `PYTHONPATH=backend/src uv run pytest backend/tests/unit/core/agent backend/tests/unit/core/config backend/tests/unit/database -q` +Expected: PASS + +**Step 2: Run integration tests** + +Run: `PYTHONPATH=backend/src uv run pytest backend/tests/integration/core/agent backend/tests/integration/v1/agent -q` +Expected: PASS + +**Step 3: Run lint and typecheck** + +Run: `PYTHONPATH=backend/src uv run ruff check backend/src backend/tests` +Expected: PASS + +Run: `PYTHONPATH=backend/src uv run basedpyright backend/src` +Expected: PASS + +**Step 4: Document protocol contracts** + +在运行手册中补充以下固定规则: +- `system_agents` + `static/crewai` 配置合并优先级。 +- `sessions.state_snapshot` 字段结构与版本号。 +- `messages` 入库顺序与 `sessions` 聚合字段更新规则。 +- 工具调用审批与恢复时序图。 +- tool_call/result 不匹配时的错误语义(`RUN_ERROR` + 可审计日志)。 + +**Step 5: Commit** + +```bash +git add docs/runtime/runtime-route.md docs/runtime/runtime-runbook.md +git commit -m "docs: add new agent runtime contracts and operational guide" +``` + +## Success Criteria + +- [ ] Agent 创建配置由 `system_agents` 与 `core/config/static/crewai` 合并生成。 +- [ ] run/resume 仅通过 Celery Worker 执行,Web 不执行编排。 +- [ ] `v1/agent` 无业务编排代码。 +- [ ] `sessions.state_snapshot` 承担运行态和工具审批恢复状态。 +- [ ] 每次 run/resume 的会话状态变更均落库到 `sessions`。 +- [ ] 用户/助手/工具消息按 `messages` 约束落库,`seq` 单调递增。 +- [ ] 前端工具与后端工具(审批/非审批)策略完整可测。 +- [ ] tool_call 与 tool_result 具备强关联校验并可恢复/报错。 +- [ ] LiteLLM 逐次计量与 run 聚合可落库。 diff --git a/docs/runtime/runtime-route.md b/docs/runtime/runtime-route.md index 6caea22..1c76719 100644 --- a/docs/runtime/runtime-route.md +++ b/docs/runtime/runtime-route.md @@ -786,72 +786,6 @@ --- -## Agent - -### POST /agent/runs - -创建 Agent 运行(需要认证,SSE 响应)。 - -**Request (RunAgentInput):** -```json -{ - "threadId": "string", - "runId": "string", - "parentRunId": "string?", - "state": {}, - "messages": [], - "tools": [], - "context": [], - "forwardedProps": {}, - "resume": null -} -``` - -**Response:** 200 OK (`text/event-stream`) - -**Errors:** -- 401: 未认证 -- 422: 请求参数无效 - -### POST /agent/runs/{run_id}/resume - -恢复被中断运行(需要认证,SSE 响应)。 - -**Request (RunAgentInput):** -```json -{ - "threadId": "string", - "runId": "string", - "state": {}, - "messages": [], - "tools": [], - "context": [], - "forwardedProps": {}, - "resume": { - "interruptId": "string", - "payload": {} - } -} -``` - -**State Snapshot Contract:** -- `state_snapshot` 仅支持 `version = 2` -- 顶层必须包含 `run_context` 与 `pending_tool_call` -- 旧格式或缺失字段会被拒绝 - -**Resume Semantics:** -- 同一 `interrupt_id` 并发恢复仅允许一个请求成功 -- `expires_at` 超时后会标记为 `EXPIRED`,恢复请求不再生效 - -**Errors:** -- 401: 未认证 -- 404: 会话不存在 -- 409: `run_id` 或 `interrupt_id` 冲突,或状态已被消费 -- 410: 挂起调用已过期 -- 422: `state_snapshot` 非法或版本不匹配 - ---- - ## Infra ### GET /infra/health diff --git a/docs/runtime/runtime-runbook.md b/docs/runtime/runtime-runbook.md index ebcde0b..dbc1fa8 100644 --- a/docs/runtime/runtime-runbook.md +++ b/docs/runtime/runtime-runbook.md @@ -159,22 +159,6 @@ curl -sS "${WEB_BASE_URL}/api/v1/profile/me" \ 通过标准:接口返回符合预期的 2xx 或受控业务错误,无 5xx。 -### L3 可选(Agent Chat 回归) - -```bash -PYTHONPATH=backend/src uv run pytest backend/tests/unit -k agent_chat -q -PYTHONPATH=backend/src uv run pytest backend/tests/integration -k agent_chat -q -PYTHONPATH=backend/src uv run pytest backend/tests/e2e/test_agent_chat_flow.py backend/tests/e2e/test_agent_chat_recent_session_home.py -q - -curl -sS -X POST "${WEB_BASE_URL}/api/v1/agent" \ - -H 'Content-Type: application/json' \ - -d '{"message":"hello"}' -``` - -通过标准:测试通过,`/api/v1/agent` 返回有效 `session_id` 与事件序列。 - ---- - ## Incident Playbook ### 1) 迁移未生效(常见于旧镜像) @@ -195,13 +179,7 @@ curl -sS -X POST "${WEB_BASE_URL}/api/v1/agent" \ - 定位:核对 `.env` 中 Supabase JWT 配置与签发方设置。 - 修复:修正配置后重启 web 进程并执行 L1/L2 验证。 -### 4) Agent Chat 启动后异常 - -- 症状:`/api/v1/agent` 返回 5xx 或事件不完整。 -- 定位:先跑 L3 测试,再看 `logs/errors/web.error.log`。 -- 修复:先恢复到可用版本,再排查迁移、配置与依赖差异。 - -### 5) Auth 邮件模板未生效 / 注册返回超时但邮件已发送 +### 4) Auth 邮件模板未生效 / 注册返回超时但邮件已发送 - 症状: - 收到默认英文模板(非 `infra/mail-templates`)。 @@ -261,7 +239,7 @@ docker compose --env-file .env -f infra/docker/docker-compose.yml up -d --force- | 2026-02-25 | 补充迁移防遗漏规则:容器迁移命令统一追加 --build;开发调试优先使用本地 CLI 一次性迁移脚本 | | 2026-02-25 | Auth 注册切换为 OTP 三段式:signup/start、signup/verify、signup/resend;邮件模板改为纯验证码展示 | | 2026-02-25 | 清理未使用配置类:删除 WebSettings/GunicornSettings/WorkerSettings/WorkerGroupSettings(脚本仍使用环境变量启动服务) | -| 2026-02-25 | 新增 Agent Chat 验证章节:bootstrap gate、分层测试命令与 run 接口 smoke 示例 | +| 2026-03-04 | Agent 运行时进入硬切重构:移除旧 Agent Chat 验证章节,待新方案落地后补充 | | 2026-02-25 | 简化启动方式:dev-app-up -> app-up,分离 bootstrap 与服务启动 | | 2026-02-25 | 重构为运维分层手册:Bootstrap Gate、分层验证、故障与回滚流程 | | 2026-02-25 | 新增配置漂移故障条目:修复 Auth 邮件模板失效与 signup 超时场景 | diff --git a/pyproject.toml b/pyproject.toml index 8a51737..fec622f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "email-validator>=2.3.0", "fastapi>=0.128.0", "litellm>=1.52.0", + "playwright>=1.57.0", "pydantic>=2.11.0", "pydantic-settings>=2.10.0", "pyjwt>=2.10.1",