diff --git a/cat-cafe-skills/guide-authoring/SKILL.md b/cat-cafe-skills/guide-authoring/SKILL.md new file mode 100644 index 000000000..8a1f6cacb --- /dev/null +++ b/cat-cafe-skills/guide-authoring/SKILL.md @@ -0,0 +1,193 @@ +--- +name: guide-authoring +description: > + 标准引导流程设计 SOP:场景识别 → YAML 编排 → 标签标注 → 注册发现 → 测试验证。 + Use when: 新建引导流程、添加场景引导、维护 Guide Catalog、编写引导 YAML。 + Not for: 使用引导(用户侧)、Guide Engine 代码实现(用 tdd)、视觉设计(用 pencil-design)。 + Output: Flow YAML + tag-manifest 更新 + registry 注册 + CI 校验通过。 +triggers: + - "新建引导" + - "添加场景引导" + - "写引导流程" + - "guide authoring" + - "引导 YAML" +--- + +# Guide Authoring + +为 Console 已有功能编写引导流程的标准 SOP:场景识别 → YAML 编排 → 标签标注 → 注册发现 → 测试验证。 + +## When to Use + +- 需要为 Console 已有功能新增一条可触发的引导流程 +- 需要维护 `guides/flows/*.yaml`、`guides/tag-manifest.yaml`、`guides/registry.yaml` +- 需要给已有功能补引导标签或补 registry 发现规则 + +**Not for**: +- 用户正在实际使用引导流程时的交互处理,用 `guide-interaction` +- Guide Engine / callback route / overlay 的代码实现与 bug 修复,用 `tdd` +- 纯视觉稿、动效或高保真设计稿,用 `pencil-design` + +## 核心知识 + +| 原则 | 说明 | +|------|------| +| 编排即产品 | Flow YAML 是终态产物,不是脚手架 | +| 页面零侵入 | 只加 `data-guide-id` 标签,不改业务逻辑 | +| 自动推进 | 用户操作即推进,无手动导航按钮(v2 KD-9) | +| 平台内聚焦 | 聚焦 Console 已有功能的引导(KD-13),外部平台配置改为独立页签 | + +**前置依赖**:F150 Guide Engine Phase A 已验收。 + +## 流程 + +### Step 1: 场景识别 + +确认需要引导的场景,产出"场景卡片": + +```yaml +# 场景卡片 +scene_id: api-provider-setup +scene_name: 配置 API Provider +target_user: 新部署用户 / 需要配置 LLM 的团队 +pain_point: Provider 配置字段多,不同 provider 参数差异大 +complexity: medium # low / medium / high +estimated_steps: 4 +estimated_time: 3min +related_features: [F150] +``` + +**判断标准**: +- complexity=high → 必须做引导 +- complexity=medium → 评估用户卡点频率再定 +- complexity=low → 不做引导,文档即可 + +### Step 2: 步骤拆分 + YAML 编排 + +按 v2 自动推进模式编排,4 种 advance mode: + +| advance | 用途 | 说明 | +|---------|------|------| +| `click` | 点击目标元素 | 用户点击后自动前进 | +| `visible` | 目标元素出现 | 页面切换/展开后自动前进 | +| `input` | 输入填充 | 用户填写输入框后前进 | +| `confirm` | 操作确认 | 需要 `guide:confirm` 事件触发(如保存成功) | + +**Flow YAML 模板**(v2 schema): + +```yaml +id: {scene_id} +name: {scene_name} +description: {一句话描述} + +steps: + - id: step-1 + target: "namespace.element" # data-guide-id 值 + tips: "点击这里开始配置" # 引导文案 + advance: click # click / visible / input / confirm + + - id: step-2 + target: "namespace.form-field" + tips: "填写 API 密钥" + advance: input + + - id: step-final + target: "namespace.save-button" + tips: "点击保存完成配置" + advance: confirm # 保存成功后 guide:confirm 触发 +``` + +**编排规则**: +- 每个 flow 必须有退出路径(HUD 退出按钮始终可用) +- target 值必须匹配 `data-guide-id`,命名空间式(如 `hub.trigger`) +- target 必须通过 whitelist:`/^[a-zA-Z0-9._-]+$/` +- 最后一步建议用 `confirm` 类型,确保操作真正成功后才完成 +- 全局 Esc 键已禁用(KD-14),防止误退出 + +### Step 3: 元素标签标注 + +给涉及的前端元素添加 `data-guide-id`: + +```tsx +// 命名规则:{页面}.{区域}.{元素} + + +``` + +**标签命名约定**: +- 用点号分层,语义而非位置 +- 避免 CSS class 名、索引号 +- 标签一旦被 flow 引用即为契约,删改需走 CI 门禁 + +**产出**:更新 `guides/tag-manifest.yaml`(CI 用于契约校验): + +```yaml +# guides/tag-manifest.yaml +tags: + hub.trigger: { page: "/hub", component: "CatCafeHub.tsx" } + cats.add-member: { page: "/hub/cats", component: "HubCatsTab.tsx" } +``` + +### Step 4: 注册到 Guide Registry + +在 `guides/registry.yaml` 添加场景条目: + +```yaml +- id: api-provider-setup + name: 配置 API Provider + keywords: [api, provider, 配置, llm, api-key, 模型] + entry_page: /hub/settings/providers + estimated_time: 3min + flow_file: guides/flows/api-provider-setup.yaml + priority: P1 +``` + +**关键词设计原则**: +- 覆盖中英文同义词 +- 包含用户可能的自然表达 +- 不要太泛(避免误匹配) + +### Step 5: CI 契约测试 + +确保以下校验全部通过(对应 AC-S3): +- [ ] Flow schema 合法(step 字段 + advance 类型) +- [ ] 所有 `target` 在 tag-manifest.yaml 中存在 +- [ ] 至少有退出路径(HUD 退出按钮 — 引擎内置,无需手动添加) + +### Step 6: 端到端验证 + +1. 启动 dev 环境 +2. 在聊天中触发引导(说匹配关键词) +3. 走完全流程:每步高亮正确 → 操作后自动推进 → 完成回调生效 +4. 测试异常路径:退出 → 刷新 → 目标元素不存在时的 locating 行为 +5. 确认完成后猫猫收到 completion 通知 + +## Quick Reference + +| 要做什么 | 文件 | 说明 | +|---------|------|------| +| 写新引导流程 | `guides/flows/{id}.yaml` | 按 Step 2 模板 | +| 加元素标签 | 前端组件 + `guides/tag-manifest.yaml` | 按 Step 3 命名约定 | +| 注册发现 | `guides/registry.yaml` | 按 Step 4 | +| 验证 | CI gate + 手动 E2E | 按 Step 5-6 | + +## Common Mistakes + +| 错误 | 后果 | 修复 | +|------|------|------| +| 标签用 CSS class 名 | UI 重构后引导失效 | 用语义命名 | +| 忘记注册 registry | 猫猫查不到引导 | Step 4 不可跳过 | +| 最后一步不用 confirm | 操作未成功就完成 | 涉及保存/提交的最后一步必须 confirm | +| 关键词太泛 | 误匹配其他场景 | 用具体术语 | +| 跳过 E2E 验证 | 线上引导卡死 | Step 6 是发布前必做 | + +## 和其他 Skill 的区别 + +- `feat-lifecycle`:管理 Feature 生命周期 — guide-authoring 是写 **引导流程文档** 的 SOP +- `tdd`:代码的测试驱动 — guide-authoring 是 **YAML 编排** 的质量纪律 +- `pencil-design`:出设计稿 — guide-authoring 定义引导 **逻辑和数据**,pencil 出 **视觉效果** + +## 下一步 + +- 引导流程写完 → `tdd` 验证 YAML + 标签 +- 验证通过 → `quality-gate` → `request-review` diff --git a/cat-cafe-skills/guide-interaction/SKILL.md b/cat-cafe-skills/guide-interaction/SKILL.md new file mode 100644 index 000000000..98a819b3e --- /dev/null +++ b/cat-cafe-skills/guide-interaction/SKILL.md @@ -0,0 +1,124 @@ +--- +name: guide-interaction +description: > + 场景引导交互模式:匹配到引导流程后,向用户提供交互式选择卡片, + 处理用户选择(开始引导/步骤概览/跳过),启动前端引导 overlay。 + Use when: 系统注入了 Guide Available(自动触发,不需要手动加载)。 + Not for: 没有匹配到引导流程的普通对话。 +triggers: + - "引导流程" + - "guide" +--- + +# Guide Interaction — 场景引导交互模式 + +## 你的角色 + +你是场景引导助手。当系统检测到用户的问题匹配了一个已有的交互引导流程时, +你负责用简短自然的方式告知用户,并提供交互选项让用户决定下一步。 + +**核心原则**: +- 不要直接给出长篇教程或步骤列表 +- 先提供选择,尊重用户意愿 +- 回复简短自然,像对话而非说明书 + +## 系统注入格式 + +当有匹配的引导流程时,系统会在你的 prompt 中注入: + +``` +🧭 Guide Available: thread={threadId} id={guideId} name={guideName} time={estimatedTime} status={status} +→ Load guide-interaction skill and act per current status. +``` + +从这行读取 `guideId`、`guideName`、`estimatedTime`、`status`。 + +## 工具速查 + +| 动作 | MCP 工具 | 参数 | +|------|----------|------| +| 持久化引导状态 | `cat_cafe_update_guide_state` | `threadId`, `guideId`, `status`, `currentStep?` | +| 发送交互选择卡片 | `cat_cafe_create_rich_block` | `block=` | +| 启动前端引导 overlay | `cat_cafe_start_guide` | `guideId` | +| 解析用户意图匹配 | `cat_cafe_guide_resolve` | `intent` | +| 控制引导进度 | `cat_cafe_guide_control` | `action` (next/back/skip/exit) | + +**重要**:状态持久化是必须的,但进入 `active` 是例外。开始引导时必须调用 `cat_cafe_start_guide`,不要手动 `status='active'`,否则前端 overlay 不会收到完整 start side effects。 + +## Status 驱动行为 + +### status: offered + +首次向用户展示引导选项。 + +1. 调用 `cat_cafe_update_guide_state(threadId, guideId, status='offered')` 持久化状态 +2. 写一句自然的话,告知用户你找到了匹配的引导流程 + - 示例:「我找到了「{guideName}」的交互引导流程,大约需要 {estimatedTime}。」 + - 可以根据对话上下文微调措辞,不必死板照搬 +3. 调用 `cat_cafe_create_rich_block`,`block` 参数传入以下 JSON 字符串: + +```json +{ + "id": "guide-offer-{guideId}-{取自系统注入的threadId的后8位}", + "kind": "interactive", + "v": 1, + "interactiveType": "select", + "title": "我找到了「{guideName}」引导流程(约 {estimatedTime})。要现在开始吗?", + "options": [ + { "id": "start", "label": "开始引导(推荐)", "emoji": "🚀" }, + { "id": "preview", "label": "先看步骤概览", "emoji": "📋" }, + { "id": "skip", "label": "暂不需要", "emoji": "⏭️" } + ], + "messageTemplate": "引导流程:{selection}" +} +``` + +4. **禁止**在这个阶段直接给出步骤教程 +5. 等待用户在选项卡中做出选择 + +### status: awaiting_choice + +用户已看到选项卡但尚未选择,或刷新了页面。 +- 不要重复发送选项卡 +- 用一句话提醒:「之前找到了「{guideName}」引导流程,你要开始吗?」 + +### 用户选择后的处理 + +用户点击选项后,系统会将选择作为消息发回(格式:`引导流程:{选项label}`)。 +根据选择内容执行: + +**用户选了「开始引导」**: +1. 调用 `cat_cafe_start_guide(guideId)` 启动前端引导 overlay +2. `cat_cafe_start_guide` 会同时完成 `offered/awaiting_choice → active` 的状态更新和 socket side effects +3. 回复一句鼓励的话,如「引导已启动,跟着页面上的提示一步步来就好!遇到问题随时问我。」 + +**用户选了「先看步骤概览」**: +1. 调用 `cat_cafe_update_guide_state(threadId, guideId, status='awaiting_choice')` 标记已看到 +2. 调用 `cat_cafe_guide_resolve(intent={guideName})` 获取步骤信息 +3. 用 3-5 条简要列出主要步骤 +4. 在最后问用户是否要开始引导 + +**用户选了「暂不需要」**: +1. 调用 `cat_cafe_update_guide_state(threadId, guideId, status='cancelled')` 标记取消 +2. 简短回复:「好的,有需要随时说。」 +3. 如果用户原本有其他问题,继续回答那个问题 + +### status: active + +引导正在进行中(前端 overlay 已启动)。 +- 监听用户反馈,感知用户遇到的困难 +- 如果用户问了和当前引导步骤相关的问题,结合引导上下文回答 +- 不要重复发送引导选项卡 +- 用户请求退出时:调用 `cat_cafe_guide_control(action='exit')` + +### status: completed + +引导已完成。 +- 不再触发引导相关行为 +- 正常对话模式 + +### status: cancelled + +引导已取消。 +- 不再触发引导相关行为 +- 正常对话模式 diff --git a/cat-cafe-skills/manifest.yaml b/cat-cafe-skills/manifest.yaml index 22978ee6a..0f97fd593 100644 --- a/cat-cafe-skills/manifest.yaml +++ b/cat-cafe-skills/manifest.yaml @@ -38,6 +38,27 @@ skills: sop_step: null merged_from: ["feat-kickoff", "feat-discussion", "feat-completion"] + # ── 引导流程设计 ── + guide-authoring: + description: > + 标准引导流程设计 SOP:场景识别 → YAML 编排 → 标签标注 → 注册发现 → 测试验证。 + Use when: 新建引导流程、添加场景引导、维护 Guide Catalog、编写引导 YAML。 + Not for: 使用引导(用户侧)、Guide Engine 代码实现(用 tdd)、视觉设计(用 pencil-design)。 + Output: Flow YAML + tag-manifest 更新 + registry 注册 + CI 校验通过。 + triggers: + - "新建引导" + - "添加场景引导" + - "写引导流程" + - "guide authoring" + - "引导 YAML" + not_for: + - "使用引导" + - "Guide Engine 实现" + - "出设计稿" + output: "Flow YAML / tag-manifest / registry entry / CI green" + next: ["pencil-design", "tdd"] + sop_step: null + # ── 协作思考 ── collaborative-thinking: description: > diff --git a/cat-cafe-skills/refs/shared-rules.md b/cat-cafe-skills/refs/shared-rules.md index 45ea17b2c..3166fa088 100644 --- a/cat-cafe-skills/refs/shared-rules.md +++ b/cat-cafe-skills/refs/shared-rules.md @@ -313,14 +313,14 @@ Rebase 遇到冲突时,**必须看三个版本**(base / ours / theirs)再 **前置条件**:`merge.conflictStyle=zdiff3`(`pnpm guards:install` 自动设置)。 设置后冲突标记自动带 base 段: -``` -<<<<<<< HEAD +```text +[conflict-start: HEAD] 猫 A 的代码(ours) -||||||| base +[base] 原始代码(改之前的样子) -======= +[separator] 猫 B 的代码(theirs / main) ->>>>>>> main +[conflict-end: main] ``` **手动查看三屏**(当 zdiff3 标记不够用时): diff --git a/docs/decisions/.gitkeep b/docs/decisions/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/docs/discussions/.gitkeep b/docs/discussions/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/docs/discussions/2026-03-27-F150-guidance-engine-convergence.md b/docs/discussions/2026-03-27-F150-guidance-engine-convergence.md new file mode 100644 index 000000000..4fe355a3c --- /dev/null +++ b/docs/discussions/2026-03-27-F150-guidance-engine-convergence.md @@ -0,0 +1,95 @@ +--- +feature_ids: [F150] +topics: [guidance-engine, ux, security, architecture] +doc_kind: discussion +created: 2026-03-27 +--- + +# F150 场景引导引擎 讨论纪要 + +**日期**: 2026-03-27 | **参与者**: 布偶猫/宪宪(opus)、缅因猫/砚砚(gpt52)、暹罗猫/烁烁(gemini25)、铲屎官 + +## 背景 + +铲屎官提出:Console 功能日益复杂,但入口简单,需要一套场景化引导系统。核心诉求: +1. 页面元素加标签,通过编排文档控制引导流程,新增场景不改代码 +2. 用户通过与猫猫对话自然触发引导 +3. 复杂外部流程(如飞书对接)也纳入引导体系 +4. **猫猫能实时观测用户操作状态**,免截图,能主动诊断问题 + +## 各方观点 + +### 布偶猫/宪宪(架构) +- 四层解耦架构:Element Tags → Guide Catalog (YAML) → Guide Engine (Frontend) → MCP Tools +- 命名空间式标签 `data-guide-id="settings.auth.add-provider"` +- 六种步骤类型:console_action / external_instruction / collect_input / verification / branch / information +- 双向可观测模型:Guide Engine 实时上报字段状态 + 用户行为 → 猫猫感知 +- Guide Engine 自治为主 + 猫猫接管为辅 +- 新增 `guide-authoring` Cat Cafe skill + +### 缅因猫/砚砚(安全 + 可测性) +- 三条安全门禁 AC-S1/S2/S3(见共识区) +- 现有代码基础:Hub 深链、HubAddMemberWizard、事件管道已有,不是绿地项目 +- CI 契约测试:tag 存在性 + flow schema + auto_fill_from 校验 + verifier 注册 +- `observe.fields` 对 sensitive 字段只上报 `{filled, valid}`,禁止侧信道泄漏 + +### 暹罗猫/烁烁(视觉 + UX) +- 聚光灯遮罩:柔和边缘 + 猫咖橙呼吸灯 +- 内外步骤视觉区分:猫咖橙(内部) vs 深空灰+外部Logo(外部) +- 沉浸式 collect_input:非模态 inline form,磁吸感呼吸效果 +- 视觉状态机:猫眼观测指示灯(正确→眯眼绿勾,错误→圆眼警示,停滞→晃动求助) +- 跨系统胶囊 HUD:`[控制台]──[飞书]` 双端状态指示 +- 心跳验证:Webhook 握手成功 → HUD 闪橙 + 动效反馈 +- auto_fill_from "数据飞入" 微动效 + +## 共识 + +### 架构共识 +1. **数据驱动**:Flow YAML 编排,新场景 = 写文档 + 打标签 + 截图,不改业务代码 +2. **稳定标签**:`data-guide-id` 命名空间式,语义而非位置 +3. **六种步骤类型**:覆盖内部操作、外部指引、数据收集、自动验证、条件分支、纯信息 +4. **双向可观测**:猫猫实时感知用户状态,主动诊断,免截图 +5. **三层触发**:对话触发(主) + 主动发现 + 目录浏览 +6. **Guide Engine 自治为主**:标准流程 Engine 跑,用户卡住时猫猫接管 + +### 安全共识(P0 硬门禁) +- **AC-S1: Sensitive Data Containment** — sensitive 值仅服务端持有,前端只拿 secretRef,刷新后强制重填,observe 不上报长度/前缀 +- **AC-S2: Verifier Permission Boundary** — 只允许 verifierId 引用,sideEffect=true 必须 confirm:required,带 thread/user scope guard +- **AC-S3: CI Contract Gate** — flow schema 合法性 + tag 存在性 + auto_fill_from 校验 + verifier 注册校验 + skip_if 限声明式 DSL + 退出路径 + +### UX 共识 +- 内外步骤用色彩/图标/HUD 位置区分 +- collect_input 为非模态 inline form +- 视觉状态机反映观测状态 +- verification 失败显示视觉自检清单(基于错误码) + +### 已拍板的决策 +1. `collect_input` 敏感值刷新后**不恢复**,强制重填 +2. 有副作用的 verification 按配置 `confirm: required | auto`,CI 校验 sideEffect→confirm 规则 +3. P0 `skip_if` 限声明式比较(eq/in/exists/gt/lt),禁止表达式 + +## 分歧 + +**无实质分歧。** 三猫从架构/安全/UX 三个维度互补,方向一致。 + +## 否决方案 + +| 方案 | 否决理由 | +|------|---------| +| Route 1: 纯前端硬编码引导 | 每加场景都改代码,维护成本高,违背"编排文档驱动"原则 | +| Route 2: 纯 MCP 动态生成步骤 | 可控性差,DOM 漂移风险,无法做 CI 契约测试 | + +## 行动项 + +| # | 行动 | 负责 | 依赖 | +|---|------|------|------| +| 1 | 立项 F150,写 feature 文档(含安全 AC + 视觉 AC) | 布偶猫/宪宪 | 铲屎官确认 | +| 2 | AC-S1/S2/S3 测试矩阵草案 | 缅因猫/砚砚 | F150 文档就绪 | +| 3 | 内外步骤视觉区分 + 非模态 collect_input 概念稿 | 暹罗猫/烁烁 | F150 文档就绪 | +| 4 | P0 场景编排:添加成员(纯内部) + 飞书对接(跨系统) | TBD | 设计稿 + 安全 AC 就绪 | + +## 收敛检查 + +1. 否决理由 → ADR?**有** → 否决 Route 1 (硬编码) 和 Route 2 (纯动态),理由记录在本纪要"否决方案"段。待立项后迁入 feature 文档 ADR 段。 +2. 踩坑教训 → lessons-learned?**没有** — 本次是新功能设计讨论,无踩坑。 +3. 操作规则 → 指引文件?**没有** — 安全规则 (AC-S1/S2/S3) 是 feature-specific AC,不是全局操作规则。 diff --git a/docs/features/.gitkeep b/docs/features/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/docs/features/F155-add-member-guide-ui-spec.md b/docs/features/F155-add-member-guide-ui-spec.md new file mode 100644 index 000000000..214467304 --- /dev/null +++ b/docs/features/F155-add-member-guide-ui-spec.md @@ -0,0 +1,233 @@ +--- +feature_ids: [F155] +related_features: [F099, F127] +topics: [guidance, ui, interaction, accessibility] +doc_kind: spec +created: 2026-03-27 +--- + +# F155: Add-Member Internal Guide UI Spec (Phase A) + +> Status: spec | Owner: 缅因猫/砚砚 (codex) | Scope: 内部场景(添加成员) + +## Why + +Pencil MCP 当前不可用(`failed to connect to running Pencil app: antigravity`)。 +为不阻塞明早演示,这份文档提供可直接实现的 UI 结构化规格。 + +## Scope + +- 仅覆盖 **场景 1:添加成员**(纯内部引导) +- 覆盖:聚光灯遮罩、HUD 步骤导航、猫眼状态指示 +- 不含:外部步骤富媒体面板(Phase B) + +## Component Tree + +```text +GuideOverlayRoot +├─ SpotlightMask +│ └─ SpotlightCutout (target rect) +├─ StepAnchorPulse (挂在目标元素附近) +├─ GuideHUD +│ ├─ GuideHUDHeader +│ │ ├─ StepTitle +│ │ ├─ StepCounter (n / total) +│ │ └─ CatEyeIndicator +│ ├─ GuideHUDBody +│ │ ├─ InstructionText +│ │ └─ ContextHint (可选) +│ └─ GuideHUDActions +│ ├─ PrevButton +│ ├─ NextButton +│ ├─ SkipButton +│ └─ ExitButton +└─ GuideStatusToast (非阻塞错误/降级提示) +``` + +## Props Contract + +```ts +export type GuideObservationState = + | 'idle' + | 'active' + | 'success' + | 'error' + | 'verifying'; + +export interface GuideStep { + id: string; + targetGuideId: string; // data-guide-id + title: string; + instruction: string; + expectedAction: 'click' | 'input' | 'select' | 'confirm'; + canSkip?: boolean; +} + +export interface GuideOverlayRootProps { + sessionId: string; + flowId: 'add-member'; + steps: GuideStep[]; + currentStepIndex: number; + observationState: GuideObservationState; + highlightToken: string; // guideSessionId + stepId + onPrev: () => void; + onNext: () => void; + onSkip: () => void; + onExit: () => void; + onRetryLocateTarget: () => void; +} +``` + +## Add-Member Step Set (Internal) + +```yaml +flow_id: add-member +steps: + - id: open-member-overview + targetGuideId: cats.overview + expectedAction: click + - id: click-add-member + targetGuideId: cats.add-member + expectedAction: click + - id: select-client + targetGuideId: add-member.client + expectedAction: select + - id: select-provider-profile + targetGuideId: add-member.provider-profile + expectedAction: select + - id: select-model + targetGuideId: add-member.model + expectedAction: select + - id: confirm-create + targetGuideId: add-member.submit + expectedAction: click + - id: edit-member-profile + targetGuideId: member-editor.profile + expectedAction: input + - id: verify-member-response + targetGuideId: member-editor.verify + expectedAction: confirm +``` + +## `data-guide-id` Naming (Phase A Required) + +- `cats.overview` +- `cats.add-member` +- `add-member.client` +- `add-member.provider-profile` +- `add-member.model` +- `add-member.submit` +- `member-editor.profile` +- `member-editor.verify` + +命名规则:`domain.section.action`,语义化,禁止位置语义(如 left/top/row1)。 + +## State Machine + +```text +hidden + -> ready(target found) +ready + -> active(user interacting) +active + -> success(step validated) +success + -> ready(next step) +active + -> error(target missing/validation fail) +error + -> ready(retry locate) +error + -> skipped(user skip) +any + -> exited(user exit) +``` + +### Transition Rules + +- `ready -> active`: 用户在目标区域发生预期动作 +- `active -> success`: 当前 step 验证通过 +- `active -> error`: 8s 内未完成预期动作,或目标节点丢失 +- `error -> ready`: 重试定位成功 +- `any -> exited`: 用户点击退出 + +## Visual Tokens + +```css +:root { + --guide-overlay-bg: rgba(12, 16, 24, 0.62); + --guide-cutout-ring: #d4853a; /* 猫咖橙 */ + --guide-cutout-shadow: rgba(212, 133, 58, 0.35); + --guide-hud-bg: #fffdf8; + --guide-hud-border: #e7dac7; + --guide-text-primary: #2b251f; + --guide-text-secondary: #6f6257; + --guide-success: #2f9e44; + --guide-error: #d94848; + --guide-z-overlay: 1100; + --guide-z-hud: 1110; + --guide-z-pulse: 1120; + --guide-radius: 14px; + --guide-gap: 12px; + --guide-motion-fast: 160ms; + --guide-motion-normal: 260ms; +} +``` + +## Motion Spec + +- Cutout 跟随目标:`transform/clip-path`,`260ms ease-out` +- Anchor Pulse:1.4s 循环,透明度 0.35 -> 0.0 +- HUD 入场:`opacity + translateY(8px)`,`160ms` +- CatEye + - `idle`: 低频摆动 + - `active`: 轻微脉冲 + - `success`: 绿色短闪 + - `error`: X 轴轻抖(不超过 2 次) + - `verifying`: 旋转 loading + +## Interaction and Fallback + +1. 定位目标失败(首次) +- 显示 `GuideStatusToast`: “未找到当前目标,正在重试定位…” +- 自动重试一次(300ms) + +2. 定位目标失败(重试后) +- HUD 切 `error` +- 显示两按钮:`重试定位` / `跳过此步` + +3. 用户停滞超时(8s) +- HUD 显示轻提示,不强制中断 +- 保持当前步骤,允许 `下一步/跳过` + +4. 退出 +- 立即销毁 overlay 和 observer +- 记录 `flowId + stepId + exitedAt` + +## Accessibility + +- 所有操作按钮必须可键盘触达 +- `Esc` 绑定 `onExit` +- `Left/Right Arrow` 可映射上一步/下一步(可选) +- HUD 必须提供 `aria-live="polite"` 文本更新 +- 遮罩不阻断屏幕阅读器读取 HUD 文本 + +## Performance Guardrail + +- 避免频繁 layout thrash:目标 rect 读取节流到 `requestAnimationFrame` +- MutationObserver 仅在引导会话活跃时挂载,结束必须 `disconnect` +- 只动画 `opacity/transform`,避免昂贵属性 + +## Acceptance Criteria (UI) + +- [ ] AC-UI-1: 8 个添加成员步骤均可被 Spotlight 正确定位 +- [ ] AC-UI-2: HUD 提供上一步/下一步/跳过/退出完整闭环 +- [ ] AC-UI-3: CatEye 5 态与 `GuideObservationState` 一一对应 +- [ ] AC-UI-4: 目标缺失可降级,不出现引导卡死 +- [ ] AC-UI-5: 移动端(>=390px)HUD 不遮挡主操作区 + +## Notes for Phase A Implementation + +- 先接通最小链路:`data-guide-id` 查询 + overlay 渲染 + step 切换 +- 再接状态:`GuideObservationState` 与 step 验证 +- 最后补动效与降级提示 diff --git a/docs/features/F155-scene-catalog.md b/docs/features/F155-scene-catalog.md new file mode 100644 index 000000000..45544a90f --- /dev/null +++ b/docs/features/F155-scene-catalog.md @@ -0,0 +1,173 @@ +--- +feature_ids: [F155] +topics: [guidance-engine, scenes] +doc_kind: note +created: 2026-03-27 +--- + +# F155 引导场景清单 + +> **原则**:核心引擎先做完 → P0 验收通过 → 再逐场景迭代补全。 +> 所有场景用同一套编排文件 + 元素标签体系,实现流程一致。 + +## 实施策略 + +``` +Phase A: 核心引擎 + P0 内部场景(添加成员) +Phase B: 双向可观测 + P0 外部场景(飞书对接) +后续迭代: 按优先级逐场景补全 YAML 编排 + 截图资产 +``` + +## 场景总览 + +### 一、成员与账户配置 + +| # | 场景 | 复杂度 | 跨系统 | 优先级 | 涉及组件 | 说明 | +|---|------|--------|--------|--------|---------|------| +| 1 | **添加成员** | 极高 | 否 | **P0** | HubCatEditor | 10+ 表单段(身份/路由/账号/策略/Codex 设置),新用户最常问 | +| 2 | 配置 API Provider | 高 | 否 | P1 | HubProviderProfilesTab | 凭证管理 + 模型发现,新部署必经 | +| 3 | 设置 Co-Creator 个人资料 | 中 | 否 | P2 | HubCoCreatorEditor | 头像/别名/品牌色,首次使用时引导 | + +### 二、IM 连接器对接 + +| # | 场景 | 复杂度 | 跨系统 | 优先级 | 涉及组件 | 说明 | +|---|------|--------|--------|--------|---------|------| +| 4 | **飞书对接** | 高 | 是 | **P0** | FeishuAdapter + HubConnectorConfigTab | 创建飞书应用 → 配权限 → 填凭证 → 配 Webhook → 验证连通 | +| 5 | 微信个人号对接 | 高 | 是 | P1 | WeixinAdapter + WeixinQrPanel | 检查微信版本 → 打开扫一扫 → Console 生成二维码 → 扫码 → 发消息验证 → 打开微信 DM 会话 | +| 6 | Telegram 对接 | 中 | 是 | P1 | TelegramAdapter | @BotFather 创建 Bot → 获取 Token → 填入 Console → 验证 | +| 7 | 钉钉对接 | 高 | 是 | P1 | DingTalkAdapter | 创建企业应用 → 配 Stream 模式 → 填 AppKey/Secret → 验证 | +| 8 | 企业微信对接 | 高 | 是 | P2 | 待实现 (F132 Phase B/C) | 依赖 F132 后续 Phase | + +### 三、系统功能配置 + +| # | 场景 | 复杂度 | 跨系统 | 优先级 | 涉及组件 | 说明 | +|---|------|--------|--------|--------|---------|------| +| 9 | 开启推送通知 | 中 | 否 | P1 | PushSettingsPanel | 浏览器权限请求 → 订阅 → 测试推送 | +| 10 | 管理猫猫能力 | 中 | 否 | P2 | HubCapabilityTab | MCP/Skills 全局 + 按猫开关,多作用域容易误操作 | +| 11 | 治理看板配置 | 中 | 否 | P2 | HubGovernanceTab | 多项目发现 + 同步状态管理 | +| 13 | 权限白名单/命令管理员配置 | 中 | 否 | P1 | HubPermissionsTab | 安全边界入口,误配会导致非管理员执行敏感命令 | +| 14 | 路由策略配置 | 中 | 否 | P2 | HubRoutingPolicyTab | Review/Architecture 路由偏好,误配导致任务分发偏航 | + +### 四、GitHub 集成 + +| # | 场景 | 复杂度 | 跨系统 | 优先级 | 涉及组件 | 说明 | +|---|------|--------|--------|--------|---------|------| +| 12 | GitHub PR 自动化配置 | 低 | 部分 | P2 | 内置连接器 | Token 配置 + 仓库绑定 | + +### 五、运维与恢复 + +| # | 场景 | 复杂度 | 跨系统 | 优先级 | 涉及组件 | 说明 | +|---|------|--------|--------|--------|---------|------| +| 15 | 连接器失效恢复 | 中 | 是 | P2 | 各 Adapter + HubConnectorConfigTab | Token 过期/二维码失效后的重连路径,区别于首次接入 | + +## 场景详情(P0 + 部分 P1 展开) + +### 场景 1: 添加成员(P0,纯内部) + +``` +前置: 无 +步骤概要: +1. [console_action] 打开 Hub → 成员总览 +2. [console_action] 点击"添加成员" +3. [console_action] Step 1: 选择 Client(Claude/Codex/Antigravity) +4. [console_action] Step 2: 选择 Provider Profile(从已配置的账号中选) +5. [branch] 如果没有 Provider Profile → 跳转"配置 API Provider"子流程 +6. [console_action] Step 3: 选择模型 +7. [console_action] 完成创建 +8. [console_action] 编辑成员详情(别名/颜色/路由策略) +9. [verification] 验证成员可响应(发送测试消息) +预计时间: 5min +``` + +### 场景 4: 飞书对接(P0,跨系统) + +``` +前置: 无 +步骤概要: +1. [external_instruction] 打开飞书开放平台,创建企业自建应用 + - assets: 2 张截图(创建应用界面 + 机器人能力开关) + - link: https://open.feishu.cn/ +2. [external_instruction] 配置权限(im:message + im:message:send_as_bot) + - assets: 1 张截图(权限列表) +3. [collect_input] 复制 App ID + App Secret +4. [console_action] 打开 Hub → 连接器配置 +5. [console_action] 填入凭证(auto_fill_from 自动填充) +6. [external_instruction] 在飞书配置事件回调 URL + - template_vars: webhook_url + - assets: 1 张截图 +7. [verification] 连通性测试(verifierId: feishu-connection-test) +8. [information] 完成!去飞书给机器人发条消息试试 +预计时间: 10min +``` + +### 场景 5: 微信个人号对接(P1,跨系统) + +``` +前置: 微信版本 ≥ 8.0.50 +步骤概要: +1. [information] 前置条件声明:微信版本要求 + - assets: 1 张截图(版本检查位置) +2. [external_instruction] 打开微信扫一扫 + - assets: 1 张截图(微信扫一扫入口) +3. [console_action] 打开微信对接页面 → 生成二维码 +4. [external_instruction] 用微信扫描屏幕上的二维码 + - assets: 1 张截图(扫码界面) +5. [verification] 等待扫码成功(verifierId: wechat-qr-scan) +6. [information] 扫码成功!现在发一条微信消息试试 +7. [console_action] 引导用户打开左侧出现的微信 DM 会话 + - observe: { fields: [{ key: wechat_dm_visible }], on_idle: 30s } +8. [information] 微信对接完成! +预计时间: 5min +``` + +### 场景 6: Telegram 对接(P1,跨系统) + +``` +前置: Telegram 账号 +步骤概要: +1. [external_instruction] 在 Telegram 找到 @BotFather + - link: https://t.me/BotFather +2. [external_instruction] 发送 /newbot,按提示创建 Bot + - assets: 2 张截图(创建流程 + Token 获取) +3. [collect_input] 复制 Bot Token +4. [console_action] 打开连接器配置 → Telegram +5. [console_action] 填入 Bot Token +6. [verification] 连通性测试 +7. [information] 完成!去 Telegram 给 Bot 发条消息 +预计时间: 5min +``` + +### 场景 7: 钉钉对接(P1,跨系统) + +``` +前置: 钉钉企业管理员权限 +步骤概要: +1. [external_instruction] 打开钉钉开放平台,创建企业内部应用 + - link: https://open-dev.dingtalk.com/ + - assets: 2 张截图 +2. [external_instruction] 启用机器人能力 + 配置 Stream 模式 + - assets: 1 张截图 +3. [collect_input] 复制 AppKey + AppSecret + RobotCode +4. [console_action] 打开连接器配置 → 钉钉 +5. [console_action] 填入凭证 +6. [verification] 连通性测试 +7. [information] 完成!在钉钉群里 @机器人试试 +预计时间: 10min +``` + +## 资产清单(截图需求汇总) + +| 场景 | 预计截图数 | 外部平台 | +|------|----------|---------| +| 飞书对接 | 4-5 张 | 飞书开放平台 | +| 微信对接 | 3-4 张 | 微信 App | +| Telegram 对接 | 2-3 张 | Telegram App | +| 钉钉对接 | 3-4 张 | 钉钉开放平台 | +| 企业微信对接 | 待定 | 企业微信管理后台 | + +> 截图在实际编排时按 `guide-authoring` skill Step 5 准备,用橙色圆圈标出关键操作位置。 + +## 变更记录 + +- 2026-03-27: 初版 12 场景 (宪宪) +- 2026-03-27: 补 3 场景 (#13 权限配置 / #14 路由策略 / #15 连接器失效恢复),基于砚砚补漏审计 diff --git a/docs/features/F155-scene-guidance-phase-a-spec.md b/docs/features/F155-scene-guidance-phase-a-spec.md new file mode 100644 index 000000000..da368ff35 --- /dev/null +++ b/docs/features/F155-scene-guidance-phase-a-spec.md @@ -0,0 +1,273 @@ +--- +feature_ids: [F155] +related_features: [F087, F110, F134, F099] +topics: [guidance, ux, mcp, frontend, security] +doc_kind: spec +created: 2026-03-27 +--- + +# F155: Scene-Based Bidirectional Guidance Engine + +> **Status**: Phase A accepted / frozen — 基础引导引擎已验收冻结,可开 PR 合入 | **Owner**: 布偶猫/宪宪 | **Priority**: P1 + +## Why + +Console 功能日益复杂,但入口简单,用户不知道从哪开始。复杂配置(如飞书对接)涉及跨系统操作,用户需要在多个平台间来回切换,容易迷失。 + +当前痛点: +- 用户不知道"添加新成员"需要先配认证 +- 飞书/钉钉等外部系统的权限配置需要反复截图沟通 +- 猫猫无法实时看到用户操作状态,只能靠用户描述和截图诊断问题 + +> 铲屎官原话:"我们的目标是让我们自己只承载我们真真需要的配置和功能;剩下的通过引导式来承载。猫猫们可以实时观察到当前用户的操作状态和效果,如果失败也就知道哪里有问题。不需要用户自己反复截图来证明和说明自己做的咋样了。" + +## What + +### 核心架构(v2 — tag-based auto-advance engine) + +``` +data-guide-id tags → Flow YAML (guides/flows/) → Runtime API → Guide Engine (Frontend) + ↕ Socket.io + Cat (状态感知) +``` + +**设计原则**(CVO Phase A 反馈收敛): +- 自动推进:用户与目标元素交互后引导自动前进,无手动 下一步/上一步/跳过 +- HUD 极简:仅显示 tips + progress dots + "退出" +- 标签驱动:前端元素仅标注 `data-guide-id`,tips 来自 YAML flow 定义 +- 运行时加载:flow 由 `GET /api/guide-flows/:guideId` 运行时获取,非构建时生成 + +### Phase A: Core Engine + 内部场景验证(✅ 已实现) + +**OrchestrationStep schema**(前后端共享): +```typescript +interface OrchestrationStep { + id: string; + target: string; // data-guide-id value + tips: string; // 引导文案(来自 YAML) + advance: 'click' | 'visible' | 'input' | 'confirm'; +} +``` + +**元素标签系统**:页面关键控件加稳定 `data-guide-id`,命名空间式(如 `hub.trigger`、`cats.add-member`),语义而非位置。Target whitelist: `/^[a-zA-Z0-9._-]+$/`。 + +**Flow YAML**:`guides/flows/*.yaml` 编排场景流程,`guides/registry.yaml` 注册发现。 + +**Guide Engine(前端)**: +- 全屏遮罩 + 目标元素区域镂空(呼吸灯动效)+ 四面板 click shield(镂空区可穿透点击) +- rAF 循环跟踪目标元素位置(rect 比较优化) +- 自动推进:`useAutoAdvance` hook 监听 click/input/visible/confirm 事件 +- `guide:confirm` CustomEvent 用于确认型步骤(如保存成功后触发) +- 终态守卫:`setPhase('complete')` 后不可被 rAF 覆写为 `locating` +- HUD:tips + progress dots + "退出",位置自动计算避免遮挡 +- Error boundary:Guide crash 不影响主应用 + +**完成回调(frontend → backend)**: +- 前端 `phase='complete'` 时自动调用 `POST /api/guide-actions/complete` +- 后端 `guideState: active → completed` + 发 `guide_complete` Socket.io 事件 +- 猫猫收到事件即可感知用户已完成引导 + +**前端 API 端点**(userId-based auth): +- `POST /api/guide-actions/start` — offered/awaiting_choice → active +- `POST /api/guide-actions/cancel` — → cancelled +- `POST /api/guide-actions/complete` — active → completed +- `GET /api/guide-flows/:guideId` — 运行时获取 flow 定义 + +**MCP 工具**(callback auth): +- `resolve` — 根据用户意图匹配候选流程 +- `start` — 启动引导 session +- `control` — next/back/skip/exit +- `update-guide-state` — 通用状态机更新 + +**CI 验证**:`scripts/gen-guide-catalog.mjs` 校验 v2 schema + target whitelist + +**P0 验证场景**:添加新成员(4 步:open-hub → go-to-cats → click-add-member → edit-member-profile) + +### Phase B: 平台内场景扩展(F155 scope) + +> **Scope 调整(KD-13 + KD-14)**: +> - Phase B 聚焦平台内已有功能的引导场景扩展,不做跨系统深度集成 +> - 双向可观测(observe/verifier)已拆出为独立 feature 待立项,不再是 F155 的一部分 +> - 外部平台(飞书/微信等)的配置流程按场景单独做配置页签,不纳入 Guide Engine + +**场景扩展**:基于已有 Console 功能逐场景补充引导流程,复用 Phase A 骨架(data-guide-id + Flow YAML + advance mode + complete callback)。具体场景所需的额外步骤类型或信息补充,结合场景实际需求决定。 + +**CI 契约测试**:flow schema + tag 存在性 + 退出路径 + +**P0 验证场景**:基于已有 Console 功能的高价值场景(如 API Provider 配置、连接器配置等) + +### 已拆出的独立方向 + +| 方向 | 归属 | 说明 | +|------|------|------| +| 自动观测 substrate | 独立 feature 待立项 | 不只服务 guide,可被 guide/debug/diagnostics 复用。含 observe.fields、idle 检测、verifier 契约、猫眼指示灯 | +| 跨系统配置页签 | 按场景单独设计 | 飞书/微信/钉钉等外部平台配置流程,不走 Guide Engine 遮罩引导 | + +### 当前进展与阶段判断(2026-04-09) + +| 维度 | 当前状态 | 说明 | +|------|---------|------| +| 核心引擎 | ✅ 完成 | tag-based runtime、YAML flow、前端遮罩/镂空、auto-advance、exit-only HUD 已跑通 | +| P0 内部场景 | ✅ 完成 | `add-member` 已收口为 4 步:`hub.trigger → cats.overview → cats.add-member → member-editor.profile(confirm)` | +| 完成态闭环 | ✅ 完成 | 前端 `complete` → 后端 `guideState=completed` → Socket 通知猫 → 一次性消费 ack | +| Esc 误退修复 | ✅ 完成 | KD-14:GuideOverlay preventDefault + CatCafeHub guideActive guard | +| CVO 验收 | ✅ 通过 | 2026-04-09 CVO 手动测试”添加成员”流程,确认链路通畅 | +| gpt52 review | ✅ 放行 | completion callback 6 轮 + 收尾 2 轮,全部 P1/P2 已修复 | +| 当前阶段判断 | **Phase A accepted / frozen** | 基础引导引擎已验收冻结,可开 PR 合入 main | + +**Phase A 交付物**: +- 前端引擎:`guideStore.ts` + `useGuideEngine.ts` + `GuideOverlay.tsx`(含 auto-advance) +- 后端 API:`guide-action-routes.ts`(start/cancel/complete) +- 路由感知:`route-serial.ts` + `route-parallel.ts`(completionAcked + guideCompletionOwner + catProducedOutput) +- Prompt 注入:`SystemPromptBuilder.ts`(completed handler) +- 测试:22 个 API 测试 +- 文档:feature doc + guide-authoring skill + flow YAML + tag manifest + +### 触发与发现规范 + +三层触发机制: +1. **对话触发(主)**:用户问意图 → 猫查 catalog → 建议引导 → [🐾 带我去做] 卡片 → 启动 +2. **主动发现**:系统检测到未完成配置 → 猫主动建议相关引导 +3. **目录浏览**:Console "场景引导" 入口,按类别列出所有可用流程 + +### guide-authoring Skill + +已创建 `cat-cafe-skills/guide-authoring/SKILL.md`,定义 6 步标准 SOP(v2): +场景识别 → YAML 编排(v2 auto-advance) → 标签标注 → 注册发现 → CI 契约 → E2E 验证。 + +### 场景优先级(能力审计结果) + +| 优先级 | 场景 | Console Tab | 复杂度 | 跨系统 | +|--------|------|------------|--------|--------| +| P0 | 添加成员 | cats → HubCatEditor | 极高 | 否 | +| ~~P0~~ deferred | 飞书对接 | 独立配置页签(不走 Guide Engine)| 高 | 是 | +| P1 | 配置 API Provider | provider-profiles | 高 | 否 | +| P1 | 添加连接器(通用) | connector config | 高 | 是 | +| P1 | 开启推送通知 | notify | 中 | 否 | +| P2 | 管理猫猫能力 | capabilities | 中 | 否 | +| P2 | 治理看板配置 | governance | 中 | 否 | + +### 触发与发现(详细设计) + +**Guide Registry**(`guides/registry.yaml`):注册所有可用引导,含 keywords + 意图映射。 +**MCP Tool**:`guide_resolve(intent, context)` → 关键词匹配 registry → 返回候选引导列表。 +**Skill Manifest**:猫检测到配置意图("怎么/如何/配置")→ 自动查 registry → 问用户"要我带你走一遍吗?"。 +**主动发现**:后端检测未完成配置状态 → 推送建议到聊天(复用现有 Socket.io 事件管道)。 + +## Acceptance Criteria + +### Phase A(Core Engine) +- [x] AC-A1: 页面关键控件有稳定 `data-guide-id` 标签(覆盖"添加成员"流程 4 个元素) +- [x] AC-A2: Guide flow YAML 加载器 + CI schema 验证(v2 schema + target whitelist) +- [x] AC-A3: Guide Engine 前端组件:遮罩 + 高亮 + 自动推进(v2: 无手动导航,HUD 仅退出) +- [x] AC-A4: MCP resolve/start/control 工具 + 前端 action routes(start/cancel/complete) +- [x] AC-A5: "添加成员" 引导流程端到端可运行(含 confirm 步骤 + 保存成功回调) +- [x] AC-A6: 对话触发:猫建议引导 → InteractiveBlock → 用户确认 → 启动 +- [x] AC-A7: 完成回调:前端 complete → 后端 guideState completed → Socket.io 通知猫猫 + +### Phase B(平台内场景扩展) +- [ ] AC-B1: 基于已有 Console 功能扩展 2+ 个引导场景(如 API Provider 配置、连接器配置) +- [ ] AC-B2: CI 契约测试通过(flow schema + tag + 退出路径) + +### 已拆出(不再属于 F155 scope) +- ~~AC-B1(旧): observe 层~~ → 独立 feature "自动观测 substrate" 待立项 +- ~~AC-B2(旧): MCP guide_observe~~ → 同上 +- ~~AC-B4(旧): 猫眼观测指示灯~~ → 同上 +- ~~AC-B5: 飞书 E2E~~ → KD-13 deferred +- ~~AC-S1: Sensitive Data Containment~~ → 随独立 observe feature 走 +- ~~AC-S2: Verifier Permission Boundary~~ → 随独立 observe feature 走 + +### 安全门禁(F155 scope 内保留) +- [ ] AC-S3: CI Contract Gate — flow schema 合法性 + tag 存在性 + 退出路径 + +## AC-S3 测试矩阵(F155 scope 内保留) + +> AC-S1(Sensitive Data)和 AC-S2(Verifier Boundary)已随 observe substrate 拆出为独立 feature。 +> 原始测试矩阵草案保留在 git 历史中(commit `a6588af` 之前),独立 feature 立项时可参考。 + +### AC-S3: CI Contract Gate + +| Test ID | 层级 | 场景 | 期望结果 | 证据 | +|---|---|---|---|---| +| S3-CI1 | CI-Static | Flow schema 校验(step 字段 + advance 类型) | 非法 flow 阻塞合并 | CI 日志 | +| S3-CI2 | CI-Static | flow target 与 `data-guide-id` manifest 对照 | 缺失/重命名标签阻塞合并 | CI 日志 | +| S3-CI3 | CI-Static | flow 退出路径校验 | 无退出路径阻塞合并 | CI 日志 | +| S3-E2E-A1 | CI-E2E | P0 场景回归:添加成员(纯内部) | 主路径可完成,关键状态可回放 | E2E junit XML | + +### 质量门禁映射 + +- PR Gate(必须):S3-CI1~CI3 +- Phase A Gate(必须):S3-E2E-A1 + +## Dependencies + +- **Related**: F087(猫猫训练营 — 类似的引导概念,但面向不同场景) +- **Related**: F110(训练营愿景引导增强 — 引导 UX 模式可复用) +- **Related**: F134(飞书群聊 — 飞书对接是 P0 验证场景之一) +- **Related**: F099(Hub 导航可扩展 — Hub tab/深链基础设施) + +## Risk + +| 风险 | 缓解 | +|------|------| +| 元素标签被 UI 重构意外删除/重命名 | CI 契约测试(AC-S3)阻塞合并 | +| 跨系统流程用户中途放弃导致状态不一致 | sessionStorage 持久化 + 猫猫感知 idle 超时 | +| collect_input 敏感值泄露 | AC-S1 封存规则 + 服务端 TTL | +| 流程文档与页面演进脱节 | CI gate 每次构建校验 tag manifest | +| Guide Engine 性能影响正常操作 | 遮罩层 z-index 隔离 + 不影响非引导区域交互 | + +## Open Questions + +| # | 问题 | 状态 | +|---|------|------| +| OQ-1 | Guide Engine 是否需要支持流程嵌套(一个 flow 调用另一个 flow 作为子步骤)? | ⬜ 未定(建议 Phase B 后评估) | +| OQ-2 | 主动发现的触发条件如何定义?由前端检测还是后端推送? | ⬜ 未定 | +| OQ-3 | guide-authoring skill 是否需要自动从录屏生成初始 flow YAML? | ⬜ 未定 | + +## Key Decisions + +| # | 决策 | 理由 | 日期 | +|---|------|------|------| +| KD-1 | 选择"标签 + YAML 编排 + Guide Runtime"方案,否决硬编码和纯动态方案 | 可测、可审计、可版本化;新场景不改代码 | 2026-03-27 | +| KD-2 | 双向可观测:猫猫实时感知用户操作状态 | 免截图诊断;猫猫能主动介入卡点 | 2026-03-27 | +| KD-3 | sensitive 值刷新后不恢复,强制重填 | 安全优先于便利 | 2026-03-27 | +| KD-4 | 有副作用的 verification 按 verifier 配置 confirm: required/auto | sideEffect=true 必须二次确认,CI 校验规则 | 2026-03-27 | +| KD-5 | P0 skip_if 限声明式比较(eq/in/exists/gt/lt),禁止表达式 | 沙箱成本高,声明式可满足 P0 需求 | 2026-03-27 | +| KD-6 | observe.fields 对 sensitive 字段只上报 {filled, valid} | 防止侧信道泄漏长度/前缀 | 2026-03-27 | +| KD-7 | 迭代策略:核心引擎先完整 → P0(1内部+1外部)验收 → 再逐场景补全 | 不一次性实现所有场景;编排文件按需补充 | 2026-03-27 | +| KD-8 | external_instruction 支持富内容(多图 + 链接 + 前置条件 + 版本要求) | 胶囊 HUD 不够,外部步骤需要完整的操作指引卡片 | 2026-03-27 | +| KD-9 | v2 重构:自动推进取代手动导航,HUD 仅保留"退出" | CVO Phase A 反馈:手动导航降低体验,用户操作即推进 | 2026-03-30 | +| KD-10 | v2 步骤类型收敛为 4 种 advance mode(click/visible/input/confirm) | 简化 Phase A 范围,6 种步骤类型推迟到 Phase B 按需扩展 | 2026-03-30 | +| KD-11 | Flow YAML 运行时加载(API),不在构建时生成 TS | 解耦部署:改 flow 不需要重新构建前端 | 2026-03-30 | +| KD-12 | 完成回调作为基础能力:前端 complete → 后端状态 + Socket 通知 | CVO 明确要求:完整流程闭环是基础能力,不是后续补充 | 2026-04-03 | +| KD-13 | Phase B 聚焦平台内引导,外部平台配置改为独立页签(不走 Guide Engine) | CVO:跨系统对接方式可能变化(扫码等),引导引擎聚焦已有功能;外部流程按场景单独做页签 | 2026-04-06 | +| KD-14 | 禁用引导模式下全局 Esc 退出,仅保留显式退出按钮 | CVO 手测反馈:误触 Esc 导致引导意外退出,体验差 | 2026-04-09 | +| KD-15 | 双向可观测拆出为独立 feature,不再是 F155 Phase B | CVO + gpt52 共识:observe substrate 应更大——不只服务 guide,可被 debug/diagnostics 复用 | 2026-04-09 | + +## Timeline + +| 日期 | 事件 | +|------|------| +| 2026-03-27 | 三猫讨论收敛 + 立项 | +| 2026-03-30 | v2 tag-based auto-advance engine 跑通,HUD 收敛为 exit-only,flow 改为运行时加载 | +| 2026-03-31 | P0 场景 add-member 4 步端到端验证通过 | +| 2026-04-01 | `add-member` 第 4 步收敛为 `confirm` 型步骤,保存成功后才允许完成 | +| 2026-04-03 | guide completion callback 打通:前端 complete → 后端 `guideState=completed` → Socket `guide_complete` | +| 2026-04-06 | CVO 方向校准:Phase B 聚焦平台内引导,跨系统配置改为独立页签(KD-13) | +| 2026-04-09 | CVO 验收 Phase A 通过;Esc 误退修复(KD-14);observe 拆出独立 feature(KD-15);Phase A accepted/frozen | + +## Review Gate + +- Phase A: 砚砚(gpt52) 负责安全边界 + 可测性 review +- Phase B: 砚砚(gpt52) 安全 review + 烁烁(gemini25) 视觉 review + +## Links + +| 类型 | 路径 | 说明 | +|------|------|------| +| **Discussion** | `docs/discussions/2026-03-27-F150-guidance-engine-convergence.md` | 三猫讨论收敛纪要 | +| **Scene Catalog** | `docs/features/F155-scene-catalog.md` | 全量引导场景清单(12 场景,含步骤概要) | +| **Skill** | `cat-cafe-skills/guide-authoring/SKILL.md` | 引导流程设计 SOP | +| **Feature** | `docs/features/F087-bootcamp.md` | 类似引导概念(训练营) | +| **Feature** | `docs/features/F134-feishu-group-chat.md` | 飞书对接(P0 验证场景) | +| **Feature** | `docs/features/F137-weixin-personal-gateway.md` | 微信对接(P1 验证场景) | diff --git a/docs/features/index.json b/docs/features/index.json index f00878d94..7bac303d5 100644 --- a/docs/features/index.json +++ b/docs/features/index.json @@ -942,12 +942,30 @@ "status": "in-progress | **Owner**: Ragdoll | **Priority**: P2", "file": "F154-cat-routing-personalization.md" }, + { + "id": "F155", + "name": "Add-Member Internal Guide UI Spec (Phase A)", + "status": "unknown", + "file": "F155-add-member-guide-ui-spec.md" + }, + { + "id": "F155", + "name": "引导场景清单", + "status": "unknown", + "file": "F155-scene-catalog.md" + }, { "id": "F155", "name": "Scene-Based Guidance Engine — 场景式交互引导", "status": "needs-discussion | **Source**: Community (mindfn) | **Priority**: TBD", "file": "F155-scene-guidance-engine.md" }, + { + "id": "F155", + "name": "Scene-Based Bidirectional Guidance Engine", + "status": "Phase A accepted / frozen — 基础引导引擎已验收冻结,可开 PR 合入 | **Owner**: 布偶猫/宪宪 | **Priority**: P1", + "file": "F155-scene-guidance-phase-a-spec.md" + }, { "id": "F156", "name": "Security Hardening — 实时通道 + 本机信任边界加固", diff --git a/guides/flows/add-member.yaml b/guides/flows/add-member.yaml new file mode 100644 index 000000000..480df9c00 --- /dev/null +++ b/guides/flows/add-member.yaml @@ -0,0 +1,28 @@ +# F150 Guide Flow: 添加成员 +# P0 验证场景 — 引导到添加成员入口 +# Schema: OrchestrationFlow v2 (tag-based engine) + +id: add-member +name: 添加成员 +description: 引导你完成新成员创建并补充成员信息 + +steps: + - id: open-hub + target: hub.trigger + tips: 点击这里打开 Hub 控制台 + advance: click + + - id: go-to-cats + target: cats.overview + tips: 进入成员管理页面 + advance: click + + - id: click-add-member + target: cats.add-member + tips: 点击这里开始添加新成员 + advance: click + + - id: edit-member-profile + target: member-editor.profile + tips: 补充成员信息并点击保存;保存成功后会自动完成本次引导 + advance: confirm diff --git a/guides/registry.yaml b/guides/registry.yaml new file mode 100644 index 000000000..cd0ee078a --- /dev/null +++ b/guides/registry.yaml @@ -0,0 +1,23 @@ +# F150 Guide Registry +# Maps guide IDs to flow files, keywords, and discovery metadata. +# MCP resolve tool matches user intent against keywords to suggest guides. + +guides: + - id: add-member + name: 添加成员 + description: 引导你完成新成员的创建和配置 + flow_file: flows/add-member.yaml + keywords: + - 添加成员 + - 加成员 + - 新成员 + - 加猫 + - 新猫 + - 新建成员 + - add member + - add cat + - create member + category: member-config + priority: P0 + cross_system: false + estimated_time: 3min diff --git a/package.json b/package.json index bfe33e0c9..1b9beccd9 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,8 @@ "dev": "pnpm -r --parallel run dev", "build": "pnpm -r run build", "lint": "pnpm -r run lint", - "check": "pnpm biome check . --diagnostic-level=error && pnpm check:features && pnpm check:env-ports && pnpm check:env-registry && pnpm check:env-example && pnpm check:start-profile-isolation", + "check": "pnpm biome check . --diagnostic-level=error && pnpm check:features && pnpm check:env-ports && pnpm check:env-registry && pnpm check:env-example && pnpm check:start-profile-isolation && pnpm check:guides", + "check:guides": "node scripts/gen-guide-catalog.mjs", "check:fix": "pnpm biome check --write .", "test": "pnpm -r --if-present run test", "test:api:redis": "pnpm --filter @cat-cafe/api run test:redis", diff --git a/packages/api/.claude/skills b/packages/api/.claude/skills new file mode 120000 index 000000000..2c4b4dae4 --- /dev/null +++ b/packages/api/.claude/skills @@ -0,0 +1 @@ +../../../cat-cafe-skills \ No newline at end of file diff --git a/packages/api/AGENTS.md b/packages/api/AGENTS.md new file mode 100644 index 000000000..23f41615d --- /dev/null +++ b/packages/api/AGENTS.md @@ -0,0 +1,30 @@ + +> Pack version: 1.3.0 | Provider: codex + +## Cat Cafe Governance Rules (Auto-managed) + +### Hard Constraints (immutable) +- **Public local defaults**: use frontend 3003 and API 3004 to avoid colliding with another local runtime. +- **Redis port 6399** is Cat Cafe's production Redis. Never connect to it from external projects. Use 6398 for dev/test. +- **No self-review**: The same individual cannot review their own code. Cross-family review preferred. +- **Identity is constant**: Never impersonate another cat. Identity is a hard constraint. + +### Collaboration Standards +- A2A handoff uses five-tuple: What / Why / Tradeoff / Open Questions / Next Action +- Vision Guardian: Read original requirements before starting. AC completion ≠ feature complete. +- Review flow: quality-gate → request-review → receive-review → merge-gate +- Skills are available via symlinked cat-cafe-skills/ — load the relevant skill before each workflow step +- Shared rules: See cat-cafe-skills/refs/shared-rules.md for full collaboration contract + +### Quality Discipline (overrides "try simplest approach first") +- **Bug: find root cause before fixing**. No guess-and-patch. Steps: reproduce → logs → call chain → confirm root cause → fix +- **Uncertain direction: stop → search → ask → confirm → then act**. Never "just try it first" +- **"Done" requires evidence** (tests pass / screenshot / logs). Bug fix = red test first, then green + +### Knowledge Engineering +- Documents use YAML frontmatter (feature_ids, topics, doc_kind, created) +- Three-layer info architecture: CLAUDE.md (≤100 lines) → Skills (on-demand) → refs/ +- Backlog: BACKLOG.md (hot) → Feature files (warm) → raw docs (cold) +- Feature lifecycle: kickoff → discussion → implementation → review → completion +- SOP: See docs/SOP.md for the 6-step workflow + diff --git a/packages/api/BACKLOG.md b/packages/api/BACKLOG.md new file mode 100644 index 000000000..ca1b5d488 --- /dev/null +++ b/packages/api/BACKLOG.md @@ -0,0 +1,13 @@ +--- +topics: [backlog] +doc_kind: note +created: 2026-03-31 +--- + +# Feature Roadmap + +> **Rules**: Only active Features (idea/spec/in-progress/review). Move to done after completion. +> Details in `docs/features/Fxxx-*.md`. + +| ID | Name | Status | Owner | Link | +|----|------|--------|-------|------| diff --git a/packages/api/CLAUDE.md b/packages/api/CLAUDE.md new file mode 100644 index 000000000..31316c6e7 --- /dev/null +++ b/packages/api/CLAUDE.md @@ -0,0 +1,30 @@ + +> Pack version: 1.3.0 | Provider: claude + +## Cat Cafe Governance Rules (Auto-managed) + +### Hard Constraints (immutable) +- **Public local defaults**: use frontend 3003 and API 3004 to avoid colliding with another local runtime. +- **Redis port 6399** is Cat Cafe's production Redis. Never connect to it from external projects. Use 6398 for dev/test. +- **No self-review**: The same individual cannot review their own code. Cross-family review preferred. +- **Identity is constant**: Never impersonate another cat. Identity is a hard constraint. + +### Collaboration Standards +- A2A handoff uses five-tuple: What / Why / Tradeoff / Open Questions / Next Action +- Vision Guardian: Read original requirements before starting. AC completion ≠ feature complete. +- Review flow: quality-gate → request-review → receive-review → merge-gate +- Skills are available via symlinked cat-cafe-skills/ — load the relevant skill before each workflow step +- Shared rules: See cat-cafe-skills/refs/shared-rules.md for full collaboration contract + +### Quality Discipline (overrides "try simplest approach first") +- **Bug: find root cause before fixing**. No guess-and-patch. Steps: reproduce → logs → call chain → confirm root cause → fix +- **Uncertain direction: stop → search → ask → confirm → then act**. Never "just try it first" +- **"Done" requires evidence** (tests pass / screenshot / logs). Bug fix = red test first, then green + +### Knowledge Engineering +- Documents use YAML frontmatter (feature_ids, topics, doc_kind, created) +- Three-layer info architecture: CLAUDE.md (≤100 lines) → Skills (on-demand) → refs/ +- Backlog: BACKLOG.md (hot) → Feature files (warm) → raw docs (cold) +- Feature lifecycle: kickoff → discussion → implementation → review → completion +- SOP: See docs/SOP.md for the 6-step workflow + diff --git a/packages/api/GEMINI.md b/packages/api/GEMINI.md new file mode 100644 index 000000000..b058371be --- /dev/null +++ b/packages/api/GEMINI.md @@ -0,0 +1,30 @@ + +> Pack version: 1.3.0 | Provider: gemini + +## Cat Cafe Governance Rules (Auto-managed) + +### Hard Constraints (immutable) +- **Public local defaults**: use frontend 3003 and API 3004 to avoid colliding with another local runtime. +- **Redis port 6399** is Cat Cafe's production Redis. Never connect to it from external projects. Use 6398 for dev/test. +- **No self-review**: The same individual cannot review their own code. Cross-family review preferred. +- **Identity is constant**: Never impersonate another cat. Identity is a hard constraint. + +### Collaboration Standards +- A2A handoff uses five-tuple: What / Why / Tradeoff / Open Questions / Next Action +- Vision Guardian: Read original requirements before starting. AC completion ≠ feature complete. +- Review flow: quality-gate → request-review → receive-review → merge-gate +- Skills are available via symlinked cat-cafe-skills/ — load the relevant skill before each workflow step +- Shared rules: See cat-cafe-skills/refs/shared-rules.md for full collaboration contract + +### Quality Discipline (overrides "try simplest approach first") +- **Bug: find root cause before fixing**. No guess-and-patch. Steps: reproduce → logs → call chain → confirm root cause → fix +- **Uncertain direction: stop → search → ask → confirm → then act**. Never "just try it first" +- **"Done" requires evidence** (tests pass / screenshot / logs). Bug fix = red test first, then green + +### Knowledge Engineering +- Documents use YAML frontmatter (feature_ids, topics, doc_kind, created) +- Three-layer info architecture: CLAUDE.md (≤100 lines) → Skills (on-demand) → refs/ +- Backlog: BACKLOG.md (hot) → Feature files (warm) → raw docs (cold) +- Feature lifecycle: kickoff → discussion → implementation → review → completion +- SOP: See docs/SOP.md for the 6-step workflow + diff --git a/packages/api/docs/SOP.md b/packages/api/docs/SOP.md new file mode 100644 index 000000000..b11204f76 --- /dev/null +++ b/packages/api/docs/SOP.md @@ -0,0 +1,24 @@ +--- +topics: [sop, workflow] +doc_kind: note +created: 2026-03-31 +--- + +# Standard Operating Procedure + +## Workflow (6 steps) + +| Step | What | Skill | +|------|------|-------| +| 1 | Create worktree | `worktree` | +| 2 | Self-check (spec compliance) | `quality-gate` | +| 3 | Peer review | `request-review` / `receive-review` | +| 4 | Merge gate | `merge-gate` | +| 5 | PR + cloud review | (merge-gate handles) | +| 6 | Merge + cleanup | (SOP steps) | + +## Code Quality + +- Biome: `pnpm check` / `pnpm check:fix` +- Types: `pnpm lint` +- File limits: 200 lines warn / 350 hard cap diff --git a/packages/api/docs/decisions/.gitkeep b/packages/api/docs/decisions/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/packages/api/docs/discussions/.gitkeep b/packages/api/docs/discussions/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/packages/api/docs/features/.gitkeep b/packages/api/docs/features/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/packages/api/docs/features/TEMPLATE.md b/packages/api/docs/features/TEMPLATE.md new file mode 100644 index 000000000..f51272c38 --- /dev/null +++ b/packages/api/docs/features/TEMPLATE.md @@ -0,0 +1,20 @@ +--- +feature_ids: [Fxxx] +related_features: [] +topics: [] +doc_kind: spec +created: 2026-03-31 +--- + +# Fxxx: Feature Name + +> Status: spec | Owner: TBD + +## Why +## What +## Acceptance Criteria +- [ ] AC-1: ... + +## Dependencies +## Risk +## Open Questions diff --git a/packages/api/src/domains/cats/services/agents/invocation/invoke-single-cat.ts b/packages/api/src/domains/cats/services/agents/invocation/invoke-single-cat.ts index 854d4060c..49a9b81b3 100644 --- a/packages/api/src/domains/cats/services/agents/invocation/invoke-single-cat.ts +++ b/packages/api/src/domains/cats/services/agents/invocation/invoke-single-cat.ts @@ -995,10 +995,11 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP // Prepend staticIdentity to prompt when injection is needed // F070-P2: missionPrefix (dispatch context) is prepended for external projects const promptWithMission = missionPrefix ? `${missionPrefix}\n\n${prompt}` : prompt; + const effectivePrompt = injectSystemPrompt && params.systemPrompt ? `${params.systemPrompt}\n\n---\n\n${promptWithMission}` - : promptWithMission; + : `${promptWithMission}`; // F089 Phase 2+3: Create tmux spawn override for agent-in-pane execution let spawnCliOverride: AgentServiceOptions['spawnCliOverride']; diff --git a/packages/api/src/domains/cats/services/agents/routing/route-parallel.ts b/packages/api/src/domains/cats/services/agents/routing/route-parallel.ts index 0c324cca5..e9f915061 100644 --- a/packages/api/src/domains/cats/services/agents/routing/route-parallel.ts +++ b/packages/api/src/domains/cats/services/agents/routing/route-parallel.ts @@ -9,6 +9,7 @@ import { getCatContextBudget } from '../../../../../config/cat-budgets.js'; import { getConfigSessionStrategy, isSessionChainEnabled } from '../../../../../config/cat-config-loader.js'; import { createModuleLogger } from '../../../../../infrastructure/logger.js'; import { estimateTokens } from '../../../../../utils/token-counter.js'; +import { canAccessGuideState, hasHiddenForeignNonTerminalGuideState } from '../../../../guides/guide-state-access.js'; import { assembleContext } from '../../context/ContextAssembler.js'; import { buildInvocationContext, @@ -45,6 +46,34 @@ import { buildVoteTally, checkVoteCompletion, extractVoteFromText, VOTE_RESULT_S const log = createModuleLogger('route-parallel'); +function shouldHandleCompletedGuide( + guideCompletionOwner: string | undefined, + targetCatIds: ReadonlySet, + fallbackCatId: string | undefined, + catId: string, +): boolean { + if (!guideCompletionOwner) return true; + if (guideCompletionOwner === catId) return true; + if (!targetCatIds.has(guideCompletionOwner)) return fallbackCatId === catId; + return false; +} + +function shouldHandleOfferedGuide( + guideOfferOwner: string | undefined, + targetCatIds: ReadonlySet, + fallbackCatId: string | undefined, + catId: string, + hasUserSelection: boolean, + allowOwnerMissingFallback = false, +): boolean { + if (!guideOfferOwner) return true; + if (guideOfferOwner === catId) return true; + if ((hasUserSelection || allowOwnerMissingFallback) && !targetCatIds.has(guideOfferOwner)) { + return fallbackCatId === catId; + } + return false; +} + export async function* routeParallel( deps: RouteStrategyDeps, targetCats: CatId[], @@ -89,12 +118,73 @@ export async function* routeParallel( let voiceMode: boolean | undefined; // F087: Bootcamp state for CVO onboarding let bootcampState: InvocationContext['bootcampState']; + // F155: Guide candidate from keyword matching against raw user message + let guideCandidate: InvocationContext['guideCandidate']; + /** catId that owns an offered guide prompt, to avoid duplicate offered→offered writes. */ + let guideOfferOwner: string | undefined; + /** catId that should receive offered-guide selection when owner is absent. */ + let guideOfferSelectionFallbackCatId: string | undefined; + /** catId that should receive the one-shot completion notice (offeredBy). */ + let guideCompletionOwner: string | undefined; + const targetCatIds = new Set(targetCats); + let guideCompletionFallbackCatId: string | undefined; + let hiddenForeignNonTerminalGuideState = false; if (deps.invocationDeps.threadStore) { try { const thread = await deps.invocationDeps.threadStore.get(threadId); routingPolicy = thread?.routingPolicy; voiceMode = thread?.voiceMode; bootcampState = thread?.bootcampState; + const threadGuideState = thread?.guideState; + hiddenForeignNonTerminalGuideState = hasHiddenForeignNonTerminalGuideState(thread, threadGuideState, userId); + const guideState = canAccessGuideState(thread, threadGuideState, userId) ? threadGuideState : undefined; + // F155: Read existing guide state from thread (authority source) + if (guideState) { + const gs = guideState; + // cancelled: fully terminal, skip + // completed: one-shot inject if not yet acked, then mark acked + const justCompleted = gs.status === 'completed' && !gs.completionAcked; + const shouldInject = (gs.status !== 'completed' && gs.status !== 'cancelled') || justCompleted; + if (shouldInject) { + let name = gs.guideId; + let estimatedTime = ''; + try { + const { getRegistryEntries } = await import('../../../../guides/guide-registry-loader.js'); + const entry = getRegistryEntries().find((e) => e.id === gs.guideId); + if (entry) { + name = entry.name; + estimatedTime = entry.estimated_time; + } + } catch { + /* best-effort */ + } + const selectionMatch = message.match(/^引导流程:(.+)$/); + guideCandidate = { + id: gs.guideId, + name, + estimatedTime, + status: gs.status as 'offered' | 'awaiting_choice' | 'active' | 'completed', + ...(gs.status === 'offered' ? { isNewOffer: false } : {}), + ...(selectionMatch ? { userSelection: selectionMatch[1] } : {}), + }; + if (gs.status === 'offered' || gs.status === 'awaiting_choice') { + guideOfferOwner = gs.offeredBy; + if ( + (selectionMatch || gs.status === 'awaiting_choice') && + gs.offeredBy && + !targetCatIds.has(gs.offeredBy) + ) { + guideOfferSelectionFallbackCatId = targetCats[0]; + } + } + if (justCompleted) { + guideCompletionOwner = gs.offeredBy; + if (gs.offeredBy && !targetCatIds.has(gs.offeredBy)) { + guideCompletionFallbackCatId = targetCats[0]; + } + } + } + } // F073 P4: Read workflow-sop if thread is linked to a backlog item if (thread?.backlogItemId && deps.invocationDeps.workflowSopStore) { try { @@ -121,6 +211,27 @@ export async function* routeParallel( const catToolNames = new Map(); const catCoverageMap = new Map(); + // F155: Match raw user message against guide registry (only if no existing guide state) + if (!guideCandidate && !hiddenForeignNonTerminalGuideState) { + try { + const { resolveGuideForIntent } = await import('../../../../guides/guide-registry-loader.js'); + const guideMatches = resolveGuideForIntent(message); + if (guideMatches.length > 0) { + const top = guideMatches[0]; + guideCandidate = { + id: top.id, + name: top.name, + estimatedTime: top.estimatedTime, + status: 'offered', + isNewOffer: true, + }; + guideOfferOwner = targetCats[0]; + } + } catch { + /* best-effort: guide matching failure does not block invocation */ + } + } + const streams = await Promise.all( targetCats.map(async (catId) => { const catConfig: CatConfig | undefined = @@ -175,6 +286,21 @@ export async function* routeParallel( ...(activeSignals ? { activeSignals } : {}), ...(voiceMode ? { voiceMode } : {}), ...(bootcampState ? { bootcampState, threadId } : {}), + ...(guideCandidate && + (guideCandidate.status === 'completed' + ? shouldHandleCompletedGuide(guideCompletionOwner, targetCatIds, guideCompletionFallbackCatId, catId) + : guideCandidate.status === 'offered' || guideCandidate.status === 'awaiting_choice' + ? shouldHandleOfferedGuide( + guideOfferOwner, + targetCatIds, + guideOfferSelectionFallbackCatId, + catId, + Boolean(guideCandidate.userSelection), + guideCandidate.status === 'awaiting_choice', + ) + : true) + ? { guideCandidate, threadId } + : {}), }); const targetContentBlocks = routeContentBlocksForCat(catId, contentBlocks); @@ -561,8 +687,10 @@ export async function* routeParallel( // recreating an orphan Redis hash key via HSET. catInvocationId.delete(msg.catId); const bufferedBlocks = getRichBlockBuffer().consume(threadId, msg.catId, ownInvId); + let catProducedOutput = false; const text = catText.get(msg.catId); if (text) { + catProducedOutput = true; const meta = catMeta.get(msg.catId); const sanitized = sanitizeInjectedContent(text); // F22: Extract cc_rich blocks from text + merge with buffered @@ -729,22 +857,27 @@ export async function* routeParallel( const sawUserFacingSystemInfo = catSawUserFacingSystemInfo.get(msg.catId) === true; const shouldPersistNoTextMessage = hasRichBlocks || (catTools?.length ?? 0) > 0 || Boolean(thinking?.trim().length ?? 0); + const shouldEmitSilentCompletion = (catTools?.length ?? 0) > 0 && !hasRichBlocks && !sawUserFacingSystemInfo; // Diagnostic: if cat ran tools but produced no text, emit a system_info so the // user sees *something* instead of a silent vanish (bugfix: silent-exit P1). - if (catTools && catTools.length > 0 && !hasRichBlocks && !sawUserFacingSystemInfo) { + if (shouldEmitSilentCompletion) { yield { type: 'system_info' as AgentMessageType, catId: msg.catId, content: JSON.stringify({ type: 'silent_completion', detail: `${msg.catId} completed with tool calls but no text response.`, - toolCount: catTools.length, + toolCount: catTools?.length ?? 0, }), timestamp: Date.now(), } as AgentMessage; } + if (shouldPersistNoTextMessage || sawUserFacingSystemInfo || shouldEmitSilentCompletion) { + catProducedOutput = true; + } + if (shouldPersistNoTextMessage) { try { await deps.messageStore.append({ @@ -905,6 +1038,37 @@ export async function* routeParallel( } } + // F155: Ack guide completion only after cat produced visible output. + // Skip ack on error-only turns so next turn retries delivery. + if ( + catProducedOutput && + guideCandidate?.status === 'completed' && + shouldHandleCompletedGuide( + guideCompletionOwner, + targetCatIds, + guideCompletionFallbackCatId, + msg.catId as string, + ) && + deps.invocationDeps.threadStore + ) { + try { + const thread = await deps.invocationDeps.threadStore.get(threadId); + const gs = thread?.guideState; + if ( + thread && + gs && + canAccessGuideState(thread, gs, userId) && + gs.guideId === guideCandidate.id && + gs.status === 'completed' && + !gs.completionAcked + ) { + await deps.invocationDeps.threadStore.updateGuideState(threadId, { ...gs, completionAcked: true }); + } + } catch { + /* best-effort: ack failure means next turn re-injects, which is acceptable */ + } + } + const isFinal = completedCount === targetCats.length; // F5: When all parallel cats are done, emit follow-up hints for A2A mentions diff --git a/packages/api/src/domains/cats/services/agents/routing/route-serial.ts b/packages/api/src/domains/cats/services/agents/routing/route-serial.ts index 6ead7533e..b512e7f23 100644 --- a/packages/api/src/domains/cats/services/agents/routing/route-serial.ts +++ b/packages/api/src/domains/cats/services/agents/routing/route-serial.ts @@ -18,6 +18,7 @@ import { getCatVoice } from '../../../../../config/cat-voices.js'; import { createModuleLogger } from '../../../../../infrastructure/logger.js'; import { detectUserMention } from '../../../../../routes/user-mention.js'; import { estimateTokens } from '../../../../../utils/token-counter.js'; +import { canAccessGuideState, hasHiddenForeignNonTerminalGuideState } from '../../../../guides/guide-state-access.js'; import { assembleContext } from '../../context/ContextAssembler.js'; import { buildInvocationContext, @@ -56,6 +57,34 @@ import { buildVoteTally, checkVoteCompletion, extractVoteFromText, VOTE_RESULT_S const log = createModuleLogger('route-serial'); +function shouldHandleCompletedGuide( + guideCompletionOwner: string | undefined, + targetCatIds: ReadonlySet, + fallbackCatId: string | undefined, + catId: string, +): boolean { + if (!guideCompletionOwner) return true; + if (guideCompletionOwner === catId) return true; + if (!targetCatIds.has(guideCompletionOwner)) return fallbackCatId === catId; + return false; +} + +function shouldHandleOfferedGuide( + guideOfferOwner: string | undefined, + targetCatIds: ReadonlySet, + fallbackCatId: string | undefined, + catId: string, + hasUserSelection: boolean, + allowOwnerMissingFallback = false, +): boolean { + if (!guideOfferOwner) return true; + if (guideOfferOwner === catId) return true; + if ((hasUserSelection || allowOwnerMissingFallback) && !targetCatIds.has(guideOfferOwner)) { + return fallbackCatId === catId; + } + return false; +} + export async function* routeSerial( deps: RouteStrategyDeps, targetCats: CatId[], @@ -113,12 +142,75 @@ export async function* routeSerial( let voiceMode: boolean | undefined; // F087: Bootcamp state for CVO onboarding let bootcampState: InvocationContext['bootcampState']; + // F155: Guide candidate from keyword matching against raw user message + let guideCandidate: InvocationContext['guideCandidate']; + /** catId that owns an offered guide prompt, to avoid duplicate offered→offered writes. */ + let guideOfferOwner: string | undefined; + /** catId that should receive offered-guide selection when owner is absent. */ + let guideOfferSelectionFallbackCatId: string | undefined; + /** catId that should receive the one-shot completion notice (offeredBy). */ + let guideCompletionOwner: string | undefined; + let guideCompletionFallbackCatId: string | undefined; + let hiddenForeignNonTerminalGuideState = false; + const targetCatIds = new Set(targetCats); if (deps.invocationDeps.threadStore) { try { const thread = await deps.invocationDeps.threadStore.get(threadId); routingPolicy = thread?.routingPolicy; voiceMode = thread?.voiceMode; bootcampState = thread?.bootcampState; + const threadGuideState = thread?.guideState; + hiddenForeignNonTerminalGuideState = hasHiddenForeignNonTerminalGuideState(thread, threadGuideState, userId); + const guideState = canAccessGuideState(thread, threadGuideState, userId) ? threadGuideState : undefined; + // F155: Read existing guide state from thread (authority source) + if (guideState) { + const gs = guideState; + // cancelled: fully terminal, skip + // completed: one-shot inject if not yet acked, then mark acked + const justCompleted = gs.status === 'completed' && !gs.completionAcked; + const shouldInject = (gs.status !== 'completed' && gs.status !== 'cancelled') || justCompleted; + if (shouldInject) { + // Back-fill display metadata from registry so prompt gets real name/time + let name = gs.guideId; + let estimatedTime = ''; + try { + const { getRegistryEntries } = await import('../../../../guides/guide-registry-loader.js'); + const entry = getRegistryEntries().find((e) => e.id === gs.guideId); + if (entry) { + name = entry.name; + estimatedTime = entry.estimated_time; + } + } catch { + /* best-effort */ + } + // Detect selection response: "引导流程:{label}" + const selectionMatch = message.match(/^引导流程:(.+)$/); + guideCandidate = { + id: gs.guideId, + name, + estimatedTime, + status: gs.status as 'offered' | 'awaiting_choice' | 'active' | 'completed', + ...(gs.status === 'offered' ? { isNewOffer: false } : {}), + ...(selectionMatch ? { userSelection: selectionMatch[1] } : {}), + }; + if (gs.status === 'offered' || gs.status === 'awaiting_choice') { + guideOfferOwner = gs.offeredBy; + if ( + (selectionMatch || gs.status === 'awaiting_choice') && + gs.offeredBy && + !targetCatIds.has(gs.offeredBy) + ) { + guideOfferSelectionFallbackCatId = targetCats[0]; + } + } + if (justCompleted) { + guideCompletionOwner = gs.offeredBy; + if (gs.offeredBy && !targetCatIds.has(gs.offeredBy)) { + guideCompletionFallbackCatId = targetCats[0]; + } + } + } + } // F073 P4: Read workflow-sop if thread is linked to a backlog item if (thread?.backlogItemId && deps.invocationDeps.workflowSopStore) { try { @@ -139,6 +231,31 @@ export async function* routeSerial( } } + // F155: Match raw user message against guide registry (only if no existing guide state) + if (!guideCandidate && !hiddenForeignNonTerminalGuideState) { + try { + const { resolveGuideForIntent } = await import('../../../../guides/guide-registry-loader.js'); + const guideMatches = resolveGuideForIntent(message); + if (guideMatches.length > 0) { + const top = guideMatches[0]; + guideCandidate = { + id: top.id, + name: top.name, + estimatedTime: top.estimatedTime, + status: 'offered', + isNewOffer: true, + }; + guideOfferOwner = targetCats[0]; + log.info( + { guideId: top.id, guideName: top.name, score: top.score }, + '[F155] guide candidate matched at routing layer', + ); + } + } catch { + /* best-effort: guide matching failure does not block invocation */ + } + } + try { while (index < worklist.length) { if (signal?.aborted) break; @@ -230,6 +347,21 @@ export async function* routeSerial( ...(activeSignals ? { activeSignals } : {}), ...(voiceMode ? { voiceMode } : {}), ...(bootcampState ? { bootcampState, threadId } : {}), + ...(guideCandidate && + (guideCandidate.status === 'completed' + ? shouldHandleCompletedGuide(guideCompletionOwner, targetCatIds, guideCompletionFallbackCatId, catId) + : guideCandidate.status === 'offered' || guideCandidate.status === 'awaiting_choice' + ? shouldHandleOfferedGuide( + guideOfferOwner, + targetCatIds, + guideOfferSelectionFallbackCatId, + catId, + Boolean(guideCandidate.userSelection), + guideCandidate.status === 'awaiting_choice', + ) + : true) + ? { guideCandidate, threadId } + : {}), }); // F24 Phase E: Bootstrap context for Session #2+ @@ -384,6 +516,8 @@ export async function* routeSerial( let firstMetadata: MessageMetadata | undefined; let doneMsg: AgentMessage | undefined; let hadError = false; + /** F155: tracks whether cat produced user-visible output (for guide completion ack). */ + let catProducedOutput = false; let sawUserFacingSystemInfo = false; // #267: track errors that happened BEFORE abort — only these are real provider failures let hadProviderError = false; @@ -633,6 +767,7 @@ export async function* routeSerial( let mentionsUser = false; if (textContent) { + catProducedOutput = true; const sanitized = sanitizeInjectedContent(textContent); // F22: Extract cc_rich blocks from text (Route B fallback for non-MCP cats) @@ -961,6 +1096,7 @@ export async function* routeSerial( const hasRichBlocks = noTextBlocks.length > 0; const shouldPersistNoTextMessage = hasRichBlocks || collectedToolEvents.length > 0 || Boolean(thinkingContent?.trim().length > 0); + const shouldEmitSilentCompletion = collectedToolEvents.length > 0 && !hasRichBlocks && !sawUserFacingSystemInfo; log.debug( { @@ -976,7 +1112,7 @@ export async function* routeSerial( ); // Diagnostic: if cat ran tools but produced no text, emit a system_info so the // user sees *something* instead of a silent vanish (bugfix: silent-exit P1). - if (collectedToolEvents.length > 0 && !hasRichBlocks && !sawUserFacingSystemInfo) { + if (shouldEmitSilentCompletion) { yield { type: 'system_info' as AgentMessageType, catId, @@ -988,6 +1124,9 @@ export async function* routeSerial( timestamp: Date.now(), } as AgentMessage; } + if (shouldPersistNoTextMessage || sawUserFacingSystemInfo || shouldEmitSilentCompletion) { + catProducedOutput = true; + } if (shouldPersistNoTextMessage) { try { @@ -1175,6 +1314,32 @@ export async function* routeSerial( }); } + // F155: Ack guide completion only after cat produced visible output. + // Skip ack on error-only turns so next turn retries delivery. + if ( + catProducedOutput && + guideCandidate?.status === 'completed' && + shouldHandleCompletedGuide(guideCompletionOwner, targetCatIds, guideCompletionFallbackCatId, catId) && + deps.invocationDeps.threadStore + ) { + try { + const thread = await deps.invocationDeps.threadStore.get(threadId); + const gs = thread?.guideState; + if ( + thread && + gs && + canAccessGuideState(thread, gs, userId) && + gs.guideId === guideCandidate.id && + gs.status === 'completed' && + !gs.completionAcked + ) { + await deps.invocationDeps.threadStore.updateGuideState(threadId, { ...gs, completionAcked: true }); + } + } catch { + /* best-effort: ack failure means next turn re-injects, which is acceptable */ + } + } + // Yield buffered done with correct isFinal (evaluated AFTER worklist may have grown) // MUST always reach here regardless of append success (缅因猫 review P1-2) if (doneMsg) { diff --git a/packages/api/src/domains/cats/services/context/SystemPromptBuilder.ts b/packages/api/src/domains/cats/services/context/SystemPromptBuilder.ts index 1c43b40fa..dea7cd4e4 100644 --- a/packages/api/src/domains/cats/services/context/SystemPromptBuilder.ts +++ b/packages/api/src/domains/cats/services/context/SystemPromptBuilder.ts @@ -102,6 +102,20 @@ export interface InvocationContext { * When present, cats inject bootcamp-guide behavior per phase. */ bootcampState?: BootcampStateV1; + /** + * F155: Matched guide candidate from routing-layer keyword match. + * When present, cats load guide-interaction skill and offer the guide. + */ + guideCandidate?: { + id: string; + name: string; + estimatedTime: string; + status: 'offered' | 'awaiting_choice' | 'active' | 'completed'; + /** True only on the first routing-layer match before any guideState has been persisted. */ + isNewOffer?: boolean; + /** When user clicked an interactive selection, carries the chosen label. */ + userSelection?: string; + }; /** * F129: Compiled pack blocks from active packs. * Injected into static identity via buildStaticIdentity → packBlocks. @@ -601,6 +615,100 @@ export function buildInvocationContext(context: InvocationContext): string { ); } + // F155: Guide candidate — inline protocol (cats don't have /Skill tool at runtime) + if (context.guideCandidate) { + const { id, name, estimatedTime, status } = context.guideCandidate; + const threadPart = context.threadId ? ` thread=${context.threadId}` : ''; + const userSelection = context.guideCandidate.userSelection; + const isNewOffer = context.guideCandidate.isNewOffer === true; + // "先看步骤概览" still comes through as a chat message; start/skip are frontend-only actions + if ((status === 'offered' || status === 'awaiting_choice') && userSelection?.includes('步骤概览')) { + const previewSteps = + status === 'offered' + ? [ + `1. 调用 cat_cafe_update_guide_state(threadId="${context.threadId}", guideId="${id}", status="awaiting_choice")`, + `2. 调用 cat_cafe_guide_resolve(intent="${name}") 获取步骤信息`, + '3. 用 3-5 条简要列出主要步骤', + '4. 在最后问用户是否要开始引导', + ] + : [ + '1. 不要再次调用 cat_cafe_update_guide_state(当前已经是 awaiting_choice)', + `2. 调用 cat_cafe_guide_resolve(intent="${name}") 获取步骤信息`, + '3. 用 3-5 条简要列出主要步骤', + '4. 在最后问用户是否要开始引导', + ]; + lines.push( + `🧭 Guide Selection:${threadPart} 用户选择了「步骤概览」 guideId=${id} name=${name}`, + '你必须按以下步骤回复:', + ...previewSteps, + '', + ); + } else if (status === 'offered' && isNewOffer) { + // First encounter: emit interactive card with frontend-direct actions for start/skip + const blockJson = JSON.stringify({ + id: `guide-offer-${id}-${(context.threadId ?? '').slice(-8) || 'x'}`, + kind: 'interactive', + v: 1, + interactiveType: 'select', + title: `我找到了「${name}」引导流程(约 ${estimatedTime})。要现在开始吗?`, + options: [ + { + id: 'start', + label: '开始引导(推荐)', + emoji: '🚀', + action: { + type: 'callback', + endpoint: '/api/guide-actions/start', + payload: { threadId: context.threadId, guideId: id }, + }, + }, + { id: 'preview', label: '先看步骤概览', emoji: '📋' }, + { + id: 'skip', + label: '暂不需要', + emoji: '⏭️', + action: { + type: 'callback', + endpoint: '/api/guide-actions/cancel', + payload: { threadId: context.threadId, guideId: id }, + }, + }, + ], + messageTemplate: '引导流程:{selection}', + }); + lines.push( + `🧭 Guide Matched:${threadPart} id=${id} name=${name} time=${estimatedTime}`, + '你必须按以下步骤回复(严格遵守):', + `1. 调用 cat_cafe_update_guide_state(threadId="${context.threadId}", guideId="${id}", status="offered")`, + `2. 写一句简短的话告知用户你找到了「${name}」引导流程`, + `3. 调用 cat_cafe_create_rich_block,block 参数传入以下 JSON 字符串:`, + blockJson, + '4. 禁止直接给出教程或步骤列表', + '5. 禁止调用 cat_cafe_start_guide(等用户在选项卡中选择后再启动)', + '', + ); + } else if (status === 'offered' || status === 'awaiting_choice') { + lines.push( + `🧭 Guide Pending:${threadPart} id=${id} name=${name} — 用户尚未选择`, + '不要重复发送选项卡。用一句话提醒:「之前找到了引导流程,你要开始吗?」', + '', + ); + } else if (status === 'active') { + lines.push( + `🧭 Guide Active:${threadPart} id=${id} name=${name}`, + '引导进行中。回答与引导相关的问题,不要重发选项卡。用户要退出时调用 cat_cafe_guide_control(action="exit")。', + '', + ); + } else if (status === 'completed') { + lines.push( + `🧭 Guide Completed:${threadPart} id=${id} name=${name}`, + '用户刚完成了这个引导流程。用一句话肯定用户的操作(如"添加成员成功了"),并询问是否需要进一步帮助。不要重发选项卡。', + '', + ); + } + // cancelled: no injection needed + } + // F091: Active Signal articles in discussion context if (context.activeSignals && context.activeSignals.length > 0) { lines.push('Signal articles linked to this thread:'); diff --git a/packages/api/src/domains/cats/services/stores/ports/ThreadStore.ts b/packages/api/src/domains/cats/services/stores/ports/ThreadStore.ts index 34eabc3b1..8e34a266b 100644 --- a/packages/api/src/domains/cats/services/stores/ports/ThreadStore.ts +++ b/packages/api/src/domains/cats/services/stores/ports/ThreadStore.ts @@ -130,6 +130,8 @@ export interface Thread { deletedAt?: number | null; /** F087: CVO Bootcamp onboarding state. */ bootcampState?: BootcampStateV1; + /** F155: Scene-based bidirectional guide state. */ + guideState?: GuideStateV1; /** F088 Phase G: Connector Hub thread state — marks this thread as an IM Hub for command isolation. */ connectorHubState?: ConnectorHubStateV1; } @@ -174,6 +176,26 @@ export interface BootcampStateV1 { completedAt?: number; } +/** F155: Guide session status */ +export type GuideStatus = 'offered' | 'awaiting_choice' | 'active' | 'completed' | 'cancelled'; + +/** F155: Scene-based bidirectional guide state — thread-level authority */ +export interface GuideStateV1 { + v: 1; + guideId: string; + status: GuideStatus; + /** Owning user for default-thread guide state. */ + userId?: string; + currentStep?: number; + offeredAt: number; + startedAt?: number; + completedAt?: number; + /** True after the first agent turn has seen the completion (one-shot consumption). */ + completionAcked?: boolean; + /** catId that offered this guide (prevents multi-cat duplicate offers). */ + offeredBy?: string; +} + /** F079: Voting state stored in thread metadata */ export interface VotingStateV1 { v: 1; @@ -246,6 +268,8 @@ export interface IThreadStore { updateVoiceMode(threadId: string, voiceMode: boolean): void | Promise; /** F087: Get/update bootcamp state. */ updateBootcampState(threadId: string, state: BootcampStateV1 | null): void | Promise; + /** F155: Get/update guide state. */ + updateGuideState(threadId: string, state: GuideStateV1 | null): void | Promise; /** F088 Phase G: Get/update connector hub state. */ updateConnectorHubState(threadId: string, state: ConnectorHubStateV1 | null): void | Promise; updateLastActive(threadId: string): void | Promise; @@ -555,6 +579,16 @@ export class ThreadStore implements IThreadStore { } } + updateGuideState(threadId: string, state: GuideStateV1 | null): void { + const thread = this.get(threadId); + if (!thread) return; + if (state === null) { + delete thread.guideState; + } else { + thread.guideState = state; + } + } + updateConnectorHubState(threadId: string, state: ConnectorHubStateV1 | null): void { const thread = this.get(threadId); if (!thread) return; diff --git a/packages/api/src/domains/cats/services/stores/redis/RedisThreadStore.ts b/packages/api/src/domains/cats/services/stores/redis/RedisThreadStore.ts index 336034e25..5c0b699c9 100644 --- a/packages/api/src/domains/cats/services/stores/redis/RedisThreadStore.ts +++ b/packages/api/src/domains/cats/services/stores/redis/RedisThreadStore.ts @@ -16,6 +16,7 @@ import type { RedisClient } from '@cat-cafe/shared/utils'; import type { BootcampStateV1, ConnectorHubStateV1, + GuideStateV1, IThreadStore, MentionActionabilityMode, Thread, @@ -443,6 +444,15 @@ export class RedisThreadStore implements IThreadStore { } } + async updateGuideState(threadId: string, state: GuideStateV1 | null): Promise { + const key = ThreadKeys.detail(threadId); + if (state === null) { + await this.redis.hdel(key, 'guideState'); + } else { + await this.redis.eval(HSET_IF_HAS_ID_LUA, 1, key, 'guideState', JSON.stringify(state)); + } + } + async updateConnectorHubState(threadId: string, state: ConnectorHubStateV1 | null): Promise { const key = ThreadKeys.detail(threadId); if (state === null) { @@ -748,6 +758,9 @@ export class RedisThreadStore implements IThreadStore { if (thread.connectorHubState) { result.connectorHubState = JSON.stringify(thread.connectorHubState); } + if (thread.guideState) { + result.guideState = JSON.stringify(thread.guideState); + } return result; } @@ -838,6 +851,16 @@ export class RedisThreadStore implements IThreadStore { /* ignore malformed JSON */ } } + if (data.guideState) { + try { + const parsed = JSON.parse(data.guideState); + if (parsed && typeof parsed === 'object' && parsed.v === 1) { + result.guideState = parsed as GuideStateV1; + } + } catch { + /* ignore malformed JSON */ + } + } return result; } diff --git a/packages/api/src/domains/guides/guide-registry-loader.ts b/packages/api/src/domains/guides/guide-registry-loader.ts new file mode 100644 index 000000000..652dce621 --- /dev/null +++ b/packages/api/src/domains/guides/guide-registry-loader.ts @@ -0,0 +1,209 @@ +/** + * F155: Load guide registry from YAML source. + * + * Provides a validated set of known guide IDs for server-side validation, + * and the full registry entries for the resolve MCP tool. + */ +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import YAML from 'yaml'; + +export interface GuideRegistryEntry { + id: string; + name: string; + description: string; + keywords: string[]; + category: string; + priority: string; + cross_system: boolean; + estimated_time: string; + flow_file: string; +} + +interface RegistryFile { + guides: GuideRegistryEntry[]; +} + +/** Resolve project root from this file's location */ +function findProjectRoot(): string { + // At runtime: packages/api/dist/domains/guides/guide-registry-loader.js + const thisDir = dirname(fileURLToPath(import.meta.url)); + return resolve(thisDir, '..', '..', '..', '..', '..'); +} + +let cachedEntries: GuideRegistryEntry[] | null = null; +let cachedIds: Set | null = null; +const GUIDE_TARGET_RE = /^[a-zA-Z0-9._-]+$/; + +function ensureLoaded(): void { + if (cachedEntries) return; + const root = findProjectRoot(); + const registryPath = resolve(root, 'guides', 'registry.yaml'); + const raw = readFileSync(registryPath, 'utf-8'); + const parsed = YAML.parse(raw) as RegistryFile; + if (!parsed?.guides || !Array.isArray(parsed.guides)) { + throw new Error('[F155] Invalid guide registry: missing "guides" array'); + } + cachedEntries = parsed.guides; + cachedIds = new Set(parsed.guides.map((g) => g.id)); +} + +/** Get set of valid guide IDs */ +export function getValidGuideIds(): Set { + ensureLoaded(); + return cachedIds!; +} + +/** Get all registry entries (for resolve tool) */ +export function getRegistryEntries(): GuideRegistryEntry[] { + ensureLoaded(); + return cachedEntries!; +} + +export function isValidGuideTarget(target: string): boolean { + return GUIDE_TARGET_RE.test(target); +} + +/** Check if a guideId is valid */ +export function isValidGuideId(guideId: string): boolean { + return getValidGuideIds().has(guideId); +} + +export interface GuideMatch { + id: string; + name: string; + description: string; + estimatedTime: string; + score: number; +} + +/** + * Match user intent against guide registry keywords. + * Returns matched guides sorted by score (highest first), or empty array. + * Used by both the MCP callback endpoint and the pre-invocation routing hook. + */ +/* ── OrchestrationFlow v2 — runtime flow loader ── */ + +export interface OrchestrationStep { + id: string; + target: string; + tips: string; + advance: 'click' | 'visible' | 'input' | 'confirm'; + page?: string; + timeoutSec?: number; +} + +export interface OrchestrationFlow { + id: string; + name: string; + description?: string; + steps: OrchestrationStep[]; +} + +interface RawFlowFile { + id: string; + name: string; + description?: string; + steps: Array<{ + id: string; + target: string; + tips: string; + advance: string; + page?: string; + timeoutSec?: number; + }>; +} + +const flowCache = new Map(); +const MIN_ASCII_REVERSE_MATCH_LENGTH = 3; +const MIN_NON_ASCII_REVERSE_MATCH_LENGTH = 2; + +function normalizeGuideIntent(text: string): string { + return text.trim().toLowerCase().replace(/\s+/g, ' '); +} + +function canUseReverseSubstringMatch(query: string): boolean { + const compact = query.replace(/\s+/g, ''); + if (!compact) return false; + return /^[a-z0-9._-]+$/i.test(compact) + ? compact.length >= MIN_ASCII_REVERSE_MATCH_LENGTH + : compact.length >= MIN_NON_ASCII_REVERSE_MATCH_LENGTH; +} + +/** + * Load a guide flow YAML at runtime and return OrchestrationFlow. + * Throws if guide ID is unknown or flow file is invalid. + */ +export function loadGuideFlow(guideId: string): OrchestrationFlow { + const cached = flowCache.get(guideId); + if (cached) return cached; + + const entries = getRegistryEntries(); + const entry = entries.find((e) => e.id === guideId); + if (!entry) throw new Error(`[F155] Unknown guide: ${guideId}`); + + const root = findProjectRoot(); + const flowPath = resolve(root, 'guides', entry.flow_file); + const raw = readFileSync(flowPath, 'utf-8'); + const parsed = YAML.parse(raw) as RawFlowFile; + + if (parsed?.id !== guideId) { + throw new Error( + `[F155] Invalid flow file for "${guideId}": expected id "${guideId}", got "${String(parsed?.id ?? '')}"`, + ); + } + + if (!parsed?.steps || !Array.isArray(parsed.steps)) { + throw new Error(`[F155] Invalid flow file for "${guideId}": missing steps`); + } + + const validAdvance = new Set(['click', 'visible', 'input', 'confirm']); + const flow: OrchestrationFlow = { + id: parsed.id, + name: parsed.name, + description: parsed.description, + steps: parsed.steps.map((s) => { + if (!validAdvance.has(s.advance)) { + throw new Error(`[F155] Invalid advance type "${s.advance}" in step "${s.id}"`); + } + if (!isValidGuideTarget(s.target)) { + throw new Error(`[F155] Invalid target "${s.target}" in step "${s.id}"`); + } + return { + id: s.id, + target: s.target, + tips: s.tips, + advance: s.advance as OrchestrationStep['advance'], + ...(s.page && { page: s.page }), + ...(s.timeoutSec && { timeoutSec: s.timeoutSec }), + }; + }), + }; + + flowCache.set(guideId, flow); + return flow; +} + +export function resolveGuideForIntent(intent: string): GuideMatch[] { + const entries = getRegistryEntries(); + const query = normalizeGuideIntent(intent); + if (!query) return []; + const allowReverseSubstringMatch = canUseReverseSubstringMatch(query); + return entries + .map((entry) => { + const score = entry.keywords.filter((kw) => { + const normalizedKeyword = normalizeGuideIntent(kw); + return query.includes(normalizedKeyword) || (allowReverseSubstringMatch && normalizedKeyword.includes(query)); + }).length; + return { + id: entry.id, + name: entry.name, + description: entry.description, + estimatedTime: entry.estimated_time, + score, + }; + }) + .filter((e) => e.score > 0) + .sort((a, b) => b.score - a.score); +} diff --git a/packages/api/src/domains/guides/guide-state-access.ts b/packages/api/src/domains/guides/guide-state-access.ts new file mode 100644 index 000000000..7f54f6a19 --- /dev/null +++ b/packages/api/src/domains/guides/guide-state-access.ts @@ -0,0 +1,30 @@ +import { DEFAULT_THREAD_ID, type GuideStateV1 } from '../cats/services/stores/ports/ThreadStore.js'; + +interface GuideThreadAccess { + id: string; + createdBy: string; +} + +export function isSharedDefaultGuideThread(thread: GuideThreadAccess | null | undefined): boolean { + return Boolean(thread && thread.id === DEFAULT_THREAD_ID && thread.createdBy === 'system'); +} + +export function canAccessGuideState( + thread: GuideThreadAccess | null | undefined, + guideState: Pick | null | undefined, + userId: string, +): boolean { + if (!thread || !guideState) return false; + if (thread.createdBy === userId) return true; + return isSharedDefaultGuideThread(thread) && guideState.userId === userId; +} + +export function hasHiddenForeignNonTerminalGuideState( + thread: GuideThreadAccess | null | undefined, + guideState: Pick | null | undefined, + userId: string, +): boolean { + if (!thread || !guideState) return false; + if (canAccessGuideState(thread, guideState, userId)) return false; + return guideState.status !== 'completed' && guideState.status !== 'cancelled'; +} diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 49bef086b..e01007025 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -135,6 +135,7 @@ import { externalProjectRoutes, featureDocDetailRoutes, governanceStatusRoute, + guideActionRoutes, intentCardRoutes, invocationsRoutes, leaderboardEventsRoutes, @@ -1141,6 +1142,10 @@ async function main(): Promise { socketManager, threadStore, }); + // F155: Frontend-facing guide actions (no MCP auth, uses userId header) + if (threadStore) { + await app.register(guideActionRoutes, { threadStore, socketManager }); + } await app.register(catsRoutes); // F149 Phase C: ACP pool diagnostics endpoint (gated by env flag) diff --git a/packages/api/src/routes/callback-guide-routes.ts b/packages/api/src/routes/callback-guide-routes.ts new file mode 100644 index 000000000..9004d18e8 --- /dev/null +++ b/packages/api/src/routes/callback-guide-routes.ts @@ -0,0 +1,340 @@ +/** + * F155: Guide Callback Routes + * POST /api/callbacks/update-guide-state — update guide session state (forward-only, non-start transitions) + * POST /api/callbacks/start-guide — start a guide (validates offered→active) + * POST /api/callbacks/guide-resolve — resolve user intent to matching guides + * POST /api/callbacks/guide-control — control an active guide (next/back/skip/exit) + */ + +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import type { InvocationRegistry } from '../domains/cats/services/agents/invocation/InvocationRegistry.js'; +import type { GuideStateV1, GuideStatus, IThreadStore } from '../domains/cats/services/stores/ports/ThreadStore.js'; +import { canAccessGuideState } from '../domains/guides/guide-state-access.js'; +import type { SocketManager } from '../infrastructure/websocket/index.js'; +import { callbackAuthSchema } from './callback-auth-schema.js'; +import { EXPIRED_CREDENTIALS_ERROR } from './callback-errors.js'; + +// --------------------------------------------------------------------------- +// State machine: valid transitions (forward-only DAG) +// --------------------------------------------------------------------------- +const VALID_TRANSITIONS: Record = { + offered: ['awaiting_choice', 'active', 'cancelled'], + awaiting_choice: ['active', 'cancelled'], + active: ['completed', 'cancelled'], + completed: [], + cancelled: [], +}; + +function isValidTransition(from: GuideStatus, to: GuideStatus): boolean { + return VALID_TRANSITIONS[from]?.includes(to) ?? false; +} + +const guideStatusSchema = z.enum(['offered', 'awaiting_choice', 'active', 'completed', 'cancelled']); + +// --------------------------------------------------------------------------- +// Schemas +// --------------------------------------------------------------------------- +const updateGuideStateSchema = callbackAuthSchema.extend({ + threadId: z.string().min(1), + guideId: z.string().min(1), + status: guideStatusSchema, + currentStep: z.number().int().min(0).optional(), +}); + +const startGuideSchema = callbackAuthSchema.extend({ + guideId: z.string().min(1), +}); + +const resolveGuideSchema = callbackAuthSchema.extend({ + intent: z.string().min(1), +}); + +const controlGuideSchema = callbackAuthSchema.extend({ + action: z.enum(['next', 'back', 'skip', 'exit']), +}); + +// --------------------------------------------------------------------------- +// Registration +// --------------------------------------------------------------------------- +export async function registerCallbackGuideRoutes( + app: FastifyInstance, + deps: { + registry: InvocationRegistry; + threadStore: IThreadStore; + socketManager: SocketManager; + loadGuideFlow?: (guideId: string) => unknown; + }, +): Promise { + const { registry, threadStore, socketManager } = deps; + const log = app.log; + + // Static ESM import — fail loudly if loader is broken + const { + isValidGuideId, + loadGuideFlow: defaultLoadGuideFlow, + resolveGuideForIntent, + } = await import('../domains/guides/guide-registry-loader.js'); + const loadGuideFlow = deps.loadGuideFlow ?? defaultLoadGuideFlow; + + // POST /api/callbacks/update-guide-state + app.post('/api/callbacks/update-guide-state', async (request, reply) => { + const parsed = updateGuideStateSchema.safeParse(request.body); + if (!parsed.success) { + reply.status(400); + return { error: 'Invalid request body', details: parsed.error.issues }; + } + + const { invocationId, callbackToken, threadId, guideId, status, currentStep } = parsed.data; + const record = registry.verify(invocationId, callbackToken); + if (!record) { + reply.status(401); + return EXPIRED_CREDENTIALS_ERROR; + } + + if (!registry.isLatest(invocationId)) { + return { status: 'stale_ignored' }; + } + + // Cross-thread binding check + if (record.threadId !== threadId) { + reply.status(403); + return { error: 'Cross-thread write rejected' }; + } + + const thread = await threadStore.get(threadId); + if (!thread) { + reply.status(404); + return { error: 'Thread not found' }; + } + + if (!isValidGuideId(guideId)) { + reply.status(400); + return { error: 'unknown_guide_id', message: `Guide "${guideId}" is not registered` }; + } + + const existing = thread.guideState; + const existingIsTerminal = existing?.status === 'completed' || existing?.status === 'cancelled'; + if (existing && !existingIsTerminal && !canAccessGuideState(thread, existing, record.userId)) { + reply.status(403); + return { error: 'Guide access denied' }; + } + + // First offer — no existing state + if (!existing) { + if (status !== 'offered') { + reply.status(400); + return { error: `Cannot create guide state with status "${status}" — must start as "offered"` }; + } + const newState: GuideStateV1 = { + v: 1, + guideId, + status: 'offered', + userId: record.userId, + offeredAt: Date.now(), + offeredBy: record.catId ?? undefined, + }; + await threadStore.updateGuideState(threadId, newState); + log.info({ guideId, threadId, catId: record.catId }, '[F155] guide state created: offered'); + return { guideState: newState }; + } + + // Guide ID mismatch — reject (one active guide per thread) + if (existing.guideId !== guideId) { + // Allow new offer only if previous guide is terminal + if (existing.status !== 'completed' && existing.status !== 'cancelled') { + reply.status(409); + return { + error: 'guide_conflict', + message: `Thread has active guide "${existing.guideId}" in status "${existing.status}" — complete or cancel it first`, + }; + } + // Previous guide is terminal, allow new offer + if (status !== 'offered') { + reply.status(400); + return { error: `Cannot create new guide state with status "${status}" — must start as "offered"` }; + } + const newState: GuideStateV1 = { + v: 1, + guideId, + status: 'offered', + userId: record.userId, + offeredAt: Date.now(), + offeredBy: record.catId ?? undefined, + }; + await threadStore.updateGuideState(threadId, newState); + log.info({ guideId, threadId }, '[F155] guide state replaced (previous was terminal)'); + return { guideState: newState }; + } + + // Same guide, terminal state — allow fresh re-offer + if ((existing.status === 'completed' || existing.status === 'cancelled') && status === 'offered') { + const newState: GuideStateV1 = { + v: 1, + guideId, + status: 'offered', + userId: record.userId, + offeredAt: Date.now(), + offeredBy: record.catId ?? undefined, + }; + await threadStore.updateGuideState(threadId, newState); + log.info({ guideId, threadId }, '[F155] guide re-offered after terminal state'); + return { guideState: newState }; + } + + if (status === 'active') { + reply.status(400); + return { + error: 'guide_start_required', + message: + 'Use /api/callbacks/start-guide to transition a pending guide to "active" so guide_start side effects run', + }; + } + + // Same guide — validate non-start state transition + if (!isValidTransition(existing.status, status)) { + reply.status(400); + return { + error: `Invalid guide transition: ${existing.status} → ${status}`, + validTransitions: VALID_TRANSITIONS[existing.status], + }; + } + + const updated: GuideStateV1 = { + ...existing, + status, + ...(status === 'completed' || status === 'cancelled' ? { completedAt: Date.now() } : {}), + ...(currentStep !== undefined ? { currentStep } : {}), + }; + await threadStore.updateGuideState(threadId, updated); + log.info({ guideId, threadId, transition: `${existing.status}→${status}` }, '[F155] guide state updated'); + return { guideState: updated }; + }); + + // POST /api/callbacks/start-guide — convenience route (validates offered/awaiting_choice → active) + app.post('/api/callbacks/start-guide', async (request, reply) => { + const parsed = startGuideSchema.safeParse(request.body); + if (!parsed.success) { + reply.status(400); + return { error: 'Invalid request', details: parsed.error.issues }; + } + const { invocationId, callbackToken, guideId } = parsed.data; + const record = registry.verify(invocationId, callbackToken); + if (!record) { + reply.status(401); + return EXPIRED_CREDENTIALS_ERROR; + } + if (!registry.isLatest(invocationId)) return { status: 'stale_ignored' }; + if (!isValidGuideId(guideId)) { + reply.status(400); + return { error: 'unknown_guide_id', message: `Guide "${guideId}" is not registered` }; + } + + // State validation: must be in offered or awaiting_choice + const thread = await threadStore.get(record.threadId); + const guideState = thread?.guideState; + if (!guideState || guideState.guideId !== guideId) { + reply.status(400); + return { + error: 'guide_not_offered', + message: `Guide "${guideId}" has not been offered in this thread — call update-guide-state first`, + }; + } + if (!canAccessGuideState(thread, guideState, record.userId)) { + reply.status(403); + return { error: 'Guide access denied' }; + } + if (guideState.status !== 'offered' && guideState.status !== 'awaiting_choice') { + reply.status(400); + return { + error: `Cannot start guide in status "${guideState.status}" — must be "offered" or "awaiting_choice"`, + }; + } + + try { + loadGuideFlow(guideId); + } catch (err) { + reply.status(400); + log.warn({ guideId, threadId: record.threadId, err }, '[F155] callback start rejected — flow not loadable'); + return { error: 'guide_flow_invalid', message: (err as Error).message }; + } + + // Transition to active + const updated: GuideStateV1 = { ...guideState, status: 'active', startedAt: Date.now() }; + await threadStore.updateGuideState(record.threadId, updated); + + // Guide UI events must stay user-scoped because the default thread is shared. + socketManager.emitToUser(record.userId, 'guide_start', { + guideId, + threadId: record.threadId, + timestamp: Date.now(), + }); + log.info({ guideId, threadId: record.threadId }, '[F155] guide started (state: active)'); + return { status: 'ok', guideId, guideState: updated }; + }); + + // POST /api/callbacks/guide-resolve + app.post('/api/callbacks/guide-resolve', async (request, reply) => { + const parsed = resolveGuideSchema.safeParse(request.body); + if (!parsed.success) { + reply.status(400); + return { error: 'Invalid request', details: parsed.error.issues }; + } + const { invocationId, callbackToken, intent } = parsed.data; + const record = registry.verify(invocationId, callbackToken); + if (!record) { + reply.status(401); + return EXPIRED_CREDENTIALS_ERROR; + } + + const matches = resolveGuideForIntent(intent); + log.info({ intent, matchCount: matches.length, threadId: record.threadId }, '[F155] guide_resolve'); + return { status: 'ok', matches }; + }); + + // POST /api/callbacks/guide-control — validates active state + app.post('/api/callbacks/guide-control', async (request, reply) => { + const parsed = controlGuideSchema.safeParse(request.body); + if (!parsed.success) { + reply.status(400); + return { error: 'Invalid request', details: parsed.error.issues }; + } + const { invocationId, callbackToken, action } = parsed.data; + const record = registry.verify(invocationId, callbackToken); + if (!record) { + reply.status(401); + return EXPIRED_CREDENTIALS_ERROR; + } + if (!registry.isLatest(invocationId)) return { status: 'stale_ignored' }; + + // State validation: must have active guide + const thread = await threadStore.get(record.threadId); + const guideState = thread?.guideState; + if (!guideState || guideState.status !== 'active') { + reply.status(400); + return { + error: 'no_active_guide', + message: `No active guide in thread — current status: ${guideState?.status ?? 'none'}`, + }; + } + if (!canAccessGuideState(thread, guideState, record.userId)) { + reply.status(403); + return { error: 'Guide access denied' }; + } + + // Exit action → cancel guide + if (action === 'exit') { + const updated: GuideStateV1 = { ...guideState, status: 'cancelled', completedAt: Date.now() }; + await threadStore.updateGuideState(record.threadId, updated); + } + + socketManager.emitToUser(record.userId, 'guide_control', { + action, + guideId: guideState.guideId, + threadId: record.threadId, + timestamp: Date.now(), + }); + log.info({ action, guideId: guideState.guideId, threadId: record.threadId }, '[F155] guide_control'); + return { status: 'ok', action }; + }); +} diff --git a/packages/api/src/routes/callbacks.ts b/packages/api/src/routes/callbacks.ts index 825d70622..6e42ab8bb 100644 --- a/packages/api/src/routes/callbacks.ts +++ b/packages/api/src/routes/callbacks.ts @@ -34,6 +34,7 @@ import { registerCallbackBootcampRoutes } from './callback-bootcamp-routes.js'; import { registerCallbackDocumentRoutes } from './callback-document-routes.js'; import { EXPIRED_CREDENTIALS_ERROR } from './callback-errors.js'; import { registerCallbackGameRoutes } from './callback-game-routes.js'; +import { registerCallbackGuideRoutes } from './callback-guide-routes.js'; import { registerCallbackLimbRoutes } from './callback-limb-routes.js'; import { registerCallbackMemoryRoutes } from './callback-memory-routes.js'; import { getMultiMentionOrchestrator, registerMultiMentionRoutes } from './callback-multi-mention-routes.js'; @@ -50,6 +51,8 @@ export interface CallbackRoutesOptions { registry: InvocationRegistry; messageStore: IMessageStore; socketManager: SocketManager; + /** F155 review fix: allow tests to inject a failing guide flow loader. */ + loadGuideFlow?: (guideId: string) => unknown; taskStore?: ITaskStore; backlogStore?: IBacklogStore; /** For thinking mode filtering in thread-context + thread-cats discovery */ @@ -202,6 +205,13 @@ const richBlockSchema = z.discriminatedUnion('kind', [ group: z.string().optional(), customInput: z.boolean().optional(), customInputPlaceholder: z.string().optional(), + action: z + .object({ + type: z.literal('callback'), + endpoint: z.string().min(1), + payload: z.record(z.unknown()).optional(), + }) + .optional(), }), ) .min(1), @@ -1349,4 +1359,14 @@ export const callbacksRoutes: FastifyPluginAsync = async // F101: Game action callback for non-Claude cats (OpenCode/Codex/Gemini) registerCallbackGameRoutes(app, { registry }); + + // F155: Guide engine — state-validated routes with ThreadStore authority + if (opts.threadStore) { + await registerCallbackGuideRoutes(app, { + registry, + threadStore: opts.threadStore, + socketManager, + ...(opts.loadGuideFlow ? { loadGuideFlow: opts.loadGuideFlow } : {}), + }); + } }; diff --git a/packages/api/src/routes/guide-action-routes.ts b/packages/api/src/routes/guide-action-routes.ts new file mode 100644 index 000000000..ff1f6ec2d --- /dev/null +++ b/packages/api/src/routes/guide-action-routes.ts @@ -0,0 +1,239 @@ +/** + * F155: Frontend-Facing Guide Action Routes + * + * These endpoints are called directly by the frontend InteractiveBlock + * when a user clicks a guide option with an `action` field. + * They use userId-based auth (X-Cat-Cafe-User header), NOT MCP callback auth. + * + * POST /api/guide-actions/start — start a guide (offered/awaiting_choice → active) + * POST /api/guide-actions/cancel — cancel a guide (offered/awaiting_choice → cancelled) + */ +import type { FastifyPluginAsync } from 'fastify'; +import { z } from 'zod'; +import { + DEFAULT_THREAD_ID, + type GuideStateV1, + type IThreadStore, +} from '../domains/cats/services/stores/ports/ThreadStore.js'; +import { loadGuideFlow } from '../domains/guides/guide-registry-loader.js'; +import { canAccessGuideState } from '../domains/guides/guide-state-access.js'; +import type { SocketManager } from '../infrastructure/websocket/index.js'; +import { resolveHeaderUserId } from '../utils/request-identity.js'; + +export interface GuideActionRoutesOptions { + threadStore: IThreadStore; + socketManager: SocketManager; +} + +const startSchema = z.object({ + threadId: z.string().min(1), + guideId: z.string().min(1), +}); + +const cancelSchema = z.object({ + threadId: z.string().min(1), + guideId: z.string().min(1), +}); + +const completeSchema = z.object({ + threadId: z.string().min(1), + guideId: z.string().min(1), +}); + +function canAccessGuideThread(thread: { id: string; createdBy: string } | null, userId: string): boolean { + if (!thread) return false; + return thread.createdBy === userId || (thread.id === DEFAULT_THREAD_ID && thread.createdBy === 'system'); +} + +export const guideActionRoutes: FastifyPluginAsync = async (app, opts) => { + const { threadStore, socketManager } = opts; + const log = app.log; + + // POST /api/guide-actions/start — frontend clicks "开始引导" + app.post('/api/guide-actions/start', async (request, reply) => { + const userId = resolveHeaderUserId(request); + if (!userId) { + reply.status(401); + return { error: 'Identity required (X-Cat-Cafe-User header)' }; + } + + const parsed = startSchema.safeParse(request.body); + if (!parsed.success) { + reply.status(400); + return { error: 'Invalid request', details: parsed.error.issues }; + } + + const { threadId, guideId } = parsed.data; + const thread = await threadStore.get(threadId); + if (!thread) { + reply.status(404); + return { error: 'Thread not found' }; + } + + if (!canAccessGuideThread(thread, userId)) { + reply.status(403); + return { error: 'Thread access denied' }; + } + + const gs = thread.guideState; + if (!gs || gs.guideId !== guideId) { + reply.status(400); + return { error: 'guide_not_offered', message: `Guide "${guideId}" not offered in this thread` }; + } + if (!canAccessGuideState(thread, gs, userId)) { + reply.status(403); + return { error: 'Guide access denied' }; + } + if (gs.status !== 'offered' && gs.status !== 'awaiting_choice') { + reply.status(400); + return { error: `Cannot start guide in status "${gs.status}"` }; + } + + // P1 fix: validate flow is loadable before committing state transition + try { + loadGuideFlow(guideId); + } catch (err) { + log.warn({ guideId, threadId, err }, '[F155] start rejected — flow not loadable'); + reply.status(400); + return { error: 'guide_flow_invalid', message: (err as Error).message }; + } + + const updated: GuideStateV1 = { ...gs, status: 'active', startedAt: Date.now() }; + await threadStore.updateGuideState(threadId, updated); + + // Guide UI events must stay user-scoped because the default thread is shared. + socketManager.emitToUser(userId, 'guide_start', { + guideId, + threadId, + timestamp: Date.now(), + }); + log.info({ guideId, threadId, userId }, '[F155] guide started via frontend action'); + return { status: 'ok', guideId, guideState: updated }; + }); + + // GET /api/guide-flows/:guideId — serve flow definition at runtime + app.get<{ Params: { guideId: string } }>('/api/guide-flows/:guideId', async (request, reply) => { + const userId = resolveHeaderUserId(request); + if (!userId) { + reply.status(401); + return { error: 'Identity required (X-Cat-Cafe-User header)' }; + } + + const { guideId } = request.params; + try { + const flow = loadGuideFlow(guideId); + return flow; + } catch (err) { + log.warn({ guideId, err }, '[F155] Failed to load guide flow'); + reply.status(404); + return { error: 'guide_not_found', message: (err as Error).message }; + } + }); + + // POST /api/guide-actions/cancel — frontend clicks "暂不需要" + app.post('/api/guide-actions/cancel', async (request, reply) => { + const userId = resolveHeaderUserId(request); + if (!userId) { + reply.status(401); + return { error: 'Identity required (X-Cat-Cafe-User header)' }; + } + + const parsed = cancelSchema.safeParse(request.body); + if (!parsed.success) { + reply.status(400); + return { error: 'Invalid request', details: parsed.error.issues }; + } + + const { threadId, guideId } = parsed.data; + const thread = await threadStore.get(threadId); + if (!thread) { + reply.status(404); + return { error: 'Thread not found' }; + } + + if (!canAccessGuideThread(thread, userId)) { + reply.status(403); + return { error: 'Thread access denied' }; + } + + const gs = thread.guideState; + if (!gs || gs.guideId !== guideId) { + reply.status(400); + return { error: 'guide_not_offered', message: `Guide "${guideId}" not offered in this thread` }; + } + if (!canAccessGuideState(thread, gs, userId)) { + reply.status(403); + return { error: 'Guide access denied' }; + } + if (gs.status === 'completed' || gs.status === 'cancelled') { + return { status: 'ok', guideState: gs }; + } + + const updated: GuideStateV1 = { ...gs, status: 'cancelled', completedAt: Date.now() }; + await threadStore.updateGuideState(threadId, updated); + + socketManager.emitToUser(userId, 'guide_control', { + action: 'exit', + guideId, + threadId, + timestamp: Date.now(), + }); + log.info({ guideId, threadId, userId }, '[F155] guide cancelled via frontend action'); + return { status: 'ok', guideState: updated }; + }); + + // POST /api/guide-actions/complete — frontend guide overlay finished all steps + app.post('/api/guide-actions/complete', async (request, reply) => { + const userId = resolveHeaderUserId(request); + if (!userId) { + reply.status(401); + return { error: 'Identity required (X-Cat-Cafe-User header)' }; + } + + const parsed = completeSchema.safeParse(request.body); + if (!parsed.success) { + reply.status(400); + return { error: 'Invalid request', details: parsed.error.issues }; + } + + const { threadId, guideId } = parsed.data; + const thread = await threadStore.get(threadId); + if (!thread) { + reply.status(404); + return { error: 'Thread not found' }; + } + + if (!canAccessGuideThread(thread, userId)) { + reply.status(403); + return { error: 'Thread access denied' }; + } + + const gs = thread.guideState; + if (!gs || gs.guideId !== guideId) { + reply.status(400); + return { error: 'guide_not_active', message: `Guide "${guideId}" not active in this thread` }; + } + if (!canAccessGuideState(thread, gs, userId)) { + reply.status(403); + return { error: 'Guide access denied' }; + } + if (gs.status === 'completed') { + return { status: 'ok', guideState: gs }; + } + if (gs.status !== 'active') { + reply.status(400); + return { error: `Cannot complete guide in status "${gs.status}"` }; + } + + const updated: GuideStateV1 = { ...gs, status: 'completed', completedAt: Date.now() }; + await threadStore.updateGuideState(threadId, updated); + + socketManager.emitToUser(userId, 'guide_complete', { + guideId, + threadId, + timestamp: Date.now(), + }); + log.info({ guideId, threadId, userId }, '[F155] guide completed via frontend action'); + return { status: 'ok', guideId, guideState: updated }; + }); +}; diff --git a/packages/api/src/routes/index.ts b/packages/api/src/routes/index.ts index d8d03547b..ec72817b1 100644 --- a/packages/api/src/routes/index.ts +++ b/packages/api/src/routes/index.ts @@ -22,6 +22,7 @@ export { externalProjectRoutes } from './external-projects.js'; export { featureDocDetailRoutes } from './feature-doc-detail.js'; export { gameRoutes } from './games.js'; export { governanceStatusRoute } from './governance-status.js'; +export { guideActionRoutes } from './guide-action-routes.js'; export { intentCardRoutes } from './intent-card-routes.js'; export { invocationsRoutes } from './invocations.js'; export { leaderboardRoutes } from './leaderboard.js'; diff --git a/packages/api/src/routes/session-chain.ts b/packages/api/src/routes/session-chain.ts index 79501acbd..6ca48b667 100644 --- a/packages/api/src/routes/session-chain.ts +++ b/packages/api/src/routes/session-chain.ts @@ -17,7 +17,7 @@ import type { ISessionSealer } from '../domains/cats/services/session/SessionSea import type { TranscriptReader } from '../domains/cats/services/session/TranscriptReader.js'; import type { IMessageStore } from '../domains/cats/services/stores/ports/MessageStore.js'; import type { ISessionChainStore } from '../domains/cats/services/stores/ports/SessionChainStore.js'; -import type { IThreadStore } from '../domains/cats/services/stores/ports/ThreadStore.js'; +import { DEFAULT_THREAD_ID, type IThreadStore } from '../domains/cats/services/stores/ports/ThreadStore.js'; import { resolveUserId } from '../utils/request-identity.js'; const bindSessionSchema = z.object({ @@ -32,6 +32,27 @@ interface SessionChainRouteOptions extends FastifyPluginOptions { sessionSealer?: ISessionSealer; } +function canAccessThread(thread: { id: string; createdBy: string } | null, userId: string): boolean { + if (!thread) return false; + if (thread.createdBy === userId) return true; + // Only the real default thread is globally readable/writable. + return thread.id === DEFAULT_THREAD_ID && thread.createdBy === 'system'; +} + +function isSharedDefaultThread(thread: { id: string; createdBy: string } | null): boolean { + return Boolean(thread && thread.id === DEFAULT_THREAD_ID && thread.createdBy === 'system'); +} + +function canAccessSessionRecord( + thread: { id: string; createdBy: string } | null, + session: { userId: string } | null, + userId: string, +): boolean { + if (!thread || !session) return false; + if (thread.createdBy === userId) return true; + return isSharedDefaultThread(thread) && session.userId === userId; +} + export async function sessionChainRoutes(app: FastifyInstance, opts: SessionChainRouteOptions): Promise { const { sessionChainStore, threadStore, messageStore, transcriptReader, sessionSealer } = opts; @@ -47,7 +68,7 @@ export async function sessionChainRoutes(app: FastifyInstance, opts: SessionChai const { threadId } = request.params; const thread = await threadStore.get(threadId); - if (!thread || thread.createdBy !== userId) { + if (!canAccessThread(thread, userId)) { reply.status(403); return { error: 'Access denied' }; } @@ -65,12 +86,18 @@ export async function sessionChainRoutes(app: FastifyInstance, opts: SessionChai return { error: `Cannot query sessions for cat '${catId}' — you are '${callerCatId}'` }; } const sessions = await sessionChainStore.getChain(effectiveCatId as CatId, threadId); - return reply.send({ sessions }); + const visibleSessions = isSharedDefaultThread(thread) + ? sessions.filter((session) => session.userId === userId) + : sessions; + return reply.send({ sessions: visibleSessions }); } - // No catId filter at all (hub UI god-view) — return all sessions for the thread + // No catId filter at all (hub UI god-view) — default thread stays user-scoped. const sessions = await sessionChainStore.getChainByThread(threadId); - return reply.send({ sessions }); + const visibleSessions = isSharedDefaultThread(thread) + ? sessions.filter((session) => session.userId === userId) + : sessions; + return reply.send({ sessions: visibleSessions }); }); app.get<{ @@ -94,7 +121,7 @@ export async function sessionChainRoutes(app: FastifyInstance, opts: SessionChai reply.status(404); return { error: 'Thread not found' }; } - if (thread.createdBy !== userId) { + if (!canAccessThread(thread, userId) || !canAccessSessionRecord(thread, session, userId)) { reply.status(403); return { error: 'Access denied' }; } @@ -125,7 +152,7 @@ export async function sessionChainRoutes(app: FastifyInstance, opts: SessionChai reply.status(404); return { error: 'Thread not found' }; } - if (thread.createdBy !== userId) { + if (!canAccessThread(thread, userId) || !canAccessSessionRecord(thread, session, userId)) { reply.status(403); return { error: 'Access denied' }; } @@ -246,13 +273,17 @@ export async function sessionChainRoutes(app: FastifyInstance, opts: SessionChai reply.status(404); return { error: 'Thread not found' }; } - if (thread.createdBy !== userId) { + if (!canAccessThread(thread, userId)) { reply.status(403); return { error: 'Access denied' }; } // Check for active session const active = await sessionChainStore.getActive(catId as CatId, threadId); + if (active && !canAccessSessionRecord(thread, active, userId)) { + reply.status(403); + return { error: 'Access denied' }; + } let session; let mode: 'updated' | 'created'; diff --git a/packages/api/test/callback-guide-routes.test.js b/packages/api/test/callback-guide-routes.test.js new file mode 100644 index 000000000..089bbce30 --- /dev/null +++ b/packages/api/test/callback-guide-routes.test.js @@ -0,0 +1,285 @@ +/** + * F155: Guide engine callback route tests + * Tests: start-guide, guide-resolve, guide-control + */ + +import assert from 'node:assert/strict'; +import { beforeEach, describe, test } from 'node:test'; +import Fastify from 'fastify'; +import './helpers/setup-cat-registry.js'; + +describe('F155 Guide callback routes', () => { + let registry; + let messageStore; + let threadStore; + let socketManager; + let broadcasts; + let emits; + + beforeEach(async () => { + const { InvocationRegistry } = await import( + '../dist/domains/cats/services/agents/invocation/InvocationRegistry.js' + ); + const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); + const { ThreadStore } = await import('../dist/domains/cats/services/stores/ports/ThreadStore.js'); + + registry = new InvocationRegistry(); + messageStore = new MessageStore(); + threadStore = new ThreadStore(); + broadcasts = []; + emits = []; + + socketManager = { + broadcastAgentMessage() {}, + broadcastToRoom(room, event, data) { + broadcasts.push({ room, event, data }); + }, + emitToUser(userId, event, data) { + emits.push({ userId, event, data }); + }, + }; + }); + + async function createApp(overrides = {}) { + const { callbacksRoutes } = await import('../dist/routes/callbacks.js'); + const app = Fastify(); + await app.register(callbacksRoutes, { + registry, + messageStore, + socketManager, + threadStore, + ...overrides, + }); + return app; + } + + function createCreds() { + const thread = threadStore.create('user-1', 'Test'); + const { invocationId, callbackToken } = registry.create('user-1', 'opus', thread.id); + return { invocationId, callbackToken, threadId: thread.id }; + } + + async function seedGuideState(threadId, guideId, status) { + await threadStore.updateGuideState(threadId, { + v: 1, + guideId, + status, + offeredAt: Date.now(), + ...(status === 'active' ? { startedAt: Date.now() } : {}), + }); + } + + // ─── start-guide ─── + + describe('POST /api/callbacks/start-guide', () => { + test('starts guide with valid guideId', async () => { + const app = await createApp(); + const { invocationId, callbackToken, threadId } = createCreds(); + await seedGuideState(threadId, 'add-member', 'offered'); + + const res = await app.inject({ + method: 'POST', + url: '/api/callbacks/start-guide', + payload: { invocationId, callbackToken, guideId: 'add-member' }, + }); + + assert.equal(res.statusCode, 200); + const body = JSON.parse(res.body); + assert.equal(body.status, 'ok'); + assert.equal(body.guideId, 'add-member'); + + assert.equal(broadcasts.length, 0); + assert.deepEqual(emits, [ + { + userId: 'user-1', + event: 'guide_start', + data: { + guideId: 'add-member', + threadId, + timestamp: emits[0].data.timestamp, + }, + }, + ]); + assert.equal(typeof emits[0].data.timestamp, 'number'); + }); + + test('rejects unknown guideId', async () => { + const app = await createApp(); + const { invocationId, callbackToken } = createCreds(); + + const res = await app.inject({ + method: 'POST', + url: '/api/callbacks/start-guide', + payload: { invocationId, callbackToken, guideId: 'nonexistent-flow' }, + }); + + assert.equal(res.statusCode, 400); + const body = JSON.parse(res.body); + assert.equal(body.error, 'unknown_guide_id'); + assert.equal(broadcasts.length, 0); + }); + + test('rejects expired credentials', async () => { + const app = await createApp(); + + const res = await app.inject({ + method: 'POST', + url: '/api/callbacks/start-guide', + payload: { invocationId: 'fake', callbackToken: 'fake', guideId: 'add-member' }, + }); + + assert.equal(res.statusCode, 401); + assert.equal(broadcasts.length, 0); + }); + + test('returns stale_ignored for non-latest invocation', async () => { + const app = await createApp(); + const { invocationId, callbackToken, threadId } = createCreds(); + // Create a newer invocation to make the first one stale + registry.create('user-1', 'opus', threadId); + + const res = await app.inject({ + method: 'POST', + url: '/api/callbacks/start-guide', + payload: { invocationId, callbackToken, guideId: 'add-member' }, + }); + + const body = JSON.parse(res.body); + assert.equal(body.status, 'stale_ignored'); + assert.equal(broadcasts.length, 0); + }); + + test('rejects callback start when guide flow is not loadable', async () => { + const app = await createApp({ + loadGuideFlow() { + throw new Error('broken flow yaml'); + }, + }); + const { invocationId, callbackToken, threadId } = createCreds(); + await seedGuideState(threadId, 'add-member', 'offered'); + + const res = await app.inject({ + method: 'POST', + url: '/api/callbacks/start-guide', + payload: { invocationId, callbackToken, guideId: 'add-member' }, + }); + + assert.equal(res.statusCode, 400); + const body = JSON.parse(res.body); + assert.equal(body.error, 'guide_flow_invalid'); + assert.equal(body.message, 'broken flow yaml'); + assert.equal((await threadStore.get(threadId)).guideState.status, 'offered'); + assert.equal(broadcasts.length, 0); + }); + }); + + // ─── guide-resolve ─── + + describe('POST /api/callbacks/guide-resolve', () => { + test('resolves matching intent', async () => { + const app = await createApp(); + const { invocationId, callbackToken } = createCreds(); + + const res = await app.inject({ + method: 'POST', + url: '/api/callbacks/guide-resolve', + payload: { invocationId, callbackToken, intent: '添加成员' }, + }); + + assert.equal(res.statusCode, 200); + const body = JSON.parse(res.body); + assert.equal(body.status, 'ok'); + assert.ok(body.matches.length > 0); + assert.equal(body.matches[0].id, 'add-member'); + }); + + test('returns empty matches for unrelated intent', async () => { + const app = await createApp(); + const { invocationId, callbackToken } = createCreds(); + + const res = await app.inject({ + method: 'POST', + url: '/api/callbacks/guide-resolve', + payload: { invocationId, callbackToken, intent: '天气预报' }, + }); + + assert.equal(res.statusCode, 200); + const body = JSON.parse(res.body); + assert.equal(body.status, 'ok'); + assert.equal(body.matches.length, 0); + }); + + test('rejects expired credentials', async () => { + const app = await createApp(); + + const res = await app.inject({ + method: 'POST', + url: '/api/callbacks/guide-resolve', + payload: { invocationId: 'fake', callbackToken: 'fake', intent: '添加' }, + }); + + assert.equal(res.statusCode, 401); + }); + }); + + // ─── guide-control ─── + + describe('POST /api/callbacks/guide-control', () => { + test('emits control action to the invocation user with valid credentials', async () => { + const app = await createApp(); + const { invocationId, callbackToken, threadId } = createCreds(); + await seedGuideState(threadId, 'add-member', 'active'); + + const res = await app.inject({ + method: 'POST', + url: '/api/callbacks/guide-control', + payload: { invocationId, callbackToken, action: 'next' }, + }); + + assert.equal(res.statusCode, 200); + const body = JSON.parse(res.body); + assert.equal(body.status, 'ok'); + assert.equal(body.action, 'next'); + assert.equal(broadcasts.length, 0); + assert.deepEqual(emits, [ + { + userId: 'user-1', + event: 'guide_control', + data: { + action: 'next', + guideId: 'add-member', + threadId, + timestamp: emits[0].data.timestamp, + }, + }, + ]); + assert.equal(typeof emits[0].data.timestamp, 'number'); + }); + + test('rejects invalid action', async () => { + const app = await createApp(); + const { invocationId, callbackToken } = createCreds(); + + const res = await app.inject({ + method: 'POST', + url: '/api/callbacks/guide-control', + payload: { invocationId, callbackToken, action: 'destroy' }, + }); + + assert.equal(res.statusCode, 400); + }); + + test('rejects expired credentials', async () => { + const app = await createApp(); + + const res = await app.inject({ + method: 'POST', + url: '/api/callbacks/guide-control', + payload: { invocationId: 'fake', callbackToken: 'fake', action: 'next' }, + }); + + assert.equal(res.statusCode, 401); + assert.equal(broadcasts.length, 0); + }); + }); +}); diff --git a/packages/api/test/callback-guide-state.test.js b/packages/api/test/callback-guide-state.test.js new file mode 100644 index 000000000..ee2c649f9 --- /dev/null +++ b/packages/api/test/callback-guide-state.test.js @@ -0,0 +1,533 @@ +/** + * F155: Guide State Callback Tests + * POST /api/callbacks/update-guide-state + * POST /api/callbacks/start-guide + * POST /api/callbacks/guide-control + * + * Tests forward-only state machine, multi-cat dedup, and authorization. + */ + +import assert from 'node:assert/strict'; +import { beforeEach, describe, test } from 'node:test'; +import Fastify from 'fastify'; +import './helpers/setup-cat-registry.js'; + +describe('F155 Guide State Callbacks', () => { + let registry; + let threadStore; + let messageStore; + let socketManager; + let broadcastCalls; + let emitCalls; + + beforeEach(async () => { + const { InvocationRegistry } = await import( + '../dist/domains/cats/services/agents/invocation/InvocationRegistry.js' + ); + const { ThreadStore } = await import('../dist/domains/cats/services/stores/ports/ThreadStore.js'); + const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); + + registry = new InvocationRegistry(); + threadStore = new ThreadStore(); + messageStore = new MessageStore(); + broadcastCalls = []; + emitCalls = []; + socketManager = { + broadcastAgentMessage() {}, + broadcastToRoom(room, event, data) { + broadcastCalls.push({ room, event, data }); + }, + emitToUser(userId, event, data) { + emitCalls.push({ userId, event, data }); + }, + getMessages() { + return []; + }, + }; + }); + + async function createApp() { + const { callbacksRoutes } = await import('../dist/routes/callbacks.js'); + const { leaderboardEventsRoutes } = await import('../dist/routes/leaderboard-events.js'); + const { GameStore } = await import('../dist/domains/leaderboard/game-store.js'); + const { AchievementStore } = await import('../dist/domains/leaderboard/achievement-store.js'); + const app = Fastify(); + await app.register(callbacksRoutes, { + registry, + messageStore, + socketManager, + threadStore, + sharedBank: 'cat-cafe-shared', + }); + await app.register(leaderboardEventsRoutes, { + gameStore: new GameStore(), + achievementStore: new AchievementStore(), + }); + return app; + } + + async function seedDefaultThread(guideId, status, userId = 'default-user') { + const thread = await threadStore.get('default'); + await threadStore.updateGuideState(thread.id, { + v: 1, + guideId, + status, + offeredAt: Date.now(), + ...(status === 'active' ? { startedAt: Date.now() } : {}), + userId, + }); + return thread; + } + + // --- update-guide-state --- + + test('creates initial guide state as "offered"', async () => { + const app = await createApp(); + const thread = await threadStore.create('user-1', 'test-thread'); + const { invocationId, callbackToken } = registry.create('user-1', 'opus', thread.id); + + const res = await app.inject({ + method: 'POST', + url: '/api/callbacks/update-guide-state', + payload: { invocationId, callbackToken, threadId: thread.id, guideId: 'add-member', status: 'offered' }, + }); + + assert.equal(res.statusCode, 200); + const body = JSON.parse(res.body); + assert.equal(body.guideState.guideId, 'add-member'); + assert.equal(body.guideState.status, 'offered'); + assert.ok(body.guideState.offeredAt > 0); + }); + + test('rejects initial state that is not "offered"', async () => { + const app = await createApp(); + const thread = await threadStore.create('user-1', 'test-thread'); + const { invocationId, callbackToken } = registry.create('user-1', 'opus', thread.id); + + const res = await app.inject({ + method: 'POST', + url: '/api/callbacks/update-guide-state', + payload: { invocationId, callbackToken, threadId: thread.id, guideId: 'add-member', status: 'active' }, + }); + + assert.equal(res.statusCode, 400); + }); + + test('update-guide-state rejects active transition and requires start-guide side effects', async () => { + const app = await createApp(); + const thread = await threadStore.create('user-1', 'test-thread'); + const { invocationId, callbackToken } = registry.create('user-1', 'opus', thread.id); + + await threadStore.updateGuideState(thread.id, { + v: 1, + guideId: 'add-member', + status: 'offered', + offeredAt: 1000, + }); + + const res = await app.inject({ + method: 'POST', + url: '/api/callbacks/update-guide-state', + payload: { invocationId, callbackToken, threadId: thread.id, guideId: 'add-member', status: 'active' }, + }); + + assert.equal(res.statusCode, 400); + const body = JSON.parse(res.body); + assert.equal(body.error, 'guide_start_required'); + assert.match(body.message, /start-guide/i); + + const stored = await threadStore.get(thread.id); + assert.equal(stored.guideState.status, 'offered'); + assert.equal(stored.guideState.offeredAt, 1000); + assert.deepEqual(emitCalls, []); + assert.deepEqual(broadcastCalls, []); + }); + + test('rejects invalid backward transition: active → offered', async () => { + const app = await createApp(); + const thread = await threadStore.create('user-1', 'test-thread'); + const { invocationId, callbackToken } = registry.create('user-1', 'opus', thread.id); + + await threadStore.updateGuideState(thread.id, { + v: 1, + guideId: 'add-member', + status: 'active', + offeredAt: 1000, + startedAt: 2000, + }); + + const res = await app.inject({ + method: 'POST', + url: '/api/callbacks/update-guide-state', + payload: { invocationId, callbackToken, threadId: thread.id, guideId: 'add-member', status: 'offered' }, + }); + + assert.equal(res.statusCode, 400); + const body = JSON.parse(res.body); + assert.ok(body.error.includes('Invalid guide transition')); + }); + + test('rejects re-offering same guide when it is still active', async () => { + const app = await createApp(); + const thread = await threadStore.create('user-1', 'test-thread'); + const { invocationId, callbackToken } = registry.create('user-1', 'opus', thread.id); + + await threadStore.updateGuideState(thread.id, { + v: 1, + guideId: 'add-member', + status: 'active', + offeredAt: 1000, + startedAt: 2000, + }); + + // Trying to go back to 'offered' is an invalid backward transition + const res = await app.inject({ + method: 'POST', + url: '/api/callbacks/update-guide-state', + payload: { invocationId, callbackToken, threadId: thread.id, guideId: 'add-member', status: 'offered' }, + }); + + assert.equal(res.statusCode, 400); + assert.ok(JSON.parse(res.body).error.includes('Invalid guide transition')); + }); + + test('allows re-offering same guide after it was completed', async () => { + const app = await createApp(); + const thread = await threadStore.create('user-1', 'test-thread'); + const { invocationId, callbackToken } = registry.create('user-1', 'opus', thread.id); + + await threadStore.updateGuideState(thread.id, { + v: 1, + guideId: 'add-member', + status: 'completed', + offeredAt: 1000, + completedAt: 3000, + }); + + // completed is terminal; completed→offered is not a normal transition, + // but the route should treat "same guideId + terminal state" as a new offer + const res = await app.inject({ + method: 'POST', + url: '/api/callbacks/update-guide-state', + payload: { invocationId, callbackToken, threadId: thread.id, guideId: 'add-member', status: 'offered' }, + }); + + assert.equal(res.statusCode, 200); + assert.equal(JSON.parse(res.body).guideState.guideId, 'add-member'); + assert.equal(JSON.parse(res.body).guideState.status, 'offered'); + }); + + test('rejects cross-thread write', async () => { + const app = await createApp(); + const thread1 = await threadStore.create('user-1', 'thread-1'); + const thread2 = await threadStore.create('user-1', 'thread-2'); + const { invocationId, callbackToken } = registry.create('user-1', 'opus', thread1.id); + + const res = await app.inject({ + method: 'POST', + url: '/api/callbacks/update-guide-state', + payload: { invocationId, callbackToken, threadId: thread2.id, guideId: 'add-member', status: 'offered' }, + }); + + assert.equal(res.statusCode, 403); + }); + + test('returns 401 for invalid credentials', async () => { + const app = await createApp(); + + const res = await app.inject({ + method: 'POST', + url: '/api/callbacks/update-guide-state', + payload: { + invocationId: 'fake', + callbackToken: 'fake', + threadId: 't1', + guideId: 'add-member', + status: 'offered', + }, + }); + + assert.equal(res.statusCode, 401); + }); + + test('update-guide-state rejects cross-user mutation on system-owned default thread', async () => { + const app = await createApp(); + const thread = await seedDefaultThread('add-member', 'offered', 'guide-owner'); + const { invocationId, callbackToken } = registry.create('attacker-user', 'opus', thread.id); + + const res = await app.inject({ + method: 'POST', + url: '/api/callbacks/update-guide-state', + payload: { invocationId, callbackToken, threadId: thread.id, guideId: 'add-member', status: 'active' }, + }); + + assert.equal(res.statusCode, 403); + const stored = await threadStore.get(thread.id); + assert.equal(stored.guideState.status, 'offered'); + assert.equal(stored.guideState.userId, 'guide-owner'); + }); + + // --- start-guide --- + + test('start-guide transitions from offered → active and emits socket event', async () => { + const app = await createApp(); + const thread = await threadStore.create('user-1', 'test-thread'); + const { invocationId, callbackToken } = registry.create('user-1', 'opus', thread.id); + + await threadStore.updateGuideState(thread.id, { + v: 1, + guideId: 'add-member', + status: 'offered', + offeredAt: 1000, + }); + + const res = await app.inject({ + method: 'POST', + url: '/api/callbacks/start-guide', + payload: { invocationId, callbackToken, guideId: 'add-member' }, + }); + + assert.equal(res.statusCode, 200); + const body = JSON.parse(res.body); + assert.equal(body.guideState.status, 'active'); + + assert.equal( + broadcastCalls.find((c) => c.event === 'guide_start'), + undefined, + 'guide_start should be user-scoped', + ); + assert.deepEqual(emitCalls, [ + { + userId: 'user-1', + event: 'guide_start', + data: { + guideId: 'add-member', + threadId: thread.id, + timestamp: emitCalls[0].data.timestamp, + }, + }, + ]); + assert.equal(typeof emitCalls[0].data.timestamp, 'number'); + }); + + test('start-guide emits guide_start only to the guide owner on shared default thread', async () => { + const app = await createApp(); + const thread = await seedDefaultThread('add-member', 'offered', 'guide-owner'); + const { invocationId, callbackToken } = registry.create('guide-owner', 'opus', thread.id); + + const res = await app.inject({ + method: 'POST', + url: '/api/callbacks/start-guide', + payload: { invocationId, callbackToken, guideId: 'add-member' }, + }); + + assert.equal(res.statusCode, 200); + assert.equal( + broadcastCalls.find((c) => c.event === 'guide_start'), + undefined, + 'shared default-thread guide_start must not use room broadcast', + ); + assert.deepEqual(emitCalls, [ + { + userId: 'guide-owner', + event: 'guide_start', + data: { + guideId: 'add-member', + threadId: thread.id, + timestamp: emitCalls[0].data.timestamp, + }, + }, + ]); + assert.equal(typeof emitCalls[0].data.timestamp, 'number'); + }); + + test('start-guide rejects when no guide is offered', async () => { + const app = await createApp(); + const thread = await threadStore.create('user-1', 'test-thread'); + const { invocationId, callbackToken } = registry.create('user-1', 'opus', thread.id); + + const res = await app.inject({ + method: 'POST', + url: '/api/callbacks/start-guide', + payload: { invocationId, callbackToken, guideId: 'add-member' }, + }); + + assert.equal(res.statusCode, 400); + assert.ok(JSON.parse(res.body).error.includes('guide_not_offered')); + }); + + test('start-guide rejects when guide is already active', async () => { + const app = await createApp(); + const thread = await threadStore.create('user-1', 'test-thread'); + const { invocationId, callbackToken } = registry.create('user-1', 'opus', thread.id); + + await threadStore.updateGuideState(thread.id, { + v: 1, + guideId: 'add-member', + status: 'active', + offeredAt: 1000, + startedAt: 2000, + }); + + const res = await app.inject({ + method: 'POST', + url: '/api/callbacks/start-guide', + payload: { invocationId, callbackToken, guideId: 'add-member' }, + }); + + assert.equal(res.statusCode, 400); + }); + + test('start-guide rejects cross-user start on system-owned default thread', async () => { + const app = await createApp(); + const thread = await seedDefaultThread('add-member', 'offered', 'guide-owner'); + const { invocationId, callbackToken } = registry.create('attacker-user', 'opus', thread.id); + + const res = await app.inject({ + method: 'POST', + url: '/api/callbacks/start-guide', + payload: { invocationId, callbackToken, guideId: 'add-member' }, + }); + + assert.equal(res.statusCode, 403); + const stored = await threadStore.get(thread.id); + assert.equal(stored.guideState.status, 'offered'); + assert.equal( + broadcastCalls.find((c) => c.event === 'guide_start'), + undefined, + ); + }); + + // --- guide-control --- + + test('guide-control emits socket event when guide is active', async () => { + const app = await createApp(); + const thread = await threadStore.create('user-1', 'test-thread'); + const { invocationId, callbackToken } = registry.create('user-1', 'opus', thread.id); + + await threadStore.updateGuideState(thread.id, { + v: 1, + guideId: 'add-member', + status: 'active', + offeredAt: 1000, + startedAt: 2000, + }); + + const res = await app.inject({ + method: 'POST', + url: '/api/callbacks/guide-control', + payload: { invocationId, callbackToken, action: 'next' }, + }); + + assert.equal(res.statusCode, 200); + assert.equal( + broadcastCalls.find((c) => c.event === 'guide_control'), + undefined, + 'guide_control should be user-scoped', + ); + assert.deepEqual(emitCalls, [ + { + userId: 'user-1', + event: 'guide_control', + data: { + action: 'next', + guideId: 'add-member', + threadId: thread.id, + timestamp: emitCalls[0].data.timestamp, + }, + }, + ]); + assert.equal(typeof emitCalls[0].data.timestamp, 'number'); + }); + + test('guide-control emits only to the guide owner on shared default thread', async () => { + const app = await createApp(); + const thread = await seedDefaultThread('add-member', 'active', 'guide-owner'); + const { invocationId, callbackToken } = registry.create('guide-owner', 'opus', thread.id); + + const res = await app.inject({ + method: 'POST', + url: '/api/callbacks/guide-control', + payload: { invocationId, callbackToken, action: 'next' }, + }); + + assert.equal(res.statusCode, 200); + assert.equal( + broadcastCalls.find((c) => c.event === 'guide_control'), + undefined, + 'shared default-thread guide_control must not use room broadcast', + ); + assert.deepEqual(emitCalls, [ + { + userId: 'guide-owner', + event: 'guide_control', + data: { + action: 'next', + guideId: 'add-member', + threadId: thread.id, + timestamp: emitCalls[0].data.timestamp, + }, + }, + ]); + assert.equal(typeof emitCalls[0].data.timestamp, 'number'); + }); + + test('guide-control rejects when no active guide', async () => { + const app = await createApp(); + const thread = await threadStore.create('user-1', 'test-thread'); + const { invocationId, callbackToken } = registry.create('user-1', 'opus', thread.id); + + const res = await app.inject({ + method: 'POST', + url: '/api/callbacks/guide-control', + payload: { invocationId, callbackToken, action: 'next' }, + }); + + assert.equal(res.statusCode, 400); + assert.ok(JSON.parse(res.body).error.includes('no_active_guide')); + }); + + test('guide-control exit cancels the guide', async () => { + const app = await createApp(); + const thread = await threadStore.create('user-1', 'test-thread'); + const { invocationId, callbackToken } = registry.create('user-1', 'opus', thread.id); + + await threadStore.updateGuideState(thread.id, { + v: 1, + guideId: 'add-member', + status: 'active', + offeredAt: 1000, + startedAt: 2000, + }); + + const res = await app.inject({ + method: 'POST', + url: '/api/callbacks/guide-control', + payload: { invocationId, callbackToken, action: 'exit' }, + }); + + assert.equal(res.statusCode, 200); + const updatedThread = await threadStore.get(thread.id); + assert.equal(updatedThread.guideState.status, 'cancelled'); + }); + + test('guide-control rejects cross-user control on system-owned default thread', async () => { + const app = await createApp(); + const thread = await seedDefaultThread('add-member', 'active', 'guide-owner'); + const { invocationId, callbackToken } = registry.create('attacker-user', 'opus', thread.id); + + const res = await app.inject({ + method: 'POST', + url: '/api/callbacks/guide-control', + payload: { invocationId, callbackToken, action: 'exit' }, + }); + + assert.equal(res.statusCode, 403); + const stored = await threadStore.get(thread.id); + assert.equal(stored.guideState.status, 'active'); + assert.equal( + broadcastCalls.find((c) => c.event === 'guide_control'), + undefined, + ); + }); +}); diff --git a/packages/api/test/guide-action-routes.test.js b/packages/api/test/guide-action-routes.test.js new file mode 100644 index 000000000..058a55685 --- /dev/null +++ b/packages/api/test/guide-action-routes.test.js @@ -0,0 +1,607 @@ +/** + * F155: Frontend-Facing Guide Action Routes Tests + * POST /api/guide-actions/start — start guide via frontend click + * POST /api/guide-actions/cancel — cancel guide via frontend click + * + * These endpoints use userId-based auth (X-Cat-Cafe-User header), + * NOT MCP callback auth. They verify the frontend-only interaction path. + */ + +import assert from 'node:assert/strict'; +import { beforeEach, describe, test } from 'node:test'; +import Fastify from 'fastify'; +import './helpers/setup-cat-registry.js'; + +describe('F155 Guide Action Routes (frontend-facing)', () => { + let threadStore; + let socketManager; + let broadcastCalls; + let emitCalls; + + beforeEach(async () => { + const { ThreadStore } = await import('../dist/domains/cats/services/stores/ports/ThreadStore.js'); + threadStore = new ThreadStore(); + broadcastCalls = []; + emitCalls = []; + socketManager = { + broadcastAgentMessage() {}, + broadcastToRoom(room, event, data) { + broadcastCalls.push({ room, event, data }); + }, + emitToUser(userId, event, data) { + emitCalls.push({ userId, event, data }); + }, + getMessages() { + return []; + }, + }; + }); + + async function createApp() { + const { guideActionRoutes } = await import('../dist/routes/guide-action-routes.js'); + const app = Fastify(); + await app.register(guideActionRoutes, { threadStore, socketManager }); + return app; + } + + /** Seed a thread with guideState in given status */ + async function seedThread(guideId, status, createdBy = 'user-1') { + const thread = await threadStore.create(createdBy, 'test-thread'); + await threadStore.updateGuideState(thread.id, { + v: 1, + guideId, + status, + offeredAt: Date.now(), + }); + return thread; + } + + async function seedDefaultThread(guideId, status, userId = 'default-user') { + const thread = await threadStore.get('default'); + await threadStore.updateGuideState(thread.id, { + v: 1, + guideId, + status, + offeredAt: Date.now(), + userId, + }); + return thread; + } + + // --- /api/guide-actions/start --- + + test('start: transitions offered → active and emits socket event', async () => { + const app = await createApp(); + const thread = await seedThread('add-member', 'offered'); + + const res = await app.inject({ + method: 'POST', + url: '/api/guide-actions/start', + headers: { 'x-cat-cafe-user': 'user-1' }, + payload: { threadId: thread.id, guideId: 'add-member' }, + }); + + assert.equal(res.statusCode, 200); + const body = JSON.parse(res.body); + assert.equal(body.guideState.status, 'active'); + assert.ok(body.guideState.startedAt); + + // Verify socket event + assert.equal(broadcastCalls.length, 0); + assert.deepEqual(emitCalls, [ + { + userId: 'user-1', + event: 'guide_start', + data: { + guideId: 'add-member', + threadId: thread.id, + timestamp: emitCalls[0].data.timestamp, + }, + }, + ]); + assert.equal(typeof emitCalls[0].data.timestamp, 'number'); + }); + + test('start: transitions awaiting_choice → active', async () => { + const app = await createApp(); + const thread = await seedThread('add-member', 'awaiting_choice'); + + const res = await app.inject({ + method: 'POST', + url: '/api/guide-actions/start', + headers: { 'x-cat-cafe-user': 'user-1' }, + payload: { threadId: thread.id, guideId: 'add-member' }, + }); + + assert.equal(res.statusCode, 200); + assert.equal(JSON.parse(res.body).guideState.status, 'active'); + }); + + test('start: rejects when guide is already active', async () => { + const app = await createApp(); + const thread = await seedThread('add-member', 'active'); + + const res = await app.inject({ + method: 'POST', + url: '/api/guide-actions/start', + headers: { 'x-cat-cafe-user': 'user-1' }, + payload: { threadId: thread.id, guideId: 'add-member' }, + }); + + assert.equal(res.statusCode, 400); + }); + + test('start: rejects when no guide offered', async () => { + const app = await createApp(); + const thread = await threadStore.create('user-1', 'test-thread'); + + const res = await app.inject({ + method: 'POST', + url: '/api/guide-actions/start', + headers: { 'x-cat-cafe-user': 'user-1' }, + payload: { threadId: thread.id, guideId: 'add-member' }, + }); + + assert.equal(res.statusCode, 400); + assert.equal(JSON.parse(res.body).error, 'guide_not_offered'); + }); + + test('start: rejects without user identity', async () => { + const app = await createApp(); + const thread = await seedThread('add-member', 'offered'); + + const res = await app.inject({ + method: 'POST', + url: '/api/guide-actions/start', + payload: { threadId: thread.id, guideId: 'add-member' }, + }); + + assert.equal(res.statusCode, 401); + }); + + // --- /api/guide-actions/cancel --- + + test('cancel: transitions offered → cancelled and emits exit control event', async () => { + const app = await createApp(); + const thread = await seedThread('add-member', 'offered'); + + const res = await app.inject({ + method: 'POST', + url: '/api/guide-actions/cancel', + headers: { 'x-cat-cafe-user': 'user-1' }, + payload: { threadId: thread.id, guideId: 'add-member' }, + }); + + assert.equal(res.statusCode, 200); + const body = JSON.parse(res.body); + assert.equal(body.guideState.status, 'cancelled'); + assert.ok(body.guideState.completedAt); + + assert.equal(broadcastCalls.length, 0); + assert.deepEqual(emitCalls, [ + { + userId: 'user-1', + event: 'guide_control', + data: { + action: 'exit', + guideId: 'add-member', + threadId: thread.id, + timestamp: emitCalls[0].data.timestamp, + }, + }, + ]); + assert.equal(typeof emitCalls[0].data.timestamp, 'number'); + }); + + test('cancel: idempotent when already cancelled', async () => { + const app = await createApp(); + const thread = await seedThread('add-member', 'cancelled'); + + const res = await app.inject({ + method: 'POST', + url: '/api/guide-actions/cancel', + headers: { 'x-cat-cafe-user': 'user-1' }, + payload: { threadId: thread.id, guideId: 'add-member' }, + }); + + assert.equal(res.statusCode, 200); + assert.equal(JSON.parse(res.body).guideState.status, 'cancelled'); + }); + + test('cancel: rejects when guide not offered', async () => { + const app = await createApp(); + const thread = await threadStore.create('user-1', 'test-thread'); + + const res = await app.inject({ + method: 'POST', + url: '/api/guide-actions/cancel', + headers: { 'x-cat-cafe-user': 'user-1' }, + payload: { threadId: thread.id, guideId: 'add-member' }, + }); + + assert.equal(res.statusCode, 400); + }); + + test('cancel: rejects without user identity', async () => { + const app = await createApp(); + const thread = await seedThread('add-member', 'offered'); + + const res = await app.inject({ + method: 'POST', + url: '/api/guide-actions/cancel', + payload: { threadId: thread.id, guideId: 'add-member' }, + }); + + assert.equal(res.statusCode, 401); + }); + + // --- P1: start must reject when flow is not loadable --- + + test('start: rejects when guide flow is not loadable (400)', async () => { + const app = await createApp(); + // Seed thread with a guideId that has no corresponding flow YAML + const thread = await seedThread('nonexistent-flow', 'offered'); + + const res = await app.inject({ + method: 'POST', + url: '/api/guide-actions/start', + headers: { 'x-cat-cafe-user': 'user-1' }, + payload: { threadId: thread.id, guideId: 'nonexistent-flow' }, + }); + + assert.equal(res.statusCode, 400, 'start must fail when flow cannot be loaded'); + const body = JSON.parse(res.body); + assert.equal(body.error, 'guide_flow_invalid'); + // Verify state was NOT updated to active + const updated = await threadStore.get(thread.id); + assert.equal(updated.guideState.status, 'offered', 'state must remain offered on flow load failure'); + }); + + // --- P1-1: Thread ownership (cross-user state tampering) --- + + test('start: rejects when user does not own the thread (403)', async () => { + const app = await createApp(); + const thread = await seedThread('add-member', 'offered'); // created by user-1 + + const res = await app.inject({ + method: 'POST', + url: '/api/guide-actions/start', + headers: { 'x-cat-cafe-user': 'attacker-user' }, + payload: { threadId: thread.id, guideId: 'add-member' }, + }); + + assert.equal(res.statusCode, 403, 'cross-user start must be rejected'); + }); + + test('cancel: rejects when user does not own the thread (403)', async () => { + const app = await createApp(); + const thread = await seedThread('add-member', 'offered'); // created by user-1 + + const res = await app.inject({ + method: 'POST', + url: '/api/guide-actions/cancel', + headers: { 'x-cat-cafe-user': 'attacker-user' }, + payload: { threadId: thread.id, guideId: 'add-member' }, + }); + + assert.equal(res.statusCode, 403, 'cross-user cancel must be rejected'); + }); + + // --- P2-1: Header-only auth (query param userId spoofing) --- + + test('start: rejects query-param userId without header (401)', async () => { + const app = await createApp(); + const thread = await seedThread('add-member', 'offered'); + + const res = await app.inject({ + method: 'POST', + url: `/api/guide-actions/start?userId=user-1`, + payload: { threadId: thread.id, guideId: 'add-member' }, + }); + + assert.equal(res.statusCode, 401, 'query-param userId must not authenticate'); + }); + + test('cancel: rejects query-param userId without header (401)', async () => { + const app = await createApp(); + const thread = await seedThread('add-member', 'offered'); + + const res = await app.inject({ + method: 'POST', + url: `/api/guide-actions/cancel?userId=user-1`, + payload: { threadId: thread.id, guideId: 'add-member' }, + }); + + assert.equal(res.statusCode, 401, 'query-param userId must not authenticate'); + }); + + // --- Default thread (createdBy='system') public access --- + + test('start: allows the guide owner on system-owned default thread', async () => { + const app = await createApp(); + const thread = await seedDefaultThread('add-member', 'offered'); + + const res = await app.inject({ + method: 'POST', + url: '/api/guide-actions/start', + headers: { 'x-cat-cafe-user': 'default-user' }, + payload: { threadId: thread.id, guideId: 'add-member' }, + }); + + assert.equal(res.statusCode, 200, 'default-thread guide owner should be allowed'); + assert.equal(JSON.parse(res.body).guideState.status, 'active'); + assert.equal(broadcastCalls.length, 0, 'guide_start must not broadcast to shared default thread room'); + assert.equal(emitCalls.length, 1, 'guide_start must be emitted only to the guide owner'); + assert.deepEqual(emitCalls[0], { + userId: 'default-user', + event: 'guide_start', + data: { + guideId: 'add-member', + threadId: thread.id, + timestamp: emitCalls[0].data.timestamp, + }, + }); + assert.equal(typeof emitCalls[0].data.timestamp, 'number'); + }); + + test('start: rejects other users on system-owned default thread', async () => { + const app = await createApp(); + const thread = await seedDefaultThread('add-member', 'offered', 'default-user'); + + const res = await app.inject({ + method: 'POST', + url: '/api/guide-actions/start', + headers: { 'x-cat-cafe-user': 'attacker-user' }, + payload: { threadId: thread.id, guideId: 'add-member' }, + }); + + assert.equal(res.statusCode, 403, 'default-thread guide must stay owner-scoped'); + }); + + test('cancel: allows the guide owner on system-owned default thread', async () => { + const app = await createApp(); + const thread = await seedDefaultThread('add-member', 'offered'); + + const res = await app.inject({ + method: 'POST', + url: '/api/guide-actions/cancel', + headers: { 'x-cat-cafe-user': 'default-user' }, + payload: { threadId: thread.id, guideId: 'add-member' }, + }); + + assert.equal(res.statusCode, 200, 'default-thread guide owner should be allowed'); + assert.equal(JSON.parse(res.body).guideState.status, 'cancelled'); + assert.equal(broadcastCalls.length, 0, 'guide_control exit must not broadcast to shared default thread room'); + assert.equal(emitCalls.length, 1, 'guide_control exit must be emitted only to the guide owner'); + assert.deepEqual(emitCalls[0], { + userId: 'default-user', + event: 'guide_control', + data: { + action: 'exit', + guideId: 'add-member', + threadId: thread.id, + timestamp: emitCalls[0].data.timestamp, + }, + }); + assert.equal(typeof emitCalls[0].data.timestamp, 'number'); + }); + + test('cancel: rejects other users on system-owned default thread', async () => { + const app = await createApp(); + const thread = await seedDefaultThread('add-member', 'offered', 'default-user'); + + const res = await app.inject({ + method: 'POST', + url: '/api/guide-actions/cancel', + headers: { 'x-cat-cafe-user': 'attacker-user' }, + payload: { threadId: thread.id, guideId: 'add-member' }, + }); + + assert.equal(res.statusCode, 403, 'default-thread guide must stay owner-scoped'); + }); + + test('start: rejects arbitrary users on non-default system-owned threads', async () => { + const app = await createApp(); + const thread = await seedThread('add-member', 'offered', 'system'); + + const res = await app.inject({ + method: 'POST', + url: '/api/guide-actions/start', + headers: { 'x-cat-cafe-user': 'any-user' }, + payload: { threadId: thread.id, guideId: 'add-member' }, + }); + + assert.equal(res.statusCode, 403); + assert.equal(JSON.parse(res.body).error, 'Thread access denied'); + }); + + test('cancel: rejects arbitrary users on non-default system-owned threads', async () => { + const app = await createApp(); + const thread = await seedThread('add-member', 'offered', 'system'); + + const res = await app.inject({ + method: 'POST', + url: '/api/guide-actions/cancel', + headers: { 'x-cat-cafe-user': 'any-user' }, + payload: { threadId: thread.id, guideId: 'add-member' }, + }); + + assert.equal(res.statusCode, 403); + assert.equal(JSON.parse(res.body).error, 'Thread access denied'); + }); + + // --- /api/guide-flows/:guideId --- + + test('guide-flows: rejects without user identity (401)', async () => { + const app = await createApp(); + + const res = await app.inject({ + method: 'GET', + url: '/api/guide-flows/add-member', + }); + + assert.equal(res.statusCode, 401); + assert.equal(JSON.parse(res.body).error, 'Identity required (X-Cat-Cafe-User header)'); + }); + + test('guide-flows: returns flow for authenticated users', async () => { + const app = await createApp(); + + const res = await app.inject({ + method: 'GET', + url: '/api/guide-flows/add-member', + headers: { 'x-cat-cafe-user': 'user-1' }, + }); + + assert.equal(res.statusCode, 200); + const body = JSON.parse(res.body); + assert.equal(body.id, 'add-member'); + assert.ok(Array.isArray(body.steps)); + assert.ok(body.steps.length > 0); + }); + + // --- /api/guide-actions/complete --- + + test('complete: transitions active → completed and emits socket event', async () => { + const app = await createApp(); + const thread = await seedThread('add-member', 'active'); + + const res = await app.inject({ + method: 'POST', + url: '/api/guide-actions/complete', + headers: { 'x-cat-cafe-user': 'user-1' }, + payload: { threadId: thread.id, guideId: 'add-member' }, + }); + + assert.equal(res.statusCode, 200); + const body = JSON.parse(res.body); + assert.equal(body.guideState.status, 'completed'); + assert.ok(body.guideState.completedAt); + + assert.equal(broadcastCalls.length, 0); + assert.deepEqual(emitCalls, [ + { + userId: 'user-1', + event: 'guide_complete', + data: { + guideId: 'add-member', + threadId: thread.id, + timestamp: emitCalls[0].data.timestamp, + }, + }, + ]); + assert.equal(typeof emitCalls[0].data.timestamp, 'number'); + }); + + test('complete: idempotent when already completed', async () => { + const app = await createApp(); + const thread = await seedThread('add-member', 'completed'); + + const res = await app.inject({ + method: 'POST', + url: '/api/guide-actions/complete', + headers: { 'x-cat-cafe-user': 'user-1' }, + payload: { threadId: thread.id, guideId: 'add-member' }, + }); + + assert.equal(res.statusCode, 200); + assert.equal(JSON.parse(res.body).guideState.status, 'completed'); + }); + + test('complete: rejects when guide not active', async () => { + const app = await createApp(); + const thread = await seedThread('add-member', 'offered'); + + const res = await app.inject({ + method: 'POST', + url: '/api/guide-actions/complete', + headers: { 'x-cat-cafe-user': 'user-1' }, + payload: { threadId: thread.id, guideId: 'add-member' }, + }); + + assert.equal(res.statusCode, 400); + }); + + test('complete: rejects when user does not own the thread (403)', async () => { + const app = await createApp(); + const thread = await seedThread('add-member', 'active'); + + const res = await app.inject({ + method: 'POST', + url: '/api/guide-actions/complete', + headers: { 'x-cat-cafe-user': 'attacker-user' }, + payload: { threadId: thread.id, guideId: 'add-member' }, + }); + + assert.equal(res.statusCode, 403, 'cross-user complete must be rejected'); + }); + + test('complete: rejects without user identity (401)', async () => { + const app = await createApp(); + const thread = await seedThread('add-member', 'active'); + + const res = await app.inject({ + method: 'POST', + url: '/api/guide-actions/complete', + payload: { threadId: thread.id, guideId: 'add-member' }, + }); + + assert.equal(res.statusCode, 401); + }); + + test('complete: allows the guide owner on system-owned default thread', async () => { + const app = await createApp(); + const thread = await seedDefaultThread('add-member', 'active'); + + const res = await app.inject({ + method: 'POST', + url: '/api/guide-actions/complete', + headers: { 'x-cat-cafe-user': 'default-user' }, + payload: { threadId: thread.id, guideId: 'add-member' }, + }); + + assert.equal(res.statusCode, 200); + assert.equal(JSON.parse(res.body).guideState.status, 'completed'); + assert.equal(broadcastCalls.length, 0, 'guide_complete must not broadcast to shared default thread room'); + assert.equal(emitCalls.length, 1, 'guide_complete must be emitted only to the guide owner'); + assert.deepEqual(emitCalls[0], { + userId: 'default-user', + event: 'guide_complete', + data: { + guideId: 'add-member', + threadId: thread.id, + timestamp: emitCalls[0].data.timestamp, + }, + }); + assert.equal(typeof emitCalls[0].data.timestamp, 'number'); + }); + + test('complete: rejects other users on system-owned default thread', async () => { + const app = await createApp(); + const thread = await seedDefaultThread('add-member', 'active', 'default-user'); + + const res = await app.inject({ + method: 'POST', + url: '/api/guide-actions/complete', + headers: { 'x-cat-cafe-user': 'attacker-user' }, + payload: { threadId: thread.id, guideId: 'add-member' }, + }); + + assert.equal(res.statusCode, 403, 'default-thread guide must stay owner-scoped'); + }); + + test('complete: rejects arbitrary users on non-default system-owned threads', async () => { + const app = await createApp(); + const thread = await seedThread('add-member', 'active', 'system'); + + const res = await app.inject({ + method: 'POST', + url: '/api/guide-actions/complete', + headers: { 'x-cat-cafe-user': 'any-user' }, + payload: { threadId: thread.id, guideId: 'add-member' }, + }); + + assert.equal(res.statusCode, 403); + assert.equal(JSON.parse(res.body).error, 'Thread access denied'); + }); +}); diff --git a/packages/api/test/guide-registry-loader.test.js b/packages/api/test/guide-registry-loader.test.js new file mode 100644 index 000000000..ab8b0c7cf --- /dev/null +++ b/packages/api/test/guide-registry-loader.test.js @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict'; +import { rmSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { describe, test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +describe('F155 guide registry loader target validation', async () => { + const { getRegistryEntries, getValidGuideIds, isValidGuideTarget, loadGuideFlow, resolveGuideForIntent } = + await import('../dist/domains/guides/guide-registry-loader.js'); + const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); + + test('accepts registry-safe target ids', () => { + assert.equal(isValidGuideTarget('hub.trigger'), true); + assert.equal(isValidGuideTarget('cats.add-member'), true); + assert.equal(isValidGuideTarget('members.row_new-confirm'), true); + }); + + test('rejects selector-breaking target ids', () => { + assert.equal(isValidGuideTarget('bad"]div'), false); + assert.equal(isValidGuideTarget('bad target'), false); + assert.equal(isValidGuideTarget('bad>target'), false); + }); + + test('loaded add-member flow contains only validated targets', () => { + const flow = loadGuideFlow('add-member'); + for (const step of flow.steps) { + assert.equal(isValidGuideTarget(step.target), true, `step ${step.id} target should be valid`); + } + }); + + test('loaded add-member flow waits for member profile save before completion', () => { + const flow = loadGuideFlow('add-member'); + const createIndex = flow.steps.findIndex((step) => step.id === 'click-add-member'); + const editIndex = flow.steps.findIndex((step) => step.id === 'edit-member-profile'); + const editStep = flow.steps[editIndex]; + + assert.ok(createIndex >= 0, 'create step should exist'); + assert.ok(editIndex > createIndex, 'edit step should happen after member creation'); + assert.ok(editStep, 'edit-member-profile step should exist'); + assert.equal(editStep.target, 'member-editor.profile'); + assert.equal(editStep.advance, 'confirm'); + }); + + test('matches meaningful partial queries without requiring full keyword', () => { + const matches = resolveGuideForIntent('添加'); + assert.equal(matches[0]?.id, 'add-member'); + }); + + test('does not offer guides for single-character queries', () => { + const matches = resolveGuideForIntent('添'); + assert.equal(matches.length, 0); + }); + + test('matches exact keyword queries regardless of reverse-match threshold', () => { + const matches = resolveGuideForIntent('加成员'); + assert.equal(matches[0]?.id, 'add-member'); + }); + + test('rejects a flow file whose internal id does not match the requested guide id', () => { + const guideId = 'test-mismatched-flow-id'; + const flowPath = resolve(repoRoot, 'guides', 'flows', `${guideId}.yaml`); + const entry = { + id: guideId, + name: 'Test mismatched flow', + description: 'Regression fixture for mismatched flow ids', + flow_file: `flows/${guideId}.yaml`, + keywords: ['mismatched flow id'], + category: 'test', + priority: 'P0', + cross_system: false, + estimated_time: '1min', + }; + + writeFileSync( + flowPath, + [ + 'id: wrong-flow-id', + 'name: Wrong Flow', + 'steps:', + ' - id: step-1', + ' target: hub.trigger', + ' tips: Open hub', + ' advance: click', + '', + ].join('\n'), + 'utf8', + ); + getRegistryEntries().push(entry); + getValidGuideIds().add(guideId); + + try { + assert.throws( + () => loadGuideFlow(guideId), + /Invalid flow file for "test-mismatched-flow-id": expected id "test-mismatched-flow-id"/, + ); + } finally { + getRegistryEntries().pop(); + getValidGuideIds().delete(guideId); + rmSync(flowPath, { force: true }); + } + }); +}); diff --git a/packages/api/test/invoke-single-cat.test.js b/packages/api/test/invoke-single-cat.test.js index 0827dde74..67b6e612f 100644 --- a/packages/api/test/invoke-single-cat.test.js +++ b/packages/api/test/invoke-single-cat.test.js @@ -2672,6 +2672,7 @@ describe('invokeSingleCat audit events (P1 fix)', () => { it('F127 P1: falls back to CAT_TEMPLATE_PATH project when thread projectPath is absent', async () => { const { createProviderProfile } = await import('./helpers/create-test-account.js'); const templateRoot = await mkdtemp(join(tmpdir(), 'f127-active-template-')); + process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = templateRoot; await writeFile(join(templateRoot, 'cat-template.json'), '{}', 'utf-8'); const boundProfile = await createProviderProfile(templateRoot, { provider: 'openai', @@ -2977,6 +2978,7 @@ describe('invokeSingleCat audit events (P1 fix)', () => { it('F127 P1: prefers member-bound openai profile over protocol active profile', async () => { const { createProviderProfile } = await import('./helpers/create-test-account.js'); const root = await mkdtemp(join(tmpdir(), 'f127-openai-profile-')); + process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = root; const apiDir = join(root, 'packages', 'api'); await mkdir(apiDir, { recursive: true }); await writeFile(join(root, 'pnpm-workspace.yaml'), 'packages:\n - "packages/*"\n', 'utf-8'); @@ -3262,6 +3264,7 @@ describe('invokeSingleCat audit events (P1 fix)', () => { it('F127: ignores legacy api_key protocol metadata when the member explicitly selected the client', async () => { const { createProviderProfile } = await import('./helpers/create-test-account.js'); const root = await mkdtemp(join(tmpdir(), 'f127-bound-mismatch-')); + process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = root; const apiDir = join(root, 'packages', 'api'); await mkdir(apiDir, { recursive: true }); await writeFile(join(root, 'pnpm-workspace.yaml'), 'packages:\n - "packages/*"\n', 'utf-8'); @@ -3399,6 +3402,7 @@ describe('invokeSingleCat audit events (P1 fix)', () => { it('F127: injects OPENROUTER_API_KEY for opencode members bound to openai api_key profiles', async () => { const { createProviderProfile } = await import('./helpers/create-test-account.js'); const root = await mkdtemp(join(tmpdir(), 'f127-openrouter-key-injection-')); + process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = root; const apiDir = join(root, 'packages', 'api'); await mkdir(apiDir, { recursive: true }); await writeFile(join(root, 'pnpm-workspace.yaml'), 'packages:\n - "packages/*"\n', 'utf-8'); @@ -3471,6 +3475,7 @@ describe('invokeSingleCat audit events (P1 fix)', () => { mod._resetOpenCodeKnownModels(new Set(['anthropic/claude-opus-4-6'])); const { createProviderProfile } = await import('./helpers/create-test-account.js'); const root = await mkdtemp(join(tmpdir(), 'f189-opencode-custom-provider-')); + process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = root; const apiDir = join(root, 'packages', 'api'); await mkdir(apiDir, { recursive: true }); await writeFile(join(root, 'pnpm-workspace.yaml'), 'packages:\n - "packages/*"\n', 'utf-8'); @@ -3551,6 +3556,7 @@ describe('invokeSingleCat audit events (P1 fix)', () => { it('F189: bare model + provider assembles composite model for custom provider routing', async () => { const { createProviderProfile } = await import('./helpers/create-test-account.js'); const root = await mkdtemp(join(tmpdir(), 'f189-oc-bare-model-')); + process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = root; const apiDir = join(root, 'packages', 'api'); await mkdir(apiDir, { recursive: true }); await writeFile(join(root, 'pnpm-workspace.yaml'), 'packages:\n - "packages/*"\n', 'utf-8'); @@ -4518,3 +4524,8 @@ describe('invokeSingleCat audit events (P1 fix)', () => { } }); }); + +// F155: Old pre-invocation guide routing hook tests removed. +// Guide matching now happens at routing layer (route-serial/route-parallel) +// and is injected via SystemPromptBuilder + guide-interaction skill. +// New tests for the routing-layer matching should be added separately. diff --git a/packages/api/test/route-strategies.test.js b/packages/api/test/route-strategies.test.js index 1a55644fa..c1581361f 100644 --- a/packages/api/test/route-strategies.test.js +++ b/packages/api/test/route-strategies.test.js @@ -56,6 +56,62 @@ function createSequentialCapturingService(catId, responses) { }; } +function createGuideAckThreadStore(initialGuideState, currentGuideState, projectPath = '/tmp/test') { + let getCount = 0; + const updates = []; + return { + updates, + async get() { + getCount += 1; + return { + id: 'thread1', + title: 'Test', + createdBy: 'user1', + participants: [], + lastActiveAt: Date.now(), + createdAt: Date.now(), + projectPath, + guideState: getCount === 1 ? initialGuideState : currentGuideState, + }; + }, + async getParticipantsWithActivity() { + return []; + }, + async updateGuideState(threadId, guideState) { + updates.push({ threadId, guideState }); + }, + }; +} + +function createSharedDefaultGuideThreadStore(guideState) { + const updates = []; + return { + updates, + async get() { + return { + id: 'default', + title: 'Default Thread', + createdBy: 'system', + participants: [], + lastActiveAt: Date.now(), + createdAt: Date.now(), + projectPath: 'default', + guideState, + }; + }, + async getParticipantsWithActivity() { + return []; + }, + async consumeMentionRoutingFeedback() { + return null; + }, + async updateParticipantActivity() {}, + async updateGuideState(threadId, nextGuideState) { + updates.push({ threadId, guideState: nextGuideState }); + }, + }; +} + function createMockDeps(services, appendCalls, threadStore = null) { let counter = 0; return { @@ -759,6 +815,622 @@ describe('routeParallel abort marks healthy (#267)', () => { }); }); +describe('F155 guide offer ownership', () => { + it('serial: suppresses fresh guide offers when another user has a non-terminal guide on shared default thread', async () => { + const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); + const codexService = createCapturingService('codex', '我来处理这个请求'); + const threadStore = createSharedDefaultGuideThreadStore({ + v: 1, + guideId: 'configure-provider', + status: 'active', + offeredAt: Date.now(), + startedAt: Date.now(), + offeredBy: 'opus', + userId: 'other-user', + }); + const deps = createMockDeps({ codex: codexService }, null, threadStore); + + for await (const _ of routeSerial(deps, ['codex'], '请帮我添加成员', 'user1', 'default')) { + } + + assert.ok( + !codexService.calls[0].includes('Guide Matched:'), + 'foreign non-terminal guide must block fresh guide matching for another user', + ); + assert.ok( + !codexService.calls[0].includes('status="offered"'), + 'routing must not emit a fresh offered guide when another user already owns the active guide', + ); + assert.equal(threadStore.updates.length, 0, 'blocked guide state must not be mutated by the wrong user'); + }); + + it('serial: ignores another user guide state on shared default thread', async () => { + const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); + const codexService = createCapturingService('codex', '我来处理这个请求'); + const threadStore = createSharedDefaultGuideThreadStore({ + v: 1, + guideId: 'configure-provider', + status: 'completed', + offeredAt: Date.now(), + completedAt: Date.now(), + offeredBy: 'opus', + userId: 'other-user', + }); + const deps = createMockDeps({ codex: codexService }, null, threadStore); + + for await (const _ of routeSerial(deps, ['codex'], '请帮我添加成员', 'user1', 'default')) { + } + + assert.ok( + codexService.calls[0].includes('Guide Matched:'), + 'foreign guide state should be hidden so current user can receive a fresh guide offer', + ); + assert.ok( + !codexService.calls[0].includes('Guide Completed:'), + 'foreign completed guide must not leak into the current user prompt', + ); + assert.equal(threadStore.updates.length, 0, 'hidden foreign guide must not be acked by the wrong user'); + }); + + it('serial: injects offered guide only to the first target cat', async () => { + const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); + const opusService = createCapturingService('opus', '我来处理引导'); + const codexService = createCapturingService('codex', '不该收到引导 offer'); + const deps = createMockDeps({ opus: opusService, codex: codexService }); + + for await (const _ of routeSerial(deps, ['opus', 'codex'], '请帮我添加成员', 'user1', 'thread1')) { + } + + assert.equal(opusService.calls.length, 1, 'first cat should be invoked'); + assert.equal(codexService.calls.length, 1, 'second cat should still be invoked'); + assert.ok(opusService.calls[0].includes('status="offered"'), 'first cat should receive guide offer instructions'); + assert.ok( + !codexService.calls[0].includes('status="offered"'), + 'second cat must not receive duplicate guide offer instructions', + ); + }); + + it('serial: passes guide selection context to a non-owner target cat', async () => { + const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); + const codexService = createCapturingService('codex', '我来给步骤概览'); + const threadStore = { + async get() { + return { + id: 'thread1', + title: 'Test', + createdBy: 'user1', + participants: [], + lastActiveAt: Date.now(), + createdAt: Date.now(), + projectPath: 'default', + guideState: { + v: 1, + guideId: 'add-member', + status: 'offered', + offeredAt: Date.now(), + offeredBy: 'opus', + }, + }; + }, + async getParticipantsWithActivity() { + return []; + }, + async consumeMentionRoutingFeedback() { + return null; + }, + async updateParticipantActivity() {}, + }; + const deps = createMockDeps({ codex: codexService }, null, threadStore); + + for await (const _ of routeSerial(deps, ['codex'], '引导流程:步骤概览', 'user1', 'thread1')) { + } + + assert.equal(codexService.calls.length, 1, 'non-owner target cat should still be invoked'); + assert.ok( + codexService.calls[0].includes('用户选择了「步骤概览」'), + 'selected guide branch must be visible to the routed cat even when it did not offer the guide', + ); + assert.ok( + !codexService.calls[0].includes('status="offered"'), + 'selection follow-up must not regress into a duplicate offered prompt', + ); + }); + + it('serial: routes owner-missing guide selection to only the first target cat', async () => { + const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); + const opusService = createCapturingService('opus', '我来给步骤概览'); + const codexService = createCapturingService('codex', '我不该收到选择分支'); + const threadStore = { + async get() { + return { + id: 'thread1', + title: 'Test', + createdBy: 'user1', + participants: [], + lastActiveAt: Date.now(), + createdAt: Date.now(), + projectPath: 'default', + guideState: { + v: 1, + guideId: 'add-member', + status: 'offered', + offeredAt: Date.now(), + offeredBy: 'dare', + }, + }; + }, + async getParticipantsWithActivity() { + return []; + }, + async consumeMentionRoutingFeedback() { + return null; + }, + async updateParticipantActivity() {}, + }; + const deps = createMockDeps({ opus: opusService, codex: codexService }, null, threadStore); + + for await (const _ of routeSerial(deps, ['opus', 'codex'], '引导流程:步骤概览', 'user1', 'thread1')) { + } + + assert.ok( + opusService.calls[0].includes('用户选择了「步骤概览」'), + 'first target cat should receive selection fallback', + ); + assert.ok( + !codexService.calls[0].includes('用户选择了「步骤概览」'), + 'second target cat must not receive duplicate selection fallback', + ); + }); + + it('serial: routes owner-missing awaiting_choice guide to only the first target cat', async () => { + const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); + const opusService = createCapturingService('opus', '我来处理等待中的引导'); + const codexService = createCapturingService('codex', '我不该收到 pending guide'); + const threadStore = { + async get() { + return { + id: 'thread1', + title: 'Test', + createdBy: 'user1', + participants: [], + lastActiveAt: Date.now(), + createdAt: Date.now(), + projectPath: 'default', + guideState: { + v: 1, + guideId: 'add-member', + status: 'awaiting_choice', + offeredAt: Date.now(), + offeredBy: 'dare', + }, + }; + }, + async getParticipantsWithActivity() { + return []; + }, + async consumeMentionRoutingFeedback() { + return null; + }, + async updateParticipantActivity() {}, + }; + const deps = createMockDeps({ opus: opusService, codex: codexService }, null, threadStore); + + for await (const _ of routeSerial(deps, ['opus', 'codex'], '继续', 'user1', 'thread1')) { + } + + assert.ok( + opusService.calls[0].includes('Guide Pending:'), + 'first target cat should receive the awaiting_choice reminder fallback', + ); + assert.ok( + !codexService.calls[0].includes('Guide Pending:'), + 'second target cat must not receive duplicate awaiting_choice context', + ); + }); + + it('parallel: passes guide selection context to a non-owner target cat', async () => { + const { routeParallel } = await import('../dist/domains/cats/services/agents/routing/route-parallel.js'); + const codexService = createCapturingService('codex', '我来给步骤概览'); + const threadStore = { + async get() { + return { + id: 'thread1', + title: 'Test', + createdBy: 'user1', + participants: [], + lastActiveAt: Date.now(), + createdAt: Date.now(), + projectPath: 'default', + guideState: { + v: 1, + guideId: 'add-member', + status: 'offered', + offeredAt: Date.now(), + offeredBy: 'opus', + }, + }; + }, + async getParticipantsWithActivity() { + return []; + }, + async updateParticipantActivity() {}, + }; + const deps = createMockDeps({ codex: codexService }, null, threadStore); + + for await (const _ of routeParallel(deps, ['codex'], '引导流程:步骤概览', 'user1', 'thread1')) { + } + + assert.equal(codexService.calls.length, 1, 'parallel non-owner target cat should still be invoked'); + assert.ok( + codexService.calls[0].includes('用户选择了「步骤概览」'), + 'parallel routed cat must see the selected guide context when the offer owner is absent', + ); + assert.ok( + !codexService.calls[0].includes('status="offered"'), + 'parallel selection follow-up must not regress into a duplicate offered prompt', + ); + }); + + it('parallel: routes owner-missing guide selection to only the first target cat', async () => { + const { routeParallel } = await import('../dist/domains/cats/services/agents/routing/route-parallel.js'); + const opusService = createCapturingService('opus', '我来给步骤概览'); + const codexService = createCapturingService('codex', '我不该收到选择分支'); + const threadStore = { + async get() { + return { + id: 'thread1', + title: 'Test', + createdBy: 'user1', + participants: [], + lastActiveAt: Date.now(), + createdAt: Date.now(), + projectPath: 'default', + guideState: { + v: 1, + guideId: 'add-member', + status: 'offered', + offeredAt: Date.now(), + offeredBy: 'dare', + }, + }; + }, + async getParticipantsWithActivity() { + return []; + }, + async updateParticipantActivity() {}, + }; + const deps = createMockDeps({ opus: opusService, codex: codexService }, null, threadStore); + + for await (const _ of routeParallel(deps, ['opus', 'codex'], '引导流程:步骤概览', 'user1', 'thread1')) { + } + + assert.ok( + opusService.calls[0].includes('用户选择了「步骤概览」'), + 'first target cat should receive selection fallback', + ); + assert.ok( + !codexService.calls[0].includes('用户选择了「步骤概览」'), + 'second target cat must not receive duplicate selection fallback', + ); + }); + + it('parallel: routes owner-missing awaiting_choice guide to only the first target cat', async () => { + const { routeParallel } = await import('../dist/domains/cats/services/agents/routing/route-parallel.js'); + const opusService = createCapturingService('opus', '我来处理等待中的引导'); + const codexService = createCapturingService('codex', '我不该收到 pending guide'); + const threadStore = { + async get() { + return { + id: 'thread1', + title: 'Test', + createdBy: 'user1', + participants: [], + lastActiveAt: Date.now(), + createdAt: Date.now(), + projectPath: 'default', + guideState: { + v: 1, + guideId: 'add-member', + status: 'awaiting_choice', + offeredAt: Date.now(), + offeredBy: 'dare', + }, + }; + }, + async getParticipantsWithActivity() { + return []; + }, + async updateParticipantActivity() {}, + }; + const deps = createMockDeps({ opus: opusService, codex: codexService }, null, threadStore); + + for await (const _ of routeParallel(deps, ['opus', 'codex'], '继续', 'user1', 'thread1')) { + } + + assert.ok( + opusService.calls[0].includes('Guide Pending:'), + 'first target cat should receive the awaiting_choice reminder fallback', + ); + assert.ok( + !codexService.calls[0].includes('Guide Pending:'), + 'second target cat must not receive duplicate awaiting_choice context', + ); + }); + + it('parallel: injects offered guide only to the first target cat', async () => { + const { routeParallel } = await import('../dist/domains/cats/services/agents/routing/route-parallel.js'); + const opusService = createCapturingService('opus', '我来处理引导'); + const codexService = createCapturingService('codex', '不该收到引导 offer'); + const deps = createMockDeps({ opus: opusService, codex: codexService }); + + for await (const _ of routeParallel(deps, ['opus', 'codex'], '请帮我添加成员', 'user1', 'thread1')) { + } + + assert.equal(opusService.calls.length, 1, 'first cat should be invoked'); + assert.equal(codexService.calls.length, 1, 'second cat should still be invoked'); + assert.ok(opusService.calls[0].includes('status="offered"'), 'first cat should receive guide offer instructions'); + assert.ok( + !codexService.calls[0].includes('status="offered"'), + 'second cat must not receive duplicate guide offer instructions', + ); + }); + + it('parallel: suppresses fresh guide offers when another user has a non-terminal guide on shared default thread', async () => { + const { routeParallel } = await import('../dist/domains/cats/services/agents/routing/route-parallel.js'); + const codexService = createCapturingService('codex', '我来处理这个请求'); + const threadStore = createSharedDefaultGuideThreadStore({ + v: 1, + guideId: 'configure-provider', + status: 'active', + offeredAt: Date.now(), + startedAt: Date.now(), + offeredBy: 'opus', + userId: 'other-user', + }); + const deps = createMockDeps({ codex: codexService }, null, threadStore); + + for await (const _ of routeParallel(deps, ['codex'], '请帮我添加成员', 'user1', 'default')) { + } + + assert.ok( + !codexService.calls[0].includes('Guide Matched:'), + 'foreign non-terminal guide must block fresh guide matching for another user', + ); + assert.ok( + !codexService.calls[0].includes('status="offered"'), + 'parallel routing must not emit a fresh offered guide when another user already owns the active guide', + ); + assert.equal(threadStore.updates.length, 0, 'blocked guide state must not be mutated by the wrong user'); + }); + + it('parallel: ignores another user guide state on shared default thread', async () => { + const { routeParallel } = await import('../dist/domains/cats/services/agents/routing/route-parallel.js'); + const codexService = createCapturingService('codex', '我来处理这个请求'); + const threadStore = createSharedDefaultGuideThreadStore({ + v: 1, + guideId: 'configure-provider', + status: 'completed', + offeredAt: Date.now(), + completedAt: Date.now(), + offeredBy: 'opus', + userId: 'other-user', + }); + const deps = createMockDeps({ codex: codexService }, null, threadStore); + + for await (const _ of routeParallel(deps, ['codex'], '请帮我添加成员', 'user1', 'default')) { + } + + assert.ok( + codexService.calls[0].includes('Guide Matched:'), + 'foreign guide state should be hidden so current user can receive a fresh guide offer', + ); + assert.ok( + !codexService.calls[0].includes('Guide Completed:'), + 'foreign completed guide must not leak into the current user prompt', + ); + assert.equal(threadStore.updates.length, 0, 'hidden foreign guide must not be acked by the wrong user'); + }); +}); + +describe('F155 guide completion ack ownership', () => { + it('serial: does not ack a different guide that replaced the completed one', async () => { + const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); + const threadStore = createGuideAckThreadStore( + { + v: 1, + guideId: 'add-member', + status: 'completed', + offeredAt: Date.now(), + completedAt: Date.now(), + offeredBy: 'opus', + }, + { + v: 1, + guideId: 'configure-provider', + status: 'offered', + offeredAt: Date.now(), + offeredBy: 'codex', + }, + ); + const deps = createMockDeps({ opus: createMockService('opus', 'done') }, null, threadStore); + + for await (const _ of routeSerial(deps, ['opus'], '继续', 'user1', 'thread1')) { + } + + assert.equal(threadStore.updates.length, 0, 'must not ack a replacement guide'); + }); + + it('parallel: does not ack a different guide that replaced the completed one', async () => { + const { routeParallel } = await import('../dist/domains/cats/services/agents/routing/route-parallel.js'); + const threadStore = createGuideAckThreadStore( + { + v: 1, + guideId: 'add-member', + status: 'completed', + offeredAt: Date.now(), + completedAt: Date.now(), + offeredBy: 'opus', + }, + { + v: 1, + guideId: 'configure-provider', + status: 'active', + offeredAt: Date.now(), + startedAt: Date.now(), + offeredBy: 'codex', + }, + ); + const deps = createMockDeps({ opus: createMockService('opus', 'done') }, null, threadStore); + + for await (const _ of routeParallel(deps, ['opus'], '继续', 'user1', 'thread1')) { + } + + assert.equal(threadStore.updates.length, 0, 'must not ack a replacement guide'); + }); + + it('serial: does not ack completed guide after a silent done-only turn', async () => { + const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); + const completedGuide = { + v: 1, + guideId: 'add-member', + status: 'completed', + offeredAt: Date.now(), + completedAt: Date.now(), + offeredBy: 'codex', + }; + const threadStore = createGuideAckThreadStore(completedGuide, completedGuide, 'default'); + const deps = createMockDeps({ codex: createDoneOnlyService('codex') }, null, threadStore); + + for await (const _ of routeSerial(deps, ['codex'], '继续', 'user1', 'thread1')) { + } + + assert.equal(threadStore.updates.length, 0, 'silent done-only turn must not ack guide completion'); + }); + + it('parallel: does not ack completed guide after a silent done-only turn', async () => { + const { routeParallel } = await import('../dist/domains/cats/services/agents/routing/route-parallel.js'); + const completedGuide = { + v: 1, + guideId: 'add-member', + status: 'completed', + offeredAt: Date.now(), + completedAt: Date.now(), + offeredBy: 'codex', + }; + const threadStore = createGuideAckThreadStore(completedGuide, completedGuide, 'default'); + const deps = createMockDeps({ codex: createDoneOnlyService('codex') }, null, threadStore); + + for await (const _ of routeParallel(deps, ['codex'], '继续', 'user1', 'thread1')) { + } + + assert.equal(threadStore.updates.length, 0, 'silent done-only turn must not ack guide completion'); + }); + + it('serial: injects and acks completed guide when owner cat is not routed', async () => { + const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); + const completedGuide = { + v: 1, + guideId: 'add-member', + status: 'completed', + offeredAt: Date.now(), + completedAt: Date.now(), + offeredBy: 'opus', + }; + const threadStore = createGuideAckThreadStore(completedGuide, completedGuide, 'default'); + const codexService = createCapturingService('codex', '好的,我继续帮你'); + const deps = createMockDeps({ codex: codexService }, null, threadStore); + + for await (const _ of routeSerial(deps, ['codex'], '继续', 'user1', 'thread1')) { + } + + assert.equal(codexService.calls.length, 1, 'routed non-owner cat should still be invoked'); + assert.ok( + codexService.calls[0].includes('Guide Completed:'), + 'routed non-owner cat must see completed guide context when owner is absent', + ); + assert.equal(threadStore.updates.length, 1, 'visible non-owner response should ack guide completion'); + assert.equal(threadStore.updates[0].guideState.completionAcked, true); + }); + + it('serial: routes completed-guide fallback only to the first target cat', async () => { + const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); + const completedGuide = { + v: 1, + guideId: 'add-member', + status: 'completed', + offeredAt: Date.now(), + completedAt: Date.now(), + offeredBy: 'dare', + }; + const threadStore = createGuideAckThreadStore(completedGuide, completedGuide, 'default'); + const opusService = createCapturingService('opus', '我来接着处理'); + const codexService = createCapturingService('codex', '我也看到了'); + const deps = createMockDeps({ opus: opusService, codex: codexService }, null, threadStore); + + for await (const _ of routeSerial(deps, ['opus', 'codex'], '继续', 'user1', 'thread1')) { + } + + assert.ok(opusService.calls[0].includes('Guide Completed:'), 'first target cat should receive completed guide'); + assert.ok( + !codexService.calls[0].includes('Guide Completed:'), + 'second target cat must not receive duplicate completed guide fallback', + ); + assert.equal(threadStore.updates.length, 1, 'only one routed cat should ack the completed guide'); + }); + + it('parallel: injects and acks completed guide when owner cat is not routed', async () => { + const { routeParallel } = await import('../dist/domains/cats/services/agents/routing/route-parallel.js'); + const completedGuide = { + v: 1, + guideId: 'add-member', + status: 'completed', + offeredAt: Date.now(), + completedAt: Date.now(), + offeredBy: 'opus', + }; + const threadStore = createGuideAckThreadStore(completedGuide, completedGuide, 'default'); + const codexService = createCapturingService('codex', '好的,我继续帮你'); + const deps = createMockDeps({ codex: codexService }, null, threadStore); + + for await (const _ of routeParallel(deps, ['codex'], '继续', 'user1', 'thread1')) { + } + + assert.equal(codexService.calls.length, 1, 'routed non-owner cat should still be invoked'); + assert.ok( + codexService.calls[0].includes('Guide Completed:'), + 'routed non-owner cat must see completed guide context when owner is absent', + ); + assert.equal(threadStore.updates.length, 1, 'visible non-owner response should ack guide completion'); + assert.equal(threadStore.updates[0].guideState.completionAcked, true); + }); + + it('parallel: routes completed-guide fallback only to the first target cat', async () => { + const { routeParallel } = await import('../dist/domains/cats/services/agents/routing/route-parallel.js'); + const completedGuide = { + v: 1, + guideId: 'add-member', + status: 'completed', + offeredAt: Date.now(), + completedAt: Date.now(), + offeredBy: 'dare', + }; + const threadStore = createGuideAckThreadStore(completedGuide, completedGuide, 'default'); + const opusService = createCapturingService('opus', '我来接着处理'); + const codexService = createCapturingService('codex', '我也看到了'); + const deps = createMockDeps({ opus: opusService, codex: codexService }, null, threadStore); + + for await (const _ of routeParallel(deps, ['opus', 'codex'], '继续', 'user1', 'thread1')) { + } + + assert.ok(opusService.calls[0].includes('Guide Completed:'), 'first target cat should receive completed guide'); + assert.ok( + !codexService.calls[0].includes('Guide Completed:'), + 'second target cat must not receive duplicate completed guide fallback', + ); + assert.equal(threadStore.updates.length, 1, 'only one routed cat should ack the completed guide'); + }); +}); + describe('routeParallel whisper privacy (F35)', () => { it('does NOT inject whisper content for non-recipient cat in parallel mode', async () => { const { routeParallel } = await import('../dist/domains/cats/services/agents/routing/route-parallel.js'); diff --git a/packages/api/test/session-bind.test.js b/packages/api/test/session-bind.test.js index 9dffa17bb..bd23bcb84 100644 --- a/packages/api/test/session-bind.test.js +++ b/packages/api/test/session-bind.test.js @@ -275,6 +275,49 @@ describe('Session bind API route', () => { } }); + test('returns 403 for non-default system-owned thread', async () => { + const { app, threadStore } = await buildApp(); + try { + const thread = await threadStore.create('system', 'System Thread'); + + const res = await app.inject({ + method: 'PATCH', + url: `/api/threads/${thread.id}/sessions/opus/bind`, + headers: { 'x-cat-cafe-user': 'other-user' }, + payload: { cliSessionId: 'cli-system' }, + }); + + assert.equal(res.statusCode, 403); + } finally { + await app.close(); + } + }); + + test('returns 403 when default-thread active session belongs to another user', async () => { + const { app, threadStore, sessionChainStore } = await buildApp(); + try { + const thread = await threadStore.get('default'); + + sessionChainStore.create({ + cliSessionId: 'cli-owner', + threadId: thread.id, + catId: 'opus', + userId: 'owner-user', + }); + + const res = await app.inject({ + method: 'PATCH', + url: `/api/threads/${thread.id}/sessions/opus/bind`, + headers: { 'x-cat-cafe-user': 'attacker-user' }, + payload: { cliSessionId: 'cli-evil' }, + }); + + assert.equal(res.statusCode, 403); + } finally { + await app.close(); + } + }); + test('supports all valid catIds', async () => { const { app, threadStore } = await buildApp(); try { diff --git a/packages/api/test/session-chain-route.test.js b/packages/api/test/session-chain-route.test.js index 9e2ca2237..8a8bfb756 100644 --- a/packages/api/test/session-chain-route.test.js +++ b/packages/api/test/session-chain-route.test.js @@ -91,6 +91,104 @@ describe('Session Chain Routes', () => { assert.equal(res.statusCode, 403); }); + it('GET /api/threads/default/sessions allows system-owned default thread', async () => { + const store = await setup( + mockThreadStore({ + default: { id: 'default', createdBy: 'system' }, + }), + ); + store.create({ cliSessionId: 'cli-default-1', threadId: 'default', catId: 'opus', userId: 'default-user' }); + + const res = await app.inject({ + method: 'GET', + url: '/api/threads/default/sessions', + headers: { 'x-cat-cafe-user': 'default-user' }, + }); + assert.equal(res.statusCode, 200); + const body = JSON.parse(res.payload); + assert.equal(body.sessions.length, 1); + }); + + it('GET /api/threads/default/sessions filters default-thread sessions to the caller', async () => { + const store = await setup( + mockThreadStore({ + default: { id: 'default', createdBy: 'system' }, + }), + ); + store.create({ cliSessionId: 'cli-default-owner', threadId: 'default', catId: 'opus', userId: 'owner-user' }); + store.create({ cliSessionId: 'cli-default-other', threadId: 'default', catId: 'codex', userId: 'other-user' }); + + const res = await app.inject({ + method: 'GET', + url: '/api/threads/default/sessions', + headers: { 'x-cat-cafe-user': 'owner-user' }, + }); + + assert.equal(res.statusCode, 200); + const body = JSON.parse(res.payload); + assert.equal(body.sessions.length, 1); + assert.equal(body.sessions[0].userId, 'owner-user'); + }); + + it('GET /api/sessions/:sessionId allows records under system-owned default thread', async () => { + const store = await setup( + mockThreadStore({ + default: { id: 'default', createdBy: 'system' }, + }), + ); + const record = store.create({ + cliSessionId: 'cli-default-2', + threadId: 'default', + catId: 'opus', + userId: 'default-user', + }); + + const res = await app.inject({ + method: 'GET', + url: `/api/sessions/${record.id}`, + headers: { 'x-cat-cafe-user': 'default-user' }, + }); + assert.equal(res.statusCode, 200); + }); + + it('GET /api/sessions/:sessionId rejects other users on system-owned default thread', async () => { + const store = await setup( + mockThreadStore({ + default: { id: 'default', createdBy: 'system' }, + }), + ); + const record = store.create({ + cliSessionId: 'cli-default-owner', + threadId: 'default', + catId: 'opus', + userId: 'owner-user', + }); + + const res = await app.inject({ + method: 'GET', + url: `/api/sessions/${record.id}`, + headers: { 'x-cat-cafe-user': 'attacker-user' }, + }); + + assert.equal(res.statusCode, 403); + }); + + it('GET /api/threads/:threadId/sessions rejects system-owned non-default threads', async () => { + await setup( + mockThreadStore({ + 'system-thread': { id: 'system-thread', createdBy: 'system' }, + }), + ); + + const res = await app.inject({ + method: 'GET', + url: '/api/threads/system-thread/sessions', + headers: { 'x-cat-cafe-user': 'other-user' }, + }); + + assert.equal(res.statusCode, 403); + }); + // --- Normal happy-path tests (with identity) --- it('GET /api/threads/:threadId/sessions returns empty array for unknown thread', async () => { @@ -311,4 +409,55 @@ describe('Session Chain Routes', () => { const body = JSON.parse(res.payload); assert.equal(body.activeSessionId, active.id); }); + + it('POST /api/sessions/:sessionId/unseal rejects system-owned non-default threads', async () => { + const store = await setup( + mockThreadStore({ + 'system-thread': { id: 'system-thread', createdBy: 'system' }, + }), + ); + const sealed = store.create({ + cliSessionId: 'cli-system-thread', + threadId: 'system-thread', + catId: 'opus', + userId: 'system', + }); + store.update(sealed.id, { status: 'sealed', sealReason: 'threshold', sealedAt: Date.now(), updatedAt: Date.now() }); + + const res = await app.inject({ + method: 'POST', + url: `/api/sessions/${sealed.id}/unseal`, + headers: { 'x-cat-cafe-user': 'other-user' }, + }); + + assert.equal(res.statusCode, 403); + }); + + it('POST /api/sessions/:sessionId/unseal rejects other users on system-owned default thread', async () => { + const store = await setup( + mockThreadStore({ + default: { id: 'default', createdBy: 'system' }, + }), + ); + const sealed = store.create({ + cliSessionId: 'cli-default-sealed', + threadId: 'default', + catId: 'opus', + userId: 'owner-user', + }); + store.update(sealed.id, { + status: 'sealed', + sealReason: 'threshold', + sealedAt: Date.now(), + updatedAt: Date.now(), + }); + + const res = await app.inject({ + method: 'POST', + url: `/api/sessions/${sealed.id}/unseal`, + headers: { 'x-cat-cafe-user': 'attacker-user' }, + }); + + assert.equal(res.statusCode, 403); + }); }); diff --git a/packages/api/test/system-prompt-builder.test.js b/packages/api/test/system-prompt-builder.test.js index 936580030..7d7a49eea 100644 --- a/packages/api/test/system-prompt-builder.test.js +++ b/packages/api/test/system-prompt-builder.test.js @@ -862,6 +862,98 @@ describe('SystemPromptBuilder', () => { assert.ok(ctx.includes('F073'), 'Should contain feature ID'); }); + test('guide prompt emits offered transition only for a brand-new guide match', async () => { + const { buildInvocationContext } = await import('../dist/domains/cats/services/context/SystemPromptBuilder.js'); + const ctx = buildInvocationContext({ + catId: 'opus', + mode: 'independent', + teammates: [], + mcpAvailable: false, + threadId: 'thread-guide', + guideCandidate: { + id: 'add-member', + name: '添加成员', + estimatedTime: '3min', + status: 'offered', + isNewOffer: true, + }, + }); + + assert.ok(ctx.includes('Guide Matched'), 'new match should emit offer card instructions'); + assert.ok(ctx.includes('status="offered"'), 'new match should persist offered transition exactly once'); + }); + + test('guide prompt does not re-send offered transition after the guide is already offered', async () => { + const { buildInvocationContext } = await import('../dist/domains/cats/services/context/SystemPromptBuilder.js'); + const ctx = buildInvocationContext({ + catId: 'opus', + mode: 'independent', + teammates: [], + mcpAvailable: false, + threadId: 'thread-guide', + guideCandidate: { + id: 'add-member', + name: '添加成员', + estimatedTime: '3min', + status: 'offered', + isNewOffer: false, + }, + }); + + assert.ok(ctx.includes('Guide Pending'), 'existing offered guide should become a stable pending reminder'); + assert.ok(!ctx.includes('status="offered"'), 'existing offered guide must not re-send offered transition'); + assert.ok(!ctx.includes('cat_cafe_create_rich_block'), 'existing offered guide must not repeat the offer card'); + }); + + test('guide preview from offered state advances to awaiting_choice once', async () => { + const { buildInvocationContext } = await import('../dist/domains/cats/services/context/SystemPromptBuilder.js'); + const ctx = buildInvocationContext({ + catId: 'opus', + mode: 'independent', + teammates: [], + mcpAvailable: false, + threadId: 'thread-guide', + guideCandidate: { + id: 'add-member', + name: '添加成员', + estimatedTime: '3min', + status: 'offered', + userSelection: '步骤概览', + }, + }); + + assert.ok(ctx.includes('Guide Selection'), 'preview branch should still activate from offered state'); + assert.ok( + ctx.includes('status="awaiting_choice"'), + 'first preview should advance the guide to awaiting_choice before resolving steps', + ); + }); + + test('guide preview from awaiting_choice does not re-send awaiting_choice transition', async () => { + const { buildInvocationContext } = await import('../dist/domains/cats/services/context/SystemPromptBuilder.js'); + const ctx = buildInvocationContext({ + catId: 'opus', + mode: 'independent', + teammates: [], + mcpAvailable: false, + threadId: 'thread-guide', + guideCandidate: { + id: 'add-member', + name: '添加成员', + estimatedTime: '3min', + status: 'awaiting_choice', + userSelection: '步骤概览', + }, + }); + + assert.ok(ctx.includes('Guide Selection'), 'preview branch should remain available after awaiting_choice'); + assert.ok(ctx.includes('cat_cafe_guide_resolve'), 'repeated preview should still resolve and summarize steps'); + assert.ok( + !ctx.includes('status="awaiting_choice"'), + 'repeated preview must not emit an awaiting_choice -> awaiting_choice self-transition', + ); + }); + test('buildInvocationContext omits SOP hint when sopStageHint absent', async () => { const { buildInvocationContext } = await import('../dist/domains/cats/services/context/SystemPromptBuilder.js'); const ctx = buildInvocationContext({ diff --git a/packages/mcp-server/src/tools/callback-tools.ts b/packages/mcp-server/src/tools/callback-tools.ts index c7728c3e9..b23bc4990 100644 --- a/packages/mcp-server/src/tools/callback-tools.ts +++ b/packages/mcp-server/src/tools/callback-tools.ts @@ -722,6 +722,42 @@ export async function handleGetThreadCats(): Promise { return callbackGet('/api/callbacks/thread-cats'); } +// F155: Guide Engine + +export const updateGuideStateInputSchema = { + threadId: z.string().min(1).describe('Thread ID where the guide is being offered/active'), + guideId: z.string().min(1).describe('Guide ID (e.g. "add-member")'), + status: z + .enum(['offered', 'awaiting_choice', 'active', 'completed', 'cancelled']) + .describe( + 'Target guide status. Valid transitions: offered→awaiting_choice/active/cancelled, awaiting_choice→active/cancelled, active→completed/cancelled', + ), + currentStep: z.number().int().min(0).optional().describe('Current step index (only when status=active)'), +}; + +export async function handleUpdateGuideState(input: { + threadId: string; + guideId: string; + status: string; + currentStep?: number | undefined; +}): Promise { + const body: Record = { threadId: input.threadId, guideId: input.guideId, status: input.status }; + if (input.currentStep !== undefined) body['currentStep'] = input.currentStep; + return callbackPost('/api/callbacks/update-guide-state', body); +} + +export async function handleStartGuide(input: { guideId: string }): Promise { + return callbackPost('/api/callbacks/start-guide', { guideId: input.guideId }); +} + +export async function handleGuideResolve(input: { intent: string }): Promise { + return callbackPost('/api/callbacks/guide-resolve', { intent: input.intent }); +} + +export async function handleGuideControl(input: { action: string }): Promise { + return callbackPost('/api/callbacks/guide-control', { action: input.action }); +} + export const callbackTools = [ { name: 'cat_cafe_post_message', @@ -919,4 +955,50 @@ export const callbackTools = [ inputSchema: bootcampEnvCheckInputSchema, handler: handleBootcampEnvCheck, }, + // ============ F155: Guide Engine ============ + { + name: 'cat_cafe_update_guide_state', + description: + 'Update the guide session state for a thread. Must be called to persist state transitions. ' + + 'First call creates state (status must be "offered"). Subsequent calls must follow valid non-start transitions: ' + + 'offered→awaiting_choice/cancelled, awaiting_choice→cancelled, active→completed/cancelled. ' + + 'Do not use this tool to enter "active" — call cat_cafe_start_guide for offered/awaiting_choice→active so frontend start side effects run. ' + + 'One active guide per thread — complete or cancel before offering a new one.', + inputSchema: updateGuideStateInputSchema, + handler: handleUpdateGuideState, + }, + { + name: 'cat_cafe_guide_resolve', + description: + 'Match user intent to available guided flows. ' + + 'Call this when a user asks how to do something (e.g. "怎么添加成员", "how to add a member"). ' + + 'Returns a ranked list of matching guide flows with IDs, names, and descriptions. ' + + 'If matches are found, suggest the top match to the user and ask if they want to start the guide. ' + + 'On confirmation, call cat_cafe_start_guide with the matched guideId.', + inputSchema: { + intent: z.string().min(1).describe('User intent text (e.g. "添加成员", "配置飞书")'), + }, + handler: handleGuideResolve, + }, + { + name: 'cat_cafe_start_guide', + description: + 'Start an interactive guided flow on the Console frontend. ' + + 'Requires the guide to be in "offered" or "awaiting_choice" state (call cat_cafe_update_guide_state first). ' + + 'Transitions guide to "active" and emits socket event for frontend overlay.', + inputSchema: { + guideId: z.string().min(1).describe('Guide flow ID (e.g. "add-member")'), + }, + handler: handleStartGuide, + }, + { + name: 'cat_cafe_guide_control', + description: + 'Control an active guide session. Requires guide to be in "active" state. ' + + 'Actions: "next" (advance), "back" (previous step), "skip" (skip step), "exit" (cancel guide).', + inputSchema: { + action: z.enum(['next', 'back', 'skip', 'exit']).describe('Guide control action'), + }, + handler: handleGuideControl, + }, ] as const; diff --git a/packages/shared/src/types/rich.ts b/packages/shared/src/types/rich.ts index e0bc0a6ae..329edf9e0 100644 --- a/packages/shared/src/types/rich.ts +++ b/packages/shared/src/types/rich.ts @@ -84,6 +84,16 @@ export interface RichAudioBlock extends RichBlockBase { mimeType?: string; } +/** F155: Direct action for interactive options that bypass the chat message pipeline */ +export interface OptionAction { + /** Action type — 'callback' calls an API endpoint directly from the frontend */ + type: 'callback'; + /** API endpoint path (e.g. '/api/guide-actions/start') */ + endpoint: string; + /** Payload sent as JSON body to the endpoint */ + payload?: Record; +} + /** F096: Interactive rich block — user can select/confirm within the block */ export interface InteractiveOption { id: string; @@ -98,6 +108,8 @@ export interface InteractiveOption { customInput?: boolean; /** Placeholder text for the custom input field */ customInputPlaceholder?: string; + /** F155: When present, clicking this option calls the endpoint directly instead of sending a chat message */ + action?: OptionAction; } export interface RichInteractiveBlock extends RichBlockBase { diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css index e9cac40d5..cfe78a12b 100644 --- a/packages/web/src/app/globals.css +++ b/packages/web/src/app/globals.css @@ -154,6 +154,24 @@ --text-secondary: var(--cafe-text-secondary); --border-radius-base: 16px; --spacing-unit: 8px; + + /* F155: Guide Engine Tokens */ + --guide-overlay-bg: rgba(12, 16, 24, 0.62); + --guide-cutout-ring: #d4853a; + --guide-cutout-shadow: rgba(212, 133, 58, 0.35); + --guide-hud-bg: #fffdf8; + --guide-hud-border: #e7dac7; + --guide-text-primary: #2b251f; + --guide-text-secondary: #6f6257; + --guide-success: #2f9e44; + --guide-error: #d94848; + --guide-z-overlay: 1100; + --guide-z-hud: 1110; + --guide-z-pulse: 1120; + --guide-radius: 14px; + --guide-gap: 12px; + --guide-motion-fast: 160ms; + --guide-motion-normal: 260ms; } /* --- Dark Mode: override semantic tokens only --- */ @@ -322,6 +340,67 @@ body { animation: pulse-subtle 2s ease-in-out infinite; } +/* F155: Guide Engine Animations */ +@keyframes guide-breathe { + 0%, + 100% { + transform: scale(1); + opacity: 1; + } + 50% { + transform: scale(1.03); + opacity: 0.85; + } +} + +@keyframes guide-hud-enter { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes guide-success-flash { + 0% { + transform: scale(1); + } + 50% { + transform: scale(0.9); + } + 100% { + transform: scale(1); + } +} + +@keyframes guide-error-shake { + 0%, + 100% { + transform: translateX(0); + } + 25% { + transform: translateX(-3px); + } + 75% { + transform: translateX(3px); + } +} + +.animate-guide-hud-enter { + animation: guide-hud-enter 160ms ease-out; +} + +.animate-guide-success { + animation: guide-success-flash 0.4s ease-out; +} + +.animate-guide-error { + animation: guide-error-shake 0.3s ease-out; +} + @keyframes shake { 0%, 100% { diff --git a/packages/web/src/app/layout.tsx b/packages/web/src/app/layout.tsx index 8e0542490..4340b0b36 100644 --- a/packages/web/src/app/layout.tsx +++ b/packages/web/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata, Viewport } from 'next'; import { BrakeModal } from '@/components/BrakeModal'; +import { GuideOverlay } from '@/components/GuideOverlay'; import { SessionBootstrap } from '@/components/SessionBootstrap'; import { ThemeProvider } from '@/components/ThemeProvider'; import { ToastContainer } from '@/components/ToastContainer'; @@ -40,6 +41,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) {children} + diff --git a/packages/web/src/components/CatCafeHub.tsx b/packages/web/src/components/CatCafeHub.tsx index 17ccd4038..2777849c3 100644 --- a/packages/web/src/components/CatCafeHub.tsx +++ b/packages/web/src/components/CatCafeHub.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from 'react'; import { useCatData } from '@/hooks/useCatData'; import { useChatStore } from '@/stores/chatStore'; +import { useGuideStore } from '@/stores/guideStore'; import { apiFetch } from '@/utils/api-client'; import { BrakeSettingsPanel } from './BrakeSettingsPanel'; import { @@ -36,6 +37,7 @@ export { findGroupForTab, resolveRequestedHubTab } from './cat-cafe-hub.navigati export function CatCafeHub() { const hubState = useChatStore((s) => s.hubState); const closeHub = useChatStore((s) => s.closeHub); + const guideActive = useGuideStore((s) => s.session !== null); const { cats, getCatById, refresh } = useCatData(); const open = hubState?.open ?? false; @@ -167,11 +169,12 @@ export function CatCafeHub() { useEffect(() => { if (!open) return; const h = (e: KeyboardEvent) => { - if (e.key === 'Escape') closeHub(); + // Skip Escape when guide overlay is active to prevent accidental exit (KD-14) + if (e.key === 'Escape' && !guideActive) closeHub(); }; window.addEventListener('keydown', h); return () => window.removeEventListener('keydown', h); - }, [open, closeHub]); + }, [open, closeHub, guideActive]); if (!open) return null; diff --git a/packages/web/src/components/GuideOverlay.tsx b/packages/web/src/components/GuideOverlay.tsx new file mode 100644 index 000000000..6b301a9e3 --- /dev/null +++ b/packages/web/src/components/GuideOverlay.tsx @@ -0,0 +1,487 @@ +'use client'; + +import React, { Component, useEffect, useRef, useState } from 'react'; +import { useGuideEngine } from '@/hooks/useGuideEngine'; +import type { OrchestrationStep } from '@/stores/guideStore'; +import { useGuideStore } from '@/stores/guideStore'; +import { apiFetch } from '@/utils/api-client'; + +/** Error boundary — prevents guide overlay crash from taking down the whole app. */ +class GuideErrorBoundary extends Component<{ children: React.ReactNode }, { hasError: boolean }> { + state = { hasError: false }; + static getDerivedStateFromError() { + return { hasError: true }; + } + componentDidCatch(error: Error) { + console.error('[GuideOverlay] Caught error, auto-recovering:', error); + useGuideStore.getState().exitGuide(); + } + render() { + if (this.state.hasError) return null; + return this.props.children; + } +} + +/** Wrapped export with error boundary. Key on sessionId forces remount after error recovery. */ +export function GuideOverlay() { + const sessionId = useGuideStore((s) => s.session?.sessionId); + return ( + + + + ); +} + +/** + * F155: Guide Overlay (v2 — tag-based engine) + * + * - Mask + spotlight on target element (found by data-guide-id) + * - Tips from flow definition (not hardcoded) + * - Auto-advance: listen for user interaction with target (click/input/etc.) + * - HUD: only "退出" + tips + progress dots + */ +function GuideOverlayInner() { + useGuideEngine(); + const session = useGuideStore((s) => s.session); + const advanceStep = useGuideStore((s) => s.advanceStep); + const exitGuide = useGuideStore((s) => s.exitGuide); + const setPhase = useGuideStore((s) => s.setPhase); + const completionPersisted = useGuideStore((s) => s.completionPersisted); + + const [targetRect, setTargetRect] = useState(null); + const rafRef = useRef(0); + const lastRectRef = useRef<{ t: number; l: number; w: number; h: number } | null>(null); + + const currentStep = + session && session.currentStepIndex < session.flow.steps.length + ? session.flow.steps[session.currentStepIndex] + : null; + const isComplete = session ? session.phase === 'complete' : false; + const handleExit = async () => { + if (session?.threadId) { + try { + const response = await apiFetch('/api/guide-actions/cancel', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ threadId: session.threadId, guideId: session.flow.id }), + }); + if (!response.ok) { + console.error('[GuideOverlay] Failed to persist guide cancellation:', response.status); + return; + } + } catch (error) { + console.error('[GuideOverlay] Failed to persist guide cancellation:', error); + return; + } + } + exitGuide(); + }; + + // rAF loop: track target element position + useEffect(() => { + if (!session || !currentStep || isComplete) return; + lastRectRef.current = null; + let cancelled = false; + const selector = buildGuideTargetSelector(currentStep.target); + + const updateRect = () => { + if (cancelled) return; + const el = document.querySelector(selector); + if (el) { + const r = el.getBoundingClientRect(); + const prev = lastRectRef.current; + if (!prev || prev.t !== r.top || prev.l !== r.left || prev.w !== r.width || prev.h !== r.height) { + lastRectRef.current = { t: r.top, l: r.left, w: r.width, h: r.height }; + setTargetRect(r); + } + if (session.phase === 'locating') setPhase('active'); + } else { + // Target not found yet — keep locating + if (session.phase !== 'locating') setPhase('locating'); + setTargetRect(null); + } + rafRef.current = requestAnimationFrame(updateRect); + }; + + rafRef.current = requestAnimationFrame(updateRect); + return () => { + cancelled = true; + cancelAnimationFrame(rafRef.current); + }; + }, [session, currentStep, isComplete, session?.phase, setPhase]); + + // Auto-advance: listen for interaction with target element + useAutoAdvance(currentStep, advanceStep, session?.phase === 'active'); + + // Keyboard: Escape disabled during guide to prevent accidental exit (KD-14). + // Users must click the explicit "退出" button in the HUD. + useEffect(() => { + if (!session) return; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') e.preventDefault(); + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [session]); + + if (!session) return null; + + // Completion screen — dismiss blocked until backend confirms persistence + if (isComplete) { + return ( +
+
+
+
🐾
+

