release: prepare v1.27.0

This commit is contained in:
coso
2026-05-05 10:56:25 +08:00
parent 080fbf761a
commit eb5794553b
92 changed files with 9456 additions and 2219 deletions
+37 -15
View File
@@ -1,12 +1,13 @@
## Lime v1.27.0
发布日期:`2026-05-02`
发布日期:`2026-05-05`
### 发布概览
- 本次发布目标 tag 为 `v1.27.0`,重点把 Agent Knowledge 从方案文档推进到 current 主链,同时继续收紧 Agent runtime、Skill 工具门禁、模型解析和 GUI 入口的一致性。
- 版本文件、Tauri 配置、headless 配置、CLI wrapper、release updater 测试样例与发布说明已同步到 `1.27.0`
- 该版本继续坚持“一个事实源”:知识包、运行时上下文、命令契约、mock、GUI 页面和输入区发送 metadata 都收敛到同一条可验证链路。
- 本次重新覆盖 `v1.27.0` tag 前,补入会话恢复、消息投影、Harness 审核导出、Knowledge GUI 冒烟和 release build 稳定性修复。
### 用户可见更新
@@ -40,6 +41,28 @@
- 中英文 patch 增加知识库相关文案,翻译覆盖测试同步更新。
- 旧的 Agent Knowledge 探索文档收敛到 `docs/roadmap/knowledge/prd.md` 与执行计划,不再保留平行旧文档入口。
#### 5. 会话恢复、消息投影与性能稳定性
- 新增会话详情拉取、hydration、retry、metadata sync、finalize 和切换快照控制器,把 `useAgentSession` 中的会话恢复逻辑拆成可测边界,降低切换历史会话时的竞态风险。
- 新增 conversation projection store、历史消息 hydration、消息渲染窗口、timeline render 和 thread timeline window 投影,减少长历史消息列表渲染和恢复路径漂移。
- 旧会话切换失败时增加可重试/可跳过分类, transient 失败不再直接破坏当前快照;不可恢复错误仍按原错误路径处理。
- MessageList 和 Agent UI 性能指标补齐旧历史内容扫描、markdown 延迟渲染、线程 item 扫描和首帧 paint 的采集与回归。
- 语音设置页滚动聚焦修复 release build 测试环境下的 `scrollIntoView` mock 污染,移除 release 环境缺失的 `@testing-library/react` 测试依赖。
#### 6. Harness 导出、审核与权限确认状态
- Evidence Pack、Handoff bundle、Analysis handoff、Replay case 和 Review decision 统一导出 `permissionState`,能区分 `not_requested``requested``resolved``denied`
- Review decision GUI、前端 API、Rust save API 和浏览器 mock 增加 denied 权限确认保护,阻止把带拒绝权限确认的运行误保存为 `accepted`
- Harness 状态面板和人工审核弹窗补齐权限确认状态、verification outcomes、copy prompt 和保存路径回归。
- `agent_runtime_*` 网关、runtime thread read 和 mock 读取面继续保持同一 facts source,避免 Evidence / Replay / Review 各自解释权限状态。
#### 7. Release build 与本地开发 fallback
- `scripts/lib/harness-eval-history-record.test.ts` 改为异步执行外部命令,避免 Vitest worker 在 release 校验中触发 `onTaskUpdate` timeout。
- 本地浏览器开发模式下 Provider 读取失败时继续提供 Lime Hub mock,并用显式 `hasTauriRuntimeMarkers=false` 测试保护该 fallback。
- `knowledge-gui-smoke` 更新为覆盖知识库入口、Agent 知识包上下文跳转和导入视图组织入口,纳入 GUI smoke 主链。
- Warp modality runtime contract 守卫补齐 entry binding inventory 与 task index inventory 文档锚点,`npm run test:contracts` 继续覆盖治理文档缺失。
### 开发者与治理更新
#### 1. 命令边界与 mock 同步
@@ -54,6 +77,8 @@
- 新增 `docs/roadmap/knowledge/prd.md`,明确 KnowledgePack / Skill / Memory / Inspiration 边界和 P0/P1/P2 目标。
- 新增 `docs/exec-plans/agent-knowledge-implementation-plan.md`,记录 Phase 1 current 主链、验证记录与后续切片。
- Warp 和多模态运行合同文档补齐 Knowledge Context Resolver、runtime prompt stage 和执行 profile 说明。
- 新增 AgentUI conversation projection 架构、fact map、实现计划与验收文档,把消息投影和会话恢复性能治理落到 repo 内 versioned artifact。
- 新增 Warp entry binding inventory 与 task index inventory,明确 `@` / button / scene 入口只能绑定底层 runtime contract,任务索引不能反向依赖 UI 临时状态。
### 已知说明
@@ -63,23 +88,20 @@
### 校验状态
- 已完成:
- `cargo fmt --manifest-path "src-tauri/Cargo.toml" --all`
- `npm run format`
- `npm run verify:app-version`
- 本次重新覆盖 `v1.27.0` tag 前已完成:
- `npm run build`
- `npm run lint`
- `npm run typecheck`
- `npm run test:contracts`
- 已做补充检查:
- `npm test -- src/lib/governance/legacyToolPermissionGuard.test.ts`
- 待补跑:
- `npm test`
- `cargo clippy --manifest-path "src-tauri/Cargo.toml"`
- `cargo test --manifest-path "src-tauri/Cargo.toml"`
- `npm run test:contracts`
- `npm run verify:gui-smoke`
- 说明:
- 本次按发布优先先行提交 `v1.27.0`,剩余全量测试与 GUI 冒烟待事后补跑。
- 全量 `npm test` 上一轮遇到 Vitest worker `onTaskUpdate` timeout`verify:gui-smoke` 上一轮遇到临时 Cargo target 目录失效,已确认属于执行环境问题,未作为本次提交阻塞项继续追查。
- `cargo fmt --manifest-path "src-tauri/Cargo.toml" --all`
- `CARGO_TARGET_DIR="/tmp/lime-release-verify-target" cargo clippy --manifest-path "src-tauri/Cargo.toml" --all-targets --all-features`
- `cargo test --manifest-path "src-tauri/Cargo.toml"`
- `git diff --check`
- 结果说明:
- 前端全量 Vitest smart suite 46 批通过。
- Rust 单测与集成测试通过;真实联网 web search 测试保持 ignored,需要 `LIME_REAL_API_TEST=1` 时单独执行。
- GUI smoke 已复用 headless Tauri 环境完成 DevBridge、workspace ready、browser runtime、site adapters、service skill entry、runtime tool surface 和 Knowledge GUI 主路径验证。
---
@@ -1,6 +1,6 @@
# Agent Knowledge 实现执行计划
> 状态:Phase 1 current 主链已接通,项目资料能力已回流到现有 Agent 输入框;最新 GUI 最小冒烟待补跑
> 状态:Phase 1 current 主链已接通,项目资料能力已回流到现有 Agent 输入框;Knowledge 模块化产品化重构与 GUI smoke 闭环已完成
> 创建时间:2026-05-01
> 路线图来源:`docs/roadmap/knowledge/prd.md`
> 当前目标:完成 Markdown-first 项目资料的导入、整理、GUI 管理、Agent 输入框显式使用与运行时受保护上下文注入。
@@ -130,6 +130,58 @@ src-tauri/crates/knowledge
- 已执行禁名扫描,用户要求禁止出现的具体名称无命中。
- 已执行普通用户路径泄露扫描,新增主路径未再暴露“聊天任务 / 当前资料 / 查看引用 / 资料助手 / Builder 生成”等旧页面语义。
- 已重跑 `CARGO_HOME="/tmp/lime-gui-smoke-cargo-home" npm run verify:gui-smoke`,本轮进入独立 target 冷构建,已完成依赖下载并编译到主 `lime` crate 收尾阶段,但约 34 分钟仍未进入 GUI smoke 断言;该次临时进程组已中止,最新 GUI smoke 不计为通过。当前未观察到业务断言失败,剩余风险是 GUI 主路径尚未在稳定 headless 环境完成最终冒烟。
- 已完成知识库真实 GUI E2E 收口:
- 清理检查确认未残留 `knowledge-gui-smoke``smoke-knowledge-gui``lime-knowledge-e2e-target` 或专用 Chrome profile 进程。
- 初始 `npm run bridge:health -- --timeout-ms 20000` 失败,确认前端 `1420` 可访问但 DevBridge `3030` 未就绪。
- 尝试启动 `CARGO_TARGET_DIR="/tmp/lime-knowledge-e2e-target" npm run tauri:dev:headless` 避免共享 target 锁;后续用户原有 `pnpm run tauri dev` 完成启动,`npm run bridge:health -- --timeout-ms 5000` 通过。
- 通过 `http://127.0.0.1:3030/invoke` 真实调用 `knowledge_import_source``knowledge_compile_pack``knowledge_update_pack_status``knowledge_set_default_pack`seed 出 `Smoke 默认项目资料``Smoke 备用项目资料` 两份资料。
- Playwright 打开 `http://127.0.0.1:1420/`,设置 onboarding 与 `lime.knowledge.working-dir` 后进入“知识库”,验证“项目资料管理 / 当前项目资料库 / 全部项目资料 / 手动导入 / 日常使用入口 / 回到 Agent”可见。
- E2E 暴露并修复两个阻塞点:`knowledge_*``mockPriorityCommands` 强制走 mock,导致真实 DevBridge seed 数据无法进 GUI;知识页只把 localStorage 目录写入输入框,未初始化 `workingDir` 状态,导致目录列表不加载。
- 修复后 Playwright 验证 `knowledge_list_packs``knowledge_get_pack` 走真实 HTTP Bridge,页面展示两份 seed 资料、默认资料标记与管理概览 `2 份项目资料`
- 点击“用于生成”后回到现有 Agent 工作区,页面出现用户消息“请基于当前项目资料生成内容”,并通过真实 `agent_runtime_submit_turn` 自动提交;输入框主路径仍展示“整理成项目资料”,没有回到独立知识页聊天。
- 本轮控制台与页面快照扫描未发现 `ERROR``Failed to load resource``ERR_CONNECTION`,也未发现 `knowledge_builder``compiled/brief.md``.lime/knowledge``frontmatter``token``聊天任务``当前资料:``查看引用``资料助手``Builder 生成` 等普通用户主路径泄露。
- 本轮启动的隔离 target headless Tauri 冷构建已中止并确认无残留;用户原有 DevBridge 仍健康。
- 已通过 `npm run test:contracts`,确认命令契约、harness 契约和治理生成检查通过。
- 已通过 `npm test -- "src/lib/dev-bridge/safeInvoke.test.ts" "src/lib/tauri-mock/core.test.ts"`,确认移除 knowledge 优先 mock 后 fallback 与 mock 行为仍稳定。
- 已通过 `npm test -- "src/features/knowledge/KnowledgePage.test.tsx"`,确认知识页初始化目录与主路径回归稳定。
- 已通过 `npm run typecheck`
### 2026-05-05 模块化产品化重构
- 已根据用户反馈确认“知识库”应按功能模块设计,而不是继续扩张单页实现。
- 已将 `src/features/knowledge/KnowledgePage.tsx` 中的状态文案、资料类型、用户可见字段、prompt builder、metadata builder、文件列表、状态标签、状态导轨、资料卡与排障面板拆到 `domain/``agent/``components/` 子模块。
- 已将输入框内项目资料控件从通用 `InputbarComposerSection.tsx` 拆到 `src/components/agent/chat/components/Inputbar/knowledge/InputbarKnowledgeControl.tsx`,通用输入框只保留组合职责。
- 已将 Workspace 中的项目资料加载、默认资料选择、启用状态、整理资料 prompt 与资料管理跳转拆到 `src/components/agent/chat/workspace/knowledge/useWorkspaceKnowledgeRuntime.ts`,继续复用现有 Agent 发送链路。
- 已将普通用户入口文案从“整理成项目资料 / 手动导入 / 高级目录”收敛为“添加项目资料 / 补充导入 / 排障设置”,并在输入框点击“添加项目资料”后先展示说明卡,再由用户明确发送给 Agent 整理。
- 已在资料管理页增加“已添加资料 / 已整理草稿 / 已确认可用”状态导轨,资料卡主动作按状态区分“继续确认 / 用于生成”。
- 已移除资料详情里的不可用“编辑资料说明”禁用按钮,避免普通用户看到无效动作。
- 已更新 `KnowledgePage.test.tsx``Inputbar/index.test.tsx`,锁定普通首屏不展示“内部标识 / 资料文件名 / 高级:手动指定项目目录”,并覆盖输入框资料整理说明卡。
- 已更新 `scripts/knowledge-gui-smoke.mjs` 的入口文案断言,跟随“补充导入”新命名。
- 已通过 `npm test -- "src/features/knowledge/KnowledgePage.test.tsx" "src/components/agent/chat/components/Inputbar/index.test.tsx"`
- 已通过 `npm run typecheck`
- 已通过 `npm run test:contracts`
- 已执行 `npm run bridge:health -- --timeout-ms 10000`,初始 DevBridge 就绪。
- 已执行 `npm run verify:gui-smoke`workspace-ready、browser-runtime、site-adapters、agent-service-skill-entry、agent-runtime-tool-surface 与 agent-runtime-tool-surface-page 均通过;进入 `smoke:knowledge-gui` 后,`browser_execute_action` 阶段 DevBridge 中途掉线,knowledge GUI smoke 未计为通过。
- 已单独重跑 `npm run smoke:knowledge-gui -- --app-url http://127.0.0.1:1420/ --health-url http://127.0.0.1:3030/health --invoke-url http://127.0.0.1:3030/invoke --timeout-ms 600000 --interval-ms 1000`,仍在 `wait-knowledge-overview` 后的 browser action 阶段触发 DevBridge 掉线,判断为当前本地 DevBridge / CDP smoke 环境稳定性问题,需要后续先清理 dev 进程和 target lock 再复测。
- 已用 Playwright MCP 手动打开现有 Lime 页签并进入知识库,确认页面展示 `项目资料管理``添加项目资料` 空态、`补充导入``排障设置`、状态导轨,普通页面未出现内部标识、资料文件名或内部目录语义;因 DevBridge 已掉线,真实 seed -> 使用资料 -> Agent 自动发送闭环本轮未完成。
- 已清理残留 `smoke-knowledge-gui` Chrome profile 进程;隔离 target headless 冷构建进度较慢,已中止,避免长时间占用本机资源。
### 2026-05-05 模块化产品化闭环收口
- 已将 `scripts/knowledge-gui-smoke.mjs` 从 DevBridge CDP `browser_execute_action` 流程改为本地 Playwright persistent context,避免 smoke 自测递归依赖浏览器桥接并把 DevBridge 带掉。
- 已让 knowledge GUI smoke 使用真实 DevBridge seed,并创建临时 `temporary` workspace;页面进入 Agent 前会等待资料管理页解析出当前项目,确保“用于生成”回到同一现有 Agent 项目上下文。
- 已将“用于生成”从自动提交改为回到现有 Agent 后预填“请基于当前项目资料生成内容”,并保留 `knowledge_pack` metadata 作为首发 request metadata;这样普通用户可以确认后再发送,不再被突然自动执行打断。
- 已修正 `AgentChatWorkspace` 无文稿入口下 `initialUserPrompt` 的默认语义:`autoRunInitialPromptOnMount=false` 时只预填,不默认发送;只有显式自动运行入口才自动发送。
- 已让资料管理页按 `workingDir` 反查项目并记录 `selectedProjectId`,避免从知识页回 Agent 后漂移到默认项目。
- 已更新 `KnowledgePage.test.tsx`,覆盖 `workingDir -> projectId` 回填、“用于生成”预填意图与 metadata 透传;已更新 `AgentChatPage` 回归,覆盖无文稿入口 initial prompt 不自动发送。
- 已将 knowledge GUI smoke 的 Agent 断言改为普通用户真实可见的输入框状态:`项目资料:未使用` 与已预填的生成意图,而不是等待首页口号或自动发送结果。
- 已通过 `npm run smoke:knowledge-gui -- --app-url http://127.0.0.1:1420/ --health-url http://127.0.0.1:3030/health --invoke-url http://127.0.0.1:3030/invoke --timeout-ms 240000 --interval-ms 1000`
- 已通过 `npm test -- "src/features/knowledge/KnowledgePage.test.tsx" "src/components/agent/chat/components/Inputbar/index.test.tsx" "src/components/agent/chat/index.test.tsx"`,共 `164` 个相关测试通过。
- 已通过 `npm run typecheck`
- 已通过 `npm run verify:gui-smoke`,覆盖 workspace-ready、browser-runtime、site-adapters、Agent service skill entry、runtime tool surface/page 与 knowledge GUI smokeknowledge 阶段完成真实 seed、资料管理页、用于生成回 Agent、补充导入入口验证。
- 已执行 `git diff --check`
- 已执行普通用户可见路径泄露扫描,`KnowledgePage.tsx`、knowledge components、Inputbar knowledge control 与 smoke 脚本未出现内部标识、资料文件名、高级目录、`knowledge_builder``compiled/brief.md``frontmatter``tokens`
## 待完成清单
@@ -156,7 +208,9 @@ src-tauri/crates/knowledge
- [x] 下线知识页内独立聊天面板。
- [x] 将知识页定位收敛为项目资料管理页。
- [x] 补齐 knowledge DevBridge dispatcher。
- [ ] 在稳定 headless 环境补跑 `npm run verify:gui-smoke` 或至少补跑 `smoke:knowledge-gui`
- [x] 在稳定 DevBridge + Playwright 环境完成知识库 GUI 主路径 E2E:真实 seed、资料列表、用于生成回 Agent、Agent 自动发送
- [x] 完成 Knowledge 前端 feature module 拆分:domain / agent / components / Inputbar knowledge / Workspace knowledge runtime。
- [x] 在清理本地 DevBridge / CDP smoke 环境后,重跑 `smoke:knowledge-gui` 并补齐真实 seed -> 使用资料 -> 回到现有 Agent 预填生成意图闭环。
## 验证记录
@@ -210,11 +264,25 @@ npm run test:contracts
# 普通用户路径泄露扫描:新增主路径未再暴露“聊天任务 / 当前资料 / 查看引用 / 资料助手 / Builder 生成”等旧页面语义。
CARGO_HOME="/tmp/lime-gui-smoke-cargo-home" npm run verify:gui-smoke
# 2026-05-05 Agent 输入框回流后 GUI smoke 未完成:独立 target 冷构建约 34 分钟仍未进入 smoke 断言,临时进程组已中止,该次不计为通过。
npm run bridge:health -- --timeout-ms 5000
# 2026-05-05 Bridge 就绪:127.0.0.1:3030 health status=ok。
# 2026-05-05 Playwright E2E:真实 DevBridge seed 两份项目资料,进入知识库,确认项目资料管理页、全部项目资料、手动导入、用于生成可见;点击用于生成后回到现有 Agent,并通过 agent_runtime_submit_turn 自动发送“请基于当前项目资料生成内容”。
npm run test:contracts
npm test -- "src/lib/dev-bridge/safeInvoke.test.ts" "src/lib/tauri-mock/core.test.ts"
npm test -- "src/features/knowledge/KnowledgePage.test.tsx"
npm run typecheck
# 控制台 / 页面泄露扫描:无 ERROR / Failed / ERR_CONNECTION;无 knowledge_builder、compiled/brief.md、.lime/knowledge、frontmatter、token、聊天任务、当前资料:、查看引用、资料助手、Builder 生成。
npm run smoke:knowledge-gui -- --app-url "http://127.0.0.1:1420/" --health-url "http://127.0.0.1:3030/health" --invoke-url "http://127.0.0.1:3030/invoke" --timeout-ms 240000 --interval-ms 1000
npm test -- "src/features/knowledge/KnowledgePage.test.tsx" "src/components/agent/chat/components/Inputbar/index.test.tsx" "src/components/agent/chat/index.test.tsx"
npm run typecheck
npm run verify:gui-smoke
git diff --check
# 普通用户可见路径泄露扫描:KnowledgePage、knowledge components、Inputbar knowledge control 与 knowledge GUI smoke 无内部实现词命中。
```
## 后续切片
1. 在稳定 headless 环境补跑 GUI 主路径冒烟:启动 Lime,确认 Agent 输入框“整理成项目资料”、项目资料选择、“用于生成”回到 Agent、手动导入和全部资料列表可用
1. 把本轮手工 Playwright E2E 沉淀回 `scripts/knowledge-gui-smoke.mjs`,避免后续再被 CDP profile 卡死或被 mockPriority 误判
2. 继续收口普通用户语言:管理页只展示资料名称、状态、风险提醒、引用摘要和确认动作;内部文件名、Skill 名称、目录结构只保留在开发文档和测试 mock 中。
3. 为运行时 Knowledge Context Resolver 增加更细的章节选择和成本控制,但只在开发者诊断或高级设置中展示,不进入普通用户默认路径。
4.`knowledge_builder` 增加示例输入 / 输出快照测试,锁定不同 `pack_type` 的生成结构。
@@ -1,7 +1,7 @@
# AgentUI 实施进度
> 状态:进行中
> 更新时间:2026-04-30
> 更新时间:2026-05-05
> 路线图:`docs/roadmap/agentui/lime-agentui-implementation-roadmap.md`
## 主目标
@@ -10,7 +10,19 @@
## 当前阶段
P1Tab 与 Task Capsule,先落旧会话切换期间的轻量投影
P1Tab 与 Task Capsule 已完成多轮性能止血;结构主线进入 P3 的前置闭环,先做对话事实源盘点与最小 Projection Store,不再继续把状态堆进 `AgentChatWorkspace` / `useAgentSession` / `MessageList`
当前结构收敛口径:
```text
Warp runtime fact sources
-> Conversation Projection Store
-> Session / Stream / Queue / Render controllers
-> selectors
-> UI
```
`Conversation Projection Store` 是 UI projection,不是 runtime fact sourceWarp 仍是 `ModalityRuntimeContract``Execution Profile``Artifact Graph``Evidence / Replay / Task Index` 的事实源。
## 本轮执行准则更新:旧 UI 顺路清理
@@ -1740,3 +1752,606 @@ npm run verify:gui-smoke
1. 若要继续压缩真实首字,需要转向后端 runtime/provider:记录 submitOp 到 provider request、provider first byte、provider first text delta 的服务端分段。
2. 前端侧可继续做感知优化:把 `firstEventToFirstTextDeltaMs > 1000` 时的运行态文案改成更明确的“模型正在生成首字”,但不要伪造模型文本。
3. 若同一页面连续打开多个标签后仍卡顿,下一刀回到 tab/sidebar keep-alive DOM 裁剪与历史标签卸载策略。
### 2026-05-05P3 前置,AgentUI 对话投影计划落库
已完成:
- 新增 [conversation-projection-architecture.md](../roadmap/agentui/conversation-projection-architecture.md),声明对话层只做 UI projection,不新增 runtime / artifact / evidence 事实源。
- 新增 [conversation-projection-implementation-plan.md](../roadmap/agentui/conversation-projection-implementation-plan.md),把后续结构瘦身拆成:
- Phase 0:对话事实源盘点
- Phase 1Projection Store 边界
- Phase 2Session lifecycle / hydration 拆分
- Phase 3Stream submission / queue / event reducer 拆分
- Phase 4Message render projection
- Phase 5Workspace shell 瘦身
- Phase 6:性能与治理守卫
- 新增 [conversation-projection-acceptance.md](../roadmap/agentui/conversation-projection-acceptance.md),固定首页输入、新建对话、打开两个历史会话、大历史 MessageList、流式输出、Artifact 恢复的验收场景。
- 更新 [AgentUI README](../roadmap/agentui/README.md) 和 [AgentUI 实施路线图](../roadmap/agentui/lime-agentui-implementation-roadmap.md),把 projection plan 纳入 current planning source。
主线收益:
- 后续不再以“继续拆大文件”作为默认目标,而是按 `fact source -> projection store -> controllers -> selectors -> UI` 收敛。
- P0/P1 已做的性能采集可以继续用于 Phase 2 / Phase 4 验收,避免架构瘦身和性能排查脱节。
-`docs/roadmap/warp` 对齐,避免 AgentUI 再长出第二套 runtime / artifact / evidence 事实源。
下一刀:
1. 执行 Phase 0,对 `sessionId/threadId/turnId/taskId``messages/threadItems/threadTurns``thread_read``queuedTurns/pendingActions``artifact_snapshot``sessionHistoryWindow`、stream trace、MessageList render window 建 fact map。
2. 从低风险 slice 开始 Phase 1,优先选择 `stream diagnostics``message render window` 接入最小 Projection Store。
3. 补 selector 测试,证明无关切片更新不会触发 MessageList / Workspace 重渲染。
### 2026-05-05P3 第一刀,Phase 0 fact map 与最小 Projection Store
已完成:
- 新增 [conversation-projection-fact-map.md](../roadmap/agentui/conversation-projection-fact-map.md),把对话状态按 runtime identity、session/history、messages/thread projection、stream/queue/actions、artifact/evidence 分组,标明 owner、writer、readers、persistence、runtime fact source、projection-only 与 `current` 分类。
- 新增 `src/components/agent/chat/projection/conversationProjectionStore.ts`
- 定义 `Conversation Projection Store` 的最小 state shape。
- 先落 `diagnostics.streamDiagnostics` slice。
- 保留 `session / stream / queue / render` slice 占位,但只保存版本,不写 runtime 事实。
- 提供 `selectConversationStreamDiagnostics``selectLatestConversationStreamDiagnostic` selector。
- `recordAgentStreamPerformanceMetric` 同步写入原有 `agentUiPerformanceMetrics` 与新的 projection store。
- 新增 projection store 与 stream diagnostics 回归,证明 diagnostics 更新不会改变其它 slice 引用。
已验证:
```bash
npm exec -- vitest run "src/components/agent/chat/projection/conversationProjectionStore.test.ts" "src/components/agent/chat/hooks/agentStreamPerformanceMetrics.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitDraft.test.ts"
npx eslint "src/components/agent/chat/projection/conversationProjectionStore.ts" "src/components/agent/chat/projection/conversationProjectionStore.test.ts" "src/components/agent/chat/hooks/agentStreamPerformanceMetrics.ts" "src/components/agent/chat/hooks/agentStreamPerformanceMetrics.test.ts" --max-warnings 0
git diff --check -- "docs/roadmap/agentui/conversation-projection-fact-map.md" "docs/roadmap/agentui/conversation-projection-implementation-plan.md" "docs/roadmap/agentui/README.md" "docs/exec-plans/agentui-implementation-progress.md" "src/components/agent/chat/projection/conversationProjectionStore.ts" "src/components/agent/chat/projection/conversationProjectionStore.test.ts" "src/components/agent/chat/hooks/agentStreamPerformanceMetrics.ts" "src/components/agent/chat/hooks/agentStreamPerformanceMetrics.test.ts"
rg "conversation-projection-fact-map|conversationProjectionStore|recordConversationStreamDiagnostic|selectLatestConversationStreamDiagnostic" "docs" "src/components/agent/chat"
```
结果:
- Projection Store / stream diagnostics 定向回归:通过,`9` 个测试通过。
- ESLint touched files:通过。
- diff 空白检查:通过。
- 引用检查:通过。
暂未完成验证:
- TypeScript 全量 `npm run typecheck -- --pretty false` 本轮运行超过约 1 分钟仍无输出;为避免继续占用本机 CPU,已停止本轮发起的 `tsc --noEmit --pretty false` 进程。新增代码为窄边界 TS 文件,已由定向 vitest 与 ESLint 覆盖;下一轮接入 `messageRenderWindow` 前补跑统一 typecheck。
主线收益:
- Phase 0 不再只是计划,已有可维护的 fact map。
- Phase 1 有了第一个低风险 current slice,后续可按同一模式迁 `messageRenderWindow`
- 当前实现只新增 UI projection,不新增 Tauri command、不改 runtime event、不改 artifact/evidence 事实源。
下一刀:
1. 用现有 E2E 性能指标确认 MessageList 是否仍是热点。
2. 若热点成立,接入 Phase 4 的最小 `messageRenderWindow` selector,而不是继续让 `MessageList` 内部同步推导窗口。
3. 若热点转向后端首字,则先补 runtime/provider 服务端分段,不扩大前端 projection。
### 2026-05-05P3 第二刀,MessageList render window 投影外移
已完成:
- 新增 `src/components/agent/chat/projection/messageRenderWindowProjection.ts`
- `filterVisibleConversationMessages`
- `resolveConversationMessageRenderWindowSettings`
- `resolveInitialConversationRenderedMessageCount`
- `buildConversationMessageRenderWindowProjection`
- `MessageList.tsx` 改为消费 `messageRenderWindowProjection` 输出的:
- `visibleMessages`
- `renderedMessages`
- `hiddenHistoryCount`
- `shouldAutoHydrateHiddenHistory`
- 渐进渲染阈值、首帧数量、批量数量与 idle delay
- 新增 `messageRenderWindowProjection.test.ts` 覆盖:
- 空白 user 消息过滤
- 普通会话尾部窗口
- 旧会话更小首帧与禁止自动补齐
- 发送中不裁剪
主线收益:
- `MessageList` 不再直接散落“可见消息过滤 + 渐进渲染窗口 + 旧会话隐藏历史”推导,后续可以继续把 `renderedTurns / renderedThreadItems / timelineByMessageId` 外移。
- 这是 Phase 4 的最小入口,不改变 runtime event、session detail、artifact/evidence 事实源,也不引入第二套 message 数据源。
已验证:
```bash
npm exec -- vitest run "src/components/agent/chat/projection/messageRenderWindowProjection.test.ts" "src/components/agent/chat/projection/conversationProjectionStore.test.ts" "src/components/agent/chat/hooks/agentStreamPerformanceMetrics.test.ts" "src/components/agent/chat/components/MessageList.test.tsx" -t "messageRenderWindowProjection|conversationProjectionStore|agentStreamPerformanceMetrics|旧会话首帧|已分页旧会话|历史"
npx eslint "src/components/agent/chat/projection/conversationProjectionStore.ts" "src/components/agent/chat/projection/conversationProjectionStore.test.ts" "src/components/agent/chat/projection/messageRenderWindowProjection.ts" "src/components/agent/chat/projection/messageRenderWindowProjection.test.ts" "src/components/agent/chat/hooks/agentStreamPerformanceMetrics.ts" "src/components/agent/chat/hooks/agentStreamPerformanceMetrics.test.ts" "src/components/agent/chat/components/MessageList.tsx" --max-warnings 0
git diff --check -- "src/components/agent/chat/projection" "src/components/agent/chat/hooks/agentStreamPerformanceMetrics.ts" "src/components/agent/chat/hooks/agentStreamPerformanceMetrics.test.ts" "src/components/agent/chat/components/MessageList.tsx" "docs/roadmap/agentui" "docs/exec-plans/agentui-implementation-progress.md"
```
结果:
- Projection / MessageList 定向回归:通过,`19` 个测试通过。
- ESLint touched files:通过。
- diff 空白检查:通过。
- 完整 MessageList 回归:通过,`97` 个测试通过。
- TypeScript `tsc --noEmit --pretty false`:通过。
下一刀:
1. 补跑完整 `MessageList.test.tsx` 与 TypeScript。
2. 如果通过,继续把 `renderedTurns / renderedThreadItems` 提取为 `threadTimelineWindowProjection`,并用 `messageListThreadItemsScanMs` / `messageListTimelineBuildMs` 判断收益。
3. 如果 TypeScript 或完整 MessageList 回归暴露旧行为差异,先修 projection selector,不继续扩展。
### 2026-05-05P3 第三刀,Thread timeline window 投影外移
已完成:
- 新增 `src/components/agent/chat/projection/threadTimelineWindowProjection.ts`
- `resolveConversationRenderedTurns`
- `resolveConversationRenderedTurnIdSet`
- `filterConversationThreadItemsForRenderedTurns`
- `buildConversationThreadTimelineWindowProjection`
- `MessageList.tsx` 改为从 projection 层获取:
- `renderedTurns`
- `renderedTurnIdSet`
- `renderedThreadItems`
- 新增 `threadTimelineWindowProjection.test.ts` 覆盖:
- 无隐藏历史时保留全部 turns / threadItems
- 旧会话按可见 assistant 数裁剪尾部 turns
- 当前 turn 不在尾部窗口时额外保留
- 按 rendered turn 精确裁剪 threadItems
- 延迟扫描时返回空 threadItems
主线收益:
- `MessageList` 的旧会话历史窗口逻辑进一步收敛到 projection 层。
- 后续可以继续把 `timelineByMessageId / messageGroups / renderGroups` 外移,并用现有 `messageListTimelineBuildMs / messageListRenderGroupsMs` 指标判断是否值得 worker 化。
已验证:
```bash
npm exec -- vitest run "src/components/agent/chat/projection/threadTimelineWindowProjection.test.ts" "src/components/agent/chat/projection/messageRenderWindowProjection.test.ts" "src/components/agent/chat/components/MessageList.test.tsx"
npx eslint "src/components/agent/chat/projection/threadTimelineWindowProjection.ts" "src/components/agent/chat/projection/threadTimelineWindowProjection.test.ts" "src/components/agent/chat/projection/messageRenderWindowProjection.ts" "src/components/agent/chat/projection/messageRenderWindowProjection.test.ts" "src/components/agent/chat/components/MessageList.tsx" --max-warnings 0
git diff --check -- "src/components/agent/chat/projection" "src/components/agent/chat/components/MessageList.tsx"
```
结果:
- Thread / Message render projection + MessageList 回归:通过,`102` 个测试通过。
- ESLint touched files:通过。
- diff 空白检查:通过。
下一刀:
1. 补跑 TypeScript。
2. 继续外移 `timelineByMessageId / messageGroups / renderGroups``messageTimelineRenderProjection`
3. 再评估是否需要把 `buildMessageTurnTimeline` 移入 idle / worker。
### 2026-05-05P3 第四刀,Message timeline render 投影外移
已完成:
- 新增 `src/components/agent/chat/projection/messageTimelineRenderProjection.ts`
- `buildTimelineByMessageIdProjection`
- `resolveLastAssistantMessage`
- `buildCurrentTurnTimelineProjection`
- `buildMessageGroupsProjection`
- `buildMessageRenderGroupsProjection`
- `MessageList.tsx` 改为从 projection 层获取:
- `timelineByMessageId`
- `lastAssistantMessage`
- `currentTurnTimeline`
- `messageGroups`
- `renderGroups`
- 保留原有 `measureMessageListComputation` 分段,`messageListTimelineBuildMs / messageListRenderGroupsMs` 等指标仍可用于判断下一刀是否需要 worker 化。
主线收益:
- `MessageList` 中“数据投影”和“React 渲染”边界更清晰。
- Phase 4 的主要同步投影已形成独立纯函数层,后续性能优化可以优先移动 projection,而不是继续在组件内部堆判断。
已验证:
```bash
npm exec -- vitest run "src/components/agent/chat/projection/messageTimelineRenderProjection.test.ts" "src/components/agent/chat/projection/threadTimelineWindowProjection.test.ts" "src/components/agent/chat/components/MessageList.test.tsx"
npx eslint "src/components/agent/chat/projection/messageTimelineRenderProjection.ts" "src/components/agent/chat/projection/messageTimelineRenderProjection.test.ts" "src/components/agent/chat/projection/threadTimelineWindowProjection.ts" "src/components/agent/chat/projection/threadTimelineWindowProjection.test.ts" "src/components/agent/chat/components/MessageList.tsx" --max-warnings 0
git diff --check -- "src/components/agent/chat/projection" "src/components/agent/chat/components/MessageList.tsx"
```
结果:
- Message timeline projection / thread window projection / MessageList 回归:通过,`102` 个测试通过。
- ESLint touched files:通过。
- diff 空白检查:通过。
- TypeScript `tsc --noEmit --pretty false`:通过。
暂未完成验证:
- Playwright MCP 当前无法进入复用页签:MCP Chrome profile 被占用,工具提示需 `--isolated`,但仓库规则禁止为本地续测新开 isolated profile。
- `npm run verify:gui-smoke` 已尝试;第一次被既有脏改阻塞,随后阻塞文件已恢复到可编译状态并通过 TypeScript。第二次 smoke 触发独立 Cargo target 全量重编,约 `8` 分钟推进到 `836/1098` 后进程退出并停止 headless Tauri,未得到最终 GUI smoke 结论;已确认未留下本轮 `verify-gui-smoke` / 临时 target 编译进程。
下一刀:
1. DevBridge / headless Tauri 稳定后,先复跑 `npm run verify:gui-smoke`
2. 再做真实 E2E,读取 `messageListTimelineBuildMs / messageListRenderGroupsMs / longTaskMaxMs`,决定是否 worker 化。
### 2026-05-05P3 第五刀,历史消息 hydration 投影外移
已完成:
- 新增 `src/components/agent/chat/projection/historicalMessageHydrationProjection.ts`
- `hasStructuredHistoricalContentHint`
- `isHistoricalAssistantMessageHydrationCandidate`
- `buildHistoricalMarkdownHydrationTargets`
- `buildHistoricalMarkdownHydrationIndexByMessageId`
- `shouldDeferHistoricalAssistantMessageDetails`
- `countDeferredHistoricalContentParts`
- `countDeferredHistoricalMarkdown`
- `MessageList.tsx` 改为从 projection 层获取旧会话历史 markdown hydration 目标、hydration index、消息细节延迟判断、延迟 contentParts 计数和延迟 markdown 数量。
- 新增 `historicalMessageHydrationProjection.test.ts`,覆盖结构化历史内容识别、旧会话 assistant hydration 候选、目标筛选、hydration 计数和延迟统计。
主线收益:
- Phase 4 中仍留在 `MessageList` 内的历史消息 hydration 扫描被移到纯 projection,后续如果 E2E 证明 `messageListHistoricalMarkdownTargetScanMs``messageListHistoricalContentPartsScanMs` 仍高,可以直接对 projection 做 idle / worker 化。
- `MessageList` 继续向“只编排状态与渲染”收敛,不新增 runtime、artifact 或 evidence 事实源。
- 结构化历史内容识别成为可单测 selector,不再散落在组件内部。
已验证:
```bash
npm exec -- vitest run "src/components/agent/chat/projection/historicalMessageHydrationProjection.test.ts" "src/components/agent/chat/projection/messageTimelineRenderProjection.test.ts" "src/components/agent/chat/components/MessageList.test.tsx"
npx eslint "src/components/agent/chat/projection/historicalMessageHydrationProjection.ts" "src/components/agent/chat/projection/historicalMessageHydrationProjection.test.ts" "src/components/agent/chat/components/MessageList.tsx" --max-warnings 0
git diff --check -- "src/components/agent/chat/projection/historicalMessageHydrationProjection.ts" "src/components/agent/chat/projection/historicalMessageHydrationProjection.test.ts" "src/components/agent/chat/components/MessageList.tsx"
npm run typecheck -- --pretty false
```
结果:
- Historical hydration projection / Message timeline projection / MessageList 回归:通过,`102` 个测试通过。
- ESLint touched files:通过。
- diff 空白检查:通过。
- TypeScript `tsc --noEmit --pretty false`:通过。
GUI / E2E 状态:
- `npm run verify:gui-smoke` 已尝试,但脚本检测到 sqlite 构建缓存损坏并切换到新的独立 Cargo target `/var/folders/.../lime-gui-smoke-target-rebuild-1777945025159-2866`,开始 `1098` 个 crate 的全量重编;为避免继续造成 CPU/鼠标繁忙,已在约 `113/1098` 时停止本轮 smoke。
- 停止 headless Tauri 后,`npm run bridge:health -- --timeout-ms 30000` 返回 `3030` 未就绪;当前仍不把 GUI smoke 记为通过。
- Playwright MCP 仍因既有 Chrome profile 占用无法进入复用页签,工具提示要求 `--isolated`,但仓库规则禁止本地续测使用 isolated profile,因此本轮未绕规则新开浏览器。
下一刀:
1. 等 DevBridge / Playwright profile 恢复后,先按验收路径采集 `messageListHistoricalMarkdownTargetScanMs / messageListHistoricalContentPartsScanMs / messageListTimelineBuildMs / messageListRenderGroupsMs / longTaskMaxMs`
2. 如果历史 hydration 或 timeline projection 仍是热点,再把对应 projection 放到 idle / worker;否则不要盲目 worker 化。
3. 若继续结构主线,进入 Phase 2,把 `useAgentSession` 的 lifecycle / hydration controller 拆成可单测模块,减少旧会话恢复链路继续堆在 hook 内。
### 2026-05-05P3 第六刀,Session hydration controller 最小化
已完成:
- 新增 `src/components/agent/chat/hooks/sessionHydrationController.ts`
- `SESSION_DETAIL_HISTORY_LIMIT`
- `normalizeSessionDetailHistoryLimit`
- `buildSessionDetailHydrationOptions`
- `buildSessionDetailPrefetchKey`
- `buildSessionDetailPrefetchSignature`
- `isCurrentSessionHydrationRequest`
- `useAgentSession.ts` 中旧会话详情、prefetch、静默 turn 恢复、missing-from-topics 校验的 `runtime.getSession` 统一通过 `buildSessionDetailHydrationOptions()` 构造请求参数。
- `useAgentSession.ts` 中 switch finalize、deferred hydrate catch、metadata sync、load full history、silent recovery、missing session verify 的 stale/session guard 改为 `isCurrentSessionHydrationRequest()`
- 新增 `sessionHydrationController.test.ts`,固定 `historyLimit: 40``resumeSessionStartHooks` 合并、prefetch key/signature 和过期 hydrate 丢弃逻辑。
主线收益:
- Phase 2 开始从 `useAgentSession` 抽出可单测 controller,先把最容易回归成“无 limit getSession / 过期结果覆盖当前 tab”的逻辑收敛到单一入口。
- 不改变 runtime 协议、不新增 Tauri command、不改变对外 hook shape;本刀只降低旧会话恢复链路的重复与排查成本。
- 后续拆 `sessionLifecycleController / topicListController` 时,可以继续复用同一 stale guard,而不是在 hook 内复制判断。
已验证:
```bash
npm exec -- vitest run "src/components/agent/chat/hooks/sessionHydrationController.test.ts" "src/components/agent/chat/projection/historicalMessageHydrationProjection.test.ts" "src/components/agent/chat/components/MessageList.test.tsx"
npx eslint "src/components/agent/chat/hooks/sessionHydrationController.ts" "src/components/agent/chat/hooks/sessionHydrationController.test.ts" "src/components/agent/chat/hooks/useAgentSession.ts" --max-warnings 0
npm run typecheck -- --pretty false
git diff --check -- "src/components/agent/chat/hooks/sessionHydrationController.ts" "src/components/agent/chat/hooks/sessionHydrationController.test.ts" "src/components/agent/chat/hooks/useAgentSession.ts" "src/components/agent/chat/components/MessageList.tsx" "src/components/agent/chat/projection" "docs/roadmap/agentui/conversation-projection-implementation-plan.md" "docs/exec-plans/agentui-implementation-progress.md"
```
结果:
- Session hydration controller / historical hydration projection / MessageList 回归:通过,`102` 个测试通过。
- ESLint touched files:通过。
- TypeScript `tsc --noEmit --pretty false`:通过。
- diff 空白检查:通过。
GUI / E2E 状态:
- 本刀未复跑 `verify:gui-smoke`,原因仍是上一轮已确认 smoke 会因 sqlite 构建缓存损坏切到独立 Cargo target 并触发全量重编,容易复现用户反馈的 CPU/鼠标繁忙。
- Playwright MCP 仍需等待既有 profile 释放;不使用 isolated profile 绕过仓库规则。
下一刀:
1. 继续 Phase 2:抽 `sessionDetailFetchController`,把 `loadRuntimeSessionDetail` 的 prefetch 命中、fetch start/success/error metrics 和 retry 决策从 hook 中移出。
2. 或等待 GUI 环境恢复后,先采集旧会话 A/B 打开和切换指标,再决定是否优先 worker 化 MessageList projection。
### 2026-05-05P3 第七刀,Session detail fetch controller
已完成:
- 新增 `src/components/agent/chat/hooks/sessionDetailFetchController.ts`
- `createSessionDetailPrefetchRegistry`
- `loadSessionDetailWithPrefetch`
- `SessionDetailFetchEvent`
- `SessionDetailFetchMode`
- `useAgentSession.ts``loadRuntimeSessionDetail` 改为委托 `loadSessionDetailWithPrefetch`
- 保留 `session.switch.fetchDetail.start / prefetch / success / error` 性能指标。
- 保留 `switchTopic.fetchDetail.*` AgentDebug 日志。
- prefetch 命中、prefetch fallback、resume 跳过 prefetch、registry 只删除当前 promise 进入 controller 单测。
- `sessionDetailPrefetchRegistry` 从裸 `Map` 改为 controller registry,避免 future prefetch 清理误删较新 promise。
主线收益:
- Phase 2 继续把 `useAgentSession` 从“状态 + 请求 + metrics + retry + registry”巨石里拆开;本刀先收敛 detail fetch 和 prefetch 复用,不改变 UI 行为。
- 旧会话打开时最关键的 detail fetch 分段现在有单独 controller 测试,后续要做取消、优先级或批量请求时不用再直接改 hook 主体。
- 继续保持 `historyLimit: 40``sessionHydrationController` 单一入口提供,避免 prefetch / switch / recovery 重新分叉。
已验证:
```bash
npm exec -- vitest run "src/components/agent/chat/hooks/sessionDetailFetchController.test.ts" "src/components/agent/chat/hooks/sessionHydrationController.test.ts" "src/components/agent/chat/projection/historicalMessageHydrationProjection.test.ts"
npm exec -- vitest run "src/components/agent/chat/hooks/useAsterAgentChat.test.tsx" -t "预取|stale 快照" --hookTimeout 180000 --testTimeout 120000
npx eslint "src/components/agent/chat/hooks/sessionDetailFetchController.ts" "src/components/agent/chat/hooks/sessionDetailFetchController.test.ts" "src/components/agent/chat/hooks/sessionHydrationController.ts" "src/components/agent/chat/hooks/sessionHydrationController.test.ts" "src/components/agent/chat/hooks/useAgentSession.ts" --max-warnings 0
npm run typecheck -- --pretty false
git diff --check -- "src/components/agent/chat/hooks/sessionDetailFetchController.ts" "src/components/agent/chat/hooks/sessionDetailFetchController.test.ts" "src/components/agent/chat/hooks/sessionHydrationController.ts" "src/components/agent/chat/hooks/sessionHydrationController.test.ts" "src/components/agent/chat/hooks/useAgentSession.ts" "docs/roadmap/agentui/conversation-projection-implementation-plan.md" "docs/exec-plans/agentui-implementation-progress.md"
```
结果:
- Fetch / hydration controller / historical hydration projection:通过,`13` 个测试通过。
- `useAsterAgentChat` 旧会话预取和 stale 快照定向:通过,`3` 个测试通过。
- ESLint touched files:通过。
- TypeScript `tsc --noEmit --pretty false`:通过。
- diff 空白检查:通过。
GUI / E2E 状态:
- 本刀仍未复跑 `verify:gui-smoke`,理由同上一刀:当前 smoke 会触发独立 Rust target 全量重编,容易造成 CPU 和鼠标繁忙;本刀是纯前端 controller 抽取,先用 hook / controller 单测覆盖行为。
- Playwright MCP 仍等待 profile 释放;不使用 isolated profile 绕过仓库规则。
下一刀:
1. 继续 Phase 2:抽 `sessionHydrationRetryController`,把 deferred hydrate retry / transient skip / error fallback 从 `switchTopic` 内联函数里移出。
2. 或恢复 GUI 环境后先做旧会话 A/B 打开与切换采样,确认是否还需要优先处理 MessageList worker 化。
### 2026-05-05P3 第八刀,Deferred hydration retry controller
已完成:
- 新增 `src/components/agent/chat/hooks/sessionHydrationRetryController.ts`
- `resolveDeferredSessionHydrationErrorAction`
- `DeferredSessionHydrationErrorAction`
- `DeferredSessionHydrationRetryContext`
- `DeferredSessionHydrationSkipContext`
- `useAgentSession.ts` 中 deferred cached topic hydrate 的 catch 分支改为委托 retry controller
- retryable transient 且未达上限:返回 `retry`,继续写 `session.switch.fetchDetail.retryScheduled` 并调度 idle retry。
- transient 且不再重试:返回 `skip`,保留缓存快照并写 `session.switch.fetchDetail.retrySkipped`
- fatal unknown:返回 `fail`,继续走 `handleSwitchTopicError(..., preserveCurrentSnapshot: true)`
- 新增 `sessionHydrationRetryController.test.ts`,覆盖 bridge health retry、connection retry 达上限跳过、timeout 直接跳过、unknown fatal fallback。
主线收益:
- Phase 2 继续把 `switchTopic` 的旧会话恢复分支从“请求 + stale guard + retry 分类 + metrics + 调度”拆开;本刀只抽 retry 决策,不改变调度行为。
- deferred hydrate 的错误处理有纯函数测试,后续如果要调整 retry 次数、退避策略或按错误类型降级,不需要在 `useAgentSession` 巨石里改条件。
- 继续保护旧会话打开体验:短暂 bridge / network 问题只保留 cached snapshot,不把用户已经看到的旧会话清空。
已验证:
```bash
npm exec -- vitest run "src/components/agent/chat/hooks/sessionHydrationRetryController.test.ts" "src/components/agent/chat/hooks/sessionDetailFetchController.test.ts" "src/components/agent/chat/hooks/sessionHydrationController.test.ts"
npm exec -- vitest run "src/components/agent/chat/hooks/useAsterAgentChat.test.tsx" -t "stale 快照|预取" --hookTimeout 180000 --testTimeout 120000
npx eslint "src/components/agent/chat/hooks/sessionHydrationRetryController.ts" "src/components/agent/chat/hooks/sessionHydrationRetryController.test.ts" "src/components/agent/chat/hooks/sessionDetailFetchController.ts" "src/components/agent/chat/hooks/sessionDetailFetchController.test.ts" "src/components/agent/chat/hooks/useAgentSession.ts" --max-warnings 0
npm run typecheck -- --pretty false
git diff --check -- "src/components/agent/chat/hooks/sessionHydrationRetryController.ts" "src/components/agent/chat/hooks/sessionHydrationRetryController.test.ts" "src/components/agent/chat/hooks/sessionDetailFetchController.ts" "src/components/agent/chat/hooks/sessionDetailFetchController.test.ts" "src/components/agent/chat/hooks/sessionHydrationController.ts" "src/components/agent/chat/hooks/sessionHydrationController.test.ts" "src/components/agent/chat/hooks/useAgentSession.ts" "docs/roadmap/agentui/conversation-projection-implementation-plan.md" "docs/exec-plans/agentui-implementation-progress.md"
```
结果:
- Retry / fetch / hydration controller:通过,`12` 个测试通过。
- `useAsterAgentChat` 旧会话预取和 stale 快照定向:通过,`3` 个测试通过。
- ESLint touched files:通过。
- TypeScript `tsc --noEmit --pretty false`:通过。
- diff 空白检查:通过。
GUI / E2E 状态:
- 本刀未复跑 `verify:gui-smoke`,原因同前:当前 smoke 会切到独立 Rust target 全量重编,容易造成 CPU 和鼠标繁忙;本刀是纯前端 controller 抽取,先用定向行为测试收口。
- Playwright MCP 仍等待 profile 释放;不使用 isolated profile 绕过仓库规则。
下一刀:
1. 继续 Phase 2:抽 `sessionSwitchSnapshotController`,把 cached snapshot apply、pending shell、localSnapshotOverride 判断从 `switchTopic` 主体中移出。
2. 或恢复 GUI 环境后先采集旧会话 A/B 打开和切换指标,决定是否进入 MessageList projection idle / worker 化。
### 2026-05-05P3 第九刀,Session switch snapshot controller
已完成:
- 新增 `src/components/agent/chat/hooks/sessionSwitchSnapshotController.ts`
- `shouldLoadCachedTopicSnapshot`
- `shouldApplyCachedTopicSnapshot`
- `shouldRefreshCachedSnapshotImmediately`
- `shouldApplyPendingSessionShell`
- `buildSessionSwitchStartMetricContext`
- `buildSessionSwitchDeferHydrationMetricContext`
- `buildPendingSessionShellMetricContext`
- `buildSessionSwitchLocalSnapshotOverride`
- `useAgentSession.ts``switchTopic` 的 cached snapshot 加载/应用、stale/running 立即刷新、defer metric、pending shell metric、`localSnapshotOverride` 引用判定改为委托 controller。
- 新增 `sessionSwitchSnapshotController.test.ts`,覆盖当前会话不重复加载 snapshot、stale/running/waiting 立即刷新、无 snapshot 时应用 pending shell、指标上下文和 local snapshot 引用匹配。
主线收益:
- Phase 2 继续瘦 `switchTopic`snapshot 相关判断不再与 detail fetch、retry、metadata sync 混在同一个长函数里。
- `localSnapshotOverride` 的引用匹配规则有单测保护,避免后续改动误把 stale cached snapshot 与当前 UI 状态错配,导致旧会话恢复覆盖当前 tab。
- pending shell 的触发条件可单测,继续保护“打开无缓存旧会话先显示壳,不等 getSession 完成”的体验。
已验证:
```bash
npm exec -- vitest run "src/components/agent/chat/hooks/sessionSwitchSnapshotController.test.ts" "src/components/agent/chat/hooks/sessionHydrationRetryController.test.ts" "src/components/agent/chat/hooks/sessionDetailFetchController.test.ts" "src/components/agent/chat/hooks/sessionHydrationController.test.ts"
npm exec -- vitest run "src/components/agent/chat/hooks/useAsterAgentChat.test.tsx" -t "stale 快照|预取|pendingShell|pending shell|空白" --hookTimeout 180000 --testTimeout 120000
npx eslint "src/components/agent/chat/hooks/sessionSwitchSnapshotController.ts" "src/components/agent/chat/hooks/sessionSwitchSnapshotController.test.ts" "src/components/agent/chat/hooks/sessionHydrationRetryController.ts" "src/components/agent/chat/hooks/sessionDetailFetchController.ts" "src/components/agent/chat/hooks/sessionHydrationController.ts" "src/components/agent/chat/hooks/useAgentSession.ts" --max-warnings 0
npm run typecheck -- --pretty false
git diff --check -- "src/components/agent/chat/hooks/sessionSwitchSnapshotController.ts" "src/components/agent/chat/hooks/sessionSwitchSnapshotController.test.ts" "src/components/agent/chat/hooks/sessionHydrationRetryController.ts" "src/components/agent/chat/hooks/sessionHydrationRetryController.test.ts" "src/components/agent/chat/hooks/sessionDetailFetchController.ts" "src/components/agent/chat/hooks/sessionDetailFetchController.test.ts" "src/components/agent/chat/hooks/sessionHydrationController.ts" "src/components/agent/chat/hooks/sessionHydrationController.test.ts" "src/components/agent/chat/hooks/useAgentSession.ts" "docs/roadmap/agentui/conversation-projection-implementation-plan.md" "docs/exec-plans/agentui-implementation-progress.md"
```
结果:
- Snapshot / retry / fetch / hydration controller:通过,`17` 个测试通过。
- `useAsterAgentChat` 旧会话预取、stale 快照和相关空白消息定向:通过,`7` 个测试通过。
- ESLint touched files:通过。
- TypeScript `tsc --noEmit --pretty false`:通过。
- diff 空白检查:通过。
GUI / E2E 状态:
- 本刀未复跑 `verify:gui-smoke`,原因同前:当前 smoke 会切到独立 Rust target 全量重编,容易造成 CPU 和鼠标繁忙;本刀是纯前端 controller 抽取,先用定向行为测试收口。
- Playwright MCP 仍等待 profile 释放;不使用 isolated profile 绕过仓库规则。
下一刀:
1. 继续 Phase 2:抽 `sessionMetadataSyncController`,把 finalize 后的 accessMode / provider / executionStrategy fallback patch 与低优先级 sync 决策从 `finalizeResolvedTopicDetail` 中移出。
2. 或恢复 GUI 环境后先采集旧会话 A/B 打开和切换指标,决定是否进入 MessageList projection idle / worker 化。
### 2026-05-05P3 第十刀,Session metadata sync controller
已完成:
- 新增 `src/components/agent/chat/hooks/sessionMetadataSyncController.ts`
- `buildSessionMetadataSyncPlan`
- `buildSessionSwitchSuccessMetricContext`
- `resolveSessionExecutionStrategySource`
- `executeSessionMetadataSync`
- `useAgentSession.ts``finalizeResolvedTopicDetail` 的 metadata fallback 决策改为委托 controller
- accessMode 来源解析与是否本地持久化。
- provider/model fallback patch。
- executionStrategy fallback patch。
- switch success metric 的 source 字段。
- 优先 `updateSessionMetadata`,缺少批量命令时回退到 `setSessionAccessMode / setSessionProviderSelection / setSessionExecutionStrategy`
- 新增 `sessionMetadataSyncController.test.ts`,覆盖 runtime source 不回填、session storage fallback patch、workspace default patch、metric context、批量 sync、分散 sync fallback。
主线收益:
- Phase 2 继续瘦 `finalizeResolvedTopicDetail`:会话详情应用、成功指标、metadata fallback、低优先级 sync 不再全部堆在一个长函数里。
- P1 里提出的“三个迁移回填 invoke 合并或去重”现在有了明确 controller 边界;当前仍优先使用已有 `updateSessionMetadata` 批量命令,fallback 分散命令只在批量命令不存在时使用。
- 模型、权限、执行策略恢复来源有单测保护,降低旧会话切换后选择器错恢复的风险。
已验证:
```bash
npm exec -- vitest run "src/components/agent/chat/hooks/sessionMetadataSyncController.test.ts" "src/components/agent/chat/hooks/sessionSwitchSnapshotController.test.ts" "src/components/agent/chat/hooks/sessionHydrationRetryController.test.ts" "src/components/agent/chat/hooks/sessionDetailFetchController.test.ts" "src/components/agent/chat/hooks/sessionHydrationController.test.ts"
npm exec -- vitest run "src/components/agent/chat/hooks/useAsterAgentChat.test.tsx" -t "模型|权限|执行策略|metadata|stale 快照|预取" --hookTimeout 180000 --testTimeout 120000
npx eslint "src/components/agent/chat/hooks/sessionMetadataSyncController.ts" "src/components/agent/chat/hooks/sessionMetadataSyncController.test.ts" "src/components/agent/chat/hooks/sessionSwitchSnapshotController.ts" "src/components/agent/chat/hooks/sessionHydrationRetryController.ts" "src/components/agent/chat/hooks/sessionDetailFetchController.ts" "src/components/agent/chat/hooks/sessionHydrationController.ts" "src/components/agent/chat/hooks/useAgentSession.ts" --max-warnings 0
npm run typecheck -- --pretty false
git diff --check -- "src/components/agent/chat/hooks/sessionMetadataSyncController.ts" "src/components/agent/chat/hooks/sessionMetadataSyncController.test.ts" "src/components/agent/chat/hooks/sessionSwitchSnapshotController.ts" "src/components/agent/chat/hooks/sessionSwitchSnapshotController.test.ts" "src/components/agent/chat/hooks/sessionHydrationRetryController.ts" "src/components/agent/chat/hooks/sessionHydrationRetryController.test.ts" "src/components/agent/chat/hooks/sessionDetailFetchController.ts" "src/components/agent/chat/hooks/sessionDetailFetchController.test.ts" "src/components/agent/chat/hooks/sessionHydrationController.ts" "src/components/agent/chat/hooks/sessionHydrationController.test.ts" "src/components/agent/chat/hooks/useAgentSession.ts" "docs/roadmap/agentui/conversation-projection-implementation-plan.md" "docs/exec-plans/agentui-implementation-progress.md"
```
结果:
- Metadata / snapshot / retry / fetch / hydration controller:通过,`23` 个测试通过。
- `useAsterAgentChat` 模型、权限、metadata、stale 快照、预取定向:通过,`25` 个测试通过。
- ESLint touched files:通过。
- TypeScript `tsc --noEmit --pretty false`:通过。
- diff 空白检查:通过。
GUI / E2E 状态:
- 本刀未复跑 `verify:gui-smoke`,原因同前:当前 smoke 会切到独立 Rust target 全量重编,容易造成 CPU 和鼠标繁忙;本刀是纯前端 controller 抽取,先用定向行为测试收口。
- Playwright MCP 仍等待 profile 释放;不使用 isolated profile 绕过仓库规则。
下一刀:
1. 继续 Phase 2:抽 `sessionFinalizeController`,把 workspace mismatch、runtime/topic/shadow source 汇总和 apply detail plan 从 `finalizeResolvedTopicDetail` 中继续移出。
2. 或恢复 GUI 环境后先采集旧会话 A/B 打开和切换指标,决定是否进入 MessageList projection idle / worker 化。
### 2026-05-05P3 第十一刀,Session finalize controller
已完成:
- 新增 `src/components/agent/chat/hooks/sessionFinalizeController.ts`
- `resolveSessionKnownWorkspaceId`
- `isCrossWorkspaceSessionDetail`
- `buildCrossWorkspaceSessionRestoreContext`
- `buildSessionWorkspaceRestorePlan`
- `resolveShadowSessionExecutionStrategyFallback`
- `resolveSessionExecutionStrategyOverride`
- `useAgentSession.ts``finalizeResolvedTopicDetail` 的跨 workspace restore guard、runtime/topic/shadow workspace 汇总、shadow execution strategy fallback 与最终 `executionStrategyOverride` 改为委托 controller。
- 新增 `sessionFinalizeController.test.ts`,覆盖 workspace 优先级、跨 workspace 拒绝条件、拒绝日志上下文、shadow execution strategy fallback 与最终执行策略优先级。
主线收益:
- Phase 2 继续瘦 `finalizeResolvedTopicDetail`:会话详情应用前的 restore guard 与执行策略恢复规则不再和 UI state apply、metadata sync、topic upsert 混在一起。
- 旧会话恢复的 workspace 保护有单测覆盖,降低多 tab / 多 workspace 切换时旧会话错误覆盖当前工作区的风险。
- 执行策略恢复的 `runtime -> topic -> shadow -> react` 优先级有独立测试,后续排查首字慢或策略错恢复时可以直接定位到 finalize controller。
已验证:
```bash
npm exec -- vitest run "src/components/agent/chat/hooks/sessionFinalizeController.test.ts" "src/components/agent/chat/hooks/sessionMetadataSyncController.test.ts" "src/components/agent/chat/hooks/sessionSwitchSnapshotController.test.ts" "src/components/agent/chat/hooks/sessionHydrationRetryController.test.ts" "src/components/agent/chat/hooks/sessionDetailFetchController.test.ts" "src/components/agent/chat/hooks/sessionHydrationController.test.ts"
npm exec -- vitest run "src/components/agent/chat/hooks/useAsterAgentChat.test.tsx" -t "模型|权限|执行策略|metadata|stale 快照|预取|workspace|跨工作区" --hookTimeout 180000 --testTimeout 120000
npx eslint "src/components/agent/chat/hooks/sessionFinalizeController.ts" "src/components/agent/chat/hooks/sessionFinalizeController.test.ts" "src/components/agent/chat/hooks/sessionMetadataSyncController.ts" "src/components/agent/chat/hooks/sessionSwitchSnapshotController.ts" "src/components/agent/chat/hooks/sessionHydrationRetryController.ts" "src/components/agent/chat/hooks/sessionDetailFetchController.ts" "src/components/agent/chat/hooks/sessionHydrationController.ts" "src/components/agent/chat/hooks/useAgentSession.ts" --max-warnings 0
npm run typecheck -- --pretty false
```
结果:
- Finalize / metadata / snapshot / retry / fetch / hydration controller:通过,`28` 个测试通过。
- `useAsterAgentChat` 模型、权限、metadata、stale 快照、预取、workspace 定向:通过,`29` 个测试通过。
- ESLint touched files:通过。
- TypeScript `tsc --noEmit --pretty false`:通过。
GUI / E2E 状态:
- 本刀未复跑 `verify:gui-smoke`,原因同前:当前 smoke 会切到独立 Rust target 全量重编,容易造成 CPU 和鼠标繁忙;本刀是纯前端 controller 抽取,先用定向行为测试收口。
- Playwright MCP 仍等待 profile 释放;不使用 isolated profile 绕过仓库规则。
下一刀:
1. 继续 Phase 2:抽 `sessionPostFinalizePersistenceController``sessionFinalizeMetadataScheduler`,把 workspace 保存、topic workspace upsert、provider preference apply、metadata idle scheduler 从 `finalizeResolvedTopicDetail` 主体继续移出。
2. 恢复 GUI 环境后按 `conversation-projection-acceptance.md` 采集旧会话 A/B 打开、切换与首字指标,决定是否进入 MessageList idle / worker 化。
### 2026-05-05P3 第十二刀,Session metadata sync scheduler
已完成:
- 新增 `src/components/agent/chat/hooks/sessionMetadataSyncScheduler.ts`
- `buildSessionMetadataSyncBrowserSkipEvent`
- `buildSessionMetadataSyncStaleSkipEvent`
- `scheduleSessionMetadataSync`
- `useAgentSession.ts``finalizeResolvedTopicDetail` 的 metadata idle 调度改为委托 scheduler
- 浏览器桥接下跳过低优先级 runtime invoke。
- 调度前取消上一条 pending metadata sync。
- idle 执行时统一 stale request/session guard。
- 成功后回写 provider preference / execution strategy 同步标记。
- 失败时保留现有 warn 行为。
- 新增 `sessionMetadataSyncScheduler.test.ts`,覆盖无 patch 不调度、无 invoke 跳过、取消旧调度、按 idle 参数调度、stale 跳过与失败回调。
主线收益:
- Phase 2 继续瘦 `finalizeResolvedTopicDetail`metadata patch 的“是否调度、何时调度、如何判过期、如何执行”不再内联在会话详情应用主链里。
- P1 的“三个回填 invoke 占用 invoke 通道”现在被明确约束在低优先级 scheduler 边界内;后续如果继续合并、降频或批处理,只需要改 scheduler/controller,不再穿透 hook 主体。
- 旧会话切换时 metadata sync 的 stale guard 有单测保护,避免 A/B 历史会话快速切换后旧调度写回当前会话。
已验证:
```bash
npm exec -- vitest run "src/components/agent/chat/hooks/sessionMetadataSyncScheduler.test.ts" "src/components/agent/chat/hooks/sessionMetadataSyncController.test.ts" "src/components/agent/chat/hooks/sessionFinalizeController.test.ts" "src/components/agent/chat/hooks/sessionSwitchSnapshotController.test.ts" "src/components/agent/chat/hooks/sessionHydrationRetryController.test.ts" "src/components/agent/chat/hooks/sessionDetailFetchController.test.ts" "src/components/agent/chat/hooks/sessionHydrationController.test.ts"
npm exec -- vitest run "src/components/agent/chat/hooks/useAsterAgentChat.test.tsx" -t "模型|权限|执行策略|metadata|stale 快照|预取|workspace|跨工作区" --hookTimeout 180000 --testTimeout 120000
npx eslint "src/components/agent/chat/hooks/sessionMetadataSyncScheduler.ts" "src/components/agent/chat/hooks/sessionMetadataSyncScheduler.test.ts" "src/components/agent/chat/hooks/sessionMetadataSyncController.ts" "src/components/agent/chat/hooks/sessionFinalizeController.ts" "src/components/agent/chat/hooks/sessionSwitchSnapshotController.ts" "src/components/agent/chat/hooks/sessionHydrationRetryController.ts" "src/components/agent/chat/hooks/sessionDetailFetchController.ts" "src/components/agent/chat/hooks/sessionHydrationController.ts" "src/components/agent/chat/hooks/useAgentSession.ts" --max-warnings 0
npm run typecheck -- --pretty false
```
结果:
- Scheduler / finalize / metadata / snapshot / retry / fetch / hydration controller:通过,`33` 个测试通过。
- `useAsterAgentChat` 模型、权限、metadata、stale 快照、预取、workspace 定向:通过,`29` 个测试通过。
- ESLint touched files:通过。
- TypeScript `tsc --noEmit --pretty false`:通过。
GUI / E2E 状态:
- 本刀未复跑 `verify:gui-smoke`,原因同前:当前 smoke 会切到独立 Rust target 全量重编,容易造成 CPU 和鼠标繁忙;本刀是纯前端 scheduler/controller 抽取,先用定向行为测试收口。
- Playwright MCP 仍等待 profile 释放;不使用 isolated profile 绕过仓库规则。
下一刀:
1. 继续 Phase 2:抽 `sessionPostFinalizePersistenceController`,把 workspace 保存、topic workspace upsert、provider preference apply 从 `finalizeResolvedTopicDetail` 主体继续移出。
2. 恢复 GUI 环境后按 `conversation-projection-acceptance.md` 采集旧会话 A/B 打开、切换与首字指标,决定是否进入 MessageList idle / worker 化。
@@ -445,3 +445,18 @@ runtime identity
- 2026-05-05:继续第七十八刀 `Phase 5/6 permission evidence and replay facts`Evidence Pack 的 `thread.runtimeFacts``observabilitySummary.runtimeFacts` 现在导出 `permissionState``signalCoverage` 增加 `permissionState` 覆盖项;Replay `runtimeFacts` 同步携带 `permissionState`,并在 `requires_confirmation` 或存在 `blockingProfileKeys` 时生成 blocking checks,要求回放不能把声明态权限误判为已获真实授权。该刀只增强审计/回放事实源,不创建 pending approval、不阻断执行、不新增命令或云策略。
- 2026-05-05:继续第七十九刀 `Phase 5/6 permission confirmation state seed``SessionExecutionRuntimePermissionState` 新增 `confirmationStatus / confirmationRequestId / confirmationSource`,当前声明态 `requires_confirmation` 明确写为 `confirmationStatus=not_requested``confirmationRequestId=null``confirmationSource=declared_profile_only`;内部辅助任务写 `not_required`。前端 execution runtime 类型同步该字段。该刀只建立真实授权接入前的单一状态槽,不复用 `ApprovalRequest` 伪造 pending request、不阻断执行、不新增命令。
- 2026-05-05:继续第八十刀 `Phase 5/6 live permission confirmation metadata``permission_review` runtime status metadata 现在同步携带 `confirmation_status / confirmation_request_id / confirmation_source`,前端 `agentProtocol` 会保留这些字段;`requires_confirmation` 的 live event 因此能明确表达“需要确认,但尚未发起真实审批请求”。该刀仍不创建 pending approval、不阻断执行、不新增命令。
- 2026-05-05:继续第八十一刀 `Phase 5/6 thread read pending approval projection``AgentRuntimeThreadReadModel.permission_state` 现在会从同一 thread read 中真实存在的 pending tool `ApprovalRequest` 派生 `confirmationStatus=requested``confirmationRequestId=<request_id>``confirmationSource=runtime_action_required``runtime_summary` 同步暴露 `permissionConfirmation*` 摘要。该刀只把已有真实 `action_required` 投影到权限确认状态槽,不创建审批、不改变 native approval 行为、不阻断执行、不新增命令。
- 2026-05-05:继续第八十二刀 `Phase 5/6 resolved approval projection`thread read 现在会从已完成或失败的真实 tool `ApprovalRequest.response.confirmed` 派生 `confirmationStatus=resolved / denied`,并保留真实 request id 与 `runtime_action_required` 来源。该刀仍只做读取模型投影,不改变审批提交、权限 enforcement 或执行恢复语义。
- 2026-05-05:继续第八十三刀 `Phase 5/6 replay denied confirmation blocking`Replay blocking checks 现在区分 permission confirmation 状态:`denied` 会生成明确阻断检查,`resolved` 不再被误报为“仍需确认”。该刀只修正 replay/grader 判定语义,不新增命令、不改审批执行、不接 GUI 或云策略。
- 2026-05-05:继续第八十四刀 `Phase 5/6 evidence permission confirmation coverage`Evidence Pack 的 `signalCoverage.permissionState` 现在会区分确认结果:`denied` 标为 `blocked` 并写入真实 request id`resolved` 保持 `exported` 并说明已通过。该刀让审计摘要与 Replay 阻断语义对齐,不新增命令、不改审批执行、不接 GUI 或云策略。
- 2026-05-05:继续第八十五刀 `Phase 5/6 evidence denied confirmation summary`Evidence Pack 的 `knownGaps``summary.md` 现在会把 `confirmationStatus=denied` 暴露为人眼可见的阻断风险,摘要同时输出 `当前阻断信号`。该刀只增强导出解释,不改审批执行、不新增命令、不接 GUI 或云策略。
- 2026-05-05:继续第八十六刀 `Phase 5/6 handoff permission confirmation summary`Handoff bundle 的 `plan.md / handoff.md / review-summary.md / progress.json` 与 Analysis handoff 的 `analysis-brief.md / analysis-context.json / copy_prompt` 现在同步读取 `thread_read.permission_state.confirmationStatus``denied` 会作为不能成功交付的阻断风险输出真实 request id/source`resolved` 会明确解释真实权限确认已通过;该刀只统一交接/外部分析摘要语义,不改审批执行、不新增命令、不接 GUI 或云策略。
- 2026-05-05:继续第八十七刀 `Phase 5/6 review decision permission confirmation summary`Review decision 模板现在从 Analysis handoff 的结构化 context 读取 `permissionConfirmationStatus / RequestId / Source / Summary`,并写入 `review-decision.md / review-decision.json` 的审核上下文、动态 checklist 与建议后续动作;`denied` 默认提示不能标记为 accepted,`resolved` 提醒不要误判为待处理阻塞。该刀只补人工审核记录的授权结论对齐,不改审批执行、不新增命令、不接 GUI 或云策略。
- 2026-05-05:继续第八十八刀 `Phase 5/6 review decision API permission confirmation contract``agent_runtime_export_review_decision_template` / `agent_runtime_save_review_decision` 的返回模型现在把 `permissionConfirmation*` 摘要提升到 API 顶层,前端 `AgentRuntimeReviewDecisionTemplate` 类型、normalizer、DevBridge mock 与 `src/lib/api/agent.test.ts` 已同步覆盖 camelCase / snake_caseGUI/API 读取 review decision 时不会再丢掉 denied/resolved 权限确认结论。该刀只固定读取契约,不改 UI 交互、不改审批执行、不新增命令、不接云策略。
- 2026-05-05:继续第八十九刀 `Phase 5/6 review decision GUI permission confirmation surface`HarnessStatusPanel 的人工审核记录卡片现在直接展示 `permissionConfirmationStatus / Summary``denied` 会出现红色阻断提示,提醒当前 review decision 不能作为成功交付证据;对应组件回归固定了权限确认文案。该刀只把已有 API 字段接到 GUI 可见面,不改审批执行、不新增命令、不接云策略。
- 2026-05-05:继续第九十刀 `Phase 5/6 review decision dialog permission confirmation guardrail``RuntimeReviewDecisionDialog` 现在在填写人工审核结果前展示同一份 `permissionConfirmationStatus / Summary / request_id``denied` 使用阻断提示说明当前 review decision 不能作为成功交付证据;HarnessStatusPanel 回归覆盖弹窗里的权限确认文案。该刀只把已有 API 字段补到人工审核填写入口,不改审批执行、不新增命令、不接云策略。
- 2026-05-05:继续第九十一刀 `Phase 5/6 review decision denied acceptance guardrail``RuntimeReviewDecisionDialog``permissionConfirmationStatus=denied` 时会禁用“接受”选项,并在保存前阻止 `accepted` 结论写回;用户仍可保存拒绝、延后或需要更多证据的人工审核结果。HarnessStatusPanel 回归覆盖 denied 时不能保存 accepted、切换 rejected 后可正常保存。该刀只守住 review decision 写回语义,不改真实审批执行、不新增命令、不接云策略。
- 2026-05-05:继续第九十二刀 `Phase 5/6 review decision save API denied acceptance guardrail`Rust `save_runtime_review_decision` 现在在 `permissionConfirmationStatus=denied` 且写入 `decisionStatus=accepted` 时直接返回错误,防止 API / SceneApp / 测试绕过 GUI 把被拒绝权限确认沉淀成成功交付结论;`rejected / deferred / needs_more_evidence` 仍可保存,Rust 回归覆盖 accepted 被拒与 rejected 可保存。该刀只守住 review decision 写回事实源,不改审批执行、不新增命令、不接云策略。
- 2026-05-05:继续第九十三刀 `Phase 5/6 review decision mock/API denied acceptance guardrail`:浏览器 `tauri-mock``agent_runtime_save_review_decision` 现在与 Rust save API 对齐,默认 denied 权限确认下保存 `accepted` 会抛错,保存 `rejected` 仍返回 denied 权限确认摘要;前端 API 回归确认后端 denied 错误会透传给调用方。该刀防止 DevBridge fallback / 浏览器测试链路假装 denied+accepted 成功,不改审批执行、不新增命令、不接云策略。
- 2026-05-05:继续第九十四刀 `Phase 7 entry binding inventory guardrail`:新增 `entry-binding-inventory.md` 并把 Phase 7 验收落进 `check-modality-runtime-contracts.mjs`,要求所有 current contract 至少有一个 entry binding、entry key 全局唯一、`entry_source` 只能引用同一 contract 下的入口、`launch_metadata_path` 只能停留在 `harness.*`,且 scene entry 只有声明 `client_scenes / scene_policy` 后才允许登记;`/scene-key` 明确保持 planned,等待 LimeCore Scene catalog 与 audit contract 接齐后再进入 current。该刀只收口入口绑定事实源,不新增 `@` 命令、不改执行器、不接云 run/poll。
- 2026-05-05:继续第九十五刀 `Phase 8 task index inventory guardrail`:新增 `task-index-inventory.md`,并把 task index 最低验收落进 `check-modality-runtime-contracts.mjs`:所有 current / partial artifact kind 必须声明 `task_id / contract_key / artifact_kind / status / created_at / updated_at`,且 `task_index_fields` 不允许重复。文档同时明确 `thread_id / turn_id / content_id / entry_key / model_id / executor_kind / cost_state / limit_state / limecore_policy_snapshot` 仍未统一稳定化,下一刀再从 `runtime_contract / entry_source / task_profile / policy snapshot` 回填查询维度。该刀只收口任务索引事实源,不新增执行器、不改 `@` 命令、不接云 run/poll。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

+17 -1
View File
@@ -1,7 +1,7 @@
# Lime AgentUI 路线图文档
> 状态:路线图与架构设计
> 更新时间:2026-04-30
> 更新时间:2026-05-05
> 范围:Lime 对话工作区下一阶段 AgentUI,包括 UI 架构、代码层级、事件流程、时序图、后端协作与落地顺序。
## 目标
@@ -26,6 +26,10 @@ AgentUI 不是再做一个聊天页面,而是把 Lime 已有的 runtime、time
| [lime-agentui-sequence-diagrams.md](lime-agentui-sequence-diagrams.md) | 端到端时序图,适合实现和排查首字慢、恢复慢、流式错乱。 |
| [lime-agentui-backend-coordination.md](lime-agentui-backend-coordination.md) | 后端配合代码架构,定义 UI 需要后端继续补齐的投影、分页、指标与批量接口。 |
| [lime-agentui-implementation-roadmap.md](lime-agentui-implementation-roadmap.md) | P0/P1/P2/P3 落地顺序、验收标准和验证命令。 |
| [conversation-projection-architecture.md](conversation-projection-architecture.md) | Warp 对齐的对话投影架构,声明 AgentUI 只做 UI projection,不新增 runtime fact source。 |
| [conversation-projection-fact-map.md](conversation-projection-fact-map.md) | 对话状态事实源地图,标明 owner、writer、readers、persistence、runtime fact source 与 projection-only 边界。 |
| [conversation-projection-implementation-plan.md](conversation-projection-implementation-plan.md) | 对话主链瘦身的分阶段实施计划,顺序为事实源盘点、Projection Store、controller、selector、UI。 |
| [conversation-projection-acceptance.md](conversation-projection-acceptance.md) | 对话投影改造的固定验收场景、性能指标、Playwright 续测口径和完成判定。 |
## 当前结论
@@ -38,6 +42,18 @@ Agent runtime event
-> Conversation / Process / Task / Artifact / Evidence UI
```
对话层结构瘦身进一步固定为 Warp 对齐的 projection 子计划:
```text
Warp runtime fact sources
-> Conversation Projection Store
-> controllers
-> selectors
-> UI
```
其中 Warp 继续拥有 `Agent runtime identity``ModalityRuntimeContract``Execution Profile``Artifact Graph``Evidence / Replay / Task Index` 等事实源;AgentUI 只消费这些事实源生成对话 UI 需要的轻量投影。
下一阶段 UI 的关键词不是“更像某个竞品”,而是:
- **首屏轻**:旧会话打开先展示 shell、缓存快照、最近消息,再渐进补 timeline / tool / artifact。
@@ -0,0 +1,192 @@
# AgentUI 对话投影验收标准
> 状态:current acceptance source
> 更新时间:2026-05-05
> 目标:为对话架构瘦身提供固定验收场景、性能指标和回归口径,避免后续只凭体感判断。
## 1. 固定验收场景
### 1.1 首页输入回车
步骤:
1. 打开首页。
2. 在首页输入框输入短 prompt。
3. 按 Enter。
验收:
1. 对话 shell 在毫秒级出现,不露出旧 Hero。
2. 进入对话页后立即显示用户消息或 pending preview。
3. 首字前有 submitted / routing / preparing / waiting provider 中至少一个可信状态。
4. 鼠标不长时间保持系统繁忙状态。
指标:
1. `homeEnterToRouteMs`
2. `routeToConversationShellMs`
3. `ensureSessionDurationMs`
4. `submitTurnInvokeDurationMs`
5. `firstRuntimeStatusMs`
6. `firstTextDeltaMs`
7. `firstTextPaintMs`
### 1.2 新建对话
步骤:
1. 当前已有一个对话 tab。
2. 点击新建对话。
3. 输入短 prompt 并发送。
验收:
1. 新建对话是新增或激活 tab,不跳出工作台。
2. 旧 tab 不全量重渲染。
3. 新 tab 先显示 shell 和输入区。
4. 发送后不等待非关键本地持久化。
### 1.3 打开两个历史会话
步骤:
1. 打开历史会话 A。
2. 打开历史会话 B。
3. 在 A/B 之间切换。
验收:
1. 每个会话先显示 shell / cached snapshot / recent messages。
2. detail hydrate 完成前 UI 不空白。
3. A 的过期 hydrate 结果不能覆盖 B。
4. 非活跃 tab 不持有重型 timeline/render projection。
5. CPU/内存不因打开多个历史会话持续飙高。
指标:
1. `clickToShellPaintMs`
2. `clickToRecentMessagesPaintMs`
3. `runtimeGetSessionDurationMs`
4. `messageProjectionDurationMs`
5. `messageRenderDurationMs`
6. `hiddenHistoryCount`
7. `deferredTimelineCount`
### 1.4 大历史 MessageList
步骤:
1. 打开包含大量 messages / threadItems / turns 的旧会话。
2. 观察首屏。
3. 展开历史或 timeline 详情。
验收:
1. 首屏不构建完整 timeline。
2. 历史 Markdown 使用轻量模式或纯文本预览。
3. tool output / artifact detail 默认摘要化。
4. 展开详情时才构建对应 projection。
### 1.5 流式输出
步骤:
1. 发送会产生 thinking、tool、text、final_done 的 prompt。
2. 观察流式过程。
3. 等完成后恢复历史。
验收:
1. `thinking_delta` 不污染最终正文。
2. `text_delta` 不重复追加。
3. `final_done` 只 reconcile。
4. 工具日志不混入最终 Markdown 正文。
5. 历史恢复不把完成 thinking 当正文重放。
### 1.6 Artifact 恢复
步骤:
1. 触发图片、转写、browser snapshot 或报告类 artifact。
2. 等任务完成。
3. 重新打开会话。
验收:
1. MessageList 只显示 artifact 摘要卡。
2. Workbench / viewer 从 artifact graph、thread_read 或 task index 恢复。
3. 不从正文文本猜 artifact kind。
4. artifact 能回到原 session / thread / turn / task / evidence。
## 2. 最低测试集合
普通 projection 改动:
```bash
npm test -- "src/components/agent/chat/hooks/useAsterAgentChat.test.tsx"
npm test -- "src/components/agent/chat/components/MessageList.test.tsx"
```
stream 改动:
```bash
npm test -- "src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts" "src/components/agent/chat/hooks/agentStreamTurnEventBinding.test.ts"
```
workspace / tab 改动:
```bash
npm test -- "src/components/agent/chat/index.test.tsx" "src/components/agent/chat/workspace/WorkspaceConversationScene.test.tsx"
```
命令 / Bridge / mock 改动:
```bash
npm run test:contracts
```
GUI 主路径改动:
```bash
npm run verify:gui-smoke
```
如果 DevBridge 不可用,必须明确记录:
1. 停留页面。
2. `3030/health` 状态。
3. 已完成的非 GUI 验证。
4. 下一次恢复 DevBridge 后要续测的场景。
## 3. Playwright 续测口径
遵守 `docs/aiprompts/playwright-e2e.md`
1. 优先复用已有 Lime 页签。
2. 不新开 isolated profile。
3. 不使用会暴露自动化横幅或 `--no-sandbox` 的临时浏览器方式。
4. 不用固定 sleep 代替状态判断。
5. 每次记录控制台 error 增量与关键性能日志。
最低 E2E 路径:
```text
首页输入短 prompt
-> 进入对话 shell
-> 等 first runtime status / first text
-> 新建对话
-> 打开历史会话 A
-> 打开历史会话 B
-> A/B 切换
-> 发送短 prompt 检查流式
```
## 4. 完成判定
一次 AgentUI 对话瘦身改动只有同时满足以下条件,才算可交付:
1. 说明推进了哪个 Phase。
2. 没有新增 UI fact source 与 Warp 冲突。
3. 有定向单测或 projection 测试。
4. GUI 主路径已验证,或明确记录 DevBridge 阻塞与续测步骤。
5. 性能指标能解释本次优化前后的关键链路。
@@ -0,0 +1,164 @@
# AgentUI 对话投影架构
> 状态:current planning source
> 更新时间:2026-05-05
> 范围:`src/components/agent/chat` 对话 UI、状态投影、旧会话恢复、流式显示、MessageList 渲染与性能排查。
## 1. 事实源声明
本计划不新增 Lime 底层运行事实源。底层事实源继续以 `docs/roadmap/warp` 为准:
```text
Agent runtime identity
-> ModalityRuntimeContract
-> Capability Matrix
-> Execution Profile
-> Artifact Graph
-> Evidence / Replay / Task Index
```
AgentUI 对话层只做 UI 投影:
```text
agent_runtime_* / thread_read / runtime event / artifact graph
-> Conversation Projection Store
-> Session / Stream / Queue / Render controllers
-> React selectors
-> UI
```
固定约束:
1. `Conversation Projection Store` 不是 runtime owner。
2. UI 层不写 `ModalityRuntimeContract``ExecutionProfile``ArtifactGraph`、Evidence。
3. `@` 命令、button、Scene 仍只是 Warp `entry_binding`,不是对话层事实源。
4. viewer / MessageList 不允许从文本猜 artifact kind;必须消费 artifact graph、thread read 或 task index。
5. Agent loop、tool protocol、permission、slash command 继续优先参考 ClaudeCodeexecution profile、artifact graph、LimeCore policy、task index 继续优先参考 Warp。
## 2. 当前痛点
当前对话主链已经拆出不少文件,但职责仍堆叠:
| Surface | 当前问题 | 目标 |
| --- | --- | --- |
| `AgentChatWorkspace` | 布局、tab、发送、workspace、artifact、task、性能分支混在一起 | 只做 workspace shell 与 panel 编排 |
| `useAgentSession` | session lifecycle、topic list、hydrate、cache、metadata sync、tab 恢复交织 | 拆成 lifecycle / hydration / list / persistence controller |
| `useAgentStream` 及 stream helpers | submit、listener、queue、runtime event、render flush 仍耦合 | 拆成 stream submission / event reducer / queue |
| `MessageList` | 同步构建 timeline、message group、artifact/tool 展示 | 只消费预投影的 render model |
| 旧会话恢复 | detail、timeline、sidebar、MessageList 可能同时抢主线程或 invoke | shell / cache / recent messages 先显示,重投影后台化 |
问题不是“文件不够多”,而是事实源、投影、控制器、UI 边界不够清晰。
## 3. 目标分层
```text
AgentChatWorkspace
AgentWorkspaceShell
SessionChrome
ConversationPane
WorkbenchPane
ProcessDrawer
Conversation Projection Store
sessionSlice
streamSlice
queueSlice
renderSlice
diagnosticsSlice
Controllers
sessionLifecycleController
sessionHydrationController
topicListController
sessionPersistenceController
streamSubmissionController
streamEventReducer
streamQueueController
messageRenderProjection
```
职责边界:
| 层 | 允许做 | 禁止做 |
| --- | --- | --- |
| Runtime / Bridge | 产生 session、thread、turn、runtime event、artifact、evidence | 依赖 React UI 状态 |
| Projection Store | 缓存 UI 需要的轻量状态和引用 | 成为新的 runtime / artifact / evidence 事实源 |
| Controllers | 把 runtime 事实折叠成 UI action | 直接渲染组件或写 UI 样式 |
| Selectors | 选择切片、构建轻量 render model | 返回新对象导致无关重渲染 |
| UI | 展示、交互、打开 panel / viewer | 推断底层合同、直接写 artifact/evidence |
## 4. 数据流
### 4.1 新会话首轮发送
```text
Home/Inputbar
-> streamSubmissionController.prepare
-> sessionLifecycleController.ensureSession
-> Projection Store: shell + waiting status
-> bind runtime listener
-> agent_runtime_submit_turn
-> streamEventReducer
-> renderSlice first status / first text
-> MessageList render model
```
首字慢必须能拆到这些阶段:
1. `ensureSession.start / done`
2. `listener.bound`
3. `submitTurn.dispatched / accepted`
4. `firstEvent`
5. `firstRuntimeStatus`
6. `firstTextDelta`
7. `firstTextPaint`
### 4.2 打开旧会话
```text
Topic click
-> sessionLifecycleController.switchSession
-> Projection Store: target shell
-> cached snapshot / recent messages
-> getSession(historyLimit)
-> message render projection
-> timeline / tool / artifact projection idle hydrate
```
旧会话首帧不等待完整 detail、timeline、artifact preview、sidebar list。
### 4.3 Artifact 展示
```text
runtime event / thread_read / task index
-> artifact graph reference
-> render projection artifact summary
-> MessageList card / Workbench viewer
```
MessageList 只能展示摘要和入口;完整编辑、预览、diff、保存进入 Workbench / viewer。
## 5. 与 Warp 的关系
本计划是 Warp 路线图的 UI / projection 子计划。
| Warp 事实源 | AgentUI 消费方式 |
| --- | --- |
| `agent_runtime_identity` | render model 保留 session/thread/turn/task 引用 |
| `modality_runtime_contract` | UI 展示 contract key,不创建或改写 contract |
| `execution_profile` | capsule / status / diagnostics 展示 profile 摘要 |
| `domain_artifact_graph` | artifact card / viewer 只读 graph |
| `evidence_pack` | harness panel / replay 入口只读 evidence |
| `entry_binding` | `@` / button / Scene 只作为启动 metadata |
如果某个 UI 优化需要绕过这些事实源才能工作,它不能进入 current 主线。
## 6. 非目标
短期不做:
1. 不新增第二套 event bus。
2. 不把 Pi 的 JSONL/RPC 作为 Lime 新协议。
3. 不把 artifact / evidence 事实塞回 MessageList。
4. 不为了拆文件新增平行 compat 组件。
5. 不把视觉重做、协议重构、store 重构混在同一刀。
@@ -0,0 +1,91 @@
# AgentUI 对话事实源地图
> 状态:current fact map
> 更新时间:2026-05-05
> 范围:AgentUI 对话主链 Phase 0,覆盖会话、消息、流式、队列、artifact、历史窗口、性能诊断与 MessageList render window。
## 1. 分类口径
| 分类 | 含义 |
| --- | --- |
| `current` | 当前允许继续演进的事实源或投影边界。 |
| `compat` | 只做适配与委托,不新增业务判断。 |
| `deprecated` | 只允许迁移和下线。 |
| `dead` | 已无入口或与 current 冲突,后续删除或补守卫。 |
`projection_only=true` 表示该状态只服务 UI 展示、订阅或性能诊断,不能反写 runtime、artifact、evidence 或 Warp governance。
## 2. Runtime Identity
| state_key | owner | current_writer | readers | persistence | runtime_fact_source | projection_only | 分类 |
| --- | --- | --- | --- | --- | --- | --- | --- |
| `workspaceId` | Workspace / App shell | Workspace selection | AgentUI hooks、runtime commands、sidebar | workspace state | Workspace runtime | false | current |
| `sessionId/topicId` | Agent runtime session | `agent_runtime_*` + session lifecycle controller | tabs、MessageList、stream、artifact、evidence | runtime session repository | `agent_runtime_identity` | false | current |
| `threadId` | Agent runtime thread | runtime event / session detail | MessageList、thread timeline、artifact linking | runtime session detail | thread runtime | false | current |
| `turnId` | Agent runtime turn | runtime event stream | stream reducer、MessageList、task capsule | runtime event / thread_read | runtime event | false | current |
| `taskId` | Task / Warp task index | runtime event / task index | capsule、workbench、evidence | task index / artifact graph | Warp task index | false | current |
约束:AgentUI projection store 可以保存这些 id 的引用,但不能生成新的 runtime identity。
## 3. Session And History
| state_key | owner | current_writer | readers | persistence | runtime_fact_source | projection_only | 分类 |
| --- | --- | --- | --- | --- | --- | --- | --- |
| `topics` | topic list controller | `agent_runtime_list_sessions` | sidebar、tab strip、initial navigation | runtime session list | runtime session index | false | current |
| `activeSessionShell` | session lifecycle controller | topic click / new task / draft materialize | workspace shell、tab strip | memory + route | runtime session id | true | current |
| `cachedSessionSnapshot` | session hydration controller | cached detail / recent messages | pending shell、MessageList | memory cache | runtime session detail | true | current |
| `sessionHistoryWindow` | session hydration controller | `getSession(historyLimit)` / paged history | MessageList、performance metrics | memory state | runtime session detail | true | current |
| `restoreCandidate` | session lifecycle controller | workspace restore logic | first load / homepage send | local storage / runtime | runtime session id | false | current |
| `detachedSessionDetail` | session hydration controller | detail hydrate for sessions not in initial list | tab、MessageList | memory | runtime session detail | true | current |
约束:所有 `getSession` 主链必须带 `historyLimit` 或分页 cursor;完整历史只能通过显式分页或用户动作加载。
## 4. Messages And Thread Projection
| state_key | owner | current_writer | readers | persistence | runtime_fact_source | projection_only | 分类 |
| --- | --- | --- | --- | --- | --- | --- | --- |
| `messages` | session hydration + stream reducer | session detail / user draft / runtime event | MessageList、workspace send、artifact sync | runtime detail + memory drafts | runtime session detail / stream | false | current |
| `threadTurns` | runtime event reducer | runtime events / session detail | MessageList、timeline、task status | runtime detail | thread runtime | false | current |
| `threadItems` | runtime event reducer | runtime events / thread_read | MessageList、tool timeline、artifact cards | runtime detail / thread_read | thread runtime | false | current |
| `threadRead` | thread read projection | runtime command / thread_read | Process drawer、artifact card、diagnostics | runtime projection | thread_read | false | current |
| `messageRenderWindow` | message render projection | MessageList selector / history window selector | MessageList | memory only | derived from messages/history | true | current |
| `timelineRenderProjection` | message render projection | timeline projection / idle hydrate | MessageList timeline cards | memory only | derived from threadItems | true | current |
下一阶段目标:`MessageList` 逐步只消费 `messageRenderWindow``timelineRenderProjection``artifactSummaryProjection`,不再同步扫描全部历史数据。
## 5. Stream, Queue And Actions
| state_key | owner | current_writer | readers | persistence | runtime_fact_source | projection_only | 分类 |
| --- | --- | --- | --- | --- | --- | --- | --- |
| `streamRequest` | stream submission controller | send preparation / submit execution | inputbar、MessageList、diagnostics | memory | submit operation | true | current |
| `streamDiagnostics` | Conversation Projection Store | `recordAgentStreamPerformanceMetric` | E2E、debug panel、future diagnostics UI | memory ring / projection store | performance trace | true | current |
| `queuedTurns` | stream queue controller | runtime queue event / local queue | inputbar、task capsule | runtime queue / memory | runtime event | false | current |
| `pendingActions` | action request controller | runtime event / A2UI | inputbar、pending panel、capsule | runtime action request | runtime event | false | current |
| `childSubagentSessions` | task / subagent projection | runtime event / session detail | task capsule、process drawer | runtime session detail | runtime event | false | current |
首个代码 slice 选择 `streamDiagnostics`,原因是它是纯 UI/诊断 projection,不改变 runtime 行为,适合验证 Projection Store 和 selector 边界。
## 6. Artifact And Evidence
| state_key | owner | current_writer | readers | persistence | runtime_fact_source | projection_only | 分类 |
| --- | --- | --- | --- | --- | --- | --- | --- |
| `artifact_snapshot` | runtime / artifact service | runtime event / artifact command | MessageList summary、Workbench | artifact service | Artifact Graph | false | current |
| `ArtifactDocument` | artifact service | artifact writer / workbench save | Workbench、viewer、evidence | filesystem / artifact db | Artifact Graph | false | current |
| `artifactSummaryProjection` | message render projection | artifact graph / thread_read selector | MessageList card | memory only | Artifact Graph reference | true | current |
| `evidencePack` | harness engine | evidence export command | Harness panel、review、replay | evidence pack storage | Evidence / Replay | false | current |
| `reviewDecision` | harness engine | review command | Harness panel | review service | Evidence / Review | false | current |
约束:MessageList 只能展示 artifact 摘要和入口,不能从正文文本猜 artifact kind,也不能成为 artifact/evidence owner。
## 7. 当前 Phase 1 接入点
本轮进入 Phase 1 的最小闭环:
```text
recordAgentStreamPerformanceMetric
-> recordConversationStreamDiagnostic
-> Conversation Projection Store: streamDiagnostics
-> selector tests
```
该闭环只新增 projection 写入,不改变现有 `window.__LIME_AGENTUI_PERF__`、E2E summary、runtime event 或 UI 展示行为。
@@ -0,0 +1,253 @@
# AgentUI 对话投影实施计划
> 状态:current planning source
> 更新时间:2026-05-05
> 目标:按 Warp 路线图的“事实源、阶段、验收、机器检查”写法,推进对话主链瘦身,解决旧会话恢复慢、首字慢、流式错乱和 MessageList 重渲染难排查问题。
## 0. 排序修正
本计划不能从“拆文件”开始。正确顺序是:
```text
对话事实源盘点
-> Projection Store 边界
-> Session lifecycle / hydration
-> Stream submission / queue / event reducer
-> Message render projection
-> Workspace shell 瘦身
-> 性能与治理守卫
```
固定原则:
1. projection 先服务现有 `useAsterAgentChat` public API,不一次性改穿所有调用方。
2. store 只做 UI projection,不成为 runtime fact source。
3. 每一刀必须能解释它如何降低首屏、首字、旧会话或 MessageList 的排查复杂度。
4. 与 Warp 事实源冲突时,以 `docs/roadmap/warp` 为准。
## Phase 0:对话事实源盘点
### 目标
把当前对话链路里的事实源、投影、UI 状态分清楚,避免继续把所有状态都塞进 hook 或组件。
### 范围
盘点:
1. `sessionId / threadId / turnId / taskId`
2. `messages / threadTurns / threadItems`
3. `thread_read / runtime_summary / permission_state`
4. `queuedTurns / pendingActions / childSubagentSessions`
5. `artifact_snapshot / ArtifactDocument / task artifact index`
6. `sessionHistoryWindow / cached snapshot / restore candidate`
7. stream performance trace
8. MessageList render window / hidden history / timeline projection
### 输出
更新或新增事实源表,字段至少包括:
1. `state_key`
2. `owner`
3. `current_writer`
4. `readers`
5. `persistence`
6. `runtime_fact_source`
7. `projection_only`
8. `current / compat / deprecated / dead`
### 验收
1. 每个对话状态能说明唯一 owner。
2. UI-only projection 与 runtime fact source 区分清楚。
3. 不能解释 owner 的状态不得继续扩展新逻辑。
## Phase 1Projection Store 边界
### 目标
建立 `Conversation Projection Store`,用于 UI 切片订阅和轻量投影。
### 改动面
优先新增:
1. store 类型与 selector helper。
2. session / stream / queue / render / diagnostics 初始 slice。
3. `useAsterAgentChat` 内部适配层。
暂不改:
1. Tauri command。
2. agent runtime event protocol。
3. Warp governance JSON。
### 验收
1. `useAsterAgentChat` 对外返回 shape 不变。
2. selector 只在对应切片变化时触发。
3. store action 不写 runtime contract、artifact graph、evidence export。
4. 新增测试证明无关切片更新不触发订阅者。
## Phase 2Session lifecycle / hydration 拆分
### 目标
`useAgentSession` 中的新建、切换、恢复、hydrate、列表、持久化拆成可单测 controller。
### 改动面
目标模块:
1. `sessionLifecycleController`
2. `sessionHydrationController`
3. `topicListController`
4. `sessionPersistenceController`
### 行为要求
1. 新建会话:先更新 shell 和内存状态,非关键本地持久化后台化。
2. 打开旧会话:先显示 target shell / cached snapshot / recent messages。
3. detail hydrate:统一带 `historyLimit`,完整历史分页。
4. 过期结果:统一通过 request version 丢弃。
5. sidebar list:低优先级,不阻塞 target session detail。
### 验收
1. 点击旧会话到 shell paint 不依赖 `getSession` 完成。
2. 打开两个历史会话来回切换不会互相覆盖。
3. 无 limit `getSession` 不再回到主链。
4. 相关测试能单独覆盖 lifecycle 与 hydration,不必挂载完整 workspace。
## Phase 3Stream submission / queue / event reducer 拆分
### 目标
把首字链路拆成可观测、可定位、可测试的阶段。
### 改动面
目标模块:
1. `streamSubmissionController`
2. `streamEventReducer`
3. `streamQueueController`
4. `streamPerformanceTrace`
### 行为要求
1. 首轮无 session 时,UI shell 和 waiting status 先出现。
2. listener bound 早于 submit dispatch。
3. `thinking_delta``text_delta``tool_*``final_done` 严格分型。
4. `final_done` 只 reconcile,不二次 append。
5. queue / steer / follow-up 不绕过 runtime 主链。
### 验收
1. TTFT 日志覆盖 ensure、listener、submit、first event、first status、first delta、first paint。
2. 首字慢能从日志判断慢在 invoke、queue、provider、event bridge、flush 或 render。
3. 重复吐字、thinking 污染正文、空 final_done 均有回归。
## Phase 4Message render projection
### 目标
`MessageList` 只消费预投影 render model,不再同步构建全部 timeline / group / artifact。
### 改动面
目标模块:
1. `messageRenderProjection`
2. `timelineRenderProjection`
3. `artifactSummaryProjection`
4. `messageWindowSelector`
### 行为要求
1. 首屏只投影可见消息窗口。
2. 历史 timeline / tool / artifact detail 延迟到 idle 或展开。
3. artifact kind 从 artifact graph、thread_read 或 task index 恢复。
4. 大 Markdown / 大 tool output 默认摘要化。
### 验收
1. 大历史会话首屏不构建完整 timeline。
2. MessageList render/projection duration 分开记录。
3. 图片、转写、browser snapshot 等不会从正文文本猜类型。
4. 大历史打开不出现长任务卡鼠标。
## Phase 5Workspace shell 瘦身
### 目标
`AgentChatWorkspace` 从巨石入口收敛成 shell 和 panel 编排。
### 目标结构
```text
AgentChatWorkspace
AgentWorkspaceShell
SessionChrome
ConversationPane
WorkbenchPane
ProcessDrawer
TaskCapsuleStrip
```
### 验收
1. `AgentChatWorkspace` 不直接处理 stream event 细节。
2. tab、capsule、conversation、workbench、process drawer 分别有稳定边界。
3. 非活跃 tab 不持有重型 render projection。
4. 新建对话、打开历史会话、切换 tab 不跳出当前工作台。
## Phase 6:性能与治理守卫
### 目标
把“不要再堆回去”变成可检查规则。
### 守卫
后续实现应补:
1. UI projection 层禁止直接写 Warp governance JSON。
2. MessageList 禁止直接调用 runtime command。
3. viewer 禁止从正文文本猜 artifact kind。
4. 新增状态必须声明 owner 和 projection / fact source 分类。
### 验收
1. `npm run governance:modality-contracts` 继续通过。
2. 对话主链改动至少跑定向 hook / projection / MessageList 测试。
3. GUI 主路径改动继续按 `verify:gui-smoke` 或 Playwright 续测证明。
4. 性能采样能输出 shell、ensure、submit、first status、first text、projection、render 分段。
## 当前下一刀建议
优先做 Phase 0 + Phase 1 的最小闭环:
1. 写出对话状态 fact map。
2. 建一个最小 `Conversation Projection Store`
3. 只接入一个低风险 slicestream diagnostics 或 message render window。
4. 用 selector 测试证明不会扩大重渲染。
## 当前实施进度
- Phase 0 已建立 [conversation-projection-fact-map.md](conversation-projection-fact-map.md)。
- Phase 1 已落最小 `Conversation Projection Store`,首个 slice 为 `streamDiagnostics`
- `recordAgentStreamPerformanceMetric` 继续写入原有 `window.__LIME_AGENTUI_PERF__`,同时同步写入 projection store;当前不改变 UI 行为和 runtime 协议。
- Phase 4 已落最小 `messageRenderWindow` 纯 projection`MessageList` 先消费该 selector 产出的 `visibleMessages / renderedMessages / hiddenHistoryCount / shouldAutoHydrateHiddenHistory`
- Phase 4 已继续落 `threadTimelineWindow` 纯 projection`MessageList` 消费该 selector 产出的 `renderedTurns / renderedTurnIdSet / renderedThreadItems`
- Phase 4 已继续落 `messageTimelineRender` 纯 projection`MessageList` 消费该 selector 产出的 `timelineByMessageId / currentTurnTimeline / messageGroups / renderGroups`
- Phase 4 已继续落 `historicalMessageHydration` 纯 projection`MessageList` 消费该 selector 产出的历史 markdown hydration 目标、hydration index、延迟 contentParts 计数与结构化历史内容识别。
- Phase 2 已开始最小 controller 化:`sessionHydrationController` 统一旧会话详情 `historyLimit`、prefetch key/signature 与 request/session stale guard,避免无 limit `getSession` 和过期 hydrate 逻辑继续散落在 `useAgentSession`
- Phase 2 已继续抽出 `sessionDetailFetchController`,统一 detail fetch / prefetch 复用 / prefetch fallback / fetch metrics / registry 清理,`useAgentSession` 只负责把事件接回现有性能日志与调试日志。
- Phase 2 已继续抽出 `sessionHydrationRetryController`,统一 deferred detail hydrate 失败后的 retry / transient skip / fatal fallback 决策,`switchTopic` 只负责调度重试或调用错误 fallback。
- Phase 2 已继续抽出 `sessionSwitchSnapshotController`,统一 cached snapshot 加载/应用、stale/running 立即刷新、pending shell 指标与 `localSnapshotOverride` 引用判定。
- Phase 2 已继续抽出 `sessionMetadataSyncController`,统一 finalize 后 accessMode / provider / executionStrategy fallback patch、switch success metric source 与批量/分散 metadata sync。
- Phase 2 已继续抽出 `sessionFinalizeController`,统一 finalize 阶段 workspace restore guard、runtime/topic/shadow workspace 汇总、shadow execution strategy fallback 与最终 execution strategy override。
- Phase 2 已继续抽出 `sessionMetadataSyncScheduler`,统一 finalize 后 metadata patch 的 invoke capability guard、旧调度取消、idle 调度、stale guard、成功回写与失败回调。
- 下一刀建议继续 Phase 2:抽 `sessionPostFinalizePersistenceController`,把保存 workspace、同步 topic 字段、provider preference apply 继续从 `finalizeResolvedTopicDetail` 主体中移出;只有 E2E 指标显示 `messageListTimelineBuildMs` 仍高时才进入 worker 化。
@@ -1,7 +1,7 @@
# Lime AgentUI 实施路线图
> 状态:实施路线图
> 更新时间:2026-04-30
> 更新时间:2026-05-05
> 目标:把 AgentUI 架构落成可分批交付的工程任务,优先解决旧会话恢复慢、首字慢、tab 卡顿、流式重复吐字和过程信息噪声。
## 1. 阶段目标
@@ -13,6 +13,24 @@
| P2 | 强化 artifact/workbench/evidence 分层 | 最终产物和证据离开正文,进入可编辑/可审计工作台 |
| P3 | 抽象 AgentUI 子系统 | `AgentChatWorkspace` 从巨石入口变成稳定 shell |
结构收敛从 2026-05-05 起按 [conversation-projection-implementation-plan.md](conversation-projection-implementation-plan.md) 继续推进。它是本路线图的 P3 子计划,但 Phase 0 / Phase 1 可以提前穿插执行,因为事实源盘点和最小 Projection Store 会直接降低 P0/P1 性能问题的排查成本。
固定收敛方向:
```text
Warp runtime fact sources
-> Conversation Projection Store
-> Session / Stream / Queue / Render controllers
-> selectors
-> Conversation / Process / Task / Artifact / Evidence UI
```
约束:
1. `Conversation Projection Store` 只保存 UI projection,不成为 runtime、artifact、evidence 的新事实源。
2. `AgentChatWorkspace``useAgentSession``MessageList` 的拆分必须回挂到 projection plan 的 phase 和验收标准。
3. Pi / ClaudeCode / Warp 只提供边界参考;Lime 不引入 Pi JSONL/RPC 或第二套 event bus。
## 2. P0:体验止血
### 2.1 旧会话渐进恢复
@@ -212,6 +230,18 @@
## 5. P3:结构收敛
P3 不从“拆文件”开始,而是先把状态 owner、projection store、controller、selector、UI 边界理清楚。实际执行顺序以 [conversation-projection-implementation-plan.md](conversation-projection-implementation-plan.md) 为准:
```text
Phase 0: 对话事实源盘点
Phase 1: Projection Store 边界
Phase 2: Session lifecycle / hydration 拆分
Phase 3: Stream submission / queue / event reducer 拆分
Phase 4: Message render projection
Phase 5: Workspace shell 瘦身
Phase 6: 性能与治理守卫
```
### 5.1 拆分 AgentChatWorkspace
目标结构:
+3 -1
View File
@@ -174,7 +174,9 @@ Lime 的 artifact graph 不应只有 `document` / `file`。
2. `src/lib/governance/modalityCapabilityMatrix.json`
3. `src/lib/governance/modalityArtifactGraph.json`
4. `src/lib/governance/modalityExecutionProfiles.json`
5. `src/lib/governance/modalityExecutionProfiles.ts`
5. `docs/roadmap/warp/entry-binding-inventory.md`
6. `docs/roadmap/warp/task-index-inventory.md`
7. `src/lib/governance/modalityExecutionProfiles.ts`
6. `scripts/check-modality-runtime-contracts.mjs`
7. `npm run governance:modality-contracts`
@@ -0,0 +1,48 @@
# Phase 7 Entry Binding Inventory
> 目标:把上层 `@` 命令、按钮和 Scene 入口收成可机器检查的 entry binding,确保入口只补 launch metadata,不直接写 task、artifact、viewer、model routing、permission profile 或 LimeCore policy。
## 1. 当前事实源
- 事实源:`src/lib/governance/modalityRuntimeContracts.json`
- 守卫:`scripts/check-modality-runtime-contracts.mjs`
- 验证入口:`npm run governance:modality-contracts`
Entry binding 嵌在对应底层 contract 的 `bound_entries[]` 中;contract 本身才拥有 `truth_source / artifact_kinds / viewer_surface / evidence_events / executor_binding / routing_slot`
## 2. Phase 7 验收口径
1. 每个 `current` contract 至少有一个 entry binding。
2. `entry_key` 在全 registry 内唯一。
3. `entry_source` 必须引用同一 contract 下的某个 `entry_key`,允许按钮或别名复用主入口来源。
4. `launch_metadata_path` 必须停留在 `harness.*`
5. Entry binding 不得携带底层字段:`truth_source``artifact_kinds``viewer_surface``evidence_events``executor_binding``routing_slot`
6. Scene entry 只有在 contract 显式声明 `client_scenes``scene_policy` policy ref 后才允许登记。
## 3. Current entry binding 清单
| Contract | 代表入口 | 已绑定入口 | 当前结论 |
| --- | --- | --- | --- |
| `image_generation` | `@配图` | `at_image_command``at_poster_command``at_image_edit_command``at_image_variation_command``at_storyboard_command``document_inline_image_action``image_workbench_followup_action` | current |
| `browser_control` | `@浏览器` | `at_browser_command``at_browser_agent_command``at_mini_tester_command``at_web_scheduler_command``at_web_manage_command` | current |
| `pdf_extract` | `@读PDF` | `at_pdf_read_command` | current |
| `voice_generation` | `@配音` | `at_voice_command` | current |
| `audio_transcription` | `@转写` | `at_transcription_command` | current |
| `web_research` | `@搜索` | `at_search_command``at_deep_search_command``at_site_search_command``at_report_command``at_competitor_command` | current |
| `text_transform` | `@总结` | `at_file_read_command``at_summary_command``at_translation_command``at_analysis_command``at_publish_compliance_command``at_logo_decomposition_command` | current |
这些入口均只声明 `entry_key / entry_kind / display_name / launch_metadata_path / entry_source / default_input_mapping / entry_visibility_policy`,不拥有底层事实源字段。
## 4. `/scene-key` 结论
`/scene-key` 暂不登记为 current entry binding。
原因:Scene 入口的目录事实源必须来自 LimeCore `client/scenes``bootstrap.sceneCatalog`,且 cloud run / scene policy 需要进入显式 audit;当前主线只完成了 policy ref / hit / evaluator seam,没有把 Scene catalog 注册成 current contract。
当前分类:`planned`,不是 `compat`,也不是 `current`
退出条件:当 LimeCore `client_scenes / scene_policy` 命中值、Scene catalog fallback、Scene run audit 与对应 contract 都进入同一 registry 后,才能新增 `entry_kind=scene` 的 binding。
## 5. 下一步
Phase 7 不继续批量扩张 `@` 命令。下一步进入 Phase 8:把任务索引与复盘统一到 `contract_key / entry_key / executor / artifact / policy / evidence` 可查询主链。
+6 -2
View File
@@ -168,7 +168,7 @@
## Phase 3ModalityExecutionProfile
当前落点:见 [execution-profile.md](./execution-profile.md)、`src/lib/governance/modalityExecutionProfiles.json``src/lib/governance/modalityExecutionProfiles.ts`;最小 profile / executor adapter registry、前端 launch metadata、Rust runtime contract snapshot、Evidence / Replay、统一媒体任务索引快照、图片/配音/转写媒体 worker 的最小 adapter preflight、Browser Assist 真实动作前 preflight、`LimeSkillTool` current Skill 合同 metadata seed / 显式冲突合同阻断、旧 `lime_run_service_skill``voice_generation` compat guard,以及 LimeCore policy refs/snapshot 种子已落地;`pending_hit_refs` / `policy_value_hits` / `policy_value_hit_count` 已为真实 policy 命中值预留稳定接线,传入 `status=resolved` hit 时会把对应 ref 计入 `evaluated_refs` 并收缩 `missing_inputs / pending_hit_refs`;图片任务执行前已能从本地 model registry assessment 生成 `model_catalog` hit,并在进入真实执行器前从已解析的 runner config/API key 与 payload provider/model 生成 `provider_offer` hitBrowser Assist 与 Web Research 类 launch 已能从请求侧 `harness.oem_routing` 生成最小 `gateway_policy` hitWorkspace send metadata 也能从 OEM Cloud bootstrap `features` 生成最小 `tenant_feature_flags` hit;最小 `policy_input_evaluator` 已能在所有 refs resolved 时输出 `allow / ask / deny``thread_read.runtime_summary.limecorePolicy` 与统一媒体任务索引也已能投影最近一次 policy decision explanation 和 evaluator blocking / ask / pending refs`thread_read.runtime_summary.modalityRuntime` 已开始投影同一合同的 profile / adapter / executor binding 摘要,`SessionExecutionRuntimeTaskProfile` 也已开始承载同一合同的 profile / adapter / binding、权限 profile 与用户锁定策略摘要;provider/model resolution 已消费 `TaskProfile.routingSlot` 做最小模型能力 enforcement,候选池、fallback 与自动重选会排除不满足 runtime slot 的模型,显式用户锁定仍 honored 但输出 capability gap`lime_runtime.permission_state` 已把 `permissionProfileKeys` 推进为最小权限摘要,`runtime_summary` 同步暴露 permission status/ask/blocking count`AgentRuntimeThreadReadModel.permission_state` 也已结构化暴露完整 required/ask/blocking profile keys 与 notes`requires_confirmation` 会额外产生 `runtime_status(phase=permission_review, declared_only=true)` 事件,前端协议解析也会保留该 phase 与权限 metadata,不再降级为普通 routingEvidence Pack 与 Replay runtime facts 现在也导出同一 `permissionState`,Replay 会把声明态需确认权限列为 blocking check防止回放误判真实授权已完成`permissionState` 已预置 `confirmationStatus / confirmationRequestId / confirmationSource`,当前 profile 声明态显式标为 `not_requested / null / declared_profile_only`live `permission_review` event 也会携带这组确认状态;配音/转写任务卡恢复层、图片 viewer 与图片消息轻卡已开始消费这些 refs 生成 policy evaluation meta;真实 `gateway:*` adapter preflight、权限 enforcement、用户确认/阻断执行、云端 policy evaluator 与更完整 GUI 可视化仍待继续。
当前落点:见 [execution-profile.md](./execution-profile.md)、`src/lib/governance/modalityExecutionProfiles.json``src/lib/governance/modalityExecutionProfiles.ts`;最小 profile / executor adapter registry、前端 launch metadata、Rust runtime contract snapshot、Evidence / Replay、统一媒体任务索引快照、图片/配音/转写媒体 worker 的最小 adapter preflight、Browser Assist 真实动作前 preflight、`LimeSkillTool` current Skill 合同 metadata seed / 显式冲突合同阻断、旧 `lime_run_service_skill``voice_generation` compat guard,以及 LimeCore policy refs/snapshot 种子已落地;`pending_hit_refs` / `policy_value_hits` / `policy_value_hit_count` 已为真实 policy 命中值预留稳定接线,传入 `status=resolved` hit 时会把对应 ref 计入 `evaluated_refs` 并收缩 `missing_inputs / pending_hit_refs`;图片任务执行前已能从本地 model registry assessment 生成 `model_catalog` hit,并在进入真实执行器前从已解析的 runner config/API key 与 payload provider/model 生成 `provider_offer` hitBrowser Assist 与 Web Research 类 launch 已能从请求侧 `harness.oem_routing` 生成最小 `gateway_policy` hitWorkspace send metadata 也能从 OEM Cloud bootstrap `features` 生成最小 `tenant_feature_flags` hit;最小 `policy_input_evaluator` 已能在所有 refs resolved 时输出 `allow / ask / deny``thread_read.runtime_summary.limecorePolicy` 与统一媒体任务索引也已能投影最近一次 policy decision explanation 和 evaluator blocking / ask / pending refs`thread_read.runtime_summary.modalityRuntime` 已开始投影同一合同的 profile / adapter / executor binding 摘要,`SessionExecutionRuntimeTaskProfile` 也已开始承载同一合同的 profile / adapter / binding、权限 profile 与用户锁定策略摘要;provider/model resolution 已消费 `TaskProfile.routingSlot` 做最小模型能力 enforcement,候选池、fallback 与自动重选会排除不满足 runtime slot 的模型,显式用户锁定仍 honored 但输出 capability gap`lime_runtime.permission_state` 已把 `permissionProfileKeys` 推进为最小权限摘要,`runtime_summary` 同步暴露 permission status/ask/blocking count`AgentRuntimeThreadReadModel.permission_state` 也已结构化暴露完整 required/ask/blocking profile keys 与 notes`requires_confirmation` 会额外产生 `runtime_status(phase=permission_review, declared_only=true)` 事件,前端协议解析也会保留该 phase 与权限 metadata,不再降级为普通 routingEvidence Pack 与 Replay runtime facts 现在也导出同一 `permissionState`,Replay 会把声明态需确认权限列为 blocking check且会把 `confirmationStatus=denied` 判为明确阻断、`resolved` 不再误报为仍需确认;Evidence Pack 的 `permissionState` coverage 会把 `denied` 标为 blocked、把 `resolved` 解释为已通过,`knownGaps``summary.md` 也会把 denied 权限确认显示为人眼可见的阻断风险,Handoff bundle、Analysis handoff 与 Review decision 也会把 `denied / resolved` 同步进交接摘要、外部分析简报、结构化 context、copy prompt、人工审核记录、前端 API 顶层返回模型、Harness 人工审核卡片与人工审核填写弹窗,并在 `denied` 时由 GUI、Rust save API、前端 API 回归与浏览器 mock 四侧阻止保存 `accepted` 结论,防止审计/回放/交接/审核/API/GUI 读取或写回误判真实授权状态`permissionState` 已预置 `confirmationStatus / confirmationRequestId / confirmationSource`,当前 profile 声明态显式标为 `not_requested / null / declared_profile_only`live `permission_review` event 也会携带这组确认状态,且 thread read 会在同一线程存在真实 tool `ApprovalRequest` 时派生 `requested / resolved / denied`、真实 request id 与 `runtime_action_required` 来源;配音/转写任务卡恢复层、图片 viewer 与图片消息轻卡已开始消费这些 refs 生成 policy evaluation meta;真实 `gateway:*` adapter preflight、权限 enforcement、用户确认/阻断执行、云端 policy evaluator 与更完整 GUI 可视化仍待继续。
### 目标
@@ -306,7 +306,7 @@ Browser Assist 必须收成:
## Phase 6LimeCore 目录与策略接线
当前落点:central runtime contract helper、前端 runtime contract resolver、Evidence Pack 与 `list_media_task_artifacts` 已携带 `limecore_policy_refs` 和最小本地默认 `limecore_policy_snapshot(status=local_defaults_evaluated, decision=allow, decision_source=local_default_policy, decision_scope=local_defaults_only, policy_inputs, missing_inputs, pending_hit_refs, policy_value_hits, policy_value_hit_count)`;这个 `allow` 只表示本地默认策略没有阻断 current 路由。默认 `policy_inputs``declared_only / limecore_pending` 输入清单,`pending_hit_refs` 指向等待真实值的 refs`policy_value_hits=[]` / `policy_value_hit_count=0` 明确不伪造真实 tenant / provider / gateway 放行;如果已有 `status=resolved` 命中值,同一 seam 会把对应 ref 写入 `evaluated_refs`,将 input 标为 `resolved` 并使用 hit 的 `value_source`。当前已先把图片任务的本地 model registry assessment 接成 `model_catalog` hit producer,把已解析 runner config/API key 与 payload provider/model 接成最小 `provider_offer` hit producer,把请求侧 `harness.oem_routing` 接成 Browser Assist / Web Research 的最小 `gateway_policy` hit producer,并把 OEM Cloud bootstrap snapshot `features` 接成请求侧 `tenant_feature_flags` hit producer`policy_evaluation` 会在所有 refs resolved 时用最小本地 `policy_input_evaluator` 折叠 `allow / ask / deny``thread_read.runtime_summary.limecorePolicy` 会把最近一次 runtime contract 的 policy decision explanation 投影给上层读取,`thread_read.runtime_summary.modalityRuntime` 会把同一合同的 profile / adapter / executor binding 摘要投影给上层读取,`lime_runtime.task_profile` 已开始携带同一合同的权限 profile、用户锁定策略摘要与 `routingSlot`provider/model resolution 已用这些字段做最小模型能力 enforcement`lime_runtime.permission_state` 已把权限 profile 声明推进成 runtime 可读摘要,并由 `AgentRuntimeThreadReadModel.permission_state`、Evidence Pack 与 Replay runtime facts 结构化暴露;当声明态需要确认时,runtime 事件流也会暴露 `permission_review` 状态,`confirmationStatus=not_requested` 明确说明尚未生成真实审批请求;`list_media_task_artifacts.modality_runtime_contracts` 也会汇总 evaluation status / decision / decision source 与 blocking / ask / pending refs;配音/转写任务卡恢复层已把 input gap / deny / ask 投影为轻卡 meta,图片 viewer 与图片消息轻卡也会从 task artifact runtime contract 展示同一 policy evaluation meta。这些 hit、evaluator、thread read 摘要、TaskProfile 摘要、权限摘要、模型能力 gap、任务索引 explanation 与任务卡/viewer meta 都只解释已命中的控制面输入,不代表 LimeCore 云 run/poll 或云默认执行。
当前落点:central runtime contract helper、前端 runtime contract resolver、Evidence Pack 与 `list_media_task_artifacts` 已携带 `limecore_policy_refs` 和最小本地默认 `limecore_policy_snapshot(status=local_defaults_evaluated, decision=allow, decision_source=local_default_policy, decision_scope=local_defaults_only, policy_inputs, missing_inputs, pending_hit_refs, policy_value_hits, policy_value_hit_count)`;这个 `allow` 只表示本地默认策略没有阻断 current 路由。默认 `policy_inputs``declared_only / limecore_pending` 输入清单,`pending_hit_refs` 指向等待真实值的 refs`policy_value_hits=[]` / `policy_value_hit_count=0` 明确不伪造真实 tenant / provider / gateway 放行;如果已有 `status=resolved` 命中值,同一 seam 会把对应 ref 写入 `evaluated_refs`,将 input 标为 `resolved` 并使用 hit 的 `value_source`。当前已先把图片任务的本地 model registry assessment 接成 `model_catalog` hit producer,把已解析 runner config/API key 与 payload provider/model 接成最小 `provider_offer` hit producer,把请求侧 `harness.oem_routing` 接成 Browser Assist / Web Research 的最小 `gateway_policy` hit producer,并把 OEM Cloud bootstrap snapshot `features` 接成请求侧 `tenant_feature_flags` hit producer`policy_evaluation` 会在所有 refs resolved 时用最小本地 `policy_input_evaluator` 折叠 `allow / ask / deny``thread_read.runtime_summary.limecorePolicy` 会把最近一次 runtime contract 的 policy decision explanation 投影给上层读取,`thread_read.runtime_summary.modalityRuntime` 会把同一合同的 profile / adapter / executor binding 摘要投影给上层读取,`lime_runtime.task_profile` 已开始携带同一合同的权限 profile、用户锁定策略摘要与 `routingSlot`provider/model resolution 已用这些字段做最小模型能力 enforcement`lime_runtime.permission_state` 已把权限 profile 声明推进成 runtime 可读摘要,并由 `AgentRuntimeThreadReadModel.permission_state`、Evidence Pack 与 Replay runtime facts 结构化暴露;当声明态需要确认时,runtime 事件流也会暴露 `permission_review` 状态,`confirmationStatus=not_requested` 明确说明尚未生成真实审批请求;当同一 thread read 中已经存在真实 tool `ApprovalRequest` 时,读取模型会把 confirmation 派生为 `requested / resolved / denied` 并记录真实 request id`list_media_task_artifacts.modality_runtime_contracts` 也会汇总 evaluation status / decision / decision source 与 blocking / ask / pending refs;配音/转写任务卡恢复层已把 input gap / deny / ask 投影为轻卡 meta,图片 viewer 与图片消息轻卡也会从 task artifact runtime contract 展示同一 policy evaluation meta。这些 hit、evaluator、thread read 摘要、TaskProfile 摘要、权限摘要、模型能力 gap、任务索引 explanation 与任务卡/viewer meta 都只解释已命中的控制面输入,不代表 LimeCore 云 run/poll 或云默认执行。
### 目标
@@ -343,6 +343,8 @@ Browser Assist 必须收成:
## Phase 7:上层入口绑定
当前落点:见 [entry-binding-inventory.md](./entry-binding-inventory.md) 与 `src/lib/governance/modalityRuntimeContracts.json``bound_entries[]`;机器守卫已要求 current contract 至少有一个 entry binding、entry key 全局唯一、`entry_source` 只引用同一 contract 下的入口、`launch_metadata_path` 停留在 `harness.*`,且 entry binding 不得携带 truth source / artifact / viewer / evidence / executor / routing 底层字段。`/scene-key` 当前明确保持 planned,等待 LimeCore `client_scenes / scene_policy`、Scene catalog fallback 与 Scene run audit 进入同一 contract 后再登记。
### 目标
在底层 contract、profile、artifact、executor、LimeCore policy 都存在后,再把 `@` 命令、按钮和 Scene 绑定到 contract。
@@ -380,6 +382,8 @@ Browser Assist 必须收成:
## Phase 8:任务索引与复盘
当前落点:见 [task-index-inventory.md](./task-index-inventory.md) 与 `src/lib/governance/modalityArtifactGraph.json``task_index_fields`;机器守卫已要求所有 current / partial artifact kind 至少携带 `task_id / contract_key / artifact_kind / status / created_at / updated_at`,且索引字段不得重复。`thread_id / turn_id / content_id / entry_key / model_id / executor_kind / cost_state / limit_state / limecore_policy_snapshot` 仍按 inventory 标为未完整稳定化,不能伪装成全量完成。
### 目标
建立可查询的多模态任务索引,服务任务中心、复盘、审计和客服诊断。
+64
View File
@@ -0,0 +1,64 @@
# Phase 8 Task Index Inventory
> 目标:把多模态任务、产物、复盘和审计收敛到同一条可查询索引主链,避免任务中心、Evidence、Replay、客服诊断各自建立事实源。
## 1. 当前事实源
- 领域产物索引声明:`src/lib/governance/modalityArtifactGraph.json``artifact_kinds[].task_index_fields`
- 运行期媒体任务索引:`list_media_task_artifacts.modality_runtime_contracts`
- 审计事实源:Evidence Pack `modalityRuntimeContracts.snapshotIndex`
- 复盘事实源:Replay case `runtimeFacts``modalityContractChecks`
- 守卫:`scripts/check-modality-runtime-contracts.mjs`
- 验证入口:`npm run governance:modality-contracts`
## 2. Phase 8 最低机器验收
所有 `current` / `partial` artifact kind 的 `task_index_fields` 必须包含稳定核心字段:
1. `task_id`
2. `contract_key`
3. `artifact_kind`
4. `status`
5. `created_at`
6. `updated_at`
同时,`task_index_fields` 不允许重复字段。这个守卫保证 task index 至少能按任务、合同、产物类型、状态和时间恢复,不会退回只能打开隐藏 JSON 的人工排查。
## 3. 当前索引覆盖
| Artifact kind | 状态 | Contract | 核心索引 | 领域扩展 |
| --- | --- | --- | --- | --- |
| `image_task` | current | `image_generation` | 已覆盖 | 图片任务 payload / runtime contract |
| `image_output` | current | `image_generation` | 已覆盖 | 图片输出关联 |
| `audio_task` | current | `voice_generation` | 已覆盖 | `audio_output_status` |
| `audio_output` | current | `voice_generation` | 已覆盖 | `audio_output_status / audio_output_path` |
| `transcript` | partial | `audio_transcription` | 已覆盖 | `transcript_*` source / language / error code |
| `browser_session` | partial | `browser_control` | 已覆盖 | `browser_session_id / action_count / last_url` |
| `browser_snapshot` | partial | `browser_control` | 已覆盖 | `observation_count / screenshot_count / last_url` |
| `pdf_extract` | partial | `pdf_extract` | 已覆盖 | `source_path` |
| `report_document` | partial | `pdf_extract / web_research / text_transform` | 已覆盖 | `source_count` |
| `presentation_document` | planned | 未进入 current | 已预留 | 后续 presentation contract |
| `webpage_artifact` | planned | `web_research` | 已预留 | `url` |
| `generic_file` | current compat | `text_transform` | 已覆盖 | `path`,只能兜底 |
## 4. 仍未宣称完成的字段
Phase 8 目标字段中的以下项当前只在部分链路、metadata 或 Evidence/Replay 中可见,还没有统一成所有 artifact kind 的稳定 task index 字段:
- `thread_id`
- `turn_id`
- `content_id`
- `entry_key`
- `modality`
- `skill_id`
- `model_id`
- `executor_kind`
- `cost_state`
- `limit_state`
- `limecore_policy_snapshot`
这些字段不能用文档伪造成已完整覆盖;后续应逐步从现有 `runtime_contract``entry_source``task_profile``limecore_policy_snapshot` 与 Evidence `snapshotIndex` 中回填到统一 task index。
## 5. 下一刀建议
下一步优先把 `entry_source` 归一为可查询 `entry_key`,并把 `executor_kind / executor_binding_key / limecore_policy_snapshot``list_media_task_artifacts.modality_runtime_contracts.snapshots[]` 提升为稳定 task index 查询维度。
+64
View File
@@ -0,0 +1,64 @@
- generic [ref=e2]:
- generic [ref=e14]:
- complementary [ref=e15]:
- generic [ref=e16]:
- generic [ref=e17]:
- button "返回 Lime 首页" [ref=e18] [cursor=pointer]:
- img "Lime" [ref=e20]
- generic [ref=e21]: Lime
- button "邀请好友" [ref=e22] [cursor=pointer]:
- img [ref=e23]
- generic [ref=e27]: 邀请好友
- button "折叠导航栏" [ref=e28] [cursor=pointer]:
- img [ref=e29]
- button "搜索任务" [ref=e32] [cursor=pointer]:
- img [ref=e33]
- generic [ref=e36]: 搜索任务
- generic [ref=e37]:
- generic [ref=e38]:
- button "新建任务" [ref=e39] [cursor=pointer]:
- img [ref=e40]
- generic [ref=e41]: 新建任务
- button "我的方法" [ref=e42] [cursor=pointer]:
- img [ref=e43]
- generic [ref=e45]: 我的方法
- button "灵感库" [ref=e46] [cursor=pointer]:
- img [ref=e47]
- generic [ref=e59]: 灵感库
- button "知识库" [ref=e60] [cursor=pointer]:
- img [ref=e61]
- generic [ref=e63]: 知识库
- generic [ref=e64]:
- generic [ref=e65]:
- generic [ref=e66]
- generic [ref=e72]
- button "归档" [ref=e78] [cursor=pointer]:
- img [ref=e79]
- text: 归档
- generic [ref=e81]:
- button "快速切换外观" [ref=e84] [cursor=pointer]:
- img [ref=e85]
- button "打开用户菜单" [ref=e92] [cursor=pointer]:
- generic [ref=e93]:
- generic [ref=e94]: 开
- generic [ref=e95]: 开源使用
- generic [ref=e96]:
- generic [ref=e97]: 本地可用
- img [ref=e98]
- main [ref=e100]:
- generic [ref=e103]:
- generic [ref=e104]:
- generic [ref=e107]:
- generic [ref=e108]
- button "展开工作区菜单" [ref=e110] [cursor=pointer]
- generic [ref=e115]:
- button "新对话" [ref=e117] [cursor=pointer]
- button "新建对话" [ref=e121] [cursor=pointer]
- generic [ref=e130]:
- generic [ref=e131]:
- generic [ref=e133]
- link "向下滑,看看 Lime 可以帮你做什么":
- /url: "#home-skill-gallery-screen"
- region "Lime 可执行任务示例" [ref=e207]:
- generic [ref=e209]
- region "Notifications alt+T"
+143 -4
View File
@@ -16,6 +16,8 @@ const REQUIRED_DOCS = [
"docs/roadmap/warp/capability-matrix.md",
"docs/roadmap/warp/execution-profile.md",
"docs/roadmap/warp/artifact-graph.md",
"docs/roadmap/warp/entry-binding-inventory.md",
"docs/roadmap/warp/task-index-inventory.md",
"docs/roadmap/warp/evolution-guide.md",
];
@@ -127,6 +129,8 @@ const REQUIRED_ARTIFACT_INDEX_FIELDS = new Set([
"contract_key",
"artifact_kind",
"status",
"created_at",
"updated_at",
]);
const ENTRY_KINDS = new Set([
"command",
@@ -142,6 +146,15 @@ const FORBIDDEN_ENTRY_FIELDS = new Set([
"executor_binding",
"routing_slot",
]);
const PHASE7_REQUIRED_ENTRY_BINDINGS = new Map([
["image_generation", ["at_image_command"]],
["browser_control", ["at_browser_command"]],
["pdf_extract", ["at_pdf_read_command"]],
["voice_generation", ["at_voice_command"]],
["audio_transcription", ["at_transcription_command"]],
["web_research", ["at_search_command"]],
["text_transform", ["at_summary_command"]],
]);
function collectUniqueObjects(errors, collection, keyName, label) {
const result = new Map();
@@ -353,6 +366,108 @@ function validateEntryBindings(errors, contract) {
});
}
function validatePhase7EntryBindingCoverage(errors, registry) {
if (!Array.isArray(registry.contracts)) {
return { entryBindingCount: 0 };
}
let entryBindingCount = 0;
const globalEntryKeys = new Map();
for (const contract of registry.contracts) {
if (!isPlainObject(contract)) {
continue;
}
const contractKey = contract.contract_key;
const entries = Array.isArray(contract.bound_entries)
? contract.bound_entries.filter(isPlainObject)
: [];
const entryKeysForContract = new Set(
entries
.map((entry) => entry.entry_key)
.filter((entryKey) => isNonEmptyString(entryKey)),
);
entryBindingCount += entries.length;
if (contract.lifecycle === "current") {
pushIf(
errors,
entries.length === 0,
`current contract ${contractKey}.bound_entries must include at least one Phase 7 entry binding`,
);
}
for (const entry of entries) {
const entryKey = entry.entry_key;
const prefix = `${contractKey}.bound_entries.${entryKey || "<missing>"}`;
if (!isNonEmptyString(entryKey)) {
continue;
}
pushIf(
errors,
globalEntryKeys.has(entryKey),
`${prefix} duplicates entry_key already owned by ${globalEntryKeys.get(entryKey)}`,
);
globalEntryKeys.set(entryKey, contractKey);
if (isNonEmptyString(entry.entry_source)) {
pushIf(
errors,
!entryKeysForContract.has(entry.entry_source),
`${prefix}.entry_source must reference an entry_key on the same contract, not ${entry.entry_source}`,
);
}
if (entry.entry_kind === "scene") {
const policyRefs = new Set(
Array.isArray(contract.limecore_policy_refs)
? contract.limecore_policy_refs
: [],
);
pushIf(
errors,
!policyRefs.has("client_scenes") && !policyRefs.has("scene_policy"),
`${prefix} is a scene entry but contract.limecore_policy_refs does not include client_scenes or scene_policy`,
);
}
}
}
for (const [contractKey, requiredEntryKeys] of PHASE7_REQUIRED_ENTRY_BINDINGS) {
const contract = registry.contracts.find(
(candidate) =>
isPlainObject(candidate) && candidate.contract_key === contractKey,
);
pushIf(
errors,
!contract,
`Phase 7 required contract is missing: ${contractKey}`,
);
if (!contract) {
continue;
}
const entryKeys = new Set(
Array.isArray(contract.bound_entries)
? contract.bound_entries
.map((entry) => entry?.entry_key)
.filter((entryKey) => isNonEmptyString(entryKey))
: [],
);
for (const requiredEntryKey of requiredEntryKeys) {
pushIf(
errors,
!entryKeys.has(requiredEntryKey),
`Phase 7 required entry binding ${contractKey}.${requiredEntryKey} is missing`,
);
}
}
return { entryBindingCount };
}
function validateCapabilityMatrix(matrix) {
const errors = [];
pushIf(errors, matrix.version !== 1, "capabilityMatrix.version must be 1");
@@ -525,6 +640,20 @@ function validateArtifactGraph(graph) {
"task_index_fields",
artifact.task_index_fields,
);
if (Array.isArray(artifact.task_index_fields)) {
const seenIndexFields = new Set();
artifact.task_index_fields.forEach((fieldName, index) => {
if (!isNonEmptyString(fieldName)) {
return;
}
pushIf(
errors,
seenIndexFields.has(fieldName),
`artifact ${kind}.task_index_fields[${index}] is duplicated: ${fieldName}`,
);
seenIndexFields.add(fieldName);
});
}
validateStringArray(
errors,
kind,
@@ -1042,7 +1171,10 @@ function validateContractRegistry(registry, matrixRefs, artifactGraphRefs) {
validateContractArtifactGraph(errors, contract, artifactGraphRefs);
}
return errors;
return {
errors,
entryBindingReport: validatePhase7EntryBindingCoverage(errors, registry),
};
}
function validateRequiredDocs() {
@@ -1055,7 +1187,7 @@ function validateRequiredDocs() {
});
}
function renderSuccess(registry, matrix, graph, profileReport) {
function renderSuccess(registry, matrix, graph, contractReport, profileReport) {
const currentCount = registry.contracts.filter(
(contract) => contract.lifecycle === "current",
).length;
@@ -1066,6 +1198,8 @@ function renderSuccess(registry, matrix, graph, profileReport) {
` capabilities: ${matrix.capabilities.length}`,
` model roles: ${matrix.model_roles.length}`,
` artifact kinds: ${graph.artifact_kinds.length}`,
` entry bindings: ${contractReport.entryBindingReport.entryBindingCount}`,
` task index core fields: ${REQUIRED_ARTIFACT_INDEX_FIELDS.size}`,
` execution profiles: ${profileReport.profileCount}`,
` executor adapters: ${profileReport.adapterCount}`,
` registry: ${CONTRACT_PATH}`,
@@ -1088,11 +1222,16 @@ function main() {
matrixReport,
graphReport,
);
const contractReport = validateContractRegistry(
registry,
matrixReport,
graphReport,
);
const errors = [
...validateRequiredDocs(),
...matrixReport.errors,
...graphReport.errors,
...validateContractRegistry(registry, matrixReport, graphReport),
...contractReport.errors,
...profileReport.errors,
];
@@ -1104,7 +1243,7 @@ function main() {
process.exit(1);
}
console.log(renderSuccess(registry, matrix, graph, profileReport));
console.log(renderSuccess(registry, matrix, graph, contractReport, profileReport));
}
main();
+233 -382
View File
@@ -4,6 +4,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import { chromium } from "playwright";
const DEFAULTS = {
appUrl: "http://127.0.0.1:1420/",
@@ -16,13 +17,9 @@ const DEFAULTS = {
const INVOKE_TIMEOUT_CEILING_MS = 180_000;
const INVOKE_RETRY_COUNT = 10;
const INVOKE_RETRY_DELAY_MS = 1_000;
const BROWSER_ACTION_RETRY_COUNT = 6;
const BROWSER_ACTION_RETRY_DELAY_MS = 1_000;
const DEFAULT_ACTION_TIMEOUT_MS = 45_000;
const POST_HEALTH_SETTLE_MS = 1_000;
const POST_LAUNCH_SETTLE_MS = 1_500;
const ONBOARDING_VERSION = "1.1.0";
const SMOKE_PROFILE_KEY = "smoke-knowledge-gui";
const DEFAULT_PACK = {
name: "smoke-default",
@@ -58,7 +55,7 @@ Lime Knowledge GUI Smoke
用途:
通过真实 Lime 页面验证项目资料管理页、全部资料列表、
用于生成视图与手动导入入口。
用于生成视图与补充导入入口。
用法:
npm run smoke:knowledge-gui
@@ -132,12 +129,6 @@ function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function logStage(label) {
console.log(`[smoke:knowledge-gui] stage=${label}`);
}
@@ -225,200 +216,253 @@ async function waitForHealth(options) {
);
}
async function closeSmokeProfileSession(options, profileKey, label) {
async function waitForPageText(page, label, needles, timeoutMs) {
try {
await invoke(options, "close_chrome_profile_session", {
profile_key: profileKey,
await page.waitForFunction(
(expectedNeedles) => {
const text = document.body?.innerText || "";
const fieldText = Array.from(
document.querySelectorAll("textarea, input"),
)
.map((element) =>
element instanceof HTMLTextAreaElement ||
element instanceof HTMLInputElement
? element.value
: "",
)
.join("\n");
const searchableText = `${text}\n${fieldText}`;
return expectedNeedles.every((needle) => searchableText.includes(needle));
},
needles,
{ timeout: timeoutMs },
);
} catch (error) {
const text = await page.locator("body").innerText().catch(() => "");
const fieldText = await page
.locator("textarea, input")
.evaluateAll((elements) =>
elements
.map((element) =>
element instanceof HTMLTextAreaElement ||
element instanceof HTMLInputElement
? element.value
: "",
)
.filter(Boolean)
.join("\n"),
)
.catch(() => "");
const searchableText = `${text}\n${fieldText}`;
const missing = needles.filter((needle) => !searchableText.includes(needle));
throw new Error(
`[smoke:knowledge-gui] ${label} 等待失败,缺少 ${JSON.stringify(
missing,
)},页面文本预览: ${searchableText.slice(0, 2_000)}`,
{ cause: error },
);
}
}
async function clickPageControl(page, { text, ariaLabel, index = 0 }) {
const locator = ariaLabel
? page.getByRole("button", { name: ariaLabel }).nth(index)
: page
.locator("button, [role='button'], a")
.filter({ hasText: text })
.nth(index);
try {
await locator.click({ timeout: DEFAULT_ACTION_TIMEOUT_MS });
} catch (error) {
const buttons = await page
.locator("button, [role='button'], a")
.evaluateAll((items) =>
items.slice(0, 80).map((item) => ({
text: (item.textContent || "").trim().replace(/\s+/g, " "),
aria: item.getAttribute("aria-label"),
title: item.getAttribute("title"),
disabled:
item instanceof HTMLButtonElement ? item.disabled : undefined,
})),
)
.catch(() => []);
throw new Error(
`[smoke:knowledge-gui] 点击控件失败 ${JSON.stringify({
text,
ariaLabel,
index,
buttons,
})}`,
{ cause: error },
);
}
}
async function createSmokeProject(options) {
const projectName = `Knowledge GUI Smoke ${process.pid}`;
const project = await invoke(options, "workspace_create", {
request: {
name: projectName,
rootPath: options.workingDir,
workspaceType: "temporary",
},
});
const projectId = String(project?.id || "").trim();
if (!projectId) {
throw new Error("[smoke:knowledge-gui] workspace_create 未返回项目 ID");
}
options.projectId = projectId;
options.projectName = String(project?.name || projectName);
}
async function cleanupSmokeProject(options) {
if (!options.projectId) {
return;
}
try {
await invoke(options, "workspace_delete", {
id: options.projectId,
deleteDirectory: false,
});
} catch (error) {
console.warn(
`[smoke:knowledge-gui] ${label}: ${
`[smoke:knowledge-gui] 清理临时项目失败: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
function isRetryableBrowserActionFailure(detail) {
return (
typeof detail === "string" &&
(detail.includes("CDP 调试端口不可用") ||
detail.includes("没有可用的 Chrome 会话"))
async function runPlaywrightGuiFlow(options) {
const userDataDir = fs.mkdtempSync(
path.join(os.tmpdir(), `lime-knowledge-gui-playwright-${process.pid}-`),
);
}
const launchOptions = {
headless: true,
viewport: { width: 1440, height: 960 },
};
let context = null;
async function runBrowserAction(options, profileKey, action, args = {}, label = action) {
for (let attempt = 1; attempt <= BROWSER_ACTION_RETRY_COUNT; attempt += 1) {
const result = await invoke(options, "browser_execute_action", {
request: {
profile_key: profileKey,
backend: "cdp_direct",
action,
args,
timeout_ms: DEFAULT_ACTION_TIMEOUT_MS,
},
try {
context = await chromium.launchPersistentContext(userDataDir, {
...launchOptions,
channel: "chrome",
});
if (result?.success === true) {
return result;
}
const detail = String(result?.error || JSON.stringify(result ?? null));
if (
isRetryableBrowserActionFailure(detail) &&
attempt < BROWSER_ACTION_RETRY_COUNT
) {
console.warn(
`[smoke:knowledge-gui] browser_execute_action(${label}) 第 ${attempt} 次失败,${BROWSER_ACTION_RETRY_DELAY_MS}ms 后重试: ${detail}`,
);
await sleep(BROWSER_ACTION_RETRY_DELAY_MS);
continue;
}
throw new Error(
`[smoke:knowledge-gui] browser_execute_action(${label}) 失败: ${detail}`,
} catch (chromeError) {
console.warn(
`[smoke:knowledge-gui] Chrome channel 启动失败,尝试 Playwright 自带 Chromium: ${
chromeError instanceof Error ? chromeError.message : String(chromeError)
}`,
);
context = await chromium.launchPersistentContext(userDataDir, launchOptions);
}
throw new Error(
`[smoke:knowledge-gui] browser_execute_action(${label}) 失败: unknown error`,
);
}
const page = context.pages()[0] ?? (await context.newPage());
const consoleErrors = [];
function extractJavascriptValue(actionResult) {
return (
actionResult?.data?.result ??
actionResult?.data?.value ??
actionResult?.data?.result?.result ??
actionResult?.data?.result?.value ??
actionResult?.data?.result ??
null
);
}
async function runJavascript(options, profileKey, expression, label = "javascript") {
const result = await runBrowserAction(
options,
profileKey,
"javascript",
{
expression,
return_by_value: true,
},
`javascript:${label}`,
);
return extractJavascriptValue(result);
}
async function waitForCheck(options, label, check) {
const startedAt = Date.now();
let lastValue = null;
while (Date.now() - startedAt < options.timeoutMs) {
lastValue = await check();
if (lastValue?.ok) {
return lastValue.value;
page.on("console", (message) => {
if (message.type() === "error") {
consoleErrors.push(message.text());
}
await sleep(options.intervalMs);
}
});
page.on("pageerror", (error) => {
consoleErrors.push(error.stack || error.message);
});
throw new Error(
`[smoke:knowledge-gui] 等待 ${label} 超时,最后结果: ${JSON.stringify(
lastValue?.value ?? null,
)}`,
);
}
try {
logStage("open-playwright-page");
await page.goto(options.appUrl, { waitUntil: "domcontentloaded" });
await page.evaluate(
({ workingDir, onboardingVersion, projectId }) => {
localStorage.setItem("lime_onboarding_complete", "true");
localStorage.setItem("lime_onboarding_version", onboardingVersion);
localStorage.setItem("lime_user_profile", "developer");
localStorage.setItem("lime.knowledge.working-dir", workingDir);
localStorage.setItem("agent_last_project_id", JSON.stringify(projectId));
},
{
workingDir: options.workingDir,
onboardingVersion: ONBOARDING_VERSION,
projectId: options.projectId,
},
);
await page.reload({ waitUntil: "domcontentloaded" });
function buildPageStorageReadyScript(appUrl) {
return `(() => {
logStage("wait-home");
await waitForPageText(page, "首页加载", ["青柠一下,灵感即来"], options.timeoutMs);
logStage("open-knowledge-page");
await clickPageControl(page, { ariaLabel: "知识库" });
logStage("wait-knowledge-overview");
await waitForPageText(
page,
"知识库总览加载",
[
"项目资料管理",
"日常使用入口",
"回到 Agent",
"全部资料",
"全部项目资料",
"已添加资料",
"已整理草稿",
"已确认可用",
DEFAULT_PACK.title,
SECONDARY_PACK.title,
options.projectName,
],
options.timeoutMs,
);
logStage("open-agent-with-knowledge");
await clickPageControl(page, { text: "用于生成" });
logStage("wait-agent");
try {
const href = window.location.href;
const readyState = document.readyState;
void window.localStorage;
return {
ok: href.startsWith(${JSON.stringify(appUrl)}) && readyState !== "loading",
href,
readyState,
title: document.title,
};
await waitForPageText(
page,
"Agent 页面加载",
["项目资料:未使用", "请基于当前项目资料生成内容"],
options.timeoutMs,
);
} catch (error) {
return {
ok: false,
href: window.location.href,
readyState: document.readyState,
title: document.title,
error: error instanceof Error ? error.message : String(error),
};
if (consoleErrors.length > 0) {
throw new Error(
`${
error instanceof Error ? error.message : String(error)
}console error: ${JSON.stringify(consoleErrors.slice(0, 5))}`,
);
}
throw error;
}
})()`;
}
function buildBootstrapStorageScript(workingDir) {
return `(() => {
localStorage.setItem("lime_onboarding_complete", "true");
localStorage.setItem("lime_onboarding_version", ${JSON.stringify(ONBOARDING_VERSION)});
localStorage.setItem("lime_user_profile", "developer");
localStorage.setItem("lime.knowledge.working-dir", ${JSON.stringify(workingDir)});
return true;
})()`;
}
logStage("return-knowledge-page");
await clickPageControl(page, { ariaLabel: "知识库" });
function buildTextCheckScript(needles) {
return `(() => {
const text = document.body ? document.body.innerText : "";
const needles = ${JSON.stringify(needles)};
return {
ok: needles.every((needle) => text.includes(needle)),
missing: needles.filter((needle) => !text.includes(needle)),
text,
};
})()`;
}
logStage("open-import-view");
await clickPageControl(page, { text: "补充导入" });
function buildClickButtonScript({ text, ariaLabel, index = 0 }) {
return `(() => {
const candidates = Array.from(document.querySelectorAll("button, [role='button'], a"));
const matches = candidates.filter((element) => {
const label = (element.getAttribute("aria-label") || "").trim();
const title = (element.getAttribute("title") || "").trim();
const content = (element.textContent || "").trim().replace(/\\s+/g, " ");
const textMatched = ${JSON.stringify(text ?? "")}
? content === ${JSON.stringify(text ?? "")} || content.includes(${JSON.stringify(text ?? "")})
: false;
const ariaMatched = ${JSON.stringify(ariaLabel ?? "")}
? label === ${JSON.stringify(ariaLabel ?? "")} || title === ${JSON.stringify(ariaLabel ?? "")}
: false;
return textMatched || ariaMatched;
});
const target = matches[${Number(index)}] || null;
if (!target) {
return {
ok: false,
reason: "missing-button",
buttons: candidates.slice(0, 80).map((button) => ({
text: (button.textContent || "").trim().replace(/\\s+/g, " "),
aria: button.getAttribute("aria-label"),
title: button.getAttribute("title"),
})),
};
logStage("wait-organize-entry");
await waitForPageText(
page,
"资料整理入口加载",
["自动整理", "整理资料", "引用摘要", "人工确认"],
options.timeoutMs,
);
if (consoleErrors.length > 0) {
throw new Error(
`[smoke:knowledge-gui] 页面存在 ${consoleErrors.length} 条 console error: ${JSON.stringify(
consoleErrors.slice(0, 5),
)}`,
);
}
if (target instanceof HTMLButtonElement && target.disabled) {
return {
ok: false,
reason: "button-disabled",
text: target.textContent,
aria: target.getAttribute("aria-label"),
};
}
target.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, view: window }));
target.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true, view: window }));
target.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window }));
return {
ok: true,
text: (target.textContent || "").trim(),
aria: target.getAttribute("aria-label"),
};
})()`;
} finally {
await context.close().catch(() => undefined);
fs.rmSync(userDataDir, { recursive: true, force: true });
}
}
async function seedPack(options, pack) {
@@ -469,215 +513,22 @@ async function main() {
path.join(os.tmpdir(), `lime-knowledge-gui-smoke-${process.pid}-`),
);
logStage("wait-health");
await waitForHealth(options);
await sleep(POST_HEALTH_SETTLE_MS);
logStage("seed-knowledge-packs");
await seedKnowledgePacks(options);
const profileKey = SMOKE_PROFILE_KEY;
let sessionId = null;
try {
logStage("cleanup-old-profile");
await closeSmokeProfileSession(
options,
profileKey,
"预清理旧 smoke profile 失败",
);
logStage("wait-health");
await waitForHealth(options);
await sleep(POST_HEALTH_SETTLE_MS);
logStage("launch-browser-session");
const launchResponse = await invoke(options, "launch_browser_session", {
request: {
profile_key: profileKey,
url: options.appUrl,
headless: true,
open_window: false,
stream_mode: "events",
},
});
logStage("seed-knowledge-packs");
await seedKnowledgePacks(options);
sessionId = launchResponse?.session?.session_id ?? null;
assert(
typeof sessionId === "string" && sessionId.trim(),
"launch_browser_session 未返回 session.session_id",
);
await sleep(POST_LAUNCH_SETTLE_MS);
logStage("create-smoke-project");
await createSmokeProject(options);
logStage("wait-page-storage-ready");
await waitForCheck(options, "Lime 首页 origin 可访问", async () => {
const value = await runJavascript(
options,
profileKey,
buildPageStorageReadyScript(options.appUrl),
"wait-page-storage-ready",
);
return {
ok: value?.ok === true,
value,
};
});
logStage("bootstrap-storage");
await runJavascript(
options,
profileKey,
buildBootstrapStorageScript(options.workingDir),
"bootstrap-storage",
);
logStage("refresh-page");
await runBrowserAction(options, profileKey, "refresh_page");
logStage("wait-home");
await waitForCheck(options, "首页加载", async () => {
const value = await runJavascript(
options,
profileKey,
buildTextCheckScript(["青柠一下,灵感即来"]),
"wait-home",
);
return {
ok: value?.ok === true,
value,
};
});
logStage("open-knowledge-page");
const navigation = await runJavascript(
options,
profileKey,
buildClickButtonScript({ ariaLabel: "知识库" }),
"open-knowledge-page",
);
assert(
navigation?.ok === true,
`打开知识库入口失败: ${JSON.stringify(navigation ?? null)}`,
);
logStage("wait-knowledge-overview");
await waitForCheck(options, "知识库总览加载", async () => {
const value = await runJavascript(
options,
profileKey,
buildTextCheckScript([
"项目资料管理",
"日常使用入口",
"回到 Agent",
"全部资料",
"全部项目资料",
"等你确认的资料",
DEFAULT_PACK.title,
SECONDARY_PACK.title,
]),
"wait-knowledge-overview",
);
return {
ok: value?.ok === true,
value,
};
});
logStage("open-agent-with-knowledge");
const usePack = await runJavascript(
options,
profileKey,
buildClickButtonScript({ text: "用于生成" }),
"open-agent-with-knowledge",
);
assert(
usePack?.ok === true,
`使用资料回到 Agent 失败: ${JSON.stringify(usePack ?? null)}`,
);
logStage("wait-agent");
await waitForCheck(options, "Agent 页面加载", async () => {
const value = await runJavascript(
options,
profileKey,
buildTextCheckScript([
"青柠一下,灵感即来",
"请基于当前项目资料生成内容",
]),
"wait-agent",
);
return {
ok: value?.ok === true,
value,
};
});
logStage("return-knowledge-page");
const backToKnowledge = await runJavascript(
options,
profileKey,
buildClickButtonScript({ ariaLabel: "知识库" }),
"return-knowledge-page",
);
assert(
backToKnowledge?.ok === true,
`返回资料管理页失败: ${JSON.stringify(backToKnowledge ?? null)}`,
);
logStage("open-import-view");
const importView = await runJavascript(
options,
profileKey,
buildClickButtonScript({ text: "导入资料" }),
"open-import-view",
);
assert(
importView?.ok === true,
`打开资料导入视图失败: ${JSON.stringify(importView ?? null)}`,
);
logStage("wait-organize-entry");
await waitForCheck(options, "资料整理入口加载", async () => {
const value = await runJavascript(
options,
profileKey,
buildTextCheckScript([
"自动整理",
"整理资料",
"预览摘要",
"人工确认",
]),
"wait-organize-entry",
);
return {
ok: value?.ok === true,
value,
};
});
console.log(
`[smoke:knowledge-gui] 通过 session=${sessionId} profile=${profileKey} workingDir=${options.workingDir}`,
);
await runPlaywrightGuiFlow(options);
console.log("[smoke:knowledge-gui] 通过");
} finally {
if (sessionId) {
logStage("close-cdp-session");
try {
await invoke(options, "close_cdp_session", {
request: {
session_id: sessionId,
},
});
} catch (error) {
console.warn(
`[smoke:knowledge-gui] 清理浏览器会话失败: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
logStage("close-profile-session");
await closeSmokeProfileSession(
options,
profileKey,
"关闭 smoke profile 失败",
);
await cleanupSmokeProject(options);
fs.rmSync(options.workingDir, { recursive: true, force: true });
}
}
@@ -17,19 +17,6 @@ function createTempRoot() {
return tempRoot;
}
function runNodeScript(scriptRelativePath: string, args: string[]) {
const output = execFileSync(
process.execPath,
[path.join(repoRoot, scriptRelativePath), ...args],
{
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
},
);
return JSON.parse(output);
}
function runNodeScriptAsync(scriptRelativePath: string, args: string[]) {
return new Promise<unknown>((resolve, reject) => {
execFile(
@@ -195,19 +182,19 @@ describe("harness-eval-history-record", () => {
});
});
it("默认入口应产出完整 harness artifact 套件", () => {
it("默认入口应产出完整 harness artifact 套件", async () => {
const tempRoot = createTempRoot();
const historyDir = path.join(tempRoot, ".lime", "harness", "history");
const workspaceRoot = path.join(tempRoot, "workspace");
const result = runNodeScript("scripts/harness-eval-history-record.mjs", [
const result = (await runNodeScriptAsync("scripts/harness-eval-history-record.mjs", [
"--format",
"json",
"--history-dir",
historyDir,
"--workspace-root",
workspaceRoot,
]);
])) as Record<string, any>;
const reportsRoot = path.join(tempRoot, ".lime", "harness", "reports");
const summaryJsonPath = path.join(reportsRoot, "harness-eval-summary.json");
@@ -289,7 +276,7 @@ describe("harness-eval-history-record", () => {
).not.toThrow();
}, 60_000);
it("应记录本地 summary 历史,并生成非 seed 的 trend / cleanup", () => {
it("应记录本地 summary 历史,并生成非 seed 的 trend / cleanup", async () => {
const tempRoot = createTempRoot();
const historyDir = path.join(tempRoot, ".lime", "harness", "history");
const workspaceRoot = path.join(tempRoot, "workspace");
@@ -315,7 +302,7 @@ describe("harness-eval-history-record", () => {
"harness-dashboard.html",
);
const firstResult = runNodeScript("scripts/harness-eval-history-record.mjs", [
const firstResult = (await runNodeScriptAsync("scripts/harness-eval-history-record.mjs", [
"--format",
"json",
"--history-dir",
@@ -330,9 +317,9 @@ describe("harness-eval-history-record", () => {
dashboardHtmlPath,
"--output-json",
path.join(tempRoot, "first.json"),
]);
])) as Record<string, any>;
const secondResult = runNodeScript(
const secondResult = (await runNodeScriptAsync(
"scripts/harness-eval-history-record.mjs",
[
"--format",
@@ -350,7 +337,7 @@ describe("harness-eval-history-record", () => {
"--output-json",
path.join(tempRoot, "second.json"),
],
);
)) as Record<string, any>;
expect(firstResult.historyCount).toBe(1);
expect(firstResult.trend.sampleCount).toBe(1);
@@ -739,6 +739,167 @@ fn extract_runtime_summary(
}))
}
fn latest_pending_tool_confirmation_request(
pending_requests: &[AgentRuntimeRequestView],
) -> Option<&AgentRuntimeRequestView> {
pending_requests.iter().find(|request| {
request.status == "pending" && is_tool_confirmation_request(&request.request_type)
})
}
struct RuntimePermissionConfirmationProjection {
status: &'static str,
request_id: String,
source: &'static str,
}
fn approval_response_confirmed(response: &serde_json::Value) -> Option<bool> {
match response {
serde_json::Value::Bool(value) => Some(*value),
serde_json::Value::Object(object) => object
.get("confirmed")
.and_then(serde_json::Value::as_bool)
.or_else(|| object.get("approved").and_then(serde_json::Value::as_bool)),
_ => None,
}
}
fn latest_resolved_tool_confirmation_item(
detail: &SessionDetail,
) -> Option<RuntimePermissionConfirmationProjection> {
detail.items.iter().rev().find_map(|item| {
let lime_core::database::dao::agent_timeline::AgentThreadItemPayload::ApprovalRequest {
request_id,
action_type,
response,
..
} = &item.payload
else {
return None;
};
if !is_tool_confirmation_request(action_type) {
return None;
}
let confirmed = response.as_ref().and_then(approval_response_confirmed);
match item.status {
lime_core::database::dao::agent_timeline::AgentThreadItemStatus::Completed => {
Some(RuntimePermissionConfirmationProjection {
status: if confirmed == Some(false) {
"denied"
} else {
"resolved"
},
request_id: request_id.clone(),
source: "runtime_action_required",
})
}
lime_core::database::dao::agent_timeline::AgentThreadItemStatus::Failed => {
Some(RuntimePermissionConfirmationProjection {
status: "denied",
request_id: request_id.clone(),
source: "runtime_action_required",
})
}
_ => None,
}
})
}
fn apply_pending_confirmation_to_permission_state(
permission_state: Option<lime_agent::SessionExecutionRuntimePermissionState>,
pending_requests: &[AgentRuntimeRequestView],
resolved_confirmation: Option<RuntimePermissionConfirmationProjection>,
) -> Option<lime_agent::SessionExecutionRuntimePermissionState> {
let mut permission_state = permission_state?;
if permission_state.status != "requires_confirmation" {
return Some(permission_state);
}
if let Some(projection) = resolved_confirmation {
permission_state.confirmation_status = Some(projection.status.to_string());
permission_state.confirmation_request_id = Some(projection.request_id);
permission_state.confirmation_source = Some(projection.source.to_string());
if !permission_state
.notes
.iter()
.any(|note| note == "真实工具确认请求已完成")
{
permission_state
.notes
.push("真实工具确认请求已完成".to_string());
}
return Some(permission_state);
}
let Some(request) = latest_pending_tool_confirmation_request(pending_requests) else {
return Some(permission_state);
};
permission_state.confirmation_status = Some("requested".to_string());
permission_state.confirmation_request_id = Some(request.id.clone());
permission_state.confirmation_source = Some("runtime_action_required".to_string());
if !permission_state
.notes
.iter()
.any(|note| note == "真实工具确认请求已进入 action_required 队列")
{
permission_state
.notes
.push("真实工具确认请求已进入 action_required 队列".to_string());
}
Some(permission_state)
}
fn merge_permission_state_confirmation_into_runtime_summary(
runtime_summary: Option<serde_json::Value>,
permission_state: Option<&lime_agent::SessionExecutionRuntimePermissionState>,
) -> Option<serde_json::Value> {
let Some(permission_state) = permission_state else {
return runtime_summary;
};
let mut summary = runtime_summary.unwrap_or_else(|| serde_json::json!({}));
let object = match summary.as_object_mut() {
Some(object) => object,
None => return Some(summary),
};
object.insert(
"permissionStatus".to_string(),
serde_json::Value::String(permission_state.status.clone()),
);
object.insert(
"permissionAskCount".to_string(),
serde_json::json!(permission_state.ask_profile_keys.len() as u32),
);
object.insert(
"permissionBlockingCount".to_string(),
serde_json::json!(permission_state.blocking_profile_keys.len() as u32),
);
if let Some(confirmation_status) = permission_state.confirmation_status.as_ref() {
object.insert(
"permissionConfirmationStatus".to_string(),
serde_json::Value::String(confirmation_status.clone()),
);
}
if let Some(confirmation_request_id) = permission_state.confirmation_request_id.as_ref() {
object.insert(
"permissionConfirmationRequestId".to_string(),
serde_json::Value::String(confirmation_request_id.clone()),
);
}
if let Some(confirmation_source) = permission_state.confirmation_source.as_ref() {
object.insert(
"permissionConfirmationSource".to_string(),
serde_json::Value::String(confirmation_source.clone()),
);
}
Some(summary)
}
fn read_policy_json_path<'a>(
value: &'a serde_json::Value,
path: &[&str],
@@ -1384,6 +1545,12 @@ impl AgentRuntimeThreadReadModel {
.execution_runtime
.as_ref()
.and_then(|runtime| runtime.permission_state.clone());
let resolved_confirmation = latest_resolved_tool_confirmation_item(detail);
let permission_state = apply_pending_confirmation_to_permission_state(
permission_state,
&pending_requests,
resolved_confirmation,
);
let limit_event = detail
.execution_runtime
.as_ref()
@@ -1399,6 +1566,10 @@ impl AgentRuntimeThreadReadModel {
"modalityRuntime",
extract_modality_runtime_thread_summary(detail),
);
let runtime_summary = merge_permission_state_confirmation_into_runtime_summary(
runtime_summary,
permission_state.as_ref(),
);
let auxiliary_task_runtime = extract_auxiliary_runtime_snapshots(detail);
let auxiliary_task_kind = read_auxiliary_runtime_string(
auxiliary_task_runtime.as_ref(),
@@ -3111,6 +3282,229 @@ mod tests {
);
}
#[test]
fn thread_read_should_project_pending_tool_approval_into_permission_confirmation_state() {
let mut detail = build_session_detail(
vec![AgentThreadTurn {
id: "turn-approval".to_string(),
thread_id: "thread-1".to_string(),
prompt_text: "需要写入文件".to_string(),
status: AgentThreadTurnStatus::Running,
started_at: seconds_ago(30),
completed_at: None,
error_message: None,
created_at: seconds_ago(30),
updated_at: seconds_ago(5),
}],
vec![AgentThreadItem {
id: "item-approval".to_string(),
thread_id: "thread-1".to_string(),
turn_id: "turn-approval".to_string(),
sequence: 1,
status: AgentThreadItemStatus::InProgress,
started_at: seconds_ago(20),
completed_at: None,
updated_at: seconds_ago(20),
payload: AgentThreadItemPayload::ApprovalRequest {
request_id: "approval-1".to_string(),
action_type: "tool_confirmation".to_string(),
prompt: Some("请确认是否允许写入文件".to_string()),
tool_name: Some("write_file".to_string()),
arguments: Some(serde_json::json!({ "path": "README.md" })),
response: None,
},
}],
);
detail.execution_runtime = Some(lime_agent::SessionExecutionRuntime {
session_id: "session-1".to_string(),
provider_selector: None,
provider_name: None,
model_name: None,
execution_strategy: None,
output_schema_runtime: None,
source: lime_agent::SessionExecutionRuntimeSource::RuntimeSnapshot,
mode: None,
latest_turn_id: Some("turn-approval".to_string()),
latest_turn_status: Some("running".to_string()),
recent_access_mode: None,
recent_preferences: None,
recent_team_selection: None,
recent_theme: None,
recent_session_mode: None,
recent_gate_key: None,
recent_run_title: None,
recent_content_id: None,
task_profile: None,
routing_decision: None,
limit_state: None,
cost_state: None,
permission_state: Some(lime_agent::SessionExecutionRuntimePermissionState {
status: "requires_confirmation".to_string(),
required_profile_keys: vec!["write_artifacts".to_string()],
ask_profile_keys: vec!["write_artifacts".to_string()],
blocking_profile_keys: Vec::new(),
decision_source: "modality_execution_profile".to_string(),
decision_scope: "declared_profile".to_string(),
confirmation_status: Some("not_requested".to_string()),
confirmation_request_id: None,
confirmation_source: Some("declared_profile_only".to_string()),
notes: Vec::new(),
}),
limit_event: None,
oem_policy: None,
runtime_summary: None,
});
let thread_read = AgentRuntimeThreadReadModel::from_session_detail(&detail, &[]);
assert_eq!(thread_read.pending_requests.len(), 1);
assert_eq!(
thread_read
.permission_state
.as_ref()
.and_then(|value| value.confirmation_status.as_deref()),
Some("requested")
);
assert_eq!(
thread_read
.permission_state
.as_ref()
.and_then(|value| value.confirmation_request_id.as_deref()),
Some("approval-1")
);
assert_eq!(
thread_read
.permission_state
.as_ref()
.and_then(|value| value.confirmation_source.as_deref()),
Some("runtime_action_required")
);
assert_eq!(
thread_read
.runtime_summary
.as_ref()
.and_then(|value| value.get("permissionConfirmationStatus"))
.and_then(serde_json::Value::as_str),
Some("requested")
);
assert_eq!(
thread_read
.runtime_summary
.as_ref()
.and_then(|value| value.get("permissionConfirmationRequestId"))
.and_then(serde_json::Value::as_str),
Some("approval-1")
);
assert_eq!(
thread_read
.runtime_summary
.as_ref()
.and_then(|value| value.get("permissionConfirmationSource"))
.and_then(serde_json::Value::as_str),
Some("runtime_action_required")
);
}
#[test]
fn thread_read_should_project_resolved_tool_approval_into_permission_confirmation_state() {
let mut detail = build_session_detail(
vec![AgentThreadTurn {
id: "turn-approval".to_string(),
thread_id: "thread-1".to_string(),
prompt_text: "需要写入文件".to_string(),
status: AgentThreadTurnStatus::Completed,
started_at: seconds_ago(30),
completed_at: Some(seconds_ago(10)),
error_message: None,
created_at: seconds_ago(30),
updated_at: seconds_ago(10),
}],
vec![AgentThreadItem {
id: "item-approval".to_string(),
thread_id: "thread-1".to_string(),
turn_id: "turn-approval".to_string(),
sequence: 1,
status: AgentThreadItemStatus::Completed,
started_at: seconds_ago(20),
completed_at: Some(seconds_ago(15)),
updated_at: seconds_ago(15),
payload: AgentThreadItemPayload::ApprovalRequest {
request_id: "approval-2".to_string(),
action_type: "tool_confirmation".to_string(),
prompt: Some("请确认是否允许写入文件".to_string()),
tool_name: Some("write_file".to_string()),
arguments: Some(serde_json::json!({ "path": "README.md" })),
response: Some(serde_json::json!({ "confirmed": false })),
},
}],
);
detail.execution_runtime = Some(lime_agent::SessionExecutionRuntime {
session_id: "session-1".to_string(),
provider_selector: None,
provider_name: None,
model_name: None,
execution_strategy: None,
output_schema_runtime: None,
source: lime_agent::SessionExecutionRuntimeSource::RuntimeSnapshot,
mode: None,
latest_turn_id: Some("turn-approval".to_string()),
latest_turn_status: Some("completed".to_string()),
recent_access_mode: None,
recent_preferences: None,
recent_team_selection: None,
recent_theme: None,
recent_session_mode: None,
recent_gate_key: None,
recent_run_title: None,
recent_content_id: None,
task_profile: None,
routing_decision: None,
limit_state: None,
cost_state: None,
permission_state: Some(lime_agent::SessionExecutionRuntimePermissionState {
status: "requires_confirmation".to_string(),
required_profile_keys: vec!["write_artifacts".to_string()],
ask_profile_keys: vec!["write_artifacts".to_string()],
blocking_profile_keys: Vec::new(),
decision_source: "modality_execution_profile".to_string(),
decision_scope: "declared_profile".to_string(),
confirmation_status: Some("requested".to_string()),
confirmation_request_id: Some("approval-2".to_string()),
confirmation_source: Some("runtime_action_required".to_string()),
notes: Vec::new(),
}),
limit_event: None,
oem_policy: None,
runtime_summary: None,
});
let thread_read = AgentRuntimeThreadReadModel::from_session_detail(&detail, &[]);
assert!(thread_read.pending_requests.is_empty());
assert_eq!(
thread_read
.permission_state
.as_ref()
.and_then(|value| value.confirmation_status.as_deref()),
Some("denied")
);
assert_eq!(
thread_read
.permission_state
.as_ref()
.and_then(|value| value.confirmation_request_id.as_deref()),
Some("approval-2")
);
assert_eq!(
thread_read
.runtime_summary
.as_ref()
.and_then(|value| value.get("permissionConfirmationStatus"))
.and_then(serde_json::Value::as_str),
Some("denied")
);
}
#[test]
fn thread_read_should_surface_oem_policy_summary() {
let mut detail = build_session_detail(Vec::new(), Vec::new());
@@ -7,6 +7,23 @@ use aster::hooks::{SessionEndReason, SessionSource};
use aster::session::load_shared_session_runtime_snapshot;
use lime_core::database::dao::agent::SessionArchiveFilter;
fn persist_default_session_access_mode_in_background(session_id: String) {
tauri::async_runtime::spawn(async move {
if let Err(error) = AsterAgentWrapper::persist_session_recent_access_mode(
&session_id,
lime_agent::SessionExecutionRuntimeAccessMode::default_for_session(),
)
.await
{
tracing::warn!(
"[AsterAgent] 后台持久化默认会话权限失败: session_id={}, error={}",
session_id,
error
);
}
});
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct SessionProviderRoutingState {
provider_selector: String,
@@ -268,11 +285,7 @@ async fn create_runtime_session_internal_impl(
),
)?;
AsterAgentWrapper::persist_session_recent_access_mode(
&session_id,
lime_agent::SessionExecutionRuntimeAccessMode::default_for_session(),
)
.await?;
persist_default_session_access_mode_in_background(session_id.clone());
if !run_start_hooks {
tracing::info!(
@@ -111,6 +111,11 @@ struct AnalysisContextSummary {
decision_reason: String,
estimated_cost_class: String,
fallback_chain: Vec<String>,
permission_status: String,
permission_confirmation_status: String,
permission_confirmation_request_id: String,
permission_confirmation_source: String,
permission_confirmation_summary: String,
failure_modes: Vec<String>,
suite_tags: Vec<String>,
pending_request_count: usize,
@@ -283,6 +288,59 @@ pub fn export_runtime_analysis_handoff(
.and_then(|value| normalize_optional_text(value.primary_blocking_summary.clone()))
})
.unwrap_or_default();
let permission_status = value_string(
input_payload
.pointer("/runtimeContext/runtimeFacts/permissionState/status")
.unwrap_or(&Value::Null),
)
.or_else(|| {
thread_read
.permission_state
.as_ref()
.map(|value| value.status.clone())
})
.unwrap_or_default();
let permission_confirmation_status = value_string(
input_payload
.pointer("/runtimeContext/runtimeFacts/permissionState/confirmationStatus")
.unwrap_or(&Value::Null),
)
.or_else(|| {
thread_read
.permission_state
.as_ref()
.and_then(|value| normalize_optional_text(value.confirmation_status.clone()))
})
.unwrap_or_default();
let permission_confirmation_request_id = value_string(
input_payload
.pointer("/runtimeContext/runtimeFacts/permissionState/confirmationRequestId")
.unwrap_or(&Value::Null),
)
.or_else(|| {
thread_read
.permission_state
.as_ref()
.and_then(|value| normalize_optional_text(value.confirmation_request_id.clone()))
})
.unwrap_or_default();
let permission_confirmation_source = value_string(
input_payload
.pointer("/runtimeContext/runtimeFacts/permissionState/confirmationSource")
.unwrap_or(&Value::Null),
)
.or_else(|| {
thread_read
.permission_state
.as_ref()
.and_then(|value| normalize_optional_text(value.confirmation_source.clone()))
})
.unwrap_or_default();
let permission_confirmation_summary = format_permission_confirmation_summary(
&permission_confirmation_status,
&permission_confirmation_request_id,
&permission_confirmation_source,
);
let replay_refs = replay_case
.artifacts
@@ -403,6 +461,11 @@ pub fn export_runtime_analysis_handoff(
.pointer("/runtimeContext/runtimeFacts/fallbackChain")
.unwrap_or(&Value::Null),
),
permission_status,
permission_confirmation_status,
permission_confirmation_request_id,
permission_confirmation_source,
permission_confirmation_summary,
failure_modes: failure_modes.clone(),
suite_tags: suite_tags.clone(),
pending_request_count: thread_read.pending_requests.len(),
@@ -411,7 +474,7 @@ pub fn export_runtime_analysis_handoff(
let reading_order = build_reading_order();
let review_checklist =
build_human_review_checklist(&input_payload, &expected_payload, &failure_modes);
build_human_review_checklist(&input_payload, &expected_payload, &failure_modes, &summary);
let external_contract = build_external_analysis_contract();
let analysis_context = AnalysisContextDocument {
@@ -604,6 +667,14 @@ fn build_analysis_brief(
),
format!("- pending request{}", summary.pending_request_count),
format!("- queued turn{}", summary.queued_turn_count),
format!(
"- 权限状态:{}",
empty_fallback(&summary.permission_status, "未导出")
),
format!(
"- 权限确认:{}",
empty_fallback(&summary.permission_confirmation_summary, "未导出")
),
String::new(),
"## 证据关联与可观测覆盖".to_string(),
String::new(),
@@ -745,6 +816,10 @@ fn build_copy_prompt(
format!(" · {}", summary.primary_blocking_summary)
}
),
format!(
"- 权限确认:{}",
empty_fallback(&summary.permission_confirmation_summary, "未导出")
),
format!("- Handoff 根目录:`{}`", to_portable_path(&handoff_bundle.bundle_absolute_root)),
format!("- Evidence 根目录:`{}`", to_portable_path(&evidence_pack.pack_absolute_root)),
format!("- Replay 根目录:`{}`", to_portable_path(&replay_case.replay_absolute_root)),
@@ -804,6 +879,7 @@ fn build_human_review_checklist(
input_payload: &Value,
expected_payload: &Value,
failure_modes: &[String],
summary: &AnalysisContextSummary,
) -> Vec<String> {
let mut checklist = vec![
"确认外部 AI 是否引用了现有证据,而不是凭空推断。".to_string(),
@@ -811,6 +887,16 @@ fn build_human_review_checklist(
"确认回归建议是否能沉淀为 replay / eval / smoke,而不是停留在口头建议。".to_string(),
];
if summary.permission_confirmation_status == "denied" {
checklist.insert(
0,
"当前真实权限确认已被拒绝,不应把 handoff / evidence / replay 当成成功交付通过证据。"
.to_string(),
);
} else if summary.permission_confirmation_status == "resolved" {
checklist.push("确认外部 AI 没有把已通过的真实权限确认误判成仍需人工确认。".to_string());
}
let requires_human_review = expected_payload
.pointer("/graderSuggestion/requiresHumanReview")
.and_then(Value::as_bool)
@@ -836,6 +922,22 @@ fn build_human_review_checklist(
checklist
}
fn format_permission_confirmation_summary(status: &str, request_id: &str, source: &str) -> String {
let request_id = empty_fallback(request_id, "未记录 confirmationRequestId");
let source = empty_fallback(source, "未记录 confirmationSource");
match status {
"denied" => {
format!("已拒绝(request_id={request_id}, source={source}),不能作为成功交付证据。")
}
"resolved" => format!("已通过(request_id={request_id}, source={source})。"),
"requested" => format!("等待处理(request_id={request_id}, source={source})。"),
"not_requested" => "声明态权限尚未发起真实审批请求。".to_string(),
"" => String::new(),
other => format!("{other}source={source})。"),
}
}
fn handoff_artifact_kind_key(kind: &RuntimeHandoffArtifactKind) -> &'static str {
match kind {
RuntimeHandoffArtifactKind::Plan => "plan",
@@ -1442,6 +1544,25 @@ mod tests {
}
}
fn set_permission_confirmation(
thread_read: &mut AgentRuntimeThreadReadModel,
confirmation_status: &str,
request_id: &str,
) {
thread_read.permission_state = Some(lime_agent::SessionExecutionRuntimePermissionState {
status: "requires_confirmation".to_string(),
required_profile_keys: vec!["browser_control".to_string()],
ask_profile_keys: vec!["browser_control".to_string()],
blocking_profile_keys: Vec::new(),
decision_source: "runtime_task_profile".to_string(),
decision_scope: "declared_profile_only".to_string(),
confirmation_status: Some(confirmation_status.to_string()),
confirmation_request_id: Some(request_id.to_string()),
confirmation_source: Some("runtime_action_required".to_string()),
notes: Vec::new(),
});
}
fn write_request_telemetry_fixture(root: &Path) {
let request_logs_dir = root.join("request_logs");
fs::create_dir_all(&request_logs_dir).expect("create request logs dir");
@@ -1643,4 +1764,68 @@ mod tests {
assert!(context.contains("\"verificationRecoveredOutcomes\": ["));
assert!(context.contains("\"outcome\": \"recovered\""));
}
#[test]
fn should_surface_denied_permission_confirmation_in_analysis_handoff() {
let temp_dir = TempDir::new().expect("temp dir");
let detail = build_detail();
let mut thread_read = build_thread_read();
set_permission_confirmation(&mut thread_read, "denied", "approval-denied");
write_request_telemetry_fixture(temp_dir.path());
let result = export_runtime_analysis_handoff(&detail, &thread_read, temp_dir.path())
.expect("export");
assert!(result.copy_prompt.contains("权限确认:已拒绝"));
assert!(result.copy_prompt.contains("approval-denied"));
let brief = fs::read_to_string(
temp_dir
.path()
.join(".lime/harness/sessions/session-1/analysis/analysis-brief.md"),
)
.expect("brief");
let context = fs::read_to_string(
temp_dir
.path()
.join(".lime/harness/sessions/session-1/analysis/analysis-context.json"),
)
.expect("context");
assert!(brief.contains("权限确认:已拒绝"));
assert!(brief.contains("approval-denied"));
assert!(brief.contains("不能作为成功交付证据"));
assert!(context.contains("\"permissionConfirmationStatus\": \"denied\""));
assert!(context.contains("\"permissionConfirmationRequestId\": \"approval-denied\""));
assert!(context.contains("\"permissionState\""));
}
#[test]
fn should_surface_resolved_permission_confirmation_in_analysis_handoff() {
let temp_dir = TempDir::new().expect("temp dir");
let detail = build_detail();
let mut thread_read = build_thread_read();
set_permission_confirmation(&mut thread_read, "resolved", "approval-resolved");
write_request_telemetry_fixture(temp_dir.path());
export_runtime_analysis_handoff(&detail, &thread_read, temp_dir.path()).expect("export");
let brief = fs::read_to_string(
temp_dir
.path()
.join(".lime/harness/sessions/session-1/analysis/analysis-brief.md"),
)
.expect("brief");
let context = fs::read_to_string(
temp_dir
.path()
.join(".lime/harness/sessions/session-1/analysis/analysis-context.json"),
)
.expect("context");
assert!(brief.contains("权限确认:已通过"));
assert!(brief.contains("approval-resolved"));
assert!(context.contains("\"permissionConfirmationStatus\": \"resolved\""));
assert!(context.contains("没有把已通过的真实权限确认误判成仍需人工确认"));
}
}
@@ -228,7 +228,7 @@ pub fn export_runtime_evidence_pack(
&modality_runtime_contracts,
&verification,
);
let known_gaps = build_known_gaps(&recent_artifacts, &signal_coverage);
let known_gaps = build_known_gaps(&recent_artifacts, &signal_coverage, thread_read);
let observability_summary = build_runtime_observability_summary_json(
detail,
thread_read,
@@ -384,7 +384,7 @@ pub(crate) fn build_runtime_evidence_sceneapp_snapshot(
&modality_runtime_contracts,
&verification,
);
let known_gaps = build_known_gaps(&recent_artifacts, &signal_coverage);
let known_gaps = build_known_gaps(&recent_artifacts, &signal_coverage, thread_read);
RuntimeEvidenceSceneAppSnapshot {
recent_artifact_paths: recent_artifacts
@@ -529,6 +529,11 @@ fn build_summary_markdown(
"- 当前证据缺口:{}",
format_observability_gap_list(observability_summary)
);
let _ = writeln!(
markdown,
"- 当前阻断信号:{}",
format_observability_signal_list(observability_summary, "blocked")
);
let _ = writeln!(markdown);
let _ = writeln!(markdown, "## 建议读取顺序");
let _ = writeln!(markdown);
@@ -1375,6 +1380,7 @@ fn extract_transcript_snapshot(document: &Value) -> Option<Value> {
fn build_known_gaps(
recent_artifacts: &[RuntimeRecentArtifact],
signal_coverage: &[RuntimeEvidenceSignalCoverageEntry],
thread_read: &AgentRuntimeThreadReadModel,
) -> Vec<String> {
let mut gaps = signal_coverage
.iter()
@@ -1382,6 +1388,10 @@ fn build_known_gaps(
.map(|entry| entry.detail.clone())
.collect::<Vec<_>>();
if let Some(gap) = permission_confirmation_known_gap(thread_read) {
gaps.push(gap);
}
if recent_artifacts.is_empty() {
gaps.push("当前未检测到最近产物路径,Artifact 证据为空。".to_string());
}
@@ -1390,6 +1400,25 @@ fn build_known_gaps(
gaps
}
fn permission_confirmation_known_gap(thread_read: &AgentRuntimeThreadReadModel) -> Option<String> {
let permission_state = thread_read.permission_state.as_ref()?;
if permission_state.confirmation_status.as_deref() != Some("denied") {
return None;
}
Some(format!(
"运行时权限确认已被拒绝,当前证据包不能作为成功交付证据:request_id={}source={}。",
permission_state
.confirmation_request_id
.as_deref()
.unwrap_or("未记录 confirmationRequestId"),
permission_state
.confirmation_source
.as_deref()
.unwrap_or("未记录 confirmationSource")
))
}
fn collect_latest_turn_summary(detail: &SessionDetail) -> Option<String> {
detail
.items
@@ -1578,6 +1607,63 @@ fn build_thread_runtime_facts_json(thread_read: &AgentRuntimeThreadReadModel) ->
})
}
fn permission_state_signal_coverage(
thread_read: &AgentRuntimeThreadReadModel,
) -> RuntimeEvidenceSignalCoverageEntry {
let Some(permission_state) = thread_read.permission_state.as_ref() else {
return RuntimeEvidenceSignalCoverageEntry {
signal: "permissionState",
status: "missing",
source: "thread_read.permission_state",
detail: "thread_read 缺少 permission_state。".to_string(),
};
};
let confirmation_status = permission_state.confirmation_status.as_deref();
let confirmation_request_id = permission_state
.confirmation_request_id
.as_deref()
.unwrap_or("未记录 confirmationRequestId");
let confirmation_source = permission_state
.confirmation_source
.as_deref()
.unwrap_or("未记录 confirmationSource");
if confirmation_status == Some("denied") {
return RuntimeEvidenceSignalCoverageEntry {
signal: "permissionState",
status: "blocked",
source: "thread_read.permission_state",
detail: format!(
"thread_read 已导出 permission_state,但真实权限确认已被拒绝:request_id={confirmation_request_id}, source={confirmation_source}。"
),
};
}
let detail = match confirmation_status {
Some("resolved") => format!(
"thread_read 已导出 permission_state,真实权限确认已通过:request_id={confirmation_request_id}, source={confirmation_source}。"
),
Some("requested") => format!(
"thread_read 已导出 permission_state,真实权限确认正在等待处理:request_id={confirmation_request_id}, source={confirmation_source}。"
),
Some("not_requested") => {
"thread_read 已导出 permission_state,声明态权限尚未发起真实审批请求。".to_string()
}
Some(other) => format!(
"thread_read 已导出 permission_stateconfirmationStatus={other}, source={confirmation_source}。"
),
None => "thread_read 已导出 permission_state。".to_string(),
};
RuntimeEvidenceSignalCoverageEntry {
signal: "permissionState",
status: "exported",
source: "thread_read.permission_state",
detail,
}
}
fn build_runtime_fact_signal_coverage(
thread_read: &AgentRuntimeThreadReadModel,
) -> Vec<RuntimeEvidenceSignalCoverageEntry> {
@@ -1646,20 +1732,7 @@ fn build_runtime_fact_signal_coverage(
"thread_read 缺少 runtime_summary。".to_string()
},
},
RuntimeEvidenceSignalCoverageEntry {
signal: "permissionState",
status: if thread_read.permission_state.is_some() {
"exported"
} else {
"missing"
},
source: "thread_read.permission_state",
detail: if thread_read.permission_state.is_some() {
"thread_read 已导出 permission_state。".to_string()
} else {
"thread_read 缺少 permission_state。".to_string()
},
},
permission_state_signal_coverage(thread_read),
RuntimeEvidenceSignalCoverageEntry {
signal: "auxiliaryTaskRuntime",
status: if thread_read
@@ -4969,6 +5042,82 @@ mod tests {
.expect("write unmatched request log");
}
#[test]
fn permission_state_signal_coverage_should_surface_denied_confirmation_as_blocked() {
let mut thread_read = build_thread_read();
let mut permission_state = thread_read
.permission_state
.clone()
.expect("permission state");
permission_state.confirmation_status = Some("denied".to_string());
permission_state.confirmation_request_id = Some("approval-denied".to_string());
permission_state.confirmation_source = Some("runtime_action_required".to_string());
thread_read.permission_state = Some(permission_state);
let coverage = permission_state_signal_coverage(&thread_read);
assert_eq!(coverage.signal, "permissionState");
assert_eq!(coverage.status, "blocked");
assert!(coverage.detail.contains("approval-denied"));
assert!(coverage.detail.contains("真实权限确认已被拒绝"));
}
#[test]
fn permission_state_signal_coverage_should_surface_resolved_confirmation_as_exported() {
let mut thread_read = build_thread_read();
let mut permission_state = thread_read
.permission_state
.clone()
.expect("permission state");
permission_state.confirmation_status = Some("resolved".to_string());
permission_state.confirmation_request_id = Some("approval-resolved".to_string());
permission_state.confirmation_source = Some("runtime_action_required".to_string());
thread_read.permission_state = Some(permission_state);
let coverage = permission_state_signal_coverage(&thread_read);
assert_eq!(coverage.signal, "permissionState");
assert_eq!(coverage.status, "exported");
assert!(coverage.detail.contains("approval-resolved"));
assert!(coverage.detail.contains("真实权限确认已通过"));
}
#[test]
fn known_gaps_should_surface_denied_permission_confirmation() {
let mut thread_read = build_thread_read();
let mut permission_state = thread_read
.permission_state
.clone()
.expect("permission state");
permission_state.confirmation_status = Some("denied".to_string());
permission_state.confirmation_request_id = Some("approval-denied".to_string());
permission_state.confirmation_source = Some("runtime_action_required".to_string());
thread_read.permission_state = Some(permission_state);
let gaps = build_known_gaps(&[], &[], &thread_read);
assert!(gaps.iter().any(|gap| gap.contains("approval-denied")));
assert!(gaps.iter().any(|gap| gap.contains("权限确认已被拒绝")));
}
#[test]
fn known_gaps_should_not_surface_resolved_permission_confirmation() {
let mut thread_read = build_thread_read();
let mut permission_state = thread_read
.permission_state
.clone()
.expect("permission state");
permission_state.confirmation_status = Some("resolved".to_string());
permission_state.confirmation_request_id = Some("approval-resolved".to_string());
permission_state.confirmation_source = Some("runtime_action_required".to_string());
thread_read.permission_state = Some(permission_state);
let gaps = build_known_gaps(&[], &[], &thread_read);
assert!(!gaps.iter().any(|gap| gap.contains("approval-resolved")));
assert!(!gaps.iter().any(|gap| gap.contains("权限确认已被拒绝")));
}
fn write_image_task_fixture(root: &Path, relative_path: &str) {
let absolute_path = root.join(relative_path.replace('/', std::path::MAIN_SEPARATOR_STR));
fs::create_dir_all(
@@ -737,6 +737,18 @@ fn build_runtime_fact_lines(thread_read: &AgentRuntimeThreadReadModel) -> Vec<St
lines.push(format!("运行时摘要 / 成本:{value}"));
}
}
if let Some(permission_state) = thread_read.permission_state.as_ref() {
lines.push(format!(
"权限状态:{}required {} / ask {} / blocking {}",
permission_state.status,
permission_state.required_profile_keys.len(),
permission_state.ask_profile_keys.len(),
permission_state.blocking_profile_keys.len()
));
if let Some(line) = format_permission_confirmation_line(permission_state) {
lines.push(line);
}
}
if let Some(oem_policy) = thread_read.oem_policy.as_ref() {
if let Some(value) = oem_policy
.get("quotaStatus")
@@ -786,6 +798,7 @@ fn build_runtime_facts_json(thread_read: &AgentRuntimeThreadReadModel) -> serde_
"costState": thread_read.cost_state,
"limitEvent": thread_read.limit_event,
"runtimeSummary": thread_read.runtime_summary,
"permissionState": thread_read.permission_state,
"oemPolicy": thread_read.oem_policy,
"auxiliaryTaskRuntime": thread_read.auxiliary_task_runtime
})
@@ -802,6 +815,16 @@ fn build_resume_order() -> Vec<&'static str> {
fn build_review_actions(thread_read: &AgentRuntimeThreadReadModel) -> Vec<String> {
let mut actions = Vec::new();
if let Some(permission_state) = thread_read.permission_state.as_ref() {
if permission_state.confirmation_status.as_deref() == Some("denied") {
actions.push(format!(
"权限确认已被拒绝,不能把当前交接作为成功交付证据:request_id={}source={}。",
permission_confirmation_request_id(permission_state),
permission_confirmation_source(permission_state)
));
}
}
if !thread_read.pending_requests.is_empty() {
actions.push(format!(
"优先处理 {} 个待确认 / 待输入请求,避免线程继续阻塞。",
@@ -840,6 +863,16 @@ fn build_review_actions(thread_read: &AgentRuntimeThreadReadModel) -> Vec<String
fn build_blocking_lines(thread_read: &AgentRuntimeThreadReadModel) -> Vec<String> {
let mut lines = Vec::new();
if let Some(permission_state) = thread_read.permission_state.as_ref() {
if permission_state.confirmation_status.as_deref() == Some("denied") {
lines.push(format!(
"权限确认已被拒绝:request_id={}source={}。",
permission_confirmation_request_id(permission_state),
permission_confirmation_source(permission_state)
));
}
}
if let Some(diagnostics) = thread_read.diagnostics.as_ref() {
if let Some(summary) = diagnostics.primary_blocking_summary.as_ref() {
lines.push(summary.clone());
@@ -863,6 +896,46 @@ fn build_blocking_lines(thread_read: &AgentRuntimeThreadReadModel) -> Vec<String
lines
}
fn format_permission_confirmation_line(
permission_state: &lime_agent::SessionExecutionRuntimePermissionState,
) -> Option<String> {
let confirmation_status = permission_state.confirmation_status.as_deref()?;
let request_id = permission_confirmation_request_id(permission_state);
let source = permission_confirmation_source(permission_state);
match confirmation_status {
"denied" => Some(format!(
"权限确认:已拒绝(request_id={request_id}, source={source}),当前交接不能作为成功交付证据。"
)),
"resolved" => Some(format!(
"权限确认:已通过(request_id={request_id}, source={source})。"
)),
"requested" => Some(format!(
"权限确认:等待处理(request_id={request_id}, source={source})。"
)),
"not_requested" => Some("权限确认:声明态权限尚未发起真实审批请求。".to_string()),
other => Some(format!("权限确认:{other}source={source})。")),
}
}
fn permission_confirmation_request_id(
permission_state: &lime_agent::SessionExecutionRuntimePermissionState,
) -> &str {
permission_state
.confirmation_request_id
.as_deref()
.unwrap_or("未记录 confirmationRequestId")
}
fn permission_confirmation_source(
permission_state: &lime_agent::SessionExecutionRuntimePermissionState,
) -> &str {
permission_state
.confirmation_source
.as_deref()
.unwrap_or("未记录 confirmationSource")
}
fn collect_plan_lines(detail: &SessionDetail) -> Vec<String> {
detail
.items
@@ -1229,6 +1302,25 @@ mod tests {
}
}
fn set_permission_confirmation(
thread_read: &mut AgentRuntimeThreadReadModel,
confirmation_status: &str,
request_id: &str,
) {
thread_read.permission_state = Some(lime_agent::SessionExecutionRuntimePermissionState {
status: "requires_confirmation".to_string(),
required_profile_keys: vec!["browser_control".to_string()],
ask_profile_keys: vec!["browser_control".to_string()],
blocking_profile_keys: Vec::new(),
decision_source: "runtime_task_profile".to_string(),
decision_scope: "declared_profile_only".to_string(),
confirmation_status: Some(confirmation_status.to_string()),
confirmation_request_id: Some(request_id.to_string()),
confirmation_source: Some("runtime_action_required".to_string()),
notes: Vec::new(),
});
}
#[test]
fn should_export_runtime_handoff_bundle_to_workspace() {
let temp_dir = TempDir::new().expect("temp dir");
@@ -1276,4 +1368,57 @@ mod tests {
assert!(progress.contains("\"sessionId\": \"session-1\""));
assert!(progress.contains("\"pendingRequestCount\": 1"));
}
#[test]
fn should_surface_denied_permission_confirmation_in_handoff_bundle() {
let temp_dir = TempDir::new().expect("temp dir");
let detail = build_detail();
let mut thread_read = build_thread_read();
set_permission_confirmation(&mut thread_read, "denied", "approval-denied");
export_runtime_handoff_bundle(&detail, &thread_read, temp_dir.path()).expect("export");
let plan = fs::read_to_string(
temp_dir
.path()
.join(".lime/harness/sessions/session-1/plan.md"),
)
.expect("plan");
let handoff = fs::read_to_string(
temp_dir
.path()
.join(".lime/harness/sessions/session-1/handoff.md"),
)
.expect("handoff");
let review = fs::read_to_string(
temp_dir
.path()
.join(".lime/harness/sessions/session-1/review-summary.md"),
)
.expect("review");
let progress = fs::read_to_string(
temp_dir
.path()
.join(".lime/harness/sessions/session-1/progress.json"),
)
.expect("progress");
assert!(plan.contains("权限确认已被拒绝"));
assert!(handoff.contains("权限确认:已拒绝"));
assert!(review.contains("权限确认已被拒绝"));
assert!(review.contains("approval-denied"));
assert!(progress.contains("\"permissionState\""));
assert!(progress.contains("\"confirmationStatus\": \"denied\""));
}
#[test]
fn runtime_fact_lines_should_surface_resolved_permission_confirmation() {
let mut thread_read = build_thread_read();
set_permission_confirmation(&mut thread_read, "resolved", "approval-resolved");
let lines = build_runtime_fact_lines(&thread_read);
assert!(lines.iter().any(|line| line.contains("权限确认:已通过")));
assert!(lines.iter().any(|line| line.contains("approval-resolved")));
}
}
@@ -821,7 +821,18 @@ fn build_blocking_checks(
}
if let Some(permission_state) = thread_read.permission_state.as_ref() {
if permission_state.status == "requires_confirmation" {
let confirmation_status = permission_state.confirmation_status.as_deref();
if confirmation_status == Some("denied") {
checks.push(format!(
"运行时权限确认已被拒绝:{};除非 replay 改写任务目标或重新获得真实授权,否则不能判 PASS。",
permission_state
.confirmation_request_id
.as_deref()
.unwrap_or("未记录 confirmationRequestId")
));
} else if permission_state.status == "requires_confirmation"
&& confirmation_status != Some("resolved")
{
checks.push(format!(
"运行时权限声明仍需确认:{};除非 replay 已接入真实授权或用户确认证据,否则不能宣称这些权限已获批。",
format_text_list(&permission_state.ask_profile_keys, "未记录 askProfileKeys")
@@ -2711,6 +2722,57 @@ mod tests {
}
}
#[test]
fn replay_blocking_checks_should_treat_denied_permission_confirmation_as_blocking() {
let mut thread_read = build_thread_read();
thread_read.pending_requests.clear();
thread_read.queued_turns.clear();
thread_read.diagnostics = None;
let mut permission_state = thread_read
.permission_state
.clone()
.expect("permission state");
permission_state.confirmation_status = Some("denied".to_string());
permission_state.confirmation_request_id = Some("approval-denied".to_string());
permission_state.confirmation_source = Some("runtime_action_required".to_string());
thread_read.permission_state = Some(permission_state);
let checks = build_blocking_checks(&thread_read, &[], &json!({}));
assert!(checks
.iter()
.any(|check| check.contains("运行时权限确认已被拒绝")));
assert!(checks.iter().any(|check| check.contains("approval-denied")));
assert!(!checks
.iter()
.any(|check| check.contains("运行时权限声明仍需确认")));
}
#[test]
fn replay_blocking_checks_should_not_block_resolved_permission_confirmation() {
let mut thread_read = build_thread_read();
thread_read.pending_requests.clear();
thread_read.queued_turns.clear();
thread_read.diagnostics = None;
let mut permission_state = thread_read
.permission_state
.clone()
.expect("permission state");
permission_state.confirmation_status = Some("resolved".to_string());
permission_state.confirmation_request_id = Some("approval-resolved".to_string());
permission_state.confirmation_source = Some("runtime_action_required".to_string());
thread_read.permission_state = Some(permission_state);
let checks = build_blocking_checks(&thread_read, &[], &json!({}));
assert!(!checks
.iter()
.any(|check| check.contains("运行时权限声明仍需确认")));
assert!(!checks
.iter()
.any(|check| check.contains("运行时权限确认已被拒绝")));
}
fn write_failed_image_contract_task_fixture(root: &Path, relative_path: &str) {
let absolute_path = root.join(relative_path.replace('/', std::path::MAIN_SEPARATOR_STR));
fs::create_dir_all(
@@ -61,6 +61,11 @@ pub struct RuntimeReviewDecisionTemplateExportResult {
pub queued_turn_count: usize,
pub default_decision_status: String,
pub verification_summary: Option<Value>,
pub permission_status: String,
pub permission_confirmation_status: String,
pub permission_confirmation_request_id: String,
pub permission_confirmation_source: String,
pub permission_confirmation_summary: String,
pub decision: RuntimeReviewDecisionContent,
pub decision_status_options: Vec<String>,
pub risk_level_options: Vec<String>,
@@ -117,6 +122,16 @@ struct ReviewDecisionContext {
verification_summary: Option<Value>,
verification_failure_outcomes: Vec<String>,
verification_recovered_outcomes: Vec<String>,
#[serde(default)]
permission_status: String,
#[serde(default)]
permission_confirmation_status: String,
#[serde(default)]
permission_confirmation_request_id: String,
#[serde(default)]
permission_confirmation_source: String,
#[serde(default)]
permission_confirmation_summary: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -192,8 +207,8 @@ fn sync_runtime_review_decision(
)
})?;
let review_checklist = build_review_checklist();
let verification_context = load_analysis_verification_context(&analysis)?;
let review_checklist = build_review_checklist(&verification_context);
let existing_decision = load_existing_review_decision_document(&review_absolute_root)?
.map(|document| document.decision);
let mut document = build_review_decision_document(
@@ -202,10 +217,15 @@ fn sync_runtime_review_decision(
&review_checklist,
&verification_context,
);
let has_decision_override = decision_override.is_some();
let decision = decision_override
.or(existing_decision)
.unwrap_or_else(|| document.decision.clone());
document.decision = normalize_review_decision_content(decision, &exported_at);
let decision = normalize_review_decision_content(decision, &exported_at);
if has_decision_override {
validate_review_decision_write(&verification_context, &decision)?;
}
document.decision = decision;
let markdown = build_review_decision_markdown(&document);
let json = serde_json::to_string_pretty(&document)
.map_err(|error| format!("序列化 review decision json 失败: {error}"))?;
@@ -247,6 +267,23 @@ fn sync_runtime_review_decision(
queued_turn_count: analysis.queued_turn_count,
default_decision_status: DEFAULT_DECISION_STATUS.to_string(),
verification_summary: document.review_context.verification_summary.clone(),
permission_status: document.review_context.permission_status.clone(),
permission_confirmation_status: document
.review_context
.permission_confirmation_status
.clone(),
permission_confirmation_request_id: document
.review_context
.permission_confirmation_request_id
.clone(),
permission_confirmation_source: document
.review_context
.permission_confirmation_source
.clone(),
permission_confirmation_summary: document
.review_context
.permission_confirmation_summary
.clone(),
decision: document.decision,
decision_status_options: document.decision_status_options,
risk_level_options: document.risk_level_options,
@@ -306,6 +343,19 @@ fn build_review_decision_document(
verification_summary: verification_context.summary.clone(),
verification_failure_outcomes: verification_context.failure_outcomes.clone(),
verification_recovered_outcomes: verification_context.recovered_outcomes.clone(),
permission_status: verification_context.permission_status.clone(),
permission_confirmation_status: verification_context
.permission_confirmation_status
.clone(),
permission_confirmation_request_id: verification_context
.permission_confirmation_request_id
.clone(),
permission_confirmation_source: verification_context
.permission_confirmation_source
.clone(),
permission_confirmation_summary: verification_context
.permission_confirmation_summary
.clone(),
},
decision: RuntimeReviewDecisionContent {
decision_status: DEFAULT_DECISION_STATUS.to_string(),
@@ -349,6 +399,11 @@ fn build_review_decision_markdown(document: &ReviewDecisionDocument) -> String {
&document.review_context.verification_recovered_outcomes,
"- 无",
);
let permission_status = empty_fallback(&document.review_context.permission_status, "未导出");
let permission_confirmation = empty_fallback(
&document.review_context.permission_confirmation_summary,
"未导出",
);
let decision_status_options = document
.decision_status_options
.iter()
@@ -378,6 +433,8 @@ fn build_review_decision_markdown(document: &ReviewDecisionDocument) -> String {
- Turn`{latest_turn_status}`\n\
- `{pending_request_count}`\n\
- `{queued_turn_count}`\n\
- `{permission_status}`\n\
- {permission_confirmation}\n\
- analysis `{analysis_relative_root}`\n\
- handoff `{handoff_bundle_relative_root}`\n\
- evidence `{evidence_pack_relative_root}`\n\
@@ -429,6 +486,8 @@ fn build_review_decision_markdown(document: &ReviewDecisionDocument) -> String {
.unwrap_or("unknown"),
pending_request_count = document.review_context.pending_request_count,
queued_turn_count = document.review_context.queued_turn_count,
permission_status = permission_status,
permission_confirmation = permission_confirmation,
analysis_relative_root = document.review_context.analysis_relative_root,
handoff_bundle_relative_root = document.review_context.handoff_bundle_relative_root,
evidence_pack_relative_root = document.review_context.evidence_pack_relative_root,
@@ -468,8 +527,8 @@ fn build_review_decision_markdown(document: &ReviewDecisionDocument) -> String {
)
}
fn build_review_checklist() -> Vec<String> {
vec![
fn build_review_checklist(verification_context: &ReviewDecisionVerificationContext) -> Vec<String> {
let mut checklist = vec![
"先阅读 analysis-brief.md 与 analysis-context.json,再决定是否进入修复。".to_string(),
"确认根因判断引用的是现有证据,而不是外部 AI 的猜测扩写。".to_string(),
"优先核对 verification failure / recovered outcomes,再决定是接受、延后还是补充证据。"
@@ -478,7 +537,21 @@ fn build_review_checklist() -> Vec<String> {
.to_string(),
"明确最小回归集合,包括 contract、GUI smoke、Replay 或其它定向验证。".to_string(),
"把最终决定记录为 accepted / deferred / rejected / needs_more_evidence 之一。".to_string(),
]
];
match verification_context.permission_confirmation_status.as_str() {
"denied" => checklist.insert(
0,
"真实权限确认已被拒绝,不应把本次 review decision 标记为 accepted,除非已有新的真实授权证据。"
.to_string(),
),
"resolved" => checklist.push(
"确认 review 决策不会把已通过的真实权限确认当作待处理阻塞。".to_string(),
),
_ => {}
}
checklist
}
#[derive(Debug, Clone, Default)]
@@ -486,6 +559,11 @@ struct ReviewDecisionVerificationContext {
summary: Option<Value>,
failure_outcomes: Vec<String>,
recovered_outcomes: Vec<String>,
permission_status: String,
permission_confirmation_status: String,
permission_confirmation_request_id: String,
permission_confirmation_source: String,
permission_confirmation_summary: String,
}
#[derive(Debug, Clone, Default)]
@@ -551,6 +629,19 @@ fn load_analysis_verification_context(
.pointer("/observability/verificationRecoveredOutcomes")
.map(value_string_list)
.unwrap_or_default(),
permission_status: value_string(payload.pointer("/summary/permissionStatus")),
permission_confirmation_status: value_string(
payload.pointer("/summary/permissionConfirmationStatus"),
),
permission_confirmation_request_id: value_string(
payload.pointer("/summary/permissionConfirmationRequestId"),
),
permission_confirmation_source: value_string(
payload.pointer("/summary/permissionConfirmationSource"),
),
permission_confirmation_summary: value_string(
payload.pointer("/summary/permissionConfirmationSummary"),
),
})
}
@@ -653,6 +744,22 @@ fn normalize_review_decision_content(
}
}
fn validate_review_decision_write(
verification_context: &ReviewDecisionVerificationContext,
decision: &RuntimeReviewDecisionContent,
) -> Result<(), String> {
if verification_context.permission_confirmation_status == "denied"
&& decision.decision_status == "accepted"
{
return Err(
"真实权限确认已被拒绝,不能把本次 review decision 保存为 accepted;请先处理真实权限确认,或改为 rejected / deferred / needs_more_evidence。"
.to_string(),
);
}
Ok(())
}
fn build_decision_status_options() -> Vec<String> {
vec![
"accepted".to_string(),
@@ -718,6 +825,17 @@ fn build_review_decision_suggested_actions(
) -> ReviewDecisionSuggestedActions {
let mut suggested_actions = ReviewDecisionSuggestedActions::default();
if verification_context.permission_confirmation_status == "denied" {
push_unique_string(
&mut suggested_actions.followup_actions,
"先处理被拒绝的权限确认或重新发起用户授权,不要基于当前交接直接判定成功交付。",
);
push_unique_string(
&mut suggested_actions.regression_requirements,
"重新导出 evidence pack / handoff / review decision,并确认 permissionConfirmationStatus 不再是 denied。",
);
}
if let Some(summary) = verification_context.summary.as_ref() {
if let Some(artifact_validator) =
summary_object_field(summary, "artifactValidator", "artifact_validator")
@@ -857,6 +975,15 @@ fn push_review_verification_eval_commands(target: &mut Vec<String>) {
push_unique_string(target, REVIEW_VERIFICATION_COMMAND_TREND);
}
fn value_string(value: Option<&Value>) -> String {
value
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
.unwrap_or_default()
}
fn value_string_list(value: &Value) -> Vec<String> {
value
.as_array()
@@ -1224,6 +1351,25 @@ mod tests {
}
}
fn set_permission_confirmation(
thread_read: &mut AgentRuntimeThreadReadModel,
confirmation_status: &str,
request_id: &str,
) {
thread_read.permission_state = Some(lime_agent::SessionExecutionRuntimePermissionState {
status: "requires_confirmation".to_string(),
required_profile_keys: vec!["browser_control".to_string()],
ask_profile_keys: vec!["browser_control".to_string()],
blocking_profile_keys: Vec::new(),
decision_source: "runtime_task_profile".to_string(),
decision_scope: "declared_profile_only".to_string(),
confirmation_status: Some(confirmation_status.to_string()),
confirmation_request_id: Some(request_id.to_string()),
confirmation_source: Some("runtime_action_required".to_string()),
notes: Vec::new(),
});
}
fn seed_recovered_verification(detail: &mut SessionDetail, root: &std::path::Path) {
let artifact_relative_path = ".lime/artifacts/thread-1/report.artifact.json";
let artifact_absolute_path =
@@ -1451,6 +1597,148 @@ mod tests {
assert!(json.contains("\"verificationSummary\": null"));
assert!(json.contains("\"verificationFailureOutcomes\": []"));
assert!(json.contains("\"verificationRecoveredOutcomes\": []"));
assert_eq!(result.permission_confirmation_status, "");
}
#[test]
fn should_surface_denied_permission_confirmation_in_review_decision() {
let temp_dir = TempDir::new().expect("temp dir");
let detail = build_detail();
let mut thread_read = build_thread_read();
set_permission_confirmation(&mut thread_read, "denied", "approval-denied");
let result =
export_runtime_review_decision_template(&detail, &thread_read, temp_dir.path())
.expect("export");
let markdown_path = temp_dir
.path()
.join(".lime/harness/sessions/session-1/review/review-decision.md");
let json_path = temp_dir
.path()
.join(".lime/harness/sessions/session-1/review/review-decision.json");
let markdown = fs::read_to_string(markdown_path).expect("markdown");
let json = fs::read_to_string(json_path).expect("json");
assert!(markdown.contains("权限确认:已拒绝"));
assert!(markdown.contains("approval-denied"));
assert!(markdown.contains("不应把本次 review decision 标记为 accepted"));
assert!(json.contains("\"permissionConfirmationStatus\": \"denied\""));
assert!(json.contains("\"permissionConfirmationRequestId\": \"approval-denied\""));
assert_eq!(result.permission_status, "requires_confirmation");
assert_eq!(result.permission_confirmation_status, "denied");
assert_eq!(result.permission_confirmation_request_id, "approval-denied");
assert!(result
.review_checklist
.iter()
.any(|item| item.contains("真实权限确认已被拒绝")));
assert!(result
.decision
.followup_actions
.iter()
.any(|item| item.contains("先处理被拒绝的权限确认")));
assert!(result
.decision
.regression_requirements
.iter()
.any(|item| item.contains("permissionConfirmationStatus 不再是 denied")));
}
#[test]
fn should_reject_accepted_review_decision_when_permission_confirmation_denied() {
let temp_dir = TempDir::new().expect("temp dir");
let detail = build_detail();
let mut thread_read = build_thread_read();
set_permission_confirmation(&mut thread_read, "denied", "approval-denied");
export_runtime_review_decision_template(&detail, &thread_read, temp_dir.path())
.expect("export");
let error = save_runtime_review_decision(
&detail,
&thread_read,
temp_dir.path(),
RuntimeReviewDecisionContent {
decision_status: "accepted".to_string(),
decision_summary: "确认交付。".to_string(),
chosen_fix_strategy: "直接接受。".to_string(),
risk_level: "low".to_string(),
risk_tags: vec!["permission".to_string()],
human_reviewer: "Lime Maintainer".to_string(),
reviewed_at: None,
followup_actions: Vec::new(),
regression_requirements: Vec::new(),
notes: String::new(),
},
)
.expect_err("denied permission confirmation must block accepted decision");
assert!(error.contains("真实权限确认已被拒绝"));
assert!(error.contains("不能把本次 review decision 保存为 accepted"));
let saved = save_runtime_review_decision(
&detail,
&thread_read,
temp_dir.path(),
RuntimeReviewDecisionContent {
decision_status: "rejected".to_string(),
decision_summary: "权限确认已拒绝,拒绝本次交付。".to_string(),
chosen_fix_strategy: "先处理真实权限确认。".to_string(),
risk_level: "high".to_string(),
risk_tags: vec!["permission".to_string()],
human_reviewer: "Lime Maintainer".to_string(),
reviewed_at: None,
followup_actions: vec!["重新发起用户授权".to_string()],
regression_requirements: vec!["重新导出 review decision".to_string()],
notes: "拒绝来自真实权限确认。".to_string(),
},
)
.expect("rejected decision remains allowed");
assert_eq!(saved.decision.decision_status, "rejected");
assert_eq!(saved.permission_confirmation_status, "denied");
assert_eq!(saved.permission_confirmation_request_id, "approval-denied");
}
#[test]
fn should_surface_resolved_permission_confirmation_in_review_decision() {
let temp_dir = TempDir::new().expect("temp dir");
let detail = build_detail();
let mut thread_read = build_thread_read();
set_permission_confirmation(&mut thread_read, "resolved", "approval-resolved");
let result =
export_runtime_review_decision_template(&detail, &thread_read, temp_dir.path())
.expect("export");
let markdown_path = temp_dir
.path()
.join(".lime/harness/sessions/session-1/review/review-decision.md");
let json_path = temp_dir
.path()
.join(".lime/harness/sessions/session-1/review/review-decision.json");
let markdown = fs::read_to_string(markdown_path).expect("markdown");
let json = fs::read_to_string(json_path).expect("json");
assert!(markdown.contains("权限确认:已通过"));
assert!(markdown.contains("approval-resolved"));
assert!(json.contains("\"permissionConfirmationStatus\": \"resolved\""));
assert_eq!(result.permission_confirmation_status, "resolved");
assert_eq!(
result.permission_confirmation_request_id,
"approval-resolved"
);
assert!(result
.review_checklist
.iter()
.any(|item| item.contains("不会把已通过的真实权限确认当作待处理阻塞")));
assert!(!result
.decision
.followup_actions
.iter()
.any(|item| item.contains("被拒绝的权限确认")));
}
#[test]
@@ -6900,7 +6900,6 @@ export function AgentChatWorkspace({
shouldUseCompactGeneralWorkbench ||
!initialDispatchKey ||
contentId ||
!sessionId ||
messages.length > 0 ||
isSending
) {
@@ -6911,6 +6910,16 @@ export function AgentChatWorkspace({
return;
}
if (!autoRunInitialPromptOnMount) {
hasTriggeredGuide.current = true;
setInput((previous) => previous.trim() || pendingInitialPrompt);
return;
}
if (!sessionId) {
return;
}
let disposed = false;
consumedInitialPromptRef.current = initialDispatchKey;
@@ -6942,6 +6951,7 @@ export function AgentChatWorkspace({
disposed = true;
};
}, [
autoRunInitialPromptOnMount,
contentId,
handleSend,
initialAutoSendRequestMetadata,
@@ -6952,6 +6962,7 @@ export function AgentChatWorkspace({
messages.length,
onInitialUserPromptConsumed,
sessionId,
setInput,
shouldUseCompactGeneralWorkbench,
effectiveChatToolPreferences.thinking,
effectiveChatToolPreferences.webSearch,
@@ -1395,6 +1395,12 @@ describe("HarnessStatusPanel", () => {
pending_request_count: 1,
queued_turn_count: 0,
default_decision_status: "pending_review",
permission_status: "requires_confirmation",
permission_confirmation_status: "denied",
permission_confirmation_request_id: "approval-denied",
permission_confirmation_source: "runtime_action_required",
permission_confirmation_summary:
"已拒绝(request_id=approval-denied, source=runtime_action_required),不能作为成功交付证据。",
verification_summary: {
artifact_validator: {
applicable: true,
@@ -1500,6 +1506,10 @@ describe("HarnessStatusPanel", () => {
);
expect(document.body.textContent).toContain("人工审核记录");
expect(document.body.textContent).toContain("待人工审核");
expect(document.body.textContent).toContain("权限确认");
expect(document.body.textContent).toContain("权限确认已拒绝");
expect(document.body.textContent).toContain("approval-denied");
expect(document.body.textContent).toContain("不能作为成功交付证据");
expect(document.body.textContent).toContain(
".lime/harness/sessions/session-review-1/review/review-decision.md",
);
@@ -1549,6 +1559,12 @@ describe("HarnessStatusPanel", () => {
pending_request_count: 1,
queued_turn_count: 0,
default_decision_status: "pending_review",
permission_status: "requires_confirmation",
permission_confirmation_status: "denied",
permission_confirmation_request_id: "approval-denied-dialog",
permission_confirmation_source: "runtime_action_required",
permission_confirmation_summary:
"已拒绝(request_id=approval-denied-dialog),不能作为成功交付证据。",
verification_summary: {
artifact_validator: {
applicable: true,
@@ -1631,6 +1647,12 @@ describe("HarnessStatusPanel", () => {
pending_request_count: 1,
queued_turn_count: 0,
default_decision_status: "pending_review",
permission_status: "requires_confirmation",
permission_confirmation_status: "denied",
permission_confirmation_request_id: "approval-denied-dialog",
permission_confirmation_source: "runtime_action_required",
permission_confirmation_summary:
"已拒绝(request_id=approval-denied-dialog),不能作为成功交付证据。",
verification_summary: {
artifact_validator: {
applicable: true,
@@ -1646,16 +1668,16 @@ describe("HarnessStatusPanel", () => {
],
},
decision: {
decision_status: "accepted",
decision_summary: "确认最小修复可以接受。",
chosen_fix_strategy: "先补 runtime save,再补 UI 回归。",
decision_status: "rejected",
decision_summary: "权限确认已拒绝,拒绝本次交付。",
chosen_fix_strategy: "先处理真实权限确认,再复验。",
risk_level: "medium",
risk_tags: ["runtime", "ui"],
risk_tags: ["runtime", "permission"],
human_reviewer: "Lime Maintainer",
reviewed_at: "2026-03-27T10:32:00.000Z",
followup_actions: ["补充 HarnessStatusPanel 测试"],
regression_requirements: ["npm run test:contracts", "Rust 定向测试"],
notes: "保持 review decision 主链单一。",
followup_actions: ["处理 approval-denied-dialog"],
regression_requirements: ["npm run test:contracts", "人工审核回归"],
notes: "拒绝状态来自真实权限确认。",
},
decision_status_options: [
"accepted",
@@ -1721,6 +1743,10 @@ describe("HarnessStatusPanel", () => {
expect(reviewDialog?.textContent).toContain(
"Artifact 校验存在 1 条未恢复 issue。",
);
expect(reviewDialog?.textContent).toContain("权限确认");
expect(reviewDialog?.textContent).toContain("已拒绝");
expect(reviewDialog?.textContent).toContain("approval-denied-dialog");
expect(reviewDialog?.textContent).toContain("不能作为成功交付证据");
const statusSelect = document.body.querySelector(
'select[aria-label="决策状态"]',
@@ -1761,30 +1787,53 @@ describe("HarnessStatusPanel", () => {
setInputValue(reviewerInput, "Lime Maintainer");
}
if (riskTagsInput) {
setInputValue(riskTagsInput, "runtime, ui");
setInputValue(riskTagsInput, "runtime, permission");
}
if (summaryTextarea) {
setInputValue(summaryTextarea, "确认最小修复可以接受。");
setInputValue(summaryTextarea, "权限确认已拒绝,拒绝本次交付。");
}
if (strategyTextarea) {
setInputValue(strategyTextarea, "先补 runtime save,再补 UI 回归。");
setInputValue(strategyTextarea, "先处理真实权限确认,再复验。");
}
if (regressionsTextarea) {
setInputValue(
regressionsTextarea,
"npm run test:contracts\nRust 定向测试",
"npm run test:contracts\n人工审核回归",
);
}
if (followupsTextarea) {
setInputValue(followupsTextarea, "补充 HarnessStatusPanel 测试");
setInputValue(followupsTextarea, "处理 approval-denied-dialog");
}
if (notesTextarea) {
setInputValue(notesTextarea, "保持 review decision 主链单一。");
setInputValue(notesTextarea, "拒绝状态来自真实权限确认。");
}
await Promise.resolve();
});
const saveButton = findButtonByText("保存审核结果");
const acceptedOption = Array.from(statusSelect?.options ?? []).find(
(option) => option.value === "accepted",
);
expect(acceptedOption?.disabled).toBe(true);
expect(reviewDialog?.textContent).toContain(
"权限确认已拒绝时不能保存“接受”",
);
expect(saveButton?.disabled).toBe(true);
await act(async () => {
saveButton?.click();
await Promise.resolve();
await Promise.resolve();
});
expect(saveAgentRuntimeReviewDecisionMock).not.toHaveBeenCalled();
await act(async () => {
if (statusSelect) {
setInputValue(statusSelect, "rejected");
}
await Promise.resolve();
});
expect(saveButton?.disabled).toBe(false);
await act(async () => {
saveButton?.click();
@@ -1797,21 +1846,21 @@ describe("HarnessStatusPanel", () => {
);
expect(saveAgentRuntimeReviewDecisionMock).toHaveBeenCalledWith({
session_id: "session-review-2",
decision_status: "accepted",
decision_summary: "确认最小修复可以接受。",
chosen_fix_strategy: "先补 runtime save,再补 UI 回归。",
decision_status: "rejected",
decision_summary: "权限确认已拒绝,拒绝本次交付。",
chosen_fix_strategy: "先处理真实权限确认,再复验。",
risk_level: "medium",
risk_tags: ["runtime", "ui"],
risk_tags: ["runtime", "permission"],
human_reviewer: "Lime Maintainer",
reviewed_at: undefined,
followup_actions: ["补充 HarnessStatusPanel 测试"],
regression_requirements: ["npm run test:contracts", "Rust 定向测试"],
notes: "保持 review decision 主链单一。",
followup_actions: ["处理 approval-denied-dialog"],
regression_requirements: ["npm run test:contracts", "人工审核回归"],
notes: "拒绝状态来自真实权限确认。",
});
expect(document.body.textContent).toContain("当前人工审核结论");
expect(document.body.textContent).toContain("确认最小修复可以接受。");
expect(document.body.textContent).toContain("权限确认已拒绝,拒绝本次交付。");
expect(document.body.textContent).toContain("Lime Maintainer");
expect(document.body.textContent).toContain("补充 HarnessStatusPanel 测试");
expect(document.body.textContent).toContain("处理 approval-denied-dialog");
expect(mockToast.success).toHaveBeenCalledWith("已保存人工审核结果");
});
@@ -705,6 +705,21 @@ function formatReviewDecisionRiskLevelLabel(riskLevel?: string): string {
}
}
function formatPermissionConfirmationStatusLabel(status?: string): string {
switch (status?.trim()) {
case "denied":
return "已拒绝";
case "resolved":
return "已通过";
case "requested":
return "等待确认";
case "not_requested":
return "未发起";
default:
return status?.trim() || "未导出";
}
}
function slugifyHarnessCase(value: string): string {
const normalized = value
.trim()
@@ -4733,7 +4748,7 @@ export function HarnessStatusPanel({
{reviewDecisionTemplate ? (
<div className="mt-3 space-y-3">
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-5">
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-6">
<InventoryStatCard
title="当前状态"
value={formatReviewDecisionStatusLabel(
@@ -4764,6 +4779,17 @@ export function HarnessStatusPanel({
: "尚未填写风险标签"
}
/>
<InventoryStatCard
title="权限确认"
value={formatPermissionConfirmationStatusLabel(
reviewDecisionTemplate.permission_confirmation_status,
)}
hint={
reviewDecisionTemplate.permission_confirmation_summary ||
reviewDecisionTemplate.permission_confirmation_request_id ||
"未导出权限确认摘要"
}
/>
<InventoryStatCard
title="分析文件"
value={`${reviewDecisionTemplate.analysis_artifacts.length}`}
@@ -4789,6 +4815,20 @@ export function HarnessStatusPanel({
</div>
</div>
{reviewDecisionTemplate.permission_confirmation_status?.trim() ===
"denied" ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/10 p-3">
<div className="flex items-center gap-2 text-sm font-medium text-destructive">
<AlertCircle className="h-4 w-4" />
<span></span>
</div>
<div className="mt-2 text-xs leading-5 text-destructive/90">
{reviewDecisionTemplate.permission_confirmation_summary ||
"当前 review decision 不能作为成功交付证据,请先处理真实权限确认。"}
</div>
</div>
) : null}
{reviewDecisionTemplate.verification_summary ? (
<HarnessVerificationSummarySection
summary={
@@ -1,7 +1,5 @@
import React, { useMemo, useState } from "react";
import styled from "styled-components";
import React, { useState } from "react";
import {
BookOpen,
ChevronDown,
ChevronUp,
FolderOpen,
@@ -40,6 +38,7 @@ import type {
InputbarKnowledgePackOption,
InputbarKnowledgePackSelection,
} from "../types";
import { InputbarKnowledgeControl } from "../knowledge/InputbarKnowledgeControl";
import type {
WorkflowGateState,
WorkflowQuickAction,
@@ -55,128 +54,6 @@ import {
} from "../styles";
import { getProviderLabel } from "@/lib/constants/providerMappings";
const KnowledgePackControlWrap = styled.div`
position: relative;
display: inline-flex;
align-items: center;
gap: 4px;
${MetaToggleLabel} {
max-width: 154px;
overflow: hidden;
text-overflow: ellipsis;
}
`;
const KnowledgePackMenuButton = styled.button`
display: inline-flex;
width: 32px;
height: 32px;
align-items: center;
justify-content: center;
border-radius: 12px;
border: 1px solid rgba(148, 163, 184, 0.24);
background: #ffffff;
color: hsl(var(--muted-foreground));
cursor: pointer;
transition:
border-color 0.18s ease,
background 0.18s ease,
color 0.18s ease,
transform 0.18s ease;
&:hover,
&:focus-visible {
border-color: rgba(16, 185, 129, 0.38);
background: var(--lime-surface-hover, #f4fdf4);
color: hsl(var(--foreground));
}
&:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--lime-focus-ring, rgba(74, 222, 128, 0.24));
}
`;
const KnowledgePackMenu = styled.div`
position: absolute;
left: 0;
bottom: calc(100% + 8px);
z-index: 120;
width: min(300px, calc(100vw - 48px));
max-height: 260px;
overflow: auto;
padding: 6px;
border-radius: 14px;
border: 1px solid rgba(203, 213, 225, 0.9);
background: #ffffff;
box-shadow: 0 18px 40px -28px rgba(15, 23, 42, 0.34);
`;
const KnowledgePackMenuItem = styled.button<{ $active?: boolean }>`
display: flex;
width: 100%;
min-width: 0;
flex-direction: column;
gap: 4px;
padding: 9px 10px;
border: 1px solid
${({ $active }) => ($active ? "rgba(16, 185, 129, 0.42)" : "transparent")};
border-radius: 10px;
background: ${({ $active }) => ($active ? "#ecfdf5" : "transparent")};
color: #0f172a;
text-align: left;
cursor: pointer;
&:hover,
&:focus-visible {
background: #f8fafc;
border-color: rgba(203, 213, 225, 0.9);
}
&:focus-visible {
outline: none;
}
`;
const KnowledgePackMenuItemTitle = styled.span`
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 8px;
font-size: 12px;
font-weight: 700;
line-height: 1.35;
> span:first-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
`;
const KnowledgePackMenuItemMeta = styled.span`
color: #64748b;
font-size: 11px;
line-height: 1.3;
`;
const KnowledgePackMenuBadge = styled.span`
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
min-height: 18px;
border-radius: 999px;
background: #d1fae5;
padding: 0 7px;
color: #047857;
font-size: 10px;
font-weight: 700;
`;
interface InputbarComposerSectionProps {
renderWorkflowGeneratingPanel: boolean;
workflowGate?: WorkflowGateState | null;
@@ -204,6 +81,7 @@ interface InputbarComposerSectionProps {
onToggleKnowledgePack?: (enabled: boolean) => void;
onSelectKnowledgePack?: (packName: string) => void;
onStartKnowledgeOrganize?: () => void;
onManageKnowledgePacks?: () => void;
onSelectTeam?: (team: TeamDefinition | null) => void;
teamWorkspaceSettings?: WorkspaceSettings | null;
onPersistCustomTeams?: (teams: TeamDefinition[]) => void | Promise<void>;
@@ -267,6 +145,7 @@ export const InputbarComposerSection: React.FC<
onToggleKnowledgePack,
onSelectKnowledgePack,
onStartKnowledgeOrganize,
onManageKnowledgePacks,
onSelectTeam,
teamWorkspaceSettings,
onPersistCustomTeams,
@@ -302,7 +181,6 @@ export const InputbarComposerSection: React.FC<
number | null
>(null);
const [showAdvancedControls, setShowAdvancedControls] = useState(false);
const [showKnowledgePackMenu, setShowKnowledgePackMenu] = useState(false);
const showSkillSelector =
!isWorkspaceVariant && isGeneralResearchTheme(activeTheme);
const currentPendingImages =
@@ -352,52 +230,17 @@ export const InputbarComposerSection: React.FC<
};
const shouldShowTeamSelector =
isGeneralResearchTheme(activeTheme) && activeTools["subagent_mode"];
const shouldShowKnowledgePackToggle = Boolean(
knowledgePackSelection?.packName && knowledgePackSelection?.workingDir,
);
const normalizedKnowledgePackOptions = useMemo(() => {
const optionMap = new Map<string, InputbarKnowledgePackOption>();
for (const option of knowledgePackOptions) {
const packName = option.packName.trim();
if (!packName || optionMap.has(packName)) {
continue;
}
optionMap.set(packName, {
...option,
packName,
});
}
const selectedPackName = knowledgePackSelection?.packName.trim();
if (selectedPackName && !optionMap.has(selectedPackName)) {
optionMap.set(selectedPackName, {
packName: selectedPackName,
label: knowledgePackSelection?.label,
status: knowledgePackSelection?.status,
});
}
return Array.from(optionMap.values());
}, [knowledgePackOptions, knowledgePackSelection]);
const hasKnowledgePackChoices = normalizedKnowledgePackOptions.length > 1;
const currentKnowledgePackLabel =
knowledgePackSelection?.label ||
knowledgePackSelection?.packName ||
"项目资料";
const handleKnowledgePackToggle = () => {
if (!knowledgePackSelection) {
return;
}
onToggleKnowledgePack?.(!knowledgePackSelection.enabled);
};
const handleSelectKnowledgePack = (packName: string) => {
onSelectKnowledgePack?.(packName);
onToggleKnowledgePack?.(true);
setShowKnowledgePackMenu(false);
};
const knowledgePackControl =
knowledgePackSelection || onStartKnowledgeOrganize ? (
<InputbarKnowledgeControl
knowledgePackSelection={knowledgePackSelection}
knowledgePackOptions={knowledgePackOptions}
onToggleKnowledgePack={onToggleKnowledgePack}
onSelectKnowledgePack={onSelectKnowledgePack}
onStartKnowledgeOrganize={onStartKnowledgeOrganize}
onManageKnowledgePacks={onManageKnowledgePacks}
/>
) : null;
const hasHighlightedAdvancedPreference =
activeTools["thinking"] ||
activeTools["web_search"] ||
@@ -406,95 +249,6 @@ export const InputbarComposerSection: React.FC<
executionStrategy === "code_orchestrated" ||
accessMode === "read-only" ||
accessMode === "full-access";
const knowledgePackControl = shouldShowKnowledgePackToggle &&
knowledgePackSelection ? (
<KnowledgePackControlWrap>
<MetaToggleButton
type="button"
$checked={knowledgePackSelection.enabled}
aria-label={
knowledgePackSelection.enabled ? "关闭项目资料" : "使用项目资料"
}
title={
knowledgePackSelection.enabled
? `已使用项目资料:${currentKnowledgePackLabel}`
: `使用项目资料:${currentKnowledgePackLabel}`
}
data-testid="inputbar-knowledge-pack-toggle"
onClick={handleKnowledgePackToggle}
>
<MetaToggleCheck
$checked={knowledgePackSelection.enabled}
aria-hidden
/>
<MetaToggleGlyph aria-hidden>
<BookOpen strokeWidth={1.8} />
</MetaToggleGlyph>
<MetaToggleLabel>
{knowledgePackSelection.enabled
? currentKnowledgePackLabel
: "项目资料"}
</MetaToggleLabel>
</MetaToggleButton>
{hasKnowledgePackChoices ? (
<KnowledgePackMenuButton
type="button"
aria-label="选择项目资料"
aria-expanded={showKnowledgePackMenu}
title="选择项目资料"
data-testid="inputbar-knowledge-pack-menu-toggle"
onClick={() => setShowKnowledgePackMenu((previous) => !previous)}
>
<ChevronDown className="h-3.5 w-3.5" aria-hidden />
</KnowledgePackMenuButton>
) : null}
{showKnowledgePackMenu ? (
<KnowledgePackMenu role="menu" data-testid="inputbar-knowledge-pack-menu">
{normalizedKnowledgePackOptions.map((option) => {
const isSelected =
option.packName === knowledgePackSelection.packName;
const label = option.label || option.packName;
return (
<KnowledgePackMenuItem
key={option.packName}
type="button"
role="menuitemradio"
aria-checked={isSelected}
data-testid={`inputbar-knowledge-pack-option-${option.packName}`}
$active={isSelected}
onClick={() => handleSelectKnowledgePack(option.packName)}
>
<KnowledgePackMenuItemTitle>
<span>{label}</span>
{option.defaultForWorkspace ? (
<KnowledgePackMenuBadge></KnowledgePackMenuBadge>
) : null}
</KnowledgePackMenuItemTitle>
<KnowledgePackMenuItemMeta>
{option.status || "未确认"}
</KnowledgePackMenuItemMeta>
</KnowledgePackMenuItem>
);
})}
</KnowledgePackMenu>
) : null}
</KnowledgePackControlWrap>
) : onStartKnowledgeOrganize ? (
<MetaToggleButton
type="button"
$checked={false}
aria-label="整理成项目资料"
title="整理成项目资料"
data-testid="inputbar-knowledge-organize"
onClick={onStartKnowledgeOrganize}
>
<MetaToggleGlyph aria-hidden>
<BookOpen strokeWidth={1.8} />
</MetaToggleGlyph>
<MetaToggleLabel></MetaToggleLabel>
</MetaToggleButton>
) : null;
const shouldShowAdvancedToggle =
showSkillSelector ||
shouldShowTeamSelector ||
@@ -2554,8 +2554,8 @@ describe("Inputbar", () => {
'[data-testid="inputbar-knowledge-pack-toggle"]',
) as HTMLButtonElement | null;
expect(toggle).toBeTruthy();
expect(toggle?.textContent).toContain("项目资料");
expect(toggle?.getAttribute("title")).toContain("使用项目资料");
expect(toggle?.textContent).toContain("项目资料:未使用");
expect(toggle?.getAttribute("title")).toContain("项目资料当前未使用");
expect(container.textContent).not.toContain("知识包");
});
@@ -2573,12 +2573,24 @@ describe("Inputbar", () => {
'[data-testid="inputbar-knowledge-organize"]',
) as HTMLButtonElement | null;
expect(organizeButton).toBeTruthy();
expect(organizeButton?.textContent).toContain("整理成项目资料");
expect(organizeButton?.textContent).toContain("添加项目资料");
act(() => {
organizeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
const organizeCard = container.querySelector(
'[data-testid="inputbar-knowledge-organize-card"]',
);
expect(organizeCard?.textContent).toContain("发送给 Agent 整理");
const organizeAction = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("发送给 Agent 整理"),
);
act(() => {
organizeAction?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onStartKnowledgeOrganize).toHaveBeenCalledTimes(1);
});
@@ -106,6 +106,7 @@ interface InputbarProps extends SkillSelectionSourceProps {
onToggleKnowledgePack?: (enabled: boolean) => void;
onSelectKnowledgePack?: (packName: string) => void;
onStartKnowledgeOrganize?: () => void;
onManageKnowledgePacks?: () => void;
queuedTurns?: QueuedTurnSnapshot[];
onPromoteQueuedTurn?: (queuedTurnId: string) => void | Promise<boolean>;
onRemoveQueuedTurn?: (queuedTurnId: string) => void | Promise<boolean>;
@@ -174,6 +175,7 @@ export const Inputbar: React.FC<InputbarProps> = ({
onToggleKnowledgePack,
onSelectKnowledgePack,
onStartKnowledgeOrganize,
onManageKnowledgePacks,
queuedTurns = [],
onPromoteQueuedTurn,
onRemoveQueuedTurn,
@@ -331,6 +333,7 @@ export const Inputbar: React.FC<InputbarProps> = ({
onToggleKnowledgePack={onToggleKnowledgePack}
onSelectKnowledgePack={onSelectKnowledgePack}
onStartKnowledgeOrganize={onStartKnowledgeOrganize}
onManageKnowledgePacks={onManageKnowledgePacks}
onSelectTeam={onSelectTeam}
teamWorkspaceSettings={teamWorkspaceSettings}
onPersistCustomTeams={onPersistCustomTeams}
@@ -0,0 +1,449 @@
import { useMemo, useState } from "react";
import styled from "styled-components";
import { BookOpen, ChevronDown, MessageSquareText } from "lucide-react";
import type {
InputbarKnowledgePackOption,
InputbarKnowledgePackSelection,
} from "../types";
import {
MetaToggleButton,
MetaToggleCheck,
MetaToggleGlyph,
MetaToggleLabel,
} from "../styles";
const KnowledgePackControlWrap = styled.div`
position: relative;
display: inline-flex;
align-items: center;
gap: 4px;
${MetaToggleLabel} {
max-width: 190px;
overflow: hidden;
text-overflow: ellipsis;
}
`;
const KnowledgePackMenuButton = styled.button`
display: inline-flex;
width: 32px;
height: 32px;
align-items: center;
justify-content: center;
border-radius: 12px;
border: 1px solid rgba(148, 163, 184, 0.24);
background: #ffffff;
color: hsl(var(--muted-foreground));
cursor: pointer;
transition:
border-color 0.18s ease,
background 0.18s ease,
color 0.18s ease,
transform 0.18s ease;
&:hover,
&:focus-visible {
border-color: rgba(16, 185, 129, 0.38);
background: var(--lime-surface-hover, #f4fdf4);
color: hsl(var(--foreground));
}
&:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--lime-focus-ring, rgba(74, 222, 128, 0.24));
}
`;
const KnowledgePackMenu = styled.div`
position: absolute;
left: 0;
bottom: calc(100% + 8px);
z-index: 120;
width: min(320px, calc(100vw - 48px));
max-height: 320px;
overflow: auto;
padding: 6px;
border-radius: 14px;
border: 1px solid rgba(203, 213, 225, 0.9);
background: #ffffff;
box-shadow: 0 18px 40px -28px rgba(15, 23, 42, 0.34);
`;
const KnowledgePackMenuItem = styled.button<{ $active?: boolean }>`
display: flex;
width: 100%;
min-width: 0;
flex-direction: column;
gap: 4px;
padding: 9px 10px;
border: 1px solid
${({ $active }) => ($active ? "rgba(16, 185, 129, 0.42)" : "transparent")};
border-radius: 10px;
background: ${({ $active }) => ($active ? "#ecfdf5" : "transparent")};
color: #0f172a;
text-align: left;
cursor: pointer;
&:hover,
&:focus-visible {
background: #f8fafc;
border-color: rgba(203, 213, 225, 0.9);
}
&:focus-visible {
outline: none;
}
`;
const KnowledgePackMenuItemTitle = styled.span`
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 8px;
font-size: 12px;
font-weight: 700;
line-height: 1.35;
> span:first-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
`;
const KnowledgePackMenuItemMeta = styled.span`
color: #64748b;
font-size: 11px;
line-height: 1.3;
`;
const KnowledgePackMenuBadge = styled.span`
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
min-height: 18px;
border-radius: 999px;
background: #d1fae5;
padding: 0 7px;
color: #047857;
font-size: 10px;
font-weight: 700;
`;
const KnowledgePackMenuDivider = styled.div`
margin: 6px 4px;
height: 1px;
background: #e2e8f0;
`;
const KnowledgeOrganizeCard = styled.div`
position: absolute;
left: 0;
bottom: calc(100% + 8px);
z-index: 120;
width: min(340px, calc(100vw - 48px));
padding: 12px;
border-radius: 16px;
border: 1px solid rgba(187, 247, 208, 0.95);
background: #ffffff;
box-shadow: 0 18px 40px -28px rgba(15, 23, 42, 0.34);
`;
const KnowledgeOrganizeTitle = styled.div`
display: flex;
align-items: center;
gap: 8px;
color: #0f172a;
font-size: 13px;
font-weight: 760;
`;
const KnowledgeOrganizeDescription = styled.p`
margin: 8px 0 0;
color: #475569;
font-size: 12px;
line-height: 1.55;
`;
const KnowledgeOrganizeActions = styled.div`
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
`;
const KnowledgeOrganizeAction = styled.button<{ $primary?: boolean }>`
display: inline-flex;
align-items: center;
justify-content: center;
height: 32px;
gap: 6px;
border-radius: 999px;
border: 1px solid ${({ $primary }) => ($primary ? "#0f172a" : "#cbd5e1")};
background: ${({ $primary }) => ($primary ? "#0f172a" : "#ffffff")};
padding: 0 12px;
color: ${({ $primary }) => ($primary ? "#ffffff" : "#334155")};
font-size: 12px;
font-weight: 700;
cursor: pointer;
&:hover {
background: ${({ $primary }) => ($primary ? "#1e293b" : "#f8fafc")};
}
`;
function normalizeKnowledgePackOptions({
knowledgePackOptions,
knowledgePackSelection,
}: {
knowledgePackOptions: InputbarKnowledgePackOption[];
knowledgePackSelection?: InputbarKnowledgePackSelection | null;
}) {
const optionMap = new Map<string, InputbarKnowledgePackOption>();
for (const option of knowledgePackOptions) {
const packName = option.packName.trim();
if (!packName || optionMap.has(packName)) {
continue;
}
optionMap.set(packName, {
...option,
packName,
});
}
const selectedPackName = knowledgePackSelection?.packName.trim();
if (selectedPackName && !optionMap.has(selectedPackName)) {
optionMap.set(selectedPackName, {
packName: selectedPackName,
label: knowledgePackSelection?.label,
status: knowledgePackSelection?.status,
});
}
return Array.from(optionMap.values());
}
export function InputbarKnowledgeControl({
knowledgePackSelection,
knowledgePackOptions = [],
onToggleKnowledgePack,
onSelectKnowledgePack,
onStartKnowledgeOrganize,
onManageKnowledgePacks,
}: {
knowledgePackSelection?: InputbarKnowledgePackSelection | null;
knowledgePackOptions?: InputbarKnowledgePackOption[];
onToggleKnowledgePack?: (enabled: boolean) => void;
onSelectKnowledgePack?: (packName: string) => void;
onStartKnowledgeOrganize?: () => void;
onManageKnowledgePacks?: () => void;
}) {
const [showKnowledgePackMenu, setShowKnowledgePackMenu] = useState(false);
const [showOrganizeCard, setShowOrganizeCard] = useState(false);
const shouldShowKnowledgePackToggle = Boolean(
knowledgePackSelection?.packName && knowledgePackSelection?.workingDir,
);
const normalizedOptions = useMemo(
() =>
normalizeKnowledgePackOptions({
knowledgePackOptions,
knowledgePackSelection,
}),
[knowledgePackOptions, knowledgePackSelection],
);
const hasKnowledgePackChoices = normalizedOptions.length > 1;
const currentKnowledgePackLabel =
knowledgePackSelection?.label || knowledgePackSelection?.packName || "项目资料";
const handleKnowledgePackToggle = () => {
if (!knowledgePackSelection) {
return;
}
onToggleKnowledgePack?.(!knowledgePackSelection.enabled);
};
const handleSelectKnowledgePack = (packName: string) => {
onSelectKnowledgePack?.(packName);
onToggleKnowledgePack?.(true);
setShowKnowledgePackMenu(false);
};
if (shouldShowKnowledgePackToggle && knowledgePackSelection) {
return (
<KnowledgePackControlWrap>
<MetaToggleButton
type="button"
$checked={knowledgePackSelection.enabled}
aria-label={
knowledgePackSelection.enabled ? "关闭项目资料" : "使用项目资料"
}
title={
knowledgePackSelection.enabled
? `正在使用项目资料:${currentKnowledgePackLabel}`
: `项目资料当前未使用:${currentKnowledgePackLabel}`
}
data-testid="inputbar-knowledge-pack-toggle"
onClick={handleKnowledgePackToggle}
>
<MetaToggleCheck
$checked={knowledgePackSelection.enabled}
aria-hidden
/>
<MetaToggleGlyph aria-hidden>
<BookOpen strokeWidth={1.8} />
</MetaToggleGlyph>
<MetaToggleLabel>
{knowledgePackSelection.enabled
? `正在使用:${currentKnowledgePackLabel}`
: "项目资料:未使用"}
</MetaToggleLabel>
</MetaToggleButton>
{hasKnowledgePackChoices || onManageKnowledgePacks ? (
<KnowledgePackMenuButton
type="button"
aria-label="管理项目资料"
aria-expanded={showKnowledgePackMenu}
title="管理项目资料"
data-testid="inputbar-knowledge-pack-menu-toggle"
onClick={() => setShowKnowledgePackMenu((previous) => !previous)}
>
<ChevronDown className="h-3.5 w-3.5" aria-hidden />
</KnowledgePackMenuButton>
) : null}
{showKnowledgePackMenu ? (
<KnowledgePackMenu role="menu" data-testid="inputbar-knowledge-pack-menu">
{normalizedOptions.map((option) => {
const isSelected = option.packName === knowledgePackSelection.packName;
const label = option.label || option.packName;
return (
<KnowledgePackMenuItem
key={option.packName}
type="button"
role="menuitemradio"
aria-checked={isSelected}
data-testid={`inputbar-knowledge-pack-option-${option.packName}`}
$active={isSelected}
onClick={() => handleSelectKnowledgePack(option.packName)}
>
<KnowledgePackMenuItemTitle>
<span>{label}</span>
{option.defaultForWorkspace ? (
<KnowledgePackMenuBadge></KnowledgePackMenuBadge>
) : null}
</KnowledgePackMenuItemTitle>
<KnowledgePackMenuItemMeta>
{option.status || "未确认"}
</KnowledgePackMenuItemMeta>
</KnowledgePackMenuItem>
);
})}
{knowledgePackSelection.enabled || onManageKnowledgePacks ? (
<KnowledgePackMenuDivider />
) : null}
{knowledgePackSelection.enabled ? (
<KnowledgePackMenuItem
type="button"
role="menuitem"
onClick={() => {
onToggleKnowledgePack?.(false);
setShowKnowledgePackMenu(false);
}}
>
<KnowledgePackMenuItemTitle>
<span></span>
</KnowledgePackMenuItemTitle>
<KnowledgePackMenuItemMeta>
</KnowledgePackMenuItemMeta>
</KnowledgePackMenuItem>
) : null}
{onManageKnowledgePacks ? (
<KnowledgePackMenuItem
type="button"
role="menuitem"
onClick={() => {
onManageKnowledgePacks();
setShowKnowledgePackMenu(false);
}}
>
<KnowledgePackMenuItemTitle>
<span></span>
</KnowledgePackMenuItemTitle>
<KnowledgePackMenuItemMeta>
</KnowledgePackMenuItemMeta>
</KnowledgePackMenuItem>
) : null}
</KnowledgePackMenu>
) : null}
</KnowledgePackControlWrap>
);
}
if (!onStartKnowledgeOrganize) {
return null;
}
return (
<KnowledgePackControlWrap>
<MetaToggleButton
type="button"
$checked={showOrganizeCard}
aria-label="添加项目资料"
aria-expanded={showOrganizeCard}
title="添加项目资料"
data-testid="inputbar-knowledge-organize"
onClick={() => setShowOrganizeCard((previous) => !previous)}
>
<MetaToggleCheck $checked={showOrganizeCard} aria-hidden />
<MetaToggleGlyph aria-hidden>
<BookOpen strokeWidth={1.8} />
</MetaToggleGlyph>
<MetaToggleLabel></MetaToggleLabel>
</MetaToggleButton>
{showOrganizeCard ? (
<KnowledgeOrganizeCard data-testid="inputbar-knowledge-organize-card">
<KnowledgeOrganizeTitle>
<BookOpen className="h-4 w-4 text-emerald-600" />
Agent
</KnowledgeOrganizeTitle>
<KnowledgeOrganizeDescription>
访稿SOP Lime
</KnowledgeOrganizeDescription>
<KnowledgeOrganizeActions>
{onManageKnowledgePacks ? (
<KnowledgeOrganizeAction
type="button"
onClick={() => {
onManageKnowledgePacks();
setShowOrganizeCard(false);
}}
>
</KnowledgeOrganizeAction>
) : null}
<KnowledgeOrganizeAction
type="button"
$primary
onClick={() => {
onStartKnowledgeOrganize();
setShowOrganizeCard(false);
}}
>
<MessageSquareText className="h-3.5 w-3.5" />
Agent
</KnowledgeOrganizeAction>
</KnowledgeOrganizeActions>
</KnowledgeOrganizeCard>
) : null}
</KnowledgePackControlWrap>
);
}
@@ -3901,6 +3901,64 @@ describe("MessageList", () => {
expect(container.textContent).toContain("正在打开 GitHub");
});
it("首字前已有运行中 turn_summary 时仍应优先展示轻量等待占位", () => {
const now = new Date();
const messages: Message[] = [
{
id: "msg-assistant-first-token-with-summary",
role: "assistant",
content: "",
timestamp: now,
isThinking: true,
runtimeStatus: {
phase: "preparing",
title: "已接收请求,正在准备执行",
detail:
"系统正在初始化本轮执行环境并整理上下文,稍后会继续返回更详细进度。",
checkpoints: ["请求已接收"],
},
},
];
const container = render(messages, {
currentTurnId: "turn-first-token-with-summary",
turns: [
{
id: "turn-first-token-with-summary",
thread_id: "thread-1",
prompt_text: "你好",
status: "running",
started_at: "2026-03-30T10:20:00Z",
created_at: "2026-03-30T10:20:00Z",
updated_at: "2026-03-30T10:20:05Z",
},
],
threadItems: [
{
id: "summary-first-token-1",
thread_id: "thread-1",
turn_id: "turn-first-token-with-summary",
sequence: 1,
status: "in_progress",
started_at: "2026-03-30T10:20:00Z",
updated_at: "2026-03-30T10:20:05Z",
type: "turn_summary",
text: "已接收请求,正在准备执行",
},
],
});
expect(
container.querySelector(
'[data-testid="assistant-first-token-placeholder"]',
),
).not.toBeNull();
expect(
container.querySelector('[data-testid="agent-thread-timeline:trailing"]'),
).toBeNull();
expect(container.textContent).toContain("已接收请求,正在准备执行");
});
it("本地工具批次的阶段结论不应再进入主消息流时间线", () => {
const now = new Date();
const messages: Message[] = [
@@ -75,11 +75,6 @@ import type {
AgentRuntimeThreadReadModel,
QueuedTurnSnapshot,
} from "@/lib/api/agentRuntime";
import {
buildMessageTurnTimeline,
type MessageTurnTimeline,
} from "../utils/threadTimelineView";
import { buildMessageTurnGroups } from "../utils/messageTurnGrouping";
import { resolveLatestProjectFileSavedSiteContentTargetFromMessage } from "../utils/latestSavedSiteContentTarget";
import {
resolveSiteSavedContentTargetDisplayName,
@@ -88,6 +83,33 @@ import {
import { type ArtifactTimelineOpenTarget } from "../utils/artifactTimelineNavigation";
import { isPureRuntimePeerMessageText } from "../utils/runtimePeerMessageDisplay";
import { LIME_BRAND_LOGO_SRC, LIME_BRAND_NAME } from "@/lib/branding";
import {
buildConversationMessageRenderWindowProjection,
filterVisibleConversationMessages,
resolveConversationMessageRenderWindowSettings,
resolveInitialConversationRenderedMessageCount,
shouldUseConversationProgressiveRender,
} from "../projection/messageRenderWindowProjection";
import {
filterConversationThreadItemsForRenderedTurns,
resolveConversationRenderedTurnIdSet,
resolveConversationRenderedTurns,
} from "../projection/threadTimelineWindowProjection";
import {
buildCurrentTurnTimelineProjection,
buildMessageGroupsProjection,
buildMessageRenderGroupsProjection,
buildTimelineByMessageIdProjection,
resolveLastAssistantMessage,
} from "../projection/messageTimelineRenderProjection";
import {
buildHistoricalMarkdownHydrationIndexByMessageId,
buildHistoricalMarkdownHydrationTargets,
countDeferredHistoricalContentParts,
countDeferredHistoricalMarkdown,
hasStructuredHistoricalContentHint,
shouldDeferHistoricalAssistantMessageDetails as shouldDeferHistoricalAssistantMessageDetailsProjection,
} from "../projection/historicalMessageHydrationProjection";
interface MessageListProps {
sessionId?: string | null;
@@ -205,11 +227,24 @@ const MESSAGE_LIST_COMPACT_HISTORICAL_ASSISTANT_THRESHOLD = 900;
const MESSAGE_LIST_COMPACT_HISTORICAL_ASSISTANT_PREVIEW_CHARS = 900;
const MESSAGE_LIST_LONG_HISTORICAL_MESSAGE_THRESHOLD = 24_000;
const MESSAGE_LIST_LONG_HISTORICAL_MESSAGE_PREVIEW_CHARS = 2_000;
const MESSAGE_LIST_STRUCTURED_HISTORY_CONTENT_RE =
/<a2ui|```\s*a2ui|<write_file|<document/i;
const MESSAGE_LIST_RESTORED_MARKDOWN_HYDRATION_INITIAL_COUNT = 2;
const MESSAGE_LIST_RESTORED_MARKDOWN_HYDRATION_BATCH_SIZE = 2;
const MESSAGE_LIST_RESTORED_MARKDOWN_HYDRATION_DELAY_MS = 140;
const MESSAGE_LIST_RENDER_WINDOW_SETTINGS = {
regular: {
progressiveRenderThreshold: MESSAGE_LIST_PROGRESSIVE_RENDER_THRESHOLD,
initialRenderCount: MESSAGE_LIST_INITIAL_RENDER_COUNT,
renderBatchSize: MESSAGE_LIST_RENDER_BATCH_SIZE,
minimumDelayMs: MESSAGE_LIST_PROGRESSIVE_RENDER_MINIMUM_DELAY_MS,
},
restored: {
progressiveRenderThreshold:
MESSAGE_LIST_RESTORED_PROGRESSIVE_RENDER_THRESHOLD,
initialRenderCount: MESSAGE_LIST_RESTORED_INITIAL_RENDER_COUNT,
renderBatchSize: MESSAGE_LIST_RESTORED_RENDER_BATCH_SIZE,
minimumDelayMs: MESSAGE_LIST_RESTORED_PROGRESSIVE_RENDER_MINIMUM_DELAY_MS,
},
} as const;
function buildHistoricalMessagePreview(
content: string,
@@ -230,10 +265,6 @@ function buildLongHistoricalMessagePreview(content: string): string {
);
}
function hasStructuredHistoricalContentHint(content: string): boolean {
return MESSAGE_LIST_STRUCTURED_HISTORY_CONTENT_RE.test(content);
}
function formatContentLength(value: number): string {
return value.toLocaleString("zh-CN");
}
@@ -781,12 +812,7 @@ const MessageListInner: React.FC<MessageListProps> = ({
useState<Set<string>>(() => new Set());
const visibleMessages = useMemo(
() =>
messages.filter((msg) => {
if (msg.role !== "user") return true;
if (msg.content.trim().length > 0) return true;
return Array.isArray(msg.images) && msg.images.length > 0;
}),
() => filterVisibleConversationMessages(messages),
[messages],
);
const visibleMessageFirstId = visibleMessages[0]?.id ?? null;
@@ -819,29 +845,33 @@ const MessageListInner: React.FC<MessageListProps> = ({
},
);
}, [isRestoredHistoryWindow, visibleMessageFirstId, visibleMessageLastId]);
const progressiveRenderThreshold = isRestoredHistoryWindow
? MESSAGE_LIST_RESTORED_PROGRESSIVE_RENDER_THRESHOLD
: MESSAGE_LIST_PROGRESSIVE_RENDER_THRESHOLD;
const progressiveInitialRenderCount = isRestoredHistoryWindow
? MESSAGE_LIST_RESTORED_INITIAL_RENDER_COUNT
: MESSAGE_LIST_INITIAL_RENDER_COUNT;
const progressiveRenderBatchSize = isRestoredHistoryWindow
? MESSAGE_LIST_RESTORED_RENDER_BATCH_SIZE
: MESSAGE_LIST_RENDER_BATCH_SIZE;
const progressiveRenderMinimumDelayMs = isRestoredHistoryWindow
? MESSAGE_LIST_RESTORED_PROGRESSIVE_RENDER_MINIMUM_DELAY_MS
: MESSAGE_LIST_PROGRESSIVE_RENDER_MINIMUM_DELAY_MS;
const shouldUseProgressiveRender =
!isSending && visibleMessages.length > progressiveRenderThreshold;
const messageRenderWindowSettings =
resolveConversationMessageRenderWindowSettings(
MESSAGE_LIST_RENDER_WINDOW_SETTINGS,
isRestoredHistoryWindow,
);
const progressiveInitialRenderCount =
messageRenderWindowSettings.initialRenderCount;
const progressiveRenderBatchSize =
messageRenderWindowSettings.renderBatchSize;
const progressiveRenderMinimumDelayMs =
messageRenderWindowSettings.minimumDelayMs;
const shouldUseProgressiveRender = shouldUseConversationProgressiveRender({
isSending,
visibleMessageCount: visibleMessages.length,
settings: messageRenderWindowSettings,
});
const visibleMessageWindowRef = useRef<{
firstId: string | null;
lastId: string | null;
length: number;
} | null>(null);
const [renderedMessageCount, setRenderedMessageCount] = useState(() =>
shouldUseProgressiveRender
? Math.min(visibleMessages.length, progressiveInitialRenderCount)
: visibleMessages.length,
resolveInitialConversationRenderedMessageCount({
isSending,
visibleMessageCount: visibleMessages.length,
settings: messageRenderWindowSettings,
}),
);
useEffect(() => {
@@ -889,11 +919,26 @@ const MessageListInner: React.FC<MessageListProps> = ({
visibleMessages.length,
]);
const hiddenHistoryCount = shouldUseProgressiveRender
? Math.max(0, visibleMessages.length - renderedMessageCount)
: 0;
const messageRenderWindow = useMemo(
() =>
buildConversationMessageRenderWindowProjection({
visibleMessages,
renderedMessageCount,
isSending,
isRestoredHistoryWindow,
settings: messageRenderWindowSettings,
}),
[
isRestoredHistoryWindow,
isSending,
messageRenderWindowSettings,
renderedMessageCount,
visibleMessages,
],
);
const hiddenHistoryCount = messageRenderWindow.hiddenHistoryCount;
const shouldAutoHydrateHiddenHistory =
shouldUseProgressiveRender && !isRestoredHistoryWindow;
messageRenderWindow.shouldAutoHydrateHiddenHistory;
useEffect(() => {
if (
@@ -927,13 +972,7 @@ const MessageListInner: React.FC<MessageListProps> = ({
visibleMessages.length,
]);
const renderedMessages = useMemo(
() =>
hiddenHistoryCount > 0
? visibleMessages.slice(-renderedMessageCount)
: visibleMessages,
[hiddenHistoryCount, renderedMessageCount, visibleMessages],
);
const renderedMessages = messageRenderWindow.renderedMessages;
const renderedAssistantMessageCount = useMemo(
() =>
renderedMessages.reduce(
@@ -942,41 +981,31 @@ const MessageListInner: React.FC<MessageListProps> = ({
),
[renderedMessages],
);
const restoredTurnWindowSize = Math.max(1, renderedAssistantMessageCount + 1);
const renderedTurns = useMemo(() => {
const shouldWindowTurns = hiddenHistoryCount > 0 || isRestoredHistoryWindow;
if (!shouldWindowTurns) {
return turns;
}
const turnWindowSize = isRestoredHistoryWindow
? restoredTurnWindowSize
: Math.max(renderedMessageCount, progressiveInitialRenderCount);
const tailTurns =
turnWindowSize > 0
? turns.slice(-Math.min(turns.length, turnWindowSize))
: [];
if (!currentTurnId || tailTurns.some((turn) => turn.id === currentTurnId)) {
return tailTurns;
}
const selectedTurnIds = new Set(tailTurns.map((turn) => turn.id));
selectedTurnIds.add(currentTurnId);
return turns.filter((turn) => selectedTurnIds.has(turn.id));
return resolveConversationRenderedTurns({
turns,
currentTurnId,
hiddenHistoryCount,
isRestoredHistoryWindow,
renderedAssistantMessageCount,
renderedMessageCount,
progressiveInitialRenderCount,
});
}, [
currentTurnId,
hiddenHistoryCount,
isRestoredHistoryWindow,
progressiveInitialRenderCount,
renderedAssistantMessageCount,
renderedMessageCount,
restoredTurnWindowSize,
turns,
]);
const renderedTurnIdSet = useMemo(() => {
if (hiddenHistoryCount <= 0 && !isRestoredHistoryWindow) {
return null;
}
return new Set(renderedTurns.map((turn) => turn.id));
return resolveConversationRenderedTurnIdSet({
renderedTurns,
hiddenHistoryCount,
isRestoredHistoryWindow,
});
}, [hiddenHistoryCount, isRestoredHistoryWindow, renderedTurns]);
const activeCurrentTurn = useMemo(() => {
if (!currentTurnId) {
@@ -1060,42 +1089,26 @@ const MessageListInner: React.FC<MessageListProps> = ({
shouldDeferRestoredThreadItemsUntilExpand);
const renderedThreadItemsMeasurement = useMemo(
() =>
measureMessageListComputation(() => {
if (shouldDeferThreadItemsScan) {
return [];
}
if (!renderedTurnIdSet) {
return threadItems;
}
if (renderedTurnIdSet.size === 0) {
return [];
}
const scopedItems: AgentThreadItem[] = [];
for (const item of threadItems) {
if (renderedTurnIdSet.has(item.turn_id)) {
scopedItems.push(item);
}
}
return scopedItems;
}),
measureMessageListComputation(() =>
filterConversationThreadItemsForRenderedTurns({
threadItems,
renderedTurnIdSet,
shouldDeferThreadItemsScan,
}),
),
[renderedTurnIdSet, shouldDeferThreadItemsScan, threadItems],
);
const renderedThreadItems = renderedThreadItemsMeasurement.value;
const timelineByMessageIdMeasurement = useMemo(
() =>
measureMessageListComputation(() => {
if (!canBuildHistoricalTimeline) {
return new Map<string, MessageTurnTimeline>();
}
return buildMessageTurnTimeline(
measureMessageListComputation(() =>
buildTimelineByMessageIdProjection({
canBuildHistoricalTimeline,
renderedMessages,
renderedTurns,
renderedThreadItems,
);
}),
}),
),
[
canBuildHistoricalTimeline,
renderedMessages,
@@ -1104,15 +1117,10 @@ const MessageListInner: React.FC<MessageListProps> = ({
],
);
const timelineByMessageId = timelineByMessageIdMeasurement.value;
const lastAssistantMessage = useMemo(() => {
for (let index = renderedMessages.length - 1; index >= 0; index -= 1) {
const message = renderedMessages[index];
if (message?.role === "assistant") {
return message;
}
}
return null;
}, [renderedMessages]);
const lastAssistantMessage = useMemo(
() => resolveLastAssistantMessage(renderedMessages),
[renderedMessages],
);
const lastAssistantMessageId = lastAssistantMessage?.id ?? null;
const shouldInspectPromptCacheNotice = useMemo(
() =>
@@ -1168,25 +1176,13 @@ const MessageListInner: React.FC<MessageListProps> = ({
threadRead,
]);
const currentTurnTimeline = useMemo(() => {
if (!activeCurrentTurnId || !activeCurrentTurn || !lastAssistantMessageId) {
return null;
}
let mappedMessageId: string | null = null;
for (const entry of timelineByMessageId.values()) {
if (entry.turn.id === activeCurrentTurnId) {
mappedMessageId = entry.messageId;
break;
}
}
return {
messageId: mappedMessageId || lastAssistantMessageId,
turn: activeCurrentTurn,
items: renderedThreadItems.filter(
(item) => item.turn_id === activeCurrentTurnId,
),
};
return buildCurrentTurnTimelineProjection({
activeCurrentTurnId,
activeCurrentTurn,
lastAssistantMessageId,
timelineByMessageId,
renderedThreadItems,
});
}, [
activeCurrentTurn,
activeCurrentTurnId,
@@ -1197,7 +1193,7 @@ const MessageListInner: React.FC<MessageListProps> = ({
const messageGroupsMeasurement = useMemo(
() =>
measureMessageListComputation(() =>
buildMessageTurnGroups(renderedMessages),
buildMessageGroupsProjection(renderedMessages),
),
[renderedMessages],
);
@@ -1205,33 +1201,11 @@ const MessageListInner: React.FC<MessageListProps> = ({
const renderGroupsMeasurement = useMemo(
() =>
measureMessageListComputation(() =>
messageGroups.map((group) => {
const lastAssistantId =
group.assistantMessages[group.assistantMessages.length - 1]?.id ??
null;
let mappedTimeline: MessageTurnTimeline | null = null;
for (const message of group.assistantMessages) {
mappedTimeline = timelineByMessageId.get(message.id) ?? null;
if (mappedTimeline) {
break;
}
}
const isCurrentTurnGroup =
Boolean(lastAssistantId) &&
currentTurnTimeline?.messageId === lastAssistantId;
const isActiveGroup =
Boolean(lastAssistantId) &&
lastAssistantId === lastAssistantMessageId;
const timeline = isCurrentTurnGroup
? currentTurnTimeline
: mappedTimeline;
return {
...group,
lastAssistantId,
timeline,
isActiveGroup,
};
buildMessageRenderGroupsProjection({
messageGroups,
timelineByMessageId,
currentTurnTimeline,
lastAssistantMessageId,
}),
),
[
@@ -1242,17 +1216,13 @@ const MessageListInner: React.FC<MessageListProps> = ({
],
);
const renderGroups = renderGroupsMeasurement.value;
const isHistoricalAssistantMessageHydrationCandidate = useCallback(
(message: Message): boolean =>
isRestoredHistoryWindow &&
!focusedTimelineItemId &&
!isSending &&
!activeCurrentTurnId &&
message.role === "assistant" &&
!message.isThinking &&
!message.thinkingContent &&
(message.toolCalls?.length ?? 0) === 0 &&
(message.actionRequests?.length ?? 0) === 0,
const historicalMessageHydrationState = useMemo(
() => ({
activeCurrentTurnId,
focusedTimelineItemId,
isRestoredHistoryWindow,
isSending,
}),
[
activeCurrentTurnId,
focusedTimelineItemId,
@@ -1262,29 +1232,13 @@ const MessageListInner: React.FC<MessageListProps> = ({
);
const historicalMarkdownHydrationTargetsMeasurement = useMemo(
() =>
measureMessageListComputation(() => {
if (!isRestoredHistoryWindow) {
return [];
}
const targetIds: string[] = [];
for (const message of renderedMessages) {
const content = message.content.trim();
if (
content &&
!hasStructuredHistoricalContentHint(content) &&
isHistoricalAssistantMessageHydrationCandidate(message)
) {
targetIds.push(message.id);
}
}
return targetIds;
}),
[
isHistoricalAssistantMessageHydrationCandidate,
isRestoredHistoryWindow,
renderedMessages,
],
measureMessageListComputation(() =>
buildHistoricalMarkdownHydrationTargets({
messages: renderedMessages,
state: historicalMessageHydrationState,
}),
),
[historicalMessageHydrationState, renderedMessages],
);
const historicalMarkdownHydrationTargets =
historicalMarkdownHydrationTargetsMeasurement.value;
@@ -1349,71 +1303,55 @@ const MessageListInner: React.FC<MessageListProps> = ({
isHistoricalTimelineReady,
isRestoredHistoryWindow,
]);
const historicalMarkdownHydrationIndexByMessageId = useMemo(() => {
const indexed = new Map<string, number>();
historicalMarkdownHydrationTargets.forEach((messageId, index) => {
indexed.set(messageId, index);
});
return indexed;
}, [historicalMarkdownHydrationTargets]);
const isHistoricalMarkdownHydrated = useCallback(
(message: Message): boolean => {
const hydrationIndex = historicalMarkdownHydrationIndexByMessageId.get(
message.id,
);
return (
hydrationIndex === undefined ||
hydrationIndex < hydratedHistoricalMarkdownCount
);
},
[
hydratedHistoricalMarkdownCount,
historicalMarkdownHydrationIndexByMessageId,
],
const historicalMarkdownHydrationIndexByMessageId = useMemo(
() =>
buildHistoricalMarkdownHydrationIndexByMessageId(
historicalMarkdownHydrationTargets,
),
[historicalMarkdownHydrationTargets],
);
const shouldDeferHistoricalAssistantMessageDetails = useCallback(
(message: Message): boolean =>
isHistoricalAssistantMessageHydrationCandidate(message) &&
(!isHistoricalTimelineReady || !isHistoricalMarkdownHydrated(message)),
shouldDeferHistoricalAssistantMessageDetailsProjection({
message,
state: historicalMessageHydrationState,
isHistoricalTimelineReady,
hydrationIndexByMessageId: historicalMarkdownHydrationIndexByMessageId,
hydratedHistoricalMarkdownCount,
}),
[
isHistoricalAssistantMessageHydrationCandidate,
isHistoricalMarkdownHydrated,
hydratedHistoricalMarkdownCount,
historicalMarkdownHydrationIndexByMessageId,
historicalMessageHydrationState,
isHistoricalTimelineReady,
],
);
const historicalContentPartsDeferredMeasurement = useMemo(
() =>
measureMessageListComputation(() => {
if (!isRestoredHistoryWindow) {
return 0;
}
let count = 0;
for (const message of renderedMessages) {
if (
(message.contentParts?.length ?? 0) > 0 &&
shouldDeferHistoricalAssistantMessageDetails(message)
) {
count += 1;
}
}
return count;
}),
measureMessageListComputation(() =>
countDeferredHistoricalContentParts({
messages: renderedMessages,
state: historicalMessageHydrationState,
isHistoricalTimelineReady,
hydrationIndexByMessageId: historicalMarkdownHydrationIndexByMessageId,
hydratedHistoricalMarkdownCount,
}),
),
[
isRestoredHistoryWindow,
hydratedHistoricalMarkdownCount,
historicalMarkdownHydrationIndexByMessageId,
historicalMessageHydrationState,
isHistoricalTimelineReady,
renderedMessages,
shouldDeferHistoricalAssistantMessageDetails,
],
);
const historicalContentPartsDeferredCount =
historicalContentPartsDeferredMeasurement.value;
const historicalMarkdownDeferredCount = isRestoredHistoryWindow
? Math.max(
0,
historicalMarkdownHydrationTargets.length -
hydratedHistoricalMarkdownCount,
)
: 0;
const historicalMarkdownDeferredCount = countDeferredHistoricalMarkdown({
isRestoredHistoryWindow,
targetCount: historicalMarkdownHydrationTargets.length,
hydratedHistoricalMarkdownCount,
});
const messageListMeasuredComputeMs =
renderedThreadItemsMeasurement.durationMs +
timelineByMessageIdMeasurement.durationMs +
@@ -1975,7 +1913,6 @@ const MessageListInner: React.FC<MessageListProps> = ({
!conversationToolCalls?.length &&
!((msg.actionRequests || []).length > 0) &&
!primaryTimeline &&
!trailingTimeline &&
!((msg.images || []).length > 0) &&
visibleAssistantArtifacts.length === 0 &&
!shouldRenderMessageCanvasShortcut &&
@@ -2320,7 +2257,9 @@ const MessageListInner: React.FC<MessageListProps> = ({
{msg.role === "assistant" &&
renderArtifactCards(visibleAssistantArtifacts)}
{msg.role === "assistant" && trailingTimeline ? (
{msg.role === "assistant" &&
trailingTimeline &&
!shouldRenderFirstTokenPlaceholder ? (
<AgentThreadTimeline
turn={trailingTimeline.turn}
items={trailingTimeline.items}
@@ -91,6 +91,23 @@ function formatRiskLevelLabel(
}
}
function formatPermissionConfirmationStatusLabel(status?: string): string {
switch (status?.trim()) {
case "not_required":
return "无需确认";
case "not_requested":
return "尚未发起";
case "requested":
return "等待确认";
case "resolved":
return "已确认";
case "denied":
return "已拒绝";
default:
return status?.trim() || "未导出";
}
}
function createFormState(
template: AgentRuntimeReviewDecisionTemplate,
): ReviewDecisionFormState {
@@ -151,11 +168,24 @@ export function RuntimeReviewDecisionDialog({
const riskLevelOptions = template?.risk_level_options.length
? template.risk_level_options
: DEFAULT_RISK_LEVEL_OPTIONS;
const permissionConfirmationStatus =
template?.permission_confirmation_status?.trim();
const permissionConfirmationSummary =
template?.permission_confirmation_summary ||
template?.permission_confirmation_request_id ||
"未导出权限确认摘要";
const permissionConfirmationDenied =
permissionConfirmationStatus === "denied";
const acceptanceBlockedByDeniedPermission =
permissionConfirmationDenied && formState?.decision_status === "accepted";
const handleSave = async () => {
if (!template || !formState) {
return;
}
if (acceptanceBlockedByDeniedPermission) {
return;
}
await onSave({
session_id: template.session_id,
@@ -201,6 +231,44 @@ export function RuntimeReviewDecisionDialog({
/>
) : null}
{permissionConfirmationStatus ? (
<div
className={`rounded-lg border px-4 py-3 text-sm ${
permissionConfirmationDenied
? "border-rose-200 bg-rose-50 text-rose-950"
: "border-slate-200 bg-slate-50 text-slate-800"
}`}
>
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-semibold"></span>
<span
className={`rounded-full border px-2 py-0.5 text-[11px] font-medium ${
permissionConfirmationDenied
? "border-rose-200 bg-white text-rose-700"
: "border-slate-200 bg-white text-slate-700"
}`}
>
{formatPermissionConfirmationStatusLabel(
permissionConfirmationStatus,
)}
</span>
</div>
<p className="mt-2 text-xs leading-5">
{permissionConfirmationSummary}
</p>
{template.permission_confirmation_request_id ? (
<p className="mt-1 font-mono text-[11px] opacity-80">
request_id={template.permission_confirmation_request_id}
</p>
) : null}
{permissionConfirmationDenied ? (
<p className="mt-2 text-xs font-medium">
review decision
</p>
) : null}
</div>
) : null}
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<label
@@ -227,11 +295,22 @@ export function RuntimeReviewDecisionDialog({
}
>
{statusOptions.map((status) => (
<option key={status} value={status}>
<option
key={status}
value={status}
disabled={
permissionConfirmationDenied && status === "accepted"
}
>
{formatStatusLabel(status)}
</option>
))}
</select>
{permissionConfirmationDenied ? (
<p className="text-xs leading-5 text-rose-700">
</p>
) : null}
</div>
<div className="space-y-2">
@@ -465,7 +544,12 @@ export function RuntimeReviewDecisionDialog({
<Button
type="button"
onClick={() => void handleSave()}
disabled={!template || !formState || saving}
disabled={
!template ||
!formState ||
saving ||
acceptanceBlockedByDeniedPermission
}
>
{saving ? "保存中..." : "保存审核结果"}
</Button>
@@ -0,0 +1,88 @@
import { afterEach, describe, expect, it } from "vitest";
import { clearAgentUiPerformanceMetrics } from "@/lib/agentUiPerformanceMetrics";
import {
clearConversationProjectionDiagnostics,
conversationProjectionStore,
selectLatestConversationStreamDiagnostic,
} from "../projection/conversationProjectionStore";
import {
extractAgentUiPerformanceTraceMetadata,
mergeAgentUiPerformanceTraceMetadata,
recordAgentStreamPerformanceMetric,
} from "./agentStreamPerformanceMetrics";
describe("agentStreamPerformanceMetrics", () => {
afterEach(() => {
clearAgentUiPerformanceMetrics();
clearConversationProjectionDiagnostics();
});
it("记录现有性能指标时应同步写入 Conversation Projection Store", () => {
const trace = {
requestId: "request-stream-a",
sessionId: "draft-session-a",
workspaceId: "workspace-a",
source: "home-input",
submittedAt: Date.now(),
};
const entry = recordAgentStreamPerformanceMetric(
"agentStream.firstTextDelta",
trace,
{
sessionId: "runtime-session-a",
deltaLength: 8,
},
);
const projection = selectLatestConversationStreamDiagnostic(
conversationProjectionStore.getSnapshot(),
"draft-session-a",
);
expect(projection).toMatchObject({
phase: "agentStream.firstTextDelta",
sessionId: "draft-session-a",
workspaceId: "workspace-a",
source: "home-input",
requestId: "request-stream-a",
actualSessionId: "runtime-session-a",
metrics: {
deltaLength: 8,
requestId: "request-stream-a",
actualSessionId: "runtime-session-a",
},
});
expect(projection?.at).toBe(entry.at);
});
it("合并 trace metadata 后应可从 requestMetadata 继续记录 projection", () => {
const requestMetadata = mergeAgentUiPerformanceTraceMetadata(undefined, {
requestId: "request-stream-b",
sessionId: "draft-session-b",
workspaceId: "workspace-b",
source: "test",
submittedAt: null,
});
recordAgentStreamPerformanceMetric(
"agentStream.submitAccepted",
extractAgentUiPerformanceTraceMetadata(requestMetadata),
{
accepted: true,
},
);
expect(
selectLatestConversationStreamDiagnostic(
conversationProjectionStore.getSnapshot(),
"draft-session-b",
),
).toMatchObject({
phase: "agentStream.submitAccepted",
requestId: "request-stream-b",
metrics: {
accepted: true,
},
});
});
});
@@ -2,6 +2,7 @@ import {
recordAgentUiPerformanceMetric,
type AgentUiPerformanceEntry,
} from "@/lib/agentUiPerformanceMetrics";
import { recordConversationStreamDiagnostic } from "../projection/conversationProjectionStore";
export const AGENT_UI_PERFORMANCE_TRACE_METADATA_KEY =
"agentUiPerformanceTrace";
@@ -115,5 +116,23 @@ export function recordAgentStreamPerformanceMetric(
);
}
return recordAgentUiPerformanceMetric(phase, normalizedContext);
const entry = recordAgentUiPerformanceMetric(phase, normalizedContext);
recordConversationStreamDiagnostic({
phase: entry.phase,
at: entry.at,
wallTime: entry.wallTime,
sessionId: entry.sessionId,
workspaceId: entry.workspaceId,
source: entry.source,
requestId:
typeof entry.metrics.requestId === "string"
? entry.metrics.requestId
: null,
actualSessionId:
typeof entry.metrics.actualSessionId === "string"
? entry.metrics.actualSessionId
: null,
metrics: entry.metrics,
});
return entry;
}
@@ -0,0 +1,145 @@
import { describe, expect, it, vi } from "vitest";
import {
createSessionDetailPrefetchRegistry,
loadSessionDetailWithPrefetch,
type SessionDetailFetchDetailLike,
} from "./sessionDetailFetchController";
function detail(
messagesCount: number,
overrides: Partial<SessionDetailFetchDetailLike> = {},
): SessionDetailFetchDetailLike {
return {
messages: Array.from({ length: messagesCount }, (_, index) => ({
id: `message-${index}`,
})),
items: [],
turns: [],
queued_turns: [],
...overrides,
};
}
function createClock(values: number[]) {
let index = 0;
return () => {
const value = values[Math.min(index, values.length - 1)] ?? 0;
index += 1;
return value;
};
}
describe("sessionDetailFetchController", () => {
it("应优先复用 matching prefetch,不再重复调用 getSession", async () => {
const registry = createSessionDetailPrefetchRegistry<SessionDetailFetchDetailLike>();
registry.set("workspace-a:session-a", {
signature: "signature-a",
promise: Promise.resolve(
detail(2, { items: [{ id: "item-a" }], turns: [{ id: "turn-a" }] }),
),
});
const getSession = vi.fn();
const onEvent = vi.fn();
const result = await loadSessionDetailWithPrefetch({
getSession,
mode: "direct",
now: createClock([100, 140, 160]),
onEvent,
prefetchRegistry: registry,
prefetchWorkspaceId: "workspace-a",
startedAt: 20,
topicId: "session-a",
workspaceId: "workspace-a",
});
expect(result.messages).toHaveLength(2);
expect(getSession).not.toHaveBeenCalled();
expect(onEvent.mock.calls.map(([event]) => event.metricName)).toEqual([
"session.switch.fetchDetail.start",
"session.switch.fetchDetail.prefetch",
]);
});
it("prefetch 失败时应记录 fallback 并继续拉取详情", async () => {
const registry = createSessionDetailPrefetchRegistry<SessionDetailFetchDetailLike>();
registry.set("workspace-a:session-a", {
signature: "signature-a",
promise: Promise.reject(new Error("prefetch failed")),
});
const getSession = vi.fn().mockResolvedValue(detail(1));
const onEvent = vi.fn();
await expect(
loadSessionDetailWithPrefetch({
getSession,
mode: "deferred",
now: createClock([100, 120, 160, 180]),
onEvent,
prefetchRegistry: registry,
prefetchWorkspaceId: "workspace-a",
startedAt: 80,
topicId: "session-a",
workspaceId: "workspace-a",
}),
).resolves.toMatchObject({ messages: [{ id: "message-0" }] });
expect(getSession).toHaveBeenCalledWith("session-a", {
historyLimit: 40,
});
expect(onEvent.mock.calls.map(([event]) => event.logEvent)).toEqual([
"switchTopic.fetchDetail.start",
"switchTopic.fetchDetail.prefetchFallback",
"switchTopic.fetchDetail.success",
]);
expect(onEvent.mock.calls[1]?.[0]).toMatchObject({
logLevel: "warn",
throttleMs: 1000,
});
});
it("resumeSessionStartHooks 时应跳过 prefetch 并透传请求参数", async () => {
const registry = createSessionDetailPrefetchRegistry<SessionDetailFetchDetailLike>();
registry.set("workspace-a:session-a", {
signature: "signature-a",
promise: Promise.resolve(detail(2)),
});
const getSession = vi.fn().mockResolvedValue(detail(1));
await loadSessionDetailWithPrefetch({
getSession,
mode: "direct",
prefetchRegistry: registry,
prefetchWorkspaceId: "workspace-a",
resumeSessionStartHooks: true,
startedAt: 0,
topicId: "session-a",
workspaceId: "workspace-a",
});
expect(getSession).toHaveBeenCalledWith("session-a", {
historyLimit: 40,
resumeSessionStartHooks: true,
});
});
it("registry 只删除当前 promise,避免清掉较新的 prefetch", async () => {
const registry = createSessionDetailPrefetchRegistry<SessionDetailFetchDetailLike>();
const olderPromise = Promise.resolve(detail(1));
const newerPromise = Promise.resolve(detail(2));
registry.set("workspace-a:session-a", {
signature: "older",
promise: olderPromise,
});
registry.set("workspace-a:session-a", {
signature: "newer",
promise: newerPromise,
});
registry.deleteIfCurrent("workspace-a:session-a", olderPromise);
expect(registry.get("workspace-a:session-a")?.signature).toBe("newer");
registry.deleteIfCurrent("workspace-a:session-a", newerPromise);
expect(registry.get("workspace-a:session-a")).toBeUndefined();
});
});
@@ -0,0 +1,203 @@
import {
buildSessionDetailHydrationOptions,
buildSessionDetailPrefetchKey,
type SessionDetailHydrationOptions,
} from "./sessionHydrationController";
export type SessionDetailFetchMode = "direct" | "deferred";
export interface SessionDetailFetchDetailLike {
messages: readonly unknown[];
items?: readonly unknown[] | null;
turns?: readonly unknown[] | null;
queued_turns?: readonly unknown[] | null;
}
export interface SessionDetailPrefetchEntry<TDetail> {
signature: string;
promise: Promise<TDetail>;
}
export interface SessionDetailPrefetchRegistry<TDetail> {
get: (key: string) => SessionDetailPrefetchEntry<TDetail> | undefined;
set: (key: string, entry: SessionDetailPrefetchEntry<TDetail>) => void;
deleteIfCurrent: (key: string, promise: Promise<TDetail>) => void;
clear: () => void;
}
export interface SessionDetailFetchEvent {
logEvent: string;
logContext: Record<string, unknown>;
metricName?: string;
metricContext?: Record<string, unknown>;
logLevel?: "warn" | "error";
throttleMs?: number;
}
export function createSessionDetailPrefetchRegistry<
TDetail,
>(): SessionDetailPrefetchRegistry<TDetail> {
const entries = new Map<string, SessionDetailPrefetchEntry<TDetail>>();
return {
get: (key) => entries.get(key),
set: (key, entry) => {
entries.set(key, entry);
},
deleteIfCurrent: (key, promise) => {
const current = entries.get(key);
if (current?.promise === promise) {
entries.delete(key);
}
},
clear: () => entries.clear(),
};
}
function buildSessionDetailFetchMetricContext<TDetail extends SessionDetailFetchDetailLike>(params: {
detail: TDetail;
mode: SessionDetailFetchMode;
requestDurationMs: number;
resumeSessionStartHooks?: boolean;
startedAt: number;
topicId: string;
workspaceId?: string | null;
now: () => number;
}): Record<string, unknown> {
return {
itemsCount: params.detail.items?.length ?? 0,
messagesCount: params.detail.messages.length,
mode: params.mode,
queuedTurnsCount: params.detail.queued_turns?.length ?? 0,
requestDurationMs: params.requestDurationMs,
...(params.resumeSessionStartHooks !== undefined
? { resumeSessionStartHooks: params.resumeSessionStartHooks }
: {}),
sessionId: params.topicId,
topicId: params.topicId,
totalElapsedMs: params.now() - params.startedAt,
turnsCount: params.detail.turns?.length ?? 0,
workspaceId: params.workspaceId,
};
}
export async function loadSessionDetailWithPrefetch<
TDetail extends SessionDetailFetchDetailLike,
>(params: {
getSession: (
topicId: string,
options: SessionDetailHydrationOptions,
) => Promise<TDetail>;
mode: SessionDetailFetchMode;
now?: () => number;
onEvent?: (event: SessionDetailFetchEvent) => void;
prefetchRegistry: SessionDetailPrefetchRegistry<TDetail>;
prefetchWorkspaceId?: string | null;
resumeSessionStartHooks?: boolean;
startedAt: number;
topicId: string;
workspaceId?: string | null;
}): Promise<TDetail> {
const now = params.now ?? Date.now;
const requestStartedAt = now();
const resumeSessionStartHooks = params.resumeSessionStartHooks === true;
const startContext = {
elapsedBeforeRequestMs: requestStartedAt - params.startedAt,
mode: params.mode,
resumeSessionStartHooks,
sessionId: params.topicId,
topicId: params.topicId,
workspaceId: params.workspaceId,
};
params.onEvent?.({
logEvent: "switchTopic.fetchDetail.start",
logContext: startContext,
metricName: "session.switch.fetchDetail.start",
metricContext: startContext,
});
try {
const prefetchKey = buildSessionDetailPrefetchKey(
params.prefetchWorkspaceId ?? params.workspaceId ?? "",
params.topicId,
);
const prefetchedDetail = !resumeSessionStartHooks
? params.prefetchRegistry.get(prefetchKey)
: undefined;
if (prefetchedDetail) {
try {
const detail = await prefetchedDetail.promise;
const context = buildSessionDetailFetchMetricContext({
detail,
mode: params.mode,
requestDurationMs: now() - requestStartedAt,
startedAt: params.startedAt,
topicId: params.topicId,
workspaceId: params.workspaceId,
now,
});
params.onEvent?.({
logEvent: "switchTopic.fetchDetail.prefetch",
logContext: context,
metricName: "session.switch.fetchDetail.prefetch",
metricContext: context,
});
return detail;
} catch (error) {
params.onEvent?.({
logEvent: "switchTopic.fetchDetail.prefetchFallback",
logContext: {
error,
mode: params.mode,
topicId: params.topicId,
workspaceId: params.workspaceId,
},
logLevel: "warn",
throttleMs: 1000,
});
}
}
const detail = await params.getSession(
params.topicId,
buildSessionDetailHydrationOptions({ resumeSessionStartHooks }),
);
const context = buildSessionDetailFetchMetricContext({
detail,
mode: params.mode,
requestDurationMs: now() - requestStartedAt,
resumeSessionStartHooks,
startedAt: params.startedAt,
topicId: params.topicId,
workspaceId: params.workspaceId,
now,
});
params.onEvent?.({
logEvent: "switchTopic.fetchDetail.success",
logContext: context,
metricName: "session.switch.fetchDetail.success",
metricContext: context,
});
return detail;
} catch (error) {
const context = {
error,
mode: params.mode,
requestDurationMs: now() - requestStartedAt,
resumeSessionStartHooks,
sessionId: params.topicId,
topicId: params.topicId,
totalElapsedMs: now() - params.startedAt,
workspaceId: params.workspaceId,
};
params.onEvent?.({
logEvent: "switchTopic.fetchDetail.error",
logContext: context,
metricName: "session.switch.fetchDetail.error",
metricContext: context,
logLevel: "error",
});
throw error;
}
}
@@ -0,0 +1,152 @@
import { describe, expect, it } from "vitest";
import {
buildCrossWorkspaceSessionRestoreContext,
buildSessionWorkspaceRestorePlan,
isCrossWorkspaceSessionDetail,
resolveSessionExecutionStrategyOverride,
resolveSessionKnownWorkspaceId,
resolveShadowSessionExecutionStrategyFallback,
} from "./sessionFinalizeController";
describe("sessionFinalizeController", () => {
it("应按 runtime / topic / shadow 顺序解析会话已知 workspace", () => {
expect(
resolveSessionKnownWorkspaceId({
runtimeWorkspaceId: "runtime-workspace",
topicWorkspaceId: "topic-workspace",
shadowWorkspaceId: "shadow-workspace",
}),
).toBe("runtime-workspace");
expect(
resolveSessionKnownWorkspaceId({
runtimeWorkspaceId: null,
topicWorkspaceId: "topic-workspace",
shadowWorkspaceId: "shadow-workspace",
}),
).toBe("topic-workspace");
expect(
resolveSessionKnownWorkspaceId({
runtimeWorkspaceId: null,
topicWorkspaceId: null,
shadowWorkspaceId: "shadow-workspace",
}),
).toBe("shadow-workspace");
expect(
resolveSessionKnownWorkspaceId({
runtimeWorkspaceId: null,
topicWorkspaceId: null,
shadowWorkspaceId: null,
}),
).toBeNull();
});
it("只有当前 workspace 与已知 workspace 同时存在且不一致时才拒绝恢复", () => {
expect(
isCrossWorkspaceSessionDetail({
resolvedWorkspaceId: "workspace-a",
knownWorkspaceId: "workspace-b",
}),
).toBe(true);
expect(
isCrossWorkspaceSessionDetail({
resolvedWorkspaceId: "workspace-a",
knownWorkspaceId: "workspace-a",
}),
).toBe(false);
expect(
isCrossWorkspaceSessionDetail({
resolvedWorkspaceId: null,
knownWorkspaceId: "workspace-a",
}),
).toBe(false);
});
it("应构造跨 workspace 恢复拒绝上下文", () => {
expect(
buildCrossWorkspaceSessionRestoreContext({
topicId: "topic-a",
resolvedWorkspaceId: "workspace-a",
knownWorkspaceId: "workspace-b",
}),
).toEqual({
currentWorkspaceId: "workspace-a",
knownWorkspaceId: "workspace-b",
topicId: "topic-a",
});
expect(
buildSessionWorkspaceRestorePlan({
topicId: "topic-a",
resolvedWorkspaceId: "workspace-a",
runtimeWorkspaceId: null,
topicWorkspaceId: "workspace-b",
shadowWorkspaceId: "workspace-c",
}),
).toEqual({
crossWorkspaceContext: {
currentWorkspaceId: "workspace-a",
knownWorkspaceId: "workspace-b",
topicId: "topic-a",
},
knownWorkspaceId: "workspace-b",
shouldReject: true,
});
});
it("runtime 或 topic 已有执行策略时不使用 shadow fallback", () => {
expect(
resolveShadowSessionExecutionStrategyFallback({
runtimeExecutionStrategy: "auto",
topicExecutionStrategy: null,
persistedExecutionStrategy: "code_orchestrated",
}),
).toBeNull();
expect(
resolveShadowSessionExecutionStrategyFallback({
runtimeExecutionStrategy: null,
topicExecutionStrategy: "code_orchestrated",
persistedExecutionStrategy: "auto",
}),
).toBeNull();
});
it("应按 runtime / topic / shadow / 默认值顺序解析最终执行策略", () => {
expect(
resolveSessionExecutionStrategyOverride({
runtimeExecutionStrategy: "auto",
topicExecutionStrategy: "code_orchestrated",
shadowExecutionStrategyFallback: "react",
}),
).toBe("auto");
expect(
resolveSessionExecutionStrategyOverride({
runtimeExecutionStrategy: null,
topicExecutionStrategy: "code_orchestrated",
shadowExecutionStrategyFallback: "auto",
}),
).toBe("code_orchestrated");
expect(
resolveSessionExecutionStrategyOverride({
runtimeExecutionStrategy: null,
topicExecutionStrategy: null,
shadowExecutionStrategyFallback: "auto",
}),
).toBe("auto");
expect(
resolveSessionExecutionStrategyOverride({
runtimeExecutionStrategy: null,
topicExecutionStrategy: null,
shadowExecutionStrategyFallback: null,
}),
).toBe("react");
});
});
@@ -0,0 +1,102 @@
import type { AsterExecutionStrategy } from "@/lib/api/agentRuntime";
export interface CrossWorkspaceSessionRestoreContext {
currentWorkspaceId: string;
knownWorkspaceId: string;
topicId: string;
}
export interface SessionWorkspaceRestorePlan {
crossWorkspaceContext: CrossWorkspaceSessionRestoreContext | null;
knownWorkspaceId: string | null;
shouldReject: boolean;
}
export function resolveSessionKnownWorkspaceId(params: {
runtimeWorkspaceId?: string | null;
shadowWorkspaceId?: string | null;
topicWorkspaceId?: string | null;
}): string | null {
return (
params.runtimeWorkspaceId ||
params.topicWorkspaceId ||
params.shadowWorkspaceId ||
null
);
}
export function isCrossWorkspaceSessionDetail(params: {
knownWorkspaceId?: string | null;
resolvedWorkspaceId?: string | null;
}): boolean {
return Boolean(
params.resolvedWorkspaceId &&
params.knownWorkspaceId &&
params.knownWorkspaceId !== params.resolvedWorkspaceId,
);
}
export function buildCrossWorkspaceSessionRestoreContext(params: {
knownWorkspaceId: string;
resolvedWorkspaceId: string;
topicId: string;
}): CrossWorkspaceSessionRestoreContext {
return {
currentWorkspaceId: params.resolvedWorkspaceId,
knownWorkspaceId: params.knownWorkspaceId,
topicId: params.topicId,
};
}
export function buildSessionWorkspaceRestorePlan(params: {
resolvedWorkspaceId?: string | null;
runtimeWorkspaceId?: string | null;
shadowWorkspaceId?: string | null;
topicId: string;
topicWorkspaceId?: string | null;
}): SessionWorkspaceRestorePlan {
const knownWorkspaceId = resolveSessionKnownWorkspaceId(params);
const shouldReject = isCrossWorkspaceSessionDetail({
knownWorkspaceId,
resolvedWorkspaceId: params.resolvedWorkspaceId,
});
return {
crossWorkspaceContext:
shouldReject && params.resolvedWorkspaceId && knownWorkspaceId
? buildCrossWorkspaceSessionRestoreContext({
knownWorkspaceId,
resolvedWorkspaceId: params.resolvedWorkspaceId,
topicId: params.topicId,
})
: null,
knownWorkspaceId,
shouldReject,
};
}
export function resolveShadowSessionExecutionStrategyFallback(params: {
persistedExecutionStrategy?: AsterExecutionStrategy | null;
runtimeExecutionStrategy?: AsterExecutionStrategy | null;
topicExecutionStrategy?: AsterExecutionStrategy | null;
}): AsterExecutionStrategy | null {
if (params.runtimeExecutionStrategy || params.topicExecutionStrategy) {
return null;
}
return params.persistedExecutionStrategy ?? null;
}
export function resolveSessionExecutionStrategyOverride(params: {
defaultExecutionStrategy?: AsterExecutionStrategy;
runtimeExecutionStrategy?: AsterExecutionStrategy | null;
shadowExecutionStrategyFallback?: AsterExecutionStrategy | null;
topicExecutionStrategy?: AsterExecutionStrategy | null;
}): AsterExecutionStrategy {
return (
params.runtimeExecutionStrategy ||
params.topicExecutionStrategy ||
params.shadowExecutionStrategyFallback ||
params.defaultExecutionStrategy ||
"react"
);
}
@@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import {
SESSION_DETAIL_HISTORY_LIMIT,
buildSessionDetailHydrationOptions,
buildSessionDetailPrefetchKey,
buildSessionDetailPrefetchSignature,
isCurrentSessionHydrationRequest,
normalizeSessionDetailHistoryLimit,
} from "./sessionHydrationController";
describe("sessionHydrationController", () => {
it("应把非法 historyLimit 收敛到旧会话详情默认窗口", () => {
expect(normalizeSessionDetailHistoryLimit(null)).toBe(
SESSION_DETAIL_HISTORY_LIMIT,
);
expect(normalizeSessionDetailHistoryLimit(0)).toBe(
SESSION_DETAIL_HISTORY_LIMIT,
);
expect(normalizeSessionDetailHistoryLimit(-1)).toBe(
SESSION_DETAIL_HISTORY_LIMIT,
);
expect(normalizeSessionDetailHistoryLimit(12.8)).toBe(12);
});
it("应统一构造带 historyLimit 的会话详情请求参数", () => {
expect(buildSessionDetailHydrationOptions()).toEqual({
historyLimit: SESSION_DETAIL_HISTORY_LIMIT,
});
expect(
buildSessionDetailHydrationOptions({ resumeSessionStartHooks: true }),
).toEqual({
historyLimit: SESSION_DETAIL_HISTORY_LIMIT,
resumeSessionStartHooks: true,
});
expect(
buildSessionDetailHydrationOptions({
resumeSessionStartHooks: false,
historyLimit: 8,
}),
).toEqual({
historyLimit: 8,
});
});
it("应稳定生成 prefetch key 与签名", () => {
const updatedAt = new Date("2026-05-05T00:00:00.000Z");
expect(buildSessionDetailPrefetchKey(" workspace-a ", " session-a ")).toBe(
"workspace-a:session-a",
);
expect(
buildSessionDetailPrefetchSignature(" session-a ", {
updatedAt,
messagesCount: 12,
}),
).toBe(`session-a:${updatedAt.getTime()}:12`);
});
it("应按 requestVersion 和 targetSessionId 丢弃过期 hydration 结果", () => {
expect(
isCurrentSessionHydrationRequest({
currentRequestVersion: 2,
requestVersion: 2,
currentSessionId: "session-a",
targetSessionId: "session-a",
}),
).toBe(true);
expect(
isCurrentSessionHydrationRequest({
currentRequestVersion: 3,
requestVersion: 2,
currentSessionId: "session-a",
targetSessionId: "session-a",
}),
).toBe(false);
expect(
isCurrentSessionHydrationRequest({
currentRequestVersion: 2,
requestVersion: 2,
currentSessionId: "session-b",
targetSessionId: "session-a",
}),
).toBe(false);
});
});
@@ -0,0 +1,73 @@
export const SESSION_DETAIL_HISTORY_LIMIT = 40;
export interface SessionDetailHydrationOptions {
historyLimit: number;
resumeSessionStartHooks?: true;
}
export interface SessionDetailPrefetchTopicLike {
updatedAt?: Date | null;
messagesCount?: number | null;
}
export function normalizeSessionDetailHistoryLimit(
value: number | null | undefined,
): number {
if (!Number.isFinite(value) || !value || value <= 0) {
return SESSION_DETAIL_HISTORY_LIMIT;
}
return Math.trunc(value);
}
export function buildSessionDetailHydrationOptions(params?: {
resumeSessionStartHooks?: boolean;
historyLimit?: number | null;
}): SessionDetailHydrationOptions {
const options: SessionDetailHydrationOptions = {
historyLimit: normalizeSessionDetailHistoryLimit(params?.historyLimit),
};
if (params?.resumeSessionStartHooks) {
options.resumeSessionStartHooks = true;
}
return options;
}
export function buildSessionDetailPrefetchKey(
workspaceId: string,
topicId: string,
): string {
return `${workspaceId.trim() || "global"}:${topicId.trim()}`;
}
export function buildSessionDetailPrefetchSignature(
topicId: string,
topic?: SessionDetailPrefetchTopicLike | null,
): string {
return [
topicId.trim(),
topic?.updatedAt?.getTime() ?? "unknown-updated",
topic?.messagesCount ?? "unknown-count",
].join(":");
}
export function isCurrentSessionHydrationRequest(params: {
currentRequestVersion?: number | null;
requestVersion?: number | null;
currentSessionId?: string | null;
targetSessionId?: string | null;
}): boolean {
if (
params.requestVersion !== undefined &&
params.requestVersion !== null &&
params.currentRequestVersion !== params.requestVersion
) {
return false;
}
const targetSessionId = params.targetSessionId?.trim();
if (!targetSessionId) {
return true;
}
return params.currentSessionId?.trim() === targetSessionId;
}
@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import { resolveDeferredSessionHydrationErrorAction } from "./sessionHydrationRetryController";
describe("sessionHydrationRetryController", () => {
it("retryable transient 错误未达上限时应安排重试", () => {
const action = resolveDeferredSessionHydrationErrorAction({
error: new Error("bridge health check failed"),
retryCount: 0,
maxRetry: 1,
retryDelayMs: 15_000,
topicId: "topic-a",
workspaceId: "workspace-a",
});
expect(action).toMatchObject({
kind: "retry",
nextRetryCount: 1,
retryDelayMs: 15_000,
metricName: "session.switch.fetchDetail.retryScheduled",
logEvent: "switchTopic.fetchDetail.retryScheduled",
logContext: {
errorCategory: "bridge_health",
retryCount: 1,
retryDelayMs: 15_000,
sessionId: "topic-a",
topicId: "topic-a",
workspaceId: "workspace-a",
},
});
});
it("retryable transient 错误达到上限后应跳过并保留缓存快照", () => {
const action = resolveDeferredSessionHydrationErrorAction({
error: new Error("fetch failed"),
retryCount: 1,
maxRetry: 1,
retryDelayMs: 15_000,
topicId: "topic-a",
workspaceId: "workspace-a",
});
expect(action).toMatchObject({
kind: "skip",
metricName: "session.switch.fetchDetail.retrySkipped",
logEvent: "switchTopic.fetchDetail.retrySkipped",
logContext: {
errorCategory: "connection",
retryCount: 1,
sessionId: "topic-a",
},
});
});
it("非 retryable transient 错误应直接跳过", () => {
const action = resolveDeferredSessionHydrationErrorAction({
error: new Error("request timeout"),
retryCount: 0,
maxRetry: 1,
retryDelayMs: 15_000,
topicId: "topic-timeout",
});
expect(action).toMatchObject({
kind: "skip",
logContext: {
errorCategory: "timeout",
retryCount: 0,
sessionId: "topic-timeout",
},
});
});
it("未知错误应交给 switchTopic error fallback", () => {
const error = new Error("permission denied");
const action = resolveDeferredSessionHydrationErrorAction({
error,
retryCount: 0,
maxRetry: 1,
retryDelayMs: 15_000,
topicId: "topic-a",
});
expect(action).toEqual({
kind: "fail",
error,
});
});
});
@@ -0,0 +1,99 @@
import {
classifySessionDetailHydrationError,
getSessionDetailHydrationErrorMessage,
type SessionDetailHydrationErrorCategory,
} from "./agentSessionDetailHydrationError";
export type DeferredSessionHydrationErrorAction =
| {
kind: "retry";
nextRetryCount: number;
retryDelayMs: number;
metricName: "session.switch.fetchDetail.retryScheduled";
logEvent: "switchTopic.fetchDetail.retryScheduled";
logContext: DeferredSessionHydrationRetryContext;
}
| {
kind: "skip";
metricName: "session.switch.fetchDetail.retrySkipped";
logEvent: "switchTopic.fetchDetail.retrySkipped";
logContext: DeferredSessionHydrationSkipContext;
}
| {
kind: "fail";
error: unknown;
};
export interface DeferredSessionHydrationRetryContext
extends Record<string, unknown> {
error: string;
errorCategory: SessionDetailHydrationErrorCategory;
retryCount: number;
retryDelayMs: number;
sessionId: string;
topicId: string;
workspaceId?: string | null;
}
export interface DeferredSessionHydrationSkipContext
extends Record<string, unknown> {
error: string;
errorCategory: SessionDetailHydrationErrorCategory;
retryCount: number;
sessionId: string;
topicId: string;
workspaceId?: string | null;
}
export function resolveDeferredSessionHydrationErrorAction(params: {
error: unknown;
retryCount: number;
maxRetry: number;
retryDelayMs: number;
topicId: string;
workspaceId?: string | null;
}): DeferredSessionHydrationErrorAction {
const hydrationError = classifySessionDetailHydrationError(params.error);
const errorMessage = getSessionDetailHydrationErrorMessage(params.error);
if (hydrationError.retryable && params.retryCount < params.maxRetry) {
const nextRetryCount = params.retryCount + 1;
return {
kind: "retry",
nextRetryCount,
retryDelayMs: params.retryDelayMs,
metricName: "session.switch.fetchDetail.retryScheduled",
logEvent: "switchTopic.fetchDetail.retryScheduled",
logContext: {
error: errorMessage,
errorCategory: hydrationError.category,
retryCount: nextRetryCount,
retryDelayMs: params.retryDelayMs,
sessionId: params.topicId,
topicId: params.topicId,
workspaceId: params.workspaceId,
},
};
}
if (hydrationError.transient) {
return {
kind: "skip",
metricName: "session.switch.fetchDetail.retrySkipped",
logEvent: "switchTopic.fetchDetail.retrySkipped",
logContext: {
error: errorMessage,
errorCategory: hydrationError.category,
retryCount: params.retryCount,
sessionId: params.topicId,
topicId: params.topicId,
workspaceId: params.workspaceId,
},
};
}
return {
kind: "fail",
error: params.error,
};
}
@@ -0,0 +1,202 @@
import { describe, expect, it, vi } from "vitest";
import {
buildSessionMetadataSyncPlan,
buildSessionSwitchSuccessMetricContext,
executeSessionMetadataSync,
resolveSessionExecutionStrategySource,
} from "./sessionMetadataSyncController";
describe("sessionMetadataSyncController", () => {
it("runtime accessMode 与 runtime preference 不应生成回填 patch", () => {
const plan = buildSessionMetadataSyncPlan({
runtimeAccessMode: "current",
runtimePreference: {
providerType: "deepseek",
model: "deepseek-chat",
},
shadowAccessMode: "full-access",
topicPreference: {
providerType: "openai",
model: "gpt-5",
},
workspaceDefaultAccessMode: "read-only",
});
expect(plan).toMatchObject({
accessMode: "current",
accessModeSource: "execution_runtime",
fallbackExecutionStrategy: null,
fallbackProviderPreference: null,
hasPatch: false,
modelPreferenceSource: "execution_runtime",
patch: {},
providerPreferenceToApply: {
providerType: "openai",
model: "gpt-5",
},
shouldPersistAccessMode: true,
});
});
it("session storage fallback 应生成 accessMode 与 provider patch", () => {
const plan = buildSessionMetadataSyncPlan({
runtimeAccessMode: null,
runtimePreference: null,
shadowAccessMode: "full-access",
shadowExecutionStrategyFallback: "code_orchestrated",
topicPreference: {
providerType: "openai",
model: "gpt-5",
},
workspaceDefaultAccessMode: "read-only",
});
expect(plan).toMatchObject({
accessMode: "full-access",
accessModeSource: "session_storage",
fallbackExecutionStrategy: "code_orchestrated",
fallbackProviderPreference: {
providerType: "openai",
model: "gpt-5",
},
hasPatch: true,
modelPreferenceSource: "session_storage",
patch: {
accessMode: "full-access",
providerType: "openai",
model: "gpt-5",
executionStrategy: "code_orchestrated",
},
shouldPersistAccessMode: false,
});
});
it("缺少 session accessMode 时应使用 workspace default 并生成 patch", () => {
const plan = buildSessionMetadataSyncPlan({
runtimeAccessMode: null,
runtimePreference: null,
shadowAccessMode: null,
topicPreference: null,
workspaceDefaultAccessMode: "read-only",
});
expect(plan).toMatchObject({
accessMode: "read-only",
accessModeSource: "workspace_default",
hasPatch: true,
patch: {
accessMode: "read-only",
},
shouldPersistAccessMode: true,
});
});
it("应稳定构造 switch success metric context 与 execution strategy source", () => {
expect(
resolveSessionExecutionStrategySource({
runtimeExecutionStrategy: null,
topicExecutionStrategy: null,
shadowExecutionStrategyFallback: "react",
}),
).toBe("shadow_cache");
expect(
buildSessionSwitchSuccessMetricContext({
accessModeSource: "workspace_default",
durationMs: 120,
executionStrategySource: "shadow_cache",
itemsCount: 3,
messagesCount: 4,
modelPreferenceSource: null,
queuedTurnsCount: 1,
topicId: "topic-a",
turnsCount: 2,
workspaceId: "workspace-a",
}),
).toEqual({
accessModeSource: "workspace_default",
durationMs: 120,
executionStrategySource: "shadow_cache",
itemsCount: 3,
messagesCount: 4,
modelPreferenceSource: null,
queuedTurnsCount: 1,
sessionId: "topic-a",
topicId: "topic-a",
turnsCount: 2,
workspaceId: "workspace-a",
});
});
it("优先使用批量 updateSessionMetadata", async () => {
const runtime = {
updateSessionMetadata: vi.fn().mockResolvedValue(undefined),
setSessionAccessMode: vi.fn(),
setSessionExecutionStrategy: vi.fn(),
setSessionProviderSelection: vi.fn(),
};
await executeSessionMetadataSync({
fallbackExecutionStrategy: "react",
fallbackProviderPreference: {
providerType: "openai",
model: "gpt-5",
},
patch: {
accessMode: "current",
providerType: "openai",
model: "gpt-5",
executionStrategy: "react",
},
runtime,
sessionId: "topic-a",
});
expect(runtime.updateSessionMetadata).toHaveBeenCalledWith("topic-a", {
accessMode: "current",
providerType: "openai",
model: "gpt-5",
executionStrategy: "react",
});
expect(runtime.setSessionAccessMode).not.toHaveBeenCalled();
expect(runtime.setSessionProviderSelection).not.toHaveBeenCalled();
expect(runtime.setSessionExecutionStrategy).not.toHaveBeenCalled();
});
it("缺少批量命令时应回退到分散 metadata 命令", async () => {
const runtime = {
setSessionAccessMode: vi.fn().mockResolvedValue(undefined),
setSessionExecutionStrategy: vi.fn().mockResolvedValue(undefined),
setSessionProviderSelection: vi.fn().mockResolvedValue(undefined),
};
await executeSessionMetadataSync({
fallbackExecutionStrategy: "code_orchestrated",
fallbackProviderPreference: {
providerType: "deepseek",
model: "deepseek-chat",
},
patch: {
accessMode: "full-access",
providerType: "deepseek",
model: "deepseek-chat",
executionStrategy: "code_orchestrated",
},
runtime,
sessionId: "topic-a",
});
expect(runtime.setSessionAccessMode).toHaveBeenCalledWith(
"topic-a",
"full-access",
);
expect(runtime.setSessionProviderSelection).toHaveBeenCalledWith(
"topic-a",
"deepseek",
"deepseek-chat",
);
expect(runtime.setSessionExecutionStrategy).toHaveBeenCalledWith(
"topic-a",
"code_orchestrated",
);
});
});
@@ -0,0 +1,200 @@
import type { AsterExecutionStrategy } from "@/lib/api/agentRuntime";
import type { AgentSessionMetadataPatch } from "./agentRuntimeAdapter";
import type { AgentAccessMode } from "./agentChatStorage";
import type { SessionModelPreference } from "./agentChatShared";
export type SessionAccessModeSource =
| "execution_runtime"
| "session_storage"
| "workspace_default";
export type SessionExecutionStrategySource =
| "session_detail"
| "topics_snapshot"
| "shadow_cache"
| "default";
export type SessionModelPreferenceSource = "execution_runtime" | "session_storage";
export interface SessionMetadataSyncPlan {
accessMode: AgentAccessMode;
accessModeSource: SessionAccessModeSource;
fallbackExecutionStrategy: AsterExecutionStrategy | null;
fallbackProviderPreference: SessionModelPreference | null;
hasPatch: boolean;
modelPreferenceSource: SessionModelPreferenceSource | null;
patch: AgentSessionMetadataPatch;
providerPreferenceToApply: SessionModelPreference | null;
shouldPersistAccessMode: boolean;
}
export interface SessionMetadataSyncRuntime {
setSessionAccessMode?: (
sessionId: string,
accessMode: AgentAccessMode,
) => Promise<void>;
setSessionExecutionStrategy: (
sessionId: string,
executionStrategy: AsterExecutionStrategy,
) => Promise<void>;
setSessionProviderSelection: (
sessionId: string,
providerType: string,
model: string,
) => Promise<void>;
updateSessionMetadata?: (
sessionId: string,
patch: AgentSessionMetadataPatch,
) => Promise<void>;
}
export function resolveSessionExecutionStrategySource(params: {
runtimeExecutionStrategy?: AsterExecutionStrategy | null;
topicExecutionStrategy?: AsterExecutionStrategy | null;
shadowExecutionStrategyFallback?: AsterExecutionStrategy | null;
}): SessionExecutionStrategySource {
if (params.runtimeExecutionStrategy) {
return "session_detail";
}
if (params.topicExecutionStrategy) {
return "topics_snapshot";
}
if (params.shadowExecutionStrategyFallback) {
return "shadow_cache";
}
return "default";
}
export function buildSessionMetadataSyncPlan(params: {
runtimeAccessMode?: AgentAccessMode | null;
runtimePreference?: SessionModelPreference | null;
shadowAccessMode?: AgentAccessMode | null;
shadowExecutionStrategyFallback?: AsterExecutionStrategy | null;
topicPreference?: SessionModelPreference | null;
workspaceDefaultAccessMode: AgentAccessMode;
}): SessionMetadataSyncPlan {
const patch: AgentSessionMetadataPatch = {};
let accessMode: AgentAccessMode;
let accessModeSource: SessionAccessModeSource;
let shouldPersistAccessMode = false;
if (params.runtimeAccessMode) {
accessMode = params.runtimeAccessMode;
accessModeSource = "execution_runtime";
shouldPersistAccessMode = true;
} else if (params.shadowAccessMode) {
accessMode = params.shadowAccessMode;
accessModeSource = "session_storage";
patch.accessMode = params.shadowAccessMode;
} else {
accessMode = params.workspaceDefaultAccessMode;
accessModeSource = "workspace_default";
shouldPersistAccessMode = true;
patch.accessMode = params.workspaceDefaultAccessMode;
}
const providerPreferenceToApply = params.topicPreference ?? null;
const fallbackProviderPreference =
providerPreferenceToApply && !params.runtimePreference
? providerPreferenceToApply
: null;
if (fallbackProviderPreference) {
patch.providerType = fallbackProviderPreference.providerType;
patch.model = fallbackProviderPreference.model;
}
const fallbackExecutionStrategy =
params.shadowExecutionStrategyFallback ?? null;
if (fallbackExecutionStrategy) {
patch.executionStrategy = fallbackExecutionStrategy;
}
return {
accessMode,
accessModeSource,
fallbackExecutionStrategy,
fallbackProviderPreference,
hasPatch: Boolean(
patch.accessMode ||
patch.providerType ||
patch.model ||
patch.executionStrategy,
),
modelPreferenceSource: params.runtimePreference
? "execution_runtime"
: providerPreferenceToApply
? "session_storage"
: null,
patch,
providerPreferenceToApply,
shouldPersistAccessMode,
};
}
export function buildSessionSwitchSuccessMetricContext(params: {
accessModeSource: SessionAccessModeSource;
durationMs: number;
executionStrategySource: SessionExecutionStrategySource;
itemsCount: number;
messagesCount: number;
modelPreferenceSource: SessionModelPreferenceSource | null;
queuedTurnsCount: number;
topicId: string;
turnsCount: number;
workspaceId?: string | null;
}): Record<string, unknown> {
return {
accessModeSource: params.accessModeSource,
durationMs: params.durationMs,
executionStrategySource: params.executionStrategySource,
itemsCount: params.itemsCount,
messagesCount: params.messagesCount,
modelPreferenceSource: params.modelPreferenceSource,
queuedTurnsCount: params.queuedTurnsCount,
sessionId: params.topicId,
topicId: params.topicId,
turnsCount: params.turnsCount,
workspaceId: params.workspaceId,
};
}
export async function executeSessionMetadataSync(params: {
fallbackExecutionStrategy: AsterExecutionStrategy | null;
fallbackProviderPreference: SessionModelPreference | null;
patch: AgentSessionMetadataPatch;
runtime: SessionMetadataSyncRuntime;
sessionId: string;
}): Promise<void> {
if (params.runtime.updateSessionMetadata) {
return params.runtime.updateSessionMetadata(params.sessionId, params.patch);
}
const tasks: Promise<void>[] = [];
if (params.patch.accessMode && params.runtime.setSessionAccessMode) {
tasks.push(
params.runtime.setSessionAccessMode(
params.sessionId,
params.patch.accessMode,
),
);
}
if (params.fallbackProviderPreference) {
tasks.push(
params.runtime.setSessionProviderSelection(
params.sessionId,
params.fallbackProviderPreference.providerType,
params.fallbackProviderPreference.model,
),
);
}
if (params.fallbackExecutionStrategy) {
tasks.push(
params.runtime.setSessionExecutionStrategy(
params.sessionId,
params.fallbackExecutionStrategy,
),
);
}
await Promise.all(tasks);
}
@@ -0,0 +1,241 @@
import { describe, expect, it, vi } from "vitest";
import type { SessionMetadataSyncPlan } from "./sessionMetadataSyncController";
import {
buildSessionMetadataSyncBrowserSkipEvent,
buildSessionMetadataSyncStaleSkipEvent,
scheduleSessionMetadataSync,
type SessionMetadataSyncScheduler,
} from "./sessionMetadataSyncScheduler";
const flushPromises = async () => {
await Promise.resolve();
await Promise.resolve();
await new Promise((resolve) => {
setTimeout(resolve, 0);
});
};
function metadataPlan(
overrides: Partial<SessionMetadataSyncPlan> = {},
): SessionMetadataSyncPlan {
return {
accessMode: "current",
accessModeSource: "execution_runtime",
fallbackExecutionStrategy: null,
fallbackProviderPreference: null,
hasPatch: true,
modelPreferenceSource: null,
patch: { accessMode: "current" },
providerPreferenceToApply: null,
shouldPersistAccessMode: true,
...overrides,
};
}
function createScheduler() {
const scheduledTasks: Array<{
options: { idleTimeoutMs: number; minimumDelayMs: number };
task: () => void;
}> = [];
const cancel = vi.fn();
const scheduler: SessionMetadataSyncScheduler = {
schedule: vi.fn((task, options) => {
scheduledTasks.push({ task, options });
return cancel;
}),
};
return { cancel, scheduledTasks, scheduler };
}
describe("sessionMetadataSyncScheduler", () => {
it("无 patch 时不应调度或触发 skip 日志", () => {
const { scheduler } = createScheduler();
const onSkipped = vi.fn();
const result = scheduleSessionMetadataSync({
getCurrentRequestVersion: () => 1,
getCurrentSessionId: () => "topic-a",
hasRuntimeInvokeCapability: true,
idleTimeoutMs: 15_000,
minimumDelayMs: 8_000,
onError: vi.fn(),
onSkipped,
onSynced: vi.fn(),
plan: metadataPlan({ hasPatch: false, patch: {} }),
runtime: {
setSessionExecutionStrategy: vi.fn(),
setSessionProviderSelection: vi.fn(),
},
scheduler,
sessionId: "topic-a",
setPendingCancel: vi.fn(),
switchRequestVersion: 1,
workspaceId: "workspace-a",
});
expect(result).toBe("skipped_no_patch");
expect(scheduler.schedule).not.toHaveBeenCalled();
expect(onSkipped).not.toHaveBeenCalled();
});
it("浏览器桥接低优先级回填时应跳过 runtime invoke", () => {
const { scheduler } = createScheduler();
const onSkipped = vi.fn();
const result = scheduleSessionMetadataSync({
getCurrentRequestVersion: () => 1,
getCurrentSessionId: () => "topic-a",
hasRuntimeInvokeCapability: false,
idleTimeoutMs: 15_000,
minimumDelayMs: 8_000,
onError: vi.fn(),
onSkipped,
onSynced: vi.fn(),
plan: metadataPlan(),
runtime: {
setSessionExecutionStrategy: vi.fn(),
setSessionProviderSelection: vi.fn(),
},
scheduler,
sessionId: "topic-a",
setPendingCancel: vi.fn(),
switchRequestVersion: 1,
workspaceId: "workspace-a",
});
expect(result).toBe("skipped_no_invoke");
expect(scheduler.schedule).not.toHaveBeenCalled();
expect(onSkipped).toHaveBeenCalledWith(
buildSessionMetadataSyncBrowserSkipEvent({
sessionId: "topic-a",
workspaceId: "workspace-a",
}),
);
});
it("应取消上一次调度并按 idle 参数安排 metadata sync", async () => {
const { cancel, scheduledTasks, scheduler } = createScheduler();
const previousCancel = vi.fn();
const setPendingCancel = vi.fn();
const runtime = {
updateSessionMetadata: vi.fn().mockResolvedValue(undefined),
setSessionExecutionStrategy: vi.fn(),
setSessionProviderSelection: vi.fn(),
};
const plan = metadataPlan({
fallbackExecutionStrategy: "code_orchestrated",
patch: {
accessMode: "current",
executionStrategy: "code_orchestrated",
},
});
const result = scheduleSessionMetadataSync({
getCurrentRequestVersion: () => 7,
getCurrentSessionId: () => "topic-a",
hasRuntimeInvokeCapability: true,
idleTimeoutMs: 15_000,
minimumDelayMs: 8_000,
onError: vi.fn(),
onSkipped: vi.fn(),
onSynced: vi.fn(),
pendingCancel: previousCancel,
plan,
runtime,
scheduler,
sessionId: "topic-a",
setPendingCancel,
switchRequestVersion: 7,
workspaceId: "workspace-a",
});
expect(result).toBe("scheduled");
expect(previousCancel).toHaveBeenCalledTimes(1);
expect(scheduler.schedule).toHaveBeenCalledTimes(1);
expect(scheduledTasks[0]?.options).toEqual({
idleTimeoutMs: 15_000,
minimumDelayMs: 8_000,
});
expect(setPendingCancel).toHaveBeenLastCalledWith(cancel);
scheduledTasks[0]?.task();
await flushPromises();
expect(setPendingCancel).toHaveBeenCalledWith(null);
expect(runtime.updateSessionMetadata).toHaveBeenCalledWith("topic-a", {
accessMode: "current",
executionStrategy: "code_orchestrated",
});
});
it("调度执行时如果会话已过期,应跳过 metadata sync", () => {
const { scheduledTasks, scheduler } = createScheduler();
const runtime = {
updateSessionMetadata: vi.fn(),
setSessionExecutionStrategy: vi.fn(),
setSessionProviderSelection: vi.fn(),
};
const onSkipped = vi.fn();
scheduleSessionMetadataSync({
getCurrentRequestVersion: () => 8,
getCurrentSessionId: () => "topic-b",
hasRuntimeInvokeCapability: true,
idleTimeoutMs: 15_000,
minimumDelayMs: 8_000,
onError: vi.fn(),
onSkipped,
onSynced: vi.fn(),
plan: metadataPlan(),
runtime,
scheduler,
sessionId: "topic-a",
setPendingCancel: vi.fn(),
switchRequestVersion: 7,
workspaceId: "workspace-a",
});
scheduledTasks[0]?.task();
expect(runtime.updateSessionMetadata).not.toHaveBeenCalled();
expect(onSkipped).toHaveBeenCalledWith(
buildSessionMetadataSyncStaleSkipEvent({
currentSessionId: "topic-b",
sessionId: "topic-a",
switchRequestVersion: 7,
workspaceId: "workspace-a",
}),
);
});
it("metadata sync 失败时应回调错误处理", async () => {
const { scheduledTasks, scheduler } = createScheduler();
const error = new Error("sync failed");
const onError = vi.fn();
scheduleSessionMetadataSync({
getCurrentRequestVersion: () => 1,
getCurrentSessionId: () => "topic-a",
hasRuntimeInvokeCapability: true,
idleTimeoutMs: 15_000,
minimumDelayMs: 8_000,
onError,
onSkipped: vi.fn(),
onSynced: vi.fn(),
plan: metadataPlan(),
runtime: {
updateSessionMetadata: vi.fn().mockRejectedValue(error),
setSessionExecutionStrategy: vi.fn(),
setSessionProviderSelection: vi.fn(),
},
scheduler,
sessionId: "topic-a",
setPendingCancel: vi.fn(),
switchRequestVersion: 1,
});
scheduledTasks[0]?.task();
await flushPromises();
expect(onError).toHaveBeenCalledWith(error);
});
});
@@ -0,0 +1,143 @@
import { isCurrentSessionHydrationRequest } from "./sessionHydrationController";
import {
executeSessionMetadataSync,
type SessionMetadataSyncPlan,
type SessionMetadataSyncRuntime,
} from "./sessionMetadataSyncController";
export interface SessionMetadataSyncScheduleEvent {
logContext: Record<string, unknown>;
logEvent: "switchTopic.metadataSyncSkipped";
logOptions?: {
throttleMs?: number;
};
}
export type SessionMetadataSyncScheduleResult =
| "scheduled"
| "skipped_no_invoke"
| "skipped_no_patch";
export interface SessionMetadataSyncSchedulerOptions {
idleTimeoutMs: number;
minimumDelayMs: number;
}
export interface SessionMetadataSyncScheduler {
schedule(
task: () => void,
options: SessionMetadataSyncSchedulerOptions,
): () => void;
}
export function buildSessionMetadataSyncBrowserSkipEvent(params: {
sessionId: string;
workspaceId?: string | null;
}): SessionMetadataSyncScheduleEvent {
return {
logEvent: "switchTopic.metadataSyncSkipped",
logContext: {
reason: "browser_bridge_low_priority_backfill",
topicId: params.sessionId,
workspaceId: params.workspaceId,
},
logOptions: { throttleMs: 1000 },
};
}
export function buildSessionMetadataSyncStaleSkipEvent(params: {
currentSessionId?: string | null;
sessionId: string;
switchRequestVersion: number;
workspaceId?: string | null;
}): SessionMetadataSyncScheduleEvent {
return {
logEvent: "switchTopic.metadataSyncSkipped",
logContext: {
currentSessionId: params.currentSessionId ?? null,
switchRequestVersion: params.switchRequestVersion,
topicId: params.sessionId,
workspaceId: params.workspaceId,
},
logOptions: { throttleMs: 1000 },
};
}
export function scheduleSessionMetadataSync(params: {
getCurrentRequestVersion: () => number;
getCurrentSessionId: () => string | null | undefined;
hasRuntimeInvokeCapability: boolean;
idleTimeoutMs: number;
minimumDelayMs: number;
onError: (error: unknown) => void;
onSkipped: (event: SessionMetadataSyncScheduleEvent) => void;
onSynced: (plan: SessionMetadataSyncPlan) => void;
pendingCancel?: (() => void) | null;
plan: SessionMetadataSyncPlan;
runtime: SessionMetadataSyncRuntime;
scheduler: SessionMetadataSyncScheduler;
sessionId: string;
setPendingCancel: (cancel: (() => void) | null) => void;
switchRequestVersion: number;
workspaceId?: string | null;
}): SessionMetadataSyncScheduleResult {
if (!params.plan.hasPatch) {
return "skipped_no_patch";
}
if (!params.hasRuntimeInvokeCapability) {
params.onSkipped(
buildSessionMetadataSyncBrowserSkipEvent({
sessionId: params.sessionId,
workspaceId: params.workspaceId,
}),
);
return "skipped_no_invoke";
}
params.pendingCancel?.();
const cancel = params.scheduler.schedule(
() => {
params.setPendingCancel(null);
const currentSessionId = params.getCurrentSessionId() ?? null;
if (
!isCurrentSessionHydrationRequest({
currentRequestVersion: params.getCurrentRequestVersion(),
requestVersion: params.switchRequestVersion,
currentSessionId,
targetSessionId: params.sessionId,
})
) {
params.onSkipped(
buildSessionMetadataSyncStaleSkipEvent({
currentSessionId,
sessionId: params.sessionId,
switchRequestVersion: params.switchRequestVersion,
workspaceId: params.workspaceId,
}),
);
return;
}
void executeSessionMetadataSync({
fallbackExecutionStrategy: params.plan.fallbackExecutionStrategy,
fallbackProviderPreference: params.plan.fallbackProviderPreference,
patch: params.plan.patch,
runtime: params.runtime,
sessionId: params.sessionId,
})
.then(() => {
params.onSynced(params.plan);
})
.catch(params.onError);
},
{
minimumDelayMs: params.minimumDelayMs,
idleTimeoutMs: params.idleTimeoutMs,
},
);
params.setPendingCancel(cancel);
return "scheduled";
}
@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import {
buildSessionPostFinalizePersistencePlan,
resolvePersistedSessionWorkspaceId,
resolveSessionDetailTopicWorkspaceId,
} from "./sessionPostFinalizePersistenceController";
describe("sessionPostFinalizePersistenceController", () => {
it("topic workspace 应按 runtime / known / resolved 顺序恢复", () => {
expect(
resolveSessionDetailTopicWorkspaceId({
runtimeWorkspaceId: "runtime-workspace",
knownWorkspaceId: "known-workspace",
resolvedWorkspaceId: "resolved-workspace",
}),
).toBe("runtime-workspace");
expect(
resolveSessionDetailTopicWorkspaceId({
runtimeWorkspaceId: null,
knownWorkspaceId: "known-workspace",
resolvedWorkspaceId: "resolved-workspace",
}),
).toBe("known-workspace");
expect(
resolveSessionDetailTopicWorkspaceId({
runtimeWorkspaceId: null,
knownWorkspaceId: null,
resolvedWorkspaceId: "resolved-workspace",
}),
).toBe("resolved-workspace");
});
it("持久化 workspace 只应使用 runtime workspace 或当前 resolved workspace", () => {
expect(
resolvePersistedSessionWorkspaceId({
runtimeWorkspaceId: "runtime-workspace",
resolvedWorkspaceId: "resolved-workspace",
}),
).toBe("runtime-workspace");
expect(
resolvePersistedSessionWorkspaceId({
runtimeWorkspaceId: null,
resolvedWorkspaceId: "resolved-workspace",
}),
).toBe("resolved-workspace");
expect(
resolvePersistedSessionWorkspaceId({
runtimeWorkspaceId: null,
resolvedWorkspaceId: null,
}),
).toBeNull();
});
it("应构造 finalize 后 workspace 与 provider 持久化计划", () => {
expect(
buildSessionPostFinalizePersistencePlan({
runtimeWorkspaceId: "runtime-workspace",
knownWorkspaceId: "known-workspace",
resolvedWorkspaceId: "resolved-workspace",
providerPreferenceToApply: {
providerType: "deepseek",
model: "deepseek-chat",
},
}),
).toEqual({
persistedWorkspaceId: "runtime-workspace",
providerPreferenceToApply: {
providerType: "deepseek",
model: "deepseek-chat",
},
runtimeTopicWorkspaceIdToApply: "runtime-workspace",
topicWorkspaceId: "runtime-workspace",
});
});
});
@@ -0,0 +1,42 @@
import type { SessionModelPreference } from "./agentChatShared";
export interface SessionPostFinalizePersistencePlan {
persistedWorkspaceId: string | null;
providerPreferenceToApply: SessionModelPreference | null;
runtimeTopicWorkspaceIdToApply: string | null;
topicWorkspaceId: string | null;
}
export function resolveSessionDetailTopicWorkspaceId(params: {
knownWorkspaceId?: string | null;
resolvedWorkspaceId?: string | null;
runtimeWorkspaceId?: string | null;
}): string | null {
return (
params.runtimeWorkspaceId ||
params.knownWorkspaceId ||
params.resolvedWorkspaceId ||
null
);
}
export function resolvePersistedSessionWorkspaceId(params: {
resolvedWorkspaceId?: string | null;
runtimeWorkspaceId?: string | null;
}): string | null {
return params.runtimeWorkspaceId || params.resolvedWorkspaceId || null;
}
export function buildSessionPostFinalizePersistencePlan(params: {
knownWorkspaceId?: string | null;
providerPreferenceToApply?: SessionModelPreference | null;
resolvedWorkspaceId?: string | null;
runtimeWorkspaceId?: string | null;
}): SessionPostFinalizePersistencePlan {
return {
persistedWorkspaceId: resolvePersistedSessionWorkspaceId(params),
providerPreferenceToApply: params.providerPreferenceToApply ?? null,
runtimeTopicWorkspaceIdToApply: params.runtimeWorkspaceId || null,
topicWorkspaceId: resolveSessionDetailTopicWorkspaceId(params),
};
}
@@ -0,0 +1,176 @@
import { describe, expect, it } from "vitest";
import {
buildPendingSessionShellMetricContext,
buildSessionSwitchDeferHydrationMetricContext,
buildSessionSwitchLocalSnapshotOverride,
buildSessionSwitchStartMetricContext,
shouldApplyCachedTopicSnapshot,
shouldApplyPendingSessionShell,
shouldLoadCachedTopicSnapshot,
shouldRefreshCachedSnapshotImmediately,
} from "./sessionSwitchSnapshotController";
import type { AgentSessionCachedSnapshot } from "./agentSessionScopedStorage";
import type { AgentThreadItem, AgentThreadTurn, Message } from "../types";
function message(id: string): Message {
return {
id,
role: "assistant",
content: id,
timestamp: new Date("2026-05-05T00:00:00.000Z"),
};
}
function snapshot(
messages: Message[] = [message("message-a")],
): AgentSessionCachedSnapshot {
return {
messages,
threadTurns: [{ id: "turn-a" }] as AgentThreadTurn[],
threadItems: [{ id: "item-a", turn_id: "turn-a" }] as AgentThreadItem[],
currentTurnId: null,
cacheMetadata: {
storageKind: "transient",
freshness: "fresh",
updatedAt: 1,
lastAccessedAt: 1,
expiresAt: 2,
staleUntil: 3,
sessionUpdatedAt: 1,
messagesCount: messages.length,
historyTruncated: false,
},
};
}
describe("sessionSwitchSnapshotController", () => {
it("当前会话不应重复加载或应用 cached snapshot", () => {
expect(
shouldLoadCachedTopicSnapshot({
currentSessionId: "topic-a",
topicId: "topic-a",
}),
).toBe(false);
expect(
shouldApplyCachedTopicSnapshot({
currentSessionId: "topic-a",
topicId: "topic-a",
}),
).toBe(false);
});
it("stale / running / waiting 话题应立即刷新 cached snapshot", () => {
expect(
shouldRefreshCachedSnapshotImmediately({
cacheFreshness: "stale",
topicStatus: "idle",
}),
).toBe(true);
expect(
shouldRefreshCachedSnapshotImmediately({
cacheFreshness: "fresh",
topicStatus: "running",
}),
).toBe(true);
expect(
shouldRefreshCachedSnapshotImmediately({
cacheFreshness: "fresh",
topicStatus: "completed",
}),
).toBe(false);
});
it("无 cached snapshot 且切换到其他会话时应先应用 pending shell", () => {
expect(
shouldApplyPendingSessionShell({
currentSessionId: "topic-a",
topicId: "topic-b",
cachedSnapshot: null,
}),
).toBe(true);
expect(
shouldApplyPendingSessionShell({
currentSessionId: "topic-a",
topicId: "topic-b",
cachedSnapshot: snapshot(),
}),
).toBe(false);
});
it("应构造 switch start / defer / pending shell 指标上下文", () => {
const cachedSnapshot = snapshot([message("message-a"), message("message-b")]);
expect(
buildSessionSwitchStartMetricContext({
cachedSnapshot,
currentSessionId: "topic-a",
messagesCount: 7,
refreshCachedSnapshotImmediately: true,
topicId: "topic-b",
workspaceId: "workspace-a",
}),
).toMatchObject({
cacheFreshness: "fresh",
cacheStorageKind: "transient",
cachedLocalMessagesCount: 2,
currentSessionId: "topic-a",
messagesCount: 7,
refreshCachedSnapshotImmediately: true,
sessionId: "topic-b",
workspaceId: "workspace-a",
});
expect(
buildSessionSwitchDeferHydrationMetricContext({
cachedSnapshot,
currentSessionId: "topic-a",
refreshImmediately: false,
topicId: "topic-b",
}),
).toMatchObject({
cachedLocalMessagesCount: 2,
refreshImmediately: false,
sessionId: "topic-b",
});
expect(
buildPendingSessionShellMetricContext({
currentSessionId: "topic-a",
topicId: "topic-b",
}),
).toEqual({
currentSessionId: "topic-a",
sessionId: "topic-b",
topicId: "topic-b",
workspaceId: undefined,
});
});
it("仅当前 UI 仍持有同一 cached snapshot 引用时才返回 localSnapshotOverride", () => {
const cachedSnapshot = snapshot();
expect(
buildSessionSwitchLocalSnapshotOverride({
cachedSnapshot,
currentSessionId: "topic-a",
messages: cachedSnapshot.messages,
threadTurns: cachedSnapshot.threadTurns,
threadItems: cachedSnapshot.threadItems,
topicId: "topic-a",
}),
).toEqual({
sessionId: "topic-a",
messages: cachedSnapshot.messages,
threadTurns: cachedSnapshot.threadTurns,
threadItems: cachedSnapshot.threadItems,
});
expect(
buildSessionSwitchLocalSnapshotOverride({
cachedSnapshot,
currentSessionId: "topic-a",
messages: cachedSnapshot.messages.slice(),
threadTurns: cachedSnapshot.threadTurns,
threadItems: cachedSnapshot.threadItems,
topicId: "topic-a",
}),
).toBeNull();
});
});
@@ -0,0 +1,125 @@
import type { AgentThreadItem, AgentThreadTurn, Message } from "../types";
import type { AgentSessionCachedSnapshot } from "./agentSessionScopedStorage";
export interface SessionSwitchLocalSnapshotOverride {
sessionId: string;
messages: Message[];
threadTurns: AgentThreadTurn[];
threadItems: AgentThreadItem[];
}
export function shouldLoadCachedTopicSnapshot(params: {
currentSessionId?: string | null;
topicId: string;
}): boolean {
return params.currentSessionId !== params.topicId;
}
export function shouldApplyCachedTopicSnapshot(params: {
currentSessionId?: string | null;
topicId: string;
}): boolean {
return shouldLoadCachedTopicSnapshot(params);
}
export function shouldRefreshCachedSnapshotImmediately(params: {
cacheFreshness?: "fresh" | "stale" | null;
topicStatus?: string | null;
}): boolean {
return (
params.cacheFreshness === "stale" ||
params.topicStatus === "running" ||
params.topicStatus === "waiting"
);
}
export function shouldApplyPendingSessionShell(params: {
currentSessionId?: string | null;
topicId: string;
cachedSnapshot?: AgentSessionCachedSnapshot | null;
}): boolean {
return params.currentSessionId !== params.topicId && !params.cachedSnapshot;
}
export function buildSessionSwitchStartMetricContext(params: {
cachedSnapshot?: AgentSessionCachedSnapshot | null;
currentSessionId?: string | null;
messagesCount: number;
refreshCachedSnapshotImmediately: boolean;
topicId: string;
workspaceId?: string | null;
}): Record<string, unknown> {
const cachedSnapshotMetadata = params.cachedSnapshot?.cacheMetadata;
return {
cacheFreshness: cachedSnapshotMetadata?.freshness ?? null,
cacheStorageKind: cachedSnapshotMetadata?.storageKind ?? null,
cachedLocalMessagesCount: params.cachedSnapshot?.messages.length ?? 0,
currentSessionId: params.currentSessionId ?? null,
messagesCount: params.messagesCount,
refreshCachedSnapshotImmediately:
params.refreshCachedSnapshotImmediately,
sessionId: params.topicId,
topicId: params.topicId,
workspaceId: params.workspaceId,
};
}
export function buildSessionSwitchDeferHydrationMetricContext(params: {
cachedSnapshot?: AgentSessionCachedSnapshot | null;
currentSessionId?: string | null;
refreshImmediately: boolean;
topicId: string;
workspaceId?: string | null;
}): Record<string, unknown> {
const cachedSnapshotMetadata = params.cachedSnapshot?.cacheMetadata;
return {
cacheFreshness: cachedSnapshotMetadata?.freshness ?? null,
cacheStorageKind: cachedSnapshotMetadata?.storageKind ?? null,
cachedLocalMessagesCount: params.cachedSnapshot?.messages.length ?? 0,
currentSessionId: params.currentSessionId ?? null,
refreshImmediately: params.refreshImmediately,
sessionId: params.topicId,
topicId: params.topicId,
workspaceId: params.workspaceId,
};
}
export function buildPendingSessionShellMetricContext(params: {
currentSessionId?: string | null;
topicId: string;
workspaceId?: string | null;
}): Record<string, unknown> {
return {
currentSessionId: params.currentSessionId ?? null,
sessionId: params.topicId,
topicId: params.topicId,
workspaceId: params.workspaceId,
};
}
export function buildSessionSwitchLocalSnapshotOverride(params: {
cachedSnapshot?: AgentSessionCachedSnapshot | null;
currentSessionId?: string | null;
messages: Message[];
threadTurns: AgentThreadTurn[];
threadItems: AgentThreadItem[];
topicId: string;
}): SessionSwitchLocalSnapshotOverride | null {
const cachedSnapshot = params.cachedSnapshot;
if (
!cachedSnapshot ||
params.currentSessionId !== params.topicId ||
params.messages !== cachedSnapshot.messages ||
params.threadTurns !== cachedSnapshot.threadTurns ||
params.threadItems !== cachedSnapshot.threadItems
) {
return null;
}
return {
sessionId: params.topicId,
messages: cachedSnapshot.messages,
threadTurns: cachedSnapshot.threadTurns,
threadItems: cachedSnapshot.threadItems,
};
}
File diff suppressed because it is too large Load Diff
@@ -3803,6 +3803,54 @@ describe("useAsterAgentChat slash skill 执行链路", () => {
}
});
it("新建任务返回前不应等待本地持久化回填", async () => {
const workspaceId = "ws-create-fresh-fast-return";
const createdSessionId = "session-fresh-fast-return";
const scheduledTasks: Array<() => void> = [];
mockCreateAgentRuntimeSession.mockResolvedValue(createdSessionId);
mockScheduleMinimumDelayIdleTask.mockImplementation((task: () => void) => {
scheduledTasks.push(task);
return () => undefined;
});
const harness = mountHook(workspaceId);
try {
await flushEffects();
await act(async () => {
const newSessionId = await harness
.getValue()
.createFreshSession("新对话");
expect(newSessionId).toBe(createdSessionId);
});
expect(harness.getValue().sessionId).toBe(createdSessionId);
expect(sessionStorage.getItem(`aster_messages_${workspaceId}`)).toBe(
null,
);
expect(
localStorage.getItem(
`agent_topic_model_pref_${workspaceId}_${createdSessionId}`,
),
).toBe(null);
expect(scheduledTasks).toHaveLength(1);
act(() => {
scheduledTasks[0]?.();
});
expect(sessionStorage.getItem(`aster_messages_${workspaceId}`)).toBe("[]");
expect(
localStorage.getItem(
`agent_topic_model_pref_${workspaceId}_${createdSessionId}`,
),
).not.toBe(null);
} finally {
harness.unmount();
}
});
it("新建任务失败后应释放创建锁,允许恢复桥接后再次新建", async () => {
const workspaceId = "ws-create-fresh-retry-after-bridge-error";
const recoveredSessionId = "session-after-create-retry";
+17
View File
@@ -4975,6 +4975,23 @@ describe("AgentChatPage 自动引导", { timeout: 20_000 }, () => {
expect(sharedTriggerAIGuideMock).not.toHaveBeenCalled();
});
it("无文稿入口存在 initialUserPrompt 时也应预填而不是默认自动发送", async () => {
const initialUserPrompt = "请基于当前项目资料生成内容";
renderPage({
projectId: "project-knowledge-prefill",
initialUserPrompt,
});
await flushEffects(12);
expect(sharedSendMessageMock).not.toHaveBeenCalled();
const latestInputbarProps = mockInputbar.mock.calls.at(-1)?.[0] as
| { input?: string }
| undefined;
expect(latestInputbarProps?.input || "").toBe(initialUserPrompt);
expect(sharedTriggerAIGuideMock).not.toHaveBeenCalled();
});
it("初始创作意图点击重新开始后应清空输入并消费待执行意图", async () => {
mockIsSpecializedWorkbenchTheme.mockReturnValue(true);
mockUseThemeContextWorkspace.mockReturnValue(
@@ -0,0 +1,98 @@
import { describe, expect, it, vi } from "vitest";
import {
createConversationProjectionStore,
selectConversationStreamDiagnostics,
selectLatestConversationStreamDiagnostic,
} from "./conversationProjectionStore";
describe("conversationProjectionStore", () => {
it("应记录 stream diagnostics,并按 session 提供最新投影", () => {
const store = createConversationProjectionStore();
const listener = vi.fn();
store.subscribe(listener);
const first = store.recordStreamDiagnostic({
phase: "agentStream.request.start",
at: 10,
wallTime: 1000,
sessionId: "session-a",
workspaceId: "workspace-a",
source: "test",
requestId: "request-a",
actualSessionId: null,
metrics: {
route: "home",
},
});
const second = store.recordStreamDiagnostic({
phase: "agentStream.firstTextDelta",
at: 20,
wallTime: 1010,
sessionId: "session-a",
workspaceId: "workspace-a",
source: "test",
requestId: "request-a",
actualSessionId: "actual-a",
metrics: {
latencyMs: 10,
},
});
const snapshot = store.getSnapshot();
expect(listener).toHaveBeenCalledTimes(2);
expect(selectConversationStreamDiagnostics(snapshot)).toEqual([
first,
second,
]);
expect(
selectLatestConversationStreamDiagnostic(snapshot, "session-a"),
).toEqual(second);
});
it("应只通知 diagnostics 订阅者,未变化的其它 slice 保持引用稳定", () => {
const store = createConversationProjectionStore();
const before = store.getSnapshot();
store.recordStreamDiagnostic({
phase: "agentStream.firstEvent",
at: 1,
wallTime: 1,
sessionId: "session-b",
workspaceId: "workspace-b",
source: "test",
requestId: "request-b",
actualSessionId: null,
metrics: {},
});
const after = store.getSnapshot();
expect(after.diagnostics).not.toBe(before.diagnostics);
expect(after.session).toBe(before.session);
expect(after.stream).toBe(before.stream);
expect(after.queue).toBe(before.queue);
expect(after.render).toBe(before.render);
});
it("无 sessionId 时应按 requestId 记录最新 stream diagnostic", () => {
const store = createConversationProjectionStore();
const entry = store.recordStreamDiagnostic({
phase: "agentStream.submitAccepted",
at: 1,
wallTime: 1,
sessionId: null,
workspaceId: "workspace-c",
source: "test",
requestId: "request-c",
actualSessionId: null,
metrics: {},
});
expect(
selectLatestConversationStreamDiagnostic(
store.getSnapshot(),
"request-c",
),
).toEqual(entry);
});
});
@@ -0,0 +1,185 @@
export type ConversationProjectionSlice =
| "session"
| "stream"
| "queue"
| "render"
| "diagnostics";
export interface ConversationStreamDiagnostic {
id: number;
phase: string;
at: number;
wallTime: number;
sessionId?: string | null;
workspaceId?: string | null;
source?: string | null;
requestId?: string | null;
actualSessionId?: string | null;
metrics: Record<string, string | number | boolean | null>;
}
export interface ConversationDiagnosticsSlice {
streamDiagnostics: ConversationStreamDiagnostic[];
latestStreamDiagnosticBySession: Record<string, ConversationStreamDiagnostic>;
}
export interface ConversationSessionProjectionSlice {
version: number;
}
export interface ConversationStreamProjectionSlice {
version: number;
}
export interface ConversationQueueProjectionSlice {
version: number;
}
export interface ConversationRenderProjectionSlice {
version: number;
}
export interface ConversationProjectionState {
session: ConversationSessionProjectionSlice;
stream: ConversationStreamProjectionSlice;
queue: ConversationQueueProjectionSlice;
render: ConversationRenderProjectionSlice;
diagnostics: ConversationDiagnosticsSlice;
}
export type ConversationProjectionListener = () => void;
export interface ConversationProjectionStore {
getSnapshot: () => ConversationProjectionState;
subscribe: (listener: ConversationProjectionListener) => () => void;
recordStreamDiagnostic: (
diagnostic: Omit<ConversationStreamDiagnostic, "id">,
) => ConversationStreamDiagnostic;
clearDiagnostics: () => void;
}
const MAX_STREAM_DIAGNOSTICS = 500;
function createInitialState(): ConversationProjectionState {
return {
session: { version: 0 },
stream: { version: 0 },
queue: { version: 0 },
render: { version: 0 },
diagnostics: {
streamDiagnostics: [],
latestStreamDiagnosticBySession: {},
},
};
}
function normalizeSessionKey(
diagnostic: Pick<ConversationStreamDiagnostic, "sessionId" | "requestId">,
): string | null {
return diagnostic.sessionId ?? diagnostic.requestId ?? null;
}
export function createConversationProjectionStore(): ConversationProjectionStore {
let state = createInitialState();
let nextDiagnosticId = 1;
const listeners = new Set<ConversationProjectionListener>();
function emit(): void {
for (const listener of listeners) {
listener();
}
}
return {
getSnapshot: () => state,
subscribe(listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
recordStreamDiagnostic(diagnostic) {
const entry: ConversationStreamDiagnostic = {
...diagnostic,
id: nextDiagnosticId,
};
nextDiagnosticId += 1;
const streamDiagnostics = [...state.diagnostics.streamDiagnostics, entry];
if (streamDiagnostics.length > MAX_STREAM_DIAGNOSTICS) {
streamDiagnostics.splice(
0,
streamDiagnostics.length - MAX_STREAM_DIAGNOSTICS,
);
}
const latestStreamDiagnosticBySession = {
...state.diagnostics.latestStreamDiagnosticBySession,
};
const sessionKey = normalizeSessionKey(entry);
if (sessionKey) {
latestStreamDiagnosticBySession[sessionKey] = entry;
}
state = {
...state,
diagnostics: {
streamDiagnostics,
latestStreamDiagnosticBySession,
},
};
emit();
return entry;
},
clearDiagnostics() {
if (
state.diagnostics.streamDiagnostics.length === 0 &&
Object.keys(state.diagnostics.latestStreamDiagnosticBySession)
.length === 0
) {
return;
}
state = {
...state,
diagnostics: {
streamDiagnostics: [],
latestStreamDiagnosticBySession: {},
},
};
nextDiagnosticId = 1;
emit();
},
};
}
export const conversationProjectionStore = createConversationProjectionStore();
export function selectConversationStreamDiagnostics(
state: ConversationProjectionState,
): ConversationStreamDiagnostic[] {
return state.diagnostics.streamDiagnostics;
}
export function selectLatestConversationStreamDiagnostic(
state: ConversationProjectionState,
key: string | null | undefined,
): ConversationStreamDiagnostic | null {
if (!key) {
return null;
}
return state.diagnostics.latestStreamDiagnosticBySession[key] ?? null;
}
export function recordConversationStreamDiagnostic(
diagnostic: Omit<ConversationStreamDiagnostic, "id">,
): ConversationStreamDiagnostic {
return conversationProjectionStore.recordStreamDiagnostic(diagnostic);
}
export function clearConversationProjectionDiagnostics(): void {
conversationProjectionStore.clearDiagnostics();
}
@@ -0,0 +1,145 @@
import { describe, expect, it } from "vitest";
import {
buildHistoricalMarkdownHydrationIndexByMessageId,
buildHistoricalMarkdownHydrationTargets,
countDeferredHistoricalContentParts,
countDeferredHistoricalMarkdown,
hasStructuredHistoricalContentHint,
isHistoricalAssistantMessageHydrationCandidate,
shouldDeferHistoricalAssistantMessageDetails,
} from "./historicalMessageHydrationProjection";
function message(
id: string,
overrides: Partial<{
role: "user" | "assistant";
content: string;
isThinking: boolean;
thinkingContent: string;
toolCalls: unknown[];
actionRequests: unknown[];
contentParts: unknown[];
}> = {},
) {
return {
id,
role: overrides.role ?? "assistant",
content: overrides.content ?? "plain markdown",
isThinking: overrides.isThinking,
thinkingContent: overrides.thinkingContent,
toolCalls: overrides.toolCalls,
actionRequests: overrides.actionRequests,
contentParts: overrides.contentParts,
};
}
const restoredState = {
isRestoredHistoryWindow: true,
focusedTimelineItemId: null,
isSending: false,
activeCurrentTurnId: null,
};
describe("historicalMessageHydrationProjection", () => {
it("应识别需要保留完整结构渲染的历史内容", () => {
expect(hasStructuredHistoricalContentHint("<a2ui-form />")).toBe(true);
expect(hasStructuredHistoricalContentHint("```a2ui\n{}\n```")).toBe(true);
expect(hasStructuredHistoricalContentHint("<document>body</document>")).toBe(
true,
);
expect(hasStructuredHistoricalContentHint("普通 markdown")).toBe(false);
});
it("仅旧会话中的普通 assistant 历史消息可进入延迟 hydration", () => {
expect(
isHistoricalAssistantMessageHydrationCandidate(
message("assistant-a"),
restoredState,
),
).toBe(true);
expect(
isHistoricalAssistantMessageHydrationCandidate(
message("user-a", { role: "user" }),
restoredState,
),
).toBe(false);
expect(
isHistoricalAssistantMessageHydrationCandidate(
message("thinking-a", { isThinking: true }),
restoredState,
),
).toBe(false);
expect(
isHistoricalAssistantMessageHydrationCandidate(message("sending-a"), {
...restoredState,
isSending: true,
}),
).toBe(false);
});
it("应只为普通 markdown 历史 assistant 生成 hydration 目标", () => {
const targets = buildHistoricalMarkdownHydrationTargets({
messages: [
message("assistant-a"),
message("assistant-structured", { content: "<write_file />" }),
message("user-a", { role: "user" }),
message("assistant-empty", { content: " " }),
],
state: restoredState,
});
expect(targets).toEqual(["assistant-a"]);
});
it("hydration 计数未覆盖目标前应延迟消息细节", () => {
const hydrationIndex = buildHistoricalMarkdownHydrationIndexByMessageId([
"assistant-a",
]);
expect(
shouldDeferHistoricalAssistantMessageDetails({
message: message("assistant-a"),
state: restoredState,
isHistoricalTimelineReady: true,
hydrationIndexByMessageId: hydrationIndex,
hydratedHistoricalMarkdownCount: 0,
}),
).toBe(true);
expect(
shouldDeferHistoricalAssistantMessageDetails({
message: message("assistant-a"),
state: restoredState,
isHistoricalTimelineReady: true,
hydrationIndexByMessageId: hydrationIndex,
hydratedHistoricalMarkdownCount: 1,
}),
).toBe(false);
});
it("应统计仍被延迟的历史 contentParts 与 markdown 数量", () => {
const hydrationIndex = buildHistoricalMarkdownHydrationIndexByMessageId([
"assistant-a",
"assistant-b",
]);
expect(
countDeferredHistoricalContentParts({
messages: [
message("assistant-a", { contentParts: [{ type: "text" }] }),
message("assistant-b"),
],
state: restoredState,
isHistoricalTimelineReady: true,
hydrationIndexByMessageId: hydrationIndex,
hydratedHistoricalMarkdownCount: 0,
}),
).toBe(1);
expect(
countDeferredHistoricalMarkdown({
isRestoredHistoryWindow: true,
targetCount: 2,
hydratedHistoricalMarkdownCount: 1,
}),
).toBe(1);
});
});
@@ -0,0 +1,160 @@
export interface HistoricalConversationMessageLike {
id: string;
role: string;
content: string;
isThinking?: boolean;
thinkingContent?: string;
toolCalls?: readonly unknown[];
actionRequests?: readonly unknown[];
contentParts?: readonly unknown[];
}
export interface HistoricalMessageHydrationState {
isRestoredHistoryWindow: boolean;
focusedTimelineItemId?: string | null;
isSending?: boolean;
activeCurrentTurnId?: string | null;
}
const STRUCTURED_HISTORY_CONTENT_RE = /<a2ui|```\s*a2ui|<write_file|<document/i;
export function hasStructuredHistoricalContentHint(content: string): boolean {
return STRUCTURED_HISTORY_CONTENT_RE.test(content);
}
export function isHistoricalAssistantMessageHydrationCandidate<
TMessage extends HistoricalConversationMessageLike,
>(
message: TMessage,
state: HistoricalMessageHydrationState,
): boolean {
return (
state.isRestoredHistoryWindow &&
!state.focusedTimelineItemId &&
!state.isSending &&
!state.activeCurrentTurnId &&
message.role === "assistant" &&
!message.isThinking &&
!message.thinkingContent &&
(message.toolCalls?.length ?? 0) === 0 &&
(message.actionRequests?.length ?? 0) === 0
);
}
export function buildHistoricalMarkdownHydrationTargets<
TMessage extends HistoricalConversationMessageLike,
>(params: {
messages: readonly TMessage[];
state: HistoricalMessageHydrationState;
}): string[] {
if (!params.state.isRestoredHistoryWindow) {
return [];
}
const targetIds: string[] = [];
for (const message of params.messages) {
const content = message.content.trim();
if (
content &&
!hasStructuredHistoricalContentHint(content) &&
isHistoricalAssistantMessageHydrationCandidate(message, params.state)
) {
targetIds.push(message.id);
}
}
return targetIds;
}
export function buildHistoricalMarkdownHydrationIndexByMessageId(
targetIds: readonly string[],
): Map<string, number> {
const indexed = new Map<string, number>();
targetIds.forEach((messageId, index) => {
indexed.set(messageId, index);
});
return indexed;
}
export function isHistoricalMarkdownHydrated(params: {
messageId: string;
hydrationIndexByMessageId: ReadonlyMap<string, number>;
hydratedHistoricalMarkdownCount: number;
}): boolean {
const hydrationIndex = params.hydrationIndexByMessageId.get(
params.messageId,
);
return (
hydrationIndex === undefined ||
hydrationIndex < params.hydratedHistoricalMarkdownCount
);
}
export function shouldDeferHistoricalAssistantMessageDetails<
TMessage extends HistoricalConversationMessageLike,
>(params: {
message: TMessage;
state: HistoricalMessageHydrationState;
isHistoricalTimelineReady: boolean;
hydrationIndexByMessageId: ReadonlyMap<string, number>;
hydratedHistoricalMarkdownCount: number;
}): boolean {
return (
isHistoricalAssistantMessageHydrationCandidate(
params.message,
params.state,
) &&
(!params.isHistoricalTimelineReady ||
!isHistoricalMarkdownHydrated({
messageId: params.message.id,
hydrationIndexByMessageId: params.hydrationIndexByMessageId,
hydratedHistoricalMarkdownCount:
params.hydratedHistoricalMarkdownCount,
}))
);
}
export function countDeferredHistoricalContentParts<
TMessage extends HistoricalConversationMessageLike,
>(params: {
messages: readonly TMessage[];
state: HistoricalMessageHydrationState;
isHistoricalTimelineReady: boolean;
hydrationIndexByMessageId: ReadonlyMap<string, number>;
hydratedHistoricalMarkdownCount: number;
}): number {
if (!params.state.isRestoredHistoryWindow) {
return 0;
}
let count = 0;
for (const message of params.messages) {
if (
(message.contentParts?.length ?? 0) > 0 &&
shouldDeferHistoricalAssistantMessageDetails({
message,
state: params.state,
isHistoricalTimelineReady: params.isHistoricalTimelineReady,
hydrationIndexByMessageId: params.hydrationIndexByMessageId,
hydratedHistoricalMarkdownCount:
params.hydratedHistoricalMarkdownCount,
})
) {
count += 1;
}
}
return count;
}
export function countDeferredHistoricalMarkdown(params: {
isRestoredHistoryWindow: boolean;
targetCount: number;
hydratedHistoricalMarkdownCount: number;
}): number {
if (!params.isRestoredHistoryWindow) {
return 0;
}
return Math.max(
0,
params.targetCount - params.hydratedHistoricalMarkdownCount,
);
}
@@ -0,0 +1,129 @@
import { describe, expect, it } from "vitest";
import {
buildConversationMessageRenderWindowProjection,
filterVisibleConversationMessages,
resolveConversationMessageRenderWindowSettings,
resolveInitialConversationRenderedMessageCount,
} from "./messageRenderWindowProjection";
const settingsSet = {
regular: {
progressiveRenderThreshold: 4,
initialRenderCount: 3,
renderBatchSize: 2,
minimumDelayMs: 120,
},
restored: {
progressiveRenderThreshold: 2,
initialRenderCount: 1,
renderBatchSize: 1,
minimumDelayMs: 600,
},
};
function message(id: number, role: "user" | "assistant", content = "x") {
return {
id: `message-${id}`,
role,
content,
};
}
describe("messageRenderWindowProjection", () => {
it("应过滤空白 user 消息,但保留带图片的空白 user 消息", () => {
const visibleMessages = filterVisibleConversationMessages([
message(1, "user", " "),
{ ...message(2, "user", " "), images: ["image-a"] },
message(3, "assistant", ""),
]);
expect(visibleMessages.map((entry) => entry.id)).toEqual([
"message-2",
"message-3",
]);
});
it("普通会话超过阈值时应只投影尾部窗口,并允许后台自动补齐", () => {
const settings = resolveConversationMessageRenderWindowSettings(
settingsSet,
false,
);
const visibleMessages = Array.from({ length: 6 }, (_, index) =>
message(index, index % 2 === 0 ? "user" : "assistant"),
);
const renderedMessageCount = resolveInitialConversationRenderedMessageCount({
isSending: false,
visibleMessageCount: visibleMessages.length,
settings,
});
const projection = buildConversationMessageRenderWindowProjection({
visibleMessages,
renderedMessageCount,
isSending: false,
isRestoredHistoryWindow: false,
settings,
});
expect(projection.renderedMessageCount).toBe(3);
expect(projection.hiddenHistoryCount).toBe(3);
expect(projection.renderedMessages.map((entry) => entry.id)).toEqual([
"message-3",
"message-4",
"message-5",
]);
expect(projection.shouldAutoHydrateHiddenHistory).toBe(true);
});
it("旧会话窗口应使用更小首帧,并禁止自动补齐隐藏历史", () => {
const settings = resolveConversationMessageRenderWindowSettings(
settingsSet,
true,
);
const visibleMessages = Array.from({ length: 4 }, (_, index) =>
message(index, "assistant"),
);
const projection = buildConversationMessageRenderWindowProjection({
visibleMessages,
renderedMessageCount: resolveInitialConversationRenderedMessageCount({
isSending: false,
visibleMessageCount: visibleMessages.length,
settings,
}),
isSending: false,
isRestoredHistoryWindow: true,
settings,
});
expect(projection.renderedMessageCount).toBe(1);
expect(projection.hiddenHistoryCount).toBe(3);
expect(projection.renderedMessages.map((entry) => entry.id)).toEqual([
"message-3",
]);
expect(projection.shouldAutoHydrateHiddenHistory).toBe(false);
expect(projection.progressiveRenderMinimumDelayMs).toBe(600);
});
it("发送中不应开启历史窗口裁剪", () => {
const settings = resolveConversationMessageRenderWindowSettings(
settingsSet,
false,
);
const visibleMessages = Array.from({ length: 8 }, (_, index) =>
message(index, "assistant"),
);
const projection = buildConversationMessageRenderWindowProjection({
visibleMessages,
renderedMessageCount: 1,
isSending: true,
isRestoredHistoryWindow: false,
settings,
});
expect(projection.shouldUseProgressiveRender).toBe(false);
expect(projection.hiddenHistoryCount).toBe(0);
expect(projection.renderedMessages).toHaveLength(8);
});
});
@@ -0,0 +1,116 @@
export interface ConversationVisibleMessageLike {
role: string;
content: string;
images?: readonly unknown[];
}
export interface ConversationMessageRenderWindowSettings {
progressiveRenderThreshold: number;
initialRenderCount: number;
renderBatchSize: number;
minimumDelayMs: number;
}
export interface ConversationMessageRenderWindowSettingsSet {
regular: ConversationMessageRenderWindowSettings;
restored: ConversationMessageRenderWindowSettings;
}
export interface ConversationMessageRenderWindowProjection<T> {
visibleMessages: T[];
renderedMessages: T[];
renderedMessageCount: number;
hiddenHistoryCount: number;
shouldUseProgressiveRender: boolean;
shouldAutoHydrateHiddenHistory: boolean;
progressiveRenderThreshold: number;
progressiveInitialRenderCount: number;
progressiveRenderBatchSize: number;
progressiveRenderMinimumDelayMs: number;
}
export function filterVisibleConversationMessages<
T extends ConversationVisibleMessageLike,
>(messages: readonly T[]): T[] {
return messages.filter((message) => {
if (message.role !== "user") {
return true;
}
if (message.content.trim().length > 0) {
return true;
}
return Array.isArray(message.images) && message.images.length > 0;
});
}
export function resolveConversationMessageRenderWindowSettings(
settingsSet: ConversationMessageRenderWindowSettingsSet,
isRestoredHistoryWindow: boolean,
): ConversationMessageRenderWindowSettings {
return isRestoredHistoryWindow ? settingsSet.restored : settingsSet.regular;
}
export function shouldUseConversationProgressiveRender(params: {
isSending: boolean;
visibleMessageCount: number;
settings: ConversationMessageRenderWindowSettings;
}): boolean {
return (
!params.isSending &&
params.visibleMessageCount > params.settings.progressiveRenderThreshold
);
}
export function resolveInitialConversationRenderedMessageCount(params: {
isSending: boolean;
visibleMessageCount: number;
settings: ConversationMessageRenderWindowSettings;
}): number {
return shouldUseConversationProgressiveRender(params)
? Math.min(params.visibleMessageCount, params.settings.initialRenderCount)
: params.visibleMessageCount;
}
export function buildConversationMessageRenderWindowProjection<
T extends ConversationVisibleMessageLike,
>(params: {
visibleMessages: readonly T[];
renderedMessageCount: number;
isSending: boolean;
isRestoredHistoryWindow: boolean;
settings: ConversationMessageRenderWindowSettings;
}): ConversationMessageRenderWindowProjection<T> {
const visibleMessages = [...params.visibleMessages];
const shouldUseProgressiveRender = shouldUseConversationProgressiveRender({
isSending: params.isSending,
visibleMessageCount: visibleMessages.length,
settings: params.settings,
});
const renderedMessageCount = shouldUseProgressiveRender
? Math.min(
visibleMessages.length,
Math.max(0, params.renderedMessageCount),
)
: visibleMessages.length;
const hiddenHistoryCount = shouldUseProgressiveRender
? Math.max(0, visibleMessages.length - renderedMessageCount)
: 0;
const renderedMessages =
hiddenHistoryCount > 0
? visibleMessages.slice(-renderedMessageCount)
: visibleMessages;
return {
visibleMessages,
renderedMessages,
renderedMessageCount,
hiddenHistoryCount,
shouldUseProgressiveRender,
shouldAutoHydrateHiddenHistory:
shouldUseProgressiveRender && !params.isRestoredHistoryWindow,
progressiveRenderThreshold: params.settings.progressiveRenderThreshold,
progressiveInitialRenderCount: params.settings.initialRenderCount,
progressiveRenderBatchSize: params.settings.renderBatchSize,
progressiveRenderMinimumDelayMs: params.settings.minimumDelayMs,
};
}
@@ -0,0 +1,111 @@
import { describe, expect, it } from "vitest";
import type { AgentThreadItem, AgentThreadTurn, Message } from "../types";
import {
buildCurrentTurnTimelineProjection,
buildMessageGroupsProjection,
buildMessageRenderGroupsProjection,
buildTimelineByMessageIdProjection,
resolveLastAssistantMessage,
} from "./messageTimelineRenderProjection";
function message(id: string, role: "user" | "assistant"): Message {
return {
id,
role,
content: id,
timestamp: new Date(`2026-05-05T00:00:0${id.slice(-1)}.000Z`),
} as Message;
}
function turn(id: string): AgentThreadTurn {
return {
id,
status: "completed",
started_at: "2026-05-05T00:00:00.000Z",
} as AgentThreadTurn;
}
function item(id: string, turnId: string): AgentThreadItem {
return {
id,
turn_id: turnId,
type: "tool_call",
sequence: 1,
started_at: "2026-05-05T00:00:00.000Z",
} as AgentThreadItem;
}
describe("messageTimelineRenderProjection", () => {
it("不允许构建历史 timeline 时应返回空映射", () => {
expect(
buildTimelineByMessageIdProjection({
canBuildHistoricalTimeline: false,
renderedMessages: [message("message-1", "assistant")],
renderedTurns: [turn("turn-1")],
renderedThreadItems: [item("item-1", "turn-1")],
}).size,
).toBe(0);
});
it("应解析最后一条 assistant 消息", () => {
expect(
resolveLastAssistantMessage([
message("message-1", "assistant"),
message("message-2", "user"),
message("message-3", "assistant"),
])?.id,
).toBe("message-3");
});
it("当前 turn 未映射到消息时应挂到最后一条 assistant 消息", () => {
const projection = buildCurrentTurnTimelineProjection({
activeCurrentTurnId: "turn-current",
activeCurrentTurn: turn("turn-current"),
lastAssistantMessageId: "message-tail",
timelineByMessageId: new Map(),
renderedThreadItems: [
item("item-1", "turn-current"),
item("item-2", "turn-other"),
],
});
expect(projection).toMatchObject({
messageId: "message-tail",
turn: { id: "turn-current" },
items: [{ id: "item-1" }],
});
});
it("应为消息组补齐 timeline 与 active 标记", () => {
const messages = [
message("message-user", "user"),
message("message-assistant", "assistant"),
];
const groups = buildMessageGroupsProjection(messages);
const renderGroups = buildMessageRenderGroupsProjection({
messageGroups: groups,
timelineByMessageId: new Map([
[
"message-assistant",
{
messageId: "message-assistant",
turn: turn("turn-1"),
items: [item("item-1", "turn-1")],
},
],
]),
currentTurnTimeline: null,
lastAssistantMessageId: "message-assistant",
});
expect(renderGroups).toHaveLength(1);
expect(renderGroups[0]).toMatchObject({
lastAssistantId: "message-assistant",
isActiveGroup: true,
timeline: {
messageId: "message-assistant",
turn: { id: "turn-1" },
},
});
});
});
@@ -0,0 +1,123 @@
import type { AgentThreadItem, AgentThreadTurn, Message } from "../types";
import {
buildMessageTurnTimeline,
type MessageTurnTimeline,
} from "../utils/threadTimelineView";
import {
buildMessageTurnGroups,
type MessageTurnGroup,
} from "../utils/messageTurnGrouping";
export interface CurrentTurnTimelineProjection {
messageId: string;
turn: AgentThreadTurn;
items: AgentThreadItem[];
}
export interface MessageRenderGroupProjection extends MessageTurnGroup {
lastAssistantId: string | null;
timeline: MessageTurnTimeline | CurrentTurnTimelineProjection | null;
isActiveGroup: boolean;
}
export function buildTimelineByMessageIdProjection(params: {
canBuildHistoricalTimeline: boolean;
renderedMessages: Message[];
renderedTurns: AgentThreadTurn[];
renderedThreadItems: AgentThreadItem[];
}): Map<string, MessageTurnTimeline> {
if (!params.canBuildHistoricalTimeline) {
return new Map<string, MessageTurnTimeline>();
}
return buildMessageTurnTimeline(
params.renderedMessages,
params.renderedTurns,
params.renderedThreadItems,
);
}
export function resolveLastAssistantMessage(
renderedMessages: readonly Message[],
): Message | null {
for (let index = renderedMessages.length - 1; index >= 0; index -= 1) {
const message = renderedMessages[index];
if (message?.role === "assistant") {
return message;
}
}
return null;
}
export function buildCurrentTurnTimelineProjection(params: {
activeCurrentTurnId: string | null;
activeCurrentTurn: AgentThreadTurn | null;
lastAssistantMessageId: string | null;
timelineByMessageId: Map<string, MessageTurnTimeline>;
renderedThreadItems: AgentThreadItem[];
}): CurrentTurnTimelineProjection | null {
if (
!params.activeCurrentTurnId ||
!params.activeCurrentTurn ||
!params.lastAssistantMessageId
) {
return null;
}
let mappedMessageId: string | null = null;
for (const entry of params.timelineByMessageId.values()) {
if (entry.turn.id === params.activeCurrentTurnId) {
mappedMessageId = entry.messageId;
break;
}
}
return {
messageId: mappedMessageId || params.lastAssistantMessageId,
turn: params.activeCurrentTurn,
items: params.renderedThreadItems.filter(
(item) => item.turn_id === params.activeCurrentTurnId,
),
};
}
export function buildMessageRenderGroupsProjection(params: {
messageGroups: MessageTurnGroup[];
timelineByMessageId: Map<string, MessageTurnTimeline>;
currentTurnTimeline: CurrentTurnTimelineProjection | null;
lastAssistantMessageId: string | null;
}): MessageRenderGroupProjection[] {
return params.messageGroups.map((group) => {
const lastAssistantId =
group.assistantMessages[group.assistantMessages.length - 1]?.id ?? null;
let mappedTimeline: MessageTurnTimeline | null = null;
for (const message of group.assistantMessages) {
mappedTimeline = params.timelineByMessageId.get(message.id) ?? null;
if (mappedTimeline) {
break;
}
}
const isCurrentTurnGroup =
Boolean(lastAssistantId) &&
params.currentTurnTimeline?.messageId === lastAssistantId;
const isActiveGroup =
Boolean(lastAssistantId) &&
lastAssistantId === params.lastAssistantMessageId;
const timeline = isCurrentTurnGroup
? params.currentTurnTimeline
: mappedTimeline;
return {
...group,
lastAssistantId,
timeline,
isActiveGroup,
};
});
}
export function buildMessageGroupsProjection(
renderedMessages: Message[],
): MessageTurnGroup[] {
return buildMessageTurnGroups(renderedMessages);
}
@@ -0,0 +1,98 @@
import { describe, expect, it } from "vitest";
import {
buildConversationThreadTimelineWindowProjection,
filterConversationThreadItemsForRenderedTurns,
resolveConversationRenderedTurns,
} from "./threadTimelineWindowProjection";
function turn(id: string) {
return { id };
}
function item(id: string, turnId: string) {
return { id, turn_id: turnId };
}
describe("threadTimelineWindowProjection", () => {
it("无隐藏历史时应保留全部 turns 和 threadItems", () => {
const turns = [turn("turn-1"), turn("turn-2")];
const threadItems = [item("item-1", "turn-1"), item("item-2", "turn-2")];
const projection = buildConversationThreadTimelineWindowProjection({
turns,
threadItems,
hiddenHistoryCount: 0,
isRestoredHistoryWindow: false,
renderedAssistantMessageCount: 1,
renderedMessageCount: 2,
progressiveInitialRenderCount: 3,
shouldDeferThreadItemsScan: false,
});
expect(projection.renderedTurns).toEqual(turns);
expect(projection.renderedTurnIdSet).toBeNull();
expect(projection.renderedThreadItems).toEqual(threadItems);
});
it("旧会话应按可见 assistant 数量裁剪尾部 turns", () => {
const turns = [turn("turn-1"), turn("turn-2"), turn("turn-3")];
expect(
resolveConversationRenderedTurns({
turns,
hiddenHistoryCount: 5,
isRestoredHistoryWindow: true,
renderedAssistantMessageCount: 1,
renderedMessageCount: 1,
progressiveInitialRenderCount: 10,
}).map((entry) => entry.id),
).toEqual(["turn-2", "turn-3"]);
});
it("尾部窗口不含 currentTurnId 时应额外保留当前 turn", () => {
const turns = [
turn("turn-current"),
turn("turn-old"),
turn("turn-tail-1"),
turn("turn-tail-2"),
];
expect(
resolveConversationRenderedTurns({
turns,
currentTurnId: "turn-current",
hiddenHistoryCount: 4,
isRestoredHistoryWindow: true,
renderedAssistantMessageCount: 0,
renderedMessageCount: 1,
progressiveInitialRenderCount: 1,
}).map((entry) => entry.id),
).toEqual(["turn-current", "turn-tail-2"]);
});
it("应按 renderedTurnIdSet 精确裁剪 threadItems", () => {
const threadItems = [
item("item-1", "turn-1"),
item("item-2", "turn-2"),
item("item-3", "turn-3"),
];
expect(
filterConversationThreadItemsForRenderedTurns({
threadItems,
renderedTurnIdSet: new Set(["turn-2", "turn-3"]),
shouldDeferThreadItemsScan: false,
}).map((entry) => entry.id),
).toEqual(["item-2", "item-3"]);
});
it("延迟扫描时应返回空 threadItems", () => {
expect(
filterConversationThreadItemsForRenderedTurns({
threadItems: [item("item-1", "turn-1")],
renderedTurnIdSet: null,
shouldDeferThreadItemsScan: true,
}),
).toEqual([]);
});
});
@@ -0,0 +1,137 @@
export interface ConversationThreadTurnLike {
id: string;
}
export interface ConversationThreadItemLike {
turn_id: string;
}
export interface ConversationThreadTimelineWindowProjection<TTurn, TItem> {
renderedTurns: TTurn[];
renderedTurnIdSet: Set<string> | null;
renderedThreadItems: TItem[];
}
export function resolveConversationRenderedTurns<
TTurn extends ConversationThreadTurnLike,
>(params: {
turns: readonly TTurn[];
currentTurnId?: string | null;
hiddenHistoryCount: number;
isRestoredHistoryWindow: boolean;
renderedAssistantMessageCount: number;
renderedMessageCount: number;
progressiveInitialRenderCount: number;
}): TTurn[] {
const shouldWindowTurns =
params.hiddenHistoryCount > 0 || params.isRestoredHistoryWindow;
if (!shouldWindowTurns) {
return [...params.turns];
}
const restoredTurnWindowSize = Math.max(
1,
params.renderedAssistantMessageCount + 1,
);
const turnWindowSize = params.isRestoredHistoryWindow
? restoredTurnWindowSize
: Math.max(
params.renderedMessageCount,
params.progressiveInitialRenderCount,
);
const tailTurns =
turnWindowSize > 0
? params.turns.slice(-Math.min(params.turns.length, turnWindowSize))
: [];
if (
!params.currentTurnId ||
tailTurns.some((turn) => turn.id === params.currentTurnId)
) {
return tailTurns;
}
const selectedTurnIds = new Set(tailTurns.map((turn) => turn.id));
selectedTurnIds.add(params.currentTurnId);
return params.turns.filter((turn) => selectedTurnIds.has(turn.id));
}
export function resolveConversationRenderedTurnIdSet<
TTurn extends ConversationThreadTurnLike,
>(params: {
renderedTurns: readonly TTurn[];
hiddenHistoryCount: number;
isRestoredHistoryWindow: boolean;
}): Set<string> | null {
if (params.hiddenHistoryCount <= 0 && !params.isRestoredHistoryWindow) {
return null;
}
return new Set(params.renderedTurns.map((turn) => turn.id));
}
export function filterConversationThreadItemsForRenderedTurns<
TItem extends ConversationThreadItemLike,
>(params: {
threadItems: readonly TItem[];
renderedTurnIdSet: Set<string> | null;
shouldDeferThreadItemsScan: boolean;
}): TItem[] {
if (params.shouldDeferThreadItemsScan) {
return [];
}
if (!params.renderedTurnIdSet) {
return [...params.threadItems];
}
if (params.renderedTurnIdSet.size === 0) {
return [];
}
const scopedItems: TItem[] = [];
for (const item of params.threadItems) {
if (params.renderedTurnIdSet.has(item.turn_id)) {
scopedItems.push(item);
}
}
return scopedItems;
}
export function buildConversationThreadTimelineWindowProjection<
TTurn extends ConversationThreadTurnLike,
TItem extends ConversationThreadItemLike,
>(params: {
turns: readonly TTurn[];
threadItems: readonly TItem[];
currentTurnId?: string | null;
hiddenHistoryCount: number;
isRestoredHistoryWindow: boolean;
renderedAssistantMessageCount: number;
renderedMessageCount: number;
progressiveInitialRenderCount: number;
shouldDeferThreadItemsScan: boolean;
}): ConversationThreadTimelineWindowProjection<TTurn, TItem> {
const renderedTurns = resolveConversationRenderedTurns({
turns: params.turns,
currentTurnId: params.currentTurnId,
hiddenHistoryCount: params.hiddenHistoryCount,
isRestoredHistoryWindow: params.isRestoredHistoryWindow,
renderedAssistantMessageCount: params.renderedAssistantMessageCount,
renderedMessageCount: params.renderedMessageCount,
progressiveInitialRenderCount: params.progressiveInitialRenderCount,
});
const renderedTurnIdSet = resolveConversationRenderedTurnIdSet({
renderedTurns,
hiddenHistoryCount: params.hiddenHistoryCount,
isRestoredHistoryWindow: params.isRestoredHistoryWindow,
});
const renderedThreadItems = filterConversationThreadItemsForRenderedTurns({
threadItems: params.threadItems,
renderedTurnIdSet,
shouldDeferThreadItemsScan: params.shouldDeferThreadItemsScan,
});
return {
renderedTurns,
renderedTurnIdSet,
renderedThreadItems,
};
}
@@ -0,0 +1,221 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { AutoContinueRequestPayload } from "@/lib/api/agentRuntime";
import { listKnowledgePacks, type KnowledgePackSummary } from "@/lib/api/knowledge";
import {
buildKnowledgeOrganizePrompt,
normalizeKnowledgeDraftName,
} from "@/features/knowledge/agent/knowledgePromptBuilder";
import { buildKnowledgeBuilderMetadata } from "@/features/knowledge/agent/knowledgeMetadata";
import type {
InputbarKnowledgePackOption,
InputbarKnowledgePackSelection,
} from "../../components/Inputbar/types";
import type { HandleSendOptions } from "../../hooks/handleSendTypes";
import type { MessageImage } from "../../types";
type KnowledgeExecutionStrategy = "react" | "code_orchestrated" | "auto";
type WorkspaceKnowledgeHandleSend = (
images?: MessageImage[],
webSearch?: boolean,
thinking?: boolean,
textOverride?: string,
executionStrategy?: KnowledgeExecutionStrategy,
autoContinuePayload?: AutoContinueRequestPayload,
sendOptions?: HandleSendOptions,
) => void | Promise<boolean> | boolean;
interface UseWorkspaceKnowledgeRuntimeParams {
projectRootPath?: string | null;
currentSessionTitle?: string | null;
input: string;
setInput: (value: string) => void;
executionStrategy?: KnowledgeExecutionStrategy;
handleSend: WorkspaceKnowledgeHandleSend;
onOpenKnowledgeManagement?: (workingDir?: string | null) => void;
}
interface UseWorkspaceKnowledgeRuntimeResult {
knowledgePackSelection: InputbarKnowledgePackSelection | null;
knowledgePackOptions: InputbarKnowledgePackOption[];
onToggleKnowledgePack: (enabled: boolean) => void;
onSelectKnowledgePack: (packName: string) => void;
onStartKnowledgeOrganize: () => void;
onManageKnowledgePacks?: () => void;
}
export function useWorkspaceKnowledgeRuntime({
projectRootPath,
currentSessionTitle,
input,
setInput,
executionStrategy,
handleSend,
onOpenKnowledgeManagement,
}: UseWorkspaceKnowledgeRuntimeParams): UseWorkspaceKnowledgeRuntimeResult {
const [knowledgePacks, setKnowledgePacks] = useState<KnowledgePackSummary[]>(
[],
);
const [selectedKnowledgePackName, setSelectedKnowledgePackName] = useState<
string | null
>(null);
const [knowledgePackEnabled, setKnowledgePackEnabled] = useState(false);
useEffect(() => {
const workingDir = projectRootPath?.trim();
if (!workingDir) {
setKnowledgePacks([]);
setSelectedKnowledgePackName(null);
setKnowledgePackEnabled(false);
return;
}
let cancelled = false;
listKnowledgePacks({ workingDir, includeArchived: false })
.then((response) => {
if (cancelled) {
return;
}
const nextDefaultPack =
response.packs.find((pack) => pack.defaultForWorkspace) ??
response.packs[0] ??
null;
setKnowledgePacks(response.packs);
setSelectedKnowledgePackName((current) => {
if (
current &&
response.packs.some((pack) => pack.metadata.name === current)
) {
return current;
}
return nextDefaultPack?.metadata.name ?? null;
});
setKnowledgePackEnabled(false);
})
.catch((error) => {
if (cancelled) {
return;
}
console.warn("[AgentChatPage] 读取项目资料失败:", error);
setKnowledgePacks([]);
setSelectedKnowledgePackName(null);
setKnowledgePackEnabled(false);
});
return () => {
cancelled = true;
};
}, [projectRootPath]);
const selectedKnowledgePack = useMemo(() => {
if (knowledgePacks.length === 0) {
return null;
}
return (
knowledgePacks.find(
(pack) => pack.metadata.name === selectedKnowledgePackName,
) ??
knowledgePacks.find((pack) => pack.defaultForWorkspace) ??
knowledgePacks[0] ??
null
);
}, [knowledgePacks, selectedKnowledgePackName]);
const knowledgePackOptions = useMemo(
() =>
knowledgePacks.map((pack) => ({
packName: pack.metadata.name,
label: pack.metadata.description || pack.metadata.name,
status: pack.metadata.status,
defaultForWorkspace: pack.defaultForWorkspace,
})),
[knowledgePacks],
);
const knowledgePackSelection = useMemo(
() =>
selectedKnowledgePack && projectRootPath
? {
enabled: knowledgePackEnabled,
packName: selectedKnowledgePack.metadata.name,
workingDir: projectRootPath,
label:
selectedKnowledgePack.metadata.description ||
selectedKnowledgePack.metadata.name,
status: selectedKnowledgePack.metadata.status,
}
: null,
[selectedKnowledgePack, knowledgePackEnabled, projectRootPath],
);
const handleSelectKnowledgePack = useCallback((packName: string) => {
setSelectedKnowledgePackName(packName);
}, []);
const handleStartKnowledgeOrganize = useCallback(() => {
const workingDir = projectRootPath?.trim();
if (!workingDir) {
setInput("请先选择一个项目,然后我会把资料整理成当前项目可复用的项目资料。");
return;
}
const trimmedInput = input.trim();
const packName = normalizeKnowledgeDraftName(
selectedKnowledgePack?.metadata.name ||
selectedKnowledgePack?.metadata.description ||
currentSessionTitle ||
"project-material",
);
const prompt = buildKnowledgeOrganizePrompt(trimmedInput);
const requestMetadata = buildKnowledgeBuilderMetadata({
workingDir,
packName,
source: "inputbar",
});
if (!trimmedInput) {
setInput(prompt);
return;
}
void handleSend(
undefined,
false,
false,
prompt,
executionStrategy || "react",
undefined,
{
requestMetadata,
displayContent: prompt,
},
);
}, [
currentSessionTitle,
executionStrategy,
handleSend,
input,
projectRootPath,
selectedKnowledgePack,
setInput,
]);
const handleManageKnowledgePacks = useMemo(
() =>
onOpenKnowledgeManagement
? () => onOpenKnowledgeManagement(projectRootPath)
: undefined,
[onOpenKnowledgeManagement, projectRootPath],
);
return {
knowledgePackSelection,
knowledgePackOptions,
onToggleKnowledgePack: setKnowledgePackEnabled,
onSelectKnowledgePack: handleSelectKnowledgePack,
onStartKnowledgeOrganize: handleStartKnowledgeOrganize,
onManageKnowledgePacks: handleManageKnowledgePacks,
};
}
@@ -1,8 +1,6 @@
import {
useCallback,
useEffect,
useMemo,
useState,
type ComponentProps,
type Dispatch,
type ReactNode,
@@ -32,10 +30,7 @@ import { GeneralWorkbenchDialogSection } from "./WorkspaceHarnessDialogs";
import type { TeamWorkbenchSurfaceProps } from "./chatSurfaceProps";
import type { GeneralWorkbenchEntryPromptState } from "./workspaceSendHelpers";
import type { CuratedTaskReferenceEntry } from "../utils/curatedTaskReferenceSelection";
import {
listKnowledgePacks,
type KnowledgePackSummary,
} from "@/lib/api/knowledge";
import { useWorkspaceKnowledgeRuntime } from "./knowledge/useWorkspaceKnowledgeRuntime";
interface GeneralWorkbenchEntryPromptAccessoryProps {
prompt: GeneralWorkbenchEntryPromptState;
@@ -130,40 +125,6 @@ const GeneralWorkbenchEntryPromptButton = styled.button<{
}
`;
const KNOWLEDGE_BUILDER_SKILL_NAME = "knowledge_builder";
function normalizeKnowledgeDraftName(value: string): string {
return (
value
.trim()
.toLowerCase()
.replace(/[^a-z0-9-]+/g, "-")
.replace(/-{2,}/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 64) || "project-material"
);
}
function buildKnowledgeOrganizePrompt(sourceText: string): string {
const trimmed = sourceText.trim();
const lines = [
"请把这些内容整理成当前项目可复用的项目资料。",
"",
"整理目标:",
"1. 提炼已确认事实、适用场景、表达风格和不能编造的边界。",
"2. 标出缺失信息、冲突内容和需要人工确认的风险提醒。",
"3. 生成一份可供我检查确认的项目资料草稿。",
];
if (trimmed) {
lines.push("", "待整理资料:", trimmed);
} else {
lines.push("", "我接下来会补充资料,请先告诉我需要提供哪些内容。");
}
return lines.join("\n");
}
function renderGeneralWorkbenchEntryPromptAccessory({
prompt,
onRestart,
@@ -528,7 +489,9 @@ interface UseWorkspaceInputbarSceneRuntimeParams {
activeTheme: InputbarParams["activeTheme"];
navigationActions: Pick<
NavigationActions,
"handleManageProviders" | "handleOpenRuntimeMemoryWorkbench"
| "handleManageProviders"
| "handleOpenRuntimeMemoryWorkbench"
| "handleOpenKnowledgeManagement"
>;
selectedTeam: InputbarParams["selectedTeam"];
handleSelectTeam: InputbarParams["onSelectTeam"];
@@ -708,153 +671,17 @@ export function useWorkspaceInputbarSceneRuntime({
inputCompletionEnabled = true,
}: UseWorkspaceInputbarSceneRuntimeParams) {
const resolvedQueuedTurns = useMemo(() => queuedTurns ?? [], [queuedTurns]);
const [knowledgePacks, setKnowledgePacks] = useState<KnowledgePackSummary[]>(
[],
);
const [selectedKnowledgePackName, setSelectedKnowledgePackName] = useState<
string | null
>(null);
const [knowledgePackEnabled, setKnowledgePackEnabled] = useState(false);
const resolvedChatToolPreferences =
chatToolPreferences ?? DEFAULT_CHAT_TOOL_PREFERENCES;
useEffect(() => {
const workingDir = projectRootPath?.trim();
if (!workingDir) {
setKnowledgePacks([]);
setSelectedKnowledgePackName(null);
setKnowledgePackEnabled(false);
return;
}
let cancelled = false;
listKnowledgePacks({ workingDir, includeArchived: false })
.then((response) => {
if (cancelled) {
return;
}
const nextDefaultPack =
response.packs.find((pack) => pack.defaultForWorkspace) ??
response.packs[0] ??
null;
setKnowledgePacks(response.packs);
setSelectedKnowledgePackName((current) => {
if (
current &&
response.packs.some((pack) => pack.metadata.name === current)
) {
return current;
}
return nextDefaultPack?.metadata.name ?? null;
});
setKnowledgePackEnabled(false);
})
.catch((error) => {
if (cancelled) {
return;
}
console.warn("[AgentChatPage] 读取默认知识包失败:", error);
setKnowledgePacks([]);
setSelectedKnowledgePackName(null);
setKnowledgePackEnabled(false);
});
return () => {
cancelled = true;
};
}, [projectRootPath]);
const selectedKnowledgePack = useMemo(() => {
if (knowledgePacks.length === 0) {
return null;
}
return (
knowledgePacks.find(
(pack) => pack.metadata.name === selectedKnowledgePackName,
) ??
knowledgePacks.find((pack) => pack.defaultForWorkspace) ??
knowledgePacks[0] ??
null
);
}, [knowledgePacks, selectedKnowledgePackName]);
const inputbarKnowledgePackOptions = useMemo(
() =>
knowledgePacks.map((pack) => ({
packName: pack.metadata.name,
label: pack.metadata.description || pack.metadata.name,
status: pack.metadata.status,
defaultForWorkspace: pack.defaultForWorkspace,
})),
[knowledgePacks],
);
const inputbarKnowledgePackSelection = useMemo(
() =>
selectedKnowledgePack && projectRootPath
? {
enabled: knowledgePackEnabled,
packName: selectedKnowledgePack.metadata.name,
workingDir: projectRootPath,
label:
selectedKnowledgePack.metadata.description ||
selectedKnowledgePack.metadata.name,
status: selectedKnowledgePack.metadata.status,
}
: null,
[selectedKnowledgePack, knowledgePackEnabled, projectRootPath],
);
const handleSelectKnowledgePack = useCallback((packName: string) => {
setSelectedKnowledgePackName(packName);
}, []);
const handleStartKnowledgeOrganize = useCallback(() => {
const workingDir = projectRootPath?.trim();
if (!workingDir) {
setInput("请先选择一个项目,然后我会把资料整理成当前项目可复用的项目资料。");
return;
}
const trimmedInput = input.trim();
const packName = normalizeKnowledgeDraftName(
selectedKnowledgePack?.metadata.name ||
selectedKnowledgePack?.metadata.description ||
currentSessionTitle ||
"project-material",
);
const prompt = buildKnowledgeOrganizePrompt(trimmedInput);
const requestMetadata = {
knowledge_builder: {
skill_name: KNOWLEDGE_BUILDER_SKILL_NAME,
pack_name: packName,
working_dir: workingDir,
source: "inputbar",
},
};
if (!trimmedInput) {
setInput(prompt);
return;
}
void handleSend(
undefined,
false,
false,
prompt,
executionStrategy || "react",
undefined,
{
requestMetadata,
displayContent: prompt,
},
);
}, [
const knowledgeRuntime = useWorkspaceKnowledgeRuntime({
projectRootPath,
currentSessionTitle,
input,
setInput,
executionStrategy,
handleSend,
input,
projectRootPath,
selectedKnowledgePack,
setInput,
]);
onOpenKnowledgeManagement: navigationActions.handleOpenKnowledgeManagement,
});
const resolvedChatToolPreferences =
chatToolPreferences ?? DEFAULT_CHAT_TOOL_PREFERENCES;
const runtimeToolAvailability = useMemo(
() => deriveRuntimeToolAvailability(toolInventory),
[toolInventory],
@@ -930,11 +757,12 @@ export function useWorkspaceInputbarSceneRuntime({
onSend: handleSend,
onStop: handleStopSending,
isLoading: isSending || resolvedQueuedTurns.length > 0,
knowledgePackSelection: inputbarKnowledgePackSelection,
knowledgePackOptions: inputbarKnowledgePackOptions,
onToggleKnowledgePack: setKnowledgePackEnabled,
onSelectKnowledgePack: handleSelectKnowledgePack,
onStartKnowledgeOrganize: handleStartKnowledgeOrganize,
knowledgePackSelection: knowledgeRuntime.knowledgePackSelection,
knowledgePackOptions: knowledgeRuntime.knowledgePackOptions,
onToggleKnowledgePack: knowledgeRuntime.onToggleKnowledgePack,
onSelectKnowledgePack: knowledgeRuntime.onSelectKnowledgePack,
onStartKnowledgeOrganize: knowledgeRuntime.onStartKnowledgeOrganize,
onManageKnowledgePacks: knowledgeRuntime.onManageKnowledgePacks,
providerType,
setProviderType,
model,
@@ -112,6 +112,20 @@ export function useWorkspaceNavigationActions({
[onNavigate],
);
const handleOpenKnowledgeManagement = useCallback(
(workingDir?: string | null) => {
onNavigate?.(
"knowledge",
workingDir?.trim()
? {
workingDir: workingDir.trim(),
}
: undefined,
);
},
[onNavigate],
);
const handleOpenChannels = useCallback(() => {
onNavigate?.("channels");
}, [onNavigate]);
@@ -151,6 +165,7 @@ export function useWorkspaceNavigationActions({
handleManageProviders,
handleOpenChannels,
handleOpenChromeRelay,
handleOpenKnowledgeManagement,
handleOpenOpenClaw,
handleOpenRuntimeMemoryWorkbench,
handleOpenAppearanceSettings,
@@ -1,6 +1,5 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
persistVoiceModelSettingsFocusRequest,
@@ -236,6 +235,10 @@ const originalScrollIntoViewDescriptor = Object.getOwnPropertyDescriptor(
Element.prototype,
"scrollIntoView",
);
const originalHtmlScrollIntoViewDescriptor = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
"scrollIntoView",
);
function renderComponent(): HTMLDivElement {
const container = document.createElement("div");
@@ -256,6 +259,17 @@ async function flushEffects(times = 4) {
});
}
async function waitForScrollIntoView() {
for (let attempt = 0; attempt < 20; attempt += 1) {
await flushEffects(2);
if (scrollIntoViewMock.mock.calls.length > 0) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 0));
}
throw new Error("等待语音模型区块滚动聚焦超时");
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
@@ -323,6 +337,11 @@ beforeEach(() => {
writable: true,
value: scrollIntoViewMock,
});
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
configurable: true,
writable: true,
value: scrollIntoViewMock,
});
vi.stubGlobal(
"requestAnimationFrame",
(callback: (timestamp: number) => void) => {
@@ -470,6 +489,15 @@ afterEach(() => {
} else {
delete (Element.prototype as Partial<Element>).scrollIntoView;
}
if (originalHtmlScrollIntoViewDescriptor) {
Object.defineProperty(
HTMLElement.prototype,
"scrollIntoView",
originalHtmlScrollIntoViewDescriptor,
);
} else {
delete (HTMLElement.prototype as Partial<HTMLElement>).scrollIntoView;
}
vi.unstubAllGlobals();
});
@@ -546,11 +574,10 @@ describe("VoiceSettings", () => {
`#${VOICE_MODEL_SETTINGS_SECTION_ID}`,
);
expect(voiceModelSection).toBeInstanceOf(HTMLElement);
await waitFor(() => {
expect(scrollIntoViewMock).toHaveBeenCalledWith({
block: "start",
behavior: "smooth",
});
await waitForScrollIntoView();
expect(scrollIntoViewMock).toHaveBeenCalledWith({
block: "start",
behavior: "smooth",
});
expect(
window.sessionStorage.getItem(VOICE_MODEL_SETTINGS_FOCUS_STORAGE_KEY),
@@ -453,11 +453,32 @@ export function VoiceSettings() {
return;
}
window.requestAnimationFrame(() => {
const focusVoiceModelSection = () => {
const target = document.getElementById(VOICE_MODEL_SETTINGS_SECTION_ID);
target?.scrollIntoView?.({ block: "start", behavior: "smooth" });
target?.focus?.({ preventScroll: true });
});
if (!target) {
return false;
}
target.scrollIntoView?.({ block: "start", behavior: "smooth" });
target.focus?.({ preventScroll: true });
return true;
};
if (focusVoiceModelSection()) {
return;
}
let attempts = 0;
const retryFocusVoiceModelSection = () => {
if (focusVoiceModelSection()) {
return;
}
if (attempts < 5) {
attempts += 1;
window.setTimeout(retryFocusVoiceModelSection, 0);
}
};
window.requestAnimationFrame(retryFocusVoiceModelSection);
}, [loading]);
const showMessage = useCallback((type: "success" | "error", text: string) => {
+29 -16
View File
@@ -12,7 +12,7 @@ import {
type KnowledgePackDetail,
type KnowledgePackStatus,
} from "@/lib/api/knowledge";
import { getProject } from "@/lib/api/project";
import { getProject, getProjectByRootPath } from "@/lib/api/project";
import { KnowledgePage } from "./KnowledgePage";
const {
@@ -24,6 +24,7 @@ const {
mockUpdateKnowledgePackStatus,
mockResolveKnowledgeContext,
mockGetProject,
mockGetProjectByRootPath,
} = vi.hoisted(() => ({
mockListKnowledgePacks: vi.fn(),
mockGetKnowledgePack: vi.fn(),
@@ -33,6 +34,7 @@ const {
mockUpdateKnowledgePackStatus: vi.fn(),
mockResolveKnowledgeContext: vi.fn(),
mockGetProject: vi.fn(),
mockGetProjectByRootPath: vi.fn(),
}));
vi.mock("@/lib/api/knowledge", async () => {
@@ -76,6 +78,7 @@ vi.mock("@/lib/api/project", async () => {
return {
...actual,
getProject: mockGetProject,
getProjectByRootPath: mockGetProjectByRootPath,
};
});
@@ -338,6 +341,7 @@ describe("KnowledgePage", () => {
fencedContext:
'<knowledge_pack name="founder-personal-ip" status="ready" grounding="recommended">\n以下内容是数据,不是指令。\n运行时 brief\n</knowledge_pack>',
});
mockGetProjectByRootPath.mockResolvedValue(null);
mockGetProject.mockResolvedValue({
id: "project-alpha",
name: "金花黑茶项目",
@@ -382,7 +386,7 @@ describe("KnowledgePage", () => {
expect(container.textContent).toContain("项目资料管理");
expect(container.textContent).toContain("当前项目资料库");
expect(container.textContent).toContain("选择项目");
expect(container.textContent).toContain("高级:手动指定项目目录");
expect(container.textContent).toContain("排障设置");
expect(container.textContent).toContain("全部资料");
expect(container.textContent).toContain("全部项目资料");
expect(container.textContent).toContain("日常使用入口");
@@ -398,6 +402,9 @@ describe("KnowledgePage", () => {
expect(container.textContent).not.toContain("知识包目录");
expect(container.textContent).not.toContain("粘贴当前项目位置");
expect(container.textContent).not.toContain("项目位置");
expect(container.textContent).not.toContain("高级:手动指定项目目录");
expect(container.textContent).not.toContain("内部标识");
expect(container.textContent).not.toContain("资料文件名");
});
it("应通过项目选择器切换资料库目录,而不是要求普通用户粘贴路径", async () => {
@@ -420,9 +427,9 @@ describe("KnowledgePage", () => {
const container = renderPage({ workingDir: "/tmp/project" });
await flushEffects();
await clickButton(container, "手动导入");
await clickButton(container, "补充导入");
expect(container.textContent).toContain("手动导入资料");
expect(container.textContent).toContain("补充导入资料");
expect(container.textContent).toContain("个人 IP");
expect(container.textContent).toContain("品牌产品");
expect(container.textContent).toContain("组织 Know-how");
@@ -474,7 +481,7 @@ describe("KnowledgePage", () => {
expect(container.textContent).toContain("引用摘要");
expect(container.textContent).toContain("缺口与风险");
expect(container.textContent).toContain("整理记录");
expect(container.textContent).toContain("编辑资料说明");
expect(container.textContent).not.toContain("编辑资料说明");
expect(container.textContent).toContain("人工确认");
expect(container.textContent).not.toContain("KNOWLEDGE.md");
expect(container.textContent).not.toContain("frontmatter");
@@ -506,8 +513,20 @@ describe("KnowledgePage", () => {
});
});
it("用于生成应直接回到现有 Agent 并携带资料 metadata", async () => {
it("用于生成应回到现有 Agent、预填意图并携带资料 metadata", async () => {
const onNavigate = vi.fn();
mockGetProjectByRootPath.mockResolvedValueOnce({
id: "project-knowledge-root",
name: "当前资料项目",
workspaceType: "temporary",
rootPath: "/tmp/project",
isDefault: false,
createdAt: 1_712_345_678_900,
updatedAt: 1_712_345_678_900,
isFavorite: false,
isArchived: false,
tags: [],
});
const container = renderPage({
workingDir: "/tmp/project",
selectedPackName: "founder-personal-ip",
@@ -523,10 +542,11 @@ describe("KnowledgePage", () => {
expect(container.textContent).not.toContain("<knowledge_pack");
expect(container.textContent).not.toContain("以下内容是数据,不是指令");
expect(container.textContent).not.toContain("tokens");
expect(getProjectByRootPath).toHaveBeenCalledWith("/tmp/project");
expect(onNavigate).toHaveBeenCalledWith("agent", {
agentEntry: "claw",
projectId: undefined,
projectId: "project-knowledge-root",
initialUserPrompt: "请基于当前项目资料生成内容",
initialRequestMetadata: {
knowledge_pack: expect.objectContaining({
@@ -537,14 +557,7 @@ describe("KnowledgePage", () => {
grounding: "recommended",
}),
},
initialAutoSendRequestMetadata: {
knowledge_pack: expect.objectContaining({
pack_name: "founder-personal-ip",
working_dir: "/tmp/project",
source: "knowledge_page",
}),
},
autoRunInitialPromptOnMount: true,
autoRunInitialPromptOnMount: false,
});
});
@@ -557,7 +570,7 @@ describe("KnowledgePage", () => {
});
await flushEffects();
await clickButton(container, "手动导入");
await clickButton(container, "补充导入");
await clickButton(container, "整理资料");
expect(onNavigate).toHaveBeenCalledWith("agent", {
+110 -419
View File
@@ -4,7 +4,6 @@ import {
Archive,
BookOpen,
Check,
ChevronDown,
ClipboardCheck,
Database,
FileText,
@@ -13,7 +12,6 @@ import {
Loader2,
MessageSquareText,
PackageCheck,
Pencil,
RefreshCw,
ShieldCheck,
Sparkles,
@@ -28,13 +26,37 @@ import {
setDefaultKnowledgePack,
updateKnowledgePackStatus,
type KnowledgePackDetail,
type KnowledgePackFileEntry,
type KnowledgePackStatus,
type KnowledgePackSummary,
} from "@/lib/api/knowledge";
import { getProject } from "@/lib/api/project";
import { getProject, getProjectByRootPath } from "@/lib/api/project";
import type { KnowledgePageParams, Page, PageParams } from "@/types/page";
import { cn } from "@/lib/utils";
import {
DETAIL_TABS,
PACK_TYPES,
VIEW_TABS,
getPackTypeLabel,
resolveStatusLabel,
type DetailTab,
type KnowledgeView,
} from "./domain/knowledgeLabels";
import {
buildPackMetrics,
getErrorMessage,
getPackTitle,
normalizePackNameInput,
} from "./domain/knowledgeVisibility";
import { buildKnowledgeBuilderPrompt } from "./agent/knowledgePromptBuilder";
import {
buildKnowledgeBuilderMetadata,
buildKnowledgeRequestMetadata,
} from "./agent/knowledgeMetadata";
import { FileEntryList } from "./components/FileEntryList";
import { KnowledgePackCard } from "./components/KnowledgePackCard";
import { KnowledgeStatusRail } from "./components/KnowledgeStatusRail";
import { KnowledgeTroubleshootingPanel } from "./components/KnowledgeTroubleshootingPanel";
import { StatusPill } from "./components/StatusPill";
interface KnowledgePageProps {
onNavigate?: (page: Page, pageParams?: PageParams) => void;
@@ -42,79 +64,10 @@ interface KnowledgePageProps {
}
type AsyncStatus = "idle" | "loading" | "ready" | "error";
type KnowledgeView = "overview" | "import" | "detail";
type DetailTab =
| "overview"
| "content"
| "sources"
| "runtime"
| "risks"
| "runs";
const WORKING_DIR_STORAGE_KEY = "lime.knowledge.working-dir";
const DEFAULT_PACK_NAME = "founder-personal-ip";
const DEFAULT_SOURCE_FILE_NAME = "source.md";
const KNOWLEDGE_BUILDER_SKILL_NAME = "knowledge_builder";
const STATUS_LABELS: Record<string, string> = {
draft: "草稿",
ready: "已确认",
"needs-review": "待确认",
stale: "可能过期",
disputed: "有争议",
archived: "已归档",
};
const STATUS_CLASS_NAMES: Record<string, string> = {
draft: "border-slate-200 bg-slate-50 text-slate-600",
ready: "border-emerald-200 bg-emerald-50 text-emerald-700",
"needs-review": "border-amber-200 bg-amber-50 text-amber-700",
stale: "border-amber-200 bg-amber-50 text-amber-700",
disputed: "border-rose-200 bg-rose-50 text-rose-700",
archived: "border-slate-200 bg-slate-100 text-slate-500",
};
const PACK_TYPES = [
{
value: "personal-ip",
label: "个人 IP",
description: "创始人介绍、故事素材、表达风格和商务话术。",
},
{
value: "brand-product",
label: "品牌产品",
description: "品牌定位、产品事实、功效边界和客服口径。",
},
{
value: "organization-knowhow",
label: "组织 Know-how",
description: "团队 SOP、交付方法、升级路径和不可回答边界。",
},
{
value: "growth-strategy",
label: "增长策略",
description: "渠道策略、投放假设、转化漏斗和复盘结论。",
},
];
const VIEW_TABS: Array<{
id: KnowledgeView;
label: string;
description: string;
}> = [
{ id: "overview", label: "全部资料", description: "管理和确认" },
{ id: "import", label: "手动导入", description: "补充更多资料" },
{ id: "detail", label: "资料详情", description: "内容和风险" },
];
const DETAIL_TABS: Array<{ id: DetailTab; label: string }> = [
{ id: "overview", label: "概览" },
{ id: "content", label: "内容" },
{ id: "sources", label: "原始资料" },
{ id: "runtime", label: "引用摘要" },
{ id: "risks", label: "缺口与风险" },
{ id: "runs", label: "整理记录" },
];
function readStoredWorkingDir(): string {
if (typeof window === "undefined") {
@@ -136,199 +89,14 @@ function persistWorkingDir(value: string): void {
}
}
function getErrorMessage(error: unknown, fallback: string): string {
if (error instanceof Error && error.message.trim()) {
return error.message;
}
if (typeof error === "string" && error.trim()) {
return error;
}
return fallback;
}
function formatCount(value: number, unit: string): string {
return `${value.toLocaleString("zh-CN")} ${unit}`;
}
function resolveStatusLabel(status: KnowledgePackStatus): string {
return STATUS_LABELS[status] ?? status;
}
function resolveStatusClassName(status: KnowledgePackStatus): string {
return STATUS_CLASS_NAMES[status] ?? STATUS_CLASS_NAMES.draft;
}
function normalizePackNameInput(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9-]+/g, "-")
.replace(/-{2,}/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 64);
}
function formatPathPreview(value: string): string {
const normalized = value.trim();
if (!normalized) {
return "未选择项目";
}
const segments = normalized.split(/[\\/]+/).filter(Boolean);
if (segments.length <= 2) {
return normalized;
}
return `.../${segments.slice(-2).join("/")}`;
}
function getPackTitle(
pack: KnowledgePackSummary | KnowledgePackDetail,
): string {
return pack.metadata.description || pack.metadata.name;
}
function getPackTypeLabel(value?: string | null): string {
return (
PACK_TYPES.find((type) => type.value === value)?.label ?? value ?? "自定义"
);
}
function buildPackMetrics(pack: KnowledgePackSummary | KnowledgePackDetail) {
return [
{ label: "原始资料", value: pack.sourceCount, caption: "已导入" },
{ label: "整理内容", value: pack.wikiCount, caption: "已生成" },
{ label: "引用摘要", value: pack.compiledCount, caption: "可用于生成" },
{ label: "整理记录", value: pack.runCount, caption: "最近处理" },
];
}
function buildEntryDisplayLabel(title: string, entry: KnowledgePackFileEntry) {
const basename = entry.relativePath.split("/").filter(Boolean).pop();
if (title.includes("原始") || title.includes("来源")) {
return basename || "资料";
}
if (title.includes("整理记录")) {
return "整理记录";
}
if (title.includes("引用")) {
return "引用摘要";
}
return basename?.replace(/\.(md|txt|json)$/i, "") || "内容";
}
function buildKnowledgeBuilderPrompt(params: {
workingDir: string;
packName: string;
packType?: string;
description?: string;
}) {
const lines = [
"请整理这个资料包,生成可审阅的项目资料草稿。",
"",
`资料包:${params.packName}`,
];
if (params.packType?.trim()) {
lines.push(`类型:${params.packType.trim()}`);
}
if (params.description?.trim()) {
lines.push(`说明:${params.description.trim()}`);
}
lines.push(
"",
"请只基于已导入资料提炼事实、适用场景、表达边界和待补充内容。",
"缺失内容请标为待补充,不要编造;完成后给出可供人工确认的摘要。",
);
return lines.join("\n");
}
function buildKnowledgeRequestMetadata(params: {
workingDir: string;
packName: string;
pack?: KnowledgePackSummary | KnowledgePackDetail | null;
}) {
return {
knowledge_pack: {
pack_name: params.packName,
working_dir: params.workingDir,
source: "knowledge_page",
status: params.pack?.metadata.status,
grounding: params.pack?.metadata.grounding ?? "recommended",
},
};
}
function StatusPill({ status }: { status: KnowledgePackStatus }) {
return (
<span
className={cn(
"inline-flex rounded-full border px-2.5 py-1 text-xs font-medium",
resolveStatusClassName(status),
)}
>
{resolveStatusLabel(status)}
</span>
);
}
function FileEntryList({
title,
entries,
emptyLabel,
}: {
title: string;
entries: KnowledgePackFileEntry[];
emptyLabel: string;
}) {
return (
<section className="rounded-[20px] border border-slate-200 bg-white shadow-sm shadow-slate-950/5">
<div className="flex items-center justify-between gap-3 px-4 pt-4">
<h3 className="text-sm font-semibold text-slate-900">{title}</h3>
<span className="rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-xs text-slate-500">
{entries.length}
</span>
</div>
<div className="mt-3 divide-y divide-slate-100 border-t border-slate-100">
{entries.length === 0 ? (
<div className="m-4 rounded-2xl border border-dashed border-slate-200 bg-slate-50 px-3 py-4 text-sm text-slate-500">
{emptyLabel}
</div>
) : (
entries.map((entry) => (
<article
key={entry.relativePath}
className="px-4 py-3 transition hover:bg-slate-50"
>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 truncate text-xs font-semibold text-slate-800">
{buildEntryDisplayLabel(title, entry)}
</div>
<div className="shrink-0 text-xs text-slate-400">
{formatCount(entry.bytes, "字节")}
</div>
</div>
{entry.preview ? (
<p className="mt-2 line-clamp-2 text-xs leading-5 text-slate-500">
{entry.preview}
</p>
) : null}
</article>
))
)}
</div>
</section>
);
}
export function KnowledgePage({ onNavigate, pageParams }: KnowledgePageProps) {
const initialWorkingDir = pageParams?.workingDir?.trim() ?? "";
const initialStoredWorkingDir =
initialWorkingDir || readStoredWorkingDir();
const [workingDirInput, setWorkingDirInput] = useState(() =>
initialWorkingDir || readStoredWorkingDir(),
initialStoredWorkingDir,
);
const [workingDir, setWorkingDir] = useState(() => initialWorkingDir);
const [workingDir, setWorkingDir] = useState(() => initialStoredWorkingDir);
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
null,
);
@@ -355,9 +123,7 @@ export function KnowledgePage({ onNavigate, pageParams }: KnowledgePageProps) {
const [packDescription, setPackDescription] =
useState("创始人个人 IP 知识库");
const [packType, setPackType] = useState("personal-ip");
const [sourceFileName, setSourceFileName] = useState(
DEFAULT_SOURCE_FILE_NAME,
);
const sourceFileName = DEFAULT_SOURCE_FILE_NAME;
const [sourceText, setSourceText] = useState("");
const selectedSummary = useMemo(
@@ -436,6 +202,31 @@ export function KnowledgePage({ onNavigate, pageParams }: KnowledgePageProps) {
void refreshCatalog(workingDir);
}, [refreshCatalog, workingDir]);
useEffect(() => {
const normalizedWorkingDir = workingDir.trim();
if (!normalizedWorkingDir) {
return;
}
let cancelled = false;
void getProjectByRootPath(normalizedWorkingDir)
.then((project) => {
if (cancelled || !project) {
return;
}
setSelectedProjectId(project.id);
setSelectedProjectName(project.name);
})
.catch(() => {
// 路径可能来自排障设置;解析不到项目时继续允许手动管理资料。
});
return () => {
cancelled = true;
};
}, [workingDir]);
useEffect(() => {
if (!workingDir || !selectedPackName) {
setSelectedPack(null);
@@ -526,7 +317,7 @@ export function KnowledgePage({ onNavigate, pageParams }: KnowledgePageProps) {
return null;
}
if (!normalizedPackName) {
setNotice("内部标识仅支持小写字母、数字和连字符");
setNotice("请填写资料名称");
return null;
}
if (!sourceText.trim()) {
@@ -708,14 +499,11 @@ export function KnowledgePage({ onNavigate, pageParams }: KnowledgePageProps) {
packType: selectedPack?.metadata.type ?? packType,
description: selectedPack?.metadata.description ?? packDescription,
});
const requestMetadata = {
knowledge_builder: {
skill_name: KNOWLEDGE_BUILDER_SKILL_NAME,
pack_name: normalizedPackName,
working_dir: workingDir,
source: "knowledge_page",
},
};
const requestMetadata = buildKnowledgeBuilderMetadata({
workingDir,
packName: normalizedPackName,
source: "knowledge_page",
});
onNavigate?.("agent", {
agentEntry: "claw",
@@ -761,8 +549,7 @@ export function KnowledgePage({ onNavigate, pageParams }: KnowledgePageProps) {
projectId: selectedProjectId ?? undefined,
initialUserPrompt: "请基于当前项目资料生成内容",
initialRequestMetadata: requestMetadata,
initialAutoSendRequestMetadata: requestMetadata,
autoRunInitialPromptOnMount: true,
autoRunInitialPromptOnMount: false,
});
}, [
onNavigate,
@@ -776,6 +563,15 @@ export function KnowledgePage({ onNavigate, pageParams }: KnowledgePageProps) {
const packMetrics = selectedSummary ? buildPackMetrics(selectedSummary) : [];
const actionBusy = Boolean(actionStatus);
const selectedPackReady = selectedSummary?.metadata.status === "ready";
const sourceBackedPackCount = packs.filter(
(pack) => pack.sourceCount > 0,
).length;
const compiledPackCount = packs.filter(
(pack) => pack.compiledCount > 0,
).length;
const readyPackCount = packs.filter(
(pack) => pack.metadata.status === "ready",
).length;
return (
<main className="flex h-full min-h-0 flex-1 overflow-auto bg-[linear-gradient(180deg,#f8fafc_0%,#eef7f2_100%)]">
<div className="mx-auto flex min-h-full w-full max-w-[1440px] flex-col gap-5 px-6 py-5">
@@ -800,12 +596,14 @@ export function KnowledgePage({ onNavigate, pageParams }: KnowledgePageProps) {
className="inline-flex h-10 items-center justify-center gap-2 rounded-2xl border border-slate-200 bg-white px-4 text-sm font-semibold text-slate-700 shadow-sm shadow-slate-950/5 transition hover:border-slate-300 hover:bg-slate-50"
>
<Upload className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => handleSendWithKnowledge()}
className="inline-flex h-10 items-center gap-2 rounded-2xl border border-slate-900 bg-slate-900 px-4 text-sm font-semibold text-white shadow-sm shadow-slate-950/10 transition hover:bg-slate-800"
disabled={!selectedPackReady}
title={selectedPackReady ? undefined : "先确认一份资料后再用于生成"}
className="inline-flex h-10 items-center gap-2 rounded-2xl border border-slate-900 bg-slate-900 px-4 text-sm font-semibold text-white shadow-sm shadow-slate-950/10 transition hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-50"
>
<MessageSquareText className="h-4 w-4" />
@@ -854,51 +652,19 @@ export function KnowledgePage({ onNavigate, pageParams }: KnowledgePageProps) {
className="inline-flex h-10 items-center justify-center gap-2 rounded-2xl border border-slate-900 bg-slate-900 px-4 text-sm font-semibold text-white transition hover:bg-slate-800"
>
<Upload className="h-4 w-4" />
</button>
</div>
</div>
<div className="mt-4 rounded-[20px] border border-slate-200 bg-slate-50 px-3 py-3">
<button
type="button"
onClick={() => setManualPathOpen((open) => !open)}
className="flex w-full items-center justify-between gap-3 text-left"
>
<span className="min-w-0">
<span className="block text-xs font-semibold text-slate-700">
</span>
<span className="mt-1 block truncate text-xs text-slate-500">
{formatPathPreview(workingDir)}
</span>
</span>
<ChevronDown
className={cn(
"h-4 w-4 shrink-0 text-slate-400 transition",
manualPathOpen && "rotate-180",
)}
/>
</button>
{manualPathOpen ? (
<div className="mt-3 grid gap-2 lg:grid-cols-[minmax(0,1fr)_auto]">
<input
value={workingDirInput}
onChange={(event) => setWorkingDirInput(event.target.value)}
placeholder="仅排障时填写,例如 /Users/me/project"
className="h-10 rounded-2xl border border-slate-200 bg-white px-3 font-mono text-sm text-slate-800 outline-none transition focus:border-emerald-300 focus:ring-2 focus:ring-emerald-100"
/>
<button
type="button"
onClick={handleApplyWorkingDir}
className="inline-flex h-10 items-center justify-center gap-2 rounded-2xl border border-slate-200 bg-white px-4 text-sm font-semibold text-slate-700 transition hover:border-slate-300 hover:bg-slate-50"
>
<RefreshCw className="h-4 w-4" />
使
</button>
</div>
) : null}
</div>
<KnowledgeTroubleshootingPanel
open={manualPathOpen}
workingDir={workingDir}
workingDirInput={workingDirInput}
onToggle={() => setManualPathOpen((open) => !open)}
onWorkingDirInputChange={setWorkingDirInput}
onApplyWorkingDir={handleApplyWorkingDir}
/>
{notice ? (
<div className="mt-3 rounded-2xl border border-sky-200 bg-sky-50 px-3 py-2 text-sm text-sky-700">
@@ -936,6 +702,12 @@ export function KnowledgePage({ onNavigate, pageParams }: KnowledgePageProps) {
{activeView === "overview" ? (
<section className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_380px]">
<div className="space-y-5">
<KnowledgeStatusRail
sourceCount={sourceBackedPackCount}
compiledCount={compiledPackCount}
readyCount={readyPackCount}
/>
{pendingPacks.length > 0 ? (
<section className="rounded-[24px] border border-amber-200 bg-amber-50 p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
@@ -991,70 +763,19 @@ export function KnowledgePage({ onNavigate, pageParams }: KnowledgePageProps) {
<div className="mt-4 grid gap-3 lg:grid-cols-2">
{packs.length === 0 && catalogStatus !== "loading" ? (
<div className="rounded-2xl border border-dashed border-slate-200 bg-slate-50 p-4 text-sm leading-6 text-slate-500">
Agent
Agent
</div>
) : (
packs.map((pack) => {
const isReady = pack.metadata.status === "ready";
return (
<article
key={pack.metadata.name}
className="rounded-[20px] border border-slate-200 bg-slate-50 p-4"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold text-slate-950">
{getPackTitle(pack)}
</h3>
<StatusPill status={pack.metadata.status} />
{pack.defaultForWorkspace ? (
<span className="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-0.5 text-xs font-medium text-emerald-700">
</span>
) : null}
</div>
<p className="mt-2 line-clamp-2 text-sm leading-6 text-slate-600">
{pack.preview ||
"等待整理适用场景、事实和边界。"}
</p>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<button
type="button"
onClick={() => openPack(pack.metadata.name)}
className="inline-flex h-9 items-center gap-2 rounded-2xl border border-slate-200 bg-white px-3 text-sm font-semibold text-slate-700 transition hover:border-slate-300 hover:bg-slate-50"
>
<BookOpen className="h-4 w-4" />
</button>
<button
type="button"
onClick={() =>
handleSetDefaultForPack(pack.metadata.name)
}
disabled={actionBusy || !isReady}
title={isReady ? undefined : "未确认资料不能设为默认"}
className="inline-flex h-9 items-center gap-2 rounded-2xl border border-slate-200 bg-white px-3 text-sm font-semibold text-slate-700 transition hover:border-slate-300 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50"
>
<ShieldCheck className="h-4 w-4" />
</button>
<button
type="button"
onClick={() =>
handleSendWithKnowledge(pack.metadata.name)
}
className="inline-flex h-9 items-center gap-2 rounded-2xl border border-slate-900 bg-slate-900 px-3 text-sm font-semibold text-white transition hover:bg-slate-800"
>
<MessageSquareText className="h-4 w-4" />
</button>
</div>
</article>
);
})
packs.map((pack) => (
<KnowledgePackCard
key={pack.metadata.name}
pack={pack}
actionBusy={actionBusy}
onOpen={(packName) => openPack(packName)}
onSetDefault={handleSetDefaultForPack}
onUse={handleSendWithKnowledge}
/>
))
)}
</div>
</section>
@@ -1119,7 +840,7 @@ export function KnowledgePage({ onNavigate, pageParams }: KnowledgePageProps) {
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-slate-200 pb-5">
<div>
<h2 className="text-xl font-semibold text-slate-950">
</h2>
<p className="mt-2 text-sm leading-6 text-slate-600">
@@ -1211,37 +932,16 @@ export function KnowledgePage({ onNavigate, pageParams }: KnowledgePageProps) {
<h3 className="text-sm font-semibold text-slate-950">
2
</h3>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<label className="grid gap-1.5 text-xs font-medium text-slate-600">
<input
value={packNameInput}
onChange={(event) =>
setPackNameInput(
normalizePackNameInput(event.target.value),
)
}
className="h-10 rounded-2xl border border-slate-200 bg-slate-50 px-3 font-mono text-sm text-slate-800 outline-none transition focus:border-emerald-300 focus:bg-white focus:ring-2 focus:ring-emerald-100"
/>
</label>
<label className="grid gap-1.5 text-xs font-medium text-slate-600">
<input
value={sourceFileName}
onChange={(event) =>
setSourceFileName(event.target.value)
}
className="h-10 rounded-2xl border border-slate-200 bg-slate-50 px-3 text-sm text-slate-800 outline-none transition focus:border-emerald-300 focus:bg-white focus:ring-2 focus:ring-emerald-100"
/>
</label>
</div>
<label className="mt-3 grid gap-1.5 text-xs font-medium text-slate-600">
<input
value={packDescription}
onChange={(event) =>
setPackDescription(event.target.value)
}
onChange={(event) => {
const nextName = event.target.value;
setPackDescription(nextName);
setPackNameInput(normalizePackNameInput(nextName));
}}
placeholder="例如:品牌产品资料"
className="h-10 rounded-2xl border border-slate-200 bg-slate-50 px-3 text-sm text-slate-800 outline-none transition focus:border-emerald-300 focus:bg-white focus:ring-2 focus:ring-emerald-100"
/>
</label>
@@ -1390,15 +1090,6 @@ export function KnowledgePage({ onNavigate, pageParams }: KnowledgePageProps) {
</p>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
disabled
title="资料说明编辑入口尚未接入"
className="inline-flex h-10 cursor-not-allowed items-center gap-2 rounded-2xl border border-slate-200 bg-slate-50 px-3 text-sm font-semibold text-slate-400"
>
<Pencil className="h-4 w-4" />
</button>
<button
type="button"
onClick={handleCompile}
@@ -0,0 +1,37 @@
import type {
KnowledgePackDetail,
KnowledgePackSummary,
} from "@/lib/api/knowledge";
import { KNOWLEDGE_BUILDER_SKILL_NAME } from "./knowledgePromptBuilder";
export function buildKnowledgeRequestMetadata(params: {
workingDir: string;
packName: string;
pack?: KnowledgePackSummary | KnowledgePackDetail | null;
source?: "knowledge_page" | "inputbar";
}) {
return {
knowledge_pack: {
pack_name: params.packName,
working_dir: params.workingDir,
source: params.source ?? "knowledge_page",
status: params.pack?.metadata.status,
grounding: params.pack?.metadata.grounding ?? "recommended",
},
};
}
export function buildKnowledgeBuilderMetadata(params: {
workingDir: string;
packName: string;
source: "knowledge_page" | "inputbar";
}) {
return {
knowledge_builder: {
skill_name: KNOWLEDGE_BUILDER_SKILL_NAME,
pack_name: params.packName,
working_dir: params.workingDir,
source: params.source,
},
};
}
@@ -0,0 +1,61 @@
export const KNOWLEDGE_BUILDER_SKILL_NAME = "knowledge_builder";
export function normalizeKnowledgeDraftName(value: string): string {
return (
value
.trim()
.toLowerCase()
.replace(/[^a-z0-9-]+/g, "-")
.replace(/-{2,}/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 64) || "project-material"
);
}
export function buildKnowledgeBuilderPrompt(params: {
workingDir: string;
packName: string;
packType?: string;
description?: string;
}) {
const lines = [
"请整理这个资料包,生成可审阅的项目资料草稿。",
"",
`资料包:${params.packName}`,
];
if (params.packType?.trim()) {
lines.push(`类型:${params.packType.trim()}`);
}
if (params.description?.trim()) {
lines.push(`说明:${params.description.trim()}`);
}
lines.push(
"",
"请只基于已导入资料提炼事实、适用场景、表达边界和待补充内容。",
"缺失内容请标为待补充,不要编造;完成后给出可供人工确认的摘要。",
);
return lines.join("\n");
}
export function buildKnowledgeOrganizePrompt(sourceText: string): string {
const trimmed = sourceText.trim();
const lines = [
"请把这些内容整理成当前项目可复用的项目资料。",
"",
"整理目标:",
"1. 提炼已确认事实、适用场景、表达风格和不能编造的边界。",
"2. 标出缺失信息、冲突内容和需要人工确认的风险提醒。",
"3. 生成一份可供我检查确认的项目资料草稿。",
];
if (trimmed) {
lines.push("", "待整理资料:", trimmed);
} else {
lines.push("", "我接下来会补充资料,请先告诉我需要提供哪些内容。");
}
return lines.join("\n");
}
@@ -0,0 +1,54 @@
import type { KnowledgePackFileEntry } from "@/lib/api/knowledge";
import {
buildEntryDisplayLabel,
formatCount,
} from "../domain/knowledgeVisibility";
export function FileEntryList({
title,
entries,
emptyLabel,
}: {
title: string;
entries: KnowledgePackFileEntry[];
emptyLabel: string;
}) {
return (
<section className="rounded-[20px] border border-slate-200 bg-white shadow-sm shadow-slate-950/5">
<div className="flex items-center justify-between gap-3 px-4 pt-4">
<h3 className="text-sm font-semibold text-slate-900">{title}</h3>
<span className="rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-xs text-slate-500">
{entries.length}
</span>
</div>
<div className="mt-3 divide-y divide-slate-100 border-t border-slate-100">
{entries.length === 0 ? (
<div className="m-4 rounded-2xl border border-dashed border-slate-200 bg-slate-50 px-3 py-4 text-sm text-slate-500">
{emptyLabel}
</div>
) : (
entries.map((entry) => (
<article
key={entry.relativePath}
className="px-4 py-3 transition hover:bg-slate-50"
>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 truncate text-xs font-semibold text-slate-800">
{buildEntryDisplayLabel(title, entry)}
</div>
<div className="shrink-0 text-xs text-slate-400">
{formatCount(entry.bytes, "字节")}
</div>
</div>
{entry.preview ? (
<p className="mt-2 line-clamp-2 text-xs leading-5 text-slate-500">
{entry.preview}
</p>
) : null}
</article>
))
)}
</div>
</section>
);
}
@@ -0,0 +1,72 @@
import { BookOpen, MessageSquareText, ShieldCheck } from "lucide-react";
import type { KnowledgePackSummary } from "@/lib/api/knowledge";
import { StatusPill } from "./StatusPill";
import { getPackTitle } from "../domain/knowledgeVisibility";
export function KnowledgePackCard({
pack,
actionBusy,
onOpen,
onSetDefault,
onUse,
}: {
pack: KnowledgePackSummary;
actionBusy: boolean;
onOpen: (packName: string) => void;
onSetDefault: (packName: string) => void;
onUse: (packName: string) => void;
}) {
const isReady = pack.metadata.status === "ready";
const primaryLabel = isReady ? "用于生成" : "继续确认";
return (
<article className="rounded-[20px] border border-slate-200 bg-slate-50 p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold text-slate-950">
{getPackTitle(pack)}
</h3>
<StatusPill status={pack.metadata.status} />
{pack.defaultForWorkspace ? (
<span className="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-0.5 text-xs font-medium text-emerald-700">
</span>
) : null}
</div>
<p className="mt-2 line-clamp-2 text-sm leading-6 text-slate-600">
{pack.preview || "等待整理适用场景、事实和边界。"}
</p>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<button
type="button"
onClick={() => (isReady ? onUse(pack.metadata.name) : onOpen(pack.metadata.name))}
className="inline-flex h-9 items-center gap-2 rounded-2xl border border-slate-900 bg-slate-900 px-3 text-sm font-semibold text-white transition hover:bg-slate-800"
>
<MessageSquareText className="h-4 w-4" />
{primaryLabel}
</button>
<button
type="button"
onClick={() => onOpen(pack.metadata.name)}
className="inline-flex h-9 items-center gap-2 rounded-2xl border border-slate-200 bg-white px-3 text-sm font-semibold text-slate-700 transition hover:border-slate-300 hover:bg-slate-50"
>
<BookOpen className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => onSetDefault(pack.metadata.name)}
disabled={actionBusy || !isReady}
title={isReady ? undefined : "未确认资料不能设为默认"}
className="inline-flex h-9 items-center gap-2 rounded-2xl border border-slate-200 bg-white px-3 text-sm font-semibold text-slate-700 transition hover:border-slate-300 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50"
>
<ShieldCheck className="h-4 w-4" />
</button>
</div>
</article>
);
}
@@ -0,0 +1,67 @@
import { Check, ClipboardCheck, FileText } from "lucide-react";
const STATUS_STEPS = [
{
label: "已添加资料",
description: "原始资料已进入当前项目",
icon: FileText,
},
{
label: "已整理草稿",
description: "Lime 提炼摘要、场景和风险",
icon: ClipboardCheck,
},
{
label: "已确认可用",
description: "人工确认后再用于正式生成",
icon: Check,
},
];
export function KnowledgeStatusRail({
sourceCount,
compiledCount,
readyCount,
}: {
sourceCount: number;
compiledCount: number;
readyCount: number;
}) {
const values = [sourceCount, compiledCount, readyCount];
return (
<section className="grid gap-3 rounded-[24px] border border-slate-200 bg-white p-4 shadow-sm shadow-slate-950/5 md:grid-cols-3">
{STATUS_STEPS.map((step, index) => {
const Icon = step.icon;
const active = values[index] > 0;
return (
<div
key={step.label}
className="flex items-start gap-3 rounded-[18px] border border-slate-200 bg-slate-50 px-3 py-3"
>
<div
className={
active
? "flex h-9 w-9 shrink-0 items-center justify-center rounded-2xl border border-emerald-200 bg-emerald-50 text-emerald-700"
: "flex h-9 w-9 shrink-0 items-center justify-center rounded-2xl border border-slate-200 bg-white text-slate-400"
}
>
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0">
<div className="text-sm font-semibold text-slate-950">
{step.label}
</div>
<div className="mt-1 text-xs leading-5 text-slate-500">
{step.description}
</div>
<div className="mt-2 text-xs font-semibold text-slate-700">
{values[index]}
</div>
</div>
</div>
);
})}
</section>
);
}
@@ -0,0 +1,62 @@
import { ChevronDown, RefreshCw } from "lucide-react";
import { cn } from "@/lib/utils";
import { formatPathPreview } from "../domain/knowledgeVisibility";
export function KnowledgeTroubleshootingPanel({
open,
workingDir,
workingDirInput,
onToggle,
onWorkingDirInputChange,
onApplyWorkingDir,
}: {
open: boolean;
workingDir: string;
workingDirInput: string;
onToggle: () => void;
onWorkingDirInputChange: (value: string) => void;
onApplyWorkingDir: () => void;
}) {
return (
<div className="mt-4 rounded-[20px] border border-slate-200 bg-slate-50 px-3 py-3">
<button
type="button"
onClick={onToggle}
className="flex w-full items-center justify-between gap-3 text-left"
>
<span className="min-w-0">
<span className="block text-xs font-semibold text-slate-700">
</span>
<span className="mt-1 block truncate text-xs text-slate-500">
{formatPathPreview(workingDir)}
</span>
</span>
<ChevronDown
className={cn(
"h-4 w-4 shrink-0 text-slate-400 transition",
open && "rotate-180",
)}
/>
</button>
{open ? (
<div className="mt-3 grid gap-2 lg:grid-cols-[minmax(0,1fr)_auto]">
<input
value={workingDirInput}
onChange={(event) => onWorkingDirInputChange(event.target.value)}
placeholder="仅排障时填写,例如 /Users/me/project"
className="h-10 rounded-2xl border border-slate-200 bg-white px-3 font-mono text-sm text-slate-800 outline-none transition focus:border-emerald-300 focus:ring-2 focus:ring-emerald-100"
/>
<button
type="button"
onClick={onApplyWorkingDir}
className="inline-flex h-10 items-center justify-center gap-2 rounded-2xl border border-slate-200 bg-white px-4 text-sm font-semibold text-slate-700 transition hover:border-slate-300 hover:bg-slate-50"
>
<RefreshCw className="h-4 w-4" />
使
</button>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,19 @@
import type { KnowledgePackStatus } from "@/lib/api/knowledge";
import { cn } from "@/lib/utils";
import {
resolveStatusClassName,
resolveStatusLabel,
} from "../domain/knowledgeLabels";
export function StatusPill({ status }: { status: KnowledgePackStatus }) {
return (
<span
className={cn(
"inline-flex rounded-full border px-2.5 py-1 text-xs font-medium",
resolveStatusClassName(status),
)}
>
{resolveStatusLabel(status)}
</span>
);
}
@@ -0,0 +1,84 @@
import type { KnowledgePackStatus } from "@/lib/api/knowledge";
export type KnowledgeView = "overview" | "import" | "detail";
export type DetailTab =
| "overview"
| "content"
| "sources"
| "runtime"
| "risks"
| "runs";
export const STATUS_LABELS: Record<string, string> = {
draft: "草稿",
ready: "已确认",
"needs-review": "待确认",
stale: "可能过期",
disputed: "有争议",
archived: "已归档",
};
export const STATUS_CLASS_NAMES: Record<string, string> = {
draft: "border-slate-200 bg-slate-50 text-slate-600",
ready: "border-emerald-200 bg-emerald-50 text-emerald-700",
"needs-review": "border-amber-200 bg-amber-50 text-amber-700",
stale: "border-amber-200 bg-amber-50 text-amber-700",
disputed: "border-rose-200 bg-rose-50 text-rose-700",
archived: "border-slate-200 bg-slate-100 text-slate-500",
};
export const PACK_TYPES = [
{
value: "personal-ip",
label: "个人 IP",
description: "创始人介绍、故事素材、表达风格和商务话术。",
},
{
value: "brand-product",
label: "品牌产品",
description: "品牌定位、产品事实、功效边界和客服口径。",
},
{
value: "organization-knowhow",
label: "组织 Know-how",
description: "团队 SOP、交付方法、升级路径和不可回答边界。",
},
{
value: "growth-strategy",
label: "增长策略",
description: "渠道策略、投放假设、转化漏斗和复盘结论。",
},
] as const;
export const VIEW_TABS: Array<{
id: KnowledgeView;
label: string;
description: string;
}> = [
{ id: "overview", label: "全部资料", description: "管理和确认" },
{ id: "import", label: "补充导入", description: "补充更多资料" },
{ id: "detail", label: "资料详情", description: "内容和风险" },
];
export const DETAIL_TABS: Array<{ id: DetailTab; label: string }> = [
{ id: "overview", label: "概览" },
{ id: "content", label: "内容" },
{ id: "sources", label: "原始资料" },
{ id: "runtime", label: "引用摘要" },
{ id: "risks", label: "缺口与风险" },
{ id: "runs", label: "整理记录" },
];
export function resolveStatusLabel(status: KnowledgePackStatus): string {
return STATUS_LABELS[status] ?? status;
}
export function resolveStatusClassName(status: KnowledgePackStatus): string {
return STATUS_CLASS_NAMES[status] ?? STATUS_CLASS_NAMES.draft;
}
export function getPackTypeLabel(value?: string | null): string {
return (
PACK_TYPES.find((type) => type.value === value)?.label ?? value ?? "自定义"
);
}
@@ -0,0 +1,75 @@
import type {
KnowledgePackDetail,
KnowledgePackFileEntry,
KnowledgePackSummary,
} from "@/lib/api/knowledge";
export function getErrorMessage(error: unknown, fallback: string): string {
if (error instanceof Error && error.message.trim()) {
return error.message;
}
if (typeof error === "string" && error.trim()) {
return error;
}
return fallback;
}
export function formatCount(value: number, unit: string): string {
return `${value.toLocaleString("zh-CN")} ${unit}`;
}
export function normalizePackNameInput(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9-]+/g, "-")
.replace(/-{2,}/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 64);
}
export function formatPathPreview(value: string): string {
const normalized = value.trim();
if (!normalized) {
return "未选择项目";
}
const segments = normalized.split(/[\\/]+/).filter(Boolean);
if (segments.length <= 2) {
return normalized;
}
return `.../${segments.slice(-2).join("/")}`;
}
export function getPackTitle(
pack: KnowledgePackSummary | KnowledgePackDetail,
): string {
return pack.metadata.description || pack.metadata.name;
}
export function buildPackMetrics(pack: KnowledgePackSummary | KnowledgePackDetail) {
return [
{ label: "原始资料", value: pack.sourceCount, caption: "已导入" },
{ label: "整理内容", value: pack.wikiCount, caption: "已生成" },
{ label: "引用摘要", value: pack.compiledCount, caption: "可用于生成" },
{ label: "整理记录", value: pack.runCount, caption: "最近处理" },
];
}
export function buildEntryDisplayLabel(
title: string,
entry: KnowledgePackFileEntry,
) {
const basename = entry.relativePath.split("/").filter(Boolean).pop();
if (title.includes("原始") || title.includes("来源")) {
return basename || "资料";
}
if (title.includes("整理记录")) {
return "整理记录";
}
if (title.includes("引用")) {
return "引用摘要";
}
return basename?.replace(/\.(md|txt|json)$/i, "") || "内容";
}
+56
View File
@@ -1325,6 +1325,12 @@ describe("Agent API 治理护栏", () => {
pendingRequestCount: 1,
queuedTurnCount: 0,
defaultDecisionStatus: "pending_review",
permissionStatus: "requires_confirmation",
permissionConfirmationStatus: "denied",
permissionConfirmationRequestId: "approval-denied",
permissionConfirmationSource: "runtime_action_required",
permissionConfirmationSummary:
"已拒绝(request_id=approval-denied, source=runtime_action_required),不能作为成功交付证据。",
verificationSummary: {
artifactValidator: {
applicable: true,
@@ -1397,6 +1403,12 @@ describe("Agent API 治理护栏", () => {
).resolves.toMatchObject({
session_id: "session-runtime-4b",
default_decision_status: "pending_review",
permission_status: "requires_confirmation",
permission_confirmation_status: "denied",
permission_confirmation_request_id: "approval-denied",
permission_confirmation_source: "runtime_action_required",
permission_confirmation_summary:
"已拒绝(request_id=approval-denied, source=runtime_action_required),不能作为成功交付证据。",
verification_summary: expect.objectContaining({
artifact_validator: expect.objectContaining({
outcome: "blocking_failure",
@@ -1472,6 +1484,12 @@ describe("Agent API 治理护栏", () => {
pendingRequestCount: 1,
queuedTurnCount: 0,
defaultDecisionStatus: "pending_review",
permission_status: "requires_confirmation",
permission_confirmation_status: "resolved",
permission_confirmation_request_id: "approval-resolved",
permission_confirmation_source: "runtime_action_required",
permission_confirmation_summary:
"已通过(request_id=approval-resolved, source=runtime_action_required)。",
verificationSummary: {
artifactValidator: {
applicable: true,
@@ -1547,6 +1565,10 @@ describe("Agent API 治理护栏", () => {
}),
).resolves.toMatchObject({
session_id: "session-runtime-4c",
permission_status: "requires_confirmation",
permission_confirmation_status: "resolved",
permission_confirmation_request_id: "approval-resolved",
permission_confirmation_source: "runtime_action_required",
verification_summary: expect.objectContaining({
artifact_validator: expect.objectContaining({
outcome: "recovered",
@@ -1585,6 +1607,40 @@ describe("Agent API 治理护栏", () => {
);
});
it("saveAgentRuntimeReviewDecision 应透传 denied 权限确认阻止 accepted 的后端错误", async () => {
mockSafeInvoke.mockRejectedValueOnce(
new Error(
"真实权限确认已被拒绝,不能把本次 review decision 保存为 accepted;请先处理真实权限确认,或改为 rejected / deferred / needs_more_evidence。",
),
);
await expect(
saveAgentRuntimeReviewDecision({
session_id: "session-runtime-4d",
decision_status: "accepted",
decision_summary: "错误接受被拒绝的权限确认。",
chosen_fix_strategy: "直接接受。",
risk_level: "low",
risk_tags: ["permission"],
human_reviewer: "Lime Maintainer",
reviewed_at: undefined,
followup_actions: [],
regression_requirements: [],
notes: "",
}),
).rejects.toThrow("真实权限确认已被拒绝");
expect(mockSafeInvoke).toHaveBeenCalledWith(
"agent_runtime_save_review_decision",
{
request: expect.objectContaining({
session_id: "session-runtime-4d",
decision_status: "accepted",
}),
},
);
});
it("getAgentRuntimeToolInventory 应走统一 runtime inventory 命令", async () => {
mockSafeInvoke.mockResolvedValueOnce({
request: {
+25
View File
@@ -1397,6 +1397,31 @@ export function normalizeReviewDecisionTemplate(
verification_summary: normalizeEvidenceVerificationSummary(
record.verificationSummary ?? record.verification_summary,
),
permission_status: readOptionalStringField(
record,
"permissionStatus",
"permission_status",
),
permission_confirmation_status: readOptionalStringField(
record,
"permissionConfirmationStatus",
"permission_confirmation_status",
),
permission_confirmation_request_id: readOptionalStringField(
record,
"permissionConfirmationRequestId",
"permission_confirmation_request_id",
),
permission_confirmation_source: readOptionalStringField(
record,
"permissionConfirmationSource",
"permission_confirmation_source",
),
permission_confirmation_summary: readOptionalStringField(
record,
"permissionConfirmationSummary",
"permission_confirmation_summary",
),
decision: normalizeReviewDecision(record.decision),
decision_status_options: readStringListField(
record,
+5
View File
@@ -909,6 +909,11 @@ export interface AgentRuntimeReviewDecisionTemplate {
queued_turn_count: number;
default_decision_status: string;
verification_summary?: AgentRuntimeEvidenceVerificationSummary;
permission_status?: string;
permission_confirmation_status?: string;
permission_confirmation_request_id?: string;
permission_confirmation_source?: string;
permission_confirmation_summary?: string;
decision: AgentRuntimeReviewDecision;
decision_status_options: AgentRuntimeReviewDecisionStatus[];
risk_level_options: AgentRuntimeReviewDecisionRiskLevel[];
+4
View File
@@ -9,6 +9,10 @@ vi.mock("@/lib/dev-bridge", () => ({
safeInvoke: vi.fn(),
}));
vi.mock("@/lib/tauri-runtime", () => ({
hasTauriRuntimeMarkers: vi.fn(() => false),
}));
describe("apiKeyProvider API", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -38,13 +38,6 @@ const mockPriorityCommands = new Set<string>([
"memory_runtime_prefetch_for_turn",
"memory_cleanup_memdir",
"memory_scaffold_memdir",
"knowledge_import_source",
"knowledge_compile_pack",
"knowledge_list_packs",
"knowledge_get_pack",
"knowledge_set_default_pack",
"knowledge_update_pack_status",
"knowledge_resolve_context",
"openclaw_check_installed",
"openclaw_get_environment_status",
"openclaw_check_node_version",
+31
View File
@@ -638,6 +638,37 @@ describe("tauri-mock/core invoke", () => {
}
});
it("review decision mock 应阻止 denied 权限确认保存为 accepted", async () => {
await expect(
invokeMockOnly("agent_runtime_save_review_decision", {
request: {
session_id: "mock-session",
decision_status: "accepted",
decision_summary: "错误接受被拒绝的权限确认。",
risk_level: "low",
},
}),
).rejects.toThrow("真实权限确认已被拒绝");
await expect(
invokeMockOnly("agent_runtime_save_review_decision", {
request: {
session_id: "mock-session",
decision_status: "rejected",
decision_summary: "权限确认已拒绝,拒绝本次交付。",
risk_level: "high",
},
}),
).resolves.toEqual(
expect.objectContaining({
permission_confirmation_status: "denied",
decision: expect.objectContaining({
decision_status: "rejected",
}),
}),
);
});
it("工具库存 fallback mock 应按 workbench + browser surface 补齐当前工具面", async () => {
mocks.invokeViaHttp.mockRejectedValueOnce(new Error("Failed to fetch"));
const consoleWarnSpy = vi
+120 -96
View File
@@ -5964,6 +5964,12 @@ const defaultMocks: Record<string, any> = {
pending_request_count: 1,
queued_turn_count: 0,
default_decision_status: "pending_review",
permission_status: "requires_confirmation",
permission_confirmation_status: "denied",
permission_confirmation_request_id: "mock-approval-denied",
permission_confirmation_source: "runtime_action_required",
permission_confirmation_summary:
"已拒绝(request_id=mock-approval-denied, source=runtime_action_required),不能作为成功交付证据。",
verification_summary: {
artifact_validator: {
applicable: true,
@@ -6044,104 +6050,122 @@ const defaultMocks: Record<string, any> = {
request,
}: {
request?: MockReviewDecisionRequest;
}) => ({
session_id: request?.session_id || request?.sessionId || "mock-session",
thread_id: "mock-thread",
workspace_root: "/mock/workspace",
review_relative_root: ".lime/harness/sessions/mock-session/review",
review_absolute_root:
"/mock/workspace/.lime/harness/sessions/mock-session/review",
analysis_relative_root: ".lime/harness/sessions/mock-session/analysis",
analysis_absolute_root:
"/mock/workspace/.lime/harness/sessions/mock-session/analysis",
handoff_bundle_relative_root: ".lime/harness/sessions/mock-session",
evidence_pack_relative_root: ".lime/harness/sessions/mock-session/evidence",
replay_case_relative_root: ".lime/harness/sessions/mock-session/replay",
exported_at: "2026-03-27T00:07:00Z",
title: "记录外部分析后的人工审核结论",
thread_status: "waiting_request",
latest_turn_status: "action_required",
pending_request_count: 1,
queued_turn_count: 0,
default_decision_status: "pending_review",
verification_summary: {
artifact_validator: {
applicable: true,
record_count: 1,
issue_count: 1,
repaired_count: 0,
fallback_used_count: 0,
outcome: "blocking_failure",
}) => {
const decisionStatus =
request?.decision_status || request?.decisionStatus || "pending_review";
if (decisionStatus === "accepted") {
throw new Error(
"真实权限确认已被拒绝,不能把本次 review decision 保存为 accepted;请先处理真实权限确认,或改为 rejected / deferred / needs_more_evidence。",
);
}
return {
session_id: request?.session_id || request?.sessionId || "mock-session",
thread_id: "mock-thread",
workspace_root: "/mock/workspace",
review_relative_root: ".lime/harness/sessions/mock-session/review",
review_absolute_root:
"/mock/workspace/.lime/harness/sessions/mock-session/review",
analysis_relative_root: ".lime/harness/sessions/mock-session/analysis",
analysis_absolute_root:
"/mock/workspace/.lime/harness/sessions/mock-session/analysis",
handoff_bundle_relative_root: ".lime/harness/sessions/mock-session",
evidence_pack_relative_root:
".lime/harness/sessions/mock-session/evidence",
replay_case_relative_root: ".lime/harness/sessions/mock-session/replay",
exported_at: "2026-03-27T00:07:00Z",
title: "记录外部分析后的人工审核结论",
thread_status: "waiting_request",
latest_turn_status: "action_required",
pending_request_count: 1,
queued_turn_count: 0,
default_decision_status: "pending_review",
permission_status: "requires_confirmation",
permission_confirmation_status: "denied",
permission_confirmation_request_id: "mock-approval-denied",
permission_confirmation_source: "runtime_action_required",
permission_confirmation_summary:
"已拒绝(request_id=mock-approval-denied, source=runtime_action_required),不能作为成功交付证据。",
verification_summary: {
artifact_validator: {
applicable: true,
record_count: 1,
issue_count: 1,
repaired_count: 0,
fallback_used_count: 0,
outcome: "blocking_failure",
},
focus_verification_failure_outcomes: [
"Artifact 校验存在 1 条未恢复 issue。",
],
focus_verification_recovered_outcomes: [],
},
focus_verification_failure_outcomes: [
"Artifact 校验存在 1 条未恢复 issue。",
decision: {
decision_status: decisionStatus,
decision_summary:
request?.decision_summary || request?.decisionSummary || "",
chosen_fix_strategy:
request?.chosen_fix_strategy || request?.chosenFixStrategy || "",
risk_level: request?.risk_level || request?.riskLevel || "unknown",
risk_tags: request?.risk_tags || request?.riskTags || [],
human_reviewer: request?.human_reviewer || request?.humanReviewer || "",
reviewed_at:
request?.reviewed_at ||
request?.reviewedAt ||
"2026-03-27T00:07:00Z",
followup_actions:
request?.followup_actions || request?.followupActions || [],
regression_requirements:
request?.regression_requirements ||
request?.regressionRequirements ||
[],
notes: request?.notes || "",
},
decision_status_options: [
"accepted",
"deferred",
"rejected",
"needs_more_evidence",
"pending_review",
],
focus_verification_recovered_outcomes: [],
},
decision: {
decision_status:
request?.decision_status || request?.decisionStatus || "pending_review",
decision_summary:
request?.decision_summary || request?.decisionSummary || "",
chosen_fix_strategy:
request?.chosen_fix_strategy || request?.chosenFixStrategy || "",
risk_level: request?.risk_level || request?.riskLevel || "unknown",
risk_tags: request?.risk_tags || request?.riskTags || [],
human_reviewer: request?.human_reviewer || request?.humanReviewer || "",
reviewed_at:
request?.reviewed_at || request?.reviewedAt || "2026-03-27T00:07:00Z",
followup_actions:
request?.followup_actions || request?.followupActions || [],
regression_requirements:
request?.regression_requirements ||
request?.regressionRequirements ||
[],
notes: request?.notes || "",
},
decision_status_options: [
"accepted",
"deferred",
"rejected",
"needs_more_evidence",
"pending_review",
],
risk_level_options: ["low", "medium", "high", "unknown"],
review_checklist: [
"先阅读 analysis-brief.md 与 analysis-context.json。",
"确认最终决策由人工审核者填写。",
],
analysis_artifacts: [
{
kind: "analysis_brief",
title: "外部分析简报",
relative_path:
".lime/harness/sessions/mock-session/analysis/analysis-brief.md",
absolute_path:
"/mock/workspace/.lime/harness/sessions/mock-session/analysis/analysis-brief.md",
bytes: 512,
},
],
artifacts: [
{
kind: "review_decision_markdown",
title: "人工审核记录",
relative_path:
".lime/harness/sessions/mock-session/review/review-decision.md",
absolute_path:
"/mock/workspace/.lime/harness/sessions/mock-session/review/review-decision.md",
bytes: 512,
},
{
kind: "review_decision_json",
title: "人工审核记录 JSON",
relative_path:
".lime/harness/sessions/mock-session/review/review-decision.json",
absolute_path:
"/mock/workspace/.lime/harness/sessions/mock-session/review/review-decision.json",
bytes: 768,
},
],
}),
risk_level_options: ["low", "medium", "high", "unknown"],
review_checklist: [
"先阅读 analysis-brief.md 与 analysis-context.json。",
"确认最终决策由人工审核者填写。",
],
analysis_artifacts: [
{
kind: "analysis_brief",
title: "外部分析简报",
relative_path:
".lime/harness/sessions/mock-session/analysis/analysis-brief.md",
absolute_path:
"/mock/workspace/.lime/harness/sessions/mock-session/analysis/analysis-brief.md",
bytes: 512,
},
],
artifacts: [
{
kind: "review_decision_markdown",
title: "人工审核记录",
relative_path:
".lime/harness/sessions/mock-session/review/review-decision.md",
absolute_path:
"/mock/workspace/.lime/harness/sessions/mock-session/review/review-decision.md",
bytes: 512,
},
{
kind: "review_decision_json",
title: "人工审核记录 JSON",
relative_path:
".lime/harness/sessions/mock-session/review/review-decision.json",
absolute_path:
"/mock/workspace/.lime/harness/sessions/mock-session/review/review-decision.json",
bytes: 768,
},
],
};
},
agent_runtime_export_handoff_bundle: () => ({
sessionId: "mock-session",
threadId: "mock-thread",