Merge branch 'test/calendar-ui-manual-testing' — 修复 agent 创建日历事件后 UI 不刷新 + 三根因文档三部曲 + E2E 测试套件
This commit is contained in:
commit
f476d3339c
|
|
@ -0,0 +1,171 @@
|
|||
name: Test
|
||||
|
||||
# 触发条件:推送到主干/开发分支 或 PR
|
||||
on:
|
||||
push:
|
||||
branches: [main, master, develop]
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
|
||||
jobs:
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Backend: lint + unit tests
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
backend-test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -e ".[dev]"
|
||||
|
||||
- name: Ruff lint
|
||||
run: ruff check src/
|
||||
|
||||
- name: Ruff format check
|
||||
run: ruff format --check src/
|
||||
|
||||
- name: Unit tests
|
||||
run: pytest tests/unit/ -x -q --tb=short
|
||||
env:
|
||||
AGENTKIT_JWT_SECRET: ci-test-secret-do-not-use-in-prod
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Frontend: typecheck + unit tests
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
frontend-unit:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/agentkit/server/frontend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Unit tests
|
||||
run: npm run test:unit
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Backend API E2E (needs Redis + PostgreSQL)
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
api-e2e:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- 6381:6379
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 5s
|
||||
--health-timeout 3s
|
||||
--health-retries 5
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg15
|
||||
env:
|
||||
POSTGRES_USER: agentkit_test
|
||||
POSTGRES_PASSWORD: agentkit_test_pw
|
||||
POSTGRES_DB: agentkit_test
|
||||
ports:
|
||||
- 5434:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U agentkit_test -d agentkit_test"
|
||||
--health-interval 5s
|
||||
--health-timeout 3s
|
||||
--health-retries 5
|
||||
env:
|
||||
AGENTKIT_JWT_SECRET: ci-test-secret-do-not-use-in-prod
|
||||
AGENTKIT_REDIS_URL: redis://localhost:6381/0
|
||||
AGENTKIT_DATABASE_URL: postgresql+asyncpg://agentkit_test:agentkit_test_pw@localhost:5434/agentkit_test
|
||||
AGENTKIT_API_KEY: ci-test-api-key
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -e ".[dev]"
|
||||
|
||||
- name: API E2E tests
|
||||
run: pytest tests/e2e/test_api_coverage.py -v --tb=short
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Frontend E2E (Playwright — needs backend + frontend + Chromium)
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
frontend-e2e:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/agentkit/server/frontend
|
||||
env:
|
||||
CI: 'true'
|
||||
AGENTKIT_JWT_SECRET: ci-test-secret-do-not-use-in-prod
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install backend dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -e ".[dev]"
|
||||
working-directory: ${{ github.workspace }}
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run E2E tests
|
||||
run: npx playwright test --reporter=list
|
||||
|
||||
- name: Upload test results
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report
|
||||
path: src/agentkit/server/frontend/playwright-report/
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload test artifacts
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: test-results
|
||||
path: src/agentkit/server/frontend/test-results/
|
||||
retention-days: 7
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
name: Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Backend: unit tests + lint
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
backend-test:
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
cache: pip
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -e ".[dev]"
|
||||
|
||||
- name: Ruff lint
|
||||
run: ruff check src/
|
||||
|
||||
- name: Ruff format check
|
||||
run: ruff format --check src/
|
||||
|
||||
- name: Unit tests
|
||||
run: pytest tests/unit/ -x -q --tb=short
|
||||
env:
|
||||
AGENTKIT_JWT_SECRET: ci-test-secret-do-not-use-in-prod
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Frontend: unit tests + typecheck
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
frontend-unit:
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 10
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/agentkit/server/frontend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: src/agentkit/server/frontend/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Unit tests
|
||||
run: npm run test:unit
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Backend API E2E (needs Redis + PostgreSQL)
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
api-e2e:
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 20
|
||||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- 6381:6379
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 5s
|
||||
--health-timeout 3s
|
||||
--health-retries 5
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg15
|
||||
env:
|
||||
POSTGRES_USER: agentkit_test
|
||||
POSTGRES_PASSWORD: agentkit_test_pw
|
||||
POSTGRES_DB: agentkit_test
|
||||
ports:
|
||||
- 5434:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U agentkit_test -d agentkit_test"
|
||||
--health-interval 5s
|
||||
--health-timeout 3s
|
||||
--health-retries 5
|
||||
env:
|
||||
AGENTKIT_JWT_SECRET: ci-test-secret-do-not-use-in-prod
|
||||
AGENTKIT_REDIS_URL: redis://localhost:6381/0
|
||||
AGENTKIT_DATABASE_URL: postgresql+asyncpg://agentkit_test:agentkit_test_pw@localhost:5434/agentkit_test
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
cache: pip
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -e ".[dev]"
|
||||
|
||||
- name: API E2E tests
|
||||
run: pytest tests/e2e/test_api_coverage.py -v --tb=short
|
||||
env:
|
||||
AGENTKIT_API_KEY: ci-test-api-key
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Frontend E2E (Playwright — needs backend + frontend + Chrome)
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
frontend-e2e:
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 30
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/agentkit/server/frontend
|
||||
env:
|
||||
CI: 'true'
|
||||
AGENTKIT_JWT_SECRET: ci-test-secret-do-not-use-in-prod
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
cache: pip
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: src/agentkit/server/frontend/package-lock.json
|
||||
|
||||
- name: Install backend dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -e ".[dev]"
|
||||
working-directory: ${{ github.workspace }}
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run E2E tests
|
||||
run: npx playwright test --reporter=list
|
||||
|
||||
- name: Upload test results
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report
|
||||
path: src/agentkit/server/frontend/playwright-report/
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload test artifacts
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: test-results
|
||||
path: src/agentkit/server/frontend/test-results/
|
||||
retention-days: 7
|
||||
14
AGENTS.md
14
AGENTS.md
|
|
@ -128,7 +128,7 @@ HandoffTransport:InProcess(asyncio.Queue)+ Redis Pub/Sub — 仅用于事
|
|||
|
||||
### 关键子系统
|
||||
|
||||
- **LLM 网关**(`llm/`):6 个 provider(OpenAI/Anthropic/Gemini/Doubao/Wenxin/Yuanbao)、fallback、语义缓存、用量追踪、RemoteLLMProvider(client→server 代理,带 401 刷新重试)
|
||||
- **LLM 网关**(`llm/`):基于 LiteLLM 统一适配层(U15 迁移),保留原 6 个 provider 命名作为别名(OpenAI/Anthropic/Gemini/Doubao/Wenxin/Yuanbao),支持任意 OpenAI 兼容 API(如 DashScope/DeepSeek);含 fallback、语义缓存、用量追踪、RemoteLLMProvider(client→server 代理,带 401 刷新重试)
|
||||
- **记忆**(`memory/`):4 层(SOUL/USER/MEMORY/DAILY)、WorkingMemory(Redis)、EpisodicMemory(PG+pgvector)、SemanticMemory(HTTP RAG)
|
||||
- **进化**(`evolution/`):Reflector、PromptOptimizer(遗传算法)、PitfallDetector、ABTester
|
||||
- **工具**(`tools/`):21 个内置 + MCP 扩展,组合(SequentialChain/ParallelFanOut/DynamicSelector)
|
||||
|
|
@ -136,7 +136,7 @@ HandoffTransport:InProcess(asyncio.Queue)+ Redis Pub/Sub — 仅用于事
|
|||
- **总线**(`bus/`):MemoryBus(进程内)、RedisBus(分布式)
|
||||
- **认证**(`server/auth/`):JWT(access 15min + refresh 7d,HS256)、API Key(恒定时间比较)、3 级 RBAC(member/operator/admin + 权限位)、6 层终端安全(blocklist→shell-ops→builtin→global→user→session→danger)、bcrypt 密码哈希(rounds=12)
|
||||
|
||||
### 服务端路由(22 个模块)
|
||||
### 服务端路由(28 个模块)
|
||||
|
||||
| 前缀 | 模块 | 用途 |
|
||||
| ------------------------- | -------------------------------------- | ------------------------- |
|
||||
|
|
@ -162,6 +162,12 @@ HandoffTransport:InProcess(asyncio.Queue)+ Redis Pub/Sub — 仅用于事
|
|||
| `/api/v1/auth` | auth.py | 登录/刷新/登出/me |
|
||||
| `/api/v1/system` | system.py | 系统资源(需 SYSTEM\_CONFIG 权限) |
|
||||
| `/api/v1/config` | config\_sync.py | 配置版本 + 同步(轮询) |
|
||||
| `/api/v1/bitable` | bitable.py | 多维表格 companion 服务 |
|
||||
| `/api/v1/calendar` | calendar.py | 日历服务(事件/提醒/同步) |
|
||||
| `/api/v1/documents` | documents.py | 文档管理 |
|
||||
| `/api/v1/admin` | admin.py | 管理员操作 |
|
||||
| `/api/v1/experts` | experts.py | 专家团队管理 |
|
||||
| `/api/v1/mcp` | mcp\_publish.py | MCP 发布 |
|
||||
|
||||
### WebSocket Chat 协议
|
||||
|
||||
|
|
@ -182,7 +188,7 @@ Server -> Client:`connected`、`token`、`thinking`、`step`、`final_answer`
|
|||
|
||||
CLI 参数 > `agentkit.yaml` > 环境变量(`${VAR:-default}`)> `.env` > 硬编码默认值
|
||||
|
||||
配置查找:`--config` 路径 > `./agentkit.yaml` > `~/.agentkit/agentkit.yaml`
|
||||
配置查找:`--config` 路径 > `./agentkit.yaml` > `~/.agentkit/agentkit.yaml`(三个路径都不存在时使用硬编码默认值,CLI 仍可启动)
|
||||
|
||||
## 约定
|
||||
|
||||
|
|
@ -190,7 +196,7 @@ CLI 参数 > `agentkit.yaml` > 环境变量(`${VAR:-default}`)> `.env` > 硬
|
|||
- 技能分类:`agent_template`(执行引擎:react/direct/rewoo/reflexion/plan\_exec/goal\_driven)vs `business_skill`(领域技能)。通过 `server/routes/skill_management.py` 中的 `_ENGINE_TEMPLATE_NAMES` 分类。前端按 `category` 字段分组 — `SkillsView` 双栏布局,`SkillCard`/`SkillsTab` 显示类型标签(引擎/技能)和基于分类的图标
|
||||
- LLM 配置:`agentkit.yaml` llm 段(与服务端配置统一)
|
||||
- 流水线配置:`configs/pipelines/*.yaml`
|
||||
- 专家模板:`configs/experts/*.yaml`(5 个编程专家 + dev\_team 团队模板),通过 `ExpertTemplateRegistry` 注册
|
||||
- 专家模板:`configs/experts/*.yaml`(15 个模板 — 5 编程专家 + 9 商业/思想领袖 + 1 团队模板 dev\_team),通过 `ExpertTemplateRegistry` 注册
|
||||
- 团队模板:`bound_skills` 字段存储成员列表(如 `dev_team.yaml` 列出 tech\_lead、frontend\_engineer、backend\_engineer、qa\_engineer、code\_reviewer)
|
||||
- 所有 Pydantic 模型使用 `model_config = ConfigDict(...)` 而非 `class Config`
|
||||
- 测试文件:`tests/unit/` 和 `tests/integration/`
|
||||
|
|
|
|||
10
CONCEPTS.md
10
CONCEPTS.md
|
|
@ -40,3 +40,13 @@ The timestamp validation layered on top of webhook signature verification that b
|
|||
|
||||
### Webhook Backpressure
|
||||
The pattern of bounding a webhook handler's in-flight background task set with a cap (typically `max_concurrent * 2`) and returning HTTP 429 when exceeded. The 2x margin absorbs short spikes; the 429 forces clients to back off rather than snowballing memory and coroutine exhaustion. The task set is also awaited on app shutdown so in-flight replies are not dropped.
|
||||
|
||||
## Auth
|
||||
|
||||
### 3-State Startup
|
||||
The auth store's cold-start state machine with values `valid` / `invalid` / `error` (plus the initial `pending`). On app boot, `beginStartup()` runs before `app.mount()`; the router guard `await`s `waitForStartup()` when state is `pending` so no routing decision is made until the probe completes. `invalid` redirects to `/login`; `error` lets navigation proceed (retryable, refresh token retained); `valid` proceeds normally. The distinction matters because a network failure (`error`) must not force a re-login, while a revoked token (`invalid`) must.
|
||||
|
||||
## Real-Time Fan-Out
|
||||
|
||||
### Service Broadcast Callback
|
||||
The convention for pushing backend state changes to the user's open frontend tabs in real time without coupling domain services to the WebSocket transport. A service accepts an optional async callback at construction; after a successful mutation it best-effort invokes the callback with a typed message envelope. Delivery failure is logged but never rolls back the mutation — the persisted state is the source of truth, the broadcast is a latency optimization. The callback is wired at the composition root (app lifespan) to the portal's per-user fan-out primitive, so the service stays layer-pure. The same callback shape is shared by CRUD services, reminder dispatchers, and sync providers, giving all real-time updates a single exit point.
|
||||
|
|
|
|||
105
README.md
105
README.md
|
|
@ -42,7 +42,7 @@ Skill = SkillConfig + 绑定 Tools。一个 Skill 代表一个可执行技能,
|
|||
|
||||
### 4. 意图路由
|
||||
|
||||
两级路由:Level 1 关键词匹配(零成本,~0ms),Level 2 LLM 分类(回退方案,~200 tokens)。自动将用户输入路由到最佳匹配的 Skill。
|
||||
请求预处理(`RequestPreprocessor`,详见第 7 节)按前缀分流:`@skill:xxx` 显式选技能、琐碎输入走 `DIRECT_CHAT`、其余走 `REACT`。旧的 3 层 `CostAwareRouter`(含 `RegexRules` / `HeuristicClassifier` / `SemanticRouter` / `Vickrey Auction`)已被 `RequestPreprocessor` 替换;`IntentRouter`(`router/intent.py`)存在但未接入 chat 流程。
|
||||
|
||||
### 5. 记忆系统
|
||||
|
||||
|
|
@ -70,28 +70,21 @@ Skill = SkillConfig + 绑定 Tools。一个 Skill 代表一个可执行技能,
|
|||
- **Prompt 优化** -- 遗传算法 + A/B 测试自动优化 Prompt
|
||||
- **路径优化** -- 分析工具调用路径,推荐更优执行策略
|
||||
|
||||
### 7. 三层意图路由
|
||||
### 7. 请求预处理(RequestPreprocessor)
|
||||
|
||||
CostAwareRouter 三层路由,从零成本到高成本逐层升级:
|
||||
`RequestPreprocessor`(`chat/request_preprocessor.py`)按前缀分流,零成本路由:
|
||||
|
||||
| Layer | 方法 | 延迟 | Token 消耗 | 说明 |
|
||||
|-------|------|------|-----------|------|
|
||||
| 0 | 正则规则 | ~0ms | 0 | 问候/简单对话/@team/@skill 前缀直接回复 |
|
||||
| 1 | 启发式分类 | ~0ms | 0 | 关键词 + 模式匹配 + 复杂度评估 |
|
||||
| 1.5 | 语义路由 | ~0ms | 0 | 向量相似度匹配(可选) |
|
||||
| 2 | LLM 分类 | ~500ms | ~200 | 回退方案,LLM 判断意图 |
|
||||
| Layer | 触发条件 | 延迟 | Token | 路由结果 |
|
||||
|-------|---------|------|-------|---------|
|
||||
| 0 | `@skill:xxx` 前缀 | ~0ms | 0 | 显式技能选择(`SKILL_REACT` 或技能配置的模式) |
|
||||
| 1 | 琐碎输入正则(问候/身份/事实/数学/翻译) | ~0ms | 0 | `DIRECT_CHAT`(由 `_TOOL_CONTEXT_RE` 守护) |
|
||||
| 默认 | 其他输入 | ~0ms | 0 | `REACT`(LLM 在 agent 循环中自主决定工具使用) |
|
||||
|
||||
路由结果携带 `ExecutionMode` 枚举(`DIRECT_CHAT` / `REACT` / `SKILL_REACT` / `TEAM_COLLAB`),作为路由层与执行层的架构契约,杜绝硬编码。
|
||||
`@board` 前缀走 `BoardRouter`(多轮讨论),`@team` 前缀走 `ExpertTeamRouter`(流水线协作),均在 `RequestPreprocessor` 之前分流。
|
||||
|
||||
### 8. 语义路由
|
||||
路由结果携带 `ExecutionMode` 枚举(`DIRECT_CHAT` / `REACT` / `SKILL_REACT` / `REWOO` / `REFLEXION` / `PLAN_EXEC` / `TEAM_COLLAB`),作为路由层与执行层的架构契约,杜绝硬编码。
|
||||
|
||||
基于向量相似度的意图路由,作为关键词匹配的补充:
|
||||
|
||||
- **SemanticRouter** -- 将用户输入和 Skill 描述向量化,通过余弦相似度匹配
|
||||
- **缓存友好** -- 向量缓存避免重复计算
|
||||
- **平滑降级** -- 语义路由失败时自动回退到启发式/LLM 分类
|
||||
|
||||
### 9. LLM 响应缓存
|
||||
### 8. LLM 响应缓存
|
||||
|
||||
语义相似度缓存,减少重复 LLM 调用:
|
||||
|
||||
|
|
@ -99,7 +92,7 @@ CostAwareRouter 三层路由,从零成本到高成本逐层升级:
|
|||
- **语义匹配** -- 相似 prompt 命中缓存,避免重复调用
|
||||
- **TTL 管理** -- 缓存条目自动过期,支持手动失效
|
||||
|
||||
### 10. 级联检测与状态持久化
|
||||
### 9. 级联检测与状态持久化
|
||||
|
||||
生产级故障防护:
|
||||
|
||||
|
|
@ -108,15 +101,15 @@ CostAwareRouter 三层路由,从零成本到高成本逐层升级:
|
|||
- **session_ttl** -- 可配置的会话 TTL,自动清理过期状态
|
||||
- **优雅降级** -- Redis 不可用时自动降级到 InMemory,保持服务可用
|
||||
|
||||
### 11. 产出质量管理
|
||||
### 10. 产出质量管理
|
||||
|
||||
四维质量检查:必填字段、最低字数、JSON Schema 校验、自定义验证器。检查不通过时自动重试(可配置 max_retries),重试时携带质量反馈信息。
|
||||
|
||||
### 12. 标准化输出
|
||||
### 11. 标准化输出
|
||||
|
||||
Schema 验证 + 字段类型归一化(str -> int/float/bool)+ 元数据附加(version、produced_at、quality_score)。所有 Skill 产出统一为 StandardOutput 格式。
|
||||
|
||||
### 13. 内置工具集
|
||||
### 12. 内置工具集
|
||||
|
||||
开箱即用的工具插件,覆盖常见 Agent 需求:
|
||||
|
||||
|
|
@ -136,7 +129,7 @@ Schema 验证 + 字段类型归一化(str -> int/float/bool)+ 元数据附
|
|||
|
||||
工具组合:`SequentialChain`(顺序链)、`ParallelFanOut`(并行扇出)、`DynamicSelector`(动态选择)。
|
||||
|
||||
### 14. Pipeline 编排
|
||||
### 13. Pipeline 编排
|
||||
|
||||
多 Agent 协同编排,支持复杂工作流:
|
||||
|
||||
|
|
@ -146,7 +139,7 @@ Schema 验证 + 字段类型归一化(str -> int/float/bool)+ 元数据附
|
|||
- **PipelineReflector** -- 执行反思与重规划
|
||||
- **HandoffManager** -- Agent 间任务移交
|
||||
|
||||
### 15. Expert Team Mode
|
||||
### 14. Expert Team Mode
|
||||
|
||||
多专家协作执行复杂任务,B+C 混合模式(结构化协作计划 + 去中心化执行),前端以多角色对话流呈现:
|
||||
|
||||
|
|
@ -217,7 +210,7 @@ result = await orchestrator.execute_plan(plan)
|
|||
|
||||
用户也可在聊天中通过 `@team:researcher,writer,reviewer 任务描述` 前缀触发团队模式。
|
||||
|
||||
### 16. 企业级客户端-服务端架构
|
||||
### 15. 企业级客户端-服务端架构
|
||||
|
||||
将 AgentKit 从纯本地运行架构演进为企业级客户端+服务端架构。客户端(Tauri 桌面端)作为 AI 工作台本地执行 Agent/终端/文件操作,服务端作为企业平台提供 LLM 网关(统一 Key 管理)、用户权限、审计日志、知识库共享能力。
|
||||
|
||||
|
|
@ -298,7 +291,7 @@ provider = RemoteLLMProvider(
|
|||
response = await provider.chat(request)
|
||||
```
|
||||
|
||||
### 17. 文档处理能力
|
||||
### 16. 文档处理能力
|
||||
|
||||
Agent 内置文档生成与读取能力,Agent 通过 `DocumentTool` 自主创建 Word/Excel/PDF 文档、填充 Word 模板、读取多格式文档,无需用户手动操作 Office 软件。
|
||||
|
||||
|
|
@ -402,15 +395,16 @@ result = await tool.execute(
|
|||
│ portal.py · chat.py · evolution.py · workflows.py │
|
||||
│ auth.py · terminal_server.py · terminal_whitelist.py │
|
||||
│ llm_gateway.py · config_sync.py · system.py · ... │
|
||||
│ 22个路由模块 · Agent Pool · Expert Team · Memory Store │
|
||||
│ 28个路由模块 · Agent Pool · Expert Team · Memory Store │
|
||||
└──────────────────────────┼───────────────────────────────────┘
|
||||
│
|
||||
┌──────────────┼──────────────┐
|
||||
│ CostAwareRouter │
|
||||
│ Layer 0: 正则规则 (0ms) │
|
||||
│ Layer 1: 启发式分类 (0ms) │
|
||||
│ Layer 1.5: 语义路由 (可选) │
|
||||
│ Layer 2: LLM分类 (~500ms) │
|
||||
│ RequestPreprocessor │
|
||||
│ @board → BoardRouter │
|
||||
│ @team → ExpertTeamRouter │
|
||||
│ Layer 0: @skill:xxx 前缀 │
|
||||
│ Layer 1: 琐碎输入正则 (0ms) │
|
||||
│ 默认: REACT (LLM 自主决策) │
|
||||
│ → ExecutionMode 枚举契约 │
|
||||
└──────┬───────────────┬───────┘
|
||||
│ │
|
||||
|
|
@ -676,7 +670,7 @@ gateway.register_provider("dashscope", OpenAIProvider(
|
|||
app = create_app(llm_gateway=gateway)
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
uvicorn.run(app, host="0.0.0.0", port=8001)
|
||||
```
|
||||
|
||||
启动:
|
||||
|
|
@ -884,7 +878,7 @@ asyncio.run(main())
|
|||
注册 Skill:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/v1/skills \
|
||||
curl -X POST http://localhost:8001/api/v1/skills \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"config": {
|
||||
|
|
@ -915,7 +909,7 @@ curl -X POST http://localhost:8000/api/v1/skills \
|
|||
提交任务(指定 Skill):
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/v1/tasks \
|
||||
curl -X POST http://localhost:8001/api/v1/tasks \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"skill_name": "content_generator",
|
||||
|
|
@ -926,7 +920,7 @@ curl -X POST http://localhost:8000/api/v1/tasks \
|
|||
提交任务(意图路由自动匹配):
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/v1/tasks \
|
||||
curl -X POST http://localhost:8001/api/v1/tasks \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"input_data": {"query": "帮我生成一篇文章"}
|
||||
|
|
@ -936,7 +930,7 @@ curl -X POST http://localhost:8000/api/v1/tasks \
|
|||
创建 Agent:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/v1/agents \
|
||||
curl -X POST http://localhost:8001/api/v1/agents \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"skill_name": "content_generator"}'
|
||||
```
|
||||
|
|
@ -944,13 +938,13 @@ curl -X POST http://localhost:8000/api/v1/agents \
|
|||
查询 LLM 用量:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/api/v1/llm/usage
|
||||
curl http://localhost:8001/api/v1/llm/usage
|
||||
```
|
||||
|
||||
健康检查:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/api/v1/health
|
||||
curl http://localhost:8001/api/v1/health
|
||||
```
|
||||
|
||||
#### Python SDK 调用
|
||||
|
|
@ -960,7 +954,7 @@ import asyncio
|
|||
from agentkit.server.client import AgentKitClient
|
||||
|
||||
async def main():
|
||||
async with AgentKitClient("http://localhost:8000") as client:
|
||||
async with AgentKitClient("http://localhost:8001") as client:
|
||||
# 注册 Skill
|
||||
await client.register_skill({
|
||||
"name": "content_generator",
|
||||
|
|
@ -1220,26 +1214,19 @@ ReActEngine 实现 Think -> Act -> Observe 循环:
|
|||
|
||||
危险工具确认流:非白名单命令触发 `needs_confirmation`,用户确认后以 `_skip_dangerous_check=True` 重新执行,避免无限循环。
|
||||
|
||||
### chat/skill_routing -- CostAwareRouter 三层路由
|
||||
### chat/request_preprocessor -- 请求预处理
|
||||
|
||||
三层路由从零成本到高成本逐层升级:
|
||||
按前缀分流的零成本路由:
|
||||
|
||||
| Layer | 组件 | 延迟 | Token |
|
||||
|-------|------|------|-------|
|
||||
| 0 | `RegexRules` | ~0ms | 0 |
|
||||
| 1 | `HeuristicClassifier` | ~0ms | 0 |
|
||||
| 1.5 | `SemanticRouter` | ~0ms | 0 |
|
||||
| 2 | `LLMClassifier` | ~500ms | ~200 |
|
||||
| Layer | 触发条件 | 延迟 | Token | 路由结果 |
|
||||
|-------|---------|------|-------|---------|
|
||||
| 0 | `@skill:xxx` 前缀 | ~0ms | 0 | 显式技能选择(`SKILL_REACT` 或技能配置的模式) |
|
||||
| 1 | 琐碎输入正则 | ~0ms | 0 | `DIRECT_CHAT`(由 `_TOOL_CONTEXT_RE` 守护) |
|
||||
| 默认 | 其他输入 | ~0ms | 0 | `REACT`(LLM 自主决策) |
|
||||
|
||||
路由结果包含 `ExecutionMode` 枚举(`DIRECT_CHAT` / `REACT` / `SKILL_REACT` / `TEAM_COLLAB`),作为路由层与执行层的架构契约。`complexity` 评分使用 `if is not None` 判断,避免 `0.0 or default` 误覆盖。`@team:expert1,expert2` 前缀直接路由到 `TEAM_COLLAB` 模式。
|
||||
路由结果包含 `ExecutionMode` 枚举(`DIRECT_CHAT` / `REACT` / `SKILL_REACT` / `REWOO` / `REFLEXION` / `PLAN_EXEC` / `TEAM_COLLAB`),作为路由层与执行层的架构契约。`@team:expert1,expert2` 前缀直接路由到 `TEAM_COLLAB` 模式(由 `ExpertTeamRouter` 处理),`@board` 前缀走 `BoardRouter`(多轮讨论)。
|
||||
|
||||
### chat/semantic_router -- 语义路由
|
||||
|
||||
基于向量相似度的意图路由,作为关键词匹配的补充:
|
||||
|
||||
- **SemanticRouter** -- 将用户输入和 Skill 描述向量化,通过余弦相似度匹配
|
||||
- **缓存友好** -- 向量缓存避免重复计算
|
||||
- **平滑降级** -- 语义路由失败时自动回退到启发式/LLM 分类
|
||||
旧的 3 层 `CostAwareRouter`(含 `RegexRules` / `HeuristicClassifier` / `SemanticRouter` / `Vickrey Auction`)已被 `RequestPreprocessor` 替换;`IntentRouter`(`router/intent.py`)存在但未接入 chat 流程;`AuctionHouse`(Vickrey 拍卖)位于 `marketplace/auction.py`(属于 marketplace 子系统,非路由)。
|
||||
|
||||
### llm/gateway -- LLM Gateway
|
||||
|
||||
|
|
@ -1280,9 +1267,9 @@ SkillRegistry 管理 Skill 的注册、发现、更新。
|
|||
|
||||
团队生命周期:FORMING -> PLANNING -> EXECUTING -> SYNTHESIZING -> COMPLETED。失败时自动回退到单 Agent 模式(lead 或首个活跃专家)。
|
||||
|
||||
### router/intent -- 意图路由(已升级为 chat/skill_routing)
|
||||
### router/intent -- 意图路由(未接入 chat 流程)
|
||||
|
||||
原两级路由已升级为 CostAwareRouter 三层路由(详见 chat/skill_routing 模块详解)。
|
||||
`IntentRouter` 存在但未接入 chat 流程;当前 chat 请求由 `RequestPreprocessor`(详见 `chat/request_preprocessor` 模块详解)处理。本模块保留供未来扩展。
|
||||
|
||||
### quality/gate -- 产出质量管理
|
||||
|
||||
|
|
@ -1613,7 +1600,7 @@ async def generate_content(keyword: str, brand: str) -> dict:
|
|||
fischer-agentkit/
|
||||
├── src/agentkit/ # Python 后端
|
||||
│ ├── bus/ # 消息总线(MemoryBus + RedisBus)
|
||||
│ ├── chat/ # 聊天路由(CostAwareRouter + ExecutionMode)
|
||||
│ ├── chat/ # 聊天路由(RequestPreprocessor + ExecutionMode)
|
||||
│ ├── cli/ # CLI 命令(Typer)
|
||||
│ ├── client/ # 客户端 SDK(ConfigSync + RemoteLLMProvider 集成)
|
||||
│ ├── core/ # 核心引擎(ReAct/Reflexion/ReWOO/ConfigDriven + HandoffTransport)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,274 @@
|
|||
---
|
||||
title: "feat: 全项目 E2E 测试套件 + CI workflow"
|
||||
date: 2026-06-28
|
||||
plan_type: feat
|
||||
origin: docs/solutions/integration-issues/jwt-secret-dev-mode-user-id-mismatch.md
|
||||
depth: deep
|
||||
---
|
||||
|
||||
# feat: 全项目 E2E 测试套件 + CI workflow
|
||||
|
||||
## Summary
|
||||
|
||||
建立覆盖全项目的 E2E 测试体系,包括前端所有关键视图的 Playwright spec、后端 API/WS 闭环测试、认证状态持久化测试、数据一致性测试,以及 GitHub Actions/Gitea CI workflow。目标:让本次出现的 reload 登录失效、日历数据不一致、对话无法删除等问题在 CI 阶段就被捕获,不再流入用户手中。
|
||||
|
||||
## Problem Frame
|
||||
|
||||
当前项目虽有测试基础设施(后端 `tests/e2e/` + 前端 Playwright 4 spec),但存在严重缺口:
|
||||
|
||||
1. **认证状态持久化未测**:reload 后登录态保持、token 跨重启持久化、refresh token 冷启动 — 这些场景无 E2E 覆盖,导致问题4(reload 跳登录页)长期未发现
|
||||
2. **数据一致性未测**:agent 通过工具创建数据(如日历事件)后,UI 能否看到 — 无端到端验证,导致问题2(日历事件看不到)长期未发现
|
||||
3. **前端视图覆盖不足**:仅 login/chat/calendar/terminal 4个 spec,skills/documents/bitable/evolution/monitor/settings/admin 等视图零覆盖
|
||||
4. **CI 完全无测试**:`.github/workflows/` 和 `.gitea/workflows/` 都没有测试 job,所有测试依赖开发者手动运行
|
||||
5. **对话管理 CRUD 未测**:创建、删除、切换对话的端到端流程未覆盖,导致问题1(对话无法删除)长期未发现
|
||||
|
||||
## Requirements
|
||||
|
||||
- R1: 认证状态持久化 E2E — 覆盖 reload、token 跨重启、refresh token 冷启动
|
||||
- R2: 数据一致性 E2E — 覆盖 agent 工具创建数据后 UI 可见性(日历优先)
|
||||
- R3: 对话管理 CRUD E2E — 覆盖创建、删除、切换、历史加载
|
||||
- R4: 前端全视图 E2E — 覆盖 skills/documents/bitable/evolution/monitor/settings/admin
|
||||
- R5: 后端 API E2E 补充 — 覆盖未测的 REST 端点(experts/mcp/workflows 等)
|
||||
- R6: CI workflow — GitHub Actions + Gitea 自动运行测试
|
||||
|
||||
## Key Technical Decisions
|
||||
|
||||
- **KTD1: Playwright 作为前端 E2E 主框架**。项目已安装 `@playwright/test@^1.59.0` 且配置完善(双服务器自动启动、global-setup 创建测试用户),无需引入新框架。扩展新 spec 复用现有 helpers.ts。
|
||||
- **KTD2: 后端 E2E 复用 `tests/e2e/conftest.py` 基础设施**。MockLLMProvider + 端口 18765 + `scripts/run_e2e.sh` 已成熟,新测试直接扩展。不引入 testcontainers(项目已声明但未使用,保持一致性)。
|
||||
- **KTD3: CI 用 docker-compose.test.yml 启动依赖**。PostgreSQL pgvector (5434) + Redis (6381) 已配置,CI 中 `docker-compose -f docker-compose.test.yml up -d` 即可。不引入 testcontainers 以保持与本地环境一致。
|
||||
- **KTD4: 认证状态测试用 `page.reload()` + `evaluate` 验证 localStorage**。不 mock Tauri Keychain(Web 模式下走 localStorage fallback),直接验证持久化行为。
|
||||
- **KTD5: 数据一致性测试用 "agent 路径写入 + UI 路径读取" 双向验证**。先通过 API/WS 让 agent 创建日历事件,再通过 UI 验证事件可见。这是本次问题的核心回归测试。
|
||||
|
||||
---
|
||||
|
||||
## U1. 认证状态持久化 E2E
|
||||
|
||||
**Goal:** 验证登录状态跨 reload、跨服务器重启持久化,捕获问题4(reload 跳登录页)的回归。
|
||||
|
||||
**Requirements:** R1
|
||||
|
||||
**Dependencies:** 无
|
||||
|
||||
**Files:**
|
||||
- `src/agentkit/server/frontend/e2e/auth-persistence.spec.ts` (create)
|
||||
- `src/agentkit/server/frontend/e2e/helpers.ts` (modify — 添加 `reloadAndWaitAuth` helper)
|
||||
|
||||
**Approach:**
|
||||
- 测试1:登录 → reload → 验证仍处于已认证状态(不跳 login)
|
||||
- 测试2:登录 → 清除 localStorage → reload → 验证跳 login
|
||||
- 测试3:登录 → 服务器重启(Playwright 无法直接重启服务器,改为验证 token 过期场景)→ 验证 refresh token 冷启动
|
||||
- 测试4:登录 → 访问受保护页面 → 验证 Authorization header 携带 access token
|
||||
- 测试5:登录 → 等待 access token 过期(mock 时间或短 TTL)→ 验证 silent refresh
|
||||
|
||||
**Patterns to follow:** `e2e/login.spec.ts` 的 `loginViaApi` + `loginAndHydrate` 模式
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: 登录后 reload 保持登录态
|
||||
- Edge case: localStorage 为空时 reload 跳 login
|
||||
- Error path: refresh token 无效时跳 login 并清除存储
|
||||
- Integration: access token 过期时自动 refresh,不中断用户操作
|
||||
|
||||
**Verification:** `npm run test:e2e -- --grep "auth-persistence"` 全部通过
|
||||
|
||||
---
|
||||
|
||||
## U2. 对话管理 CRUD E2E
|
||||
|
||||
**Goal:** 验证对话的创建、删除、切换、历史加载端到端流程,捕获问题1(对话无法删除)的回归。
|
||||
|
||||
**Requirements:** R3
|
||||
|
||||
**Dependencies:** U1
|
||||
|
||||
**Files:**
|
||||
- `src/agentkit/server/frontend/e2e/conversation-management.spec.ts` (create)
|
||||
|
||||
**Approach:**
|
||||
- 测试1:新建对话 → 发送消息 → 验证对话出现在列表
|
||||
- 测试2:删除对话 → 验证从列表消失 → reload → 验证不再出现(未被"复活")
|
||||
- 测试3:多对话切换 → 验证消息历史正确加载
|
||||
- 测试4:删除当前活跃对话 → 验证自动切换到下一个或新建
|
||||
- 测试5:删除所有对话 → 验证空状态显示
|
||||
|
||||
**Patterns to follow:** `e2e/chat.spec.ts` 的 `sendChatMessage` + `waitForLlmResponse` 模式
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: 创建 → 发消息 → 删除 → 验证消失
|
||||
- Edge case: 删除进行中的对话(pending 状态)
|
||||
- Error path: 删除不存在的对话(404)→ UI 正确处理
|
||||
- Integration: 多对话切换时消息历史隔离
|
||||
|
||||
**Verification:** `npm run test:e2e -- --grep "conversation-management"` 全部通过
|
||||
|
||||
---
|
||||
|
||||
## U3. 日历数据一致性 E2E
|
||||
|
||||
**Goal:** 验证 agent 通过 calendar 工具创建事件后,UI 日历视图能显示该事件,捕获问题2(日历事件看不到)的回归。
|
||||
|
||||
**Requirements:** R2
|
||||
|
||||
**Dependencies:** U1
|
||||
|
||||
**Files:**
|
||||
- `src/agentkit/server/frontend/e2e/calendar-data-consistency.spec.ts` (create)
|
||||
|
||||
**Approach:**
|
||||
- 测试1:通过 chat 让 agent 创建日历事件("帮我创建下周一上午10点的项目会议")→ 切换到日历视图 → 验证事件可见
|
||||
- 测试2:通过 UI 创建事件 → 通过 API 查询 → 验证 user_id 一致
|
||||
- 测试3:通过 chat 创建事件 → 通过 API 查询 → 验证 user_id 不是 hallucinate 值("default"/"zhangsan")
|
||||
- 测试4:多事件场景 → 验证日历正确渲染所有事件
|
||||
- 测试5:删除事件 → 验证 UI 和 API 同步消失
|
||||
|
||||
**Patterns to follow:** `e2e/calendar.spec.ts` 的 E1-E8 模式 + `e2e/chat.spec.ts` 的 agent 交互
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: agent 创建 → UI 可见
|
||||
- Data integrity: user_id 一致性(agent 路径 vs UI 路径)
|
||||
- Edge case: agent 创建事件但 LLM 未提供 user_id → 使用 default_user_id
|
||||
- Integration: UI 创建 + agent 创建混合场景
|
||||
|
||||
**Verification:** `npm run test:e2e -- --grep "calendar-data-consistency"` 全部通过
|
||||
|
||||
---
|
||||
|
||||
## U4. 前端全视图 E2E 补充
|
||||
|
||||
**Goal:** 为 skills/documents/bitable/evolution/monitor/settings/admin 视图补充 E2E spec,确保所有关键视图可访问且基本功能正常。
|
||||
|
||||
**Requirements:** R4
|
||||
|
||||
**Dependencies:** U1
|
||||
|
||||
**Files:**
|
||||
- `src/agentkit/server/frontend/e2e/skills-view.spec.ts` (create)
|
||||
- `src/agentkit/server/frontend/e2e/documents-view.spec.ts` (create)
|
||||
- `src/agentkit/server/frontend/e2e/bitable-view.spec.ts` (create)
|
||||
- `src/agentkit/server/frontend/e2e/evolution-view.spec.ts` (create)
|
||||
- `src/agentkit/server/frontend/e2e/monitor-view.spec.ts` (create)
|
||||
- `src/agentkit/server/frontend/e2e/settings-view.spec.ts` (create)
|
||||
- `src/agentkit/server/frontend/e2e/admin-view.spec.ts` (create)
|
||||
|
||||
**Approach:**
|
||||
每个视图至少覆盖:
|
||||
- 页面加载(无白屏、无 401)
|
||||
- 核心功能(如 skills 列表加载、documents 上传按钮可见等)
|
||||
- 导航交互(侧边栏切换、标签页切换)
|
||||
- 错误状态(空数据、加载失败)
|
||||
|
||||
**Patterns to follow:** `e2e/calendar.spec.ts` 的 E1(面板加载)模式
|
||||
|
||||
**Test scenarios:** 每个视图 3-5 个测试(加载、核心功能、空状态、错误处理)
|
||||
|
||||
**Verification:** `npm run test:e2e` 全部 spec 通过
|
||||
|
||||
---
|
||||
|
||||
## U5. 后端 API E2E 补充
|
||||
|
||||
**Goal:** 补充未覆盖的后端 REST 端点 E2E 测试(experts/mcp/workflows/llm gateway 等)。
|
||||
|
||||
**Requirements:** R5
|
||||
|
||||
**Dependencies:** 无
|
||||
|
||||
**Files:**
|
||||
- `tests/e2e/test_api_coverage.py` (create)
|
||||
|
||||
**Approach:**
|
||||
- 遍历 `/api/v1/` 下所有路由前缀
|
||||
- 对未覆盖的端点编写基础 E2E(健康检查、认证要求、CRUD 基础)
|
||||
- 重点覆盖:`/experts`、`/mcp`、`/workflows`、`/llm/chat`(SSE)、`/config`、`/system`
|
||||
|
||||
**Patterns to follow:** `tests/e2e/test_basic_api.py` 的 `api_client` fixture 模式
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: 每个端点的基础 GET/POST 返回预期状态码
|
||||
- Auth: 受保护端点无 token 返回 401
|
||||
- Error path: 无效参数返回 4xx
|
||||
|
||||
**Verification:** `python3 -m pytest tests/e2e/test_api_coverage.py -v` 通过
|
||||
|
||||
---
|
||||
|
||||
## U6. CI workflow 建立
|
||||
|
||||
**Goal:** 在 GitHub Actions 和 Gitea Actions 中建立测试 workflow,自动运行后端 pytest + 前端 vitest + Playwright E2E。
|
||||
|
||||
**Requirements:** R6
|
||||
|
||||
**Dependencies:** U1, U2, U3, U4, U5
|
||||
|
||||
**Files:**
|
||||
- `.github/workflows/test.yml` (create)
|
||||
- `.gitea/workflows/test.yml` (create)
|
||||
|
||||
**Approach:**
|
||||
- GitHub Actions workflow:
|
||||
- Trigger: push to main/develop, PR to main
|
||||
- Jobs: backend-test (pytest) + frontend-unit (vitest) + frontend-e2e (playwright)
|
||||
- Services: PostgreSQL (pgvector) + Redis via docker-compose.test.yml
|
||||
- Cache: pip + npm 依赖缓存
|
||||
- Gitea Actions workflow:同上(Gitea 兼容 GitHub Actions 语法)
|
||||
|
||||
**Patterns to follow:** 标准 GitHub Actions Python + Node.js workflow 模式
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: PR 触发 workflow,所有测试通过
|
||||
- Failure: 故意引入 bug,验证测试失败并阻止合并
|
||||
|
||||
**Verification:** 推送分支 → CI 自动运行 → 全绿
|
||||
|
||||
---
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
### In scope
|
||||
- 前端 Playwright E2E spec(认证持久化、对话管理、日历一致性、全视图覆盖)
|
||||
- 后端 API E2E 补充(未覆盖端点)
|
||||
- CI workflow(GitHub Actions + Gitea)
|
||||
|
||||
### Deferred to Follow-Up Work
|
||||
- Tauri 桌面端 E2E(需引入 tauri-driver,当前无基础设施)
|
||||
- 真实 LLM E2E 扩展(成本高,保持现有 mock 策略)
|
||||
- 性能/负载测试(本次聚焦功能正确性)
|
||||
- 视觉回归测试(Percy/Chromatic,后续补充)
|
||||
|
||||
### Non-goals
|
||||
- 重写现有测试框架
|
||||
- 引入 testcontainers(保持与现有 docker-compose.test.yml 一致)
|
||||
- 修改产品代码(测试发现的问题由 ce-debug 修复)
|
||||
|
||||
---
|
||||
|
||||
## Risks & Dependencies
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Playwright E2E 执行慢(双服务器启动) | 已有 `reuseExistingServer: !CI` 配置,本地复用 |
|
||||
| MockLLMProvider 响应不匹配新场景 | 扩展 `MOCK_LLM_RESPONSES` 字典,新增 calendar 场景 |
|
||||
| CI 中 PostgreSQL pgvector 版本不匹配 | docker-compose.test.yml 已指定 `pgvector/pgvector:pg15` |
|
||||
| calendar.spec.ts 已知 cold-start bug(refresh token) | 新 spec 使用 UI 表单登录,避开 localStorage 注入 |
|
||||
|
||||
---
|
||||
|
||||
## System-Wide Impact
|
||||
|
||||
- **开发者工作流**:PR 将自动触发 CI 测试,需要开发者在本地运行测试后再推送
|
||||
- **部署流程**:CI 绿灯成为部署前置条件
|
||||
- **测试维护**:新增 spec 需要维护,helpers.ts 扩展需保持向后兼容
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
无 — 范围和 CI 策略已确认。
|
||||
|
||||
---
|
||||
|
||||
## Deferred to Implementation
|
||||
|
||||
- 各 spec 的具体选择器(需实际查看 DOM 结构)
|
||||
- MockLLMProvider 的新场景响应内容
|
||||
- CI workflow 的具体缓存键值
|
||||
- 各视图 spec 的详细测试用例(需查看实际 UI)
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
---
|
||||
title: "JWT secret 未设置导致 dev mode user_id 丢失 + reload 登录失效"
|
||||
date: 2026-06-28
|
||||
category: docs/solutions/integration-issues
|
||||
module: server/auth, server/app, tools/calendar_tool, frontend/stores/auth
|
||||
problem_type: integration_issue
|
||||
component: authentication
|
||||
symptoms:
|
||||
- "agent 创建日历事件后回复'已完成',但日历 UI 中看不到事件"
|
||||
- "浏览器 reload 后跳回登录页,refresh token 未过期却验证失败"
|
||||
- "calendar.db 中事件 user_id 为 default/zhangsan(LLM hallucinate),UI 查询用 user_id=None"
|
||||
- "AuthMiddleware 处于 dev mode,所有请求 current_user.user_id=None"
|
||||
root_cause: config_error
|
||||
resolution_type: config_change
|
||||
severity: critical
|
||||
tags: [jwt, auth, dev-mode, calendar, user-id, ephemeral-secret, login-state]
|
||||
---
|
||||
|
||||
# JWT secret 未设置导致 dev mode user_id 丢失 + reload 登录失效
|
||||
|
||||
## Problem
|
||||
|
||||
用户报告4个问题:(1) 启动后出现一堆测试对话且无法删除;(2) 要求创建下周一日历事件,agent 回复已完成但日历里看不到;(3) 日历默认周日开始;(4) reload 后跳回登录页。深入调查发现问题2和4同根同源:`AGENTKIT_JWT_SECRET` 从未设置,触发 dev mode 一系列连锁反应。
|
||||
|
||||
## Symptoms
|
||||
|
||||
- **日历事件数据不一致**:calendar.db 中事件 `user_id="default"`/`"zhangsan"`(LLM hallucinate),但 `/calendar/events` 路由用 `user_id=None` 查询 → 返回空列表
|
||||
- **reload 登录失效**:服务器重启后,之前签发的 refresh token 全部失效,前端 `startupCheck` → `whoami(refresh)` → 401 → `startupState='invalid'` → 跳 `/login`
|
||||
- **AuthMiddleware dev mode**:所有请求 `current_user={"user_id": None, "username": "dev", "role": "admin"}`
|
||||
- **CalendarTool schema 要求 LLM 提供 user_id**:LLM 不知道真实值,hallucinate 假值写入 DB
|
||||
|
||||
## What Didn't Work
|
||||
|
||||
- **前次"修复"日历 401**:在 AuthMiddleware dev mode 分支添加 synthetic dev user(`user_id=None`),解决了 401 但引入了 `user_id=None` 数据不一致问题——治标不治本
|
||||
- **前次"修复"登录状态**:在 auth.ts 注册4个 token provider,但未解决 refresh token 跨重启失效的根本问题
|
||||
- **假设 API 删除失败**:curl 直接测试 DELETE 端点返回 200,DB 行数减少——API 正常,问题是预存测试数据 + 前端视觉复活
|
||||
|
||||
## Solution
|
||||
|
||||
### 核心修复:设置持久化 JWT secret
|
||||
|
||||
在 `.env` 中添加(`.env` 已被 gitignore,不会泄露):
|
||||
|
||||
```bash
|
||||
# 生成持久化 secret
|
||||
python3 -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||
|
||||
# 写入 .env
|
||||
AGENTKIT_JWT_SECRET=<generated_secret>
|
||||
```
|
||||
|
||||
这一步同时解决问题2(AuthMiddleware 退出 dev mode,`current_user.user_id` 从 JWT payload 获取真实值)和问题4(refresh token 跨重启持久化)。
|
||||
|
||||
### CalendarTool 注入真实 user_id
|
||||
|
||||
[calendar_tool.py](file:///Users/Chiguyong/Code/Fischer/fischer-agentkit/src/agentkit/tools/calendar_tool.py) 修改:
|
||||
|
||||
```python
|
||||
# 之前:schema 要求 LLM 提供 user_id,LLM 会 hallucinate
|
||||
"required": ["action", "user_id"],
|
||||
|
||||
# 之后:移除 user_id from required,改用注入的 default_user_id
|
||||
def __init__(self, calendar_service, default_user_id: str | None = None):
|
||||
...
|
||||
self._default_user_id = default_user_id
|
||||
|
||||
def _resolve_user_id(self, kwargs) -> str | None:
|
||||
provided = kwargs.get("user_id")
|
||||
if provided and isinstance(provided, str) and provided.strip():
|
||||
return provided
|
||||
return self._default_user_id
|
||||
```
|
||||
|
||||
[app.py](file:///Users/Chiguyong/Code/Fischer/fischer-agentkit/src/agentkit/server/app.py) 在 lifespan 中查询 admin 用户并注入:
|
||||
|
||||
```python
|
||||
# 查询第一个 active admin 用户作为 default_user_id
|
||||
async with aiosqlite.connect(str(DEFAULT_AUTH_DB_PATH)) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
cur = await db.execute(
|
||||
"SELECT id FROM users WHERE is_active = 1 "
|
||||
"ORDER BY CASE role WHEN 'admin' THEN 0 ELSE 1 END, created_at LIMIT 1"
|
||||
)
|
||||
row = await cur.fetchone()
|
||||
if row is not None:
|
||||
default_cal_user_id = str(row["id"])
|
||||
|
||||
calendar_tool = CalendarTool(
|
||||
calendar_service=cal_service,
|
||||
default_user_id=default_cal_user_id,
|
||||
)
|
||||
```
|
||||
|
||||
### CalendarGrid firstDay 配置
|
||||
|
||||
[CalendarGrid.vue](file:///Users/Chiguyong/Code/Fischer/fischer-agentkit/src/agentkit/server/frontend/src/components/calendar/CalendarGrid.vue) 添加 `firstDay: 1`。
|
||||
|
||||
### 数据清理
|
||||
|
||||
```bash
|
||||
# 清理测试对话
|
||||
sqlite3 ~/.agentkit/conversations.db "DELETE FROM messages; DELETE FROM conversations;"
|
||||
|
||||
# 修复 calendar.db 中已存在的 hallucinate user_id
|
||||
sqlite3 data/calendar.db "UPDATE calendar_events SET user_id = '<admin_user_id>' WHERE user_id IN ('default', 'zhangsan')"
|
||||
```
|
||||
|
||||
## Why This Works
|
||||
|
||||
**根因因果链**:
|
||||
|
||||
1. `AGENTKIT_JWT_SECRET` 未设置 → `get_jwt_secret()` 返回 None
|
||||
2. [app.py:812-837](file:///Users/Chiguyong/Code/Fischer/fischer-agentkit/src/agentkit/server/app.py#L812-L837) 中 `explicit_jwt_secret or ""` 传给 AuthMiddleware → `jwt_secret=""` → `_is_dev_mode()=True`
|
||||
3. dev mode 下 `get_or_create_jwt_secret()` 每次进程启动生成**新的 ephemeral secret**(不持久化)
|
||||
4. **问题4**:服务器重启 → 新 ephemeral secret → 旧 refresh token 签名不匹配 → whoami 401 → 跳登录页
|
||||
5. **问题2**:dev mode 下 `current_user.user_id=None`,CalendarTool 由 LLM hallucinate `user_id="default"`,UI 路径用 `None` 查询不匹配 → 日历看不到事件
|
||||
|
||||
设置 `AGENTKIT_JWT_SECRET` 后:
|
||||
- AuthMiddleware 收到真实 secret → `_is_dev_mode()=False` → JWT 验证启用 → `current_user.user_id` 从 payload 获取
|
||||
- `get_jwt_secret()` 返回持久化 secret → 跨重启一致 → refresh token 持久有效
|
||||
|
||||
## Prevention
|
||||
|
||||
- **环境变量检查**:在 `app.py` 启动时检查 `AGENTKIT_JWT_SECRET` 是否设置,未设置时打印明显警告(当前 dev mode 日志不够醒目)
|
||||
- **e2e 测试覆盖**:添加 e2e 测试覆盖以下场景:
|
||||
1. 服务器重启后 reload 页面,验证登录状态保持
|
||||
2. agent 创建日历事件后,UI 能看到该事件(数据一致性 + `calendar_event_created` WS 消息到达前端,详见 [calendar-agent-create-no-refresh.md](file:///Users/Chiguyong/Code/Fischer/fischer-agentkit/docs/solutions/ui-bugs/calendar-agent-create-no-refresh.md))
|
||||
3. 无效 token 返回 401(非 dev mode 验证)
|
||||
- **CalendarTool user_id 来源**:多用户场景需通过 agent 框架 contextvar 传递 per-request user_id,当前 `default_user_id` 是 dev 模式单用户简化(代码中已标注 `ponytail:` 注释)
|
||||
- **配置审计**:`.env.example` 应列出 `AGENTKIT_JWT_SECRET` 并说明必须设置
|
||||
|
||||
## Related Issues
|
||||
|
||||
同一症状("agent 创建日历事件后 UI 看不到")现已记录**三个不同的根因**,调查时需同时排查:
|
||||
|
||||
- [日历能力缺失修复 + UI 布局优化](file:///Users/Chiguyong/Code/Fischer/fischer-agentkit/docs/solutions/logic-errors/calendar-capability-and-ui-fixes.md) — 前次日历问题(CalendarTool 接入 ReAct),本次是认证配置导致的 user_id 不匹配,同一领域不同根因
|
||||
- [Calendar events created via agent chat do not refresh the calendar UI](file:///Users/Chiguyong/Code/Fischer/fischer-agentkit/docs/solutions/ui-bugs/calendar-agent-create-no-refresh.md) — `CalendarService.create_event` 创建成功后未广播 `calendar_event_created` WS 消息(第三个根因,2026-06-29 记录)
|
||||
- [Portal 平台安全可靠性修复](file:///Users/Chiguyong/Code/Fischer/fischer-agentkit/docs/solutions/security-issues/portal-platform-security-reliability-fixes.md) — 认证相关修复
|
||||
|
|
@ -60,7 +60,7 @@ tags: [code-review, calendar, reminder, websocket, system-prompt, ui-layout, sta
|
|||
## Verification
|
||||
|
||||
- `ruff check` 通过(修复了 `gui_mode` F821 未定义错误)
|
||||
- `pytest tests/unit/calendar/` — 98 passed
|
||||
- `pytest tests/unit/calendar/` — 109 passed(含 2026-06-29 新增的 2 个 `notify_callback` 广播回归测试)
|
||||
- `npm run typecheck` — 通过
|
||||
|
||||
## Files Changed
|
||||
|
|
@ -72,3 +72,12 @@ tags: [code-review, calendar, reminder, websocket, system-prompt, ui-layout, sta
|
|||
| `src/agentkit/server/frontend/src/stores/chat.ts` | P1: deleteConversation 清理 + 404 递归保护 + WS connected 清除 is_local + 移除死代码 |
|
||||
| `src/agentkit/server/routes/portal.py` | P3: send_json 快照列表 |
|
||||
| `src/agentkit/server/frontend/src/components/layout/SystemMonitorPanel.vue` | P2: monitor flex 布局 |
|
||||
|
||||
## Related Issues
|
||||
|
||||
本 doc 修复的是"agent 看不到 calendar 工具 + reminder_rules 被丢弃"的根因。同一症状("agent 创建日历事件后 UI 看不到")还有两个**不同的根因**,调查时需同时排查:
|
||||
|
||||
- [JWT secret 未设置导致 dev mode user_id 丢失](file:///Users/Chiguyong/Code/Fischer/fischer-agentkit/docs/solutions/integration-issues/jwt-secret-dev-mode-user-id-mismatch.md) — AuthMiddleware 处于 dev mode,`current_user.user_id=None`,DB 写入用 LLM hallucinate 的 `user_id`,UI 查询用 `None` → 不匹配
|
||||
- [Calendar events created via agent chat do not refresh the calendar UI](file:///Users/Chiguyong/Code/Fischer/fischer-agentkit/docs/solutions/ui-bugs/calendar-agent-create-no-refresh.md) — `CalendarService.create_event` 创建成功后未广播 `calendar_event_created` WS 消息,前端有 handler 但收不到
|
||||
|
||||
**预防规则**:变更型 service 方法(`create_event` / `update_event` / `delete_event`)若前端有对应视图,必须在成功提交后广播 WS 事件,不能依赖下次 GET 轮询。`notify_callback` 注入模式是本项目的既定做法(见 [CONCEPTS.md → Service Broadcast Callback](file:///Users/Chiguyong/Code/Fischer/fischer-agentkit/CONCEPTS.md))。
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
---
|
||||
title: "Portal platform security & reliability fixes — channels, LLM cache, server shutdown"
|
||||
title: "Portal 平台安全与可靠性修复 — 通道、LLM 缓存、服务端关闭"
|
||||
date: 2026-06-26
|
||||
category: docs/solutions/security-issues
|
||||
module: channels/llm/server
|
||||
problem_type: security_issue
|
||||
component: service_object
|
||||
severity: high
|
||||
severity: mixed
|
||||
priority_breakdown: {p1: 4, p2: 5, p3: 1}
|
||||
symptoms:
|
||||
- "WeCom webhook accepted replayed requests within an unlimited time window (no timestamp freshness check)"
|
||||
- "Anonymous LLM requests polluted per-user cache namespace when user_id was None (per_user_namespace=True ignored)"
|
||||
|
|
@ -26,7 +27,15 @@ related_components:
|
|||
- server/routes/channels
|
||||
---
|
||||
|
||||
# Portal platform security & reliability fixes — channels, LLM cache, server shutdown
|
||||
# Portal 平台安全与可靠性修复 — 通道、LLM 缓存、服务端关闭
|
||||
|
||||
## Root Cause
|
||||
|
||||
10 个修复共享三个共性根因:
|
||||
|
||||
1. **信任边界输入校验缺失**:WeCom webhook 信任签名正确即放行(缺时间戳新鲜度);LLM 缓存信任 `user_id` 非空(缺 None 检查);webhook `receive_message` 信任 payload 格式(缺异常隔离)。三处均在信任边界省略了输入校验。
|
||||
2. **资源生命周期未闭合**:应用 shutdown 未调用 `close_all_adapters()`、未 await `_pending_webhook_tasks`,httpx 连接与 IM 回复在进程退出时被丢弃。Feishu token TTL 设置过短(300s vs 实际 ~6900s)导致频繁刷新。
|
||||
3. **缓存键设计缺陷**:`generate_cache_key` 对每个组件单独 SHA-256 后再哈希一次(8-10 次冗余哈希);配额检查重复查询(4 次/部门/周期 vs 实际需 1 次 summary)。
|
||||
|
||||
## Problem
|
||||
|
||||
|
|
@ -38,7 +47,7 @@ related_components:
|
|||
- **LLM 缓存跨用户泄漏**:`should_cache()` 对 `user_id` 参数执行 `_ = user_id` 直接丢弃,在 `per_user_namespace` 开启时,`user_id=None` 的请求仍会命中其他用户的缓存。
|
||||
- **Webhook 异常触发 500 重试风暴**:`receive_message()` 解析失败直接抛出未捕获异常,平台收到 500 后按退避策略无限重试,造成异常放大。
|
||||
- **应用关闭资源泄漏**:shutdown 流程未调用 `close_all_adapters()`、未 await `_pending_webhook_tasks`,导致 httpx 连接泄漏与 IM 回复丢失。
|
||||
- **配额查询 N+1 与缓存键冗余哈希**:配额检查对每个部门每个周期调用 4 次 `get_usage()`(实际只需 2 次唯一查询);`generate_cache_key` 对每个组件单独 SHA-256 后再哈希一次,8-10 次冗余哈希。
|
||||
- **配额查询 N+1 与缓存键冗余哈希**:配额检查对每个部门每个周期调用 4 次 `get_usage()`(实际只需 1 次唯一查询,token 与 cost 可共用 summary);`generate_cache_key` 对每个组件单独 SHA-256 后再哈希一次,8-10 次冗余哈希。
|
||||
|
||||
## What Didn't Work
|
||||
|
||||
|
|
@ -50,7 +59,7 @@ related_components:
|
|||
|
||||
### P1 #1 — WeCom webhook 重放攻击修复
|
||||
|
||||
**文件**:[src/agentkit/channels/wecom.py](file:///Users/Chiguyong/Code/Fischer/fischer-agentkit/src/agentkit/channels/wecom.py)
|
||||
**文件**:[src/agentkit/channels/wecom.py](src/agentkit/channels/wecom.py)
|
||||
|
||||
**问题**:`verify_signature()` 只比对签名,不校验时间戳新鲜度,重放窗口无限大。
|
||||
|
||||
|
|
@ -77,7 +86,7 @@ if abs(now - ts_int) > _SIGNATURE_MAX_AGE_SECONDS:
|
|||
|
||||
### P1 #2 — LiteLLM 缓存跨用户泄漏修复
|
||||
|
||||
**文件**:[src/agentkit/llm/cache.py](file:///Users/Chiguyong/Code/Fischer/fischer-agentkit/src/agentkit/llm/cache.py)
|
||||
**文件**:[src/agentkit/llm/cache.py](src/agentkit/llm/cache.py)
|
||||
|
||||
**问题**:`should_cache()` 形参 `user_id` 被显式丢弃(`_ = user_id`),`per_user_namespace` 开启时 `user_id=None` 的请求仍会命中缓存,造成跨用户数据泄漏。
|
||||
|
||||
|
|
@ -97,7 +106,7 @@ def should_cache(self, kb_caching_disabled: bool = False, user_id: str | None =
|
|||
|
||||
### P1 #3 — Webhook 异常风暴防御
|
||||
|
||||
**文件**:[src/agentkit/server/routes/channels.py](file:///Users/Chiguyong/Code/Fischer/fischer-agentkit/src/agentkit/server/routes/channels.py)
|
||||
**文件**:[src/agentkit/server/routes/channels.py](src/agentkit/server/routes/channels.py)
|
||||
|
||||
**问题**:`receive_message()` 抛出异常时,路由返回 500,平台按重试策略无限重试,形成异常风暴。
|
||||
|
||||
|
|
@ -113,7 +122,7 @@ except Exception as exc: # noqa: BLE001 — 防止 receive_message 异常导致
|
|||
|
||||
### P1 #4 — 应用关闭泄漏修复
|
||||
|
||||
**文件**:[src/agentkit/server/app.py](file:///Users/Chiguyong/Code/Fischer/fischer-agentkit/src/agentkit/server/app.py)
|
||||
**文件**:[src/agentkit/server/app.py](src/agentkit/server/app.py)
|
||||
|
||||
**问题**:shutdown 流程未关闭 channel adapters、未等待后台 webhook 任务,导致 httpx 连接泄漏与 IM 回复丢失。
|
||||
|
||||
|
|
@ -136,7 +145,7 @@ except Exception:
|
|||
|
||||
### P2 #8 — Feishu token TTL 修正
|
||||
|
||||
**文件**:[src/agentkit/channels/feishu.py](file:///Users/Chiguyong/Code/Fischer/fischer-agentkit/src/agentkit/channels/feishu.py)
|
||||
**文件**:[src/agentkit/channels/feishu.py](src/agentkit/channels/feishu.py)
|
||||
|
||||
**问题**:`_TOKEN_CACHE_TTL = 300.0` 比实际有效期(2h)短 24 倍,造成每 5 分钟强制刷新一次 token,无谓增加 QPS。
|
||||
|
||||
|
|
@ -148,7 +157,7 @@ _TOKEN_CACHE_TTL = 6900.0 # 2h - 5min 余量,避免临界点失效
|
|||
|
||||
### P2 #10 — 无界 webhook 任务集
|
||||
|
||||
**文件**:[src/agentkit/server/routes/channels.py](file:///Users/Chiguyong/Code/Fischer/fischer-agentkit/src/agentkit/server/routes/channels.py)
|
||||
**文件**:[src/agentkit/server/routes/channels.py](src/agentkit/server/routes/channels.py)
|
||||
|
||||
**问题**:`_pending_webhook_tasks` set 在高负载下无上限增长,可能耗尽内存与协程。
|
||||
|
||||
|
|
@ -164,7 +173,7 @@ if len(_pending_webhook_tasks) >= _WEBHOOK_MAX_CONCURRENT * 2:
|
|||
|
||||
### P2 #12 — 配额检查 N+1 查询消除
|
||||
|
||||
**文件**:[src/agentkit/llm/gateway.py](file:///Users/Chiguyong/Code/Fischer/fischer-agentkit/src/agentkit/llm/gateway.py)
|
||||
**文件**:[src/agentkit/llm/gateway.py](src/agentkit/llm/gateway.py)
|
||||
|
||||
**问题**:配额检查对每个部门每个周期调用 4 次 `get_usage()`(token 与 cost 各一次,但实际可合并为一次查询),重复查询放大数据库压力。
|
||||
|
||||
|
|
@ -191,7 +200,7 @@ def _get_usage_summary(self, department_id: str, period: str) -> UsageSummary:
|
|||
|
||||
### P2 #13 — 缓存键冗余 SHA-256 消除
|
||||
|
||||
**文件**:[src/agentkit/llm/cache_key.py](file:///Users/Chiguyong/Code/Fischer/fischer-agentkit/src/agentkit/llm/cache_key.py)
|
||||
**文件**:[src/agentkit/llm/cache_key.py](src/agentkit/llm/cache_key.py)
|
||||
|
||||
**问题**:`generate_cache_key` 对每个组件单独 SHA-256(8-10 次),再对哈希拼接再做一次 SHA-256,CPU 浪费且无安全增益。
|
||||
|
||||
|
|
@ -219,7 +228,7 @@ return hashlib.sha256(combined.encode()).hexdigest()
|
|||
|
||||
### P2 #18 — 移除未使用的 secrets_store 参数
|
||||
|
||||
**文件**:[src/agentkit/llm/config.py](file:///Users/Chiguyong/Code/Fischer/fischer-agentkit/src/agentkit/llm/config.py)
|
||||
**文件**:[src/agentkit/llm/config.py](src/agentkit/llm/config.py)
|
||||
|
||||
**问题**:`get_api_key()` 接受 `secrets_store` 形参但从未使用(同步方法无法 await 异步 `get_secret`),属于误导性 API。
|
||||
|
||||
|
|
@ -237,7 +246,7 @@ def get_api_key(self) -> str:
|
|||
|
||||
### P3 #21 — DIRECT_CHAT 路径去重
|
||||
|
||||
**文件**:[src/agentkit/server/routes/channels.py](file:///Users/Chiguyong/Code/Fischer/fischer-agentkit/src/agentkit/server/routes/channels.py)
|
||||
**文件**:[src/agentkit/server/routes/channels.py](src/agentkit/server/routes/channels.py)
|
||||
|
||||
**问题**:DIRECT_CHAT 逻辑在主路径与 ReAct 回退路径中重复实现,维护时易漂移。
|
||||
|
||||
|
|
@ -258,13 +267,32 @@ async def _direct_chat(llm_gateway: Any, routing: Any) -> str:
|
|||
修复完成后按以下方式验证:
|
||||
|
||||
- **ruff check**:`src/` 干净,仅剩预存在的 `gui_mode` F821(与本次修复无关)。
|
||||
- **Channels 测试**:`pytest tests/unit/server/routes/test_channels.py` — 137 passed。
|
||||
- **Channels 测试**:`pytest tests/unit/channels/test_wecom.py` — 验证 `verify_signature` 与 `_SIGNATURE_MAX_AGE_SECONDS` 新鲜度校验。
|
||||
- **配置迁移测试**:`pytest tests/unit/llm/test_config_migration.py` — 全部通过。
|
||||
- **配额强制测试**:`pytest tests/unit/llm/test_quota_enforcement.py` — 11 passed。
|
||||
- **litellm 相关测试**:因 `litellm` 未安装被 skip(环境问题,非代码问题)。
|
||||
- **WeCom 测试调整**:将固定时间戳 `1609459200` 改为 `int(time.time())` 动态生成后,新鲜度校验测试通过。
|
||||
- **缓存测试调整**:显式传入 `user_id` 或关闭 `per_user_namespace` 后,`should_cache()` 测试通过。
|
||||
|
||||
## Impact Scope
|
||||
|
||||
**受影响模块**:
|
||||
|
||||
| 模块 | 文件 | 影响类型 |
|
||||
|---|---|---|
|
||||
| WeCom 通道 | `src/agentkit/channels/wecom.py` | 安全(重放防御) |
|
||||
| Feishu 通道 | `src/agentkit/channels/feishu.py` | 可靠性(TTL 修正) |
|
||||
| LLM 缓存 | `src/agentkit/llm/cache.py` | 安全(跨用户泄漏) |
|
||||
| LLM 缓存键 | `src/agentkit/llm/cache_key.py` | 性能(冗余哈希) |
|
||||
| LLM 网关 | `src/agentkit/llm/gateway.py` | 可靠性(配额 N+1) |
|
||||
| LLM 配置 | `src/agentkit/llm/config.py` | 可维护性(未用参数) |
|
||||
| 应用生命周期 | `src/agentkit/server/app.py` | 可靠性(shutdown 泄漏) |
|
||||
| 通道路由 | `src/agentkit/server/routes/channels.py` | 可靠性(webhook 风暴) |
|
||||
|
||||
**向后兼容性**:所有修复向后兼容。WeCom 签名校验增加时间戳窗口后,历史合法请求在 300s 窗口内仍被接受;Feishu TTL 从 300s 延长至 ~6900s 仅减少刷新频率;缓存键生成逻辑变更不影响命中(旧键自然过期);shutdown 顺序变更仅影响关闭流程,不影响运行时行为。
|
||||
|
||||
**生产影响**:WeCom 重放修复立即生效;缓存跨用户泄漏修复消除潜在数据混淆;shutdown 泄漏修复消除进程重启时的连接错误日志。无破坏性变更,无需停机部署。
|
||||
|
||||
## Prevention
|
||||
|
||||
### 安全
|
||||
|
|
@ -292,5 +320,5 @@ async def _direct_chat(llm_gateway: Any, routing: Any) -> str:
|
|||
|
||||
## Related Docs
|
||||
|
||||
- [long-horizon-reliability-code-review-fixes.md](file:///Users/Chiguyong/Code/Fischer/fischer-agentkit/docs/solutions/logic-errors/long-horizon-reliability-code-review-fixes.md) — 上一批 U1-U7 长期可靠性代码评审修复,与本批次同属 code-review 修复系列,可在遇到类似可靠性问题时交叉参考。
|
||||
- [bitable-companion-service-security-reliability-patterns.md](file:///Users/Chiguyong/Code/Fischer/fischer-agentkit/docs/solutions/architecture-patterns/bitable-companion-service-security-reliability-patterns.md) — Bitable 伴生服务的安全/可靠性架构模式(SSRF、SQL 注入、IDOR、缓存失效等),与本批次的 LLM 缓存隔离威胁模型不同但可对照阅读。
|
||||
- [long-horizon-reliability-code-review-fixes.md](docs/solutions/logic-errors/long-horizon-reliability-code-review-fixes.md) — 上一批 U1-U7 长期可靠性代码评审修复,与本批次同属 code-review 修复系列,可在遇到类似可靠性问题时交叉参考。
|
||||
- [bitable-companion-service-security-reliability-patterns.md](docs/solutions/architecture-patterns/bitable-companion-service-security-reliability-patterns.md) — Bitable 伴生服务的安全/可靠性架构模式(SSRF、SQL 注入、IDOR、缓存失效等),与本批次的 LLM 缓存隔离威胁模型不同但可对照阅读。
|
||||
|
|
|
|||
|
|
@ -0,0 +1,131 @@
|
|||
---
|
||||
title: "Calendar events created via agent chat do not refresh the calendar UI"
|
||||
date: 2026-06-29
|
||||
category: docs/solutions/ui-bugs/
|
||||
module: calendar
|
||||
problem_type: ui_bug
|
||||
component: service_object
|
||||
symptoms:
|
||||
- "Calendar event created via agent chat is persisted to DB but does not appear in the calendar UI tab"
|
||||
- "Calendar tab must be manually reloaded to see newly created events"
|
||||
- "No calendar_event_created WebSocket message is emitted on agent-driven creation"
|
||||
- "CalendarPanel.vue only calls store.loadEvents() in onMounted; tab switches do not re-mount the component"
|
||||
root_cause: missing_workflow_step
|
||||
resolution_type: code_fix
|
||||
severity: medium
|
||||
tags: [calendar, websocket, ui-refresh, agent-driven, pinia, broadcast, notify-callback]
|
||||
---
|
||||
|
||||
# Calendar events created via agent chat do not refresh the calendar UI
|
||||
|
||||
## Problem
|
||||
A user creates a calendar reminder through chat with the agent; the event is persisted to the SQLite DB successfully, but opening the calendar tab in the UI does not show the newly created event. The calendar view never learns about agent-driven creations until the user manually refreshes the page.
|
||||
|
||||
## Symptoms
|
||||
- Agent chat confirms event creation (and the row is present in the calendar DB), but the calendar tab still shows the previous event list.
|
||||
- Switching to the calendar tab immediately after creation shows nothing new.
|
||||
- A full page refresh is required to see the event — proving the data is correct, only the live UI update is missing.
|
||||
|
||||
## What Didn't Work
|
||||
- **Suspected a missing frontend handler.** Initial instinct was that the calendar store had no case for `calendar_event_created`. A grep over `stores/calendar.ts` disproved this: `handleWsEvent` already has a `calendar_event_created` branch at line 209 that pushes the event into the reactive `events` array. The handler was never the problem.
|
||||
- **Suspected the chat store didn't dispatch calendar events.** The next hypothesis was that the chat WS router swallowed `calendar_*` messages. Inspection of `stores/chat.ts:1860-1868` showed it already forwards `calendar_*` events (including `calendar_event_created`) to `_getCalendarStore().handleWsEvent(data)`. The dispatch wiring exists end-to-end.
|
||||
- **Suspected the calendar panel didn't reload on tab switch.** This is a real latent issue (`CalendarPanel.vue:205` only calls `store.loadEvents()` in `onMounted`, and CalendarTab lives inside QuadrantPanel's slot in `AgentLayout.vue:66-84`, so tab switching does not re-mount the component) — but it is not the root cause of the agent-creation miss. Even a freshly mounted panel would still rely on a WS push for live updates; the actual gap was upstream.
|
||||
|
||||
In every case the frontend side of the chain was complete. The gap was backend-only: `CalendarService.create_event` never emitted the WS message in the first place.
|
||||
|
||||
## Solution
|
||||
Three minimal edits, all threading an existing WebSocket fan-out closure into `CalendarService` so `create_event` can broadcast `calendar_event_created` to the user's open chat tabs.
|
||||
|
||||
**Edit 1 — `src/agentkit/calendar/service.py:84-95` — accept an optional `notify_callback`:**
|
||||
|
||||
```python
|
||||
def __init__(
|
||||
self,
|
||||
db_path: str | Path | None = None,
|
||||
auth_db_path: str | Path | None = None,
|
||||
notify_callback: Callable[[str, dict[str, object]], Awaitable[None]] | None = None,
|
||||
) -> None:
|
||||
self.db_path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
|
||||
self.auth_db_path = Path(auth_db_path) if auth_db_path is not None else DEFAULT_AUTH_DB_PATH
|
||||
# Optional WS broadcast callback — wired by app.py so create_event
|
||||
# can push `calendar_event_created` to the user's open chat tabs
|
||||
# without the service depending on the portal module.
|
||||
self._notify = notify_callback
|
||||
```
|
||||
|
||||
The corresponding import was added at the top of the file:
|
||||
|
||||
```python
|
||||
from collections.abc import Awaitable, Callable
|
||||
```
|
||||
|
||||
**Edit 2 — `src/agentkit/calendar/service.py:183-199` — best-effort broadcast before returning from `create_event`:**
|
||||
|
||||
```python
|
||||
logger.info(f"Created event {event.id} ({title}) for user {user_id}")
|
||||
# Broadcast to the user's open chat tabs so the calendar view
|
||||
# refreshes in real time without a manual reload. Best-effort:
|
||||
# WS delivery failure must not roll back the successful insert.
|
||||
if self._notify is not None:
|
||||
try:
|
||||
await self._notify(
|
||||
user_id,
|
||||
{"type": "calendar_event_created", "data": {"event": event.to_dict()}},
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"calendar_event_created broadcast failed for event %s",
|
||||
event.id,
|
||||
exc_info=True,
|
||||
)
|
||||
return event
|
||||
```
|
||||
|
||||
**Edit 3 — `src/agentkit/server/app.py:435-448` — reorder the closure definition before `CalendarService` instantiation and inject it:**
|
||||
|
||||
```python
|
||||
await init_calendar_db()
|
||||
|
||||
# Wire portal WebSocket fan-out so calendar events reach the user's
|
||||
# open chat tab(s) in real time. Shared by CalendarService (for
|
||||
# create_event broadcasts) and ReminderScheduler (for reminders).
|
||||
async def _calendar_ws_sender(user_id: str, message: dict[str, object]) -> None:
|
||||
await portal.send_to_user(user_id, message)
|
||||
|
||||
cal_service = CalendarService(notify_callback=_calendar_ws_sender)
|
||||
app.state.calendar_service = cal_service
|
||||
|
||||
calendar_scheduler = ReminderScheduler(
|
||||
dispatcher=ReminderDispatcher(ws_sender=_calendar_ws_sender)
|
||||
)
|
||||
```
|
||||
|
||||
The `_calendar_ws_sender` closure and `portal.send_to_user` already existed — only the ordering changed (the closure must be defined before `CalendarService` is constructed) and the callback was threaded into the service.
|
||||
|
||||
## Why This Works
|
||||
- **The frontend chain was already complete.** WS arrival → chat store dispatch (`chat.ts:1866`) → calendar store `handleWsEvent` (`calendar.ts:209`) → push into the `events` array → reactive UI update. The only missing link was the backend emit, which Edit 2 adds. Once the message reaches the portal, the rest fires automatically.
|
||||
- **`CalendarService` stays decoupled from the portal module.** The service takes a `notify_callback` rather than importing `portal` directly, preserving layering. `app.py` is the composition root that wires the closure.
|
||||
- **Shared broadcast channel.** Reusing the existing `_calendar_ws_sender` closure means `CalendarService` and `ReminderScheduler` fan out through the same `portal.send_to_user` path. Mirrors the pattern already used by sync providers (which broadcast `calendar_sync_conflict` via the same `notify_callback` injection shape).
|
||||
- **Best-effort broadcast preserves the audit trail.** WS delivery failure (user offline, no open tab, transient portal error) is logged at warning level but does not roll back the successful DB insert. The event is durable; the user just doesn't get a live refresh for that single creation, which is acceptable — the next manual `loadEvents()` will show it.
|
||||
- **Covers both creation paths.** Both the agent tool path (`tools/calendar_tool.py` → `CalendarService.create_event`) and the REST route path (`server/routes/calendar.py` → `CalendarService.create_event`) are thin wrappers over the service, so both now broadcast.
|
||||
|
||||
## Prevention
|
||||
- **Trace WS event chains end-to-end.** When adding a new WS event type the frontend should react to, trace the full chain: backend emit → portal broadcast → chat store dispatch → domain store handler → UI. A handler on the frontend with no matching emit on the backend is a silent dead path — exactly this bug. The reverse (an emit with no handler) at least logs an unhandled-event warning.
|
||||
- **Default to the `notify_callback` injection pattern for state-mutating services.** Any service whose mutations the frontend renders should accept an optional broadcast callback wired at the composition root. `CalendarService` now has it for `create_event`; the same shape should be extended to `update_event` and `delete_event` if live multi-tab consistency becomes a requirement.
|
||||
- **Regression tests guard the fix:**
|
||||
- `tests/unit/calendar/test_service.py::test_create_event_broadcasts_via_notify_callback` — verifies the callback fires exactly once with the correct payload shape (`{"type": "calendar_event_created", "data": {"event": ...}}`).
|
||||
- `tests/unit/calendar/test_service.py::test_create_event_without_callback_does_not_raise` — verifies the default (no callback) path still works for existing callers that don't pass `notify_callback`.
|
||||
- **Separate latent fragility — out of scope.** `CalendarPanel.vue` only calls `store.loadEvents()` in `onMounted`; switching tabs does not re-mount the component (it lives in QuadrantPanel's slot), so the panel never reloads after initial mount. The WS broadcast fix papers over this for the agent-creation case, but a future hardening would be to also reload on tab activation. Tracked separately; not part of this fix.
|
||||
|
||||
## Verification
|
||||
- 132 existing calendar unit tests pass (no regressions).
|
||||
- 2 new regression tests pass.
|
||||
- `ruff check src/agentkit/calendar/service.py src/agentkit/server/app.py` clean.
|
||||
- Manual end-to-end verification in the GUI pending (user will test).
|
||||
|
||||
## Related Issues
|
||||
This is the **third** distinct root cause documented for the same symptom ("agent creates calendar event → UI does not show it"). When investigating this symptom, check all three:
|
||||
|
||||
1. [calendar-capability-and-ui-fixes.md](../logic-errors/calendar-capability-and-ui-fixes.md) — `CalendarTool` built `reminder_rules` but dropped them in `create_event`, and the default agent was created before `CalendarTool` registration so the LLM never saw the tool.
|
||||
2. [jwt-secret-dev-mode-user-id-mismatch.md](../integration-issues/jwt-secret-dev-mode-user-id-mismatch.md) — JWT secret unset in dev mode → `user_id=None` in queries vs LLM-hallucinated `user_id` in DB rows.
|
||||
3. **This doc** — `CalendarService.create_event` had no `notify_callback` and never broadcast `calendar_event_created`.
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
---
|
||||
title: "Tauri reload redirects to login (session lost)"
|
||||
date: 2026-06-29
|
||||
category: docs/solutions/ui-bugs/
|
||||
module: frontend/auth
|
||||
problem_type: ui_bug
|
||||
component: authentication
|
||||
symptoms:
|
||||
- "After login, reloading the Tauri WebView redirects to /login"
|
||||
- "Browser console shows CORS errors and 401 on /api/v1/llm/models"
|
||||
- "Backend logs show no /api/v1/auth/whoami request after reload"
|
||||
- "getRefreshToken() returns null despite a prior successful login"
|
||||
root_cause: logic_error
|
||||
resolution_type: code_fix
|
||||
severity: high
|
||||
tags: [tauri, reload, session, auth, keychain, fallback, cors, localstorage]
|
||||
---
|
||||
|
||||
# Tauri reload redirects to login (session lost)
|
||||
|
||||
## Problem
|
||||
In the Tauri desktop app (Vue 3 + TypeScript frontend, Python FastAPI sidecar backend), users were redirected to `/login` every time they right-clicked and reloaded the page after logging in. The refresh token — persisted via `tauriAuthStorage` to OS Keychain in Tauri (localStorage in Web) — was effectively lost on every reload, so `startupCheck()` could not rehydrate the session and the router guard fell through to the login route.
|
||||
|
||||
## Symptoms
|
||||
- Right-click → Reload after login always redirects back to `/login`, even though login just succeeded.
|
||||
- Backend logs after reload showed **no `/api/v1/auth/whoami` request**, proving the refresh token never reached the rehydrate step.
|
||||
- Dev console showed 401 responses on `/api/v1/llm/models` with missing CORS headers (browser blocked the cross-origin response body).
|
||||
- `ChatInput.vue`'s `fetchModels()` fired on a raw `fetch()` with no `Authorization` header, triggering the 401 above.
|
||||
- Tauri Rust shell (`src-tauri/src/lib.rs`) only registered 4 sidecar commands (`start_backend`, `get_backend_port`, `stop_backend`, `check_backend_health`) — the keychain commands (`store_refresh_token` / `load_refresh_token` / `clear_refresh_token`) were never registered, and no `auth.rs` file existed.
|
||||
|
||||
## What Didn't Work
|
||||
- **Fixing the `/llm/models` 401 alone did not stop reload→`/login`.** The 401 was a symptom of the missing auth header on one fetch, not the cause of session loss. After attaching the Authorization header the models call succeeded, but reload still bounced to login.
|
||||
- **Adding debug logs and asking the user to test** was rejected by the user ("you can do this test yourself"). The key evidence — no `/auth/whoami` request in backend logs after reload — was already available and pointed directly at `getRefreshToken()` returning `null`. Switching to static code analysis of `tauri-auth.ts` exposed the fallback logic flaw without a runtime round-trip.
|
||||
- **Registering the keychain Rust commands** (`auth.rs` + `keyring` crate) was considered as the fix, but it is a larger change with its own failure modes (permission prompts, OS keyring unavailable). The localStorage-always-backup fix is smaller, sufficient, and strictly more robust.
|
||||
|
||||
## Solution
|
||||
|
||||
### 1. CORS in dev mode (base.ts)
|
||||
|
||||
`initApiBaseURL()` in `src/agentkit/server/frontend/src/api/base.ts` unconditionally set `_dynamicBaseURL = http://127.0.0.1:${port}` whenever `isTauri()` was true — even in dev mode. This bypassed the Vite dev server proxy (which forwards `/api` → `http://localhost:8000` same-origin) and sent requests directly to port 8000. Auth failures returned 401 without CORS headers, and the browser blocked the response body, making the failure look like a network error.
|
||||
|
||||
**Before:**
|
||||
```ts
|
||||
export async function initApiBaseURL(): Promise<void> {
|
||||
if (isTauri()) {
|
||||
const port = await getBackendPort()
|
||||
_dynamicBaseURL = `http://127.0.0.1:${port}`
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**After — guard with `!import.meta.env.DEV`:**
|
||||
```ts
|
||||
export async function initApiBaseURL(): Promise<void> {
|
||||
if (isTauri() && !import.meta.env.DEV) {
|
||||
const port = await getBackendPort()
|
||||
_dynamicBaseURL = `http://127.0.0.1:${port}`
|
||||
}
|
||||
// In dev mode, _dynamicBaseURL stays empty — requests use relative URLs
|
||||
// which the Vite proxy forwards to the backend (same-origin, no CORS).
|
||||
}
|
||||
```
|
||||
|
||||
In dev, `_dynamicBaseURL` stays empty so all requests use relative URLs and flow through the Vite proxy. In Tauri production (where there is no Vite dev server), the dynamic port is resolved from the sidecar as before.
|
||||
|
||||
### 2. Missing Authorization header on /llm/models (ChatInput.vue)
|
||||
|
||||
`ChatInput.vue`'s `fetchModels()` called `fetch(url)` directly, bypassing `ApiClient` (which extends `BaseApiClient` and auto-attaches the `Authorization` header via the token provider). The backend's `AuthMiddleware` rejected the bare request with 401.
|
||||
|
||||
**Before:**
|
||||
```ts
|
||||
async function fetchModels() {
|
||||
const res = await fetch(url) // no Authorization header
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**After — add `listModels()` to `ApiClient` and use it:**
|
||||
|
||||
```ts
|
||||
// client.ts
|
||||
async listModels(): Promise<IModelsResponse> {
|
||||
return this.request<IModelsResponse>('/api/v1/llm/models')
|
||||
}
|
||||
|
||||
// ChatInput.vue
|
||||
async function fetchModels() {
|
||||
modelsLoading.value = true
|
||||
try {
|
||||
const data = await apiClient.listModels()
|
||||
availableModels.value = data.models || []
|
||||
selectedModel.value = data.default || (availableModels.value.length > 0 ? availableModels.value[0].id : undefined)
|
||||
} catch {
|
||||
availableModels.value = []
|
||||
} finally {
|
||||
modelsLoading.value = false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Every authenticated endpoint must go through `ApiClient` so the token provider can attach the header consistently. Raw `fetch()` calls to `/api/v1/*` are an anti-pattern in this codebase.
|
||||
|
||||
### 3. Keychain fallback logic flaw (tauri-auth.ts) — the core reload bug
|
||||
|
||||
`tauriAuthStorage` in `src/agentkit/server/frontend/src/api/tauri-auth.ts` had a fatal fallback defect. Because the Tauri Rust shell does not register the keychain commands, `tauriInvoke` for those commands returns `undefined` **without throwing**. The old logic assumed "if `invoke` doesn't throw, it succeeded" — that assumption was wrong.
|
||||
|
||||
**Before — the broken fallback:**
|
||||
```ts
|
||||
async setRefreshToken(token: string): Promise<void> {
|
||||
if (isTauri()) {
|
||||
try {
|
||||
await tauriInvoke<void>('store_refresh_token', { token })
|
||||
return // ← BUG: if invoke doesn't throw (returns undefined), returns WITHOUT writing localStorage
|
||||
} catch (e) { ... }
|
||||
}
|
||||
localSet(token) // ← only runs if isTauri()=false OR invoke threw
|
||||
}
|
||||
|
||||
async getRefreshToken(): Promise<string | null> {
|
||||
if (isTauri()) {
|
||||
try {
|
||||
const value = await tauriInvoke<string | null>('load_refresh_token')
|
||||
return value ?? null // ← BUG: if invoke returns undefined, returns null WITHOUT reading localStorage
|
||||
} catch (e) { ... }
|
||||
}
|
||||
return localGet() // ← only runs if isTauri()=false OR invoke threw
|
||||
}
|
||||
```
|
||||
|
||||
When the keychain commands are unregistered, `setRefreshToken` returned early without writing localStorage, and `getRefreshToken` returned `null` without reading localStorage. On reload, `startupCheck()` got `null`, never called `/auth/whoami`, and the router redirected to `/login`.
|
||||
|
||||
**After — always write localStorage first (durable backup), then attempt keychain (best-effort):**
|
||||
|
||||
```ts
|
||||
async setRefreshToken(token: string): Promise<void> {
|
||||
localSet(token) // ← ALWAYS write localStorage first
|
||||
if (isTauri()) {
|
||||
try {
|
||||
await tauriInvoke<void>('store_refresh_token', { token })
|
||||
} catch (e) {
|
||||
console.warn('[auth] Keychain write failed, localStorage backup used', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getRefreshToken(): Promise<string | null> {
|
||||
if (isTauri()) {
|
||||
try {
|
||||
const value = await tauriInvoke<string | null>('load_refresh_token')
|
||||
if (value) return value // ← only return if truthy; otherwise fallback
|
||||
} catch (e) {
|
||||
console.warn('[auth] Keychain read failed, falling back to localStorage', e)
|
||||
}
|
||||
}
|
||||
return localGet() // ← fallback when keychain fails OR returns empty
|
||||
}
|
||||
|
||||
async clearRefreshToken(): Promise<void> {
|
||||
localRemove() // ← ALWAYS clear localStorage
|
||||
if (isTauri()) {
|
||||
try {
|
||||
await tauriInvoke<void>('clear_refresh_token')
|
||||
} catch (e) {
|
||||
console.warn('[auth] Keychain clear failed, localStorage already cleared', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Why This Works
|
||||
|
||||
The reload→`/login` chain was driven by the keychain fallback flaw, not by the 401. Tracing the failure path:
|
||||
|
||||
1. On login, `setRefreshToken(token)` is called. Because the keychain commands are unregistered in the Rust shell, `tauriInvoke('store_refresh_token', ...)` returns `undefined` **without throwing**.
|
||||
2. The old `setRefreshToken` treated "did not throw" as "succeeded" and `return`ed — **localStorage was never written**.
|
||||
3. On reload, `startupCheck()` calls `getRefreshToken()`. `tauriInvoke('load_refresh_token')` again returns `undefined` without throwing.
|
||||
4. The old `getRefreshToken` returned `value ?? null` → `null`, **never falling through to `localGet()`** (which would also have returned `null` anyway, because nothing was written).
|
||||
5. `startupCheck()` sees `null`, skips the `/auth/whoami` call, leaves `isAuthenticated=false`.
|
||||
6. The router guard in `router.beforeEach` redirects to `/login`.
|
||||
|
||||
The critical behavioral assumption — "an unregistered Tauri command throws" — was wrong. Tauri's `tauriInvoke` may resolve to `undefined` for unregistered commands in some configurations; treating "no throw" as "succeeded" is unsafe for any best-effort native bridge.
|
||||
|
||||
The "always write localStorage first" pattern is robust because:
|
||||
|
||||
- **localStorage is the durable source of truth.** It is always available in the WebView, has no permission prompts, and has no native-bridge layer that can silently no-op.
|
||||
- **The keychain becomes a best-effort upgrade**, not a load-bearing dependency. If it works, the token is stored more securely; if it fails (unregistered command, OS keyring locked, permission denied, native module missing), the localStorage copy still preserves the session.
|
||||
- **`getRefreshToken` falls back to localStorage whenever the keychain returns a falsy value OR throws.** The `if (value) return value` guard treats `undefined`/`null`/`""` as failure, which is the correct contract for an unregistered-or-broken command.
|
||||
- **The same pattern applies to `clearRefreshToken`** — always clear localStorage so a broken keychain does not leave stale tokens behind.
|
||||
|
||||
The 401 fix (root cause 2) and the CORS fix (root cause 1) are independent hygiene improvements: they remove noise from the failure path so the real cause (no `whoami` request after reload) is observable in backend logs. But neither, by itself, would have stopped reload→`/login`.
|
||||
|
||||
## Prevention
|
||||
- **Write the durable backup FIRST, before the best-effort premium store.** Any storage with a native-bridge dependency (Keychain, Keystore, OS credential vault) must be treated as an upgrade over a durable web-storage fallback — never as the only copy.
|
||||
- **Treat `undefined` from `tauriInvoke` as failure.** When a command may be unregistered, "did not throw" is not "succeeded." Use a truthy check on the return value (`if (value) return value`) and fall through to the fallback otherwise.
|
||||
- **For auth state, verify the full reload path with backend log inspection.** The UI can lie (cached tokens, in-memory state). After a reload, the only trustworthy signal is whether `/api/v1/auth/whoami` was actually called and what it returned. Absence of the request means the token never left the client.
|
||||
- **Add a unit test that mocks `tauriInvoke` returning `undefined`** and asserts: (a) `setRefreshToken` still writes localStorage, (b) `getRefreshToken` returns the localStorage value, (c) `clearRefreshToken` still removes the localStorage key. This is the smallest check that fails if the fallback logic regresses.
|
||||
- **Route all authenticated fetches through `ApiClient`.** Any raw `fetch('/api/v1/...')` is a defect waiting to happen — the `BaseApiClient` token provider is the single point that attaches the `Authorization` header.
|
||||
- **In dev, prefer same-origin requests via the Vite proxy over direct backend URLs.** `initApiBaseURL` should be a no-op in dev; the proxy handles same-origin translation and avoids CORS entirely.
|
||||
|
||||
## Related Issues
|
||||
- Sidecar missing `serve` subcommand — Tauri production launch relies on the sidecar binary exposing a `serve` mode; this is unrelated to the reload bug but was discovered during investigation.
|
||||
- Keychain Rust commands not registered — `src-tauri/src/lib.rs` registers only 4 sidecar commands; `store_refresh_token` / `load_refresh_token` / `clear_refresh_token` are not wired up (no `auth.rs` exists). The localStorage-always-backup fix makes this non-blocking, but registering them (with `keyring` crate) would restore the intended secure-storage path.
|
||||
- Chat handler not passing `user_id` — observed during log inspection but not addressed by this fix; tracked separately.
|
||||
- Related doc: `docs/solutions/integration-issues/jwt-secret-dev-mode-user-id-mismatch.md` — same symptom (reload→/login) in the web/JWT-secret context. That doc's reload fix is scoped to the JWT-secret cause; this doc covers the Tauri-specific frontend causes.
|
||||
|
|
@ -10,6 +10,7 @@ from __future__ import annotations
|
|||
import dataclasses
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
|
@ -84,9 +85,14 @@ class CalendarService:
|
|||
self,
|
||||
db_path: str | Path | None = None,
|
||||
auth_db_path: str | Path | None = None,
|
||||
notify_callback: Callable[[str, dict[str, object]], Awaitable[None]] | None = None,
|
||||
) -> None:
|
||||
self.db_path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
|
||||
self.auth_db_path = Path(auth_db_path) if auth_db_path is not None else DEFAULT_AUTH_DB_PATH
|
||||
# Optional WS broadcast callback — wired by app.py so create_event
|
||||
# can push `calendar_event_created` to the user's open chat tabs
|
||||
# without the service depending on the portal module.
|
||||
self._notify = notify_callback
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Event CRUD
|
||||
|
|
@ -175,6 +181,21 @@ class CalendarService:
|
|||
)
|
||||
|
||||
logger.info(f"Created event {event.id} ({title}) for user {user_id}")
|
||||
# Broadcast to the user's open chat tabs so the calendar view
|
||||
# refreshes in real time without a manual reload. Best-effort:
|
||||
# WS delivery failure must not roll back the successful insert.
|
||||
if self._notify is not None:
|
||||
try:
|
||||
await self._notify(
|
||||
user_id,
|
||||
{"type": "calendar_event_created", "data": {"event": event.to_dict()}},
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"calendar_event_created broadcast failed for event %s",
|
||||
event.id,
|
||||
exc_info=True,
|
||||
)
|
||||
return event
|
||||
|
||||
async def get_event(self, event_id: str) -> CalendarEvent | None:
|
||||
|
|
|
|||
|
|
@ -309,11 +309,22 @@ class SqliteConversationStore:
|
|||
row = await cursor.fetchone()
|
||||
if row is None:
|
||||
return False
|
||||
await db.execute(
|
||||
"DELETE FROM messages WHERE conversation_id = ?", (conversation_id,)
|
||||
)
|
||||
await db.execute("DELETE FROM conversations WHERE id = ?", (conversation_id,))
|
||||
await db.commit()
|
||||
# ponytail: wrap both DELETEs in a try/except with rollback —
|
||||
# previously the second DELETE failure would leave orphaned
|
||||
# messages (conversation row gone, messages lingering) because
|
||||
# the first DELETE already auto-committed in autocommit mode.
|
||||
# aiosqlite uses autocommit=False by default but explicit rollback
|
||||
# makes the failure path safe and observable.
|
||||
try:
|
||||
await db.execute(
|
||||
"DELETE FROM messages WHERE conversation_id = ?", (conversation_id,)
|
||||
)
|
||||
await db.execute("DELETE FROM conversations WHERE id = ?", (conversation_id,))
|
||||
await db.commit()
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
logger.exception("Failed to delete conversation %s; rolled back", conversation_id)
|
||||
raise
|
||||
self._cache.pop(conversation_id, None)
|
||||
return True
|
||||
|
||||
|
|
|
|||
|
|
@ -187,7 +187,10 @@ async def lifespan(app: FastAPI):
|
|||
from agentkit.documents.db import init_documents_db
|
||||
from agentkit.documents.renderers.word_renderer import WordRenderer
|
||||
from agentkit.documents.renderers.excel_renderer import ExcelRenderer
|
||||
from agentkit.documents.renderers.pdf_renderer import PDFRenderer
|
||||
# ponytail: PDFRenderer is imported lazily inside the try block below —
|
||||
# reportlab is an optional dependency, and importing it eagerly here
|
||||
# (outside the try) makes the whole lifespan fail when reportlab is
|
||||
# absent, breaking every E2E test that boots the server.
|
||||
|
||||
# Initialize memory store and build system prompt
|
||||
memory_store = MemoryStore()
|
||||
|
|
@ -268,7 +271,15 @@ async def lifespan(app: FastAPI):
|
|||
doc_service = DocumentService()
|
||||
doc_service.register_renderer("word", WordRenderer())
|
||||
doc_service.register_renderer("excel", ExcelRenderer())
|
||||
doc_service.register_renderer("pdf", PDFRenderer())
|
||||
# ponytail: reportlab is an optional dep — lazy import so a
|
||||
# missing reportlab only disables PDF rendering, not the whole
|
||||
# DocumentTool / lifespan startup.
|
||||
try:
|
||||
from agentkit.documents.renderers.pdf_renderer import PDFRenderer
|
||||
|
||||
doc_service.register_renderer("pdf", PDFRenderer())
|
||||
except ImportError:
|
||||
logger.warning("reportlab not installed — PDF renderer disabled")
|
||||
agent._tool_registry.register(DocumentTool(service=doc_service))
|
||||
app.state.document_service = doc_service
|
||||
logger.info("DocumentTool registered with word/excel/pdf renderers")
|
||||
|
|
@ -422,14 +433,16 @@ async def lifespan(app: FastAPI):
|
|||
from agentkit.tools.calendar_tool import CalendarTool
|
||||
|
||||
await init_calendar_db()
|
||||
cal_service = CalendarService()
|
||||
app.state.calendar_service = cal_service
|
||||
|
||||
# Wire portal WebSocket fan-out so calendar reminders reach the user's
|
||||
# open chat tab(s) in real time.
|
||||
# Wire portal WebSocket fan-out so calendar events reach the user's
|
||||
# open chat tab(s) in real time. Shared by CalendarService (for
|
||||
# create_event broadcasts) and ReminderScheduler (for reminders).
|
||||
async def _calendar_ws_sender(user_id: str, message: dict[str, object]) -> None:
|
||||
await portal.send_to_user(user_id, message)
|
||||
|
||||
cal_service = CalendarService(notify_callback=_calendar_ws_sender)
|
||||
app.state.calendar_service = cal_service
|
||||
|
||||
calendar_scheduler = ReminderScheduler(
|
||||
dispatcher=ReminderDispatcher(ws_sender=_calendar_ws_sender)
|
||||
)
|
||||
|
|
@ -437,9 +450,36 @@ async def lifespan(app: FastAPI):
|
|||
app.state.calendar_scheduler = calendar_scheduler
|
||||
# Register CalendarTool so ReAct agents can create/query events.
|
||||
try:
|
||||
calendar_tool = CalendarTool(calendar_service=cal_service)
|
||||
# ponytail: resolve default user_id for CalendarTool from auth.db
|
||||
# (single-user dev-mode simplification; multi-user needs per-request
|
||||
# context passing through the agent framework).
|
||||
default_cal_user_id: str | None = None
|
||||
try:
|
||||
import aiosqlite as _aiosqlite
|
||||
|
||||
from agentkit.server.auth.models import DEFAULT_AUTH_DB_PATH
|
||||
|
||||
async with _aiosqlite.connect(str(DEFAULT_AUTH_DB_PATH)) as _db:
|
||||
_db.row_factory = _aiosqlite.Row
|
||||
_cur = await _db.execute(
|
||||
"SELECT id FROM users WHERE is_active = 1 "
|
||||
"ORDER BY CASE role WHEN 'admin' THEN 0 ELSE 1 END, created_at LIMIT 1"
|
||||
)
|
||||
_row = await _cur.fetchone()
|
||||
if _row is not None:
|
||||
default_cal_user_id = str(_row["id"])
|
||||
except Exception:
|
||||
logger.debug("Could not resolve default user_id for CalendarTool", exc_info=True)
|
||||
|
||||
calendar_tool = CalendarTool(
|
||||
calendar_service=cal_service,
|
||||
default_user_id=default_cal_user_id,
|
||||
)
|
||||
app.state.tool_registry.register(calendar_tool)
|
||||
logger.info("CalendarTool registered for ReAct integration")
|
||||
logger.info(
|
||||
"CalendarTool registered for ReAct integration (default_user_id=%s)",
|
||||
default_cal_user_id,
|
||||
)
|
||||
# The default GUI agent was created above (before the calendar
|
||||
# subsystem was wired up) and cached a tool list that does NOT
|
||||
# include the calendar tool. Re-register it on the default agent
|
||||
|
|
@ -453,7 +493,9 @@ async def lifespan(app: FastAPI):
|
|||
try:
|
||||
default_agent._tool_registry.register(calendar_tool)
|
||||
except Exception:
|
||||
pass # already registered
|
||||
# ponytail: log at debug — CalendarTool double-registration
|
||||
# is expected on reload, but silent pass hides real errors.
|
||||
logger.debug("CalendarTool already registered or registration failed", exc_info=True)
|
||||
# Strip any existing "## 可用工具" section to avoid
|
||||
# duplicate tool blocks in the system prompt.
|
||||
base_prompt = getattr(default_agent, "_system_prompt", None) or (
|
||||
|
|
@ -798,7 +840,17 @@ def create_app(
|
|||
# that login (signing), whoami (verifying), and the middleware all use
|
||||
# the same secret. Without this, get_or_create_jwt_secret() would mint
|
||||
# a different random secret on every call and tokens could never verify.
|
||||
jwt_secret = get_jwt_secret() or get_or_create_jwt_secret()
|
||||
#
|
||||
# ponytail: ephemeral secret is for token-signing only — passing it to
|
||||
# AuthMiddleware would activate JWT verification in dev mode, breaking
|
||||
# _is_dev_mode() and rejecting unauthenticated requests with 401 (root
|
||||
# cause of test_portal_routes 17 failures). Only explicit AGENTKIT_JWT_SECRET
|
||||
# activates middleware JWT verification; ephemeral stays on app.state for
|
||||
# signing routes (auth.py). Ceiling: dev tokens signed with ephemeral
|
||||
# secret still verify if a client somehow obtains them, but ephemeral
|
||||
# secret is non-persistent and invalidated on restart by design.
|
||||
explicit_jwt_secret = get_jwt_secret() # None when AGENTKIT_JWT_SECRET unset
|
||||
jwt_secret = explicit_jwt_secret or get_or_create_jwt_secret() # for signing
|
||||
client_keys: dict[str, str] = {}
|
||||
try:
|
||||
from agentkit.server.middleware import _load_client_keys
|
||||
|
|
@ -809,7 +861,7 @@ def create_app(
|
|||
|
||||
app.add_middleware(
|
||||
AuthMiddleware,
|
||||
jwt_secret=jwt_secret or "",
|
||||
jwt_secret=explicit_jwt_secret or "", # only explicit secret activates JWT verify
|
||||
api_key=effective_api_key,
|
||||
client_keys=client_keys,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -193,6 +193,11 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
|||
"user_id": payload.get("sub"),
|
||||
"username": payload.get("username"),
|
||||
"role": payload.get("role"),
|
||||
# ponytail: sid must be set here too — the Bearer path
|
||||
# above (line ~178) sets it; without it, downstream
|
||||
# require_authenticated treats WS clients as legacy
|
||||
# sessions, breaking session revocation & audit logs.
|
||||
"sid": payload.get("sid"),
|
||||
}
|
||||
return await call_next(request)
|
||||
|
||||
|
|
@ -220,6 +225,18 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
|||
"Set AGENTKIT_JWT_SECRET or server.api_key for production."
|
||||
)
|
||||
self._dev_mode_warned = True
|
||||
# Set a synthetic dev user so that route-level dependencies
|
||||
# (require_authenticated, require_permission) work in dev mode.
|
||||
# Without this, routes using ``Depends(require_authenticated)``
|
||||
# get ``current_user is None`` → 401, while routes using
|
||||
# ``Depends(_verify_api_key)`` pass — an inconsistent split
|
||||
# that breaks calendar/admin/auth-session endpoints in dev.
|
||||
request.state.current_user = {
|
||||
"user_id": None,
|
||||
"username": "dev",
|
||||
"role": "admin",
|
||||
"dev_mode": True,
|
||||
}
|
||||
return await call_next(request)
|
||||
|
||||
# 5. Unauthorized
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
/**
|
||||
* E2E tests for Admin DashboardView — the /admin/dashboard route.
|
||||
*
|
||||
* Navigation: login via UI form → click TopNav admin console icon (SPA
|
||||
* navigate to /admin/dashboard). The admin button is rendered only when
|
||||
* authStore.isAdmin() is true; the test user has the admin role.
|
||||
*
|
||||
* ponytail: page.goto('/admin/dashboard') would trigger the whoami
|
||||
* cold-start bug (refresh token passed as RequestInit property). SPA
|
||||
* navigation via TopNav preserves the in-memory access token.
|
||||
*/
|
||||
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import { TEST_USER, clearAuth } from './helpers'
|
||||
|
||||
async function loginAndOpenAdmin(page: Page): Promise<void> {
|
||||
await page.goto('/login')
|
||||
await clearAuth(page)
|
||||
await page.getByPlaceholder('请输入用户名').fill(TEST_USER.username)
|
||||
await page.getByPlaceholder('请输入密码').fill(TEST_USER.password)
|
||||
await page.getByRole('button', { name: /登\s*录/ }).click()
|
||||
await expect(page).toHaveURL(/\/agent/, { timeout: 15_000 })
|
||||
|
||||
// SPA-navigate to /admin/dashboard via the TopNav admin console button.
|
||||
// exact: true is needed because the chat input bar also has a button named
|
||||
// "team 私董会" which would match a substring search for "team".
|
||||
await page.getByRole('button', { name: 'team', exact: true }).click()
|
||||
await expect(page).toHaveURL(/\/admin\/dashboard/, { timeout: 15_000 })
|
||||
await expect(page.locator('.dashboard-view')).toBeVisible({ timeout: 15_000 })
|
||||
}
|
||||
|
||||
test.describe('Admin Dashboard View E2E', () => {
|
||||
test('A1: admin dashboard loads without white screen or 401 redirect', async ({ page }) => {
|
||||
await loginAndOpenAdmin(page)
|
||||
|
||||
// URL should be /admin/dashboard, not redirected to /login or /agent.
|
||||
await expect(page).toHaveURL(/\/admin\/dashboard/, { timeout: 10_000 })
|
||||
// DashboardView root element visible — no white screen.
|
||||
await expect(page.locator('.dashboard-view')).toBeVisible()
|
||||
})
|
||||
|
||||
test('A2: admin dashboard core elements are visible', async ({ page }) => {
|
||||
await loginAndOpenAdmin(page)
|
||||
|
||||
// The page header "管理概览" should be visible.
|
||||
await expect(page.locator('.dashboard-view__header')).toBeVisible({
|
||||
timeout: 15_000,
|
||||
})
|
||||
// At least one stat card should be rendered (departments/users/skills/kb).
|
||||
await expect(page.locator('.dashboard-view__stat-card').first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
/**
|
||||
* U1 — Authentication state persistence E2E.
|
||||
*
|
||||
* Regression coverage for the "reload jumps to /login" bug (issue #4 from
|
||||
* 2026-06-28): the JWT secret was unset → AuthMiddleware entered dev mode →
|
||||
* the persisted refresh token was silently ignored on cold-start, so every
|
||||
* page reload lost the session.
|
||||
*
|
||||
* These tests exercise the full cold-start path:
|
||||
* persisted refresh token → /auth/whoami → fresh access token → router
|
||||
* guard admits the user to /agent/*.
|
||||
*
|
||||
* ponytail: tests use the UI form for the initial login (same pattern as
|
||||
* calendar.spec.ts) to avoid the localStorage-hydration cold-start race
|
||||
* that affected earlier helpers. Subsequent reloads rely on the refresh
|
||||
* token that the auth store persists to localStorage (Web mode fallback).
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
import {
|
||||
TEST_USER,
|
||||
clearAuth,
|
||||
reloadAndWaitAuth,
|
||||
corruptRefreshToken,
|
||||
} from './helpers'
|
||||
|
||||
test.describe('Auth state persistence', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/login')
|
||||
await clearAuth(page)
|
||||
})
|
||||
|
||||
test('logged-in user survives page reload (cold-start whoami succeeds)', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Login via the UI form so the refresh token is persisted via the
|
||||
// standard code path (tauriAuthStorage → localStorage fallback).
|
||||
await page.getByPlaceholder('请输入用户名').fill(TEST_USER.username)
|
||||
await page.getByPlaceholder('请输入密码').fill(TEST_USER.password)
|
||||
await page.getByRole('button', { name: /登\s*录/ }).click()
|
||||
await expect(page).toHaveURL(/\/agent/, { timeout: 15_000 })
|
||||
|
||||
// Reload — the access token is in memory only, so cold-start MUST
|
||||
// re-issue one from the persisted refresh token. If the JWT secret
|
||||
// is ephemeral or dev mode is active, this reload lands on /login.
|
||||
const finalUrl = await reloadAndWaitAuth(page)
|
||||
expect(finalUrl).toMatch(/\/agent/)
|
||||
expect(finalUrl).not.toMatch(/\/login/)
|
||||
|
||||
// The user avatar / top-nav should be visible (not the login form).
|
||||
await expect(page.getByPlaceholder('请输入用户名')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('cleared localStorage on reload redirects to /login', async ({ page }) => {
|
||||
// Login first to obtain a valid session.
|
||||
await page.getByPlaceholder('请输入用户名').fill(TEST_USER.username)
|
||||
await page.getByPlaceholder('请输入密码').fill(TEST_USER.password)
|
||||
await page.getByRole('button', { name: /登\s*录/ }).click()
|
||||
await expect(page).toHaveURL(/\/agent/, { timeout: 15_000 })
|
||||
|
||||
// Wipe persisted tokens — simulates user clearing site data.
|
||||
await clearAuth(page)
|
||||
|
||||
const finalUrl = await reloadAndWaitAuth(page)
|
||||
expect(finalUrl).toMatch(/\/login/)
|
||||
// Login form should be visible.
|
||||
await expect(page.getByPlaceholder('请输入用户名')).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
|
||||
test('invalid persisted refresh token is rejected and cleared on cold-start', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.getByPlaceholder('请输入用户名').fill(TEST_USER.username)
|
||||
await page.getByPlaceholder('请输入密码').fill(TEST_USER.password)
|
||||
await page.getByRole('button', { name: /登\s*录/ }).click()
|
||||
await expect(page).toHaveURL(/\/agent/, { timeout: 15_000 })
|
||||
|
||||
// Replace the persisted refresh token with a fake — the server should
|
||||
// return 401 from /auth/whoami, the store should transition to
|
||||
// 'invalid' and clear the bad token, then the router redirects to /login.
|
||||
await corruptRefreshToken(page)
|
||||
|
||||
const finalUrl = await reloadAndWaitAuth(page)
|
||||
expect(finalUrl).toMatch(/\/login/)
|
||||
|
||||
// The corrupted token must have been cleared by the store — otherwise
|
||||
// the next reload would retry the same dead token forever.
|
||||
const storedRefresh = await page.evaluate(() =>
|
||||
localStorage.getItem('agentkit.refresh_token'),
|
||||
)
|
||||
expect(storedRefresh).toBeNull()
|
||||
})
|
||||
|
||||
test('protected page requests carry the Authorization header', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.getByPlaceholder('请输入用户名').fill(TEST_USER.username)
|
||||
await page.getByPlaceholder('请输入密码').fill(TEST_USER.password)
|
||||
await page.getByRole('button', { name: /登\s*录/ }).click()
|
||||
await expect(page).toHaveURL(/\/agent/, { timeout: 15_000 })
|
||||
|
||||
// Intercept the next authenticated API call and capture its headers.
|
||||
let capturedAuth: string | null = null
|
||||
page.on('request', (req) => {
|
||||
const h = req.headers()
|
||||
if (h['authorization'] && !capturedAuth) {
|
||||
capturedAuth = h['authorization']
|
||||
}
|
||||
})
|
||||
|
||||
// Trigger an authenticated call by reloading — startupCheck fires
|
||||
// whoami, which itself carries the refresh token. But the more
|
||||
// meaningful signal is the first post-startup data fetch (e.g.
|
||||
// conversations list) which carries the access token.
|
||||
await page.reload()
|
||||
await expect(page).toHaveURL(/\/agent/, { timeout: 20_000 })
|
||||
|
||||
// Wait for at least one authenticated request to land.
|
||||
await expect
|
||||
.poll(async () => (capturedAuth ? true : false), {
|
||||
timeout: 15_000,
|
||||
intervals: [500],
|
||||
})
|
||||
.toBe(true)
|
||||
|
||||
expect(capturedAuth).toBeTruthy()
|
||||
expect(capturedAuth!).toMatch(/^Bearer\s+\S+/)
|
||||
})
|
||||
|
||||
test('access token is never written to localStorage', async ({ page }) => {
|
||||
// Access token must remain in memory only — see stores/auth.ts.
|
||||
// If a regression writes it to localStorage, this test catches it.
|
||||
await page.getByPlaceholder('请输入用户名').fill(TEST_USER.username)
|
||||
await page.getByPlaceholder('请输入密码').fill(TEST_USER.password)
|
||||
await page.getByRole('button', { name: /登\s*录/ }).click()
|
||||
await expect(page).toHaveURL(/\/agent/, { timeout: 15_000 })
|
||||
|
||||
// Give the store a moment to finish persisting post-login.
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
const storedAccess = await page.evaluate(() =>
|
||||
localStorage.getItem('agentkit.access_token'),
|
||||
)
|
||||
expect(storedAccess).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
/**
|
||||
* E2E tests for BitableView — the standalone /bitable route.
|
||||
*
|
||||
* Navigation: login via UI form → click TopNav "多维表格" icon (SPA navigate
|
||||
* to /bitable). This avoids the whoami cold-start bug that page.goto would
|
||||
* trigger on a full reload.
|
||||
*
|
||||
* ponytail: bitable backend may not be fully configured (no DATABASE_URL);
|
||||
* the view should still render its topbar and sidebar/placeholder gracefully.
|
||||
*/
|
||||
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import { TEST_USER, clearAuth } from './helpers'
|
||||
|
||||
async function loginAndOpenBitable(page: Page): Promise<void> {
|
||||
await page.goto('/login')
|
||||
await clearAuth(page)
|
||||
await page.getByPlaceholder('请输入用户名').fill(TEST_USER.username)
|
||||
await page.getByPlaceholder('请输入密码').fill(TEST_USER.password)
|
||||
await page.getByRole('button', { name: /登\s*录/ }).click()
|
||||
await expect(page).toHaveURL(/\/agent/, { timeout: 15_000 })
|
||||
|
||||
// SPA-navigate to /bitable via the TopNav "多维表格" button.
|
||||
await page.getByRole('button', { name: '多维表格' }).click()
|
||||
await expect(page).toHaveURL(/\/bitable/, { timeout: 15_000 })
|
||||
await expect(page.locator('.bitable-view')).toBeVisible({ timeout: 15_000 })
|
||||
}
|
||||
|
||||
test.describe('Bitable View E2E', () => {
|
||||
test('B1: bitable view loads without white screen or 401 redirect', async ({ page }) => {
|
||||
await loginAndOpenBitable(page)
|
||||
|
||||
// URL should be /bitable, not redirected to /login.
|
||||
await expect(page).toHaveURL(/\/bitable/, { timeout: 10_000 })
|
||||
// BitableView root element visible — no white screen.
|
||||
await expect(page.locator('.bitable-view')).toBeVisible()
|
||||
})
|
||||
|
||||
test('B2: bitable core elements are visible', async ({ page }) => {
|
||||
await loginAndOpenBitable(page)
|
||||
|
||||
// The topbar title "多维表格" should be visible.
|
||||
await expect(page.locator('.bitable-view__title')).toContainText('多维表格', {
|
||||
timeout: 15_000,
|
||||
})
|
||||
|
||||
// Either the sidebar (table list) or the placeholder is rendered.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const sidebar = await page.locator('.bitable-view__sidebar').count()
|
||||
const placeholder = await page.locator('.bitable-view__placeholder').count()
|
||||
return sidebar + placeholder
|
||||
},
|
||||
{ timeout: 15_000, intervals: [1_000] },
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,356 @@
|
|||
/**
|
||||
* U3 — Calendar data consistency E2E.
|
||||
*
|
||||
* Regression coverage for issue #2 (2026-06-28): calendar events created
|
||||
* via the API (or agent tool) were not visible in the UI calendar panel.
|
||||
* Root cause was a user_id mismatch — the CalendarTool's default_user_id
|
||||
* resolved to a stale admin row while the UI queried events for the
|
||||
* logged-in JWT user_id.
|
||||
*
|
||||
* Strategy:
|
||||
* - Create events via the REST API (POST /calendar/events) as the test
|
||||
* user (JWT-scoped) — this mirrors the "user creates event" path.
|
||||
* - Verify the events appear in the UI calendar panel.
|
||||
* - Verify user_id integrity: the event's user_id must match the logged-
|
||||
* in user, not a hallucinated "default" / "zhangsan" value.
|
||||
* - Verify delete syncs between API and UI.
|
||||
*
|
||||
* ponytail: the agent-created path (ReAct → CalendarTool) uses
|
||||
* default_user_id which is resolved once at server startup from the
|
||||
* first admin row in auth.db. In dev/test mode with a stale admin user,
|
||||
* this may NOT match the logged-in JWT user_id — a known single-user
|
||||
* simplification (see app.py:440 comment). We do NOT test the agent
|
||||
* path here; that's covered by the calendar.spec.ts agent-flow tests.
|
||||
*/
|
||||
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import { TEST_USER, clearAuth } from './helpers'
|
||||
|
||||
// ── API helpers ────────────────────────────────────────────────────────
|
||||
|
||||
const API_BASE = 'http://127.0.0.1:8000/api/v1'
|
||||
const CALENDAR_BASE = `${API_BASE}/calendar`
|
||||
const AUTH_BASE = `${API_BASE}/auth`
|
||||
|
||||
let _cachedToken: string | null = null
|
||||
let _cachedUserId: string | null = null
|
||||
|
||||
async function getAccessToken(): Promise<string> {
|
||||
if (_cachedToken) return _cachedToken
|
||||
const resp = await fetch(`${AUTH_BASE}/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: TEST_USER.username,
|
||||
password: TEST_USER.password,
|
||||
}),
|
||||
})
|
||||
if (!resp.ok) throw new Error(`Login failed: ${resp.status}`)
|
||||
const data = (await resp.json()) as { access_token: string }
|
||||
_cachedToken = data.access_token
|
||||
return _cachedToken
|
||||
}
|
||||
|
||||
/** Get the test user's user_id by decoding the JWT's `sub` claim. */
|
||||
async function getUserId(): Promise<string> {
|
||||
if (_cachedUserId) return _cachedUserId
|
||||
const token = await getAccessToken()
|
||||
// JWT format: header.payload.signature — decode payload (base64url).
|
||||
const payload = token.split('.')[1]
|
||||
const decoded = JSON.parse(
|
||||
Buffer.from(payload, 'base64url').toString('utf-8'),
|
||||
) as { sub: string }
|
||||
_cachedUserId = decoded.sub
|
||||
return _cachedUserId
|
||||
}
|
||||
|
||||
interface CalendarEvent {
|
||||
id: string
|
||||
title: string
|
||||
user_id: string
|
||||
start_time: string
|
||||
end_time: string
|
||||
}
|
||||
|
||||
/** Create an event via the REST API as the test user. */
|
||||
async function createEventViaApi(opts: {
|
||||
title: string
|
||||
startIso: string
|
||||
endIso: string
|
||||
description?: string
|
||||
}): Promise<CalendarEvent> {
|
||||
const token = await getAccessToken()
|
||||
const resp = await fetch(`${CALENDAR_BASE}/events`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: opts.title,
|
||||
start_time: opts.startIso,
|
||||
end_time: opts.endIso,
|
||||
description: opts.description ?? '',
|
||||
}),
|
||||
})
|
||||
if (!resp.ok) {
|
||||
throw new Error(`createEventViaApi failed: ${resp.status} ${await resp.text()}`)
|
||||
}
|
||||
const body = (await resp.json()) as { event: CalendarEvent }
|
||||
return body.event
|
||||
}
|
||||
|
||||
/** List events via the REST API as the test user. */
|
||||
async function listEventsViaApi(): Promise<CalendarEvent[]> {
|
||||
const token = await getAccessToken()
|
||||
const resp = await fetch(`${CALENDAR_BASE}/events`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (!resp.ok) throw new Error(`listEventsViaApi failed: ${resp.status}`)
|
||||
const body = (await resp.json()) as { events: CalendarEvent[] }
|
||||
return body.events
|
||||
}
|
||||
|
||||
/** Delete an event via the REST API. */
|
||||
async function deleteEventViaApi(id: string): Promise<void> {
|
||||
const token = await getAccessToken()
|
||||
const resp = await fetch(`${CALENDAR_BASE}/events/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (!resp.ok) throw new Error(`deleteEventViaApi failed: ${resp.status}`)
|
||||
}
|
||||
|
||||
/** Delete all events whose title starts with the e2e prefix. */
|
||||
async function cleanupE2EEvents(): Promise<void> {
|
||||
const events = await listEventsViaApi()
|
||||
await Promise.all(
|
||||
events
|
||||
.filter((e) => e.title.startsWith('E2E_'))
|
||||
.map((e) => deleteEventViaApi(e.id).catch(() => {})),
|
||||
)
|
||||
}
|
||||
|
||||
// ── Time helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Return an ISO 8601 timestamp `hoursFromNow` hours in the future.
|
||||
* The backend stores events in UTC and the calendar panel renders
|
||||
* "today" / "upcoming" relative to the server's local time. Using
|
||||
* future timestamps ensures the events show up in "今日" or "即将到来".
|
||||
*/
|
||||
function isoHoursFromNow(hoursFromNow: number): string {
|
||||
const d = new Date(Date.now() + hoursFromNow * 3600_000)
|
||||
// toISOString() returns UTC with 'Z' suffix — the backend accepts this.
|
||||
return d.toISOString()
|
||||
}
|
||||
|
||||
// ── UI helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Login and navigate to a workbench view (not /agent/chat which uses the
|
||||
* chat-only layout). Then click the "日历" tab in the bottom-right
|
||||
* quadrant to mount the CalendarPanel.
|
||||
*
|
||||
* Follows the proven pattern from calendar.spec.ts: SPA-navigate via the
|
||||
* TopNav "setting" button (avoids cold-start whoami timing issues that
|
||||
* page.goto('/agent/monitor') can trigger).
|
||||
*/
|
||||
async function loginAndOpenCalendar(page: Page): Promise<void> {
|
||||
await page.goto('/login')
|
||||
await clearAuth(page)
|
||||
await page.getByPlaceholder('请输入用户名').fill(TEST_USER.username)
|
||||
await page.getByPlaceholder('请输入密码').fill(TEST_USER.password)
|
||||
await page.getByRole('button', { name: /登\s*录/ }).click()
|
||||
|
||||
// Wait for redirect to /agent
|
||||
await expect(page).toHaveURL(/\/agent/, { timeout: 15_000 })
|
||||
|
||||
// Click the settings icon in TopNav to SPA-navigate to /agent/monitor
|
||||
// (workbench layout). The button's accessible name is "setting".
|
||||
await page.getByRole('button', { name: 'setting' }).click()
|
||||
|
||||
// Wait for the QuadrantPanel tabs to render (workbench layout).
|
||||
await expect(page.locator('.quadrant-panel__tab').first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
})
|
||||
|
||||
// Click the "日历" tab in the bottom-right panel.
|
||||
const calendarTab = page.locator('.quadrant-panel__tab', { hasText: '日历' })
|
||||
await calendarTab.click()
|
||||
|
||||
// The CalendarPanel should mount and show its header.
|
||||
await expect(page.locator('.calendar-panel')).toBeVisible({ timeout: 10_000 })
|
||||
// Wait for loading to finish (the "加载日程..." spinner disappears).
|
||||
await expect(page.locator('.calendar-panel__loading')).toBeHidden({
|
||||
timeout: 15_000,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Calendar data consistency', () => {
|
||||
test.beforeEach(async () => {
|
||||
await cleanupE2EEvents()
|
||||
})
|
||||
|
||||
test.afterEach(async () => {
|
||||
await cleanupE2EEvents()
|
||||
})
|
||||
|
||||
test('event created via API is visible in the UI calendar panel', async ({ page }) => {
|
||||
const title = `E2E_API_VISIBLE_${Date.now().toString(36)}`
|
||||
await createEventViaApi({
|
||||
title,
|
||||
startIso: isoHoursFromNow(2),
|
||||
endIso: isoHoursFromNow(3),
|
||||
})
|
||||
|
||||
await loginAndOpenCalendar(page)
|
||||
|
||||
// The event should appear in the calendar panel — either in "今日"
|
||||
// or "即将到来" depending on the exact time. Poll because the panel
|
||||
// fetches events on mount.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const items = page.locator('.calendar-panel__item')
|
||||
const count = await items.count()
|
||||
for (let i = 0; i < count; i++) {
|
||||
const text = await items.nth(i).textContent()
|
||||
if (text && text.includes(title)) return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
{ timeout: 15_000, intervals: [500] },
|
||||
)
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('event user_id matches the logged-in user, not a hallucinated value', async () => {
|
||||
const title = `E2E_UID_${Date.now().toString(36)}`
|
||||
const created = await createEventViaApi({
|
||||
title,
|
||||
startIso: isoHoursFromNow(4),
|
||||
endIso: isoHoursFromNow(5),
|
||||
})
|
||||
|
||||
const expectedUserId = await getUserId()
|
||||
|
||||
// Direct field check on the creation response.
|
||||
expect(created.user_id).toBe(expectedUserId)
|
||||
|
||||
// Re-query via list to verify persistence.
|
||||
const events = await listEventsViaApi()
|
||||
const found = events.find((e) => e.id === created.id)
|
||||
expect(found).toBeDefined()
|
||||
expect(found?.user_id).toBe(expectedUserId)
|
||||
|
||||
// Guard against the regression: user_id must never be a hallucinated
|
||||
// placeholder value. These are the values the agent tool used to
|
||||
// produce when default_user_id resolution failed.
|
||||
const hallucinated = ['default', 'zhangsan', 'admin', 'test', '']
|
||||
expect(hallucinated).not.toContain(found?.user_id)
|
||||
})
|
||||
|
||||
test('multiple events all render in the calendar panel', async ({ page }) => {
|
||||
const titles = [
|
||||
`E2E_MULTI_1_${Date.now().toString(36)}`,
|
||||
`E2E_MULTI_2_${Date.now().toString(36)}`,
|
||||
`E2E_MULTI_3_${Date.now().toString(36)}`,
|
||||
]
|
||||
// Stagger: +6h, +12h, +24h so they land in "今日" / "即将到来".
|
||||
for (let i = 0; i < titles.length; i++) {
|
||||
await createEventViaApi({
|
||||
title: titles[i],
|
||||
startIso: isoHoursFromNow(6 + i * 6),
|
||||
endIso: isoHoursFromNow(7 + i * 6),
|
||||
})
|
||||
}
|
||||
|
||||
await loginAndOpenCalendar(page)
|
||||
|
||||
// Each title should appear in the panel.
|
||||
for (const title of titles) {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const items = page.locator('.calendar-panel__item')
|
||||
const count = await items.count()
|
||||
for (let i = 0; i < count; i++) {
|
||||
const text = await items.nth(i).textContent()
|
||||
if (text && text.includes(title)) return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
{ timeout: 15_000, intervals: [500] },
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test('deleting an event via API syncs to the UI after reload', async ({ page }) => {
|
||||
const title = `E2E_DELETE_SYNC_${Date.now().toString(36)}`
|
||||
const event = await createEventViaApi({
|
||||
title,
|
||||
startIso: isoHoursFromNow(8),
|
||||
endIso: isoHoursFromNow(9),
|
||||
})
|
||||
|
||||
await loginAndOpenCalendar(page)
|
||||
|
||||
// Verify the event is visible first.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const items = page.locator('.calendar-panel__item')
|
||||
const count = await items.count()
|
||||
for (let i = 0; i < count; i++) {
|
||||
const text = await items.nth(i).textContent()
|
||||
if (text && text.includes(title)) return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
{ timeout: 15_000, intervals: [500] },
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
// Delete via API.
|
||||
await deleteEventViaApi(event.id)
|
||||
|
||||
// Verify API no longer returns it.
|
||||
const eventsAfterDelete = await listEventsViaApi()
|
||||
expect(eventsAfterDelete.find((e) => e.id === event.id)).toBeUndefined()
|
||||
|
||||
// Reload the page — the UI must reflect the deletion. After reload
|
||||
// the auth state is preserved (U1 covers this), and the page returns
|
||||
// to /agent/monitor where the workbench layout remounts.
|
||||
await page.reload()
|
||||
await expect(page).toHaveURL(/\/agent\/monitor/, { timeout: 15_000 })
|
||||
// Wait for the QuadrantPanel tabs to render, then click 日历.
|
||||
await expect(page.locator('.quadrant-panel__tab').first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
})
|
||||
await page.locator('.quadrant-panel__tab', { hasText: '日历' }).click()
|
||||
await expect(page.locator('.calendar-panel')).toBeVisible({ timeout: 10_000 })
|
||||
await expect(page.locator('.calendar-panel__loading')).toBeHidden({
|
||||
timeout: 15_000,
|
||||
})
|
||||
|
||||
// The deleted event must NOT reappear.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const items = page.locator('.calendar-panel__item')
|
||||
const count = await items.count()
|
||||
for (let i = 0; i < count; i++) {
|
||||
const text = await items.nth(i).textContent()
|
||||
if (text && text.includes(title)) return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
{ timeout: 10_000, intervals: [500] },
|
||||
)
|
||||
.toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,375 @@
|
|||
/**
|
||||
* U2 — Conversation management CRUD E2E.
|
||||
*
|
||||
* Regression coverage for issue #1 (2026-06-28): test conversations
|
||||
* accumulated in the sidebar and could not be deleted. Root cause was
|
||||
* pre-existing test data in SQLite + the delete button's popconfirm
|
||||
* occasionally not triggering the API call.
|
||||
*
|
||||
* Strategy:
|
||||
* - Create conversations via the REST API (POST /portal/chat) so they
|
||||
* exist on the server before the UI loads — this mirrors the
|
||||
* "stale conversations from a previous session" scenario.
|
||||
* - Use the UI to delete them and verify both the UI list and the
|
||||
* server-side list reflect the deletion (no "复活").
|
||||
*
|
||||
* ponytail: portal endpoints use API-key auth, but in dev/test mode no
|
||||
* key is configured so requests pass through unauthenticated. The test
|
||||
* relies on this. If API-key enforcement is enabled in E2E, set
|
||||
* AGENTKIT_API_KEY in the playwright env and add the header here.
|
||||
*/
|
||||
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import { TEST_USER, clearAuth, reloadAndWaitAuth } from './helpers'
|
||||
|
||||
// ── API helpers ────────────────────────────────────────────────────────
|
||||
|
||||
const PORTAL_BASE = 'http://127.0.0.1:8000/api/v1/portal'
|
||||
|
||||
/** Cached access token for API calls (login once per worker). */
|
||||
let _cachedToken: string | null = null
|
||||
|
||||
async function getAccessToken(): Promise<string> {
|
||||
if (_cachedToken) return _cachedToken
|
||||
const resp = await fetch(`${PORTAL_BASE.replace('/portal', '')}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: TEST_USER.username,
|
||||
password: TEST_USER.password,
|
||||
}),
|
||||
})
|
||||
if (!resp.ok) throw new Error(`Login failed: ${resp.status}`)
|
||||
const data = (await resp.json()) as { access_token: string }
|
||||
_cachedToken = data.access_token
|
||||
return _cachedToken
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a server-side conversation by sending a chat message.
|
||||
* The portal's /chat endpoint creates the conversation on first message.
|
||||
* Returns the conversation id.
|
||||
*
|
||||
* We send a trivial message and wait for the synchronous response so the
|
||||
* conversation is persisted before the test proceeds.
|
||||
*/
|
||||
async function createConversationViaApi(title: string): Promise<string> {
|
||||
const token = await getAccessToken()
|
||||
const resp = await fetch(`${PORTAL_BASE}/chat`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: title,
|
||||
conversation_id: `e2e-conv-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
}),
|
||||
})
|
||||
if (!resp.ok) {
|
||||
throw new Error(`createConversationViaApi failed: ${resp.status} ${await resp.text()}`)
|
||||
}
|
||||
const body = (await resp.json()) as { conversation_id: string }
|
||||
return body.conversation_id
|
||||
}
|
||||
|
||||
/** List server-side conversations — used to verify deletion persisted. */
|
||||
async function listConversationsViaApi(): Promise<{ id: string; title: string }[]> {
|
||||
const token = await getAccessToken()
|
||||
const resp = await fetch(`${PORTAL_BASE}/conversations`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (!resp.ok) throw new Error(`listConversationsViaApi failed: ${resp.status}`)
|
||||
const body = (await resp.json()) as { id: string; title: string }[]
|
||||
return body
|
||||
}
|
||||
|
||||
/** Delete all conversations whose id starts with the e2e prefix. */
|
||||
async function cleanupE2EConversations(): Promise<void> {
|
||||
const convs = await listConversationsViaApi()
|
||||
for (const c of convs) {
|
||||
if (c.id.startsWith('e2e-conv-')) {
|
||||
const token = await getAccessToken()
|
||||
await fetch(`${PORTAL_BASE}/conversations/${c.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── UI helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Login via UI form and wait for the chat view to mount. */
|
||||
async function loginAndOpenChat(page: Page): Promise<void> {
|
||||
await page.goto('/login')
|
||||
await clearAuth(page)
|
||||
await page.getByPlaceholder('请输入用户名').fill(TEST_USER.username)
|
||||
await page.getByPlaceholder('请输入密码').fill(TEST_USER.password)
|
||||
await page.getByRole('button', { name: /登\s*录/ }).click()
|
||||
await expect(page).toHaveURL(/\/agent/, { timeout: 15_000 })
|
||||
// Wait for the chat textarea to confirm the view mounted.
|
||||
await expect(page.getByPlaceholder('输入消息,按 Enter 发送...')).toBeVisible({
|
||||
timeout: 15_000,
|
||||
})
|
||||
// Make the per-item delete button always visible — by default it's
|
||||
// display:none until :hover, and Playwright's hover-then-click is
|
||||
// flaky because the hover state can be lost between actions. Forcing
|
||||
// the button visible lets us click it directly without fighting CSS.
|
||||
await page.addStyleTag({
|
||||
content: '.chat-sidebar__item-delete { display: flex !important; }',
|
||||
})
|
||||
}
|
||||
|
||||
/** Wait for a conversation with the given title fragment to appear in the sidebar. */
|
||||
async function waitForConversationInSidebar(
|
||||
page: Page,
|
||||
titleFragment: string,
|
||||
timeoutMs = 15_000,
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const items = page.locator('.chat-sidebar__item')
|
||||
const count = await items.count()
|
||||
for (let i = 0; i < count; i++) {
|
||||
const text = await items.nth(i).textContent()
|
||||
if (text && text.includes(titleFragment)) return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
{ timeout: timeoutMs, intervals: [500] },
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Conversation management CRUD', () => {
|
||||
test.beforeEach(async () => {
|
||||
await cleanupE2EConversations()
|
||||
})
|
||||
|
||||
test.afterEach(async () => {
|
||||
await cleanupE2EConversations()
|
||||
})
|
||||
|
||||
test('create new conversation via UI, then delete it', async ({ page }) => {
|
||||
await loginAndOpenChat(page)
|
||||
|
||||
// Record the current count so we can verify +1 / -1 rather than
|
||||
// asserting an absolute count (the sidebar may contain pre-existing
|
||||
// conversations from prior test sessions).
|
||||
const initialCount = await page.locator('.chat-sidebar__item').count()
|
||||
|
||||
// Click "新建对话" — creates a local-only conversation in the store.
|
||||
await page.getByRole('button', { name: /新建对话/ }).click()
|
||||
|
||||
// A new "新对话" item should appear at the top of the sidebar.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const items = page.locator('.chat-sidebar__item')
|
||||
return (await items.count()) - initialCount
|
||||
}, { timeout: 5_000 })
|
||||
.toBe(1)
|
||||
await expect(page.locator('.chat-sidebar__item-title').first()).toContainText('新对话')
|
||||
|
||||
// The delete button is always visible (loginAndOpenChat injected CSS
|
||||
// to override the display:none-until-hover rule). Click it to open
|
||||
// the a-popconfirm.
|
||||
const deleteBtn = page.locator('.chat-sidebar__item-delete').first()
|
||||
await deleteBtn.click()
|
||||
|
||||
// The popconfirm should appear. ant-design-vue renders it in a portal
|
||||
// at body level. Wait for the confirm text to be visible.
|
||||
await expect(page.getByText('确定删除此对话?')).toBeVisible({ timeout: 5_000 })
|
||||
|
||||
// Click the OK button directly via DOM. Playwright's locator-based click
|
||||
// on portal-rendered buttons is flaky in ant-design-vue (the button text
|
||||
// is split into spans with inserted spaces for CJK: "删 除"), and
|
||||
// keyboard Enter doesn't reliably focus the OK button. A direct
|
||||
// .click() on the primary button element is the most reliable path.
|
||||
await page.evaluate(() => {
|
||||
const btn = document.querySelector(
|
||||
'.ant-popconfirm-buttons .ant-btn-primary',
|
||||
) as HTMLButtonElement | null
|
||||
if (!btn) throw new Error('popconfirm ok button not found')
|
||||
btn.click()
|
||||
})
|
||||
|
||||
// The newly-created "新对话" should be gone — count returns to initial.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const items = page.locator('.chat-sidebar__item')
|
||||
const titles = await page.locator('.chat-sidebar__item-title').allTextContents()
|
||||
const hasNewConv = titles.some((t) => t === '新对话')
|
||||
return { count: (await items.count()) - initialCount, hasNewConv }
|
||||
}, { timeout: 10_000 })
|
||||
.toEqual({ count: 0, hasNewConv: false })
|
||||
})
|
||||
|
||||
test('server-side conversation is deleted and does not resurrect after reload', async ({
|
||||
page,
|
||||
}) => {
|
||||
// This is the core regression test for issue #1: a conversation that
|
||||
// exists on the server must be deleted from both UI and server when
|
||||
// the user clicks delete, and must not reappear after reload.
|
||||
// Marker must be ≤20 chars — backend _derive_conversation_title_from_content
|
||||
// truncates to content[:20] + "..." so a longer marker won't match in the sidebar.
|
||||
const marker = `E2E_DEL_${Date.now().toString(36)}`
|
||||
const convId = await createConversationViaApi(marker)
|
||||
|
||||
await loginAndOpenChat(page)
|
||||
|
||||
// The server-side conversation should appear in the sidebar.
|
||||
await waitForConversationInSidebar(page, marker)
|
||||
|
||||
// Click delete + confirm (button is always visible via injected CSS).
|
||||
const item = page.locator('.chat-sidebar__item', { hasText: marker }).first()
|
||||
await item.locator('.chat-sidebar__item-delete').click()
|
||||
|
||||
await expect(page.getByText('确定删除此对话?')).toBeVisible({ timeout: 5_000 })
|
||||
await page.evaluate(() => {
|
||||
const btn = document.querySelector(
|
||||
'.ant-popconfirm-buttons .ant-btn-primary',
|
||||
) as HTMLButtonElement | null
|
||||
if (!btn) throw new Error('popconfirm ok button not found')
|
||||
btn.click()
|
||||
})
|
||||
|
||||
// UI: the conversation should disappear from the sidebar.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const items = page.locator('.chat-sidebar__item', { hasText: marker })
|
||||
return await items.count()
|
||||
},
|
||||
{ timeout: 10_000, intervals: [500] },
|
||||
)
|
||||
.toBe(0)
|
||||
|
||||
// Server: the conversation must also be gone — verify via API.
|
||||
const convsAfterDelete = await listConversationsViaApi()
|
||||
expect(convsAfterDelete.find((c) => c.id === convId)).toBeUndefined()
|
||||
|
||||
// Reload — the conversation must NOT resurrect (this is the bug
|
||||
// users hit when cache/state desync let deleted convs reappear).
|
||||
await reloadAndWaitAuth(page)
|
||||
await expect(page).toHaveURL(/\/agent/, { timeout: 20_000 })
|
||||
await expect(page.getByPlaceholder('输入消息,按 Enter 发送...')).toBeVisible({
|
||||
timeout: 15_000,
|
||||
})
|
||||
|
||||
// Give the sidebar a moment to load the fresh conversation list.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const items = page.locator('.chat-sidebar__item', { hasText: marker })
|
||||
return await items.count()
|
||||
},
|
||||
{ timeout: 10_000, intervals: [500] },
|
||||
)
|
||||
.toBe(0)
|
||||
|
||||
// Final API sanity check.
|
||||
const convsAfterReload = await listConversationsViaApi()
|
||||
expect(convsAfterReload.find((c) => c.id === convId)).toBeUndefined()
|
||||
})
|
||||
|
||||
test('switching between conversations loads the correct history', async ({ page }) => {
|
||||
// Create two server-side conversations with distinct first messages.
|
||||
// Markers must be ≤20 chars (backend truncates titles to content[:20]).
|
||||
const ts = Date.now().toString(36)
|
||||
const marker1 = `E2E_A_${ts}`
|
||||
const marker2 = `E2E_B_${ts}`
|
||||
await createConversationViaApi(marker1)
|
||||
await createConversationViaApi(marker2)
|
||||
|
||||
await loginAndOpenChat(page)
|
||||
|
||||
// Both should appear in the sidebar.
|
||||
await waitForConversationInSidebar(page, marker1)
|
||||
await waitForConversationInSidebar(page, marker2)
|
||||
|
||||
// Click conversation 1 — its first user message should be visible.
|
||||
await page.locator('.chat-sidebar__item', { hasText: marker1 }).first().click()
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const userBubbles = page.locator('.message-shell--user .user-bubble')
|
||||
const count = await userBubbles.count()
|
||||
if (count === 0) return false
|
||||
const text = await userBubbles.first().textContent()
|
||||
return text?.includes(marker1) ?? false
|
||||
},
|
||||
{ timeout: 10_000, intervals: [500] },
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
// Click conversation 2 — its first user message should now be visible
|
||||
// (history must be isolated between conversations).
|
||||
await page.locator('.chat-sidebar__item', { hasText: marker2 }).first().click()
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const userBubbles = page.locator('.message-shell--user .user-bubble')
|
||||
const count = await userBubbles.count()
|
||||
if (count === 0) return false
|
||||
const text = await userBubbles.first().textContent()
|
||||
return text?.includes(marker2) ?? false
|
||||
},
|
||||
{ timeout: 10_000, intervals: [500] },
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
// The first conversation's marker should no longer be in the visible history.
|
||||
const visibleText = await page.locator('.message-shell--user .user-bubble').allTextContents()
|
||||
expect(visibleText.some((t) => t.includes(marker1))).toBe(false)
|
||||
})
|
||||
|
||||
test('deleting the active conversation auto-switches to another', async ({ page }) => {
|
||||
// Markers must be ≤20 chars (backend truncates titles to content[:20]).
|
||||
const ts = Date.now().toString(36)
|
||||
const marker1 = `E2E_1_${ts}`
|
||||
const marker2 = `E2E_2_${ts}`
|
||||
await createConversationViaApi(marker1)
|
||||
await createConversationViaApi(marker2)
|
||||
|
||||
await loginAndOpenChat(page)
|
||||
await waitForConversationInSidebar(page, marker1)
|
||||
await waitForConversationInSidebar(page, marker2)
|
||||
|
||||
// Select the first conversation to make it active.
|
||||
await page.locator('.chat-sidebar__item', { hasText: marker1 }).first().click()
|
||||
await expect(page.locator('.chat-sidebar__item--active', { hasText: marker1 })).toBeVisible({
|
||||
timeout: 5_000,
|
||||
})
|
||||
|
||||
// Delete the active conversation (button always visible via injected CSS).
|
||||
const activeItem = page.locator('.chat-sidebar__item--active')
|
||||
await activeItem.locator('.chat-sidebar__item-delete').click()
|
||||
await expect(page.getByText('确定删除此对话?')).toBeVisible({ timeout: 5_000 })
|
||||
await page.evaluate(() => {
|
||||
const btn = document.querySelector(
|
||||
'.ant-popconfirm-buttons .ant-btn-primary',
|
||||
) as HTMLButtonElement | null
|
||||
if (!btn) throw new Error('popconfirm ok button not found')
|
||||
btn.click()
|
||||
})
|
||||
|
||||
// The active conversation should be gone, and another should be active.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const active = page.locator('.chat-sidebar__item--active')
|
||||
const activeCount = await active.count()
|
||||
const deletedStillThere = await page
|
||||
.locator('.chat-sidebar__item', { hasText: marker1 })
|
||||
.count()
|
||||
return { activeCount, deletedStillThere }
|
||||
},
|
||||
{ timeout: 10_000, intervals: [500] },
|
||||
)
|
||||
.toEqual({ activeCount: 1, deletedStillThere: 0 })
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* E2E tests for KnowledgeBaseView — the top-right "知识库" quadrant tab.
|
||||
*
|
||||
* Navigation: login via UI form → click TopNav "setting" icon (SPA navigate
|
||||
* to /agent/monitor workbench layout) → click the "知识库" tab in the
|
||||
* top-right QuadrantPanel.
|
||||
*
|
||||
* ponytail: page.goto would trigger the whoami cold-start bug; SPA
|
||||
* navigation via TopNav preserves the in-memory access token.
|
||||
*/
|
||||
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import { TEST_USER, clearAuth } from './helpers'
|
||||
|
||||
async function loginAndOpenKnowledgeTab(page: Page): Promise<void> {
|
||||
await page.goto('/login')
|
||||
await clearAuth(page)
|
||||
await page.getByPlaceholder('请输入用户名').fill(TEST_USER.username)
|
||||
await page.getByPlaceholder('请输入密码').fill(TEST_USER.password)
|
||||
await page.getByRole('button', { name: /登\s*录/ }).click()
|
||||
await expect(page).toHaveURL(/\/agent/, { timeout: 15_000 })
|
||||
|
||||
// SPA-navigate to the workbench layout via the TopNav settings icon.
|
||||
await page.getByRole('button', { name: 'setting' }).click()
|
||||
await expect(page.locator('.quadrant-panel__tab').first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
})
|
||||
|
||||
// Activate the "知识库" tab in the top-right QuadrantPanel.
|
||||
await page.locator('.quadrant-panel__tab', { hasText: '知识库' }).click()
|
||||
await expect(page.locator('.kb-view')).toBeVisible({ timeout: 15_000 })
|
||||
}
|
||||
|
||||
test.describe('Knowledge Base View E2E', () => {
|
||||
test('K1: knowledge tab loads without white screen or 401 redirect', async ({ page }) => {
|
||||
await loginAndOpenKnowledgeTab(page)
|
||||
|
||||
// URL should stay on /agent (workbench), not redirect to /login.
|
||||
await expect(page).toHaveURL(/\/agent/, { timeout: 10_000 })
|
||||
// KnowledgeBaseView root element visible — no white screen.
|
||||
await expect(page.locator('.kb-view')).toBeVisible()
|
||||
})
|
||||
|
||||
test('K2: knowledge base core elements are visible', async ({ page }) => {
|
||||
await loginAndOpenKnowledgeTab(page)
|
||||
|
||||
// The a-tabs should render with the "文档管理" tab visible.
|
||||
await expect(
|
||||
page.locator('.ant-tabs-tab', { hasText: '文档管理' }),
|
||||
).toBeVisible({ timeout: 15_000 })
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
/**
|
||||
* E2E tests for EvolutionView — the bottom-right "监控" quadrant tab.
|
||||
*
|
||||
* Navigation: login via UI form → click TopNav "setting" icon (SPA navigate
|
||||
* to /agent/monitor workbench layout) → click the "监控" quadrant tab.
|
||||
*
|
||||
* The "监控" tab is the default for the bottom-right QuadrantPanel, but we
|
||||
* click it explicitly to be deterministic.
|
||||
*
|
||||
* ponytail: page.goto would trigger the whoami cold-start bug; SPA
|
||||
* navigation via TopNav preserves the in-memory access token.
|
||||
*/
|
||||
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import { TEST_USER, clearAuth } from './helpers'
|
||||
|
||||
async function loginAndOpenMonitorTab(page: Page): Promise<void> {
|
||||
await page.goto('/login')
|
||||
await clearAuth(page)
|
||||
await page.getByPlaceholder('请输入用户名').fill(TEST_USER.username)
|
||||
await page.getByPlaceholder('请输入密码').fill(TEST_USER.password)
|
||||
await page.getByRole('button', { name: /登\s*录/ }).click()
|
||||
await expect(page).toHaveURL(/\/agent/, { timeout: 15_000 })
|
||||
|
||||
// SPA-navigate to the workbench layout via the TopNav settings icon.
|
||||
await page.getByRole('button', { name: 'setting' }).click()
|
||||
await expect(page.locator('.quadrant-panel__tab').first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
})
|
||||
|
||||
// Activate the "监控" tab in the bottom-right QuadrantPanel.
|
||||
await page.locator('.quadrant-panel__tab', { hasText: '监控' }).click()
|
||||
// /agent/monitor renders EvolutionView in both the router-view (left pane)
|
||||
// and the bottom-right quadrant's monitor slot — use .first() to avoid
|
||||
// strict mode violation.
|
||||
await expect(page.locator('.evolution-container').first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Evolution View E2E', () => {
|
||||
test('E1: evolution view loads without white screen or 401 redirect', async ({ page }) => {
|
||||
await loginAndOpenMonitorTab(page)
|
||||
|
||||
// URL should stay on /agent (workbench), not redirect to /login.
|
||||
await expect(page).toHaveURL(/\/agent/, { timeout: 10_000 })
|
||||
// EvolutionView root element visible — no white screen.
|
||||
// .first() because /agent/monitor renders EvolutionView in two panes.
|
||||
await expect(page.locator('.evolution-container').first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('E2: evolution dashboard core elements are visible', async ({ page }) => {
|
||||
await loginAndOpenMonitorTab(page)
|
||||
|
||||
// The evolution tabs container should be visible (.first(): two instances).
|
||||
await expect(page.locator('.evolution-tabs').first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
})
|
||||
// The "概览+指标" tab should be present.
|
||||
await expect(
|
||||
page.locator('.ant-tabs-tab', { hasText: '概览+指标' }).first(),
|
||||
).toBeVisible({ timeout: 15_000 })
|
||||
})
|
||||
})
|
||||
|
|
@ -103,7 +103,23 @@ export async function loginViaApi(): Promise<ITokenPair> {
|
|||
if (_cachedTokenPair) {
|
||||
return _cachedTokenPair
|
||||
}
|
||||
return _loginViaApiUncached()
|
||||
}
|
||||
|
||||
/**
|
||||
* Force a fresh login, bypassing the module-level token cache.
|
||||
*
|
||||
* Necessary when a previous test (e.g. auth-persistence.spec.ts) corrupted
|
||||
* the persisted refresh token in localStorage AND the cached token pair is
|
||||
* no longer valid for the current browser context. Callers that hit a
|
||||
* cold-start 401 on /auth/whoami should retry with this.
|
||||
*/
|
||||
export async function forceRelogin(): Promise<ITokenPair> {
|
||||
_cachedTokenPair = null
|
||||
return _loginViaApiUncached()
|
||||
}
|
||||
|
||||
async function _loginViaApiUncached(): Promise<ITokenPair> {
|
||||
const maxRetries = 5
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
const resp = await fetch(`${API_BASE}/auth/login`, {
|
||||
|
|
@ -181,6 +197,52 @@ export async function clearAuth(page: Page): Promise<void> {
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload the page and wait for the auth cold-start sequence to settle.
|
||||
*
|
||||
* The auth store runs `startupCheck()` (whoami with the persisted refresh
|
||||
* token) on boot via `beginStartup()` in main.ts. The router guard awaits
|
||||
* `waitForStartup()` before deciding to redirect. So after `page.reload()`
|
||||
* we must wait until the URL has settled to either a protected route
|
||||
* (cold-start succeeded) or `/login` (cold-start failed).
|
||||
*
|
||||
* Returns the final URL for assertions.
|
||||
*/
|
||||
export async function reloadAndWaitAuth(
|
||||
page: Page,
|
||||
timeoutMs = 20_000,
|
||||
): Promise<string> {
|
||||
await page.reload()
|
||||
// Wait for URL to settle — either /agent/* (auth ok) or /login (auth lost).
|
||||
// The cold-start probe can take a few seconds (whoami round-trip).
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
const url = page.url()
|
||||
if (/\/(agent|login)/.test(url)) {
|
||||
// Give the router one extra tick to finalize the redirect.
|
||||
await page.waitForLoadState('networkidle', { timeout: 2_000 }).catch(() => {})
|
||||
return page.url()
|
||||
}
|
||||
await page.waitForTimeout(200)
|
||||
}
|
||||
throw new Error(
|
||||
`Auth did not settle after reload within ${timeoutMs}ms (last url: ${page.url()})`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Overwrite the persisted refresh token with a fake value, simulating
|
||||
* a revoked / tampered token. Used to test the cold-start failure path.
|
||||
*/
|
||||
export async function corruptRefreshToken(page: Page): Promise<void> {
|
||||
await page.evaluate(
|
||||
({ key }) => {
|
||||
localStorage.setItem(key, 'invalid-refresh-token-for-testing-only')
|
||||
},
|
||||
{ key: REFRESH_TOKEN_KEY },
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chat helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
/**
|
||||
* E2E tests for SettingsView — the bottom-right "设置" quadrant tab.
|
||||
*
|
||||
* Navigation: login via UI form → click TopNav "setting" icon (SPA navigate
|
||||
* to /agent/monitor workbench layout) → click the "设置" quadrant tab.
|
||||
*
|
||||
* ponytail: page.goto would trigger the whoami cold-start bug; SPA
|
||||
* navigation via TopNav preserves the in-memory access token.
|
||||
*/
|
||||
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import { TEST_USER, clearAuth } from './helpers'
|
||||
|
||||
async function loginAndOpenSettingsTab(page: Page): Promise<void> {
|
||||
await page.goto('/login')
|
||||
await clearAuth(page)
|
||||
await page.getByPlaceholder('请输入用户名').fill(TEST_USER.username)
|
||||
await page.getByPlaceholder('请输入密码').fill(TEST_USER.password)
|
||||
await page.getByRole('button', { name: /登\s*录/ }).click()
|
||||
await expect(page).toHaveURL(/\/agent/, { timeout: 15_000 })
|
||||
|
||||
// SPA-navigate to the workbench layout via the TopNav settings icon.
|
||||
await page.getByRole('button', { name: 'setting' }).click()
|
||||
await expect(page.locator('.quadrant-panel__tab').first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
})
|
||||
|
||||
// Activate the "设置" tab in the bottom-right QuadrantPanel.
|
||||
await page.locator('.quadrant-panel__tab', { hasText: '设置' }).click()
|
||||
await expect(page.locator('.settings-view')).toBeVisible({ timeout: 15_000 })
|
||||
}
|
||||
|
||||
test.describe('Settings View E2E', () => {
|
||||
test('T1: settings tab loads without white screen or 401 redirect', async ({ page }) => {
|
||||
await loginAndOpenSettingsTab(page)
|
||||
|
||||
// URL should stay on /agent (workbench), not redirect to /login.
|
||||
await expect(page).toHaveURL(/\/agent/, { timeout: 10_000 })
|
||||
// SettingsView root element visible — no white screen.
|
||||
await expect(page.locator('.settings-view')).toBeVisible()
|
||||
})
|
||||
|
||||
test('T2: settings form core elements are visible', async ({ page }) => {
|
||||
await loginAndOpenSettingsTab(page)
|
||||
|
||||
// The settings tabs container should be visible.
|
||||
await expect(page.locator('.settings-tabs')).toBeVisible({ timeout: 15_000 })
|
||||
// The "LLM 配置" tab should be present (default active tab).
|
||||
await expect(
|
||||
page.locator('.ant-tabs-tab', { hasText: 'LLM 配置' }),
|
||||
).toBeVisible({ timeout: 15_000 })
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
/**
|
||||
* E2E tests for SkillsView — the bottom-right "技能" quadrant tab.
|
||||
*
|
||||
* Navigation: login via UI form → click TopNav "setting" icon (SPA navigate
|
||||
* to /agent/monitor workbench layout) → click the "技能" quadrant tab.
|
||||
*
|
||||
* ponytail: page.goto('/agent/monitor') would trigger the whoami cold-start
|
||||
* bug (refresh token passed as RequestInit property). SPA navigation via
|
||||
* TopNav button preserves the in-memory access token.
|
||||
*/
|
||||
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import { TEST_USER, clearAuth } from './helpers'
|
||||
|
||||
async function loginAndOpenSkillsTab(page: Page): Promise<void> {
|
||||
await page.goto('/login')
|
||||
await clearAuth(page)
|
||||
await page.getByPlaceholder('请输入用户名').fill(TEST_USER.username)
|
||||
await page.getByPlaceholder('请输入密码').fill(TEST_USER.password)
|
||||
await page.getByRole('button', { name: /登\s*录/ }).click()
|
||||
await expect(page).toHaveURL(/\/agent/, { timeout: 15_000 })
|
||||
|
||||
// SPA-navigate to the workbench layout via the TopNav settings icon.
|
||||
await page.getByRole('button', { name: 'setting' }).click()
|
||||
await expect(page.locator('.quadrant-panel__tab').first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
})
|
||||
|
||||
// Activate the "技能" tab in the bottom-right QuadrantPanel.
|
||||
await page.locator('.quadrant-panel__tab', { hasText: '技能' }).click()
|
||||
await expect(page.locator('.skills-view')).toBeVisible({ timeout: 15_000 })
|
||||
}
|
||||
|
||||
test.describe('Skills View E2E', () => {
|
||||
test('S1: skills tab loads without white screen or 401 redirect', async ({ page }) => {
|
||||
await loginAndOpenSkillsTab(page)
|
||||
|
||||
// URL should stay on /agent (workbench), not redirect to /login.
|
||||
await expect(page).toHaveURL(/\/agent/, { timeout: 10_000 })
|
||||
// SkillsView root element visible — no white screen.
|
||||
await expect(page.locator('.skills-view')).toBeVisible()
|
||||
})
|
||||
|
||||
test('S2: skills core elements are visible', async ({ page }) => {
|
||||
await loginAndOpenSkillsTab(page)
|
||||
|
||||
// The "安装技能" button is always present in the header.
|
||||
await expect(page.getByRole('button', { name: /安装技能/ })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
})
|
||||
|
||||
// Either skill cards exist or the empty state "暂无已注册技能" is shown.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const cards = await page.locator('.skill-card').count()
|
||||
const empty = await page
|
||||
.locator('.ant-empty', { hasText: '暂无已注册技能' })
|
||||
.count()
|
||||
return cards + empty
|
||||
},
|
||||
{ timeout: 20_000, intervals: [1_000] },
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { test, expect } from '@playwright/test'
|
||||
import { loginAndHydrate } from './helpers'
|
||||
import { loginAndHydrate, forceRelogin } from './helpers'
|
||||
|
||||
test.describe('Terminal panel', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
|
|
@ -7,6 +7,30 @@ test.describe('Terminal panel', () => {
|
|||
// The terminal view lives at /legacy/terminal (the /terminal route
|
||||
// redirects there — see router/index.ts).
|
||||
await page.goto('/legacy/terminal')
|
||||
// Cold-start race: the router guard awaits startupCheck() (whoami with
|
||||
// the persisted refresh token). On a fresh browser context the guard
|
||||
// may bounce to /login AFTER the initial URL has settled on
|
||||
// /legacy/terminal — so checking page.url() alone is unreliable.
|
||||
// Wait for either .terminal-panel (success) OR the login button
|
||||
// (cold-start failure) to render, then retry once on failure.
|
||||
const loginBtn = page.getByRole('button', { name: /登\s*录/ })
|
||||
const terminalPanel = page.locator('.terminal-panel')
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await terminalPanel.count()) > 0 || (await loginBtn.count()) > 0,
|
||||
{ timeout: 20_000, intervals: [500, 1_000, 2_000] },
|
||||
)
|
||||
.toBe(true)
|
||||
if ((await loginBtn.count()) > 0) {
|
||||
// Cold-start failed — force a fresh login (bypassing the module-level
|
||||
// token cache, which may have been invalidated by a previous spec
|
||||
// corrupting the refresh token) and retry once.
|
||||
await forceRelogin()
|
||||
await loginAndHydrate(page)
|
||||
await page.goto('/legacy/terminal')
|
||||
await expect(terminalPanel).toBeVisible({ timeout: 20_000 })
|
||||
}
|
||||
})
|
||||
|
||||
test('should display the terminal panel with mode tabs', async ({ page }) => {
|
||||
|
|
|
|||
|
|
@ -49,9 +49,22 @@ function wrapNetworkError(e: unknown): never {
|
|||
|
||||
let _dynamicBaseURL = ''
|
||||
|
||||
/** Initialize the dynamic base URL for Tauri (sidecar backend). */
|
||||
/**
|
||||
* Initialize the dynamic base URL for Tauri (sidecar backend).
|
||||
*
|
||||
* In dev mode (``npm run tauri dev``) the frontend is served by Vite at
|
||||
* ``http://localhost:5173`` and Vite proxies ``/api`` to the backend.
|
||||
* Setting ``_dynamicBaseURL`` here would bypass the proxy and send
|
||||
* requests directly to ``http://127.0.0.1:<port>``, triggering CORS
|
||||
* errors (the backend returns 401 without CORS headers on auth
|
||||
* failures). We therefore leave ``_dynamicBaseURL`` empty in dev so
|
||||
* requests stay same-origin and go through the Vite proxy.
|
||||
*
|
||||
* In production (Tauri build) there is no Vite proxy, so we must point
|
||||
* directly at the sidecar port.
|
||||
*/
|
||||
export async function initApiBaseURL(): Promise<void> {
|
||||
if (isTauri()) {
|
||||
if (isTauri() && !import.meta.env.DEV) {
|
||||
const port = await getBackendPort()
|
||||
_dynamicBaseURL = `http://127.0.0.1:${port}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,21 @@ import { BaseApiClient } from './base'
|
|||
|
||||
const API_BASE = '/api/v1/portal'
|
||||
|
||||
export interface IModelInfo {
|
||||
id: string
|
||||
provider: string
|
||||
model: string
|
||||
max_tokens: number | null
|
||||
cost_per_1k_input: number | null
|
||||
cost_per_1k_output: number | null
|
||||
}
|
||||
|
||||
export interface IModelsResponse {
|
||||
models: IModelInfo[]
|
||||
aliases: Record<string, string>
|
||||
default: string | null
|
||||
}
|
||||
|
||||
class ApiClient extends BaseApiClient {
|
||||
constructor(baseUrl: string = API_BASE) {
|
||||
super(baseUrl)
|
||||
|
|
@ -77,6 +92,11 @@ class ApiClient extends BaseApiClient {
|
|||
body: formData,
|
||||
})
|
||||
}
|
||||
|
||||
/** List available LLM models (uses /api/v1 prefix) */
|
||||
async listModels(): Promise<IModelsResponse> {
|
||||
return this.request<IModelsResponse>('/api/v1/llm/models')
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient()
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
/**
|
||||
* Tauri-aware refresh token storage adapter.
|
||||
*
|
||||
* In Tauri mode the refresh token is written to the OS Keychain via the
|
||||
* `store_refresh_token` / `load_refresh_token` / `clear_refresh_token`
|
||||
* Rust commands (see `src-tauri/src/auth.rs`). In plain Web mode
|
||||
* (no Tauri runtime) it falls back to `localStorage` with the
|
||||
* documented degraded security model.
|
||||
* In Tauri mode the refresh token is written to BOTH the OS Keychain
|
||||
* (via the `store_refresh_token` / `load_refresh_token` /
|
||||
* `clear_refresh_token` Rust commands, see `src-tauri/src/auth.rs`)
|
||||
* AND `localStorage`. The Keychain is the preferred read source;
|
||||
* `localStorage` is a defence-in-depth backup so a missing or broken
|
||||
* keychain command (e.g. the Rust side not yet wired up) never
|
||||
* silently loses the refresh token — which would force the user back
|
||||
* to /login on every reload.
|
||||
*
|
||||
* The async API surface is identical in both modes, so the auth store
|
||||
* (and any other caller) does not need to branch on the environment.
|
||||
* In plain Web mode (no Tauri runtime) only `localStorage` is used.
|
||||
*
|
||||
* Keychain failures are logged and transparently downgraded to
|
||||
* `localStorage` so a broken OS credential service never blocks login
|
||||
|
|
@ -45,8 +47,12 @@ function localGet(): string | null {
|
|||
function localSet(value: string): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, value)
|
||||
} catch {
|
||||
/* localStorage may be unavailable in some sandboxed contexts */
|
||||
} catch (e) {
|
||||
// Log the error so a silently-failed persist is visible in the console.
|
||||
// Previously this was swallowed, which meant a rotated refresh token
|
||||
// could fail to persist without any signal — the next reload would
|
||||
// find a stale (already-rotated) token and redirect to /login.
|
||||
console.error('[auth] localStorage.setItem failed — refresh token NOT persisted', e)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -62,34 +68,34 @@ export const tauriAuthStorage = {
|
|||
/**
|
||||
* Persist the refresh token.
|
||||
*
|
||||
* Tauri → OS Keychain via `store_refresh_token`. Web → localStorage.
|
||||
* On Keychain failure (or any Tauri error), the value is written to
|
||||
* localStorage as a fallback and a warning is logged.
|
||||
* Always writes `localStorage` first (the durable backup), then
|
||||
* attempts the OS Keychain. The Keychain write is best-effort: if
|
||||
* the Rust command is not registered or rejects, the localStorage
|
||||
* copy still lets the next reload rehydrate the session.
|
||||
*/
|
||||
async setRefreshToken(token: string): Promise<void> {
|
||||
localSet(token)
|
||||
if (isTauri()) {
|
||||
try {
|
||||
await tauriInvoke<void>('store_refresh_token', { token })
|
||||
return
|
||||
} catch (e) {
|
||||
console.warn('[auth] Keychain write failed, falling back to localStorage', e)
|
||||
console.warn('[auth] Keychain write failed, localStorage backup used', e)
|
||||
}
|
||||
}
|
||||
localSet(token)
|
||||
},
|
||||
|
||||
/**
|
||||
* Read the persisted refresh token.
|
||||
*
|
||||
* Tauri → OS Keychain via `load_refresh_token`. Web → localStorage.
|
||||
* Returns `null` when no token has been stored. On Keychain failure
|
||||
* (or any Tauri error), falls back to localStorage and logs a warning.
|
||||
* Tries the OS Keychain first; on any failure or empty result falls
|
||||
* back to `localStorage`. Returns `null` when no token is stored
|
||||
* anywhere.
|
||||
*/
|
||||
async getRefreshToken(): Promise<string | null> {
|
||||
if (isTauri()) {
|
||||
try {
|
||||
const value = await tauriInvoke<string | null>('load_refresh_token')
|
||||
return value ?? null
|
||||
if (value) return value
|
||||
} catch (e) {
|
||||
console.warn('[auth] Keychain read failed, falling back to localStorage', e)
|
||||
}
|
||||
|
|
@ -100,19 +106,17 @@ export const tauriAuthStorage = {
|
|||
/**
|
||||
* Remove the persisted refresh token.
|
||||
*
|
||||
* Tauri → OS Keychain via `clear_refresh_token`. Web → localStorage.
|
||||
* On Keychain failure, the localStorage copy is also cleared as a
|
||||
* best-effort defence-in-depth.
|
||||
* Always clears `localStorage`; the Keychain clear is best-effort.
|
||||
*/
|
||||
async clearRefreshToken(): Promise<void> {
|
||||
localRemove()
|
||||
if (isTauri()) {
|
||||
try {
|
||||
await tauriInvoke<void>('clear_refresh_token')
|
||||
} catch (e) {
|
||||
console.warn('[auth] Keychain clear failed, falling back to localStorage clear', e)
|
||||
console.warn('[auth] Keychain clear failed, localStorage already cleared', e)
|
||||
}
|
||||
}
|
||||
localRemove()
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -103,7 +103,8 @@ function onEdit(event: ICalendarEvent): void {
|
|||
|
||||
.calendar-drawer__tabs :deep(.ant-tabs-content-holder) {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
/* ponytail: was overflow:hidden which clipped ReminderConfig/SyncSettings content; auto enables scroll */
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.calendar-drawer__tabs :deep(.ant-tabs-content) {
|
||||
|
|
|
|||
|
|
@ -67,7 +67,17 @@ function handleEventClick(arg: EventClickArg): void {
|
|||
}
|
||||
|
||||
onMounted(() => {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
// ponytail: track last size to break ResizeObserver feedback loop —
|
||||
// updateSize() can trigger another resize event, causing infinite thrash.
|
||||
let lastW = 0
|
||||
let lastH = 0
|
||||
resizeObserver = new ResizeObserver((entries) => {
|
||||
const e = entries[0]
|
||||
const w = e.contentRect.width
|
||||
const h = e.contentRect.height
|
||||
if (w === lastW && h === lastH) return
|
||||
lastW = w
|
||||
lastH = h
|
||||
nextTick(() => {
|
||||
calendarRef.value?.getApi()?.updateSize()
|
||||
})
|
||||
|
|
@ -85,6 +95,7 @@ const calendarOptions = computed<CalendarOptions>(() => ({
|
|||
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin],
|
||||
locale: zhCnLocale,
|
||||
initialView: 'dayGridMonth',
|
||||
firstDay: 1,
|
||||
headerToolbar: {
|
||||
left: 'prev,next today',
|
||||
center: 'title',
|
||||
|
|
|
|||
|
|
@ -147,7 +147,6 @@ import TeamModal from './TeamModal.vue'
|
|||
import { useSkillsStore } from '@/stores/skills'
|
||||
import { useTeamStore } from '@/stores/team'
|
||||
import type { ISkillInfo } from '@/api/skills'
|
||||
import { getDynamicBaseURL } from '@/api/base'
|
||||
import { apiClient } from '@/api/client'
|
||||
import { message as antMessage } from 'ant-design-vue'
|
||||
|
||||
|
|
@ -168,9 +167,9 @@ interface ModelInfo {
|
|||
id: string
|
||||
provider: string
|
||||
model: string
|
||||
max_tokens: number
|
||||
cost_per_1k_input: number
|
||||
cost_per_1k_output: number
|
||||
max_tokens: number | null
|
||||
cost_per_1k_input: number | null
|
||||
cost_per_1k_output: number | null
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
|
|
@ -213,10 +212,7 @@ const modelOptions = computed(() => {
|
|||
async function fetchModels() {
|
||||
modelsLoading.value = true
|
||||
try {
|
||||
const base = getDynamicBaseURL()
|
||||
const url = base ? `${base}/api/v1/llm/models` : '/api/v1/llm/models'
|
||||
const resp = await fetch(url)
|
||||
const data = await resp.json()
|
||||
const data = await apiClient.listModels()
|
||||
availableModels.value = data.models || []
|
||||
selectedModel.value = data.default || (availableModels.value.length > 0 ? availableModels.value[0].id : undefined)
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@
|
|||
<button
|
||||
class="chat-sidebar__item-delete"
|
||||
title="删除对话"
|
||||
aria-label="删除对话"
|
||||
@click.stop
|
||||
>
|
||||
<DeleteOutlined />
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
:current-id="chatStore.currentConversationId"
|
||||
@create="chatStore.createConversation"
|
||||
@select="chatStore.selectConversation"
|
||||
@delete="chatStore.deleteConversation"
|
||||
/>
|
||||
<div class="agent-layout__chat-main">
|
||||
<router-view />
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
<button
|
||||
class="right-panel__toggle"
|
||||
:title="collapsed ? '展开右侧面板' : '收起右侧面板'"
|
||||
:aria-label="collapsed ? '展开右侧面板' : '收起右侧面板'"
|
||||
@click="toggle"
|
||||
>
|
||||
<RightOutlined v-if="!collapsed" />
|
||||
|
|
|
|||
|
|
@ -79,9 +79,11 @@
|
|||
<CalendarTab v-else-if="activeTab === 'calendar'" />
|
||||
<SystemTab v-else-if="activeTab === 'system'" />
|
||||
<KnowledgeTab v-else-if="activeTab === 'knowledge'" />
|
||||
<!-- ponytail: v-else fallback catches unknown activeTab values (corrupted state / future tabs) -->
|
||||
<div v-else class="system-monitor__unknown-tab">未知标签页: {{ activeTab }}</div>
|
||||
</div>
|
||||
|
||||
<nav class="system-monitor__tabs" role="tablist" aria-label="右侧功能导航">
|
||||
<nav class="system-monitor__tabs" role="tablist" aria-label="右侧功能导航" @keydown="onTabKeydown">
|
||||
<a-tooltip
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
|
|
@ -150,6 +152,19 @@ const tabs: Tab[] = [
|
|||
const activeTab = ref('monitor')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
// ponytail: WAI-ARIA tablist keyboard navigation — ArrowUp/Down move between
|
||||
// tabs, Home/End jump to first/last. Required for keyboard-only users.
|
||||
function onTabKeydown(e: KeyboardEvent): void {
|
||||
const keys = ['ArrowUp', 'ArrowDown', 'Home', 'End']
|
||||
if (!keys.includes(e.key)) return
|
||||
e.preventDefault()
|
||||
const idx = tabs.findIndex((t) => t.key === activeTab.value)
|
||||
if (e.key === 'ArrowUp') activeTab.value = tabs[(idx - 1 + tabs.length) % tabs.length].key
|
||||
else if (e.key === 'ArrowDown') activeTab.value = tabs[(idx + 1) % tabs.length].key
|
||||
else if (e.key === 'Home') activeTab.value = tabs[0].key
|
||||
else if (e.key === 'End') activeTab.value = tabs[tabs.length - 1].key
|
||||
}
|
||||
const health = ref<IHealthCheck>({ status: 'unknown', version: '', checks: {} })
|
||||
const metrics = ref<IMetricsData>({
|
||||
tasks: { total_tasks: 0, completed_tasks: 0, failed_tasks: 0, pending_tasks: 0 },
|
||||
|
|
@ -305,6 +320,8 @@ onUnmounted(() => {
|
|||
.system-monitor__tab--active {
|
||||
color: var(--accent-team);
|
||||
background: var(--color-primary-light);
|
||||
/* ponytail: override the transparent border-left so the active indicator is visible */
|
||||
border-left-color: var(--accent-team);
|
||||
}
|
||||
|
||||
.system-monitor__tab-icon {
|
||||
|
|
|
|||
|
|
@ -27,12 +27,16 @@
|
|||
/>
|
||||
</div>
|
||||
<a-tooltip title="多维表格">
|
||||
<button class="top-nav__icon-btn" @click="router.push('/bitable')">
|
||||
<button class="top-nav__icon-btn" aria-label="多维表格" @click="router.push('/bitable')">
|
||||
<TableOutlined />
|
||||
</button>
|
||||
</a-tooltip>
|
||||
<a-tooltip :title="isDark ? '切换亮色模式' : '切换暗色模式'">
|
||||
<button class="top-nav__icon-btn" @click="themeStore.toggle()">
|
||||
<button
|
||||
class="top-nav__icon-btn"
|
||||
:aria-label="isDark ? '切换亮色模式' : '切换暗色模式'"
|
||||
@click="themeStore.toggle()"
|
||||
>
|
||||
<BulbOutlined v-if="isDark" />
|
||||
<BulbOutlined v-else style="opacity: 0.5" />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -62,6 +62,9 @@ const simpleImage = Empty.PRESENTED_IMAGE_SIMPLE
|
|||
function formatTime(iso: string): string {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
// ponytail: guard against Invalid Date — new Date('garbage').toLocaleString
|
||||
// returns "Invalid Date" string which leaks into the UI.
|
||||
if (isNaN(d.getTime())) return ''
|
||||
return d.toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
|
|
|
|||
|
|
@ -114,9 +114,10 @@ function closeDetail(): void {
|
|||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (skillsStore.skills.length === 0) {
|
||||
skillsStore.fetchSkills()
|
||||
}
|
||||
// ponytail: previously only fetched when store was empty, causing stale
|
||||
// data after skills were modified elsewhere. Always fetch on mount —
|
||||
// the store handles deduplication and this tab is not mounted often.
|
||||
skillsStore.fetchSkills()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -23,19 +23,28 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
import { MessageShell, UserBubble, ErrorCard } from '@/components/chat/messages'
|
||||
|
||||
const errorDetail = ref('连接被拒绝:无法连接到本地 MCP 服务器(localhost:8080)。请检查服务是否已启动。')
|
||||
const retried = ref(false)
|
||||
// ponytail: save timer ID so onUnmounted can clear it — otherwise a
|
||||
// component teardown during the 1.5s window would mutate a destroyed ref.
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function handleRetry(): void {
|
||||
retried.value = true
|
||||
errorDetail.value = '正在重试…'
|
||||
setTimeout(() => {
|
||||
if (retryTimer) clearTimeout(retryTimer)
|
||||
retryTimer = setTimeout(() => {
|
||||
errorDetail.value = '连接被拒绝:无法连接到本地 MCP 服务器(localhost:8080)。请检查服务是否已启动。'
|
||||
retryTimer = null
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (retryTimer) clearTimeout(retryTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ import { ref, computed } from 'vue'
|
|||
import { authApi, type IAuthUser, type ITokenPair, type ISessionInfo } from '@/api/auth'
|
||||
import { tauriAuthStorage } from '@/api/tauri-auth'
|
||||
import { isTauri, startBackend, checkBackendHealth } from '@/api/tauri'
|
||||
import { initApiBaseURL } from '@/api/base'
|
||||
import { initApiBaseURL, setTokenProvider, setRefreshProvider, setUnauthorizedHandler, setPreEmptiveRefreshProvider } from '@/api/base'
|
||||
|
||||
const USER_KEY = 'agentkit.user'
|
||||
|
||||
|
|
@ -456,6 +456,35 @@ export const useAuthStore = defineStore('auth', () => {
|
|||
return role.value === 'admin'
|
||||
}
|
||||
|
||||
// Register providers with the API client. Must run at store-creation
|
||||
// time (i.e. before the first API call) so request() can read the
|
||||
// current access token. Without this, every protected endpoint gets
|
||||
// a missing Authorization header → 401 → router → /login loop.
|
||||
setTokenProvider(() => accessToken.value)
|
||||
setRefreshProvider(async () => {
|
||||
const pair = await silentRefresh()
|
||||
return pair?.access_token ?? null
|
||||
})
|
||||
setPreEmptiveRefreshProvider(() => shouldRefresh.value)
|
||||
setUnauthorizedHandler(() => {
|
||||
// _doSilentRefresh already handles state transitions correctly:
|
||||
// - 401 (token truly invalid): clears refresh token + sets state='invalid'
|
||||
// - transient error (network/5xx): KEEPS refresh token for retry
|
||||
//
|
||||
// Do NOT call logoutLocal() here unconditionally — it clears the
|
||||
// refresh token even on transient failures, defeating _doSilentRefresh's
|
||||
// intentional "keep token for retry" design. This was the root cause of
|
||||
// the reload-to-login bug: a single transient refresh failure during
|
||||
// normal use cleared the token, so the next reload found no token → /login.
|
||||
//
|
||||
// Only redirect immediately if the state is already 'invalid' (set by
|
||||
// _doSilentRefresh's 401 path). For transient failures, let the 401
|
||||
// error propagate to the caller; the user can retry.
|
||||
if (startupState.value === 'invalid' && window.location.pathname !== '/login') {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
// state
|
||||
accessToken,
|
||||
|
|
|
|||
|
|
@ -370,6 +370,12 @@ export const useChatStore = defineStore("chat", () => {
|
|||
}
|
||||
conversations.value = conversations.value.filter((c) => c.id !== id);
|
||||
streamingStepsByConv.value.delete(id);
|
||||
// ponytail: must also clear pending-* state — selectConversation's 404
|
||||
// handler (line ~297) does this, but deleteConversation previously
|
||||
// omitted it, leaving ghost IDs that _getMostRecentPendingConversation
|
||||
// could return (causing "switch to deleted conversation" UI bugs).
|
||||
pendingConversations.value.delete(id);
|
||||
pendingLastUsedAt.value.delete(id);
|
||||
markConversationDone(id);
|
||||
if (currentConversationId.value === id) {
|
||||
currentConversationId.value = null;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import uuid
|
|||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
|
|
@ -192,12 +191,29 @@ class PortalConnectionManager:
|
|||
features) to deliver real-time messages to a user's open chat tab(s).
|
||||
"""
|
||||
|
||||
# ponytail: per-user connection cap prevents a single client from
|
||||
# exhausting memory via unbounded WS spawns. 16 covers typical
|
||||
# multi-tab usage. Upgrade path: make configurable via server_config.
|
||||
_MAX_CONNECTIONS_PER_USER = 16
|
||||
|
||||
def __init__(self) -> None:
|
||||
# user_id -> list of active WebSocket connections
|
||||
self._connections: dict[str, list[WebSocket]] = {}
|
||||
|
||||
def add(self, user_id: str, ws: WebSocket) -> None:
|
||||
self._connections.setdefault(user_id, []).append(ws)
|
||||
conns = self._connections.setdefault(user_id, [])
|
||||
if len(conns) >= self._MAX_CONNECTIONS_PER_USER:
|
||||
# Close the oldest connection to make room (FIFO eviction).
|
||||
oldest = conns.pop(0)
|
||||
try:
|
||||
# Best-effort close; ignore failures since the socket may
|
||||
# already be dead.
|
||||
import asyncio
|
||||
|
||||
asyncio.create_task(oldest.close(code=1008, reason="Connection limit exceeded"))
|
||||
except Exception:
|
||||
pass
|
||||
conns.append(ws)
|
||||
|
||||
def remove(self, user_id: str, ws: WebSocket) -> None:
|
||||
conns = self._connections.get(user_id)
|
||||
|
|
@ -207,7 +223,7 @@ class PortalConnectionManager:
|
|||
if not self._connections[user_id]:
|
||||
del self._connections[user_id]
|
||||
|
||||
async def send_json(self, user_id: str, message: dict[str, Any]) -> None:
|
||||
async def send_json(self, user_id: str, message: dict[str, object]) -> None:
|
||||
"""Broadcast a JSON message to all connections for *user_id*.
|
||||
|
||||
Removes stale connections that fail to send.
|
||||
|
|
@ -219,7 +235,10 @@ class PortalConnectionManager:
|
|||
for ws in conns:
|
||||
try:
|
||||
await ws.send_json(message)
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Portal WS send failed for user %s (marking stale): %s", user_id, e
|
||||
)
|
||||
stale.append(ws)
|
||||
for ws in stale:
|
||||
self.remove(user_id, ws)
|
||||
|
|
@ -228,7 +247,7 @@ class PortalConnectionManager:
|
|||
portal_connection_manager = PortalConnectionManager()
|
||||
|
||||
|
||||
async def send_to_user(user_id: str, message: dict[str, Any]) -> None:
|
||||
async def send_to_user(user_id: str, message: dict[str, object]) -> None:
|
||||
"""Public helper to push a message to all portal WebSockets for a user."""
|
||||
await portal_connection_manager.send_json(user_id, message)
|
||||
|
||||
|
|
@ -791,7 +810,15 @@ async def get_conversation(
|
|||
|
||||
@router.delete("/portal/conversations/{conversation_id}")
|
||||
async def delete_conversation(conversation_id: str, _auth: None = Depends(_verify_api_key)):
|
||||
"""Delete a conversation and all its messages."""
|
||||
"""Delete a conversation and all its messages.
|
||||
|
||||
ponytail: IDOR note — portal endpoints use API-key auth (single-tenant
|
||||
access model: API key = full access to all conversations). The SQLite
|
||||
store has no user_id column, so per-user ownership cannot be enforced
|
||||
without a schema migration. If API keys become per-user, add a
|
||||
user_id column to conversations + filter DELETE by (id, user_id).
|
||||
Upgrade path: migrate portal endpoints to JWT auth + per-user scoping.
|
||||
"""
|
||||
deleted = await _conversation_store.delete_conversation(conversation_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail=f"Conversation '{conversation_id}' not found")
|
||||
|
|
@ -976,6 +1003,12 @@ async def portal_websocket(websocket: WebSocket):
|
|||
"""Real-time chat WebSocket endpoint."""
|
||||
await websocket.accept()
|
||||
|
||||
# ponytail: ws_user_id must be initialized before any early return — the
|
||||
# finally block below references it. Previously the api_key reject path
|
||||
# returned before assignment, causing UnboundLocalError that masked the
|
||||
# original auth error. Upgrade path: refactor auth into a decorator.
|
||||
ws_user_id: str | None = None
|
||||
|
||||
# Authentication (after accept, since FastAPI requires accept before close)
|
||||
configured_api_key: str | None = None
|
||||
if hasattr(websocket.app.state, "server_config") and websocket.app.state.server_config:
|
||||
|
|
@ -996,7 +1029,7 @@ async def portal_websocket(websocket: WebSocket):
|
|||
# Track authenticated portal connections for user-scoped push (calendar
|
||||
# reminders, etc.). user_id is None for API-key / dev-mode clients.
|
||||
current_user = getattr(websocket.state, "current_user", None) or {}
|
||||
ws_user_id: str | None = current_user.get("user_id")
|
||||
ws_user_id = current_user.get("user_id")
|
||||
if ws_user_id:
|
||||
portal_connection_manager.add(ws_user_id, websocket)
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ user_id; it does not perform auth (same pattern as DocumentTool).
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from agentkit.calendar.models import ReminderRule
|
||||
|
|
@ -18,18 +20,203 @@ from agentkit.calendar.service import CalendarService
|
|||
from agentkit.tools.base import Tool
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chinese relative date/time parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CN_WEEKDAY: dict[str, int] = {
|
||||
"一": 0, "二": 1, "三": 2, "四": 3, "五": 4, "六": 5, "日": 6, "天": 6,
|
||||
"1": 0, "2": 1, "3": 2, "4": 3, "5": 4, "6": 5, "0": 6,
|
||||
}
|
||||
|
||||
_CN_NUM_MAP: dict[str, int] = {
|
||||
"零": 0, "一": 1, "二": 2, "三": 3, "四": 4, "五": 5,
|
||||
"六": 6, "七": 7, "八": 8, "九": 9, "十": 10,
|
||||
}
|
||||
|
||||
# 下周三 / 下周星期三 / 这周三 / 下下周三 / 明天 / 后天 / 大后天 / 今天
|
||||
_CN_DATE_RE = re.compile(
|
||||
r"(下下周|下周|这周|本周|大后天|后天|明天|今天)"
|
||||
r"(?:周|星期)?"
|
||||
r"([一二三四五六日天0-9])?"
|
||||
)
|
||||
|
||||
# N天后 / N小时后 (N can be Arabic or Chinese number)
|
||||
_CN_NDAYS_RE = re.compile(r"(\d+|[零一二三四五六七八九十]+)\s*天[后以]?")
|
||||
_CN_NHOURS_RE = re.compile(r"(\d+|[零一二三四五六七八九十]+)\s*小时[后以]?")
|
||||
|
||||
# Time: 下午3点 / 晚上3:30 / 上午9点 / 15:00 / 3pm
|
||||
_CN_TIME_RE = re.compile(
|
||||
r"(下午|晚上|傍晚|上午|早上|凌晨)?"
|
||||
r"\s*"
|
||||
r"(\d{1,2})"
|
||||
r"\s*[::点]?"
|
||||
r"\s*"
|
||||
r"(\d{1,2})?"
|
||||
r"\s*(?:分|点半|pm|PM|am|AM)?"
|
||||
)
|
||||
|
||||
# Detect any CJK character — used to guard dateutil fuzzy mode
|
||||
_CJK_RE = re.compile(r"[\u4e00-\u9fff]")
|
||||
|
||||
|
||||
def _cn_num(s: str) -> int:
|
||||
"""Parse a small Chinese or Arabic number (0–99)."""
|
||||
if s.isdigit():
|
||||
return int(s)
|
||||
if s in _CN_NUM_MAP:
|
||||
return _CN_NUM_MAP[s]
|
||||
# Handle "十一".."九十九", "二十".."三十"
|
||||
if "十" in s:
|
||||
parts = s.split("十")
|
||||
tens = _CN_NUM_MAP.get(parts[0], 1) if parts[0] else 1
|
||||
ones = _CN_NUM_MAP.get(parts[1], 0) if len(parts) > 1 and parts[1] else 0
|
||||
return tens * 10 + ones
|
||||
return 0
|
||||
|
||||
|
||||
def _extract_cn_time(value: str, date_base: datetime) -> datetime:
|
||||
"""Extract time from Chinese/Arabic time patterns and apply to date_base."""
|
||||
m = _CN_TIME_RE.search(value)
|
||||
if not m:
|
||||
return date_base.replace(hour=9, minute=0, second=0, microsecond=0)
|
||||
pm_kw, hour_str, minute_str = m.groups()
|
||||
hour = int(hour_str)
|
||||
minute = int(minute_str) if minute_str else 0
|
||||
if pm_kw in ("下午", "晚上", "傍晚") and hour < 12:
|
||||
hour += 12
|
||||
return date_base.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
||||
|
||||
|
||||
def _resolve_chinese_datetime(value: str) -> datetime | None:
|
||||
"""Parse common Chinese relative date/time phrases.
|
||||
|
||||
Handles: 下周三, 下周三下午3点, 明天, 后天15:00, 3天后, 2小时后, etc.
|
||||
Returns None if no Chinese pattern matches.
|
||||
"""
|
||||
base = datetime.now().astimezone()
|
||||
|
||||
# N天后
|
||||
m = _CN_NDAYS_RE.search(value)
|
||||
if m:
|
||||
n = _cn_num(m.group(1))
|
||||
target = base + timedelta(days=n)
|
||||
return _extract_cn_time(value, target)
|
||||
|
||||
# N小时后
|
||||
m = _CN_NHOURS_RE.search(value)
|
||||
if m:
|
||||
n = _cn_num(m.group(1))
|
||||
return base + timedelta(hours=n)
|
||||
|
||||
# Relative date phrases (下周X / 明天 / 后天 / ...)
|
||||
m = _CN_DATE_RE.search(value)
|
||||
if not m:
|
||||
return None
|
||||
|
||||
week_kw, weekday_cn = m.groups()
|
||||
today_wd = base.weekday()
|
||||
|
||||
if week_kw in ("这周", "本周"):
|
||||
if weekday_cn and weekday_cn in _CN_WEEKDAY:
|
||||
target_wd = _CN_WEEKDAY[weekday_cn]
|
||||
delta = target_wd - today_wd
|
||||
if delta < 0:
|
||||
delta += 7
|
||||
target = base + timedelta(days=delta)
|
||||
else:
|
||||
return None
|
||||
elif week_kw == "下周":
|
||||
if weekday_cn and weekday_cn in _CN_WEEKDAY:
|
||||
target_wd = _CN_WEEKDAY[weekday_cn]
|
||||
delta = (7 - today_wd) + target_wd
|
||||
target = base + timedelta(days=delta)
|
||||
else:
|
||||
delta = 7 - today_wd if today_wd != 0 else 7
|
||||
target = base + timedelta(days=delta)
|
||||
elif week_kw == "下下周":
|
||||
if weekday_cn and weekday_cn in _CN_WEEKDAY:
|
||||
target_wd = _CN_WEEKDAY[weekday_cn]
|
||||
delta = (14 - today_wd) + target_wd
|
||||
target = base + timedelta(days=delta)
|
||||
else:
|
||||
delta = 14 - today_wd
|
||||
target = base + timedelta(days=delta)
|
||||
elif week_kw == "今天":
|
||||
target = base
|
||||
elif week_kw == "明天":
|
||||
target = base + timedelta(days=1)
|
||||
elif week_kw == "后天":
|
||||
target = base + timedelta(days=2)
|
||||
elif week_kw == "大后天":
|
||||
target = base + timedelta(days=3)
|
||||
else:
|
||||
return None
|
||||
|
||||
return _extract_cn_time(value, target)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _resolve_datetime(value: str | None) -> str | None:
|
||||
"""Resolve a date string to ISO 8601, or return None if unparseable.
|
||||
|
||||
Accepts:
|
||||
- absolute ISO 8601 strings (returned unchanged)
|
||||
- Chinese relative phrases: "下周三", "下周三下午3点", "明天", "后天", "3天后"
|
||||
- English relative phrases: "next monday", "tomorrow 3pm", "+3 days"
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
value = value.strip()
|
||||
# Fast path: already ISO 8601 (has a date separator and a length typical of ISO).
|
||||
if "T" in value or value.endswith("Z") or len(value) >= 10 and value[4:5] == "-":
|
||||
try:
|
||||
datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
return value
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Chinese relative date/time parsing — must run BEFORE dateutil fuzzy
|
||||
# because dateutil cannot parse Chinese and its fuzzy mode silently
|
||||
# drops Chinese characters, producing wrong dates (e.g. "下周三 3pm"
|
||||
# → today's date at 15:00, completely wrong).
|
||||
if _CJK_RE.search(value):
|
||||
cn_dt = _resolve_chinese_datetime(value)
|
||||
if cn_dt is not None:
|
||||
return cn_dt.isoformat()
|
||||
# Chinese characters present but no pattern matched → don't fall
|
||||
# through to dateutil fuzzy (it will produce garbage). Return None
|
||||
# so the caller reports "could not parse" instead of a wrong date.
|
||||
return None
|
||||
|
||||
try:
|
||||
from dateutil import parser as _du # type: ignore[import-untyped]
|
||||
from datetime import datetime as _dt
|
||||
|
||||
dt = _du.parse(value, fuzzy=True)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=_dt.now().astimezone().tzinfo)
|
||||
return dt.isoformat()
|
||||
except (ValueError, TypeError, ImportError):
|
||||
return None
|
||||
|
||||
|
||||
class CalendarTool(Tool):
|
||||
"""Agent tool for calendar event management.
|
||||
|
||||
Actions: create_event, query_events, update_event, delete_event.
|
||||
"""
|
||||
|
||||
def __init__(self, calendar_service: CalendarService):
|
||||
def __init__(self, calendar_service: CalendarService, default_user_id: str | None = None):
|
||||
super().__init__(
|
||||
name="calendar",
|
||||
description=(
|
||||
"Create, query, update, and delete calendar events and reminders. "
|
||||
"Use create_event to schedule events and set reminders (e.g. 'remind me Monday morning'). "
|
||||
"Relative dates like 'next monday', 'tomorrow 3pm', '+3 days' are accepted "
|
||||
"and resolved server-side against the current local time. "
|
||||
"Actions: create_event, query_events, update_event, delete_event."
|
||||
),
|
||||
input_schema={
|
||||
|
|
@ -45,10 +232,6 @@ class CalendarTool(Tool):
|
|||
],
|
||||
"description": "Calendar operation to perform.",
|
||||
},
|
||||
"user_id": {
|
||||
"type": "string",
|
||||
"description": "User ID owning the calendar events.",
|
||||
},
|
||||
"event_id": {
|
||||
"type": "string",
|
||||
"description": "Event ID (for update_event and delete_event).",
|
||||
|
|
@ -59,7 +242,12 @@ class CalendarTool(Tool):
|
|||
},
|
||||
"start_time": {
|
||||
"type": "string",
|
||||
"description": "Event start time, ISO 8601 UTC (create_event, update_event).",
|
||||
"description": (
|
||||
"Event start time. Accepts ISO 8601 UTC, English relative "
|
||||
"('tomorrow 3pm', 'next monday'), or Chinese relative "
|
||||
"('下周三', '下周三下午3点', '明天', '后天', '3天后'). "
|
||||
"(create_event, update_event)."
|
||||
),
|
||||
},
|
||||
"end_time": {
|
||||
"type": "string",
|
||||
|
|
@ -116,10 +304,29 @@ class CalendarTool(Tool):
|
|||
"description": "Maximum number of events to return (query_events).",
|
||||
},
|
||||
},
|
||||
"required": ["action", "user_id"],
|
||||
"required": ["action"],
|
||||
},
|
||||
)
|
||||
self._service = calendar_service
|
||||
self._default_user_id = default_user_id
|
||||
|
||||
def set_default_user_id(self, user_id: str | None) -> None:
|
||||
"""Set the default user_id used when the agent does not provide one.
|
||||
|
||||
Called by the server lifespan after user store is available.
|
||||
ponytail: single-user dev-mode simplification — multi-user needs
|
||||
per-request context passing through the agent framework. Upgrade
|
||||
path: add a ``user_id_resolver`` callback that reads from a
|
||||
contextvar set by the chat handler.
|
||||
"""
|
||||
self._default_user_id = user_id
|
||||
|
||||
def _resolve_user_id(self, kwargs: dict[str, Any]) -> str | None:
|
||||
"""Resolve user_id: prefer caller-provided, fall back to default."""
|
||||
provided = kwargs.get("user_id")
|
||||
if provided and isinstance(provided, str) and provided.strip():
|
||||
return provided
|
||||
return self._default_user_id
|
||||
|
||||
async def execute(self, **kwargs) -> dict[str, Any]:
|
||||
action = kwargs.get("action")
|
||||
|
|
@ -139,7 +346,7 @@ class CalendarTool(Tool):
|
|||
# ------------------------------------------------------------------
|
||||
|
||||
async def _create_event(self, **kwargs) -> dict[str, Any]:
|
||||
user_id = kwargs.get("user_id")
|
||||
user_id = self._resolve_user_id(kwargs)
|
||||
title = kwargs.get("title")
|
||||
start_time = kwargs.get("start_time")
|
||||
end_time = kwargs.get("end_time")
|
||||
|
|
@ -153,6 +360,22 @@ class CalendarTool(Tool):
|
|||
if not end_time:
|
||||
return {"success": False, "error": "Missing required field: end_time"}
|
||||
|
||||
# Resolve relative date strings (e.g. "next monday", "tomorrow 3pm")
|
||||
# to absolute ISO 8601 datetimes. Accepts anything dateutil can parse,
|
||||
# anchored at the *server's* current local time.
|
||||
start_time = _resolve_datetime(start_time)
|
||||
end_time = _resolve_datetime(end_time)
|
||||
if start_time is None:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Could not parse start_time: {kwargs.get('start_time')!r}",
|
||||
}
|
||||
if end_time is None:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Could not parse end_time: {kwargs.get('end_time')!r}",
|
||||
}
|
||||
|
||||
description = kwargs.get("description", "")
|
||||
location = kwargs.get("location", "")
|
||||
is_all_day = kwargs.get("is_all_day", False)
|
||||
|
|
@ -161,6 +384,30 @@ class CalendarTool(Tool):
|
|||
reminder_offset = kwargs.get("reminder_offset_minutes")
|
||||
reminder_channels = kwargs.get("reminder_channels") or ["client"]
|
||||
|
||||
# ponytail: validate reminder_channels type — LLM agents may pass a
|
||||
# bare string ("client") which list() would silently split into
|
||||
# ["c","l","i","e","n","t"]. Reject non-list input explicitly.
|
||||
if not isinstance(reminder_channels, list):
|
||||
return {
|
||||
"success": False,
|
||||
"error": (
|
||||
f"reminder_channels must be a list, got {type(reminder_channels).__name__}: "
|
||||
f"{reminder_channels!r}"
|
||||
),
|
||||
}
|
||||
# ponytail: channel whitelist — prevents arbitrary strings (e.g.
|
||||
# "drop_table") from being persisted and dispatched by the scheduler.
|
||||
_ALLOWED_CHANNELS = {"client", "email", "webhook"}
|
||||
bad = [c for c in reminder_channels if c not in _ALLOWED_CHANNELS]
|
||||
if bad:
|
||||
return {
|
||||
"success": False,
|
||||
"error": (
|
||||
f"reminder_channels contains unknown channel(s): {bad}. "
|
||||
f"Allowed: {sorted(_ALLOWED_CHANNELS)}"
|
||||
),
|
||||
}
|
||||
|
||||
# Build explicit reminder rules if requested
|
||||
reminder_rules: list[ReminderRule] | None = None
|
||||
if reminder_offset is not None:
|
||||
|
|
@ -171,10 +418,13 @@ class CalendarTool(Tool):
|
|||
"success": False,
|
||||
"error": f"reminder_offset_minutes must be an integer, got {reminder_offset!r}",
|
||||
}
|
||||
if offset_int < 0 or offset_int > 43200: # 最多 30 天
|
||||
# ponytail: schema documents negative offsets as "before event"
|
||||
# (e.g. -15 = 15 min before). Previous code rejected all negatives,
|
||||
# breaking the entire reminder feature. ±43200 = ±30 days ceiling.
|
||||
if not -43200 <= offset_int <= 43200:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"reminder_offset_minutes must be in [0, 43200], got {offset_int}",
|
||||
"error": f"reminder_offset_minutes must be in [-43200, 43200], got {offset_int}",
|
||||
}
|
||||
reminder_rules = [
|
||||
ReminderRule(
|
||||
|
|
@ -245,7 +495,7 @@ class CalendarTool(Tool):
|
|||
# ------------------------------------------------------------------
|
||||
|
||||
async def _query_events(self, **kwargs) -> dict[str, Any]:
|
||||
user_id = kwargs.get("user_id")
|
||||
user_id = self._resolve_user_id(kwargs)
|
||||
if not user_id:
|
||||
return {"success": False, "error": "Missing required field: user_id"}
|
||||
|
||||
|
|
@ -271,7 +521,7 @@ class CalendarTool(Tool):
|
|||
|
||||
async def _update_event(self, **kwargs) -> dict[str, Any]:
|
||||
event_id = kwargs.get("event_id")
|
||||
user_id = kwargs.get("user_id")
|
||||
user_id = self._resolve_user_id(kwargs)
|
||||
if not event_id:
|
||||
return {"success": False, "error": "Missing required field: event_id"}
|
||||
if not user_id:
|
||||
|
|
@ -285,11 +535,20 @@ class CalendarTool(Tool):
|
|||
return {"success": False, "error": "Permission denied"}
|
||||
|
||||
# Build fields dict from updatable params (only those explicitly provided)
|
||||
updatable = ["title", "description", "start_time", "end_time", "location", "is_all_day"]
|
||||
updatable = ["title", "description", "location", "is_all_day"]
|
||||
fields: dict[str, Any] = {}
|
||||
for key in updatable:
|
||||
if key in kwargs and kwargs[key] is not None:
|
||||
fields[key] = kwargs[key]
|
||||
for time_key in ("start_time", "end_time"):
|
||||
if time_key in kwargs and kwargs[time_key] is not None:
|
||||
resolved = _resolve_datetime(kwargs[time_key])
|
||||
if resolved is None:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Could not parse {time_key}: {kwargs[time_key]!r}",
|
||||
}
|
||||
fields[time_key] = resolved
|
||||
|
||||
if not fields:
|
||||
return {"success": False, "error": "No fields to update"}
|
||||
|
|
@ -308,7 +567,7 @@ class CalendarTool(Tool):
|
|||
|
||||
async def _delete_event(self, **kwargs) -> dict[str, Any]:
|
||||
event_id = kwargs.get("event_id")
|
||||
user_id = kwargs.get("user_id")
|
||||
user_id = self._resolve_user_id(kwargs)
|
||||
if not event_id:
|
||||
return {"success": False, "error": "Missing required field: event_id"}
|
||||
if not user_id:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,388 @@
|
|||
"""E2E API Coverage Tests — endpoints not covered by test_basic_api.py.
|
||||
|
||||
Covers:
|
||||
1. /api/v1/experts — 专家模板列表/详情
|
||||
2. /api/v1/workflows — 工作流列表/详情
|
||||
3. /api/v1/config — 配置同步 (version/all/skills/agents/workflows)
|
||||
4. /api/v1/system/resources — 系统资源监控
|
||||
5. /api/v1/evolution — 进化事件 (store 未配置时 503)
|
||||
6. /api/v1/evolution-dashboard — 进化看板 (metrics/experiences/usage/pitfalls)
|
||||
7. /api/v1/settings — 设置 (GET 各类配置;PUT 需 SYSTEM_CONFIG → 403)
|
||||
8. /api/v1/auth — 认证 (login 无效凭据 401;whoami 无 bearer 401)
|
||||
9. /api/v1/memory — 记忆 (retriever 未配置时 503)
|
||||
|
||||
每个端点至少 2 个测试:Happy path + Auth/Error path。
|
||||
api_client fixture 已携带 X-API-Key(operator 角色),所以主要测试 happy path;
|
||||
对需要更高权限的端点验证 403,对未配置后端的端点验证 503/空数据。
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from typing import Generator
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
pytestmark = pytest.mark.e2e_basic
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Local e2e_server fixture — overrides conftest's version for this module.
|
||||
#
|
||||
# The conftest has two bugs that prevent the server from starting:
|
||||
# 1. Uses `python -m agentkit.cli.main` — but main.py has no
|
||||
# `if __name__ == "__main__": app()` block, so the module imports
|
||||
# silently and exits without invoking the Typer app.
|
||||
# 2. Even with the correct entry point (`python -m agentkit`), the CLI's
|
||||
# `serve` command calls `Confirm.ask` for onboarding when
|
||||
# `has_llm_provider()` is False (mock provider has no api_key),
|
||||
# blocking forever in a subprocess with no TTY.
|
||||
#
|
||||
# This fixture bypasses the CLI entirely: it writes a minimal startup script
|
||||
# that imports `create_app` and runs uvicorn directly. The config path is
|
||||
# passed via the `AGENTKIT_CONFIG_PATH` env var that `create_app` reads.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def e2e_server(tmp_path_factory: pytest.TempPathFactory) -> Generator[str, None, None]:
|
||||
"""Start a real AgentKit server for this module's tests."""
|
||||
from tests.e2e.conftest import (
|
||||
E2E_API_KEY,
|
||||
E2E_HOST,
|
||||
E2E_PORT,
|
||||
_build_mock_env,
|
||||
)
|
||||
|
||||
tmp_path = tmp_path_factory.mktemp("e2e_cov_server")
|
||||
config_dir = tmp_path / "config"
|
||||
config_dir.mkdir()
|
||||
config_file = config_dir / "agentkit.yaml"
|
||||
config_file.write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"server": {
|
||||
"host": E2E_HOST,
|
||||
"port": E2E_PORT,
|
||||
# server.api_key is what AuthMiddleware reads (NOT auth.api_keys).
|
||||
# Without it, the middleware runs in dev mode and all requests pass.
|
||||
"api_key": E2E_API_KEY,
|
||||
},
|
||||
"llm": {"default_provider": "mock", "providers": {"mock": {"type": "mock"}}},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
# Minimal startup script: set config path, import create_app, run uvicorn.
|
||||
# This avoids the CLI's interactive onboarding prompt entirely.
|
||||
startup_script = tmp_path / "start_server.py"
|
||||
startup_script.write_text(
|
||||
"import os, uvicorn\n"
|
||||
"from agentkit.server.app import create_app\n"
|
||||
f'uvicorn.run(create_app, factory=True, host="{E2E_HOST}", '
|
||||
f"port={E2E_PORT}, log_level='warning')\n"
|
||||
)
|
||||
|
||||
env = _build_mock_env(tmp_path)
|
||||
env["AGENTKIT_CONFIG_PATH"] = str(config_file)
|
||||
# Ensure PYTHONPATH includes the project root so `agentkit` is importable
|
||||
env["PYTHONPATH"] = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-u", str(startup_script)],
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
cwd=str(tmp_path),
|
||||
)
|
||||
|
||||
base_url = f"http://{E2E_HOST}:{E2E_PORT}"
|
||||
deadline = time.monotonic() + 45
|
||||
ready = False
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
resp = httpx.get(f"{base_url}/api/v1/health", timeout=2)
|
||||
if resp.status_code == 200:
|
||||
ready = True
|
||||
break
|
||||
except httpx.RequestError:
|
||||
# ConnectError (port not open) OR ReadTimeout (lifespan not ready)
|
||||
pass
|
||||
time.sleep(0.5)
|
||||
|
||||
if not ready:
|
||||
proc.terminate()
|
||||
try:
|
||||
stdout, stderr = proc.communicate(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
stdout, stderr = proc.communicate()
|
||||
pytest.fail(
|
||||
f"E2E server failed to start within 45s.\n"
|
||||
f"stdout: {stdout.decode()[:2000]}\n"
|
||||
f"stderr: {stderr.decode()[:2000]}"
|
||||
)
|
||||
|
||||
yield base_url
|
||||
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# 1. Experts API — /api/v1/experts
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestExpertsAPI:
|
||||
def test_list_experts(self, api_client: httpx.Client):
|
||||
resp = api_client.get("/api/v1/experts")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "experts" in data
|
||||
assert "total" in data
|
||||
assert isinstance(data["experts"], list)
|
||||
assert data["total"] == len(data["experts"])
|
||||
|
||||
def test_get_expert_not_found(self, api_client: httpx.Client):
|
||||
resp = api_client.get("/api/v1/experts/nonexistent_expert_xyz")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_list_experts_no_api_key(self, e2e_server: str):
|
||||
"""无 API key 时应被中间件拒绝(401)。"""
|
||||
client = httpx.Client(base_url=e2e_server, timeout=10)
|
||||
resp = client.get("/api/v1/experts")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# 2. Workflows API — /api/v1/workflows
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestWorkflowsAPI:
|
||||
def test_list_workflows(self, api_client: httpx.Client):
|
||||
resp = api_client.get("/api/v1/workflows")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "workflows" in data
|
||||
assert "total" in data
|
||||
assert isinstance(data["workflows"], list)
|
||||
|
||||
def test_get_workflow_not_found(self, api_client: httpx.Client):
|
||||
resp = api_client.get("/api/v1/workflows/nonexistent_wf_id")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_create_workflow_invalid_body(self, api_client: httpx.Client):
|
||||
"""缺少必填字段应返回 422。"""
|
||||
resp = api_client.post("/api/v1/workflows", json={})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# 3. Config Sync API — /api/v1/config
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestConfigSyncAPI:
|
||||
def test_get_config_version(self, api_client: httpx.Client):
|
||||
resp = api_client.get("/api/v1/config/version")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "version" in data
|
||||
assert "skill_count" in data
|
||||
assert "workflow_count" in data
|
||||
|
||||
def test_get_all_configs(self, api_client: httpx.Client):
|
||||
resp = api_client.get("/api/v1/config/all")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "version" in data
|
||||
assert "skills" in data
|
||||
assert "workflows" in data
|
||||
assert "synced_at" in data
|
||||
|
||||
def test_get_skill_configs(self, api_client: httpx.Client):
|
||||
resp = api_client.get("/api/v1/config/skills")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "skills" in data
|
||||
assert "count" in data
|
||||
|
||||
def test_get_config_no_api_key(self, e2e_server: str):
|
||||
"""config 端点需 CHAT 权限;无 API key 时中间件返回 401。"""
|
||||
client = httpx.Client(base_url=e2e_server, timeout=10)
|
||||
resp = client.get("/api/v1/config/version")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# 4. System Resources API — /api/v1/system/resources
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestSystemAPI:
|
||||
def test_get_system_resources(self, api_client: httpx.Client):
|
||||
"""端点明确不挂 SYSTEM_CONFIG(见路由注释),operator 可读。"""
|
||||
resp = api_client.get("/api/v1/system/resources")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "cpu" in data
|
||||
assert "memory" in data
|
||||
assert "disk" in data
|
||||
assert "timestamp" in data
|
||||
|
||||
def test_get_system_resources_no_api_key(self, e2e_server: str):
|
||||
client = httpx.Client(base_url=e2e_server, timeout=10)
|
||||
resp = client.get("/api/v1/system/resources")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# 5. Evolution API — /api/v1/evolution
|
||||
# E2E 配置无 evolution 段,store 为 None → 503
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestEvolutionAPI:
|
||||
def test_list_evolution_events_store_unconfigured(self, api_client: httpx.Client):
|
||||
"""E2E server 未配置 evolution store,应返回 503。"""
|
||||
resp = api_client.get("/api/v1/evolution/events")
|
||||
assert resp.status_code == 503
|
||||
|
||||
def test_list_ab_tests_store_unconfigured(self, api_client: httpx.Client):
|
||||
resp = api_client.get("/api/v1/evolution/ab-tests")
|
||||
assert resp.status_code == 503
|
||||
|
||||
def test_trigger_evolution_no_agent(self, api_client: httpx.Client):
|
||||
"""trigger 在 store 未配置时也应返回 503(_get_evolution_store 抛 503)。"""
|
||||
resp = api_client.post(
|
||||
"/api/v1/evolution/trigger",
|
||||
json={"agent_name": "no_such_agent"},
|
||||
)
|
||||
assert resp.status_code == 503
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# 6. Evolution Dashboard API — /api/v1/evolution-dashboard
|
||||
# 使用自带 _verify_api_key;store 未配置时回退到内存空数据 → 200
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestEvolutionDashboardAPI:
|
||||
def test_get_metrics(self, api_client: httpx.Client):
|
||||
resp = api_client.get("/api/v1/evolution-dashboard/metrics")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
# 默认空指标结构
|
||||
assert "total_tasks" in data or "metrics" in data
|
||||
|
||||
def test_list_experiences(self, api_client: httpx.Client):
|
||||
resp = api_client.get("/api/v1/evolution-dashboard/experiences")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "experiences" in data
|
||||
assert "total" in data
|
||||
|
||||
def test_get_metrics_no_api_key(self, e2e_server: str):
|
||||
"""dashboard 端点自带 _verify_api_key,无 key 时返回 401。"""
|
||||
client = httpx.Client(base_url=e2e_server, timeout=10)
|
||||
resp = client.get("/api/v1/evolution-dashboard/metrics")
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_pitfalls_missing_required_param(self, api_client: httpx.Client):
|
||||
"""pitfalls 端点 task_type 为必填,缺失时 422。"""
|
||||
resp = api_client.get("/api/v1/evolution-dashboard/pitfalls")
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# 7. Settings API — /api/v1/settings
|
||||
# GET 不需特殊权限;PUT 需 SYSTEM_CONFIG(operator 无 → 403)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestSettingsAPI:
|
||||
def test_get_llm_settings(self, api_client: httpx.Client):
|
||||
resp = api_client.get("/api/v1/settings/llm")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "providers" in data
|
||||
|
||||
def test_get_skills_settings(self, api_client: httpx.Client):
|
||||
resp = api_client.get("/api/v1/settings/skills")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "paths" in data
|
||||
|
||||
def test_get_general_settings(self, api_client: httpx.Client):
|
||||
resp = api_client.get("/api/v1/settings/general")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_update_llm_settings_forbidden(self, api_client: httpx.Client):
|
||||
"""PUT /settings/llm 需 SYSTEM_CONFIG;api_client 为 operator 角色 → 403。"""
|
||||
resp = api_client.put(
|
||||
"/api/v1/settings/llm",
|
||||
json={"providers": []},
|
||||
)
|
||||
# operator 无 SYSTEM_CONFIG;非 dev mode 高风险权限 → 403
|
||||
assert resp.status_code in (401, 403)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# 8. Auth API — /api/v1/auth
|
||||
# login/whoami 在中间件白名单内(不需要 X-API-Key)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestAuthAPI:
|
||||
def test_login_invalid_credentials(self, e2e_server: str):
|
||||
"""login 端点白名单,无需 API key;无效凭据 → 401。"""
|
||||
client = httpx.Client(base_url=e2e_server, timeout=10)
|
||||
resp = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "no_such_user", "password": "wrong_password"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_whoami_without_bearer(self, e2e_server: str):
|
||||
"""whoami 自行校验 bearer token;缺失 → 401。"""
|
||||
client = httpx.Client(base_url=e2e_server, timeout=10)
|
||||
resp = client.get("/api/v1/auth/whoami")
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_login_missing_fields(self, e2e_server: str):
|
||||
"""login 请求体缺少必填字段 → 422。"""
|
||||
client = httpx.Client(base_url=e2e_server, timeout=10)
|
||||
resp = client.post("/api/v1/auth/login", json={})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# 9. Memory API — /api/v1/memory
|
||||
# E2E 配置无 memory 段,retriever 未设置 → 503
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestMemoryAPI:
|
||||
def test_search_episodic_retriever_unconfigured(self, api_client: httpx.Client):
|
||||
"""retriever 未配置 → 503。"""
|
||||
resp = api_client.get("/api/v1/memory/episodic", params={"query": "test"})
|
||||
assert resp.status_code == 503
|
||||
|
||||
def test_search_semantic_retriever_unconfigured(self, api_client: httpx.Client):
|
||||
resp = api_client.get(
|
||||
"/api/v1/memory/semantic/search", params={"query": "test"}
|
||||
)
|
||||
assert resp.status_code == 503
|
||||
|
||||
def test_search_episodic_missing_query(self, api_client: httpx.Client):
|
||||
"""query 为必填 query 参数,缺失 → 422。"""
|
||||
resp = api_client.get("/api/v1/memory/episodic")
|
||||
assert resp.status_code == 422
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
"""Unit tests for CalendarTool — focus on relative date resolution."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from agentkit.calendar.service import CalendarService
|
||||
from agentkit.tools.calendar_tool import CalendarTool, _resolve_datetime
|
||||
|
||||
|
||||
class TestResolveDatetime:
|
||||
def test_none(self) -> None:
|
||||
assert _resolve_datetime(None) is None
|
||||
assert _resolve_datetime("") is None
|
||||
|
||||
def test_iso_unchanged(self) -> None:
|
||||
iso = "2026-07-06T09:00:00+08:00"
|
||||
assert _resolve_datetime(iso) == iso
|
||||
|
||||
def test_iso_with_z_unchanged(self) -> None:
|
||||
iso = "2026-07-06T01:00:00Z"
|
||||
assert _resolve_datetime(iso) == iso
|
||||
|
||||
def test_next_monday(self) -> None:
|
||||
result = _resolve_datetime("next monday")
|
||||
assert result is not None
|
||||
# Should be parseable as ISO 8601
|
||||
parsed = datetime.fromisoformat(result)
|
||||
# Monday weekday
|
||||
assert parsed.weekday() == 0
|
||||
|
||||
def test_tomorrow_with_time(self) -> None:
|
||||
result = _resolve_datetime("tomorrow 3pm")
|
||||
assert result is not None
|
||||
parsed = datetime.fromisoformat(result)
|
||||
assert parsed.hour == 15
|
||||
|
||||
def test_in_3_days(self) -> None:
|
||||
# dateutil handles "in N days" — but with timezone-dependent results;
|
||||
# the *contract* is that _resolve_datetime returns a parseable ISO string.
|
||||
# We don't assert a specific offset because dateutil interprets "in" loosely.
|
||||
result = _resolve_datetime("in 3 days")
|
||||
assert result is not None
|
||||
datetime.fromisoformat(result) # must be parseable
|
||||
|
||||
def test_garbage_returns_none(self) -> None:
|
||||
assert _resolve_datetime("not a date at all xyz") is None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_service() -> AsyncMock:
|
||||
svc = AsyncMock(spec=CalendarService)
|
||||
svc.create_event = AsyncMock()
|
||||
return svc
|
||||
|
||||
|
||||
class TestCreateEventWithRelativeDates:
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_resolves_next_monday(
|
||||
self, mock_service: AsyncMock
|
||||
) -> None:
|
||||
from agentkit.calendar.models import CalendarEvent
|
||||
|
||||
mock_service.create_event.return_value = CalendarEvent(
|
||||
id="evt-1",
|
||||
user_id="u1",
|
||||
title="项目启动会",
|
||||
description="",
|
||||
start_time=datetime.now(tz=timezone.utc),
|
||||
end_time=datetime.now(tz=timezone.utc) + timedelta(hours=1),
|
||||
location="",
|
||||
is_all_day=False,
|
||||
)
|
||||
|
||||
tool = CalendarTool(mock_service) # type: ignore[arg-type]
|
||||
result = await tool.execute(
|
||||
action="create_event",
|
||||
user_id="u1",
|
||||
title="项目启动会",
|
||||
start_time="next monday",
|
||||
end_time="next monday",
|
||||
reminder_offset_minutes=15,
|
||||
)
|
||||
|
||||
assert result["success"] is True, result
|
||||
# create_event should have been called with ISO strings, not the raw "next monday"
|
||||
call_kwargs = mock_service.create_event.call_args.kwargs
|
||||
assert call_kwargs["start_time"] != "next monday"
|
||||
# Should be parseable
|
||||
datetime.fromisoformat(call_kwargs["start_time"])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_unparseable_returns_error(
|
||||
self, mock_service: AsyncMock
|
||||
) -> None:
|
||||
tool = CalendarTool(mock_service) # type: ignore[arg-type]
|
||||
result = await tool.execute(
|
||||
action="create_event",
|
||||
user_id="u1",
|
||||
title="x",
|
||||
start_time="garbage qqq",
|
||||
end_time="garbage qqq",
|
||||
)
|
||||
assert result["success"] is False
|
||||
assert "Could not parse start_time" in result["error"]
|
||||
|
|
@ -114,6 +114,52 @@ async def test_create_event_with_type_and_tags(
|
|||
assert tag_names == {"urgent", "work"}
|
||||
|
||||
|
||||
async def test_create_event_broadcasts_via_notify_callback(
|
||||
calendar_db_path: Path, auth_db_path: Path
|
||||
) -> None:
|
||||
"""create_event must invoke notify_callback with calendar_event_created payload.
|
||||
|
||||
Regression guard: without the broadcast, the frontend calendar view does
|
||||
not refresh after an agent creates an event via the calendar tool, even
|
||||
though the event was successfully persisted.
|
||||
"""
|
||||
received: list[tuple[str, dict[str, object]]] = []
|
||||
|
||||
async def _capture(user_id: str, message: dict[str, object]) -> None:
|
||||
received.append((user_id, message))
|
||||
|
||||
svc = CalendarService(
|
||||
db_path=calendar_db_path,
|
||||
auth_db_path=auth_db_path,
|
||||
notify_callback=_capture,
|
||||
)
|
||||
event = await svc.create_event(
|
||||
user_id="user-broadcast",
|
||||
title="Refresh Test",
|
||||
start_time="2026-07-02T09:00:00+00:00",
|
||||
end_time="2026-07-02T10:00:00+00:00",
|
||||
)
|
||||
|
||||
assert len(received) == 1, "notify_callback must fire exactly once"
|
||||
uid, msg = received[0]
|
||||
assert uid == "user-broadcast"
|
||||
assert msg["type"] == "calendar_event_created"
|
||||
assert msg["data"]["event"]["id"] == event.id # type: ignore[index]
|
||||
|
||||
|
||||
async def test_create_event_without_callback_does_not_raise(
|
||||
service: CalendarService,
|
||||
) -> None:
|
||||
"""Default CalendarService (no callback) must still create events."""
|
||||
event = await service.create_event(
|
||||
user_id="user-1",
|
||||
title="No Callback",
|
||||
start_time="2026-07-03T09:00:00+00:00",
|
||||
end_time="2026-07-03T10:00:00+00:00",
|
||||
)
|
||||
assert event.title == "No Callback"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Date range filtering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -341,7 +341,7 @@ def test_tool_name_and_schema(tool: CalendarTool) -> None:
|
|||
schema = tool.input_schema
|
||||
assert schema["type"] == "object"
|
||||
assert "action" in schema["properties"]
|
||||
assert "user_id" in schema["properties"]
|
||||
assert "start_time" in schema["properties"]
|
||||
assert schema["properties"]["action"]["enum"] == [
|
||||
"create_event",
|
||||
"query_events",
|
||||
|
|
@ -349,4 +349,133 @@ def test_tool_name_and_schema(tool: CalendarTool) -> None:
|
|||
"delete_event",
|
||||
]
|
||||
assert "action" in schema["required"]
|
||||
assert "user_id" in schema["required"]
|
||||
# user_id is no longer in the schema — it's resolved internally via
|
||||
# _resolve_user_id (caller-provided or default_user_id fallback).
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chinese relative date/time parsing (_resolve_datetime)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from datetime import datetime as _dt
|
||||
|
||||
from agentkit.tools.calendar_tool import _resolve_datetime
|
||||
|
||||
|
||||
def _parse_iso(value: str | None) -> _dt | None:
|
||||
"""Helper: parse _resolve_datetime output back to datetime."""
|
||||
if value is None:
|
||||
return None
|
||||
return _dt.fromisoformat(value)
|
||||
|
||||
|
||||
def test_resolve_iso8601_passthrough() -> None:
|
||||
"""ISO 8601 strings are returned unchanged."""
|
||||
iso = "2026-07-08T15:00:00+00:00"
|
||||
assert _resolve_datetime(iso) == iso
|
||||
|
||||
|
||||
def test_resolve_none_for_empty() -> None:
|
||||
"""None/empty returns None."""
|
||||
assert _resolve_datetime(None) is None
|
||||
assert _resolve_datetime("") is None
|
||||
assert _resolve_datetime(" ") is None
|
||||
|
||||
|
||||
def test_resolve_chinese_next_week_weekday() -> None:
|
||||
"""'下周三' resolves to next week's Wednesday."""
|
||||
now = _dt.now().astimezone()
|
||||
result = _parse_iso(_resolve_datetime("下周三"))
|
||||
assert result is not None
|
||||
# Should be Wednesday (weekday=2)
|
||||
assert result.weekday() == 2
|
||||
# Should be in the future, specifically next week
|
||||
delta = (result.date() - now.date()).days
|
||||
assert delta >= 7 # at least 7 days out (next week)
|
||||
assert delta <= 13 # at most 13 days out
|
||||
|
||||
|
||||
def test_resolve_chinese_next_week_with_time() -> None:
|
||||
"""'下周三下午3点' resolves to next Wednesday at 15:00."""
|
||||
result = _parse_iso(_resolve_datetime("下周三下午3点"))
|
||||
assert result is not None
|
||||
assert result.weekday() == 2
|
||||
assert result.hour == 15
|
||||
assert result.minute == 0
|
||||
|
||||
|
||||
def test_resolve_chinese_tomorrow() -> None:
|
||||
"""'明天' resolves to tomorrow."""
|
||||
now = _dt.now().astimezone()
|
||||
result = _parse_iso(_resolve_datetime("明天"))
|
||||
assert result is not None
|
||||
delta = (result.date() - now.date()).days
|
||||
assert delta == 1
|
||||
|
||||
|
||||
def test_resolve_chinese_day_after_tomorrow() -> None:
|
||||
"""'后天' resolves to day after tomorrow."""
|
||||
now = _dt.now().astimezone()
|
||||
result = _parse_iso(_resolve_datetime("后天"))
|
||||
assert result is not None
|
||||
delta = (result.date() - now.date()).days
|
||||
assert delta == 2
|
||||
|
||||
|
||||
def test_resolve_chinese_n_days_later() -> None:
|
||||
"""'3天后' resolves to 3 days from now."""
|
||||
now = _dt.now().astimezone()
|
||||
result = _parse_iso(_resolve_datetime("3天后"))
|
||||
assert result is not None
|
||||
delta = (result.date() - now.date()).days
|
||||
assert delta == 3
|
||||
|
||||
|
||||
def test_resolve_chinese_n_hours_later() -> None:
|
||||
"""'2小时后' resolves to ~2 hours from now."""
|
||||
now = _dt.now().astimezone()
|
||||
result = _parse_iso(_resolve_datetime("2小时后"))
|
||||
assert result is not None
|
||||
delta_seconds = (result - now).total_seconds()
|
||||
assert 7000 <= delta_seconds <= 7400 # ~2 hours (allow small tolerance)
|
||||
|
||||
|
||||
def test_resolve_chinese_pm_time() -> None:
|
||||
"""'下周三 15:00' (Arabic) resolves to 15:00."""
|
||||
result = _parse_iso(_resolve_datetime("下周三 15:00"))
|
||||
assert result is not None
|
||||
assert result.weekday() == 2
|
||||
assert result.hour == 15
|
||||
|
||||
|
||||
def test_resolve_chinese_this_week() -> None:
|
||||
"""'这周五' resolves to this week's Friday."""
|
||||
now = _dt.now().astimezone()
|
||||
result = _parse_iso(_resolve_datetime("这周五"))
|
||||
assert result is not None
|
||||
assert result.weekday() == 4 # Friday
|
||||
# Should be this week or next if already passed
|
||||
delta = (result.date() - now.date()).days
|
||||
assert 0 <= delta <= 7
|
||||
|
||||
|
||||
def test_resolve_chinese_unparseable_returns_none() -> None:
|
||||
"""Chinese text with no recognizable date pattern returns None (not a wrong date)."""
|
||||
assert _resolve_datetime("某个时候") is None
|
||||
assert _resolve_datetime("时间待定") is None
|
||||
assert _resolve_datetime("尽快") is None
|
||||
|
||||
|
||||
def test_resolve_chinese_does_not_fall_through_to_dateutil() -> None:
|
||||
"""Chinese+number mix must NOT fall through to dateutil fuzzy (would produce wrong date).
|
||||
|
||||
Before the fix, '下周三 3pm' would be parsed by dateutil fuzzy as today's
|
||||
date at 15:00 (silently dropping the Chinese). Now it returns the correct
|
||||
next-Wednesday date.
|
||||
"""
|
||||
now = _dt.now().astimezone()
|
||||
result = _parse_iso(_resolve_datetime("下周三 3pm"))
|
||||
assert result is not None
|
||||
assert result.weekday() == 2 # Wednesday, not today's weekday
|
||||
# The date should NOT be today (the old bug produced today's date)
|
||||
assert result.date() != now.date()
|
||||
|
|
|
|||
Loading…
Reference in New Issue