引导完成!

+

+ 你已经完成了「{session.flow.name}」的全部步骤。 +

+ +
+
+ ); + } + + if (!currentStep) return null; + + const pad = 8; + const cutoutStyle: React.CSSProperties = targetRect + ? { + position: 'fixed', + top: targetRect.top - pad, + left: targetRect.left - pad, + width: targetRect.width + pad * 2, + height: targetRect.height + pad * 2, + borderRadius: 'var(--guide-radius)', + boxShadow: '0 0 0 9999px var(--guide-overlay-bg)', + transition: 'all var(--guide-motion-normal) ease-out', + zIndex: 'var(--guide-z-overlay)' as unknown as number, + pointerEvents: 'none' as const, + } + : { + position: 'fixed' as const, + inset: 0, + backgroundColor: 'var(--guide-overlay-bg)', + zIndex: 'var(--guide-z-overlay)' as unknown as number, + pointerEvents: 'none' as const, + }; + + const ringStyle: React.CSSProperties = targetRect + ? { + position: 'fixed', + top: targetRect.top - pad - 2, + left: targetRect.left - pad - 2, + width: targetRect.width + pad * 2 + 4, + height: targetRect.height + pad * 2 + 4, + borderRadius: 'var(--guide-radius)', + border: '2px solid var(--guide-cutout-ring)', + boxShadow: '0 0 12px var(--guide-cutout-shadow), inset 0 0 8px var(--guide-cutout-shadow)', + transition: 'all var(--guide-motion-normal) ease-out', + zIndex: 1105, + pointerEvents: 'none' as const, + animation: 'guide-breathe 1.8s ease-in-out infinite', + } + : {}; + + const shieldZ = 1101; + const panels = targetRect ? computeShieldPanels(targetRect, pad) : null; + + return ( + <> +