feat: 添加日历批量操作与客户端时区感知功能,优化前端 UI 交互体验
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
# Calendar Timezone Unification Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Eliminate calendar time mismatches by enforcing one end-to-end timezone policy across App input, Agent runtime context, tool execution, and UTC database storage.
|
||||
|
||||
**Architecture:** Keep database schema unchanged (`start_at/end_at TIMESTAMPTZ + timezone`) and enforce strict runtime normalization. Device timezone is injected from `RunAgentInput.forwardedProps`, resolved into a single `effective_timezone`, then written explicitly into tool arguments and persisted as event timezone while timestamps are stored in UTC. Calendar read responses include deterministic event-timezone-rendered values so frontend rendering is stable and no implicit `toLocal()` conversion remains.
|
||||
|
||||
**Tech Stack:** FastAPI, Pydantic v2, AgentScope runtime/tooling, Flutter (Dart), PostgreSQL TIMESTAMPTZ, pytest, Flutter test.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: Protocol and Backend Runtime Normalization
|
||||
|
||||
### Task 1: Freeze protocol and timezone precedence contract
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/protocols/agent/run-agent-input.md`
|
||||
- Create: `docs/protocols/calendar/timezone-policy.md`
|
||||
|
||||
- [ ] **Step 1: Write protocol delta checklist in docs first**
|
||||
|
||||
Document the exact policy:
|
||||
- `event_timezone > device_timezone > profile.timezone > UTC`
|
||||
- `event_timezone` must be present in final tool call
|
||||
- `start_at/end_at` must be timezone-aware
|
||||
- DB stores UTC timestamps and IANA timezone string
|
||||
|
||||
- [ ] **Step 2: Update RunAgentInput protocol with forwardedProps contract**
|
||||
|
||||
Add canonical payload example:
|
||||
|
||||
```json
|
||||
{
|
||||
"forwardedProps": {
|
||||
"client_time": {
|
||||
"device_timezone": "America/Los_Angeles",
|
||||
"client_now_iso": "2026-03-16T09:12:33-07:00",
|
||||
"client_epoch_ms": 1773658353000
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add calendar timezone policy protocol doc**
|
||||
|
||||
Include:
|
||||
- accepted datetime formats
|
||||
- explicit error codes
|
||||
- write/read response semantics
|
||||
- DST handling rule
|
||||
|
||||
- [ ] **Step 4: Verify docs consistency**
|
||||
|
||||
Run: `cd backend && uv run python -m pytest tests/unit/core/agentscope/test_system_prompt.py -q`
|
||||
Expected: PASS (no protocol-breaking prompt assumptions)
|
||||
|
||||
|
||||
### Task 2: Parse forwarded device time and compute effective timezone
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/src/core/agentscope/schemas/agui_input.py`
|
||||
- Modify: `backend/src/core/agentscope/runtime/runner.py`
|
||||
- Modify: `backend/src/core/agentscope/prompts/system_prompt.py`
|
||||
- Test: `backend/tests/unit/core/agentscope/test_system_prompt.py`
|
||||
|
||||
- [ ] **Step 1: Write failing tests for effective timezone resolution**
|
||||
|
||||
Add tests covering:
|
||||
- forwarded `device_timezone` present -> selected
|
||||
- missing forwarded timezone -> fallback profile timezone
|
||||
- invalid forwarded timezone -> fallback profile timezone
|
||||
|
||||
- [ ] **Step 2: Run tests to confirm RED**
|
||||
|
||||
Run: `cd backend && uv run pytest tests/unit/core/agentscope/test_system_prompt.py -k timezone -v`
|
||||
Expected: FAIL on new assertions
|
||||
|
||||
- [ ] **Step 3: Implement minimal runtime context extraction**
|
||||
|
||||
Implement a typed helper in runner path to read:
|
||||
- `run_input.forwarded_props.client_time.device_timezone`
|
||||
- `client_now_iso`
|
||||
- `client_epoch_ms`
|
||||
|
||||
Compute `effective_timezone` using fixed precedence and pass it into `build_system_prompt(...)`.
|
||||
|
||||
- [ ] **Step 4: Inject effective_timezone into ENV section**
|
||||
|
||||
Update `build_system_prompt` env payload to include:
|
||||
- `timezone_profile`
|
||||
- `timezone_device`
|
||||
- `timezone_effective`
|
||||
|
||||
Update guidance sentence to resolve ambiguous time with `timezone_effective`.
|
||||
|
||||
- [ ] **Step 5: Run tests to confirm GREEN**
|
||||
|
||||
Run: `cd backend && uv run pytest tests/unit/core/agentscope/test_system_prompt.py -v`
|
||||
Expected: PASS
|
||||
|
||||
|
||||
### Task 3: Remove timezone ambiguity and hidden fallbacks from calendar write
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/src/core/agentscope/tools/utils/calendar_domain.py`
|
||||
- Modify: `backend/src/core/agentscope/tools/custom/calendar.py`
|
||||
- Modify: `backend/src/v1/schedule_items/schemas.py`
|
||||
- Modify: `backend/src/v1/schedule_items/service.py`
|
||||
- Test: `backend/tests/unit/core/agentscope/test_calendar_tools.py`
|
||||
- Test: `backend/tests/unit/v1/schedule_items/test_schemas.py`
|
||||
- Test: `backend/tests/unit/v1/schedule_items/test_service.py`
|
||||
|
||||
- [ ] **Step 1: Write failing tests for forbidden naive datetime and required timezone**
|
||||
|
||||
Add tests for:
|
||||
- naive `start_at` rejected
|
||||
- missing `event_timezone` rejected in tool path
|
||||
- parse failure does not fallback to `now + 1h`
|
||||
|
||||
- [ ] **Step 2: Run tests to confirm RED**
|
||||
|
||||
Run: `cd backend && uv run pytest tests/unit/core/agentscope/test_calendar_tools.py tests/unit/v1/schedule_items/test_schemas.py -v`
|
||||
Expected: FAIL on new constraints
|
||||
|
||||
- [ ] **Step 3: Implement strict parsing and normalization**
|
||||
|
||||
Implementation requirements:
|
||||
- `parse_iso_datetime` rejects naive input
|
||||
- remove default `Asia/Shanghai` in tool
|
||||
- remove fallback auto-generated start time
|
||||
- validate IANA timezone and normalize `start_at/end_at` to UTC before persistence
|
||||
|
||||
- [ ] **Step 4: Enforce service-level invariants**
|
||||
|
||||
Service invariant set:
|
||||
- timezone non-empty and valid IANA
|
||||
- `end_at is None or end_at >= start_at`
|
||||
|
||||
- [ ] **Step 5: Run backend tests**
|
||||
|
||||
Run: `cd backend && uv run pytest tests/unit/core/agentscope/test_calendar_tools.py tests/unit/v1/schedule_items/test_schemas.py tests/unit/v1/schedule_items/test_service.py tests/integration/test_schedule_items_routes.py -v`
|
||||
Expected: PASS
|
||||
|
||||
|
||||
### Task 4: Keep DB schema, add non-breaking constraint migration only
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/alembic/versions/20260316_000x_schedule_items_time_constraints.py`
|
||||
- Test: `backend/tests/integration/test_schedule_items_routes.py`
|
||||
|
||||
- [ ] **Step 1: Write migration test expectation first**
|
||||
|
||||
Add/extend integration assertion for invalid `end_at < start_at` returning 422.
|
||||
|
||||
- [ ] **Step 2: Run integration test to confirm RED**
|
||||
|
||||
Run: `cd backend && uv run pytest tests/integration/test_schedule_items_routes.py -k end_at -v`
|
||||
Expected: FAIL
|
||||
|
||||
- [ ] **Step 3: Implement migration with CHECK only (no new columns)**
|
||||
|
||||
Migration includes:
|
||||
- `CHECK (end_at IS NULL OR end_at >= start_at)`
|
||||
|
||||
- [ ] **Step 4: Run migration + integration test**
|
||||
|
||||
Run: `cd backend && uv run alembic upgrade head && uv run pytest tests/integration/test_schedule_items_routes.py -v`
|
||||
Expected: PASS
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: Frontend Deterministic Display and Agent Input Wiring
|
||||
|
||||
### Task 5: Wire device timezone into RunAgentInput forwardedProps
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/lib/features/chat/data/services/ag_ui_service.dart`
|
||||
- Modify: `apps/lib/features/chat/data/models/ag_ui_event.dart` (only if serialization helper is needed)
|
||||
- Test: `apps/test/features/chat/ag_ui_event_test.dart`
|
||||
|
||||
- [ ] **Step 1: Write failing test for forwarded client_time payload**
|
||||
|
||||
Assert outgoing run request contains:
|
||||
- `forwardedProps.client_time.device_timezone`
|
||||
- `client_now_iso`
|
||||
- `client_epoch_ms`
|
||||
|
||||
- [ ] **Step 2: Run test to confirm RED**
|
||||
|
||||
Run: `cd apps && flutter test test/features/chat/ag_ui_event_test.dart`
|
||||
Expected: FAIL
|
||||
|
||||
- [ ] **Step 3: Implement payload injection in one place**
|
||||
|
||||
Add a single helper to build client time context and attach it to run input requests.
|
||||
|
||||
- [ ] **Step 4: Run test to confirm GREEN**
|
||||
|
||||
Run: `cd apps && flutter test test/features/chat/ag_ui_event_test.dart`
|
||||
Expected: PASS
|
||||
|
||||
|
||||
### Task 6: Remove implicit local-time rendering and render by event timezone
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/lib/features/calendar/data/models/schedule_item_model.dart`
|
||||
- Modify: `apps/lib/features/calendar/ui/screens/calendar_event_detail_screen.dart`
|
||||
- Modify: `apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart`
|
||||
- Modify: `apps/lib/features/calendar/ui/screens/calendar_month_screen.dart`
|
||||
- Modify: `apps/lib/features/messages/ui/widgets/calendar_message_card.dart`
|
||||
- Modify: `apps/lib/features/calendar/ui/widgets/create_event_sheet.dart`
|
||||
- Test: `apps/test/features/calendar/ui/calendar_time_utils_test.dart`
|
||||
- Test: `apps/test/features/calendar/ui/create_event_sheet_time_align_test.dart`
|
||||
|
||||
- [ ] **Step 1: Write failing tests for timezone-specific rendering**
|
||||
|
||||
Cover cases:
|
||||
- same UTC event shows different local clock time under different `event.timezone`
|
||||
- list/day/week/month are consistent for one event
|
||||
- create sheet sends explicit timezone in payload
|
||||
|
||||
- [ ] **Step 2: Run tests to confirm RED**
|
||||
|
||||
Run: `cd apps && flutter test test/features/calendar/ui/calendar_time_utils_test.dart test/features/calendar/ui/create_event_sheet_time_align_test.dart`
|
||||
Expected: FAIL
|
||||
|
||||
- [ ] **Step 3: Implement deterministic time conversion utility**
|
||||
|
||||
Implement one utility used by all calendar UI surfaces:
|
||||
- input: UTC datetime + IANA timezone
|
||||
- output: event-local datetime
|
||||
|
||||
Replace direct `.toLocal()` usage in calendar model/view with this utility.
|
||||
|
||||
- [ ] **Step 4: Enforce explicit timezone on create/update payload**
|
||||
|
||||
Create/update must always include `timezone` field from selected event timezone.
|
||||
|
||||
- [ ] **Step 5: Run Flutter tests**
|
||||
|
||||
Run: `cd apps && flutter test test/features/calendar/ui/calendar_time_utils_test.dart test/features/calendar/ui/create_event_sheet_time_align_test.dart`
|
||||
Expected: PASS
|
||||
|
||||
|
||||
### Task 7: End-to-end verification matrix and release checklist
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/plans/timezone-e2e-checklist.md`
|
||||
- Test: `backend/tests/integration/test_schedule_items_routes.py`
|
||||
- Test: `apps/test/features/calendar/ui/create_event_sheet_time_align_test.dart`
|
||||
|
||||
- [ ] **Step 1: Add reproducible matrix**
|
||||
|
||||
Matrix axes:
|
||||
- device timezone: `America/Los_Angeles`, `Asia/Shanghai`
|
||||
- profile timezone: `Asia/Shanghai`, `Europe/Paris`
|
||||
- explicit event timezone: `Asia/Tokyo`
|
||||
|
||||
- [ ] **Step 2: Run backend + frontend verification commands**
|
||||
|
||||
Run:
|
||||
- `cd backend && uv run pytest tests/unit/core/agentscope/test_system_prompt.py tests/unit/core/agentscope/test_calendar_tools.py tests/integration/test_schedule_items_routes.py -v`
|
||||
- `cd apps && flutter test test/features/chat/ag_ui_event_test.dart test/features/calendar/ui/calendar_time_utils_test.dart test/features/calendar/ui/create_event_sheet_time_align_test.dart`
|
||||
|
||||
Expected: all PASS
|
||||
|
||||
- [ ] **Step 3: Manual scenario check**
|
||||
|
||||
Manual script:
|
||||
1. device timezone set to Los Angeles
|
||||
2. profile timezone set to Shanghai
|
||||
3. ask agent create "明天上午9点开会"
|
||||
4. verify assistant text, tool card, DB UTC value, and calendar detail all align to chosen event timezone semantics
|
||||
|
||||
- [ ] **Step 4: Capture release notes**
|
||||
|
||||
Record:
|
||||
- removed hidden timezone defaults
|
||||
- deterministic precedence
|
||||
- no schema expansion
|
||||
|
||||
---
|
||||
|
||||
Plan complete and saved to `docs/superpowers/plans/2026-03-16-calendar-timezone-unification.md`. Ready to execute?
|
||||
@@ -185,6 +185,44 @@ interface Context {
|
||||
|
||||
---
|
||||
|
||||
## forwardedProps.client_time Schema
|
||||
|
||||
`RunAgentInput.forwardedProps` 支持透传客户端时间上下文。日历相关能力必须使用以下结构:
|
||||
|
||||
```typescript
|
||||
interface ForwardedProps {
|
||||
client_time?: {
|
||||
device_timezone: string; // IANA 时区,例如 "America/Los_Angeles"
|
||||
client_now_iso: string; // RFC3339 带偏移时间,例如 "2026-03-16T09:12:33-07:00"
|
||||
client_epoch_ms: number; // Unix epoch 毫秒
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 时间来源优先级(固定)
|
||||
|
||||
后端在运行时按以下顺序解析事件时区:
|
||||
|
||||
1. `event_timezone`(工具调用显式传参)
|
||||
2. `forwardedProps.client_time.device_timezone`
|
||||
3. `users.profile.settings.timezone`
|
||||
4. `UTC`
|
||||
|
||||
### 约束
|
||||
|
||||
- `device_timezone` 必须是有效 IANA 时区。
|
||||
- `client_now_iso` 必须是 RFC3339 且包含时区偏移。
|
||||
- `client_epoch_ms` 必须是整数毫秒时间戳。
|
||||
- 业务代码不得使用服务器本地时区作为事件语义时区。
|
||||
|
||||
### 说明
|
||||
|
||||
- `forwardedProps` 是透传字段,不改变 AG-UI 主体协议结构。
|
||||
- 当 `forwardedProps.client_time` 缺失或非法时,运行时回退到 `users.profile.settings.timezone`。
|
||||
- 日历写入必须在最终工具调用中带上 `event_timezone`,不得依赖工具默认值。
|
||||
|
||||
---
|
||||
|
||||
## Validation Rules
|
||||
|
||||
Backend 实现了以下验证规则:
|
||||
@@ -203,6 +241,16 @@ Backend 实现了以下验证规则:
|
||||
| binary 不允许使用 data | `binary content data is not allowed` |
|
||||
| 单条消息最多 3 张附件 | `Too many attachments` |
|
||||
|
||||
### forwardedProps.client_time Validation
|
||||
|
||||
建议在后端校验层返回以下错误(按业务实现映射到 4xx):
|
||||
|
||||
| Rule | Error Message |
|
||||
|------|---------------|
|
||||
| `device_timezone` 非 IANA 时区 | `invalid client_time.device_timezone` |
|
||||
| `client_now_iso` 无法解析或缺少时区 | `invalid client_time.client_now_iso` |
|
||||
| `client_epoch_ms` 非整数毫秒值 | `invalid client_time.client_epoch_ms` |
|
||||
|
||||
---
|
||||
|
||||
## Request Example
|
||||
@@ -292,6 +340,32 @@ Backend 实现了以下验证规则:
|
||||
}
|
||||
```
|
||||
|
||||
### 带 forwardedProps.client_time 的请求
|
||||
|
||||
```json
|
||||
{
|
||||
"threadId": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"runId": "run-004",
|
||||
"state": {},
|
||||
"messages": [
|
||||
{
|
||||
"id": "msg-001",
|
||||
"role": "user",
|
||||
"content": "帮我明天早上9点创建一个日历"
|
||||
}
|
||||
],
|
||||
"tools": [],
|
||||
"context": [],
|
||||
"forwardedProps": {
|
||||
"client_time": {
|
||||
"device_timezone": "America/Los_Angeles",
|
||||
"client_now_iso": "2026-03-16T09:12:33-07:00",
|
||||
"client_epoch_ms": 1773658353000
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Response
|
||||
@@ -454,3 +528,4 @@ interface UiSchemaRenderer {
|
||||
- backend 验证通过后,会将 binary url 转换为内部存储路径
|
||||
- `tools` 是前端工具通道字段;当前后端运行时不基于该字段构造后端工具 prompt
|
||||
- `RunAgentInput` 同时接受 camelCase 与 snake_case 别名输入(推荐统一使用 camelCase)
|
||||
- 日历能力依赖 `forwardedProps.client_time` 透传设备时间上下文;缺失时回退用户 profile 时区
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# Calendar Timezone Policy Protocol
|
||||
|
||||
## Version
|
||||
|
||||
- Current: `1.0`
|
||||
- Status: Active
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
统一日历事件在 App、Agent、工具、数据库之间的时间语义,消除时区不一致导致的显示和落库偏差。
|
||||
|
||||
---
|
||||
|
||||
## Canonical Rules
|
||||
|
||||
1. 数据库存储基准为 UTC。
|
||||
2. 事件语义时区使用 IANA 时区字符串(`event_timezone` / `timezone`)。
|
||||
3. 禁止无时区时间(naive datetime)进入日历写入链路。
|
||||
4. 日历写入必须显式确定事件时区,不允许工具层硬编码默认时区。
|
||||
|
||||
---
|
||||
|
||||
## Timezone Resolution Priority
|
||||
|
||||
运行时事件时区解析顺序固定如下:
|
||||
|
||||
1. `event_timezone`(工具调用显式传参)
|
||||
2. `forwardedProps.client_time.device_timezone`
|
||||
3. `users.profile.settings.timezone`
|
||||
4. `UTC`
|
||||
|
||||
---
|
||||
|
||||
## Write Contract
|
||||
|
||||
### Required fields
|
||||
|
||||
- `start_at`: RFC3339 且必须包含时区偏移
|
||||
- `timezone`: IANA 时区
|
||||
|
||||
### Optional fields
|
||||
|
||||
- `end_at`: RFC3339 且必须包含时区偏移(若提供)
|
||||
|
||||
### Validation
|
||||
|
||||
- `timezone` 非法 -> 拒绝请求
|
||||
- `start_at`/`end_at` 无时区 -> 拒绝请求
|
||||
- `end_at < start_at` -> 拒绝请求
|
||||
|
||||
---
|
||||
|
||||
## Read Contract
|
||||
|
||||
读接口最小语义要求:
|
||||
|
||||
- 返回 UTC 时间字段(`start_at`, `end_at`)
|
||||
- 返回事件时区字段(`timezone`)
|
||||
- 前端展示必须以 `timezone` 作为事件本地时间转换基准,不允许直接按设备本地时区隐式渲染
|
||||
|
||||
---
|
||||
|
||||
## Error Codes
|
||||
|
||||
推荐错误码(由后端映射为 4xx):
|
||||
|
||||
- `INVALID_DATETIME_FORMAT`
|
||||
- `NAIVE_DATETIME_FORBIDDEN`
|
||||
- `INVALID_TIMEZONE`
|
||||
- `TIMEZONE_REQUIRED`
|
||||
- `INVALID_TIME_RANGE`
|
||||
|
||||
---
|
||||
|
||||
## DST Rule
|
||||
|
||||
- 夏令时切换期间,时间解释以 IANA 时区数据库为准。
|
||||
- 对于歧义本地时间(例如回拨重复小时),由后端统一按标准库解析策略处理并返回确定 UTC 结果。
|
||||
|
||||
---
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- 本协议不引入新的数据库时间列。
|
||||
- 本协议不改变 `schedule_items` 现有 `TIMESTAMPTZ + timezone` 存储结构。
|
||||
Reference in New Issue
Block a user