refactor: 移除前端 Mock API,新增共享组件,优化认证流程

- 删除 mock_api_client、mock_calendar_service、mock_history_service
- 新增 fixed_length_code_input、link_button、message_composer 共享组件
- 优化登录/注册/密码重置页面使用新组件
- 简化 injection.dart 移除 mock 分支
- 更新 env.dart 配置(BACKEND_URL 替换 API_URL)
- 后端 agentscope 工具和测试更新
- 重构 AGENTS.md 文档结构
- 新增 deploy/ 目录和 protocol 文档
This commit is contained in:
qzl
2026-03-12 16:41:45 +08:00
parent d7fbb74bf8
commit 01c36eb32e
70 changed files with 5138 additions and 5829 deletions
+19 -6
View File
@@ -2,6 +2,13 @@
This document defines Python/FastAPI backend development constraints.
## Scope and Precedence
- This file applies to all changes under `backend/**`.
- It extends root routing rules in `AGENTS.md` and workspace global runtime rules.
- If rules conflict, follow stricter requirements.
- Keep backend-only rules here; do not duplicate them in root `AGENTS.md`.
## Python Environment
**MUST use uv for dependency management and virtual environment execution.**
@@ -71,7 +78,7 @@ Do not bypass or weaken checks (no ignores, disables, or config relaxations). Re
3. Implement minimal code (GREEN) - only to pass
4. Run tests - confirm success
5. Refactor (IMPROVE)
6. Verify coverage - must be 80%+
6. Verify coverage - target 80%+
### Enforcement
@@ -132,10 +139,13 @@ Before ANY commit:
# NEVER: Hardcoded secrets
api_key = "sk-proj-xxxxx"
# ALWAYS: Environment variables
api_key = os.environ.get("OPENAI_API_KEY")
# ALWAYS: Read through centralized settings
from core.config.settings import Settings
settings = Settings()
api_key = settings.openai_api_key
if not api_key:
raise ValueError("OPENAI_API_KEY not configured")
raise ValueError("OPENAI_API_KEY not configured in settings")
```
## Database Development Rules
@@ -227,6 +237,11 @@ class AgentType(str, Enum):
- [ ] Downgrade path is reversible and does not silently weaken intended production security
- [ ] Any exemption is documented with clear non-exposure evidence
## Backend Startup
**Always use `./infra/scripts/app.sh` to start/stop the backend.** Do not start uvicorn directly.
**Always use `./logs/*.log` to check the backend log output.**
## Agent Loop (AG-UI Protocol)
Agent loop functionality MUST follow the AG-UI protocol. **Use the `ag-ui` skill** for protocol reference and implementation guidance.
@@ -235,8 +250,6 @@ Agent loop functionality MUST follow the AG-UI protocol. **Use the `ag-ui` skill
Multi-agent orchestration MUST use the AgentScope framework. **Use the `agentscope-skill`** for framework reference and implementation guidance.
For workflows involving routing, LiteLLM proxy cost audit, or frontend/backend human approval loops, **use the `agentscope-hitl-cost` skill**.
### Core Principles
- Use AgentScope for orchestrating multiple agents working together
+3 -1
View File
@@ -9,7 +9,9 @@ RUN uv sync --frozen --no-dev
COPY backend/src ./backend/src
COPY backend/alembic ./backend/alembic
COPY backend/scripts ./backend/scripts
ENV PYTHONPATH=/app/backend/src
ENV PYTHONDONTWRITEBYTECODE=1
CMD ["uv", "run", "uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]
CMD ["uv", "run", "uvicorn", "app:app", "--host", "0.0.0.0", "--port", "5775", "--workers", "2"]
@@ -20,7 +20,7 @@ def upgrade() -> None:
"""
CREATE TABLE invite_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(8) NOT NULL UNIQUE CHECK (code ~ '^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{8}$'),
code VARCHAR(4) NOT NULL UNIQUE CHECK (code ~ '^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{4}$'),
owner_id UUID REFERENCES profiles(id) ON DELETE SET NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled', 'expired')),
used_count INTEGER NOT NULL DEFAULT 0 CHECK (used_count >= 0),
@@ -63,7 +63,7 @@ def upgrade() -> None:
result TEXT := '';
i INT;
BEGIN
FOR i IN 1..8 LOOP
FOR i IN 1..4 LOOP
result := result || substr(chars, floor(random() * length(chars) + 1)::int, 1);
END LOOP;
RETURN result;
@@ -126,9 +126,9 @@ def upgrade() -> None:
END LOOP;
invite_code_value := NEW.raw_user_meta_data ->> 'invite_code';
IF invite_code_value IS NOT NULL AND length(invite_code_value) = 8 THEN
IF invite_code_value IS NOT NULL AND length(invite_code_value) = 4 THEN
invite_code_value := upper(invite_code_value);
IF invite_code_value ~ '^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{8}$' THEN
IF invite_code_value ~ '^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{4}$' THEN
UPDATE public.invite_codes
SET used_count = used_count + 1
WHERE code = invite_code_value
@@ -47,11 +47,6 @@ def build_tool_content_summary(
if target:
return _truncate(f"已分享日程给 {target}")
if tool_name == "user_resolve":
target = _pick_first_str(normalized_result, ("name", "userName", "userId"))
if target:
return _truncate(f"已匹配用户:{target}")
result_content = _pick_first_str(normalized_result, ("content", "message"))
if result_content:
return _truncate(result_content)
@@ -0,0 +1,770 @@
"""
UI Schema Protocol Implementation.
This module is the single source of truth for UI Schema.
All implementations must follow docs/protocols/ui-schema.md.
Version: 1.0
"""
from __future__ import annotations
from enum import Enum
from typing import Any, Literal, NotRequired, TypedDict, Union
# ========== Enums ==========
class SchemaType(str, Enum):
"""Schema type identifier."""
TOOL_RESULT = "tool_result"
AGENT_RESPONSE = "agent_response"
NOTIFICATION = "notification"
class UiStatus(str, Enum):
"""Unified status for all nodes."""
INFO = "info"
SUCCESS = "success"
WARNING = "warning"
ERROR = "error"
PENDING = "pending"
class IconSource(str, Enum):
"""Icon source type."""
ICON = "icon"
EMOJI = "emoji"
URL = "url"
class ActionType(str, Enum):
"""Action type identifier."""
NAVIGATION = "navigation"
URL = "url"
EVENT = "event"
TOOL = "tool"
COPY = "copy"
PAYLOAD = "payload"
class OperationType(str, Enum):
"""Operation node operation type."""
CREATE = "create"
UPDATE = "update"
DELETE = "delete"
EXECUTE = "execute"
class OperationResult(str, Enum):
"""Operation node result type."""
SUCCESS = "success"
FAILURE = "failure"
PARTIAL = "partial"
class ContainerDirection(str, Enum):
"""Container node direction."""
VERTICAL = "vertical"
HORIZONTAL = "horizontal"
class TextFormat(str, Enum):
"""Text node format."""
PLAIN = "plain"
MARKDOWN = "markdown"
class KvLayout(str, Enum):
"""Key-value node layout."""
VERTICAL = "vertical"
HORIZONTAL = "horizontal"
GRID = "grid"
class BadgeVariant(str, Enum):
"""Badge variant."""
DEFAULT = "default"
SUCCESS = "success"
WARNING = "warning"
ERROR = "error"
INFO = "info"
class ActionStyle(str, Enum):
"""Action button style."""
PRIMARY = "primary"
SECONDARY = "secondary"
GHOST = "ghost"
DANGER = "danger"
class RendererTheme(str, Enum):
"""Renderer theme."""
DEFAULT = "default"
DARK = "dark"
LIGHT = "light"
# ========== Common Types ==========
class UiIcon(TypedDict, total=False):
"""Icon structure."""
source: IconSource
value: str
color: str
size: int
class UiBadge(TypedDict, total=False):
"""Badge structure."""
label: str
variant: BadgeVariant
class Pagination(TypedDict):
"""Pagination info."""
page: int
pageSize: int
total: int
hasMore: bool
class ActionConfirm(TypedDict, total=False):
"""Action confirmation config."""
title: str
message: str
confirmLabel: str
cancelLabel: str
class KeyValuePair(TypedDict, total=False):
"""Key-value pair."""
key: str
label: str
value: Union[str, int, bool]
copyable: bool
class TableColumn(TypedDict, total=False):
"""Table column definition."""
key: str
label: str
width: str
align: Literal["left", "center", "right"]
class TableRow(TypedDict, total=False):
"""Table row."""
id: str
cells: dict[str, Any]
metadata: dict[str, Any]
actions: list[dict[str, Any]]
class ListItem(TypedDict, total=False):
"""List item."""
id: str
title: str
subtitle: str
description: str
icon: UiIcon
badge: UiBadge
metadata: dict[str, Any]
actions: list[dict[str, Any]]
# ========== Action Types ==========
class NavigateAction(TypedDict):
"""Navigate to internal path."""
type: Literal["navigation"]
path: str
params: NotRequired[dict[str, Any]]
class LinkAction(TypedDict):
"""Open external URL."""
type: Literal["url"]
url: str
target: NotRequired[Literal["_self", "_blank"]]
class EventAction(TypedDict):
"""Trigger frontend event."""
type: Literal["event"]
event: str
payload: NotRequired[dict[str, Any]]
class ToolAction(TypedDict):
"""Re-execute a tool."""
type: Literal["tool"]
toolId: str
params: NotRequired[dict[str, Any]]
class CopyAction(TypedDict):
"""Copy content to clipboard."""
type: Literal["copy"]
content: str
successMessage: NotRequired[str]
class PayloadAction(TypedDict):
"""Submit payload to endpoint."""
type: Literal["payload"]
payload: dict[str, Any]
submitTo: NotRequired[str]
# ========== Action ==========
class UiAction(TypedDict, total=False):
"""Action structure."""
id: str
label: str
icon: UiIcon
style: ActionStyle
disabled: bool
action: (
NavigateAction
| LinkAction
| EventAction
| ToolAction
| CopyAction
| PayloadAction
)
confirm: ActionConfirm
# ========== Node Types (using dict for simplicity) ==========
# Type alias for any node
UiNode = dict[str, Any]
# ========== Builder Functions ==========
def build_document(
status: UiStatus,
nodes: list[UiNode],
*,
version: str = "1.0",
schema_type: SchemaType = SchemaType.TOOL_RESULT,
doc_id: str | None = None,
timestamp: str | None = None,
locale: str = "zh-CN",
renderer: dict[str, Any] | None = None,
meta: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Build a UI schema document."""
doc: dict[str, Any] = {
"version": version,
"schemaType": schema_type.value,
"status": status.value,
"nodes": nodes,
}
if doc_id:
doc["docId"] = doc_id
if timestamp:
doc["timestamp"] = timestamp
if locale:
doc["locale"] = locale
if renderer:
doc["renderer"] = renderer
if meta:
doc["meta"] = meta
return doc
def build_success_document(
nodes: list[UiNode],
**kwargs: Any,
) -> dict[str, Any]:
"""Build a success document."""
return build_document(status=UiStatus.SUCCESS, nodes=nodes, **kwargs)
def build_error_document(
nodes: list[UiNode],
**kwargs: Any,
) -> dict[str, Any]:
"""Build an error document."""
return build_document(status=UiStatus.ERROR, nodes=nodes, **kwargs)
def build_text_node(
content: str,
*,
node_id: str | None = None,
format: TextFormat = TextFormat.PLAIN,
icon: dict[str, Any] | None = None,
actions: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Build a text node."""
node: dict[str, Any] = {
"type": "text",
"content": content,
"format": format.value,
}
if node_id:
node["id"] = node_id
if icon:
node["icon"] = icon
if actions:
node["actions"] = actions
return node
def build_card_node(
*,
node_id: str | None = None,
title: str | None = None,
description: str | None = None,
icon: dict[str, Any] | None = None,
status: UiStatus | None = None,
timestamp: str | None = None,
children: list[UiNode] | None = None,
footer: dict[str, Any] | None = None,
extensions: dict[str, Any] | None = None,
actions: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Build a card node."""
node: dict[str, Any] = {"type": "card"}
if node_id:
node["id"] = node_id
if title:
node["title"] = title
if description:
node["description"] = description
if icon:
node["icon"] = icon
if status:
node["status"] = status.value
if timestamp:
node["timestamp"] = timestamp
if children:
node["children"] = children
if footer:
node["footer"] = footer
if extensions:
node["extensions"] = extensions
if actions:
node["actions"] = actions
return node
def build_kv_node(
pairs: list[dict[str, Any]],
*,
node_id: str | None = None,
title: str | None = None,
description: str | None = None,
icon: dict[str, Any] | None = None,
status: UiStatus | None = None,
layout: KvLayout = KvLayout.VERTICAL,
extensions: dict[str, Any] | None = None,
actions: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Build a key-value node."""
node: dict[str, Any] = {
"type": "kv",
"pairs": pairs,
"layout": layout.value,
}
if node_id:
node["id"] = node_id
if title:
node["title"] = title
if description:
node["description"] = description
if icon:
node["icon"] = icon
if status:
node["status"] = status.value
if extensions:
node["extensions"] = extensions
if actions:
node["actions"] = actions
return node
def build_list_node(
items: list[dict[str, Any]],
*,
node_id: str | None = None,
title: str | None = None,
description: str | None = None,
icon: dict[str, Any] | None = None,
status: UiStatus | None = None,
pagination: dict[str, Any] | None = None,
empty_text: str | None = None,
extensions: dict[str, Any] | None = None,
actions: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Build a list node."""
node: dict[str, Any] = {
"type": "list",
"items": items,
}
if node_id:
node["id"] = node_id
if title:
node["title"] = title
if description:
node["description"] = description
if icon:
node["icon"] = icon
if status:
node["status"] = status.value
if pagination:
node["pagination"] = pagination
if empty_text:
node["emptyText"] = empty_text
if extensions:
node["extensions"] = extensions
if actions:
node["actions"] = actions
return node
def build_error_node(
error_code: str,
message: str,
*,
node_id: str | None = None,
title: str | None = None,
icon: dict[str, Any] | None = None,
details: str | None = None,
stack: str | None = None,
retryable: bool = False,
suggestions: list[str] | None = None,
retry: dict[str, Any] | None = None,
support: dict[str, Any] | None = None,
actions: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Build an error node."""
node: dict[str, Any] = {
"type": "error",
"errorCode": error_code,
"message": message,
"retryable": retryable,
}
if node_id:
node["id"] = node_id
if title:
node["title"] = title
if icon:
node["icon"] = icon
if details:
node["details"] = details
if stack:
node["stack"] = stack
if suggestions:
node["suggestions"] = suggestions
if retry:
node["retry"] = retry
if support:
node["support"] = support
if actions:
node["actions"] = actions
return node
def build_operation_node(
operation: OperationType,
result: OperationResult,
*,
node_id: str | None = None,
title: str | None = None,
description: str | None = None,
icon: dict[str, Any] | None = None,
status: UiStatus | None = None,
message: str | None = None,
affected_count: int | None = None,
details: dict[str, Any] | None = None,
rollback: dict[str, Any] | None = None,
extensions: dict[str, Any] | None = None,
actions: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Build an operation node."""
node: dict[str, Any] = {
"type": "operation",
"operation": operation.value,
"result": result.value,
}
if node_id:
node["id"] = node_id
if title:
node["title"] = title
if description:
node["description"] = description
if icon:
node["icon"] = icon
if status:
node["status"] = status.value
if message:
node["message"] = message
if affected_count is not None:
node["affectedCount"] = affected_count
if details:
node["details"] = details
if rollback:
node["rollback"] = rollback
if extensions:
node["extensions"] = extensions
if actions:
node["actions"] = actions
return node
def build_container_node(
children: list[UiNode],
direction: ContainerDirection = ContainerDirection.VERTICAL,
*,
node_id: str | None = None,
gap: int | None = None,
actions: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Build a container node."""
node: dict[str, Any] = {
"type": "container",
"direction": direction.value,
"children": children,
}
if node_id:
node["id"] = node_id
if gap is not None:
node["gap"] = gap
if actions:
node["actions"] = actions
return node
def build_action(
action_id: str,
label: str,
action: (
NavigateAction
| LinkAction
| EventAction
| ToolAction
| CopyAction
| PayloadAction
),
*,
icon: dict[str, Any] | None = None,
style: ActionStyle | None = None,
disabled: bool = False,
confirm: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Build an action."""
act: dict[str, Any] = {
"id": action_id,
"label": label,
"action": action,
}
if icon:
act["icon"] = icon
if style:
act["style"] = style.value
if disabled:
act["disabled"] = disabled
if confirm:
act["confirm"] = confirm
return act
def build_icon(
source: IconSource,
value: str,
*,
color: str | None = None,
size: int | None = None,
) -> dict[str, Any]:
"""Build an icon."""
icon: dict[str, Any] = {"source": source.value, "value": value}
if color:
icon["color"] = color
if size is not None:
icon["size"] = size
return icon
# ========== Legacy Compatibility Wrappers ==========
# These wrappers maintain API compatibility with existing code
def build_card(
card_type: str,
data: dict[str, Any],
*,
version: str = "1.0",
actions: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Legacy wrapper - builds a card node."""
return {
"type": card_type,
"version": version,
"data": data,
"actions": actions or [],
}
def build_calendar_card(
title: str,
start_at: str,
*,
id: str | None = None,
end_at: str | None = None,
description: str | None = None,
timezone: str | None = None,
location: str | None = None,
color: str | None = None,
source_type: str | None = None,
actions: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Legacy wrapper for calendar card."""
data: dict[str, Any] = {
"title": title,
"startAt": start_at,
}
if id is not None:
data["id"] = id
if end_at is not None:
data["endAt"] = end_at
if description is not None:
data["description"] = description
if timezone is not None:
data["timezone"] = timezone
if location is not None:
data["location"] = location
if color is not None:
data["color"] = color
if source_type is not None:
data["sourceType"] = source_type
return build_card(
card_type="calendar_card.v1",
data=data,
actions=actions,
)
def build_calendar_list(
items: list[dict[str, Any]],
*,
page: int = 1,
page_size: int = 20,
total: int | None = None,
total_pages: int | None = None,
) -> dict[str, Any]:
"""Legacy wrapper for calendar list."""
pagination: dict[str, Any] = {
"page": page,
"pageSize": page_size,
}
if total is not None:
pagination["total"] = total
if total_pages is not None:
pagination["totalPages"] = total_pages
return build_card(
card_type="calendar_event_list.v1",
data={
"items": items,
"pagination": pagination,
},
)
def build_calendar_operation(
operation: str,
ok: bool,
message: str,
*,
code: str | None = None,
actions: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Legacy wrapper for calendar operation."""
data: dict[str, Any] = {
"operation": operation,
"ok": ok,
"message": message,
}
if code is not None:
data["code"] = code
return build_card(
card_type="calendar_operation.v1",
data=data,
actions=actions,
)
def build_error_card(
message: str,
*,
code: str | None = None,
) -> dict[str, Any]:
"""Legacy wrapper for error card."""
data: dict[str, Any] = {"message": message}
if code is not None:
data["code"] = code
return build_card(
card_type="error_card.v1",
data=data,
)
def build_action_legacy(
label: str,
action_type: str = "primary",
*,
target: str | None = None,
action: str | None = None,
) -> dict[str, Any]:
"""Legacy wrapper for action."""
result: dict[str, Any] = {
"type": action_type,
"label": label,
}
if target is not None:
result["target"] = target
if action is not None:
result["action"] = action
return result
@@ -1,7 +1,7 @@
from core.agentscope.tools.custom.calendar import (
calendar_share,
calendar_read,
calendar_write,
user_resolve,
)
__all__ = ["calendar_read", "calendar_write", "user_resolve"]
__all__ = ["calendar_read", "calendar_write", "calendar_share"]
@@ -1,42 +1,21 @@
from typing import Annotated, Any, Literal, cast
from uuid import UUID
from fastapi import HTTPException
from pydantic import Field
from core.auth.jwt_verifier import JwtVerifier, TokenValidationError
from core.agentscope.tools.custom.calendar_backend_ops import (
_execute_list_calendar_events,
_execute_mutate_calendar_event,
_execute_resolve_user_identity,
_execute_share_calendar_event,
)
from core.agentscope.tools.tool_response_builder import build_tool_response
from core.agentscope.schemas.ui_schema import (
build_calendar_list,
build_calendar_operation,
)
from core.config.settings import config
from core.agentscope.tools.response import build_tool_response
def _unauthorized_response() -> dict[str, object]:
return {
"type": "calendar_operation.v1",
"version": "v1",
"data": {
"ok": False,
"code": "UNAUTHORIZED",
"message": "calendar.write requires validated user token",
},
"actions": [],
}
def _invalid_argument_response(*, message: str) -> dict[str, object]:
return {
"type": "calendar_operation.v1",
"version": "v1",
"data": {
"ok": False,
"code": "INVALID_ARGUMENT",
"message": message,
},
"actions": [],
}
def _verify_user_token(*, user_token: str, owner_id: UUID) -> bool:
@@ -56,6 +35,67 @@ def _verify_user_token(*, user_token: str, owner_id: UUID) -> bool:
return isinstance(subject, str) and subject == str(owner_id)
def _failure_response(
*,
card_type: Literal["calendar_event_list.v1", "calendar_operation.v1"],
operation: str | None,
code: str,
message: str,
) -> dict[str, object]:
if card_type == "calendar_event_list.v1":
return build_calendar_list(
items=[],
page=1,
page_size=20,
total=0,
) | {"data": {"ok": False, "code": code, "message": message}}
return build_calendar_operation(
operation=operation or "operation",
ok=False,
message=message,
code=code,
)
def _authorized_or_response(
*,
session: Any,
owner_id: Any,
user_token: str | None,
card_type: Literal["calendar_event_list.v1", "calendar_operation.v1"],
operation: str | None,
) -> tuple[Any, UUID] | dict[str, object]:
if session is None or owner_id is None:
raise ValueError("calendar tool missing runtime preset arguments")
if not isinstance(user_token, str) or not user_token.strip():
return _failure_response(
card_type=card_type,
operation=operation,
code="UNAUTHORIZED",
message="calendar tool requires validated user token",
)
if not _verify_user_token(user_token=user_token, owner_id=cast(UUID, owner_id)):
return _failure_response(
card_type=card_type,
operation=operation,
code="UNAUTHORIZED",
message="calendar tool requires validated user token",
)
return cast(Any, session), cast(UUID, owner_id)
def _map_exception(exc: Exception) -> tuple[str, str]:
if isinstance(exc, HTTPException):
detail = exc.detail
if isinstance(detail, str) and detail.strip():
return "OPERATION_FAILED", detail.strip()
return "OPERATION_FAILED", "calendar operation failed"
if isinstance(exc, ValueError):
return "INVALID_ARGUMENT", str(exc)
return "INTERNAL_ERROR", "calendar operation failed"
async def calendar_read(
query: Annotated[
str | None,
@@ -73,31 +113,34 @@ async def calendar_read(
owner_id: Any = None,
user_token: str | None = None,
) -> Any:
"""Read calendar events and return a structured paginated response.
Args:
query: Optional search keyword for event filtering.
page: Page index starting from 1.
page_size: Page size for pagination.
session: Runtime-injected database session.
owner_id: Runtime-injected user ID.
user_token: Runtime-injected user access token.
Returns:
A tool response payload containing a calendar event list.
"""
if session is None or owner_id is None:
raise ValueError("calendar.read missing runtime preset arguments")
if not isinstance(user_token, str) or not user_token.strip():
return build_tool_response(_unauthorized_response())
if not _verify_user_token(user_token=user_token, owner_id=cast(UUID, owner_id)):
return build_tool_response(_unauthorized_response())
result = await _execute_list_calendar_events(
session=cast(Any, session),
owner_id=cast(UUID, owner_id),
tool_args={"query": query, "page": page, "pageSize": page_size},
auth_result = _authorized_or_response(
session=session,
owner_id=owner_id,
user_token=user_token,
card_type="calendar_event_list.v1",
operation=None,
)
if isinstance(auth_result, dict):
return build_tool_response(auth_result)
runtime_session, runtime_owner_id = auth_result
try:
result = await _execute_list_calendar_events(
session=runtime_session,
owner_id=runtime_owner_id,
tool_args={"query": query, "page": page, "pageSize": page_size},
)
except Exception as exc:
code, message = _map_exception(exc)
return build_tool_response(
_failure_response(
card_type="calendar_event_list.v1",
operation=None,
code=code,
message=message,
)
)
return build_tool_response(result)
@@ -151,6 +194,68 @@ async def calendar_write(
bool,
Field(description="Whether to use the replace strategy for conflicts."),
] = False,
session: Any = None,
owner_id: Any = None,
user_token: str | None = None,
) -> Any:
auth_result = _authorized_or_response(
session=session,
owner_id=owner_id,
user_token=user_token,
card_type="calendar_operation.v1",
operation=operation,
)
if isinstance(auth_result, dict):
return build_tool_response(auth_result)
runtime_session, runtime_owner_id = auth_result
tool_args: dict[str, object] = {"operation": operation, "replace": replace}
if event_id is not None:
tool_args["eventId"] = event_id
if title is not None:
tool_args["title"] = title
if description is not None:
tool_args["description"] = description
if start_at is not None:
tool_args["startAt"] = start_at
if end_at is not None:
tool_args["endAt"] = end_at
if timezone is not None:
tool_args["timezone"] = timezone
if location is not None:
tool_args["location"] = location
if color is not None:
tool_args["color"] = color
if reminder_minutes is not None:
tool_args["reminderMinutes"] = reminder_minutes
if status is not None:
tool_args["status"] = status
try:
result = await _execute_mutate_calendar_event(
session=runtime_session,
owner_id=runtime_owner_id,
tool_args=tool_args,
)
except Exception as exc:
code, message = _map_exception(exc)
return build_tool_response(
_failure_response(
card_type="calendar_operation.v1",
operation=operation,
code=code,
message=message,
)
)
return build_tool_response(result)
async def calendar_share(
event_id: Annotated[
str,
Field(description="Target event ID (UUID string)."),
],
invite_user_emails: Annotated[
list[str] | None,
Field(description="Optional invite targets by email."),
@@ -179,136 +284,45 @@ async def calendar_write(
owner_id: Any = None,
user_token: str | None = None,
) -> Any:
"""Execute calendar write operations with runtime authorization checks.
Args:
operation: Write operation type.
event_id: Target event ID.
title: Event title.
description: Event description.
start_at: Event start time in ISO 8601 format.
end_at: Event end time in ISO 8601 format.
timezone: Event timezone.
location: Event location.
color: Event color.
reminder_minutes: Reminder minutes before event start.
status: Event lifecycle status.
replace: Replace-strategy flag for conflict handling.
session: Runtime-injected database session.
owner_id: Runtime-injected user ID.
user_token: Runtime-injected user access token.
Returns:
A tool response payload describing the mutation result.
"""
if operation in ("update", "delete") and (
not isinstance(event_id, str) or not event_id.strip()
):
return build_tool_response(
_invalid_argument_response(
message="event_id is required for update and delete operations"
)
)
if operation == "create" and isinstance(event_id, str) and event_id.strip():
return build_tool_response(
_invalid_argument_response(
message="event_id must not be provided for create operation"
)
)
if isinstance(title, str) and len(title.strip()) > 255:
return build_tool_response(
_invalid_argument_response(message="title length must be <= 255")
)
if isinstance(description, str) and len(description.strip()) > 2000:
return build_tool_response(
_invalid_argument_response(message="description length must be <= 2000")
)
if isinstance(timezone, str) and len(timezone.strip()) > 50:
return build_tool_response(
_invalid_argument_response(message="timezone length must be <= 50")
)
if reminder_minutes is not None and (
reminder_minutes < 0 or reminder_minutes > 10080
):
return build_tool_response(
_invalid_argument_response(message="reminder_minutes must be 0..10080")
)
if session is None or owner_id is None:
raise ValueError("calendar.write missing runtime preset arguments")
if not isinstance(user_token, str) or not user_token.strip():
return build_tool_response(_unauthorized_response())
if not _verify_user_token(user_token=user_token, owner_id=cast(UUID, owner_id)):
return build_tool_response(_unauthorized_response())
auth_result = _authorized_or_response(
session=session,
owner_id=owner_id,
user_token=user_token,
card_type="calendar_operation.v1",
operation="share",
)
if isinstance(auth_result, dict):
return build_tool_response(auth_result)
runtime_session, runtime_owner_id = auth_result
tool_args: dict[str, object] = {
"operation": operation,
"replace": replace,
"eventId": event_id,
"invitePermissionView": invite_permission_view,
"invitePermissionEdit": invite_permission_edit,
"invitePermissionInvite": invite_permission_invite,
}
if event_id is not None:
tool_args["eventId"] = event_id
if title is not None:
tool_args["title"] = title
if description is not None:
tool_args["description"] = description
if start_at is not None:
tool_args["startAt"] = start_at
if end_at is not None:
tool_args["endAt"] = end_at
if timezone is not None:
tool_args["timezone"] = timezone
if location is not None:
tool_args["location"] = location
if color is not None:
tool_args["color"] = color
if reminder_minutes is not None:
tool_args["reminderMinutes"] = reminder_minutes
if status is not None:
tool_args["status"] = status
if invite_user_emails is not None:
tool_args["inviteUserEmails"] = invite_user_emails
if invite_user_names is not None:
tool_args["inviteUserNames"] = invite_user_names
if invite_user_ids is not None:
tool_args["inviteUserIds"] = invite_user_ids
tool_args["invitePermissionView"] = invite_permission_view
tool_args["invitePermissionEdit"] = invite_permission_edit
tool_args["invitePermissionInvite"] = invite_permission_invite
result = await _execute_mutate_calendar_event(
session=cast(Any, session),
owner_id=cast(UUID, owner_id),
tool_args=tool_args,
)
return build_tool_response(result)
async def user_resolve(
user_email: Annotated[
str | None,
Field(description="User email to resolve user ID."),
] = None,
user_name: Annotated[
str | None,
Field(description="Username to resolve user ID."),
] = None,
session: Any = None,
owner_id: Any = None,
user_token: str | None = None,
) -> Any:
if session is None or owner_id is None:
raise ValueError("user.resolve missing runtime preset arguments")
if not isinstance(user_token, str) or not user_token.strip():
return build_tool_response(_unauthorized_response())
if not _verify_user_token(user_token=user_token, owner_id=cast(UUID, owner_id)):
return build_tool_response(_unauthorized_response())
result = await _execute_resolve_user_identity(
session=cast(Any, session),
owner_id=cast(UUID, owner_id),
tool_args={
"userEmail": user_email,
"userName": user_name,
},
)
try:
result = await _execute_share_calendar_event(
session=runtime_session,
owner_id=runtime_owner_id,
tool_args=tool_args,
)
except Exception as exc:
code, message = _map_exception(exc)
return build_tool_response(
_failure_response(
card_type="calendar_operation.v1",
operation="share",
code=code,
message=message,
)
)
return build_tool_response(result)
@@ -379,12 +379,6 @@ async def _execute_create(
)
event_data = _event_payload(created)
event_id = str(event_data["id"])
invite_result = await _share_event_with_invitees(
session=service._session,
owner_id=service.require_user_id(),
event_id=UUID(event_id),
tool_args=tool_args,
)
return {
"type": "calendar_card.v1",
"version": "v1",
@@ -393,7 +387,6 @@ async def _execute_create(
"sourceType": "agent_generated",
"ok": True,
"message": "日程已创建",
"inviteResult": invite_result,
},
"actions": [
{
@@ -475,12 +468,6 @@ async def _execute_update(
ScheduleItemUpdateRequest.model_validate(update_data),
)
event_data = _event_payload(updated)
invite_result = await _share_event_with_invitees(
session=service._session,
owner_id=service.require_user_id(),
event_id=UUID(str(event_data["id"])),
tool_args=tool_args,
)
return {
"type": "calendar_card.v1",
"version": "v1",
@@ -489,7 +476,6 @@ async def _execute_update(
"sourceType": "agent_generated",
"ok": True,
"message": "日程已更新",
"inviteResult": invite_result,
},
"actions": [
{
@@ -538,3 +524,34 @@ async def _execute_mutate_calendar_event(
if operation == "delete":
return await _execute_delete(service=service, tool_args=tool_args)
raise ValueError("operation must be one of: create, update, delete")
async def _execute_share_calendar_event(
*,
session: AsyncSession,
owner_id: UUID,
tool_args: dict[str, object],
) -> dict[str, object]:
event_id = _parse_event_id(tool_args.get("eventId"))
invite_result = await _share_event_with_invitees(
session=session,
owner_id=owner_id,
event_id=event_id,
tool_args=tool_args,
)
if invite_result is None:
raise ValueError(
"at least one invite target is required: inviteUserEmails, inviteUserNames, or inviteUserIds"
)
return {
"type": "calendar_operation.v1",
"version": "v1",
"data": {
"operation": "share",
"id": str(event_id),
"ok": True,
"message": "日程已分享",
"shareResult": invite_result,
},
"actions": [],
}
@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import Any, AsyncGenerator, Callable
from core.agentscope.tools.response import build_tool_response
from core.agentscope.tools.tool_response_builder import build_tool_response
from core.agentscope.tools.tool_meta import ToolMeta
@@ -6,7 +6,7 @@ from dataclasses import dataclass
TOOL_APPROVAL_REQUIRED: dict[str, bool] = {
"calendar_read": False,
"calendar_write": False,
"user_resolve": False,
"calendar_share": False,
}
+5 -7
View File
@@ -7,9 +7,9 @@ from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from core.agentscope.tools.custom.calendar import (
calendar_share,
calendar_read,
calendar_write,
user_resolve,
)
from core.agentscope.tools.hitl_middleware import register_tool_middlewares
from core.agentscope.tools.tool_meta import TOOL_META
@@ -29,12 +29,10 @@ class ToolGroup:
TOOL_GROUPS: dict[str, ToolGroup] = {
"intent": ToolGroup(
stage="intent", tool_names=frozenset({"calendar_read", "user_resolve"})
),
"intent": ToolGroup(stage="intent", tool_names=frozenset({"calendar_read"})),
"execution": ToolGroup(
stage="execution",
tool_names=frozenset({"calendar_read", "calendar_write", "user_resolve"}),
tool_names=frozenset({"calendar_read", "calendar_write", "calendar_share"}),
),
"report": ToolGroup(stage="report", tool_names=frozenset()),
}
@@ -73,8 +71,8 @@ def _load_custom_tool_bindings(
},
),
CustomToolBinding(
name="user_resolve",
func=user_resolve,
name="calendar_share",
func=calendar_share,
preset_kwargs={
"session": session,
"owner_id": owner_id,
+3 -3
View File
@@ -68,12 +68,12 @@ def _verified_access_token_for_user(
*,
authorization: str | None,
current_user: CurrentUser,
) -> str | None:
) -> str:
if not isinstance(authorization, str):
return None
raise HTTPException(status_code=401, detail="Unauthorized")
normalized = authorization.strip()
if not normalized:
return None
raise HTTPException(status_code=401, detail="Unauthorized")
if not normalized.lower().startswith("bearer "):
raise HTTPException(status_code=401, detail="Unauthorized")
token = normalized[7:].strip()
+1 -6
View File
@@ -15,12 +15,7 @@ class VerificationCreateRequest(BaseModel):
email: EmailStr
password: str = Field(min_length=SUPABASE_PASSWORD_MIN_LENGTH)
redirect_to: str | None = None
invite_code: str | None = Field(
default=None,
min_length=8,
max_length=8,
pattern=r"^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{8}$",
)
invite_code: str | None = None
class VerificationResendRequest(BaseModel):
+20 -1
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import re
from typing import Protocol
from v1.auth.schemas import (
@@ -56,7 +57,11 @@ class AuthService:
async def create_verification(
self, request: VerificationCreateRequest
) -> VerificationCreateResponse:
return await self._gateway.create_verification(request)
normalized_invite_code = _normalize_invite_code(request.invite_code)
normalized_request = request.model_copy(
update={"invite_code": normalized_invite_code}
)
return await self._gateway.create_verification(normalized_request)
async def verify_verification(
self, request: VerificationVerifyRequest
@@ -82,3 +87,17 @@ class AuthService:
self, request: PasswordResetConfirmRequest
) -> None:
await self._gateway.confirm_password_reset(request)
_INVITE_CODE_PATTERN = re.compile(r"^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{4}$")
def _normalize_invite_code(value: str | None) -> str | None:
if value is None:
return None
normalized = value.strip().upper()
if not normalized:
return None
return normalized if _INVITE_CODE_PATTERN.fullmatch(normalized) else None
+7 -11
View File
@@ -783,7 +783,7 @@ class TestInviteCodeSignup:
"username": "demo",
"email": "user@example.com",
"password": "secret123",
"invite_code": "A2B3C4D5",
"invite_code": "A2B3",
},
)
assert response.status_code == 202
@@ -791,7 +791,7 @@ class TestInviteCodeSignup:
finally:
app.dependency_overrides = {}
def test_signup_with_invalid_invite_code_length_returns_422(self) -> None:
def test_signup_with_invalid_invite_code_length_returns_202(self) -> None:
user = AuthUser(id="user-1", email="user@example.com")
token_response = SessionResponse(
access_token="access",
@@ -815,14 +815,12 @@ class TestInviteCodeSignup:
"invite_code": "ABC123",
},
)
assert response.status_code == 422
assert response.headers["content-type"].startswith(
"application/problem+json"
)
assert response.status_code == 202
assert response.json() == {"email": "user@example.com"}
finally:
app.dependency_overrides = {}
def test_signup_with_invalid_invite_code_chars_returns_422(self) -> None:
def test_signup_with_invalid_invite_code_chars_returns_202(self) -> None:
user = AuthUser(id="user-1", email="user@example.com")
token_response = SessionResponse(
access_token="access",
@@ -846,9 +844,7 @@ class TestInviteCodeSignup:
"invite_code": "ABCD1234",
},
)
assert response.status_code == 422
assert response.headers["content-type"].startswith(
"application/problem+json"
)
assert response.status_code == 202
assert response.json() == {"email": "user@example.com"}
finally:
app.dependency_overrides = {}
@@ -5,6 +5,7 @@ from types import SimpleNamespace
from uuid import uuid4
from ag_ui.core import RunAgentInput
from fastapi import HTTPException
from fastapi.testclient import TestClient
from app import app
@@ -150,6 +151,7 @@ def test_run_requires_auth_and_returns_202_task_id() -> None:
app.dependency_overrides[get_agent_service] = lambda: _FakeAgentService()
client = TestClient(app)
original_allow_run = agent_router._allow_run_request
original_verify_token = agent_router._verified_access_token_for_user
async def _allow_run(*, user_id: str) -> bool:
del user_id
@@ -157,6 +159,13 @@ def test_run_requires_auth_and_returns_202_task_id() -> None:
agent_router._allow_run_request = _allow_run # type: ignore[assignment]
def _verify_token(**kwargs: object) -> str:
if kwargs.get("authorization"):
return "token-ok"
raise HTTPException(status_code=401, detail="Unauthorized")
agent_router._verified_access_token_for_user = _verify_token # type: ignore[assignment]
try:
unauthorized = client.post(
"/api/v1/agent/runs",
@@ -177,6 +186,7 @@ def test_run_requires_auth_and_returns_202_task_id() -> None:
)
authorized = client.post(
"/api/v1/agent/runs",
headers={"Authorization": "Bearer token-ok"},
json={
"threadId": "00000000-0000-0000-0000-000000000001",
"runId": "run-1",
@@ -192,8 +202,23 @@ def test_run_requires_auth_and_returns_202_task_id() -> None:
assert authorized.json()["threadId"] == "00000000-0000-0000-0000-000000000001"
assert authorized.json()["runId"] == "run-1"
assert authorized.json()["created"] is False
missing_header = client.post(
"/api/v1/agent/runs",
json={
"threadId": "00000000-0000-0000-0000-000000000001",
"runId": "run-2",
"state": {},
"messages": [{"id": "u2", "role": "user", "content": "hello"}],
"tools": [],
"context": [],
"forwardedProps": {},
},
)
assert missing_header.status_code == 401
finally:
agent_router._allow_run_request = original_allow_run # type: ignore[assignment]
agent_router._verified_access_token_for_user = original_verify_token # type: ignore[assignment]
app.dependency_overrides = {}
@@ -390,10 +415,19 @@ def test_resume_accepts_tool_message_without_user_message() -> None:
id=uuid4(), email="user@example.com"
)
client = TestClient(app)
original_verify_token = agent_router._verified_access_token_for_user
def _verify_token(**kwargs: object) -> str:
if kwargs.get("authorization"):
return "token-ok"
raise HTTPException(status_code=401, detail="Unauthorized")
agent_router._verified_access_token_for_user = _verify_token # type: ignore[assignment]
try:
response = client.post(
"/api/v1/agent/runs/00000000-0000-0000-0000-000000000001/resume",
headers={"Authorization": "Bearer token-ok"},
json={
"threadId": "00000000-0000-0000-0000-000000000001",
"runId": "run-resume-1",
@@ -413,7 +447,29 @@ def test_resume_accepts_tool_message_without_user_message() -> None:
)
assert response.status_code == 202
assert response.json()["taskId"] == "task-resume-1"
missing_header = client.post(
"/api/v1/agent/runs/00000000-0000-0000-0000-000000000001/resume",
json={
"threadId": "00000000-0000-0000-0000-000000000001",
"runId": "run-resume-2",
"state": {},
"messages": [
{
"id": "tool-2",
"role": "tool",
"toolCallId": "call-2",
"content": '{"toolName":"navigate_to_route","toolArgs":{"target":"/calendar/dayweek"},"nonce":"n2","result":{"ok":true}}',
}
],
"tools": [],
"context": [],
"forwardedProps": {},
},
)
assert missing_header.status_code == 401
finally:
agent_router._verified_access_token_for_user = original_verify_token # type: ignore[assignment]
app.dependency_overrides = {}
@@ -16,13 +16,9 @@ async def test_calendar_read_returns_list_payload(
) -> None:
async def _fake_execute(**kwargs: Any) -> dict[str, object]:
del kwargs
return {"type": "calendar_event_list.v1", "version": "v1", "data": {}}
return {"type": "calendar_event_list.v1", "version": "v1", "data": {"ok": True}}
monkeypatch.setattr(
calendar_module,
"_execute_list_calendar_events",
_fake_execute,
)
monkeypatch.setattr(calendar_module, "_execute_list_calendar_events", _fake_execute)
monkeypatch.setattr(calendar_module, "_verify_user_token", lambda **_: True)
monkeypatch.setattr(calendar_module, "build_tool_response", lambda payload: payload)
@@ -62,9 +58,7 @@ async def test_calendar_write_maps_event_id_for_update(
return {"type": "calendar_card.v1", "version": "v1", "data": {"ok": True}}
monkeypatch.setattr(
calendar_module,
"_execute_mutate_calendar_event",
_fake_execute,
calendar_module, "_execute_mutate_calendar_event", _fake_execute
)
monkeypatch.setattr(calendar_module, "_verify_user_token", lambda **_: True)
monkeypatch.setattr(calendar_module, "build_tool_response", lambda payload: payload)
@@ -82,57 +76,6 @@ async def test_calendar_write_maps_event_id_for_update(
assert "eventId" in captured
@pytest.mark.asyncio
async def test_calendar_write_requires_preset_user_token(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(calendar_module, "build_tool_response", lambda payload: payload)
monkeypatch.setattr(calendar_module, "_verify_user_token", lambda **_: False)
result = await calendar_module.calendar_write(
session=cast(AsyncSession, SimpleNamespace()),
owner_id=uuid4(),
user_token="bad-token",
operation="create",
)
assert result["data"]["ok"] is False
assert result["data"]["code"] == "UNAUTHORIZED"
@pytest.mark.asyncio
async def test_calendar_write_rejects_missing_event_id_for_update(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(calendar_module, "build_tool_response", lambda payload: payload)
result = await calendar_module.calendar_write(
session=cast(AsyncSession, SimpleNamespace()),
owner_id=uuid4(),
user_token="token-abc",
operation="update",
)
assert result["data"]["ok"] is False
assert result["data"]["code"] == "INVALID_ARGUMENT"
@pytest.mark.asyncio
async def test_calendar_write_rejects_event_id_for_create(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(calendar_module, "build_tool_response", lambda payload: payload)
result = await calendar_module.calendar_write(
session=cast(AsyncSession, SimpleNamespace()),
owner_id=uuid4(),
user_token="token-abc",
operation="create",
event_id=str(uuid4()),
)
assert result["data"]["ok"] is False
assert result["data"]["code"] == "INVALID_ARGUMENT"
@pytest.mark.asyncio
async def test_calendar_write_maps_reminder_minutes(
monkeypatch: pytest.MonkeyPatch,
@@ -144,9 +87,7 @@ async def test_calendar_write_maps_reminder_minutes(
return {"type": "calendar_card.v1", "version": "v1", "data": {"ok": True}}
monkeypatch.setattr(
calendar_module,
"_execute_mutate_calendar_event",
_fake_execute,
calendar_module, "_execute_mutate_calendar_event", _fake_execute
)
monkeypatch.setattr(calendar_module, "_verify_user_token", lambda **_: True)
monkeypatch.setattr(calendar_module, "build_tool_response", lambda payload: payload)
@@ -163,46 +104,54 @@ async def test_calendar_write_maps_reminder_minutes(
@pytest.mark.asyncio
async def test_calendar_write_rejects_invalid_reminder_minutes(
async def test_calendar_write_returns_failed_tool_response_on_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def _fake_execute(**kwargs: Any) -> dict[str, object]:
del kwargs
raise ValueError("eventId is required")
monkeypatch.setattr(
calendar_module, "_execute_mutate_calendar_event", _fake_execute
)
monkeypatch.setattr(calendar_module, "_verify_user_token", lambda **_: True)
monkeypatch.setattr(calendar_module, "build_tool_response", lambda payload: payload)
result = await calendar_module.calendar_write(
session=cast(AsyncSession, SimpleNamespace()),
owner_id=uuid4(),
user_token="token-abc",
operation="create",
reminder_minutes=10081,
operation="update",
)
assert result["type"] == "calendar_operation.v1"
assert result["data"]["ok"] is False
assert result["data"]["code"] == "INVALID_ARGUMENT"
@pytest.mark.asyncio
async def test_calendar_write_maps_invite_arguments(
async def test_calendar_share_maps_arguments(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, object] = {}
async def _fake_execute(**kwargs: Any) -> dict[str, object]:
captured.update(cast(dict[str, object], kwargs["tool_args"]))
return {"type": "calendar_card.v1", "version": "v1", "data": {"ok": True}}
return {
"type": "calendar_operation.v1",
"version": "v1",
"data": {"operation": "share", "ok": True},
}
monkeypatch.setattr(
calendar_module,
"_execute_mutate_calendar_event",
_fake_execute,
)
monkeypatch.setattr(calendar_module, "_execute_share_calendar_event", _fake_execute)
monkeypatch.setattr(calendar_module, "_verify_user_token", lambda **_: True)
monkeypatch.setattr(calendar_module, "build_tool_response", lambda payload: payload)
await calendar_module.calendar_write(
result = await calendar_module.calendar_share(
session=cast(AsyncSession, SimpleNamespace()),
owner_id=uuid4(),
user_token="token-abc",
operation="create",
event_id=str(uuid4()),
invite_user_emails=["a@example.com"],
invite_user_names=["alice"],
invite_user_ids=[str(uuid4())],
@@ -211,6 +160,8 @@ async def test_calendar_write_maps_invite_arguments(
invite_permission_invite=True,
)
assert result["type"] == "calendar_operation.v1"
assert captured["eventId"]
assert captured["inviteUserEmails"] == ["a@example.com"]
assert captured["inviteUserNames"] == ["alice"]
assert isinstance(captured["inviteUserIds"], list)
@@ -220,46 +171,18 @@ async def test_calendar_write_maps_invite_arguments(
@pytest.mark.asyncio
async def test_user_resolve_maps_identity_arguments(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, object] = {}
async def _fake_execute(**kwargs: Any) -> dict[str, object]:
captured.update(cast(dict[str, object], kwargs["tool_args"]))
return {"type": "user_lookup.v1", "version": "v1", "data": {"ok": True}}
monkeypatch.setattr(
calendar_module,
"_execute_resolve_user_identity",
_fake_execute,
)
monkeypatch.setattr(calendar_module, "_verify_user_token", lambda **_: True)
monkeypatch.setattr(calendar_module, "build_tool_response", lambda payload: payload)
result = await calendar_module.user_resolve(
session=cast(AsyncSession, SimpleNamespace()),
owner_id=uuid4(),
user_token="token-abc",
user_email="a@example.com",
)
assert result["type"] == "user_lookup.v1"
assert captured == {"userEmail": "a@example.com", "userName": None}
@pytest.mark.asyncio
async def test_user_resolve_requires_valid_user_token(
async def test_calendar_share_requires_valid_user_token(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(calendar_module, "_verify_user_token", lambda **_: False)
monkeypatch.setattr(calendar_module, "build_tool_response", lambda payload: payload)
result = await calendar_module.user_resolve(
result = await calendar_module.calendar_share(
session=cast(AsyncSession, SimpleNamespace()),
owner_id=uuid4(),
user_token="bad-token",
user_name="alice",
event_id=str(uuid4()),
invite_user_emails=["a@example.com"],
)
assert result["data"]["ok"] is False
@@ -22,7 +22,7 @@ async def test_build_toolkit_registers_calendar_tools() -> None:
names = {item["function"]["name"] for item in schemas}
assert "calendar_read" in names
assert "calendar_write" in names
assert "user_resolve" in names
assert "calendar_share" in names
write_schema = next(
item for item in schemas if item["function"]["name"] == "calendar_write"
@@ -28,6 +28,17 @@ def test_signup_requires_username() -> None:
)
def test_signup_allows_any_invite_code_input() -> None:
request = VerificationCreateRequest(
username="demo",
email="user@example.com",
password="secret123",
invite_code="abc123",
)
assert request.invite_code == "abc123"
def test_signup_verify_requires_six_digit_token() -> None:
with pytest.raises(ValidationError):
VerificationVerifyRequest(email="user@example.com", token="abc123")
@@ -22,10 +22,12 @@ from v1.auth.service import AuthService, AuthServiceGateway
class FakeGateway(AuthServiceGateway):
def __init__(self, response: SessionResponse) -> None:
self._response = response
self.last_create_verification_request: VerificationCreateRequest | None = None
async def create_verification(
self, request: VerificationCreateRequest
) -> VerificationCreateResponse:
self.last_create_verification_request = request
return VerificationCreateResponse(email=request.email)
async def verify_verification(
@@ -121,6 +123,58 @@ async def test_signup_resend_returns_none() -> None:
assert result is None
@pytest.mark.asyncio
async def test_create_verification_ignores_invalid_invite_code() -> None:
user = AuthUser(id="user-1", email="user@example.com")
token_response = SessionResponse(
access_token="access",
refresh_token="refresh",
expires_in=3600,
token_type="bearer",
user=user,
)
gateway = FakeGateway(token_response)
service = AuthService(gateway=gateway)
await service.create_verification(
VerificationCreateRequest(
username="demo",
email="user@example.com",
password="secret123",
invite_code="bad-code",
)
)
assert gateway.last_create_verification_request is not None
assert gateway.last_create_verification_request.invite_code is None
@pytest.mark.asyncio
async def test_create_verification_normalizes_valid_invite_code() -> None:
user = AuthUser(id="user-1", email="user@example.com")
token_response = SessionResponse(
access_token="access",
refresh_token="refresh",
expires_in=3600,
token_type="bearer",
user=user,
)
gateway = FakeGateway(token_response)
service = AuthService(gateway=gateway)
await service.create_verification(
VerificationCreateRequest(
username="demo",
email="user@example.com",
password="secret123",
invite_code="a2b3",
)
)
assert gateway.last_create_verification_request is not None
assert gateway.last_create_verification_request.invite_code == "A2B3"
@pytest.mark.asyncio
async def test_supabase_signup_passes_username_in_metadata(
monkeypatch: pytest.MonkeyPatch,