diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index f30d712ce..5dcdcb04d 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,62 +1,58 @@ -## Lime v1.15.0 +## Lime v1.16.0 ### 发布概览 -- 本次版本基于当前待发布工作树整理,主线集中在 `技能 / 灵感库 / 生成` 闭环收口、参考运行时 `hooks / skills / peer messaging / config` current 推进、`Lime Browser Bridge 0.4.0` 上架准备,以及官网直达桌面入口与品牌资产更新。 -- 本次发布目标 tag 为 `v1.15.0`。 +- 本次版本基于当前整批待发布工作树整理,覆盖 `225` 个文件,包含运行时 current 对齐、`@命令 / ServiceSkill / SkillCatalog / SceneApp` 本地执行面收口、工作台 IA 与多页面重构、自动化与资料库体验整理、开发脚本与工程文档同步,以及版本入口统一升级。 +- 本次发布目标 tag 为 `v1.16.0`。 -### 生成主线与结果模板 +### 运行时、hooks 与宿主能力推进 -- 结果模板启动事实开始跨首页、技能页、slash、launcher 与 `GeneralWorkbench` 连续流转:`launchInputValues`、`referenceEntries`、默认参考对象与 follow-up prompt 不再只靠首轮 prompt 文本恢复。 -- `CuratedTaskLauncherDialog` 现在支持打开态实时刷新最近参考对象,并保留用户已经手动勾选的参考项;确认启动时会继续带上这些引用,而不是被 recent list 刷新静默挤掉。 -- 参考对象的前台语言从泛化“灵感”收口为 `风格参考 / 偏好基线 / 参考素材 / 成果样本 / 收藏线索`,推荐理由、launcher 引用区、默认标题和来源标签都改成 category-aware 呈现。 -- 复盘结果开始回流到结果模板推荐信号,最近一次 review decision 会影响首页、技能页、slash 与 launcher 的下一轮推荐排序与推荐理由。 -- 结果模板卡片与 launcher 补充了“结果去向”“下一步动作”“跨模板 follow-up target”等信息,`复盘 -> 下一轮内容方案` 这类 continuation 会继续保留上一轮已确认的启动上下文。 -- 首页 `继续上次做法` 与 slash `最近使用` 改成同页实时回流;最近使用过的本地 Skill 会回到首页继续层,而不是只留在技能页里。 +- `runtime_project_hooks` 已真正接入当前 runtime 主链,补齐 `UserPromptSubmit`、`SessionStart(startup / compact)`、stop、permission request 等 hook 生命周期,不再停留在“存在 loader / executor、但主提交链没用上”的半接线状态。 +- `subagent_runtime`、`tool_runtime`、`runtime_turn`、`session_runtime` 与 `prompt_context` 同步推进,补上子代理自定义、frontmatter hooks、`allowed_tools` / `disallowed_tools`、会话级工具 allow/deny 与警告链,统一多代理与工具调用边界。 +- `aster` 侧继续向参考运行时 current 口径对齐:`skills/loader.rs`、`skills/registry.rs`、`skills/tool.rs`、`agents/tool_execution.rs`、`agents/subagent_tool.rs`、hooks registry / executor / types、permission inspector 与 workflow tool 全面更新,补齐 plugin skill reload、skill frontmatter hooks、permission hook current、subagent tool scope current。 +- session store、subagent profile、API key provider、agent runtime DTO / types、配置类型与 catalog 同步收口,宿主配置、权限与会话状态更加统一。 -### 技能工作台闭环 +### ServiceSkill、SkillCatalog、SceneApp 本地执行面收口 -- `沉淀为做法 -> 创建 Skill -> 我的方法库 -> 进入生成` 现在是完整闭环:创建 scaffold 成功后会立即刷新本地技能列表、关闭整理弹窗、把新做法高亮到“我的方法库”,并支持 optimistic 插入避免刷新空档。 -- 技能页 `先拿结果 -> 进入生成` 会把 `launchInputValues`、`referenceEntries` 与 reference memory ids 一起写回 capability route,避免只剩 prompt 与 reference prefill 的半状态。 -- 结果沉淀后的新 Skill 会以“刚沉淀”状态回到列表,用户可以直接继续进入生成,而不需要重新回到导入整理流。 +- `src/lib/api/serviceSkills.ts`、`skillCatalog.ts`、`sceneapp.ts`、seeded package、compat projection 与 Tauri mock 已统一改成“目录控制面 + 本地执行面”模型:`cloud_required`、`cloud_scene` 只再作为历史输入解析,不再冒充当前执行真相。 +- `SceneApp` 前后端协议继续收口到 `agent_turn / browser_assist / automation_job / native_skill / cloud_scene(compat)` 同一套 current 分层;planner、launch、presentation、catalog、review、run-entry navigation 与 Rust `sceneapp/*` runtime / governance / dto / adapters 一并对齐。 +- `serviceSkillRuns.ts` 与 `cloudRunStorage.ts` 已移除,`useWorkspaceServiceSkillEntryActions.ts` 不再依赖云端 run/poll 主链;`service-skills/storage.ts`、`mentionEntryUsage.ts` 改为带 changed event / subscribe 的最近使用与实时回流,避免“执行完成但入口状态不刷新”的断层。 +- `@命令`、`ServiceSkill`、`SceneApp` 与 seeded base-setup 包的兼容投影继续减少双轨:旧云会话命名仅保留 compat 入口,当前前台与执行链统一表达为本地即时执行或可恢复的真实业务入口。 -### 官网入口与品牌更新 +### 工作台 IA、页面与桌面壳更新 -- 桌面端新增 `lime://open` deep link 协议,官网现在可以直接打开结果模板、服务技能或提示词入口;桌面收到 deep link 后会把它解析成当前可用的工作台导航。 -- 侧边栏、关于页、公共品牌常量与静态资源统一切到新的 Lime 品牌 logo,桌面托盘与扩展图标也同步更新。 -- Chrome Relay 设置页新增明确提示:不要直接加载仓库源码目录里的 `extensions/lime-chrome`,应使用 Lime 导出的带配置连接器目录。 +- 侧边栏、导航与首页信息架构已重组为 `任务 / 能力 / 资料 / 系统` 主分区,`生成 / 我的方法 / 创作场景 / 持续流程 / 资料库 / 灵感库` 等工作台入口重新编排;插件中心、OpenClaw、桌宠等历史系统入口改为可配置隐藏而非默认暴露。 +- `SkillsWorkspacePage`、聊天空状态、聊天侧栏、卡片区和输入区围绕“最近继续、推荐理由、输出去向、下一步动作、结果复盘”重写前台文案与布局,技能启动、精选任务与最近入口更贴近当前产品语义。 +- `SceneAppsPage`、`SceneAppsCatalogPanel`、`SceneAppDetailPanel`、`SceneAppRunDetailPanel`、scorecard / governance / workflow rail 继续产品化,前台口径改成 `做法目录 / 生成准备 / 做法复盘`,同时补齐最近访问、一键继续、运行复盘与回到真实业务入口的链路。 +- `ResourcesPage` 从“管理式资料页”收口到“浏览 / 打开 / 切换”主链,查询逻辑抽到 `services/resourceQueries.ts`,去掉 store selectors 和创建文件夹/文档主路径,补上分类、分页、排序、面包屑与空态表达。 +- `MemoryPage`、`ImageGallery`、`VideoWorkspace`、`StartupLoadingScreen`、`main.tsx`、`index.html`、i18n patch 与 `Appearance` 设置页一并更新,启动壳、轻量 renderer 注册与首屏体验更统一。 -### Lime Browser Bridge 0.4.0 +### 自动化、开发脚本与工程支撑同步 -- 扩展版本推进到 `0.4.0`,并补齐 Chrome Web Store 提交底稿、上传包、SHA-256、截图与发布计划。 -- Browser Bridge 现在会把缺少 `serverUrl / bridgeKey` 的状态显式标成 `Setup Required / 需要配置`,而不是继续混在“连接中”状态里。 -- popup 与 options 页都新增了更清晰的缺配置引导文案,用户可以更直接理解需要从 Lime 同步连接配置或手动粘贴配置。 -- options 页新增中英文切换、完整状态说明、故障排查与设置说明,扩展安装/引导页的上架材料也同步收口到当前版本事实源。 +- `settings-v2/system/automation`、`serviceSkillContext`、`useAutomationSceneAppRuntime` 与相关测试同步更新,把自动化详情、SceneApp runtime 引用和 ServiceSkill 语义进一步接回同一条主链。 +- 开发与启动脚本已补齐新的本地壳流程:新增 `scripts/start-tauri-dev-server.mjs`、`scripts/lib/vite-dev-server-bootstrap.mjs`,并更新 `start-web-bridge-dev.mjs` 与 `vite.config.ts`,让 Tauri 壳与 Web bridge 共用更稳定的 dev server bootstrap。 +- 前后端回归与契约测试同步扩充,`AppSidebar`、`EmptyState`、`SkillsWorkspacePage`、`SceneAppsPage`、`ResourcesPage`、`serviceSkillContext`、`resourceQueries`、`agentChatWorkspaceLoader` 等相关测试文件一起更新,保证页面重构和执行语义调整有稳定断言。 -### 运行时与工程边界 +### 文档、路线图与治理同步 -- project hooks 已真正进入 runtime current:`UserPromptSubmit`、`SessionStart(startup / compact)` 都会按当前 workspace 加载 project hooks 并执行,不再停留在 loader/executor 存在但主路径不接入的半成品。 -- hooks executor 已从占位实现推进到 `command / url / prompt / agent / mcp` 全执行链:Prompt hook、Agent hook 与 MCP hook 都会真实调用当前 provider/runtime,而不是只打 warning。 -- `SkillTool` 现在能够真实执行 `prompt` / `workflow` 技能;`SkillExecutionMode::Agent` 仍明确保持未实现状态,没有假装进入 current。 -- 本机 cross-session peer messaging 进入最小 current:`ListPeers` 会暴露 synthetic `uds:` local peers,`SendMessage` 可把纯文本消息投递到同工作目录下的其他活跃 session。 -- `ConfigTool` 已接入 `classifierPermissionsEnabled` current key,并明确收口 `remoteControlAtStartup`、移动推送类 setting 与 `permissions.defaultMode` 的真实支持边界,不再把它们模糊混成“未知 setting”。 -- 旧 `channels_cmd` CRUD stub 命令已从 Tauri 命令面正式下线,并加入治理目录守卫,防止旧命令从 Rust 或前端路径重新回流。 -- provider 模型加载策略进一步收口:`anthropic-compatible` 等渠道在没有返回实时模型目录时仍允许保留和手动维护模型,不再被“必须拿到 live model truth”误伤。 +- 工程文档、执行计划与路线图已整体更新到最新事实源:`command-runtime`、`commands`、`quality-workflow`、`playwright-e2e`、`skill-standard`、`limecore-collaboration-entry`、`limenext-progress`、`upstream-runtime-alignment-progress`、`tech-debt-tracker`、`exec-plans/README`、`lime-service-skill-cloud-config-prd` 等文件已同步收口。 +- 新增 `docs/exec-plans/at-command-local-execution-alignment-plan.md`,把 `@命令` 本地执行面纠偏明确落为可追踪计划;现有文档口径统一强调“目录控制面 + 本地执行面”,并继续追踪 compat 旧命名与历史锚点的退出路径。 -### 版本与发布同步 +### 版本、发布物与同步项 - 应用版本入口已对齐到 `package.json`、`src-tauri/Cargo.toml`、`src-tauri/tauri.conf.json`、`src-tauri/tauri.conf.headless.json`。 -- CLI npm wrapper 与发布示例已同步到 `1.15.0`。 +- CLI npm wrapper 与发布示例已同步到 `1.16.0`。 - `package-lock.json`、`src-tauri/Cargo.lock` 与校验结果以本次最终验证通过的状态为准。 +- 本说明按当前整批待提交文件重新整理,覆盖运行时、前台、脚本、文档与版本同步内容,而不是仅记录版本号变更。 ### 已执行校验 - `npm run verify:app-version`:通过 - `cargo fmt --manifest-path "src-tauri/Cargo.toml" --all`:已执行 -- `env CARGO_TARGET_DIR="/tmp/lime-release-1.15.0-test" cargo test --manifest-path "src-tauri/Cargo.toml"`:通过,`984` 个单测通过,额外 `2` 个集成测试通过;另有 `2` 个真实联网用例按默认配置保持 `ignored` -- `env CARGO_TARGET_DIR="/tmp/lime-release-1.15.0-test" cargo clippy --manifest-path "src-tauri/Cargo.toml"`:通过,当前保留 `1` 条 clippy 告警,位于 `src-tauri/crates/skills/src/lime_llm_provider.rs:255`(`clippy::too_many_arguments`);同时 `lime` crate 还存在 `3` 条 `dead_code` 级别的非阻塞 warning,集中在 `runtime_project_hooks.rs` 与 `session_runtime.rs` +- `env CARGO_TARGET_DIR="/tmp/lime-release-1.16.0-test" cargo test --manifest-path "src-tauri/Cargo.toml"`:通过,主库 `1010` 条单测通过,额外 `2` 条集成测试通过;另有 `2` 条真实联网用例按默认配置保持 `ignored` +- `env CARGO_TARGET_DIR="/tmp/lime-release-1.16.0-test" cargo clippy --manifest-path "src-tauri/Cargo.toml"`:通过,当前保留 `1` 条 clippy 告警,位于 `src-tauri/crates/skills/src/lime_llm_provider.rs:255`(`clippy::too_many_arguments`) - `npm run lint`:通过 --- -**完整变更**: `v1.14.0` -> `v1.15.0` +**完整变更**: `v1.15.0` -> `v1.16.0` diff --git a/docs/aiprompts/command-runtime.md b/docs/aiprompts/command-runtime.md index 3d8998c9b..37db12796 100644 --- a/docs/aiprompts/command-runtime.md +++ b/docs/aiprompts/command-runtime.md @@ -44,9 +44,9 @@ Lime 的命令体系固定按以下关系理解: 4. `skill` 是能力绑定抽象 它背后可能是: - 本地 CLI - - 服务端 API - - 混合链路 - 本地 runtime + - 浏览器 / workspace / automation 执行器 + - 目录控制面下发的配置绑定 5. `task file` 只是异步媒体/资源能力的真相之一 它不是所有命令的统一产品真相。 @@ -126,16 +126,16 @@ Lime 的命令体系固定按以下关系理解: - `useWorkspaceSendActions` 先识别 `/scene-key ...` - 从统一 `SkillCatalog.entries` 里解析 `scene -> linkedSkillId -> ServiceSkillHomeItem` -- 前端只负责把结构化 `service_scene_launch` 写进当前 turn metadata,不负责前端直建云端 run -- Rust 侧会把该 turn 收口到 `workbench`,并通过系统提示强约束 Agent 首刀优先调用 `lime_run_service_skill` -- `lime_run_service_skill` 再根据当前 turn 绑定的 `serviceSkillId + OEM runtime` 发起服务端 run / 短轮询,保证 slash scene 也走 `Agent -> tool -> timeline` 主链 +- 前端只负责把结构化 `service_scene_launch` 写进当前 turn metadata,不负责任何服务端 run 创建 +- Rust 侧会把该 turn 收口到 `workbench`,并通过系统提示强约束 Agent 直接按当前本地 `service_scene_launch` 上下文执行;如果实现里仍沿用 `lime_run_service_skill` 一类历史命名,也只允许视为 compat 护栏 +- `service_scene_launch` 只表达目录命中和本地运行时路由提示,保证 slash scene 继续走 `Agent -> tool -> timeline` 主链 - 未命中统一目录的 slash 文本必须继续回到普通 slash 流程,不能被错误吞成“未找到本地 Skill” 当前 `scene` slash 还必须遵守下面三条长期规则: - `Scene Skill` 是产品场景真相;slash 只是触发入口,不能在前端把流程写死成某个站点分支 - 推荐用 `Pipeline` 作为主模式,再按需要叠加 `Inversion`、`Generator`、`Tool Wrapper` -- 聊天区“saved content / viewer 预览 / 运行摘要”都只是消费层投影,不能反过来定义 scene runtime 真相 +- 聊天区“saved content / viewer 预览 / 运行摘要”都只是消费层投影,不能反过来定义 `ServiceSkill` 或 slash 场景的执行真相 如果 `scene` 绑定的是 `site_adapter / browser_assist` 型技能,还要额外遵守以下边界: @@ -251,8 +251,8 @@ Lime 的命令体系固定按以下关系理解: - `/scene-key` 不再直接落回本地 slash skill 预处理 - 先按统一目录找到 `scene` 与其 `linkedSkillId` -- 把 `service_scene_launch` 作为当前 turn 的 binding 上下文,而不是前端直接调用云端 run -- 由 Agent 首刀调用 `lime_run_service_skill` 执行服务型技能 run +- 把 `service_scene_launch` 作为当前 turn 的 binding 上下文,而不是任何云端 run 协议 +- 由 Agent 直接依据当前 `service_scene_launch` 上下文完成本地执行;若实现里仍保留 `lime_run_service_skill` 这类历史名字,也只应理解为 compat 护栏,而不是 current 运行时桥接 - 服务端目录失联或 scene 未命中时,客户端 seeded/fallback 仍要保证 slash 输入能回到普通工作区主链 - 如果 `ServiceSkill` 底层绑定的是 `site_adapter / browser_assist`,允许 Rust runtime 先做一次预执行收口浏览器上下文与保存逻辑;但这次预执行必须继续走标准 `tool_start / tool_end` 事件,并以内联过程步骤显示在当前对话中 diff --git a/docs/aiprompts/commands.md b/docs/aiprompts/commands.md index af985e744..13ecf4ae6 100644 --- a/docs/aiprompts/commands.md +++ b/docs/aiprompts/commands.md @@ -114,7 +114,7 @@ - `sceneapp_plan_launch` 只负责 preview 当前 planning,并读取已有项目级 Context Snapshot;不要再把它当成自动落盘命令 - 显式把当前灵感对象、输入摘要与风格基线写回项目目录时,只能走 `sceneapp_save_context_baseline` - 新的 SceneApp UI 先消费 `SceneAppDescriptor / SceneAppPlanResult / SceneAppScorecard`,不要重新从 `serviceSkills.ts`、`skillCatalog.ts`、卡片配置和 selector 里各自拼语义 -- 真正的执行仍应通过 runtime adapter 继续挂回现有 `agent turn / browser_assist / automation_job / cloud_scene` 主链;`sceneapp_*` 不用于重新发明第二套 runtime taxonomy +- 真正的执行仍应通过 runtime adapter 继续挂回现有本地主链,例如 `agent turn / browser_assist / automation_job / native_skill`;历史 `cloud_scene` 只允许按 compat binding family 理解,不再代表 current 执行面;`sceneapp_*` 不用于重新发明第二套 runtime taxonomy 技能脚手架创建同样只允许走当前命令网关主链: @@ -132,11 +132,11 @@ - 发送前由 `src/components/agent/chat/workspace/useWorkspaceSendActions.ts` 统一拦截 slash 场景 - 运行时只从统一 catalog 解析 `scene -> linkedSkillId -> ServiceSkillHomeItem`,并把结构化上下文写入 `request_metadata.harness.service_scene_launch` -- Rust 侧 `runtime_turn` 会把这类 turn 统一切到 `workbench`,并通过 `prompt_context` 强约束首刀优先调用 `lime_run_service_skill` -- `lime_run_service_skill` 负责基于当前 session / turn 上下文读取已绑定的 `serviceSkillId + OEM runtime`,再向 OEM Scene Runtime 发起 run / poll -- slash scene 不应再在前端直接调用 `createServiceSkillRun(...)` 或其它云端 run API;客户端当前职责只剩 catalog 解析、metadata 注入与 seeded/fallback 托底 +- Rust 侧 `runtime_turn` 会把这类 turn 统一切到 `workbench`,并通过 `prompt_context` 强约束当前回合直接按本地 `service_scene_launch` 上下文执行;如果实现里仍保留 `lime_run_service_skill` 这类历史命名,也只允许视为 compat 护栏,不属于 current 主链 +- `service_scene_launch` 只表达“目录命中 + 本地运行时路由提示”,不表达服务端 run / poll;slash scene 不应在前端或 Rust 侧继续扩成云端执行协议 +- 客户端当前职责固定为 catalog 解析、metadata 注入、seeded/fallback 托底与本地 runtime dispatch - 未命中统一 scene 目录的 slash 文本必须继续回到普通 slash / Codex 命令流,不能误报本地 Skill 不存在 -- `/scene` 的长期产品真相应落在 `Scene Skill`;`site_adapter` 只是 step provider,不是 scene runtime 本体 +- `/scene` 的长期产品真相应落在 `Scene Skill`;`site_adapter` 只是 step provider,不是独立执行面 - 如果 scene 缺少 URL、项目等必填输入,前端不应只 toast 结束;应打开统一 `scene gate`,由 `slotSchema` / `readinessRequirements` 驱动补参 - 如果某个 scene 背后绑定的是 `site_adapter / browser_assist` 型技能,前端可以继续只暴露 `scene`,不必把底层 site skill 再平铺成首页目录项;但运行时解析 `scene -> linkedSkillId` 时必须能回退完整 `ServiceSkill` 目录,而不是只看首页可见 skill 列表,否则会出现目录可见但执行找不到 skill 的协议漂移 - 如果某个 `site_adapter / browser_assist` scene 还声明了 `readinessRequirements.requiresProject=true`,或 `saveMode=project_resource` 需要真实项目目录,输入框 slash 发送时必须沿用当前选中的项目;若当前没有项目,前端必须通过 `scene gate` 收集项目,不能静默 `getOrCreateDefaultProject()` 把结果写进 default 项目 @@ -289,7 +289,7 @@ Skill 执行链路同样遵循单一命令边界。当前前端入口为 `src/li `Claw` 的纯文本配音命令也应沿同一条服务型技能主链收敛: -- Agent 驱动的配音命令:`@配音` / `@voice` / `@dubbing` / `@dub` 在 `src/components/agent/chat/workspace/useWorkspaceSendActions.ts` 中保留原始用户文本发送。聊天发送边界会优先从当前 `serviceSkills` / seeded fallback 中解析配音能力(当前兜底为 `cloud-video-dubbing`),并把结构化 `service_scene_launch` 写入 `request_metadata.harness.service_scene_launch`,其中固定 `scene_key=voice_runtime`、`entry_source=at_voice_command`,同时注入 OEM `scene_base_url / tenant_id / session_token` 运行时上下文。Rust 侧 `runtime_turn.rs`、`prompt_context.rs` 与 `tool_runtime/service_skill_tools.rs` 会把当前 turn 切到 `workbench`,并强约束首刀优先调用 `lime_run_service_skill`,由 OEM scene runtime 负责 run / poll。当前上下文缺少明确配音要求时,允许 Agent 最多追问 1 个关键问题;但不能退回普通聊天解释、不能伪造“配音已完成”,也不能重新回流到旧的本地 TTS 测试命令。 +- Agent 驱动的配音命令:`@配音` / `@voice` / `@dubbing` / `@dub` 在 `src/components/agent/chat/workspace/useWorkspaceSendActions.ts` 中保留原始用户文本发送。聊天发送边界会优先从当前 `serviceSkills` / seeded fallback 中解析配音能力;若现有兜底 ID 仍沿用 `cloud-video-dubbing` 之类历史命名,也只允许按“本地 service skill 标识”理解,不得再把它解释成云执行能力。后续把结构化 `service_scene_launch` 写入 `request_metadata.harness.service_scene_launch` 时,只保留 `scene_key=voice_runtime`、`entry_source=at_voice_command` 这类本地路由提示,不再注入 `scene_base_url / session_token` 一类云端运行上下文。Rust 侧 `runtime_turn.rs`、`prompt_context.rs` 与 `tool_runtime/service_skill_tools.rs` 会把当前 turn 切到 `workbench`,并强约束当前回合直接按本地 `service_scene_launch` 上下文执行;若实现里仍保留 `lime_run_service_skill` 等历史 helper 名,也必须只作为 compat 护栏,不再代表 current 执行桥。当前上下文缺少明确配音要求时,允许 Agent 最多追问 1 个关键问题;但不能退回普通聊天解释、不能伪造“配音已完成”,也不能重新回流到旧的本地 TTS 测试命令。 `Claw` 的纯文本浏览器命令也应沿同一条真实浏览器工具主链收敛: diff --git a/docs/aiprompts/limecore-collaboration-entry.md b/docs/aiprompts/limecore-collaboration-entry.md index f459e6e8e..0823000d8 100644 --- a/docs/aiprompts/limecore-collaboration-entry.md +++ b/docs/aiprompts/limecore-collaboration-entry.md @@ -28,7 +28,7 @@ - AI 服务商页、云端 Provider、默认来源、模型目录 - `client/bootstrap`、`client/session`、`client/profile` - `client/skills`、`skillCatalog.entries`、`client/service-skills` -- Gateway、Scene、Service Skill 云配置同步 +- Gateway、命令目录、Service Skill 配置同步 - 任何“客户端要不要本地维护一份服务端数据”的判断 ## 为什么主文档放在 `limecore` @@ -40,7 +40,7 @@ - `client/skills` 统一命令目录 - 用户资料与账户能力 - Provider Offer / 服务目录 / Scene Catalog -- Gateway 与云端运行时策略 +- Gateway 与目录/配置策略 客户端仓库更适合作为实现消费方,而不是这些能力的唯一背景事实源。 @@ -61,6 +61,7 @@ - 云事实源不要在客户端长期维护第二份 - `@` / 产品型 `/` 的统一目录优先看 `client/skills.entries` - Lime 客户端必须保留 seeded / fallback 韧性兜底,不能只靠服务端在线返回 +- `limecore` 只提供目录与配置,不承担 `@` / `/scene` / service skill 的执行 - 能走运行时配置和 `bootstrap.features` 的,不要写死在前端 - 用户界面不要直接暴露 “OEM” 技术概念 -- `scene` 目录项要稳定提供 `sceneKey` 与 `linkedSkillId`。Lime 当前会用它把 `/scene-key` 解析到现有 `ServiceSkill` 启动链;若云端 run 创建前失败,客户端会自动回退到本地工作区 prompt 主链 +- `scene` 目录项要稳定提供 `sceneKey` 与 `linkedSkillId`。Lime 当前会用它把 `/scene-key` 解析到现有 `ServiceSkill` 本地启动链;不要把目录命中误写成服务端 run diff --git a/docs/aiprompts/playwright-e2e.md b/docs/aiprompts/playwright-e2e.md index b4c7d9ce8..64efcd452 100644 --- a/docs/aiprompts/playwright-e2e.md +++ b/docs/aiprompts/playwright-e2e.md @@ -318,8 +318,8 @@ npm run test:contracts 1. 在 `Claw` 对话框输入 `@配音 目标语言: 英文 风格: 科技感 给这个新品视频做一版发布配音稿` 2. 确认聊天区保留原始 `@配音 ...` 文本,而不是被改写成 slash scene 或其它内部协议 3. 如页面可查看发送详情或运行时摘要,确认 `request_metadata.harness.service_scene_launch.service_scene_run.scene_key=voice_runtime`,并且 `skill_id` 已绑定到当前可用的配音 service skill -4. 确认首刀进入 `lime_run_service_skill` 主链,而不是普通聊天解释、站点型 `lime_site_run`,或旧的本地 TTS 测试命令 -5. 如果当前 OEM 云端会话缺失,确认界面明确提示需要登录 / 注入会话,而不是伪造“配音已完成” +4. 确认首轮执行直接依据 `service_scene_launch` 进入本地 service-scene 主链,而不是普通聊天解释、站点型 `lime_site_run`,或旧的本地 TTS 测试命令 +5. 确认界面与时间线不会再把 `scene_base_url / session_token / OEM 云会话` 当作 current 执行前提,也不会伪造“配音已提交云端” 6. 刷新页面或切换会话后再返回原话题,确认该配音任务的时间线与最近使用状态仍可恢复 ### Claw `@浏览器` 真实浏览器任务验证 @@ -386,7 +386,7 @@ npm run test:contracts 1. 进入 `Claw` 对话框,确认当前租户目录里存在一个 `entries.kind=scene` 的场景,例如 `/daily-trend-brief` 2. 输入 `/daily-trend-brief 帮我整理今天的小红书趋势赛题` 3. 确认聊天区先出现正常的用户消息,再进入 Agent 执行态,而不是前端静默直接提交云端 run -4. 打开时间线,确认首个执行器是 `lime_run_service_skill`,而不是前端本地直接产出结果卡 +4. 打开时间线,确认执行链落在本地 service-scene / tool timeline,而不是前端本地直接产出结果卡或旧 `lime_run_service_skill` compat 桥 5. 如当前 OEM 会话可用,确认工具结果会回流 run 状态或摘要;若当前会话缺失,确认聊天区明确提示需要登录或注入会话,而不是伪造成功 6. 未命中 scene 目录时,确认 `/unknown-scene ...` 仍回到普通 slash / Codex 流程,不会被误报为本地技能异常 7. 如果当前 scene 绑定的是站点型 skill,例如 `/x文章转存 https://x.com/.../article/...`,确认 slash 菜单可见后仍能成功解析到底层 site skill,而不是因为首页未暴露 site skill 就在发送时失配 diff --git a/docs/aiprompts/quality-workflow.md b/docs/aiprompts/quality-workflow.md index ad1c5c408..354a4f195 100644 --- a/docs/aiprompts/quality-workflow.md +++ b/docs/aiprompts/quality-workflow.md @@ -234,9 +234,9 @@ CI 里的 `.github/workflows/quality.yml` 结果摘要现在也会透出 `bridge - 检查纯文本 `Claw @渠道预览` 是否已经走 `原始用户消息 -> displayContent 保留 -> /content_post_with_cover -> artifact` 主链,而不是退回普通聊天解释、重新长出另一套 `channel_preview_task` 协议,或静默混成正式 `@发布`;同时确认 `publish_command.intent=preview`、`entry_source=at_channel_preview_command` 与预览稿意图补齐仍然成立 - 检查纯文本 `Claw @上传` 是否已经走 `原始用户消息 -> displayContent 保留 -> /content_post_with_cover -> artifact` 主链,而不是退回普通聊天解释、重新长出另一套 `upload_task` 协议,或静默混成正式 `@发布`;同时确认 `publish_command.intent=upload`、`entry_source=at_upload_command` 与上传稿意图补齐仍然成立;若命中平台后台,也要确认浏览器门禁继续生效 - 检查纯文本 `Claw @发布` 是否已经走 `原始用户消息 -> displayContent 保留 -> dispatch /content_post_with_cover -> content_post workflow` 主链,而不是直接把 `@发布` 文本原样当普通聊天发送,或重新造一套 `publish_task` 协议;同时确认平台后台类输入会继续触发 `browser_requirement` -- 检查纯文本 `Claw @配音` 是否已经走 `原始用户消息 -> harness.service_scene_launch(scene_key=voice_runtime) -> Agent 首刀 lime_run_service_skill -> OEM scene run/timeline` 主链,而不是退回普通聊天解释、误走站点型 `service_skill_launch`,或重新回流旧的本地 TTS 测试命令;同时确认 `skill_id`、OEM runtime 上下文与最近使用记录都能写回 +- 检查纯文本 `Claw @配音` 是否已经走 `原始用户消息 -> harness.service_scene_launch(scene_key=voice_runtime) -> Agent 基于本地 service-scene 上下文直接执行 -> 本地 ServiceSkill / tool timeline` 主链,而不是退回普通聊天解释、误走站点型 `service_skill_launch`,或重新回流旧的本地 TTS 测试命令;同时确认 `skill_id` 与最近使用记录都能写回,且不会再注入 `scene_base_url / session_token` 一类旧云运行上下文 - 检查纯文本 `Claw @浏览器` 是否已经走 `原始用户消息 -> harness.browser_requirement/browser_launch_url -> Browser Assist / mcp__lime-browser__* timeline` 主链,而不是退回 WebSearch、普通聊天解释,或错误伪装成站点型 `service_skill_launch`;同时确认前端本轮 `webSearch` 已关闭 -- 检查产品型 `/scene-key` 是否已经走 `原始用户消息 -> harness.service_scene_launch -> Agent 首刀 lime_run_service_skill -> OEM run/timeline` 主链,而不是前端直接调用云端 run API +- 检查产品型 `/scene-key` 是否已经走 `原始用户消息 -> harness.service_scene_launch -> Agent 基于本地 service-scene 上下文直接执行 -> 本地 ServiceSkill / tool timeline` 主链,而不是前端直接调用云端 run API 或在 Rust 侧重新长出云执行分支 - 如果某个 `/scene-key` 绑定的是 `site_adapter` 型技能,还要额外检查 `scene -> linkedSkillId -> 完整 ServiceSkill 目录 -> harness.service_skill_launch` 这条绑定链是否仍然成立,避免首页隐藏 site skill 后 slash scene 变成“目录可见但执行找不到 skill” - 如果某个 `site_adapter` 结果开始返回 `markdown_bundle`,还要确认保存链会把 Markdown、图片和 `meta.json` 一起落到项目导出目录,并把重写后的相对图片路径写回内容 metadata;同时确认聊天轻卡或 tool timeline 能显示项目目录、Markdown 路径和图片数量,不能只把远程图片 URL 或临时 DOM 文本留在聊天结果里;进入工作区后还要实际打开项目里的真实 `index.md`,确认正文不是运行摘要副本,且相对图片已经在预览里渲染出来 @@ -284,9 +284,9 @@ CI 里的 `.github/workflows/quality.yml` 结果摘要现在也会透出 `bridge - 修改 `@渠道预览` parser、`useWorkspaceSendActions`、`publish_command` metadata 或 `content_post_with_cover` 预览意图编排,尤其是调整 `Claw @渠道预览 -> publish_command.intent=preview -> /content_post_with_cover -> artifact` 主链 - 修改 `@上传` parser、`useWorkspaceSendActions`、`publish_command` metadata、浏览器门禁推导或 `content_post_with_cover` 上传意图编排,尤其是调整 `Claw @上传 -> publish_command.intent=upload -> /content_post_with_cover -> artifact` 主链 - 修改 `@发布` parser、`useWorkspaceSendActions`、content post workflow 入口或浏览器门禁推导,尤其是调整 `Claw @发布 -> displayContent/raw -> /content_post_with_cover -> publish workflow` 主链 -- 修改 `@配音` parser、`useWorkspaceSendActions`、`service_scene_launch` 组装、OEM runtime 上下文注入或 `lime_run_service_skill` 配音场景接线,尤其是调整 `Claw @配音 -> harness.service_scene_launch(scene_key=voice_runtime) -> lime_run_service_skill -> OEM run/timeline` 主链 +- 修改 `@配音` parser、`useWorkspaceSendActions`、`service_scene_launch` 组装或 compat `lime_run_service_skill` 护栏,尤其是调整 `Claw @配音 -> harness.service_scene_launch(scene_key=voice_runtime) -> 本地 service-scene 直驱执行 -> 本地 ServiceSkill / tool timeline` 主链 - 修改 `@浏览器` parser、`useWorkspaceSendActions`、Browser Assist 直发策略、`browser_requirement` 推导或 `mcp__lime-browser__*` 浏览器工具接线,尤其是调整 `Claw @浏览器 -> harness.browser_requirement/browser_launch_url -> Browser Assist timeline` 主链 -- 修改 `/scene-key` 解析、`serviceSkillSceneLaunch`、`useWorkspaceSendActions`、`runtime_turn`、`prompt_context`、`lime_run_service_skill` 或 `client/skills` scene 目录协议,尤其是调整 `Claw /scene-key -> harness.service_scene_launch -> Agent 首刀 lime_run_service_skill -> OEM run/timeline` 主链 +- 修改 `/scene-key` 解析、`serviceSkillSceneLaunch`、`useWorkspaceSendActions`、`runtime_turn`、`prompt_context`、compat `lime_run_service_skill` 或 `client/skills` scene 目录协议,尤其是调整 `Claw /scene-key -> harness.service_scene_launch -> 本地 service-scene 直驱执行 -> 本地 ServiceSkill / tool timeline` 主链 - 修改 `src/lib/dev-bridge/` - 修改 `src/lib/tauri-mock/` - 修改 `src-tauri/src/app/runner.rs` @@ -447,7 +447,7 @@ CI 里的 `.github/workflows/quality.yml` 结果摘要现在也会透出 `bridge 如果本轮修改了 `Claw @配音` 或配音服务型技能接线,最低校验至少包含: - `npx vitest run "src/components/agent/chat/utils/voiceWorkbenchCommand.test.ts" "src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx" "src/components/agent/chat/skill-selection/CharacterMention.test.tsx" "src/lib/api/skillCatalog.test.ts" "src/lib/api/serviceSkills.test.ts"` -- 如有改动扩散到 `service_scene_launch` runtime、`lime_run_service_skill`、OEM scene run/poll 或云端结果回流,再补对应 `runtime_turn` / `prompt_context` / `tool_runtime/service_skill_tools` 定向测试 +- 如有改动扩散到 `service_scene_launch` runtime、`lime_run_service_skill`、本地 service-scene 执行桥或 compat 结果回流,再补对应 `runtime_turn` / `prompt_context` / `tool_runtime/service_skill_tools` 定向测试 - 若 `service_scene_launch` / harness 协议继续扩散,再补 `npm run test:contracts` - 若 GUI 主路径受影响,再补 `npm run verify:gui-smoke` diff --git a/docs/aiprompts/skill-standard.md b/docs/aiprompts/skill-standard.md index c6b79cde6..25bf5d3de 100644 --- a/docs/aiprompts/skill-standard.md +++ b/docs/aiprompts/skill-standard.md @@ -251,9 +251,13 @@ Lime 的技能标准必须分成五层: - `agent_turn` - `browser_assist` - `automation_job` -- `cloud_scene` - `native_skill` +兼容说明: + +- `cloud_scene` 只允许作为历史目录输入或 compat binding family 理解 +- current 客户端主链必须先把它正规化为 `agent_turn / service_scene` 语义,再继续进入工作区执行 + 运行时层回答的是“怎么执行”,不是“对用户如何命名”。 ### 5. 分发层 @@ -345,10 +349,10 @@ Lime 技能能力必须明确区分四个对象: | 模式 | 回答什么 | 什么时候优先用 | 常用目录 | 常见运行时搭配 | |------|------|------|------|------| | `Tool Wrapper` | 如何把某个库、站点、协议、规范封装成专家上下文 | 封装 framework、site adapter、browser protocol、内部规范 | `references/`、`scripts/` | `browser_assist`、`native_skill`、`agent_turn` | -| `Generator` | 如何稳定地产出结构化结果 | 输出模板固定、格式不能漂移 | `assets/`、`references/` | `agent_turn`、`cloud_scene`、Artifact | +| `Generator` | 如何稳定地产出结构化结果 | 输出模板固定、格式不能漂移 | `assets/`、`references/` | `agent_turn`、Artifact | | `Reviewer` | 如何按 checklist 打分、归类严重性、提出修复建议 | QA、合规、发布前复核、代码审查 | `references/checklist*.md` | `agent_turn`、review/evidence | | `Inversion` | 如何先提问、补参、过 gate,再继续执行 | 需求不完整、项目/权限/账号/审批门禁 | 问题清单、gate template | `slotSchema`、`scene gate`、`a2ui` | -| `Pipeline` | 如何强制按顺序执行多步流程,并在 checkpoint 处停住 | 多步任务、外部依赖多、不能跳步 | `references/`、`assets/`、`scripts/` 全部都可能 | `agent_turn`、`browser_assist`、`automation_job`、`cloud_scene` | +| `Pipeline` | 如何强制按顺序执行多步流程,并在 checkpoint 处停住 | 多步任务、外部依赖多、不能跳步 | `references/`、`assets/`、`scripts/` 全部都可能 | `agent_turn`、`browser_assist`、`automation_job`、`native_skill` | 固定规则: @@ -594,16 +598,17 @@ ClaudeCode 对 Lime 最值得借鉴的,不是“再做一个 ClaudeCode skills - 必须说明首轮结果、后续调度、失败处理和结果回流方式 -### 4. `cloud_scene` +### 4. compat:`cloud_scene` 适用于: -- 必须由云端托管执行的技能 +- 只用于读取历史目录、旧 planner 或旧 skill 包里的 compat binding 要求: -- 客户端默认只做目录消费、提交和结果回流 -- 不把普通本地即时技能错误迁成云端必跑 +- current 文档、当前前台对象和当前运行时都不应再把它描述成“云端托管执行” +- 客户端命中后必须先正规化成 `agent_turn / service_scene` 语义,再继续本地执行 +- 不把普通本地即时技能错误迁回旧 `cloud_scene` 叙事 ## UI 表达标准 diff --git a/docs/exec-plans/README.md b/docs/exec-plans/README.md index 1aac1960a..7bb4ec635 100644 --- a/docs/exec-plans/README.md +++ b/docs/exec-plans/README.md @@ -26,6 +26,7 @@ - 路线图主线:`docs/roadmap/` - 参考运行时主链总计划:`docs/exec-plans/upstream-runtime-alignment-plan.md` - 参考运行时主链进度日志:`docs/exec-plans/upstream-runtime-alignment-progress.md` +- `@` 命令本地执行纠偏计划:`docs/exec-plans/at-command-local-execution-alignment-plan.md` - LimeNext 总实施计划:`docs/exec-plans/limenext-plan.md` - LimeNext 推进日志:`docs/exec-plans/limenext-progress.md` - 技术债追踪:`docs/exec-plans/tech-debt-tracker.md` diff --git a/docs/exec-plans/at-command-local-execution-alignment-plan.md b/docs/exec-plans/at-command-local-execution-alignment-plan.md new file mode 100644 index 000000000..bfeb542b2 --- /dev/null +++ b/docs/exec-plans/at-command-local-execution-alignment-plan.md @@ -0,0 +1,197 @@ +# `@` 命令本地执行面纠偏计划 + +## 摘要 + +本计划用于把 Lime 客户端仓库中关于 `@` 命令、产品型 `/scene`、`ServiceSkill` 的 current 文档收口到同一个正确前提: + +- `limecore` 只负责目录控制、发布治理与配置同步 +- `lime` 客户端负责全部执行 +- 不存在服务端代跑 `@` 命令、`/scene` 或 `service skill` 的 current 主链 + +这份计划只覆盖客户端仓库的文档与后续本地实现收口,同时定义服务端文档完成后的跨仓校验要求。 + +## 背景 + +最近两仓围绕 Ribbi 风格统一调用面的分析,暴露出一个持续回流的错误前提: + +1. 一部分文档把 `limecore` 写成了命令执行面 +2. 一部分文档把 `scene runtime`、`run / poll`、`cloud_required` 继续当成 current 产品模型 +3. 这会误导后续实现,把目录控制面继续扩成运行时 + +用户已明确产品边界: + +1. 云端不会执行任何东西 +2. `limecore` 只承担 `client/bootstrap`、`client/skills`、`client/service-skills` 这类目录与配置事实源 +3. Lime 客户端是唯一执行面 + +因此,客户端 current 文档必须先于代码实现完成纠偏,否则新增 `@` 命令会继续叠加在错误边界上。 + +## 正确边界 + +### `current` + +- `SkillCatalog.entries` 作为 `@ / / / skill` 的统一目录协议 +- `serviceSkillCatalog` 作为完整服务型技能目录 +- seeded / fallback 作为客户端韧性兜底 +- `request_metadata.harness.*` 作为本地运行时路由提示 +- Agent、tool、browser、workspace、task、viewer 全部留在本地执行 + +### `deprecated` + +- `cloud_required` +- `executionLocation = cloud_required` +- `OEM Scene Runtime run / poll` +- `lime_run_service_skill` 被解释成服务端执行入口 +- `cloud-video-dubbing` 这类带云语义的历史命名 + +### `dead` + +- 服务端代跑 `@` 命令 +- 服务端代跑 `/scene` +- 服务端代跑 `service skill` +- 任何把目录命中直接翻译成云端 run 的 current 叙事 + +## 本轮文档改动 + +需要优先收口以下客户端 current 文档: + +1. `docs/aiprompts/commands.md` +2. `docs/aiprompts/command-runtime.md` +3. `docs/aiprompts/limecore-collaboration-entry.md` +4. `docs/roadmap/lime-service-skill-cloud-config-prd.md` + +改动原则: + +1. 删除或改写 `run / poll`、`cloud_required`、`scene runtime` 的 current 叙事 +2. 如果现有实现或历史 ID 仍沿用旧命名,文档里要明确它只是历史命名,不代表当前边界 +3. 不新发明第二套术语,统一收敛到“目录控制面 + 本地执行面” + +## 后续实现顺序 + +### Phase 1:文档先行 + +- 先把 current 文档纠偏 +- 在实现前统一团队和 AI 的事实源 + +### Phase 2:客户端代码收口 + +后续只在本仓库推进以下本地实现检查: + +- `src/components/agent/chat/workspace/useWorkspaceSendActions.ts` +- `src/components/agent/chat/workspace/serviceSkillSceneLaunch.ts` +- `src/components/agent/chat/service-skills/*` +- `src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.ts` +- `src/lib/base-setup/seededCommandPackage.ts` +- `src/lib/base-setup/seededServiceSkillPackage.ts` +- `src/components/settings-v2/system/automation/serviceSkillContext.ts` + +目标: + +- 去掉任何 current 主链中的云端 run / poll 假设 +- 保留目录命中与 metadata 注入 +- 把 `@配音`、`/scene-key`、service skill launch 全部收回本地执行链 + +### Phase 3:服务端完成后的联调校验 + +服务端会由另一个进程按 `limecore/docs/exec-plans/at-command-control-plane-only.md` 推进。客户端这边在服务端文档完成后,需要补一次跨仓一致性校验。 + +## 校验要求 + +### 客户端本地校验 + +至少重新检查: + +- `@配音` +- `/scene-key` +- `@浏览器` +- `@搜索` +- `@发布` +- `@渠道预览` + +校验点: + +1. 在线目录可命中 +2. seeded / fallback 不失能 +3. current 文档不再暗示云执行 +4. 轻卡、timeline、viewer 仍以本地运行态为真相 + +### 跨仓校验 + +服务端文档完成后,再做一次双仓扫描: + +- `cloud_required` +- `云执行` +- `run / poll` +- `scene runtime` +- `not_executable_in_cloud` + +扫描结果必须区分为: + +1. 已移除 +2. 仅保留在历史参考 +3. 仍误留在 current,需要继续收口 + +## 当前状态 + +- 2026-04-21:创建本计划,作为客户端纠偏的 current 执行入口 +- 2026-04-21:与用户确认真实产品边界为“云端不执行,只做目录控制” +- 2026-04-21:本轮优先目标固定为“先改文档事实源,再进入客户端实现收口” +- 2026-04-21:已完成 current 文档纠偏,统一改写为“云端只做目录控制,客户端负责执行” +- 2026-04-21:已继续同步质量事实源;`quality-workflow` 中的 `@配音 / /scene-key` 校验口径已从 `OEM run/timeline` 收回到本地 `service-scene` 直驱执行与 `ServiceSkill / tool timeline` +- 2026-04-21:已继续收口 Rust `service_scene_launch` current 默认值;后端当前默认按 `local_service_skill` 解析,并只把 `cloud_scene` 当 compat alias 读取 +- 2026-04-21:已完成客户端 current 主链收口,`/scene` 非站点技能与 `@配音` 不再依赖 OEM run / poll,而是统一生成本地 `composeServiceSkillPrompt(...)` 并通过工作区/Agent 本地执行 +- 2026-04-21:已完成 service skill 首页、@ 提及面板、设置自动化文案的执行语义纠偏;`cloud_required` 当前只作为兼容目录标记展示,不再表示云端执行 +- 2026-04-21:已继续收口 automation 设置页 current 展示;任务列表、详情弹窗和运行摘要里的 `cloud_required` 现在会显式显示为“客户端执行 + 旧目录兼容”,不再把 compat 标记伪装成当前执行真相 +- 2026-04-21:已完成技能工作台 current surface 的旧云运行状态收口,`ServiceSkillHomeItem` 与技能页展示不再消费 `cloudStatus` +- 2026-04-21:已删除零入口 compat 残留 `src/components/agent/chat/service-skills/cloudRunStorage.ts` 与 `src/lib/api/serviceSkillRuns.ts`,避免旧云 run 语义继续回流 +- 2026-04-21:已进一步把 current seeded package 主动产出的 `cloud_scene / cloud-scene-instant` 收掉;当前只保留历史 seeded id(如 `cloud-video-dubbing`)与 compat projection/sceneapp 中的旧命名,避免 current 目录事实源继续给云执行语义续命 +- 2026-04-21:已继续收口 sceneapp current 主链;Tauri / mock sceneapp planner 产出的 `service_scene_launch.kind` 与 `execution_kind` 已统一改为 `local_service_skill`,并补 `execution_location = client_default` +- 2026-04-21:已移除 current seeded sceneapp 中额外的 `cloud_session` 启动前置,避免 sceneapp detail/runtime 继续把“云端运行时”误当成当前执行门槛 +- 2026-04-21:已继续收口 sceneapp 恢复链内部命名;run summary / entry action / resume helper 中的 `cloud_scene_runtime_ref`、`cloudSceneRuntimeRef`、`open_cloud_scene_session` 已统一改成 `service_scene` 语义,并保留 Rust 侧 alias 兼容旧字段 +- 2026-04-21:已完成最小交付校验,`npm run test:contracts`、定向 Vitest、`npm run verify:local`、`npm run governance:legacy-report` 全部通过 +- 2026-04-21:已补充 sceneapp 定向校验;`npm run verify:gui-smoke` 与 sceneapp 定向 Vitest 已通过,Rust service-scene 新用例改名后需按当前测试名单独补测 +- 2026-04-21:已继续收口 sceneapp current planner/action 命名;当前 Tauri/mock planner 改为产出 `open_service_scene_session`,旧 `launch_cloud_scene` 仅保留为 compat 反序列化输入 +- 2026-04-21:已把 seeded `voice-runtime` 从 `cloud_managed` current 模型收回到本地即时类型;当前种子目录不再把它描述成“云托管场景” +- 2026-04-21:已继续收口 sceneapp runtime context;`cloud_session_ready` 不再参与 current readiness / tool refs 注入,只再保留为 compat 输入字段,避免 current planning 再把“云会话”带回执行语义 +- 2026-04-21:已继续收口 sceneapp seeded/mock 原始目录展示;current `capabilityRefs`、`infraProfile` 与 composition step binding 不再主动写出 `cloud_scene / cloud_runtime`,旧 `cloud_scene` 只再保留在 service-scene adapter 锚点与 compat 输入 +- 2026-04-21:已继续收口 sceneapp current entry binding;seeded/mock 里的 `story-video-suite`、`voice-runtime` 当前入口已改成 `agent_turn`,Rust/mock planner 会按 service-scene 特征继续产出 `open_service_scene_session` +- 2026-04-21:已补跑本轮相关前端定向回归;`useWorkspaceSendActions`、`sceneapp` API/目录/展示与 SceneApps 页面共 8 个 Vitest 文件、154 条用例通过 +- 2026-04-21:已补跑 `npm run governance:legacy-report`,结果为 `边界违规 0 / 分类漂移候选 0` +- 2026-04-21:已继续收口 sceneapp 展示层 current 边界;`presentation/product` 与 SceneApps 页面测试夹具默认改成 `agent_turn`,展示层不再主动持有 `cloud_scene` current 标签 +- 2026-04-21:Rust 定向测试 `cargo test should_build_service_scene_adapter_plan_for_hybrid_sceneapp` 因工作区存在多路并发 `cargo test` 抢占 `target`/artifact lock,未拿到最终结果;本轮不把它记为已通过,后续需在较干净的 Rust 构建窗口补测 +- 2026-04-21:已尝试用独立 `CARGO_TARGET_DIR=/tmp/lime-target-codex-sceneapp` 复测 Rust service-scene 新用例,但首次编译 `aster`/`lime` 依赖链耗时过长,本轮仍未拿到最终结果;为避免持续占用机器资源,已主动停止该测试进程,后续需在更稳定的 Rust 构建窗口补跑 + +## 本轮实现结果 + +### 已完成 + +- `/scene` 非站点技能:改为构造 `kind = local_service_skill` 的 request metadata,并生成本地 prompt;不再向 current 主链注入 `oem_runtime` 或云端 session 语义 +- `@配音`:保留用户可见原文,但底层 dispatch 改为 `composeServiceSkillPrompt(...)` 生成的本地 prompt;metadata 同步切换到 `local_service_skill` +- service skill 入口点击:删除 current 对 `createServiceSkillRun` / `getServiceSkillRun` / run-poll 的依赖,统一进入本地工作区准备态 +- 首页与提及展示:`runnerLabel`、`runnerDescription`、`actionLabel`、`outputDestination` 改为本地执行叙事;legacy `cloud_required` 只按兼容目录标记处理 +- automation 设置页展示:`service skill` 任务列表、详情弹窗与运行摘要当前都会补显 `旧目录兼容` 徽标与说明文案;即使 run metadata 已被正规化为 `client_default`,只要来源 job 仍带旧目录标记,也不会在 current 展示层把 compat 信息洗掉 +- Rust `service_scene_launch` 解析:当前默认 `kind` 已切到 `local_service_skill`,并允许 `cloud_scene` 只作为 compat alias 继续读取;`aster_agent_cmd` 定向测试样例也已同步改成 current 命名 +- Rust `prompt_context / lime_run_service_skill`:当前系统提示已改成“本地 service-scene 直驱执行”,`lime_run_service_skill` 只再作为 compat 护栏保留,不再发 OEM `/runs` 请求 +- seeded 目录:保留历史 id 以避免目录断裂,但 `@配音` 的 command binding 已切到 `agent_turn`,技能标题/摘要已改成当前本地执行语义 +- sceneapp planner:Tauri 与 mock 当前改为产出 `open_service_scene_session`;旧 `launch_cloud_scene` 只作为 compat 反序列化输入保留,避免历史 planner / fixture 直接失效 +- sceneapp seeded / fallback:当前目录不再额外声明 `cloud_session` 启动前置;sceneapp detail 展示会继续聚焦“目录控制面 + 客户端执行”,不再把云端运行时写成 current 门槛 +- sceneapp seeded voice-runtime:当前目录模型已改成 `local_instant` + 本地执行文案;旧 `cloud_managed` 只再保留给 compat 目录输入与展示兜底 +- sceneapp runtime context:当前 context compiler 不再把 `cloud_session` 注入 tool refs,且 runtime readiness 对 `cloud_session` 只按 compat no-op 处理;这样历史字段还能读,但不会再影响 current 启动判断 +- sceneapp 恢复链:当前做法复盘和继续执行链已统一改为 `service_scene` 命名;旧 `cloud_scene_runtime_ref` 只再作为 Rust 反序列化 alias 保留,避免历史 run summary 数据读取失败 +- mention / sceneapp 内部命名:聊天发送边界里的 usage slot 提取已统一改成 `service_scene` 语义,sceneapp current seeded/mock 目录的展示能力摘要也已改成 `agent_turn` / 本地工作区表述,避免 current 实现继续把内部变量写成“云场景” +- sceneapp current service-scene 入口:当前 seeded/mock 目录不再依赖 `entryBindings.bindingFamily = cloud_scene` 才能走本地场景执行;planner 会在 `agent_turn` current 目录下按 linked service skill / scene key 继续产出 `open_service_scene_session` +- sceneapp 展示层:`presentation/product` 与 SceneApps 页面当前测试夹具、组合步骤、入口绑定和执行摘要默认都已切到 `agent_turn`;`cloud_scene` 不再作为 current UI 文案映射保留在展示层 + +### 仍保留为 compat / 历史锚点 + +- `cloud-video-dubbing` +- `cloud_scene` +- `executionLocation = cloud_required` +- `launch_cloud_scene` / `open_cloud_scene_session` + +这些字段当前仍存在于历史 skill id、compat projection、sceneapp 规划与测试中,用于兼容历史目录和旧数据,不再代表 current 执行边界;其中 current seeded package 已不再主动产出 `cloud_scene / cloud-scene-instant`,sceneapp current planner 也不再主动写出 `service_scene_launch.kind = cloud_scene`。 + +## 下一步 + +- 等服务端进程完成 `limecore` 文档与目录控制面实现后,按本计划里的“跨仓校验”重新扫描双仓术语一致性 +- 如果后续要继续做减法,优先清理 `sceneapp` 与 compat projection 中仍残留的 `cloud_scene` 历史命名,但这不阻塞当前客户端本地执行主链交付 diff --git a/docs/exec-plans/limenext-progress.md b/docs/exec-plans/limenext-progress.md index f6483853c..7087e98e9 100644 --- a/docs/exec-plans/limenext-progress.md +++ b/docs/exec-plans/limenext-progress.md @@ -6,10 +6,584 @@ > > 补充说明(2026-04-18):旧 Ribbi 过渡目录已清理。历史条目若继续出现旧 Ribbi 路径,默认按“当时旧路径、现已由 `docs/research/ribbi/*` 替代”理解。 +## 2026-04-21 + +### 已完成 + +- 把 Rust `service_scene_launch` 提取器的 current 默认值收回 `local_service_skill`,避免前端 metadata 已改成本地执行,但后端解析层仍把缺省 `kind` 视作 `cloud_scene`: + - 已更新: + - `src-tauri/src/commands/aster_agent_cmd/service_skill_launch.rs` + - `src-tauri/src/commands/aster_agent_cmd/tool_runtime/service_skill_tools.rs` + - `src-tauri/src/commands/aster_agent_cmd/tests.rs` + - 当前统一结论: + - `extract_service_scene_launch_context(...)` 当前默认按 `local_service_skill` 解析 + - Rust 侧当前同时接受 `local_service_skill / cloud_scene`,其中前者是 current,后者只再作为 compat alias + - `aster_agent_cmd` 的 current 定向测试样例也已切到 `local_service_skill`,避免测试事实源继续把 `cloud_scene` 当默认值 + - 当前计划中的最小验证: + - `rustfmt --edition 2021 "src-tauri/src/commands/aster_agent_cmd/service_skill_launch.rs" "src-tauri/src/commands/aster_agent_cmd/tool_runtime/service_skill_tools.rs" "src-tauri/src/commands/aster_agent_cmd/tests.rs"` + - `cargo test --manifest-path "src-tauri/Cargo.toml" service_scene_launch` + - `cargo test --manifest-path "src-tauri/Cargo.toml" should_extract_launch_context_from_environment_payload -- --exact` + - 本轮验证备注: + - 已完成 `rustfmt --edition 2021` + - 上述两条 Rust 定向测试会触发 `aster` crate 长时间重编;本轮已发起但未在可接受窗口内拿到最终结果,已主动中止,不记为已通过 + +- 把 `quality-workflow` 中仍残留的 `OEM run/timeline` 旧叙事改回 current 本地执行事实源,避免后续按质量清单做回归时又把 `@配音 / /scene-key` 理解成云执行链: + - 已更新: + - `docs/aiprompts/quality-workflow.md` + - 当前统一结论: + - `@配音` 的质量清单当前明确收口为 `service_scene_launch -> 本地 service-scene 直驱执行 -> 本地 ServiceSkill / tool timeline` + - `/scene-key` 的质量清单当前也明确收口为 `service_scene_launch -> 本地 service-scene 直驱执行 -> tool timeline` 主链,不再允许用 `OEM run/timeline` 或“Rust 侧云执行分支”描述 current 事实 + - `lime_run_service_skill` 当前已只作为 compat 护栏保留,不再允许在质量清单里被写成 current 执行桥 + - `service_scene_launch runtime` 的补充校验当前只再围绕本地 service-scene 执行桥与 compat 结果回流,不再暗示云端 run/poll + +- 把 `automation / service skill` 的 current 消费面继续收回“客户端执行 + 兼容目录标记”语义,避免设置页任务列表、详情弹窗和运行摘要仍把 `cloud_required` 展示成无差别的当前执行位置: + - 已更新: + - `src/components/settings-v2/system/automation/serviceSkillContext.ts` + - `src/components/settings-v2/system/automation/serviceSkillContext.test.ts` + - `src/components/settings-v2/system/automation/index.tsx` + - `src/components/settings-v2/system/automation/index.test.tsx` + - `src/components/settings-v2/system/automation/AutomationJobDetailsDialog.tsx` + - 当前统一结论: + - `cloud_required` 当前在 automation 上下文里会继续显示为 `客户端执行`,但会额外标记为 `旧目录兼容` + - 任务列表、任务详情和运行摘要当前都会把 compat 信息显式展示成“沿用旧目录兼容标记,实际仍在客户端执行”,不再让旧目录字段伪装成 current 执行面 + - `run metadata = client_default`、`job payload = cloud_required` 这类混合输入当前也会继续保留 compat 标记,避免运行层正规化后把旧目录来源洗掉 + - 当前计划中的最小验证: + - `npx eslint "src/components/settings-v2/system/automation/serviceSkillContext.ts" "src/components/settings-v2/system/automation/serviceSkillContext.test.ts" "src/components/settings-v2/system/automation/index.tsx" "src/components/settings-v2/system/automation/index.test.tsx" "src/components/settings-v2/system/automation/AutomationJobDetailsDialog.tsx"` + - `npm exec vitest run "src/components/settings-v2/system/automation/serviceSkillContext.test.ts" "src/components/settings-v2/system/automation/index.test.tsx"` + +- 把 `service skill` 的旧云特例执行暗示继续压回“目录兼容标记”,避免 current 文档已经切到“目录云同步、本地执行”,但入口投影和工作区测试仍在暗示 `cloud_required = 云端执行`: + - 已更新: + - `src/lib/api/serviceSkills.ts` + - `src/components/agent/chat/service-skills/promptComposer.ts` + - `src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.test.tsx` + - 当前统一结论: + - `cloud_required` 当前只再表示旧目录兼容标记,不再表示“云端完成后回流” + - `ServiceSkill` bundle 推导出的 `output destination / compatibility` 当前都已收口到客户端执行语义 + - `workspace service skill entry` 的 current 测试也已同步改成“legacy 标记 -> 仍走本地执行主链”,并清掉未被当前主链使用的旧 `serviceSkillRuns` mock + - 当前计划中的最小验证: + - `npx eslint "src/lib/api/serviceSkills.ts" "src/components/agent/chat/service-skills/promptComposer.ts" "src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.test.tsx"` + - `npm exec vitest run "src/lib/api/serviceSkills.test.ts" "src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.test.tsx"` + +- 把 `Base Setup -> ServiceSkillCatalog` 的 compat 编译投影也收回 current 本地执行语义,避免包里还沿用 `cloud_scene / cloud_required` 时,前台 catalog item 又重新把它当成当前执行真相: + - 已更新: + - `src/lib/base-setup/compat/serviceSkillCatalogProjection.ts` + - `src/lib/api/skillCatalog.test.ts` + - 当前统一结论: + - `bindingFamily = cloud_scene` 当前在编译成 `ServiceSkillItem` 时会被正规化成 `defaultExecutorBinding = agent_turn` + - `executionLocation = cloud_required` 当前在编译投影里也会被正规化成 `client_default` + - 因此前台消费到的 `ServiceSkillCatalog / SkillCatalogItem` 当前不再把旧包标记继续投射成 current 执行边界;旧标记当前只允许停留在 legacy Base Setup / 远端 compat 输入层 + - 当前计划中的最小验证: + - `npx eslint "src/lib/base-setup/compat/serviceSkillCatalogProjection.ts" "src/lib/api/skillCatalog.test.ts"` + - `npm exec vitest run "src/lib/base-setup/seededServiceSkillPackage.test.ts" "src/lib/api/skillCatalog.test.ts" "src/lib/base-setup/compat/commandCatalogProjection.test.ts"` + +- 把 `Base Setup -> command/scene catalog` 的 compat execution kind 也往 current 本地语义收回,避免目录项明明已经只负责本地路由提示,却还继续对外暴露 `cloud_scene` 作为 current 执行真相: + - 已更新: + - `src/lib/base-setup/compat/commandCatalogProjection.ts` + - `src/lib/base-setup/compat/sceneCatalogProjection.ts` + - `src/lib/base-setup/compat/commandCatalogProjection.test.ts` + - `src/lib/api/skillCatalog.test.ts` + - 当前统一结论: + - `bindingFamily = cloud_scene` 当前在编译成 `command/scene` projection 时会被正规化成 `executionKind = agent_turn` + - `scene_detail` 这类 viewer/render contract 当前保持不变;只收口 current 执行语义,不改结果展示合同 + - 因此前台目录里的 command/scene 当前不再把旧 `cloud_scene` 继续显影成当前执行面;它当前只再是 legacy Base Setup / compat projection 的输入信号 + - 当前计划中的最小验证: + - `npx eslint "src/lib/base-setup/compat/commandCatalogProjection.ts" "src/lib/base-setup/compat/sceneCatalogProjection.ts" "src/lib/base-setup/compat/commandCatalogProjection.test.ts" "src/lib/api/skillCatalog.test.ts"` + - `npm exec vitest run "src/lib/base-setup/compat/commandCatalogProjection.test.ts" "src/lib/base-setup/seededCommandPackage.test.ts" "src/lib/api/skillCatalog.test.ts"` + +- 把 current seeded package 里还在主动产出的 `cloud_scene / cloud-scene-instant` dead compat surface 直接删掉,避免目录编译已经正规化成本地执行,但源包本身还继续给旧云语义续命: + - 已更新: + - `src/lib/base-setup/seededCommandPackage.ts` + - `src/lib/base-setup/seededServiceSkillPackage.ts` + - `src/lib/base-setup/seededCommandPackage.test.ts` + - `src/lib/base-setup/seededServiceSkillPackage.test.ts` + - 当前统一结论: + - `seededCommandPackage` 当前不再保留 `cloud-scene-instant` binding profile,也不再声明 `cloud_scene` kernel capability + - `seededServiceSkillPackage` 当前也不再保留 `cloud-scene-instant` binding profile 或 `cloud_scene` capability;`cloud-video-dubbing` 仅保留历史 skill id,不再携带云执行 binding + - 因此 current Base Setup 事实源当前已不再主动产出 `cloud_scene` 执行面;剩余旧命名仅留在 compat projection、sceneapp 规划与历史数据锚点 + - 当前计划中的最小验证: + - `npx eslint "src/lib/base-setup/seededCommandPackage.ts" "src/lib/base-setup/seededServiceSkillPackage.ts" "src/lib/base-setup/seededCommandPackage.test.ts" "src/lib/base-setup/seededServiceSkillPackage.test.ts"` + - `npm exec vitest run "src/lib/base-setup/seededCommandPackage.test.ts" "src/lib/base-setup/seededServiceSkillPackage.test.ts"` + - `npm run typecheck` + +- 给 `cloud_required / cloud_scene` 的类型定义补上 legacy compat 护栏注释,避免后续实现再次把“仍保留 union 分支”误读成“仍允许 current 云执行”: + - 已更新: + - `src/lib/base-setup/types.ts` + - `src/lib/api/serviceSkills.ts` + - `src/lib/api/skillCatalog.ts` + - 当前统一结论: + - `cloud_required` 当前在类型层被明确标注为旧目录兼容标记 + - `cloud_scene` 当前在 Base Setup / ServiceSkill / SkillCatalog 三处类型层都被明确标注为 legacy compat input,而不是 current 执行面 + - 当前计划中的最小验证: + - `npx eslint "src/lib/base-setup/types.ts" "src/lib/api/serviceSkills.ts" "src/lib/api/skillCatalog.ts"` + - `npm run typecheck` + +- 把 `SkillCatalog` 的 raw compat 输入也收回 current 本地执行语义,避免远端缓存或旧目录把 `cloud_scene` 直接写进前台目录对象后,又被 UI 当成当前执行面: + - 已更新: + - `src/lib/api/skillCatalog.ts` + - `src/lib/api/skillCatalog.test.ts` + - 当前统一结论: + - raw `skillCatalog.items[*].execution.kind = cloud_scene` 当前会在解析时正规化成 `agent_turn` + - raw `command.binding.executionKind = cloud_scene` 与 `scene.executionKind = cloud_scene` 当前也会一并正规化成 `agent_turn` + - 旧目录若仍依赖 `defaultExecutorBinding = cloud_scene` 或 `executionLocation = cloud_required` 自动生成 scene entry,当前仍可保留 auto scene 行为,但前台对象不会再显影 `cloud_scene` + - 因此 `SkillCatalog` 当前对象、缓存落盘与 UI 消费层继续统一回到本地执行语义;`cloud_scene` 只再允许停留在 compat 输入层 + - 当前计划中的最小验证: + - `npx eslint "src/lib/api/skillCatalog.ts" "src/lib/api/skillCatalog.test.ts"` + - `npm exec vitest run "src/lib/api/skillCatalog.test.ts"` + - `npm run typecheck` + +- 把 `ServiceSkill.skillBundle.metadata` 的 compat 泄漏也收回 current 本地语义,避免展示文案已经纠偏,但自动补齐的 bundle metadata 仍把 `cloud_required / cloud_scene` 写回当前前台对象: + - 已更新: + - `src/lib/api/serviceSkills.ts` + - `src/lib/api/serviceSkills.test.ts` + - 当前统一结论: + - 旧版 compat 服务技能若缺少 `skillBundle`,当前自动派生的 `Lime_execution_location` 会正规化为 `client_default` + - 同一条链路下的 `Lime_executor_binding` 当前也会正规化为 `agent_turn` + - `ServiceSkillItem` 原始 compat 字段仍可保留用于目录兼容,但当前 bundle metadata 不会再把它们显影成当前执行真相 + - 当前计划中的最小验证: + - `npx eslint "src/lib/api/serviceSkills.ts" "src/lib/api/serviceSkills.test.ts"` + - `npm exec vitest run "src/lib/api/serviceSkills.test.ts"` + - `npm run typecheck` + +- 把 `sceneapp/catalog` 的目录描述对象也继续去云执行化,避免 current 目录页虽然已经只谈“做法 / 本地执行”,但 `SceneAppDescriptor` 仍向前台显影 `cloud_scene / cloud_session / cloud_runtime`: + - 已更新: + - `src/lib/sceneapp/catalog.ts` + - `src/lib/sceneapp/catalog.test.ts` + - 当前统一结论: + - compat 输入里的 `bindingFamily = cloud_scene` 当前在 `SceneAppDescriptor.entryBindings / compositionProfile.steps` 中会正规化成 `agent_turn` + - compat 输入里的 `capabilityRefs = [cloud_scene]` 当前也会在目录描述对象里正规化成 `agent_turn` + - `launchRequirements` 当前不再因为 `cloud_scene / cloud_required` 自动长出 `cloud_session` + - `infraProfile` 当前也不再继续显影 `cloud_runtime` + - 单一旧 `cloud_scene` SceneApp 当前会在目录层被理解为 `local_instant`;混合编排场景则继续保留 `hybrid`,但不再把 compat 云绑定显影成当前执行真相 + - 当前计划中的最小验证: + - `npx eslint "src/lib/sceneapp/catalog.ts" "src/lib/sceneapp/catalog.test.ts"` + - `npm exec vitest run "src/lib/sceneapp/catalog.test.ts"` + - `npm run typecheck` + +- 把 `sceneapp presentation / product` 的 compat 展示词也收回 current 口径,避免目录对象已经正规化,但详情页和卡片摘要仍把 `cloud_scene / cloud_managed / cloud_runtime` 讲成当前产品语义: + - 已更新: + - `src/lib/sceneapp/presentation.ts` + - `src/lib/sceneapp/presentation.test.ts` + - `src/lib/sceneapp/product.ts` + - `src/lib/sceneapp/product.test.ts` + - 当前统一结论: + - compat `cloud_scene` 当前在展示层不再显示为“场景技能”,统一收成 `Agent 工作区` + - compat `cloud_runtime` 当前在基础设施摘要里不再显示为“目录控制面”,统一收成 `目录同步` + - compat `cloud_managed` 当前在类型标签与 fallback copy 里不再显示为“目录托管”,统一收成 `目录同步` + - 因此即使后续还有 compat descriptor 漏进展示层,前台也不会再继续讲“云执行/托管”叙事 + - 当前计划中的最小验证: + - `npx eslint "src/lib/sceneapp/presentation.ts" "src/lib/sceneapp/presentation.test.ts" "src/lib/sceneapp/product.ts" "src/lib/sceneapp/product.test.ts"` + - `npm exec vitest run "src/lib/sceneapp/presentation.test.ts" "src/lib/sceneapp/product.test.ts"` + - `npm run typecheck` + +- 把 `sceneapp launch` 的 current 启动草稿也继续收回本地执行主链,避免 compat `launch_cloud_scene` 还通过启动文案、notes 和 draft 适配器继续向工作区泄露旧“场景技能”语义: + - 已更新: + - `src/lib/sceneapp/launch.ts` + - `src/lib/sceneapp/launch.test.ts` + - `src/lib/tauri-mock/core.ts` + - 当前统一结论: + - `launch_cloud_scene` 当前仍保留为 compat runtime action 名,不直接删协议 + - 但 `workspace execution draft.adapterKind` 当前已正规化成 `agent_turn` + - `launch` 输出的 notes 当前会把“场景技能主链 / 场景技能入口”统一收成 `Agent 工作区主链 / Agent 工作区入口` + - `launch_cloud_scene` 在无显式用户输入时的 current prompt 也已改成“执行做法 / 在 Agent 工作区推进”,不再显影旧云场景语气 + - mock planner 与注入工具说明同步改成 current 口径,避免 dev/mock 场景继续把旧词带回前台 + - 当前计划中的最小验证: + - `npx eslint "src/lib/sceneapp/launch.ts" "src/lib/sceneapp/launch.test.ts" "src/lib/tauri-mock/core.ts"` + - `npm exec vitest run "src/lib/sceneapp/launch.test.ts" "src/components/agent/chat/workspace/sceneAppLaunch.test.ts" "src/components/agent/chat/workspace/useWorkspaceSceneAppEntryActions.test.tsx"` + - `npm run typecheck` + +- 把 `sceneapp run detail -> entry action` 的 current 文案也收回 `生成` 主执行面,避免结果页仍把 compat `open_service_scene_session` 讲成“场景会话 / 场景上下文”: + - 已更新: + - `src/lib/sceneapp/product.ts` + - `src/lib/sceneapp/product.test.ts` + - `src/lib/sceneapp/runEntryNavigation.ts` + - `src/lib/sceneapp/runEntryNavigation.test.ts` + - `src/components/sceneapps/SceneAppsPage.test.tsx` + - 当前统一结论: + - compat action kind `open_service_scene_session` 当前继续保留为内部协议,不直接删名 + - 但 run detail entry action 的用户可见标签当前已收成 `回到生成会话 / 恢复生成上下文` + - 结果页跳转到 Agent 页时的 banner 文案也同步改成 `恢复生成会话 / 恢复生成上下文` + - 因此 current 结果视图不再继续把 `sceneapp` 的恢复动作讲成另一套“场景会话”产品面 + - 当前计划中的最小验证: + - `npx eslint "src/lib/sceneapp/product.ts" "src/lib/sceneapp/product.test.ts" "src/lib/sceneapp/runEntryNavigation.ts" "src/lib/sceneapp/runEntryNavigation.test.ts" "src/components/sceneapps/SceneAppsPage.test.tsx"` + - `npm exec vitest run "src/lib/sceneapp/product.test.ts" "src/lib/sceneapp/runEntryNavigation.test.ts" "src/components/sceneapps/SceneAppsPage.test.tsx"` + - `npm run typecheck` + +- 把 `sceneapp API` 的 raw compat planner 输出也收回 current 对象语义,避免前面 catalog / launch / product 都已经正规化,但 `safeInvoke(sceneapp_*)` 仍直接把 `cloud_scene / launch_cloud_scene / cloud_runtime` 透给前端: + - 已更新: + - `src/lib/api/sceneapp.ts` + - `src/lib/api/sceneapp.test.ts` + - 当前统一结论: + - `listSceneAppCatalog` / `getSceneAppDescriptor` 当前会先正规化 descriptor: + - `capabilityRefs.cloud_scene -> agent_turn` + - `entryBindings.bindingFamily.cloud_scene -> agent_turn` + - `compositionProfile.steps.bindingFamily.cloud_scene -> agent_turn` + - `infraProfile.cloud_runtime` 不再继续显影 + - `planSceneAppLaunch` / `saveSceneAppContextBaseline` 当前会先正规化 `SceneAppPlanResult.plan`: + - `executorKind.cloud_scene -> agent_turn` + - `bindingFamily.cloud_scene -> agent_turn` + - `adapterPlan.adapterKind.cloud_scene -> agent_turn` + - `adapterPlan.runtimeAction.launch_cloud_scene -> open_service_scene_session` + - 因此 current API 对象层现在也和 `skillCatalog / serviceSkills / sceneapp catalog / launch draft` 保持同一套本地执行语义 + - 当前计划中的最小验证: + - `npx eslint "src/lib/api/sceneapp.ts" "src/lib/api/sceneapp.test.ts"` + - `npm exec vitest run "src/lib/api/sceneapp.test.ts" "src/lib/sceneapp/launch.test.ts"` + - `npm run typecheck` + +- 给 `sceneapp` 的剩余旧云 runtime 类型补上 current/compat 分层护栏,避免实现面已经收回本地执行,但类型层仍把 `cloud_session / launch_cloud_scene` 看起来像 current 分支: + - 已更新: + - `src/lib/sceneapp/types.ts` + - 当前统一结论: + - `SceneAppLaunchRequirementKind.cloud_session` 当前明确标注为 `legacy compat only` + - `SceneAppRuntimeContext.cloudSessionReady` 当前明确标注为 compat 输入,而不是 current 运行时门槛 + - `SceneAppRuntimeAction` 当前拆成: + - `SceneAppCurrentRuntimeAction` + - `SceneAppCompatRuntimeAction(launch_cloud_scene)` + - 因此类型层现在也更接近 `current = open_service_scene_session / 本地执行`,`compat = launch_cloud_scene / 旧云痕迹` 的事实源 + - 当前计划中的最小验证: + - `npx eslint "src/lib/sceneapp/types.ts"` + - `npm run typecheck` + +- 把首页 `service skill` 入口里的 compat binding 判断也收回 current 分支结构,避免首页推荐逻辑继续把 `cloud_scene` 写成并列执行面: + - 已更新: + - `src/components/agent/chat/service-skills/homeEntrySkills.ts` + - 当前统一结论: + - 首页推荐与 seeded fallback 当前会先把 `cloud_scene` 正规化成 `agent_turn` + - `resolveServiceSkillExecutionKind(...)` 当前不再把 `cloud_scene` 当 current switch 分支,而是只在 compat normalizer 里处理 + - 因此首页 skill 推荐这条 current 入口现在也更接近“先正规化、再按本地执行判断”的统一模型 + - 当前计划中的最小验证: + - `npx eslint "src/components/agent/chat/service-skills/homeEntrySkills.ts"` + - `npm run typecheck` + +- 把 `@命令` 的 recent 也补成可订阅的 live loop,避免输入层已经写入新的 builtin command recent,但面板还要关掉重开才刷新: + - 已更新: + - `src/components/agent/chat/skill-selection/mentionEntryUsage.ts` + - `src/components/agent/chat/skill-selection/CharacterMention.tsx` + - `src/components/agent/chat/skill-selection/CharacterMentionPanel.tsx` + - `src/components/agent/chat/skill-selection/CharacterMention.test.tsx` + - `docs/roadmap/limenextv2/implementation-roadmap.md` + - `docs/roadmap/limenextv2/skill-first-system.md` + - `docs/exec-plans/limenext-progress.md` + - 当前统一结论: + - `mentionEntryUsage` 当前新增统一 changed event,并同时支持同页 custom event 与跨窗口 storage 监听 + - `CharacterMention` 当前会订阅这条事实源,并即时重建 `@` 面板里的 `最近使用` + - 因此 builtin command recent 当前会在面板已打开时直接刷新,不再停留在静态快照 + - 当前已确认通过: + - `npx eslint "src/components/agent/chat/skill-selection/mentionEntryUsage.ts" "src/components/agent/chat/skill-selection/CharacterMention.tsx" "src/components/agent/chat/skill-selection/CharacterMentionPanel.tsx" "src/components/agent/chat/skill-selection/CharacterMention.test.tsx"` + - `npm exec vitest run "src/components/agent/chat/skill-selection/CharacterMention.test.tsx"` + - 当前环境备注: + - `npm run typecheck` 当前被工作区中既有的 `src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.ts` 断链阻塞,报错集中在缺失 `createServiceSkillRun / recordServiceSkillCloudRun / getServiceSkillRunStatusLabel / sleep / prepareServiceSkillCloudResultWorkspacePayload` 等符号,不属于本轮 `@命令` recent live loop 改动 + - `npm run verify:gui-smoke` 当前也在 `smoke:agent-service-skill-entry` 被同一条 `useWorkspaceServiceSkillEntryActions.test.tsx` 既有失败阻塞,并非本轮输入层 recent 面板刷新引起 + +- 把 `service skill usage` 也补成可订阅的 recent 事实源,避免 `Generate` 或 `@命令` 已经成功写入 recent,但首页 / `我的方法` / slash 面板还停在旧排序: + - 已更新: + - `src/components/agent/chat/service-skills/storage.ts` + - `src/components/agent/chat/service-skills/useServiceSkills.ts` + - `src/components/agent/chat/service-skills/useServiceSkills.test.tsx` + - `docs/roadmap/limenextv2/implementation-roadmap.md` + - `docs/roadmap/limenextv2/skill-first-system.md` + - `docs/exec-plans/limenext-progress.md` + - 当前统一结论: + - `service skill usage` 当前新增统一 changed event,并同时支持同页 custom event 与跨窗口 storage 监听 + - `useServiceSkills(...)` 当前会订阅这条事实源,统一重算 `recentUsedAt / isRecent / badge` + - 因此首页 `继续上次做法`、`我的方法` 页分组排序,以及 slash 面板里的 recent service skill,当前都会在同页即时刷新,而不是等 catalog refresh + - 当前已确认通过: + - `npx eslint "src/components/agent/chat/service-skills/storage.ts" "src/components/agent/chat/service-skills/useServiceSkills.ts" "src/components/agent/chat/service-skills/useServiceSkills.test.tsx"` + - `npm exec vitest run "src/components/agent/chat/service-skills/useServiceSkills.test.tsx"` + - `npm run typecheck` + - `npm run verify:gui-smoke` + +- 把 `curated task` 的 recent writeback 从“只在 launcher 确认时记录”补成“Generate 内成功发送也会更新”,避免工作台 continuation、初始 capability bootstrap 或输入区二次编辑后,recent 仍停留在旧启动参数: + - 已更新: + - `src/components/agent/chat/utils/curatedTaskTemplates.ts` + - `src/components/agent/chat/utils/curatedTaskTemplates.test.ts` + - `src/components/agent/chat/components/Inputbar/hooks/useInputbarSend.ts` + - `src/components/agent/chat/components/Inputbar/index.test.tsx` + - `src/components/agent/chat/components/EmptyState.tsx` + - `src/components/agent/chat/skill-selection/CharacterMention.tsx` + - `src/components/agent/chat/skill-selection/CharacterMentionPanel.tsx` + - `src/components/skills/SkillsWorkspacePage.tsx` + - `docs/roadmap/limenextv2/implementation-roadmap.md` + - `docs/roadmap/limenextv2/skill-first-system.md` + - `docs/exec-plans/limenext-progress.md` + - 当前统一结论: + - `curated task usage` 当前新增统一 changed event,首页、`我的方法` 页与 slash 面板都能订阅同一条 recent 事实源 + - `Generate` 内成功发送激活中的 `curated_task` 时,当前会把最新 `launchInputValues + referenceMemoryIds + referenceEntries` 写回 recent usage + - 因此 `GeneralWorkbench` 的建议下一步、`initialInputCapability(curated_task)` 启动,以及输入区里二次编辑结果模板后再发送,当前都不再把 recent 留在旧参数上 + - 当前已确认通过: + - `npx eslint "src/components/agent/chat/utils/curatedTaskTemplates.ts" "src/components/agent/chat/utils/curatedTaskTemplates.test.ts" "src/components/agent/chat/components/Inputbar/hooks/useInputbarSend.ts" "src/components/agent/chat/components/EmptyState.tsx" "src/components/agent/chat/components/Inputbar/index.test.tsx" "src/components/agent/chat/skill-selection/CharacterMention.tsx" "src/components/agent/chat/skill-selection/CharacterMentionPanel.tsx" "src/components/skills/SkillsWorkspacePage.tsx"` + - `npm exec vitest run "src/components/agent/chat/utils/curatedTaskTemplates.test.ts" "src/components/agent/chat/components/Inputbar/index.test.tsx" "src/components/agent/chat/components/EmptyState.test.tsx" "src/components/agent/chat/skill-selection/CharacterMention.test.tsx" "src/components/skills/SkillsWorkspacePage.test.tsx"` + - `npm run typecheck` + - `npm run verify:gui-smoke` + +- 把 `@命令 -> service skill` 的 recent continuation 也补成“字段 + 意图”双恢复,避免命令入口已经成功触发 service skill,但后续 recent 里只剩字段快照: + - 已更新: + - `src/components/agent/chat/workspace/useWorkspaceSendActions.ts` + - `src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx` + - `src/components/agent/chat/service-skills/serviceSkillLaunchPrefill.ts` + - `src/components/agent/chat/service-skills/serviceSkillLaunchPrefill.test.ts` + - `docs/roadmap/limenextv2/implementation-roadmap.md` + - `docs/roadmap/limenextv2/skill-first-system.md` + - `docs/exec-plans/limenext-progress.md` + - 当前统一结论: + - `resolveMentionCommandUsage(...)` 当前会从现有 command metadata 抽取 `prompt / user_input` + - 这条显式意图当前会一并写进 `service skill usage.launchUserInput` + - 因此 command-bound service skill recent 当前也能和 direct service skill recent 一样显影 `上次补充` + - recent summary 当前还补了最小去重:如果 `launchUserInput` 与已有槽位值一致,不再重复显示第二遍 + - 当前已确认通过: + - `npx eslint "src/components/agent/chat/workspace/useWorkspaceSendActions.ts" "src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx" "src/components/agent/chat/service-skills/serviceSkillLaunchPrefill.ts" "src/components/agent/chat/service-skills/serviceSkillLaunchPrefill.test.ts"` + - `npm exec vitest run "src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx" "src/components/agent/chat/service-skills/serviceSkillLaunchPrefill.test.ts"` + - `npm run typecheck` + - `npm run verify:gui-smoke` + +- 把 `service skill` recent continuation 从“只恢复 slotValues”补到“显式补充目标也能继续带回生成”,避免 recent 卡片已经显影 `上次填写`,但上一轮补充要求在再启动时静默丢失: + - 已更新: + - `src/components/agent/chat/service-skills/types.ts` + - `src/components/agent/chat/service-skills/storage.ts` + - `src/components/agent/chat/service-skills/serviceSkillLaunchPrefill.ts` + - `src/components/agent/chat/service-skills/serviceSkillLaunchPrefill.test.ts` + - `src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.ts` + - `src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.test.tsx` + - `src/components/agent/chat/components/EmptyState.tsx` + - `src/components/agent/chat/skill-selection/inputCapabilitySections.ts` + - `src/components/skills/SkillsWorkspacePage.tsx` + - `src/components/skills/SkillsWorkspacePage.test.tsx` + - `docs/roadmap/limenextv2/implementation-roadmap.md` + - `docs/roadmap/limenextv2/skill-first-system.md` + - `docs/exec-plans/limenext-progress.md` + - 当前统一结论: + - `service skill usage` 当前可选记录 `launchUserInput` + - recent summary 当前会同时显影: + - `上次填写` + - `上次补充` + - `我的方法 -> 生成` 的 `initialPendingServiceSkillLaunch` 当前也会一并带回这条补充信息 + - `useWorkspaceServiceSkillEntryActions` 当前会把显式透传的 `launchUserInput` 继续写回 recent usage,保证后续 continuation 真能续上同一条方法 + - 当前已确认通过: + - `npx eslint "src/components/agent/chat/service-skills/serviceSkillLaunchPrefill.ts" "src/components/agent/chat/service-skills/serviceSkillLaunchPrefill.test.ts" "src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.ts" "src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.test.tsx" "src/components/skills/SkillsWorkspacePage.tsx" "src/components/skills/SkillsWorkspacePage.test.tsx" "src/components/agent/chat/components/EmptyState.tsx" "src/components/agent/chat/skill-selection/inputCapabilitySections.ts"` + - `npm exec vitest run "src/components/agent/chat/service-skills/serviceSkillLaunchPrefill.test.ts" "src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.test.tsx" "src/components/skills/SkillsWorkspacePage.test.tsx"` + - `npm exec vitest run "src/components/agent/chat/components/EmptyState.test.tsx"` + - `npm exec vitest run "src/components/agent/chat/skill-selection/CharacterMention.test.tsx"` + - `npm run typecheck` + - `npm run verify:gui-smoke` + +- 把 `我的方法库 -> 生成` 的 installed skill continuation 补成真闭环,避免卡片已经显影 `上次目标`,点进去却还得自己重打一遍: + - 已更新: + - `src/components/skills/SkillsWorkspacePage.tsx` + - `src/components/skills/SkillsWorkspacePage.test.tsx` + - `docs/roadmap/limenextv2/implementation-roadmap.md` + - `docs/roadmap/limenextv2/skill-first-system.md` + - `docs/exec-plans/limenext-progress.md` + - 当前统一结论: + - `我的方法库` 里的 installed skill 卡片如果已有 `slash usage.replayText` + - 点击 `进入生成` 时当前会同时带上: + - `initialInputCapability(installed_skill)` + - `initialUserPrompt(replayText)` + - 因此这条链路当前已经和首页 `继续上次做法` 的 installed skill continuation 对齐,不再只是“看得到上次目标”,而是能真正沿用它进入 `生成` + - 当前已确认通过: + - `npx eslint "src/components/skills/SkillsWorkspacePage.tsx" "src/components/skills/SkillsWorkspacePage.test.tsx"` + - `npm exec vitest run "src/components/skills/SkillsWorkspacePage.test.tsx"` + - `npm run verify:local` + +- 修复桌面端启动白屏的两条 current 主路径兜底,避免窗口已经起来但用户只看到空白页: + - 已更新: + - `src/i18n/withI18nPatch.tsx` + - `src/i18n/withI18nPatch.test.tsx` + - `src/components/agent/chat/index.tsx` + - `src/components/agent/chat/agentChatWorkspaceLoader.ts` + - `src/components/agent/chat/agentChatWorkspaceLoader.test.ts` + - `docs/exec-plans/limenext-progress.md` + - 当前统一结论: + - `withI18nPatch` 启动期不再直接 `return null`,而是先渲染轻量启动屏,避免 `get_config` 尚未返回时桌面端出现整窗白屏 + - `withI18nPatch` 的 ready 提交不再只依赖 `requestAnimationFrame`,额外补了超时兜底,避免窗口可见前 `opacity=0` 长时间挂住 + - `AgentChatPage` 当前对 `AgentChatWorkspace` 的懒加载增加了模块导入瞬时失败重试,并把 `fallback={null}` 收成可见加载态,避免 Vite dev 热启动或重连期间直接落成空白页 + - 这一步服务的不是旧 compat,而是 current `生成` 主执行面启动稳定性 + +- 把 `sceneapps` 内层详情/复盘面板继续从“工程说明页”收成更接近 Ribbi 的结果视角,避免外层已经叫 `全部做法 / 生成准备 / 做法复盘`,真正点进去后却又退回 `Project Pack / Governance Loop / Planning 基线`: + - 已更新: + - `src/components/sceneapps/SceneAppDetailPanel.tsx` + - `src/components/sceneapps/SceneAppGovernancePanel.tsx` + - `src/components/sceneapps/SceneAppRunDetailPanel.tsx` + - `src/components/sceneapps/SceneAppScorecardPanel.tsx` + - `src/components/sceneapps/SceneAppProjectPackRuntimePanel.tsx` + - `src/components/sceneapps/SceneAppsPage.tsx` + - `src/components/sceneapps/SceneAppsPage.test.tsx` + - `src/lib/sceneapp/presentation.ts` + - `src/lib/sceneapp/product.ts` + - `src/lib/sceneapp/reviewDecision.ts` + - `src/lib/sceneapp/executionPromptActions.ts` + - `src/lib/sceneapp/product.test.ts` + - `src/lib/sceneapp/executionPromptActions.test.ts` + - `docs/roadmap/limenextv2/implementation-roadmap.md` + - `docs/roadmap/limenextv2/skill-first-system.md` + - `docs/exec-plans/limenext-progress.md` + - 当前统一结论: + - `生成准备` 内层当前已围绕 `默认会拿到 / 通常会这样推进 / 默认怎么判断 / 这轮准备情况 / 默认结果去向 / 开始这次生成` 收口,不再默认暴露工程说明词 + - `做法复盘` 左右两块当前也已继续收成 `做法复盘 / 这轮结果 / 做法表现` 这组前台语言,优先讲这轮拿到了什么、卡在哪里、下一步怎么走 + - `sceneapp` 的 runtime summary、运行建议、缺件补齐 prompt 与轻量反馈文案当前也已同步收口,不再默认把 `SceneApp / Project Pack / 治理材料 / Artifact` 直接暴露给用户 + - `Project Pack / Governance Loop / Planning 基线 / PRIMARY` 这类词当前已经从这些面板的首层用户可见文案里移走;保留的只是内部实现锚点与测试 id + - 这一步继续服务 P1:不是删掉复盘能力,而是把它们翻译成用户能直接理解的结果与判断页面 + - 当前已确认通过: + - `npx eslint "src/components/sceneapps/SceneAppDetailPanel.tsx" "src/components/sceneapps/SceneAppGovernancePanel.tsx" "src/components/sceneapps/SceneAppRunDetailPanel.tsx" "src/components/sceneapps/SceneAppScorecardPanel.tsx" "src/components/sceneapps/SceneAppProjectPackRuntimePanel.tsx" "src/components/sceneapps/SceneAppsPage.tsx" "src/components/sceneapps/SceneAppsPage.test.tsx"` + - `npx eslint "src/lib/sceneapp/presentation.ts" "src/lib/sceneapp/executionPromptActions.ts" "src/lib/sceneapp/reviewDecision.ts" "src/lib/sceneapp/product.ts" "src/lib/sceneapp/product.test.ts" "src/components/sceneapps/SceneAppsPage.test.tsx"` + - `npm exec vitest run "src/lib/sceneapp/product.test.ts" "src/lib/sceneapp/executionPromptActions.test.ts" "src/components/sceneapps/SceneAppsPage.test.tsx"` + - 当前环境备注: + - 已再次尝试 `npm run verify:gui-smoke -- --timeout-ms 1200000` + - 这次没有进入页面级断言失败,而是长时间停留在 `bridge:health` 等待阶段 + - 具体现象是 headless Tauri 冷启动后,Rust 构建持续占用十多分钟,`http://127.0.0.1:3030/health` 始终返回 `fetch failed` + - 因为阻塞点仍停在冷编译/DevBridge 就绪前,当前更像本地 GUI smoke 环境成本问题,而不是这轮 `sceneapps` 内层面板收口引入的新断链 + +- 把 `全部做法` 页继续从“分页控制面”收成更接近技能页的线性前台容器,避免 `sceneapps` 虽然退成内部锚点,但真实页面仍然像另一块并列工作台: + - 已更新: + - `src/components/sceneapps/SceneAppsPage.tsx` + - `src/components/sceneapps/SceneAppsPage.test.tsx` + - `docs/roadmap/limenextv2/implementation-roadmap.md` + - `docs/roadmap/limenextv2/skill-first-system.md` + - `docs/exec-plans/limenext-progress.md` + - 当前统一结论: + - `全部做法` 页首当前不再强调 `ALL FLOWS` 之类工程词,而是直接说明这页只回答“做什么 / 会拿到什么 / 下一步去哪” + - `做法目录 / 生成准备 / 做法复盘` 当前改成带摘要的步骤卡,用户更容易理解这是同一条线性路径,而不是三块平级控制台 + - 目录筛选当前也不再直接把 `Pipeline / Reviewer / Inversion` 这类英文模式甩给用户,而是翻成更像做法路径的中文,并补显式 `清空筛选` + - `当前已带入` 与 `当前做法` 当前改成轻量状态卡,不再像顶部控制栏 + - `治理复盘` 对外当前继续收成 `做法复盘`,进一步和 `做法目录 / 做法准备` 主词对齐 + - 当前已确认通过: + - `npx eslint "src/components/sceneapps/SceneAppsPage.tsx" "src/components/sceneapps/SceneAppsPage.test.tsx"` + - `npm exec vitest run "src/components/sceneapps/SceneAppsPage.test.tsx"` + - 当前环境备注: + - 本轮再次尝试 `npm run verify:gui-smoke` + - `workspace-ready / browser-runtime / site-adapters / agent-service-skill-entry / agent-runtime-tool-surface` 当前都已通过 + - 仍失败在 `smoke:agent-runtime-tool-surface-page` + - 这次症状不再是 `fetch failed`,而是等待 `Runtime 能力摘要` 出现超时,最后结果为 `hasWorkbench=true`、`hasRuntimeSummary=false` + - 当前更像既有 agent runtime page smoke 的环境/时序抖动,不像这轮 `全部做法` 页收口本身引入的新断链 + +- 把 `技能` 页继续从“信息摊平的合同目录”收成更接近 Ribbi 的前台结构,只保留少数强入口与方法目录,不再默认把整套系统结构暴露给用户: + - 已更新: + - `src/components/skills/SkillsWorkspacePage.tsx` + - `src/components/skills/SkillsWorkspacePage.test.tsx` + - `docs/roadmap/limenextv2/implementation-roadmap.md` + - `docs/roadmap/limenextv2/skill-first-system.md` + - `docs/exec-plans/limenext-progress.md` + - 当前统一结论: + - `先拿结果` 当前提升到主列顶部,成为技能页真正的第一起手层 + - `方法目录` 当前退回第二层,继续承担“知道方向后按组找做法”的角色 + - `继续常用做法 / 我的方法库` 当前继续保留在右侧,但卡片改成紧凑摘要,不再把一整页切成多块重说明板 + - `curated task` 卡片仍保留 `你来给 / 可选参考 / 会拿到 / 结果去向 / 下一步可继续` 这套 current 事实,只是改成单卡内的紧凑合同,不再用多层内嵌子卡放大结构感 + - 当前已确认通过: + - `npx eslint "src/components/skills/SkillsWorkspacePage.tsx" "src/components/skills/SkillsWorkspacePage.test.tsx"` + - `npm exec vitest run "src/components/skills/SkillsWorkspacePage.test.tsx"` + - 当前环境备注: + - 本轮再次尝试 `npm run verify:gui-smoke` + - `workspace-ready / browser-runtime / site-adapters / agent-service-skill-entry / agent-runtime-tool-surface` 当前都已通过 + - 仍失败在 `smoke:agent-runtime-tool-surface-page` + - 具体现象是 `browser_execute_action / close_cdp_session / close_chrome_profile_session` 连续 `fetch failed` + - 这更像复用 headless 浏览器 page 会话时的既有环境抖动,不像这轮技能页布局收口本身引入的新断链 + ## 2026-04-20 ### 已完成 +- 把 `memory / automation` 旁路页继续收口到 `做法` 主词,避免用户离开主路径后又被旧 `创作场景` 文案拉回去: + - 已更新: + - `src/components/memory/MemoryPage.tsx` + - `src/components/settings-v2/system/automation/AutomationJobDetailsDialog.tsx` + - `src/components/memory/MemoryPage.test.tsx` + - `src/components/settings-v2/system/automation/index.test.tsx` + - `docs/roadmap/limenextv2/implementation-roadmap.md` + - `docs/exec-plans/limenext-progress.md` + - 当前统一结论: + - `灵感库` 当前会把“带参考进入 sceneapps”这类动作明确写成 `去做法目录` + - `taste` 和灵感条目说明当前统一改成服务 `做法 planning` + - 自动化详情里的闭环摘要当前改成 `做法闭环 / 回到做法准备`,不再把自动化旁路继续描述成另一条 `创作场景` 主链 + - 当前已确认通过: + - `npx eslint "src/components/memory/MemoryPage.tsx" "src/components/settings-v2/system/automation/AutomationJobDetailsDialog.tsx" "src/components/memory/MemoryPage.test.tsx" "src/components/settings-v2/system/automation/index.test.tsx"` + - `npm exec vitest run "src/components/memory/MemoryPage.test.tsx" "src/components/settings-v2/system/automation/index.test.tsx"` + +- 把 `sceneapps` 深层运行态的 current 前台旧词继续收掉,避免只收页首和侧栏、真正进入做法详情和生成回桥后又退回 `创作场景`: + - 已更新: + - `src/components/sceneapps/useSceneAppsPageRuntime.ts` + - `src/components/sceneapps/SceneAppDetailPanel.tsx` + - `src/components/sceneapps/SceneAppsCatalogPanel.tsx` + - `src/components/agent/chat/AgentChatWorkspace.tsx` + - `src/components/agent/chat/workspace/useWorkspaceSceneAppEntryActions.ts` + - `src/components/agent/chat/workspace/SceneAppExecutionSummaryCard.tsx` + - `src/lib/sceneapp/product.ts` + - `src/lib/sceneapp/presentation.ts` + - `src/lib/sceneapp/launch.ts` + - `src/lib/sceneapp/reviewDecision.ts` + - `src/lib/sceneapp/runEntryNavigation.ts` + - `src/components/sceneapps/SceneAppsPage.test.tsx` + - `src/components/agent/chat/workspace/SceneAppExecutionSummaryCard.test.tsx` + - `src/lib/sceneapp/launch.test.ts` + - `src/lib/sceneapp/runEntryNavigation.test.ts` + - `docs/roadmap/limenextv2/implementation-roadmap.md` + - `docs/roadmap/limenextv2/skill-first-system.md` + - `docs/exec-plans/limenext-progress.md` + - 当前统一结论: + - `做法目录 / 做法复盘 / 当前做法基线 / 做法执行摘要` 当前已经进入 `sceneapps` 详情页、治理页、生成回桥和轻量反馈主链 + - `SceneAppsCatalogPanel` 的搜索框当前也已改成 `搜索做法标题`,目录页不再一半讲“做法”、一半讲“场景” + - `presentation / launch / reviewDecision / runEntryNavigation` 这批 current fallback prompt 与回跳文案当前也开始同步改成 `做法` 口径,避免外层文案收口后,真实启动和复盘链又把用户拉回旧词 + - 当前已确认通过: + - `npx eslint "src/components/sceneapps/useSceneAppsPageRuntime.ts" "src/components/sceneapps/SceneAppDetailPanel.tsx" "src/components/sceneapps/SceneAppsCatalogPanel.tsx" "src/components/agent/chat/AgentChatWorkspace.tsx" "src/components/agent/chat/workspace/useWorkspaceSceneAppEntryActions.ts" "src/components/agent/chat/workspace/SceneAppExecutionSummaryCard.tsx" "src/lib/sceneapp/product.ts" "src/lib/sceneapp/presentation.ts" "src/lib/sceneapp/launch.ts" "src/lib/sceneapp/reviewDecision.ts" "src/lib/sceneapp/runEntryNavigation.ts" "src/components/sceneapps/SceneAppsPage.test.tsx" "src/components/agent/chat/workspace/SceneAppExecutionSummaryCard.test.tsx" "src/lib/sceneapp/launch.test.ts" "src/lib/sceneapp/runEntryNavigation.test.ts"` + - `npm exec vitest run "src/components/sceneapps/SceneAppsPage.test.tsx" "src/components/agent/chat/workspace/SceneAppExecutionSummaryCard.test.tsx" "src/lib/sceneapp/launch.test.ts" "src/lib/sceneapp/runEntryNavigation.test.ts"` + - 当前环境备注: + - `npm run verify:gui-smoke` 本轮仍失败在复用 headless 环境下的 `smoke:browser-runtime` + - 具体现象是 `launch_browser_session` 与后续 `close_chrome_profile_session` 连续返回 `fetch failed` + - 这次阻塞发生在既有浏览器运行时 smoke 链,不像本轮 `sceneapps / 生成回桥 / 做法文案` 收口本身引入的新断链;后续应单独回到 `browser-runtime` 复用环境继续排查 + +- 把 `技能 -> 全部做法 -> SceneApps` 这条回桥补回 current 主线,并同步把 `sceneapps` 首屏从“创作场景主舞台”收回“全部做法”角色: + - 已更新: + - `src/components/skills/SkillsWorkspacePage.tsx` + - `src/components/skills/SkillsWorkspacePage.test.tsx` + - `src/components/sceneapps/SceneAppsPage.tsx` + - `src/components/sceneapps/SceneAppsPage.test.tsx` + - `docs/roadmap/limenextv2/implementation-roadmap.md` + - `docs/roadmap/limenextv2/skill-first-system.md` + - `docs/roadmap/limenextv2/information-architecture.md` + - `docs/exec-plans/limenext-progress.md` + - 当前统一结论: + - `技能` 页的 `方法目录` 当前已新增弱入口 `查看全部做法`,用于显式进入 `sceneapps` 目录页 + - 如果用户已在 `技能` 页输入搜索词,这个入口当前会继续带着当前关键词打开 `sceneapps` 的 `catalog` 视图,避免再从头找一次 + - `sceneapps` 页首当前改成 `全部做法`,`场景目录` 当前改成 `做法目录`,空态和首轮治理提示也开始按“整套做法”的角色来讲自己 + - 因此这条页面已经不再像一块并列主舞台,而更像 `技能` 下的完整做法目录与后续装配页 + - 当前已确认通过: + - `npx eslint "src/components/skills/SkillsWorkspacePage.tsx" "src/components/skills/SkillsWorkspacePage.test.tsx" "src/components/sceneapps/SceneAppsPage.tsx" "src/components/sceneapps/SceneAppsPage.test.tsx"` + - `npm exec vitest run "src/components/skills/SkillsWorkspacePage.test.tsx" "src/components/sceneapps/SceneAppsPage.test.tsx"` + +- 把左侧导航继续收回 `技能 / 灵感库 / 生成` 主词,避免 `创作场景` 仍以一级入口身份和 current 主舞台抢叙事: + - 已更新: + - `src/lib/navigation/sidebarNav.ts` + - `src/lib/navigation/sidebarNav.test.ts` + - `src/components/AppSidebar.tsx` + - `src/components/AppSidebar.test.tsx` + - `docs/roadmap/limenextv2/implementation-roadmap.md` + - `docs/roadmap/limenextv2/skill-first-system.md` + - `docs/exec-plans/limenext-progress.md` + - 当前统一结论: + - 左侧一级侧栏当前改成 `创作 / 支撑 / 系统` 三段;`创作` 下固定只保留 `新建任务 / 技能 / 灵感库 / 生成` + - `创作场景` 当前不再作为一级侧栏入口直接暴露;`SceneApps` 仍保留为 current route,但它的对外进入点改回 `查看全部做法` 与 `技能` 所属区域 + - 当用户当前位于 `sceneapps` 页面时,侧栏当前会把它归属到 `技能` 主入口;因此 `SceneApps` 继续存在,但不再伪装成和 `技能 / 灵感库 / 生成` 平级的主舞台 + - 当前已确认通过: + - `npx eslint "src/lib/navigation/sidebarNav.ts" "src/lib/navigation/sidebarNav.test.ts" "src/components/AppSidebar.tsx" "src/components/AppSidebar.test.tsx"` + - `npm exec vitest run "src/lib/navigation/sidebarNav.test.ts" "src/components/AppSidebar.test.tsx"` + +- 把首页底部原来并列的 `更多起手方式` 与 `支撑能力` 继续收成一条更弱的续接条,避免结果入口下面又长出两个“平台补位块”: + - 已更新: + - `src/components/agent/chat/components/EmptyState.tsx` + - `src/components/agent/chat/components/EmptyState.test.tsx` + - `docs/roadmap/limenextv2/implementation-roadmap.md` + - `docs/roadmap/limenextv2/skill-first-system.md` + - `docs/exec-plans/limenext-progress.md` + - 当前统一结论: + - 首页底部当前默认只保留 `继续最近做法 / 查看全部做法 / 连接浏览器 / 查看支撑能力` 这类轻入口 + - 旧的能力大卡当前不再从 hero 底部长成第二视觉中心;展开 `查看支撑能力` 时,当前只显示轻量说明列表 + - `SceneApp` 补位、浏览器接入与能力边界说明,当前都退回“如果你已经知道怎么接着做”的辅助层,不再与主结果入口平级竞争 + - 当前已确认通过: + - `npx eslint "src/components/agent/chat/components/EmptyState.tsx" "src/components/agent/chat/components/EmptyState.test.tsx"` + - `npm exec vitest run "src/components/agent/chat/components/EmptyState.test.tsx"` + - 当前环境备注: + - 已额外尝试 `npm run verify:gui-smoke` + - 第一次卡在复用旧 headless 链路后的 `smoke:browser-runtime` 过期 session 清理异常 + - 第二次主链 smoke 通过到最后一步,但 `smoke:agent-runtime-tool-surface-page` 卡在 `launch_browser_session / close_chrome_profile_session` + - 这轮应按 `DevBridge 浏览器会话层环境抖动` 理解,而不是首页 `EmptyState` 结构改动本身阻塞 + - 把首页 `结果入口` 与 `CuratedTaskLauncherDialog` 一起继续收口,避免 current 主路径虽然已经 skill-first,但前台仍像“结果模板货架 + 重表单弹窗”: - 已更新: - `src/components/agent/chat/components/EmptyState.tsx` diff --git a/docs/exec-plans/tech-debt-tracker.md b/docs/exec-plans/tech-debt-tracker.md index 972d7a1f4..74b507e56 100644 --- a/docs/exec-plans/tech-debt-tracker.md +++ b/docs/exec-plans/tech-debt-tracker.md @@ -10,14 +10,15 @@ ## 条目 -| ID | 日期 | 区域 | 差距 / 债务 | 影响 | 下一小步 | 关联文档 | 状态 | -| --- | --- | --- | --- | --- | --- | --- | --- | -| CCD-001 | 2026-04-14 | Query Loop | Lime 已有 `submit_turn / runtime_turn / tool_runtime / evidence` 主链,但过去缺少单一 Query Loop 事实源;现已补齐 current 文档入口 | 已从“主链解释分散”降到“代码边界仍待继续收口”,后续推进顺序已稳定 | 继续沿 `docs/aiprompts/query-loop.md` 盘点代码侧重复边界,准备下一刀 helper 收口 | `docs/exec-plans/upstream-runtime-alignment-plan.md` | 已完成 | -| CCD-002 | 2026-04-14 | Remote Runtime | Telegram remote、OpenClaw、DevBridge、browser remote/debugging 等入口过去并存且语义混杂;现已补齐 `消息渠道 runtime + 浏览器连接器 / ChromeBridge` 的 current 主链,并明确 `DevBridge / OpenClaw` 为 compat、`telegram_remote_cmd` 为 deprecated | 已从“remote 入口平级并存”降到“只剩 compat / deprecated 退场减法”,后续 remote 主线解释已稳定 | 后续只允许 remote 新能力落到 `gateway_channel_*` 或 `browser connector / ChromeBridge` current ingress;`DevBridge / OpenClaw / telegram_remote_cmd` 默认只做减法和兼容维护 | `docs/exec-plans/upstream-runtime-alignment-plan.md` | 已完成 | -| CCD-003 | 2026-04-14 | Task / Agent | `subagent / automation / execution tracker / scheduler / heartbeat` 过去缺少统一 taxonomy;现已补齐 `agent turn / subagent turn / automation job` current 分型,并明确 `ExecutionTracker` 为摘要层、`SchedulerService` 为 compat 触发壳 | 已从“排期语言混乱”降到“只剩 compat / deprecated 退场减法”,后续主线解释已稳定 | 后续只允许新增长时执行需求落到 `agent turn / subagent turn / automation job` 三类之一;`SchedulerService` 与 `heartbeat_executions` 只继续做减法和退场 | `docs/exec-plans/upstream-runtime-alignment-plan.md` | 已完成 | -| CCD-004 | 2026-04-14 | Memory / Compaction | `memory_runtime_*`、`unified_memory_*`、`agent_runtime_compact_session` 与来源链 control plane 过去缺少统一主链叙事;现已补齐 current 文档入口,并明确 `project_memory_get` 为 compat 附属层、`memory_feedback_cmd` 为 deprecated | 已从“记忆、压缩、恢复分头演进”降到“后续只需守住 current 边界不回流” | 后续只允许新记忆 / 压缩需求落到 `docs/aiprompts/memory-compaction.md` 定义的 current 主链;发现回流时做定点治理 | `docs/exec-plans/upstream-runtime-alignment-plan.md` | 已完成 | -| CCD-005 | 2026-04-14 | State / History / Telemetry | reliability、harness、history、review、replay、evidence 过去分别成文且入口分散;现已补齐 `SessionDetail -> AgentRuntimeThreadReadModel -> RequestLog 关联键 -> export/history` 的 current 状态地图,并明确旧状态模型方案、reliability 计划与原始 request log 浏览面退回附属层 | 已从“状态模型叙事分散”降到“只剩 compat / deprecated 退场减法”,后续主线解释已稳定 | 后续只允许新状态 / 历史 / 遥测能力落到 `docs/aiprompts/state-history-telemetry.md` 定义的 current 主链;优先继续清 `reliability` 计划与 cleanup 旧语义 | `docs/exec-plans/upstream-runtime-alignment-plan.md` | 已完成 | -| CCD-006 | 2026-04-20 | Config / Host Surface | 参考运行时架构图复盘后确认:Lime 的 `ConfigTool` 宿主配置矩阵仍未补齐,但 `classifierPermissionsEnabled` 已接入 `current` 主链并与安全扫描 ML toggle 打通;进一步审计后又确认 `remoteControlAtStartup` 不能映射到现有 `get_auto_launch_status / set_auto_launch`,`taskCompleteNotifEnabled / inputNeededNotifEnabled / agentPushNotifEnabled` 也不能映射到更新提醒窗口或已下线的 `channels_cmd.rs` stub。`permissions.defaultMode` 这轮也已进一步收紧:prompt 层虽有 `PermissionMode::Plan / DontAsk` 文本能力,但 `plan` 仍只是显式 `EnterPlanMode / ExitPlanMode` 工具流,`dontAsk` 也还缺 provider flag 与 `PermissionInspector` 的全局 runtime 语义 | 若停在当前状态,`Config` 虽然不再把 classifier 开关卡死在 `unsupported`,但 remote-control / push-notification / permission-mode 仍无法通过 `current` 配置面真实控制;更糟的是,后续实现者容易把 OS auto-launch、更新提醒、plan prompt 文本或 stub notification CRUD 误判成可复用事实源,继续制造假对齐 | 已完成“真实宿主落点”与 `permissions.defaultMode` 执行语义审计;下一小步进一步收敛为二选一:要么新增真正的 remote-control / mobile push / dontAsk runtime 宿主面并把 `plan` 重构为可持久化权限模式,要么把这组差异明确沉淀为产品层不对齐;并继续明确 `channels_cmd.rs` 已非可复用宿主面,不再参与这组 setting 的事实源判断 | `docs/exec-plans/upstream-runtime-alignment-progress.md` | 进行中 | -| CCD-007 | 2026-04-20 | Team / Peer Messaging | `SendMessage / ListPeers` 这轮已经从“只支持 team 内 peers”前进到“team peers + synthetic `uds:` 本机会话 peers”的最小 `current`:`ListPeers` 会额外枚举同一 `working_dir` 下最近的本机顶层 session,`SendMessage` 也会把 `uds:` 映射到现有 runtime queue,并在投递前包一层 ``。但这仍不是参考运行时的完整对齐,因为 Lime 现在用的是 synthetic local registry,不是真实 UDS/socket peer transport;`bridge:` 也仍保持受控失败 | 若停在当前状态,Lime 至少不再是“识别 `uds:` 但永远失败”的半成品,但仍会缺少 upstream 那种 live session registry、真实 local ingress 和 remote peer identity;后续实现者仍可能把 synthetic local `uds:` 误当成“已经完全具备 UDS peer transport” | 下一小步继续收敛为两段:先判断是否要把当前 synthetic local session registry 从“同 working_dir 最近 session”提升成真正的 live registry / local ingress;若要追平参考运行时,再单独评估 `bridge:` remote peer messaging。当前不建议把 `bridge:` 混进这条最小 local current,一步到位做双底座 | `docs/exec-plans/upstream-runtime-alignment-progress.md` | 进行中 | -| CCD-008 | 2026-04-20 | Extension / Hook / Skill | `SkillTool` 已进入 `Prompt / Workflow / Agent current`,project-level `UserPromptSubmit` hooks 已接入 `runtime_turn`,`SessionStart(startup / compact)` 已接入 runtime 宿主入口;`Prompt hook`、`Agent hook` 与 `MCP hook` 也都已从占位实现推进成真实执行。`SkillExecutionMode::Agent` 这轮已明确按本地参考运行时收口到 `skills/tool.rs -> Agent.reply(...)` 主链:skill content 会注入 `Base directory for this skill: ...`、替换 `${CLAUDE_SKILL_DIR}` / `${CLAUDE_SESSION_ID}`,并使用隔离的临时 subagent session store 执行,而不是继续往 `SkillExecutor` 补第二套 runtime。同时再次确认 `agent_runtime_resume_thread` 只是恢复排队执行,不应被硬映射为 `SessionStart(resume)` | 若停在当前状态,Lime 已不再是“skills 会假执行、hooks 永远绕过用户提交、新建 session、compact,或把 prompt/agent/mcp hook 当空壳”的半成品;但扩展体系仍会停留在“skill frontmatter hooks / plugin hooks / 更多生命周期 bootstrap 缺席,且 agent skill 还没有真实 `allowed_tools` 权限收紧”的阶段。所有需要 skill 级 hooks、plugin hooks、resume lifecycle 或严格 tool 白名单的能力,仍会被迫绕开 current 边界 | 下一小步继续收口为两段:先把 `SessionStart(resume)` 明确保留为 gap,不再回头做假的 session lifecycle 映射;随后优先推进 skill frontmatter hooks 或 agent skill 的 `allowed_tools` 真实权限上下文。plugin hook load/hot reload 继续留在后续梯队,不建议与前两刀同轮并摊 | `docs/exec-plans/upstream-runtime-alignment-progress.md` | 进行中 | -| CCD-009 | 2026-04-20 | Remote Governance / Legacy Surface | 参考运行时对比后再次确认:`channels_cmd.rs` 这组 AI / 通知渠道 CRUD 基本仍是空壳,但仓库 current remote 事实源已经固定到 `gateway_channel_* + browser connector / ChromeBridge`;本轮已确认 `ImConfigPage` 走的是 `channelsRuntime.ts -> gateway_channel_*` current API,不依赖 `channels_cmd.rs` 旧 CRUD 命令,并已完成宿主面下线:移除 `runner.rs` 注册、移除 `commands/mod.rs` 模块声明、删除 `channels_cmd.rs` 文件本体,同时补上 `legacySurfaceCatalog` 的命令组与 Rust 文本守卫 | 若不下线,Rust 侧注册与 stub 文件会继续制造“仓库还有另一套可用渠道管理面”的误导,影响 remote 主链收口;现已通过删除与守卫把这类误导性 surface 从宿主编译图中清出 | 已完成删除后的最小验证:`legacySurfaceCatalog.test.ts`、`governance:legacy-report`、`test:contracts`、`cargo check --manifest-path src-tauri/Cargo.toml -p lime --lib` 均通过;下一步回到 `CCD-007`,继续判断 `SendMessage / ListPeers` 的 `uds:` / `bridge:` cross-session peer transport 是否应进入 `current` | `docs/aiprompts/remote-runtime.md` | 已完成 | +| ID | 日期 | 区域 | 差距 / 债务 | 影响 | 下一小步 | 关联文档 | 状态 | +| ------- | ---------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | ------ | +| CCD-001 | 2026-04-14 | Query Loop | Lime 已有 `submit_turn / runtime_turn / tool_runtime / evidence` 主链,但过去缺少单一 Query Loop 事实源;现已补齐 current 文档入口 | 已从“主链解释分散”降到“代码边界仍待继续收口”,后续推进顺序已稳定 | 继续沿 `docs/aiprompts/query-loop.md` 盘点代码侧重复边界,准备下一刀 helper 收口 | `docs/exec-plans/upstream-runtime-alignment-plan.md` | 已完成 | +| CCD-002 | 2026-04-14 | Remote Runtime | Telegram remote、OpenClaw、DevBridge、browser remote/debugging 等入口过去并存且语义混杂;现已补齐 `消息渠道 runtime + 浏览器连接器 / ChromeBridge` 的 current 主链,并明确 `DevBridge / OpenClaw` 为 compat、`telegram_remote_cmd` 为 deprecated | 已从“remote 入口平级并存”降到“只剩 compat / deprecated 退场减法”,后续 remote 主线解释已稳定 | 后续只允许 remote 新能力落到 `gateway_channel_*` 或 `browser connector / ChromeBridge` current ingress;`DevBridge / OpenClaw / telegram_remote_cmd` 默认只做减法和兼容维护 | `docs/exec-plans/upstream-runtime-alignment-plan.md` | 已完成 | +| CCD-003 | 2026-04-14 | Task / Agent | `subagent / automation / execution tracker / scheduler / heartbeat` 过去缺少统一 taxonomy;现已补齐 `agent turn / subagent turn / automation job` current 分型,并明确 `ExecutionTracker` 为摘要层、`SchedulerService` 为 compat 触发壳 | 已从“排期语言混乱”降到“只剩 compat / deprecated 退场减法”,后续主线解释已稳定 | 后续只允许新增长时执行需求落到 `agent turn / subagent turn / automation job` 三类之一;`SchedulerService` 与 `heartbeat_executions` 只继续做减法和退场 | `docs/exec-plans/upstream-runtime-alignment-plan.md` | 已完成 | +| CCD-004 | 2026-04-14 | Memory / Compaction | `memory_runtime_*`、`unified_memory_*`、`agent_runtime_compact_session` 与来源链 control plane 过去缺少统一主链叙事;现已补齐 current 文档入口,并明确 `project_memory_get` 为 compat 附属层、`memory_feedback_cmd` 为 deprecated | 已从“记忆、压缩、恢复分头演进”降到“后续只需守住 current 边界不回流” | 后续只允许新记忆 / 压缩需求落到 `docs/aiprompts/memory-compaction.md` 定义的 current 主链;发现回流时做定点治理 | `docs/exec-plans/upstream-runtime-alignment-plan.md` | 已完成 | +| CCD-005 | 2026-04-14 | State / History / Telemetry | reliability、harness、history、review、replay、evidence 过去分别成文且入口分散;现已补齐 `SessionDetail -> AgentRuntimeThreadReadModel -> RequestLog 关联键 -> export/history` 的 current 状态地图,并明确旧状态模型方案、reliability 计划与原始 request log 浏览面退回附属层 | 已从“状态模型叙事分散”降到“只剩 compat / deprecated 退场减法”,后续主线解释已稳定 | 后续只允许新状态 / 历史 / 遥测能力落到 `docs/aiprompts/state-history-telemetry.md` 定义的 current 主链;优先继续清 `reliability` 计划与 cleanup 旧语义 | `docs/exec-plans/upstream-runtime-alignment-plan.md` | 已完成 | +| CCD-006 | 2026-04-20 | Config / Host Surface | 参考运行时架构图复盘后确认:Lime 的 `ConfigTool` 宿主配置矩阵仍未补齐,但 `classifierPermissionsEnabled` 已接入 `current` 主链并与安全扫描 ML toggle 打通;进一步审计后又确认 `remoteControlAtStartup` 不能映射到现有 `get_auto_launch_status / set_auto_launch`,`taskCompleteNotifEnabled / inputNeededNotifEnabled / agentPushNotifEnabled` 也不能映射到更新提醒窗口或已下线的 `channels_cmd.rs` stub。`permissions.defaultMode` 这轮也已进一步收紧:prompt 层虽有 `PermissionMode::Plan / DontAsk` 文本能力,但 `plan` 仍只是显式 `EnterPlanMode / ExitPlanMode` 工具流,`dontAsk` 也还缺 provider flag 与 `PermissionInspector` 的全局 runtime 语义 | 若停在当前状态,`Config` 虽然不再把 classifier 开关卡死在 `unsupported`,但 remote-control / push-notification / permission-mode 仍无法通过 `current` 配置面真实控制;更糟的是,后续实现者容易把 OS auto-launch、更新提醒、plan prompt 文本或 stub notification CRUD 误判成可复用事实源,继续制造假对齐 | 已完成“真实宿主落点”与 `permissions.defaultMode` 执行语义审计;下一小步进一步收敛为二选一:要么新增真正的 remote-control / mobile push / dontAsk runtime 宿主面并把 `plan` 重构为可持久化权限模式,要么把这组差异明确沉淀为产品层不对齐;并继续明确 `channels_cmd.rs` 已非可复用宿主面,不再参与这组 setting 的事实源判断 | `docs/exec-plans/upstream-runtime-alignment-progress.md` | 进行中 | +| CCD-007 | 2026-04-20 | Team / Peer Messaging | `SendMessage / ListPeers` 这轮已经从“只支持 team 内 peers”前进到“team peers + synthetic `uds:` 本机会话 peers”的最小 `current`:`ListPeers` 会额外枚举同一 `working_dir` 下最近的本机顶层 session,`SendMessage` 也会把 `uds:` 映射到现有 runtime queue,并在投递前包一层 ``。但这仍不是参考运行时的完整对齐,因为 Lime 现在用的是 synthetic local registry,不是真实 UDS/socket peer transport;`bridge:` 也仍保持受控失败 | 若停在当前状态,Lime 至少不再是“识别 `uds:` 但永远失败”的半成品,但仍会缺少 upstream 那种 live session registry、真实 local ingress 和 remote peer identity;后续实现者仍可能把 synthetic local `uds:` 误当成“已经完全具备 UDS peer transport” | 下一小步继续收敛为两段:先判断是否要把当前 synthetic local session registry 从“同 working_dir 最近 session”提升成真正的 live registry / local ingress;若要追平参考运行时,再单独评估 `bridge:` remote peer messaging。当前不建议把 `bridge:` 混进这条最小 local current,一步到位做双底座 | `docs/exec-plans/upstream-runtime-alignment-progress.md` | 进行中 | +| CCD-008 | 2026-04-20 | Extension / Hook / Skill | `SkillTool` 已进入 `Prompt / Workflow / Agent current`,project-level `UserPromptSubmit` hooks 已接入 `runtime_turn`,`SessionStart(startup / compact)`、`PreCompact`、`PermissionRequest`、`Stop` 与 `SessionEnd(other via delete_session)` 都已接入 runtime 宿主入口;`Prompt hook`、`Agent hook` 与 `MCP hook` 也都已从占位实现推进成真实执行。`SkillExecutionMode::Agent` 已按本地参考运行时收口到 `skills/tool.rs -> Agent.reply(...)` 主链,skill frontmatter hooks 已进入 `SkillDefinition -> SkillTool -> SessionHookStore -> run_hooks_with_registry_and_context(...)` current 路径;agent frontmatter hooks 也已收进 `AgentRuntimeSpawnSubagentRequest / SpawnAgentRequest -> child session -> SessionHookStore` current 主链,并在注册层完成 `Stop -> SubagentStop` 改写。plugin hooks 已接到 `runtime_project_hooks.rs -> HookRegistry` current 主链,当前 `SessionStart / UserPromptSubmit / PreCompact / PermissionRequest / Stop / SessionEnd` 都已进入同一条 project/plugin/session hook 当前宿主面;同时,plugin hooks 与 plugin skills 现在也已共享 `claude_plugin_cache.rs` 单一 resolver,统一合并 home/workspace `settings*.json`、统一选择 cache 最高版本目录,并继续对齐 upstream 当前真实 cache resolver:versioned cache 优先、legacy non-versioned `~/.claude/plugins/cache//` 次之;plugin manifest 也已统一对齐 `.claude-plugin/plugin.json` 优先、根目录 `plugin.json` fallback,而 plugin skill loader 现在还会按 upstream 当前执行语义识别 `manifest.skills` 显式路径,并在 `manifest.skills` 存在时停止默认 `skills/` autodetect;`@builtin` 的 skipped reason 也已进一步收紧为“缺少独立 builtin plugin registry/current 宿主,不能回退为 marketplace cache plugin 或其他 builtin surface”。进一步按 upstream 审计后也已确认:Lime 当前只有 `SessionStart(startup / compact)` 与 `SessionEnd(other via delete_session)` 的真实宿主,`SessionStart.resume / clear` 与 `SessionEnd.resume / clear / prompt_input_exit / logout / bypass_permissions_disabled` 仍无 honest host,并已补上显式 unsupported guard,避免 `resume_thread`、`/clear` 或退出动作被误接成 lifecycle current;对于 upstream 的 managed plugin policy,resolver 现在也会显式探测 `~/.aster/managed_settings.yaml` / `policy.yaml` 里的 Claude 风格 plugin policy keys,并把它们记成 skipped reason,而不是把 Lime 通用 `policySettings` 硬接成 plugin loader current。与此同时,`PermissionRequest.updatedInput` 与 `PermissionRequest.deny + interrupt` 也都已进入 `current`,前者会真实改写工具执行输入,后者会直接驱动当前 session 的 cancel token;`PermissionRequest.updatedPermissions` 现已从“warning-only”收紧为 fail-closed 回退原生审批流,避免出现“当前工具被 hook 放行、但权限事实源并未真正更新”的假对齐;`Stop` richer 语义则已钉实为“只有 event host,没有 continuation gate host”,并把 `continue:false` unsupported 守卫升级为前台 warning + timeline 可见,而不再只是 tracing log。再进一步按 builtin plugin 宿主审计后也已确认:Claude Code 当前确实有 builtin plugin registry scaffold,但 `src/plugins/bundled/index.ts` 今天还没有实际注册任何 builtin plugins,所以这条差距目前属于“结构存在、活跃能力尚未启用”的 dormant gap,而不是需要用 Lime 现有 builtin skill/profile/preset/extension 去硬补的 active current。最新几轮又已补上:一是 `SkillRegistry` 的 plugin skill snapshot 按需刷新,`SkillTool / WorkflowTool` 会在 current 入口前检查 plugin enable/disable、cache version 切换和 `SKILL.md` 内容变化,并在需要时重载 plugin/user/project skills,同时保留 invoked 历史;二是 platform `skills` extension 已降为非默认启用,并改为只委托共享 `SkillRegistry`,不再自己扫描 `.aster/skills`、`.agents/skills` 等平行目录语义,因此仓库内部也不再残留第二套技能 loader current | 若停在当前状态,Lime 已不再是“plugin 只能带技能、不能真正带 runtime hooks”“压缩前完全绕过 hooks”“权限审批仍完全绕过 hooks”“turn 正常结束完全绕过 Stop hooks”“删除 session 完全绕过 SessionEnd hooks”“PermissionRequest 看起来支持 richer allow、但实际不会改写工具输入”“PermissionRequest deny 看起来只是拦一个工具、但无法中断整轮执行”“plugin settings / cache 已变化,但 SkillRegistry 仍读旧 plugin skill 集合”,也不会再让仓库内部平台 `skills` extension 自己长出 `.aster/skills` / `.agents/skills` 这类第二目录语义,或把 `.aster managed_settings.yaml`、builtin skill/profile/preset/extension 这些别的 builtin 概念误判成 Claude 风格 plugin 宿主;但扩展体系仍会停留在“`SessionStart(resume / clear)` 与 `SessionEnd(resume / clear / prompt_input_exit / logout / bypass_permissions_disabled)` 仍无真实 current 宿主、`Stop` 仍缺与 Claude Code 等价的 `preventContinuation / stopReason` continuation gate、`PermissionRequest` 仍缺 `updatedPermissions` richer decision 与 session 级权限事实源、plugin loader 与 upstream 的 push-style settings listener / managed policy merge / builtin plugin 加载链仍未完全同构,且 `hook.if / asyncRewake / 非 bash shell / allowedEnvVars` 仍显式未支持”的阶段。所有需要 resume / 完整 session-end lifecycle、完整 managed plugin policy、active builtin plugin hooks、push-style plugin listener、richer stop/permission rewrites 或更广 event 面的能力,仍会被迫绕开 current 边界 | 下一小步继续收口为两段:先继续把 `SessionStart(resume / clear)`、`SessionEnd(resume / clear / prompt_input_exit / logout / bypass_permissions_disabled)` 与 `Stop.preventContinuation / stopReason` 明确保留为 gap,除非 Lime 新增真实 lifecycle / continuation host,否则不再做假映射;然后只在找到真实权限事实源后,再推进 `PermissionRequest.updatedPermissions`,不要为了追平 upstream 语义去硬做半套 permission compat。当前 `updatedPermissions` 已 fail-closed 回退审批流,后续若要实现,必须先补 session 级 permission host,再评估支持矩阵;plugin loader 侧则继续审是否真的需要 upstream 风格的 settings listener / managed policy merge host,在没有 honest host 前,不把旧 installer、legacy manager、`.aster policySettings` 或其它非 current plugin 系统接成 compat | `docs/exec-plans/upstream-runtime-alignment-progress.md` | 进行中 | +| CCD-009 | 2026-04-20 | Remote Governance / Legacy Surface | 参考运行时对比后再次确认:`channels_cmd.rs` 这组 AI / 通知渠道 CRUD 基本仍是空壳,但仓库 current remote 事实源已经固定到 `gateway_channel_* + browser connector / ChromeBridge`;本轮已确认 `ImConfigPage` 走的是 `channelsRuntime.ts -> gateway_channel_*` current API,不依赖 `channels_cmd.rs` 旧 CRUD 命令,并已完成宿主面下线:移除 `runner.rs` 注册、移除 `commands/mod.rs` 模块声明、删除 `channels_cmd.rs` 文件本体,同时补上 `legacySurfaceCatalog` 的命令组与 Rust 文本守卫 | 若不下线,Rust 侧注册与 stub 文件会继续制造“仓库还有另一套可用渠道管理面”的误导,影响 remote 主链收口;现已通过删除与守卫把这类误导性 surface 从宿主编译图中清出 | 已完成删除后的最小验证:`legacySurfaceCatalog.test.ts`、`governance:legacy-report`、`test:contracts`、`cargo check --manifest-path src-tauri/Cargo.toml -p lime --lib` 均通过;下一步回到 `CCD-007`,继续判断 `SendMessage / ListPeers` 的 `uds:` / `bridge:` cross-session peer transport 是否应进入 `current` | `docs/aiprompts/remote-runtime.md` | 已完成 | +| CCD-010 | 2026-04-20 | Resources / Library | 资料库页过去同时存在“大 Hero + 页面级添加入口 + 侧栏新建入口 + 空态动作卡 + store selector / page 各写一套搜索排序分类规则”的旧心智;当前已把资料库 current 主链收窄为“项目切换 / 分类切换 / 搜索排序 / 浏览打开 / 重命名删除 / 图片视图上传插入”,并新增 `resourceQueries` 作为目录视图、分类视图与目录派生状态共用的查询事实源;同时已删掉 `selectors` 中间层,把内容区顶部收成“控制栏 + 当前范围状态条”一套壳,并把跨项目媒体提示并入当前范围状态条、移除内容列表头里的重复最近更新徽标 | 若停在旧状态,后续实现者会继续在页面和 selector 两侧分别补过滤/分类规则,导致资料库行为漂移,也容易把已删除的页面级创建入口或重复状态壳重新接回 current 主链 | 下一小步继续沿资料库 current 主链做减法:优先评估顶部总览区里的 `currentScopeDescription / 范围 badge / 结果统计` 是否还能继续收薄,避免同一范围信息在总览卡和当前范围状态条重复出现;新过滤或目录派生规则只允许落到 `src/components/resources/services/resourceQueries.ts` | `src/components/resources/services/resourceQueries.ts` | 进行中 | diff --git a/docs/exec-plans/upstream-runtime-alignment-progress.md b/docs/exec-plans/upstream-runtime-alignment-progress.md index 9e7618b25..b4a381602 100644 --- a/docs/exec-plans/upstream-runtime-alignment-progress.md +++ b/docs/exec-plans/upstream-runtime-alignment-progress.md @@ -709,3 +709,794 @@ - `rustfmt --edition 2021 "src-tauri/crates/aster-rust/crates/aster/src/skills/tool.rs"` 通过 - `env CARGO_INCREMENTAL=0 RUSTFLAGS="-Cdebuginfo=0" CARGO_TARGET_DIR="/Users/coso/Documents/dev/ai/aiclientproxy/lime/.codex-target" cargo test --manifest-path "src-tauri/crates/aster-rust/crates/aster/Cargo.toml" test_skill_tool_ --lib --no-default-features` 通过(`9 passed`) - 运行时仍会看到工作区既有 `unused import` warning:`task_list_tools.rs`、`worktree_tools.rs`;与本轮 `SkillExecutionMode::Agent` 收口无关,继续不顺手扩大范围清理。 + +### 继续推进(CCD-008 / Skill frontmatter hooks current) + +- 继续对照本地参考运行时: + - `"/Users/coso/Documents/dev/js/claudecode/src/skills/loadSkillsDir.ts"` + - `"/Users/coso/Documents/dev/js/claudecode/src/utils/processUserInput/processSlashCommand.tsx"` + - `"/Users/coso/Documents/dev/js/claudecode/src/utils/hooks/registerSkillHooks.ts"` + - `"/Users/coso/Documents/dev/js/claudecode/src/utils/hooks/sessionHooks.ts"` + 后确认 `skill frontmatter hooks` 也不该再额外造一套 skill-specific hook executor;upstream 的事实源本质是“skill 加载出 hooks,slash command 执行时注册到 session-scoped hook store,真正执行仍走统一 hooks runtime”。因此 Lime 本轮继续把事实源收口到现有 `HookConfig + run_hooks_with_registry_and_context(...)` 主链,而不是给 skill hooks 再补平行 runtime。 +- [`hooks/types.rs`](../../src-tauri/crates/aster-rust/crates/aster/src/hooks/types.rs) 与 [`skills/types.rs`](../../src-tauri/crates/aster-rust/crates/aster/src/skills/types.rs) 现已补齐 current 类型面: + - `SkillFrontmatter` / `SkillDefinition` 新增 `hooks` + - `FrontmatterHooks = event -> matcher[] -> hooks[]` + - `command / prompt / agent / http(url alias)` 会在注册阶段转换成 Lime 当前 `HookConfig` + - `timeout` 按 upstream 秒制收口到当前毫秒制 + - `once` 进入注册语义,而不是留在前端假字段 +- 为了避免假兼容,当前没有真实 runtime 语义的字段继续显式拒绝,而不是静默吞掉: + - `hook.if` + - `command.asyncRewake` + - `command.shell != bash` + - `http.allowedEnvVars` + 这些字段现在会在注册阶段返回明确错误,并通过 `SkillTool` warning 暴露,不会伪装成“已支持”。 +- [`hooks/registry.rs`](../../src-tauri/crates/aster-rust/crates/aster/src/hooks/registry.rs) 新增 `SessionHookStore`,把 skill frontmatter hooks 收口到 session-scoped current store: + - 每条注册项都有唯一 `entry.id` + - `once` hook 成功后按 entry 级别删除,避免重复注册后一删全删 + - `clear_hooks()` 与 `run_session_end_hooks(...)` 都会清掉当前 session hooks +- [`hooks/executor.rs`](../../src-tauri/crates/aster-rust/crates/aster/src/hooks/executor.rs) 现已在统一 hooks runtime 中合并 session hooks: + - 先跑 registry hooks,再按 `input.session_id` 合并 session hooks + - `once` 仅在 hook 成功后移除 + - 没有为了接 skill hooks 把执行逻辑分叉成第二条路径 +- [`skills/loader.rs`](../../src-tauri/crates/aster-rust/crates/aster/src/skills/loader.rs) 与 [`skills/tool.rs`](../../src-tauri/crates/aster-rust/crates/aster/src/skills/tool.rs) 现已把这条链真正接到 Skill current 主入口: + - loader 会把 `SKILL.md` frontmatter 里的 `hooks` 读进 `SkillDefinition` + - `SkillTool.execute_skill(...)` 会在 skill 真正执行前,把 frontmatter hooks 注册到当前 session + - 若当前调用缺少 `session_id`,只会 warning 并跳过注册;不会为了“看起来兼容”偷偷落回全局共享 registry +- 宿主主路径也已补了回归证明这条链真的接进 Lime runtime,而不只是 crate 内部自测: + - [`runtime_project_hooks.rs`](../../src-tauri/src/commands/aster_agent_cmd/runtime_project_hooks.rs) 新增定向测试,证明 `run_runtime_session_start_project_hooks_for_session(...)` 执行 `SessionStart(compact)` 时,会同时命中当前 session 的 skill hooks +- 这一步后的 `CCD-008` 边界继续收紧为: + - `current`:`TaskCreated / TaskCompleted` hooks、project-level `UserPromptSubmit`、`SessionStart(startup / compact)`、`command / url / prompt / agent / mcp` hook executor、`SkillTool` prompt/workflow/agent execution、skill frontmatter hooks + - `current gap`:agent frontmatter hooks 及其 `Stop -> SubagentStop` 事件改写、`SessionStart(resume)`、plugin hook load/hot reload、更多 event bootstrap、agent skill 的 `allowed_tools` 真实权限收紧 + - `explicitly unsupported gap`:`hook.if`、`asyncRewake`、非 `bash` shell、`allowedEnvVars` + - `compat / deprecated / dead`:无新增;本轮没有为了接 skill hooks 再造 compat executor 或回退到全局共享状态 +- 这一步服务路线图主目标的关系是:把 `CCD-008` 里“skill frontmatter hooks 只是 schema 存在、runtime 仍空着”这一块推进到 Lime current,避免扩展体系继续停在“project hooks 能执行、agent skill 能执行,但 skill 自己声明的 hooks 仍完全不生效”的断层状态。 +- 已执行定向校验: + - `rustfmt --edition 2021 "src-tauri/crates/aster-rust/crates/aster/src/hooks/types.rs" "src-tauri/crates/aster-rust/crates/aster/src/hooks/registry.rs" "src-tauri/crates/aster-rust/crates/aster/src/hooks/executor.rs" "src-tauri/crates/aster-rust/crates/aster/src/skills/loader.rs" "src-tauri/crates/aster-rust/crates/aster/src/skills/tool.rs" "src-tauri/crates/aster-rust/crates/aster/src/hooks/tests.rs" "src-tauri/src/commands/aster_agent_cmd/runtime_project_hooks.rs"` 通过 + - `env CARGO_TARGET_DIR="/Users/coso/Documents/dev/ai/aiclientproxy/lime/.codex-target-2" cargo test --manifest-path "src-tauri/crates/aster-rust/crates/aster/Cargo.toml" frontmatter --lib --no-default-features` 通过(`26 passed`) + - `env CARGO_TARGET_DIR="/Users/coso/Documents/dev/ai/aiclientproxy/lime/.codex-target-2" cargo test --manifest-path "src-tauri/Cargo.toml" run_runtime_session_start_project_hooks_for_session_should_merge_session_skill_hooks --lib` 通过(`1 passed`) +- 下一刀最值得继续推进的是: + - 先补 agent frontmatter hooks,并按 upstream 明确把 `Stop` 重写为 `SubagentStop` + - 然后再处理 agent skill `allowed_tools` 的真实权限上下文 + - `plugin hooks / hot reload` 继续留在下一梯队,不建议与前两刀同轮并摊 + +### 继续推进(CCD-008 / Agent skill allowed_tools current) + +- 继续对照本地参考运行时: + - `"/Users/coso/Documents/dev/js/claudecode/src/tools/AgentTool/runAgent.ts"` + - `"/Users/coso/Documents/dev/js/claudecode/src/tools/AgentTool/loadAgentsDir.ts"` + - `"/Users/coso/Documents/dev/js/claudecode/src/utils/hooks/registerFrontmatterHooks.ts"` + 后确认 agent skill 的 `allowed_tools / disallowed_tools` 也不该再额外造一套 agent-specific permission 容器;upstream 的事实源本质是“agent surface 接收 tool scope,再把它下沉到当前 session 权限规则”。因此 Lime 本轮继续把事实源收口到现有 `ToolPermissionManager + PermissionScope::Session` 主链,而不是为了迁就旧实现再补一层 compat 包装。 +- [`dto.rs`](../../src-tauri/src/commands/aster_agent_cmd/dto.rs)、[`types.ts`](../../src/lib/api/agentRuntime/types.ts)、[`tool_runtime/subagent_tools.rs`](../../src-tauri/src/commands/aster_agent_cmd/tool_runtime/subagent_tools.rs) 与 [`agent_control.rs`](../../src-tauri/crates/aster-rust/crates/aster/src/tools/agent_control.rs) 现已补齐 current 输入面: + - `AgentRuntimeSpawnSubagentRequest` 新增 `allowed_tools / disallowed_tools` + - `SpawnAgentRequest` 同步承载同名字段 + - callback-backed `Agent` current surface 也会把这两组字段带入 runtime,而不是只在 prompt 或 metadata 里做展示 +- [`subagent_profiles.rs`](../../src-tauri/crates/agent/src/subagent_profiles.rs)、[`session_store.rs`](../../src-tauri/crates/agent/src/session_store.rs) 与 [`subagent_runtime.rs`](../../src-tauri/src/commands/aster_agent_cmd/subagent_runtime.rs) 现已把这组 tool scope 收进 subagent current 持久化主链: + - `SubagentCustomizationState` 新增 `allowed_tools / disallowed_tools` + - `build_subagent_customization_state(...)` 会做去空、去重、归一化 + - subagent system prompt 会显式渲染 `Allowed Tools` 与 `Disallowed Tools` + - spawn / send_input 进入子会话时,这两组字段都会写入 `request.metadata.subagent` +- [`tool_runtime.rs`](../../src-tauri/src/commands/aster_agent_cmd/tool_runtime.rs) 现已把 metadata 里的 tool scope 真正转成 session-scoped runtime 权限,而不是停留在“前端传了、后端知道、执行时没用”的假支持: + - 新增 `append_subagent_tool_scope_session_permissions(...)` + - 若存在 `allowed_tools`,先写一条 `tool="*"` 的 session-scoped deny,优先级 `1298` + - 再为每个 allow 工具写入 session-scoped allow,优先级 `1299` + - 最后为每个 disallow 工具写入 session-scoped deny,优先级 `1300` + - 这样保持“白名单默认收紧,黑名单高于白名单,并且只作用于当前 child session” +- [`agent.rs`](../../src-tauri/crates/aster-rust/crates/aster/src/agents/agent.rs) 与 [`subagent_tool.rs`](../../src-tauri/crates/aster-rust/crates/aster/src/agents/subagent_tool.rs) 继续把 current/非 current 边界收紧: + - current callback-backed `Agent` surface 只要携带 `allowed_tools / disallowed_tools`,就强制走 callback-backed runtime,不再悄悄退回旧的 foreground native subagent path + - 非 callback-backed runtime 若传这两项,会显式报错 `allowed_tools is only supported in callback-backed runtimes` / `disallowed_tools is only supported in callback-backed runtimes` + - 也就是说,本轮没有为了“看起来兼容”把不生效的字段继续吞掉 +- 这一步后的 `CCD-008` 边界继续收紧为: + - `current`:`TaskCreated / TaskCompleted` hooks、project-level `UserPromptSubmit`、`SessionStart(startup / compact)`、`command / url / prompt / agent / mcp` hook executor、`SkillTool` prompt/workflow/agent execution、skill frontmatter hooks、agent skill `allowed_tools / disallowed_tools` session permission 收口 + - `current gap`:agent frontmatter hooks 及其 `Stop -> SubagentStop` 事件改写、`SessionStart(resume)`、plugin hook load/hot reload、更多 event bootstrap + - `explicitly unsupported gap`:`hook.if`、`asyncRewake`、非 `bash` shell、`allowedEnvVars` + - `compat / deprecated / dead`:无新增;本轮没有为了 agent tool scope 再引入第二套 subagent 权限容器,也没有把旧 foreground path 包装成“看起来支持” +- 这一步服务路线图主目标的关系是:把 `CCD-008` 里“agent skill 看起来能声明工具白名单,但 runtime 实际不会收紧权限”的最后一段假对齐推进到 Lime current,避免扩展体系继续停在“schema 有字段、system prompt 有文案、真实执行权限仍没变”的断层状态。 +- 已执行定向校验: + - `rustfmt --edition 2021 "src-tauri/crates/aster-rust/crates/aster/src/agents/subagent_tool.rs"` 通过 + - `env CARGO_TARGET_DIR="/Users/coso/Documents/dev/ai/aiclientproxy/lime/.codex-target-subagent-runtime" cargo test --manifest-path "src-tauri/Cargo.toml" test_append_subagent_tool_scope_session_permissions_enforces_child_session_scope --lib` 通过(`1 passed`) + - `env CARGO_TARGET_DIR="/Users/coso/Documents/dev/ai/aiclientproxy/lime/.codex-target-aster-subagent" cargo test --manifest-path "src-tauri/crates/aster-rust/crates/aster/Cargo.toml" test_agent_tool_routes_tool_scope_through_callbacks --lib --no-default-features` 通过(`1 passed`) + - `env CARGO_TARGET_DIR="/Users/coso/Documents/dev/ai/aiclientproxy/lime/.codex-target-lime-agent" cargo test --manifest-path "src-tauri/crates/agent/Cargo.toml" build_child_subagent_session_summary_should_merge_customization_state --lib` 通过(`1 passed`) + - `env CARGO_TARGET_DIR="/Users/coso/Documents/dev/ai/aiclientproxy/lime/.codex-target-subagent-runtime" cargo test --manifest-path "src-tauri/Cargo.toml" spawn_subagent_request_should_parse_current_fields --lib` 通过(`1 passed`) + - `env CARGO_TARGET_DIR="/Users/coso/Documents/dev/ai/aiclientproxy/lime/.codex-target-subagent-runtime" cargo test --manifest-path "src-tauri/Cargo.toml" test_build_subagent_customization_system_prompt_renders_builtin_configuration --lib` 通过(`1 passed`) +- 下一刀最值得继续推进的是: + - 先补 agent frontmatter hooks,并按 upstream 明确把 `Stop` 重写为 `SubagentStop` + - 再决定 `SessionStart(resume)` 是否存在真实 current 入口;若没有,就不要硬映射 + - `plugin hooks / hot reload` 继续留在下一梯队,不建议与前两刀同轮并摊 + +### 继续推进(CCD-008 / Agent frontmatter hooks current) + +- 继续对照本地参考运行时: + - `"/Users/coso/Documents/dev/js/claudecode/src/tools/AgentTool/runAgent.ts"` + - `"/Users/coso/Documents/dev/js/claudecode/src/utils/hooks/registerFrontmatterHooks.ts"` + 后确认 agent frontmatter hooks 也不该再额外造一套 agent-specific hook runtime;upstream 的事实源本质是“agent spawn 请求携带 hooks,child session 创建完成后注册到 session-scoped hook store,并在 agent 场景把 `Stop` 改写为 `SubagentStop`”。因此 Lime 本轮继续把事实源收口到现有 `SpawnAgentRequest -> SessionHookStore -> unified hooks executor` 主链,而不是为了迁就旧实现去扩 `.claude/agents`、`loadAgentsDir` 或第二套 hook 容器。 +- [`hooks/types.rs`](../../src-tauri/crates/aster-rust/crates/aster/src/hooks/types.rs) 与 [`hooks/registry.rs`](../../src-tauri/crates/aster-rust/crates/aster/src/hooks/registry.rs) 现已补齐 agent frontmatter hooks 的 current 注册能力: + - frontmatter hook 相关类型补了 `PartialEq / Eq`,确保 `SpawnAgentRequest` 与 `SubagentCustomizationState` 继续能作为 current 状态值稳定比较 + - 新增 `register_agent_session_frontmatter_hooks(...)` + - `Stop -> SubagentStop` 只在 frontmatter 注册层做事件改写,不把分支散到执行器里 + - 空 matcher / 空 hooks 会在注册前被收掉,不会为了“看起来兼容”留下空壳配置 +- [`agent_control.rs`](../../src-tauri/crates/aster-rust/crates/aster/src/tools/agent_control.rs)、[`dto.rs`](../../src-tauri/src/commands/aster_agent_cmd/dto.rs) 与 [`types.ts`](../../src/lib/api/agentRuntime/types.ts) 现已补齐 current 输入面: + - `SpawnAgentRequest` 新增 `hooks` + - `AgentRuntimeSpawnSubagentRequest` 新增 `hooks` + - 前端 runtime 类型同步暴露 `AgentRuntimeFrontmatterHooks` + - `SpawnAgentTool` 继续显式传 `hooks: None`,没有把这组字段扩成模型自由生成的新工具协议 +- [`subagent_profiles.rs`](../../src-tauri/crates/agent/src/subagent_profiles.rs)、[`subagent_runtime.rs`](../../src-tauri/src/commands/aster_agent_cmd/subagent_runtime.rs) 与 [`tool_runtime/subagent_tools.rs`](../../src-tauri/src/commands/aster_agent_cmd/tool_runtime/subagent_tools.rs) 现已把 callback-backed runtime 的 agent hooks 收进 child session current 主链: + - `SubagentCustomizationState` 新增 `hooks` + - `build_subagent_customization_state(...)` 会保留并归一化 `request.hooks` + - callback-backed runtime 转发 spawn request 时会继续透传 `hooks` + - `create_runtime_subagent_session(...)` 在 child session 初始化完成后调用 `register_runtime_subagent_frontmatter_hooks(...)` +- [`execution/manager.rs`](../../src-tauri/crates/aster-rust/crates/aster/src/execution/manager.rs) 则把 aster 原生 spawn path 一并收口: + - 新增 `register_spawned_agent_frontmatter_hooks(...)` + - `spawn_agent_with_runtime(...)` 在 child session 完整创建后注册 hooks + - 这样 Lime 当前两条真实 child session 创建点都已接上 agent frontmatter hooks,而不是只补 callback-backed 一半 +- 这一步后的 `CCD-008` 边界继续收紧为: + - `current`:`TaskCreated / TaskCompleted` hooks、project-level `UserPromptSubmit`、`SessionStart(startup / compact)`、`command / url / prompt / agent / mcp` hook executor、`SkillTool` prompt/workflow/agent execution、skill frontmatter hooks、agent skill `allowed_tools / disallowed_tools` session permission 收口、agent frontmatter hooks 与 `Stop -> SubagentStop` 注册改写 + - `current gap`:`SessionStart(resume)`、plugin hook load/hot reload、更多 event bootstrap + - `explicitly unsupported gap`:`hook.if`、`asyncRewake`、非 `bash` shell、`allowedEnvVars` + - `compat / deprecated / dead`:无新增;本轮没有为了 agent hooks 再发明第二套 agent definition / hook runtime +- 这一步服务路线图主目标的关系是:把 `CCD-008` 里“skill hooks 已 current,但 agent 自己声明的 frontmatter hooks 仍完全不生效”的最后一段 frontmatter 断层推进到 Lime current,避免扩展体系继续停在“project/skill 都会注册 hooks,唯独 subagent surface 仍绕开 hooks”的假对齐状态。 +- 已执行定向校验: + - `env CARGO_TARGET_DIR="/Users/coso/Documents/dev/ai/aiclientproxy/lime/.codex-target-aster-subagent" cargo test --manifest-path "src-tauri/crates/aster-rust/crates/aster/Cargo.toml" test_agent_frontmatter_hooks_rewrite_stop_to_subagent_stop --lib --no-default-features` 通过(`1 passed`) + - `env CARGO_TARGET_DIR="/Users/coso/Documents/dev/ai/aiclientproxy/lime/.codex-target-aster-subagent" cargo test --manifest-path "src-tauri/crates/aster-rust/crates/aster/Cargo.toml" test_spawned_named_agent_registers_name_route_for_parent_session --lib --no-default-features` 通过(`1 passed`) + - `env CARGO_TARGET_DIR="/Users/coso/Documents/dev/ai/aiclientproxy/lime/.codex-target-subagent-runtime" cargo test --manifest-path "src-tauri/Cargo.toml" spawn_subagent_request_should_parse_current_fields --lib` 通过(`1 passed`) + - `env CARGO_TARGET_DIR="/Users/coso/Documents/dev/ai/aiclientproxy/lime/.codex-target-subagent-runtime" cargo test --manifest-path "src-tauri/Cargo.toml" test_build_subagent_customization_state_keeps_frontmatter_hooks --lib` 通过(`1 passed`) + - `env CARGO_TARGET_DIR="/Users/coso/Documents/dev/ai/aiclientproxy/lime/.codex-target-subagent-runtime" cargo test --manifest-path "src-tauri/Cargo.toml" test_register_runtime_subagent_frontmatter_hooks_rewrites_stop_event --lib` 通过(`1 passed`) + - `env CARGO_TARGET_DIR="/Users/coso/Documents/dev/ai/aiclientproxy/lime/.codex-target-lime-agent" cargo test --manifest-path "src-tauri/crates/agent/Cargo.toml" build_child_subagent_session_summary_should_merge_customization_state --lib` 通过(`1 passed`) + - `npm run test:contracts` 通过 +- 下一刀最值得继续推进的是: + - 先判断 `SessionStart(resume)` 是否存在真实 current 宿主入口;若没有,就明确留作 gap,不再反复犹豫 + - 然后转向 `plugin hooks / hot reload` + - 更多 event bootstrap 继续按真实宿主入口逐条推进,不建议为了“枚举对齐”先补假调用点 + +### 继续推进(CCD-008 / Plugin hooks current) + +- 继续对照本地参考运行时: + - `"/Users/coso/Documents/dev/js/claudecode/src/utils/plugins/loadPluginHooks.ts"` + - `"/Users/coso/Documents/dev/js/claudecode/src/utils/sessionStart.ts"` + 后确认 Lime 这一刀不该把旧 `PluginInstaller / plugin_cmd.rs` 再抬成 runtime hook 事实源;upstream 的真实主链是“enabled plugin settings + plugin cache -> hooks merge -> SessionStart/UserPromptSubmit bootstrap”。因此本轮继续把事实源收口到 `runtime_project_hooks.rs -> HookRegistry` 当前主链,而不是为了迁就旧实现再补第二套 plugin hook executor。 +- [`runtime_project_hooks.rs`](../../src-tauri/src/commands/aster_agent_cmd/runtime_project_hooks.rs) 现已把 plugin hooks 接进当前 runtime registry: + - `load_runtime_project_hook_registry(...)` 现会在装载 project hooks 后继续合并 enabled plugin cache hooks + - enabled plugin 读取链先按 `~/.claude/settings.json -> ~/.claude/settings.local.json -> /.claude/settings.json -> /.claude/settings.local.json` 覆盖收口 + - 插件目录只认 `~/.claude/plugins/cache////` current cache,不再把 Lime 自己的 installer 目录包装成“看起来也能跑”的 compat 主链 + - hook 装载支持标准 `hooks/hooks.json` 与 `.claude-plugin/plugin.json` 里的 `manifest.hooks`,包括 path / inline / array 三种 upstream 当前形态 + - runtime registry 每次 `SessionStart` / `UserPromptSubmit` 入口都会重新构建,因此已支持的 plugin hook 变更会在下一次 runtime 入口自然生效,不再额外发明 watcher 或第二层缓存失效协议 +- 这一步同时把 `SessionStart(resume)` 的结论显式钉住: + - Lime 当前公开的 [`agent_runtime_resume_thread`](../../src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs) 仍只是恢复排队线程执行,不是 Claude Code 那种明确的 session lifecycle resume + - 因此 `SessionStart(resume)` 继续保留为 `current gap`,本轮没有把 queue resume 或前端本地清空动作硬包装成假宿主入口 +- 新增定向测试已经把两条 plugin hook current 路径锚住: + - 标准 `hooks/hooks.json` 插件 hook 会进入 `SessionStart` current registry,并真实执行 + - `.claude-plugin/plugin.json` 里的 inline `manifest.hooks` 会进入 `UserPromptSubmit` current registry,并能真实阻止提交 +- 这一步后的 `CCD-008` 边界继续收紧为: + - `current`:`TaskCreated / TaskCompleted` hooks、project-level `UserPromptSubmit`、`SessionStart(startup / compact)`、`command / url / prompt / agent / mcp` hook executor、`SkillTool` prompt/workflow/agent execution、skill frontmatter hooks、agent frontmatter hooks、plugin hooks(runtime `SessionStart / UserPromptSubmit` current surfaces) + - `current gap`:`SessionStart(resume)`、更多 runtime event bootstrap、plugin hook 与 upstream managed policy / builtin plugin 加载链的完全同构仍未补齐 + - `explicitly unsupported gap`:`hook.if`、`asyncRewake`、非 `bash` shell、`allowedEnvVars` + - `compat / deprecated / dead`:无新增;本轮没有为了 plugin hooks 再接 `PluginInstaller` 目录、也没有发明第二套 plugin hook runtime +- 这一步服务路线图主目标的关系是:把 `CCD-008` 里“project/skill/agent hooks 都已 current,但 plugin 自带 hooks 仍完全绕过 runtime”的最后一段 plugin 断层推进到 Lime current,避免扩展体系继续停在“插件只会带技能,不会真正带 runtime hooks”的假对齐状态。 +- 已执行定向校验: + - `cargo fmt --manifest-path "src-tauri/Cargo.toml" --all` + - `cargo check --manifest-path "src-tauri/Cargo.toml" -p lime --tests` 通过 + - `cargo test --manifest-path "src-tauri/Cargo.toml" -p lime --lib load_runtime_project_hook_registry_should_include_` 通过(`2 passed`) + - `cargo test --manifest-path "src-tauri/Cargo.toml" -p lime --lib runtime_project_hooks::tests::` 通过(`7 passed`) +- 下一刀最值得继续推进的是: + - 继续保持 `SessionStart(resume)` 为显式 gap,不再来回犹豫 + - 然后按真实宿主入口继续补更多 runtime event bootstrap,而不是为了枚举对齐先补假调用点 + - 如果要追平 plugin loader 语义,再单独评估 managed policy / builtin plugin hooks 是否也要并入当前 Rust runtime,而不是把旧 installer 重新接回来 + +### 继续推进(CCD-008 / PreCompact hooks current) + +- 继续对照本地参考运行时: + - `"/Users/coso/Documents/dev/js/claudecode/src/services/compact/compact.ts"` + - `"/Users/coso/Documents/dev/js/claudecode/src/utils/hooks.ts"` + 后确认这一步最适合继续补的是 `PreCompact`,而不是为了追枚举先去硬造 `Stop / SessionEnd / PermissionRequest` 宿主入口。Lime 当前唯一干净、单一的压缩宿主入口就是 [`compact_runtime_session_with_trigger(...)`](../../src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs),因此本轮继续把事实源收口到 `runtime_turn -> runtime_project_hooks -> HookRegistry` 当前主链,不去扩第二套 lifecycle runtime。 +- [`executor.rs`](../../src-tauri/crates/aster-rust/crates/aster/src/hooks/executor.rs) 现已补齐 `PreCompact` 的 registry-aware current 执行入口: + - 新增 `run_pre_compact_hooks_with_registry(...)` + - 新增 `run_pre_compact_hooks_with_registry_and_context(...)` + - 现有 `run_pre_compact_hooks(...)` 只负责委托到 registry-aware helper,不再把 `PreCompact` 隐式绑死在全局注册表上 +- [`runtime_project_hooks.rs`](../../src-tauri/src/commands/aster_agent_cmd/runtime_project_hooks.rs) 现已补齐 `PreCompact` 的 per-workspace runtime helper: + - 新增 `enforce_runtime_pre_compact_project_hooks_with_runtime(...)` + - 新增 `enforce_runtime_pre_compact_project_hooks_for_session_with_runtime(...)` + - helper 会继续复用现有 `resolve_runtime_project_hook_workspace_root(...)`,因此 `workspace_id -> workspace_root` 与 `working_dir fallback` 仍保持单一事实源,不会为了压缩前 hooks 再散出一套 project root 猜测逻辑 + - hook 结果继续经过 `log_runtime_project_hook_results(...)`;失败型 hooks 只记 warning,真正 `blocked` 才会中断压缩 +- [`runtime_turn.rs`](../../src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs) 现已把 `PreCompact` 真正接到 runtime 宿主入口: + - `compact_runtime_session_with_trigger(...)` 会在读取到可压缩 conversation 之后、真正创建 compaction turn 之前,先运行 project/plugin/session 的 `PreCompact` + - `manual / auto` 两种压缩统一映射到 `CompactTrigger::Manual / Auto`,不再只有手动 `/compact` 才经过 hooks + - `current_tokens` 会优先使用 session 上的 `total_tokens`,缺失时再回退 `TokenEstimator::estimate_total_tokens(conversation.messages())`,避免把 `PreCompact` 输入继续留成空壳 + - 若 `PreCompact` 返回 `blocked`,手动压缩会直接失败;自动压缩则继续复用现有降级策略,由 `maybe_auto_compact_runtime_session_before_turn(...)` 把失败转成 warning 并继续当前 turn +- 这一步同时也把“当前不该补什么”钉得更清楚: + - `SessionStart(resume)` 仍然继续保留为 gap,本轮没有回头把 queue resume 伪装成 session lifecycle + - `Stop / SessionEnd / PermissionRequest` 仍待先确认 Lime 是否存在单一、可接受的 current 宿主入口;本轮没有为了枚举对齐先补假调用点 +- 新增定向测试已经把 `PreCompact` current 路径锚住: + - `PreCompact` hook 会收到真实 `event / trigger / current_tokens / session_id` + - 阻塞型 `PreCompact` hook 会真实中断压缩,而不是只打日志继续往下执行 +- 这一步后的 `CCD-008` 边界继续收紧为: + - `current`:`TaskCreated / TaskCompleted` hooks、project-level `UserPromptSubmit`、`SessionStart(startup / compact)`、`PreCompact`、`command / url / prompt / agent / mcp` hook executor、`SkillTool` prompt/workflow/agent execution、skill frontmatter hooks、agent frontmatter hooks、plugin hooks(runtime `SessionStart / UserPromptSubmit / PreCompact` current surfaces) + - `current gap`:`SessionStart(resume)`、`Stop / SessionEnd / PermissionRequest` 等更多 runtime event bootstrap、plugin hook 与 upstream managed policy / builtin plugin 加载链的完全同构仍未补齐 + - `explicitly unsupported gap`:`hook.if`、`asyncRewake`、非 `bash` shell、`allowedEnvVars` + - `compat / deprecated / dead`:无新增;本轮没有为了 `PreCompact` 再发明第二套 compaction lifecycle runtime +- 这一步服务路线图主目标的关系是:把参考运行时图里“压缩前也会经过 hooks”这条 extension 主链补进 Lime current,避免扩展体系继续停在“SessionStart / UserPromptSubmit 已 current,但 compaction 仍完全绕过 hooks”的断层状态。 +- 已执行定向校验: + - `rustfmt --edition 2021 "src-tauri/crates/aster-rust/crates/aster/src/hooks/executor.rs" "src-tauri/src/commands/aster_agent_cmd/runtime_project_hooks.rs" "src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs"` 通过 + - `cargo test --manifest-path "src-tauri/Cargo.toml" -p lime --lib enforce_runtime_pre_compact_project_hooks_for_session_should_` 通过(`2 passed`) + - `cargo test --manifest-path "src-tauri/Cargo.toml" -p lime --lib runtime_project_hooks::tests::` 通过(`9 passed`) + - `cargo check --manifest-path "src-tauri/Cargo.toml" -p lime --tests` 通过(仅剩工作区既有 warning:`crates/services/src/api_key_provider_service.rs` 的 `selected_key` 未使用) +- 下一刀最值得继续推进的是: + - 继续保持 `SessionStart(resume)` 为显式 gap,不再来回犹豫 + - 优先核定 `Stop / SessionEnd / PermissionRequest` 哪一条在 Lime 里已有单一 current 宿主入口,再补下一类 event bootstrap + - 如果要追平 plugin loader 语义,再单独评估 managed policy / builtin plugin hooks 是否也要并入当前 Rust runtime,而不是把旧 installer 重新接回来 + +### 继续推进(CCD-008 / PermissionRequest hooks current) + +- 继续对照本地参考运行时: + - `"/Users/coso/Documents/dev/js/claudecode/src/utils/hooks.ts"` + - `"/Users/coso/Documents/dev/js/claudecode/src/utils/permissions/permissions.ts"` + - `"/Users/coso/Documents/dev/js/claudecode/src/hooks/toolPermission/PermissionContext.ts"` + - `"/Users/coso/Documents/dev/js/claudecode/src/cli/structuredIO.ts"` + 后确认这一步最需要补的不是再去给 `ToolRegistry` 造一层 callback 兼容壳,而是把 `PermissionRequest` 直接接到 Lime 当前真实 approval host:[`handle_approval_tool_requests(...)`](../../src-tauri/crates/aster-rust/crates/aster/src/agents/tool_execution.rs)。因此本轮继续把事实源收口到 `runtime_turn -> agent permission_request_hook_handler -> tool_execution approval host -> runtime_project_hooks -> HookRegistry` 当前主链,不去扩第二套 permission runtime。 +- [`types.rs`](../../src-tauri/crates/aster-rust/crates/aster/src/agents/types.rs)、[`mod.rs`](../../src-tauri/crates/aster-rust/crates/aster/src/agents/mod.rs) 与 [`agent.rs`](../../src-tauri/crates/aster-rust/crates/aster/src/agents/agent.rs) 现已补齐 `PermissionRequest` 当前宿主注入面: + - 新增 `PermissionRequestHookContext / PermissionRequestHookDecision / PermissionRequestHookHandler` + - `Agent` 新增 `permission_request_hook_handler` 字段与 `set_permission_request_hook_handler(...)` + - handler 默认仍为 `None`,避免把 hooks 语义硬塞进所有 runtime;只有 Lime current 宿主入口会显式安装它 +- [`tool_execution.rs`](../../src-tauri/crates/aster-rust/crates/aster/src/agents/tool_execution.rs) 现已把 `PermissionRequest` 接进真实 approval host: + - `handle_approval_tool_requests(...)` 会在发出 `action_required` 之前,先执行 `run_permission_request_hook_handler(...)` + - hook 返回 `Allow` 时会直接 `dispatch_tool_call(...)`,不再继续发人工审批 + - hook 返回 `Deny { message }` 时会直接把错误 `CallToolResult` 写回 response message,不再继续发人工审批 + - hook 执行失败时只记 warning,并显式回退到现有人工审批链,不会因为 hook 出错把当前 permission path 打断 + - `permission_mode` 当前只做保守映射:`Approve / SmartApprove -> default`、`Auto -> bypassPermissions`、`Chat -> None`;本轮没有为了追平文本枚举去硬塞 `plan / dontAsk` +- [`runtime_turn.rs`](../../src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs) 与 [`runtime_project_hooks.rs`](../../src-tauri/src/commands/aster_agent_cmd/runtime_project_hooks.rs) 现已补齐 `PermissionRequest` 的 current 宿主对接: + - `prepare_runtime_turn_entry(...)` 会安装 `PermissionRequestHookHandler` + - handler closure 继续只捕获当前 single-source runtime 依赖:`db + AsterAgentState + McpManagerState` + - `decide_runtime_permission_request_project_hooks_for_session_with_runtime(...)` 继续复用 `resolve_runtime_project_hook_workspace_root(...)` 与同一条 project/plugin/session hook registry 主链,不为 `PermissionRequest` 再散一套 project root / plugin loader 旁路 + - 当前 hook JSON 决策只支持: + - `{"decision":"allow"}` + - `{"decision":"deny","message":"..."}` +- [`hooks/types.rs`](../../src-tauri/crates/aster-rust/crates/aster/src/hooks/types.rs) 与 [`hooks/executor.rs`](../../src-tauri/crates/aster-rust/crates/aster/src/hooks/executor.rs) 现已把 `permission_mode` 接进现有 hook 输入与执行环境: + - `HookInput` 新增 `permission_mode` + - command hook 环境变量新增 `CLAUDE_HOOK_PERMISSION_MODE` + - command 占位符新增 `$PERMISSION_MODE` + - url hook payload 也会继续透传 `permissionMode` +- 这一步同时也把“当前不该假装完成什么”钉得更清楚: + - `SessionStart(resume)` 仍然继续保留为 gap,本轮没有回头把 queue resume 伪装成 session lifecycle + - `Stop / SessionEnd` 仍待先确认 Lime 是否存在单一、可接受的 current 宿主入口;本轮没有为了补齐 event 名字先补假调用点 + - upstream `PermissionRequestResult` 里的 `updatedInput / updatedPermissions / interrupt` 仍未实现;本轮只先补最小 current allow/deny 决策,不为迁就旧逻辑去拼半套兼容协议 + - plugin hook 与 upstream managed policy / builtin plugin 加载链的完全同构仍未补齐 +- 新增定向测试已经把 `PermissionRequest` current 路径锚住: + - project hook 会收到真实 `event / permission_mode / tool_use_id / session_id` + - `allow / deny(message)` 两条 decision path 都已落到 runtime helper 定向测试 + - approval host 侧已补 `auto-allow / auto-deny` 两条 end-to-end 单测,防止之后又回流成 `action_required` +- 这一步后的 `CCD-008` 边界继续收紧为: + - `current`:`TaskCreated / TaskCompleted` hooks、project-level `UserPromptSubmit`、`SessionStart(startup / compact)`、`PreCompact`、`PermissionRequest`、`command / url / prompt / agent / mcp` hook executor、`SkillTool` prompt/workflow/agent execution、skill frontmatter hooks、agent frontmatter hooks、plugin hooks(runtime `SessionStart / UserPromptSubmit / PreCompact / PermissionRequest` current surfaces) + - `current gap`:`SessionStart(resume)`、`Stop / SessionEnd` 等更多 runtime event bootstrap、`PermissionRequest` richer decision surface(`updatedInput / updatedPermissions / interrupt`)、plugin hook 与 upstream managed policy / builtin plugin 加载链的完全同构仍未补齐 + - `explicitly unsupported gap`:`hook.if`、`asyncRewake`、非 `bash` shell、`allowedEnvVars` + - `compat / deprecated / dead`:无新增;本轮没有为了 `PermissionRequest` 再发明第二套 approval runtime,也没有把旧 callback 边界重新接回主链 +- 这一步服务路线图主目标的关系是:把参考运行时图里“工具审批前也会经过 project/plugin/session PermissionRequest hooks”这条 extension 主链补进 Lime current,避免扩展体系继续停在“PreCompact 已 current,但权限审批仍完全绕过 hooks”的断层状态。 +- 已执行定向校验: + - `cargo test --manifest-path "src-tauri/Cargo.toml" -p lime --lib decide_runtime_permission_request_project_hooks_for_session_should_` 通过(`2 passed`) + - `cargo test --manifest-path "src-tauri/crates/aster-rust/crates/aster/Cargo.toml" handle_approval_tool_requests_should_auto_ --lib` 通过(`2 passed`;仅剩工作区既有 warning:`task_list_tools.rs / worktree_tools.rs` 的 `SessionManager` 未使用) + - `cargo check --manifest-path "src-tauri/Cargo.toml" -p lime --tests` 通过(增量复查已确认本轮新增 warning 已收干净) +- 下一刀最值得继续推进的是: + - 继续保持 `SessionStart(resume)` 为显式 gap,不再来回犹豫 + - 优先核定 `Stop / SessionEnd` 哪一条在 Lime 里已有单一 current 宿主入口,再补下一类 lifecycle event bootstrap + - 如果要继续追平 `PermissionRequest` 语义,再单独补 `updatedInput / updatedPermissions / interrupt`,不要把这三种 richer decision 混进现有最小 current allow/deny 路径里硬拼 + - 如果要追平 plugin loader 语义,再单独评估 managed policy / builtin plugin hooks 是否也要并入当前 Rust runtime,而不是把旧 installer 重新接回来 + +### 继续推进(CCD-008 / Stop hooks current) + +- 继续对照本地参考运行时: + - `"/Users/coso/Documents/dev/js/claudecode/src/query.ts"` + - `"/Users/coso/Documents/dev/js/claudecode/src/query/stopHooks.ts"` + - `"/Users/coso/Documents/dev/js/claudecode/src/entrypoints/sdk/coreSchemas.ts"` + 后确认这一步最该补的是 `Stop`,而且只能接在 Lime 当前真正的 turn 正常收尾宿主上;因此本轮明确把事实源收口到 [`runtime_turn.rs`](../../src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs) 的 `finalize_runtime_turn_result(...) -> runtime_project_hooks -> HookRegistry` 当前主链,没有把 `agent_runtime_interrupt_turn(...)`、session delete、queue resume 或前端 clear 动作硬包装成假 `Stop / SessionEnd` 入口。 +- [`hooks/types.rs`](../../src-tauri/crates/aster-rust/crates/aster/src/hooks/types.rs) 现已把上游 `Stop` 最小输入面补回当前 `HookInput`: + - 新增 `stop_hook_active` + - 新增 `last_assistant_message` + - 这两个字段会继续沿现有 command stdin / url payload JSON 透传,不再要求 hook 自己回读 transcript 才能拿到最后一条 assistant 文本 +- [`runtime_project_hooks.rs`](../../src-tauri/src/commands/aster_agent_cmd/runtime_project_hooks.rs) 现已补齐 `Stop` 的 per-workspace runtime helper: + - 新增 `run_runtime_stop_project_hooks_with_runtime(...)` + - 新增 `run_runtime_stop_project_hooks_for_session_with_runtime(...)` + - 测试侧补了 `run_runtime_stop_project_hooks(...) / ...for_session(...)`,继续复用 `resolve_runtime_project_hook_workspace_root(...)` 这条唯一 `workspace_id -> workspace_root / working_dir fallback` 事实源,不为 `Stop` 再散一套项目根目录猜测逻辑 + - hook 结果继续统一走 `log_runtime_project_hook_results("Stop", ...)`,本轮只先收通 current 宿主执行,不把 `preventContinuation` 假装映射成 turn 失败或中断 +- [`runtime_turn.rs`](../../src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs) 现已把 `Stop` 真正接到 turn 正常结束宿主: + - `execute_runtime_stream_attempt(...)` 现在会把最终 `assistant_output` 返回给收尾阶段 + - `finalize_runtime_turn_result(...)` 在成功分支里、发出 `FinalDone` 之前,先运行 project/plugin/session 的 `Stop` + - 失败分支仍只发 `Error`,不会把 provider error / interrupt / timeout 误记成 `Stop` + - `stop_hook_active` 当前固定传 `false`;`last_assistant_message` 直接取本回合真实 `assistant_output` +- 这一步同时把“当前不该假装完成什么”钉得更清楚: + - upstream `Stop` 不是用户手工停止,也不是 `SessionEnd` + - `SessionStart(resume)` 仍继续保留为 gap,本轮没有回头把 queue resume 伪装成 lifecycle resume + - `SessionEnd` 仍未接;`clear / shutdown / delete session` 也没有被硬并到 `Stop` + - upstream `Stop` richer 行为里的 `preventContinuation / stopReason` 当前仍未映射到 Lime runtime turn policy;本轮只先补 current 执行宿主与真实输入面,不做假兼容 +- 新增定向测试已经把 `Stop` current 路径锚住: + - `Stop` hook 会收到真实 `event / session_id / stop_hook_active / last_assistant_message` + - `runtime_turn` 原有 `UserPromptSubmit` 定向测试继续通过,证明这次签名改动没有把既有 turn hook 主链带坏 +- 这一步后的 `CCD-008` 边界继续收紧为: + - `current`:`TaskCreated / TaskCompleted` hooks、project-level `UserPromptSubmit`、`SessionStart(startup / compact)`、`PreCompact`、`PermissionRequest`、`Stop`、`command / url / prompt / agent / mcp` hook executor、`SkillTool` prompt/workflow/agent execution、skill frontmatter hooks、agent frontmatter hooks、plugin hooks(runtime `SessionStart / UserPromptSubmit / PreCompact / PermissionRequest / Stop` current surfaces) + - `current gap`:`SessionStart(resume)`、`SessionEnd`、`Stop` richer continuation policy、`PermissionRequest` richer decision surface(`updatedInput / updatedPermissions / interrupt`)、plugin hook 与 upstream managed policy / builtin plugin 加载链的完全同构仍未补齐 + - `explicitly unsupported gap`:`hook.if`、`asyncRewake`、非 `bash` shell、`allowedEnvVars` + - `compat / deprecated / dead`:无新增;本轮没有为了 `Stop` 再发明第二套 turn-end lifecycle runtime,也没有把 interrupt/delete path 接回 current +- 这一步服务路线图主目标的关系是:把参考运行时图里“每个 query turn 正常结束后会经过 Stop hooks”这条 lifecycle extension 主链补进 Lime current,避免扩展体系继续停在“PermissionRequest 已 current,但 turn 正常收尾仍完全绕过 Stop hooks”的断层状态。 +- 已执行定向校验: + - `rustfmt --edition 2021 "src-tauri/crates/aster-rust/crates/aster/src/hooks/types.rs" "src-tauri/src/commands/aster_agent_cmd/runtime_project_hooks.rs" "src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs"` 通过 + - `cargo test --manifest-path "src-tauri/Cargo.toml" -p lime --lib run_runtime_stop_project_hooks_for_session_should_pass_last_assistant_message_and_flag -- --nocapture` 通过(`1 passed`) + - `cargo test --manifest-path "src-tauri/Cargo.toml" -p lime --lib enforce_runtime_turn_user_prompt_submit_hooks_should_ -- --nocapture` 通过(`2 passed`) + - `cargo check --manifest-path "src-tauri/Cargo.toml" -p lime --tests` 通过 +- 下一刀最值得继续推进的是: + - 继续保持 `SessionStart(resume)` 为显式 gap,不再来回犹豫 + - 优先核定 `SessionEnd` 在 Lime 里是否存在单一 current 宿主入口,再继续补 lifecycle event bootstrap + - 如果要继续追平 `Stop` 语义,再单独设计 `preventContinuation / stopReason` 应该如何落到 Lime 的 auto-continue / turn policy,而不是把它硬塞成通用错误返回 + +## 2026-04-21 + +### 继续推进(CCD-008 / SessionEnd hooks current) + +- 继续对照本地参考运行时: + - `"/Users/coso/Documents/dev/js/claudecode/src/utils/hooks.ts"` + - `"/Users/coso/Documents/dev/js/claudecode/src/commands/clear/conversation.ts"` + - `"/Users/coso/Documents/dev/js/claudecode/src/screens/REPL.tsx"` + - `"/Users/coso/Documents/dev/js/claudecode/src/utils/gracefulShutdown.ts"` + - `"/Users/coso/Documents/dev/js/claudecode/src/entrypoints/sdk/coreSchemas.ts"` + 后确认这一步最该补的是 `SessionEnd` 的真实 session lifecycle 宿主,而且只能接在 Lime 当前唯一诚实的 session 结束入口上;因此本轮明确把事实源收口到 [`action_runtime.rs`](../../src-tauri/src/commands/aster_agent_cmd/action_runtime.rs) 的 `agent_runtime_delete_session(...) -> runtime_project_hooks -> HookRegistry -> session_runtime` 当前主链,没有把 `agent_runtime_resume_thread(...)`、`agent_runtime_interrupt_turn(...)`、compact 或前端 `/clear` 动作硬包装成假 `SessionEnd` 入口。 +- [`executor.rs`](../../src-tauri/crates/aster-rust/crates/aster/src/hooks/executor.rs) 现已补齐 `SessionEnd` 的 registry-aware current 执行入口: + - 新增 `run_session_end_hooks_with_registry(...)` + - 新增 `run_session_end_hooks_with_registry_and_context(...)` + - `SessionEnd` 执行完成后会继续 `clear_session_hooks(session_id)`,避免 session 级 hook 注册在生命周期结束后残留成脏状态 +- [`runtime_project_hooks.rs`](../../src-tauri/src/commands/aster_agent_cmd/runtime_project_hooks.rs) 现已补齐 `SessionEnd` 的 per-workspace runtime helper: + - 新增 `run_runtime_session_end_project_hooks(...)` + - 新增 `run_runtime_session_end_project_hooks_with_runtime(...)` + - 新增 `run_runtime_session_end_project_hooks_for_session(...)` + - 新增 `run_runtime_session_end_project_hooks_for_session_with_runtime(...)` + - helper 继续复用 `resolve_runtime_project_hook_workspace_root(...)` 这条唯一 `workspace_id -> workspace_root / working_dir fallback` 事实源,不为 `SessionEnd` 再散一套项目根目录猜测逻辑 +- [`action_runtime.rs`](../../src-tauri/src/commands/aster_agent_cmd/action_runtime.rs) 与 [`session_runtime.rs`](../../src-tauri/src/commands/aster_agent_cmd/session_runtime.rs) 现已把 `SessionEnd` 真正接到 Lime 当前 session 删除宿主: + - `agent_runtime_delete_session(...)` 会先 `cancel session` + - 再 `clear runtime queue` + - 然后运行 project/plugin/session 的 `SessionEnd` + - 最后才真正删除 session + - `SessionEndReason` 当前固定落为 `other`;本轮没有把删除会话伪装成 upstream 的 `clear / prompt_input_exit / logout` + - 原先零引用的 `delete_runtime_session_internal(...)` 已删除,只保留 `delete_runtime_session_internal_with_runtime(...)` 当前主路径,避免继续保留无 runtime hook 的旧 delete 旁路 +- 这一步同时也把“当前不该假装完成什么”钉得更清楚: + - `SessionStart(resume)` 仍继续保留为 gap,本轮没有回头把 queue resume 伪装成 lifecycle resume + - `SessionEnd` 当前只接住 `agent_runtime_delete_session -> SessionEnd(other)`;`clear / prompt_input_exit / logout` 仍没有 Lime 当前真实宿主 + - `agent_runtime_interrupt_turn(...)` 仍不是 `Stop`,也不是 `SessionEnd` + - compact 仍只属于 `SessionStart(compact)` / `PreCompact`,本轮没有把它并进 session 结束语义 +- 新增定向测试已经把 `SessionEnd` current 路径锚住: + - `SessionEnd` hook 会收到真实 `event / session_id / reason=other` + - session hook 会在 `SessionEnd` 后被真实清掉,不会残留到后续 session 生命周期 +- 这一步后的 `CCD-008` 边界继续收紧为: + - `current`:`TaskCreated / TaskCompleted` hooks、project-level `UserPromptSubmit`、`SessionStart(startup / compact)`、`PreCompact`、`PermissionRequest`、`Stop`、`SessionEnd(other via delete_session)`、`command / url / prompt / agent / mcp` hook executor、`SkillTool` prompt/workflow/agent execution、skill frontmatter hooks、agent frontmatter hooks、plugin hooks(runtime `SessionStart / UserPromptSubmit / PreCompact / PermissionRequest / Stop / SessionEnd` current surfaces) + - `current gap`:`SessionStart(resume)`、`SessionEnd(clear / prompt_input_exit / logout)`、`Stop` richer continuation policy、`PermissionRequest` richer decision surface(`updatedInput / updatedPermissions / interrupt`)、plugin hook 与 upstream managed policy / builtin plugin 加载链的完全同构仍未补齐 + - `explicitly unsupported gap`:`hook.if`、`asyncRewake`、非 `bash` shell、`allowedEnvVars` + - `compat / deprecated / dead`:无新增;本轮没有为了 `SessionEnd` 再发明第二套 session lifecycle runtime,也没有把 interrupt / resume / clear path 接回 current +- 这一步服务路线图主目标的关系是:把参考运行时图里“session 生命周期结束时也会经过 hooks”这条 extension 主链补进 Lime current,避免扩展体系继续停在“`Stop` 已 current,但 session delete 仍完全绕过 `SessionEnd` hooks”的断层状态,同时继续把没有真实宿主的 lifecycle 差异显式保留为 gap,而不是做假兼容。 +- 已执行定向校验: + - `cargo test --manifest-path "src-tauri/Cargo.toml" -p lime --lib run_runtime_session_end_project_hooks_for_session_should_pass_reason_and_clear_session_hooks -- --nocapture` 通过(`1 passed`) + - `cargo check --manifest-path "src-tauri/Cargo.toml" -p lime --tests` 通过 +- 下一刀最值得继续推进的是: + - 继续保持 `SessionStart(resume)` 为显式 gap,不再来回犹豫 + - 优先核定 Lime 当前是否存在真实 `SessionEnd(clear / prompt_input_exit / logout)` 宿主;若没有,就明确保留为产品差异,不做假映射 + - 如果要继续追平 `Stop / PermissionRequest` 语义,再分别单独补 `preventContinuation / stopReason` 与 `updatedInput / updatedPermissions / interrupt`,不要把 richer 行为混进现有最小 current 路径里硬拼 + +### 继续推进(CCD-008 / SessionEnd lifecycle host audit) + +- 继续对照本地参考运行时: + - `"/Users/coso/Documents/dev/js/claudecode/src/commands/clear/conversation.ts"` + - `"/Users/coso/Documents/dev/js/claudecode/src/utils/gracefulShutdown.ts"` + - `"/Users/coso/Documents/dev/js/claudecode/src/screens/REPL.tsx"` + - `"/Users/coso/Documents/dev/js/claudecode/src/entrypoints/sdk/coreSchemas.ts"` + 后确认 upstream 的 `SessionEnd` 宿主矩阵并不是抽象枚举,而是四类真实 lifecycle: + - `/clear -> executeSessionEndHooks("clear")` + - `REPL resume -> executeSessionEndHooks("resume")` + - `gracefulShutdown -> executeSessionEndHooks("prompt_input_exit" / "logout" / "other")` + - schema 侧还显式保留 `bypass_permissions_disabled` +- 继续排查 Lime current 宿主后,确认这几类入口目前都不存在等价实现: + - 仓内没有 Claude Code 那种 `clearConversation(...) + regenerateSessionId(...)` 会话清空并重生的 runtime 生命周期;当前前端删除 topic 只是 [`useAgentSession.ts`](../../src/components/agent/chat/hooks/useAgentSession.ts) 在 `runtime.deleteSession(...)` 后清本地状态,不是 session clear host。 + - 仓内没有 `gracefulShutdown(...)` / `prompt_input_exit` 这类 runtime session 退出宿主;当前 `logout` 相关代码要么是 [`external_tools_cmd.rs`](../../src-tauri/src/commands/external_tools_cmd.rs) 返回 `"codex logout"` 字符串,要么是 [`oemCloudControlPlane.ts`](../../src/lib/api/oemCloudControlPlane.ts) 的 OEM control plane 登出接口,都不是 Aster runtime session lifecycle。 + - 当前公开的 [`agent_runtime_resume_thread`](../../src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs) 仍只是恢复排队线程执行,不是 upstream 那种 session 切换前后的 `SessionEnd(resume) / SessionStart(resume)` 宿主。 +- 因此这一轮把 `SessionEnd` 剩余差距进一步钉成显式结论: + - `current`:只有 `agent_runtime_delete_session -> SessionEnd(other)` + - `current gap`:`SessionEnd(resume / clear / prompt_input_exit / logout)` 与 `SessionStart(resume)` + - `product difference until new host exists`:`bypass_permissions_disabled` + - `not a host`:`open_codex_cli_logout`、OEM cloud `logoutClient(...)`、topic 删除后的前端本地清空 +- 这一步服务路线图主目标的关系是:把 `SessionEnd` 剩余 gap 从“还要继续找找看”推进成“已审计确认 Lime 当前没有这些宿主”,后续除非新增真实 current lifecycle host,否则不再围绕 `clear / resume / logout / prompt_input_exit` 反复做假映射判断。 +- 下一刀最值得继续推进的是: + - 继续保持 `SessionStart(resume)` 与 `SessionEnd(resume / clear / prompt_input_exit / logout)` 为显式 gap,不再来回犹豫 + - 后续只有在 Lime 真新增对应 lifecycle host 时,才重新打开这组对齐项 + - 当前主线应回到 richer 语义差距:`Stop` 的 `preventContinuation / stopReason` 与 `PermissionRequest` 的 `updatedInput / updatedPermissions / interrupt` + +### 继续推进(CCD-008 / PermissionRequest updatedInput current) + +- 继续对照本地参考运行时: + - `"/Users/coso/Documents/dev/js/claudecode/src/types/hooks.ts"` + - `"/Users/coso/Documents/dev/js/claudecode/src/cli/structuredIO.ts"` + - `"/Users/coso/Documents/dev/js/claudecode/src/services/tools/toolExecution.ts"` + 后确认 upstream 的 `PermissionRequest.updatedInput` 不是展示字段,而是会真实改写后续工具执行输入;因此这一步继续只沿 `runtime_project_hooks -> PermissionRequestHookDecision -> handle_approval_tool_requests(...)` 当前主链推进,不额外发明第二套 permission rewrite runtime。 +- [`types.rs`](../../src-tauri/crates/aster-rust/crates/aster/src/agents/types.rs) 现已把 `PermissionRequestHookDecision::Allow` 收成带载荷的 current 语义: + - `Allow { updated_input: Option> }` + - `updated_input` 只接受 object 形状,与 Lime 当前 `CallToolRequestParam.arguments` 和 upstream `Record` 保持同构,不再用宽泛 `Value` 做伪兼容 +- [`runtime_project_hooks.rs`](../../src-tauri/src/commands/aster_agent_cmd/runtime_project_hooks.rs) 现已把 `updatedInput` 接进 PermissionRequest hook 解析: + - `{"decision":"allow","updatedInput":{...}}` 会被解析成 `PermissionRequestHookDecision::Allow { updated_input: Some(...) }` + - 若 `updatedInput` 不是 object,会直接 warning 并忽略该 decision,回退到后续 hook / 人工审批;不会偷偷拿原始输入继续 auto-allow + - 现有 `allow`、`deny(message)` 路径保持不变,没有为了迁就 richer 语义再扩 compat 协议 +- [`tool_execution.rs`](../../src-tauri/crates/aster-rust/crates/aster/src/agents/tool_execution.rs) 现已把改写后的输入真正送入工具执行: + - 新增 `apply_permission_request_updated_input(...)` + - `handle_approval_tool_requests(...)` 的 auto-allow 分支会先改写 `CallToolRequestParam.arguments` + - 然后再 `dispatch_tool_call(...)`,确保 hook 返回的 `updatedInput` 真正进入工具,而不是只在 decision 对象里“看起来支持” +- 新增定向测试已经把这条 richer current 路径锚住: + - `runtime_project_hooks` 侧新增 `allow + updatedInput` 解析测试 + - `tool_execution` 侧新增 end-to-end 测试,确认 auto-allow 时工具实际收到改写后的参数,而不是原始 `README.md` +- 这一步后的 `CCD-008` 边界继续收紧为: + - `current`:`TaskCreated / TaskCompleted` hooks、project-level `UserPromptSubmit`、`SessionStart(startup / compact)`、`PreCompact`、`PermissionRequest`(含 `allow + updatedInput`)、`Stop`、`SessionEnd(other via delete_session)`、`command / url / prompt / agent / mcp` hook executor、`SkillTool` prompt/workflow/agent execution、skill frontmatter hooks、agent frontmatter hooks、plugin hooks(runtime `SessionStart / UserPromptSubmit / PreCompact / PermissionRequest / Stop / SessionEnd` current surfaces) + - `current gap`:`SessionStart(resume)`、`SessionEnd(clear / prompt_input_exit / logout)`、`Stop` richer continuation policy、`PermissionRequest` richer decision surface(`updatedPermissions / interrupt`)、plugin hook 与 upstream managed policy / builtin plugin 加载链的完全同构仍未补齐 + - `explicitly unsupported gap`:`hook.if`、`asyncRewake`、非 `bash` shell、`allowedEnvVars` + - `compat / deprecated / dead`:无新增;本轮没有为了 `updatedInput` 再发明新的 approval 协议,也没有把宽泛 JSON 重写偷偷塞回当前主链 +- 这一步服务路线图主目标的关系是:把参考运行时图里 `PermissionRequest` 剩余最贴近 current 的 richer 语义推进到 Lime 当前主链,避免扩展体系继续停在“allow / deny 已 current,但 hook 仍无法真实改写工具输入”的半成品状态。 +- 已执行定向校验: + - `rustfmt --edition 2021 "src-tauri/crates/aster-rust/crates/aster/src/agents/types.rs" "src-tauri/crates/aster-rust/crates/aster/src/agents/tool_execution.rs" "src-tauri/src/commands/aster_agent_cmd/runtime_project_hooks.rs"` 通过 + - `cargo test --manifest-path "src-tauri/Cargo.toml" -p lime --lib decide_runtime_permission_request_project_hooks_for_session_should_ -- --nocapture` 通过(`3 passed`) + - `cargo test --manifest-path "src-tauri/crates/aster-rust/crates/aster/Cargo.toml" handle_approval_tool_requests_should_ --lib -- --nocapture` 通过(`3 passed`;仅剩工作区既有 warning:`task_list_tools.rs / worktree_tools.rs` 的 `SessionManager` 未使用) + - `cargo check --manifest-path "src-tauri/Cargo.toml" -p lime --tests` 通过 +- 下一刀最值得继续推进的是: + - 继续保持 `SessionStart(resume)` 与 `SessionEnd(resume / clear / prompt_input_exit / logout)` 为显式 gap,不再来回犹豫 + - 若继续追 `PermissionRequest` richer 语义,只能在找到真实权限事实源与 interrupt 宿主后,再分别推进 `updatedPermissions` 与 `interrupt` + - 当前更稳的一刀是回到 `Stop` 的 `preventContinuation / stopReason` 审计,先确认 Lime 是否存在同样诚实的 continuation gate,再决定是否进入 current + +### 继续推进(CCD-008 / Stop continuation gate audit) + +- 继续对照本地参考运行时: + - `"/Users/coso/Documents/dev/js/claudecode/src/query/stopHooks.ts"` + - `"/Users/coso/Documents/dev/js/claudecode/src/query.ts"` + - `"/Users/coso/Documents/dev/js/claudecode/src/types/hooks.ts"` + 后确认 upstream 的 `Stop.preventContinuation / stopReason` 不是展示字段,而是 query loop 里的真实 continuation gate: + - `handleStopHooks(...)` 会把 `preventContinuation` 返回给 query loop + - query loop 会据此走 `stop_hook_prevented / hook_stopped`,阻断后续 continuation + - `stopReason` 只是在这个 gate 生效时给用户看的解释,不是独立功能 +- 继续排查 Lime current 宿主后,确认本地目前没有与之等价的 continuation gate: + - [`runtime_turn.rs`](../../src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs) 的 [`finalize_runtime_turn_result(...)`](../../src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs#L3616) 只会在 turn 成功后 fire-and-forget 运行 `Stop` hooks,然后继续发送 `FinalDone`;当前不会回收任何 hook continuation 决策。 + - [`prompt_context.rs`](../../src-tauri/src/commands/aster_agent_cmd/prompt_context.rs#L3) 与 [`dto.rs`](../../src-tauri/src/commands/aster_agent_cmd/dto.rs#L1834) 里的 `auto_continue` 只是“文稿续写”提示词增强,不是 upstream 那种 query-loop continuation state machine。 + - 因此 Lime 当前只有 `Stop event host`,没有 `Stop continuation gate host`;不能把 `auto_continue.enabled`、turn 正常收尾、或任何现有 runtime flag 硬映射成 `preventContinuation` +- 为了避免继续制造假对齐,这一轮补了最小守卫而不是伪实现: + - [`runtime_project_hooks.rs`](../../src-tauri/src/commands/aster_agent_cmd/runtime_project_hooks.rs) 新增 `parse_runtime_stop_hook_continuation_request(...)` + - 当 `Stop` hook 输出 upstream 形状的 `{"continue":false,"stopReason":"..."}` 时,Lime 会显式 warning: + 当前 runtime 识别到了这个 richer 请求,但仍无与 Claude Code 等价的 continuation gate,本次不会阻断 turn 收尾 + - [`runtime_turn.rs`](../../src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs) 现已把这条 warning 从后台 tracing log 提升为前台 `RuntimeAgentEvent::Warning`,并写入 timeline;因此 gap 不再只是“开发者看日志才知道”,而是 turn 收尾前用户也能看到这是显式 unsupported + - 这样 gap 从“静默忽略”收口成“显式 unsupported”,但没有为了迁就差异去伪造一套 continuation 行为 +- 新增定向测试已经把这层 guardrail 锚住: + - `run_runtime_stop_project_hooks_for_session_should_detect_unsupported_continue_false_request` + - 覆盖 `continue:false + stopReason` 的解析与统一 warning message 返回路径,确保后续不会再把这种 richer 请求默默当成功输出吞掉 +- 这一步后的 `CCD-008` 边界继续收紧为: + - `current`:`TaskCreated / TaskCompleted` hooks、project-level `UserPromptSubmit`、`SessionStart(startup / compact)`、`PreCompact`、`PermissionRequest`(含 `allow + updatedInput`)、`Stop` event host、`SessionEnd(other via delete_session)`、`command / url / prompt / agent / mcp` hook executor、`SkillTool` prompt/workflow/agent execution、skill frontmatter hooks、agent frontmatter hooks、plugin hooks(runtime `SessionStart / UserPromptSubmit / PreCompact / PermissionRequest / Stop / SessionEnd` current surfaces) + - `current gap`:`SessionStart(resume)`、`SessionEnd(clear / prompt_input_exit / logout)`、`Stop` richer continuation policy(`preventContinuation / stopReason` 仍无 honest host)、`PermissionRequest.updatedPermissions`、plugin hook 与 upstream managed policy / builtin plugin 加载链的完全同构仍未补齐 + - `explicitly unsupported gap`:`hook.if`、`asyncRewake`、非 `bash` shell、`allowedEnvVars` + - `compat / deprecated / dead`:无新增;本轮没有为了 `Stop` richer 语义再发明第二套 continuation runtime,也没有把 `auto_continue` 或 turn 正常结束硬包装成 query-loop gate +- 这一步服务路线图主目标的关系是:把 `Stop` richer gap 从“也许还能找个现有 flag 复用”推进成“已审计确认 Lime 当前没有等价 continuation gate”,并用代码守卫封住静默假对齐,避免后续继续把 `auto_continue` 或普通 turn 收尾误判成 upstream `preventContinuation`。 +- 已执行定向校验: + - `cargo test --manifest-path "src-tauri/Cargo.toml" -p lime --lib run_runtime_stop_project_hooks_for_session_should_ -- --nocapture` + - `cargo check --manifest-path "src-tauri/Cargo.toml" -p lime --tests` +- 下一刀最值得继续推进的是: + - 继续保持 `Stop.preventContinuation / stopReason` 为显式 gap,除非 Lime 新增真实 continuation gate,否则不再围绕现有 `auto_continue` / turn 收尾反复犹豫 + - 若继续追 richer 语义,更值得回到 `PermissionRequest.updatedPermissions / interrupt` 的真实宿主审计 + +### 继续推进(CCD-008 / PermissionRequest interrupt current) + +- 继续对照本地参考运行时: + - `"/Users/coso/Documents/dev/js/claudecode/src/types/hooks.ts"` + - `"/Users/coso/Documents/dev/js/claudecode/src/hooks/toolPermission/PermissionContext.ts"` + - `"/Users/coso/Documents/dev/js/claudecode/src/cli/structuredIO.ts"` + 后确认 upstream 的 `PermissionRequest.deny + interrupt` 不是普通提示字段,而是会直接触发当前 turn 的中断: + - hook 返回 `{"decision":"deny","message":"...","interrupt":true}` 后,会走 `toolUseContext.abortController.abort()` + - deny message 仍会保留给当前工具拒绝结果,但 query / tool-use 主链会把这次 turn 当作被中断处理 +- 继续审计 Lime current 主链后,确认本地其实已经存在与之诚实对应的 interrupt 宿主: + - [`runtime_turn.rs`](../../src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs) 的 `with_runtime_turn_session_scope(...)` 会为当前 session 建立单一 `CancellationToken` + - [`agent.rs`](../../src-tauri/crates/aster-rust/crates/aster/src/agents/agent.rs) 的 `reply(...) / reply_internal(...)`、tool stream 合并循环与 turn finalize 都会持续检查这个 token;一旦被 cancel,turn 会按 `Aborted` 收尾 + - 因此 `interrupt` 不需要伪造第二套 runtime;只要沿当前 `PermissionRequest hook -> session cancel token` 主链接上,就是 honest host +- 这一轮据此把 `interrupt` 正式接进 Lime current,而没有继续把它误判成 gap: + - [`runtime_project_hooks.rs`](../../src-tauri/src/commands/aster_agent_cmd/runtime_project_hooks.rs) 新增 `RuntimePermissionRequestHookRequest` + - `parse_runtime_permission_request_hook_request(...)` 现在会解析 deny 分支里的 `interrupt:boolean` + - `decide_runtime_permission_request_project_hooks_with_runtime(...)` 在 production current path 上发现 `interrupt:true` 后,会直接调用 `AsterAgentState.cancel_session(session_id)`,并记录 `source=hook` 的 interrupt marker + - 这样 `PermissionRequest` deny 结果仍保持当前工具级拒绝语义,但 turn 本身也会像 upstream 一样进入中断收尾,而不是只回一条 deny message 后继续跑 +- 同时继续把 `updatedPermissions` 诚实保留为 gap,而不是硬拼半套权限映射: + - `PermissionRequest` allow 分支若带 `updatedPermissions`,Lime 当前不再“继续执行 allow 但忽略更新”,而是 fail-closed 回退到原生审批流: + - 不执行 hook allow + - 不应用 `updatedInput` + - 不应用 `updatedPermissions` + - 这样做是为了避免假对齐:upstream 的 `updatedPermissions.destination="session"` 会直接写回 `ToolPermissionContext`,既影响后续 turn,也会立刻影响当前 turn 后续工具决策;如果 Lime 只放行当前工具、却不真正改写权限事实源,会把 hook 作者带进错误语义 + - 继续往本地权限设施下钻后也确认,这不是“仓库里完全没权限代码”,而是“现有权限代码没有接在当前 approval 主链上,也没有 session 级权限事实源”: + - [`tool_runtime.rs`](../../src-tauri/src/commands/aster_agent_cmd/tool_runtime.rs) 每次 turn 都会重建 `ToolPermissionManager::new(None)` 并重新挂到 `ToolRegistry`;这套 session permission 只是本 turn 内存对象 + - [`session_execution_runtime.rs`](../../src-tauri/crates/agent/src/session_execution_runtime.rs) 当前持久化到 session extension data 的只有 `recent_access_mode / recent_preferences / recent_team_selection`,没有 permission rules、额外目录或 upstream `defaultMode` + - [`runtime_store.rs`](../../src-tauri/crates/aster-rust/crates/aster/src/session/runtime_store.rs) 的 `SessionRuntimeSnapshot` 只聚合 thread / turn runtime,也没有 session 权限规则快照 + - [`tool_inspection.rs`](../../src-tauri/crates/aster-rust/crates/aster/src/tool_inspection.rs) 里的 `update_permission_manager(...)` 只会把用户点击 `AlwaysAllow` 收成按工具名写入的 legacy `PermissionManager` + - [`permission/permission_inspector.rs`](../../src-tauri/crates/aster-rust/crates/aster/src/permission/permission_inspector.rs) 的 `IntegratedPermissionManager` 只是可选能力,当前 `PermissionRequest` hook path 并不会在 allow 分支里消费 hook 返回的 permission update payload + - [`permission/manager.rs`](../../src-tauri/crates/aster-rust/crates/aster/src/permission/manager.rs) 虽然存在更宽的 `ToolPermissionUpdate / update_permission(...)`,但当前仓库没有把这套更新器接到 `runtime_project_hooks -> PermissionRequest -> tool_execution` 这条 current path 上 + - 也就是说,这一轮只把 `interrupt` 从误判 gap 里收回 `current`;`updatedPermissions` 仍保持“显式 unsupported 的 current gap”,并且当前行为已经收紧为“显式回退审批流”,不再半执行 hook allow +- 新增定向测试已经把这条边界钉住: + - `decide_runtime_permission_request_project_hooks_should_fallback_to_native_approval_when_allow_requests_updated_permissions` + - `parse_runtime_permission_request_hook_request_should_detect_updated_permissions_request` + - `decide_runtime_permission_request_project_hooks_with_runtime_should_interrupt_session_on_deny_interrupt_true` +- 这一步后的 `CCD-008` 边界继续收紧为: + - `current`:`TaskCreated / TaskCompleted` hooks、project-level `UserPromptSubmit`、`SessionStart(startup / compact)`、`PreCompact`、`PermissionRequest`(含 `allow + updatedInput` 与 `deny + interrupt`)、`Stop` event host、`SessionEnd(other via delete_session)`、`command / url / prompt / agent / mcp` hook executor、`SkillTool` prompt/workflow/agent execution、skill frontmatter hooks、agent frontmatter hooks、plugin hooks(runtime `SessionStart / UserPromptSubmit / PreCompact / PermissionRequest / Stop / SessionEnd` current surfaces) + - `current gap`:`SessionStart(resume)`、`SessionEnd(clear / prompt_input_exit / logout)`、`Stop` richer continuation policy(`preventContinuation / stopReason` 仍无 honest host)、`PermissionRequest.updatedPermissions`、plugin hook 与 upstream managed policy / builtin plugin 加载链的完全同构仍未补齐 + - `explicitly unsupported gap`:`hook.if`、`asyncRewake`、非 `bash` shell、`allowedEnvVars` + - `compat / deprecated / dead`:无新增;本轮没有为了 `updatedPermissions` 再造一层 permission compat,也没有把用户 API `agent_runtime_interrupt_turn` 包一层假 hook host,而是直接复用当前 turn cancel token +- 这一步服务路线图主目标的关系是:把 `PermissionRequest` richer 语义里原本混在一起的两部分彻底拆开,只把已经存在 honest host 的 `interrupt` 收进 current,把还没有真实权限事实源的 `updatedPermissions` 留在显式 gap,避免后续再围绕“这俩是不是都没法做”产生误判。 +- 已执行定向校验: + - `rustfmt --edition 2021 "src-tauri/src/commands/aster_agent_cmd/runtime_project_hooks.rs"` 通过 + - `cargo test --manifest-path "src-tauri/Cargo.toml" -p lime --lib decide_runtime_permission_request_project_hooks_ -- --nocapture` 通过(`5 passed`) + - `cargo test --manifest-path "src-tauri/Cargo.toml" -p lime --lib parse_runtime_permission_request_hook_request_should_detect_updated_permissions_request -- --nocapture` 通过(`1 passed`) + - `cargo check --manifest-path "src-tauri/Cargo.toml" -p lime --tests` 通过 +- 下一刀最值得继续推进的是: + - 继续保持 `PermissionRequest.updatedPermissions` 为显式 gap,除非 Lime 先收敛出真实权限事实源,否则不做半套规则映射 + - `Stop.preventContinuation / stopReason` 仍应保持 gap,不要因为 `interrupt` 已 current 就误判 `Stop` 也有 continuation gate + - 若继续追平 upstream extension 语义,更值得回到 managed plugin policy / builtin plugin hooks 的完全同构审计 + +### 继续推进(CCD-008 / Session lifecycle unsupported host guard) + +- 继续对照本地参考运行时: + - `"/Users/coso/Documents/dev/js/claudecode/src/utils/hooks.ts"` + - `"/Users/coso/Documents/dev/js/claudecode/src/utils/sessionStart.ts"` + - `"/Users/coso/Documents/dev/js/claudecode/src/commands/clear/conversation.ts"` + - `"/Users/coso/Documents/dev/js/claudecode/src/screens/REPL.tsx"` + - `"/Users/coso/Documents/dev/js/claudecode/src/utils/gracefulShutdown.ts"` + 后确认 upstream 的 session lifecycle 比 Lime 当前宿主面更宽: + - `SessionStart` 的 source 真实包含 `startup / resume / clear / compact` + - `SessionEnd` 的 reason 真实包含 `clear / resume / logout / prompt_input_exit / other / bypass_permissions_disabled` +- 继续审计 Lime current 宿主后,确认本地真正有调用点的只有: + - [`session_runtime.rs`](../../src-tauri/src/commands/aster_agent_cmd/session_runtime.rs) 里的 `create_runtime_session_internal_with_optional_runtime(...) -> SessionSource::Startup` + - [`runtime_turn.rs`](../../src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs) 的压缩入口 -> `SessionSource::Compact` + - [`session_runtime.rs`](../../src-tauri/src/commands/aster_agent_cmd/session_runtime.rs) 的 `delete_runtime_session_internal_with_runtime(...) -> SessionEndReason::Other` + - [`command_api/runtime_api.rs`](../../src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs) 的 `agent_runtime_resume_thread(...)` 只是恢复 runtime queue,不是 upstream `/resume` 那种“旧 session 结束 + 新 query session start” + - [`execute_commands.rs`](../../src-tauri/crates/aster-rust/crates/aster/src/agents/execute_commands.rs) 的 `/clear` 只是清空 conversation,也还没有接成 upstream `/clear` 那种 `SessionEnd(clear) + SessionStart(clear)` lifecycle host +- 为了避免后续有人把这些 enum 误当成“已经有 current host”,这一轮继续补了显式 unsupported guard,而不是偷接假宿主: + - [`runtime_project_hooks.rs`](../../src-tauri/src/commands/aster_agent_cmd/runtime_project_hooks.rs) 新增 `build_runtime_session_start_unsupported_warning_message(...)` + - 对 `SessionStart.resume / clear`,当前 runtime 会显式 warning 并直接跳过 hook 执行;不会因为 enum 已存在就默认放行 + - 同文件新增 `build_runtime_session_end_unsupported_warning_message(...)` + - 对 `SessionEnd.clear / logout / prompt_input_exit`,当前 runtime 也会显式 warning 并直接跳过 hook 执行;当前唯一允许的 `SessionEnd` current host 仍只有 `delete_session -> other` + - 这样做的目的不是“删掉未来能力”,而是先把 current 宿主边界钉死,防止 `resume_thread`、`/clear` 或其他局部动作被误包成完整 lifecycle hook host +- 新增定向测试已经把这层 guardrail 锚住: + - `build_runtime_session_start_unsupported_warning_message_should_allow_only_current_sources` + - `run_runtime_session_start_project_hooks_for_session_should_skip_unsupported_resume_source` + - `build_runtime_session_end_unsupported_warning_message_should_allow_only_other_reason` + - `run_runtime_session_end_project_hooks_for_session_should_skip_unsupported_clear_reason` +- 这一步后的 `CCD-008` 边界继续收紧为: + - `current`:`TaskCreated / TaskCompleted` hooks、project-level `UserPromptSubmit`、`SessionStart(startup / compact)`、`PreCompact`、`PermissionRequest`(含 `allow + updatedInput` 与 `deny + interrupt`)、`Stop` event host、`SessionEnd(other via delete_session)`、`command / url / prompt / agent / mcp` hook executor、`SkillTool` prompt/workflow/agent execution、skill frontmatter hooks、agent frontmatter hooks、plugin hooks(runtime `SessionStart / UserPromptSubmit / PreCompact / PermissionRequest / Stop / SessionEnd` current surfaces) + - `current gap`:`SessionStart(resume / clear)`、`SessionEnd(resume / clear / prompt_input_exit / logout / bypass_permissions_disabled)`、`Stop` richer continuation policy(`preventContinuation / stopReason` 仍无 honest host)、`PermissionRequest.updatedPermissions`、plugin hook 与 upstream managed policy / builtin plugin 加载链的完全同构仍未补齐 + - `explicitly unsupported gap`:`hook.if`、`asyncRewake`、非 `bash` shell、`allowedEnvVars` + - `compat / deprecated / dead`:无新增;本轮没有把 `agent_runtime_resume_thread`、`/clear` 或退出动作硬包装成 Session lifecycle compat host,而是先用代码守卫封住误接线 +- 这一步服务路线图主目标的关系是:把 `CCD-008` 里最容易被 enum / schema 误导的 lifecycle 差距进一步钉死为“只有真实宿主才允许进入 current”,避免后续实现者把 queue resume、conversation clear 或任意退出路径误判成已经对齐 Claude Code 的 `SessionStart / SessionEnd` 生命周期。 +- 已执行定向校验: + - `rustfmt --edition 2021 "src-tauri/src/commands/aster_agent_cmd/runtime_project_hooks.rs" "src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs"` 通过 + - `cargo test --manifest-path "src-tauri/Cargo.toml" -p lime --lib run_runtime_session_start_project_hooks_for_session_should_ -- --nocapture` 通过 + - `cargo test --manifest-path "src-tauri/Cargo.toml" -p lime --lib run_runtime_session_end_project_hooks_for_session_should_ -- --nocapture` 通过 + - `cargo check --manifest-path "src-tauri/Cargo.toml" -p lime --tests` 通过 +- 下一刀最值得继续推进的是: + - 若要继续追 `SessionStart(clear / resume)` 或 `SessionEnd(...)`,先找出单一、真实、产品层接受的 lifecycle host,再接 hooks;不要反过来从 enum 倒推宿主 + - 更可能直接推进主线的一刀,仍是继续审 `managed plugin policy / builtin plugin hooks` 与 upstream loader 同构差距 + +## 2026-04-21 + +### 继续推进(CCD-008 / plugin cache fact-source convergence) + +- 继续对照本地参考运行时: + - `"/Users/coso/Documents/dev/js/claudecode/src/utils/plugins/loadPluginHooks.ts"` + - `"/Users/coso/Documents/dev/js/claudecode/src/utils/plugins/pluginLoader.ts"` + - `"/Users/coso/Documents/dev/js/claudecode/src/utils/plugins/pluginStartupCheck.ts"` + 后确认 Lime 当前更直接的漂移点不是“还没补 managed policy”,而是仓库内部已经有两套 `enabledPlugins + cache root` 解析事实源: + - [`runtime_project_hooks.rs`](../../src-tauri/src/commands/aster_agent_cmd/runtime_project_hooks.rs) 会合并 `home/workspace + settings/settings.local`,再只加载 marketplace cache plugin hooks + - [`skills/loader.rs`](../../src-tauri/crates/aster-rust/crates/aster/src/skills/loader.rs) 之前却只看 `~/.claude/settings.json`,并自己重扫一遍 `~/.claude/plugins/cache` +- 这一轮先没有为了“补名字”把旧 plugin installer 或另一套 plugin system 接回 current,而是直接把 repo 内部双轨收口成单一 helper: + - 新增 [`claude_plugin_cache.rs`](../../src-tauri/crates/aster-rust/crates/aster/src/claude_plugin_cache.rs),统一负责: + - 合并 `~/.claude/settings.json` + - 合并 `~/.claude/settings.local.json` + - 合并 `/.claude/settings.json` + - 合并 `/.claude/settings.local.json` + - 解析 `plugin@marketplace` + - 选择 `~/.claude/plugins/cache///` 下的最高版本目录 + - [`runtime_project_hooks.rs`](../../src-tauri/src/commands/aster_agent_cmd/runtime_project_hooks.rs) 现在直接复用这条 helper 加载 plugin hooks,不再自己维护 `enabledPlugins` merge、plugin id 解析和 cache version 排序 + - [`skills/loader.rs`](../../src-tauri/crates/aster-rust/crates/aster/src/skills/loader.rs) 现在也复用同一 helper 加载 plugin skills,并补了一条定向测试锁住 `workspace/.claude/settings.local.json` 对 plugin skill 可见 + - `@builtin` 不再在 hook / skill 两侧各自静默掉过,而是统一标记为显式 unsupported gap:当前没有 honest host,就不伪装成 marketplace cache plugin current +- 这一步后的 `CCD-008` 边界继续收紧为: + - `current`:plugin hooks 与 plugin skills 已共享 `claude_plugin_cache.rs` 单一 resolver;home/workspace settings 合并、latest-version 选择与 `@builtin` gap 口径都已收敛 + - `current gap`:upstream 的 managed plugin policy、builtin plugin hooks、plugin hot reload / prune 仍未同构;Lime 目前只对齐到 marketplace cache plugin 这一层 current 事实源 + - `compat / deprecated / dead`:无新增;这一轮没有把旧 installer、legacy plugin manager 或另一套 plugin surface 抬回 current +- 这一步服务路线图主目标的关系是:先把 Lime 仓库内部自己长出来的 plugin loader 双轨清掉,避免后续一边补 upstream gap、一边继续让 hook 与 skill 各自漂移;下一刀才值得继续追 `managed policy / builtin plugin hooks` 的真实宿主审计。 +- 已执行定向校验: + - `rustfmt --edition 2021 "src-tauri/crates/aster-rust/crates/aster/src/claude_plugin_cache.rs" "src-tauri/crates/aster-rust/crates/aster/src/lib.rs" "src-tauri/crates/aster-rust/crates/aster/src/skills/loader.rs" "src-tauri/src/commands/aster_agent_cmd/runtime_project_hooks.rs"` 通过 + - `cargo test --manifest-path "src-tauri/crates/aster-rust/crates/aster/Cargo.toml" claude_plugin_cache::tests --lib -- --nocapture` 通过(`3 passed`) + - `cargo test --manifest-path "src-tauri/crates/aster-rust/crates/aster/Cargo.toml" test_load_skills_from_plugin_cache_uses_workspace_settings_local_and_latest_version --lib -- --nocapture` 通过(`1 passed`) + - `cargo test --manifest-path "src-tauri/Cargo.toml" -p lime --lib runtime_project_hooks -- --nocapture` 通过(`22 passed`) + - `cargo check --manifest-path "src-tauri/Cargo.toml" -p lime --tests` 通过 +- 下一刀最值得继续推进的是: + - 继续审 `managed plugin policy / builtin plugin hooks` 与 upstream loader 的同构差距 + - 在没有真实宿主前,继续保持 `@builtin` 为显式 gap,不把旧 plugin installer、legacy manager 或其他非 current plugin 系统重新接回主链 + +### 继续推进(CCD-008 / managed plugin policy unsupported guard) + +- 继续对照本地参考运行时后,进一步把 `managed plugin policy` 和 Lime 现有 `policySettings` 区分开: + - upstream 的 plugin loader 会把 `policySettings.enabledPlugins / strictKnownMarketplaces / blockedMarketplaces / extraKnownMarketplaces` 直接并进 `loadAllPluginsCacheOnly()` 的 discovery / merge / policy current 主链 + - Lime 当前虽然在 [`config_manager.rs`](../../src-tauri/crates/aster-rust/crates/aster/src/config/config_manager.rs) 有 `.aster/managed_settings.yaml` 的 `policySettings` 概念,但它属于通用配置管理器,不是 Claude 风格 `.claude plugin policy` 当前宿主,也没有接进 `plugin hook / plugin skill` loader +- 因此这一轮没有把 `.aster policySettings` 硬接成 plugin managed host,而是继续做 honest guard: + - [`claude_plugin_cache.rs`](../../src-tauri/crates/aster-rust/crates/aster/src/claude_plugin_cache.rs) 现在会额外探测 `~/.aster/managed_settings.yaml` / `~/.aster/policy.yaml` + - 如果这些文件里声明了 Claude 风格 plugin policy keys:`enabledPlugins / extraKnownMarketplaces / strictKnownMarketplaces / blockedMarketplaces` + - resolver 会统一返回显式 skipped reason,说明“检测到 managed plugin policy key,但当前 runtime 尚未接入 policySettings/plugin loader current 宿主,已忽略” + - 这样 `runtime_project_hooks` 和 `skills/loader` 复用同一 resolver 时,会共享这条 warning 口径,而不是继续静默掉过 +- 这一步后的 `CCD-008` 边界继续收紧为: + - `current`:marketplace cache plugin 的 hook / skill loader 继续共享单一 resolver;`@builtin` 与 managed plugin policy gap 都已有统一的代码级 skipped reason + - `current gap`:upstream 的 managed plugin policy merge、builtin plugin registry、builtin plugin hooks、plugin hot reload / prune 仍未同构;Lime 还没有可复用的 honest host + - `compat / deprecated / dead`:无新增;本轮没有把 `.aster policySettings`、旧 plugin installer 或其他 builtin/managed 相关旧面抬成 current +- 这一步服务路线图主目标的关系是:把“Lime 也有 policySettings”这种最容易诱发假对齐的点钉死成显式 unsupported guard,避免后续实现者把 `.aster managed_settings.yaml` 误当成 Claude Code 的 plugin managed policy current 宿主。 +- 已执行定向校验: + - `rustfmt --edition 2021 "src-tauri/crates/aster-rust/crates/aster/src/claude_plugin_cache.rs"` 通过 + - `cargo test --manifest-path "src-tauri/crates/aster-rust/crates/aster/Cargo.toml" claude_plugin_cache::tests --lib -- --nocapture` 通过(`5 passed`) + - `cargo check --manifest-path "src-tauri/Cargo.toml" -p lime --tests` 通过 +- 下一刀最值得继续推进的是: + - 继续审 upstream 的 builtin plugin registry 与 builtin plugin hooks 是否在 Lime 存在真实宿主;如果没有,就继续保持 gap,而不是把 Lime 现有 builtin skill / extension / preset 系统误抬成 plugin current + +### 继续推进(CCD-008 / builtin plugin registry host audit) + +- 继续对照本地参考运行时: + - `"/Users/coso/Documents/dev/js/claudecode/src/plugins/builtinPlugins.ts"` + - `"/Users/coso/Documents/dev/js/claudecode/src/plugins/bundled/index.ts"` + - `"/Users/coso/Documents/dev/js/claudecode/src/utils/plugins/pluginLoader.ts"` + 后确认 upstream 的 `@builtin` 确实不是 marketplace cache plugin,而是单独的 builtin plugin registry 宿主: + - [`builtinPlugins.ts`](../../../../js/claudecode/src/plugins/builtinPlugins.ts) 定义了 `BuiltinPluginDefinition -> registerBuiltinPlugin() -> getBuiltinPlugins()` 这一条独立 registry + - [`pluginLoader.ts`](../../../../js/claudecode/src/utils/plugins/pluginLoader.ts) 也明确把 `marketplace` 与 `builtin` 分开装配,builtins 通过 `getBuiltinPlugins()` 合并进最终 plugin load result,而不是走 `plugins/cache` + - 但按当前参考源码,[`plugins/bundled/index.ts`](../../../../js/claudecode/src/plugins/bundled/index.ts) 仍是空 scaffold,今天还没有真正注册任何 builtin plugin +- 这意味着 Lime 当前不该做的事更明确了: + - 不能把 `@builtin` 当成另一种 cache plugin 继续沿 `~/.claude/plugins/cache` 猜目录 + - 也不能把本地现有的 builtin skill / builtin profile / builtin team preset / builtin extension config 这些“名字里带 builtin”的系统,误抬成 Claude Code 的 builtin plugin registry current 宿主 +- 因此这一轮只继续补 honest guard,不做假映射: + - [`claude_plugin_cache.rs`](../../src-tauri/crates/aster-rust/crates/aster/src/claude_plugin_cache.rs) 里的 `@builtin` skipped reason 现在进一步收紧为: + - 当前缺少独立的 builtin plugin registry/current 宿主 + - 不能回退为 marketplace cache plugin + - 也不能回退到其他 builtin surface +- 这一步后的 `CCD-008` 边界继续收紧为: + - `current`:marketplace cache plugin 的 hook / skill loader 与 managed-policy unsupported guard 已收敛到单一 resolver + - `current gap`:upstream 的 builtin plugin registry host 目前在 Lime 缺席;不过参考运行时今天也还没有实际注册的 builtin plugins,所以这条 gap 当前属于“结构已存在、活跃能力尚未落地”的 dormant gap + - `compat / deprecated / dead`:无新增;本轮没有把 builtin skill / profile / preset / extension 平移成 plugin compat +- 这一步服务路线图主目标的关系是:把 `@builtin` 的误导性空间进一步压扁,避免后续实现者因为仓库里到处有 `builtin` 命名,就误判 Lime 已经具备 Claude Code 的 builtin plugin current 宿主。 +- 已执行定向校验: + - `rustfmt --edition 2021 "src-tauri/crates/aster-rust/crates/aster/src/claude_plugin_cache.rs"` 通过 + - `cargo test --manifest-path "src-tauri/crates/aster-rust/crates/aster/Cargo.toml" claude_plugin_cache::tests --lib -- --nocapture` 通过(`5 passed`) + - `cargo check --manifest-path "src-tauri/Cargo.toml" -p lime --tests` 通过 +- 下一刀最值得继续推进的是: + - 若继续沿 plugin loader 对齐主线推进,比起 builtin plugin,更值得回到 plugin hot reload / prune 与 managed policy merge 的 current 宿主差距 + +### 继续推进(CCD-008 / plugin skill on-demand refresh) + +- 继续对照本地参考运行时并回看 Lime 当前实现后,进一步确认 `plugin hot reload / prune` 在仓库里的真实漂移点已经收缩: + - [`runtime_project_hooks.rs`](../../src-tauri/src/commands/aster_agent_cmd/runtime_project_hooks.rs) 这条 hook current 主链本来就是“按次构建 `HookRegistry` -> 重新加载 project/plugin hooks”,所以 `plugin hooks` 侧并不存在一个额外的全局缓存宿主需要单独热刷新 + - 真正仍会漂的是 [`skills/registry.rs`](../../src-tauri/crates/aster-rust/crates/aster/src/skills/registry.rs) 里的全局 `SkillRegistry`:它之前挂在 `OnceLock` 后只初始化一次,导致 plugin skill 的 enable/disable、cache version 切换、同一路径 `SKILL.md` 内容变更都不会反映到后续 skill / workflow 使用 +- 因此这一轮没有去发明新的 plugin listener、也没有把别的 legacy plugin/extension 面接回 current,而是继续沿现有 Rust runtime 补最诚实的一刀: + - [`skills/loader.rs`](../../src-tauri/crates/aster-rust/crates/aster/src/skills/loader.rs) 新增 `build_plugin_skill_registry_snapshot_with_context(...)`,基于共享的 [`claude_plugin_cache.rs`](../../src-tauri/crates/aster-rust/crates/aster/src/claude_plugin_cache.rs) resolver 生成 plugin skill snapshot;snapshot 会记录: + - 当前可见的 `plugins` + - 当前 `skipped` reasons + - 每个 plugin root 下 `skills/*/SKILL.md` 的路径、文件长度、修改时间与内容 hash + - [`skills/registry.rs`](../../src-tauri/crates/aster-rust/crates/aster/src/skills/registry.rs) 新增 `plugin_snapshot` 状态,以及: + - `initialize_with_context(...)` + - `reload_with_context(...)` + - `refresh_plugin_skills_if_needed_with_context(...)` + - `refresh_shared_registry_if_needed(...)` + - 刷新语义保持极简: + - snapshot 不变时不 reload + - snapshot 变化时重新装载整套 skills(plugin / user / project),沿用既有优先级 + - `invoked` 历史不清空,不为了刷新把使用记录抹掉 + - [`skills/tool.rs`](../../src-tauri/crates/aster-rust/crates/aster/src/skills/tool.rs) 与 [`workflow_tool.rs`](../../src-tauri/crates/aster-rust/crates/aster/src/tools/workflow_tool.rs) 现在都会在 current 入口前触发这层 refresh,因此下一次使用 `SkillTool` / `WorkflowTool` 时,就会真实反映 plugin skill 的启停、prune、版本切换和 `SKILL.md` 更新 +- 这一步后的 `CCD-008` 边界继续收紧为: + - `current`:plugin hooks 继续通过按次重建 registry 获得天然刷新;plugin skills 则已补上基于 snapshot 的 on-demand refresh,当前 `SkillTool / WorkflowTool` 会在使用前自动收敛到最新可见 plugin skill 集合 + - `current gap`:Lime 还没有 upstream 那种 push-style plugin settings listener / managed policy merge host;builtin plugin current 宿主也仍缺席,所以这一步只是把“效果对齐”补到当前 skill runtime,而不是宣称已拥有同构的 plugin loader 生命周期 + - `compat / deprecated / dead`:无新增;本轮没有为了追平热刷新语义,把旧 installer、legacy manager、`.aster policySettings` 或其它 extension surface 抬回 current +- 这一步服务路线图主目标的关系是:把 `CCD-008` 在仓库内部最后一个明显的 plugin skill 全局缓存漂移点收掉,让 Lime 现有 skill/runtime 主链不再停在“settings 已变、skill 仍读旧快照”的半成品阶段;下一刀才值得继续评估是否需要真正的 push listener / policy merge 宿主。 +- 已执行定向校验: + - `rustfmt --edition 2021 "src-tauri/crates/aster-rust/crates/aster/src/skills/loader.rs" "src-tauri/crates/aster-rust/crates/aster/src/skills/registry.rs" "src-tauri/crates/aster-rust/crates/aster/src/skills/tool.rs" "src-tauri/crates/aster-rust/crates/aster/src/tools/workflow_tool.rs"` 通过 + - `cargo test --manifest-path "src-tauri/crates/aster-rust/crates/aster/Cargo.toml" refresh_plugin_skills_if_needed_with_context --lib -- --nocapture` 通过(`3 passed`) + - `cargo check --manifest-path "src-tauri/Cargo.toml" -p lime --tests` 通过 +- 下一刀最值得继续推进的是: + - 继续判断 Lime 是否真的需要补 upstream 风格的 plugin settings listener / managed policy merge host;如果没有真实宿主,就继续把这层差异保留为 gap,而不是把别的系统硬包装成 current + +### 继续推进(CCD-008 / platform skills extension fact-source convergence) + +- 顺着 `plugin skill` 刷新主线继续盘点后,又发现仓库里还留着一条容易误导实现者的平行技能面: + - [`agents/skills_extension.rs`](../../src-tauri/crates/aster-rust/crates/aster/src/agents/skills_extension.rs) 过去会自己扫描 `~/.claude/skills`、`~/.config/agents/skills`、`$PWD/.claude/skills`、`$PWD/.aster/skills`、`$PWD/.agents/skills` + - 这条 platform extension loader 与当前 [`skills/loader.rs`](../../src-tauri/crates/aster-rust/crates/aster/src/skills/loader.rs) / [`skills/registry.rs`](../../src-tauri/crates/aster-rust/crates/aster/src/skills/registry.rs) 的 current skill 主链不是同一事实源,甚至会把 `.aster/skills`、`.agents/skills` 这类 Claude Code 当前语义里并不存在的目录重新抬成可见 surface + - 如果继续默认启用,它会形成第二套 `skills__loadSkill` 工具面,与 native `SkillTool` / `WorkflowTool` 并存,进一步偏离参考运行时 +- 这一轮没有为了保留旧行为继续补 compat,而是直接收口: + - [`extension.rs`](../../src-tauri/crates/aster-rust/crates/aster/src/agents/extension.rs) 里 `skills` platform extension 已从 `default_enabled: true` 降为 `false`,并在描述里明确 native `Skill tool` 才是 current surface + - [`skills_extension.rs`](../../src-tauri/crates/aster-rust/crates/aster/src/agents/skills_extension.rs) 即使被手动启用,也不再自己扫描那套旧目录,而是改为直接复用共享的 `SkillRegistry`: + - 读取前先执行 `refresh_shared_registry_if_needed(...)` + - `loadSkill` 改为通过共享 registry 查找 skill + - skill instructions 与工具可见性也都改为从共享 registry 读取 + - 这样处理后,仓库里不再存在“native skills 一套事实源,platform skills extension 另一套事实源”的并行 current +- 这一步后的 `CCD-008` 边界继续收紧为: + - `current`:native `SkillTool / WorkflowTool + SkillRegistry` 继续是唯一技能 current surface + - `compat`:platform `skills` extension 现在只剩显式 opt-in legacy surface;即便开启,也只能委托共享 `SkillRegistry`,不再拥有独立 loader / directory semantics + - `dead`:`.aster/skills`、`.agents/skills` 这类 platform skills extension 私有目录语义已不再参与当前运行时事实源 +- 这一步服务路线图主目标的关系是:把 Lime 仓库内部最后一个“技能系统自己再长一套目录发现逻辑”的平行面压回去,避免 `CCD-008` 主线刚把 native skill/runtime 收口完,另一边 platform extension 又把旧目录和旧工具面重新接回产品。 +- 已执行定向校验: + - `rustfmt --edition 2021 "src-tauri/crates/aster-rust/crates/aster/src/agents/extension.rs" "src-tauri/crates/aster-rust/crates/aster/src/agents/skills_extension.rs"` 通过 + - `cargo test --manifest-path "src-tauri/crates/aster-rust/crates/aster/Cargo.toml" skills_extension::tests --lib -- --nocapture` 通过(`12 passed`) + - `cargo check --manifest-path "src-tauri/Cargo.toml" -p lime --tests` 通过 +- 下一刀最值得继续推进的是: + - 回到真正还没有 honest host 的部分,继续审 `plugin settings listener / managed policy merge` 是否需要产品级 current 宿主;如果没有,就继续把这层差异留在 gap,而不是从旧 extension / config surface 里找替身 + +### 继续推进(CCD-008 / plugin cache legacy fallback) + +- 继续对照本地参考运行时 [`pluginLoader.ts`](../../../../js/claudecode/src/utils/plugins/pluginLoader.ts) 后,确认 plugin cache 目录语义还差最后一层真实 resolver 行为: + - upstream 的 `resolvePluginPath(...)` 不是“只认 versioned cache” + - 它会先尝试 `~/.claude/plugins/cache////` + - 若该 versioned 路径不存在,再回退到历史遗留的 `~/.claude/plugins/cache//` +- 因此这一轮没有去补没有 honest host 的 settings listener / managed policy merge,而是先把 Lime 的 cache resolver 对齐到 upstream 当前真实行为: + - [`claude_plugin_cache.rs`](../../src-tauri/crates/aster-rust/crates/aster/src/claude_plugin_cache.rs) 里的 `resolve_cached_plugin_root(...)` 现在改为: + - 先在 versioned `cache///` 下选择最高版本目录 + - 找不到可用 versioned root 时,再回退到 legacy non-versioned `cache//` + - marketplace / plugin path component 也改为按 upstream 同样的 sanitize 规则解析,避免 `@scope/plugin@demo.market` 这类真实插件 ID 因路径字符差异被误判成不存在 + - 这样 `runtime_project_hooks.rs` 与 `skills/loader.rs` 继续共享同一个 resolver 时,会同时获得: + - versioned cache 优先 + - legacy cache fallback + - `@builtin` / managed policy gap 的统一 skipped reason + - 这一步只对齐 Claude Code 当前仍承认的 legacy cache path,不等于把 Lime 自己的 installer、`.aster` 插件目录或其它非 Claude cache 面接回 current +- 也补了最贴边界的回归测试,锁住 resolver 口径: + - 只有 legacy `cache//` 时,仍能解析出 plugin root + - versioned 与 legacy 同时存在时,必须优先选 versioned root + - scoped plugin / 带点号 marketplace 会命中 sanitize 后的 cache 路径 + - `runtime_project_hooks.rs` 已补消费者级 legacy cache 集成测试,证明 plugin hook 当前主链也会吃到这条 fallback + - `skills/loader.rs` 已补消费者级 sanitized path 集成测试,证明 plugin skill 当前主链也会吃到同一条 path 规则 +- 这一步后的 `CCD-008` 边界继续收紧为: + - `current`:plugin hooks / plugin skills 继续共享单一 `claude_plugin_cache.rs` resolver,当前口径已覆盖 home/workspace `settings*.json` 合并、highest-version 选择,以及 upstream 仍承认的 legacy cache fallback + - `current gap`:push-style plugin settings listener、managed policy merge、builtin plugin registry / hooks 仍无 honest host;这些差距继续保留为 gap,不做假映射 + - `compat / deprecated / dead`:无新增;本轮没有把其它 plugin installer、legacy manager 或 `.aster policySettings` 抬回 current +- 这一步服务路线图主目标的关系是:把 `CCD-008` 在 plugin cache resolver 这一层收成真正与 upstream 一致的 current 事实源,避免后续实现者继续把“只认 versioned cache”的半截语义误当成已经对齐。 +- 已执行定向校验: + - `rustfmt --edition 2021 "src-tauri/src/commands/aster_agent_cmd/runtime_project_hooks.rs" "src-tauri/crates/aster-rust/crates/aster/src/skills/loader.rs"` 通过 + - `cargo test --manifest-path "src-tauri/Cargo.toml" -p lime load_runtime_project_hook_registry_should_include_legacy_cached_plugin_user_prompt_hooks -- --nocapture` 通过(`1 passed`) + - `cargo test --manifest-path "src-tauri/crates/aster-rust/crates/aster/Cargo.toml" test_load_skills_from_plugin_cache_uses_sanitized_plugin_cache_paths --lib -- --nocapture` 通过(`1 passed`) + - `cargo check --manifest-path "src-tauri/Cargo.toml" -p lime --tests` 通过 + +### 继续推进(CCD-008 / legacy plugin manifest fallback) + +- 继续对照本地参考运行时 [`pluginLoader.ts`](../../../../js/claudecode/src/utils/plugins/pluginLoader.ts) 后,又确认 plugin hook loader 还有一条当前仍有效的旧布局读取语义: + - upstream 读取 plugin manifest 时,优先 `.claude-plugin/plugin.json` + - 若主路径不存在,仍会 fallback 到根目录 `plugin.json` +- Lime 之前的 [`runtime_project_hooks.rs`](../../src-tauri/src/commands/aster_agent_cmd/runtime_project_hooks.rs) 只读 `.claude-plugin/plugin.json`,这会让一部分旧 cache 布局下的 plugin hooks 被误判成“没有 manifest hooks”。 +- 因此这一轮继续补的是当前真实读取顺序,而不是新造 compat: + - plugin hook loader 现在改为 `resolve_plugin_manifest_path(...)` + - 顺序固定为: + - 先读 `.claude-plugin/plugin.json` + - 若不存在,再读 legacy 根目录 `plugin.json` + - 如果主路径存在,就不会去用 legacy 路径顶替,保持与 upstream 同样的优先级 +- 也补了对应消费者级集成测试: + - versioned plugin cache + legacy 根目录 `plugin.json` 时,`runtime_project_hooks` 仍能加载 `UserPromptSubmit` hook +- 这一步后的 `CCD-008` 边界继续收紧为: + - `current`:plugin hook 当前主链现在同时对齐了 upstream 的 cache path 解析顺序与 manifest path 解析顺序 + - `current gap`:push-style plugin settings listener、managed policy merge、builtin plugin registry / hooks 仍无 honest host + - `compat / deprecated / dead`:无新增;这一轮没有把旧 installer 或其他非 current plugin 面接回运行时 +- 这一步服务路线图主目标的关系是:把 `CCD-008` 的 plugin hook loader 再往 upstream 当前真实布局推进一层,避免“cache 目录找对了,但 manifest 仍漏读 legacy layout”这种半对齐状态。 +- 已执行定向校验: + - `rustfmt --edition 2021 "src-tauri/src/commands/aster_agent_cmd/runtime_project_hooks.rs"` 通过 + - `cargo test --manifest-path "src-tauri/Cargo.toml" -p lime load_runtime_project_hook_registry_should_include_legacy_manifest_plugin_user_prompt_hooks -- --nocapture` 通过(`1 passed`) + - `cargo check --manifest-path "src-tauri/Cargo.toml" -p lime --tests` 通过 + +### 继续推进(CCD-008 / plugin manifest.skills current) + +- 继续对照本地参考运行时 [`pluginLoader.ts`](../../../../js/claudecode/src/utils/plugins/pluginLoader.ts) 与 [`loadPluginCommands.ts`](../../../../js/claudecode/src/utils/plugins/loadPluginCommands.ts) 后,又确认 plugin skill loader 还有一条当前真实发现语义: + - upstream 不只是扫默认 `skills/` + - `plugin.json` 的 `skills` 字段会显式指定 plugin skill 目录 + - 并且按当前执行代码,`manifest.skills` 一旦存在,就不再自动注册默认 `skills/` 目录;只有 `manifest.skills` 缺席时才做默认 autodetect + - 每个 skill path 既可以是“目录本身就是一个 skill(含 `SKILL.md`)”,也可以是“目录下再挂多个 skill 子目录” +- Lime 之前的 [`loader.rs`](../../src-tauri/crates/aster-rust/crates/aster/src/skills/loader.rs) 只会扫 `plugin_root/skills/*/SKILL.md`,因此: + - manifest 指定的额外 skill 目录不会进入 current + - 根目录 `plugin.json` 这条 legacy manifest fallback 也不会影响 plugin skill 发现 + - `manifest.skills` 存在时默认 `skills/` 是否应继续自动加载,也与 upstream 当前执行语义不一致 +- 因此这一轮继续把 plugin skill current 主链收口到 upstream 当前 loader 语义: + - [`claude_plugin_cache.rs`](../../src-tauri/crates/aster-rust/crates/aster/src/claude_plugin_cache.rs) 新增共享 `resolve_cached_plugin_manifest_path(...)` + - [`runtime_project_hooks.rs`](../../src-tauri/src/commands/aster_agent_cmd/runtime_project_hooks.rs) 与 [`loader.rs`](../../src-tauri/crates/aster-rust/crates/aster/src/skills/loader.rs) 都统一复用这条 manifest path 事实源,不再各自维护 `.claude-plugin/plugin.json` / 根目录 `plugin.json` fallback + - plugin skills 现在改为: + - `manifest.skills` 缺席:只自动扫描默认 `skills/` + - `manifest.skills` 存在:只加载 manifest 显式声明的 skill 目录 + - 单个 skill 目录和“skill 容器目录”两种形态都支持 + - 同一路径命中的 `SKILL.md` 会做去重,避免标准目录与显式目录重叠时重复装载 +- 这一步后的 `CCD-008` 边界继续收紧为: + - `current`:plugin skill loader 现在同时对齐了 upstream 的 cache path、manifest path、以及 `manifest.skills` 发现优先级 + - `current gap`:push-style plugin settings listener、managed policy merge、builtin plugin registry / hooks 仍无 honest host;这些差距继续保留为 gap + - `compat / deprecated / dead`:无新增;本轮没有把旧 plugin installer 或其它非 current plugin 面接回技能主链 +- 这一步服务路线图主目标的关系是:把 `CCD-008` 的 plugin skill loader 从“只认默认目录”的半对齐推进到“manifest 显式技能路径也进入 current”,避免后续实现者继续按错误的自动发现规则补 plugin skills。 +- 已执行定向校验: + - `rustfmt --edition 2021 "src-tauri/crates/aster-rust/crates/aster/src/claude_plugin_cache.rs" "src-tauri/crates/aster-rust/crates/aster/src/skills/loader.rs" "src-tauri/src/commands/aster_agent_cmd/runtime_project_hooks.rs"` 通过 + - `cargo test --manifest-path "src-tauri/crates/aster-rust/crates/aster/Cargo.toml" test_load_skills_from_plugin_cache_prefers_manifest_skills_paths_and_legacy_manifest --lib -- --nocapture` 通过(`1 passed`) + - `cargo test --manifest-path "src-tauri/Cargo.toml" -p lime load_runtime_project_hook_registry_should_include_legacy_manifest_plugin_user_prompt_hooks -- --nocapture` 通过(`1 passed`) + - `cargo check --manifest-path "src-tauri/Cargo.toml" -p lime --tests` 通过 diff --git a/docs/roadmap/lime-service-skill-cloud-config-prd.md b/docs/roadmap/lime-service-skill-cloud-config-prd.md index cffb6ba6a..dd3272c4d 100644 --- a/docs/roadmap/lime-service-skill-cloud-config-prd.md +++ b/docs/roadmap/lime-service-skill-cloud-config-prd.md @@ -1,126 +1,107 @@ -# Lime 服务型技能:端优先执行、云配置同步 PRD +# Lime 服务型技能:目录云同步、本地执行 PRD > 状态:current supporting cross-repo plan -> 更新时间:2026-04-19 +> 更新时间:2026-04-21 > 当前主规划:`docs/roadmap/limenextv2/README.md` -> 目标:吸收 Ribbi 式服务型技能入口的产品方法,但保持 Lime 的端优先执行与低云成本架构 -> 作用域:本文只保留服务型技能在 `lime` / `limecore` 之间的产品对象、目录分层与执行边界,不替代 `docs/aiprompts/command-runtime.md`、`docs/aiprompts/limecore-collaboration-entry.md` 与 `limecore/docs/api/lime-client-integration.md` +> 目标:吸收 Ribbi 式服务型技能入口的方法,但保持 `limecore` 只做目录控制、`lime` 客户端负责全部执行 +> 作用域:本文只保留 `ServiceSkill` 在 `lime` / `limecore` 之间的产品对象、目录边界与本地执行原则,不替代 `docs/aiprompts/command-runtime.md`、`docs/aiprompts/limecore-collaboration-entry.md` -## 1. 文档依据 +## 1. 为什么要重写这份文档 -本文不是从抽象产品概念反推,而是基于当前 `lime` 与 `limecore` 两个仓库的现役事实源编写。 +这份文档原本承接过一条“端优先执行、云特例执行”的设计叙事,里面出现过: -`lime` 侧主要事实源: +- `cloud_required` +- `executionLocation` +- `scene-orchestrator-svc` 作为特例执行器 +- 目录命中后服务端 run / poll -- `src/lib/api/serviceSkills.ts` -- `src/lib/serviceSkillCatalogBootstrap.ts` -- `src/hooks/useOemCloudAccess.ts` -- `src/components/agent/chat/workspace/serviceSkillSceneLaunch.ts` -- `src/components/agent/chat/workspace/useWorkspaceSendActions.ts` -- `docs/aiprompts/command-runtime.md` +现在这条叙事已经不再成立。 -`limecore` 侧主要事实源: +用户已明确新的产品边界: -- `docs/api/lime-client-integration.md` -- `docs/aiprompts/lime-limecore-collaboration.md` -- `services/control-plane-svc/internal/model/client_bootstrap.go` -- `services/control-plane-svc/internal/controller/public_client.go` -- `services/scene-orchestrator-svc/README.md` -- `services/control-plane-svc/internal/model/commercial.go` +1. 云端不会执行任何东西 +2. `limecore` 只负责目录控制、发布治理与配置同步 +3. `lime` 客户端负责全部 `@` 命令、`/scene`、`service skill` 执行 -关键环境事实: +因此,本文件重写后的目标只有一个: -1. `lime` 已有任务入口、技能执行链、自动化调度链、执行追踪链。 -2. `limecore` 已有 `control-plane-svc / gateway-svc / scene-orchestrator-svc`,并已提供 `client/bootstrap`、`client/skills`、`client/service-skills`。 -3. `client/bootstrap` 当前已同时返回 `skillCatalog` 与 `serviceSkillCatalog`: - - `skillCatalog.entries` 是统一命令发现协议 - - `serviceSkillCatalog` 是完整服务型技能目录 - - `serviceCatalog` 仍保持商业服务目录语义,不应混用 -4. `lime` 当前已消费在线目录并保留 seeded / fallback 韧性兜底,不再只依赖客户端静态常量。 -5. `scene-orchestrator-svc` 已定位为 Scene 运行时服务,但不应在本方案中演进为普通任务的默认主执行流。 +**把 `ServiceSkill` 收口成“目录云同步、本地执行”的产品对象。** -## 1.1 当前吸收结果(2026-04-19) +## 2. 当前事实 -这份 PRD 的核心判断已经被 current 主链部分吸收: +### 2.1 `lime` 侧 -1. `limecore` 负责在线目录事实源: - - `bootstrap.skillCatalog` - - `bootstrap.serviceSkillCatalog` - - `GET /api/v1/public/tenants/:tenantId/client/skills` - - `GET /api/v1/public/tenants/:tenantId/client/service-skills` -2. `lime` 负责本地 catalog 同步、seeded fallback、补参启动与执行路由。 -3. `skillCatalog.entries` 负责统一 `@ / / skill` 发现协议;`serviceSkillCatalog` 继续保留完整服务型技能目录,不与 `serviceCatalog` 混用。 +客户端已经具备: -因此,本文今天更适合作为跨仓产品边界说明,而不是单独的待落地提案。 +- 工作区、项目、消息与执行追踪主链 +- Agent 编排、browser assist、automation、本地 task 主链 +- `skillCatalog` 与 `serviceSkillCatalog` 的 seeded / fallback 兜底 -## 2. 背景与问题 +### 2.2 `limecore` 侧 -Ribbi 这类产品最值得借鉴的,不是“49 个技能入口”或“看起来像多 Agent”,而是它把底层复杂能力包装成了用户能直接理解的服务项: +服务端当前应继续承担: -- 复制短视频 -- 每日趋势摘要 -- 跟踪账号表现 +- `client/bootstrap` +- `client/skills` +- `client/service-skills` +- OEM / 租户目录发布、排序、可见性、默认参数、版本、灰度 -这种产品方式对 Lime 有吸引力,但如果简单把所有入口、参数、执行器和 OEM 配置都堆进客户端,会出现三个问题: +固定约束: -1. 产品入口层、执行层、配置层混在一起,客户端越来越杂乱。 -2. OEM/租户差异化无法优雅下发,只能写死在本地版本里。 -3. 如果为了统一而把执行迁到云端,又会把云成本做重,违背 Lime 想利用客户端算力的目标。 - -因此,本方案要解决的核心矛盾是: - -**既要获得服务型技能的产品入口优势,又不能把主执行流云化。** +**`limecore` 不执行 `@` 命令,不执行 `/scene`,不执行 `service skill`。** ## 3. 核心判断 本方案采用如下主张: -**端优先执行,云端优先配置。** +**目录云同步,本地执行。** 具体含义: -1. `lime` 客户端负责即时执行、本地工作区、本地调度、本地素材与项目上下文、本地执行追踪。 -2. `limecore` 负责 OEM/租户级目录发布、开关、排序、默认参数、版本、连接器引用,以及少量必须云托管的特例场景。 -3. 默认所有服务型技能都先尝试在客户端执行,只有显式标记为 `cloud_required` 的场景才允许走云执行。 -4. 服务型技能目录采用 **云主本辅**: - - 云目录是产品默认事实源 - - 本地技能与项目级技能继续存在,但不污染 OEM 正式目录 +1. `limecore` 是在线目录事实源,而不是运行时执行面 +2. `lime` 客户端负责: + - 参数采集 + - 本地运行时路由 + - Agent / tool / browser / task / viewer +3. 服务型技能目录采用“云主本辅”: + - 云目录提供产品默认入口 + - 客户端 seeded / fallback 保证离线与降级可用 +4. 目录命中不等于服务端执行 一句话总结: -**Lime 继续是工作站,LimeCore 负责目录、策略与发布,而不是默认运行时。** +**LimeCore 决定前台能看到什么,Lime 决定真正如何执行。** ## 4. 目标与非目标 -## 4.1 总目标 +### 4.1 总目标 -为 Lime 增加一层 `ServiceSkill` 产品对象,让用户以“我要完成什么任务”作为入口,而不是先理解模型、Provider 和能力开关。 +让用户以“我要完成什么任务”作为入口,而不是先理解底层模型、Provider 或工具名。 目标链路: -`服务型技能入口 -> 参数采集 -> 执行路由 -> 本地执行 / 云特例执行 -> 结果与追踪` +`服务型技能入口 -> 参数采集 -> 本地执行路由 -> 结果与追踪` -## 4.2 子目标 +### 4.2 子目标 -1. 首页优先展示云下发的服务型技能目录。 -2. 参数采集走结构化 `slotSchema`,不再单靠 prompt 模板。 -3. 默认执行继续落在客户端 `skill / agent / browser / automation`。 -4. 离线时仍可使用缓存目录与本地执行能力。 -5. 为 OEM/租户提供目录与策略配置能力,但不引入重云执行成本。 +1. 首页优先展示在线下发的服务型技能目录 +2. 参数采集走结构化 `slotSchema` +3. 默认执行继续落在客户端 `skill / agent / browser / automation` +4. 离线时仍能依赖缓存目录和本地执行能力 +5. 为 OEM / 租户提供目录与策略配置能力,但不把服务端变成执行器 -## 4.3 非目标 +### 4.3 非目标 -1. 不重写 `AgentChatWorkspace`。 -2. 不把 `scene-orchestrator-svc` 变成普通任务的默认执行器。 -3. 不在本阶段做云端大规模内容生成或视频处理。 -4. 不在本阶段做本地技能反向发布云端。 -5. 不在本阶段做完整运营后台编辑器。 +1. 不把 `scene-orchestrator-svc` 写成当前主链执行器 +2. 不继续使用 `executionLocation = cloud_required` +3. 不在本阶段做服务端代跑内容生成 +4. 不把目录控制面和执行面重新混在一起 ## 5. 术语与对象模型 -## 5.1 ServiceSkill +### 5.1 ServiceSkill -`ServiceSkill` 是面向用户的产品入口对象,不等同于现有 Agent Skills 标准包。 +`ServiceSkill` 是面向用户的产品入口对象,不等同于云端 scene,也不等同于某个单一 tool。 建议最小字段如下: @@ -132,43 +113,32 @@ Ribbi 这类产品最值得借鉴的,不是“49 个技能入口”或“看 | `category` | 分类,如社媒、视频、图文、运营 | | `slotSchema` | 参数槽位定义 | | `runnerType` | `instant / scheduled / managed` | -| `defaultExecutorBinding` | `native_skill / agent_turn / browser_assist / automation_job / cloud_scene` | -| `executionLocation` | `client_default / cloud_required` | +| `defaultExecutorBinding` | `native_skill / agent_turn / browser_assist / automation_job / local_service_skill` | | `readinessRequirements` | 模型、浏览器、技能、项目、账号等依赖 | | `version` | 目录版本或服务项版本 | | `source` | `cloud_catalog / local_custom` | -## 5.2 ServiceSkillCatalog +固定约束: -当前客户端目录已经分成两层: +- `ServiceSkill` 允许由云端目录下发 +- `ServiceSkill` 的执行必须落在客户端本地 runtime + +### 5.2 ServiceSkillCatalog + +当前客户端目录分成两层: 1. `skillCatalog` - 聚合后的技能中心目录 - - `entries` 负责统一命令发现协议 + - `entries` 负责统一 `@ / / / skill` 发现协议 2. `serviceSkillCatalog` - 完整服务型技能目录 - 适合技能发布、诊断、细粒度刷新与运行时绑定解析 -`serviceSkillCatalog` 仍然不应复用 `serviceCatalog`。 +`serviceSkillCatalog` 不应复用 `serviceCatalog`。 -原因: +### 5.3 SlotSchema -- `serviceCatalog` 在 `limecore` 中仍偏商业服务目录与计费目录 -- `skillCatalog` 与 `serviceSkillCatalog` 分别承担“统一发现”和“完整目录”两种不同职责 -- 强行混用会让商业服务、通用技能与服务型技能重新打成一团 - -建议结构: - -| 字段 | 说明 | -|------|------| -| `version` | 目录版本 | -| `tenantId` | 所属租户 | -| `syncedAt` | 同步时间 | -| `items` | `ServiceSkill[]` | - -## 5.3 SlotSchema - -服务型技能的参数采集协议,首期支持以下类型: +服务型技能的参数采集协议,首期支持: - `text` - `url` @@ -179,39 +149,21 @@ Ribbi 这类产品最值得借鉴的,不是“49 个技能入口”或“看 - `schedule_time` - `account_list` -每个槽位至少包含: +### 5.4 RunnerType -- `key` -- `label` -- `type` -- `required` -- `placeholder` -- `defaultValue` -- `helpText` +`runnerType` 只表达任务形态: -## 5.4 RunnerType 与 ExecutionLocation +- `instant` +- `scheduled` +- `managed` -`runnerType` 表示任务形态: +固定约束: -- `instant`:一次性交付 -- `scheduled`:定时运行 -- `managed`:持续跟踪或持续托管 - -`executionLocation` 表示默认执行位置: - -- `client_default`:默认客户端执行 -- `cloud_required`:必须云执行 - -约束: - -- 不是所有 `managed` 都必须上云 -- 不是所有 `scheduled` 都必须上云 -- 只有显式声明 `cloud_required` 的场景才允许默认路由到 `scene-orchestrator-svc` +- `runnerType` 不再隐含云执行 +- `managed` 与 `scheduled` 也不自动等于服务端运行 ## 6. 系统架构 -## 6.1 总体架构图 - ```mermaid flowchart TB subgraph UX["用户交互层(Lime 客户端)"] @@ -220,32 +172,31 @@ flowchart TB Workspace["Workspace / 任务中心"] end - subgraph Client["客户端运行层(lime)"] + subgraph Client["客户端执行层(lime)"] Cache["serviceSkillCatalog 缓存"] - Router["执行路由器"] + Router["本地执行路由器"] SkillRuntime["Skills / Agent Runtime"] Browser["Browser Assist"] Automation["Automation"] Tracker["ExecutionTracker"] end - subgraph CloudControl["云端控制层(limecore)"] - Bootstrap["control-plane-svc\nclient bootstrap"] - SkillCatalog["serviceSkillCatalog\nOEM/租户目录"] - Policy["开关 / 排序 / 默认参数 / 版本"] - end - - subgraph CloudRuntime["云端特例运行层(仅 cloud_required)"] - Scene["scene-orchestrator-svc"] - Gateway["gateway-svc"] - Connector["Hosted Connector / 外部连接器"] + subgraph Control["目录控制层(limecore)"] + Bootstrap["client/bootstrap"] + SkillCatalog["client/skills"] + ServiceSkills["client/service-skills"] + Policy["可见性 / 排序 / 默认参数 / 版本"] end Home --> Slots --> Router Home --> Cache Workspace --> Tracker Bootstrap --> Cache - Policy --> SkillCatalog --> Bootstrap + SkillCatalog --> Cache + ServiceSkills --> Cache + Policy --> Bootstrap + Policy --> SkillCatalog + Policy --> ServiceSkills Router --> SkillRuntime Router --> Browser @@ -255,325 +206,48 @@ flowchart TB Browser --> Tracker Automation --> Tracker Tracker --> Workspace - - Router -. 仅 cloud_required .-> Scene - Scene --> Gateway - Scene --> Connector - Scene -. 返回结果 .-> Workspace ``` -## 6.2 职责划分 +## 7. 边界说明 -### `lime` 客户端负责 +### 7.1 `limecore` 负责 -- 首页入口展示 -- 参数采集 -- 本地即时执行 -- 本地定时与持续任务 -- 本地工作区与结果展示 -- 本地执行追踪 -- 云目录缓存与离线回退 -- 本地自定义技能发现 +- 目录模型 +- 发布状态 +- OEM / 租户可见性 +- 默认参数、别名、排序、灰度 +- bootstrap 聚合输出 -### `limecore control-plane-svc` 负责 +### 7.2 `lime` 负责 -- `bootstrap.skillCatalog` 与 `bootstrap.serviceSkillCatalog` 聚合 -- `client/skills` 与 `client/service-skills` 下发 -- OEM/租户级目录配置 -- 开关、排序、默认参数、版本 -- 客户端 bootstrap 聚合与目录事实源发布 +- `@` 命令与 `/scene` 命中 +- 参数采集与补参 +- Agent 首刀分析 +- 本地 skill / tool / browser / automation / task 执行 +- timeline / artifact / viewer / recovery -### `limecore scene-orchestrator-svc` 负责 +### 7.3 不再允许的叙事 -- 少量 `cloud_required` 场景运行 -- 调用 `native / gateway / hosted connector` -- 输出统一云端运行结果 +- `cloud_required` +- 目录命中后服务端创建 run +- `scene-orchestrator-svc` 作为服务型技能执行器 +- 把 `lime_run_service_skill` 解释成云端调用入口 -### `limecore gateway-svc` 负责 +## 8. 对客户端实现的直接要求 -- 网关型模型访问 -- OpenAI 兼容补全接口 -- 不承担服务型技能目录职责 +1. `service_scene_launch` 只作为本地运行时路由提示 +2. `serviceSkills` / seeded fallback 命中后,必须继续走本地执行链 +3. 历史命名如 `cloud-video-dubbing` 若仍存在,也只能按本地 skill 标识理解 +4. 输入区、场景面板、首页技能入口统一消费在线目录 + seeded fallback -## 7. 核心流程 +## 9. 与服务端计划的关系 -## 7.1 目录同步时序图 +服务端文档纠偏由 `limecore/docs/exec-plans/at-command-control-plane-only.md` 统一承接。 -```mermaid -sequenceDiagram - participant User as 用户 - participant Lime as Lime 客户端 - participant Cache as 本地目录缓存 - participant CP as control-plane-svc +本文件只描述客户端如何消费目录与执行,不替服务端继续定义执行协议。 - User->>Lime: 启动客户端 - Lime->>CP: GET /client/bootstrap - alt 拉取成功 - CP-->>Lime: bootstrap(skillCatalog + serviceSkillCatalog) - Lime->>Cache: 写入目录缓存(version, syncedAt, items) - Lime-->>User: 首页展示云目录服务项 - else 拉取失败 - Lime->>Cache: 读取最近一次成功目录 - alt 缓存存在 - Cache-->>Lime: 返回缓存目录 - Lime-->>User: 首页展示缓存目录服务项 - else 无缓存 - Lime-->>User: 回退本地技能入口 - end - end -``` +## 10. 当前状态 -## 7.2 本地即时执行时序图 - -```mermaid -sequenceDiagram - participant User as 用户 - participant Lime as Lime 客户端 - participant Router as 执行路由器 - participant Runtime as Skills/Agent/Browser - participant Tracker as ExecutionTracker - - User->>Lime: 选择 ServiceSkill - Lime-->>User: 展示 slotSchema 参数采集 - User->>Lime: 提交参数 - Lime->>Router: 根据 runnerType + defaultExecutorBinding + executionLocation 决策 - Router->>Runtime: 启动本地执行 - Runtime->>Tracker: 记录 run metadata(service_skill_id 等) - Runtime-->>Lime: 返回结果 / 产物 / 状态 - Tracker-->>Lime: 返回执行摘要 - Lime-->>User: 在 Workspace 展示结果与轨迹 -``` - -## 7.3 云端特例执行时序图 - -```mermaid -sequenceDiagram - participant User as 用户 - participant Lime as Lime 客户端 - participant Router as 执行路由器 - participant Scene as scene-orchestrator-svc - participant Gateway as gateway-svc / hosted connector - - User->>Lime: 选择 cloud_required ServiceSkill - Lime-->>User: 展示 slotSchema 参数采集 - User->>Lime: 提交参数 - Lime->>Router: 识别 executionLocation = cloud_required - Router->>Scene: 创建 Scene Run - Scene->>Gateway: 调用 gateway / connector - Gateway-->>Scene: 返回执行结果 - Scene-->>Lime: 返回 Scene Run 结果 - Lime-->>User: 展示云端结果与状态 -``` - -## 7.4 启动与目录选择流程图 - -```mermaid -flowchart TD - A["客户端启动"] --> B{"bootstrap 拉取成功?"} - B -- 是 --> C["读取 serviceSkillCatalog"] - C --> D["写入本地缓存"] - D --> E["首页优先展示云目录服务项"] - B -- 否 --> F{"本地有缓存目录?"} - F -- 是 --> G["读取缓存目录"] - G --> E - F -- 否 --> H["回退本地技能入口"] -``` - -## 7.5 执行决策流程图 - -```mermaid -flowchart TD - A["用户选择 ServiceSkill"] --> B["按 slotSchema 采集参数"] - B --> C{"必填参数齐全?"} - C -- 否 --> D["继续补参"] - D --> B - C -- 是 --> E{"readiness 满足?"} - E -- 否 --> F["提示缺失模型/浏览器/技能/账号"] - E -- 是 --> G{"executionLocation"} - G -- client_default --> H{"runnerType"} - H -- instant --> I["路由到 native_skill / agent_turn / browser_assist"] - H -- scheduled --> J["创建本地 automation job"] - H -- managed --> K["创建本地持续任务 / automation job"] - G -- cloud_required --> L["创建 Scene Run"] - I --> M["进入 ExecutionTracker 与 Workspace"] - J --> N["进入任务中心与本地调度"] - K --> N - L --> O["展示云端运行结果"] -``` - -## 7.6 任务生命周期流程图 - -```mermaid -flowchart LR - A["创建 scheduled / managed 任务"] --> B["待运行"] - B --> C["运行中"] - C --> D{"执行结果"} - D -- 成功 --> E["写入历史结果"] - E --> F["等待下次运行"] - F --> C - D -- 失败 --> G["记录失败原因"] - G --> H{"是否重试 / 下次调度?"} - H -- 是 --> F - H -- 否 --> I["暂停 / 停止"] -``` - -## 8. 产品与接口设计 - -## 8.1 目录事实源 - -采用 **云主本辅**: - -- 云目录:默认首页产品事实源 -- 本地技能:补充来源 -- 项目级技能:本地能力延伸,不写回 OEM 目录 - -展示策略: - -1. 首页优先展示 `source = cloud_catalog` 的服务型技能。 -2. 本地技能放在“本地技能 / 自定义技能”分组。 -3. 不允许用本地同名覆盖云目录入口。 - -## 8.2 客户端 bootstrap 扩展 - -建议在现有 `bootstrap` 中新增: - -```json -{ - "serviceSkillCatalog": { - "version": "2026-03-24-v1", - "tenantId": "tenant-0001", - "syncedAt": "2026-03-24T00:00:00Z", - "items": [] - } -} -``` - -不建议复用现有: - -- `serviceCatalog` -- `sceneCatalog` - -原因: - -- `serviceCatalog` 偏商业计费目录 -- `sceneCatalog` 偏云运行场景目录 -- `ServiceSkill` 是前台产品入口目录,语义独立 - -## 8.3 本地缓存模型 - -客户端缓存最小结构: - -| 字段 | 说明 | -|------|------| -| `tenantId` | 当前租户 | -| `version` | 云目录版本 | -| `syncedAt` | 最近同步时间 | -| `items` | 缓存的 `ServiceSkill[]` | - -缓存规则: - -1. 启动时优先拉取在线目录。 -2. 拉取失败时回退缓存。 -3. 缓存只作为只读目录快照,不承载用户编辑。 - -## 9. 默认执行策略 - -## 9.1 规则 - -默认执行位置如下: - -| runnerType | 默认执行位置 | 默认绑定 | -|------|------|------| -| `instant` | 客户端 | `native_skill / agent_turn / browser_assist` | -| `scheduled` | 客户端 | `automation_job` | -| `managed` | 客户端 | `automation_job` 或本地持续任务模型 | - -只有下列情况才允许 `cloud_required`: - -1. 必须使用受保护的云连接器密钥 -2. 必须跨设备持续在线 -3. 必须依赖租户侧受控外部系统 - -## 9.2 成本原则 - -本方案明确规定: - -1. 云端默认不承担 CPU 密集型内容生产任务。 -2. 云端默认不承担大规模定时创作。 -3. 云端默认不承担普通技能主执行流。 -4. 所有能在客户端执行的任务,默认都应留在客户端。 - -## 10. MVP 范围 - -首期只做 6 个服务型技能入口,覆盖三类 runner。 - -| 服务型技能 | runnerType | executionLocation | 默认执行绑定 | -|------|------|------|------| -| 复制轮播帖 | `instant` | `client_default` | `native_skill` | -| 复制短视频脚本 | `instant` | `client_default` | `agent_turn` | -| 文章转 Slide 视频提纲 | `instant` | `client_default` | `native_skill` | -| 视频配音成其他语言 | `instant` | `client_default` | `agent_turn` | -| 每日趋势摘要 | `scheduled` | `client_default` | `automation_job` | -| 跟踪账号表现 | `managed` | `client_default` | `automation_job` | - -首期不把这些默认迁到云端。 - -## 11. 实施阶段 - -### Phase 1:目录与即时执行 - -- `limecore` 下发 `skillCatalog.entries` 与 `serviceSkillCatalog` -- `lime` 接收并缓存云目录 -- 首页展示服务型技能卡片 -- 跑通 `instant + client_default` - -### Phase 2:本地任务化 - -- 跑通 `scheduled + managed` -- 增加任务中心中的参数摘要、最近运行、失败原因 -- 增加离线目录与本地技能并存展示 - -### Phase 3:云端特例分支 - -- 为少量 `cloud_required` 场景接入 `scene-orchestrator-svc` -- 增加客户端提示与状态展示 -- 完善 OEM 目录版本与灰度策略 - -## 12. 验收标准 - -满足以下条件视为本 PRD 成立: - -1. 客户端可从 `bootstrap.skillCatalog + bootstrap.serviceSkillCatalog` 读取统一发现目录与完整服务型技能目录。 -2. 云目录可本地缓存,离线时可回退展示。 -3. 首页可直接启动服务型技能,而不是先选模型和能力开关。 -4. 参数采集由 `slotSchema` 驱动,而不是纯 prompt 注入。 -5. 至少一个 `instant`、一个 `scheduled`、一个 `managed` 场景可闭环。 -6. 普通服务型技能默认留在客户端执行。 -7. 只有显式声明 `cloud_required` 的场景才允许走云执行。 -8. 执行结果仍统一进入现有 `ExecutionTracker` 体系。 - -## 13. 风险与约束 - -1. 客户端职责膨胀风险 - - 如果目录、参数采集、执行、任务中心同时无边界扩张,客户端仍会变重 -2. 术语冲突风险 - - `serviceCatalog / skillCatalog / serviceSkillCatalog / sceneCatalog` 若不统一,会让实现和产品都混乱 -3. 本地调度可靠性风险 - - `scheduled / managed` 留在本地会受到应用在线状态影响 -4. 云执行漂移风险 - - 如果实现时偷懒把普通服务项转去 `scene-orchestrator-svc`,成本结构会失控 - -## 14. 结论 - -Ribbi 式产品方式值得借鉴,但 Lime 不应复制其重 SaaS 执行模式。 - -正确路线是: - -**把服务型技能的目录、配置、策略和版本放到 LimeCore;把主要执行留在 Lime 客户端。** - -这能同时满足四个目标: - -1. 前台入口更清晰 -2. OEM 配置可下发 -3. 云成本可控 -4. 客户端算力得到充分利用 +- 2026-04-21:本文重写为“目录云同步、本地执行”版 PRD +- 2026-04-21:旧的 `cloud_required / 云特例执行` 叙事应视为 `deprecated` +- 2026-04-21:后续实现只允许继续向“目录控制面 + 本地执行面”收敛 diff --git a/index.html b/index.html index 9986a2171..b7ddfd394 100644 --- a/index.html +++ b/index.html @@ -5,6 +5,25 @@ Lime +
diff --git a/package-lock.json b/package-lock.json index bcde1933f..3aa62d87d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lime", - "version": "1.15.0", + "version": "1.16.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lime", - "version": "1.15.0", + "version": "1.16.0", "dependencies": { "@babel/standalone": "^7.29.0", "@fabianlars/tauri-plugin-oauth": "^2", diff --git a/package.json b/package.json index 2580b994d..dbfa1df2c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "lime", "private": true, - "version": "1.15.0", + "version": "1.16.0", "type": "module", "engines": { "node": ">=22.0.0" diff --git a/packages/lime-cli-npm/README.md b/packages/lime-cli-npm/README.md index 65e821e49..a2d3ee100 100644 --- a/packages/lime-cli-npm/README.md +++ b/packages/lime-cli-npm/README.md @@ -112,7 +112,7 @@ npm run build:release -- \ ```bash npm run build:release -- \ --target-triple "aarch64-apple-darwin" \ - --version "1.15.0" \ + --version "1.16.0" \ --out-dir "./dist" ``` diff --git a/packages/lime-cli-npm/package.json b/packages/lime-cli-npm/package.json index 4d6ad8450..d486836b5 100644 --- a/packages/lime-cli-npm/package.json +++ b/packages/lime-cli-npm/package.json @@ -1,6 +1,6 @@ { "name": "@limecloud/lime-cli", - "version": "1.15.0", + "version": "1.16.0", "description": "Lime 官方任务 CLI", "bin": { "lime": "scripts/run.js" diff --git a/scripts/lib/vite-dev-server-bootstrap.mjs b/scripts/lib/vite-dev-server-bootstrap.mjs new file mode 100644 index 000000000..e893e85c1 --- /dev/null +++ b/scripts/lib/vite-dev-server-bootstrap.mjs @@ -0,0 +1,247 @@ +import { spawn } from "node:child_process"; +import process from "node:process"; + +const DEV_URL_TIMEOUT_MS = 1_500; +const ENTRY_MODULE_TIMEOUT_MS = 6_000; +const OPTIMIZED_DEPS_TIMEOUT_MS = 6_000; +const REUSE_ONLY_TIMEOUT_MS = 10_000; +const REUSE_ONLY_INTERVAL_MS = 500; +const ROOT_MARKERS = ["Lime", '
']; +const OPTIMIZED_DEP_FILES = [ + "react.js", + "react-dom_client.js", + "react_jsx-dev-runtime.js", +]; + +function isLimeDevShell(html) { + return ROOT_MARKERS.some((marker) => html.includes(marker)); +} + +function isEntryModuleReady(code) { + return ( + code.includes("ReactDOM.createRoot") || + code.includes("registerLightweightRenderers") + ); +} + +function isOptimizedDepReady(code) { + return code.includes("export") || code.includes("import "); +} + +async function fetchText(url, timeoutMs) { + const response = await fetch(url, { + method: "GET", + signal: AbortSignal.timeout(timeoutMs), + }); + const text = await response.text(); + + return { + response, + text, + }; +} + +function createEnv({ browserBridge }) { + const env = { ...process.env }; + + if (browserBridge) { + delete env.TAURI_ENV_PLATFORM; + env.LIME_BROWSER_BRIDGE = "1"; + } + + return env; +} + +function resolveOptimizedDepUrls(url, { browserBridge }) { + const depsDir = browserBridge ? ".vite-web" : ".vite-tauri"; + return OPTIMIZED_DEP_FILES.map((file) => + new URL(`/node_modules/${depsDir}/deps/${file}`, url).toString(), + ); +} + +async function probeExistingDevServer(url, options) { + if (typeof fetch !== "function") { + return { reachable: false }; + } + + try { + const { response, text: html } = await fetchText(url, DEV_URL_TIMEOUT_MS); + const limeDevShellReady = response.ok && isLimeDevShell(html); + + let entryModuleReady = false; + let optimizedDepsReady = false; + + if (limeDevShellReady) { + try { + const entryModuleUrl = new URL(options.entryModulePath, url).toString(); + const { response: entryResponse, text: entryCode } = await fetchText( + entryModuleUrl, + ENTRY_MODULE_TIMEOUT_MS, + ); + entryModuleReady = + entryResponse.ok && isEntryModuleReady(entryCode); + } catch { + entryModuleReady = false; + } + + if (entryModuleReady) { + const optimizedDepResults = await Promise.all( + resolveOptimizedDepUrls(url, options).map(async (depUrl) => { + try { + const { response: depResponse, text: depCode } = await fetchText( + depUrl, + OPTIMIZED_DEPS_TIMEOUT_MS, + ); + return depResponse.ok && isOptimizedDepReady(depCode); + } catch { + return false; + } + }), + ); + optimizedDepsReady = optimizedDepResults.every(Boolean); + } + } + + return { + reachable: true, + status: response.status, + statusText: response.statusText, + isLimeDevShell: limeDevShellReady, + isEntryModuleReady: entryModuleReady, + areOptimizedDepsReady: optimizedDepsReady, + }; + } catch { + return { reachable: false }; + } +} + +async function waitForExistingDevServer(url, options) { + const startedAt = Date.now(); + let lastProbe = { reachable: false }; + + while (Date.now() - startedAt < REUSE_ONLY_TIMEOUT_MS) { + lastProbe = await probeExistingDevServer(url, options); + + if ( + lastProbe.reachable && + lastProbe.isLimeDevShell && + lastProbe.isEntryModuleReady && + lastProbe.areOptimizedDepsReady + ) { + return lastProbe; + } + + if (lastProbe.reachable && !lastProbe.isLimeDevShell) { + const statusLabel = `${lastProbe.status} ${lastProbe.statusText}`.trim(); + throw new Error( + `[${options.logLabel}] ${options.devUrl} 已被其他服务占用,且返回内容不是 Lime dev shell(${statusLabel})。请先关闭占用进程后重试。`, + ); + } + + await new Promise((resolve) => setTimeout(resolve, REUSE_ONLY_INTERVAL_MS)); + } + + throw new Error( + `[${options.logLabel}] 要求复用已有 Lime dev server,但 ${url} 在 ${REUSE_ONLY_TIMEOUT_MS}ms 内未完成入口模块与优化依赖预热。`, + ); +} + +async function runCommand(command, args, env, label) { + await new Promise((resolve, reject) => { + const child = spawn(command, args, { + stdio: "inherit", + shell: true, + env, + }); + + child.on("exit", (code, signal) => { + if (signal) { + reject(new Error(`[${label}] 子进程被信号中断: ${signal}`)); + return; + } + + if ((code ?? 0) !== 0) { + reject(new Error(`[${label}] 子进程退出码异常: ${code ?? 0}`)); + return; + } + + resolve(); + }); + }); +} + +function startVite(env) { + const child = spawn("npx", ["vite"], { + stdio: "inherit", + shell: true, + env, + }); + + child.on("exit", (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 0); + }); +} + +async function waitForExitSignal() { + await new Promise((resolve) => { + const handleExit = () => resolve(); + process.once("SIGINT", handleExit); + process.once("SIGTERM", handleExit); + }); +} + +export async function runViteDevServerBootstrap({ + browserBridge = false, + devUrl = "http://127.0.0.1:1420/", + entryModulePath = "/src/main.tsx", + reuseExistingOnly = false, + logLabel = "vite:dev", +} = {}) { + const env = createEnv({ browserBridge }); + const options = { + browserBridge, + devUrl, + entryModulePath, + logLabel, + }; + + if (reuseExistingOnly) { + await waitForExistingDevServer(devUrl, options); + console.log(`[${logLabel}] 复用已存在的 Lime dev server: ${devUrl}`); + await waitForExitSignal(); + return; + } + + const existingServer = await probeExistingDevServer(devUrl, options); + + if (existingServer.reachable) { + if (!existingServer.isLimeDevShell) { + const statusLabel = `${existingServer.status} ${existingServer.statusText}`.trim(); + throw new Error( + `[${logLabel}] ${devUrl} 已被其他服务占用,且返回内容不是 Lime dev shell(${statusLabel})。请先关闭占用进程后重试。`, + ); + } + + if ( + !existingServer.isEntryModuleReady || + !existingServer.areOptimizedDepsReady + ) { + await waitForExistingDevServer(devUrl, options); + } + + console.log( + `[${logLabel}] 复用已存在的 Lime dev server: ${devUrl}(入口模块与优化依赖均已就绪)`, + ); + await waitForExitSignal(); + return; + } + + console.log(`[${logLabel}] 先执行 vite optimize,避免 Tauri 抢跑到半就绪 dev server。`); + await runCommand("npx", ["vite", "optimize"], env, logLabel); + startVite(env); + await waitForExitSignal(); +} diff --git a/scripts/start-tauri-dev-server.mjs b/scripts/start-tauri-dev-server.mjs new file mode 100644 index 000000000..6754fc7fe --- /dev/null +++ b/scripts/start-tauri-dev-server.mjs @@ -0,0 +1,15 @@ +#!/usr/bin/env node + +import process from "node:process"; +import { runViteDevServerBootstrap } from "./lib/vite-dev-server-bootstrap.mjs"; + +runViteDevServerBootstrap({ + browserBridge: false, + devUrl: "http://127.0.0.1:1420/", + entryModulePath: "/src/main.tsx", + reuseExistingOnly: false, + logLabel: "dev:tauri-shell", +}).catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/scripts/start-web-bridge-dev.mjs b/scripts/start-web-bridge-dev.mjs index e1876c68b..947ada999 100755 --- a/scripts/start-web-bridge-dev.mjs +++ b/scripts/start-web-bridge-dev.mjs @@ -1,125 +1,22 @@ #!/usr/bin/env node -import { spawn } from 'node:child_process'; -import process from 'node:process'; +import process from "node:process"; +import { runViteDevServerBootstrap } from "./lib/vite-dev-server-bootstrap.mjs"; -const DEV_URL = process.env.LIME_WEB_BRIDGE_URL?.trim() || 'http://127.0.0.1:1420/'; -const DEV_URL_TIMEOUT_MS = 1_500; -const REUSE_ONLY_TIMEOUT_MS = 10_000; -const REUSE_ONLY_INTERVAL_MS = 500; -const ROOT_MARKERS = ['Lime', '
']; +const DEV_URL = + process.env.LIME_WEB_BRIDGE_URL?.trim() || "http://127.0.0.1:1420/"; +const ENTRY_MODULE_PATH = + process.env.LIME_WEB_BRIDGE_ENTRY_PATH?.trim() || "/src/main.tsx"; const REUSE_EXISTING_ONLY = - process.env.LIME_WEB_BRIDGE_REUSE_EXISTING_ONLY?.trim() === '1'; + process.env.LIME_WEB_BRIDGE_REUSE_EXISTING_ONLY?.trim() === "1"; -const env = { ...process.env }; -delete env.TAURI_ENV_PLATFORM; - -env.LIME_BROWSER_BRIDGE = '1'; - -function isLimeDevShell(html) { - return ROOT_MARKERS.some((marker) => html.includes(marker)); -} - -async function probeExistingDevServer(url) { - if (typeof fetch !== 'function') { - return { reachable: false }; - } - - try { - const response = await fetch(url, { - method: 'GET', - signal: AbortSignal.timeout(DEV_URL_TIMEOUT_MS), - }); - const html = await response.text(); - - return { - reachable: true, - status: response.status, - statusText: response.statusText, - isLimeDevShell: response.ok && isLimeDevShell(html), - }; - } catch { - return { reachable: false }; - } -} - -async function waitForExistingDevServer(url) { - const startedAt = Date.now(); - let lastProbe = { reachable: false }; - - while (Date.now() - startedAt < REUSE_ONLY_TIMEOUT_MS) { - lastProbe = await probeExistingDevServer(url); - - if (lastProbe.reachable && lastProbe.isLimeDevShell) { - return lastProbe; - } - - if (lastProbe.reachable && !lastProbe.isLimeDevShell) { - const statusLabel = `${lastProbe.status} ${lastProbe.statusText}`.trim(); - throw new Error( - `[dev:web-bridge] ${DEV_URL} 已被其他服务占用,且返回内容不是 Lime dev shell(${statusLabel})。请先关闭占用进程后重试。`, - ); - } - - await new Promise((resolve) => setTimeout(resolve, REUSE_ONLY_INTERVAL_MS)); - } - - throw new Error( - `[dev:web-bridge] 要求复用已有 Lime dev server,但 ${url} 在 ${REUSE_ONLY_TIMEOUT_MS}ms 内未就绪。`, - ); -} - -async function waitForExitSignal() { - await new Promise((resolve) => { - const handleExit = () => resolve(); - process.once('SIGINT', handleExit); - process.once('SIGTERM', handleExit); - }); -} - -function startVite() { - const child = spawn('npx', ['vite'], { - stdio: 'inherit', - shell: true, - env, - }); - - child.on('exit', (code, signal) => { - if (signal) { - process.kill(process.pid, signal); - return; - } - process.exit(code ?? 0); - }); -} - -async function main() { - if (REUSE_EXISTING_ONLY) { - await waitForExistingDevServer(DEV_URL); - console.log(`[dev:web-bridge] 复用已存在的 Lime dev server: ${DEV_URL}`); - await waitForExitSignal(); - return; - } - - const existingServer = await probeExistingDevServer(DEV_URL); - - if (existingServer.reachable) { - if (!existingServer.isLimeDevShell) { - const statusLabel = `${existingServer.status} ${existingServer.statusText}`.trim(); - throw new Error( - `[dev:web-bridge] ${DEV_URL} 已被其他服务占用,且返回内容不是 Lime dev shell(${statusLabel})。请先关闭占用进程后重试。`, - ); - } - - console.log(`[dev:web-bridge] 复用已存在的 Lime dev server: ${DEV_URL}`); - await waitForExitSignal(); - return; - } - - startVite(); -} - -main().catch((error) => { +runViteDevServerBootstrap({ + browserBridge: true, + devUrl: DEV_URL, + entryModulePath: ENTRY_MODULE_PATH, + reuseExistingOnly: REUSE_EXISTING_ONLY, + logLabel: "dev:web-bridge", +}).catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exit(1); }); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 9de1daae8..2f05c5758 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5101,7 +5101,7 @@ dependencies = [ [[package]] name = "lime" -version = "1.15.0" +version = "1.16.0" dependencies = [ "anyhow", "arboard", @@ -5205,7 +5205,7 @@ dependencies = [ [[package]] name = "lime-agent" -version = "1.15.0" +version = "1.16.0" dependencies = [ "anyhow", "aster-core", @@ -5234,7 +5234,7 @@ dependencies = [ [[package]] name = "lime-browser-runtime" -version = "1.15.0" +version = "1.16.0" dependencies = [ "chrono", "futures", @@ -5251,7 +5251,7 @@ dependencies = [ [[package]] name = "lime-cli" -version = "1.15.0" +version = "1.16.0" dependencies = [ "clap", "lime-core", @@ -5263,7 +5263,7 @@ dependencies = [ [[package]] name = "lime-config" -version = "1.15.0" +version = "1.16.0" dependencies = [ "async-trait", "lime-core", @@ -5279,7 +5279,7 @@ dependencies = [ [[package]] name = "lime-core" -version = "1.15.0" +version = "1.16.0" dependencies = [ "aster-models", "async-trait", @@ -5319,7 +5319,7 @@ dependencies = [ [[package]] name = "lime-credential" -version = "1.15.0" +version = "1.16.0" dependencies = [ "axum 0.7.9", "base64 0.22.1", @@ -5354,7 +5354,7 @@ dependencies = [ [[package]] name = "lime-gateway" -version = "1.15.0" +version = "1.16.0" dependencies = [ "aes", "axum 0.7.9", @@ -5384,7 +5384,7 @@ dependencies = [ [[package]] name = "lime-infra" -version = "1.15.0" +version = "1.16.0" dependencies = [ "chrono", "dashmap 5.5.3", @@ -5404,7 +5404,7 @@ dependencies = [ [[package]] name = "lime-mcp" -version = "1.15.0" +version = "1.16.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5420,7 +5420,7 @@ dependencies = [ [[package]] name = "lime-media-runtime" -version = "1.15.0" +version = "1.16.0" dependencies = [ "axum 0.7.9", "chrono", @@ -5451,7 +5451,7 @@ dependencies = [ [[package]] name = "lime-processor" -version = "1.15.0" +version = "1.16.0" dependencies = [ "async-trait", "lime-core", @@ -5470,7 +5470,7 @@ dependencies = [ [[package]] name = "lime-providers" -version = "1.15.0" +version = "1.16.0" dependencies = [ "anyhow", "async-stream", @@ -5525,7 +5525,7 @@ dependencies = [ [[package]] name = "lime-server" -version = "1.15.0" +version = "1.16.0" dependencies = [ "aster-core", "async-stream", @@ -5570,7 +5570,7 @@ dependencies = [ [[package]] name = "lime-server-utils" -version = "1.15.0" +version = "1.16.0" dependencies = [ "axum 0.7.9", "futures", @@ -5585,7 +5585,7 @@ dependencies = [ [[package]] name = "lime-services" -version = "1.15.0" +version = "1.16.0" dependencies = [ "anyhow", "aster-core", @@ -5627,7 +5627,7 @@ dependencies = [ [[package]] name = "lime-skills" -version = "1.15.0" +version = "1.16.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5645,7 +5645,7 @@ dependencies = [ [[package]] name = "lime-websocket" -version = "1.15.0" +version = "1.16.0" dependencies = [ "axum 0.7.9", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0ca5699ee..185b14d1f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -4,7 +4,7 @@ exclude = ["crates/aster", "crates/aster-models", "crates/aster-rust"] resolver = "2" [workspace.package] -version = "1.15.0" +version = "1.16.0" edition = "2021" authors = ["coso"] repository = "https://github.com/aiclientproxy/lime" @@ -188,7 +188,7 @@ version = "2.4" [package] name = "lime" -version = "1.15.0" +version = "1.16.0" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" diff --git a/src-tauri/crates/agent/src/session_store.rs b/src-tauri/crates/agent/src/session_store.rs index 56a1a0e57..f8e33f1fc 100644 --- a/src-tauri/crates/agent/src/session_store.rs +++ b/src-tauri/crates/agent/src/session_store.rs @@ -1685,6 +1685,9 @@ mod tests { source: Some("builtin".to_string()), directory: None, }], + hooks: None, + allowed_tools: Vec::new(), + disallowed_tools: Vec::new(), } .into_updated_extension_data(&session) .expect("merge customization"); @@ -1804,6 +1807,9 @@ mod tests { source: Some("builtin".to_string()), directory: None, }], + hooks: None, + allowed_tools: Vec::new(), + disallowed_tools: Vec::new(), } .into_updated_extension_data(&session) .expect("merge customization"); diff --git a/src-tauri/crates/agent/src/subagent_profiles.rs b/src-tauri/crates/agent/src/subagent_profiles.rs index 87d2b011d..d1dfc83a3 100644 --- a/src-tauri/crates/agent/src/subagent_profiles.rs +++ b/src-tauri/crates/agent/src/subagent_profiles.rs @@ -1,3 +1,4 @@ +use aster::hooks::FrontmatterHooks; use aster::session::extension_data::{ExtensionData, ExtensionState}; use aster::session::Session; @@ -65,6 +66,12 @@ pub struct SubagentCustomizationState { pub skill_ids: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub skills: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hooks: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allowed_tools: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub disallowed_tools: Vec, } impl ExtensionState for SubagentCustomizationState { @@ -104,6 +111,13 @@ impl SubagentCustomizationState { && self.system_overlay.is_none() && self.skill_ids.is_empty() && self.skills.is_empty() + && self + .hooks + .as_ref() + .map(|hooks| hooks.is_empty()) + .unwrap_or(true) + && self.allowed_tools.is_empty() + && self.disallowed_tools.is_empty() } } @@ -407,6 +421,18 @@ pub fn build_subagent_customization_prompt( if let Some(output_contract) = customization.output_contract.as_deref() { header_lines.push(format!("- 输出契约:{output_contract}")); } + if !customization.allowed_tools.is_empty() { + header_lines.push(format!( + "- Allowed Tools:{}", + customization.allowed_tools.join(", ") + )); + } + if !customization.disallowed_tools.is_empty() { + header_lines.push(format!( + "- Disallowed Tools:{}", + customization.disallowed_tools.join(", ") + )); + } sections.push(header_lines.join("\n")); if let Some(system_overlay) = customization.system_overlay.as_deref() { diff --git a/src-tauri/crates/aster-rust/crates/aster/src/agents/agent.rs b/src-tauri/crates/aster-rust/crates/aster/src/agents/agent.rs index 223af4fc0..d91c4edb0 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/agents/agent.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/agents/agent.rs @@ -23,7 +23,9 @@ use crate::agents::retry::{RetryManager, RetryResult}; use crate::agents::subagent_task_config::TaskConfig; use crate::agents::subagent_tool::{create_subagent_tool, handle_subagent_tool, AGENT_TOOL_NAME}; use crate::agents::types::SessionConfig; -use crate::agents::types::{FrontendTool, SharedProvider, ToolResultReceiver}; +use crate::agents::types::{ + FrontendTool, PermissionRequestHookHandler, SharedProvider, ToolResultReceiver, +}; use crate::config::{get_enabled_extensions, AsterMode, Config}; use crate::context::ContextTraceStep; use crate::context_mgmt::{ @@ -155,6 +157,10 @@ struct CurrentAgentToolRequest { isolation: Option, #[serde(default)] cwd: Option, + #[serde(default)] + allowed_tools: Vec, + #[serde(default)] + disallowed_tools: Vec, } #[derive(Debug)] @@ -646,6 +652,22 @@ fn normalize_agent_cwd(value: Option) -> Result, ErrorDat Ok(Some(cwd)) } +fn normalize_agent_tool_list(values: &[String]) -> Vec { + let mut normalized = Vec::new(); + let mut seen = std::collections::BTreeSet::new(); + + for value in values { + let Some(item) = normalize_agent_optional_text(Some(value.clone())) else { + continue; + }; + if seen.insert(item.clone()) { + normalized.push(item); + } + } + + normalized +} + fn parse_current_agent_tool_request( arguments: Value, ) -> Result { @@ -683,6 +705,8 @@ fn prepare_callback_backed_agent_spawn( let name = normalize_agent_optional_text(request.name.clone()); let team_name = normalize_agent_optional_text(request.team_name.clone()); let cwd = normalize_agent_cwd(request.cwd.clone())?; + let allowed_tools = normalize_agent_tool_list(&request.allowed_tools); + let disallowed_tools = normalize_agent_tool_list(&request.disallowed_tools); let team_subagent = session_allows_subagent_teammate_tools(session); if team_subagent && request.run_in_background { return Err(ErrorData::new( @@ -701,7 +725,12 @@ fn prepare_callback_backed_agent_spawn( } let should_use_callback = !team_subagent - && (request.run_in_background || name.is_some() || team_name.is_some() || cwd.is_some()); + && (request.run_in_background + || name.is_some() + || team_name.is_some() + || cwd.is_some() + || !allowed_tools.is_empty() + || !disallowed_tools.is_empty()); if !should_use_callback { return Ok(None); } @@ -736,6 +765,9 @@ fn prepare_callback_backed_agent_spawn( theme: None, system_overlay: None, output_contract: None, + hooks: None, + allowed_tools, + disallowed_tools, mode, isolation, cwd, @@ -844,6 +876,7 @@ pub struct Agent { pub(super) scheduler_service: Mutex>>, pub(super) retry_manager: RetryManager, pub(super) tool_inspection_manager: ToolInspectionManager, + pub(super) permission_request_hook_handler: Option, /// Tool registry for native tools (Requirements: 11.3, 11.4, 11.5) pub(super) tool_registry: Arc>, @@ -1398,6 +1431,7 @@ impl Agent { scheduler_service: Mutex::new(None), retry_manager: RetryManager::new(), tool_inspection_manager: Self::create_default_tool_inspection_manager(), + permission_request_hook_handler: None, tool_registry: Arc::new(RwLock::new(tool_registry)), file_read_history, session_store: None, // 默认使用全局 SessionManager @@ -1435,6 +1469,13 @@ impl Agent { self } + pub fn set_permission_request_hook_handler( + &mut self, + handler: Option, + ) { + self.permission_request_hook_handler = handler; + } + /// 设置 Agent 身份配置(Builder 模式) /// /// 允许应用层完全控制 Agent 的身份,包括名称、语言、描述等。 @@ -1521,6 +1562,7 @@ impl Agent { scheduler_service: Mutex::new(scheduler), retry_manager: RetryManager::new(), tool_inspection_manager: Self::create_default_tool_inspection_manager(), + permission_request_hook_handler: None, tool_registry: Arc::new(RwLock::new(tool_registry)), file_read_history, session_store: None, @@ -6308,6 +6350,90 @@ mod tests { assert_eq!(captured_request.parent_session_id, session.id); assert_eq!(captured_request.message, "检查这个改动是否会影响子代理通信"); assert_eq!(captured_request.name.as_deref(), Some("verifier")); + assert!(captured_request.allowed_tools.is_empty()); + assert!(captured_request.disallowed_tools.is_empty()); + + Ok(()) + } + + #[tokio::test] + async fn test_agent_tool_routes_tool_scope_through_callbacks() -> Result<()> { + initialize_session_runtime_store(Arc::new(InMemoryThreadRuntimeStore::default())); + + let captured = Arc::new(std::sync::Mutex::new(None::)); + let captured_clone = captured.clone(); + let spawn_agent_callback = Arc::new(move |request: SpawnAgentRequest| { + *captured_clone.lock().expect("capture lock") = Some(request.clone()); + Box::pin(async move { + Ok(SpawnAgentResponse { + agent_id: "agent-tools".to_string(), + nickname: Some("tool-scope-agent".to_string()), + extra: std::collections::BTreeMap::new(), + }) + }) + as Pin> + Send>> + }); + + let agent = + Agent::with_tool_config(ToolRegistrationConfig::new().with_agent_control_tools( + AgentControlToolConfig::new().with_spawn_agent_callback(spawn_agent_callback), + )); + let session = SessionManager::create_session( + PathBuf::from("."), + "agent-tool-scope-callback".to_string(), + SessionType::User, + ) + .await?; + agent + .update_provider(Arc::new(NativeOutputSchemaProvider), &session.id) + .await?; + + let arguments = serde_json::json!({ + "description": "工具白名单", + "prompt": "把工具限制同步给子代理", + "allowed_tools": ["Read", "Bash", "Read", " "], + "disallowed_tools": ["WebSearch", "WebSearch"] + }); + let tool_call = CallToolRequestParam { + name: AGENT_TOOL_NAME.into(), + arguments: Some( + arguments + .as_object() + .cloned() + .expect("agent tool arguments should be an object"), + ), + }; + + let (_request_id, tool_result) = agent + .dispatch_tool_call( + tool_call, + "req-agent-tool-scope".to_string(), + None, + &session, + ) + .await; + let tool_result = tool_result.expect("agent dispatch should succeed"); + let call_result = tool_result + .result + .await + .expect("callback-backed agent result"); + + assert_eq!( + call_result + .structured_content + .as_ref() + .and_then(|value| value.get("status")) + .and_then(Value::as_str), + Some("async_launched") + ); + + let captured_request = captured + .lock() + .expect("capture lock") + .clone() + .expect("spawn callback should capture request"); + assert_eq!(captured_request.allowed_tools, vec!["Read", "Bash"]); + assert_eq!(captured_request.disallowed_tools, vec!["WebSearch"]); Ok(()) } diff --git a/src-tauri/crates/aster-rust/crates/aster/src/agents/extension.rs b/src-tauri/crates/aster-rust/crates/aster/src/agents/extension.rs index bc4252a3f..9d40e8b8e 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/agents/extension.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/agents/extension.rs @@ -70,8 +70,9 @@ pub static PLATFORM_EXTENSIONS: Lazy skills_extension::EXTENSION_NAME, PlatformExtensionDef { name: skills_extension::EXTENSION_NAME, - description: "Load and use skills from relevant directories", - default_enabled: true, + description: + "Legacy skill loader extension; prefer the native Skill tool current surface", + default_enabled: false, client_factory: |ctx| Box::new(skills_extension::SkillsClient::new(ctx).unwrap()), }, ); diff --git a/src-tauri/crates/aster-rust/crates/aster/src/agents/mod.rs b/src-tauri/crates/aster-rust/crates/aster/src/agents/mod.rs index 6c41ea736..169464c47 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/agents/mod.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/agents/mod.rs @@ -89,7 +89,10 @@ pub use extension_manager::ExtensionManager; pub use identity::AgentIdentity; pub use prompt_manager::PromptManager; pub use subagent_task_config::TaskConfig; -pub use types::{FrontendTool, RetryConfig, SessionConfig, SuccessCheck}; +pub use types::{ + FrontendTool, PermissionRequestHookContext, PermissionRequestHookDecision, + PermissionRequestHookHandler, RetryConfig, SessionConfig, SuccessCheck, +}; // ============================================================================ // Context Module Re-exports diff --git a/src-tauri/crates/aster-rust/crates/aster/src/agents/skills_extension.rs b/src-tauri/crates/aster-rust/crates/aster/src/agents/skills_extension.rs index 640365e33..6e1ffb8d2 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/agents/skills_extension.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/agents/skills_extension.rs @@ -1,6 +1,10 @@ use crate::agents::extension::PlatformExtensionContext; use crate::agents::mcp_client::{Error, McpClientTrait}; use crate::config::paths::Paths; +use crate::skills::{ + global_registry as global_skill_registry, refresh_shared_registry_if_needed, + SharedSkillRegistry, SkillDefinition, +}; use anyhow::Result; use async_trait::async_trait; use indoc::indoc; @@ -16,6 +20,7 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; +use tracing::warn; pub static EXTENSION_NAME: &str = "skills"; @@ -41,10 +46,16 @@ struct Skill { pub struct SkillsClient { info: InitializeResult, skills: HashMap, + registry: Option, } impl SkillsClient { pub fn new(_context: PlatformExtensionContext) -> Result { + let registry = global_skill_registry().clone(); + if let Err(error) = refresh_shared_registry_if_needed(®istry) { + warn!("[SkillsExtension] 刷新共享 skill 注册表失败: {}", error); + } + let info = InitializeResult { protocol_version: ProtocolVersion::V_2025_03_26, capabilities: ServerCapabilities { @@ -64,18 +75,14 @@ impl SkillsClient { icons: None, website_url: None, }, - instructions: Some(String::new()), + instructions: Some(Self::generate_instructions_from_registry(®istry)), }; - let directories = Self::get_default_skill_directories() - .into_iter() - .filter(|d| d.exists()) - .collect::>(); - let skills = Self::discover_skills_in_directories(&directories); - - let mut client = Self { info, skills }; - client.info.instructions = Some(client.generate_instructions()); - Ok(client) + Ok(Self { + info, + skills: HashMap::new(), + registry: Some(registry), + }) } fn get_default_skill_directories() -> Vec { @@ -179,6 +186,10 @@ impl SkillsClient { } fn generate_instructions(&self) -> String { + if let Some(registry) = self.registry.as_ref() { + return Self::generate_instructions_from_registry(registry); + } + if self.skills.is_empty() { return String::new(); } @@ -195,6 +206,82 @@ impl SkillsClient { instructions } + fn generate_instructions_from_registry(registry: &SharedSkillRegistry) -> String { + if let Err(error) = refresh_shared_registry_if_needed(registry) { + warn!("[SkillsExtension] 刷新共享 skill 注册表失败: {}", error); + return String::new(); + } + + let Ok(registry_guard) = registry.read() else { + return String::new(); + }; + let mut skill_list = registry_guard + .get_all() + .into_iter() + .cloned() + .collect::>(); + if skill_list.is_empty() { + return String::new(); + } + + let mut instructions = String::from( + "You have these skills at your disposal, when it is clear they can help you solve a problem or you are asked to use them:\n\n", + ); + skill_list.sort_by(|left, right| left.skill_name.cmp(&right.skill_name)); + + for skill in skill_list { + instructions.push_str(&format!("- {}: {}\n", skill.skill_name, skill.description)); + } + + instructions + } + + fn build_skill_response( + skill_name: &str, + body: &str, + directory: &Path, + supporting_files: &[PathBuf], + ) -> String { + let mut response = format!("# Skill: {}\n\n{}\n\n", skill_name, body); + + if !supporting_files.is_empty() { + response.push_str(&format!( + "## Supporting Files\n\nSkill directory: {}\n\n", + directory.display() + )); + response.push_str("The following supporting files are available:\n"); + for file in supporting_files { + if let Ok(relative) = file.strip_prefix(directory) { + response.push_str(&format!("- {}\n", relative.display())); + } + } + response.push_str("\nUse the view file tools to access these files as needed, or run scripts as directed with dev extension.\n"); + } + + response + } + + fn skill_from_definition(skill: &SkillDefinition) -> Skill { + Skill { + metadata: SkillMetadata { + name: skill.skill_name.clone(), + description: skill.description.clone(), + }, + body: skill.markdown_content.clone(), + directory: skill.base_dir.clone(), + supporting_files: skill.supporting_files.clone(), + } + } + + fn registry_has_skills(&self) -> Result, String> { + let Some(registry) = self.registry.as_ref() else { + return Ok(None); + }; + refresh_shared_registry_if_needed(registry)?; + let registry_guard = registry.read().map_err(|error| error.to_string())?; + Ok(Some(!registry_guard.is_empty())) + } + async fn handle_load_skill( &self, arguments: Option, @@ -206,28 +293,33 @@ impl SkillsClient { .and_then(|v| v.as_str()) .ok_or("Missing required parameter: name")?; + if let Some(registry) = self.registry.as_ref() { + refresh_shared_registry_if_needed(registry)?; + let registry_guard = registry.read().map_err(|error| error.to_string())?; + let skill = registry_guard + .find(skill_name) + .ok_or_else(|| format!("Skill '{}' not found", skill_name))?; + let skill = Self::skill_from_definition(skill); + + return Ok(vec![Content::text(Self::build_skill_response( + &skill.metadata.name, + &skill.body, + &skill.directory, + &skill.supporting_files, + ))]); + } + let skill = self .skills .get(skill_name) .ok_or_else(|| format!("Skill '{}' not found", skill_name))?; - let mut response = format!("# Skill: {}\n\n{}\n\n", skill.metadata.name, skill.body); - - if !skill.supporting_files.is_empty() { - response.push_str(&format!( - "## Supporting Files\n\nSkill directory: {}\n\n", - skill.directory.display() - )); - response.push_str("The following supporting files are available:\n"); - for file in &skill.supporting_files { - if let Ok(relative) = file.strip_prefix(&skill.directory) { - response.push_str(&format!("- {}\n", relative.display())); - } - } - response.push_str("\nUse the view file tools to access these files as needed, or run scripts as directed with dev extension.\n"); - } - - Ok(vec![Content::text(response)]) + Ok(vec![Content::text(Self::build_skill_response( + &skill.metadata.name, + &skill.body, + &skill.directory, + &skill.supporting_files, + ))]) } fn get_tools() -> Vec { @@ -284,10 +376,19 @@ impl McpClientTrait for SkillsClient { _next_cursor: Option, _cancellation_token: CancellationToken, ) -> Result { - let tools = if self.skills.is_empty() { - Vec::new() - } else { + let has_skills = match self.registry_has_skills() { + Ok(Some(value)) => value, + Ok(None) => !self.skills.is_empty(), + Err(error) => { + warn!("[SkillsExtension] 读取共享 skill 注册表失败: {}", error); + !self.skills.is_empty() + } + }; + + let tools = if has_skills { Self::get_tools() + } else { + Vec::new() }; Ok(ListToolsResult { tools, @@ -592,6 +693,7 @@ Content from dir3 instructions: Some(String::new()), }, skills, + registry: None, }; let instructions = client.generate_instructions(); @@ -634,6 +736,7 @@ Content from dir3 instructions: Some(String::new()), }, skills, + registry: None, }; let result = client @@ -688,6 +791,7 @@ Content instructions: Some(String::new()), }, skills, + registry: None, }; let result = client @@ -756,6 +860,7 @@ Content instructions: Some(String::new()), }, skills, + registry: None, }; let instructions = client.generate_instructions(); diff --git a/src-tauri/crates/aster-rust/crates/aster/src/agents/subagent_tool.rs b/src-tauri/crates/aster-rust/crates/aster/src/agents/subagent_tool.rs index 4a27eb816..76826f5b4 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/agents/subagent_tool.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/agents/subagent_tool.rs @@ -86,6 +86,10 @@ struct AgentToolParams { #[serde(default)] cwd: Option, #[serde(default)] + allowed_tools: Vec, + #[serde(default)] + disallowed_tools: Vec, + #[serde(default)] images: Option>, } @@ -161,6 +165,16 @@ pub fn create_subagent_tool(sub_recipes: &[SubRecipe]) -> Tool { "type": "string", "description": "Optional working directory override for the agent. The current runtime accepts an absolute path to an existing directory." }, + "allowed_tools": { + "type": "array", + "items": { "type": "string" }, + "description": "Optional explicit tool allowlist. In callback-backed runtimes, this is enforced as session-scoped permissions." + }, + "disallowed_tools": { + "type": "array", + "items": { "type": "string" }, + "description": "Optional explicit tool denylist. Takes precedence over allowed_tools in callback-backed runtimes." + }, "images": { "type": "array", "items": { @@ -372,6 +386,8 @@ fn map_agent_tool_params( mode, isolation, cwd: _, + allowed_tools, + disallowed_tools, images, } = params; @@ -389,6 +405,16 @@ fn map_agent_tool_params( if normalize_optional_text(isolation).is_some() { return Err(anyhow!("isolation is not supported in the current runtime")); } + if !allowed_tools.is_empty() { + return Err(anyhow!( + "allowed_tools is only supported in callback-backed runtimes" + )); + } + if !disallowed_tools.is_empty() { + return Err(anyhow!( + "disallowed_tools is only supported in callback-backed runtimes" + )); + } let description = normalize_required_text(description, "description")?; let prompt = normalize_required_text(prompt, "prompt")?; @@ -925,6 +951,8 @@ mod tests { mode: None, isolation: None, cwd: None, + allowed_tools: Vec::new(), + disallowed_tools: Vec::new(), images: None, }, &sub_recipes, @@ -958,6 +986,8 @@ mod tests { mode: None, isolation: None, cwd: None, + allowed_tools: Vec::new(), + disallowed_tools: Vec::new(), images: None, }, &HashMap::new(), @@ -993,6 +1023,8 @@ mod tests { mode: None, isolation: None, cwd: None, + allowed_tools: Vec::new(), + disallowed_tools: Vec::new(), images: Some(images.clone()), }, &HashMap::new(), diff --git a/src-tauri/crates/aster-rust/crates/aster/src/agents/tool_execution.rs b/src-tauri/crates/aster-rust/crates/aster/src/agents/tool_execution.rs index bc231dad7..4e863c737 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/agents/tool_execution.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/agents/tool_execution.rs @@ -10,12 +10,13 @@ use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; use crate::config::permission::PermissionLevel; +use crate::config::AsterMode; use crate::mcp_utils::ToolResult; use crate::permission::{ AuditLogEntry, AuditLogLevel, AuditLogger, Permission, PermissionContext, ToolPermissionManager, }; use crate::tools::{ToolContext, ToolRegistry}; -use rmcp::model::{Content, ServerNotification}; +use rmcp::model::{CallToolRequestParam, Content, ServerNotification}; // ToolCallResult combines the result of a tool call with an optional notification stream that // can be used to receive notifications from the tool. @@ -34,7 +35,7 @@ impl From> for ToolCallResult { } use super::agent::{tool_stream, ToolStream}; -use crate::agents::Agent; +use crate::agents::{Agent, PermissionRequestHookContext, PermissionRequestHookDecision}; use crate::conversation::message::{Message, ToolRequest}; use crate::session::Session; use crate::tool_inspection::get_security_finding_id_from_results; @@ -52,7 +53,70 @@ pub const CHAT_MODE_TOOL_SKIPPED_RESPONSE: &str = "Let the user know the tool ca 2. **Outline Steps** - Break down the steps.\n \ If needed, adjust the explanation based on user preferences or questions."; +const PERMISSION_REQUEST_HOOK_DENIED_RESPONSE: &str = "Permission denied by PermissionRequest hook"; + +fn map_permission_request_mode(mode: AsterMode) -> Option { + match mode { + AsterMode::Auto => Some("bypassPermissions".to_string()), + AsterMode::Approve | AsterMode::SmartApprove => Some("default".to_string()), + AsterMode::Chat => None, + } +} + +fn build_permission_request_hook_denied_message(message: Option) -> String { + message + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| PERMISSION_REQUEST_HOOK_DENIED_RESPONSE.to_string()) +} + +fn apply_permission_request_updated_input( + tool_call: &CallToolRequestParam, + updated_input: Option>, +) -> CallToolRequestParam { + let mut rewritten_tool_call = tool_call.clone(); + if let Some(updated_input) = updated_input { + rewritten_tool_call.arguments = Some(updated_input); + } + rewritten_tool_call +} + impl Agent { + async fn run_permission_request_hook_handler( + &self, + tool_call: &CallToolRequestParam, + request_id: &str, + session: &Session, + ) -> Option { + let handler = self.permission_request_hook_handler.clone()?; + let permission_mode = self + .tool_inspection_manager + .current_permission_mode() + .await + .and_then(map_permission_request_mode); + + let input = PermissionRequestHookContext { + tool_name: tool_call.name.to_string(), + tool_input: tool_call.arguments.clone().map(serde_json::Value::Object), + tool_use_id: request_id.to_string(), + session_id: session.id.clone(), + permission_mode, + }; + + match handler(input).await { + Ok(decision) => decision, + Err(error) => { + tracing::warn!( + "[AsterAgent] PermissionRequest hooks 执行失败,已回退到人工审批: tool={}, request_id={}, error={}", + tool_call.name, + request_id, + error + ); + None + } + } + } + pub(crate) fn handle_approval_tool_requests<'a>( &'a self, tool_requests: &'a [ToolRequest], @@ -65,6 +129,62 @@ impl Agent { try_stream! { for request in tool_requests.iter() { if let Ok(tool_call) = request.tool_call.clone() { + if let Some(decision) = self + .run_permission_request_hook_handler(&tool_call, &request.id, session) + .await + { + match decision { + PermissionRequestHookDecision::Allow { updated_input } => { + let rewritten_tool_call = + apply_permission_request_updated_input(&tool_call, updated_input); + let (req_id, tool_result) = self + .dispatch_tool_call( + rewritten_tool_call, + request.id.clone(), + cancellation_token.clone(), + session, + ) + .await; + let mut futures = tool_futures.lock().await; + + futures.push(( + req_id, + match tool_result { + Ok(result) => tool_stream( + result + .notification_stream + .unwrap_or_else(|| Box::new(stream::empty())), + result.result, + ), + Err(e) => tool_stream( + Box::new(stream::empty()), + futures::future::ready(Err(e)), + ), + }, + )); + continue; + } + PermissionRequestHookDecision::Deny { message } => { + if let Some(response_msg) = request_to_response_map.get(&request.id) { + let mut response = response_msg.lock().await; + *response = response.clone().with_tool_response_with_metadata( + request.id.clone(), + Ok(rmcp::model::CallToolResult { + content: vec![Content::text( + build_permission_request_hook_denied_message(message), + )], + structured_content: None, + is_error: Some(true), + meta: None, + }), + request.metadata.as_ref(), + ); + } + continue; + } + } + } + // Find the corresponding inspection result for this tool request let security_message = inspection_results.iter() .find(|result| result.tool_request_id == request.id) @@ -592,3 +712,304 @@ impl Agent { audit_logger.log_permission_check(entry); } } + +#[cfg(test)] +mod tests { + use super::*; + use async_trait::async_trait; + use futures::{StreamExt, TryStreamExt}; + use rmcp::model::CallToolRequestParam; + use serde_json::json; + use std::collections::HashMap; + use tempfile::tempdir; + + use crate::agents::agent::ToolStreamItem; + use crate::conversation::message::{MessageContent, ToolResponse}; + use crate::session::{SessionManager, SessionType}; + use crate::tools::{Tool, ToolError}; + + struct StaticTool { + name: String, + response: String, + recorded_params: Option>>>, + } + + #[async_trait] + impl Tool for StaticTool { + fn name(&self) -> &str { + &self.name + } + + fn description(&self) -> &str { + "static tool" + } + + fn input_schema(&self) -> serde_json::Value { + json!({ + "type": "object" + }) + } + + async fn execute( + &self, + params: serde_json::Value, + _context: &ToolContext, + ) -> Result { + if let Some(recorded_params) = &self.recorded_params { + recorded_params.lock().await.push(params); + } + Ok(crate::tools::ToolResult::success(self.response.clone())) + } + } + + fn build_request(id: &str, name: &str, arguments: serde_json::Value) -> ToolRequest { + ToolRequest { + id: id.to_string(), + tool_call: Ok(CallToolRequestParam { + name: name.to_string().into(), + arguments: arguments.as_object().cloned(), + }), + metadata: None, + tool_meta: None, + } + } + + #[tokio::test] + async fn handle_approval_tool_requests_should_auto_allow_via_permission_request_hook() { + let mut agent = Agent::new(); + { + let mut registry = agent.tool_registry.write().await; + let _ = registry.unregister("Dangerous"); + registry.register(Box::new(StaticTool { + name: "Dangerous".to_string(), + response: "hook-allowed".to_string(), + recorded_params: None, + })); + } + agent.set_permission_request_hook_handler(Some(Arc::new(|context| { + Box::pin(async move { + assert_eq!(context.tool_name, "Dangerous"); + assert_eq!(context.permission_mode.as_deref(), Some("default")); + Ok(Some(PermissionRequestHookDecision::Allow { + updated_input: None, + })) + }) + }))); + + let temp_dir = tempdir().expect("create temp dir"); + let session = SessionManager::create_session( + temp_dir.path().to_path_buf(), + "permission-hook-allow".to_string(), + SessionType::User, + ) + .await + .expect("create session"); + + let tool_futures = Arc::new(Mutex::new(Vec::new())); + let requests = [build_request( + "req-permission-allow", + "Dangerous", + json!({"path": "README.md"}), + )]; + let request_map = HashMap::new(); + let stream = agent.handle_approval_tool_requests( + &requests, + tool_futures.clone(), + &request_map, + None, + &session, + &[], + ); + + let messages = stream + .try_collect::>() + .await + .expect("stream should complete"); + assert!(messages.is_empty()); + + let mut futures = tool_futures.lock().await; + assert_eq!(futures.len(), 1); + let (_req_id, tool_stream) = futures.pop().expect("should enqueue tool future"); + drop(futures); + + let items = tool_stream.collect::>().await; + assert_eq!(items.len(), 1); + let ToolStreamItem::Result(result) = &items[0] else { + panic!("expected tool result"); + }; + let call_result = result.as_ref().expect("tool should succeed"); + let text = call_result + .content + .first() + .and_then(|content| content.as_text()) + .map(|text| text.text.clone()) + .expect("tool result should contain text"); + assert_eq!(text, "hook-allowed"); + } + + #[tokio::test] + async fn handle_approval_tool_requests_should_execute_with_hook_updated_input() { + let mut agent = Agent::new(); + let recorded_params = Arc::new(Mutex::new(Vec::new())); + { + let mut registry = agent.tool_registry.write().await; + let _ = registry.unregister("Dangerous"); + registry.register(Box::new(StaticTool { + name: "Dangerous".to_string(), + response: "hook-rewrote-input".to_string(), + recorded_params: Some(recorded_params.clone()), + })); + } + agent.set_permission_request_hook_handler(Some(Arc::new(|context| { + Box::pin(async move { + assert_eq!(context.tool_name, "Dangerous"); + let mut updated_input = serde_json::Map::new(); + updated_input.insert( + "path".to_string(), + serde_json::Value::String("HOOKED.md".to_string()), + ); + Ok(Some(PermissionRequestHookDecision::Allow { + updated_input: Some(updated_input), + })) + }) + }))); + + let temp_dir = tempdir().expect("create temp dir"); + let session = SessionManager::create_session( + temp_dir.path().to_path_buf(), + "permission-hook-updated-input".to_string(), + SessionType::User, + ) + .await + .expect("create session"); + + let tool_futures = Arc::new(Mutex::new(Vec::new())); + let requests = [build_request( + "req-permission-updated-input", + "Dangerous", + json!({"path": "README.md"}), + )]; + let request_map = HashMap::new(); + let stream = agent.handle_approval_tool_requests( + &requests, + tool_futures.clone(), + &request_map, + None, + &session, + &[], + ); + + let messages = stream + .try_collect::>() + .await + .expect("stream should complete"); + assert!(messages.is_empty()); + + let mut futures = tool_futures.lock().await; + assert_eq!(futures.len(), 1); + let (_req_id, tool_stream) = futures.pop().expect("should enqueue tool future"); + drop(futures); + + let items = tool_stream.collect::>().await; + assert_eq!(items.len(), 1); + let ToolStreamItem::Result(result) = &items[0] else { + panic!("expected tool result"); + }; + let call_result = result.as_ref().expect("tool should succeed"); + let text = call_result + .content + .first() + .and_then(|content| content.as_text()) + .map(|text| text.text.clone()) + .expect("tool result should contain text"); + assert_eq!(text, "hook-rewrote-input"); + + let recorded_input = recorded_params + .lock() + .await + .first() + .cloned() + .expect("tool should execute with rewritten params"); + assert_eq!( + recorded_input.get("path"), + Some(&serde_json::Value::String("HOOKED.md".to_string())) + ); + } + + #[tokio::test] + async fn handle_approval_tool_requests_should_auto_deny_via_permission_request_hook() { + let mut agent = Agent::new(); + agent.set_permission_request_hook_handler(Some(Arc::new(|context| { + Box::pin(async move { + assert_eq!(context.tool_name, "Dangerous"); + Ok(Some(PermissionRequestHookDecision::Deny { + message: Some("hook denied execution".to_string()), + })) + }) + }))); + + let temp_dir = tempdir().expect("create temp dir"); + let session = SessionManager::create_session( + temp_dir.path().to_path_buf(), + "permission-hook-deny".to_string(), + SessionType::User, + ) + .await + .expect("create session"); + + let response_message = Arc::new(Mutex::new(Message::assistant().with_tool_response( + "req-permission-deny", + Ok(rmcp::model::CallToolResult { + content: vec![Content::text("pending".to_string())], + structured_content: None, + is_error: Some(false), + meta: None, + }), + ))); + let request_map = + HashMap::from([("req-permission-deny".to_string(), response_message.clone())]); + let tool_futures = Arc::new(Mutex::new(Vec::new())); + let requests = [build_request( + "req-permission-deny", + "Dangerous", + json!({"command": "git push"}), + )]; + let stream = agent.handle_approval_tool_requests( + &requests, + tool_futures.clone(), + &request_map, + None, + &session, + &[], + ); + + let messages = stream + .try_collect::>() + .await + .expect("stream should complete"); + assert!(messages.is_empty()); + assert!(tool_futures.lock().await.is_empty()); + + let response = response_message.lock().await.clone(); + let tool_result = response + .content + .iter() + .rev() + .find_map(|content| match content { + MessageContent::ToolResponse(ToolResponse { tool_result, .. }) => Some(tool_result), + _ => None, + }) + .expect("expected tool response content"); + let call_result = tool_result + .as_ref() + .expect("tool response should be ok envelope"); + assert_eq!(call_result.is_error, Some(true)); + let text = call_result + .content + .first() + .and_then(|content| content.as_text()) + .map(|text| text.text.clone()) + .expect("tool deny result should contain text"); + assert_eq!(text, "hook denied execution"); + } +} diff --git a/src-tauri/crates/aster-rust/crates/aster/src/agents/types.rs b/src-tauri/crates/aster-rust/crates/aster/src/agents/types.rs index d8a9d2f35..f05a1c581 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/agents/types.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/agents/types.rs @@ -4,6 +4,9 @@ use crate::providers::base::Provider; use crate::session::TurnContextOverride; use rmcp::model::{CallToolResult, Tool}; use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::future::Future; +use std::pin::Pin; use std::sync::Arc; use tokio::sync::{mpsc, Mutex}; use utoipa::ToSchema; @@ -15,6 +18,31 @@ pub type ToolResultReceiver = Arc>>>; +pub type PermissionRequestHookHandlerFuture = + Pin, String>> + Send>>; + +pub type PermissionRequestHookHandler = + Arc PermissionRequestHookHandlerFuture + Send + Sync>; + +#[derive(Debug, Clone, PartialEq)] +pub struct PermissionRequestHookContext { + pub tool_name: String, + pub tool_input: Option, + pub tool_use_id: String, + pub session_id: String, + pub permission_mode: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum PermissionRequestHookDecision { + Allow { + updated_input: Option>, + }, + Deny { + message: Option, + }, +} + /// Default timeout for retry operations (5 minutes) pub const DEFAULT_RETRY_TIMEOUT_SECONDS: u64 = 300; diff --git a/src-tauri/crates/aster-rust/crates/aster/src/claude_plugin_cache.rs b/src-tauri/crates/aster-rust/crates/aster/src/claude_plugin_cache.rs new file mode 100644 index 000000000..9a3fdac08 --- /dev/null +++ b/src-tauri/crates/aster-rust/crates/aster/src/claude_plugin_cache.rs @@ -0,0 +1,719 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ClaudePluginCacheEntry { + pub plugin_id: String, + pub plugin_name: String, + pub marketplace: String, + pub root: PathBuf, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct ClaudePluginCacheResolution { + pub plugins: Vec, + pub skipped: Vec, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct EnabledClaudePluginsResolution { + pub plugin_ids: BTreeSet, + pub skipped: Vec, +} + +pub fn resolve_enabled_claude_plugin_ids( + workspace_root: Option<&Path>, + home_dir: Option<&Path>, +) -> EnabledClaudePluginsResolution { + let Some(home_dir) = home_dir else { + return EnabledClaudePluginsResolution::default(); + }; + + let mut merged = BTreeMap::::new(); + let mut skipped = Vec::new(); + let mut settings_paths = vec![ + home_dir.join(".claude").join("settings.json"), + home_dir.join(".claude").join("settings.local.json"), + ]; + + if let Some(workspace_root) = workspace_root { + settings_paths.push(workspace_root.join(".claude").join("settings.json")); + settings_paths.push(workspace_root.join(".claude").join("settings.local.json")); + } + + for path in settings_paths { + merge_enabled_plugins_from_settings(&path, &mut merged, &mut skipped); + } + + EnabledClaudePluginsResolution { + plugin_ids: merged + .into_iter() + .filter_map(|(plugin_id, enabled)| enabled.then_some(plugin_id)) + .collect(), + skipped, + } +} + +pub fn resolve_claude_plugin_cache_entries( + workspace_root: Option<&Path>, + home_dir: Option<&Path>, +) -> ClaudePluginCacheResolution { + let Some(home_dir) = home_dir else { + return ClaudePluginCacheResolution::default(); + }; + + let enabled_plugins = resolve_enabled_claude_plugin_ids(workspace_root, Some(home_dir)); + let mut resolution = ClaudePluginCacheResolution { + plugins: Vec::new(), + skipped: enabled_plugins.skipped, + }; + if let Some(reason) = detect_managed_plugin_policy_gap(home_dir) { + resolution.skipped.push(reason); + } + + for plugin_id in enabled_plugins.plugin_ids { + let parsed = match parse_cached_plugin_id(&plugin_id) { + Ok(parsed) => parsed, + Err(reason) => { + resolution.skipped.push(format!("{plugin_id}: {reason}")); + continue; + } + }; + + let versioned_plugin_cache_dir = home_dir + .join(".claude") + .join("plugins") + .join("cache") + .join(sanitize_plugin_cache_path_component(&parsed.marketplace)) + .join(sanitize_plugin_cache_path_component(&parsed.plugin_name)); + let legacy_plugin_cache_dir = home_dir + .join(".claude") + .join("plugins") + .join("cache") + .join(sanitize_plugin_cache_path_component(&parsed.plugin_name)); + + let Some(root) = + resolve_cached_plugin_root(&versioned_plugin_cache_dir, &legacy_plugin_cache_dir) + else { + resolution.skipped.push(format!( + "{plugin_id}: 未找到可用插件缓存目录(versioned/legacy 均缺失)" + )); + continue; + }; + + resolution.plugins.push(ClaudePluginCacheEntry { + plugin_id, + plugin_name: parsed.plugin_name, + marketplace: parsed.marketplace, + root, + }); + } + + resolution.plugins.sort_by(|left, right| { + left.plugin_id + .cmp(&right.plugin_id) + .then_with(|| left.root.cmp(&right.root)) + }); + resolution +} + +pub fn resolve_cached_plugin_manifest_path(plugin_root: &Path) -> Option { + let primary_manifest_path = plugin_root.join(".claude-plugin").join("plugin.json"); + if primary_manifest_path.exists() { + return Some(primary_manifest_path); + } + + let legacy_manifest_path = plugin_root.join("plugin.json"); + legacy_manifest_path + .exists() + .then_some(legacy_manifest_path) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClaudeManifestRelativePathKind { + Any, + JsonFile, +} + +pub fn load_cached_plugin_manifest_json( + plugin_root: &Path, +) -> Result, String> { + let Some(manifest_path) = resolve_cached_plugin_manifest_path(plugin_root) else { + return Ok(None); + }; + let content = fs::read_to_string(&manifest_path).map_err(|error| { + format!( + "plugin manifest 读取失败 ({}): {}", + manifest_path.display(), + error + ) + })?; + let manifest = serde_json::from_str::(&content).map_err(|error| { + format!( + "plugin manifest 解析失败 ({}): {}", + manifest_path.display(), + error + ) + })?; + + if !manifest.is_object() { + return Err(format!( + "plugin manifest 必须是 JSON object ({})", + manifest_path.display() + )); + } + + Ok(Some((manifest_path, manifest))) +} + +pub fn validate_claude_manifest_relative_path( + relative_path: &str, + kind: ClaudeManifestRelativePathKind, +) -> Result<&str, String> { + if relative_path.is_empty() { + return Err("路径不能为空".to_string()); + } + + if !relative_path.starts_with("./") { + return Err("路径必须以 ./ 开头".to_string()); + } + + if matches!(kind, ClaudeManifestRelativePathKind::JsonFile) && !relative_path.ends_with(".json") + { + return Err("路径必须指向 .json 文件".to_string()); + } + + Ok(relative_path) +} + +pub fn resolve_claude_manifest_relative_path( + plugin_root: &Path, + relative_path: &str, + kind: ClaudeManifestRelativePathKind, +) -> Result { + let relative_path = validate_claude_manifest_relative_path(relative_path, kind)?; + Ok(plugin_root.join(relative_path)) +} + +const MANAGED_PLUGIN_POLICY_KEYS: &[&str] = &[ + "enabledPlugins", + "extraKnownMarketplaces", + "strictKnownMarketplaces", + "blockedMarketplaces", +]; + +fn build_builtin_plugin_unsupported_reason() -> String { + "@builtin 当前缺少独立的 builtin plugin registry/current 宿主,不能回退为 marketplace cache plugin 或其他 builtin surface".to_string() +} + +fn merge_enabled_plugins_from_settings( + path: &Path, + merged: &mut BTreeMap, + skipped: &mut Vec, +) { + let Ok(content) = fs::read_to_string(path) else { + return; + }; + let Ok(settings) = serde_json::from_str::(&content) else { + skipped.push(format!("{}: enabledPlugins 配置解析失败", path.display())); + return; + }; + let Some(enabled_plugins) = settings + .get("enabledPlugins") + .and_then(serde_json::Value::as_object) + else { + return; + }; + + for (plugin_id, enabled) in enabled_plugins { + if let Some(enabled) = enabled.as_bool() { + merged.insert(plugin_id.clone(), enabled); + } + } +} + +fn detect_managed_plugin_policy_gap(home_dir: &Path) -> Option { + let policy_paths = [ + home_dir.join(".aster").join("managed_settings.yaml"), + home_dir.join(".aster").join("policy.yaml"), + ]; + + for path in policy_paths { + let Some(keys) = extract_managed_plugin_policy_keys(&path) else { + continue; + }; + if keys.is_empty() { + continue; + } + let key_list = keys.into_iter().collect::>().join(", "); + return Some(format!( + "{}: 检测到 Claude 风格 managed plugin policy keys [{}],但当前 runtime 尚未接入 policySettings/plugin loader current 宿主,已忽略", + path.display(), + key_list + )); + } + + None +} + +fn extract_managed_plugin_policy_keys(path: &Path) -> Option> { + let content = fs::read_to_string(path).ok()?; + let parsed = serde_yaml::from_str::(&content).ok()?; + let root = parsed.as_mapping()?; + let mut keys = BTreeSet::new(); + + collect_managed_plugin_policy_keys(root, &mut keys); + for scope_key in ["defaults", "enforced"] { + let Some(scope_value) = root.get(serde_yaml::Value::String(scope_key.to_string())) else { + continue; + }; + let Some(scope_map) = scope_value.as_mapping() else { + continue; + }; + collect_managed_plugin_policy_keys(scope_map, &mut keys); + } + + Some(keys) +} + +fn collect_managed_plugin_policy_keys( + mapping: &serde_yaml::Mapping, + keys: &mut BTreeSet<&'static str>, +) { + for candidate in MANAGED_PLUGIN_POLICY_KEYS { + if mapping.contains_key(serde_yaml::Value::String((*candidate).to_string())) { + keys.insert(*candidate); + } + } +} + +struct ParsedPluginId { + plugin_name: String, + marketplace: String, +} + +fn parse_cached_plugin_id(plugin_id: &str) -> Result { + let Some((plugin_name, marketplace)) = plugin_id.rsplit_once('@') else { + return Err("不是 plugin@marketplace current 插件标识".to_string()); + }; + + let plugin_name = plugin_name.trim(); + let marketplace = marketplace.trim(); + + if plugin_name.is_empty() || marketplace.is_empty() { + return Err("不是 plugin@marketplace current 插件标识".to_string()); + } + + if marketplace.eq_ignore_ascii_case("builtin") { + return Err(build_builtin_plugin_unsupported_reason()); + } + + Ok(ParsedPluginId { + plugin_name: plugin_name.to_string(), + marketplace: marketplace.to_string(), + }) +} + +fn sanitize_plugin_cache_path_component(value: &str) -> String { + value + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + ch + } else { + '-' + } + }) + .collect() +} + +fn resolve_cached_plugin_root( + versioned_plugin_cache_dir: &Path, + legacy_plugin_cache_dir: &Path, +) -> Option { + if let Some(root) = resolve_latest_versioned_plugin_root(versioned_plugin_cache_dir) { + return Some(root); + } + + legacy_plugin_cache_dir + .is_dir() + .then_some(legacy_plugin_cache_dir.to_path_buf()) +} + +fn resolve_latest_versioned_plugin_root(plugin_cache_dir: &Path) -> Option { + let entries = fs::read_dir(plugin_cache_dir).ok()?; + let mut candidates = entries + .filter_map(|entry| entry.ok().map(|item| item.path())) + .filter(|path| path.is_dir()) + .collect::>(); + + candidates.sort_by(|left, right| compare_cached_plugin_version_dirs(left, right)); + candidates.into_iter().next() +} + +fn compare_cached_plugin_version_dirs(left: &Path, right: &Path) -> std::cmp::Ordering { + let left_name = left + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or_default(); + let right_name = right + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or_default(); + + match ( + parse_cached_plugin_version(left_name), + parse_cached_plugin_version(right_name), + ) { + (Some(left_version), Some(right_version)) => right_version + .cmp(&left_version) + .then_with(|| right_name.cmp(left_name)), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => right_name.cmp(left_name), + } +} + +fn parse_cached_plugin_version(value: &str) -> Option<(u64, u64, u64)> { + let normalized = value.trim().trim_start_matches('v'); + let normalized = normalized.split('-').next().unwrap_or(normalized); + let mut parts = normalized.split('.'); + let major = parts.next()?.parse::().ok()?; + let minor = parts.next().unwrap_or("0").parse::().ok()?; + let patch = parts.next().unwrap_or("0").parse::().ok()?; + Some((major, minor, patch)) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn write_enabled_plugin_settings( + root: &Path, + file_name: &str, + enabled_plugins: serde_json::Value, + ) { + let claude_dir = root.join(".claude"); + fs::create_dir_all(&claude_dir).expect("创建 .claude 目录失败"); + fs::write( + claude_dir.join(file_name), + serde_json::to_string_pretty(&serde_json::json!({ + "enabledPlugins": enabled_plugins + })) + .expect("序列化 enabledPlugins 失败"), + ) + .expect("写入 enabledPlugins 配置失败"); + } + + fn write_cached_plugin(home_root: &Path, plugin_name: &str, marketplace: &str, version: &str) { + fs::create_dir_all( + home_root + .join(".claude") + .join("plugins") + .join("cache") + .join(marketplace) + .join(plugin_name) + .join(version), + ) + .expect("创建 plugin cache 目录失败"); + } + + fn write_legacy_cached_plugin(home_root: &Path, plugin_name: &str) { + fs::create_dir_all( + home_root + .join(".claude") + .join("plugins") + .join("cache") + .join(plugin_name), + ) + .expect("创建 legacy plugin cache 目录失败"); + } + + fn write_aster_managed_settings(home_root: &Path, content: &str) { + let aster_dir = home_root.join(".aster"); + fs::create_dir_all(&aster_dir).expect("创建 .aster 目录失败"); + fs::write(aster_dir.join("managed_settings.yaml"), content) + .expect("写入 managed_settings.yaml 失败"); + } + + #[test] + fn resolve_enabled_claude_plugin_ids_should_merge_home_workspace_and_local_settings() { + let home = TempDir::new().expect("create temp home"); + let workspace = TempDir::new().expect("create temp workspace"); + + write_enabled_plugin_settings( + home.path(), + "settings.json", + serde_json::json!({ + "alpha@demo-market": true, + "beta@demo-market": false + }), + ); + write_enabled_plugin_settings( + home.path(), + "settings.local.json", + serde_json::json!({ + "beta@demo-market": true + }), + ); + write_enabled_plugin_settings( + workspace.path(), + "settings.json", + serde_json::json!({ + "alpha@demo-market": false + }), + ); + write_enabled_plugin_settings( + workspace.path(), + "settings.local.json", + serde_json::json!({ + "gamma@demo-market": true + }), + ); + + let resolution = + resolve_enabled_claude_plugin_ids(Some(workspace.path()), Some(home.path())); + + assert_eq!( + resolution.plugin_ids, + BTreeSet::from([ + "beta@demo-market".to_string(), + "gamma@demo-market".to_string() + ]) + ); + assert!(resolution.skipped.is_empty()); + } + + #[test] + fn resolve_claude_plugin_cache_entries_should_choose_latest_version_directory() { + let home = TempDir::new().expect("create temp home"); + let workspace = TempDir::new().expect("create temp workspace"); + write_enabled_plugin_settings( + workspace.path(), + "settings.local.json", + serde_json::json!({ + "ranker@demo-market": true + }), + ); + + write_cached_plugin(home.path(), "ranker", "demo-market", "v1.2.0"); + write_cached_plugin(home.path(), "ranker", "demo-market", "v1.10.0"); + write_cached_plugin(home.path(), "ranker", "demo-market", "latest"); + + let resolution = + resolve_claude_plugin_cache_entries(Some(workspace.path()), Some(home.path())); + + assert_eq!(resolution.plugins.len(), 1); + assert_eq!(resolution.plugins[0].plugin_id, "ranker@demo-market"); + assert_eq!( + resolution.plugins[0] + .root + .file_name() + .and_then(|value| value.to_str()), + Some("v1.10.0") + ); + } + + #[test] + fn resolve_claude_plugin_cache_entries_should_fallback_to_legacy_cache_directory() { + let home = TempDir::new().expect("create temp home"); + let workspace = TempDir::new().expect("create temp workspace"); + write_enabled_plugin_settings( + workspace.path(), + "settings.local.json", + serde_json::json!({ + "ranker@demo-market": true + }), + ); + + write_legacy_cached_plugin(home.path(), "ranker"); + + let resolution = + resolve_claude_plugin_cache_entries(Some(workspace.path()), Some(home.path())); + + assert_eq!(resolution.plugins.len(), 1); + assert_eq!(resolution.plugins[0].plugin_id, "ranker@demo-market"); + assert_eq!( + resolution.plugins[0].root, + home.path() + .join(".claude") + .join("plugins") + .join("cache") + .join("ranker") + ); + } + + #[test] + fn resolve_claude_plugin_cache_entries_should_prefer_versioned_directory_over_legacy_cache() { + let home = TempDir::new().expect("create temp home"); + let workspace = TempDir::new().expect("create temp workspace"); + write_enabled_plugin_settings( + workspace.path(), + "settings.local.json", + serde_json::json!({ + "ranker@demo-market": true + }), + ); + + write_cached_plugin(home.path(), "ranker", "demo-market", "v1.10.0"); + write_legacy_cached_plugin(home.path(), "ranker"); + + let resolution = + resolve_claude_plugin_cache_entries(Some(workspace.path()), Some(home.path())); + + assert_eq!(resolution.plugins.len(), 1); + assert_eq!(resolution.plugins[0].plugin_id, "ranker@demo-market"); + assert_eq!( + resolution.plugins[0].root, + home.path() + .join(".claude") + .join("plugins") + .join("cache") + .join("demo-market") + .join("ranker") + .join("v1.10.0") + ); + } + + #[test] + fn resolve_claude_plugin_cache_entries_should_use_sanitized_cache_path_components() { + let home = TempDir::new().expect("create temp home"); + let workspace = TempDir::new().expect("create temp workspace"); + write_enabled_plugin_settings( + workspace.path(), + "settings.local.json", + serde_json::json!({ + "@scope/ranker@demo.market": true + }), + ); + + write_cached_plugin(home.path(), "-scope-ranker", "demo-market", "v1.2.3"); + + let resolution = + resolve_claude_plugin_cache_entries(Some(workspace.path()), Some(home.path())); + + assert_eq!(resolution.plugins.len(), 1); + assert_eq!(resolution.plugins[0].plugin_id, "@scope/ranker@demo.market"); + assert_eq!( + resolution.plugins[0].root, + home.path() + .join(".claude") + .join("plugins") + .join("cache") + .join("demo-market") + .join("-scope-ranker") + .join("v1.2.3") + ); + } + + #[test] + fn resolve_claude_plugin_cache_entries_should_mark_builtin_as_gap() { + let home = TempDir::new().expect("create temp home"); + let workspace = TempDir::new().expect("create temp workspace"); + write_enabled_plugin_settings( + workspace.path(), + "settings.local.json", + serde_json::json!({ + "capture@builtin": true + }), + ); + + let resolution = + resolve_claude_plugin_cache_entries(Some(workspace.path()), Some(home.path())); + + assert!(resolution.plugins.is_empty()); + assert_eq!(resolution.skipped.len(), 1); + assert!(resolution.skipped[0].contains("capture@builtin")); + assert!(resolution.skipped[0].contains("builtin plugin registry")); + } + + #[test] + fn resolve_claude_plugin_cache_entries_should_mark_managed_plugin_policy_as_gap() { + let home = TempDir::new().expect("create temp home"); + write_aster_managed_settings( + home.path(), + r#" +defaults: + enabledPlugins: + capture@claude-code-marketplace: true +enforced: + strictKnownMarketplaces: + - claude-code-marketplace +"#, + ); + + let resolution = resolve_claude_plugin_cache_entries(None, Some(home.path())); + + assert!(resolution.plugins.is_empty()); + assert_eq!(resolution.skipped.len(), 1); + assert!(resolution.skipped[0].contains("managed_settings.yaml")); + assert!(resolution.skipped[0].contains("enabledPlugins")); + assert!(resolution.skipped[0].contains("strictKnownMarketplaces")); + assert!(resolution.skipped[0].contains("current 宿主")); + } + + #[test] + fn resolve_claude_plugin_cache_entries_should_ignore_unrelated_managed_settings() { + let home = TempDir::new().expect("create temp home"); + write_aster_managed_settings( + home.path(), + r#" +disabled_features: + - browser +allowed_tools: + - Read +"#, + ); + + let resolution = resolve_claude_plugin_cache_entries(None, Some(home.path())); + + assert!(resolution.plugins.is_empty()); + assert!(resolution.skipped.is_empty()); + } + + #[test] + fn load_cached_plugin_manifest_json_should_reject_non_object_manifest() { + let root = TempDir::new().expect("create temp root"); + let plugin_root = root.path().join("plugin-root"); + fs::create_dir_all(plugin_root.join(".claude-plugin")).expect("创建 manifest 目录失败"); + fs::write( + plugin_root.join(".claude-plugin").join("plugin.json"), + "\"not-an-object\"", + ) + .expect("写入 manifest 失败"); + + let error = + load_cached_plugin_manifest_json(&plugin_root).expect_err("非 object manifest 应报错"); + assert!(error.contains("plugin manifest 必须是 JSON object")); + } + + #[test] + fn validate_claude_manifest_relative_path_should_require_dot_slash_and_json_suffix() { + assert_eq!( + validate_claude_manifest_relative_path( + "./skills/writer", + ClaudeManifestRelativePathKind::Any, + ) + .expect("应接受 Claude 风格相对路径"), + "./skills/writer" + ); + assert!( + validate_claude_manifest_relative_path( + "skills/writer", + ClaudeManifestRelativePathKind::Any, + ) + .is_err(), + "缺少 ./ 前缀应被拒绝" + ); + assert!( + validate_claude_manifest_relative_path( + "./hooks/extra-hooks", + ClaudeManifestRelativePathKind::JsonFile, + ) + .is_err(), + "hooks 路径必须指向 json 文件" + ); + } +} diff --git a/src-tauri/crates/aster-rust/crates/aster/src/execution/manager.rs b/src-tauri/crates/aster-rust/crates/aster/src/execution/manager.rs index 3ab673abe..a7cec3a4a 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/execution/manager.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/execution/manager.rs @@ -3,6 +3,7 @@ use crate::agents::{Agent, SessionConfig}; use crate::config::paths::Paths; use crate::config::Config; use crate::conversation::message::Message; +use crate::hooks::register_agent_session_frontmatter_hooks; use crate::scheduler::Scheduler; use crate::scheduler_trait::SchedulerTrait; use crate::session::{ @@ -120,6 +121,29 @@ fn session_name_for_spawn(request: &SpawnAgentRequest) -> String { .unwrap_or_else(|| "Background agent".to_string()) } +fn register_spawned_agent_frontmatter_hooks( + session_id: &str, + hooks: Option<&crate::hooks::FrontmatterHooks>, +) { + let Some(hooks) = hooks else { + return; + }; + + let report = register_agent_session_frontmatter_hooks(session_id, hooks); + if report.registered > 0 { + info!( + "Registered {} agent frontmatter hook(s) for spawned session {}", + report.registered, session_id + ); + } + for skipped in report.skipped { + warn!( + "Skipped agent frontmatter hook for spawned session {}: {}", + session_id, skipped + ); + } +} + fn build_agent_control_tool_config(runtime: AgentManagerRuntime) -> AgentControlToolConfig { let spawn_runtime = runtime.clone(); let send_runtime = runtime; @@ -419,6 +443,7 @@ async fn spawn_agent_with_runtime( ); } } + register_spawned_agent_frontmatter_hooks(&child_session.id, request.hooks.as_ref()); let submission = send_input_with_runtime( runtime, @@ -760,6 +785,7 @@ mod tests { #[tokio::test] #[serial] async fn test_spawned_named_agent_registers_name_route_for_parent_session() { + use crate::hooks::{clear_session_hooks, get_matching_session_hooks, HookEvent}; use crate::providers::testprovider::TestProvider; use crate::session::{resolve_named_subagent_child_session, SessionManager, SessionType}; use crate::tools::SpawnAgentRequest; @@ -807,6 +833,23 @@ mod tests { theme: None, system_overlay: None, output_contract: None, + hooks: Some( + serde_json::from_value(serde_json::json!({ + "Stop": [ + { + "hooks": [ + { + "type": "prompt", + "prompt": "Summarize child session" + } + ] + } + ] + })) + .expect("hooks should deserialize"), + ), + allowed_tools: Vec::new(), + disallowed_tools: Vec::new(), mode: None, isolation: None, cwd: None, @@ -821,6 +864,13 @@ mod tests { .expect("应能按名字解析刚创建的子 agent"); assert_eq!(resolved.id, response.agent_id); + assert!(get_matching_session_hooks(&response.agent_id, HookEvent::Stop, None).is_empty()); + assert_eq!( + get_matching_session_hooks(&response.agent_id, HookEvent::SubagentStop, None).len(), + 1 + ); + + clear_session_hooks(&response.agent_id); } #[tokio::test] @@ -891,6 +941,9 @@ mod tests { theme: None, system_overlay: None, output_contract: None, + hooks: None, + allowed_tools: Vec::new(), + disallowed_tools: Vec::new(), mode: None, isolation: None, cwd: Some(temp_dir.path().display().to_string()), diff --git a/src-tauri/crates/aster-rust/crates/aster/src/hooks/executor.rs b/src-tauri/crates/aster-rust/crates/aster/src/hooks/executor.rs index 940ea77c2..646da8716 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/hooks/executor.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/hooks/executor.rs @@ -2,7 +2,10 @@ //! //! 执行各种类型的 hooks -use super::registry::{global_registry, SharedHookRegistry}; +use super::registry::{ + clear_session_hooks, get_matching_session_hooks, global_registry, + unregister_session_hook_entry, SharedHookRegistry, +}; use super::types::*; use crate::agents::{Agent, AgentEvent, SessionConfig}; use crate::config::{AsterMode, Config}; @@ -38,6 +41,10 @@ fn replace_command_variables(command: &str, input: &HookInput) -> String { &input.event.map(|e| e.to_string()).unwrap_or_default(), ) .replace("$SESSION_ID", input.session_id.as_deref().unwrap_or("")) + .replace( + "$PERMISSION_MODE", + input.permission_mode.as_deref().unwrap_or(""), + ) } const PROMPT_HOOK_SYSTEM_PROMPT: &str = r#"You are evaluating an Aster/Lime prompt hook. @@ -662,6 +669,10 @@ async fn execute_command_hook(hook: &CommandHookConfig, input: &HookInput) -> Ho "CLAUDE_HOOK_SESSION_ID".to_string(), input.session_id.clone().unwrap_or_default(), ); + env.insert( + "CLAUDE_HOOK_PERMISSION_MODE".to_string(), + input.permission_mode.clone().unwrap_or_default(), + ); // 准备输入 JSON let input_json = serde_json::to_string(input).unwrap_or_default(); @@ -735,6 +746,7 @@ async fn execute_url_hook(hook: &UrlHookConfig, input: &HookInput) -> HookResult "toolOutput": input.tool_output, "message": input.message, "sessionId": input.session_id, + "permissionMode": input.permission_mode, "timestamp": chrono::Utc::now().to_rfc3339(), "tool_use_id": input.tool_use_id, "error": input.error, @@ -1029,11 +1041,10 @@ pub async fn run_hooks_with_registry_and_context( None => return vec![], }; - let matching_hooks = registry.get_matching(event, input.tool_name.as_deref()); let mut results = Vec::new(); - for hook in &matching_hooks { - let result = execute_hook(hook, &input, runtime).await; + for hook in registry.get_matching(event, input.tool_name.as_deref()) { + let result = execute_hook(&hook, &input, runtime).await; let is_blocked = result.blocked; let is_blocking = hook.is_blocking(); results.push(result); @@ -1044,6 +1055,29 @@ pub async fn run_hooks_with_registry_and_context( } } + if let Some(session_id) = input + .session_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + for entry in get_matching_session_hooks(session_id, event, input.tool_name.as_deref()) { + let result = execute_hook(&entry.config, &input, runtime).await; + let is_blocked = result.blocked; + let is_blocking = entry.config.is_blocking(); + let should_remove_once = entry.once && result.success; + results.push(result); + + if should_remove_once { + let _ = unregister_session_hook_entry(session_id, event, entry.id); + } + + if is_blocked && is_blocking { + break; + } + } + } + results } @@ -1157,19 +1191,52 @@ pub async fn run_pre_compact_hooks( current_tokens: Option, trigger: Option, ) -> (bool, Option) { - let results = run_hooks(HookInput { - event: Some(HookEvent::PreCompact), - current_tokens, - trigger, - session_id, - ..Default::default() - }) - .await; + let registry = global_registry(); + let results = + run_pre_compact_hooks_with_registry(session_id, current_tokens, trigger, ®istry).await; let (blocked, message) = is_blocked(&results); (!blocked, message) } +/// 使用指定注册表运行 PreCompact hook +pub async fn run_pre_compact_hooks_with_registry( + session_id: Option, + current_tokens: Option, + trigger: Option, + registry: &SharedHookRegistry, +) -> Vec { + run_pre_compact_hooks_with_registry_and_context( + session_id, + current_tokens, + trigger, + registry, + &HookRuntimeContext::default(), + ) + .await +} + +pub async fn run_pre_compact_hooks_with_registry_and_context( + session_id: Option, + current_tokens: Option, + trigger: Option, + registry: &SharedHookRegistry, + runtime: &HookRuntimeContext, +) -> Vec { + run_hooks_with_registry_and_context( + HookInput { + event: Some(HookEvent::PreCompact), + current_tokens, + trigger, + session_id, + ..Default::default() + }, + registry, + runtime, + ) + .await +} + /// PostToolUseFailure hook #[allow(clippy::too_many_arguments)] pub async fn run_post_tool_use_failure_hooks( @@ -1239,13 +1306,43 @@ pub async fn run_session_start_hooks_with_registry_and_context( /// SessionEnd hook pub async fn run_session_end_hooks(session_id: String, reason: Option) { - let _ = run_hooks(HookInput { - event: Some(HookEvent::SessionEnd), + let registry = global_registry(); + let _ = run_session_end_hooks_with_registry(session_id, reason, ®istry).await; +} + +pub async fn run_session_end_hooks_with_registry( + session_id: String, + reason: Option, + registry: &SharedHookRegistry, +) -> Vec { + run_session_end_hooks_with_registry_and_context( + session_id, reason, - session_id: Some(session_id), - ..Default::default() - }) + registry, + &HookRuntimeContext::default(), + ) + .await +} + +pub async fn run_session_end_hooks_with_registry_and_context( + session_id: String, + reason: Option, + registry: &SharedHookRegistry, + runtime: &HookRuntimeContext, +) -> Vec { + let results = run_hooks_with_registry_and_context( + HookInput { + event: Some(HookEvent::SessionEnd), + reason, + session_id: Some(session_id.clone()), + ..Default::default() + }, + registry, + runtime, + ) .await; + clear_session_hooks(&session_id); + results } /// SubagentStart hook diff --git a/src-tauri/crates/aster-rust/crates/aster/src/hooks/registry.rs b/src-tauri/crates/aster-rust/crates/aster/src/hooks/registry.rs index 122c47ab1..d9b1ecd91 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/hooks/registry.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/hooks/registry.rs @@ -2,10 +2,11 @@ //! //! 管理已注册的 hooks -use super::types::{HookConfig, HookEvent, LegacyHookConfig}; +use super::types::{FrontmatterHooks, HookConfig, HookEvent, LegacyHookConfig}; use parking_lot::RwLock; use regex::Regex; use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; /// 已注册的 Hooks 存储 @@ -47,25 +48,7 @@ impl HookRegistry { event_hooks .iter() - .filter(|hook| { - if let Some(matcher) = hook.matcher() { - if let Some(name) = tool_name { - // 支持正则匹配 - if matcher.starts_with('/') && matcher.ends_with('/') { - let pattern = matcher - .get(1..matcher.len().saturating_sub(1)) - .unwrap_or(""); - if let Ok(regex) = Regex::new(pattern) { - return regex.is_match(name); - } - } - // 精确匹配 - return matcher == name; - } - return false; - } - true - }) + .filter(|hook| matcher_matches(hook.matcher(), tool_name)) .cloned() .collect() } @@ -150,18 +133,235 @@ impl HookRegistry { } } +fn matcher_matches(matcher: Option<&str>, tool_name: Option<&str>) -> bool { + let Some(matcher) = matcher else { + return true; + }; + + let Some(name) = tool_name else { + return false; + }; + + if matcher.starts_with('/') && matcher.ends_with('/') { + let pattern = matcher + .get(1..matcher.len().saturating_sub(1)) + .unwrap_or(""); + if let Ok(regex) = Regex::new(pattern) { + return regex.is_match(name); + } + } + + matcher == name +} + +#[derive(Debug, Clone)] +pub struct SessionHookEntry { + pub id: u64, + pub config: HookConfig, + pub once: bool, +} + +#[derive(Debug, Default, Clone)] +pub struct SessionHookRegistrationReport { + pub registered: usize, + pub skipped: Vec, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +struct FrontmatterHookRegistrationOptions { + rewrite_stop_to_subagent_stop: bool, +} + +type RegisteredSessionHooks = HashMap>>; + +#[derive(Debug, Default)] +pub struct SessionHookStore { + hooks: RwLock, + next_id: AtomicU64, +} + +impl SessionHookStore { + pub fn new() -> Self { + Self { + hooks: RwLock::new(HashMap::new()), + next_id: AtomicU64::new(1), + } + } + + pub fn register( + &self, + session_id: &str, + event: HookEvent, + config: HookConfig, + once: bool, + ) -> u64 { + let entry_id = self.next_id.fetch_add(1, Ordering::Relaxed); + let mut hooks = self.hooks.write(); + hooks + .entry(session_id.to_string()) + .or_default() + .entry(event) + .or_default() + .push(SessionHookEntry { + id: entry_id, + config, + once, + }); + entry_id + } + + pub fn register_frontmatter_hooks( + &self, + session_id: &str, + hooks: &FrontmatterHooks, + ) -> SessionHookRegistrationReport { + self.register_frontmatter_hooks_with_options( + session_id, + hooks, + FrontmatterHookRegistrationOptions::default(), + ) + } + + pub fn register_agent_frontmatter_hooks( + &self, + session_id: &str, + hooks: &FrontmatterHooks, + ) -> SessionHookRegistrationReport { + self.register_frontmatter_hooks_with_options( + session_id, + hooks, + FrontmatterHookRegistrationOptions { + rewrite_stop_to_subagent_stop: true, + }, + ) + } + + fn register_frontmatter_hooks_with_options( + &self, + session_id: &str, + hooks: &FrontmatterHooks, + options: FrontmatterHookRegistrationOptions, + ) -> SessionHookRegistrationReport { + let mut report = SessionHookRegistrationReport::default(); + + for (event, matchers) in hooks { + let target_event = rewrite_frontmatter_event_for_registration(*event, options); + for matcher in matchers { + for hook in &matcher.hooks { + match hook.to_registration(matcher.matcher.as_deref()) { + Ok(registration) => { + self.register( + session_id, + target_event, + registration.config, + registration.once, + ); + report.registered += 1; + } + Err(error) => { + report.skipped.push(format!("{target_event}: {error}")); + } + } + } + } + } + + report + } + + pub fn get_matching( + &self, + session_id: &str, + event: HookEvent, + tool_name: Option<&str>, + ) -> Vec { + let hooks = self.hooks.read(); + let session_hooks = match hooks.get(session_id) { + Some(items) => items, + None => return vec![], + }; + let event_hooks = match session_hooks.get(&event) { + Some(items) => items, + None => return vec![], + }; + + event_hooks + .iter() + .filter(|entry| matcher_matches(entry.config.matcher(), tool_name)) + .cloned() + .collect() + } + + pub fn unregister_entry(&self, session_id: &str, event: HookEvent, entry_id: u64) -> bool { + let mut hooks = self.hooks.write(); + let Some(session_hooks) = hooks.get_mut(session_id) else { + return false; + }; + let Some(event_hooks) = session_hooks.get_mut(&event) else { + return false; + }; + + let initial_len = event_hooks.len(); + event_hooks.retain(|entry| entry.id != entry_id); + let removed = event_hooks.len() < initial_len; + + if event_hooks.is_empty() { + session_hooks.remove(&event); + } + if session_hooks.is_empty() { + hooks.remove(session_id); + } + + removed + } + + pub fn clear_session(&self, session_id: &str) { + self.hooks.write().remove(session_id); + } + + pub fn clear_all(&self) { + self.hooks.write().clear(); + } + + pub fn count_for_session(&self, session_id: &str) -> usize { + let hooks = self.hooks.read(); + hooks + .get(session_id) + .map(|events| events.values().map(|items| items.len()).sum()) + .unwrap_or(0) + } +} + +fn rewrite_frontmatter_event_for_registration( + event: HookEvent, + options: FrontmatterHookRegistrationOptions, +) -> HookEvent { + if options.rewrite_stop_to_subagent_stop && matches!(event, HookEvent::Stop) { + HookEvent::SubagentStop + } else { + event + } +} + /// 共享的 Hook 注册表 pub type SharedHookRegistry = Arc; +pub type SharedSessionHookStore = Arc; /// 全局注册表 static GLOBAL_REGISTRY: once_cell::sync::Lazy = once_cell::sync::Lazy::new(|| Arc::new(HookRegistry::new())); +static SESSION_HOOK_STORE: once_cell::sync::Lazy = + once_cell::sync::Lazy::new(|| Arc::new(SessionHookStore::new())); /// 获取全局注册表 pub fn global_registry() -> SharedHookRegistry { GLOBAL_REGISTRY.clone() } +pub fn global_session_hook_store() -> SharedSessionHookStore { + SESSION_HOOK_STORE.clone() +} + /// 注册 hook 到全局注册表 pub fn register_hook(event: HookEvent, config: HookConfig) { global_registry().register(event, config); @@ -175,6 +375,41 @@ pub fn register_legacy_hook(config: LegacyHookConfig) { /// 清除全局注册表 pub fn clear_hooks() { global_registry().clear(); + global_session_hook_store().clear_all(); +} + +pub fn register_session_frontmatter_hooks( + session_id: &str, + hooks: &FrontmatterHooks, +) -> SessionHookRegistrationReport { + global_session_hook_store().register_frontmatter_hooks(session_id, hooks) +} + +pub fn register_agent_session_frontmatter_hooks( + session_id: &str, + hooks: &FrontmatterHooks, +) -> SessionHookRegistrationReport { + global_session_hook_store().register_agent_frontmatter_hooks(session_id, hooks) +} + +pub fn get_matching_session_hooks( + session_id: &str, + event: HookEvent, + tool_name: Option<&str>, +) -> Vec { + global_session_hook_store().get_matching(session_id, event, tool_name) +} + +pub fn clear_session_hooks(session_id: &str) { + global_session_hook_store().clear_session(session_id); +} + +pub fn unregister_session_hook_entry(session_id: &str, event: HookEvent, entry_id: u64) -> bool { + global_session_hook_store().unregister_entry(session_id, event, entry_id) +} + +pub fn get_session_hook_count(session_id: &str) -> usize { + global_session_hook_store().count_for_session(session_id) } /// 获取 hook 总数 diff --git a/src-tauri/crates/aster-rust/crates/aster/src/hooks/tests.rs b/src-tauri/crates/aster-rust/crates/aster/src/hooks/tests.rs index c69085a81..f406568c2 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/hooks/tests.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/hooks/tests.rs @@ -133,6 +133,49 @@ fn test_hook_config_serialization() { } } +#[test] +fn test_frontmatter_command_hook_converts_to_current_config() { + let hook: FrontmatterHookCommand = serde_json::from_value(serde_json::json!({ + "type": "command", + "command": "echo session", + "timeout": 5, + "once": true, + "async": true + })) + .expect("frontmatter command hook should deserialize"); + + let registration = hook + .to_registration(Some("Bash")) + .expect("frontmatter command hook should convert"); + + assert!(registration.once); + match registration.config { + HookConfig::Command(config) => { + assert_eq!(config.command, "echo session"); + assert_eq!(config.timeout, 5000); + assert!(!config.blocking); + assert_eq!(config.matcher.as_deref(), Some("Bash")); + } + _ => panic!("Expected Command config"), + } +} + +#[test] +fn test_frontmatter_hook_rejects_if_condition_until_runtime_supports_it() { + let hook: FrontmatterHookCommand = serde_json::from_value(serde_json::json!({ + "type": "prompt", + "prompt": "Verify", + "if": "Bash(git status)" + })) + .expect("frontmatter prompt hook should deserialize"); + + let error = hook + .to_registration(Some("Bash")) + .expect_err("frontmatter hook.if should remain unsupported for now"); + + assert!(error.contains("hook.if")); +} + #[test] fn test_agent_hook_config_supports_current_prompt_fields() { let parsed: HookConfig = serde_json::from_value(serde_json::json!({ @@ -245,6 +288,70 @@ fn test_legacy_hook_conversion() { } } +#[tokio::test] +async fn test_session_frontmatter_once_hook_is_removed_after_success() { + clear_session_hooks("session-once-hook"); + let hooks: FrontmatterHooks = serde_yaml::from_str( + r#" +PreToolUse: + - matcher: Bash + hooks: + - type: command + command: "printf 'ok'" + once: true +"#, + ) + .expect("frontmatter hooks should parse"); + + let report = register_session_frontmatter_hooks("session-once-hook", &hooks); + assert_eq!(report.registered, 1); + assert!(report.skipped.is_empty()); + assert_eq!(get_session_hook_count("session-once-hook"), 1); + + let registry = Arc::new(HookRegistry::new()); + let results = run_hooks_with_registry( + HookInput { + event: Some(HookEvent::PreToolUse), + tool_name: Some("Bash".to_string()), + session_id: Some("session-once-hook".to_string()), + ..Default::default() + }, + ®istry, + ) + .await; + + assert_eq!(results.len(), 1); + assert!(results[0].success); + assert_eq!(get_session_hook_count("session-once-hook"), 0); + clear_session_hooks("session-once-hook"); +} + +#[test] +fn test_agent_frontmatter_hooks_rewrite_stop_to_subagent_stop() { + clear_session_hooks("session-agent-hook"); + let hooks: FrontmatterHooks = serde_yaml::from_str( + r#" +Stop: + - hooks: + - type: prompt + prompt: "summarize stop" +"#, + ) + .expect("frontmatter hooks should parse"); + + let report = register_agent_session_frontmatter_hooks("session-agent-hook", &hooks); + + assert_eq!(report.registered, 1); + assert!(report.skipped.is_empty()); + assert!(get_matching_session_hooks("session-agent-hook", HookEvent::Stop, None).is_empty()); + assert_eq!( + get_matching_session_hooks("session-agent-hook", HookEvent::SubagentStop, None).len(), + 1 + ); + + clear_session_hooks("session-agent-hook"); +} + #[tokio::test] async fn test_run_user_prompt_submit_hooks_with_registry_blocks_project_hook() { let temp_dir = tempfile::TempDir::new().expect("create temp dir"); diff --git a/src-tauri/crates/aster-rust/crates/aster/src/hooks/types.rs b/src-tauri/crates/aster-rust/crates/aster/src/hooks/types.rs index 21fa93d38..a8d2db4c7 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/hooks/types.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/hooks/types.rs @@ -258,6 +258,229 @@ impl HookConfig { } } +/// Skill / agent frontmatter hooks 配置 +/// +/// 对齐参考运行时的 `event -> matcher[] -> hooks[]` 结构, +/// 但最终仍会收口到 Lime 当前的 `HookConfig` 执行边界。 +pub type FrontmatterHooks = HashMap>; + +/// frontmatter hook matcher +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct FrontmatterHookMatcher { + /// 可选的外层 matcher + #[serde(default)] + pub matcher: Option, + /// 命中的 hook 列表 + #[serde(default)] + pub hooks: Vec, +} + +/// frontmatter hook 注册结果 +#[derive(Debug, Clone)] +pub struct FrontmatterHookRegistration { + pub config: HookConfig, + pub once: bool, +} + +/// frontmatter command hook 配置 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FrontmatterCommandHookConfig { + pub command: String, + #[serde(default)] + pub timeout: Option, + #[serde(default)] + pub once: bool, + #[serde(default)] + pub shell: Option, + #[serde(default, rename = "if")] + pub if_condition: Option, + #[serde(default, rename = "statusMessage")] + pub status_message: Option, + #[serde(default, rename = "async")] + pub async_mode: bool, + #[serde(default, rename = "asyncRewake")] + pub async_rewake: bool, +} + +/// frontmatter prompt hook 配置 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FrontmatterPromptHookConfig { + pub prompt: String, + #[serde(default)] + pub timeout: Option, + #[serde(default)] + pub model: Option, + #[serde(default)] + pub once: bool, + #[serde(default, rename = "if")] + pub if_condition: Option, + #[serde(default, rename = "statusMessage")] + pub status_message: Option, +} + +/// frontmatter agent hook 配置 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FrontmatterAgentHookConfig { + pub prompt: String, + #[serde(default)] + pub timeout: Option, + #[serde(default)] + pub model: Option, + #[serde(default)] + pub once: bool, + #[serde(default, rename = "if")] + pub if_condition: Option, + #[serde(default, rename = "statusMessage")] + pub status_message: Option, +} + +/// frontmatter http hook 配置 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FrontmatterHttpHookConfig { + pub url: String, + #[serde(default)] + pub timeout: Option, + #[serde(default)] + pub headers: HashMap, + #[serde(default)] + pub once: bool, + #[serde(default, rename = "if")] + pub if_condition: Option, + #[serde(default, rename = "statusMessage")] + pub status_message: Option, + #[serde(default, rename = "allowedEnvVars")] + pub allowed_env_vars: Option>, +} + +/// frontmatter hook 命令 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum FrontmatterHookCommand { + Command(FrontmatterCommandHookConfig), + Prompt(FrontmatterPromptHookConfig), + Agent(FrontmatterAgentHookConfig), + #[serde(alias = "url")] + Http(FrontmatterHttpHookConfig), +} + +impl FrontmatterHookCommand { + pub fn to_registration( + &self, + matcher: Option<&str>, + ) -> Result { + match self { + FrontmatterHookCommand::Command(config) => { + if config.async_rewake { + return Err( + "暂不支持 skill frontmatter command hook.asyncRewake;当前 runtime 还没有对应唤醒语义" + .to_string(), + ); + } + + if let Some(shell) = normalize_optional_text(config.shell.as_deref()) { + if !shell.eq_ignore_ascii_case("bash") { + return Err(format!( + "暂不支持 skill frontmatter command hook.shell='{}';当前 runtime 只支持默认 shell 执行", + shell + )); + } + } + + let resolved_matcher = + resolve_frontmatter_matcher(matcher, config.if_condition.as_deref())?; + + Ok(FrontmatterHookRegistration { + config: HookConfig::Command(CommandHookConfig { + command: config.command.clone(), + args: Vec::new(), + env: HashMap::new(), + timeout: timeout_secs_to_millis(config.timeout, default_timeout()), + blocking: !config.async_mode, + matcher: resolved_matcher, + }), + once: config.once, + }) + } + FrontmatterHookCommand::Prompt(config) => Ok(FrontmatterHookRegistration { + config: HookConfig::Prompt(PromptHookConfig { + prompt: config.prompt.clone(), + model: normalize_optional_text(config.model.as_deref()), + timeout: timeout_secs_to_millis(config.timeout, default_timeout()), + blocking: true, + matcher: resolve_frontmatter_matcher(matcher, config.if_condition.as_deref())?, + }), + once: config.once, + }), + FrontmatterHookCommand::Agent(config) => Ok(FrontmatterHookRegistration { + config: HookConfig::Agent(AgentHookConfig { + agent_type: default_agent_type(), + prompt: Some(config.prompt.clone()), + model: normalize_optional_text(config.model.as_deref()), + agent_config: None, + timeout: timeout_secs_to_millis(config.timeout, default_agent_timeout()), + blocking: true, + matcher: resolve_frontmatter_matcher(matcher, config.if_condition.as_deref())?, + }), + once: config.once, + }), + FrontmatterHookCommand::Http(config) => { + if config + .allowed_env_vars + .as_ref() + .is_some_and(|items| !items.is_empty()) + { + return Err( + "暂不支持 skill frontmatter http hook.allowedEnvVars;当前 runtime 还没有 header env 白名单插值语义" + .to_string(), + ); + } + + Ok(FrontmatterHookRegistration { + config: HookConfig::Url(UrlHookConfig { + url: config.url.clone(), + method: HttpMethod::Post, + headers: config.headers.clone(), + timeout: timeout_secs_to_millis(config.timeout, default_url_timeout()), + blocking: true, + matcher: resolve_frontmatter_matcher( + matcher, + config.if_condition.as_deref(), + )?, + }), + once: config.once, + }) + } + } + } +} + +fn normalize_optional_text(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) +} + +fn resolve_frontmatter_matcher( + matcher: Option<&str>, + if_condition: Option<&str>, +) -> Result, String> { + if let Some(condition) = normalize_optional_text(if_condition) { + return Err(format!( + "暂不支持 skill frontmatter hook.if='{}';当前 runtime 还没有 permission-rule 过滤器", + condition + )); + } + + Ok(normalize_optional_text(matcher)) +} + +fn timeout_secs_to_millis(timeout_secs: Option, default_millis: u64) -> u64 { + timeout_secs + .map(|seconds| seconds.saturating_mul(1000)) + .unwrap_or(default_millis) +} + /// 错误类型 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -338,9 +561,18 @@ pub struct HookInput { /// 消息 #[serde(default)] pub message: Option, + /// Stop hook 是否已在上层激活 + #[serde(default)] + pub stop_hook_active: Option, + /// Stop 前最后一条 assistant 文本 + #[serde(default)] + pub last_assistant_message: Option, /// 会话 ID #[serde(default)] pub session_id: Option, + /// 权限模式 + #[serde(default)] + pub permission_mode: Option, /// 工具使用 ID #[serde(default)] pub tool_use_id: Option, diff --git a/src-tauri/crates/aster-rust/crates/aster/src/lib.rs b/src-tauri/crates/aster-rust/crates/aster/src/lib.rs index bb050f74b..6325229c0 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/lib.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/lib.rs @@ -14,6 +14,7 @@ pub mod blueprint; pub mod checkpoint; pub mod chrome; pub mod chrome_mcp; +pub mod claude_plugin_cache; pub mod codesign; pub mod config; pub mod context; diff --git a/src-tauri/crates/aster-rust/crates/aster/src/permission/permission_inspector.rs b/src-tauri/crates/aster-rust/crates/aster/src/permission/permission_inspector.rs index 4c1f742ba..31257381c 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/permission/permission_inspector.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/permission/permission_inspector.rs @@ -103,6 +103,10 @@ impl PermissionInspector { *mode = new_mode; } + pub async fn current_mode(&self) -> AsterMode { + *self.mode.lock().await + } + /// Create a permission context for the current request fn create_permission_context(&self, tool_name: &str) -> PermissionContext { PermissionContext { diff --git a/src-tauri/crates/aster-rust/crates/aster/src/skills/executor.rs b/src-tauri/crates/aster-rust/crates/aster/src/skills/executor.rs index 8a0542ee2..2a178b466 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/skills/executor.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/skills/executor.rs @@ -1104,6 +1104,7 @@ mod tests { execution_mode: mode, provider: None, workflow: None, + hooks: None, } } diff --git a/src-tauri/crates/aster-rust/crates/aster/src/skills/loader.rs b/src-tauri/crates/aster-rust/crates/aster/src/skills/loader.rs index da9d4777b..de23ec101 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/skills/loader.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/skills/loader.rs @@ -3,9 +3,17 @@ //! Handles parsing and loading skills from SKILL.md files. use super::types::{SkillDefinition, SkillExecutionMode, SkillFrontmatter, SkillSource}; -use std::collections::HashMap; +use crate::claude_plugin_cache::{ + load_cached_plugin_manifest_json, resolve_claude_manifest_relative_path, + resolve_claude_plugin_cache_entries, resolve_enabled_claude_plugin_ids, + ClaudeManifestRelativePathKind, +}; +use serde::Serialize; +use std::collections::{HashMap, HashSet}; use std::fs; +use std::hash::{Hash, Hasher}; use std::path::{Path, PathBuf}; +use std::time::UNIX_EPOCH; /// Parse frontmatter from skill content /// @@ -191,6 +199,7 @@ pub fn load_skill_from_file( execution_mode, provider: frontmatter.provider, workflow: frontmatter.workflow, + hooks: frontmatter.hooks, }) } @@ -201,178 +210,284 @@ pub fn load_skill_from_file( pub fn load_skills_from_directory(dir_path: &Path, source: SkillSource) -> Vec { let mut results = Vec::new(); - if !dir_path.exists() { - return results; - } - - // 1. Check for SKILL.md in root directory (single skill mode) - let root_skill_file = dir_path.join("SKILL.md"); - if root_skill_file.exists() { + for skill_file in discover_skill_markdown_files(dir_path) { let skill_name = format!( "{}:{}", source, - dir_path - .file_name() + skill_file + .parent() + .and_then(|path| path.file_name()) .and_then(|n| n.to_str()) .unwrap_or("unknown") ); - if let Ok(skill) = load_skill_from_file(&skill_name, &root_skill_file, source) { + if let Ok(skill) = load_skill_from_file(&skill_name, &skill_file, source) { results.push(skill); } - return results; - } - - // 2. Scan subdirectories for SKILL.md files - if let Ok(entries) = fs::read_dir(dir_path) { - for entry in entries.flatten() { - let path = entry.path(); - if !path.is_dir() { - continue; - } - - let skill_file = path.join("SKILL.md"); - if skill_file.exists() { - let skill_name = format!( - "{}:{}", - source, - path.file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unknown") - ); - - if let Ok(skill) = load_skill_from_file(&skill_name, &skill_file, source) { - results.push(skill); - } - } - } } results } -/// Get enabled plugins from settings -pub fn get_enabled_plugins() -> std::collections::HashSet { - let mut enabled = std::collections::HashSet::new(); +#[derive(Debug, Serialize)] +struct PluginSkillRegistrySnapshot { + skipped: Vec, + plugins: Vec, +} - if let Some(home) = dirs::home_dir() { - let settings_path = home.join(".claude/settings.json"); - if let Ok(content) = fs::read_to_string(&settings_path) { - if let Ok(settings) = serde_json::from_str::(&content) { - if let Some(plugins) = settings.get("enabledPlugins").and_then(|v| v.as_object()) { - for (plugin_id, is_enabled) in plugins { - if is_enabled.as_bool().unwrap_or(false) { - enabled.insert(plugin_id.clone()); - } - } - } - } - } +#[derive(Debug, Serialize)] +struct PluginSkillRegistrySnapshotPlugin { + plugin_id: String, + plugin_name: String, + marketplace: String, + root: String, + skills: Vec, +} + +#[derive(Debug, Serialize)] +struct PluginSkillRegistrySnapshotSkill { + path: String, + len: u64, + modified_unix_nanos: Option, + content_hash: Option, +} + +#[derive(Debug, Clone)] +enum PluginSkillDirectorySpec { + AutoDetectDefault, + Explicit(Vec), +} + +fn discover_skill_markdown_files(dir_path: &Path) -> Vec { + if !dir_path.exists() { + return Vec::new(); } - enabled + let root_skill_file = dir_path.join("SKILL.md"); + if root_skill_file.exists() { + return vec![root_skill_file]; + } + + let mut skill_files = match fs::read_dir(dir_path) { + Ok(entries) => entries + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.is_dir()) + .map(|path| path.join("SKILL.md")) + .filter(|path| path.exists()) + .collect::>(), + Err(_) => Vec::new(), + }; + skill_files.sort(); + skill_files +} + +fn resolve_plugin_skill_directory_spec( + plugin_root: &Path, +) -> Result { + let Some((_manifest_path, manifest)) = load_cached_plugin_manifest_json(plugin_root)? else { + return Ok(PluginSkillDirectorySpec::AutoDetectDefault); + }; + let Some(skills_value) = manifest.get("skills") else { + return Ok(PluginSkillDirectorySpec::AutoDetectDefault); + }; + + Ok(PluginSkillDirectorySpec::Explicit( + resolve_explicit_plugin_skill_directories(plugin_root, skills_value)?, + )) +} + +fn resolve_explicit_plugin_skill_directories( + plugin_root: &Path, + skills_value: &serde_json::Value, +) -> Result, String> { + match skills_value { + serde_json::Value::String(relative_path) => Ok(vec![resolve_plugin_relative_directory( + plugin_root, + relative_path, + )?]), + serde_json::Value::Array(paths) => paths + .iter() + .map(|value| { + let Some(relative_path) = value.as_str() else { + return Err("manifest.skills 只能是 string 或 string[]".to_string()); + }; + resolve_plugin_relative_directory(plugin_root, relative_path) + }) + .collect(), + _ => Err("manifest.skills 只能是 string 或 string[]".to_string()), + } +} + +fn resolve_plugin_relative_directory( + plugin_root: &Path, + relative_path: &str, +) -> Result { + resolve_claude_manifest_relative_path( + plugin_root, + relative_path, + ClaudeManifestRelativePathKind::Any, + ) + .map_err(|error| format!("manifest.skills 路径无效({}):{}", relative_path, error)) +} + +fn collect_plugin_skill_markdown_files_with_report( + plugin_root: &Path, +) -> (Vec, Vec) { + let mut skipped = Vec::new(); + let skill_roots = match resolve_plugin_skill_directory_spec(plugin_root) { + Ok(PluginSkillDirectorySpec::AutoDetectDefault) => { + let default_skills_path = plugin_root.join("skills"); + default_skills_path + .exists() + .then_some(vec![default_skills_path]) + .unwrap_or_default() + } + Ok(PluginSkillDirectorySpec::Explicit(paths)) => paths + .into_iter() + .filter_map(|path| { + if path.exists() { + Some(path) + } else { + skipped.push(format!("manifest.skills 路径不存在 ({})", path.display())); + None + } + }) + .collect(), + Err(error) => return (Vec::new(), vec![error]), + }; + + let mut seen = HashSet::::new(); + let mut skill_files = skill_roots + .into_iter() + .flat_map(|path| discover_skill_markdown_files(&path)) + .filter(|path| { + let normalized = path.canonicalize().unwrap_or_else(|_| path.clone()); + seen.insert(normalized) + }) + .collect::>(); + skill_files.sort(); + (skill_files, skipped) +} + +fn hash_plugin_skill_content(path: &Path) -> Option { + let content = fs::read(path).ok()?; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + content.hash(&mut hasher); + Some(hasher.finish()) +} + +fn plugin_skill_modified_unix_nanos(metadata: &fs::Metadata) -> Option { + metadata + .modified() + .ok()? + .duration_since(UNIX_EPOCH) + .ok() + .map(|duration| duration.as_nanos()) +} + +pub fn build_plugin_skill_registry_snapshot_with_context( + workspace_root: Option<&Path>, + home_dir: Option<&Path>, +) -> String { + let resolution = resolve_claude_plugin_cache_entries(workspace_root, home_dir); + let mut skipped = resolution.skipped; + let mut plugins = Vec::new(); + + for plugin in resolution.plugins { + let (skill_files, plugin_skipped) = + collect_plugin_skill_markdown_files_with_report(&plugin.root); + skipped.extend( + plugin_skipped + .into_iter() + .map(|reason| format!("{}: {}", plugin.plugin_id, reason)), + ); + plugins.push(PluginSkillRegistrySnapshotPlugin { + plugin_id: plugin.plugin_id, + plugin_name: plugin.plugin_name, + marketplace: plugin.marketplace, + root: plugin.root.display().to_string(), + skills: skill_files + .into_iter() + .map(|path| { + let metadata = fs::metadata(&path).ok(); + PluginSkillRegistrySnapshotSkill { + path: path.display().to_string(), + len: metadata.as_ref().map(|value| value.len()).unwrap_or(0), + modified_unix_nanos: metadata + .as_ref() + .and_then(plugin_skill_modified_unix_nanos), + content_hash: hash_plugin_skill_content(&path), + } + }) + .collect(), + }); + } + + let snapshot = PluginSkillRegistrySnapshot { skipped, plugins }; + + serde_json::to_string(&snapshot).unwrap_or_else(|_| "{\"plugins\":[],\"skipped\":[]}".into()) +} + +/// Get enabled plugins from settings +pub fn get_enabled_plugins() -> std::collections::HashSet { + let workspace_root = std::env::current_dir().ok(); + let home_dir = dirs::home_dir(); + resolve_enabled_claude_plugin_ids(workspace_root.as_deref(), home_dir.as_deref()) + .plugin_ids + .into_iter() + .collect() } /// Load skills from plugin cache /// pub fn load_skills_from_plugin_cache() -> Vec { + let workspace_root = std::env::current_dir().ok(); + let home_dir = dirs::home_dir(); + load_skills_from_plugin_cache_with_context(workspace_root.as_deref(), home_dir.as_deref()) +} + +pub fn load_skills_from_plugin_cache_with_context( + workspace_root: Option<&Path>, + home_dir: Option<&Path>, +) -> Vec { let mut results = Vec::new(); + let resolution = resolve_claude_plugin_cache_entries(workspace_root, home_dir); + let mut skipped = resolution.skipped; + let plugins = resolution.plugins; - let home = match dirs::home_dir() { - Some(h) => h, - None => return results, - }; + for plugin in plugins { + let (skill_files, plugin_skipped) = + collect_plugin_skill_markdown_files_with_report(&plugin.root); + skipped.extend( + plugin_skipped + .into_iter() + .map(|reason| format!("{}: {}", plugin.plugin_id, reason)), + ); - let plugins_cache_dir = home.join(".claude/plugins/cache"); - if !plugins_cache_dir.exists() { - return results; - } - - let enabled_plugins = get_enabled_plugins(); - - // Traverse marketplace directories - let marketplaces = match fs::read_dir(&plugins_cache_dir) { - Ok(entries) => entries, - Err(_) => return results, - }; - - for marketplace_entry in marketplaces.flatten() { - if !marketplace_entry.path().is_dir() { - continue; - } - - let marketplace_name = marketplace_entry.file_name(); - let marketplace_path = marketplace_entry.path(); - - let plugins = match fs::read_dir(&marketplace_path) { - Ok(entries) => entries, - Err(_) => continue, - }; - - for plugin_entry in plugins.flatten() { - if !plugin_entry.path().is_dir() { - continue; - } - - let plugin_name = plugin_entry.file_name(); - let plugin_id = format!( - "{}@{}", - plugin_name.to_string_lossy(), - marketplace_name.to_string_lossy() + for skill_md_path in skill_files { + let skill_name = format!( + "{}:{}", + plugin.plugin_name, + skill_md_path + .parent() + .and_then(|path| path.file_name()) + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| "unknown".to_string()) ); - // Check if plugin is enabled - if !enabled_plugins.contains(&plugin_id) { - continue; - } - - let plugin_path = plugin_entry.path(); - let versions = match fs::read_dir(&plugin_path) { - Ok(entries) => entries, - Err(_) => continue, - }; - - for version_entry in versions.flatten() { - if !version_entry.path().is_dir() { - continue; - } - - let skills_path = version_entry.path().join("skills"); - if !skills_path.exists() { - continue; - } - - let skill_dirs = match fs::read_dir(&skills_path) { - Ok(entries) => entries, - Err(_) => continue, - }; - - for skill_dir_entry in skill_dirs.flatten() { - if !skill_dir_entry.path().is_dir() { - continue; - } - - let skill_md_path = skill_dir_entry.path().join("SKILL.md"); - if !skill_md_path.exists() { - continue; - } - - let skill_name = format!( - "{}:{}", - plugin_name.to_string_lossy(), - skill_dir_entry.file_name().to_string_lossy() - ); - - if let Ok(skill) = - load_skill_from_file(&skill_name, &skill_md_path, SkillSource::Plugin) - { - results.push(skill); - } - } + if let Ok(skill) = + load_skill_from_file(&skill_name, &skill_md_path, SkillSource::Plugin) + { + results.push(skill); } } } + if !skipped.is_empty() { + tracing::warn!("[Aster] plugin skill 部分跳过: {}", skipped.join(" | ")); + } + results } @@ -634,6 +749,36 @@ Content. assert_eq!(fm.model, Some("claude-3-opus".to_string())); } + #[test] + fn test_parse_frontmatter_with_skill_hooks() { + let content = r#"--- +name: hook-skill +description: Skill with hooks +hooks: + PreToolUse: + - matcher: Bash + hooks: + - type: command + command: echo "before bash" + timeout: 5 + once: true +--- + +# Hook Skill + +Content. +"#; + + let (fm, _) = parse_frontmatter(content); + let hooks = fm.hooks.expect("skill hooks should parse"); + let matchers = hooks + .get(&crate::hooks::HookEvent::PreToolUse) + .expect("PreToolUse hooks should exist"); + assert_eq!(matchers.len(), 1); + assert_eq!(matchers[0].matcher.as_deref(), Some("Bash")); + assert_eq!(matchers[0].hooks.len(), 1); + } + #[test] fn test_parse_frontmatter_execution_mode_default() { // 不指定 execution-mode 时应为 None @@ -730,6 +875,7 @@ Instructions here. assert_eq!(skill.skill_name, "user:workflow-skill"); assert_eq!(skill.execution_mode, SkillExecutionMode::Workflow); assert_eq!(skill.provider, Some("gemini".to_string())); + assert!(skill.hooks.is_none()); } #[test] @@ -816,6 +962,312 @@ Content. assert!(skill.workflow.is_none()); } + fn write_enabled_plugin_settings( + root: &Path, + file_name: &str, + enabled_plugins: serde_json::Value, + ) { + let claude_dir = root.join(".claude"); + fs::create_dir_all(&claude_dir).expect("创建 .claude 目录失败"); + fs::write( + claude_dir.join(file_name), + serde_json::to_string_pretty(&serde_json::json!({ + "enabledPlugins": enabled_plugins + })) + .expect("序列化 enabledPlugins 失败"), + ) + .expect("写入 enabledPlugins 配置失败"); + } + + fn write_cached_plugin_skill( + home_root: &Path, + plugin_name: &str, + marketplace: &str, + version: &str, + skill_dir_name: &str, + skill_body: &str, + ) { + let skill_root = home_root + .join(".claude") + .join("plugins") + .join("cache") + .join(marketplace) + .join(plugin_name) + .join(version) + .join("skills") + .join(skill_dir_name); + write_cached_plugin_skill_at_root(&skill_root, skill_body); + } + + fn write_cached_plugin_skill_at_root(skill_root: &Path, skill_body: &str) { + fs::create_dir_all(&skill_root).expect("创建 plugin skill 目录失败"); + fs::write(skill_root.join("SKILL.md"), skill_body).expect("写入 plugin SKILL.md 失败"); + } + + fn write_cached_plugin_manifest_at_root(manifest_path: &Path, manifest: serde_json::Value) { + if let Some(parent) = manifest_path.parent() { + fs::create_dir_all(parent).expect("创建 plugin manifest 目录失败"); + } + fs::write( + manifest_path, + serde_json::to_string_pretty(&manifest).expect("序列化 plugin manifest 失败"), + ) + .expect("写入 plugin manifest 失败"); + } + + #[test] + fn test_load_skills_from_plugin_cache_uses_workspace_settings_local_and_latest_version() { + let temp_home = TempDir::new().expect("create temp home"); + let temp_workspace = TempDir::new().expect("create temp workspace"); + let plugin_id = "capsule@demo-market"; + + write_enabled_plugin_settings( + temp_home.path(), + "settings.json", + serde_json::json!({ + plugin_id: false + }), + ); + write_enabled_plugin_settings( + temp_workspace.path(), + "settings.local.json", + serde_json::json!({ + plugin_id: true + }), + ); + write_cached_plugin_skill( + temp_home.path(), + "capsule", + "demo-market", + "1.0.0", + "writer", + r#"--- +name: writer +description: old plugin skill +--- +old +"#, + ); + write_cached_plugin_skill( + temp_home.path(), + "capsule", + "demo-market", + "2.0.0", + "writer", + r#"--- +name: writer +description: latest plugin skill +--- +latest +"#, + ); + + let skills = load_skills_from_plugin_cache_with_context( + Some(temp_workspace.path()), + Some(temp_home.path()), + ); + + assert_eq!(skills.len(), 1); + assert_eq!(skills[0].skill_name, "capsule:writer"); + assert_eq!(skills[0].description, "latest plugin skill"); + assert!(skills[0] + .file_path + .to_string_lossy() + .contains("/2.0.0/skills/writer/SKILL.md")); + } + + #[test] + fn test_load_skills_from_plugin_cache_uses_sanitized_plugin_cache_paths() { + let temp_home = TempDir::new().expect("create temp home"); + let temp_workspace = TempDir::new().expect("create temp workspace"); + let plugin_id = "@scope/capsule@demo.market"; + + write_enabled_plugin_settings( + temp_workspace.path(), + "settings.local.json", + serde_json::json!({ + plugin_id: true + }), + ); + write_cached_plugin_skill_at_root( + &temp_home + .path() + .join(".claude") + .join("plugins") + .join("cache") + .join("demo-market") + .join("-scope-capsule") + .join("2.0.0") + .join("skills") + .join("writer"), + r#"--- +name: writer +description: sanitized plugin skill +--- +sanitized +"#, + ); + + let skills = load_skills_from_plugin_cache_with_context( + Some(temp_workspace.path()), + Some(temp_home.path()), + ); + + assert_eq!(skills.len(), 1); + assert_eq!(skills[0].skill_name, "@scope/capsule:writer"); + assert_eq!(skills[0].description, "sanitized plugin skill"); + assert!(skills[0] + .file_path + .to_string_lossy() + .contains("/demo-market/-scope-capsule/2.0.0/skills/writer/SKILL.md")); + } + + #[test] + fn test_load_skills_from_plugin_cache_prefers_manifest_skills_paths_and_legacy_manifest() { + let temp_home = TempDir::new().expect("create temp home"); + let temp_workspace = TempDir::new().expect("create temp workspace"); + let plugin_id = "capsule@demo-market"; + let plugin_root = temp_home + .path() + .join(".claude") + .join("plugins") + .join("cache") + .join("demo-market") + .join("capsule") + .join("2.0.0"); + + write_enabled_plugin_settings( + temp_workspace.path(), + "settings.local.json", + serde_json::json!({ + plugin_id: true + }), + ); + write_cached_plugin_manifest_at_root( + &plugin_root.join("plugin.json"), + serde_json::json!({ + "name": "capsule", + "version": "2.0.0", + "skills": "./extra-skill" + }), + ); + write_cached_plugin_skill( + temp_home.path(), + "capsule", + "demo-market", + "2.0.0", + "default-writer", + r#"--- +name: default-writer +description: default plugin skill +--- +default +"#, + ); + write_cached_plugin_skill_at_root( + &plugin_root.join("extra-skill"), + r#"--- +name: extra-skill +description: manifest plugin skill +--- +manifest +"#, + ); + + let skills = load_skills_from_plugin_cache_with_context( + Some(temp_workspace.path()), + Some(temp_home.path()), + ); + + assert_eq!(skills.len(), 1); + assert_eq!(skills[0].skill_name, "capsule:extra-skill"); + assert_eq!(skills[0].description, "manifest plugin skill"); + assert!(skills[0] + .file_path + .to_string_lossy() + .contains("/demo-market/capsule/2.0.0/extra-skill/SKILL.md")); + } + + #[test] + fn test_load_skills_from_plugin_cache_requires_dot_slash_manifest_skill_paths() { + let temp_home = TempDir::new().expect("create temp home"); + let temp_workspace = TempDir::new().expect("create temp workspace"); + let plugin_id = "capsule@demo-market"; + let plugin_root = temp_home + .path() + .join(".claude") + .join("plugins") + .join("cache") + .join("demo-market") + .join("capsule") + .join("2.0.0"); + + write_enabled_plugin_settings( + temp_workspace.path(), + "settings.local.json", + serde_json::json!({ + plugin_id: true + }), + ); + write_cached_plugin_manifest_at_root( + &plugin_root.join("plugin.json"), + serde_json::json!({ + "name": "capsule", + "version": "2.0.0", + "skills": "extra-skill" + }), + ); + write_cached_plugin_skill( + temp_home.path(), + "capsule", + "demo-market", + "2.0.0", + "default-writer", + r#"--- +name: default-writer +description: default plugin skill +--- +default +"#, + ); + write_cached_plugin_skill_at_root( + &plugin_root.join("extra-skill"), + r#"--- +name: extra-skill +description: manifest plugin skill +--- +manifest +"#, + ); + + let snapshot = build_plugin_skill_registry_snapshot_with_context( + Some(temp_workspace.path()), + Some(temp_home.path()), + ); + let snapshot_json: serde_json::Value = + serde_json::from_str(&snapshot).expect("snapshot 应为合法 JSON"); + let skipped = snapshot_json + .get("skipped") + .and_then(serde_json::Value::as_array) + .expect("snapshot.skipped 应存在"); + assert!( + skipped.iter().any(|item| item + .as_str() + .map(|text| text.contains("manifest.skills 路径无效(extra-skill)")) + .unwrap_or(false)), + "应显式报告缺少 ./ 前缀的 manifest.skills 路径" + ); + + let skills = load_skills_from_plugin_cache_with_context( + Some(temp_workspace.path()), + Some(temp_home.path()), + ); + assert!( + skills.is_empty(), + "manifest.skills 不符合 Claude 当前路径语义时,不应回退加载默认 skills/" + ); + } + // ==================== Workflow YAML 解析综合测试 ==================== // Task 2.2: 添加 YAML workflow 解析逻辑 // Requirements: 3.1, 3.2, 3.3, 3.4 diff --git a/src-tauri/crates/aster-rust/crates/aster/src/skills/registry.rs b/src-tauri/crates/aster-rust/crates/aster/src/skills/registry.rs index 0a5b344e7..aea995f25 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/skills/registry.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/skills/registry.rs @@ -2,7 +2,10 @@ //! //! Manages skill discovery, registration, and lookup. -use super::loader::{load_skills_from_directory, load_skills_from_plugin_cache}; +use super::loader::{ + build_plugin_skill_registry_snapshot_with_context, load_skills_from_directory, + load_skills_from_plugin_cache_with_context, +}; use super::types::{InvokedSkill, SkillDefinition, SkillSource}; use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -16,6 +19,8 @@ pub struct SkillRegistry { skills: HashMap, /// Invoked skills history invoked: HashMap, + /// Plugin skills snapshot for on-demand refresh + plugin_snapshot: Option, /// Whether skills have been loaded loaded: bool, } @@ -120,26 +125,61 @@ impl SkillRegistry { pub fn clear(&mut self) { self.skills.clear(); self.invoked.clear(); + self.plugin_snapshot = None; self.loaded = false; } /// Get default skill directories pub fn get_default_directories() -> Vec<(PathBuf, SkillSource)> { + let workspace_root = std::env::current_dir().ok(); + let home_dir = dirs::home_dir(); + Self::get_default_directories_with_context(workspace_root.as_deref(), home_dir.as_deref()) + } + + /// Get default skill directories with explicit context + pub fn get_default_directories_with_context( + workspace_root: Option<&Path>, + home_dir: Option<&Path>, + ) -> Vec<(PathBuf, SkillSource)> { let mut dirs = Vec::new(); // User-level directories - if let Some(home) = dirs::home_dir() { + if let Some(home) = home_dir { dirs.push((home.join(".claude/skills"), SkillSource::User)); } // Project-level directories - if let Ok(cwd) = std::env::current_dir() { - dirs.push((cwd.join(".claude/skills"), SkillSource::Project)); + if let Some(workspace_root) = workspace_root { + dirs.push((workspace_root.join(".claude/skills"), SkillSource::Project)); } dirs } + fn load_all_skills_with_context( + &mut self, + workspace_root: Option<&Path>, + home_dir: Option<&Path>, + plugin_snapshot: Option, + ) { + self.skills.clear(); + self.plugin_snapshot = Some(plugin_snapshot.unwrap_or_else(|| { + build_plugin_skill_registry_snapshot_with_context(workspace_root, home_dir) + })); + + for skill in load_skills_from_plugin_cache_with_context(workspace_root, home_dir) { + self.skills.insert(skill.skill_name.clone(), skill); + } + + for (dir, source) in Self::get_default_directories_with_context(workspace_root, home_dir) { + for skill in load_skills_from_directory(&dir, source) { + self.skills.insert(skill.skill_name.clone(), skill); + } + } + + self.loaded = true; + } + /// Initialize and load all skills /// /// Loading order (later overrides earlier): @@ -147,31 +187,63 @@ impl SkillRegistry { /// 2. User skills (~/.claude/skills/) /// 3. Project skills (.claude/skills/) (highest priority) pub fn initialize(&mut self) { + let workspace_root = std::env::current_dir().ok(); + let home_dir = dirs::home_dir(); + self.initialize_with_context(workspace_root.as_deref(), home_dir.as_deref()); + } + + pub fn initialize_with_context( + &mut self, + workspace_root: Option<&Path>, + home_dir: Option<&Path>, + ) { if self.loaded { return; } - - self.skills.clear(); - - // 1. Load plugin skills (lowest priority) - for skill in load_skills_from_plugin_cache() { - self.skills.insert(skill.skill_name.clone(), skill); - } - - // 2. Load from default directories - for (dir, source) in Self::get_default_directories() { - for skill in load_skills_from_directory(&dir, source) { - self.skills.insert(skill.skill_name.clone(), skill); - } - } - - self.loaded = true; + self.load_all_skills_with_context(workspace_root, home_dir, None); } /// Reload skills (clear and reinitialize) pub fn reload(&mut self) { + let workspace_root = std::env::current_dir().ok(); + let home_dir = dirs::home_dir(); + self.reload_with_context(workspace_root.as_deref(), home_dir.as_deref()); + } + + pub fn reload_with_context(&mut self, workspace_root: Option<&Path>, home_dir: Option<&Path>) { self.loaded = false; - self.initialize(); + self.load_all_skills_with_context(workspace_root, home_dir, None); + } + + pub fn refresh_plugin_skills_if_needed(&mut self) -> bool { + let workspace_root = std::env::current_dir().ok(); + let home_dir = dirs::home_dir(); + self.refresh_plugin_skills_if_needed_with_context( + workspace_root.as_deref(), + home_dir.as_deref(), + ) + } + + pub fn refresh_plugin_skills_if_needed_with_context( + &mut self, + workspace_root: Option<&Path>, + home_dir: Option<&Path>, + ) -> bool { + let next_snapshot = + build_plugin_skill_registry_snapshot_with_context(workspace_root, home_dir); + + if !self.loaded { + self.load_all_skills_with_context(workspace_root, home_dir, Some(next_snapshot)); + return true; + } + + if self.plugin_snapshot.as_deref() == Some(next_snapshot.as_str()) { + return false; + } + + self.loaded = false; + self.load_all_skills_with_context(workspace_root, home_dir, Some(next_snapshot)); + true } /// Generate instructions for available skills @@ -202,6 +274,11 @@ pub fn new_shared_registry() -> SharedSkillRegistry { Arc::new(RwLock::new(SkillRegistry::new())) } +pub fn refresh_shared_registry_if_needed(registry: &SharedSkillRegistry) -> Result { + let mut guard = registry.write().map_err(|error| error.to_string())?; + Ok(guard.refresh_plugin_skills_if_needed()) +} + /// Global skill registry instance static GLOBAL_REGISTRY: std::sync::OnceLock = std::sync::OnceLock::new(); @@ -220,6 +297,7 @@ pub fn global_registry() -> &'static SharedSkillRegistry { mod tests { use super::*; use crate::skills::types::SkillExecutionMode; + use std::collections::BTreeSet; #[allow(unused_imports)] use std::fs; #[allow(unused_imports)] @@ -246,9 +324,54 @@ mod tests { execution_mode: SkillExecutionMode::default(), provider: None, workflow: None, + hooks: None, } } + fn create_plugin_skill_fixture( + home_dir: &Path, + plugin_id: &str, + skill_name: &str, + skill_content: &str, + ) -> PathBuf { + let (plugin_name, marketplace) = plugin_id + .rsplit_once('@') + .expect("plugin fixture must use plugin@marketplace format"); + let plugin_root = home_dir + .join(".claude/plugins/cache") + .join(marketplace) + .join(plugin_name) + .join("1.0.0"); + let skill_dir = plugin_root.join("skills").join(skill_name); + fs::create_dir_all(&skill_dir).unwrap(); + let skill_path = skill_dir.join("SKILL.md"); + fs::write(&skill_path, skill_content).unwrap(); + skill_path + } + + fn write_enabled_plugin_settings(path: &Path, entries: &[(&str, bool)]) { + let enabled_plugins = entries + .iter() + .map(|(plugin_id, enabled)| { + ((*plugin_id).to_string(), serde_json::Value::Bool(*enabled)) + }) + .collect::>(); + let settings = serde_json::json!({ "enabledPlugins": enabled_plugins }); + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(path, serde_json::to_vec(&settings).unwrap()).unwrap(); + } + + fn collect_skill_names(registry: &SkillRegistry) -> BTreeSet { + registry + .get_all() + .into_iter() + .map(|skill| skill.skill_name.clone()) + .collect() + } + #[test] fn test_registry_new() { let registry = SkillRegistry::new(); @@ -362,4 +485,100 @@ mod tests { assert_eq!(r.len(), 1); } } + + #[test] + fn test_refresh_plugin_skills_if_needed_with_context_detects_noop_snapshot() { + let workspace_dir = TempDir::new().unwrap(); + let home_dir = TempDir::new().unwrap(); + create_plugin_skill_fixture( + home_dir.path(), + "plugin-a@acme", + "alpha", + "---\nname: alpha\ndescription: first\n---\n\nbody\n", + ); + write_enabled_plugin_settings( + &home_dir.path().join(".claude/settings.json"), + &[("plugin-a@acme", true)], + ); + + let mut registry = SkillRegistry::new(); + registry.initialize_with_context(Some(workspace_dir.path()), Some(home_dir.path())); + + assert!(registry.find("plugin-a:alpha").is_some()); + assert!(!registry.refresh_plugin_skills_if_needed_with_context( + Some(workspace_dir.path()), + Some(home_dir.path()) + )); + } + + #[test] + fn test_refresh_plugin_skills_if_needed_with_context_reloads_when_skill_content_changes() { + let workspace_dir = TempDir::new().unwrap(); + let home_dir = TempDir::new().unwrap(); + let skill_path = create_plugin_skill_fixture( + home_dir.path(), + "plugin-a@acme", + "alpha", + "---\nname: alpha\ndescription: first\n---\n\nbody v1\n", + ); + write_enabled_plugin_settings( + &home_dir.path().join(".claude/settings.json"), + &[("plugin-a@acme", true)], + ); + + let mut registry = SkillRegistry::new(); + registry.initialize_with_context(Some(workspace_dir.path()), Some(home_dir.path())); + registry.record_invoked("plugin-a:alpha", &skill_path, "before-refresh"); + + fs::write( + &skill_path, + "---\nname: alpha\ndescription: updated version\n---\n\nbody v2 changed\n", + ) + .unwrap(); + + assert!(registry.refresh_plugin_skills_if_needed_with_context( + Some(workspace_dir.path()), + Some(home_dir.path()) + )); + + let skill = registry.find("plugin-a:alpha").unwrap(); + assert_eq!(skill.description, "updated version"); + assert_eq!(registry.get_invoked().len(), 1); + assert!(registry.get_invoked().contains_key("plugin-a:alpha")); + } + + #[test] + fn test_refresh_plugin_skills_if_needed_with_context_prunes_disabled_plugin_skills() { + let workspace_dir = TempDir::new().unwrap(); + let home_dir = TempDir::new().unwrap(); + create_plugin_skill_fixture( + home_dir.path(), + "plugin-a@acme", + "alpha", + "---\nname: alpha\ndescription: first\n---\n\nbody\n", + ); + write_enabled_plugin_settings( + &home_dir.path().join(".claude/settings.json"), + &[("plugin-a@acme", true)], + ); + + let mut registry = SkillRegistry::new(); + registry.initialize_with_context(Some(workspace_dir.path()), Some(home_dir.path())); + assert_eq!( + collect_skill_names(®istry), + BTreeSet::from(["plugin-a:alpha".to_string()]) + ); + + write_enabled_plugin_settings( + &workspace_dir.path().join(".claude/settings.json"), + &[("plugin-a@acme", false)], + ); + + assert!(registry.refresh_plugin_skills_if_needed_with_context( + Some(workspace_dir.path()), + Some(home_dir.path()) + )); + assert!(registry.find("plugin-a:alpha").is_none()); + assert!(registry.is_empty()); + } } diff --git a/src-tauri/crates/aster-rust/crates/aster/src/skills/tool.rs b/src-tauri/crates/aster-rust/crates/aster/src/skills/tool.rs index 066b441ec..302dbf644 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/skills/tool.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/skills/tool.rs @@ -2,7 +2,7 @@ //! //! Tool implementation for executing skills. -use super::registry::global_registry; +use super::registry::{global_registry, refresh_shared_registry_if_needed}; use super::{ LlmProvider, SharedSkillRegistry, SkillDefinition, SkillError, SkillExecutionMode, SkillExecutionResult, SkillExecutor, @@ -12,6 +12,7 @@ use crate::config::AsterMode; use crate::conversation::message::Message; use crate::conversation::Conversation; use crate::execution::manager::AgentManager; +use crate::hooks::register_session_frontmatter_hooks; use crate::model::ModelConfig; use crate::providers::base::Provider; use crate::providers::errors::ProviderError; @@ -72,6 +73,8 @@ impl SkillTool { args: Option<&str>, context: &ToolContext, ) -> Result { + refresh_shared_registry_if_needed(&self.registry)?; + // First, get all the data we need from the skill let skill = { let registry_guard = self.registry.read().map_err(|e| e.to_string())?; @@ -111,6 +114,35 @@ impl SkillTool { registry_write.record_invoked(&skill.skill_name, &skill.file_path, &skill_content); } + if let Some(frontmatter_hooks) = skill.hooks.as_ref() { + if context.session_id.trim().is_empty() { + tracing::warn!( + "[SkillTool] skill '{}' 声明了 frontmatter hooks,但当前调用缺少 session_id,已跳过注册", + skill.skill_name + ); + } else { + let report = register_session_frontmatter_hooks( + context.session_id.trim(), + frontmatter_hooks, + ); + if report.registered > 0 { + tracing::info!( + "[SkillTool] 已为 session={} 注册 {} 个 skill frontmatter hooks: {}", + context.session_id, + report.registered, + skill.skill_name + ); + } + for skipped in report.skipped { + tracing::warn!( + "[SkillTool] 跳过 skill '{}' 的 frontmatter hook: {}", + skill.skill_name, + skipped + ); + } + } + } + if skill.execution_mode == SkillExecutionMode::Agent { return Ok(self .execute_agent_skill(&skill, &skill_content, context) @@ -128,6 +160,10 @@ impl SkillTool { /// Generate tool description with available skills fn generate_description(&self) -> String { + if let Err(error) = refresh_shared_registry_if_needed(&self.registry) { + tracing::warn!("[SkillTool] 刷新 plugin skill 注册表失败: {}", error); + } + let skills_xml = if let Ok(registry_guard) = self.registry.read() { registry_guard .get_all() @@ -873,6 +909,10 @@ impl Tool for SkillTool { #[cfg(test)] mod tests { use super::*; + use crate::hooks::{ + clear_session_hooks, get_session_hook_count, is_blocked, run_hooks_with_registry, + FrontmatterHooks, HookEvent, HookInput, HookRegistry, + }; use crate::providers::base::{Provider, ProviderMetadata, ProviderUsage, Usage}; use crate::skills::new_shared_registry; use crate::skills::types::{ @@ -903,6 +943,7 @@ mod tests { execution_mode: SkillExecutionMode::default(), provider: None, workflow: None, + hooks: None, } } @@ -1178,4 +1219,62 @@ mod tests { "应返回 provider 缺失错误" ); } + + #[tokio::test] + async fn test_skill_tool_registers_session_frontmatter_hooks_for_current_session() { + clear_session_hooks("skill-session"); + + let registry = new_shared_registry(); + { + let mut guard = registry.write().expect("registry write"); + let mut skill = create_test_skill(); + skill.hooks = Some( + serde_yaml::from_str::( + r#" +PreToolUse: + - matcher: Bash + hooks: + - type: command + command: "printf '%s' '{\"blocked\":true,\"message\":\"skill hook blocked\"}'; exit 2" +"#, + ) + .expect("frontmatter hooks should parse"), + ); + guard.register(skill); + } + let tool = SkillTool::with_registry(registry); + let provider: Arc = Arc::new(MockProvider::new("openai", "gpt-4o")); + + let result = tool + .execute( + serde_json::json!({ + "skill": "test:example", + "args": "注册 session hooks" + }), + &build_tool_context(provider), + ) + .await + .expect("skill result"); + + assert!(result.success); + assert_eq!(get_session_hook_count("skill-session"), 1); + + let empty_registry = Arc::new(HookRegistry::new()); + let hook_results = run_hooks_with_registry( + HookInput { + event: Some(HookEvent::PreToolUse), + tool_name: Some("Bash".to_string()), + session_id: Some("skill-session".to_string()), + ..Default::default() + }, + &empty_registry, + ) + .await; + + let (blocked, message) = is_blocked(&hook_results); + assert!(blocked); + assert_eq!(message.as_deref(), Some("skill hook blocked")); + + clear_session_hooks("skill-session"); + } } diff --git a/src-tauri/crates/aster-rust/crates/aster/src/skills/types.rs b/src-tauri/crates/aster-rust/crates/aster/src/skills/types.rs index ef769fe82..736834811 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/skills/types.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/skills/types.rs @@ -2,6 +2,7 @@ //! //! Core types for the skills system. +use crate::hooks::FrontmatterHooks; use serde::{Deserialize, Serialize}; use std::path::PathBuf; @@ -317,6 +318,12 @@ pub struct SkillFrontmatter { /// 当 `execution-mode` 为 `workflow` 时,此字段定义工作流的步骤、 /// 依赖关系和执行配置。 pub workflow: Option, + + /// Skill frontmatter hooks + /// + /// 对齐参考运行时的 `event -> matcher[] -> hooks[]` 结构; + /// 注册阶段仍会收口到 Lime 当前 hooks runtime。 + pub hooks: Option, } /// Skill definition @@ -378,6 +385,10 @@ pub struct SkillDefinition { /// 依赖关系和执行配置。 #[serde(default)] pub workflow: Option, + + /// Skill frontmatter hooks + #[serde(default)] + pub hooks: Option, } impl SkillDefinition { @@ -730,6 +741,7 @@ mod tests { execution_mode: SkillExecutionMode::default(), provider: None, workflow: None, + hooks: None, }; assert_eq!(skill.short_name(), "my-skill"); @@ -758,6 +770,7 @@ mod tests { execution_mode: SkillExecutionMode::default(), provider: None, workflow: None, + hooks: None, }; assert_eq!(skill.short_name(), "simple-skill"); diff --git a/src-tauri/crates/aster-rust/crates/aster/src/tool_inspection.rs b/src-tauri/crates/aster-rust/crates/aster/src/tool_inspection.rs index 459311d63..3b7cc2183 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/tool_inspection.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/tool_inspection.rs @@ -132,6 +132,19 @@ impl ToolInspectionManager { tracing::warn!("Permission inspector not found for mode update"); } + pub async fn current_permission_mode(&self) -> Option { + for inspector in &self.inspectors { + if inspector.name() == "permission" { + if let Some(permission_inspector) = + inspector.as_any().downcast_ref::() + { + return Some(permission_inspector.current_mode().await); + } + } + } + None + } + /// Update the permission manager for a specific tool pub async fn update_permission_manager( &self, diff --git a/src-tauri/crates/aster-rust/crates/aster/src/tools/agent_control.rs b/src-tauri/crates/aster-rust/crates/aster/src/tools/agent_control.rs index e45cfc7b4..944a0f3a6 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/tools/agent_control.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/tools/agent_control.rs @@ -12,6 +12,7 @@ use std::future::Future; use std::pin::Pin; use std::sync::Arc; +use crate::hooks::FrontmatterHooks; use crate::session::{query_session, resolve_named_subagent_child_session, resolve_team_context}; use crate::tools::base::Tool; use crate::tools::context::{ToolContext, ToolResult}; @@ -101,6 +102,12 @@ pub struct SpawnAgentRequest { #[serde(alias = "output_contract")] pub output_contract: Option, #[serde(default)] + pub hooks: Option, + #[serde(default, alias = "allowed_tools")] + pub allowed_tools: Vec, + #[serde(default, alias = "disallowed_tools")] + pub disallowed_tools: Vec, + #[serde(default)] pub mode: Option, #[serde(default)] pub isolation: Option, @@ -139,6 +146,10 @@ struct AgentToolInput { reasoning_effort: Option, #[serde(default, alias = "fork_context")] fork_context: bool, + #[serde(default, alias = "allowed_tools")] + allowed_tools: Vec, + #[serde(default, alias = "disallowed_tools")] + disallowed_tools: Vec, #[serde(default)] cwd: Option, } @@ -266,6 +277,22 @@ fn pretty_json(value: &T) -> Result { .map_err(|error| ToolError::execution_failed(format!("序列化结果失败: {error}"))) } +fn normalize_optional_vec(values: &[String]) -> Vec { + let mut normalized = Vec::new(); + let mut seen = std::collections::BTreeSet::new(); + + for value in values { + let Some(item) = normalize_optional_text(Some(value.clone())) else { + continue; + }; + if seen.insert(item.clone()) { + normalized.push(item); + } + } + + normalized +} + #[derive(Clone)] pub struct SpawnAgentTool { callback: SpawnAgentCallback, @@ -300,6 +327,16 @@ impl Tool for SpawnAgentTool { "team_name": { "type": "string", "description": "可选 team 名称;未传时沿用当前 team 上下文。" }, "mode": { "type": "string", "description": "可选权限模式;当前 runtime 是否支持由宿主决定。" }, "isolation": { "type": "string", "enum": ["worktree", "remote"], "description": "可选隔离模式;当前 runtime 是否支持由宿主决定。" }, + "allowed_tools": { + "type": "array", + "items": { "type": "string" }, + "description": "可选子代理工具白名单;当前 runtime 会把它下沉到 session 级真实权限限制。" + }, + "disallowed_tools": { + "type": "array", + "items": { "type": "string" }, + "description": "可选子代理工具黑名单;优先级高于 allowed_tools。" + }, "reasoning_effort": { "type": "string", "description": "可选推理强度覆盖。" }, "fork_context": { "type": "boolean", "description": "是否复制当前上下文给子代理。" }, "cwd": { "type": "string", "description": "可选工作目录绝对路径。" } @@ -336,6 +373,9 @@ impl Tool for SpawnAgentTool { theme: None, system_overlay: None, output_contract: None, + hooks: None, + allowed_tools: normalize_optional_vec(&input.allowed_tools), + disallowed_tools: normalize_optional_vec(&input.disallowed_tools), mode: normalize_optional_text(input.mode), isolation: normalize_optional_text(input.isolation), cwd: normalize_optional_text(input.cwd), diff --git a/src-tauri/crates/aster-rust/crates/aster/src/tools/workflow_tool.rs b/src-tauri/crates/aster-rust/crates/aster/src/tools/workflow_tool.rs index eda03f468..6636eae41 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/tools/workflow_tool.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/tools/workflow_tool.rs @@ -11,8 +11,8 @@ use crate::providers::base::Provider; use crate::providers::errors::ProviderError; use crate::providers::{create_with_default_model, create_with_named_model}; use crate::skills::{ - global_registry, LlmProvider, SharedSkillRegistry, SkillDefinition, SkillError, - SkillExecutionMode, SkillExecutionResult, SkillExecutor, + global_registry, refresh_shared_registry_if_needed, LlmProvider, SharedSkillRegistry, + SkillDefinition, SkillError, SkillExecutionMode, SkillExecutionResult, SkillExecutor, }; use crate::tools::base::{PermissionCheckResult, Tool}; use crate::tools::context::{ToolContext, ToolResult}; @@ -56,6 +56,10 @@ impl WorkflowTool { } fn list_workflow_skills(&self) -> Result, ToolError> { + refresh_shared_registry_if_needed(&self.registry).map_err(|error| { + ToolError::execution_failed(format!("刷新 workflow 注册表失败: {error}")) + })?; + let registry = self.registry.read().map_err(|error| { ToolError::execution_failed(format!("读取 workflow 注册表失败: {error}")) })?; @@ -74,6 +78,10 @@ impl WorkflowTool { } fn find_workflow_skill(&self, workflow_name: &str) -> Result { + refresh_shared_registry_if_needed(&self.registry).map_err(|error| { + ToolError::execution_failed(format!("刷新 workflow 注册表失败: {error}")) + })?; + let registry = self.registry.read().map_err(|error| { ToolError::execution_failed(format!("读取 workflow 注册表失败: {error}")) })?; @@ -541,6 +549,7 @@ mod tests { WorkflowStep::new("step2", "步骤二", "继续 ${result1}", "result2") .with_dependency("step1"), ])), + hooks: None, } } diff --git a/src-tauri/crates/core/src/config/types.rs b/src-tauri/crates/core/src/config/types.rs index 7d296852e..3f1f6ec1c 100644 --- a/src-tauri/crates/core/src/config/types.rs +++ b/src-tauri/crates/core/src/config/types.rs @@ -710,13 +710,13 @@ pub struct CompanionDefaultsConfig { /// 导航栏模块配置 /// -/// 配置左侧导航栏中显示的功能模块 +/// 配置左侧导航栏里可显式开启的隐藏系统入口 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct NavigationConfig { /// 工作区导航默认值版本 #[serde(default)] pub schema_version: u8, - /// 启用的导航模块列表 + /// 已显式开启的隐藏系统入口列表;固定主导航不写入配置 #[serde(default = "default_enabled_nav_items")] pub enabled_items: Vec, } @@ -734,90 +734,23 @@ impl Default for NavigationConfig { } } -const CURRENT_MAIN_NAV_ITEM_IDS: &[&str] = &[ - "home-general", - "claw", - "video", - "image-gen", - "automation", - "terminal", - "plugins", -]; +const CURRENT_CONFIGURABLE_NAV_ITEM_IDS: &[&str] = &["plugins", "openclaw", "companion"]; -const CURRENT_FOOTER_NAV_ITEM_IDS: &[&str] = - &["openclaw", "settings", "resources", "tools", "memory"]; +fn normalize_navigation_enabled_items(items: &[String]) -> Vec { + let mut normalized = Vec::new(); -const REMOVED_NAV_ITEM_IDS: &[&str] = &["api-server"]; - -const LEGACY_ONLY_NAV_ITEM_IDS: &[&str] = &["agent", "projects", "provider-pool"]; - -const LEGACY_DEFAULT_NAV_ITEM_SETS: &[&[&str]] = &[ - &["home-general", "claw", "video", "image-gen"], - &["home-general", "claw", "video", "image-gen", "automation"], - &["home-general", "video", "image-gen", "plugins"], - &["home-general", "video", "image-gen", "terminal", "plugins"], -]; - -const STALE_DEFAULT_NAV_ITEM_SETS: &[&[&str]] = &[ - &[ - "home-general", - "claw", - "video", - "image-gen", - "automation", - "openclaw", - "resources", - "memory", - ], - &[ - "home-general", - "claw", - "video", - "automation", - "openclaw", - "resources", - "memory", - ], -]; - -fn has_same_members(items: &[String], expected: &[&str]) -> bool { - if items.len() != expected.len() { - return false; + for item in items { + let item_str = item.as_str(); + if !CURRENT_CONFIGURABLE_NAV_ITEM_IDS.contains(&item_str) { + continue; + } + if normalized.iter().any(|current| current == item) { + continue; + } + normalized.push(item.clone()); } - let item_set: std::collections::HashSet<&str> = items.iter().map(String::as_str).collect(); - expected.iter().all(|item| item_set.contains(item)) -} - -fn has_stale_default_nav_items(items: &[String]) -> bool { - STALE_DEFAULT_NAV_ITEM_SETS - .iter() - .any(|stale_items| has_same_members(items, stale_items)) -} - -fn should_upgrade_legacy_navigation_defaults(items: &[String]) -> bool { - if items.is_empty() { - return true; - } - - if LEGACY_DEFAULT_NAV_ITEM_SETS - .iter() - .any(|legacy_items| has_same_members(items, legacy_items)) - { - return true; - } - - let contains_footer_items = items - .iter() - .any(|item| CURRENT_FOOTER_NAV_ITEM_IDS.contains(&item.as_str())); - let contains_legacy_only_items = items - .iter() - .any(|item| LEGACY_ONLY_NAV_ITEM_IDS.contains(&item.as_str())); - let main_only_legacy_items = items - .iter() - .all(|item| CURRENT_MAIN_NAV_ITEM_IDS.contains(&item.as_str())); - - contains_legacy_only_items || (main_only_legacy_items && !contains_footer_items) + normalized } // ============ 实验室功能配置类型 ============ @@ -2212,16 +2145,10 @@ impl Config { let mut changed = false; let current_version = current_workspace_preferences_schema_version(); - let original_nav_len = self.navigation.enabled_items.len(); - self.navigation - .enabled_items - .retain(|item| !REMOVED_NAV_ITEM_IDS.contains(&item.as_str())); - if self.navigation.enabled_items.len() != original_nav_len { - changed = true; - } - - if has_stale_default_nav_items(&self.navigation.enabled_items) { - self.navigation.enabled_items = default_enabled_nav_items(); + let normalized_navigation_items = + normalize_navigation_enabled_items(&self.navigation.enabled_items); + if self.navigation.enabled_items != normalized_navigation_items { + self.navigation.enabled_items = normalized_navigation_items; changed = true; } @@ -2231,10 +2158,6 @@ impl Config { } if self.navigation.schema_version < current_version { - if should_upgrade_legacy_navigation_defaults(&self.navigation.enabled_items) { - self.navigation.enabled_items = default_enabled_nav_items(); - } - self.navigation.schema_version = current_version; changed = true; } @@ -2864,12 +2787,7 @@ mod unit_tests { let mut config = Config::default(); config.workspace_preferences.schema_version = 0; config.navigation.schema_version = 0; - config.navigation.enabled_items = vec![ - "home-general".to_string(), - "claw".to_string(), - "resources".to_string(), - "tools".to_string(), - ]; + config.navigation.enabled_items = vec!["plugins".to_string(), "companion".to_string()]; let changed = config.normalize_workspace_preferences(); @@ -2878,12 +2796,7 @@ mod unit_tests { assert_eq!(config.navigation.schema_version, 1); assert_eq!( config.navigation.enabled_items, - vec![ - "home-general".to_string(), - "claw".to_string(), - "resources".to_string(), - "tools".to_string(), - ] + vec!["plugins".to_string(), "companion".to_string()] ); } @@ -2904,17 +2817,22 @@ mod unit_tests { let changed = config.normalize_workspace_preferences(); assert!(changed); - assert!(config.navigation.enabled_items.is_empty()); + assert_eq!( + config.navigation.enabled_items, + vec!["openclaw".to_string()] + ); } #[test] - fn test_normalize_workspace_preferences_removes_api_server_entry_even_on_current_schema() { + fn test_normalize_workspace_preferences_filters_legacy_fixed_and_unknown_entries() { let mut config = Config::default(); config.navigation.enabled_items = vec![ + "plugins".to_string(), "home-general".to_string(), - "claw".to_string(), - REMOVED_NAV_ITEM_IDS[0].to_string(), - "resources".to_string(), + "api-server".to_string(), + "companion".to_string(), + "plugins".to_string(), + "settings".to_string(), ]; let changed = config.normalize_workspace_preferences(); @@ -2922,11 +2840,7 @@ mod unit_tests { assert!(changed); assert_eq!( config.navigation.enabled_items, - vec![ - "home-general".to_string(), - "claw".to_string(), - "resources".to_string(), - ] + vec!["plugins".to_string(), "companion".to_string()] ); } diff --git a/src-tauri/crates/services/src/api_key_provider_service.rs b/src-tauri/crates/services/src/api_key_provider_service.rs index 99e198830..cd019b0c2 100644 --- a/src-tauri/crates/services/src/api_key_provider_service.rs +++ b/src-tauri/crates/services/src/api_key_provider_service.rs @@ -951,6 +951,69 @@ impl ApiKeyProviderService { Ok(result.plaintext) } + fn next_round_robin_candidate_indices( + &self, + scope: &str, + len: usize, + ) -> Result, String> { + if len == 0 { + return Ok(Vec::new()); + } + + let start = { + let mut indices = self.round_robin_index.write().map_err(|e| e.to_string())?; + indices + .entry(scope.to_string()) + .or_insert_with(|| AtomicUsize::new(0)) + .fetch_add(1, Ordering::SeqCst) + }; + + Ok((0..len).map(|offset| (start + offset) % len).collect()) + } + + fn log_skipped_invalid_api_key(&self, key: &ApiKeyEntry, error: &str) { + tracing::warn!( + "[ApiKeyProviderService] 跳过不可解密 API Key {} (provider={}): {}", + key.id, + key.provider_id, + error + ); + } + + fn select_next_decryptable_api_key<'a>( + &self, + conn: &rusqlite::Connection, + scope: &str, + keys: &'a [ApiKeyEntry], + ) -> Result, String> { + for index in self.next_round_robin_candidate_indices(scope, keys.len())? { + let candidate = &keys[index]; + match self.decrypt_api_key_entry_with_migration(conn, candidate) { + Ok(api_key) => return Ok(Some((candidate, api_key))), + Err(error) => self.log_skipped_invalid_api_key(candidate, &error), + } + } + + Ok(None) + } + + fn select_next_decryptable_api_key_with_provider<'a>( + &self, + conn: &rusqlite::Connection, + scope: &str, + keys: &'a [(ApiKeyEntry, ApiKeyProvider)], + ) -> Result, String> { + for index in self.next_round_robin_candidate_indices(scope, keys.len())? { + let (candidate, provider) = &keys[index]; + match self.decrypt_api_key_entry_with_migration(conn, candidate) { + Ok(api_key) => return Ok(Some((candidate, provider, api_key))), + Err(error) => self.log_skipped_invalid_api_key(candidate, &error), + } + } + + Ok(None) + } + pub fn migrate_legacy_api_key_encryption(&self, db: &DbConnection) -> Result { let conn = lime_core::database::lock_db(db)?; let providers = ApiKeyProviderDao::get_all_providers_with_keys(&conn) @@ -1987,20 +2050,14 @@ impl ApiKeyProviderService { return Ok(None); } - // 获取或创建轮询索引 - let index = { - let mut indices = self.round_robin_index.write().map_err(|e| e.to_string())?; - indices - .entry(provider_id.to_string()) - .or_insert_with(|| AtomicUsize::new(0)) - .fetch_add(1, Ordering::SeqCst) + let Some((_selected_key, decrypted)) = + self.select_next_decryptable_api_key(&conn, provider_id, &keys)? + else { + return Err(format!( + "Provider {provider_id} 的所有已启用 API Key 都无法解密" + )); }; - // 选择 API Key - let selected_key = &keys[index % keys.len()]; - - // 解密并返回 - let decrypted = self.decrypt_api_key_entry_with_migration(&conn, selected_key)?; Ok(Some(decrypted)) } @@ -2020,20 +2077,14 @@ impl ApiKeyProviderService { return Ok(None); } - // 获取或创建轮询索引 - let index = { - let mut indices = self.round_robin_index.write().map_err(|e| e.to_string())?; - indices - .entry(provider_id.to_string()) - .or_insert_with(|| AtomicUsize::new(0)) - .fetch_add(1, Ordering::SeqCst) + let Some((selected_key, decrypted)) = + self.select_next_decryptable_api_key(&conn, provider_id, &keys)? + else { + return Err(format!( + "Provider {provider_id} 的所有已启用 API Key 都无法解密" + )); }; - // 选择 API Key - let selected_key = &keys[index % keys.len()]; - - // 解密并返回 - let decrypted = self.decrypt_api_key_entry_with_migration(&conn, selected_key)?; Ok(Some((selected_key.id.clone(), decrypted))) } @@ -2078,20 +2129,14 @@ impl ApiKeyProviderService { return Ok(None); } - // 获取或创建轮询索引 - let index = { - let mut indices = self.round_robin_index.write().map_err(|e| e.to_string())?; - indices - .entry(provider_id.to_string()) - .or_insert_with(|| AtomicUsize::new(0)) - .fetch_add(1, Ordering::SeqCst) + let Some((_selected_key, decrypted)) = + self.select_next_decryptable_api_key(&conn, provider_id, &keys)? + else { + return Err(format!( + "Provider {provider_id} 的所有已启用 API Key 都无法解密" + )); }; - // 选择 API Key - let selected_key = &keys[index % keys.len()]; - - // 解密并返回 - let decrypted = self.decrypt_api_key_entry_with_migration(&conn, selected_key)?; Ok(Some((decrypted, provider))) } @@ -2112,21 +2157,15 @@ impl ApiKeyProviderService { return Ok(None); } - // 使用类型名称作为轮询索引的 key let type_key = format!("type:{provider_type}"); - let index = { - let mut indices = self.round_robin_index.write().map_err(|e| e.to_string())?; - indices - .entry(type_key) - .or_insert_with(|| AtomicUsize::new(0)) - .fetch_add(1, Ordering::SeqCst) + let Some((selected_key, provider, decrypted)) = + self.select_next_decryptable_api_key_with_provider(&conn, &type_key, &keys)? + else { + return Err(format!( + "Provider 类型 {provider_type} 的所有已启用 API Key 都无法解密" + )); }; - // 选择 API Key - let (selected_key, provider) = &keys[index % keys.len()]; - - // 解密并返回 - let decrypted = self.decrypt_api_key_entry_with_migration(&conn, selected_key)?; Ok(Some((selected_key.id.clone(), decrypted, provider.clone()))) } diff --git a/src-tauri/src/agent_tools/catalog.rs b/src-tauri/src/agent_tools/catalog.rs index c5b7be251..8963653d4 100644 --- a/src-tauri/src/agent_tools/catalog.rs +++ b/src-tauri/src/agent_tools/catalog.rs @@ -597,7 +597,7 @@ static NATIVE_TOOL_CATALOG: &[ToolCatalogEntry] = &[ name: LIME_RUN_SERVICE_SKILL_TOOL_NAME, profiles: WORKBENCH_PROFILES, capabilities: EXECUTION_CAP, - lifecycle: ToolLifecycle::Current, + lifecycle: ToolLifecycle::Compat, source: ToolSourceKind::LimeInjected, permission_plane: ToolPermissionPlane::SessionAllowlist, workspace_default_allow: true, diff --git a/src-tauri/src/commands/aster_agent_cmd/action_runtime.rs b/src-tauri/src/commands/aster_agent_cmd/action_runtime.rs index d0c203cc0..f164dc35c 100644 --- a/src-tauri/src/commands/aster_agent_cmd/action_runtime.rs +++ b/src-tauri/src/commands/aster_agent_cmd/action_runtime.rs @@ -1,6 +1,8 @@ +use super::session_runtime::delete_runtime_session_internal_with_runtime; use super::*; use lime_agent::AgentEvent as RuntimeAgentEvent; use lime_core::workspace::WorkspaceSettings; +use tauri::Manager; /// 统一运行时:删除会话。 #[tauri::command] @@ -13,7 +15,13 @@ pub async fn agent_runtime_delete_session( let trimmed_session_id = session_id.trim().to_string(); let _ = state.cancel_session(&trimmed_session_id).await; let _ = clear_runtime_queue_service(&app, &trimmed_session_id).await; - delete_runtime_session_internal(db.inner(), &trimmed_session_id).await + delete_runtime_session_internal_with_runtime( + db.inner(), + state.inner(), + app.state::().inner(), + &trimmed_session_id, + ) + .await } /// 确认权限请求 diff --git a/src-tauri/src/commands/aster_agent_cmd/dto.rs b/src-tauri/src/commands/aster_agent_cmd/dto.rs index 060d9a295..fee760cd9 100644 --- a/src-tauri/src/commands/aster_agent_cmd/dto.rs +++ b/src-tauri/src/commands/aster_agent_cmd/dto.rs @@ -1672,6 +1672,12 @@ pub struct AgentRuntimeSpawnSubagentRequest { #[serde(default, alias = "outputContract")] pub output_contract: Option, #[serde(default)] + pub hooks: Option, + #[serde(default, alias = "allowedTools")] + pub allowed_tools: Vec, + #[serde(default, alias = "disallowedTools")] + pub disallowed_tools: Vec, + #[serde(default)] pub mode: Option, #[serde(default)] pub isolation: Option, @@ -1918,6 +1924,20 @@ mod tests { "teamName": "delivery-team", "agentType": "explorer", "runInBackground": true, + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "prompt", + "prompt": "Summarize the delegated result" + } + ] + } + ] + }, + "allowedTools": ["Read", "Bash"], + "disallowedTools": ["WebSearch"], "mode": "plan", "isolation": "worktree", "cwd": "/tmp/workspace" @@ -1930,6 +1950,14 @@ mod tests { assert_eq!(request.team_name.as_deref(), Some("delivery-team")); assert_eq!(request.agent_type.as_deref(), Some("explorer")); assert!(request.run_in_background); + assert_eq!(request.hooks.as_ref().map(|hooks| hooks.len()), Some(1)); + assert!(request + .hooks + .as_ref() + .and_then(|hooks| hooks.get(&aster::hooks::HookEvent::Stop)) + .is_some()); + assert_eq!(request.allowed_tools, vec!["Read", "Bash"]); + assert_eq!(request.disallowed_tools, vec!["WebSearch"]); assert_eq!(request.mode.as_deref(), Some("plan")); assert_eq!(request.isolation.as_deref(), Some("worktree")); assert_eq!(request.cwd.as_deref(), Some("/tmp/workspace")); diff --git a/src-tauri/src/commands/aster_agent_cmd/mod.rs b/src-tauri/src/commands/aster_agent_cmd/mod.rs index dc02057f8..e308eb407 100644 --- a/src-tauri/src/commands/aster_agent_cmd/mod.rs +++ b/src-tauri/src/commands/aster_agent_cmd/mod.rs @@ -461,11 +461,10 @@ pub(crate) use service_skill_launch::{ ServiceSkillLaunchPreloadExecution, }; pub(crate) use session_runtime::{ - delete_runtime_session_internal, persist_session_provider_routing, - resolve_recent_preference_from_sources, resolve_session_provider_selector, - resolve_session_recent_harness_context, resolve_session_recent_preferences, - resolve_session_recent_runtime_context, SessionRecentHarnessContext, - SessionRecentRuntimeContext, + persist_session_provider_routing, resolve_recent_preference_from_sources, + resolve_session_provider_selector, resolve_session_recent_harness_context, + resolve_session_recent_preferences, resolve_session_recent_runtime_context, + SessionRecentHarnessContext, SessionRecentRuntimeContext, }; pub(crate) use site_search_skill_launch::{ append_site_search_skill_launch_session_permissions, @@ -488,17 +487,17 @@ pub(crate) use summary_skill_launch::{ }; #[allow(unused_imports)] pub(crate) use tool_runtime::social_generate_cover_image_cmd; +#[cfg(test)] +#[allow(unused_imports)] +pub(crate) use tool_runtime::{ + append_subagent_tool_scope_session_permissions, extract_runtime_subagent_result_text, + LimeBrowserMcpTool, SocialGenerateCoverImageTool, ToolSearchBridgeTool, +}; pub(crate) use tool_runtime::{apply_workspace_sandbox_permissions, ImageInput}; pub(crate) use tool_runtime::{ ensure_browser_mcp_tools_registered, ensure_creation_task_tools_registered, ensure_runtime_support_tools_registered, ensure_social_image_tool_registered, }; -#[cfg(test)] -#[allow(unused_imports)] -pub(crate) use tool_runtime::{ - extract_runtime_subagent_result_text, LimeBrowserMcpTool, SocialGenerateCoverImageTool, - ToolSearchBridgeTool, -}; pub(crate) use transcription_skill_launch::{ append_transcription_skill_launch_session_permissions, merge_system_prompt_with_transcription_skill_launch, diff --git a/src-tauri/src/commands/aster_agent_cmd/prompt_context.rs b/src-tauri/src/commands/aster_agent_cmd/prompt_context.rs index 660c93f22..daaa63c58 100644 --- a/src-tauri/src/commands/aster_agent_cmd/prompt_context.rs +++ b/src-tauri/src/commands/aster_agent_cmd/prompt_context.rs @@ -370,26 +370,14 @@ fn build_service_scene_launch_system_prompt( ) -> Option { let context = super::service_skill_launch::extract_service_scene_launch_context(request_metadata)?; - let tool_input = if let Some(user_input) = context - .user_input - .clone() - .filter(|value| !value.trim().is_empty()) - { - serde_json::json!({ "input": user_input }).to_string() - } else { - "{}".to_string() - }; let mut lines = vec![ SERVICE_SKILL_LAUNCH_PROMPT_MARKER.to_string(), "- 当前回合来自服务型场景启动,不要把它当成普通聊天或纯文本分析。".to_string(), - "- 先快速确认当前 slash 场景目标,然后立刻调用服务型技能运行工具;不要停留在泛泛解释。".to_string(), - "- 第一优先工具调用必须是 lime_run_service_skill。".to_string(), - "- lime_run_service_skill 会自动读取当前回合绑定的 serviceSkillId 与 OEM 运行时上下文,通常不需要手动补鉴权字段。".to_string(), - format!("- 推荐第一工具调用参数 JSON:{tool_input}。"), - "- 如果用户没有追加补充要求,直接传 {} 也可以;不要把 scene metadata 里的 session_token、tenant_id、scene_base_url 重新抄进工具参数。".to_string(), - "- 调用 lime_run_service_skill 后,若返回 queued/running,可以继续等待一次或基于当前状态向用户汇报“已提交云端,正在处理中”;不要伪造已完成结果。".to_string(), - "- 如果工具返回缺少 OEM 配置、缺少 Session Token 或授权失败,不要伪造成功结果;直接说明当前云端会话不可用,并引导用户先完成登录或注入会话。".to_string(), + "- 当前主链是“目录命中 + 本地 Agent 执行”;直接在当前回合使用可见的本地工具、技能和工作区能力完成任务。".to_string(), + "- 不要调用 lime_run_service_skill;它只为历史兼容保留,不再代表 current 执行桥。".to_string(), + "- 不要向用户汇报“已提交云端 / 正在云端处理中”,也不要再把 OEM Session Token、scene_base_url、tenant_id 当作当前执行前提。".to_string(), + "- 优先依据当前 user_input、scene_key、skill_title、skill_summary 与 slot_values 直接推进任务;需要额外工具时,只选当前本地回合真正需要的那一个。".to_string(), format!("- 当前服务型技能 ID:{}。", context.service_skill_id), format!( "- 当前服务型技能标题:{}。", @@ -421,6 +409,28 @@ fn build_service_scene_launch_system_prompt( ), ]; + match context.execution_kind.as_deref() { + Some("automation_job") => lines.push( + "- 当前 execution_kind=automation_job:本回合先产出首轮结果、调度建议或仍需补齐的信息;除非用户明确要求,不要伪造“任务已经开始自动运行”。" + .to_string(), + ), + Some("native_skill") => lines.push( + "- 当前 execution_kind=native_skill:若当前回合里已有明确的本地 Skill/工具入口可以直接完成任务,就优先复用;否则继续在当前本地回合完成,不要回退 compat 云桥。" + .to_string(), + ), + _ => lines.push( + "- 当前 execution_kind=agent_turn:直接在本地回合完成分析、生成、检索、整理与结果回写。" + .to_string(), + ), + } + + if context.launch_kind == "cloud_scene" { + lines.push( + "- 当前 launch kind=cloud_scene 只是旧目录兼容输入;执行仍以本地 current 主链为准。" + .to_string(), + ); + } + if let Some(value) = context.user_input.as_deref() { lines.push(format!("- 当前补充要求:{value}")); } else if let Some(value) = context.raw_text.as_deref() { @@ -435,20 +445,30 @@ fn build_service_scene_launch_system_prompt( if let Some(value) = context.skill_summary.as_deref() { lines.push(format!("- 当前技能说明:{value}")); } - if let Some(value) = context.oem_runtime.scene_base_url.as_deref() { - lines.push(format!("- 当前 scene_base_url:{value}。")); - } else { + if let Some(value) = context.service_skill_key.as_deref() { + lines.push(format!("- 当前服务型技能 key:{value}。")); + } + if let Some(value) = context.runner_type.as_deref() { + lines.push(format!("- 当前 runner_type:{value}。")); + } + if let Some(value) = context.execution_kind.as_deref() { + lines.push(format!("- 当前 execution_kind:{value}。")); + } + if let Some(value) = context.execution_location.as_deref() { + lines.push(format!("- 当前 execution_location:{value}。")); + } + if let Some(value) = render_json_inline(context.slot_values.as_ref()) { + lines.push(format!("- 当前 slot_values(JSON):{value}。")); + } + if context.oem_runtime.scene_base_url.is_some() + || context.oem_runtime.session_token.is_some() + || context.oem_runtime.tenant_id.is_some() + { lines.push( - "- 当前缺少 scene_base_url。若工具返回缺少 OEM 配置,直接向用户说明未完成云端接线。" + "- 当前 metadata 中仍带有旧 OEM 运行时字段;它们只再作为 compat 输入保留,不是 current 执行门槛。" .to_string(), ); } - if context.oem_runtime.session_token.is_some() { - lines - .push("- 当前回合已绑定 OEM Session Token,可直接调用服务型技能运行工具。".to_string()); - } else { - lines.push("- 当前回合尚未绑定 OEM Session Token。若工具返回授权失败,不要重试伪造结果,直接要求用户先登录 OEM 云端。".to_string()); - } Some(lines.join("\n")) } diff --git a/src-tauri/src/commands/aster_agent_cmd/runtime_project_hooks.rs b/src-tauri/src/commands/aster_agent_cmd/runtime_project_hooks.rs index bde88a939..0c35a6df5 100644 --- a/src-tauri/src/commands/aster_agent_cmd/runtime_project_hooks.rs +++ b/src-tauri/src/commands/aster_agent_cmd/runtime_project_hooks.rs @@ -1,12 +1,28 @@ use super::*; use crate::agent_tools::catalog::mcp_extension_runtime_name; +use aster::agents::PermissionRequestHookDecision; +use aster::claude_plugin_cache::{ + load_cached_plugin_manifest_json, resolve_claude_manifest_relative_path, + resolve_claude_plugin_cache_entries, ClaudeManifestRelativePathKind, +}; +#[cfg(test)] use aster::hooks::{ - load_project_hooks_to_registry, run_session_start_hooks_with_registry, - run_session_start_hooks_with_registry_and_context, run_user_prompt_submit_hooks_with_registry, - run_user_prompt_submit_hooks_with_registry_and_context, HookRegistry, HookResult, - HookRuntimeContext, McpHookConfig, SessionSource, + get_matching_session_hooks, run_hooks_with_registry, run_pre_compact_hooks_with_registry, + run_session_end_hooks_with_registry, run_user_prompt_submit_hooks_with_registry, +}; +use aster::hooks::{ + is_blocked, load_project_hooks_to_registry, run_hooks_with_registry_and_context, + run_pre_compact_hooks_with_registry_and_context, + run_session_end_hooks_with_registry_and_context, run_session_start_hooks_with_registry, + run_session_start_hooks_with_registry_and_context, + run_user_prompt_submit_hooks_with_registry_and_context, CompactTrigger, FrontmatterHooks, + HookEvent, HookInput, HookRegistry, HookResult, HookRuntimeContext, McpHookConfig, + SessionEndReason, SessionHookRegistrationReport, SessionSource, }; use rmcp::model::{CallToolRequestParam, CallToolResult, Content, ErrorData, RawContent}; +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; use tokio_util::sync::CancellationToken; pub(crate) fn load_runtime_project_hook_registry( @@ -17,11 +33,300 @@ pub(crate) fn load_runtime_project_hook_registry( return Err("workspace_root 不能为空".to_string()); } + load_runtime_project_hook_registry_with_home( + Path::new(trimmed_workspace_root), + dirs::home_dir().as_deref(), + ) +} + +fn load_runtime_project_hook_registry_with_home( + workspace_root: &Path, + home_dir: Option<&Path>, +) -> Result, String> { let registry = Arc::new(HookRegistry::new()); - load_project_hooks_to_registry(Path::new(trimmed_workspace_root), ®istry)?; + load_project_hooks_to_registry(workspace_root, ®istry)?; + + if let Some(home_dir) = home_dir { + let report = + load_runtime_plugin_hook_registry(workspace_root, home_dir, Arc::as_ref(®istry)); + if !report.skipped.is_empty() { + tracing::warn!( + "[AsterAgent] plugin hooks 部分跳过: {}", + report.skipped.join(" | ") + ); + } + if report.registered > 0 { + tracing::info!( + "[AsterAgent] 已加载 plugin hooks: plugins={}, files={}, inline_specs={}, registered={}", + report.plugins, + report.files, + report.inline_specs, + report.registered + ); + } + } + Ok(registry) } +#[derive(Debug, Default)] +struct RuntimePluginHookLoadReport { + plugins: usize, + files: usize, + inline_specs: usize, + registered: usize, + skipped: Vec, +} + +#[derive(Debug, Clone)] +enum PluginManifestHookEntry { + Path { source: String, path: PathBuf }, + Inline(serde_json::Value), +} + +fn load_runtime_plugin_hook_registry( + workspace_root: &Path, + home_dir: &Path, + registry: &HookRegistry, +) -> RuntimePluginHookLoadReport { + let resolved_plugins = + resolve_claude_plugin_cache_entries(Some(workspace_root), Some(home_dir)); + let mut report = RuntimePluginHookLoadReport { + plugins: resolved_plugins.plugins.len(), + files: 0, + inline_specs: 0, + registered: 0, + skipped: resolved_plugins.skipped, + }; + + for plugin in resolved_plugins.plugins { + load_plugin_hooks_from_cached_root(&plugin.plugin_id, &plugin.root, registry, &mut report); + } + + report +} + +fn load_plugin_hooks_from_cached_root( + plugin_id: &str, + plugin_root: &Path, + registry: &HookRegistry, + report: &mut RuntimePluginHookLoadReport, +) { + let manifest_hook_entries = match load_cached_plugin_manifest_json(plugin_root) { + Ok(Some((_manifest_path, manifest))) => match manifest.get("hooks") { + Some(hooks_spec) => match resolve_plugin_manifest_hook_entries(plugin_root, hooks_spec) + { + Ok(entries) => entries, + Err(error) => { + report.skipped.push(format!("{plugin_id}: {error}")); + return; + } + }, + None => Vec::new(), + }, + Ok(None) => Vec::new(), + Err(error) => { + report.skipped.push(format!("{plugin_id}: {error}")); + return; + } + }; + + let mut loaded_hook_files = HashSet::::new(); + let standard_hooks_path = plugin_root.join("hooks").join("hooks.json"); + load_plugin_hook_file( + plugin_id, + &standard_hooks_path, + None, + true, + registry, + &mut loaded_hook_files, + report, + ); + + for entry in manifest_hook_entries { + match entry { + PluginManifestHookEntry::Path { source, path } => load_plugin_hook_file( + plugin_id, + &path, + Some(source.as_str()), + false, + registry, + &mut loaded_hook_files, + report, + ), + PluginManifestHookEntry::Inline(hooks_spec) => { + report.inline_specs += 1; + register_plugin_hook_settings( + plugin_id, + &format!("{plugin_id} manifest.hooks"), + &hooks_spec, + registry, + report, + ); + } + } + } +} + +fn resolve_plugin_manifest_hook_entries( + plugin_root: &Path, + hooks_spec: &serde_json::Value, +) -> Result, String> { + match hooks_spec { + serde_json::Value::String(relative_path) => Ok(vec![PluginManifestHookEntry::Path { + source: relative_path.clone(), + path: resolve_claude_manifest_relative_path( + plugin_root, + relative_path, + ClaudeManifestRelativePathKind::JsonFile, + ) + .map_err(|error| format!("manifest.hooks 路径无效({}):{}", relative_path, error))?, + }]), + serde_json::Value::Array(entries) => { + let mut resolved = Vec::new(); + for entry in entries { + resolved.extend(resolve_plugin_manifest_hook_entries(plugin_root, entry)?); + } + Ok(resolved) + } + serde_json::Value::Object(_) => { + Ok(vec![PluginManifestHookEntry::Inline(hooks_spec.clone())]) + } + _ => Err("manifest.hooks 只能是 ./json 路径、hooks object 或其数组".to_string()), + } +} + +fn load_plugin_hook_file( + plugin_id: &str, + hook_path: &Path, + source_label: Option<&str>, + missing_is_ok: bool, + registry: &HookRegistry, + loaded_hook_files: &mut HashSet, + report: &mut RuntimePluginHookLoadReport, +) { + if !hook_path.exists() { + if !missing_is_ok { + report.skipped.push(format!( + "{plugin_id}: hook 文件不存在 ({})", + hook_path.display() + )); + } + return; + } + + let normalized_path = hook_path + .canonicalize() + .unwrap_or_else(|_| hook_path.to_path_buf()); + if !loaded_hook_files.insert(normalized_path.clone()) { + let source_label = source_label + .map(std::borrow::ToOwned::to_owned) + .unwrap_or_else(|| hook_path.display().to_string()); + report.skipped.push(format!( + "{plugin_id}: Duplicate hooks file detected: {} resolves to already-loaded file {}. The standard hooks/hooks.json is loaded automatically, so manifest.hooks should only reference additional hook files.", + source_label, + normalized_path.display() + )); + return; + } + + let Ok(content) = fs::read_to_string(hook_path) else { + report.skipped.push(format!( + "{plugin_id}: 读取 hook 文件失败 ({})", + hook_path.display() + )); + return; + }; + let Ok(parsed) = serde_json::from_str::(&content) else { + report.skipped.push(format!( + "{plugin_id}: hook 文件 JSON 解析失败 ({})", + hook_path.display() + )); + return; + }; + + let hook_settings = parsed + .get("hooks") + .cloned() + .unwrap_or_else(|| parsed.clone()); + report.files += 1; + register_plugin_hook_settings( + plugin_id, + normalized_path.to_string_lossy().as_ref(), + &hook_settings, + registry, + report, + ); +} + +fn register_plugin_hook_settings( + plugin_id: &str, + source_label: &str, + hook_settings: &serde_json::Value, + registry: &HookRegistry, + report: &mut RuntimePluginHookLoadReport, +) { + let Ok(frontmatter_hooks) = parse_supported_frontmatter_hooks(hook_settings) else { + report + .skipped + .push(format!("{plugin_id}: hooks 配置无效 ({source_label})")); + return; + }; + + let registration_report = register_frontmatter_hooks_to_registry(registry, &frontmatter_hooks); + report.registered += registration_report.registered; + report.skipped.extend( + registration_report + .skipped + .into_iter() + .map(|item| format!("{plugin_id}: {item} ({source_label})")), + ); +} + +fn parse_supported_frontmatter_hooks( + value: &serde_json::Value, +) -> Result { + let Some(object) = value.as_object() else { + return Err("hooks 必须是 object".to_string()); + }; + + let filtered = object + .iter() + .filter(|(event_name, _)| parse_supported_hook_event(event_name).is_some()) + .map(|(event_name, hook_value)| (event_name.clone(), hook_value.clone())) + .collect::>(); + + serde_json::from_value(serde_json::Value::Object(filtered)) + .map_err(|error| format!("解析 hooks 失败: {error}")) +} + +fn parse_supported_hook_event(event_name: &str) -> Option { + serde_json::from_value::(serde_json::Value::String(event_name.to_string())).ok() +} + +fn register_frontmatter_hooks_to_registry( + registry: &HookRegistry, + hooks: &FrontmatterHooks, +) -> SessionHookRegistrationReport { + let mut report = SessionHookRegistrationReport::default(); + + for (event, matchers) in hooks { + for matcher in matchers { + for hook in &matcher.hooks { + match hook.to_registration(matcher.matcher.as_deref()) { + Ok(registration) => { + registry.register(*event, registration.config); + report.registered += 1; + } + Err(error) => report.skipped.push(format!("{event}: {error}")), + } + } + } + } + + report +} + fn build_runtime_project_hook_context( db: &DbConnection, state: &AsterAgentState, @@ -232,6 +537,244 @@ fn log_runtime_project_hook_results(event_name: &str, results: &[HookResult]) { } } +#[derive(Debug, Clone, PartialEq, Eq)] +struct RuntimeStopHookContinuationRequest { + stop_reason: Option, +} + +fn parse_runtime_stop_hook_continuation_request( + results: &[HookResult], +) -> Option { + for result in results { + let Some(output) = result.output.as_deref() else { + continue; + }; + let Ok(json) = serde_json::from_str::(output) else { + continue; + }; + if json.get("continue").and_then(serde_json::Value::as_bool) != Some(false) { + continue; + } + + let stop_reason = json + .get("stopReason") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string); + return Some(RuntimeStopHookContinuationRequest { stop_reason }); + } + + None +} + +fn build_runtime_stop_hook_unsupported_warning_message( + request: &RuntimeStopHookContinuationRequest, +) -> String { + let stop_reason_suffix = request + .stop_reason + .as_deref() + .map(|reason| format!(" (stopReason={reason})")) + .unwrap_or_default(); + + format!( + "Stop hook 请求 continue=false{stop_reason_suffix},但 Lime 当前 runtime 仍无与 Claude Code 等价的 continuation gate;本次只记录,不会阻断 turn 收尾" + ) +} + +fn log_runtime_stop_hook_results(results: &[HookResult]) -> Option { + log_runtime_project_hook_results("Stop", results); + + if let Some(request) = parse_runtime_stop_hook_continuation_request(results) { + let message = build_runtime_stop_hook_unsupported_warning_message(&request); + tracing::warn!("[AsterAgent] {}", message); + return Some(message); + } + + None +} + +fn build_runtime_session_start_unsupported_warning_message( + source: SessionSource, +) -> Option { + let source_label = match source { + SessionSource::Startup | SessionSource::Compact => return None, + SessionSource::Resume => "resume", + SessionSource::Clear => "clear", + }; + + Some(format!( + "SessionStart hook 请求 source={source_label},但 Lime 当前 runtime 只有 create_session(startup) 与 compact 的 honest host;本次不会执行这类 lifecycle hook" + )) +} + +fn build_runtime_session_end_unsupported_warning_message( + reason: SessionEndReason, +) -> Option { + let reason_label = match reason { + SessionEndReason::Other => return None, + SessionEndReason::Clear => "clear", + SessionEndReason::Logout => "logout", + SessionEndReason::PromptInputExit => "prompt_input_exit", + }; + + Some(format!( + "SessionEnd hook 请求 reason={reason_label},但 Lime 当前 runtime 只有 delete_session -> other 的 honest host;本次不会执行这类 lifecycle hook" + )) +} + +#[derive(Debug, Clone, PartialEq)] +struct RuntimePermissionRequestHookRequest { + decision: PermissionRequestHookDecision, + updated_permissions_requested: bool, + interrupt_requested: bool, +} + +fn log_runtime_permission_request_hook_results( + results: &[HookResult], + _request: Option<&RuntimePermissionRequestHookRequest>, +) { + for result in results { + if result.blocked { + tracing::warn!( + "[AsterAgent] PermissionRequest hook 返回 blocked,但当前入口只认 allow/deny JSON: {}", + result.block_message.as_deref().unwrap_or("未提供阻止原因") + ); + continue; + } + + if !result.success { + tracing::warn!( + "[AsterAgent] PermissionRequest hook 执行失败: {}", + result.error.as_deref().unwrap_or("未知错误") + ); + } + } +} + +fn parse_runtime_permission_request_hook_request( + results: &[HookResult], +) -> Option { + for result in results { + let Some(output) = result.output.as_deref() else { + continue; + }; + let Ok(json) = serde_json::from_str::(output) else { + continue; + }; + let Some(decision) = json.get("decision").and_then(serde_json::Value::as_str) else { + continue; + }; + + match decision { + "allow" => { + let updated_input = match json.get("updatedInput") { + Some(serde_json::Value::Object(value)) => Some(value.clone()), + Some(_) => { + tracing::warn!( + "[AsterAgent] PermissionRequest hook 的 updatedInput 必须是 object,已忽略该 decision" + ); + continue; + } + None => None, + }; + return Some(RuntimePermissionRequestHookRequest { + decision: PermissionRequestHookDecision::Allow { updated_input }, + updated_permissions_requested: json.get("updatedPermissions").is_some(), + interrupt_requested: false, + }); + } + "deny" => { + let message = json + .get("message") + .and_then(serde_json::Value::as_str) + .map(ToString::to_string); + let interrupt_requested = match json.get("interrupt") { + Some(serde_json::Value::Bool(value)) => *value, + Some(_) => { + tracing::warn!( + "[AsterAgent] PermissionRequest hook 的 interrupt 必须是 boolean,已忽略该字段" + ); + false + } + None => false, + }; + return Some(RuntimePermissionRequestHookRequest { + decision: PermissionRequestHookDecision::Deny { message }, + updated_permissions_requested: false, + interrupt_requested, + }); + } + _ => continue, + } + } + + None +} + +fn build_runtime_permission_request_interrupt_reason(message: Option<&str>) -> String { + let detail = message.map(str::trim).filter(|value| !value.is_empty()); + match detail { + Some(detail) => format!("PermissionRequest hook 请求中断当前执行:{detail}"), + None => "PermissionRequest hook 请求中断当前执行".to_string(), + } +} + +fn should_fallback_to_native_permission_approval( + request: &RuntimePermissionRequestHookRequest, +) -> bool { + request.updated_permissions_requested + && matches!( + request.decision, + PermissionRequestHookDecision::Allow { .. } + ) +} + +fn normalize_runtime_permission_request_hook_request( + request: Option, +) -> Option { + let request = request?; + if should_fallback_to_native_permission_approval(&request) { + tracing::warn!( + "[AsterAgent] PermissionRequest hook 请求 allow + updatedPermissions,但 Lime 当前 runtime 仍无与 Claude Code 等价的 permission update host;为避免假对齐,本次回退到原生审批流,不执行 hook allow,也不会应用 updatedInput/updatedPermissions" + ); + return None; + } + + Some(request) +} + +async fn apply_runtime_permission_request_hook_side_effects( + state: &AsterAgentState, + session_id: &str, + request: &RuntimePermissionRequestHookRequest, +) { + if !request.interrupt_requested { + return; + } + + let cancelled = state.cancel_session(session_id).await; + if !cancelled { + tracing::warn!( + "[AsterAgent] PermissionRequest hook 请求 interrupt=true,但当前 session 未找到可取消的 turn token;本次不会中断当前执行" + ); + return; + } + + let reason = match &request.decision { + PermissionRequestHookDecision::Deny { message } => { + build_runtime_permission_request_interrupt_reason(message.as_deref()) + } + PermissionRequestHookDecision::Allow { .. } => { + "PermissionRequest hook 请求中断当前执行".to_string() + } + }; + let _ = state + .record_interrupt_request(session_id, "hook", &reason) + .await; +} + +#[cfg(test)] pub(crate) async fn enforce_runtime_turn_user_prompt_submit_hooks( prompt: &str, session_id: &str, @@ -278,11 +821,145 @@ pub(crate) async fn enforce_runtime_turn_user_prompt_submit_hooks_with_runtime( )) } +#[cfg(test)] +pub(crate) async fn decide_runtime_permission_request_project_hooks( + session_id: &str, + workspace_root: &str, + tool_name: &str, + tool_input: Option, + tool_use_id: &str, + permission_mode: Option, +) -> Result, String> { + let registry = load_runtime_project_hook_registry(workspace_root) + .map_err(|error| format!("加载 PermissionRequest project hooks 失败: {error}"))?; + let results = run_hooks_with_registry( + HookInput { + event: Some(HookEvent::PermissionRequest), + tool_name: Some(tool_name.to_string()), + tool_input, + tool_use_id: Some(tool_use_id.to_string()), + session_id: Some(session_id.to_string()), + permission_mode, + ..Default::default() + }, + ®istry, + ) + .await; + let request = normalize_runtime_permission_request_hook_request( + parse_runtime_permission_request_hook_request(&results), + ); + log_runtime_permission_request_hook_results(&results, request.as_ref()); + Ok(request.map(|value| value.decision)) +} + +pub(crate) async fn decide_runtime_permission_request_project_hooks_with_runtime( + session_id: &str, + workspace_root: &str, + tool_name: &str, + tool_input: Option, + tool_use_id: &str, + permission_mode: Option, + db: &DbConnection, + state: &AsterAgentState, + mcp_manager: &McpManagerState, +) -> Result, String> { + let registry = load_runtime_project_hook_registry(workspace_root) + .map_err(|error| format!("加载 PermissionRequest project hooks 失败: {error}"))?; + let runtime = build_runtime_project_hook_context(db, state, mcp_manager); + let results = run_hooks_with_registry_and_context( + HookInput { + event: Some(HookEvent::PermissionRequest), + tool_name: Some(tool_name.to_string()), + tool_input, + tool_use_id: Some(tool_use_id.to_string()), + session_id: Some(session_id.to_string()), + permission_mode, + ..Default::default() + }, + ®istry, + &runtime, + ) + .await; + let request = normalize_runtime_permission_request_hook_request( + parse_runtime_permission_request_hook_request(&results), + ); + log_runtime_permission_request_hook_results(&results, request.as_ref()); + if let Some(request) = request.as_ref() { + apply_runtime_permission_request_hook_side_effects(state, session_id, request).await; + } + Ok(request.map(|value| value.decision)) +} + +#[cfg(test)] +pub(crate) async fn enforce_runtime_pre_compact_project_hooks( + session_id: &str, + workspace_root: &str, + current_tokens: Option, + trigger: CompactTrigger, +) -> Result<(), String> { + let registry = load_runtime_project_hook_registry(workspace_root) + .map_err(|error| format!("加载 PreCompact project hooks 失败: {error}"))?; + let results = run_pre_compact_hooks_with_registry( + Some(session_id.to_string()), + current_tokens, + Some(trigger), + ®istry, + ) + .await; + log_runtime_project_hook_results("PreCompact", &results); + let (blocked, message) = is_blocked(&results); + if !blocked { + return Ok(()); + } + + Err(format!( + "PreCompact hook 已阻止本次压缩:{}", + message.unwrap_or_else(|| "未提供阻止原因".to_string()) + )) +} + +pub(crate) async fn enforce_runtime_pre_compact_project_hooks_with_runtime( + session_id: &str, + workspace_root: &str, + current_tokens: Option, + trigger: CompactTrigger, + db: &DbConnection, + state: &AsterAgentState, + mcp_manager: &McpManagerState, +) -> Result<(), String> { + let registry = load_runtime_project_hook_registry(workspace_root) + .map_err(|error| format!("加载 PreCompact project hooks 失败: {error}"))?; + let runtime = build_runtime_project_hook_context(db, state, mcp_manager); + let results = run_pre_compact_hooks_with_registry_and_context( + Some(session_id.to_string()), + current_tokens, + Some(trigger), + ®istry, + &runtime, + ) + .await; + log_runtime_project_hook_results("PreCompact", &results); + let (blocked, message) = is_blocked(&results); + if !blocked { + return Ok(()); + } + + Err(format!( + "PreCompact hook 已阻止本次压缩:{}", + message.unwrap_or_else(|| "未提供阻止原因".to_string()) + )) +} + pub(crate) async fn run_runtime_session_start_project_hooks( session_id: &str, workspace_root: &str, source: SessionSource, ) { + if let Some(message) = build_runtime_session_start_unsupported_warning_message(source) { + tracing::warn!("[AsterAgent] {}", message); + return; + } + let registry = match load_runtime_project_hook_registry(workspace_root) { Ok(registry) => registry, Err(error) => { @@ -309,6 +986,11 @@ pub(crate) async fn run_runtime_session_start_project_hooks_with_runtime( state: &AsterAgentState, mcp_manager: &McpManagerState, ) { + if let Some(message) = build_runtime_session_start_unsupported_warning_message(source) { + tracing::warn!("[AsterAgent] {}", message); + return; + } + let registry = match load_runtime_project_hook_registry(workspace_root) { Ok(registry) => registry, Err(error) => { @@ -332,6 +1014,146 @@ pub(crate) async fn run_runtime_session_start_project_hooks_with_runtime( log_runtime_project_hook_results("SessionStart", &results); } +#[cfg(test)] +pub(crate) async fn run_runtime_stop_project_hooks( + session_id: &str, + workspace_root: &str, + stop_hook_active: bool, + last_assistant_message: Option<&str>, +) -> Option { + let registry = match load_runtime_project_hook_registry(workspace_root) { + Ok(registry) => registry, + Err(error) => { + tracing::warn!( + "[AsterAgent] 加载 Stop project hooks 失败: workspace_root={}, error={}", + workspace_root, + error + ); + return None; + } + }; + + let results = run_hooks_with_registry( + HookInput { + event: Some(HookEvent::Stop), + session_id: Some(session_id.to_string()), + stop_hook_active: Some(stop_hook_active), + last_assistant_message: last_assistant_message + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string), + ..Default::default() + }, + ®istry, + ) + .await; + log_runtime_stop_hook_results(&results) +} + +pub(crate) async fn run_runtime_stop_project_hooks_with_runtime( + session_id: &str, + workspace_root: &str, + stop_hook_active: bool, + last_assistant_message: Option<&str>, + db: &DbConnection, + state: &AsterAgentState, + mcp_manager: &McpManagerState, +) -> Option { + let registry = match load_runtime_project_hook_registry(workspace_root) { + Ok(registry) => registry, + Err(error) => { + tracing::warn!( + "[AsterAgent] 加载 Stop project hooks 失败: workspace_root={}, error={}", + workspace_root, + error + ); + return None; + } + }; + + let runtime = build_runtime_project_hook_context(db, state, mcp_manager); + let results = run_hooks_with_registry_and_context( + HookInput { + event: Some(HookEvent::Stop), + session_id: Some(session_id.to_string()), + stop_hook_active: Some(stop_hook_active), + last_assistant_message: last_assistant_message + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string), + ..Default::default() + }, + ®istry, + &runtime, + ) + .await; + log_runtime_stop_hook_results(&results) +} + +#[cfg(test)] +pub(crate) async fn run_runtime_session_end_project_hooks( + session_id: &str, + workspace_root: &str, + reason: SessionEndReason, +) { + if let Some(message) = build_runtime_session_end_unsupported_warning_message(reason) { + tracing::warn!("[AsterAgent] {}", message); + return; + } + + let registry = match load_runtime_project_hook_registry(workspace_root) { + Ok(registry) => registry, + Err(error) => { + tracing::warn!( + "[AsterAgent] 加载 SessionEnd project hooks 失败: workspace_root={}, error={}", + workspace_root, + error + ); + return; + } + }; + + let results = + run_session_end_hooks_with_registry(session_id.to_string(), Some(reason), ®istry).await; + log_runtime_project_hook_results("SessionEnd", &results); +} + +pub(crate) async fn run_runtime_session_end_project_hooks_with_runtime( + session_id: &str, + workspace_root: &str, + reason: SessionEndReason, + db: &DbConnection, + state: &AsterAgentState, + mcp_manager: &McpManagerState, +) { + if let Some(message) = build_runtime_session_end_unsupported_warning_message(reason) { + tracing::warn!("[AsterAgent] {}", message); + return; + } + + let registry = match load_runtime_project_hook_registry(workspace_root) { + Ok(registry) => registry, + Err(error) => { + tracing::warn!( + "[AsterAgent] 加载 SessionEnd project hooks 失败: workspace_root={}, error={}", + workspace_root, + error + ); + return; + } + }; + + let runtime = build_runtime_project_hook_context(db, state, mcp_manager); + let results = run_session_end_hooks_with_registry_and_context( + session_id.to_string(), + Some(reason), + ®istry, + &runtime, + ) + .await; + log_runtime_project_hook_results("SessionEnd", &results); +} + pub(crate) fn resolve_runtime_project_hook_workspace_root( db: &DbConnection, session_id: &str, @@ -389,6 +1211,7 @@ pub(crate) fn resolve_runtime_project_hook_workspace_root( .ok_or_else(|| format!("SessionStart project hooks 缺少可用 workspace_root: {session_id}")) } +#[cfg(test)] pub(crate) async fn run_runtime_session_start_project_hooks_for_session( db: &DbConnection, session_id: &str, @@ -409,6 +1232,121 @@ pub(crate) async fn run_runtime_session_start_project_hooks_for_session( run_runtime_session_start_project_hooks(session_id, &workspace_root, source).await; } +#[cfg(test)] +pub(crate) async fn run_runtime_stop_project_hooks_for_session( + db: &DbConnection, + session_id: &str, + stop_hook_active: bool, + last_assistant_message: Option<&str>, +) -> Option { + let workspace_root = match resolve_runtime_project_hook_workspace_root(db, session_id) { + Ok(workspace_root) => workspace_root, + Err(error) => { + tracing::warn!( + "[AsterAgent] 解析 Stop project hooks workspace_root 失败: session_id={}, error={}", + session_id, + error + ); + return None; + } + }; + + run_runtime_stop_project_hooks( + session_id, + &workspace_root, + stop_hook_active, + last_assistant_message, + ) + .await +} + +#[cfg(test)] +pub(crate) async fn run_runtime_session_end_project_hooks_for_session( + db: &DbConnection, + session_id: &str, + reason: SessionEndReason, +) { + let workspace_root = match resolve_runtime_project_hook_workspace_root(db, session_id) { + Ok(workspace_root) => workspace_root, + Err(error) => { + tracing::warn!( + "[AsterAgent] 解析 SessionEnd project hooks workspace_root 失败: session_id={}, error={}", + session_id, + error + ); + return; + } + }; + + run_runtime_session_end_project_hooks(session_id, &workspace_root, reason).await; +} + +#[cfg(test)] +pub(crate) async fn enforce_runtime_pre_compact_project_hooks_for_session( + db: &DbConnection, + session_id: &str, + current_tokens: Option, + trigger: CompactTrigger, +) -> Result<(), String> { + let workspace_root = resolve_runtime_project_hook_workspace_root(db, session_id) + .map_err(|error| format!("解析 PreCompact project hooks workspace_root 失败: {error}"))?; + + enforce_runtime_pre_compact_project_hooks(session_id, &workspace_root, current_tokens, trigger) + .await +} + +pub(crate) async fn enforce_runtime_pre_compact_project_hooks_for_session_with_runtime( + db: &DbConnection, + state: &AsterAgentState, + mcp_manager: &McpManagerState, + session_id: &str, + current_tokens: Option, + trigger: CompactTrigger, +) -> Result<(), String> { + let workspace_root = resolve_runtime_project_hook_workspace_root(db, session_id) + .map_err(|error| format!("解析 PreCompact project hooks workspace_root 失败: {error}"))?; + + enforce_runtime_pre_compact_project_hooks_with_runtime( + session_id, + &workspace_root, + current_tokens, + trigger, + db, + state, + mcp_manager, + ) + .await +} + +pub(crate) async fn decide_runtime_permission_request_project_hooks_for_session_with_runtime( + db: &DbConnection, + state: &AsterAgentState, + mcp_manager: &McpManagerState, + session_id: &str, + tool_name: &str, + tool_input: Option, + tool_use_id: &str, + permission_mode: Option, +) -> Result, String> { + let workspace_root = + resolve_runtime_project_hook_workspace_root(db, session_id).map_err(|error| { + format!("解析 PermissionRequest project hooks workspace_root 失败: {error}") + })?; + + decide_runtime_permission_request_project_hooks_with_runtime( + session_id, + &workspace_root, + tool_name, + tool_input, + tool_use_id, + permission_mode, + db, + state, + mcp_manager, + ) + .await +} + pub(crate) async fn run_runtime_session_start_project_hooks_for_session_with_runtime( db: &DbConnection, state: &AsterAgentState, @@ -439,10 +1377,76 @@ pub(crate) async fn run_runtime_session_start_project_hooks_for_session_with_run .await; } +pub(crate) async fn run_runtime_stop_project_hooks_for_session_with_runtime( + db: &DbConnection, + state: &AsterAgentState, + mcp_manager: &McpManagerState, + session_id: &str, + stop_hook_active: bool, + last_assistant_message: Option<&str>, +) -> Option { + let workspace_root = match resolve_runtime_project_hook_workspace_root(db, session_id) { + Ok(workspace_root) => workspace_root, + Err(error) => { + tracing::warn!( + "[AsterAgent] 解析 Stop project hooks workspace_root 失败: session_id={}, error={}", + session_id, + error + ); + return None; + } + }; + + run_runtime_stop_project_hooks_with_runtime( + session_id, + &workspace_root, + stop_hook_active, + last_assistant_message, + db, + state, + mcp_manager, + ) + .await +} + +pub(crate) async fn run_runtime_session_end_project_hooks_for_session_with_runtime( + db: &DbConnection, + state: &AsterAgentState, + mcp_manager: &McpManagerState, + session_id: &str, + reason: SessionEndReason, +) { + let workspace_root = match resolve_runtime_project_hook_workspace_root(db, session_id) { + Ok(workspace_root) => workspace_root, + Err(error) => { + tracing::warn!( + "[AsterAgent] 解析 SessionEnd project hooks workspace_root 失败: session_id={}, error={}", + session_id, + error + ); + return; + } + }; + + run_runtime_session_end_project_hooks_with_runtime( + session_id, + &workspace_root, + reason, + db, + state, + mcp_manager, + ) + .await; +} + #[cfg(test)] mod tests { use super::*; use crate::tests::runtime_test_support::shared_aster_runtime_test_root; + use aster::hooks::{ + clear_session_hooks, register_session_frontmatter_hooks, FrontmatterCommandHookConfig, + FrontmatterHookCommand, FrontmatterHookMatcher, FrontmatterHooks, HookEvent, + }; use aster::session::{ delete_managed_session, initialize_shared_session_runtime_with_root, is_global_session_store_set, @@ -451,6 +1455,7 @@ mod tests { use lime_services::aster_session_store::LimeSessionStore; use rmcp::model::Content; use rusqlite::Connection; + use std::collections::HashMap; use tokio::sync::OnceCell; async fn ensure_runtime_project_hooks_test_manager() { @@ -500,6 +1505,315 @@ mod tests { .expect("写入 settings.json 失败"); } + fn write_session_end_capture_hook(workspace_root: &Path, output_path: &Path) { + let claude_dir = workspace_root.join(".claude"); + std::fs::create_dir_all(&claude_dir).expect("创建 .claude 目录失败"); + + let settings = serde_json::json!({ + "hooks": { + "SessionEnd": [ + { + "type": "command", + "command": "cat > \"$HOOK_OUTPUT_PATH\"", + "blocking": true, + "env": { + "HOOK_OUTPUT_PATH": output_path.to_string_lossy().to_string(), + } + } + ] + } + }); + std::fs::write( + claude_dir.join("settings.json"), + serde_json::to_string_pretty(&settings).expect("序列化 settings 失败"), + ) + .expect("写入 settings.json 失败"); + } + + fn write_stop_capture_hook(workspace_root: &Path, output_path: &Path) { + let claude_dir = workspace_root.join(".claude"); + std::fs::create_dir_all(&claude_dir).expect("创建 .claude 目录失败"); + + let settings = serde_json::json!({ + "hooks": { + "Stop": [ + { + "type": "command", + "command": "cat > \"$HOOK_OUTPUT_PATH\"", + "blocking": true, + "env": { + "HOOK_OUTPUT_PATH": output_path.to_string_lossy().to_string(), + } + } + ] + } + }); + std::fs::write( + claude_dir.join("settings.json"), + serde_json::to_string_pretty(&settings).expect("序列化 settings 失败"), + ) + .expect("写入 settings.json 失败"); + } + + fn write_stop_continuation_hook(workspace_root: &Path, stop_reason: Option<&str>) { + let claude_dir = workspace_root.join(".claude"); + std::fs::create_dir_all(&claude_dir).expect("创建 .claude 目录失败"); + + let mut payload = serde_json::Map::new(); + payload.insert("continue".to_string(), serde_json::Value::Bool(false)); + if let Some(stop_reason) = stop_reason { + payload.insert( + "stopReason".to_string(), + serde_json::Value::String(stop_reason.to_string()), + ); + } + let output = serde_json::Value::Object(payload).to_string(); + + let settings = serde_json::json!({ + "hooks": { + "Stop": [ + { + "type": "command", + "command": format!("printf '%s' '{}'", output.replace('\'', "'\\''")), + "blocking": true, + } + ] + } + }); + std::fs::write( + claude_dir.join("settings.json"), + serde_json::to_string_pretty(&settings).expect("序列化 settings 失败"), + ) + .expect("写入 settings.json 失败"); + } + + fn write_pre_compact_capture_hook(workspace_root: &Path, output_path: &Path) { + let claude_dir = workspace_root.join(".claude"); + std::fs::create_dir_all(&claude_dir).expect("创建 .claude 目录失败"); + + let settings = serde_json::json!({ + "hooks": { + "PreCompact": [ + { + "type": "command", + "command": "cat > \"$HOOK_OUTPUT_PATH\"", + "blocking": true, + "env": { + "HOOK_OUTPUT_PATH": output_path.to_string_lossy().to_string(), + } + } + ] + } + }); + std::fs::write( + claude_dir.join("settings.json"), + serde_json::to_string_pretty(&settings).expect("序列化 settings 失败"), + ) + .expect("写入 settings.json 失败"); + } + + fn write_blocking_pre_compact_hook(workspace_root: &Path, message: &str) { + let claude_dir = workspace_root.join(".claude"); + std::fs::create_dir_all(&claude_dir).expect("创建 .claude 目录失败"); + + let blocking_payload = serde_json::json!({ + "blocked": true, + "message": message, + }) + .to_string(); + let settings = serde_json::json!({ + "hooks": { + "PreCompact": [ + { + "type": "command", + "command": format!("printf '%s' '{blocking_payload}'; exit 2"), + "blocking": true, + } + ] + } + }); + std::fs::write( + claude_dir.join("settings.json"), + serde_json::to_string_pretty(&settings).expect("序列化 settings 失败"), + ) + .expect("写入 settings.json 失败"); + } + + fn write_permission_request_capture_hook( + workspace_root: &Path, + output_path: &Path, + decision: &str, + message: Option<&str>, + updated_input: Option, + updated_permissions: Option, + interrupt: Option, + ) { + let claude_dir = workspace_root.join(".claude"); + std::fs::create_dir_all(&claude_dir).expect("创建 .claude 目录失败"); + + let mut payload = serde_json::Map::new(); + payload.insert( + "decision".to_string(), + serde_json::Value::String(decision.to_string()), + ); + if let Some(message) = message { + payload.insert( + "message".to_string(), + serde_json::Value::String(message.to_string()), + ); + } + if let Some(updated_input) = updated_input { + payload.insert("updatedInput".to_string(), updated_input); + } + if let Some(updated_permissions) = updated_permissions { + payload.insert("updatedPermissions".to_string(), updated_permissions); + } + if let Some(interrupt) = interrupt { + payload.insert("interrupt".to_string(), serde_json::Value::Bool(interrupt)); + } + + let decision_payload = serde_json::Value::Object(payload).to_string(); + + let settings = serde_json::json!({ + "hooks": { + "PermissionRequest": [ + { + "type": "command", + "command": format!( + "cat > \"$HOOK_OUTPUT_PATH\"; printf '%s' '{}'", + decision_payload.replace('\'', "'\\''"), + ), + "blocking": true, + "env": { + "HOOK_OUTPUT_PATH": output_path.to_string_lossy().to_string(), + } + } + ] + } + }); + std::fs::write( + claude_dir.join("settings.json"), + serde_json::to_string_pretty(&settings).expect("序列化 settings 失败"), + ) + .expect("写入 settings.json 失败"); + } + + fn write_enabled_plugin_settings(home_root: &Path, plugin_id: &str) { + let claude_dir = home_root.join(".claude"); + std::fs::create_dir_all(&claude_dir).expect("创建 home .claude 目录失败"); + let settings = serde_json::json!({ + "enabledPlugins": { + plugin_id: true + } + }); + std::fs::write( + claude_dir.join("settings.json"), + serde_json::to_string_pretty(&settings).expect("序列化 plugin settings 失败"), + ) + .expect("写入 plugin settings 失败"); + } + + fn write_plugin_at_root( + plugin_root: &Path, + manifest: serde_json::Value, + standard_hooks: Option, + ) { + write_plugin_at_root_with_manifest_path( + plugin_root, + &plugin_root.join(".claude-plugin").join("plugin.json"), + manifest, + standard_hooks, + ); + } + + fn write_plugin_at_root_with_manifest_path( + plugin_root: &Path, + manifest_path: &Path, + manifest: serde_json::Value, + standard_hooks: Option, + ) { + if let Some(parent) = manifest_path.parent() { + std::fs::create_dir_all(parent).expect("创建 plugin manifest 目录失败"); + } + std::fs::write( + manifest_path, + serde_json::to_string_pretty(&manifest).expect("序列化 plugin manifest 失败"), + ) + .expect("写入 plugin manifest 失败"); + + if let Some(standard_hooks) = standard_hooks { + std::fs::create_dir_all(plugin_root.join("hooks")).expect("创建 hooks 目录失败"); + std::fs::write( + plugin_root.join("hooks").join("hooks.json"), + serde_json::to_string_pretty(&standard_hooks) + .expect("序列化 standard plugin hooks 失败"), + ) + .expect("写入 standard plugin hooks 失败"); + } + } + + fn write_cached_plugin( + home_root: &Path, + plugin_name: &str, + marketplace: &str, + version: &str, + manifest: serde_json::Value, + standard_hooks: Option, + ) -> PathBuf { + let plugin_root = home_root + .join(".claude") + .join("plugins") + .join("cache") + .join(marketplace) + .join(plugin_name) + .join(version); + write_plugin_at_root(&plugin_root, manifest, standard_hooks); + plugin_root + } + + fn write_cached_plugin_with_legacy_manifest( + home_root: &Path, + plugin_name: &str, + marketplace: &str, + version: &str, + manifest: serde_json::Value, + standard_hooks: Option, + ) -> PathBuf { + let plugin_root = home_root + .join(".claude") + .join("plugins") + .join("cache") + .join(marketplace) + .join(plugin_name) + .join(version); + write_plugin_at_root_with_manifest_path( + &plugin_root, + &plugin_root.join("plugin.json"), + manifest, + standard_hooks, + ); + plugin_root + } + + fn write_legacy_cached_plugin( + home_root: &Path, + plugin_name: &str, + manifest: serde_json::Value, + standard_hooks: Option, + ) -> PathBuf { + let plugin_root = home_root + .join(".claude") + .join("plugins") + .join("cache") + .join(plugin_name); + write_plugin_at_root(&plugin_root, manifest, standard_hooks); + plugin_root + } + + fn shell_quote(path: &Path) -> String { + format!("'{}'", path.to_string_lossy().replace('\'', "'\\''")) + } + #[tokio::test] async fn run_runtime_session_start_project_hooks_for_session_should_use_workspace_root_for_compact_source( ) { @@ -563,6 +1877,77 @@ mod tests { .expect("清理测试 session 失败"); } + #[test] + fn build_runtime_session_start_unsupported_warning_message_should_allow_only_current_sources() { + assert_eq!( + build_runtime_session_start_unsupported_warning_message(SessionSource::Startup), + None + ); + assert_eq!( + build_runtime_session_start_unsupported_warning_message(SessionSource::Compact), + None + ); + assert!( + build_runtime_session_start_unsupported_warning_message(SessionSource::Resume) + .expect("resume 应提示 unsupported") + .contains("source=resume") + ); + assert!( + build_runtime_session_start_unsupported_warning_message(SessionSource::Clear) + .expect("clear 应提示 unsupported") + .contains("source=clear") + ); + } + + #[tokio::test] + async fn run_runtime_session_start_project_hooks_for_session_should_skip_unsupported_resume_source( + ) { + ensure_runtime_project_hooks_test_manager().await; + + let conn = Connection::open_in_memory().expect("创建内存数据库失败"); + create_tables(&conn).expect("初始化表结构失败"); + let db = Arc::new(Mutex::new(conn)); + + let temp_dir = tempfile::TempDir::new().expect("create temp dir"); + let workspace_root = temp_dir.path().join("session-start-resume-workspace"); + std::fs::create_dir_all(&workspace_root).expect("创建 workspace 目录失败"); + let hook_output_path = temp_dir.path().join("session-start-resume.json"); + write_session_start_capture_hook(&workspace_root, &hook_output_path); + + let manager = WorkspaceManager::new(db.clone()); + let workspace = manager + .create( + "Runtime SessionStart Resume Workspace".to_string(), + workspace_root.clone(), + ) + .expect("创建 workspace 失败"); + + let session_id = AsterAgentWrapper::create_session_sync( + &db, + Some("Runtime SessionStart Resume Session".to_string()), + Some(workspace_root.to_string_lossy().to_string()), + workspace.id.clone(), + Some(AsterExecutionStrategy::React.as_db_value().to_string()), + ) + .expect("创建 session 失败"); + + run_runtime_session_start_project_hooks_for_session( + &db, + &session_id, + SessionSource::Resume, + ) + .await; + + assert!( + !hook_output_path.exists(), + "unsupported SessionStart resume 不应执行任何 hook" + ); + + delete_managed_session(&session_id) + .await + .expect("清理测试 session 失败"); + } + #[tokio::test] async fn run_runtime_session_start_project_hooks_for_session_should_fallback_to_working_dir_when_workspace_missing( ) { @@ -614,6 +1999,1318 @@ mod tests { .expect("清理测试 session 失败"); } + #[tokio::test] + async fn run_runtime_session_start_project_hooks_for_session_should_merge_session_skill_hooks() + { + ensure_runtime_project_hooks_test_manager().await; + + let conn = Connection::open_in_memory().expect("创建内存数据库失败"); + create_tables(&conn).expect("初始化表结构失败"); + let db = Arc::new(Mutex::new(conn)); + + let temp_dir = tempfile::TempDir::new().expect("create temp dir"); + let workspace_root = temp_dir.path().join("session-skill-hooks-workspace"); + std::fs::create_dir_all(&workspace_root).expect("创建 workspace 目录失败"); + let hook_output_path = temp_dir.path().join("session-skill-hook.json"); + + let manager = WorkspaceManager::new(db.clone()); + let workspace = manager + .create( + "Runtime Session Skill Hook Workspace".to_string(), + workspace_root.clone(), + ) + .expect("创建 workspace 失败"); + + let session_id = AsterAgentWrapper::create_session_sync( + &db, + Some("Runtime Session Skill Hook Session".to_string()), + Some(workspace_root.to_string_lossy().to_string()), + workspace.id.clone(), + Some(AsterExecutionStrategy::React.as_db_value().to_string()), + ) + .expect("创建 session 失败"); + + let mut hooks: FrontmatterHooks = HashMap::new(); + hooks.insert( + HookEvent::SessionStart, + vec![FrontmatterHookMatcher { + matcher: None, + hooks: vec![FrontmatterHookCommand::Command( + FrontmatterCommandHookConfig { + command: format!("cat > {}", shell_quote(&hook_output_path)), + timeout: None, + once: false, + shell: None, + if_condition: None, + status_message: None, + async_mode: false, + async_rewake: false, + }, + )], + }], + ); + + let report = register_session_frontmatter_hooks(&session_id, &hooks); + assert_eq!(report.registered, 1); + assert!(report.skipped.is_empty()); + + run_runtime_session_start_project_hooks_for_session( + &db, + &session_id, + SessionSource::Compact, + ) + .await; + + let hook_input: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(&hook_output_path).expect("应能读取 session hook 输入"), + ) + .expect("session hook 输入应为有效 JSON"); + assert_eq!( + hook_input.get("source").and_then(serde_json::Value::as_str), + Some("compact") + ); + assert_eq!( + hook_input + .get("session_id") + .and_then(serde_json::Value::as_str), + Some(session_id.as_str()) + ); + + clear_session_hooks(&session_id); + delete_managed_session(&session_id) + .await + .expect("清理测试 session 失败"); + } + + #[tokio::test] + async fn run_runtime_stop_project_hooks_for_session_should_pass_last_assistant_message_and_flag( + ) { + ensure_runtime_project_hooks_test_manager().await; + + let conn = Connection::open_in_memory().expect("创建内存数据库失败"); + create_tables(&conn).expect("初始化表结构失败"); + let db = Arc::new(Mutex::new(conn)); + + let temp_dir = tempfile::TempDir::new().expect("create temp dir"); + let workspace_root = temp_dir.path().join("stop-hook-workspace"); + std::fs::create_dir_all(&workspace_root).expect("创建 workspace 目录失败"); + let hook_output_path = temp_dir.path().join("stop-hook-input.json"); + write_stop_capture_hook(&workspace_root, &hook_output_path); + + let manager = WorkspaceManager::new(db.clone()); + let workspace = manager + .create( + "Runtime Stop Hook Workspace".to_string(), + workspace_root.clone(), + ) + .expect("创建 workspace 失败"); + + let session_id = AsterAgentWrapper::create_session_sync( + &db, + Some("Runtime Stop Hook Session".to_string()), + Some(workspace_root.to_string_lossy().to_string()), + workspace.id.clone(), + Some(AsterExecutionStrategy::React.as_db_value().to_string()), + ) + .expect("创建 session 失败"); + + run_runtime_stop_project_hooks_for_session( + &db, + &session_id, + false, + Some("本回合最后一条 assistant 消息"), + ) + .await; + + let hook_input: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(&hook_output_path).expect("应能读取 Stop hook 输入"), + ) + .expect("Stop hook 输入应为有效 JSON"); + assert_eq!( + hook_input.get("event").and_then(serde_json::Value::as_str), + Some("Stop") + ); + assert_eq!( + hook_input + .get("stop_hook_active") + .and_then(serde_json::Value::as_bool), + Some(false) + ); + assert_eq!( + hook_input + .get("last_assistant_message") + .and_then(serde_json::Value::as_str), + Some("本回合最后一条 assistant 消息") + ); + assert_eq!( + hook_input + .get("session_id") + .and_then(serde_json::Value::as_str), + Some(session_id.as_str()) + ); + + delete_managed_session(&session_id) + .await + .expect("清理测试 session 失败"); + } + + #[tokio::test] + async fn run_runtime_stop_project_hooks_for_session_should_detect_unsupported_continue_false_request( + ) { + ensure_runtime_project_hooks_test_manager().await; + + let conn = Connection::open_in_memory().expect("创建内存数据库失败"); + create_tables(&conn).expect("初始化表结构失败"); + let db = Arc::new(Mutex::new(conn)); + + let temp_dir = tempfile::TempDir::new().expect("create temp dir"); + let workspace_root = temp_dir.path().join("stop-hook-continuation-workspace"); + std::fs::create_dir_all(&workspace_root).expect("创建 workspace 目录失败"); + write_stop_continuation_hook(&workspace_root, Some("hook asked to stop continuation")); + + let manager = WorkspaceManager::new(db.clone()); + let workspace = manager + .create( + "Runtime Stop Continuation Workspace".to_string(), + workspace_root.clone(), + ) + .expect("创建 workspace 失败"); + + let session_id = AsterAgentWrapper::create_session_sync( + &db, + Some("Runtime Stop Continuation Session".to_string()), + Some(workspace_root.to_string_lossy().to_string()), + workspace.id.clone(), + Some(AsterExecutionStrategy::React.as_db_value().to_string()), + ) + .expect("创建 session 失败"); + + let registry = load_runtime_project_hook_registry(&workspace_root.to_string_lossy()) + .expect("应能加载 stop continuation hook registry"); + let results = run_hooks_with_registry( + HookInput { + event: Some(HookEvent::Stop), + session_id: Some(session_id.clone()), + stop_hook_active: Some(false), + last_assistant_message: Some("最后一条消息".to_string()), + ..Default::default() + }, + ®istry, + ) + .await; + + assert_eq!( + parse_runtime_stop_hook_continuation_request(&results), + Some(RuntimeStopHookContinuationRequest { + stop_reason: Some("hook asked to stop continuation".to_string()), + }) + ); + + let warning_message = run_runtime_stop_project_hooks_for_session( + &db, + &session_id, + false, + Some("最后一条消息"), + ) + .await; + assert_eq!( + warning_message, + Some(build_runtime_stop_hook_unsupported_warning_message( + &RuntimeStopHookContinuationRequest { + stop_reason: Some("hook asked to stop continuation".to_string()), + } + )) + ); + + delete_managed_session(&session_id) + .await + .expect("清理测试 session 失败"); + } + + #[tokio::test] + async fn run_runtime_session_end_project_hooks_for_session_should_pass_reason_and_clear_session_hooks( + ) { + ensure_runtime_project_hooks_test_manager().await; + + let conn = Connection::open_in_memory().expect("创建内存数据库失败"); + create_tables(&conn).expect("初始化表结构失败"); + let db = Arc::new(Mutex::new(conn)); + + let temp_dir = tempfile::TempDir::new().expect("create temp dir"); + let workspace_root = temp_dir.path().join("session-end-hook-workspace"); + std::fs::create_dir_all(&workspace_root).expect("创建 workspace 目录失败"); + let hook_output_path = temp_dir.path().join("session-end-hook-input.json"); + write_session_end_capture_hook(&workspace_root, &hook_output_path); + + let manager = WorkspaceManager::new(db.clone()); + let workspace = manager + .create( + "Runtime SessionEnd Hook Workspace".to_string(), + workspace_root.clone(), + ) + .expect("创建 workspace 失败"); + + let session_id = AsterAgentWrapper::create_session_sync( + &db, + Some("Runtime SessionEnd Hook Session".to_string()), + Some(workspace_root.to_string_lossy().to_string()), + workspace.id.clone(), + Some(AsterExecutionStrategy::React.as_db_value().to_string()), + ) + .expect("创建 session 失败"); + + let mut hooks: FrontmatterHooks = HashMap::new(); + hooks.insert( + HookEvent::SessionEnd, + vec![FrontmatterHookMatcher { + matcher: None, + hooks: vec![FrontmatterHookCommand::Command( + FrontmatterCommandHookConfig { + command: "printf 'session-end-inline'".to_string(), + timeout: None, + once: false, + shell: None, + if_condition: None, + status_message: None, + async_mode: false, + async_rewake: false, + }, + )], + }], + ); + let report = register_session_frontmatter_hooks(&session_id, &hooks); + assert_eq!(report.registered, 1); + assert!(report.skipped.is_empty()); + assert_eq!( + get_matching_session_hooks(&session_id, HookEvent::SessionEnd, None).len(), + 1 + ); + + run_runtime_session_end_project_hooks_for_session( + &db, + &session_id, + SessionEndReason::Other, + ) + .await; + + let hook_input: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(&hook_output_path).expect("应能读取 SessionEnd hook 输入"), + ) + .expect("SessionEnd hook 输入应为有效 JSON"); + assert_eq!( + hook_input.get("event").and_then(serde_json::Value::as_str), + Some("SessionEnd") + ); + assert_eq!( + hook_input.get("reason").and_then(serde_json::Value::as_str), + Some("other") + ); + assert_eq!( + hook_input + .get("session_id") + .and_then(serde_json::Value::as_str), + Some(session_id.as_str()) + ); + assert!(get_matching_session_hooks(&session_id, HookEvent::SessionEnd, None).is_empty()); + + delete_managed_session(&session_id) + .await + .expect("清理测试 session 失败"); + } + + #[test] + fn build_runtime_session_end_unsupported_warning_message_should_allow_only_other_reason() { + assert_eq!( + build_runtime_session_end_unsupported_warning_message(SessionEndReason::Other), + None + ); + assert!( + build_runtime_session_end_unsupported_warning_message(SessionEndReason::Clear) + .expect("clear 应提示 unsupported") + .contains("reason=clear") + ); + assert!( + build_runtime_session_end_unsupported_warning_message(SessionEndReason::Logout) + .expect("logout 应提示 unsupported") + .contains("reason=logout") + ); + assert!(build_runtime_session_end_unsupported_warning_message( + SessionEndReason::PromptInputExit + ) + .expect("prompt_input_exit 应提示 unsupported") + .contains("reason=prompt_input_exit")); + } + + #[tokio::test] + async fn run_runtime_session_end_project_hooks_for_session_should_skip_unsupported_clear_reason( + ) { + ensure_runtime_project_hooks_test_manager().await; + + let conn = Connection::open_in_memory().expect("创建内存数据库失败"); + create_tables(&conn).expect("初始化表结构失败"); + let db = Arc::new(Mutex::new(conn)); + + let temp_dir = tempfile::TempDir::new().expect("create temp dir"); + let workspace_root = temp_dir.path().join("session-end-clear-workspace"); + std::fs::create_dir_all(&workspace_root).expect("创建 workspace 目录失败"); + let hook_output_path = temp_dir.path().join("session-end-clear.json"); + write_session_end_capture_hook(&workspace_root, &hook_output_path); + + let manager = WorkspaceManager::new(db.clone()); + let workspace = manager + .create( + "Runtime SessionEnd Clear Workspace".to_string(), + workspace_root.clone(), + ) + .expect("创建 workspace 失败"); + + let session_id = AsterAgentWrapper::create_session_sync( + &db, + Some("Runtime SessionEnd Clear Session".to_string()), + Some(workspace_root.to_string_lossy().to_string()), + workspace.id.clone(), + Some(AsterExecutionStrategy::React.as_db_value().to_string()), + ) + .expect("创建 session 失败"); + + let mut hooks: FrontmatterHooks = HashMap::new(); + hooks.insert( + HookEvent::SessionEnd, + vec![FrontmatterHookMatcher { + matcher: None, + hooks: vec![FrontmatterHookCommand::Command( + FrontmatterCommandHookConfig { + command: "printf 'session-end-inline'".to_string(), + timeout: None, + once: false, + shell: None, + if_condition: None, + status_message: None, + async_mode: false, + async_rewake: false, + }, + )], + }], + ); + let report = register_session_frontmatter_hooks(&session_id, &hooks); + assert_eq!(report.registered, 1); + + run_runtime_session_end_project_hooks_for_session( + &db, + &session_id, + SessionEndReason::Clear, + ) + .await; + + assert!( + !hook_output_path.exists(), + "unsupported SessionEnd clear 不应执行任何 hook" + ); + assert_eq!( + get_matching_session_hooks(&session_id, HookEvent::SessionEnd, None).len(), + 1, + "unsupported SessionEnd clear 不应清空 session hooks" + ); + + clear_session_hooks(&session_id); + delete_managed_session(&session_id) + .await + .expect("清理测试 session 失败"); + } + + #[tokio::test] + async fn enforce_runtime_pre_compact_project_hooks_for_session_should_pass_trigger_and_tokens() + { + ensure_runtime_project_hooks_test_manager().await; + + let conn = Connection::open_in_memory().expect("创建内存数据库失败"); + create_tables(&conn).expect("初始化表结构失败"); + let db = Arc::new(Mutex::new(conn)); + + let temp_dir = tempfile::TempDir::new().expect("create temp dir"); + let workspace_root = temp_dir.path().join("pre-compact-workspace"); + std::fs::create_dir_all(&workspace_root).expect("创建 workspace 目录失败"); + let hook_output_path = temp_dir.path().join("pre-compact-input.json"); + write_pre_compact_capture_hook(&workspace_root, &hook_output_path); + + let manager = WorkspaceManager::new(db.clone()); + let workspace = manager + .create( + "Runtime PreCompact Hook Workspace".to_string(), + workspace_root.clone(), + ) + .expect("创建 workspace 失败"); + + let session_id = AsterAgentWrapper::create_session_sync( + &db, + Some("Runtime PreCompact Hook Session".to_string()), + Some(workspace_root.to_string_lossy().to_string()), + workspace.id.clone(), + Some(AsterExecutionStrategy::React.as_db_value().to_string()), + ) + .expect("创建 session 失败"); + + enforce_runtime_pre_compact_project_hooks_for_session( + &db, + &session_id, + Some(321), + CompactTrigger::Manual, + ) + .await + .expect("PreCompact hook 不应阻止本次压缩"); + + let hook_input: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(&hook_output_path).expect("应能读取 PreCompact hook 输入"), + ) + .expect("PreCompact hook 输入应为有效 JSON"); + assert_eq!( + hook_input.get("event").and_then(serde_json::Value::as_str), + Some("PreCompact") + ); + assert_eq!( + hook_input + .get("trigger") + .and_then(serde_json::Value::as_str), + Some("manual") + ); + assert_eq!( + hook_input + .get("current_tokens") + .and_then(serde_json::Value::as_u64), + Some(321) + ); + assert_eq!( + hook_input + .get("session_id") + .and_then(serde_json::Value::as_str), + Some(session_id.as_str()) + ); + + delete_managed_session(&session_id) + .await + .expect("清理测试 session 失败"); + } + + #[tokio::test] + async fn enforce_runtime_pre_compact_project_hooks_for_session_should_block_when_hook_blocks() { + ensure_runtime_project_hooks_test_manager().await; + + let conn = Connection::open_in_memory().expect("创建内存数据库失败"); + create_tables(&conn).expect("初始化表结构失败"); + let db = Arc::new(Mutex::new(conn)); + + let temp_dir = tempfile::TempDir::new().expect("create temp dir"); + let workspace_root = temp_dir.path().join("pre-compact-block-workspace"); + std::fs::create_dir_all(&workspace_root).expect("创建 workspace 目录失败"); + write_blocking_pre_compact_hook(&workspace_root, "need preserve this context"); + + let manager = WorkspaceManager::new(db.clone()); + let workspace = manager + .create( + "Runtime PreCompact Blocking Workspace".to_string(), + workspace_root.clone(), + ) + .expect("创建 workspace 失败"); + + let session_id = AsterAgentWrapper::create_session_sync( + &db, + Some("Runtime PreCompact Blocking Session".to_string()), + Some(workspace_root.to_string_lossy().to_string()), + workspace.id.clone(), + Some(AsterExecutionStrategy::React.as_db_value().to_string()), + ) + .expect("创建 session 失败"); + + let error = enforce_runtime_pre_compact_project_hooks_for_session( + &db, + &session_id, + Some(512), + CompactTrigger::Auto, + ) + .await + .expect_err("阻塞型 PreCompact hook 应阻止本次压缩"); + + assert!(error.contains("PreCompact hook 已阻止本次压缩")); + assert!(error.contains("need preserve this context")); + + delete_managed_session(&session_id) + .await + .expect("清理测试 session 失败"); + } + + #[tokio::test] + async fn decide_runtime_permission_request_project_hooks_for_session_should_capture_permission_mode_and_allow( + ) { + ensure_runtime_project_hooks_test_manager().await; + + let conn = Connection::open_in_memory().expect("创建内存数据库失败"); + create_tables(&conn).expect("初始化表结构失败"); + let db = Arc::new(Mutex::new(conn)); + + let temp_dir = tempfile::TempDir::new().expect("create temp dir"); + let workspace_root = temp_dir.path().join("permission-request-allow-workspace"); + std::fs::create_dir_all(&workspace_root).expect("创建 workspace 目录失败"); + let hook_output_path = temp_dir.path().join("permission-request-input.json"); + write_permission_request_capture_hook( + &workspace_root, + &hook_output_path, + "allow", + None, + None, + None, + None, + ); + + let manager = WorkspaceManager::new(db.clone()); + let workspace = manager + .create( + "Runtime PermissionRequest Hook Workspace".to_string(), + workspace_root.clone(), + ) + .expect("创建 workspace 失败"); + + let session_id = AsterAgentWrapper::create_session_sync( + &db, + Some("Runtime PermissionRequest Hook Session".to_string()), + Some(workspace_root.to_string_lossy().to_string()), + workspace.id.clone(), + Some(AsterExecutionStrategy::React.as_db_value().to_string()), + ) + .expect("创建 session 失败"); + + let decision = decide_runtime_permission_request_project_hooks( + &session_id, + &workspace_root.to_string_lossy(), + "Bash", + Some(serde_json::json!({ + "command": "rm -rf tmp" + })), + "req-permission-allow", + Some("default".to_string()), + ) + .await + .expect("PermissionRequest hook 执行不应失败"); + + assert_eq!( + decision, + Some(PermissionRequestHookDecision::Allow { + updated_input: None + }) + ); + + let hook_input: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(&hook_output_path) + .expect("应能读取 PermissionRequest hook 输入"), + ) + .expect("PermissionRequest hook 输入应为有效 JSON"); + assert_eq!( + hook_input.get("event").and_then(serde_json::Value::as_str), + Some("PermissionRequest") + ); + assert_eq!( + hook_input + .get("permission_mode") + .and_then(serde_json::Value::as_str), + Some("default") + ); + assert_eq!( + hook_input + .get("tool_use_id") + .and_then(serde_json::Value::as_str), + Some("req-permission-allow") + ); + assert_eq!( + hook_input + .get("session_id") + .and_then(serde_json::Value::as_str), + Some(session_id.as_str()) + ); + + delete_managed_session(&session_id) + .await + .expect("清理测试 session 失败"); + } + + #[tokio::test] + async fn decide_runtime_permission_request_project_hooks_for_session_should_return_updated_input_on_allow( + ) { + ensure_runtime_project_hooks_test_manager().await; + + let conn = Connection::open_in_memory().expect("创建内存数据库失败"); + create_tables(&conn).expect("初始化表结构失败"); + let db = Arc::new(Mutex::new(conn)); + + let temp_dir = tempfile::TempDir::new().expect("create temp dir"); + let workspace_root = temp_dir + .path() + .join("permission-request-updated-input-workspace"); + std::fs::create_dir_all(&workspace_root).expect("创建 workspace 目录失败"); + let hook_output_path = temp_dir + .path() + .join("permission-request-updated-input.json"); + write_permission_request_capture_hook( + &workspace_root, + &hook_output_path, + "allow", + None, + Some(serde_json::json!({ + "command": "echo updated by hook", + "timeout": 1200 + })), + None, + None, + ); + + let manager = WorkspaceManager::new(db.clone()); + let workspace = manager + .create( + "Runtime PermissionRequest UpdatedInput Workspace".to_string(), + workspace_root.clone(), + ) + .expect("创建 workspace 失败"); + + let session_id = AsterAgentWrapper::create_session_sync( + &db, + Some("Runtime PermissionRequest UpdatedInput Session".to_string()), + Some(workspace_root.to_string_lossy().to_string()), + workspace.id.clone(), + Some(AsterExecutionStrategy::React.as_db_value().to_string()), + ) + .expect("创建 session 失败"); + + let decision = decide_runtime_permission_request_project_hooks( + &session_id, + &workspace_root.to_string_lossy(), + "Bash", + Some(serde_json::json!({ + "command": "echo original" + })), + "req-permission-updated-input", + Some("default".to_string()), + ) + .await + .expect("PermissionRequest updatedInput hook 执行不应失败"); + + assert_eq!( + decision, + Some(PermissionRequestHookDecision::Allow { + updated_input: Some({ + let mut updated_input = serde_json::Map::new(); + updated_input.insert( + "command".to_string(), + serde_json::Value::String("echo updated by hook".to_string()), + ); + updated_input.insert( + "timeout".to_string(), + serde_json::Value::Number(serde_json::Number::from(1200)), + ); + updated_input + }), + }) + ); + + let hook_input: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(&hook_output_path) + .expect("应能读取 PermissionRequest updatedInput hook 输入"), + ) + .expect("PermissionRequest updatedInput hook 输入应为有效 JSON"); + assert_eq!( + hook_input.get("event").and_then(serde_json::Value::as_str), + Some("PermissionRequest") + ); + assert_eq!( + hook_input + .get("tool_use_id") + .and_then(serde_json::Value::as_str), + Some("req-permission-updated-input") + ); + + delete_managed_session(&session_id) + .await + .expect("清理测试 session 失败"); + } + + #[tokio::test] + async fn decide_runtime_permission_request_project_hooks_for_session_should_return_deny_message( + ) { + ensure_runtime_project_hooks_test_manager().await; + + let conn = Connection::open_in_memory().expect("创建内存数据库失败"); + create_tables(&conn).expect("初始化表结构失败"); + let db = Arc::new(Mutex::new(conn)); + + let temp_dir = tempfile::TempDir::new().expect("create temp dir"); + let workspace_root = temp_dir.path().join("permission-request-deny-workspace"); + std::fs::create_dir_all(&workspace_root).expect("创建 workspace 目录失败"); + let hook_output_path = temp_dir.path().join("permission-request-deny.json"); + write_permission_request_capture_hook( + &workspace_root, + &hook_output_path, + "deny", + Some("need manual review"), + None, + None, + None, + ); + + let manager = WorkspaceManager::new(db.clone()); + let workspace = manager + .create( + "Runtime PermissionRequest Deny Workspace".to_string(), + workspace_root.clone(), + ) + .expect("创建 workspace 失败"); + + let session_id = AsterAgentWrapper::create_session_sync( + &db, + Some("Runtime PermissionRequest Deny Session".to_string()), + Some(workspace_root.to_string_lossy().to_string()), + workspace.id.clone(), + Some(AsterExecutionStrategy::React.as_db_value().to_string()), + ) + .expect("创建 session 失败"); + + let decision = decide_runtime_permission_request_project_hooks( + &session_id, + &workspace_root.to_string_lossy(), + "Bash", + Some(serde_json::json!({ + "command": "git push" + })), + "req-permission-deny", + Some("default".to_string()), + ) + .await + .expect("PermissionRequest deny hook 执行不应失败"); + + assert_eq!( + decision, + Some(PermissionRequestHookDecision::Deny { + message: Some("need manual review".to_string()), + }) + ); + + delete_managed_session(&session_id) + .await + .expect("清理测试 session 失败"); + } + + #[tokio::test] + async fn decide_runtime_permission_request_project_hooks_should_fallback_to_native_approval_when_allow_requests_updated_permissions( + ) { + ensure_runtime_project_hooks_test_manager().await; + + let conn = Connection::open_in_memory().expect("创建内存数据库失败"); + create_tables(&conn).expect("初始化表结构失败"); + let db = Arc::new(Mutex::new(conn)); + + let temp_dir = tempfile::TempDir::new().expect("create temp dir"); + let workspace_root = temp_dir + .path() + .join("permission-request-updated-permissions-workspace"); + std::fs::create_dir_all(&workspace_root).expect("创建 workspace 目录失败"); + let hook_output_path = temp_dir + .path() + .join("permission-request-updated-permissions.json"); + write_permission_request_capture_hook( + &workspace_root, + &hook_output_path, + "allow", + None, + Some(serde_json::json!({ + "command": "echo rewritten but unsupported" + })), + Some(serde_json::json!([ + { + "type": "setMode", + "mode": "acceptEdits", + "destination": "session" + } + ])), + None, + ); + + let manager = WorkspaceManager::new(db.clone()); + let workspace = manager + .create( + "Runtime PermissionRequest UpdatedPermissions Workspace".to_string(), + workspace_root.clone(), + ) + .expect("创建 workspace 失败"); + + let session_id = AsterAgentWrapper::create_session_sync( + &db, + Some("Runtime PermissionRequest UpdatedPermissions Session".to_string()), + Some(workspace_root.to_string_lossy().to_string()), + workspace.id.clone(), + Some(AsterExecutionStrategy::React.as_db_value().to_string()), + ) + .expect("创建 session 失败"); + + let decision = decide_runtime_permission_request_project_hooks( + &session_id, + &workspace_root.to_string_lossy(), + "Bash", + Some(serde_json::json!({ + "command": "echo original" + })), + "req-permission-updated-permissions", + Some("default".to_string()), + ) + .await + .expect("PermissionRequest updatedPermissions hook 执行不应失败"); + + assert_eq!(decision, None); + + delete_managed_session(&session_id) + .await + .expect("清理测试 session 失败"); + } + + #[test] + fn parse_runtime_permission_request_hook_request_should_detect_updated_permissions_request() { + let results = vec![HookResult::success(Some( + serde_json::json!({ + "decision": "allow", + "updatedPermissions": [ + { + "type": "setMode", + "mode": "acceptEdits", + "destination": "session" + } + ] + }) + .to_string(), + ))]; + + assert_eq!( + parse_runtime_permission_request_hook_request(&results), + Some(RuntimePermissionRequestHookRequest { + decision: PermissionRequestHookDecision::Allow { + updated_input: None, + }, + updated_permissions_requested: true, + interrupt_requested: false, + }) + ); + } + + #[tokio::test] + async fn decide_runtime_permission_request_project_hooks_with_runtime_should_interrupt_session_on_deny_interrupt_true( + ) { + ensure_runtime_project_hooks_test_manager().await; + + let conn = Connection::open_in_memory().expect("创建内存数据库失败"); + create_tables(&conn).expect("初始化表结构失败"); + let db = Arc::new(Mutex::new(conn)); + + let temp_dir = tempfile::TempDir::new().expect("create temp dir"); + let workspace_root = temp_dir + .path() + .join("permission-request-interrupt-workspace"); + std::fs::create_dir_all(&workspace_root).expect("创建 workspace 目录失败"); + let hook_output_path = temp_dir.path().join("permission-request-interrupt.json"); + write_permission_request_capture_hook( + &workspace_root, + &hook_output_path, + "deny", + Some("stop this turn"), + None, + None, + Some(true), + ); + + let manager = WorkspaceManager::new(db.clone()); + let workspace = manager + .create( + "Runtime PermissionRequest Interrupt Workspace".to_string(), + workspace_root.clone(), + ) + .expect("创建 workspace 失败"); + + let session_id = AsterAgentWrapper::create_session_sync( + &db, + Some("Runtime PermissionRequest Interrupt Session".to_string()), + Some(workspace_root.to_string_lossy().to_string()), + workspace.id.clone(), + Some(AsterExecutionStrategy::React.as_db_value().to_string()), + ) + .expect("创建 session 失败"); + + let state = AsterAgentState::new(); + let cancel_token = state.create_cancel_token(&session_id).await; + let mcp_manager: McpManagerState = Arc::new(tokio::sync::Mutex::new( + crate::mcp::McpClientManager::new(None), + )); + + let decision = decide_runtime_permission_request_project_hooks_with_runtime( + &session_id, + &workspace_root.to_string_lossy(), + "Bash", + Some(serde_json::json!({ + "command": "sleep 5" + })), + "req-permission-interrupt", + Some("default".to_string()), + &db, + &state, + &mcp_manager, + ) + .await + .expect("PermissionRequest interrupt hook 执行不应失败"); + + assert_eq!( + decision, + Some(PermissionRequestHookDecision::Deny { + message: Some("stop this turn".to_string()), + }) + ); + assert!(cancel_token.is_cancelled()); + + let interrupt_marker = state + .get_interrupt_marker(&session_id) + .await + .expect("应记录 PermissionRequest hook interrupt marker"); + assert_eq!(interrupt_marker.source, "hook"); + assert_eq!( + interrupt_marker.reason, + "PermissionRequest hook 请求中断当前执行:stop this turn" + ); + + delete_managed_session(&session_id) + .await + .expect("清理测试 session 失败"); + } + + #[tokio::test] + async fn load_runtime_project_hook_registry_should_include_enabled_plugin_session_start_hooks() + { + let temp_home = tempfile::TempDir::new().expect("create temp home"); + let temp_workspace = tempfile::TempDir::new().expect("create temp workspace"); + let workspace_root = temp_workspace.path(); + let output_path = temp_workspace.path().join("plugin-session-start.json"); + let plugin_id = "capture-start@demo-market"; + + write_enabled_plugin_settings(temp_home.path(), plugin_id); + write_cached_plugin( + temp_home.path(), + "capture-start", + "demo-market", + "1.2.3", + serde_json::json!({ + "name": "capture-start", + "version": "1.2.3" + }), + Some(serde_json::json!({ + "description": "capture session start", + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": format!("cat > {}", shell_quote(&output_path)) + } + ] + } + ] + } + })), + ); + + let registry = + load_runtime_project_hook_registry_with_home(workspace_root, Some(temp_home.path())) + .expect("加载 runtime hook registry 失败"); + assert_eq!(registry.count_for_event(HookEvent::SessionStart), 1); + + let results = run_session_start_hooks_with_registry( + "plugin-session-start".to_string(), + Some(SessionSource::Startup), + ®istry, + ) + .await; + assert_eq!(results.len(), 1); + + let hook_input: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(&output_path) + .expect("应能读取 plugin session start hook 输出"), + ) + .expect("plugin session start hook 输出应为有效 JSON"); + assert_eq!( + hook_input.get("event").and_then(serde_json::Value::as_str), + Some("SessionStart") + ); + assert_eq!( + hook_input.get("source").and_then(serde_json::Value::as_str), + Some("startup") + ); + } + + #[tokio::test] + async fn load_runtime_project_hook_registry_should_include_manifest_inline_plugin_user_prompt_hooks( + ) { + let temp_home = tempfile::TempDir::new().expect("create temp home"); + let temp_workspace = tempfile::TempDir::new().expect("create temp workspace"); + let plugin_id = "prompt-guard@demo-market"; + + write_enabled_plugin_settings(temp_home.path(), plugin_id); + write_cached_plugin( + temp_home.path(), + "prompt-guard", + "demo-market", + "2.0.0", + serde_json::json!({ + "name": "prompt-guard", + "version": "2.0.0", + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "printf '%s' '{\"blocked\":true,\"message\":\"plugin hook blocked\"}'; exit 2" + } + ] + } + ] + } + }), + None, + ); + + let registry = load_runtime_project_hook_registry_with_home( + temp_workspace.path(), + Some(temp_home.path()), + ) + .expect("加载 runtime hook registry 失败"); + assert_eq!(registry.count_for_event(HookEvent::UserPromptSubmit), 1); + + let (allowed, message) = run_user_prompt_submit_hooks_with_registry( + "please block".trim(), + Some("plugin-user-prompt-session".to_string()), + ®istry, + ) + .await; + assert!(!allowed); + assert_eq!(message.as_deref(), Some("plugin hook blocked")); + } + + #[test] + fn load_runtime_plugin_hook_registry_should_require_dot_slash_manifest_hook_paths() { + let temp_home = tempfile::TempDir::new().expect("create temp home"); + let temp_workspace = tempfile::TempDir::new().expect("create temp workspace"); + let plugin_id = "strict-hook-guard@demo-market"; + + write_enabled_plugin_settings(temp_home.path(), plugin_id); + let plugin_root = write_cached_plugin( + temp_home.path(), + "strict-hook-guard", + "demo-market", + "2.0.0", + serde_json::json!({ + "name": "strict-hook-guard", + "version": "2.0.0", + "hooks": "extra-hooks.json" + }), + Some(serde_json::json!({ + "description": "standard hooks should not load when manifest path syntax is invalid", + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "printf '%s' '{\"blocked\":true,\"message\":\"should not load\"}'; exit 2" + } + ] + } + ] + } + })), + ); + std::fs::write( + plugin_root.join("extra-hooks.json"), + serde_json::to_string_pretty(&serde_json::json!({ + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "printf '%s' '{\"blocked\":true,\"message\":\"also should not load\"}'; exit 2" + } + ] + } + ] + } + })) + .expect("序列化 extra hooks 失败"), + ) + .expect("写入 extra hooks 失败"); + + let registry = HookRegistry::new(); + let report = + load_runtime_plugin_hook_registry(temp_workspace.path(), temp_home.path(), ®istry); + + assert_eq!(registry.count_for_event(HookEvent::UserPromptSubmit), 0); + assert_eq!(report.registered, 0); + assert!( + report + .skipped + .iter() + .any(|item| item.contains("manifest.hooks 路径无效(extra-hooks.json)")), + "应显式报告缺少 ./ 前缀的 manifest.hooks 路径" + ); + } + + #[test] + fn load_runtime_plugin_hook_registry_should_report_duplicate_manifest_hook_files() { + let temp_home = tempfile::TempDir::new().expect("create temp home"); + let temp_workspace = tempfile::TempDir::new().expect("create temp workspace"); + let plugin_id = "duplicate-hook-guard@demo-market"; + + write_enabled_plugin_settings(temp_home.path(), plugin_id); + write_cached_plugin( + temp_home.path(), + "duplicate-hook-guard", + "demo-market", + "2.0.0", + serde_json::json!({ + "name": "duplicate-hook-guard", + "version": "2.0.0", + "hooks": "./hooks/hooks.json" + }), + Some(serde_json::json!({ + "description": "standard hooks", + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "printf '%s' '{\"blocked\":true,\"message\":\"duplicate hook blocked\"}'; exit 2" + } + ] + } + ] + } + })), + ); + + let registry = HookRegistry::new(); + let report = + load_runtime_plugin_hook_registry(temp_workspace.path(), temp_home.path(), ®istry); + + assert_eq!(registry.count_for_event(HookEvent::UserPromptSubmit), 1); + assert_eq!(report.registered, 1); + assert!( + report + .skipped + .iter() + .any(|item| item.contains("Duplicate hooks file detected: ./hooks/hooks.json")), + "manifest.hooks 指回 standard hooks/hooks.json 时应显式记为 duplicate" + ); + } + + #[tokio::test] + async fn load_runtime_project_hook_registry_should_include_legacy_cached_plugin_user_prompt_hooks( + ) { + let temp_home = tempfile::TempDir::new().expect("create temp home"); + let temp_workspace = tempfile::TempDir::new().expect("create temp workspace"); + let plugin_id = "legacy-guard@demo-market"; + + write_enabled_plugin_settings(temp_home.path(), plugin_id); + write_legacy_cached_plugin( + temp_home.path(), + "legacy-guard", + serde_json::json!({ + "name": "legacy-guard", + "version": "0.9.0", + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "printf '%s' '{\"blocked\":true,\"message\":\"legacy plugin hook blocked\"}'; exit 2" + } + ] + } + ] + } + }), + None, + ); + + let registry = load_runtime_project_hook_registry_with_home( + temp_workspace.path(), + Some(temp_home.path()), + ) + .expect("加载 runtime hook registry 失败"); + assert_eq!(registry.count_for_event(HookEvent::UserPromptSubmit), 1); + + let (allowed, message) = run_user_prompt_submit_hooks_with_registry( + "please block via legacy cache", + Some("plugin-user-prompt-legacy-session".to_string()), + ®istry, + ) + .await; + assert!(!allowed); + assert_eq!(message.as_deref(), Some("legacy plugin hook blocked")); + } + + #[tokio::test] + async fn load_runtime_project_hook_registry_should_include_legacy_manifest_plugin_user_prompt_hooks( + ) { + let temp_home = tempfile::TempDir::new().expect("create temp home"); + let temp_workspace = tempfile::TempDir::new().expect("create temp workspace"); + let plugin_id = "legacy-manifest-guard@demo-market"; + + write_enabled_plugin_settings(temp_home.path(), plugin_id); + write_cached_plugin_with_legacy_manifest( + temp_home.path(), + "legacy-manifest-guard", + "demo-market", + "1.0.0", + serde_json::json!({ + "name": "legacy-manifest-guard", + "version": "1.0.0", + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "printf '%s' '{\"blocked\":true,\"message\":\"legacy manifest plugin hook blocked\"}'; exit 2" + } + ] + } + ] + } + }), + None, + ); + + let registry = load_runtime_project_hook_registry_with_home( + temp_workspace.path(), + Some(temp_home.path()), + ) + .expect("加载 runtime hook registry 失败"); + assert_eq!(registry.count_for_event(HookEvent::UserPromptSubmit), 1); + + let (allowed, message) = run_user_prompt_submit_hooks_with_registry( + "please block via legacy manifest", + Some("plugin-user-prompt-legacy-manifest-session".to_string()), + ®istry, + ) + .await; + assert!(!allowed); + assert_eq!( + message.as_deref(), + Some("legacy manifest plugin hook blocked") + ); + } + #[test] fn convert_runtime_mcp_call_result_should_block_from_structured_content() { let result = CallToolResult { diff --git a/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs b/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs index e03d84710..d97113a78 100644 --- a/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs +++ b/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs @@ -1,12 +1,15 @@ #[cfg(test)] use super::runtime_project_hooks::enforce_runtime_turn_user_prompt_submit_hooks; use super::runtime_project_hooks::{ + decide_runtime_permission_request_project_hooks_for_session_with_runtime, + enforce_runtime_pre_compact_project_hooks_for_session_with_runtime, enforce_runtime_turn_user_prompt_submit_hooks_with_runtime, run_runtime_session_start_project_hooks_for_session_with_runtime, + run_runtime_stop_project_hooks_for_session_with_runtime, }; use super::service_skill_launch::build_service_skill_preload_tool_projection; use super::*; -use aster::hooks::SessionSource; +use aster::hooks::{CompactTrigger, SessionSource}; use aster::session::TurnContextOverride; use aster::tools::ConfigTool; use lime_agent::AgentEvent as RuntimeAgentEvent; @@ -21,6 +24,7 @@ const ARTIFACT_DOCUMENT_PERSIST_FAILED_WARNING_CODE: &str = "artifact_document_p const AUTO_CONTEXT_COMPACTION_EVENT_PREFIX: &str = "agent_context_compaction_auto_internal"; const AUTO_CONTEXT_COMPACTION_FAILED_WARNING_CODE: &str = "context_compaction_auto_failed"; const CONTEXT_COMPACTION_NOT_NEEDED_WARNING_CODE: &str = "context_compaction_not_needed"; +const STOP_HOOK_CONTINUATION_UNSUPPORTED_WARNING_CODE: &str = "stop_hook_continuation_unsupported"; const TURN_MEMORY_PREFETCH_PROMPT_MARKER: &str = "【运行时记忆召回】"; const TURN_LOCAL_PATH_FOCUS_PROMPT_MARKER: &str = "【本回合本地路径焦点】"; const LIME_RUNTIME_METADATA_KEY: &str = "lime_runtime"; @@ -82,6 +86,43 @@ async fn ensure_host_backed_config_tool_registered( Ok(()) } +async fn ensure_runtime_permission_request_hook_handler_registered( + state: &AsterAgentState, + db: &DbConnection, + mcp_manager: &McpManagerState, +) -> Result<(), String> { + let db = db.clone(); + let hook_state = state.clone(); + let mcp_manager = mcp_manager.clone(); + + state + .with_agent_mut(move |agent| { + let db = db.clone(); + let hook_state = hook_state.clone(); + let mcp_manager = mcp_manager.clone(); + + agent.set_permission_request_hook_handler(Some(Arc::new(move |context| { + let db = db.clone(); + let hook_state = hook_state.clone(); + let mcp_manager = mcp_manager.clone(); + Box::pin(async move { + decide_runtime_permission_request_project_hooks_for_session_with_runtime( + &db, + &hook_state, + &mcp_manager, + &context.session_id, + &context.tool_name, + context.tool_input, + &context.tool_use_id, + context.permission_mode, + ) + .await + }) + }))); + }) + .await +} + fn merge_runtime_memory_prefetch_prompt( base_prompt: Option, prefetch_prompt: Option<&str>, @@ -815,7 +856,7 @@ struct RuntimeTurnExecutionContext { } impl RuntimeTurnExecutionContext { - fn build_run_finish_decision(&self, result: &Result<(), String>) -> RunFinishDecision { + fn build_run_finish_decision(&self, result: &Result) -> RunFinishDecision { build_runtime_run_finish_decision(result, &self.run_start_metadata, &self.run_observation) } @@ -824,6 +865,7 @@ impl RuntimeTurnExecutionContext { tracker: &ExecutionTracker, agent: &Agent, app: &AppHandle, + state: &AsterAgentState, db: &DbConnection, request: &AsterChatRequest, runtime_memory_config: &lime_core::config::MemoryConfig, @@ -882,6 +924,7 @@ impl RuntimeTurnExecutionContext { finalize_runtime_turn_result( agent, app, + state, db, &request.event_name, &self.timeline_recorder, @@ -1014,6 +1057,7 @@ impl RuntimeTurnPreparedExecution { agent: &Agent, tracker: &ExecutionTracker, app: &AppHandle, + state: &AsterAgentState, db: &DbConnection, request: &AsterChatRequest, runtime_memory_config: &lime_core::config::MemoryConfig, @@ -1046,6 +1090,7 @@ impl RuntimeTurnPreparedExecution { tracker, agent, app, + state, db, request, runtime_memory_config, @@ -2107,6 +2152,7 @@ async fn execute_runtime_turn_submit( agent, &submit_bootstrap.tracker, app, + state, db, request, &submit_bootstrap.runtime_memory_config, @@ -2264,6 +2310,7 @@ async fn prepare_runtime_turn_entry( } ensure_host_backed_config_tool_registered(app, state).await?; + ensure_runtime_permission_request_hook_handler_registered(state, db, mcp_manager).await?; ensure_runtime_support_tools_registered(state, mcp_manager).await } @@ -3238,7 +3285,7 @@ async fn execute_runtime_stream_attempt( session_config: aster::agents::types::SessionConfig, cancel_token: CancellationToken, request_tool_policy: &RequestToolPolicy, -) -> Result<(), ReplyAttemptError> { +) -> Result { let execution = stream_reply_once( agent, app, @@ -3287,7 +3334,7 @@ async fn execute_runtime_stream_attempt( &execution, ); - Ok(()) + Ok(execution.text_output) } async fn remove_code_execution_extension_if_added( @@ -3325,7 +3372,7 @@ async fn execute_runtime_stream_with_strategy( request_tool_policy: &RequestToolPolicy, effective_strategy: AsterExecutionStrategy, build_session_config: F, -) -> Result<(), String> +) -> Result where F: Fn() -> aster::agents::types::SessionConfig, { @@ -3357,7 +3404,7 @@ where .await; let run_result = match primary_result { - Ok(()) => Ok(()), + Ok(assistant_output) => Ok(assistant_output), Err(primary_error) if effective_strategy == AsterExecutionStrategy::CodeOrchestrated && should_fallback_to_react_from_code_orchestrated(&primary_error) => @@ -3520,8 +3567,8 @@ fn record_runtime_stream_event( } } -fn build_runtime_run_finish_decision( - result: &Result<(), String>, +fn build_runtime_run_finish_decision( + result: &Result, run_start_metadata: &serde_json::Map, run_observation: &Arc>, ) -> RunFinishDecision { @@ -3553,13 +3600,14 @@ fn build_runtime_run_finish_decision( async fn finalize_runtime_turn_result( agent: &Agent, app: &AppHandle, + state: &AsterAgentState, db: &DbConnection, event_name: &str, timeline_recorder: &Arc>, workspace_root: &str, runtime_status_session_config: &aster::agents::types::SessionConfig, session_id: &str, - result: Result<(), String>, + result: Result, ) -> Result<(), String> { complete_runtime_status_projection( agent, @@ -3577,13 +3625,13 @@ async fn finalize_runtime_turn_result( Err(error) => error.into_inner(), }; match result.as_ref() { - Ok(()) => recorder.complete_turn_success(), + Ok(_) => recorder.complete_turn_success(), Err(error) => recorder.fail_turn(error), } }; if let Err(error) = &terminal_events { let message = match result.as_ref() { - Ok(()) => "完成 turn 时间线失败", + Ok(_) => "完成 turn 时间线失败", Err(_) => "记录失败 turn 时间线失败", }; tracing::warn!("[AsterAgent] {}(已降级继续): {}", message, error); @@ -3593,7 +3641,28 @@ async fn finalize_runtime_turn_result( } match result { - Ok(()) => { + Ok(assistant_output) => { + let unsupported_stop_warning = run_runtime_stop_project_hooks_for_session_with_runtime( + db, + state, + app.state::().inner(), + session_id, + false, + Some(assistant_output.as_str()), + ) + .await; + if let Some(message) = unsupported_stop_warning { + emit_runtime_side_event( + app, + event_name, + timeline_recorder, + workspace_root, + RuntimeAgentEvent::Warning { + code: Some(STOP_HOOK_CONTINUATION_UNSUPPORTED_WARNING_CODE.to_string()), + message, + }, + ); + } let done_event = resolve_runtime_final_done_event(session_id, Some(db)).await; if let RuntimeAgentEvent::FinalDone { usage: Some(ref usage), @@ -3872,6 +3941,13 @@ impl RuntimeSessionCompactionTrigger { } } +fn resolve_pre_compact_hook_trigger(trigger: RuntimeSessionCompactionTrigger) -> CompactTrigger { + match trigger { + RuntimeSessionCompactionTrigger::Manual => CompactTrigger::Manual, + RuntimeSessionCompactionTrigger::Auto => CompactTrigger::Auto, + } +} + fn build_auto_context_compaction_event_name(session_id: &str) -> String { format!( "{AUTO_CONTEXT_COMPACTION_EVENT_PREFIX}_{session_id}_{}", @@ -3899,6 +3975,18 @@ fn resolve_context_compaction_conversation<'a>( Ok(Some(conversation)) } +fn resolve_pre_compact_current_tokens(session: &aster::session::Session) -> Option { + session + .total_tokens + .and_then(|value| u64::try_from(value).ok()) + .or_else(|| { + session.conversation.as_ref().map(|conversation| { + aster::context::TokenEstimator::estimate_total_tokens(conversation.messages()) + as u64 + }) + }) +} + fn emit_context_compaction_skip(app: &AppHandle, event_name: &str, message: &str) { let warning_event = RuntimeAgentEvent::Warning { code: Some(CONTEXT_COMPACTION_NOT_NEEDED_WARNING_CODE.to_string()), @@ -4021,6 +4109,16 @@ async fn compact_runtime_session_with_trigger( } return Ok(()); }; + let pre_compact_current_tokens = resolve_pre_compact_current_tokens(&session); + enforce_runtime_pre_compact_project_hooks_for_session_with_runtime( + db, + state, + app.state::().inner(), + &session_id, + pre_compact_current_tokens, + resolve_pre_compact_hook_trigger(trigger), + ) + .await?; let cancel_token = state.create_cancel_token(&session_id).await; let agent_arc = state.get_agent_arc(); diff --git a/src-tauri/src/commands/aster_agent_cmd/service_skill_launch.rs b/src-tauri/src/commands/aster_agent_cmd/service_skill_launch.rs index 4ce87d62d..18b6b7241 100644 --- a/src-tauri/src/commands/aster_agent_cmd/service_skill_launch.rs +++ b/src-tauri/src/commands/aster_agent_cmd/service_skill_launch.rs @@ -58,6 +58,7 @@ pub(crate) struct ServiceSceneLaunchOemRuntimeContext { #[derive(Debug, Clone, PartialEq)] pub(crate) struct ServiceSceneLaunchContext { + pub(crate) launch_kind: String, pub(crate) raw_text: Option, pub(crate) user_input: Option, pub(crate) scene_key: Option, @@ -66,12 +67,26 @@ pub(crate) struct ServiceSceneLaunchContext { pub(crate) service_skill_key: Option, pub(crate) skill_title: Option, pub(crate) skill_summary: Option, + pub(crate) runner_type: Option, + pub(crate) execution_kind: Option, + pub(crate) execution_location: Option, + pub(crate) slot_values: Option, pub(crate) project_id: Option, pub(crate) content_id: Option, pub(crate) entry_source: Option, pub(crate) oem_runtime: ServiceSceneLaunchOemRuntimeContext, } +const CURRENT_SERVICE_SCENE_LAUNCH_KIND: &str = "local_service_skill"; +const COMPAT_SERVICE_SCENE_LAUNCH_KIND: &str = "cloud_scene"; + +fn is_supported_service_scene_launch_kind(kind: &str) -> bool { + matches!( + kind, + CURRENT_SERVICE_SCENE_LAUNCH_KIND | COMPAT_SERVICE_SCENE_LAUNCH_KIND + ) +} + fn extract_object_string( object: &serde_json::Map, keys: &[&str], @@ -315,9 +330,9 @@ pub(crate) fn extract_service_scene_launch_context( request_metadata, &["service_scene_launch", "serviceSceneLaunch"], )?; - let kind = - extract_object_string(launch, &["kind"]).unwrap_or_else(|| "cloud_scene".to_string()); - if kind != "cloud_scene" { + let kind = extract_object_string(launch, &["kind"]) + .unwrap_or_else(|| CURRENT_SERVICE_SCENE_LAUNCH_KIND.to_string()); + if !is_supported_service_scene_launch_kind(&kind) { return None; } @@ -335,6 +350,7 @@ pub(crate) fn extract_service_scene_launch_context( .and_then(serde_json::Value::as_object); Some(ServiceSceneLaunchContext { + launch_kind: kind, raw_text: extract_object_string(service_scene_run, &["raw_text", "rawText"]), user_input: extract_object_string(service_scene_run, &["user_input", "userInput"]), scene_key: extract_object_string(service_scene_run, &["scene_key", "sceneKey"]), @@ -346,6 +362,20 @@ pub(crate) fn extract_service_scene_launch_context( service_skill_key: extract_object_string(service_scene_run, &["skill_key", "skillKey"]), skill_title: extract_object_string(service_scene_run, &["skill_title", "skillTitle"]), skill_summary: extract_object_string(service_scene_run, &["skill_summary", "skillSummary"]), + runner_type: extract_object_string(service_scene_run, &["runner_type", "runnerType"]), + execution_kind: extract_object_string( + service_scene_run, + &["execution_kind", "executionKind"], + ), + execution_location: extract_object_string( + service_scene_run, + &["execution_location", "executionLocation"], + ), + slot_values: service_scene_run + .get("slot_values") + .or_else(|| service_scene_run.get("slotValues")) + .cloned() + .filter(|value| value.is_object()), project_id: extract_object_string(service_scene_run, &["project_id", "projectId"]), content_id: extract_object_string(service_scene_run, &["content_id", "contentId"]), entry_source: extract_object_string(service_scene_run, &["entry_source", "entrySource"]), diff --git a/src-tauri/src/commands/aster_agent_cmd/session_runtime.rs b/src-tauri/src/commands/aster_agent_cmd/session_runtime.rs index 4cfbbe97f..757abf94a 100644 --- a/src-tauri/src/commands/aster_agent_cmd/session_runtime.rs +++ b/src-tauri/src/commands/aster_agent_cmd/session_runtime.rs @@ -1,8 +1,9 @@ use super::runtime_project_hooks::{ + run_runtime_session_end_project_hooks_for_session_with_runtime, run_runtime_session_start_project_hooks, run_runtime_session_start_project_hooks_with_runtime, }; use super::*; -use aster::hooks::SessionSource; +use aster::hooks::{SessionEndReason, SessionSource}; use aster::session::load_shared_session_runtime_snapshot; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -168,6 +169,7 @@ pub(crate) fn resolve_recent_preference_from_sources( extract_harness_bool(request_metadata, keys).or(session_recent_preference) } +#[cfg(test)] pub(crate) async fn create_runtime_session_internal( db: &DbConnection, working_dir: Option, @@ -317,11 +319,21 @@ pub(crate) fn rename_runtime_session_internal( AsterAgentWrapper::rename_session_sync(db, session_id, name) } -pub(crate) async fn delete_runtime_session_internal( +pub(crate) async fn delete_runtime_session_internal_with_runtime( db: &DbConnection, + state: &AsterAgentState, + mcp_manager: &McpManagerState, session_id: &str, ) -> Result<(), String> { tracing::info!("[AsterAgent] 删除会话: {}", session_id); + run_runtime_session_end_project_hooks_for_session_with_runtime( + db, + state, + mcp_manager, + session_id, + SessionEndReason::Other, + ) + .await; AsterAgentWrapper::delete_session(db, session_id).await?; Ok(()) } diff --git a/src-tauri/src/commands/aster_agent_cmd/subagent_runtime.rs b/src-tauri/src/commands/aster_agent_cmd/subagent_runtime.rs index 797738996..d6ab1992d 100644 --- a/src-tauri/src/commands/aster_agent_cmd/subagent_runtime.rs +++ b/src-tauri/src/commands/aster_agent_cmd/subagent_runtime.rs @@ -184,6 +184,57 @@ fn normalize_optional_vec(values: &[String]) -> Vec { normalized } +fn normalize_frontmatter_hooks( + hooks: Option, +) -> Option { + let hooks = hooks?; + let normalized = hooks + .into_iter() + .filter_map(|(event, matchers)| { + let matchers = matchers + .into_iter() + .filter(|matcher| !matcher.hooks.is_empty()) + .collect::>(); + if matchers.is_empty() { + None + } else { + Some((event, matchers)) + } + }) + .collect::(); + + if normalized.is_empty() { + None + } else { + Some(normalized) + } +} + +fn register_runtime_subagent_frontmatter_hooks( + session_id: &str, + hooks: Option<&aster::hooks::FrontmatterHooks>, +) { + let Some(hooks) = hooks else { + return; + }; + + let report = aster::hooks::register_agent_session_frontmatter_hooks(session_id, hooks); + if report.registered > 0 { + tracing::info!( + "[AsterAgent][Subagent] 已为 child session={} 注册 {} 个 agent frontmatter hooks", + session_id, + report.registered + ); + } + for skipped in report.skipped { + tracing::warn!( + "[AsterAgent][Subagent] 跳过 child session={} 的 agent frontmatter hook: {}", + session_id, + skipped + ); + } +} + fn validate_spawn_request_surface( request: &AgentRuntimeSpawnSubagentRequest, ) -> Result<(), String> { @@ -332,6 +383,9 @@ pub(crate) fn build_subagent_customization_state( skill_ids.extend(normalize_optional_vec(&request.skill_ids)); let skill_ids = normalize_optional_vec(&skill_ids); let skill_directories = normalize_optional_vec(&request.skill_directories); + let hooks = normalize_frontmatter_hooks(request.hooks.clone()); + let allowed_tools = normalize_optional_vec(&request.allowed_tools); + let disallowed_tools = normalize_optional_vec(&request.disallowed_tools); let mut skills = skill_ids .iter() @@ -369,6 +423,9 @@ pub(crate) fn build_subagent_customization_state( .or_else(|| profile.map(|descriptor| descriptor.system_overlay.to_string())), skill_ids, skills, + hooks, + allowed_tools, + disallowed_tools, }; if state.is_empty() { @@ -747,6 +804,12 @@ async fn create_runtime_subagent_session( request.model.as_deref(), ) .await?; + register_runtime_subagent_frontmatter_hooks( + &session.id, + customization + .as_ref() + .and_then(|state| state.hooks.as_ref()), + ); Ok(PreparedRuntimeSubagentSession { session, @@ -832,6 +895,8 @@ pub(crate) async fn agent_runtime_spawn_subagent_internal( "output_contract": customization.as_ref().and_then(|state| state.output_contract.clone()), "skill_ids": customization.as_ref().map(|state| state.skill_ids.clone()).unwrap_or_default(), "skills": customization.as_ref().map(|state| state.skills.clone()).unwrap_or_default(), + "allowed_tools": customization.as_ref().map(|state| state.allowed_tools.clone()).unwrap_or_default(), + "disallowed_tools": customization.as_ref().map(|state| state.disallowed_tools.clone()).unwrap_or_default(), } })), turn_id: None, @@ -911,6 +976,8 @@ pub(crate) async fn agent_runtime_send_subagent_input_internal( "output_contract": customization.as_ref().and_then(|state| state.output_contract.clone()), "skill_ids": customization.as_ref().map(|state| state.skill_ids.clone()).unwrap_or_default(), "skills": customization.as_ref().map(|state| state.skills.clone()).unwrap_or_default(), + "allowed_tools": customization.as_ref().map(|state| state.allowed_tools.clone()).unwrap_or_default(), + "disallowed_tools": customization.as_ref().map(|state| state.disallowed_tools.clone()).unwrap_or_default(), } })), turn_id: None, @@ -1109,6 +1176,9 @@ mod tests { theme: None, system_overlay: None, output_contract: None, + hooks: None, + allowed_tools: Vec::new(), + disallowed_tools: Vec::new(), mode: None, isolation: None, cwd: None, @@ -1155,6 +1225,9 @@ mod tests { theme: None, system_overlay: None, output_contract: None, + hooks: None, + allowed_tools: Vec::new(), + disallowed_tools: Vec::new(), mode: Some("plan".to_string()), isolation: None, cwd: None, @@ -1200,4 +1273,96 @@ mod tests { lime_agent::SessionExecutionRuntimeAccessMode::ReadOnly ); } + + #[test] + fn test_build_subagent_customization_state_keeps_frontmatter_hooks() { + let customization = build_subagent_customization_state(&AgentRuntimeSpawnSubagentRequest { + parent_session_id: "parent-1".to_string(), + message: "检查 agent hooks".to_string(), + name: None, + team_name: None, + agent_type: None, + model: None, + run_in_background: false, + reasoning_effort: None, + fork_context: false, + blueprint_role_id: None, + blueprint_role_label: None, + profile_id: None, + profile_name: None, + role_key: None, + skill_ids: Vec::new(), + skill_directories: Vec::new(), + team_preset_id: None, + theme: None, + system_overlay: None, + output_contract: None, + hooks: Some( + serde_json::from_value(serde_json::json!({ + "Stop": [ + { + "hooks": [ + { + "type": "prompt", + "prompt": "Summarize before exit" + } + ] + } + ] + })) + .expect("hooks should deserialize"), + ), + allowed_tools: Vec::new(), + disallowed_tools: Vec::new(), + mode: None, + isolation: None, + cwd: None, + }) + .expect("build customization state") + .expect("customization should exist"); + + assert!(customization + .hooks + .as_ref() + .and_then(|hooks| hooks.get(&aster::hooks::HookEvent::Stop)) + .is_some()); + } + + #[test] + fn test_register_runtime_subagent_frontmatter_hooks_rewrites_stop_event() { + aster::hooks::clear_session_hooks("runtime-subagent-hook"); + let hooks: aster::hooks::FrontmatterHooks = serde_json::from_value(serde_json::json!({ + "Stop": [ + { + "hooks": [ + { + "type": "prompt", + "prompt": "Summarize before exit" + } + ] + } + ] + })) + .expect("hooks should deserialize"); + + register_runtime_subagent_frontmatter_hooks("runtime-subagent-hook", Some(&hooks)); + + assert!(aster::hooks::get_matching_session_hooks( + "runtime-subagent-hook", + aster::hooks::HookEvent::Stop, + None, + ) + .is_empty()); + assert_eq!( + aster::hooks::get_matching_session_hooks( + "runtime-subagent-hook", + aster::hooks::HookEvent::SubagentStop, + None, + ) + .len(), + 1 + ); + + aster::hooks::clear_session_hooks("runtime-subagent-hook"); + } } diff --git a/src-tauri/src/commands/aster_agent_cmd/tests.rs b/src-tauri/src/commands/aster_agent_cmd/tests.rs index 006244f61..77c677a6a 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tests.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tests.rs @@ -782,6 +782,105 @@ mod tests { assert!(permissions.is_empty()); } + #[test] + fn test_append_subagent_tool_scope_session_permissions_builds_whitelist_and_deny_rules() { + let metadata = serde_json::json!({ + "subagent": { + "allowed_tools": ["Read", "Bash", "Read", " "], + "disallowed_tools": ["WebSearch", "Bash"] + } + }); + let mut permissions = Vec::new(); + + append_subagent_tool_scope_session_permissions( + &mut permissions, + "session-subagent-scope-1", + Some(&metadata), + ); + + let default_deny = permissions + .iter() + .find(|permission| permission.tool == "*") + .expect("should add wildcard deny rule"); + assert!(!default_deny.allowed); + assert_eq!(default_deny.priority, 1298); + assert_eq!(default_deny.conditions.len(), 1); + assert_eq!( + default_deny.conditions[0].value, + serde_json::json!("session-subagent-scope-1") + ); + + let read_allow = permissions + .iter() + .find(|permission| permission.tool == "Read" && permission.allowed) + .expect("should add Read allow rule"); + assert_eq!(read_allow.priority, 1299); + assert_eq!(read_allow.conditions, default_deny.conditions); + + let bash_deny = permissions + .iter() + .find(|permission| permission.tool == "Bash" && !permission.allowed) + .expect("should add Bash deny rule"); + assert_eq!(bash_deny.priority, 1300); + assert_eq!(bash_deny.conditions, default_deny.conditions); + } + + #[test] + fn test_append_subagent_tool_scope_session_permissions_enforces_child_session_scope() { + let metadata = serde_json::json!({ + "subagent": { + "allowed_tools": ["Read", "Bash"], + "disallowed_tools": ["Bash"] + } + }); + let mut permissions = Vec::new(); + + append_subagent_tool_scope_session_permissions( + &mut permissions, + "session-subagent-scope-2", + Some(&metadata), + ); + + let mut manager = aster::permission::ToolPermissionManager::new(None); + for permission in permissions { + manager.add_permission(permission, PermissionScope::Session); + } + + let context = aster::permission::PermissionContext { + working_directory: std::path::PathBuf::from("."), + session_id: "session-subagent-scope-2".to_string(), + timestamp: chrono::Utc::now().timestamp(), + user: None, + environment: std::collections::HashMap::new(), + metadata: std::collections::HashMap::new(), + }; + let other_context = aster::permission::PermissionContext { + session_id: "session-subagent-scope-other".to_string(), + ..context.clone() + }; + + assert!( + manager + .is_allowed("Read", &std::collections::HashMap::new(), &context) + .allowed + ); + assert!( + !manager + .is_allowed("Edit", &std::collections::HashMap::new(), &context) + .allowed + ); + assert!( + !manager + .is_allowed("Bash", &std::collections::HashMap::new(), &context) + .allowed + ); + assert!( + manager + .is_allowed("Edit", &std::collections::HashMap::new(), &other_context) + .allowed + ); + } + #[test] fn test_prune_fast_chat_request_tool_policy_tools_from_registry_hides_web_tools_when_disabled() { @@ -4286,7 +4385,7 @@ mod tests { let metadata = serde_json::json!({ "harness": { "service_scene_launch": { - "kind": "cloud_scene", + "kind": "local_service_skill", "service_scene_run": { "skill_id": "skill-scene-1", "skill_title": "趋势赛题日报", @@ -4294,6 +4393,8 @@ mod tests { "scene_key": "daily-trend-brief", "command_prefix": "/daily-trend-brief", "user_input": "帮我输出今天的小红书趋势赛题", + "execution_kind": "agent_turn", + "execution_location": "client_default", "entry_source": "slash_scene_command", "project_id": "project-1", "content_id": "content-1", @@ -4313,11 +4414,12 @@ mod tests { .expect("should contain merged prompt"); assert!(merged.contains(SERVICE_SKILL_LAUNCH_PROMPT_MARKER)); - assert!(merged.contains("第一优先工具调用必须是 lime_run_service_skill")); - assert!(merged.contains("不要把 scene metadata 里的 session_token")); + assert!(merged.contains("当前主链是“目录命中 + 本地 Agent 执行”")); + assert!(merged.contains("不要调用 lime_run_service_skill")); assert!(merged.contains("当前服务型技能 ID:skill-scene-1")); assert!(merged.contains("当前 scene_key:daily-trend-brief")); - assert!(merged.contains("当前回合已绑定 OEM Session Token")); + assert!(merged.contains("当前 execution_kind=agent_turn")); + assert!(merged.contains("旧 OEM 运行时字段")); } #[test] @@ -5568,7 +5670,7 @@ mod tests { let metadata = serde_json::json!({ "harness": { "service_scene_launch": { - "kind": "cloud_scene", + "kind": "local_service_skill", "service_scene_run": { "skill_id": "skill-scene-1", "scene_key": "daily-trend-brief" @@ -6596,6 +6698,23 @@ mod tests { theme: None, system_overlay: None, output_contract: None, + hooks: Some( + serde_json::from_value(serde_json::json!({ + "Stop": [ + { + "hooks": [ + { + "type": "prompt", + "prompt": "Summarize child session" + } + ] + } + ] + })) + .expect("hooks should deserialize"), + ), + allowed_tools: vec!["Read".to_string(), "Bash".to_string()], + disallowed_tools: vec!["WebSearch".to_string()], mode: None, isolation: None, cwd: None, @@ -6624,6 +6743,13 @@ mod tests { assert!(customization .skill_ids .contains(&"verification-report".to_string())); + assert!(customization + .hooks + .as_ref() + .and_then(|hooks| hooks.get(&aster::hooks::HookEvent::Stop)) + .is_some()); + assert_eq!(customization.allowed_tools, vec!["Read", "Bash"]); + assert_eq!(customization.disallowed_tools, vec!["WebSearch"]); } #[test] @@ -6647,6 +6773,9 @@ mod tests { source: Some("builtin".to_string()), directory: None, }], + hooks: None, + allowed_tools: vec!["Read".to_string(), "ToolSearch".to_string()], + disallowed_tools: vec!["WebSearch".to_string()], })) .expect("prompt build should succeed") .expect("prompt should exist"); @@ -6657,6 +6786,8 @@ mod tests { assert!(prompt.contains("代码排障团队")); assert!(prompt.contains("仓库探索")); assert!(prompt.contains("输出问题定位、证据与影响面。")); + assert!(prompt.contains("Allowed Tools:Read, ToolSearch")); + assert!(prompt.contains("Disallowed Tools:WebSearch")); } #[test] diff --git a/src-tauri/src/commands/aster_agent_cmd/tool_runtime.rs b/src-tauri/src/commands/aster_agent_cmd/tool_runtime.rs index 57a68a70e..58d5075a3 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tool_runtime.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tool_runtime.rs @@ -110,6 +110,9 @@ fn unregister_named_tools(registry: &mut aster::tools::ToolRegistry, tool_names: const FAST_CHAT_DISABLED_WEB_TOOL_PATTERNS: &[&str] = &["WebSearch", "web_search", "WebFetch", "web_fetch"]; +const SUBAGENT_TOOL_SCOPE_DEFAULT_DENY_PRIORITY: i32 = 1298; +const SUBAGENT_TOOL_SCOPE_ALLOW_PRIORITY: i32 = 1299; +const SUBAGENT_TOOL_SCOPE_DISALLOW_PRIORITY: i32 = 1300; fn build_tool_runtime_session_scoped_permission_conditions( session_id: &str, @@ -129,6 +132,109 @@ fn build_tool_runtime_session_scoped_permission_conditions( }] } +fn normalize_tool_scope_list(values: &[serde_json::Value]) -> Vec { + let mut normalized = Vec::new(); + let mut seen = HashSet::new(); + + for value in values { + let Some(raw) = value.as_str() else { + continue; + }; + let trimmed = raw.trim(); + if trimmed.is_empty() { + continue; + } + if seen.insert(trimmed.to_string()) { + normalized.push(trimmed.to_string()); + } + } + + normalized +} + +fn extract_subagent_tool_scope_lists( + request_metadata: Option<&serde_json::Value>, +) -> (Vec, Vec) { + let subagent = request_metadata + .and_then(|value| value.get("subagent")) + .and_then(serde_json::Value::as_object); + let allowed_tools = subagent + .and_then(|value| { + value + .get("allowed_tools") + .or_else(|| value.get("allowedTools")) + }) + .and_then(serde_json::Value::as_array) + .map(|values| normalize_tool_scope_list(values)) + .unwrap_or_default(); + let disallowed_tools = subagent + .and_then(|value| { + value + .get("disallowed_tools") + .or_else(|| value.get("disallowedTools")) + }) + .and_then(serde_json::Value::as_array) + .map(|values| normalize_tool_scope_list(values)) + .unwrap_or_default(); + + (allowed_tools, disallowed_tools) +} + +pub(crate) fn append_subagent_tool_scope_session_permissions( + permissions: &mut Vec, + session_id: &str, + request_metadata: Option<&serde_json::Value>, +) { + let (allowed_tools, disallowed_tools) = extract_subagent_tool_scope_lists(request_metadata); + if allowed_tools.is_empty() && disallowed_tools.is_empty() { + return; + } + + let conditions = build_tool_runtime_session_scoped_permission_conditions(session_id); + + if !allowed_tools.is_empty() { + permissions.push(ToolPermission { + tool: "*".to_string(), + allowed: false, + priority: SUBAGENT_TOOL_SCOPE_DEFAULT_DENY_PRIORITY, + conditions: conditions.clone(), + parameter_restrictions: Vec::new(), + scope: PermissionScope::Session, + reason: Some("subagent current surface 已启用 allowed_tools 白名单".to_string()), + expires_at: None, + metadata: HashMap::new(), + }); + + for tool_name in allowed_tools { + permissions.push(ToolPermission { + tool: tool_name, + allowed: true, + priority: SUBAGENT_TOOL_SCOPE_ALLOW_PRIORITY, + conditions: conditions.clone(), + parameter_restrictions: Vec::new(), + scope: PermissionScope::Session, + reason: Some("subagent current surface 显式允许该工具".to_string()), + expires_at: None, + metadata: HashMap::new(), + }); + } + } + + for tool_name in disallowed_tools { + permissions.push(ToolPermission { + tool: tool_name, + allowed: false, + priority: SUBAGENT_TOOL_SCOPE_DISALLOW_PRIORITY, + conditions: conditions.clone(), + parameter_restrictions: Vec::new(), + scope: PermissionScope::Session, + reason: Some("subagent current surface 显式禁止该工具".to_string()), + expires_at: None, + metadata: HashMap::new(), + }); + } +} + fn should_hide_fast_chat_web_tools( execution_profile: TurnExecutionProfile, request_tool_policy: &RequestToolPolicy, @@ -304,6 +410,7 @@ pub(crate) async fn apply_workspace_sandbox_permissions( execution_profile, request_tool_policy, ); + append_subagent_tool_scope_session_permissions(&mut permissions, session_id, request_metadata); append_image_skill_launch_session_permissions(&mut permissions, session_id, request_metadata); append_cover_skill_launch_session_permissions(&mut permissions, session_id, request_metadata); append_video_skill_launch_session_permissions(&mut permissions, session_id, request_metadata); diff --git a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/service_skill_tools.rs b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/service_skill_tools.rs index f93ff0b3a..e0d2f55e3 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/service_skill_tools.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/service_skill_tools.rs @@ -5,11 +5,6 @@ use crate::commands::aster_agent_cmd::service_skill_launch::{ }; use aster::session::{load_shared_session_runtime_snapshot, SessionRuntimeSnapshot}; -const DEFAULT_SERVICE_SKILL_POLL_ATTEMPTS: u32 = 6; -const DEFAULT_SERVICE_SKILL_POLL_INTERVAL_MS: u64 = 1_500; -const MAX_SERVICE_SKILL_POLL_ATTEMPTS: u32 = 20; -const MAX_SERVICE_SKILL_POLL_INTERVAL_MS: u64 = 8_000; -const TERMINAL_SERVICE_SKILL_STATUSES: &[&str] = &["success", "failed", "canceled", "timeout"]; const SERVICE_SCENE_LAUNCH_CONTEXT_ENV_KEYS: &[&str] = &[ "LIME_SERVICE_SCENE_LAUNCH_CONTEXT", "PROXYCAST_SERVICE_SCENE_LAUNCH_CONTEXT", @@ -20,60 +15,12 @@ const SERVICE_SCENE_LAUNCH_CONTEXT_ENV_KEYS: &[&str] = &[ struct ServiceSkillRunToolInput { #[serde(default)] input: Option, - #[serde(default)] - wait_for_completion: Option, - #[serde(default)] - poll_attempts: Option, - #[serde(default)] - poll_interval_ms: Option, -} - -#[derive(Debug, Deserialize, Serialize, Clone, Default)] -#[serde(rename_all = "camelCase")] -struct ServiceSkillRunRecord { - id: String, - #[serde(default)] - status: String, - #[serde(default)] - run_type: Option, - #[serde(default)] - scene_id: Option, - #[serde(default)] - service_skill_id: Option, - #[serde(default)] - service_skill_key: Option, - #[serde(default)] - executor_kind: Option, - #[serde(default)] - input_summary: Option, - #[serde(default)] - output_summary: Option, - #[serde(default)] - output_text: Option, - #[serde(default)] - error_code: Option, - #[serde(default)] - error_message: Option, - #[serde(default)] - fallback_applied: Option, - #[serde(default)] - fallback_kind: Option, - #[serde(default)] - started_at: Option, - #[serde(default)] - finished_at: Option, - #[serde(default)] - updated_at: Option, -} - -#[derive(Debug, Deserialize)] -struct ServiceSkillRunEnvelope { - #[serde(default)] - code: Option, - #[serde(default)] - message: Option, - #[serde(default)] - data: Option, + #[serde(default, rename = "waitForCompletion")] + _wait_for_completion: Option, + #[serde(default, rename = "pollAttempts")] + _poll_attempts: Option, + #[serde(default, rename = "pollIntervalMs")] + _poll_interval_ms: Option, } #[derive(Clone)] @@ -91,17 +38,6 @@ impl LimeRunServiceSkillTool { .map(ToString::to_string) } - fn normalize_status(status: &str) -> String { - status.trim().to_ascii_lowercase() - } - - fn is_terminal_status(status: &str) -> bool { - let normalized = Self::normalize_status(status); - TERMINAL_SERVICE_SKILL_STATUSES - .iter() - .any(|candidate| normalized == *candidate) - } - fn build_request_metadata_value( metadata: &HashMap, ) -> Option { @@ -212,94 +148,13 @@ impl LimeRunServiceSkillTool { Ok(effective_input) } - fn resolve_scene_base_url( + fn build_compat_payload( launch_context: &ServiceSceneLaunchContext, - ) -> Result { - Self::normalize_optional_text(launch_context.oem_runtime.scene_base_url.as_deref()) - .ok_or_else(|| { - ToolError::execution_failed( - "缺少 OEM sceneBaseUrl,请先完成 OEM 云端接线".to_string(), - ) - }) - } - - fn resolve_session_token( - launch_context: &ServiceSceneLaunchContext, - ) -> Result { - Self::normalize_optional_text(launch_context.oem_runtime.session_token.as_deref()) - .ok_or_else(|| { - ToolError::execution_failed( - "缺少 OEM Session Token,请先登录或注入 OEM 云端会话".to_string(), - ) - }) - } - - async fn request_run( - client: &reqwest::Client, - scene_base_url: &str, - session_token: &str, - path: &str, - method: reqwest::Method, - body: Option, - ) -> Result { - let url = format!("{}{}", scene_base_url.trim_end_matches('/'), path); - let mut request = client - .request(method, &url) - .header(reqwest::header::ACCEPT, "application/json") - .bearer_auth(session_token) - .header(reqwest::header::CONTENT_TYPE, "application/json"); - - if let Some(body) = body { - request = request.json(&body); - } - - let response = request.send().await.map_err(|error| { - ToolError::execution_failed(format!("请求服务型技能运行时失败: {error}")) - })?; - let status = response.status(); - let payload = response - .json::() - .await - .map_err(|error| { - ToolError::execution_failed(format!("解析服务型技能运行结果失败: {error}")) - })?; - - if !status.is_success() { - let message = payload - .message - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or("服务端返回失败"); - return Err(ToolError::execution_failed(format!( - "服务型技能运行请求失败 ({}): {}", - status.as_u16(), - message - ))); - } - - if let Some(code) = payload.code { - if code >= 400 { - return Err(ToolError::execution_failed( - payload - .message - .unwrap_or_else(|| "服务端返回非法运行结果".to_string()), - )); - } - } - - payload.data.ok_or_else(|| { - ToolError::execution_failed("服务端返回的 service skill run 记录为空".to_string()) - }) - } - - fn build_success_payload( - launch_context: &ServiceSceneLaunchContext, - run: &ServiceSkillRunRecord, submitted_input: &str, ) -> serde_json::Value { serde_json::json!({ - "ok": run.status == "success", + "ok": false, + "compatOnly": true, "submittedInput": submitted_input, "serviceSkill": { "id": launch_context.service_skill_id, @@ -311,39 +166,29 @@ impl LimeRunServiceSkillTool { "sceneKey": launch_context.scene_key, "commandPrefix": launch_context.command_prefix, }, - "run": run, + "execution": { + "launchKind": launch_context.launch_kind, + "runnerType": launch_context.runner_type, + "executionKind": launch_context.execution_kind, + "executionLocation": launch_context.execution_location, + }, + "message": "lime_run_service_skill 仅为历史兼容保留;current 服务型做法应直接在当前本地回合执行。", }) } - fn build_result_summary( - launch_context: &ServiceSceneLaunchContext, - run: &ServiceSkillRunRecord, - ) -> String { + fn build_compat_summary(launch_context: &ServiceSceneLaunchContext) -> String { let title = launch_context .skill_title .as_deref() .filter(|value| !value.trim().is_empty()) .unwrap_or("服务型技能"); - let status = run.status.trim(); - - if status == "success" { - if let Some(summary) = Self::normalize_optional_text(run.output_summary.as_deref()) { - return format!("{title} 执行完成:{summary}"); - } - return format!("{title} 执行完成"); + if launch_context.execution_kind.as_deref() == Some("automation_job") { + return format!( + "{title} 已切到本地主链:请直接在当前回合产出首轮结果或调度建议,不要再调用 lime_run_service_skill。" + ); } - if Self::is_terminal_status(status) { - if let Some(message) = Self::normalize_optional_text(run.error_message.as_deref()) { - return format!("{title} 执行失败:{message}"); - } - return format!("{title} 已结束,状态为 {status}"); - } - - if let Some(summary) = Self::normalize_optional_text(run.output_summary.as_deref()) { - return format!("{title} 当前状态 {status}:{summary}"); - } - format!("{title} 已提交云端,当前状态 {status}") + format!("{title} 已切到本地主链:请直接在当前回合继续执行,不再提交 OEM 云端运行。") } } @@ -354,7 +199,7 @@ impl Tool for LimeRunServiceSkillTool { } fn description(&self) -> &str { - "运行当前回合绑定的服务型技能场景,提交到 OEM Scene Runtime 并返回最新运行状态。" + "兼容旧会话的服务型做法运行工具。current 主链已改为本地 Agent 直接执行,不再提交 OEM 云端运行。" } fn input_schema(&self) -> serde_json::Value { @@ -363,29 +208,29 @@ impl Tool for LimeRunServiceSkillTool { "properties": { "input": { "type": "string", - "description": "可选补充输入。默认取当前 scene launch 里的 user_input 或 raw_text。" + "description": "兼容参数:可选补充输入,默认取当前 scene launch 里的 user_input 或 raw_text。" }, "waitForCompletion": { "type": "boolean", - "description": "是否在当前工具调用内短轮询等待一轮结果,默认 true。" + "description": "兼容参数:当前已忽略,不再触发云端轮询。" }, "pollAttempts": { "type": "integer", "minimum": 1, "maximum": 20, - "description": "短轮询次数,默认 6。" + "description": "兼容参数:当前已忽略。" }, "pollIntervalMs": { "type": "integer", "minimum": 200, "maximum": 8000, - "description": "轮询间隔毫秒数,默认 1500。" + "description": "兼容参数:当前已忽略。" } }, "additionalProperties": false, "x-lime": { "always_visible": true, - "tags": ["service-skill", "scene", "cloud-runtime"], + "tags": ["service-skill", "scene", "compat"], "allowed_callers": ["assistant", "skill"] } }) @@ -400,74 +245,15 @@ impl Tool for LimeRunServiceSkillTool { .map_err(|error| ToolError::invalid_params(format!("参数解析失败: {error}")))?; let launch_context = Self::resolve_launch_context(context).await?; let effective_input = Self::resolve_effective_input(&launch_context, &input)?; - let scene_base_url = Self::resolve_scene_base_url(&launch_context)?; - let session_token = Self::resolve_session_token(&launch_context)?; - let wait_for_completion = input.wait_for_completion.unwrap_or(true); - let poll_attempts = input - .poll_attempts - .unwrap_or(DEFAULT_SERVICE_SKILL_POLL_ATTEMPTS) - .clamp(1, MAX_SERVICE_SKILL_POLL_ATTEMPTS); - let poll_interval_ms = input - .poll_interval_ms - .unwrap_or(DEFAULT_SERVICE_SKILL_POLL_INTERVAL_MS) - .clamp(200, MAX_SERVICE_SKILL_POLL_INTERVAL_MS); - let client = reqwest::Client::new(); - - let create_path = format!( - "/v1/service-skills/{}/runs", - urlencoding::encode(&launch_context.service_skill_id) - ); - let mut run = Self::request_run( - &client, - &scene_base_url, - &session_token, - &create_path, - reqwest::Method::POST, - Some(serde_json::json!({ - "input": effective_input, - })), - ) - .await?; - - if wait_for_completion && !Self::is_terminal_status(&run.status) { - for _ in 0..poll_attempts { - tokio::time::sleep(std::time::Duration::from_millis(poll_interval_ms)).await; - let run_path = format!( - "/v1/service-skills/runs/{}", - urlencoding::encode(run.id.as_str()) - ); - run = Self::request_run( - &client, - &scene_base_url, - &session_token, - &run_path, - reqwest::Method::GET, - None, - ) - .await?; - if Self::is_terminal_status(&run.status) { - break; - } - } - } - - let payload = Self::build_success_payload(&launch_context, &run, &effective_input); - let summary = Self::build_result_summary(&launch_context, &run); - let serialized = - serde_json::to_string_pretty(&payload).unwrap_or_else(|_| payload.to_string()); - let mut result = if Self::normalize_status(&run.status) == "failed" - || Self::normalize_status(&run.status) == "canceled" - || Self::normalize_status(&run.status) == "timeout" - { - ToolResult::error(summary) - } else { - ToolResult::success(serialized) - }; + let payload = Self::build_compat_payload(&launch_context, &effective_input); + let summary = Self::build_compat_summary(&launch_context); + let mut result = ToolResult::error(summary.clone()); result = result .with_metadata("tool_family", serde_json::json!("service_skill")) + .with_metadata("compat_only", serde_json::json!(true)) .with_metadata("result", payload) - .with_metadata("run_status", serde_json::json!(run.status)) + .with_metadata("compat_summary", serde_json::json!(summary)) .with_metadata( "service_skill_id", serde_json::json!(launch_context.service_skill_id), @@ -519,9 +305,9 @@ mod tests { Some("旧 turn".to_string()), Some(TurnContextOverride { metadata: metadata_map(serde_json::json!({ - "harness": { - "service_scene_launch": { - "kind": "cloud_scene", + "harness": { + "service_scene_launch": { + "kind": "local_service_skill", "service_scene_run": { "skill_id": "skill-older", "scene_key": "scene-older", @@ -546,9 +332,9 @@ mod tests { Some("新 turn".to_string()), Some(TurnContextOverride { metadata: metadata_map(serde_json::json!({ - "harness": { - "service_scene_launch": { - "kind": "cloud_scene", + "harness": { + "service_scene_launch": { + "kind": "local_service_skill", "service_scene_run": { "skill_id": "skill-latest", "scene_key": "scene-latest", @@ -624,4 +410,44 @@ mod tests { Some("https://example.com/scene-api") ); } + + #[tokio::test] + async fn should_return_compat_guard_result_when_tool_is_called() { + let tool = LimeRunServiceSkillTool::new(); + let context = ToolContext::new(PathBuf::from("/tmp/service-scene")).with_environment( + HashMap::from([( + SERVICE_SCENE_LAUNCH_CONTEXT_ENV_KEYS[0].to_string(), + serde_json::json!({ + "kind": "local_service_skill", + "service_scene_run": { + "skill_id": "skill-local", + "skill_title": "趋势日报", + "scene_key": "daily-trend-brief", + "execution_kind": "agent_turn", + "user_input": "帮我整理今天的 AI Agent 趋势" + } + }) + .to_string(), + )]), + ); + + let result = tool + .execute(serde_json::json!({}), &context) + .await + .expect("tool should return compat guard result"); + + assert!(!result.success); + assert!(result + .error + .as_deref() + .is_some_and(|value| value.contains("已切到本地主链"))); + assert_eq!( + result.metadata.get("compat_only"), + Some(&serde_json::json!(true)) + ); + assert_eq!( + result.metadata.get("service_skill_id"), + Some(&serde_json::json!("skill-local")) + ); + } } diff --git a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/subagent_tools.rs b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/subagent_tools.rs index c091e6bb2..d6e7094cd 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/subagent_tools.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/subagent_tools.rs @@ -115,6 +115,9 @@ fn build_agent_control_tool_config( theme: request.theme, system_overlay: request.system_overlay, output_contract: request.output_contract, + hooks: request.hooks, + allowed_tools: request.allowed_tools, + disallowed_tools: request.disallowed_tools, mode: None, isolation: None, cwd: request.cwd, diff --git a/src-tauri/src/sceneapp/adapters.rs b/src-tauri/src/sceneapp/adapters.rs index 3d2cff1a7..62ab01bbb 100644 --- a/src-tauri/src/sceneapp/adapters.rs +++ b/src-tauri/src/sceneapp/adapters.rs @@ -45,6 +45,29 @@ fn primary_binding_family(descriptor: &SceneAppDescriptor) -> SceneAppBindingFam .unwrap_or(SceneAppBindingFamily::AgentTurn) } +fn should_open_service_scene_session( + descriptor: &SceneAppDescriptor, + binding_family: &SceneAppBindingFamily, +) -> bool { + match binding_family { + SceneAppBindingFamily::CloudScene => true, + SceneAppBindingFamily::AgentTurn => { + matches!( + descriptor.sceneapp_type, + SceneAppType::LocalInstant | SceneAppType::Hybrid + ) && (descriptor.linked_service_skill_id.is_some() + || descriptor.linked_scene_key.is_some()) + && descriptor.entry_bindings.iter().any(|binding| { + matches!( + binding.kind, + SceneAppEntryBindingKind::ServiceSkill | SceneAppEntryBindingKind::Scene + ) + }) + } + _ => false, + } +} + fn default_sceneapp_schedule() -> TaskSchedule { TaskSchedule::Every { every_secs: 3600 } } @@ -292,30 +315,33 @@ fn build_sceneapp_runtime_request_metadata( return root; }; + if should_open_service_scene_session(descriptor, adapter_kind) { + harness.insert( + "service_scene_launch".to_string(), + json!({ + "kind": "local_service_skill", + "service_scene_run": { + "sceneapp_id": descriptor.id.clone(), + "scene_key": descriptor.linked_scene_key.clone(), + "linked_skill_id": descriptor.linked_service_skill_id.clone(), + "skill_id": descriptor.linked_service_skill_id.clone(), + "skill_title": descriptor.title.clone(), + "skill_summary": descriptor.summary.clone(), + "execution_kind": "local_service_skill", + "execution_location": "client_default", + "entry_source": launch_intent.entry_source.clone().unwrap_or_else(|| "sceneapp_plan".to_string()), + "workspace_id": launch_intent.workspace_id.clone(), + "project_id": launch_intent.project_id.clone(), + "user_input": launch_intent.user_input.clone(), + "reference_memory_ids": launch_intent.reference_memory_ids.clone(), + "slots": launch_intent.slots.clone(), + } + }), + ); + return root; + } + match adapter_kind { - SceneAppBindingFamily::CloudScene => { - harness.insert( - "service_scene_launch".to_string(), - json!({ - "kind": "cloud_scene", - "service_scene_run": { - "sceneapp_id": descriptor.id.clone(), - "scene_key": descriptor.linked_scene_key.clone(), - "linked_skill_id": descriptor.linked_service_skill_id.clone(), - "skill_id": descriptor.linked_service_skill_id.clone(), - "skill_title": descriptor.title.clone(), - "skill_summary": descriptor.summary.clone(), - "execution_kind": "cloud_scene", - "entry_source": launch_intent.entry_source.clone().unwrap_or_else(|| "sceneapp_plan".to_string()), - "workspace_id": launch_intent.workspace_id.clone(), - "project_id": launch_intent.project_id.clone(), - "user_input": launch_intent.user_input.clone(), - "reference_memory_ids": launch_intent.reference_memory_ids.clone(), - "slots": launch_intent.slots.clone(), - } - }), - ); - } SceneAppBindingFamily::BrowserAssist => { let adapter_name = browser_assist_adapter_name(descriptor) .map(str::to_string) @@ -381,6 +407,7 @@ fn build_sceneapp_runtime_request_metadata( json!("submit_agent_turn"), ); } + SceneAppBindingFamily::CloudScene => {} } root @@ -1182,9 +1209,9 @@ fn read_nested_launch_context<'a>(value: &'a Value, keys: &[&str]) -> Option<&'a keys.iter().find_map(|key| object.get(*key)) } -fn extract_cloud_scene_runtime_ref_from_metadata_value( +fn extract_service_scene_runtime_ref_from_metadata_value( parsed: &Value, -) -> Option { +) -> Option { let launch_candidates = [ parsed.get("service_scene_launch"), parsed.get("serviceSceneLaunch"), @@ -1246,7 +1273,7 @@ fn extract_cloud_scene_runtime_ref_from_metadata_value( return None; } - Some(SceneAppCloudSceneRuntimeRef { + Some(SceneAppServiceSceneRuntimeRef { scene_key, skill_id, project_id, @@ -1421,12 +1448,19 @@ fn build_sceneapp_request_metadata( )) } -fn build_sceneapp_runtime_action(binding_family: &SceneAppBindingFamily) -> SceneAppRuntimeAction { +fn build_sceneapp_runtime_action( + descriptor: &SceneAppDescriptor, + binding_family: &SceneAppBindingFamily, +) -> SceneAppRuntimeAction { + if should_open_service_scene_session(descriptor, binding_family) { + return SceneAppRuntimeAction::OpenServiceSceneSession; + } + match binding_family { SceneAppBindingFamily::AgentTurn => SceneAppRuntimeAction::SubmitAgentTurn, SceneAppBindingFamily::BrowserAssist => SceneAppRuntimeAction::LaunchBrowserAssist, SceneAppBindingFamily::AutomationJob => SceneAppRuntimeAction::CreateAutomationJob, - SceneAppBindingFamily::CloudScene => SceneAppRuntimeAction::LaunchCloudScene, + SceneAppBindingFamily::CloudScene => SceneAppRuntimeAction::OpenServiceSceneSession, SceneAppBindingFamily::NativeSkill => SceneAppRuntimeAction::LaunchNativeSkill, } } @@ -1436,7 +1470,8 @@ pub fn build_sceneapp_runtime_adapter_plan( launch_intent: &SceneAppLaunchIntent, ) -> SceneAppRuntimeAdapterPlan { let adapter_kind = primary_binding_family(descriptor); - let runtime_action = build_sceneapp_runtime_action(&adapter_kind); + let service_scene_session = should_open_service_scene_session(descriptor, &adapter_kind); + let runtime_action = build_sceneapp_runtime_action(descriptor, &adapter_kind); let request_metadata = Value::Object(build_sceneapp_runtime_request_metadata( descriptor, launch_intent, @@ -1445,7 +1480,11 @@ pub fn build_sceneapp_runtime_adapter_plan( let mut notes = vec![format!( "当前 SceneApp 规划先映射到 {} 主链,再由后续 runtime adapter 负责真实执行。", - binding_family_to_string(&adapter_kind) + if service_scene_session { + "agent_turn" + } else { + binding_family_to_string(&adapter_kind) + } )]; let preferred_profile_key = match adapter_kind { @@ -1455,31 +1494,135 @@ pub fn build_sceneapp_runtime_adapter_plan( _ => None, }; - let target_ref = match adapter_kind { - SceneAppBindingFamily::BrowserAssist => browser_assist_adapter_name(descriptor) - .map(str::to_string) - .or_else(|| descriptor.linked_scene_key.clone()) - .unwrap_or_else(|| descriptor.id.clone()), - SceneAppBindingFamily::CloudScene - | SceneAppBindingFamily::NativeSkill - | SceneAppBindingFamily::AutomationJob => descriptor + let target_ref = if service_scene_session { + descriptor .linked_service_skill_id .clone() .or_else(|| descriptor.linked_scene_key.clone()) - .unwrap_or_else(|| descriptor.id.clone()), - SceneAppBindingFamily::AgentTurn => descriptor.id.clone(), + .unwrap_or_else(|| descriptor.id.clone()) + } else { + match adapter_kind { + SceneAppBindingFamily::BrowserAssist => browser_assist_adapter_name(descriptor) + .map(str::to_string) + .or_else(|| descriptor.linked_scene_key.clone()) + .unwrap_or_else(|| descriptor.id.clone()), + SceneAppBindingFamily::CloudScene + | SceneAppBindingFamily::NativeSkill + | SceneAppBindingFamily::AutomationJob => descriptor + .linked_service_skill_id + .clone() + .or_else(|| descriptor.linked_scene_key.clone()) + .unwrap_or_else(|| descriptor.id.clone()), + SceneAppBindingFamily::AgentTurn => descriptor.id.clone(), + } }; - let launch_payload = match adapter_kind { - SceneAppBindingFamily::CloudScene => { - if matches!(descriptor.sceneapp_type, SceneAppType::Hybrid) { + let launch_payload = if service_scene_session { + if matches!(descriptor.sceneapp_type, SceneAppType::Hybrid) { + notes.push( + "当前 SceneApp 属于 hybrid,但首发执行会先进入场景技能入口;后续本地编排步骤由 composition blueprint 接续。" + .to_string(), + ); + } + + json!({ + "sceneapp_id": descriptor.id.clone(), + "scene_key": descriptor.linked_scene_key.clone(), + "service_skill_id": descriptor.linked_service_skill_id.clone(), + "workspace_id": launch_intent.workspace_id.clone(), + "project_id": launch_intent.project_id.clone(), + "entry_source": launch_intent.entry_source.clone().unwrap_or_else(|| "sceneapp_plan".to_string()), + "user_input": launch_intent.user_input.clone(), + "reference_memory_ids": launch_intent.reference_memory_ids.clone(), + "slots": launch_intent.slots.clone(), + }) + } else { + match adapter_kind { + SceneAppBindingFamily::BrowserAssist => { + let args = build_browser_assist_args(descriptor, launch_intent); + if !args.contains_key("url") && descriptor.id == "x-article-export" { + notes.push( + "当前 planner 还无法仅凭 descriptor 判断 article_url 是否齐备;执行前应继续通过 scene gate 补齐目标链接。" + .to_string(), + ); + } + + json!({ + "sceneapp_id": descriptor.id.clone(), + "service_skill_id": descriptor.linked_service_skill_id.clone(), + "adapter_name": target_ref.clone(), + "profile_key": preferred_profile_key.clone(), + "args": Value::Object(args), + "project_id": launch_intent.project_id.clone(), + "workspace_id": launch_intent.workspace_id.clone(), + "save_mode": "project_resource", + }) + } + SceneAppBindingFamily::AutomationJob => { + notes.push("当前 planner 只生成 durable automation draft;具体 schedule、delivery 与 run-now 策略可继续由 UI 调整。".to_string()); + + json!({ + "sceneapp_id": descriptor.id.clone(), + "name": format!("{} 自动化", descriptor.title), + "enabled": true, + "execution_mode": "intelligent", + "schedule": { + "kind": "every", + "every_secs": 3600, + }, + "delivery": { + "mode": "none", + "channel": null, + "target": null, + "best_effort": false, + "output_schema": null, + "output_format": null, + }, + "launch_intent": { + "sceneapp_id": launch_intent.sceneapp_id.clone(), + "entry_source": launch_intent.entry_source.clone(), + "workspace_id": launch_intent.workspace_id.clone(), + "project_id": launch_intent.project_id.clone(), + "user_input": launch_intent.user_input.clone(), + "reference_memory_ids": launch_intent.reference_memory_ids.clone(), + "slots": launch_intent.slots.clone(), + "runtime_context": launch_intent.runtime_context.clone(), + }, + }) + } + SceneAppBindingFamily::NativeSkill => { notes.push( - "当前 SceneApp 属于 hybrid,但首发执行仍先收敛到 cloud_scene;本地编排步骤由后续 composition blueprint 接续。" + "native_skill 目前仍建议由统一 SceneApp UI 继续补参后,再把 draft 投递给本地 skill 执行入口。" .to_string(), ); - } - json!({ + json!({ + "sceneapp_id": descriptor.id.clone(), + "service_skill_id": descriptor.linked_service_skill_id.clone(), + "skill_key": descriptor.linked_scene_key.clone(), + "workspace_id": launch_intent.workspace_id.clone(), + "project_id": launch_intent.project_id.clone(), + "user_input": launch_intent.user_input.clone(), + "reference_memory_ids": launch_intent.reference_memory_ids.clone(), + "slots": launch_intent.slots.clone(), + }) + } + SceneAppBindingFamily::AgentTurn => { + notes.push( + "agent_turn 类型 SceneApp 当前仍建议走统一聊天 turn,并把 sceneapp_launch metadata 合并进 request_metadata。" + .to_string(), + ); + + json!({ + "sceneapp_id": descriptor.id.clone(), + "message": launch_intent.user_input.clone().unwrap_or_default(), + "workspace_id": launch_intent.workspace_id.clone(), + "project_id": launch_intent.project_id.clone(), + "reference_memory_ids": launch_intent.reference_memory_ids.clone(), + "slots": launch_intent.slots.clone(), + }) + } + SceneAppBindingFamily::CloudScene => json!({ "sceneapp_id": descriptor.id.clone(), "scene_key": descriptor.linked_scene_key.clone(), "service_skill_id": descriptor.linked_service_skill_id.clone(), @@ -1489,91 +1632,7 @@ pub fn build_sceneapp_runtime_adapter_plan( "user_input": launch_intent.user_input.clone(), "reference_memory_ids": launch_intent.reference_memory_ids.clone(), "slots": launch_intent.slots.clone(), - }) - } - SceneAppBindingFamily::BrowserAssist => { - let args = build_browser_assist_args(descriptor, launch_intent); - if !args.contains_key("url") && descriptor.id == "x-article-export" { - notes.push( - "当前 planner 还无法仅凭 descriptor 判断 article_url 是否齐备;执行前应继续通过 scene gate 补齐目标链接。" - .to_string(), - ); - } - - json!({ - "sceneapp_id": descriptor.id.clone(), - "service_skill_id": descriptor.linked_service_skill_id.clone(), - "adapter_name": target_ref.clone(), - "profile_key": preferred_profile_key.clone(), - "args": Value::Object(args), - "project_id": launch_intent.project_id.clone(), - "workspace_id": launch_intent.workspace_id.clone(), - "save_mode": "project_resource", - }) - } - SceneAppBindingFamily::AutomationJob => { - notes.push("当前 planner 只生成 durable automation draft;具体 schedule、delivery 与 run-now 策略可继续由 UI 调整。".to_string()); - - json!({ - "sceneapp_id": descriptor.id.clone(), - "name": format!("{} 自动化", descriptor.title), - "enabled": true, - "execution_mode": "intelligent", - "schedule": { - "kind": "every", - "every_secs": 3600, - }, - "delivery": { - "mode": "none", - "channel": null, - "target": null, - "best_effort": false, - "output_schema": null, - "output_format": null, - }, - "launch_intent": { - "sceneapp_id": launch_intent.sceneapp_id.clone(), - "entry_source": launch_intent.entry_source.clone(), - "workspace_id": launch_intent.workspace_id.clone(), - "project_id": launch_intent.project_id.clone(), - "user_input": launch_intent.user_input.clone(), - "reference_memory_ids": launch_intent.reference_memory_ids.clone(), - "slots": launch_intent.slots.clone(), - "runtime_context": launch_intent.runtime_context.clone(), - }, - }) - } - SceneAppBindingFamily::NativeSkill => { - notes.push( - "native_skill 目前仍建议由统一 SceneApp UI 继续补参后,再把 draft 投递给本地 skill 执行入口。" - .to_string(), - ); - - json!({ - "sceneapp_id": descriptor.id.clone(), - "service_skill_id": descriptor.linked_service_skill_id.clone(), - "skill_key": descriptor.linked_scene_key.clone(), - "workspace_id": launch_intent.workspace_id.clone(), - "project_id": launch_intent.project_id.clone(), - "user_input": launch_intent.user_input.clone(), - "reference_memory_ids": launch_intent.reference_memory_ids.clone(), - "slots": launch_intent.slots.clone(), - }) - } - SceneAppBindingFamily::AgentTurn => { - notes.push( - "agent_turn 类型 SceneApp 当前仍建议走统一聊天 turn,并把 sceneapp_launch metadata 合并进 request_metadata。" - .to_string(), - ); - - json!({ - "sceneapp_id": descriptor.id.clone(), - "message": launch_intent.user_input.clone().unwrap_or_default(), - "workspace_id": launch_intent.workspace_id.clone(), - "project_id": launch_intent.project_id.clone(), - "reference_memory_ids": launch_intent.reference_memory_ids.clone(), - "slots": launch_intent.slots.clone(), - }) + }), } }; @@ -1829,9 +1888,9 @@ fn build_sceneapp_run_summary_from_agent_run_inner( browser_runtime_ref: parsed .as_ref() .and_then(extract_browser_runtime_ref_from_metadata_value), - cloud_scene_runtime_ref: parsed + service_scene_runtime_ref: parsed .as_ref() - .and_then(extract_cloud_scene_runtime_ref_from_metadata_value), + .and_then(extract_service_scene_runtime_ref_from_metadata_value), native_skill_runtime_ref: parsed .as_ref() .and_then(extract_native_skill_runtime_ref_from_metadata_value), @@ -1920,7 +1979,7 @@ pub fn build_sceneapp_run_summary_from_automation_job( source_ref: Some(job.id.clone()), session_id: None, browser_runtime_ref: None, - cloud_scene_runtime_ref: None, + service_scene_runtime_ref: None, native_skill_runtime_ref: None, started_at: job .last_run_at @@ -2111,7 +2170,7 @@ mod tests { ] ); assert_eq!(summary.browser_runtime_ref, None); - assert_eq!(summary.cloud_scene_runtime_ref, None); + assert_eq!(summary.service_scene_runtime_ref, None); assert_eq!(summary.native_skill_runtime_ref, None); assert_eq!( summary.delivery_completed_parts, @@ -2160,18 +2219,18 @@ mod tests { target_id: Some("target-1".to_string()), }) ); - assert_eq!(summary.cloud_scene_runtime_ref, None); + assert_eq!(summary.service_scene_runtime_ref, None); assert_eq!(summary.native_skill_runtime_ref, None); } #[test] - fn build_sceneapp_run_summary_from_agent_run_should_extract_cloud_scene_runtime_ref() { + fn build_sceneapp_run_summary_from_agent_run_should_extract_service_scene_runtime_ref() { let run = create_agent_run(json!({ "sceneapp": { "id": "story-video-suite" }, "request_metadata": { "harness": { "service_scene_launch": { - "kind": "cloud_scene", + "kind": "local_service_skill", "service_scene_run": { "scene_key": "story-video-suite", "skill_id": "sceneapp-service-story-video", @@ -2193,8 +2252,8 @@ mod tests { build_sceneapp_run_summary_from_agent_run(&run, None, "story-video-suite".to_string()); assert_eq!( - summary.cloud_scene_runtime_ref, - Some(SceneAppCloudSceneRuntimeRef { + summary.service_scene_runtime_ref, + Some(SceneAppServiceSceneRuntimeRef { scene_key: Some("story-video-suite".to_string()), skill_id: Some("sceneapp-service-story-video".to_string()), project_id: Some("project-video".to_string()), @@ -2460,7 +2519,7 @@ mod tests { assert_eq!(summary.source_ref.as_deref(), Some("job-1")); assert_eq!(summary.session_id, None); assert_eq!(summary.browser_runtime_ref, None); - assert_eq!(summary.cloud_scene_runtime_ref, None); + assert_eq!(summary.service_scene_runtime_ref, None); assert_eq!(summary.native_skill_runtime_ref, None); assert_eq!( summary.delivery_required_parts, diff --git a/src-tauri/src/sceneapp/application.rs b/src-tauri/src/sceneapp/application.rs index c3ead45ca..d9d654901 100644 --- a/src-tauri/src/sceneapp/application.rs +++ b/src-tauri/src/sceneapp/application.rs @@ -453,7 +453,7 @@ fn resolve_feedback_scope_from_run(run: &SceneAppRunSummary) -> (Option, .find_map(|artifact| normalize_optional_id(artifact.project_id.as_deref())) }) .or_else(|| { - run.cloud_scene_runtime_ref + run.service_scene_runtime_ref .as_ref() .and_then(|runtime| normalize_optional_id(runtime.project_id.as_deref())) }) @@ -473,7 +473,7 @@ fn resolve_feedback_scope_from_run(run: &SceneAppRunSummary) -> (Option, .find_map(|artifact| normalize_optional_id(artifact.workspace_id.as_deref())) }) .or_else(|| { - run.cloud_scene_runtime_ref + run.service_scene_runtime_ref .as_ref() .and_then(|runtime| normalize_optional_id(runtime.workspace_id.as_deref())) }) @@ -793,7 +793,7 @@ impl SceneAppService { source_ref: None, session_id: None, browser_runtime_ref: None, - cloud_scene_runtime_ref: None, + service_scene_runtime_ref: None, native_skill_runtime_ref: None, started_at: "2026-04-15T00:00:00.000Z".to_string(), finished_at: Some("2026-04-15T00:08:00.000Z".to_string()), @@ -838,7 +838,7 @@ impl SceneAppService { source_ref: None, session_id: None, browser_runtime_ref: None, - cloud_scene_runtime_ref: None, + service_scene_runtime_ref: None, native_skill_runtime_ref: None, started_at: "2026-04-15T00:12:00.000Z".to_string(), finished_at: None, @@ -1168,7 +1168,7 @@ mod tests { source_ref: Some("job-story-video".to_string()), session_id: Some("session-story-video".to_string()), browser_runtime_ref: None, - cloud_scene_runtime_ref: Some(SceneAppCloudSceneRuntimeRef { + service_scene_runtime_ref: Some(SceneAppServiceSceneRuntimeRef { scene_key: Some("story-video".to_string()), skill_id: Some("skill-story-video".to_string()), project_id: Some(workspace_id.to_string()), @@ -1239,10 +1239,7 @@ mod tests { user_input: Some("根据发布会亮点生成 30 秒短视频草稿".to_string()), reference_memory_ids: Vec::new(), slots: Default::default(), - runtime_context: Some(SceneAppRuntimeContext { - cloud_session_ready: true, - ..Default::default() - }), + runtime_context: Some(SceneAppRuntimeContext::default()), }, ) .expect("planning 失败"); @@ -1273,10 +1270,7 @@ mod tests { user_input: Some("根据发布会亮点生成 30 秒短视频草稿".to_string()), reference_memory_ids: Vec::new(), slots: Default::default(), - runtime_context: Some(SceneAppRuntimeContext { - cloud_session_ready: true, - ..Default::default() - }), + runtime_context: Some(SceneAppRuntimeContext::default()), }, ) .expect("写入场景基线失败"); diff --git a/src-tauri/src/sceneapp/catalog.rs b/src-tauri/src/sceneapp/catalog.rs index bcbef6b9a..45e2bb656 100644 --- a/src-tauri/src/sceneapp/catalog.rs +++ b/src-tauri/src/sceneapp/catalog.rs @@ -117,7 +117,7 @@ fn seeded_story_video_suite() -> SceneAppDescriptor { SceneAppPattern::Reviewer, ], capability_refs: vec![ - "cloud_scene".to_string(), + "agent_turn".to_string(), "native_skill".to_string(), "workspace_storage".to_string(), "artifact_viewer".to_string(), @@ -126,7 +126,7 @@ fn seeded_story_video_suite() -> SceneAppDescriptor { "composition_blueprint".to_string(), "project_pack".to_string(), "workspace_storage".to_string(), - "cloud_runtime".to_string(), + "agent_turn".to_string(), "timeline".to_string(), ], delivery_contract: SceneAppDeliveryContract::ProjectPack, @@ -134,13 +134,13 @@ fn seeded_story_video_suite() -> SceneAppDescriptor { output_hint: "短视频项目包".to_string(), entry_bindings: vec![ service_skill_binding( - SceneAppBindingFamily::CloudScene, + SceneAppBindingFamily::AgentTurn, "sceneapp-service-story-video", "story-video-suite", &["story-video", "mv-pipeline"], ), scene_binding( - SceneAppBindingFamily::CloudScene, + SceneAppBindingFamily::AgentTurn, "story-video-suite", "/story-video-suite", &["story-video-scene"], @@ -155,10 +155,6 @@ fn seeded_story_video_suite() -> SceneAppDescriptor { SceneAppLaunchRequirementKind::Project, "需要项目目录承接线框图、脚本和媒体结果。", ), - requirement( - SceneAppLaunchRequirementKind::CloudSession, - "需要可用的云端运行时来完成多模态媒体处理。", - ), ], linked_service_skill_id: Some("sceneapp-service-story-video".to_string()), linked_scene_key: Some("story-video-suite".to_string()), @@ -200,13 +196,13 @@ fn seeded_story_video_suite() -> SceneAppDescriptor { "music_refs", 4, "story-video-cloud-binding", - SceneAppBindingFamily::CloudScene, + SceneAppBindingFamily::AgentTurn, ), composition_step( "video_draft", 5, "story-video-cloud-binding", - SceneAppBindingFamily::CloudScene, + SceneAppBindingFamily::AgentTurn, ), composition_step( "review_note", @@ -315,40 +311,36 @@ fn seeded_voice_runtime() -> SceneAppDescriptor { SceneAppDescriptor { id: "voice-runtime".to_string(), title: "配音生成".to_string(), - summary: "把文稿或视频内容交给托管能力完成配音,并回流成可试听的媒体产物。".to_string(), + summary: "把文稿或视频内容交给客户端里的服务技能完成配音,并回流成可试听的媒体产物。" + .to_string(), category: "Scene Apps".to_string(), - sceneapp_type: SceneAppType::CloudManaged, + sceneapp_type: SceneAppType::LocalInstant, pattern_primary: SceneAppPattern::Pipeline, pattern_stack: vec![SceneAppPattern::Pipeline, SceneAppPattern::Generator], capability_refs: vec![ - "cloud_scene".to_string(), + "agent_turn".to_string(), "workspace_storage".to_string(), "artifact_viewer".to_string(), ], infra_profile: vec![ - "cloud_runtime".to_string(), - "media_artifact".to_string(), + "agent_turn".to_string(), + "workspace_storage".to_string(), + "artifact_bundle".to_string(), "json_snapshot".to_string(), ], delivery_contract: SceneAppDeliveryContract::ArtifactBundle, artifact_kind: Some("report".to_string()), output_hint: "媒体结果".to_string(), entry_bindings: vec![service_skill_binding( - SceneAppBindingFamily::CloudScene, + SceneAppBindingFamily::AgentTurn, "sceneapp-service-voice-runtime", "voice-runtime", &["voiceover", "tts-scene"], )], - launch_requirements: vec![ - requirement( - SceneAppLaunchRequirementKind::UserInput, - "需要文稿正文或待配音素材说明。", - ), - requirement( - SceneAppLaunchRequirementKind::CloudSession, - "需要云端媒体运行时或 OEM 托管会话。", - ), - ], + launch_requirements: vec![requirement( + SceneAppLaunchRequirementKind::UserInput, + "需要文稿正文或待配音素材说明。", + )], linked_service_skill_id: Some("sceneapp-service-voice-runtime".to_string()), linked_scene_key: Some("voice-runtime".to_string()), delivery_profile: Some(delivery_profile( diff --git a/src-tauri/src/sceneapp/context/compiler.rs b/src-tauri/src/sceneapp/context/compiler.rs index 0e07b1b0d..cd0920c76 100644 --- a/src-tauri/src/sceneapp/context/compiler.rs +++ b/src-tauri/src/sceneapp/context/compiler.rs @@ -249,9 +249,6 @@ fn build_tool_refs(descriptor: &SceneAppDescriptor, intent: &SceneAppLaunchInten if runtime_context.browser_session_attached { push_unique(&mut refs, Some("browser_session".to_string())); } - if runtime_context.cloud_session_ready { - push_unique(&mut refs, Some("cloud_session".to_string())); - } if runtime_context.automation_enabled { push_unique(&mut refs, Some("automation".to_string())); } @@ -670,6 +667,11 @@ mod tests { .reference_items .iter() .any(|item| item.id == "memory:memory-1" && item.label == "夏日短视频语气")); + assert!(!overlay + .snapshot + .tool_refs + .iter() + .any(|item| item == "cloud_session")); assert!(overlay .snapshot .taste_profile diff --git a/src-tauri/src/sceneapp/dto.rs b/src-tauri/src/sceneapp/dto.rs index 4c88aa59f..85111e530 100644 --- a/src-tauri/src/sceneapp/dto.rs +++ b/src-tauri/src/sceneapp/dto.rs @@ -10,6 +10,7 @@ pub enum SceneAppType { LocalInstant, LocalDurable, BrowserGrounded, + // legacy compat only:current 不再把 SceneApp 解释成云端托管执行面。 CloudManaged, Hybrid, } @@ -30,6 +31,7 @@ pub enum SceneAppBindingFamily { AgentTurn, BrowserAssist, AutomationJob, + // legacy compat only:current 执行仍收敛到本地主链,cloud_scene 只保留历史目录输入。 CloudScene, NativeSkill, } @@ -57,6 +59,7 @@ pub enum SceneAppLaunchRequirementKind { UserInput, Project, BrowserSession, + // legacy compat only:current 不再把云端会话当成启动门槛。 CloudSession, Automation, } @@ -170,6 +173,7 @@ pub struct SceneAppCatalog { #[serde(rename_all = "camelCase")] pub struct SceneAppRuntimeContext { pub browser_session_attached: bool, + // legacy compat only:保留旧目录/旧计划输入,不参与 current readiness。 pub cloud_session_ready: bool, pub automation_enabled: bool, } @@ -294,7 +298,8 @@ pub enum SceneAppRuntimeAction { SubmitAgentTurn, LaunchBrowserAssist, CreateAutomationJob, - LaunchCloudScene, + #[serde(alias = "launch_cloud_scene")] + OpenServiceSceneSession, LaunchNativeSkill, } @@ -323,7 +328,7 @@ pub struct SceneAppBrowserRuntimeRef { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] -pub struct SceneAppCloudSceneRuntimeRef { +pub struct SceneAppServiceSceneRuntimeRef { pub scene_key: Option, pub skill_id: Option, pub project_id: Option, @@ -388,7 +393,13 @@ pub struct SceneAppRunSummary { pub source_ref: Option, pub session_id: Option, pub browser_runtime_ref: Option, - pub cloud_scene_runtime_ref: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "cloudSceneRuntimeRef", + alias = "cloud_scene_runtime_ref" + )] + pub service_scene_runtime_ref: Option, pub native_skill_runtime_ref: Option, pub started_at: String, pub finished_at: Option, diff --git a/src-tauri/src/sceneapp/governance.rs b/src-tauri/src/sceneapp/governance.rs index 0e3dd7592..12c49d587 100644 --- a/src-tauri/src/sceneapp/governance.rs +++ b/src-tauri/src/sceneapp/governance.rs @@ -42,7 +42,7 @@ fn sceneapp_type_label(sceneapp_type: &SceneAppType) -> &'static str { SceneAppType::LocalInstant => "本地即时", SceneAppType::LocalDurable => "本地 durable", SceneAppType::BrowserGrounded => "浏览器依赖", - SceneAppType::CloudManaged => "云端托管", + SceneAppType::CloudManaged => "目录同步", SceneAppType::Hybrid => "多模态组合", } } @@ -187,7 +187,8 @@ fn build_seeded_sceneapp_scorecard(descriptor: &SceneAppDescriptor) -> SceneAppS ], ), SceneAppType::CloudManaged => ( - "云端托管型场景具备扩张潜力,但要持续关注完成率和单位成功成本。".to_string(), + "目录同步型场景仍可能带入历史兼容目录输入,但 current 执行已经回到本地主链,应持续关注目录质量、完成率和单位成功成本。" + .to_string(), SceneAppRecommendedAction::Launch, vec![ metric( @@ -519,7 +520,7 @@ mod tests { source_ref: Some("job-daily-trend".to_string()), session_id: None, browser_runtime_ref: None, - cloud_scene_runtime_ref: None, + service_scene_runtime_ref: None, native_skill_runtime_ref: None, started_at: started_at.to_string(), finished_at: Some(started_at.to_string()), @@ -559,7 +560,7 @@ mod tests { source_ref: Some("session-story-video".to_string()), session_id: Some("session-story-video".to_string()), browser_runtime_ref: None, - cloud_scene_runtime_ref: None, + service_scene_runtime_ref: None, native_skill_runtime_ref: None, started_at: started_at.to_string(), finished_at: Some(started_at.to_string()), @@ -735,7 +736,7 @@ mod tests { source_ref: None, session_id: Some("session-1".to_string()), browser_runtime_ref: None, - cloud_scene_runtime_ref: None, + service_scene_runtime_ref: None, native_skill_runtime_ref: None, started_at: "2026-04-15T00:00:00.000Z".to_string(), finished_at: Some("2026-04-15T00:00:00.000Z".to_string()), @@ -765,7 +766,7 @@ mod tests { source_ref: None, session_id: Some("session-2".to_string()), browser_runtime_ref: None, - cloud_scene_runtime_ref: None, + service_scene_runtime_ref: None, native_skill_runtime_ref: None, started_at: "2026-04-15T01:00:00.000Z".to_string(), finished_at: Some("2026-04-15T01:00:00.000Z".to_string()), @@ -795,7 +796,7 @@ mod tests { source_ref: None, session_id: Some("session-3".to_string()), browser_runtime_ref: None, - cloud_scene_runtime_ref: None, + service_scene_runtime_ref: None, native_skill_runtime_ref: None, started_at: "2026-04-15T02:00:00.000Z".to_string(), finished_at: Some("2026-04-15T02:00:00.000Z".to_string()), @@ -825,7 +826,7 @@ mod tests { source_ref: None, session_id: Some("session-4".to_string()), browser_runtime_ref: None, - cloud_scene_runtime_ref: None, + service_scene_runtime_ref: None, native_skill_runtime_ref: None, started_at: "2026-04-15T03:00:00.000Z".to_string(), finished_at: Some("2026-04-15T03:00:00.000Z".to_string()), @@ -855,7 +856,7 @@ mod tests { source_ref: None, session_id: Some("session-5".to_string()), browser_runtime_ref: None, - cloud_scene_runtime_ref: None, + service_scene_runtime_ref: None, native_skill_runtime_ref: None, started_at: "2026-04-15T04:00:00.000Z".to_string(), finished_at: Some("2026-04-15T04:00:00.000Z".to_string()), diff --git a/src-tauri/src/sceneapp/runtime.rs b/src-tauri/src/sceneapp/runtime.rs index bfb8c26d5..f1812bc4a 100644 --- a/src-tauri/src/sceneapp/runtime.rs +++ b/src-tauri/src/sceneapp/runtime.rs @@ -70,7 +70,8 @@ fn requirement_satisfied( .as_deref() .is_some_and(|value| !value.trim().is_empty()), SceneAppLaunchRequirementKind::BrowserSession => runtime.browser_session_attached, - SceneAppLaunchRequirementKind::CloudSession => runtime.cloud_session_ready, + // legacy compat only:current SceneApp 不再把云端会话视为执行门槛。 + SceneAppLaunchRequirementKind::CloudSession => true, SceneAppLaunchRequirementKind::Automation => runtime.automation_enabled, } } @@ -295,13 +296,13 @@ mod tests { use crate::sceneapp::context::dto::{ContextLayerSourceKind, ReferenceItem, TasteProfile}; use crate::sceneapp::context::store::PersistedSceneAppContext; use crate::sceneapp::dto::{ - SceneAppDeliveryContract, SceneAppLaunchIntent, SceneAppRuntimeAction, - SceneAppRuntimeContext, + SceneAppBindingFamily, SceneAppDeliveryContract, SceneAppLaunchIntent, + SceneAppRuntimeAction, SceneAppRuntimeContext, }; use std::collections::BTreeMap; #[test] - fn should_build_cloud_scene_adapter_plan_for_hybrid_sceneapp() { + fn should_build_service_scene_adapter_plan_for_hybrid_sceneapp() { let descriptor = get_sceneapp_descriptor("story-video-suite") .expect("story-video-suite descriptor should exist"); let plan = build_launch_plan( @@ -314,10 +315,7 @@ mod tests { user_input: Some("根据发布会内容生成 30 秒短视频草稿".to_string()), reference_memory_ids: Vec::new(), slots: BTreeMap::new(), - runtime_context: Some(SceneAppRuntimeContext { - cloud_session_ready: true, - ..SceneAppRuntimeContext::default() - }), + runtime_context: Some(SceneAppRuntimeContext::default()), }, None, &[], @@ -325,8 +323,10 @@ mod tests { assert_eq!( plan.plan.adapter_plan.runtime_action, - SceneAppRuntimeAction::LaunchCloudScene + SceneAppRuntimeAction::OpenServiceSceneSession ); + assert_eq!(plan.plan.executor_kind, SceneAppBindingFamily::AgentTurn); + assert_eq!(plan.plan.binding_family, SceneAppBindingFamily::AgentTurn); assert!(plan.context_overlay.is_some()); assert_eq!( plan.context_overlay @@ -356,6 +356,30 @@ mod tests { .request_metadata .pointer("/harness/service_scene_launch/service_scene_run/scene_key") .is_some()); + assert_eq!( + plan.plan + .adapter_plan + .request_metadata + .pointer("/harness/service_scene_launch/kind") + .and_then(|value| value.as_str()), + Some("local_service_skill") + ); + assert_eq!( + plan.plan + .adapter_plan + .request_metadata + .pointer("/harness/service_scene_launch/service_scene_run/execution_kind") + .and_then(|value| value.as_str()), + Some("local_service_skill") + ); + assert_eq!( + plan.plan + .adapter_plan + .request_metadata + .pointer("/harness/service_scene_launch/service_scene_run/execution_location") + .and_then(|value| value.as_str()), + Some("client_default") + ); } #[test] @@ -508,10 +532,7 @@ mod tests { user_input: Some("根据这次发布会做 30 秒短视频".to_string()), reference_memory_ids: Vec::new(), slots, - runtime_context: Some(SceneAppRuntimeContext { - cloud_session_ready: true, - ..SceneAppRuntimeContext::default() - }), + runtime_context: Some(SceneAppRuntimeContext::default()), }, Some(&persisted_context), &[], diff --git a/src-tauri/tauri.conf.headless.json b/src-tauri/tauri.conf.headless.json index 4ce085d9b..30a395cda 100644 --- a/src-tauri/tauri.conf.headless.json +++ b/src-tauri/tauri.conf.headless.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Lime", - "version": "1.15.0", + "version": "1.16.0", "identifier": "com.limecloud.lime.headless", "build": { "beforeDevCommand": "npm run dev:web-bridge", diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index bf6d94b6f..fe9ff54c0 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,10 +1,10 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Lime", - "version": "1.15.0", + "version": "1.16.0", "identifier": "com.limecloud.lime", "build": { - "beforeDevCommand": "npm run dev", + "beforeDevCommand": "node scripts/start-tauri-dev-server.mjs", "devUrl": "http://127.0.0.1:1420", "beforeBuildCommand": "npm run build", "frontendDist": "../dist" diff --git a/src/components/AppPageContent.test.tsx b/src/components/AppPageContent.test.tsx index 977696a4d..cd0798480 100644 --- a/src/components/AppPageContent.test.tsx +++ b/src/components/AppPageContent.test.tsx @@ -447,7 +447,7 @@ describe("AppPageContent", () => { expect(agentChatLifecycle.unmounts).toBe(1); }); - it("agent 页面应把创作场景执行摘要与自动发送 metadata 透传给 AgentChatPage", async () => { + it("agent 页面应把做法执行摘要与自动发送 metadata 透传给 AgentChatPage", async () => { const pageParams: AgentPageParams = { agentEntry: "claw", projectId: "project-sceneapp", @@ -458,7 +458,7 @@ describe("AppPageContent", () => { summary: "把线框图、脚本与配乐压成同一条结果链。", businessLabel: "内容闭环", typeLabel: "多模态组合", - executionChainLabel: "创作场景 -> 生成 -> Project Pack", + executionChainLabel: "做法 -> 生成 -> Project Pack", deliveryContractLabel: "Project Pack", planningStatusLabel: "已就绪", planningSummary: "当前可直接进入生成。", diff --git a/src/components/AppSidebar.test.tsx b/src/components/AppSidebar.test.tsx index 87e3af4f8..298de2735 100644 --- a/src/components/AppSidebar.test.tsx +++ b/src/components/AppSidebar.test.tsx @@ -3,22 +3,32 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { AgentPageParams, Page, PageParams } from "@/types/page"; -import { recordSceneAppRecentVisit } from "@/lib/sceneapp"; +import { SettingsTabs } from "@/types/settings"; import { AppSidebar } from "./AppSidebar"; -const { mockGetConfig, mockGetPluginsForSurface } = vi.hoisted(() => ({ +const { mockGetConfig, mockGetPluginsForSurface, mockSubscribeAppConfigChanged } = + vi.hoisted(() => ({ mockGetConfig: vi.fn(), mockGetPluginsForSurface: vi.fn(), + mockSubscribeAppConfigChanged: vi.fn(), })); vi.mock("@/lib/api/appConfig", () => ({ getConfig: mockGetConfig, + subscribeAppConfigChanged: mockSubscribeAppConfigChanged, })); vi.mock("@/lib/api/pluginUI", () => ({ getPluginsForSurface: mockGetPluginsForSurface, })); +vi.mock("@/lib/utils/scheduleMinimumDelayIdleTask", () => ({ + scheduleMinimumDelayIdleTask: (task: () => void) => { + task(); + return () => undefined; + }, +})); + interface MountedSidebar { container: HTMLDivElement; root: Root; @@ -51,56 +61,12 @@ function mountSidebar(options?: { return container; } -function mountControlledSidebar(options?: { - currentPage?: Page; - currentPageParams?: PageParams; -}) { - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - const navigationCalls: Array<{ page: Page; params?: PageParams }> = []; - - function ControlledSidebar() { - const [currentPage, setCurrentPage] = React.useState( - options?.currentPage ?? "agent", - ); - const [currentPageParams, setCurrentPageParams] = React.useState( - options?.currentPageParams ?? {}, - ); - - const handleNavigate = React.useCallback( - (page: Page, params?: PageParams) => { - navigationCalls.push({ page, params }); - setCurrentPage(page); - setCurrentPageParams(params ?? {}); - }, - [], - ); - - return ( - - ); +async function flushEffects(times = 1) { + for (let index = 0; index < times; index += 1) { + await act(async () => { + await Promise.resolve(); + }); } - - act(() => { - root.render(); - }); - - mountedSidebars.push({ container, root }); - return { - container, - navigationCalls, - }; -} - -async function flushEffects() { - await act(async () => { - await Promise.resolve(); - }); } describe("AppSidebar", () => { @@ -109,6 +75,19 @@ describe("AppSidebar", () => { localStorage.clear(); mockGetConfig.mockResolvedValue({}); mockGetPluginsForSurface.mockResolvedValue([]); + mockSubscribeAppConfigChanged.mockImplementation( + (listener: () => void) => { + (globalThis as typeof globalThis & { __appConfigListener?: () => void }) + .__appConfigListener = listener; + return () => { + ( + globalThis as typeof globalThis & { + __appConfigListener?: () => void; + } + ).__appConfigListener = undefined; + }; + }, + ); }); afterEach(() => { @@ -117,16 +96,23 @@ describe("AppSidebar", () => { if (!mounted) { continue; } + act(() => { mounted.root.unmount(); }); mounted.container.remove(); } + vi.clearAllMocks(); vi.unstubAllGlobals(); + ( + globalThis as typeof globalThis & { + __appConfigListener?: () => void; + } + ).__appConfigListener = undefined; }); - it("进入 Claw 生成页时应自动折叠导航栏", async () => { + it("进入生成页时应自动折叠导航栏", async () => { localStorage.setItem(APP_SIDEBAR_COLLAPSED_STORAGE_KEY, "false"); const container = mountSidebar({ @@ -144,7 +130,7 @@ describe("AppSidebar", () => { ); }); - it("新建任务页应自动展开导航栏,不沿用上一个页面的折叠状态", async () => { + it("新建任务页应自动展开导航栏", async () => { localStorage.setItem(APP_SIDEBAR_COLLAPSED_STORAGE_KEY, "true"); const container = mountSidebar({ @@ -162,10 +148,49 @@ describe("AppSidebar", () => { ); }); - it("旧导航配置未包含能力入口时也应显示固定能力分组和核心入口", async () => { + it("默认应渲染任务、能力、资料、系统四段导航,并隐藏系统扩展入口", async () => { + const container = mountSidebar({ + currentPageParams: { + agentEntry: "new-task", + } as AgentPageParams, + }); + await flushEffects(2); + + 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("消息渠道"); + expect(container.textContent).toContain("资料"); + expect(container.textContent).toContain("资料库"); + expect(container.textContent).toContain("灵感库"); + expect(container.textContent).toContain("系统"); + expect(container.textContent).toContain("设置"); + expect(container.textContent).not.toContain("插件中心"); + expect(container.textContent).not.toContain("OpenClaw"); + expect(container.textContent).not.toContain("桌宠"); + expect(container.textContent).not.toContain("支撑"); + expect(container.textContent).not.toContain("技能"); + }); + + it("恢复默认 agent 页时应把当前主舞台归到生成", async () => { + const container = mountSidebar({ + currentPage: "agent", + }); + await flushEffects(2); + + expect( + container.querySelector('button[aria-label="生成"][aria-current="page"]'), + ).not.toBeNull(); + }); + + it("显式开启后应显示隐藏的系统扩展入口", async () => { mockGetConfig.mockResolvedValue({ navigation: { - enabled_items: ["home-general", "claw"], + enabled_items: ["plugins", "openclaw", "companion"], }, }); @@ -174,46 +199,80 @@ describe("AppSidebar", () => { agentEntry: "new-task", } as AgentPageParams, }); - await flushEffects(); + await flushEffects(2); - 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).not.toContain("视频"); - expect(container.textContent).not.toContain("持续流程"); + expect(container.textContent).toContain("插件中心"); + expect(container.textContent).toContain("OpenClaw"); + expect(container.textContent).toContain("桌宠"); }); - it("进入消息渠道页时应高亮对应能力入口并保留能力分组标题", async () => { + it("配置变更后应重新读取隐藏入口并刷新侧栏", async () => { + mockGetConfig + .mockResolvedValueOnce({ + navigation: { + enabled_items: [], + }, + }) + .mockResolvedValueOnce({ + navigation: { + enabled_items: ["plugins", "companion"], + }, + }); + const container = mountSidebar({ - currentPage: "channels", + currentPageParams: { + agentEntry: "new-task", + } as AgentPageParams, }); - await flushEffects(); + await flushEffects(2); - expect(container.textContent).toContain("能力"); - expect( - container.querySelector( - 'button[aria-label="消息渠道"][aria-current="page"]', - ), - ).not.toBeNull(); + expect(container.textContent).not.toContain("插件中心"); + expect(container.textContent).not.toContain("桌宠"); + + await act(async () => { + ( + globalThis as typeof globalThis & { + __appConfigListener?: () => void; + } + ).__appConfigListener?.(); + await Promise.resolve(); + }); + await flushEffects(2); + + expect(container.textContent).toContain("插件中心"); + expect(container.textContent).toContain("桌宠"); }); - it("点击当前已激活的创作场景入口时不应重复导航", async () => { - const onNavigate = vi.fn(); + it("sceneapps 页面应高亮创作场景,而不是把它并回我的方法", async () => { const container = mountSidebar({ currentPage: "sceneapps", currentPageParams: { sceneappId: "story-video-suite", projectId: "project-9", }, + }); + await flushEffects(2); + + expect( + container.querySelector( + 'button[aria-label="创作场景"][aria-current="page"]', + ), + ).not.toBeNull(); + expect( + container.querySelector('button[aria-label="我的方法"][aria-current="page"]'), + ).toBeNull(); + }); + + it("点击当前已激活的我的方法入口时不应重复导航", async () => { + const onNavigate = vi.fn(); + const container = mountSidebar({ + currentPage: "skills", onNavigate, }); await flushEffects(); const button = container.querySelector( - 'button[aria-label="创作场景"]', + 'button[aria-label="我的方法"]', ) as HTMLButtonElement | null; expect(button).not.toBeNull(); @@ -226,10 +285,10 @@ describe("AppSidebar", () => { expect(onNavigate).not.toHaveBeenCalled(); }); - it("侧边栏重新挂载前应先恢复缓存的可选入口,避免持续流程短暂消失", () => { + it("旧的 enabled-items 本地缓存不应再复活历史导航", async () => { localStorage.setItem( APP_SIDEBAR_ENABLED_ITEMS_STORAGE_KEY, - JSON.stringify(["automation"]), + JSON.stringify(["plugins", "openclaw", "companion", "video"]), ); mockGetConfig.mockImplementation(() => new Promise(() => undefined)); @@ -238,143 +297,83 @@ describe("AppSidebar", () => { agentEntry: "new-task", } as AgentPageParams, }); - - expect(container.textContent).toContain("持续流程"); - }); - - it("点击创作场景入口时应优先恢复最近一次 SceneApp 上下文", async () => { - const onNavigate = vi.fn(); - recordSceneAppRecentVisit( - { - sceneappId: "story-video-suite", - projectId: "project-9", - runId: "run-2", - }, - { - visitedAt: 600, - }, - ); - - const container = mountSidebar({ - currentPageParams: { - agentEntry: "new-task", - } as AgentPageParams, - onNavigate, - }); await flushEffects(); - const button = container.querySelector( - 'button[aria-label="创作场景"]', - ) as HTMLButtonElement | null; - - expect(button).not.toBeNull(); - - act(() => { - button?.click(); - }); - - expect(onNavigate).toHaveBeenCalledWith("sceneapps", { - sceneappId: "story-video-suite", - projectId: "project-9", - runId: "run-2", - }); - }); - - it("快速从持续流程切回创作场景时不应吞掉第二次导航", async () => { - localStorage.setItem( - APP_SIDEBAR_ENABLED_ITEMS_STORAGE_KEY, - JSON.stringify(["automation"]), - ); - mockGetConfig.mockResolvedValue({ - navigation: { - enabled_items: ["automation"], - }, - }); - recordSceneAppRecentVisit( - { - sceneappId: "story-video-suite", - projectId: "project-9", - runId: "run-2", - }, - { - visitedAt: 600, - }, - ); - - const { container, navigationCalls } = mountControlledSidebar({ - currentPage: "sceneapps", - currentPageParams: { - sceneappId: "story-video-suite", - projectId: "project-9", - runId: "run-2", - }, - }); - await flushEffects(); - - const automationButton = container.querySelector( - 'button[aria-label="持续流程"]', - ) as HTMLButtonElement | null; - const sceneAppsButton = container.querySelector( - 'button[aria-label="创作场景"]', - ) as HTMLButtonElement | null; - - expect(automationButton).not.toBeNull(); - expect(sceneAppsButton).not.toBeNull(); - - act(() => { - automationButton?.dispatchEvent( - new MouseEvent("click", { bubbles: true, cancelable: true }), - ); - sceneAppsButton?.dispatchEvent( - new MouseEvent("click", { bubbles: true, cancelable: true }), - ); - }); - await flushEffects(); - - expect(navigationCalls).toEqual([ - { - page: "automation", - params: undefined, - }, - { - page: "sceneapps", - params: { - sceneappId: "story-video-suite", - projectId: "project-9", - runId: "run-2", - }, - }, - ]); - expect( - container.querySelector( - 'button[aria-label="创作场景"][aria-current="page"]', - ), - ).not.toBeNull(); - }); - - it("默认应隐藏系统区的可选入口,只保留设置", async () => { - const container = mountSidebar({ - currentPageParams: { - agentEntry: "new-task", - } as AgentPageParams, - }); - await flushEffects(); - - expect(container.textContent).toContain("系统"); - expect(container.textContent).toContain("设置"); - expect(container.textContent).not.toContain("终端"); + expect(container.textContent).not.toContain("插件中心"); + expect(container.textContent).not.toContain("OpenClaw"); expect(container.textContent).not.toContain("桌宠"); }); - it("侧边栏不应再渲染旧主题分组标题", async () => { + it("桌宠入口开启后,进入 companion 视图应高亮桌宠", async () => { + mockGetConfig.mockResolvedValue({ + navigation: { + enabled_items: ["companion"], + }, + }); + + const container = mountSidebar({ + currentPage: "settings", + currentPageParams: { + tab: SettingsTabs.Providers, + providerView: "companion", + }, + }); + await flushEffects(2); + + expect( + container.querySelector('button[aria-label="桌宠"][aria-current="page"]'), + ).not.toBeNull(); + expect( + container.querySelector('button[aria-label="设置"][aria-current="page"]'), + ).toBeNull(); + }); + + it("插件中心未开启时不应渲染插件扩展分组", async () => { + mockGetPluginsForSurface.mockResolvedValue([ + { + pluginId: "demo-sidebar", + name: "Demo Sidebar", + description: "demo", + icon: "Bot", + surfaces: ["sidebar"], + }, + ]); + const container = mountSidebar({ currentPageParams: { agentEntry: "new-task", } as AgentPageParams, }); - await flushEffects(); + await flushEffects(3); - expect(container.textContent).not.toContain("创作主题"); - expect(container.textContent).not.toContain("社媒内容"); + expect(container.textContent).not.toContain("插件扩展"); + expect(container.textContent).not.toContain("Demo Sidebar"); + }); + + it("插件中心开启后才应显示插件扩展分组", async () => { + mockGetConfig.mockResolvedValue({ + navigation: { + enabled_items: ["plugins"], + }, + }); + mockGetPluginsForSurface.mockResolvedValue([ + { + pluginId: "demo-sidebar", + name: "Demo Sidebar", + description: "demo", + icon: "Bot", + surfaces: ["sidebar"], + }, + ]); + + const container = mountSidebar({ + currentPageParams: { + agentEntry: "new-task", + } as AgentPageParams, + }); + await flushEffects(3); + + expect(container.textContent).toContain("插件扩展"); + expect(container.textContent).toContain("Demo Sidebar"); }); }); diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index 5119e80b3..99d69887c 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -1,7 +1,8 @@ /** * 全局应用侧边栏 * - * 参考生成主执行面信息架构:任务、工作台、能力、资料库、系统入口分层展示 + * 参考 LimeNext V2 current IA:任务 / 能力 / 资料 / 系统 四段式导航, + * 默认只暴露主线入口,历史系统面收回到显式开启的隐藏项。 */ import { useState, useEffect, useMemo, useRef, type ReactElement } from "react"; @@ -20,7 +21,7 @@ import { import * as LucideIcons from "lucide-react"; import { getPluginsForSurface, PluginUIInfo } from "@/lib/api/pluginUI"; import { AgentPageParams, Page, PageParams } from "@/types/page"; -import { getConfig } from "@/lib/api/appConfig"; +import { getConfig, subscribeAppConfigChanged } from "@/lib/api/appConfig"; import { buildHomeAgentParams, } from "@/lib/workspace/navigation"; @@ -55,7 +56,7 @@ type SidebarNavItem = SidebarNavItemDefinition; type SidebarNavSection = SidebarNavSectionDefinition; const APP_SIDEBAR_COLLAPSED_STORAGE_KEY = "lime.app-sidebar.collapsed"; -const APP_SIDEBAR_ENABLED_ITEMS_STORAGE_KEY = "lime.app-sidebar.enabled-items"; +const SIDEBAR_PLUGIN_CENTER_NAV_ITEM_ID = "plugins"; const SIDEBAR_PLUGIN_IDLE_TIMEOUT_MS = 1200; const SIDEBAR_PLUGIN_BROWSER_IDLE_TIMEOUT_MS = 6000; @@ -114,45 +115,6 @@ function isSameSidebarNavigationTarget( ); } -function readCachedEnabledNavItems(): string[] { - if (typeof window === "undefined") { - return [...DEFAULT_ENABLED_SIDEBAR_NAV_ITEM_IDS]; - } - - try { - const raw = window.localStorage.getItem( - APP_SIDEBAR_ENABLED_ITEMS_STORAGE_KEY, - ); - if (!raw) { - return [...DEFAULT_ENABLED_SIDEBAR_NAV_ITEM_IDS]; - } - - const parsed = JSON.parse(raw); - if (!Array.isArray(parsed)) { - return [...DEFAULT_ENABLED_SIDEBAR_NAV_ITEM_IDS]; - } - - return resolveEnabledSidebarNavItems(parsed); - } catch { - return [...DEFAULT_ENABLED_SIDEBAR_NAV_ITEM_IDS]; - } -} - -function persistEnabledNavItems(items: string[]): void { - if (typeof window === "undefined") { - return; - } - - try { - window.localStorage.setItem( - APP_SIDEBAR_ENABLED_ITEMS_STORAGE_KEY, - JSON.stringify(items), - ); - } catch { - // ignore - } -} - const Container = styled.aside<{ $collapsed?: boolean; $themeMode: "light" | "dark"; @@ -559,8 +521,9 @@ export function AppSidebar({ return "light"; }); - const [enabledNavItems, setEnabledNavItems] = - useState(readCachedEnabledNavItems); + const [enabledNavItems, setEnabledNavItems] = useState( + DEFAULT_ENABLED_SIDEBAR_NAV_ITEM_IDS, + ); const [sidebarPlugins, setSidebarPlugins] = useState([]); const [refreshTrigger, setRefreshTrigger] = useState(0); const [expandedGroupIds, setExpandedGroupIds] = useState(() => @@ -578,7 +541,6 @@ export function AppSidebar({ config.navigation?.enabled_items, ); setEnabledNavItems(resolvedItems); - persistEnabledNavItems(resolvedItems); } catch (error) { console.error("加载配置失败:", error); } @@ -586,15 +548,9 @@ export function AppSidebar({ loadNavConfig(); - const handleConfigChange = () => { - loadNavConfig(); - }; - - window.addEventListener("nav-config-changed", handleConfigChange); - - return () => { - window.removeEventListener("nav-config-changed", handleConfigChange); - }; + return subscribeAppConfigChanged(() => { + void loadNavConfig(); + }); }, []); const filteredMainSections = useMemo(() => { @@ -618,6 +574,11 @@ export function AppSidebar({ }, [enabledNavItems]); useEffect(() => { + if (!enabledNavItems.includes(SIDEBAR_PLUGIN_CENTER_NAV_ITEM_ID)) { + setSidebarPlugins([]); + return; + } + let cancelled = false; const loadSidebarPlugins = async (forceRefresh = false) => { @@ -648,7 +609,7 @@ export function AppSidebar({ cancelled = true; cancelScheduledLoad(); }; - }, [refreshTrigger]); + }, [enabledNavItems, refreshTrigger]); useEffect(() => { const handleStorageChange = (event: StorageEvent) => { @@ -718,6 +679,9 @@ export function AppSidebar({ }; }); }, [sidebarPlugins]); + const shouldShowPluginExtensionsSection = + enabledNavItems.includes(SIDEBAR_PLUGIN_CENTER_NAV_ITEM_ID) && + assistantItems.length > 0; const isActive = (item: SidebarNavItem): boolean => { if (item.children && item.children.length > 0) { @@ -784,17 +748,6 @@ export function AppSidebar({ return; } - if ( - isActive(item) && - isSameSidebarNavigationTarget( - requestedNavigationTargetRef.current, - activeNavigationTarget.page, - activeNavigationTarget.rawParams, - ) - ) { - return; - } - if ( isSameSidebarNavigationTarget( target, @@ -953,9 +906,9 @@ export function AppSidebar({ ))} - {assistantItems.length > 0 && ( + {shouldShowPluginExtensionsSection && (
- 助手 + 插件扩展 {assistantItems.map((item) => renderNavItem(item))}
)} diff --git a/src/components/agent/chat/AgentChatWorkspace.tsx b/src/components/agent/chat/AgentChatWorkspace.tsx index 24ed90cf4..e018ba0ba 100644 --- a/src/components/agent/chat/AgentChatWorkspace.tsx +++ b/src/components/agent/chat/AgentChatWorkspace.tsx @@ -1073,7 +1073,7 @@ export function AgentChatWorkspace({ }, [_onNavigate]); const handleOpenSceneAppsDirectory = useCallback(() => { if (!_onNavigate) { - toast.error("当前入口暂不支持跳转到创作场景目录"); + toast.error("当前入口暂不支持跳转到做法目录"); return; } @@ -1092,7 +1092,7 @@ export function AgentChatWorkspace({ }, [_onNavigate, input, projectId]); const handleResumeRecentSceneApp = useCallback(() => { if (!_onNavigate) { - toast.error("当前入口暂不支持跳转到创作场景目录"); + toast.error("当前入口暂不支持跳转到做法目录"); return; } diff --git a/src/components/agent/chat/agentChatWorkspaceLoader.test.ts b/src/components/agent/chat/agentChatWorkspaceLoader.test.ts new file mode 100644 index 000000000..4ea95327d --- /dev/null +++ b/src/components/agent/chat/agentChatWorkspaceLoader.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from "vitest"; +import { loadModuleWithRetry } from "./agentChatWorkspaceLoader"; + +describe("loadModuleWithRetry", () => { + it("应在模块动态导入瞬时失败时自动重试", async () => { + const loader = vi + .fn<() => Promise<{ ok: boolean }>>() + .mockRejectedValueOnce( + new Error("Failed to fetch dynamically imported module: /agent"), + ) + .mockResolvedValueOnce({ ok: true }); + + await expect(loadModuleWithRetry(loader, [0])).resolves.toEqual({ + ok: true, + }); + expect(loader).toHaveBeenCalledTimes(2); + }); + + it("非模块导入错误不应重试", async () => { + const loader = vi + .fn<() => Promise<{ ok: boolean }>>() + .mockRejectedValueOnce(new Error("workspace init failed")); + + await expect(loadModuleWithRetry(loader, [0, 0])).rejects.toThrow( + "workspace init failed", + ); + expect(loader).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/agent/chat/agentChatWorkspaceLoader.ts b/src/components/agent/chat/agentChatWorkspaceLoader.ts new file mode 100644 index 000000000..9401010db --- /dev/null +++ b/src/components/agent/chat/agentChatWorkspaceLoader.ts @@ -0,0 +1,58 @@ +const MODULE_IMPORT_FAILURE_PATTERNS = [ + "Importing a module script failed", + "Failed to fetch dynamically imported module", +] as const; + +const DEFAULT_RETRY_DELAYS_MS = [200, 600] as const; + +function isRetryableModuleImportFailure(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + + return MODULE_IMPORT_FAILURE_PATTERNS.some((pattern) => + error.message.includes(pattern), + ); +} + +function waitForDelay(delayMs: number): Promise { + if (delayMs <= 0) { + return Promise.resolve(); + } + + return new Promise((resolve) => { + window.setTimeout(resolve, delayMs); + }); +} + +export async function loadModuleWithRetry( + loader: () => Promise, + retryDelaysMs: readonly number[] = DEFAULT_RETRY_DELAYS_MS, +): Promise { + let lastError: unknown = null; + + for (let attempt = 0; attempt <= retryDelaysMs.length; attempt += 1) { + try { + return await loader(); + } catch (error) { + lastError = error; + const nextDelayMs = retryDelaysMs[attempt]; + if ( + nextDelayMs === undefined || + !isRetryableModuleImportFailure(error) + ) { + throw error; + } + + await waitForDelay(nextDelayMs); + } + } + + throw lastError instanceof Error + ? lastError + : new Error("模块加载失败"); +} + +export function loadAgentChatWorkspaceModule() { + return loadModuleWithRetry(() => import("./AgentChatWorkspace")); +} diff --git a/src/components/agent/chat/components/AgentRuntimeStrip.tsx b/src/components/agent/chat/components/AgentRuntimeStrip.tsx index 8383b6fd8..a6c020843 100644 --- a/src/components/agent/chat/components/AgentRuntimeStrip.tsx +++ b/src/components/agent/chat/components/AgentRuntimeStrip.tsx @@ -287,7 +287,7 @@ export const AgentRuntimeStrip: React.FC = ({ {selectedTeamSummary?.trim() || (hasSelectedTeam ? `已配置 ${selectedTeamRoleCount} 个角色,系统会按需拆出子任务。` - : "已开启分工模式,本次可选择或自定义任务方案。")} + : "已开启分工模式,本次可选择或自定义分工方案。")} ) : null} diff --git a/src/components/agent/chat/components/AgentThreadReliabilityPanel.tsx b/src/components/agent/chat/components/AgentThreadReliabilityPanel.tsx index 381f087e3..11241c35c 100644 --- a/src/components/agent/chat/components/AgentThreadReliabilityPanel.tsx +++ b/src/components/agent/chat/components/AgentThreadReliabilityPanel.tsx @@ -582,7 +582,7 @@ function buildReliabilityDiagnosticText(params: { `- 模型:${diagnosticRuntimeContext?.model || "未知"}`, `- 执行策略:${diagnosticRuntimeContext?.executionStrategy || "未知"}`, `- 主题:${diagnosticRuntimeContext?.activeTheme || "未知"}`, - `- 任务方案:${diagnosticRuntimeContext?.selectedTeamLabel || "未设置"}`, + `- 分工方案:${diagnosticRuntimeContext?.selectedTeamLabel || "未设置"}`, `- 工作区根目录:${diagnosticRuntimeContext?.workingDir || "未知"}`, "", "### 当前状态", diff --git a/src/components/agent/chat/components/ChatSidebar.test.tsx b/src/components/agent/chat/components/ChatSidebar.test.tsx index d44c1393b..954b24901 100644 --- a/src/components/agent/chat/components/ChatSidebar.test.tsx +++ b/src/components/agent/chat/components/ChatSidebar.test.tsx @@ -150,14 +150,21 @@ describe("ChatSidebar", () => { topics: [], currentTopicId: null, }); + const searchInput = container.querySelector( + 'input[placeholder="搜索生成标题、结果或摘要"]', + ) as HTMLInputElement | null; expect(container.textContent).toContain("生成现场"); expect(container.textContent).toContain( "在这里继续推进当前创作、回看最近结果和旧历史。", ); - expect(container.textContent).toContain("还没有进行中的生成任务"); + expect(searchInput).toBeTruthy(); + expect(container.textContent).toContain("开始生成"); + expect(container.textContent).toContain("全部记录"); + expect(container.textContent).toContain("仅看待继续"); + expect(container.textContent).toContain("还没有待继续的生成记录"); expect(container.textContent).toContain( - "从“新建任务”开始也很自然,开始后会回到这里继续生成。", + "从“开始生成”发起也很自然,开始后会回到这里继续生成。", ); }); @@ -458,7 +465,7 @@ describe("ChatSidebar", () => { ]); expect(cards[0]?.textContent).toContain("当前焦点"); expect(container.textContent).toContain( - "当前线程来自主任务,可直接返回主任务并切换其他子任务;正在处理的任务会排在前面。", + "当前线程来自主助手,可直接返回主助手并切换其他子任务;正在处理的任务会排在前面。", ); }); diff --git a/src/components/agent/chat/components/ChatSidebar.tsx b/src/components/agent/chat/components/ChatSidebar.tsx index 628994c74..7003265d3 100644 --- a/src/components/agent/chat/components/ChatSidebar.tsx +++ b/src/components/agent/chat/components/ChatSidebar.tsx @@ -808,13 +808,23 @@ export const ChatSidebar: React.FC = ({ contextVariant === "task-center" ? "在这里继续推进当前创作、回看最近结果和旧历史。" : null; + const searchPlaceholder = + contextVariant === "task-center" + ? "搜索生成标题、结果或摘要" + : "搜索任务标题或摘要"; + const newChatLabel = + contextVariant === "task-center" ? "开始生成" : "新建任务"; + const allTasksLabel = + contextVariant === "task-center" ? "全部记录" : "全部任务"; + const activeTasksLabel = + contextVariant === "task-center" ? "仅看待继续" : "仅看进行中"; const emptyStateTitle = contextVariant === "task-center" - ? "还没有进行中的生成任务" + ? "还没有待继续的生成记录" : "还没有任务"; const emptyStateDescription = contextVariant === "task-center" - ? "从“新建任务”开始也很自然,开始后会回到这里继续生成。" + ? "从“开始生成”发起也很自然,开始后会回到这里继续生成。" : "从“新建任务”开始输入需求,创建后会出现在这里。"; const olderSectionMoreLabel = contextVariant === "task-center" @@ -1073,7 +1083,7 @@ export const ChatSidebar: React.FC = ({ type="search" value={searchKeyword} onChange={(event) => setSearchKeyword(event.target.value)} - placeholder="搜索任务标题或摘要" + placeholder={searchPlaceholder} className="h-11 w-full rounded-[18px] border border-emerald-200/40 bg-white/90 pl-9 pr-3 text-sm text-slate-700 shadow-sm shadow-slate-950/5 outline-none transition focus:border-emerald-300/60 focus:ring-2 focus:ring-emerald-100/50 dark:border-white/10 dark:bg-white/5 dark:text-slate-100 dark:focus:border-white/20 dark:focus:ring-white/10" /> @@ -1084,7 +1094,7 @@ export const ChatSidebar: React.FC = ({ className={CHAT_SIDEBAR_PRIMARY_ACTION_BUTTON_CLASSNAME} > - 新建任务 + {newChatLabel}
@@ -1098,7 +1108,7 @@ export const ChatSidebar: React.FC = ({ : "border-slate-200/80 bg-white/90 text-slate-500 hover:border-slate-300 hover:bg-white hover:text-slate-800 dark:border-white/10 dark:bg-white/5 dark:text-slate-300", )} > - 全部任务 + {allTasksLabel}
@@ -1224,7 +1234,7 @@ export const ChatSidebar: React.FC = ({ {teamSectionCollapsed ? collapsedTeamSummary : subagentParentContext - ? "当前线程来自主任务,可直接返回主任务并切换其他子任务;正在处理的任务会排在前面。" + ? "当前线程来自主助手,可直接返回主助手并切换其他子任务;正在处理的任务会排在前面。" : "这里优先展示正在处理的子任务,再回到当前任务和后续节点。"}

diff --git a/src/components/agent/chat/components/CuratedTaskLauncherDialog.test.tsx b/src/components/agent/chat/components/CuratedTaskLauncherDialog.test.tsx index 916a49724..367fcef87 100644 --- a/src/components/agent/chat/components/CuratedTaskLauncherDialog.test.tsx +++ b/src/components/agent/chat/components/CuratedTaskLauncherDialog.test.tsx @@ -416,7 +416,7 @@ describe("CuratedTaskLauncherDialog", () => { }); expect(document.body.textContent).toContain("品牌语气偏好"); - expect(document.body.textContent).toContain("已选择 2 条参考基线"); + expect(document.body.textContent).toContain("已选择 2 条参考对象"); await act(async () => { window.dispatchEvent( diff --git a/src/components/agent/chat/components/CuratedTaskLauncherDialog.tsx b/src/components/agent/chat/components/CuratedTaskLauncherDialog.tsx index 91a84bbbb..8287e6dcb 100644 --- a/src/components/agent/chat/components/CuratedTaskLauncherDialog.tsx +++ b/src/components/agent/chat/components/CuratedTaskLauncherDialog.tsx @@ -388,7 +388,7 @@ export function CuratedTaskLauncherDialog({
- 想的话,再带几条参考 + 想的话,再带几条参考对象
风格、偏好、项目结果和当前上下文都可以。不是必填,但会让这一轮更贴近你的目标。 @@ -491,7 +491,7 @@ export function CuratedTaskLauncherDialog({ referenceEntries.length === 0 && !referenceEntriesError ? (
- 当前还没有可选参考条目,后面补进来也可以。 + 当前还没有可选参考对象,后面补进来也可以。
) : null} @@ -503,7 +503,7 @@ export function CuratedTaskLauncherDialog({ {selectedReferenceEntryIds.length > 0 ? (
- 已选择 {selectedReferenceEntryIds.length} 条参考基线,本轮会一起带入生成。 + 已选择 {selectedReferenceEntryIds.length} 条参考对象,本轮会一起带入生成。 {missingSelectedReferenceCount > 0 ? ` 其中 ${missingSelectedReferenceCount} 条未出现在最近列表里,但发送时仍会保留。` : ""} diff --git a/src/components/agent/chat/components/EmptyState.test.tsx b/src/components/agent/chat/components/EmptyState.test.tsx index d88326294..690370a90 100644 --- a/src/components/agent/chat/components/EmptyState.test.tsx +++ b/src/components/agent/chat/components/EmptyState.test.tsx @@ -9,6 +9,7 @@ import type { UnifiedMemory } from "@/lib/api/unifiedMemory"; import type { ServiceSkillHomeItem } from "../service-skills/types"; import type { SceneAppEntryCardItem } from "../sceneappEntryTypes"; import type { InputCapabilitySelection } from "../skill-selection/inputCapabilitySelection"; +import { recordServiceSkillUsage } from "../service-skills/storage"; import { recordSlashEntryUsage } from "../skill-selection/slashEntryUsage"; import { buildCuratedTaskLaunchPrompt, @@ -773,6 +774,10 @@ describe("EmptyState", () => { ) as HTMLButtonElement | null; expect(recentTemplateButton).toBeTruthy(); expect(recentTemplateButton?.textContent).toContain("内容主稿生成"); + expect(recentTemplateButton?.textContent).toContain( + "上次填写:主题或产品信息=上次沉淀的主线升级;目标受众=品牌内容负责人", + ); + expect(recentTemplateButton?.textContent).toContain("参考:上次主稿参考"); expect(recentTemplateButton?.textContent).toContain( "需要:主题或产品信息、目标受众", ); @@ -807,7 +812,7 @@ describe("EmptyState", () => { expect(subjectInput?.value).toBe("上次沉淀的主线升级"); expect(audienceInput?.value).toBe("品牌内容负责人"); expect(document.body.textContent).toContain( - "已选择 1 条参考基线,本轮会一起带入生成。", + "已选择 1 条参考对象,本轮会一起带入生成。", ); await act(async () => { @@ -941,6 +946,9 @@ describe("EmptyState", () => { ) as HTMLButtonElement | null; expect(installedSkillButton).toBeTruthy(); expect(installedSkillButton?.textContent).toContain("内容主稿方法"); + expect(installedSkillButton?.textContent).toContain( + "上次目标:继续优化这套内容主稿", + ); expect(installedSkillButton?.textContent).toContain( "当你需要继续复用这套内容主稿方法时使用。", ); @@ -1001,6 +1009,50 @@ describe("EmptyState", () => { expect(installedSkillButton).toBeTruthy(); expect(container.textContent).toContain("继续上次做法"); expect(installedSkillButton?.textContent).toContain("内容主稿方法"); + expect(installedSkillButton?.textContent).toContain( + "上次目标:继续完善这套内容方法", + ); + }); + + it("最近使用的 service skill 也应显影上次补过的关键信息", async () => { + act(() => { + recordServiceSkillUsage({ + skillId: "github-repo-radar", + runnerType: "instant", + slotValues: { + repository_query: "AI Agent 监控", + }, + }); + }); + + const onSelectServiceSkill = vi.fn<(skill: ServiceSkillHomeItem) => void>(); + const recentMethod: ServiceSkillHomeItem = { + ...createGithubSearchServiceSkill(), + recentUsedAt: 1_900_000_000_000, + isRecent: true, + }; + + const container = renderEmptyState({ + activeTheme: "general", + onSelectServiceSkill, + serviceSkills: [recentMethod], + }); + + await act(async () => { + await Promise.resolve(); + }); + + const recentMethodButton = container.querySelector( + '[data-testid="entry-continuation-method-github-repo-radar"]', + ) as HTMLButtonElement | null; + expect(recentMethodButton).toBeTruthy(); + expect(recentMethodButton?.textContent).toContain( + "上次填写:检索主题=AI Agent 监控", + ); + expect(recentMethodButton?.textContent).toContain("需要:检索主题"); + expect(recentMethodButton?.textContent).toContain( + "交付:仓库列表 + 关键线索", + ); }); it("传入项目切换能力时应继续保留项目选择器入口", async () => { @@ -1023,7 +1075,7 @@ describe("EmptyState", () => { ).toBeNull(); }); - it("通用首页默认收起支撑能力卡,展开后才显示 4 个能力入口", async () => { + it("通用首页应把补充入口收成轻量续接条,展开后再显示能力说明", async () => { const container = renderEmptyState({ activeTheme: "general", onLaunchBrowserAssist: vi.fn(), @@ -1034,23 +1086,18 @@ describe("EmptyState", () => { }); const expectedLabels = ["我的方法", "持续流程", "任务拆分", "浏览器接入"]; - const expectedAlts = [ - "方法能力卡占位图", - "持续流程能力卡占位图", - "任务拆分能力卡占位图", - "浏览器接入能力卡占位图", - ]; - expect(container.textContent).toContain("支撑能力"); + expect( + container.querySelector('[data-testid="entry-supplemental-panel"]'), + ).toBeTruthy(); + expect(container.textContent).toContain( + "如果你已经知道怎么接着做,也可以直接从这里续上。", + ); expect(container.textContent).toContain("查看支撑能力"); expect( container.querySelector('[data-testid="entry-connect-browser"]'), ).toBeTruthy(); - for (const alt of expectedAlts) { - expect(container.querySelector(`img[alt="${alt}"]`)).toBeNull(); - } - const toggleButton = container.querySelector( '[data-testid="entry-capability-toggle"]', ) as HTMLButtonElement | null; @@ -1067,12 +1114,6 @@ describe("EmptyState", () => { expect(container.textContent).toContain("重复任务可持续复用"); expect(container.textContent).toContain("复杂任务可拆分并行推进"); expect(container.textContent).toContain("网页登录与网页执行"); - - for (const alt of expectedAlts) { - const image = container.querySelector(`img[alt="${alt}"]`); - expect(image).toBeTruthy(); - expect(image?.getAttribute("src")).toBeTruthy(); - } }); it("通用首页不再渲染 runtime 总览层", async () => { @@ -1141,7 +1182,7 @@ describe("EmptyState", () => { expect(onOpenMemoryWorkbench).not.toHaveBeenCalled(); }); - it("点击浏览器能力卡图片应触发浏览器接入", async () => { + it("点击补充入口里的连接浏览器应触发浏览器接入", async () => { const onLaunchBrowserAssist = vi.fn(); const container = renderEmptyState({ activeTheme: "general", @@ -1152,17 +1193,8 @@ describe("EmptyState", () => { await Promise.resolve(); }); - const toggleButton = container.querySelector( - '[data-testid="entry-capability-toggle"]', - ) as HTMLButtonElement | null; - expect(toggleButton).toBeTruthy(); - - act(() => { - toggleButton?.click(); - }); - const mediaButton = container.querySelector( - 'button[aria-label="连接浏览器"]', + '[data-testid="entry-connect-browser"]', ) as HTMLButtonElement | null; expect(mediaButton).toBeTruthy(); @@ -1809,7 +1841,7 @@ describe("EmptyState", () => { }); expect(onSend).toHaveBeenCalledWith( - expect.stringContaining("本轮可优先参考这些参考基线"), + expect.stringContaining("本轮可优先参考这些参考对象"), "react", undefined, expect.objectContaining({ @@ -1899,7 +1931,7 @@ describe("EmptyState", () => { }); expect(document.body.textContent).toContain( - "已选择 1 条参考基线,本轮会一起带入生成。", + "已选择 1 条参考对象,本轮会一起带入生成。", ); }); @@ -2588,7 +2620,9 @@ describe("EmptyState", () => { await Promise.resolve(); }); - expect(container.textContent).toContain("更多起手方式"); + expect(container.textContent).toContain( + "如果你已经知道怎么接着做,也可以直接从这里续上。", + ); expect(container.textContent).toContain("短视频编排"); expect( container.querySelector('[data-testid="sceneapps-home-directory"]'), @@ -2655,10 +2689,9 @@ describe("EmptyState", () => { await Promise.resolve(); }); - expect(container.textContent).toContain("更多起手方式"); - const resumeButton = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.includes("继续最近做法"), - ); + const resumeButton = container.querySelector( + '[data-testid="entry-sceneapp-resume"]', + ) as HTMLButtonElement | null; expect(resumeButton).toBeTruthy(); act(() => { diff --git a/src/components/agent/chat/components/EmptyState.tsx b/src/components/agent/chat/components/EmptyState.tsx index 9a73dade0..8d6ff5b45 100644 --- a/src/components/agent/chat/components/EmptyState.tsx +++ b/src/components/agent/chat/components/EmptyState.tsx @@ -1,11 +1,7 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; import styled, { keyframes } from "styled-components"; import { - Lightbulb, - Globe, - ListChecks, Settings2, - Workflow, ArrowRight, } from "lucide-react"; import { getConfig } from "@/lib/api/appConfig"; @@ -20,6 +16,7 @@ import { isTeamRuntimeRecommendation, } from "../utils/contextualRecommendations"; import { + buildCuratedTaskRecentUsageDescription, buildCuratedTaskCapabilityDescription, buildCuratedTaskLaunchPrompt, findCuratedTaskTemplateById, @@ -28,6 +25,7 @@ import { recordCuratedTaskTemplateUsage, replaceCuratedTaskLaunchPromptInInput, resolveCuratedTaskTemplateLaunchPrefill, + subscribeCuratedTaskTemplateUsageChanged, type CuratedTaskInputValues, type CuratedTaskTemplateItem, } from "../utils/curatedTaskTemplates"; @@ -85,10 +83,10 @@ import { import { resolveServiceSkillEntryDescription } from "../service-skills/entryAdapter"; import { listFeaturedHomeServiceSkills } from "../service-skills/homeEntrySkills"; import { buildServiceSkillCapabilityDescription } from "../service-skills/skillPresentation"; -import capabilitySkillsPlaceholder from "@/assets/entry-surface/capability-skills-lime.png"; -import capabilityAutomationsPlaceholder from "@/assets/entry-surface/capability-automations-lime.png"; -import capabilityAgentTeamsPlaceholder from "@/assets/entry-surface/capability-agent-teams-lime.png"; -import capabilityBrowserAssistPlaceholder from "@/assets/entry-surface/capability-browser-assist-lime.png"; +import { + buildServiceSkillLaunchPrefillSummary, + resolveServiceSkillLaunchPrefill, +} from "../service-skills/serviceSkillLaunchPrefill"; import type { SceneAppEntryCardItem } from "../sceneappEntryTypes"; import type { RuntimeToolAvailability } from "../utils/runtimeToolAvailability"; import type { AgentTaskRuntimeCardModel } from "../utils/agentTaskRuntime"; @@ -357,6 +355,91 @@ const RecommendationAssistFootnote = styled.div` color: rgb(148 163 184); `; +const RecommendationSupplementalPanel = styled.div` + display: flex; + min-width: 0; + flex-direction: column; + gap: 0.42rem; + padding: 0.2rem 0.2rem 0; +`; + +const RecommendationSupplementalLabel = styled.div` + font-size: 11px; + line-height: 1.5; + color: rgb(148 163 184); +`; + +const RecommendationSupplementalRow = styled.div` + display: flex; + min-width: 0; + flex-wrap: wrap; + align-items: center; + gap: 0.45rem; +`; + +const RecommendationSupplementalLink = styled.button` + display: inline-flex; + align-items: center; + border-radius: 9999px; + border: 1px solid rgb(226 232 240); + background: rgb(255 255 255); + padding: 0.34rem 0.62rem; + font-size: 11px; + font-weight: 500; + line-height: 1.2; + color: rgb(71 85 105); + transition: + border-color 180ms ease, + background-color 180ms ease, + color 180ms ease; + + &:hover { + border-color: rgb(203 213 225); + background: rgb(248 250 252); + color: rgb(15 23 42); + } +`; + +const RecommendationCapabilityGrid = styled.div` + display: grid; + gap: 0.45rem; + grid-template-columns: minmax(0, 1fr); + + @media (min-width: 768px) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +`; + +const RecommendationCapabilityCard = styled.div` + display: flex; + min-width: 0; + flex-direction: column; + gap: 0.18rem; + border-radius: 16px; + border: 1px solid rgb(226 232 240); + background: rgb(248 250 252); + padding: 0.78rem 0.82rem; +`; + +const RecommendationCapabilityCardTitle = styled.div` + font-size: 12px; + font-weight: 600; + line-height: 1.45; + color: rgb(15 23 42); +`; + +const RecommendationCapabilityCardValue = styled.div` + font-size: 11px; + line-height: 1.5; + color: rgb(71 85 105); +`; + +const RecommendationCapabilityCardDescription = styled.div` + font-size: 11px; + line-height: 1.55; + color: rgb(100 116 139); +`; + interface EmptyStateProps extends SkillSelectionSourceProps { input: string; setInput: (value: string) => void; @@ -743,6 +826,12 @@ export const EmptyState: React.FC = ({ }; }, []); + useEffect(() => { + return subscribeCuratedTaskTemplateUsageChanged(() => { + setCuratedTaskTemplatesVersion((previous) => previous + 1); + }); + }, []); + useEffect(() => { return subscribeCuratedTaskRecommendationSignalsChanged(() => { setCuratedTaskRecommendationSignalsVersion((previous) => previous + 1); @@ -1107,92 +1196,41 @@ export const EmptyState: React.FC = ({ return badges.slice(0, 5); }, [creationMode, showCreationModeSelector, activeSkillDisplayLabel]); - const workspaceCards = useMemo(() => { - const cards: Array<{ - key: string; - eyebrow: string; - title: string; - value: string; - description: string; - icon: React.ReactNode; - imageSrc?: string; - imageAlt?: string; - tone?: "slate" | "sky" | "emerald" | "amber" | "lime"; - action?: React.ReactNode; - onMediaAction?: () => void; - mediaActionLabel?: string; - mediaActionDisabled?: boolean; - }> = [ + const capabilitySummaryItems = useMemo( + () => [ { key: "skills", - eyebrow: "沉淀能力", title: "我的方法", value: methodSummaryLabel, description: "把跑通过的常用做法沉淀下来,下次遇到同类任务可以直接续上。", - icon: , - imageSrc: capabilitySkillsPlaceholder, - imageAlt: "方法能力卡占位图", - tone: "lime", }, { key: "automation", - eyebrow: "沉淀能力", title: "持续流程", value: planEnabled ? "当前任务会按步骤推进" : "重复任务可持续复用", description: "适合批量任务和重复流程,让一套做法可以自己持续往下跑,不再每次从头开始。", - icon: , - imageSrc: capabilityAutomationsPlaceholder, - imageAlt: "持续流程能力卡占位图", - tone: "lime", }, { key: "agent-teams", - eyebrow: "支撑能力", title: "任务拆分", value: subagentEnabled ? "当前任务支持分工推进" : "复杂任务可拆分并行推进", description: "当一个目标需要调研、方案和执行同时推进时,可以拆成多个分工后统一回收结论。", - icon: , - imageSrc: capabilityAgentTeamsPlaceholder, - imageAlt: "任务拆分能力卡占位图", - tone: "lime", }, - ]; - - cards.push({ - key: "browser", - eyebrow: "支撑能力", - title: "浏览器接入", - value: browserAssistLoading ? "正在检查连接状态" : "网页登录与网页执行", - description: - "登录、验证和网页操作可以直接在当前任务里继续,不必再切到单独工作台。", - icon: , - imageSrc: capabilityBrowserAssistPlaceholder, - imageAlt: "浏览器接入能力卡占位图", - tone: "lime", - onMediaAction: onLaunchBrowserAssist - ? () => { - void onLaunchBrowserAssist(); - } - : undefined, - mediaActionLabel: browserAssistLoading - ? "浏览器连接准备中" - : "连接浏览器", - mediaActionDisabled: browserAssistLoading, - }); - - return cards; - }, [ - browserAssistLoading, - planEnabled, - methodSummaryLabel, - onLaunchBrowserAssist, - subagentEnabled, - ]); + { + key: "browser", + title: "浏览器接入", + value: browserAssistLoading ? "正在检查连接状态" : "网页登录与网页执行", + description: + "登录、验证和网页操作可以直接在当前任务里继续,不必再切到单独工作台。", + }, + ], + [browserAssistLoading, methodSummaryLabel, planEnabled, subagentEnabled], + ); const recommendationShelfItems = useMemo(() => { const curatedTemplateRecommendations = listFeaturedHomeCuratedTaskTemplates( @@ -1316,12 +1354,20 @@ export const EmptyState: React.FC = ({ return { key: `solution-${template.id}`, title: template.title, - summary: buildCuratedTaskCapabilityDescription(template, { - includeSummary: false, - includeResultDestination: true, - includeFollowUpActions: true, - followUpLimit: 1, - }), + summary: [ + buildCuratedTaskRecentUsageDescription({ + task: template, + prefill: launchPrefill, + }), + buildCuratedTaskCapabilityDescription(template, { + includeSummary: false, + includeResultDestination: true, + includeFollowUpActions: true, + followUpLimit: 1, + }), + ] + .filter((segment) => segment.length > 0) + .join(" · "), badge: "结果模板", usedAt: template.recentUsedAt as number, testId: `entry-continuation-solution-${template.id}`, @@ -1350,7 +1396,14 @@ export const EmptyState: React.FC = ({ return { key: `installed-skill-${skill.key}`, title: skill.name, - summary: buildInstalledSkillCapabilityDescription(skill), + summary: [ + usage?.replayText + ? `上次目标:${truncatePrompt(usage.replayText, 56)}` + : "", + buildInstalledSkillCapabilityDescription(skill), + ] + .filter((segment) => segment.length > 0) + .join(" · "), badge: "我的方法", usedAt, testId: `entry-continuation-method-${skill.key}`, @@ -1368,6 +1421,9 @@ export const EmptyState: React.FC = ({ typeof onSelectServiceSkill === "function" ? (serviceSkills ?? []) .map((skill) => { + const recentPrefill = resolveServiceSkillLaunchPrefill({ + skill, + }); const serviceSkillUsedAt = typeof skill.recentUsedAt === "number" ? skill.recentUsedAt : 0; const sceneUsedAt = skill.sceneBinding?.sceneKey @@ -1383,9 +1439,18 @@ export const EmptyState: React.FC = ({ return { key: `method-${skill.id}`, title: skill.title, - summary: buildServiceSkillCapabilityDescription(skill, { - includeSummary: false, - }), + summary: [ + buildServiceSkillLaunchPrefillSummary({ + skill, + slotValues: recentPrefill?.slotValues, + launchUserInput: recentPrefill?.launchUserInput, + }), + buildServiceSkillCapabilityDescription(skill, { + includeSummary: false, + }), + ] + .filter((segment) => segment.length > 0) + .join(" · "), badge: "我的方法", usedAt, testId: `entry-continuation-method-${skill.id}`, @@ -1728,68 +1793,100 @@ export const EmptyState: React.FC = ({ ); - const generalSceneAppsPanel = shouldShowSceneAppsPanel ? ( - - ) : null; - const shouldShowCapabilitySummaryPanel = showCapabilityCards || Boolean(onLaunchBrowserAssist) || hasAutoLaunchSiteSkill || hasReusableMethodContinuation; + const shouldShowSupplementalPanel = + shouldShowSceneAppsPanel || shouldShowCapabilitySummaryPanel; - const generalCapabilitySummaryPanel = ( - - - - - 支撑能力 - - - 当前已有 {methodSummaryLabel} - ;持续流程、任务拆分和浏览器接入会按需要自动挂上,你不用先选能力。 - - -
- {onLaunchBrowserAssist ? ( - - ) : null} - -
-
- - {showCapabilityCards - ? "下方已展开当前会自动接入的能力说明。" - : "默认先聚焦结果入口,只有在你想确认能力边界时再展开。"} - -
- ); + + ) : null} + + {showCapabilityCards ? ( + + {capabilitySummaryItems.map((item) => ( + + + {item.title} + + + {item.value} + + + {item.description} + + + ))} + + ) : ( + + 当前已有 {methodSummaryLabel} + ,持续流程、任务拆分和浏览器接入会按需要自动挂上。 + + )} + + ) : null; const headerControls = onProjectChange ? (
@@ -1839,16 +1936,13 @@ export const EmptyState: React.FC = ({ description={workbenchCopy.description} supportingDescription={workbenchCopy.supportingDescription} badges={workspaceBadges} - cards={isGeneralTheme && !showCapabilityCards ? [] : workspaceCards} + cards={[]} prioritySlot={composerPanel} supportingSlot={ isGeneralTheme ? ( <> {generalResultShelfPanel} - {generalSceneAppsPanel} - {shouldShowCapabilitySummaryPanel - ? generalCapabilitySummaryPanel - : null} + {generalSupplementalPanel} ) : ( defaultQuickActionsPanel diff --git a/src/components/agent/chat/components/EmptyStateSceneAppsPanel.tsx b/src/components/agent/chat/components/EmptyStateSceneAppsPanel.tsx index a6ba72ee2..9a943c340 100644 --- a/src/components/agent/chat/components/EmptyStateSceneAppsPanel.tsx +++ b/src/components/agent/chat/components/EmptyStateSceneAppsPanel.tsx @@ -5,9 +5,6 @@ interface EmptyStateSceneAppsPanelProps { loading?: boolean; launchingSceneAppId?: string | null; onLaunchSceneApp?: (sceneappId: string) => void | Promise; - canResumeRecentSceneApp?: boolean; - onResumeRecentSceneApp?: () => void; - onOpenSceneAppsDirectory?: () => void; } export function EmptyStateSceneAppsPanel({ @@ -15,116 +12,54 @@ export function EmptyStateSceneAppsPanel({ loading = false, launchingSceneAppId = null, onLaunchSceneApp, - canResumeRecentSceneApp = false, - onResumeRecentSceneApp, - onOpenSceneAppsDirectory, }: EmptyStateSceneAppsPanelProps) { const hasLaunchableEntries = items.length > 0; - if (!loading && !hasLaunchableEntries && !canResumeRecentSceneApp) { + if (!loading && !hasLaunchableEntries) { return null; } return ( -
-
-
-
- 更多起手方式 -
-

- {hasLaunchableEntries - ? "只有当整套做法比直接拿结果更省事时,再从这里进入;已经知道想拿什么结果,优先用上面的结果入口就够了。" - : "这次如果只想继续上一套做法,可以直接从这里回到原流程。"} -

-
- {canResumeRecentSceneApp || onOpenSceneAppsDirectory ? ( -
- {canResumeRecentSceneApp && onResumeRecentSceneApp ? ( - - ) : null} - {onOpenSceneAppsDirectory ? ( - - ) : null} -
- ) : null} -
- + <> {loading && items.length === 0 ? ( -
- 正在整理可直接启动的场景做法… -
+ + 正在整理可直接续上的整套做法… + ) : hasLaunchableEntries ? (
- {items.map((item, index) => { + {items.map((item) => { const isLaunching = launchingSceneAppId === item.id; - const disabled = Boolean(item.disabledReason) || !onLaunchSceneApp; + const disabled = + Boolean(item.disabledReason) || !onLaunchSceneApp || isLaunching; return ( -
{ + if (disabled) { + return; + } + void onLaunchSceneApp?.(item.id); + }} > - {disabled ? ( - - {item.title} - - ) : ( - - )} - {isLaunching ? ( - 准备中… - ) : null} - {item.disabledReason ? ( - - {item.disabledReason} - - ) : null} - {index < items.length - 1 ? ( - / - ) : null} -
+ {isLaunching ? `${item.title} 准备中…` : item.title} + ); })}
) : ( -
+ 最近跑过的整套做法可以直接续上,不必重新装配。 -
+ )} -
+ ); } diff --git a/src/components/agent/chat/components/HarnessStatusPanel.tsx b/src/components/agent/chat/components/HarnessStatusPanel.tsx index 2deeef426..0126d01cb 100644 --- a/src/components/agent/chat/components/HarnessStatusPanel.tsx +++ b/src/components/agent/chat/components/HarnessStatusPanel.tsx @@ -2799,7 +2799,7 @@ export function HarnessStatusPanel({
- {selectedTeamLabel || "当前已启用任务方案"} + {selectedTeamLabel || "当前已启用分工方案"}
{selectedTeamSummary ? (
@@ -2807,7 +2807,7 @@ export function HarnessStatusPanel({
) : (
- 本次会优先参考所选任务方案的角色分工,按需拆出子任务继续处理。 + 本次会优先参考所选分工方案,按需拆出子任务继续处理。
)}
diff --git a/src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx b/src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx index d1ad1f7cd..fa28b7561 100644 --- a/src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx +++ b/src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx @@ -380,7 +380,7 @@ export const InputbarComposerSection: React.FC< ? workflowGate?.status === "waiting" ? "说说你的选择,剩下的交给我" : contextVariant === "task-center" - ? "继续补充当前生成任务,或回到左侧继续旧历史" + ? "继续补充这轮生成,或回到左侧继续旧历史" : "试着输入任何指令,剩下的交给我" : undefined } diff --git a/src/components/agent/chat/components/Inputbar/hooks/useInputbarSend.ts b/src/components/agent/chat/components/Inputbar/hooks/useInputbarSend.ts index b0a32b323..2ae9ece5e 100644 --- a/src/components/agent/chat/components/Inputbar/hooks/useInputbarSend.ts +++ b/src/components/agent/chat/components/Inputbar/hooks/useInputbarSend.ts @@ -2,6 +2,7 @@ import { useCallback } from "react"; import type { AutoContinueRequestPayload } from "@/lib/api/agentRuntime"; import type { MessageImage } from "../../../types"; import type { HandleSendOptions } from "../../../hooks/handleSendTypes"; +import { recordCuratedTaskTemplateUsage } from "../../../utils/curatedTaskTemplates"; import { resolveInputCapabilityDispatch, type InputCapabilitySelection, @@ -77,6 +78,14 @@ export function useInputbarSend({ if (result === false) { return; } + if (activeCapability?.kind === "curated_task") { + recordCuratedTaskTemplateUsage({ + templateId: activeCapability.task.id, + launchInputValues: activeCapability.launchInputValues, + referenceMemoryIds: activeCapability.referenceMemoryIds, + referenceEntries: activeCapability.referenceEntries, + }); + } clearPendingImages(); clearActiveCapability(); } catch { diff --git a/src/components/agent/chat/components/Inputbar/index.test.tsx b/src/components/agent/chat/components/Inputbar/index.test.tsx index 84661f1e7..f544b73f7 100644 --- a/src/components/agent/chat/components/Inputbar/index.test.tsx +++ b/src/components/agent/chat/components/Inputbar/index.test.tsx @@ -8,7 +8,11 @@ import type { Skill } from "@/lib/api/skills"; import type { ServiceSkillHomeItem } from "@/components/agent/chat/service-skills/types"; import type { BuiltinInputCommand, RuntimeSceneSlashCommand } from "../../skill-selection/builtinCommands"; import type { InputCapabilitySelection } from "../../skill-selection/inputCapabilitySelection"; -import { buildCuratedTaskLaunchPrompt } from "../../utils/curatedTaskTemplates"; +import { + buildCuratedTaskLaunchPrompt, + findCuratedTaskTemplateById, + resolveCuratedTaskTemplateLaunchPrefill, +} from "../../utils/curatedTaskTemplates"; const mockCharacterMention = vi.fn< @@ -386,6 +390,7 @@ beforeEach(() => { IS_REACT_ACT_ENVIRONMENT?: boolean; } ).IS_REACT_ACT_ENVIRONMENT = true; + window.localStorage.clear(); }); afterEach(() => { @@ -803,6 +808,128 @@ describe("Inputbar", () => { ); }); + it("Generate 内发送 curated_task 后,应把最新启动事实写回 recent usage", async () => { + const template = findCuratedTaskTemplateById("daily-trend-briefing"); + expect(template).toBeTruthy(); + + const launchInputValues = { + theme_target: "AI 内容创作", + platform_region: "X 与 TikTok 北美区", + }; + const referenceEntries = [ + { + id: "memory-1", + sourceKind: "memory" as const, + title: "品牌风格样本", + summary: "轻盈但专业的品牌语气参考。", + category: "context" as const, + categoryLabel: "参考", + tags: ["品牌", "语气"], + }, + ]; + const prompt = buildCuratedTaskLaunchPrompt({ + task: template!, + inputValues: launchInputValues, + referenceEntries, + }); + const onSend = vi.fn().mockResolvedValue(true); + + renderInputbar({ + input: prompt, + onSend, + activeTheme: "general", + initialInputCapability: { + capabilityRoute: { + kind: "curated_task", + taskId: "daily-trend-briefing", + taskTitle: "每日趋势摘要", + prompt, + launchInputValues, + referenceMemoryIds: ["memory-1"], + referenceEntries, + }, + requestKey: 2026042101, + }, + }); + + await act(async () => { + await Promise.resolve(); + }); + + const sendButton = document.querySelector( + '[data-testid="send-btn"]', + ) as HTMLButtonElement | null; + expect(sendButton).toBeTruthy(); + + await act(async () => { + sendButton?.click(); + await Promise.resolve(); + }); + + expect(resolveCuratedTaskTemplateLaunchPrefill(template!)).toEqual( + expect.objectContaining({ + inputValues: launchInputValues, + referenceMemoryIds: ["memory-1"], + hint: expect.stringContaining("已根据你上次启动"), + referenceEntries: [ + expect.objectContaining({ + id: "memory-1", + sourceKind: "memory", + title: "品牌风格样本", + summary: "轻盈但专业的品牌语气参考。", + }), + ], + }), + ); + }); + + it("发送被上层拦截时,不应写入 curated task recent usage", async () => { + const template = findCuratedTaskTemplateById("daily-trend-briefing"); + expect(template).toBeTruthy(); + + const prompt = buildCuratedTaskLaunchPrompt({ + task: template!, + inputValues: { + theme_target: "AI 内容创作", + }, + }); + const onSend = vi.fn().mockResolvedValue(false); + + renderInputbar({ + input: prompt, + onSend, + activeTheme: "general", + initialInputCapability: { + capabilityRoute: { + kind: "curated_task", + taskId: "daily-trend-briefing", + taskTitle: "每日趋势摘要", + prompt, + launchInputValues: { + theme_target: "AI 内容创作", + }, + }, + requestKey: 2026042102, + }, + }); + + await act(async () => { + await Promise.resolve(); + }); + + const sendButton = document.querySelector( + '[data-testid="send-btn"]', + ) as HTMLButtonElement | null; + expect(sendButton).toBeTruthy(); + + await act(async () => { + sendButton?.click(); + await Promise.resolve(); + }); + + expect(resolveCuratedTaskTemplateLaunchPrefill(template!)).toBeNull(); + }); + it("带着缺少启动槽位的初始结果模板进入时,应先打开 launcher 而不是直接预填 prompt", async () => { const setInput = vi.fn(); renderInputbar({ @@ -2066,9 +2193,7 @@ describe("Inputbar", () => { const latestCall = mockInputbarCore.mock.calls[mockInputbarCore.mock.calls.length - 1]?.[0]; expect(latestCall).toBeTruthy(); - expect(latestCall.placeholder).toContain( - "继续补充当前生成任务,或回到左侧继续旧历史", - ); + expect(latestCall.placeholder).toContain("继续补充这轮生成,或回到左侧继续旧历史"); }); it("工作区工作流在待启动状态下不应显示闸门条", async () => { diff --git a/src/components/agent/chat/components/MessageList.test.tsx b/src/components/agent/chat/components/MessageList.test.tsx index a74e18b0c..1b3101ae4 100644 --- a/src/components/agent/chat/components/MessageList.test.tsx +++ b/src/components/agent/chat/components/MessageList.test.tsx @@ -287,9 +287,9 @@ describe("MessageList", () => { "这里是 Lime 的生成主执行面,可以继续进行中的创作、回看最近结果和旧历史。", ); expect(container.textContent).toContain( - "还没有生成任务时,从新建任务开始也很自然;跑过的结果、做法和中间素材后面都会继续留在这里。", + "还没开始生成时,从“开始生成”发起也很自然;跑过的结果、做法和中间素材后面都会继续留在这里。", ); - expect(container.textContent).toContain("左侧会继续显示正在推进的生成任务"); + expect(container.textContent).toContain("左侧会继续显示待继续的生成记录"); expect(container.textContent).toContain("最近结果会继续在这里回访"); expect(container.textContent).toContain( "常用做法和恢复中的会话会自动回到这里", diff --git a/src/components/agent/chat/components/MessageList.tsx b/src/components/agent/chat/components/MessageList.tsx index 8ec934113..c10bc8e67 100644 --- a/src/components/agent/chat/components/MessageList.tsx +++ b/src/components/agent/chat/components/MessageList.tsx @@ -1405,7 +1405,7 @@ const MessageListInner: React.FC = ({ 这里是 Lime 的生成主执行面,可以继续进行中的创作、回看最近结果和旧历史。

- 还没有生成任务时,从新建任务开始也很自然;跑过的结果、做法和中间素材后面都会继续留在这里。 + 还没开始生成时,从“开始生成”发起也很自然;跑过的结果、做法和中间素材后面都会继续留在这里。

@@ -1413,7 +1413,7 @@ const MessageListInner: React.FC = ({
- 左侧会继续显示正在推进的生成任务 + 左侧会继续显示待继续的生成记录 最近结果会继续在这里回访 diff --git a/src/components/agent/chat/components/TeamMemoryShadowCard.test.tsx b/src/components/agent/chat/components/TeamMemoryShadowCard.test.tsx index dbb05f5dc..5ed9086d5 100644 --- a/src/components/agent/chat/components/TeamMemoryShadowCard.test.tsx +++ b/src/components/agent/chat/components/TeamMemoryShadowCard.test.tsx @@ -82,11 +82,11 @@ describe("TeamMemoryShadowCard", () => { ).not.toBeNull(); expect(container.textContent).toContain("任务记忆影子"); expect(container.textContent).toContain( - "当前仓库已缓存 3 条任务续接上下文", + "当前仓库已缓存 3 条分工续接上下文", ); expect(container.textContent).toContain("/workspace/lime"); - 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("父会话上下文"); diff --git a/src/components/agent/chat/components/TeamMemoryShadowCard.tsx b/src/components/agent/chat/components/TeamMemoryShadowCard.tsx index 76c2f5080..1f666e54c 100644 --- a/src/components/agent/chat/components/TeamMemoryShadowCard.tsx +++ b/src/components/agent/chat/components/TeamMemoryShadowCard.tsx @@ -17,7 +17,7 @@ interface TeamMemoryDisplayEntry { const ENTRY_META: Record = { "team.selection": { - label: "当前任务方案", + label: "当前分工方案", icon: Users, }, "team.subagents": { @@ -88,10 +88,10 @@ export function TeamMemoryShadowCard({ 任务记忆影子
- 当前仓库已缓存 {entries.length} 条任务续接上下文 + 当前仓库已缓存 {entries.length} 条分工续接上下文

- 仅保存在当前仓库本地作用域,用来续接任务方案选择、子任务概览和父会话上下文。 + 仅保存在当前仓库本地作用域,用来续接分工方案选择、子任务概览和父会话上下文。

diff --git a/src/components/agent/chat/components/TeamWorkbenchSummaryPanel.test.tsx b/src/components/agent/chat/components/TeamWorkbenchSummaryPanel.test.tsx index 52eb4c41d..580c81adc 100644 --- a/src/components/agent/chat/components/TeamWorkbenchSummaryPanel.test.tsx +++ b/src/components/agent/chat/components/TeamWorkbenchSummaryPanel.test.tsx @@ -75,7 +75,7 @@ describe("TeamWorkbenchSummaryPanel", () => { expect(container.textContent).toContain("生成工作台"); expect(container.textContent).toContain("任务记忆影子"); expect(container.textContent).toContain("/workspace/lime"); - expect(container.textContent).toContain("当前任务方案"); + expect(container.textContent).toContain("当前分工方案"); expect(container.textContent).toContain("研究双人组"); }); @@ -118,7 +118,7 @@ describe("TeamWorkbenchSummaryPanel", () => { expect(container.textContent).toContain("任务分工已准备好"); expect(container.textContent).toContain("当前任务分工"); expect(container.textContent).toContain( - "当前任务方案已就绪。任务拆出后,这里会从方案视图过渡到当前进展。", + "当前分工方案已就绪。任务拆出后,这里会从方案视图过渡到当前进展。", ); expect(container.textContent).toContain("参考方案:代码排障团队"); }); diff --git a/src/components/agent/chat/components/TeamWorkspaceBoard.test.tsx b/src/components/agent/chat/components/TeamWorkspaceBoard.test.tsx index 367155d90..4427083fe 100644 --- a/src/components/agent/chat/components/TeamWorkspaceBoard.test.tsx +++ b/src/components/agent/chat/components/TeamWorkspaceBoard.test.tsx @@ -288,7 +288,7 @@ describe("TeamWorkspaceBoard", () => { ], }); - expect(container.textContent).toContain("临时修复任务方案"); + expect(container.textContent).toContain("临时修复分工方案"); expect(container.textContent).toContain("2 个计划分工"); const expandButton = container.querySelector( @@ -713,7 +713,7 @@ describe("TeamWorkspaceBoard", () => { ); const returnButton = Array.from(container.querySelectorAll("button")).find( - (element) => element.textContent?.includes("返回主任务"), + (element) => element.textContent?.includes("返回主助手"), ); expect(returnButton).toBeTruthy(); @@ -2104,7 +2104,7 @@ describe("TeamWorkspaceBoard", () => { expect(container.textContent).toContain("正在准备任务分工"); expect(container.textContent).toContain("准备中"); - expect(container.textContent).toContain("任务方案 · 排障任务方案"); + expect(container.textContent).toContain("分工方案 · 排障分工方案"); expect(container.textContent).toContain("参考方案 · 代码排障团队"); }); @@ -2184,7 +2184,7 @@ describe("TeamWorkspaceBoard", () => { expect(container.textContent).toContain("任务分工准备失败"); expect(container.textContent).toContain( - "Provider 认证失败,无法生成任务方案。", + "Provider 认证失败,无法生成分工方案。", ); }); }); diff --git a/src/components/agent/chat/components/TeamWorkspaceDock.test.tsx b/src/components/agent/chat/components/TeamWorkspaceDock.test.tsx index b227634f2..ebab932b8 100644 --- a/src/components/agent/chat/components/TeamWorkspaceDock.test.tsx +++ b/src/components/agent/chat/components/TeamWorkspaceDock.test.tsx @@ -175,9 +175,9 @@ describe("TeamWorkspaceDock", () => { }); 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("当前任务方案:前端联调团队"); + expect(document.body.textContent).toContain("当前分工方案:前端联调团队"); expect(document.body.textContent).toContain("分析、实现、验证三段式推进。"); expect(document.body.textContent).not.toContain("查看详情"); const emptyCard = document.body.querySelector( @@ -411,7 +411,7 @@ describe("TeamWorkspaceDock", () => { ); expect(toggleButton).toBeTruthy(); - expect(toggleButton?.textContent).toContain("修复任务方案"); + expect(toggleButton?.textContent).toContain("修复分工方案"); expect(toggleButton?.textContent).toContain("1 项处理中"); expect(toggleButton?.textContent).not.toContain("查看当前进展"); @@ -769,7 +769,7 @@ describe("TeamWorkspaceDock", () => { }); expect(document.body.textContent).toContain("任务分工已准备好"); - expect(document.body.textContent).toContain("修复任务方案"); + expect(document.body.textContent).toContain("修复分工方案"); expect(document.body.textContent).toContain("分析"); }); }); diff --git a/src/components/agent/chat/components/TeamWorkspaceDock.tsx b/src/components/agent/chat/components/TeamWorkspaceDock.tsx index 3a9892b51..a5cd9d990 100644 --- a/src/components/agent/chat/components/TeamWorkspaceDock.tsx +++ b/src/components/agent/chat/components/TeamWorkspaceDock.tsx @@ -927,7 +927,7 @@ export function TeamWorkspaceDock({ {dispatchPreviewState ? "查看当前任务分工与参考方案" - : "查看当前任务方案与分工"} + : "查看当前分工方案"}
{teamDetailExpanded ? ( diff --git a/src/components/agent/chat/components/team-workspace-board/TeamWorkspaceBoardHeader.tsx b/src/components/agent/chat/components/team-workspace-board/TeamWorkspaceBoardHeader.tsx index 3b1da33b0..5aee64cd5 100644 --- a/src/components/agent/chat/components/team-workspace-board/TeamWorkspaceBoardHeader.tsx +++ b/src/components/agent/chat/components/team-workspace-board/TeamWorkspaceBoardHeader.tsx @@ -120,7 +120,7 @@ export function TeamWorkspaceBoardHeader({ onClick={() => void onReturnToParentSession()} > - 返回主任务 + 返回主助手 ) : null} diff --git a/src/components/agent/chat/components/team-workspace-board/teamWorkspaceBoardPropBuilders.test.ts b/src/components/agent/chat/components/team-workspace-board/teamWorkspaceBoardPropBuilders.test.ts index 245cfc80f..0daa27b85 100644 --- a/src/components/agent/chat/components/team-workspace-board/teamWorkspaceBoardPropBuilders.test.ts +++ b/src/components/agent/chat/components/team-workspace-board/teamWorkspaceBoardPropBuilders.test.ts @@ -47,7 +47,7 @@ describe("teamWorkspaceBoardPropBuilders", () => { const headerProps = buildTeamWorkspaceBoardHeaderProps({ boardChromeDisplay: { - boardHeadline: "主任务总览", + boardHeadline: "主助手总览", boardHint: "主线程会持续汇总子任务进展。", compactBoardHeadline: "任务进行中 · 3 项处理中", compactToolbarChips: [], @@ -101,7 +101,7 @@ describe("teamWorkspaceBoardPropBuilders", () => { }, overviewChromeProps: { boardChromeDisplay: { - boardHeadline: "主任务总览", + boardHeadline: "主助手总览", boardHint: "主线程会持续汇总子任务进展。", compactBoardHeadline: "任务进行中 · 3 项处理中", compactToolbarChips: [], @@ -168,7 +168,7 @@ describe("teamWorkspaceBoardPropBuilders", () => { const props = buildTeamWorkspaceBoardCanvasSectionProps({ boardChromeDisplay: { boardHeadline: "任务进行中 · 1 项处理中 / 1 项稍后开始", - boardHint: "当前主任务会继续汇总进展。", + boardHint: "当前主助手会继续汇总进展。", compactBoardHeadline: "任务进行中 · 1 项处理中 / 1 项稍后开始", compactToolbarChips: [ { diff --git a/src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardShellProps.test.tsx b/src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardShellProps.test.tsx index a801337a9..a64d9fe7e 100644 --- a/src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardShellProps.test.tsx +++ b/src/components/agent/chat/components/team-workspace-board/useTeamWorkspaceBoardShellProps.test.tsx @@ -95,7 +95,7 @@ function renderHook(props?: Partial) { presentationState: { boardBodyClassName: "board-body", boardChromeDisplay: { - boardHeadline: "主任务总览", + boardHeadline: "主助手总览", boardHint: "主线程会持续汇总子任务进展。", compactBoardHeadline: "任务进行中 · 2 项处理中", compactToolbarChips: [], diff --git a/src/components/agent/chat/index.tsx b/src/components/agent/chat/index.tsx index 7d70937de..2eb4e4052 100644 --- a/src/components/agent/chat/index.tsx +++ b/src/components/agent/chat/index.tsx @@ -1,13 +1,17 @@ import { Suspense, lazy, useEffect } from "react"; import type { AgentChatWorkspaceProps } from "./agentChatWorkspaceContract"; +import { loadAgentChatWorkspaceModule } from "./agentChatWorkspaceLoader"; const WORKSPACE_PREFETCH_IDLE_TIMEOUT_MS = 1_500; const WORKSPACE_PREFETCH_FALLBACK_DELAY_MS = 180; - -const loadAgentChatWorkspace = () => import("./AgentChatWorkspace"); +const WORKSPACE_LOADING_FALLBACK = ( +
+ 正在准备生成工作区... +
+); const LazyAgentChatWorkspace = lazy(async () => { - const module = await loadAgentChatWorkspace(); + const module = await loadAgentChatWorkspaceModule(); return { default: module.AgentChatWorkspace }; }); @@ -73,7 +77,7 @@ export function AgentChatPage(props: AgentChatWorkspaceProps) { useEffect(() => { return scheduleWorkspacePrefetch(() => { - void loadAgentChatWorkspace(); + void loadAgentChatWorkspaceModule().catch(() => undefined); }); }, []); @@ -93,7 +97,7 @@ export function AgentChatPage(props: AgentChatWorkspaceProps) { ]); return ( - + ; - return ( - typeof record.skillId === "string" && - record.skillId.length > 0 && - typeof record.runId === "string" && - record.runId.length > 0 && - typeof record.status === "string" && - record.status.length > 0 && - typeof record.statusLabel === "string" && - record.statusLabel.length > 0 && - typeof record.tone === "string" && - typeof record.updatedAt === "number" && - Number.isFinite(record.updatedAt) - ); -} - -function emitCloudRunsChanged(): void { - if (!hasWindow()) { - return; - } - - window.dispatchEvent( - new CustomEvent(SERVICE_SKILL_CLOUD_RUNS_CHANGED_EVENT, { - detail: { - timestamp: Date.now(), - }, - }), - ); -} - -function persistCloudRuns( - records: ServiceSkillCloudRunRecord[], -): ServiceSkillCloudRunRecord[] { - if (!hasWindow()) { - return records; - } - - try { - window.localStorage.setItem( - SERVICE_SKILL_CLOUD_RUNS_STORAGE_KEY, - JSON.stringify(records), - ); - } catch { - // ignore write errors - } - - emitCloudRunsChanged(); - return records; -} - -export function listServiceSkillCloudRuns(): ServiceSkillCloudRunRecord[] { - if (!hasWindow()) { - return []; - } - - try { - const raw = window.localStorage.getItem( - SERVICE_SKILL_CLOUD_RUNS_STORAGE_KEY, - ); - if (!raw) { - return []; - } - - const parsed = JSON.parse(raw); - if (!Array.isArray(parsed)) { - return []; - } - - return parsed - .filter(isValidCloudRunRecord) - .sort((left, right) => right.updatedAt - left.updatedAt); - } catch { - return []; - } -} - -export function getServiceSkillCloudRunStatusMap(): Record< - string, - ServiceSkillCloudRunStatus -> { - return listServiceSkillCloudRuns().reduce< - Record - >((result, record) => { - result[record.skillId] = { - runId: record.runId, - statusLabel: record.statusLabel, - tone: record.tone, - detail: record.detail, - updatedAt: record.updatedAt, - }; - return result; - }, {}); -} - -export function recordServiceSkillCloudRun( - skillId: string, - run: ServiceSkillRun, -): ServiceSkillCloudRunRecord[] { - const normalizedSkillId = skillId.trim(); - const normalizedRunId = run.id.trim(); - if (!normalizedSkillId || !normalizedRunId) { - return listServiceSkillCloudRuns(); - } - - const nextRecord: ServiceSkillCloudRunRecord = { - skillId: normalizedSkillId, - runId: normalizedRunId, - status: run.status, - statusLabel: resolveCloudRunStatusLabel(run.status), - tone: resolveCloudRunStatusTone(run.status), - detail: resolveCloudRunDetail(run), - updatedAt: - parseTimestamp(run.updatedAt) || - parseTimestamp(run.finishedAt) || - parseTimestamp(run.startedAt) || - Date.now(), - }; - - const nextRecords = [ - nextRecord, - ...listServiceSkillCloudRuns().filter( - (record) => record.skillId !== nextRecord.skillId, - ), - ]; - - return persistCloudRuns(nextRecords); -} - -export function subscribeServiceSkillCloudRunsChanged( - callback: () => void, -): () => void { - if (!hasWindow()) { - return () => undefined; - } - - const customEventHandler = () => { - callback(); - }; - - const storageHandler = (event: StorageEvent) => { - if (event.key !== SERVICE_SKILL_CLOUD_RUNS_STORAGE_KEY) { - return; - } - callback(); - }; - - window.addEventListener( - SERVICE_SKILL_CLOUD_RUNS_CHANGED_EVENT, - customEventHandler, - ); - window.addEventListener("storage", storageHandler); - - return () => { - window.removeEventListener( - SERVICE_SKILL_CLOUD_RUNS_CHANGED_EVENT, - customEventHandler, - ); - window.removeEventListener("storage", storageHandler); - }; -} diff --git a/src/components/agent/chat/service-skills/homeEntrySkills.ts b/src/components/agent/chat/service-skills/homeEntrySkills.ts index 50a553550..c3e9495d1 100644 --- a/src/components/agent/chat/service-skills/homeEntrySkills.ts +++ b/src/components/agent/chat/service-skills/homeEntrySkills.ts @@ -30,17 +30,19 @@ interface ListFeaturedHomeServiceSkillsOptions { const DEFAULT_FEATURED_HOME_SERVICE_SKILL_LIMIT = 2; +function normalizeHomeServiceSkillBinding( + binding: ServiceSkillExecutorBinding, +): Exclude { + return binding === "cloud_scene" ? "agent_turn" : binding; +} + function resolveServiceSkillExecutionKind( skill: Pick< ServiceSkillItem, "defaultExecutorBinding" | "executionLocation" >, ): SkillCatalogExecutionKind { - if (skill.executionLocation === "cloud_required") { - return "cloud_scene"; - } - - switch (skill.defaultExecutorBinding) { + switch (normalizeHomeServiceSkillBinding(skill.defaultExecutorBinding)) { case "browser_assist": return "site_adapter"; case "automation_job": @@ -49,8 +51,6 @@ function resolveServiceSkillExecutionKind( return "native_skill"; case "agent_turn": return "agent_turn"; - case "cloud_scene": - return "cloud_scene"; default: return "agent_turn"; } @@ -70,7 +70,6 @@ function buildSeededHomeServiceSkillItem( runnerDescription: getServiceSkillRunnerDescription(skill), actionLabel: getServiceSkillActionLabel(skill), automationStatus: null, - cloudStatus: null, }; } diff --git a/src/components/agent/chat/service-skills/promptComposer.ts b/src/components/agent/chat/service-skills/promptComposer.ts index 1f39f551b..a06bfd9a4 100644 --- a/src/components/agent/chat/service-skills/promptComposer.ts +++ b/src/components/agent/chat/service-skills/promptComposer.ts @@ -23,7 +23,7 @@ const RUNNER_LABELS = { const EXECUTION_LOCATION_LABELS = { client_default: "客户端默认执行", - cloud_required: "服务端特例执行", + cloud_required: "客户端执行(兼容旧目录标记)", } as const; function resolvePromptTemplateKey( diff --git a/src/components/agent/chat/service-skills/serviceSkillLaunchPrefill.test.ts b/src/components/agent/chat/service-skills/serviceSkillLaunchPrefill.test.ts index 58ccf5153..d260abe7c 100644 --- a/src/components/agent/chat/service-skills/serviceSkillLaunchPrefill.test.ts +++ b/src/components/agent/chat/service-skills/serviceSkillLaunchPrefill.test.ts @@ -1,7 +1,10 @@ import { beforeEach, describe, expect, it } from "vitest"; import { recordSlashEntryUsage } from "../skill-selection/slashEntryUsage"; import type { CreationReplayMetadata } from "../utils/creationReplayMetadata"; -import { resolveServiceSkillLaunchPrefill } from "./serviceSkillLaunchPrefill"; +import { + buildServiceSkillLaunchPrefillSummary, + resolveServiceSkillLaunchPrefill, +} from "./serviceSkillLaunchPrefill"; import { recordServiceSkillUsage } from "./storage"; import type { ServiceSkillHomeItem } from "./types"; @@ -43,7 +46,6 @@ function createResearchSkill(): ServiceSkillHomeItem { runnerDescription: "会直接在当前工作区生成首版结果。", actionLabel: "对话内补参", automationStatus: null, - cloudStatus: null, groupKey: "general", }; } @@ -111,6 +113,7 @@ describe("resolveServiceSkillLaunchPrefill", () => { target_duration: "120 秒", ignored_empty: " ", }, + launchUserInput: " 保留更强的团队协作视角 ", }); expect( @@ -122,10 +125,39 @@ describe("resolveServiceSkillLaunchPrefill", () => { article_source: "上次沉淀的文章摘要", target_duration: "120 秒", }, + launchUserInput: "保留更强的团队协作视角", hint: "已根据你上次成功执行 深度研究 时的参数自动预填,可继续修改后执行。", }); }); + it("summary 应同时显影上次填写与上次补充", () => { + expect( + buildServiceSkillLaunchPrefillSummary({ + skill: createResearchSkill(), + slotValues: { + article_source: "上次沉淀的文章摘要", + target_duration: "120 秒", + }, + launchUserInput: + "保留更强的团队协作视角,并压缩成一版更利于分享的总结。", + }), + ).toBe( + "上次填写:文章链接/正文=上次沉淀的文章摘要;目标时长=120 秒 · 上次补充:保留更强的团队协作视角,并压缩成一版更利于分享的总结。", + ); + }); + + it("launchUserInput 与已显影槽位重复时,不应再重复显示上次补充", () => { + expect( + buildServiceSkillLaunchPrefillSummary({ + skill: createResearchSkill(), + slotValues: { + article_source: "上次沉淀的文章摘要", + }, + launchUserInput: "上次沉淀的文章摘要", + }), + ).toBe("上次填写:文章链接/正文=上次沉淀的文章摘要"); + }); + it("creation replay 命中时应覆盖最近成功参数", () => { recordServiceSkillUsage({ skillId: "service-skill-1", diff --git a/src/components/agent/chat/service-skills/serviceSkillLaunchPrefill.ts b/src/components/agent/chat/service-skills/serviceSkillLaunchPrefill.ts index 03170c220..52983e530 100644 --- a/src/components/agent/chat/service-skills/serviceSkillLaunchPrefill.ts +++ b/src/components/agent/chat/service-skills/serviceSkillLaunchPrefill.ts @@ -13,9 +13,19 @@ import type { export interface ServiceSkillLaunchPrefillResult { slotValues?: ServiceSkillSlotValues; + launchUserInput?: string; hint?: string; } +function summarizePrefillValue(value: string, maxLength = 32): string { + const normalized = value.replace(/\s+/g, " ").trim(); + if (normalized.length <= maxLength) { + return normalized; + } + + return `${normalized.slice(0, maxLength).trimEnd()}...`; +} + function normalizeOptionalText(value: unknown): string | undefined { if (typeof value !== "string") { return undefined; @@ -41,17 +51,65 @@ function compactSlotValues( return Object.keys(nextValues).length > 0 ? nextValues : undefined; } +export function buildServiceSkillLaunchPrefillSummary(params: { + skill: Pick; + slotValues?: ServiceSkillSlotValues; + launchUserInput?: string; + limit?: number; +}): string { + const limit = params.limit ?? 2; + const summaryItems = params.skill.slotSchema + .map((slot) => { + const value = compactSlotValues(params.slotValues)?.[slot.key]; + if (!value) { + return null; + } + + return `${slot.label}=${summarizePrefillValue(value)}`; + }) + .filter((item): item is string => Boolean(item)); + const launchUserInput = normalizeOptionalText(params.launchUserInput); + const hasDuplicatedLaunchUserInput = launchUserInput + ? Object.values(compactSlotValues(params.slotValues) ?? {}).some( + (value) => value === launchUserInput, + ) + : false; + + if (summaryItems.length === 0 && !launchUserInput) { + return ""; + } + + const segments: string[] = []; + + if (summaryItems.length > 0) { + const visibleItems = summaryItems.slice(0, limit); + segments.push( + `上次填写:${visibleItems.join(";")}${ + summaryItems.length > limit ? ` 等 ${summaryItems.length} 项` : "" + }`, + ); + } + + if (launchUserInput && !hasDuplicatedLaunchUserInput) { + segments.push(`上次补充:${summarizePrefillValue(launchUserInput, 40)}`); + } + + return segments.join(" · "); +} + function resolveRecentServiceSkillPrefill( skill: ServiceSkillHomeItem, ): ServiceSkillLaunchPrefillResult | undefined { const recentUsage = getServiceSkillUsageMap().get(skill.id); const slotValues = compactSlotValues(recentUsage?.slotValues); - if (!slotValues) { + const launchUserInput = normalizeOptionalText(recentUsage?.launchUserInput); + if (!slotValues && !launchUserInput) { return undefined; } return { - slotValues, + ...(slotValues ? { slotValues } : {}), + ...(launchUserInput ? { launchUserInput } : {}), hint: `已根据你上次成功执行 ${skill.title} 时的参数自动预填,可继续修改后执行。`, }; } @@ -118,6 +176,7 @@ export function resolveServiceSkillLaunchPrefill(params: { return { slotValues, + launchUserInput: recentServicePrefill?.launchUserInput, hint: creationReplayPrefill?.hint ?? recentServicePrefill?.hint ?? diff --git a/src/components/agent/chat/service-skills/skillPresentation.ts b/src/components/agent/chat/service-skills/skillPresentation.ts index 7b02b3d00..419bd4c1a 100644 --- a/src/components/agent/chat/service-skills/skillPresentation.ts +++ b/src/components/agent/chat/service-skills/skillPresentation.ts @@ -175,9 +175,6 @@ export function buildServiceSkillCapabilityDescription( } export function getServiceSkillRunnerLabel(item: ServiceSkillItem): string { - if (item.executionLocation === "cloud_required") { - return "云端执行"; - } if (resolveServiceSkillType(item) === "site") { return "浏览器采集"; } @@ -185,20 +182,14 @@ export function getServiceSkillRunnerLabel(item: ServiceSkillItem): string { } export function getServiceSkillRunnerTone( - item: Pick, + item: Pick, ): ServiceSkillTone { - if (item.executionLocation === "cloud_required") { - return "slate"; - } return RUNNER_TONES[item.runnerType]; } export function getServiceSkillRunnerDescription( item: ServiceSkillItem, ): string { - if (item.executionLocation === "cloud_required") { - return "会交给云端处理,完成后再把结果回流到当前工作区。"; - } if (resolveServiceSkillType(item) === "site") { return "会复用当前浏览器里的真实登录态执行站点任务,并优先把结果沉淀到当前工作区。"; } @@ -206,9 +197,6 @@ export function getServiceSkillRunnerDescription( } export function getServiceSkillActionLabel(item: ServiceSkillItem): string { - if (item.executionLocation === "cloud_required") { - return "云端执行"; - } if (resolveServiceSkillType(item) === "site") { if (hasRequiredSlots(item)) { return "对话内补参"; @@ -233,10 +221,6 @@ export function getServiceSkillOutputDestination( return outputDestinationFromBundle; } - if (item.executionLocation === "cloud_required") { - return "运行结果会在云端完成后回流到当前工作区。"; - } - if (isServiceSkillExecutableAsSiteAdapter(item)) { return item.siteCapabilityBinding.saveMode === "project_resource" ? "结果会沉淀为当前项目资源,方便后续复用。" @@ -282,9 +266,6 @@ export function getServiceSkillPrimaryActionLabel( if (canCreateAutomation) { return "创建任务并进入工作区"; } - if (skill.executionLocation === "cloud_required") { - return "云端执行"; - } if (resolveServiceSkillType(skill) === "site") { if (hasRequiredSlots(skill)) { return "对话内补参"; diff --git a/src/components/agent/chat/service-skills/storage.ts b/src/components/agent/chat/service-skills/storage.ts index 99d935b0a..6400d4908 100644 --- a/src/components/agent/chat/service-skills/storage.ts +++ b/src/components/agent/chat/service-skills/storage.ts @@ -6,6 +6,8 @@ import type { const SERVICE_SKILL_USAGE_STORAGE_KEY = "lime:service-skill-usage:v1"; const MAX_SERVICE_SKILL_USAGE_RECORDS = 12; +export const SERVICE_SKILL_USAGE_CHANGED_EVENT = + "lime:service-skill-usage-changed"; function normalizeOptionalText(value: unknown): string | undefined { if (typeof value !== "string") { @@ -50,12 +52,14 @@ function readUsageRecord(value: unknown): ServiceSkillUsageRecord | null { } const slotValues = normalizeServiceSkillSlotValues(record.slotValues); + const launchUserInput = normalizeOptionalText(record.launchUserInput); return { skillId, usedAt: record.usedAt, runnerType: runnerType as ServiceSkillUsageRecord["runnerType"], ...(slotValues ? { slotValues } : {}), + ...(launchUserInput ? { launchUserInput } : {}), }; } @@ -94,15 +98,58 @@ export function getServiceSkillUsageMap(): Map< ); } +function emitServiceSkillUsageChanged(): void { + if (typeof window === "undefined") { + return; + } + + window.dispatchEvent(new CustomEvent(SERVICE_SKILL_USAGE_CHANGED_EVENT)); +} + +export function subscribeServiceSkillUsageChanged( + callback: () => void, +): () => void { + if (typeof window === "undefined") { + return () => undefined; + } + + const customEventHandler = () => { + callback(); + }; + + const storageHandler = (event: StorageEvent) => { + if (event.key !== SERVICE_SKILL_USAGE_STORAGE_KEY) { + return; + } + callback(); + }; + + window.addEventListener( + SERVICE_SKILL_USAGE_CHANGED_EVENT, + customEventHandler, + ); + window.addEventListener("storage", storageHandler); + + return () => { + window.removeEventListener( + SERVICE_SKILL_USAGE_CHANGED_EVENT, + customEventHandler, + ); + window.removeEventListener("storage", storageHandler); + }; +} + export function recordServiceSkillUsage( input: RecordServiceSkillUsageInput, ): ServiceSkillUsageRecord[] { const slotValues = normalizeServiceSkillSlotValues(input.slotValues); + const launchUserInput = normalizeOptionalText(input.launchUserInput); const nextRecord: ServiceSkillUsageRecord = { skillId: input.skillId, usedAt: input.usedAt ?? Date.now(), runnerType: input.runnerType, ...(slotValues ? { slotValues } : {}), + ...(launchUserInput ? { launchUserInput } : {}), }; const nextRecords = [ @@ -121,6 +168,7 @@ export function recordServiceSkillUsage( SERVICE_SKILL_USAGE_STORAGE_KEY, JSON.stringify(nextRecords), ); + emitServiceSkillUsageChanged(); } catch { // ignore write errors } diff --git a/src/components/agent/chat/service-skills/types.ts b/src/components/agent/chat/service-skills/types.ts index f1839f015..3cd3954c9 100644 --- a/src/components/agent/chat/service-skills/types.ts +++ b/src/components/agent/chat/service-skills/types.ts @@ -65,14 +65,6 @@ export interface ServiceSkillAutomationLinkRecord { linkedAt: number; } -export interface ServiceSkillCloudRunStatus { - runId: string; - statusLabel: string; - tone: ServiceSkillTone; - detail: string | null; - updatedAt: number; -} - export interface ServiceSkillHomeItem extends ServiceSkillItem { groupKey?: string; executionKind?: SkillCatalogExecutionKind; @@ -84,7 +76,6 @@ export interface ServiceSkillHomeItem extends ServiceSkillItem { runnerDescription: string; actionLabel: string; automationStatus: ServiceSkillAutomationStatus | null; - cloudStatus?: ServiceSkillCloudRunStatus | null; } export interface ServiceSkillCatalogMeta { @@ -102,6 +93,7 @@ export interface ServiceSkillUsageRecord { usedAt: number; runnerType: ServiceSkillRunnerType; slotValues?: ServiceSkillSlotValues; + launchUserInput?: string; } export interface RecordServiceSkillUsageInput { @@ -109,4 +101,5 @@ export interface RecordServiceSkillUsageInput { usedAt?: number; runnerType: ServiceSkillRunnerType; slotValues?: ServiceSkillSlotValues; + launchUserInput?: string; } diff --git a/src/components/agent/chat/service-skills/useServiceSkills.test.tsx b/src/components/agent/chat/service-skills/useServiceSkills.test.tsx index 0625d4e2d..2909892a9 100644 --- a/src/components/agent/chat/service-skills/useServiceSkills.test.tsx +++ b/src/components/agent/chat/service-skills/useServiceSkills.test.tsx @@ -8,7 +8,7 @@ import { type SkillCatalog, } from "@/lib/api/skillCatalog"; import { recordServiceSkillAutomationLink } from "./automationLinkStorage"; -import { recordServiceSkillCloudRun } from "./cloudRunStorage"; +import { recordServiceSkillUsage } from "./storage"; import { useServiceSkills } from "./useServiceSkills"; interface HookHarness { @@ -74,16 +74,15 @@ function buildCloudCatalog(): SkillCatalog { { ...seeded.items[1]!, id: "cloud-video-dubbing", - title: "云端视频配音", - summary: - "把参考视频与文案提交到 OEM 云端执行,并把结果回流到本地工作区。", + title: "视频配音", + summary: "围绕参考视频与文案整理一版本地可继续加工的配音稿。", executionLocation: "cloud_required", defaultExecutorBinding: "cloud_scene", themeTarget: "general", version: "tenant-2026-03-30", groupKey: "general", execution: { - kind: "cloud_scene", + kind: "agent_turn", }, }, ], @@ -409,7 +408,7 @@ describe("useServiceSkills", () => { } }); - it("cloud_required 技能状态变更后应回灌到首页技能列表", async () => { + it("legacy cloud_required 技能进入首页后也应按本地执行语义展示", async () => { const harness = mountHook(); try { @@ -421,37 +420,51 @@ describe("useServiceSkills", () => { await flushEffects(); - act(() => { - recordServiceSkillCloudRun("cloud-video-dubbing", { - id: "cloud-run-1", - status: "success", - outputSummary: "云端结果已生成", - finishedAt: "2026-03-30T12:03:00.000Z", - updatedAt: "2026-03-30T12:03:00.000Z", - }); - }); - - await flushEffects(); - - expect( - harness - .getValue() - .skills.find((skill) => skill.id === "cloud-video-dubbing") - ?.cloudStatus, - ).toEqual( - expect.objectContaining({ - runId: "cloud-run-1", - statusLabel: "成功", - tone: "emerald", - detail: "云端结果已生成", - }), - ); expect( harness .getValue() .skills.find((skill) => skill.id === "cloud-video-dubbing") ?.runnerLabel, - ).toBe("云端执行"); + ).toBe("立即开始"); + } finally { + harness.unmount(); + } + }); + + it("外部写入 service skill usage 后应即时刷新 recent 排序与徽标", async () => { + const harness = mountHook(); + + try { + await flushEffects(); + + const targetSkill = harness.getValue().skills[1]; + expect(targetSkill).toBeTruthy(); + expect(targetSkill?.recentUsedAt).toBeNull(); + expect(targetSkill?.badge).not.toBe("最近使用"); + + act(() => { + recordServiceSkillUsage({ + skillId: targetSkill!.id, + runnerType: targetSkill!.runnerType, + slotValues: { + article_source: "沿着这条增长主线继续展开", + }, + }); + }); + + await flushEffects(); + + expect(harness.getValue().skills[0]?.id).toBe(targetSkill?.id); + expect( + harness.getValue().skills.find((skill) => skill.id === targetSkill?.id), + ).toEqual( + expect.objectContaining({ + id: targetSkill?.id, + badge: "最近使用", + isRecent: true, + recentUsedAt: expect.any(Number), + }), + ); } finally { harness.unmount(); } diff --git a/src/components/agent/chat/service-skills/useServiceSkills.ts b/src/components/agent/chat/service-skills/useServiceSkills.ts index 3a22a77f3..c5a706f1a 100644 --- a/src/components/agent/chat/service-skills/useServiceSkills.ts +++ b/src/components/agent/chat/service-skills/useServiceSkills.ts @@ -15,10 +15,6 @@ import { resolveServiceSkillAutomationLinks, subscribeServiceSkillAutomationLinksChanged, } from "./automationLinkStorage"; -import { - getServiceSkillCloudRunStatusMap, - subscribeServiceSkillCloudRunsChanged, -} from "./cloudRunStorage"; import { getServiceSkillActionLabel, getServiceSkillRunnerDescription, @@ -28,12 +24,15 @@ import { import { shouldExposeServiceSkillHomeItem } from "./homeEntrySkills"; import { scheduleMinimumDelayIdleTask } from "@/lib/utils/scheduleMinimumDelayIdleTask"; import { supportsServiceSkillLocalAutomation } from "./automationDraft"; -import { getServiceSkillUsageMap, recordServiceSkillUsage } from "./storage"; +import { + getServiceSkillUsageMap, + recordServiceSkillUsage, + subscribeServiceSkillUsageChanged, +} from "./storage"; import type { RecordServiceSkillUsageInput, ServiceSkillAutomationStatus, ServiceSkillCatalogMeta, - ServiceSkillCloudRunStatus, ServiceSkillHomeItem, } from "./types"; @@ -52,7 +51,6 @@ function getSkillBadge(item: SkillCatalogItem, isRecent: boolean): string { function buildHomeItems( items: SkillCatalogItem[], automationStatusMap: Record, - cloudRunStatusMap: Record, ): ServiceSkillHomeItem[] { const usageMap = getServiceSkillUsageMap(); const mapped: Array = @@ -73,10 +71,6 @@ function buildHomeItems( runnerDescription: getServiceSkillRunnerDescription(item), actionLabel: getServiceSkillActionLabel(item), automationStatus: automationStatusMap[item.id] ?? null, - cloudStatus: - item.executionLocation === "cloud_required" - ? (cloudRunStatusMap[item.id] ?? null) - : null, _sortIndex: index, }; }); @@ -142,9 +136,6 @@ export function useServiceSkills( const [automationStatusMap, setAutomationStatusMap] = useState< Record >({}); - const [cloudRunStatusMap, setCloudRunStatusMap] = useState< - Record - >({}); const [catalogMeta, setCatalogMeta] = useState(null); const [isLoading, setIsLoading] = useState(enabled); @@ -275,26 +266,6 @@ export function useServiceSkills( }; }, [enabled, loadCurrentCatalog]); - useEffect(() => { - if (!enabled) { - setCloudRunStatusMap({}); - return; - } - - const syncCloudRuns = () => { - setCloudRunStatusMap(getServiceSkillCloudRunStatusMap()); - }; - - syncCloudRuns(); - const unsubscribeCloudRuns = subscribeServiceSkillCloudRunsChanged(() => { - syncCloudRuns(); - }); - - return () => { - unsubscribeCloudRuns(); - }; - }, [enabled]); - useEffect(() => { if (!enabled || automationLinkCount === 0) { return; @@ -309,18 +280,27 @@ export function useServiceSkills( }; }, [automationLinkCount, enabled, refresh]); + useEffect(() => { + if (!enabled) { + return; + } + + return subscribeServiceSkillUsageChanged(() => { + setUsageVersion((previous) => previous + 1); + }); + }, [enabled]); + const recordUsageAndRefresh = useCallback( (input: RecordServiceSkillUsageInput) => { recordServiceSkillUsage(input); - setUsageVersion((previous) => previous + 1); }, [], ); const skills = useMemo(() => { void usageVersion; - return buildHomeItems(items, automationStatusMap, cloudRunStatusMap); - }, [items, usageVersion, automationStatusMap, cloudRunStatusMap]); + return buildHomeItems(items, automationStatusMap); + }, [items, usageVersion, automationStatusMap]); return { skills, diff --git a/src/components/agent/chat/skill-selection/CharacterMention.test.tsx b/src/components/agent/chat/skill-selection/CharacterMention.test.tsx index a046b7874..7d985537e 100644 --- a/src/components/agent/chat/skill-selection/CharacterMention.test.tsx +++ b/src/components/agent/chat/skill-selection/CharacterMention.test.tsx @@ -17,6 +17,7 @@ import type { ServiceSkillGroup, ServiceSkillHomeItem, } from "@/components/agent/chat/service-skills/types"; +import { recordServiceSkillUsage } from "@/components/agent/chat/service-skills/storage"; import type { BuiltinInputCommand, RuntimeSceneSlashCommand, @@ -1457,9 +1458,35 @@ describe("CharacterMention", () => { }); it("最近使用的服务技能应优先显示在独立分组,且不在技能组里重复", async () => { + act(() => { + recordServiceSkillUsage({ + skillId: "recent-trend-briefing", + runnerType: "scheduled", + slotValues: { + platform_focus: "X + TikTok", + keyword_focus: "AI 内容创作", + }, + }); + }); const recentSkill = createServiceSkill({ id: "recent-trend-briefing", title: "最近趋势摘要", + slotSchema: [ + { + key: "platform_focus", + label: "关注平台", + type: "text", + required: true, + placeholder: "例如 X + TikTok", + }, + { + key: "keyword_focus", + label: "关键词", + type: "text", + required: true, + placeholder: "例如 AI 内容创作", + }, + ], recentUsedAt: 1_712_345_678_000, isRecent: true, }); @@ -1476,6 +1503,9 @@ describe("CharacterMention", () => { expect(document.body.textContent).toContain("最近使用"); expect(document.body.textContent).toContain("推荐技能"); + expect(document.body.textContent).toContain( + "上次填写:关注平台=X + TikTok;关键词=AI 内容创作", + ); const recentButtons = Array.from( document.body.querySelectorAll("button"), @@ -1510,6 +1540,29 @@ describe("CharacterMention", () => { expect(recentCommandButtons).toHaveLength(1); }); + it("@ 面板打开后新增内建命令 recent usage 时,应即时刷新最近使用分组", async () => { + const container = renderHarness(); + const textarea = getTextarea(container); + + await typeAtAndWait(textarea); + + expect(document.body.textContent).not.toContain("最近使用"); + + await act(async () => { + recordMentionEntryUsage({ + kind: "builtin_command", + entryId: "research", + usedAt: 1_712_345_678_901, + replayText: "关键词:AI Agent 融资 站点:36Kr", + }); + await Promise.resolve(); + }); + + expect(document.body.textContent).toContain("最近使用"); + expect(document.body.textContent).toContain("@搜索"); + expect(document.body.textContent).toContain("上次输入:关键词:AI Agent 融资"); + }); + it("选择最近使用的 @命令时应回填上次成功草稿", async () => { const replayText = "关键词:AI Agent 融资 站点:36Kr"; act(() => { @@ -2458,7 +2511,7 @@ describe("CharacterMention", () => { }); expect(document.body.textContent).toContain( - "已选择 1 条参考基线,本轮会一起带入生成。", + "已选择 1 条参考对象,本轮会一起带入生成。", ); }); @@ -2757,6 +2810,12 @@ describe("CharacterMention", () => { document.body.querySelectorAll("button"), ).find((button) => button.textContent?.includes("内容主稿生成")); expect(recentCuratedTaskButton).toBeTruthy(); + expect(recentCuratedTaskButton?.textContent).toContain( + "上次填写:主题或产品信息=上次的品牌 campaign 主线;目标受众=海外增长负责人", + ); + expect(recentCuratedTaskButton?.textContent).toContain( + "参考:上次 campaign 参考", + ); await act(async () => { recentCuratedTaskButton?.click(); @@ -2777,7 +2836,7 @@ describe("CharacterMention", () => { expect(subjectInput?.value).toBe("上次的品牌 campaign 主线"); expect(audienceInput?.value).toBe("海外增长负责人"); expect(document.body.textContent).toContain( - "已选择 1 条参考基线,本轮会一起带入生成。", + "已选择 1 条参考对象,本轮会一起带入生成。", ); }); diff --git a/src/components/agent/chat/skill-selection/CharacterMention.tsx b/src/components/agent/chat/skill-selection/CharacterMention.tsx index 2a6c1155c..8caf33adb 100644 --- a/src/components/agent/chat/skill-selection/CharacterMention.tsx +++ b/src/components/agent/chat/skill-selection/CharacterMention.tsx @@ -54,12 +54,14 @@ import { recordSlashEntryUsage, subscribeSlashEntryUsageChanged, } from "./slashEntryUsage"; +import { subscribeMentionEntryUsageChanged } from "./mentionEntryUsage"; import { partitionMentionableSkills } from "./skillQuery"; import { useRuntimeInputCapabilityCatalog } from "./runtimeInputCapabilityCatalog"; import { useIdleModulePreload } from "./useIdleModulePreload"; import { buildCuratedTaskLaunchPrompt, recordCuratedTaskTemplateUsage, + subscribeCuratedTaskTemplateUsageChanged, type CuratedTaskInputValues, type CuratedTaskTemplateItem, } from "../utils/curatedTaskTemplates"; @@ -243,8 +245,6 @@ function toServiceSkillHomeItem(skill: ServiceSkillItem): ServiceSkillHomeItem { runnerDescription: getServiceSkillRunnerDescription(skill), actionLabel: getServiceSkillActionLabel(skill), automationStatus: null, - cloudStatus: - skill.executionLocation === "cloud_required" ? null : undefined, }; } @@ -285,6 +285,9 @@ export function CharacterMention({ const panelRef = useRef(null); const [curatedTaskRecommendationSignalsVersion, setCuratedTaskRecommendationSignalsVersion] = useState(0); + const [curatedTaskTemplatesVersion, setCuratedTaskTemplatesVersion] = + useState(0); + const [mentionEntryUsageVersion, setMentionEntryUsageVersion] = useState(0); const [slashEntryUsageVersion, setSlashEntryUsageVersion] = useState(0); const { builtinCommands: runtimeBuiltinCommands, sceneCommands: runtimeSceneCommands } = useRuntimeInputCapabilityCatalog(); @@ -430,12 +433,24 @@ export function CharacterMention({ }; }, [inputRef, showMentions]); + useEffect(() => { + return subscribeCuratedTaskTemplateUsageChanged(() => { + setCuratedTaskTemplatesVersion((previous) => previous + 1); + }); + }, []); + useEffect(() => { return subscribeCuratedTaskRecommendationSignalsChanged(() => { setCuratedTaskRecommendationSignalsVersion((previous) => previous + 1); }); }, []); + useEffect(() => { + return subscribeMentionEntryUsageChanged(() => { + setMentionEntryUsageVersion((previous) => previous + 1); + }); + }, []); + useEffect(() => { return subscribeSlashEntryUsageChanged(() => { setSlashEntryUsageVersion((previous) => previous + 1); @@ -1131,9 +1146,11 @@ export function CharacterMention({ projectId={projectId} sessionId={sessionId} referenceEntries={defaultCuratedTaskReferenceEntries} + curatedTaskTemplatesVersion={curatedTaskTemplatesVersion} curatedTaskRecommendationSignalsVersion={ curatedTaskRecommendationSignalsVersion } + mentionEntryUsageVersion={mentionEntryUsageVersion} slashEntryUsageVersion={slashEntryUsageVersion} commandRef={commandRef} onQueryChange={setMentionQuery} diff --git a/src/components/agent/chat/skill-selection/CharacterMentionPanel.tsx b/src/components/agent/chat/skill-selection/CharacterMentionPanel.tsx index 98cee6330..cb1f7ca2d 100644 --- a/src/components/agent/chat/skill-selection/CharacterMentionPanel.tsx +++ b/src/components/agent/chat/skill-selection/CharacterMentionPanel.tsx @@ -48,7 +48,9 @@ interface CharacterMentionPanelProps { projectId?: string | null; sessionId?: string | null; referenceEntries?: CuratedTaskReferenceEntry[]; + curatedTaskTemplatesVersion?: number; curatedTaskRecommendationSignalsVersion?: number; + mentionEntryUsageVersion?: number; slashEntryUsageVersion?: number; commandRef: React.RefObject; onQueryChange: (query: string) => void; @@ -97,7 +99,9 @@ export const CharacterMentionPanel: React.FC = ({ projectId, sessionId, referenceEntries = [], + curatedTaskTemplatesVersion = 0, curatedTaskRecommendationSignalsVersion = 0, + mentionEntryUsageVersion = 0, slashEntryUsageVersion = 0, commandRef, onQueryChange, @@ -113,7 +117,9 @@ export const CharacterMentionPanel: React.FC = ({ }) => { const sections = React.useMemo( () => { + void curatedTaskTemplatesVersion; void curatedTaskRecommendationSignalsVersion; + void mentionEntryUsageVersion; void slashEntryUsageVersion; return buildInputCapabilitySections({ mode, @@ -134,9 +140,11 @@ export const CharacterMentionPanel: React.FC = ({ [ availableSkills, builtinCommands, + curatedTaskTemplatesVersion, curatedTaskRecommendationSignalsVersion, filteredCharacters, installedSkills, + mentionEntryUsageVersion, mentionQuery, mentionServiceSkills, mode, diff --git a/src/components/agent/chat/skill-selection/inputCapabilitySections.ts b/src/components/agent/chat/skill-selection/inputCapabilitySections.ts index f6ae2b819..010b55bde 100644 --- a/src/components/agent/chat/skill-selection/inputCapabilitySections.ts +++ b/src/components/agent/chat/skill-selection/inputCapabilitySections.ts @@ -2,6 +2,10 @@ import type { Character } from "@/lib/api/memory"; import type { Skill } from "@/lib/api/skills"; import { buildServiceSkillRecommendationBuckets } from "@/components/agent/chat/service-skills/recommendedServiceSkills"; import { buildServiceSkillCapabilityDescription } from "@/components/agent/chat/service-skills/skillPresentation"; +import { + buildServiceSkillLaunchPrefillSummary, + resolveServiceSkillLaunchPrefill, +} from "@/components/agent/chat/service-skills/serviceSkillLaunchPrefill"; import type { ServiceSkillGroup, ServiceSkillHomeItem, @@ -23,6 +27,7 @@ import { type SlashEntryUsageKind, } from "./slashEntryUsage"; import { + buildCuratedTaskRecentUsageDescription, buildCuratedTaskCapabilityDescription, filterCuratedTaskTemplates, listFeaturedHomeCuratedTaskTemplates, @@ -356,12 +361,24 @@ function buildMentionCapabilitySections( continue; } + const recentPrefill = resolveServiceSkillLaunchPrefill({ + skill, + }); visibleRecentMentionEntries.push({ key: `service-skill:${skill.id}`, kind: "service_skill", kindLabel: "技能", title: skill.title, - description: buildServiceSkillCapabilityDescription(skill), + description: [ + buildServiceSkillLaunchPrefillSummary({ + skill, + slotValues: recentPrefill?.slotValues, + launchUserInput: recentPrefill?.launchUserInput, + }), + buildServiceSkillCapabilityDescription(skill), + ] + .filter((segment) => segment.length > 0) + .join(" · "), usedAt: skill.recentUsedAt, skillId: skill.id, }); @@ -664,20 +681,29 @@ function buildSlashCapabilitySections( continue; } + const launchPrefill = resolveCuratedTaskTemplateLaunchPrefill(template); visibleRecentSlashEntries.push({ key: `curated-task:${template.id}`, kind: "curated_task", kindLabel: "结果模板", title: template.title, - description: resolveRecentSlashEntryDescription({ - fallbackDescription: buildCuratedTaskCapabilityDescription(template, { - includeSummary: false, - includeResultDestination: true, - includeFollowUpActions: true, - followUpLimit: 1, + description: [ + buildCuratedTaskRecentUsageDescription({ + task: template, + prefill: launchPrefill, }), - fallbackTitle: template.title, - }), + resolveRecentSlashEntryDescription({ + fallbackDescription: buildCuratedTaskCapabilityDescription(template, { + includeSummary: false, + includeResultDestination: true, + includeFollowUpActions: true, + followUpLimit: 1, + }), + fallbackTitle: template.title, + }), + ] + .filter((segment) => segment.length > 0) + .join(" · "), usedAt: template.recentUsedAt, taskId: template.id, }); diff --git a/src/components/agent/chat/skill-selection/mentionEntryUsage.ts b/src/components/agent/chat/skill-selection/mentionEntryUsage.ts index d8e462733..1c5f63fc3 100644 --- a/src/components/agent/chat/skill-selection/mentionEntryUsage.ts +++ b/src/components/agent/chat/skill-selection/mentionEntryUsage.ts @@ -21,6 +21,8 @@ export interface RecordMentionEntryUsageInput { const MENTION_ENTRY_USAGE_STORAGE_KEY = "lime:mention-entry-usage:v1"; const MAX_MENTION_ENTRY_USAGE_RECORDS = 12; const MAX_MENTION_ENTRY_REPLAY_TEXT_LENGTH = 400; +export const MENTION_ENTRY_USAGE_CHANGED_EVENT = + "lime:mention-entry-usage-changed"; function normalizeOptionalText(value: unknown): string | undefined { if (typeof value !== "string") { @@ -133,6 +135,47 @@ export function getMentionEntryUsageMap(): Map< ); } +function emitMentionEntryUsageChanged(): void { + if (typeof window === "undefined") { + return; + } + + window.dispatchEvent(new CustomEvent(MENTION_ENTRY_USAGE_CHANGED_EVENT)); +} + +export function subscribeMentionEntryUsageChanged( + callback: () => void, +): () => void { + if (typeof window === "undefined") { + return () => undefined; + } + + const customEventHandler = () => { + callback(); + }; + + const storageHandler = (event: StorageEvent) => { + if (event.key !== MENTION_ENTRY_USAGE_STORAGE_KEY) { + return; + } + callback(); + }; + + window.addEventListener( + MENTION_ENTRY_USAGE_CHANGED_EVENT, + customEventHandler, + ); + window.addEventListener("storage", storageHandler); + + return () => { + window.removeEventListener( + MENTION_ENTRY_USAGE_CHANGED_EVENT, + customEventHandler, + ); + window.removeEventListener("storage", storageHandler); + }; +} + export function recordMentionEntryUsage( input: RecordMentionEntryUsageInput, ): MentionEntryUsageRecord[] { @@ -165,6 +208,7 @@ export function recordMentionEntryUsage( MENTION_ENTRY_USAGE_STORAGE_KEY, JSON.stringify(nextRecords), ); + emitMentionEntryUsageChanged(); } catch { // ignore write errors } diff --git a/src/components/agent/chat/team-workspace-runtime/boardChromeSelectors.ts b/src/components/agent/chat/team-workspace-runtime/boardChromeSelectors.ts index 61d1f0b52..6cdb49e77 100644 --- a/src/components/agent/chat/team-workspace-runtime/boardChromeSelectors.ts +++ b/src/components/agent/chat/team-workspace-runtime/boardChromeSelectors.ts @@ -61,7 +61,7 @@ function buildBoardHeadline(params: { (statusSummary.failed ?? 0) + (statusSummary.aborted ?? 0); if (isChildSession) { - return parentSessionName?.trim() || "主任务总览"; + return parentSessionName?.trim() || "主助手总览"; } if (!hasRuntimeSessions) { return "需要时会自动拆出任务"; @@ -104,7 +104,7 @@ function buildBoardHint(params: { if (isChildSession) { return siblingCount > 0 ? `当前任务正与 ${siblingCount} 项并行子任务一起推进` - : "当前正在处理这项子任务,结果会回流到主任务。"; + : "当前正在处理这项子任务,结果会回流到主助手。"; } if (!hasRuntimeSessions) { return "系统会在需要时自动拆出任务、安排处理顺序,并把结果回流到当前任务。"; diff --git a/src/components/agent/chat/team-workspace-runtime/formationDisplaySelectors.test.ts b/src/components/agent/chat/team-workspace-runtime/formationDisplaySelectors.test.ts index b5aa149db..81378ce90 100644 --- a/src/components/agent/chat/team-workspace-runtime/formationDisplaySelectors.test.ts +++ b/src/components/agent/chat/team-workspace-runtime/formationDisplaySelectors.test.ts @@ -20,7 +20,7 @@ describe("formationDisplaySelectors", () => { expect(state.hasSelectedTeamPlan).toBe(true); expect(state.summaryBadges.map((badge) => badge.text)).toEqual([ - "任务方案 · 代码排障团队", + "分工方案 · 代码排障团队", "1 个计划分工", ]); expect(state.roleCards).toEqual([ @@ -66,7 +66,7 @@ describe("formationDisplaySelectors", () => { expect(state.hasRuntimeFormation).toBe(true); expect(state.hint).toContain("当前任务的分工已经准备好"); expect(state.summaryBadges.map((badge) => badge.text)).toEqual([ - "任务方案 · 修复任务方案", + "分工方案 · 修复分工方案", "已就绪", "1 条当前进展", "参考方案 · 代码排障团队", @@ -80,7 +80,7 @@ describe("formationDisplaySelectors", () => { label: "分析", summary: "先定位问题与影响面。", }); - expect(state.noticeText).toContain("当前任务方案已就绪"); + expect(state.noticeText).toContain("当前分工方案已就绪"); expect(state.noticeText).toContain("任务拆出后"); expect(state.noticeText).toContain("当前进展"); }); @@ -99,8 +99,8 @@ describe("formationDisplaySelectors", () => { }, }); - expect(state.panelDescription).toBe("Provider 认证失败,无法生成任务方案。"); - expect(state.emptyDetail).toBe("Provider 认证失败,无法生成任务方案。"); - expect(state.noticeText).toBe("Provider 认证失败,无法生成任务方案。"); + expect(state.panelDescription).toBe("Provider 认证失败,无法生成分工方案。"); + expect(state.emptyDetail).toBe("Provider 认证失败,无法生成分工方案。"); + expect(state.noticeText).toBe("Provider 认证失败,无法生成分工方案。"); }); }); diff --git a/src/components/agent/chat/team-workspace-runtime/formationDisplaySelectors.ts b/src/components/agent/chat/team-workspace-runtime/formationDisplaySelectors.ts index e3944d775..09d91fa1b 100644 --- a/src/components/agent/chat/team-workspace-runtime/formationDisplaySelectors.ts +++ b/src/components/agent/chat/team-workspace-runtime/formationDisplaySelectors.ts @@ -82,7 +82,7 @@ export function buildRuntimeFormationEmptyDetail( case "forming": return "系统正在根据当前任务准备分工。完成后,这里会先展示分工卡片,再接入真实处理进展。"; case "formed": - return "当前任务方案已经准备好。画布会先展示当前分工,等任务真正开始处理后,再自动切换为当前进展。"; + return "当前分工方案已经准备好。画布会先展示当前分工,等任务真正开始处理后,再自动切换为当前进展。"; case "failed": return errorMessage || "当前任务分工准备失败,暂时无法展示当前进展。"; default: @@ -180,7 +180,7 @@ export function buildRuntimeFormationDisplayState(params: { state?.status === "forming" ? "系统正在准备当前任务分工,完成后会先展示分工卡片,后续再切换为独立的当前进展。" : state?.status === "formed" - ? "当前任务方案已就绪。任务拆出后,这里会从方案视图过渡到当前进展。" + ? "当前分工方案已就绪。任务拆出后,这里会从方案视图过渡到当前进展。" : state?.status === "failed" ? errorMessage || "当前任务分工准备失败,暂时还没有任务接手。" : `${TEAM_WORKSPACE_IDLE_STATUS_LABEL}。系统开始分工后,这里会生成独立的当前进展。`; @@ -239,7 +239,7 @@ export function buildRuntimeFormationDisplayState(params: { panelDescription: state?.status === "failed" ? errorMessage || "当前任务分工准备失败,暂时无法展示更多内容。" - : summary || "这里会先展示当前任务方案,任务拆出后再切换成当前进展。", + : summary || "这里会先展示当前分工方案,任务拆出后再切换成当前进展。", referenceLabel, memberCards, blueprintRoleCards, diff --git a/src/components/agent/chat/utils/curatedTaskReferenceSelection.ts b/src/components/agent/chat/utils/curatedTaskReferenceSelection.ts index 07ed4cd2a..62228cb87 100644 --- a/src/components/agent/chat/utils/curatedTaskReferenceSelection.ts +++ b/src/components/agent/chat/utils/curatedTaskReferenceSelection.ts @@ -345,7 +345,7 @@ export function buildCuratedTaskReferencePromptBlock( return null; } - return `本轮可优先参考这些参考基线:\n${entries + return `本轮可优先参考这些参考对象:\n${entries .slice(0, 3) .map((entry) => buildReferencePromptLine(entry)) .join("\n")}`; diff --git a/src/components/agent/chat/utils/curatedTaskTemplates.test.ts b/src/components/agent/chat/utils/curatedTaskTemplates.test.ts index 9ff8beb88..d663e6453 100644 --- a/src/components/agent/chat/utils/curatedTaskTemplates.test.ts +++ b/src/components/agent/chat/utils/curatedTaskTemplates.test.ts @@ -1,10 +1,16 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { findCuratedTaskTemplateById, + recordCuratedTaskTemplateUsage, resolveCuratedTaskFollowUpActionTarget, + subscribeCuratedTaskTemplateUsageChanged, } from "./curatedTaskTemplates"; describe("curatedTaskTemplates", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + it("复盘模板的下游动作应能路由到正确的结果模板", () => { const resolved = resolveCuratedTaskFollowUpActionTarget({ taskId: "account-project-review", @@ -25,4 +31,19 @@ describe("curatedTaskTemplates", () => { }), ).toBeNull(); }); + + it("记录 recent usage 时应发出统一 changed 事件", () => { + const callback = vi.fn(); + const unsubscribe = subscribeCuratedTaskTemplateUsageChanged(callback); + + recordCuratedTaskTemplateUsage({ + templateId: "daily-trend-briefing", + launchInputValues: { + theme_target: "AI 内容创作", + }, + }); + + expect(callback).toHaveBeenCalledTimes(1); + unsubscribe(); + }); }); diff --git a/src/components/agent/chat/utils/curatedTaskTemplates.ts b/src/components/agent/chat/utils/curatedTaskTemplates.ts index c036700d0..987c815c0 100644 --- a/src/components/agent/chat/utils/curatedTaskTemplates.ts +++ b/src/components/agent/chat/utils/curatedTaskTemplates.ts @@ -92,6 +92,8 @@ interface CuratedTaskTemplateUsageRecord { const CURATED_TASK_TEMPLATE_USAGE_STORAGE_KEY = "lime:curated-task-template-usage:v2"; const MAX_CURATED_TASK_TEMPLATE_USAGE_RECORDS = 12; +export const CURATED_TASK_TEMPLATE_USAGE_CHANGED_EVENT = + "lime:curated-task-template-usage-changed"; export interface CuratedTaskTemplateLaunchPrefill { inputValues?: CuratedTaskInputValues; @@ -544,6 +546,50 @@ function getCuratedTaskTemplateUsageMap(): Map< ); } +function emitCuratedTaskTemplateUsageChanged(): void { + if (typeof window === "undefined") { + return; + } + + window.dispatchEvent( + new CustomEvent(CURATED_TASK_TEMPLATE_USAGE_CHANGED_EVENT), + ); +} + +export function subscribeCuratedTaskTemplateUsageChanged( + callback: () => void, +): () => void { + if (typeof window === "undefined") { + return () => undefined; + } + + const customEventHandler = () => { + callback(); + }; + + const storageHandler = (event: StorageEvent) => { + if (event.key !== CURATED_TASK_TEMPLATE_USAGE_STORAGE_KEY) { + return; + } + + callback(); + }; + + window.addEventListener( + CURATED_TASK_TEMPLATE_USAGE_CHANGED_EVENT, + customEventHandler, + ); + window.addEventListener("storage", storageHandler); + + return () => { + window.removeEventListener( + CURATED_TASK_TEMPLATE_USAGE_CHANGED_EVENT, + customEventHandler, + ); + window.removeEventListener("storage", storageHandler); + }; +} + export function resolveCuratedTaskTemplateLaunchPrefill( task: | Pick @@ -675,6 +721,72 @@ export function summarizeCuratedTaskFollowUpActions( return summarizeCuratedTaskFactItems(task.followUpActions, limit); } +function summarizeCuratedTaskRecentValue( + value: string, + maxLength = 36, +): string { + const normalized = value.replace(/\s+/g, " ").trim(); + if (normalized.length <= maxLength) { + return normalized; + } + + return `${normalized.slice(0, maxLength).trimEnd()}...`; +} + +export function buildCuratedTaskRecentUsageDescription(params: { + task: Pick; + prefill?: CuratedTaskTemplateLaunchPrefill | null; + fieldLimit?: number; +}): string { + const fieldLimit = params.fieldLimit ?? 2; + const launchInputSummaryItems = params.task.requiredInputFields + .map((field) => { + const rawValue = params.prefill?.inputValues?.[field.key]; + const normalizedValue = normalizeCuratedTaskInputValue(rawValue); + if (!normalizedValue) { + return null; + } + + return `${field.label}=${summarizeCuratedTaskRecentValue( + normalizedValue, + )}`; + }) + .filter((item): item is string => Boolean(item)); + + const segments: string[] = []; + + if (launchInputSummaryItems.length > 0) { + const visibleItems = launchInputSummaryItems.slice(0, fieldLimit); + segments.push( + `上次填写:${visibleItems.join(";")}${ + launchInputSummaryItems.length > fieldLimit + ? ` 等 ${launchInputSummaryItems.length} 项` + : "" + }`, + ); + } + + const referenceEntries = + mergeCuratedTaskReferenceEntries(params.prefill?.referenceEntries ?? []); + if (referenceEntries.length > 0) { + const referenceTitles = referenceEntries + .map((entry) => entry.title.trim()) + .filter((title) => title.length > 0); + const visibleTitles = referenceTitles.slice(0, fieldLimit); + segments.push( + visibleTitles.length > 0 + ? `参考:${visibleTitles.join(";")}${ + referenceTitles.length > fieldLimit + ? ` 等 ${referenceTitles.length} 条` + : "" + }` + : `参考:${referenceEntries.length} 条参考对象`, + ); + } + + return segments.join(" · "); +} + export function buildCuratedTaskFollowUpDescription( task: Pick, options: { @@ -1187,6 +1299,7 @@ export function recordCuratedTaskTemplateUsage( CURATED_TASK_TEMPLATE_USAGE_STORAGE_KEY, JSON.stringify(nextRecords), ); + emitCuratedTaskTemplateUsageChanged(); } catch { // ignore write errors } diff --git a/src/components/agent/chat/utils/sceneAppExecutionPromptContinuation.test.ts b/src/components/agent/chat/utils/sceneAppExecutionPromptContinuation.test.ts index 65077e3eb..2be862793 100644 --- a/src/components/agent/chat/utils/sceneAppExecutionPromptContinuation.test.ts +++ b/src/components/agent/chat/utils/sceneAppExecutionPromptContinuation.test.ts @@ -96,7 +96,7 @@ describe("sceneAppExecutionPromptContinuation", () => { summary: "当前结果已进入补件阶段。", businessLabel: "内容闭环", typeLabel: "多模态组合", - executionChainLabel: "创作场景 -> 生成 -> Project Pack", + executionChainLabel: "做法 -> 生成 -> Project Pack", deliveryContractLabel: "Project Pack", planningStatusLabel: "已就绪", planningSummary: "已装配上下文。", diff --git a/src/components/agent/chat/utils/sceneAppExecutionPromptContinuation.ts b/src/components/agent/chat/utils/sceneAppExecutionPromptContinuation.ts index d064ee2e5..55cd0e198 100644 --- a/src/components/agent/chat/utils/sceneAppExecutionPromptContinuation.ts +++ b/src/components/agent/chat/utils/sceneAppExecutionPromptContinuation.ts @@ -17,8 +17,8 @@ function resolveSceneAppExecutionRuntimeSceneKey(params: { } const entryAction = params.detailView?.entryAction; - if (entryAction?.kind === "open_cloud_scene_session") { - const sceneKey = entryAction.cloudSceneRuntimeRef.sceneKey?.trim() || ""; + if (entryAction?.kind === "open_service_scene_session") { + const sceneKey = entryAction.serviceSceneRuntimeRef.sceneKey?.trim() || ""; if (sceneKey) { return sceneKey; } diff --git a/src/components/agent/chat/utils/sceneAppSkillScaffoldDraft.test.ts b/src/components/agent/chat/utils/sceneAppSkillScaffoldDraft.test.ts index 58b81173b..6d31b9504 100644 --- a/src/components/agent/chat/utils/sceneAppSkillScaffoldDraft.test.ts +++ b/src/components/agent/chat/utils/sceneAppSkillScaffoldDraft.test.ts @@ -12,7 +12,7 @@ function createSummary(): SceneAppExecutionSummaryViewModel { summary: "把线框图、脚本、配乐和短视频草稿压成同一条结果链。", businessLabel: "内容闭环", typeLabel: "多模态组合", - executionChainLabel: "创作场景 -> 生成 -> Project Pack", + executionChainLabel: "做法 -> 生成 -> Project Pack", deliveryContractLabel: "Project Pack", planningStatusLabel: "已就绪", planningSummary: "当前已经带入 2 条参考与 1 条风格偏好,可直接进入生成。", @@ -158,16 +158,32 @@ describe("buildSkillsPageParamsFromSceneAppExecution", () => { "当你需要继续产出“短视频编排”这类内容闭环结果时使用。", ]), ); + expect(result?.initialScaffoldDraft?.whenToUse).toEqual( + expect.arrayContaining([ + "适合继续沿用这轮已验证过的 做法 -> 生成 -> Project Pack 执行路径与当前结果交付约定。", + ]), + ); expect(result?.initialScaffoldDraft?.inputs).toEqual( expect.arrayContaining([ - "执行骨架:创作场景 -> 生成 -> Project Pack", + "执行骨架:做法 -> 生成 -> Project Pack", "人工复盘:封面已经稳定,下一步继续生成渠道预览稿并整理上传稿。", ]), ); + const steps = result?.initialScaffoldDraft?.steps ?? []; + expect(steps).toEqual( + expect.arrayContaining([ + "先确认这次任务是否仍适合同一条场景执行路径与当前结果交付约定。", + "沿用当前已验证过的参考对象、风格摘要与项目结果基线,补齐最少必要输入。", + "输出后按当前结果判断线索与人工复盘结论,继续进入复核、发布或下一轮生成。", + ]), + ); + expect(steps.join(" ")).not.toContain("交付合同"); + expect(steps.join(" ")).not.toContain("Context Layer"); + expect(steps.join(" ")).not.toContain("Scorecard"); expect(result?.initialScaffoldDraft?.fallbackStrategy).toEqual( expect.arrayContaining([ "如果仍缺少复核意见,先补齐缺失部件,再把整套做法沉淀下来。", - "如果再次出现复核阻塞,先回看证据与治理材料,不要直接放大复用。", + "如果再次出现复核阻塞,先回看证据与复盘材料,不要直接放大复用。", ]), ); }); diff --git a/src/components/agent/chat/utils/sceneAppSkillScaffoldDraft.ts b/src/components/agent/chat/utils/sceneAppSkillScaffoldDraft.ts index 13d3b0719..415a33965 100644 --- a/src/components/agent/chat/utils/sceneAppSkillScaffoldDraft.ts +++ b/src/components/agent/chat/utils/sceneAppSkillScaffoldDraft.ts @@ -130,9 +130,9 @@ export function buildSkillsPageParamsFromSceneAppExecution( ], 3).join(" "), whenToUse: dedupeItems([ `当你需要继续产出“${summary.title}”这类${summary.businessLabel}结果时使用。`, - `适合继续沿用这轮已验证过的 ${summary.executionChainLabel} 执行路径与 ${summary.deliveryContractLabel} 交付合同。`, + `适合继续沿用这轮已验证过的 ${summary.executionChainLabel} 执行路径与当前结果交付约定。`, summary.referenceCount > 0 - ? `当前这轮已经验证过 ${summary.referenceCount} 条参考基线,适合在相近题材、平台或项目约束下继续复用。` + ? `当前这轮已经验证过 ${summary.referenceCount} 条参考对象,适合在相近题材、平台或项目约束下继续复用。` : null, reviewSnippet, ]), @@ -141,8 +141,8 @@ export function buildSkillsPageParamsFromSceneAppExecution( `目标与结果方向:${summarySnippet}`, `执行骨架:${summary.executionChainLabel}`, summary.referenceCount > 0 - ? `参考基线:${summary.referenceCount} 条参考对象已被验证可继续沿用。` - : "参考基线:按当前项目上下文补充最关键的素材、示例或历史结果。", + ? `参考对象:${summary.referenceCount} 条参考对象已被验证可继续沿用。` + : "参考对象:按当前项目上下文补充最关键的素材、示例或历史结果。", reviewSignalSummary ? `人工复盘:${truncate(reviewSignalSummary, 88)}` : null, @@ -152,21 +152,21 @@ export function buildSkillsPageParamsFromSceneAppExecution( 5, ), outputs: dedupeItems([ - `交付一份与“${summary.title}”同类型、可直接继续消费的${summary.deliveryContractLabel}结果。`, + `交付一份与“${summary.title}”同类型、可直接继续消费的结果。`, `结果状态参考:${deliverySnippet}`, `后续建议:${nextActionSnippet}`, ]), steps: dedupeItems([ - "先确认这次任务是否仍适合同一条场景执行路径与交付合同。", - "沿用当前 Context Layer 的参考、风格摘要与项目结果基线,补齐最少必要输入。", - "输出后按当前 Scorecard 与人工复盘结论,继续进入复核、发布或下一轮生成。", + "先确认这次任务是否仍适合同一条场景执行路径与当前结果交付约定。", + "沿用当前已验证过的参考对象、风格摘要与项目结果基线,补齐最少必要输入。", + "输出后按当前结果判断线索与人工复盘结论,继续进入复核、发布或下一轮生成。", ]), fallbackStrategy: dedupeItems([ missingPartsLabel ? `如果仍缺少${missingPartsLabel},先补齐缺失部件,再把整套做法沉淀下来。` : null, failureSignalLabel - ? `如果再次出现${failureSignalLabel},先回看证据与治理材料,不要直接放大复用。` + ? `如果再次出现${failureSignalLabel},先回看证据与复盘材料,不要直接放大复用。` : null, reviewSignalSummary ? `如果最近人工复盘提出新的约束,优先按“${truncate(reviewSignalSummary, 72)}”处理后再继续复用。` diff --git a/src/components/agent/chat/utils/teamMemoryDisplay.ts b/src/components/agent/chat/utils/teamMemoryDisplay.ts index 63c00067a..e06ca519e 100644 --- a/src/components/agent/chat/utils/teamMemoryDisplay.ts +++ b/src/components/agent/chat/utils/teamMemoryDisplay.ts @@ -4,6 +4,7 @@ export function normalizeTeamMemoryDisplayText(value?: string | null): string { } return value - .replace(/^Team[::]\s*/gm, "任务方案:") + .replace(/^Team[::]\s*/gm, "分工方案:") + .replace(/^任务方案[::]\s*/gm, "分工方案:") .replace(/^子代理[::]\s*/gm, "子任务:"); } diff --git a/src/components/agent/chat/utils/teamWorkspaceCopy.ts b/src/components/agent/chat/utils/teamWorkspaceCopy.ts index 3b9b901eb..0627109af 100644 --- a/src/components/agent/chat/utils/teamWorkspaceCopy.ts +++ b/src/components/agent/chat/utils/teamWorkspaceCopy.ts @@ -28,8 +28,8 @@ export type TeamWorkspaceDisplayMemberStatus = export const TEAM_WORKSPACE_SURFACE_TITLE = "生成工作台"; export const TEAM_WORKSPACE_REALTIME_BADGE_LABEL = "任务进行时"; export const TEAM_WORKSPACE_MEMBER_NOUN = "子任务"; -export const TEAM_WORKSPACE_PLAN_LABEL = "任务方案"; -export const TEAM_WORKSPACE_MAIN_ASSISTANT_LABEL = "主任务"; +export const TEAM_WORKSPACE_PLAN_LABEL = "分工方案"; +export const TEAM_WORKSPACE_MAIN_ASSISTANT_LABEL = "主助手"; export const TEAM_WORKSPACE_STABLE_PROCESSING_LABEL = STABLE_PROCESSING_LABEL; export const TEAM_WORKSPACE_WAITING_HEADLINE = "等待任务接手"; export const TEAM_WORKSPACE_IDLE_STATUS_LABEL = "还没有任务接手"; diff --git a/src/components/agent/chat/utils/teamWorkspaceDisplay.ts b/src/components/agent/chat/utils/teamWorkspaceDisplay.ts index 6feb317c4..b6af4f7e1 100644 --- a/src/components/agent/chat/utils/teamWorkspaceDisplay.ts +++ b/src/components/agent/chat/utils/teamWorkspaceDisplay.ts @@ -1,15 +1,17 @@ const TEAM_WORKSPACE_DISPLAY_REPLACEMENTS: Array = [ [/\bteam\s*状态事件\b/gi, "任务状态事件"], - [/\bTeam\b/g, "任务方案"], - [/\bteam\b/g, "任务方案"], + [/\bTeam\b/g, "分工方案"], + [/\bteam\b/g, "分工方案"], + [/任务方案/g, "分工方案"], + [/主任务/g, "主助手"], [/子代理/g, "子任务"], [/多代理/g, "任务拆分"], ]; function normalizeInsertedChineseSpacing(value: string): string { return value - .replace(/([\u4e00-\u9fff])\s+(任务方案|任务|子任务|任务拆分)/g, "$1$2") - .replace(/(任务方案|任务|子任务|任务拆分)\s+([\u4e00-\u9fff])/g, "$1$2"); + .replace(/([\u4e00-\u9fff])\s+(分工方案|任务|子任务|任务拆分)/g, "$1$2") + .replace(/(分工方案|任务|子任务|任务拆分)\s+([\u4e00-\u9fff])/g, "$1$2"); } export function normalizeTeamWorkspaceDisplayText(value?: string | null): string { diff --git a/src/components/agent/chat/utils/teamWorkspaceSessions.test.ts b/src/components/agent/chat/utils/teamWorkspaceSessions.test.ts index 96d912d18..0e958cf5f 100644 --- a/src/components/agent/chat/utils/teamWorkspaceSessions.test.ts +++ b/src/components/agent/chat/utils/teamWorkspaceSessions.test.ts @@ -51,7 +51,7 @@ describe("teamWorkspaceSessions", () => { profile_id: "profile-research", profile_name: "研究员", parent_session_id: "parent-session", - parent_session_name: "主任务", + parent_session_name: "主助手", role_key: "researcher", team_preset_id: "preset-1", theme: "general", @@ -162,7 +162,7 @@ describe("teamWorkspaceSessions", () => { it("应按任务状态优先级构建 task schedule 顺序与默认选中项", () => { const orchestrator = createSessionCard({ id: "session-main", - name: "主任务", + name: "主助手", sessionType: "user", }); const planner = createSessionCard({ @@ -260,11 +260,11 @@ describe("teamWorkspaceSessions", () => { isChildSession: false, selectedSession: createSessionCard({ id: "orchestrator", - name: "主任务", + name: "主助手", sessionType: "user", }), }), - ).toContain("主任务会负责整理需求、安排任务顺序"); + ).toContain("主助手会负责整理需求、安排任务顺序"); }); it("应暴露稳定的状态与时间展示 helper", () => { diff --git a/src/components/agent/chat/utils/teamWorkspaceSessions.ts b/src/components/agent/chat/utils/teamWorkspaceSessions.ts index b20170612..bb2891a2f 100644 --- a/src/components/agent/chat/utils/teamWorkspaceSessions.ts +++ b/src/components/agent/chat/utils/teamWorkspaceSessions.ts @@ -173,7 +173,7 @@ export function buildOrchestratorSession( name: currentSessionName?.trim() || TEAM_WORKSPACE_MAIN_ASSISTANT_LABEL, runtimeStatus: currentSessionRuntimeStatus, taskSummary: - "当前主任务会负责拆解任务、安排处理顺序,并把各部分结果汇总到同一份内容里。", + "当前主助手会负责拆解任务、安排处理顺序,并把各部分结果汇总到同一份内容里。", roleHint: "orchestrator", sessionType: "user", isCurrent: true, @@ -492,10 +492,10 @@ export function buildFallbackSummary(params: { return "还没有任务接入。需要时系统会自动补充分工,并在这里展示最新进展。"; } if (selectedSession?.sessionType === "user") { - return "主任务会负责整理需求、安排任务顺序,并把各部分结果汇总到当前内容里。"; + return "主助手会负责整理需求、安排任务顺序,并把各部分结果汇总到当前内容里。"; } if (isChildSession) { - return "这项任务正在处理主任务分配的内容,你可以在这里切换查看其他并行任务的进展。"; + return "这项任务正在处理主助手分配的内容,你可以在这里切换查看其他并行任务的进展。"; } return "选中一项任务后,这里会展示它正在推进什么,以及目前进展到哪一步。"; } diff --git a/src/components/agent/chat/utils/toolDisplayInfo.test.ts b/src/components/agent/chat/utils/toolDisplayInfo.test.ts index 992b337a8..6a624018b 100644 --- a/src/components/agent/chat/utils/toolDisplayInfo.test.ts +++ b/src/components/agent/chat/utils/toolDisplayInfo.test.ts @@ -88,13 +88,13 @@ describe("toolDisplayInfo", () => { expect(resolveToolDisplayLabel("AgentOutputTool")).toBe("任务输出"); expect(resolveToolDisplayLabel("BashOutputTool")).toBe("任务输出"); expect(resolveToolDisplayLabel("lime_create_transcription_task")).toBe( - "转写任务", + "转写", ); expect( resolveToolDisplayLabel("lime_create_modal_resource_search_task"), - ).toBe("素材检索任务"); + ).toBe("素材检索"); expect(resolveToolDisplayLabel("lime_run_service_skill")).toBe( - "服务技能执行", + "服务技能兼容执行", ); expect(resolveToolDisplayLabel("lime_site_recommend")).toBe( "站点能力推荐", @@ -125,7 +125,7 @@ describe("toolDisplayInfo", () => { expect(resolveUserFacingToolDisplayLabel("REPLTool")).toBe("运行命令"); expect( resolveUserFacingToolDisplayLabel("lime_run_service_skill"), - ).toBe("运行服务技能"); + ).toBe("运行兼容服务技能"); expect(resolveUserFacingToolDisplayLabel("lime_site_recommend")).toBe( "推荐站点能力", ); diff --git a/src/components/agent/chat/utils/toolDisplayInfo.ts b/src/components/agent/chat/utils/toolDisplayInfo.ts index 622d9efe8..d74d60d3c 100644 --- a/src/components/agent/chat/utils/toolDisplayInfo.ts +++ b/src/components/agent/chat/utils/toolDisplayInfo.ts @@ -1132,8 +1132,8 @@ const EXACT_TOOL_CONFIGS = new Map([ "limecreatevideogenerationtask", { family: "task", - label: "视频生成任务", - verb: "创建任务", + label: "视频生成", + verb: "发起", icon: FilePlus, groupTitle: "任务", actionKey: "task", @@ -1143,8 +1143,8 @@ const EXACT_TOOL_CONFIGS = new Map([ "limecreatetranscriptiontask", { family: "task", - label: "转写任务", - verb: "创建任务", + label: "转写", + verb: "发起", icon: FilePlus, groupTitle: "任务", actionKey: "task", @@ -1154,8 +1154,8 @@ const EXACT_TOOL_CONFIGS = new Map([ "limecreatebroadcastgenerationtask", { family: "task", - label: "口播生成任务", - verb: "创建任务", + label: "口播生成", + verb: "发起", icon: FilePlus, groupTitle: "任务", actionKey: "task", @@ -1165,8 +1165,8 @@ const EXACT_TOOL_CONFIGS = new Map([ "limecreatecovergenerationtask", { family: "task", - label: "封面生成任务", - verb: "创建任务", + label: "封面生成", + verb: "发起", icon: FilePlus, groupTitle: "任务", actionKey: "task", @@ -1176,8 +1176,8 @@ const EXACT_TOOL_CONFIGS = new Map([ "limecreateresourcesearchtask", { family: "task", - label: "素材检索任务", - verb: "创建任务", + label: "素材检索", + verb: "发起", icon: FilePlus, groupTitle: "任务", actionKey: "task", @@ -1187,8 +1187,8 @@ const EXACT_TOOL_CONFIGS = new Map([ "limecreatemodalresourcesearchtask", { family: "task", - label: "素材检索任务", - verb: "创建任务", + label: "素材检索", + verb: "发起", icon: FilePlus, groupTitle: "任务", actionKey: "task", @@ -1198,8 +1198,8 @@ const EXACT_TOOL_CONFIGS = new Map([ "limecreateimagegenerationtask", { family: "task", - label: "图片生成任务", - verb: "创建任务", + label: "图片生成", + verb: "发起", icon: FilePlus, groupTitle: "任务", actionKey: "task", @@ -1209,8 +1209,8 @@ const EXACT_TOOL_CONFIGS = new Map([ "limecreateurlparsetask", { family: "task", - label: "链接解析任务", - verb: "创建任务", + label: "链接解析", + verb: "发起", icon: FilePlus, groupTitle: "任务", actionKey: "task", @@ -1220,8 +1220,8 @@ const EXACT_TOOL_CONFIGS = new Map([ "limecreatetypesettingtask", { family: "task", - label: "排版任务", - verb: "创建任务", + label: "排版", + verb: "发起", icon: FilePlus, groupTitle: "任务", actionKey: "task", @@ -1231,7 +1231,7 @@ const EXACT_TOOL_CONFIGS = new Map([ "limerunserviceskill", { family: "generic", - label: "服务技能执行", + label: "服务技能兼容执行", verb: "执行", icon: Globe, groupTitle: "服务技能", @@ -1804,7 +1804,7 @@ const USER_FACING_TOOL_LABELS: Record = { 站点能力搜索: "搜索站点能力", 站点能力详情: "查看站点能力", 站点能力执行: "运行站点能力", - 服务技能执行: "运行服务技能", + 服务技能兼容执行: "运行兼容服务技能", }; export const normalizeToolNameKey = (value: string): string => { diff --git a/src/components/agent/chat/utils/toolProcessSummary.test.ts b/src/components/agent/chat/utils/toolProcessSummary.test.ts index 20163998b..d560ae908 100644 --- a/src/components/agent/chat/utils/toolProcessSummary.test.ts +++ b/src/components/agent/chat/utils/toolProcessSummary.test.ts @@ -216,8 +216,12 @@ describe("toolProcessSummary", () => { }), ); - expect(serviceSkillNarrative.preSummary).toBe("先执行服务技能 渠道预览"); - expect(serviceSkillNarrative.summary).toBe("先执行服务技能 渠道预览"); + expect(serviceSkillNarrative.preSummary).toBe( + "先走服务技能兼容执行 渠道预览", + ); + expect(serviceSkillNarrative.summary).toBe( + "先走服务技能兼容执行 渠道预览", + ); expect(siteRecommendNarrative.preSummary).toBe( "先推荐适合 GitHub issue 搜索 的站点能力", ); @@ -226,7 +230,7 @@ describe("toolProcessSummary", () => { ); }); - it("应为新补齐的任务工具生成任务提交文案", () => { + it("应为新补齐的任务工具生成更贴近当前前台的发起文案", () => { const transcriptionNarrative = resolveToolProcessNarrative( createToolCall({ name: "lime_create_transcription_task", @@ -257,21 +261,21 @@ describe("toolProcessSummary", () => { ); expect(transcriptionNarrative.preSummary).toBe( - "先提交 /tmp/interview.mp4 的转写任务", + "先发起 /tmp/interview.mp4 的转写", ); expect(transcriptionNarrative.postSummary).toBe( - "已提交 /tmp/interview.mp4 的转写任务", + "已发起 /tmp/interview.mp4 的转写", ); expect(resourceNarrative.preSummary).toBe( - "先提交 科技播客 BGM 的素材检索任务", + "先发起 科技播客 BGM 的素材检索", ); expect(resourceNarrative.summary).toBe( - "先提交 科技播客 BGM 的素材检索任务", + "先发起 科技播客 BGM 的素材检索", ); expect(urlParseNarrative.postSummary).toBe( - "已提交 https://example.com/report 的链接解析任务", + "已发起 https://example.com/report 的链接解析", ); - expect(typesettingNarrative.preSummary).toBe("先提交 小红书 的排版任务"); + expect(typesettingNarrative.preSummary).toBe("先发起 小红书 的排版"); }); it("应把 WebSearch 协议错误翻译成可操作提示", () => { diff --git a/src/components/agent/chat/utils/toolProcessSummary.ts b/src/components/agent/chat/utils/toolProcessSummary.ts index c87cec72e..6d90a515e 100644 --- a/src/components/agent/chat/utils/toolProcessSummary.ts +++ b/src/components/agent/chat/utils/toolProcessSummary.ts @@ -359,11 +359,11 @@ function buildLimeTaskSummary( } const normalizedSubject = normalizeNarrativeSubject(subject); - const prefix = phase === "pre" ? "先提交" : "已提交"; + const prefix = phase === "pre" ? "先发起" : "已发起"; return normalizedSubject - ? `${prefix} ${normalizedSubject} 的${taskLabel}任务` - : `${prefix}${taskLabel}任务`; + ? `${prefix} ${normalizedSubject} 的${taskLabel}` + : `${prefix}${taskLabel}`; } function buildSiteToolSummary( @@ -638,8 +638,8 @@ function buildGenericPostSummary(params: { } if (normalizedName === "limerunserviceskill") { return normalizedSubject - ? `已执行服务技能 ${normalizedSubject}` - : "已执行服务技能"; + ? `已走服务技能兼容执行 ${normalizedSubject}` + : "已走服务技能兼容执行"; } if (normalizedName === "mcp") { return "已完成 MCP 工具调用"; @@ -670,7 +670,7 @@ function buildGenericPostSummary(params: { ? `已获取 ${normalizedSubject} 内容` : "已获取外部内容"; case "task": - return "已创建任务"; + return "已发起这一步"; case "subagent": return "已把任务拆给子任务继续处理"; case "search": @@ -887,8 +887,8 @@ function buildGenericPreSummary(params: { if (normalizedName === "limerunserviceskill") { return normalizedSubject - ? `先执行服务技能 ${normalizedSubject}` - : "先执行服务技能"; + ? `先走服务技能兼容执行 ${normalizedSubject}` + : "先走服务技能兼容执行"; } if (normalizedName === "mcp") { @@ -930,7 +930,7 @@ function buildGenericPreSummary(params: { ? `准备修改 ${normalizedSubject}` : "准备修改目标文件"; case "task": - return "先创建生成任务"; + return "先发起这一步"; case "plan": return normalizedSubject ? `先处理 ${normalizedSubject}` diff --git a/src/components/agent/chat/workspace/SceneAppExecutionSummaryCard.test.tsx b/src/components/agent/chat/workspace/SceneAppExecutionSummaryCard.test.tsx index efb6efb17..5988efd08 100644 --- a/src/components/agent/chat/workspace/SceneAppExecutionSummaryCard.test.tsx +++ b/src/components/agent/chat/workspace/SceneAppExecutionSummaryCard.test.tsx @@ -129,7 +129,7 @@ function renderCard( summary: "把线框图、脚本、配乐和短视频草稿压成同一条结果链。", businessLabel: "内容闭环", typeLabel: "多模态组合", - executionChainLabel: "创作场景 -> 生成 -> Project Pack", + executionChainLabel: "做法 -> 生成 -> Project Pack", deliveryContractLabel: "Project Pack", planningStatusLabel: "已就绪", planningSummary: "当前已经带入 2 条参考与 1 条风格偏好,可直接进入生成。", @@ -242,13 +242,17 @@ afterEach(() => { }); describe("SceneAppExecutionSummaryCard", () => { - it("应展示创作场景执行摘要的核心合同信息", () => { + it("应展示做法执行摘要的核心合同信息", () => { const container = renderCard(); - expect(container.textContent).toContain("创作场景执行摘要"); + expect(container.textContent).toContain("做法执行摘要"); expect(container.textContent).toContain("短视频编排"); - expect(container.textContent).toContain("创作场景 -> 生成 -> Project Pack"); + expect(container.textContent).toContain("做法 -> 生成 -> Project Pack"); expect(container.textContent).toContain("当前已经带入 2 条参考与 1 条风格偏好"); + expect(container.textContent).toContain("当前带入对象"); + expect(container.textContent).toContain("结果去向与交付"); + expect(container.textContent).toContain("这轮怎么判断"); + expect(container.textContent).toContain("当前带入:2 条参考对象"); expect( container.querySelector('[data-testid="sceneapp-execution-summary-project-pack"]') ?.textContent, diff --git a/src/components/agent/chat/workspace/SceneAppExecutionSummaryCard.tsx b/src/components/agent/chat/workspace/SceneAppExecutionSummaryCard.tsx index b9ae1e9a8..7a586db44 100644 --- a/src/components/agent/chat/workspace/SceneAppExecutionSummaryCard.tsx +++ b/src/components/agent/chat/workspace/SceneAppExecutionSummaryCard.tsx @@ -169,8 +169,9 @@ function buildExecutionFollowupDestinations( ) { destinations.push({ key: "task-center", - label: "生成 / 看板", - description: "结构化治理材料已经就绪,后续更适合继续喂给生成页、看板统计或自动治理。", + label: "生成工作台", + description: + "这轮结果的复盘材料已经整理好,后续更适合回到生成工作台继续推进下一步。", }); } @@ -227,10 +228,19 @@ export function SceneAppExecutionSummaryCard({ const promptActions = latestPackResultDetailView ? buildSceneAppExecutionPromptActions(latestPackResultDetailView) : []; + const deliveryContractLabel = + summary.projectPackPlan?.packKindLabel ?? summary.deliveryContractLabel; + const deliveryDestinationLabel = + summary.projectPackPlan?.viewerLabel || deliveryContractLabel || "待补齐"; + const scorecardSummaryLabel = + summary.scorecardProfileRef || + (summary.scorecardMetricKeys.length > 0 + ? `${summary.scorecardMetricKeys.length} 项判断指标` + : "待补齐"); const hasFollowupSection = Boolean( onReviewCurrentProject || onSaveAsSkill || - onOpenSceneAppDetail || + onOpenSceneAppDetail || onOpenSceneAppGovernance || humanReviewAvailable || quickReviewActions.length || @@ -246,7 +256,7 @@ export function SceneAppExecutionSummaryCard({
- 创作场景执行摘要 + 做法执行摘要

@@ -265,7 +275,7 @@ export function SceneAppExecutionSummaryCard({ {summary.typeLabel} - {summary.deliveryContractLabel} + {deliveryContractLabel} {summary.planningStatusLabel} @@ -286,16 +296,16 @@ export function SceneAppExecutionSummaryCard({ {summary.executionChainLabel}

- 参考注入: - {summary.referenceCount} 条 + 当前带入: + {summary.referenceCount} 条参考对象
- Project Pack: - {summary.projectPackPlan?.packKindLabel || "待补齐"} + 结果去向: + {deliveryDestinationLabel}
- Scorecard: - {summary.scorecardProfileRef || "待补齐"} + 判断口径: + {scorecardSummaryLabel}
@@ -397,7 +407,7 @@ export function SceneAppExecutionSummaryCard({ {summary.runtimeBackflow.governanceArtifacts.length ? (
- 治理材料 + 复盘材料
{renderPartChips( summary.runtimeBackflow.governanceArtifacts, @@ -412,10 +422,10 @@ export function SceneAppExecutionSummaryCard({
- Context Layer + 当前带入对象

- 当前场景进入生成前,已把上下文分层压缩成一份稳定的执行摘要。 + 这一轮开工前,参考、风格、偏好和项目结果已经被压成同一份可继续复用的准备基线。

{renderPartChips( summary.activeLayers, @@ -462,28 +472,28 @@ export function SceneAppExecutionSummaryCard({ data-testid="sceneapp-execution-summary-project-pack" >
- Project Pack + 结果去向与交付
{summary.projectPackPlan ? ( <>
- 包类型: + 结果形态: {summary.projectPackPlan.packKindLabel}
- 完成口径: + 完成判断: {summary.projectPackPlan.completionStrategyLabel}
{summary.projectPackPlan.primaryPart ? (
- 默认主件: + 默认主结果: {summary.projectPackPlan.primaryPart}
) : null} {summary.projectPackPlan.viewerLabel ? (
- 查看方式: + 查看入口: {summary.projectPackPlan.viewerLabel}
) : null} @@ -507,7 +517,7 @@ export function SceneAppExecutionSummaryCard({ ) : (

- 当前场景还没有显式声明 Project Pack 合同,后续需要继续补齐。 + 当前这套做法还没有明确结果去向,后续需要继续补齐。

)}
@@ -516,13 +526,15 @@ export function SceneAppExecutionSummaryCard({ className="rounded-[20px] border border-slate-200 bg-white p-4" data-testid="sceneapp-execution-summary-scorecard" > -
Scorecard
+
+ 这轮怎么判断 +

- 生成页现在可以直接看到当前场景的经营口径,而不是只接一段 prompt。 + 这里保留继续复核、放量或回退时最关键的判断线索,不把经营判断藏在后台。

{summary.scorecardProfileRef ? (
- Profile: + 判断基线: {summary.scorecardProfileRef}
) : null} @@ -542,7 +554,7 @@ export function SceneAppExecutionSummaryCard({
继续动作

- 如果要补上下文、查看经营口径或继续做人工复核,直接回到同一条创作场景闭环。 + 如果要补上下文、查看经营口径或继续做人工复核,直接回到同一套做法闭环。

{onReviewCurrentProject ? ( @@ -598,7 +610,7 @@ export function SceneAppExecutionSummaryCard({ data-testid="sceneapp-execution-summary-open-governance" onClick={onOpenSceneAppGovernance} > - 去治理复盘 + 去做法复盘 ) : null} {humanReviewAvailable ? ( @@ -614,7 +626,7 @@ export function SceneAppExecutionSummaryCard({
{latestPackResultUsesFallback ? (

- 当前主运行还在继续,治理复盘会优先定位到最近一轮已交付样本,方便直接延续结果消费与放量判断。 + 当前主运行还在继续,做法复盘会优先定位到最近一轮已交付样本,方便直接延续结果消费与放量判断。

) : null} {humanReviewAvailable && quickReviewActions.length ? ( @@ -653,7 +665,7 @@ export function SceneAppExecutionSummaryCard({ 生成后动作编排

- 同一轮结果现在可以直接进入复盘、治理材料准备或底层运行入口恢复,不再只是回页面找下一步。 + 同一轮结果现在可以直接进入复盘、复盘材料准备或底层运行入口恢复,不再只是回页面找下一步。

{followupDestinations.length ? ( @@ -833,7 +845,7 @@ export function SceneAppExecutionSummaryCard({ {latestPackResultDetailView.governanceArtifactEntries.length ? (
- 基础治理材料 + 基础复盘材料
{latestPackResultDetailView.governanceArtifactEntries.map( diff --git a/src/components/agent/chat/workspace/sceneAppLaunch.test.ts b/src/components/agent/chat/workspace/sceneAppLaunch.test.ts index d24c3fa9e..5bf9197d0 100644 --- a/src/components/agent/chat/workspace/sceneAppLaunch.test.ts +++ b/src/components/agent/chat/workspace/sceneAppLaunch.test.ts @@ -114,17 +114,17 @@ describe("sceneAppLaunch", () => { }, plan: { sceneappId: "story-video-suite", - executorKind: "cloud_scene", - bindingFamily: "cloud_scene", + executorKind: "agent_turn", + bindingFamily: "agent_turn", adapterPlan: { - adapterKind: "cloud_scene", - runtimeAction: "launch_cloud_scene", + adapterKind: "agent_turn", + runtimeAction: "open_service_scene_session", targetRef: "sceneapp-service-story-video", targetLabel: "短视频编排", requestMetadata: { harness: { service_scene_launch: { - kind: "cloud_scene", + kind: "local_service_skill", }, }, }, @@ -155,7 +155,7 @@ describe("sceneAppLaunch", () => { kind: "navigate_agent", executionDraft: expect.objectContaining({ kind: "workspace_entry", - runtimeAction: "launch_cloud_scene", + runtimeAction: "open_service_scene_session", }), resolvedEntry: expect.objectContaining({ navigationParams: expect.objectContaining({ @@ -211,7 +211,7 @@ describe("sceneAppLaunch", () => { runtimeAction: "launch_native_skill", }), reason: "missing_project", - message: "SceneApp「本地分析场景」需要项目工作区,请先选择项目后再启动。", + message: "做法「本地分析场景」需要项目工作区,请先选择项目后再启动。", }); }); @@ -509,7 +509,7 @@ describe("sceneAppLaunch", () => { bindingFamily: "cloud_scene", adapterPlan: { adapterKind: "cloud_scene", - runtimeAction: "launch_cloud_scene", + runtimeAction: "open_service_scene_session", targetRef: "sceneapp-service-story-video", targetLabel: "短视频编排", requestMetadata: {}, @@ -537,10 +537,10 @@ describe("sceneAppLaunch", () => { kind: "workspace_entry", executionDraft: expect.objectContaining({ kind: "workspace_entry", - runtimeAction: "launch_cloud_scene", + runtimeAction: "open_service_scene_session", }), reason: "missing_navigate", - message: "当前入口暂不支持切换到 SceneApp 工作区,请从桌面主界面重试。", + message: "当前入口暂不支持切换到做法工作区,请从桌面主界面重试。", }); }); }); diff --git a/src/components/agent/chat/workspace/serviceSkillSceneLaunch.test.ts b/src/components/agent/chat/workspace/serviceSkillSceneLaunch.test.ts index 1afc77f6e..abe5eee1c 100644 --- a/src/components/agent/chat/workspace/serviceSkillSceneLaunch.test.ts +++ b/src/components/agent/chat/workspace/serviceSkillSceneLaunch.test.ts @@ -44,8 +44,8 @@ function createCloudSceneSkill(): ServiceSkillHomeItem { return { id: "cloud-video-dubbing", skillKey: "campaign-launch", - title: "云端视频配音", - summary: "把视频文案与素材提交到云端,生成一版可继续加工的配音结果。", + title: "视频配音", + summary: "围绕视频文案与素材整理一版可继续加工的配音稿。", category: "视频创作", outputHint: "配音文案 + 结果摘要", source: "cloud_catalog", @@ -56,10 +56,10 @@ function createCloudSceneSkill(): ServiceSkillHomeItem { badge: "云目录", recentUsedAt: null, isRecent: false, - runnerLabel: "云端托管执行", + runnerLabel: "立即开始", runnerTone: "slate", - runnerDescription: "提交到 OEM 云端执行,结果由服务端异步返回。", - actionLabel: "提交云端", + runnerDescription: "直接在当前工作区整理首版配音稿。", + actionLabel: "对话内补参", automationStatus: null, slotSchema: [], }; @@ -218,7 +218,7 @@ describe("serviceSkillSceneLaunch", () => { expect(request?.skill.id).toBe("x-article-export"); }); - it("应构建统一的 service scene launch request metadata", async () => { + it("应构建统一的本地 service scene launch request metadata", async () => { mockGetSkillCatalog.mockResolvedValueOnce({ entries: [] }); mockListSkillCatalogSceneEntries.mockReturnValueOnce([ { @@ -232,20 +232,6 @@ describe("serviceSkillSceneLaunch", () => { executionKind: "cloud_scene", }, ]); - mockResolveOemCloudRuntimeContext.mockReturnValueOnce({ - baseUrl: "https://user.limeai.run", - controlPlaneBaseUrl: "https://user.limeai.run/api", - sceneBaseUrl: "https://user.limeai.run/scene-api", - gatewayBaseUrl: "https://user.limeai.run/gateway-api", - tenantId: "tenant-demo", - sessionToken: "session-token-demo", - hubProviderName: null, - loginPath: "/login", - desktopClientId: "desktop-client", - desktopOauthRedirectUrl: "lime://oauth/callback", - desktopOauthNextPath: "/welcome", - }); - const request = await resolveRuntimeSceneLaunchRequest({ rawText: "/campaign-launch 帮我做一版新品活动启动方案", serviceSkills: [createCloudSceneSkill()], @@ -262,22 +248,18 @@ describe("serviceSkillSceneLaunch", () => { expect(requestMetadata).toMatchObject({ harness: { service_scene_launch: { - kind: "cloud_scene", + kind: "local_service_skill", service_scene_run: { scene_key: "campaign-launch", skill_id: "cloud-video-dubbing", project_id: "project-1", content_id: "content-1", user_input: "帮我做一版新品活动启动方案", - oem_runtime: { - scene_base_url: "https://user.limeai.run/scene-api", - tenant_id: "tenant-demo", - session_token: "session-token-demo", - }, }, }, }, }); + expect(request?.dispatchText).toContain("[技能任务] 视频配音"); }); it("site skill scene 应根据 skill 声明注入 service_skill_launch metadata", async () => { diff --git a/src/components/agent/chat/workspace/serviceSkillSceneLaunch.ts b/src/components/agent/chat/workspace/serviceSkillSceneLaunch.ts index 8ec0a14e3..13fbd9732 100644 --- a/src/components/agent/chat/workspace/serviceSkillSceneLaunch.ts +++ b/src/components/agent/chat/workspace/serviceSkillSceneLaunch.ts @@ -7,7 +7,6 @@ import { listServiceSkills, type ServiceSkillItem, } from "@/lib/api/serviceSkills"; -import { resolveOemCloudRuntimeContext } from "@/lib/api/oemCloudRuntime"; import { siteGetAdapterLaunchReadiness } from "@/lib/webview-api"; import { buildServiceSkillClawLaunchContext, @@ -21,6 +20,7 @@ import { resolveRuntimeSceneSkillFromEntry, } from "../service-skills/runtimeSceneBinding"; import type { ServiceSkillSlotValues } from "../service-skills/types"; +import { composeServiceSkillPrompt } from "../service-skills/promptComposer"; import { buildRuntimeSceneGateRequest, formatRuntimeSceneGateValidationMessage, @@ -57,6 +57,7 @@ interface ServiceSceneLaunchRequest { skill: ServiceSkillItem; sceneEntry: SkillCatalogSceneEntry; requestContext: ServiceSceneLaunchRequestContext; + dispatchText?: string; } export class RuntimeSceneLaunchValidationError extends Error { @@ -201,20 +202,36 @@ function resolveSiteSceneProjectId( }; } +function normalizeLocalServiceSceneExecutionKind( + value?: string | null, +): "agent_turn" | "native_skill" | "automation_job" { + if (value === "native_skill") { + return "native_skill"; + } + + if (value === "automation_job") { + return "automation_job"; + } + + return "agent_turn"; +} + function buildServiceSceneLaunchRequestContext(params: { rawText: string; parsedCommand: ParsedRuntimeSceneCommand; sceneEntry: SkillCatalogSceneEntry; skill: ServiceSkillItem; + slotValues: ServiceSkillSlotValues; projectId?: string | null; contentId?: string | null; }): Record { const { rawText, parsedCommand, sceneEntry, skill, projectId, contentId } = params; - const runtime = resolveOemCloudRuntimeContext(); + const slotValues = + Object.keys(params.slotValues).length > 0 ? params.slotValues : undefined; return { - kind: "cloud_scene", + kind: "local_service_skill", service_scene_run: { raw_text: rawText, user_input: @@ -230,20 +247,15 @@ function buildServiceSceneLaunchRequestContext(params: { skill_title: skill.title, skill_summary: skill.summary, runner_type: skill.runnerType, - execution_kind: sceneEntry.executionKind ?? skill.defaultExecutorBinding, - execution_location: skill.executionLocation, + execution_kind: normalizeLocalServiceSceneExecutionKind( + sceneEntry.executionKind ?? skill.defaultExecutorBinding, + ), + execution_location: "client_default", project_id: projectId ?? undefined, content_id: contentId ?? undefined, entry_source: "slash_scene_command", render_contract: sceneEntry.renderContract ?? undefined, - oem_runtime: runtime - ? { - base_url: runtime.baseUrl, - scene_base_url: runtime.sceneBaseUrl, - tenant_id: runtime.tenantId, - session_token: runtime.sessionToken ?? undefined, - } - : undefined, + slot_values: slotValues, }, }; } @@ -262,7 +274,7 @@ function buildSiteSceneLaunchRequestContext(params: { if (!isServiceSkillSiteCapabilityBound(skill)) { throw new RuntimeSceneLaunchValidationError( - "当前场景没有绑定站点执行能力。", + "当前这套做法没有绑定站点执行能力。", ); } @@ -320,7 +332,7 @@ export function buildServiceSceneLaunchRequestMetadata( kind: typeof requestContext.kind === "string" ? requestContext.kind - : "cloud_scene", + : "local_service_skill", ...(serviceSceneRun ? { service_scene_run: serviceSceneRun, @@ -363,6 +375,7 @@ export async function resolveRuntimeSceneLaunchRequest(params: { } let requestContext: ServiceSceneLaunchRequestContext; + let dispatchText: string | undefined; if (matchedSkill.defaultExecutorBinding === "browser_assist") { const slotResolution = resolveSiteSceneSlotValues({ skill: matchedSkill, @@ -409,19 +422,31 @@ export async function resolveRuntimeSceneLaunchRequest(params: { ), }); } else { + const slotResolution = resolveSiteSceneSlotValues({ + skill: matchedSkill, + userInput: parsedSceneCommand.userInput, + slotValueOverrides: params.slotValueOverrides, + }); requestContext = buildServiceSceneLaunchRequestContext({ rawText: params.rawText, parsedCommand: parsedSceneCommand, sceneEntry, skill: matchedSkill, + slotValues: slotResolution.resolvedSlotValues, projectId: params.projectId, contentId: params.contentId, }); + dispatchText = composeServiceSkillPrompt({ + skill: matchedSkill, + slotValues: slotResolution.resolvedSlotValues, + userInput: normalizeOptionalText(parsedSceneCommand.userInput), + }); } return { skill: matchedSkill, sceneEntry, requestContext, + dispatchText, }; } diff --git a/src/components/agent/chat/workspace/useSceneAppExecutionSummaryRuntime.test.tsx b/src/components/agent/chat/workspace/useSceneAppExecutionSummaryRuntime.test.tsx index 6a2f97777..3cc09b340 100644 --- a/src/components/agent/chat/workspace/useSceneAppExecutionSummaryRuntime.test.tsx +++ b/src/components/agent/chat/workspace/useSceneAppExecutionSummaryRuntime.test.tsx @@ -24,7 +24,7 @@ function createInitialSummary(): SceneAppExecutionSummaryViewModel { summary: "把线框图、脚本、配乐和短视频草稿压成同一条结果链。", businessLabel: "内容闭环", typeLabel: "多模态组合", - executionChainLabel: "创作场景 -> 生成 -> Project Pack", + executionChainLabel: "做法 -> 生成 -> Project Pack", deliveryContractLabel: "Project Pack", planningStatusLabel: "已就绪", planningSummary: "当前已经带入 2 条参考与 1 条风格偏好,可直接进入生成。", diff --git a/src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.test.ts b/src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.test.ts index 05fd36554..e5a42babd 100644 --- a/src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.test.ts +++ b/src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.test.ts @@ -442,7 +442,7 @@ describe("useWorkspaceConversationSceneRuntime", () => { ); }); - it("应把创作场景执行摘要卡透传给 WorkspaceConversationScene", () => { + it("应把做法执行摘要卡透传给 WorkspaceConversationScene", () => { const sceneAppExecutionSummaryCard = React.createElement( "div", { "data-testid": "sceneapp-summary-card-probe" }, diff --git a/src/components/agent/chat/workspace/useWorkspaceSceneAppEntryActions.test.tsx b/src/components/agent/chat/workspace/useWorkspaceSceneAppEntryActions.test.tsx index 125e71b46..c464c8714 100644 --- a/src/components/agent/chat/workspace/useWorkspaceSceneAppEntryActions.test.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceSceneAppEntryActions.test.tsx @@ -228,7 +228,7 @@ function createPlanResult( adapterPlan: { adapterKind: adapterPlanOverrides.adapterKind ?? "cloud_scene", runtimeAction: - adapterPlanOverrides.runtimeAction ?? "launch_cloud_scene", + adapterPlanOverrides.runtimeAction ?? "open_service_scene_session", targetRef: adapterPlanOverrides.targetRef ?? "sceneapp-service-story-video", targetLabel: adapterPlanOverrides.targetLabel ?? "短视频编排", @@ -363,11 +363,11 @@ describe("useWorkspaceSceneAppEntryActions", () => { }, plan: { sceneappId: "story-video-suite", - executorKind: "cloud_scene", - bindingFamily: "cloud_scene", + executorKind: "agent_turn", + bindingFamily: "agent_turn", adapterPlan: { - adapterKind: "cloud_scene", - runtimeAction: "launch_cloud_scene", + adapterKind: "agent_turn", + runtimeAction: "open_service_scene_session", targetRef: "sceneapp-service-story-video", targetLabel: "短视频编排", requestMetadata: { diff --git a/src/components/agent/chat/workspace/useWorkspaceSceneAppEntryActions.ts b/src/components/agent/chat/workspace/useWorkspaceSceneAppEntryActions.ts index 4ba60b0f2..df334d546 100644 --- a/src/components/agent/chat/workspace/useWorkspaceSceneAppEntryActions.ts +++ b/src/components/agent/chat/workspace/useWorkspaceSceneAppEntryActions.ts @@ -137,7 +137,7 @@ export function useWorkspaceSceneAppEntryActions({ async (sceneappId: string) => { const descriptor = descriptorMap.get(sceneappId); if (!descriptor) { - toast.error("当前创作场景目录尚未就绪,请稍后重试"); + toast.error("当前做法目录尚未就绪,请稍后重试"); return; } @@ -148,7 +148,7 @@ export function useWorkspaceSceneAppEntryActions({ urlCandidate, }); if (!seed) { - toast.error("当前创作场景还缺少启动内容,请先补充输入"); + toast.error("当前这套做法还缺少启动内容,请先补充输入"); return; } diff --git a/src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx b/src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx index c7c5700f4..510eda9d1 100644 --- a/src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx @@ -138,8 +138,8 @@ function createCloudSceneSkill(): ServiceSkillHomeItem { return { id: "cloud-video-dubbing", skillKey: "campaign-launch", - title: "云端视频配音", - summary: "把视频文案与素材提交到云端,生成一版可继续加工的配音结果。", + title: "视频配音", + summary: "围绕视频文案与素材整理一版可继续加工的配音稿。", category: "视频创作", outputHint: "配音文案 + 结果摘要", source: "cloud_catalog", @@ -150,10 +150,10 @@ function createCloudSceneSkill(): ServiceSkillHomeItem { badge: "云目录", recentUsedAt: null, isRecent: false, - runnerLabel: "云端托管执行", + runnerLabel: "立即开始", runnerTone: "slate", - runnerDescription: "提交到 OEM 云端执行,结果由服务端异步返回。", - actionLabel: "提交云端", + runnerDescription: "直接在当前工作区整理首版配音稿。", + actionLabel: "对话内补参", automationStatus: null, slotSchema: [ { @@ -1741,6 +1741,7 @@ describe("useWorkspaceSendActions", () => { expect.objectContaining({ skillId: "research", runnerType: "instant", + launchUserInput: "AI Agent 融资 36Kr 近30天 融资额与产品发布", slotValues: { prompt: "AI Agent 融资 36Kr 近30天 融资额与产品发布", query: "AI Agent 融资", @@ -1940,19 +1941,6 @@ describe("useWorkspaceSendActions", () => { executionKind: "cloud_scene", }, ]); - mockResolveOemCloudRuntimeContext.mockReturnValue({ - baseUrl: "https://user.limeai.run", - controlPlaneBaseUrl: "https://user.limeai.run/api", - sceneBaseUrl: "https://user.limeai.run/scene-api", - gatewayBaseUrl: "https://user.limeai.run/gateway-api", - tenantId: "tenant-demo", - sessionToken: "session-token-demo", - hubProviderName: null, - loginPath: "/login", - desktopClientId: "desktop-client", - desktopOauthRedirectUrl: "lime://oauth/callback", - desktopOauthNextPath: "/welcome", - }); const harness = mountHook({ input: "帮我做一版新品活动启动方案", serviceSkills: [createCloudSceneSkill()], @@ -1982,8 +1970,9 @@ describe("useWorkspaceSendActions", () => { }); expect(mockSendMessage).toHaveBeenCalledTimes(1); - expect(mockSendMessage.mock.calls[0]?.[0]).toBe( - "/campaign-launch 帮我做一版新品活动启动方案", + expect(mockSendMessage.mock.calls[0]?.[0]).toContain("[技能任务] 视频配音"); + expect(mockSendMessage.mock.calls[0]?.[0]).toContain( + "[补充要求] 帮我做一版新品活动启动方案", ); const sendOptions = mockSendMessage.mock.calls[0]?.[8]; expect(sendOptions).toMatchObject({ @@ -1997,7 +1986,7 @@ describe("useWorkspaceSendActions", () => { expect(sendOptions?.requestMetadata).toMatchObject({ harness: { service_scene_launch: { - kind: "cloud_scene", + kind: "local_service_skill", service_scene_run: expect.objectContaining({ scene_key: "campaign-launch", user_input: "帮我做一版新品活动启动方案", @@ -3796,6 +3785,7 @@ describe("useWorkspaceSendActions", () => { expect.objectContaining({ skillId: "content_post_with_cover", runnerType: "instant", + launchUserInput: "帮我把这篇春日咖啡活动文案整理成可直接上传的版本", slotValues: { prompt: "帮我把这篇春日咖啡活动文案整理成可直接上传的版本", content: @@ -3888,20 +3878,7 @@ describe("useWorkspaceSendActions", () => { } }); - it("@配音 应保留原始消息,并通过 service_scene_launch 交给云端技能主链", async () => { - mockResolveOemCloudRuntimeContext.mockReturnValue({ - baseUrl: "https://user.limeai.run", - controlPlaneBaseUrl: "https://user.limeai.run/api", - sceneBaseUrl: "https://user.limeai.run/scene-api", - gatewayBaseUrl: "https://user.limeai.run/gateway-api", - tenantId: "tenant-demo", - sessionToken: "session-token-demo", - hubProviderName: null, - loginPath: "/login", - desktopClientId: "desktop-client", - desktopOauthRedirectUrl: "lime://oauth/callback", - desktopOauthNextPath: "/welcome", - }); + it("@配音 应保留原始消息,并通过本地 service scene launch 注入配音 prompt", async () => { const harness = mountHook({ input: "@配音 目标语言: 英文 风格: 科技感 给这个新品视频做一版发布配音稿", serviceSkills: [createCloudSceneSkill()], @@ -3914,28 +3891,26 @@ describe("useWorkspaceSendActions", () => { }); expect(mockSendMessage).toHaveBeenCalledTimes(1); - expect(mockSendMessage.mock.calls[0]?.[0]).toBe( - "@配音 目标语言: 英文 风格: 科技感 给这个新品视频做一版发布配音稿", + expect(mockSendMessage.mock.calls[0]?.[0]).toContain("[技能任务] 视频配音"); + expect(mockSendMessage.mock.calls[0]?.[0]).toContain( + "[补充要求] 给这个新品视频做一版发布配音稿", ); expect(mockSendMessage.mock.calls[0]?.[8]).toMatchObject({ + displayContent: "@配音 目标语言: 英文 风格: 科技感 给这个新品视频做一版发布配音稿", requestMetadata: { harness: { service_scene_launch: { - kind: "cloud_scene", + kind: "local_service_skill", service_scene_run: expect.objectContaining({ scene_key: "voice_runtime", command_prefix: "@配音", skill_id: "cloud-video-dubbing", - skill_title: "云端视频配音", + skill_title: "视频配音", user_input: "给这个新品视频做一版发布配音稿", entry_source: "at_voice_command", project_id: "project-1", target_language: "英文", voice_style: "科技感", - oem_runtime: expect.objectContaining({ - scene_base_url: "https://user.limeai.run/scene-api", - tenant_id: "tenant-demo", - }), }), }, }, @@ -3952,6 +3927,7 @@ describe("useWorkspaceSendActions", () => { expect.objectContaining({ skillId: "cloud-video-dubbing", runnerType: "instant", + launchUserInput: "给这个新品视频做一版发布配音稿", slotValues: { user_input: "给这个新品视频做一版发布配音稿", target_language: "英文", @@ -4102,19 +4078,6 @@ describe("useWorkspaceSendActions", () => { executionKind: "cloud_scene", }, ]); - mockResolveOemCloudRuntimeContext.mockReturnValueOnce({ - baseUrl: "https://user.limeai.run", - controlPlaneBaseUrl: "https://user.limeai.run/api", - sceneBaseUrl: "https://user.limeai.run/scene-api", - gatewayBaseUrl: "https://user.limeai.run/gateway-api", - tenantId: "tenant-demo", - sessionToken: "session-token-demo", - hubProviderName: null, - loginPath: "/login", - desktopClientId: "desktop-client", - desktopOauthRedirectUrl: "lime://oauth/callback", - desktopOauthNextPath: "/welcome", - }); const harness = mountHook({ input: "/campaign-launch 帮我做一版新品活动启动方案", serviceSkills: [createCloudSceneSkill()], @@ -4127,10 +4090,12 @@ describe("useWorkspaceSendActions", () => { }); expect(mockSendMessage).toHaveBeenCalledTimes(1); - expect(mockSendMessage.mock.calls[0]?.[0]).toBe( - "/campaign-launch 帮我做一版新品活动启动方案", + expect(mockSendMessage.mock.calls[0]?.[0]).toContain("[技能任务] 视频配音"); + expect(mockSendMessage.mock.calls[0]?.[0]).toContain( + "[补充要求] 帮我做一版新品活动启动方案", ); expect(mockSendMessage.mock.calls[0]?.[8]).toMatchObject({ + displayContent: "/campaign-launch 帮我做一版新品活动启动方案", capabilityRoute: { kind: "runtime_scene", sceneKey: "campaign-launch", @@ -4139,17 +4104,13 @@ describe("useWorkspaceSendActions", () => { requestMetadata: { harness: { service_scene_launch: { - kind: "cloud_scene", + kind: "local_service_skill", service_scene_run: expect.objectContaining({ scene_key: "campaign-launch", skill_id: "cloud-video-dubbing", - skill_title: "云端视频配音", + skill_title: "视频配音", user_input: "帮我做一版新品活动启动方案", project_id: "project-1", - oem_runtime: expect.objectContaining({ - scene_base_url: "https://user.limeai.run/scene-api", - tenant_id: "tenant-demo", - }), }), }, }, diff --git a/src/components/agent/chat/workspace/useWorkspaceSendActions.ts b/src/components/agent/chat/workspace/useWorkspaceSendActions.ts index 709af8312..53cb87f54 100644 --- a/src/components/agent/chat/workspace/useWorkspaceSendActions.ts +++ b/src/components/agent/chat/workspace/useWorkspaceSendActions.ts @@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import type { Dispatch, SetStateAction } from "react"; import type { AutoContinueRequestPayload } from "@/lib/api/agentRuntime"; -import { resolveOemCloudRuntimeContext } from "@/lib/api/oemCloudRuntime"; import { getOrCreateDefaultProject } from "@/lib/api/project"; import { parseAnalysisWorkbenchCommand } from "../utils/analysisWorkbenchCommand"; import { parseBrowserWorkbenchCommand } from "../utils/browserWorkbenchCommand"; @@ -111,6 +110,7 @@ import { } from "../skill-selection/mentionEntryUsage"; import { useRuntimeMentionCommandCatalog } from "../skill-selection/runtimeInputCapabilityCatalog"; import { recordServiceSkillUsage } from "../service-skills/storage"; +import { composeServiceSkillPrompt } from "../service-skills/promptComposer"; import { recordSlashEntryUsage } from "../skill-selection/slashEntryUsage"; import { CONTENT_POST_SKILL_KEY } from "../utils/contentPostSkill"; @@ -195,6 +195,7 @@ type CompletedMentionUsage = { skillId: string; runnerType: ServiceSkillHomeItem["runnerType"]; slotValues?: ServiceSkillSlotValues; + launchUserInput?: string; }; type CompletedMentionCommandUsage = { entryId: string; @@ -353,7 +354,7 @@ const MENTION_USAGE_REQUEST_FIELDS: Readonly> "style", "tech_stack", ], - cloud_scene: ["user_input", "target_language", "voice_style"], + service_scene: ["user_input", "target_language", "voice_style"], publish_command: [ "prompt", "content", @@ -490,7 +491,7 @@ function resolveMentionCommandUsageSlotValues( if (serviceSceneRun) { return pickUsageSlotValues( serviceSceneRun, - MENTION_USAGE_REQUEST_FIELDS.cloud_scene, + MENTION_USAGE_REQUEST_FIELDS.service_scene, ); } @@ -517,6 +518,51 @@ function resolveMentionCommandUsageSlotValues( return undefined; } +function resolveMentionCommandUsageLaunchUserInput( + requestMetadata: Record | undefined, +): string | undefined { + const harness = asRecord(requestMetadata?.harness); + if (!harness) { + return undefined; + } + + const publishCommand = asRecord(harness.publish_command); + if (publishCommand) { + return normalizeOptionalText(publishCommand.prompt as string | undefined); + } + + const serviceSceneRun = asRecord( + asRecord(harness.service_scene_launch)?.service_scene_run, + ); + if (serviceSceneRun) { + return normalizeOptionalText( + serviceSceneRun.user_input as string | undefined, + ); + } + + for (const launch of MENTION_USAGE_MODEL_SKILL_LAUNCHES) { + const launchMetadata = asRecord(harness[launch.launchKey]); + if (!launchMetadata) { + continue; + } + + const scopedRequestContext = resolveLaunchScopedRequestContext( + launchMetadata, + launch.requestContextKey, + ); + if (!scopedRequestContext) { + continue; + } + + return normalizeOptionalText( + ((scopedRequestContext.user_input ?? + scopedRequestContext.prompt) as string | undefined) ?? undefined, + ); + } + + return undefined; +} + function resolveImageMentionCommandKey( trigger: ParsedImageWorkbenchCommand["trigger"], ): string | null { @@ -1094,11 +1140,15 @@ function resolveMentionCommandUsage(params: { const slotValues = resolveMentionCommandUsageSlotValues( params.requestMetadata, ); + const launchUserInput = resolveMentionCommandUsageLaunchUserInput( + params.requestMetadata, + ); return { skillId: matchedSkill.id, runnerType: matchedSkill.runnerType, ...(slotValues ? { slotValues } : {}), + ...(launchUserInput ? { launchUserInput } : {}), }; } @@ -2160,21 +2210,44 @@ function resolveVoiceCommandServiceSkill( serviceSkills.find( (skill) => matchesVoiceCommandSkill(skill) && - (skill.defaultExecutorBinding === "cloud_scene" || - skill.executionLocation === "cloud_required"), + skill.defaultExecutorBinding !== "browser_assist" && + skill.slotSchema.some((slot) => + ["reference_video", "target_language", "voice_style"].includes( + slot.key, + ), + ), ) || serviceSkills.find((skill) => matchesVoiceCommandSkill(skill)) || null ); } +function normalizeLocalServiceSkillExecutionKind( + value?: string | null, +): "agent_turn" | "native_skill" | "automation_job" { + if (value === "native_skill") { + return "native_skill"; + } + + if (value === "automation_job") { + return "automation_job"; + } + + return "agent_turn"; +} + +interface VoiceSkillLaunchRequest { + dispatchText: string; + requestContext: Record; +} + async function resolveVoiceSkillLaunchRequestContext(params: { rawText: string; parsedCommand: ParsedVoiceWorkbenchCommand; serviceSkills: ServiceSkillHomeItem[]; projectId?: string | null; contentId?: string | null; -}): Promise | null> { +}): Promise { const skill = resolveVoiceCommandServiceSkill(params.serviceSkills); if (!skill) { toast.error("当前未安装可用的配音技能,请先同步技能目录后再试"); @@ -2203,39 +2276,49 @@ async function resolveVoiceSkillLaunchRequestContext(params: { return null; } - const runtime = resolveOemCloudRuntimeContext(); - if (!runtime) { - toast.error("当前未连接云端运行时,请稍后再试"); - return null; - } + const slotValues: ServiceSkillSlotValues = { + ...(params.parsedCommand.targetLanguage + ? { + target_language: params.parsedCommand.targetLanguage, + } + : {}), + ...(params.parsedCommand.voiceStyle + ? { + voice_style: params.parsedCommand.voiceStyle, + } + : {}), + }; return { - kind: "cloud_scene", - service_scene_run: { - raw_text: params.rawText, - user_input: prompt, - entry_id: "command:voice_runtime", - scene_key: "voice_runtime", - command_prefix: params.parsedCommand.trigger, - linked_skill_id: skill.id, - skill_id: skill.id, - skill_key: skill.skillKey || undefined, - skill_title: skill.title, - skill_summary: skill.summary, - runner_type: skill.runnerType, - execution_kind: skill.defaultExecutorBinding, - execution_location: skill.executionLocation, - project_id: resolvedProjectId, - content_id: normalizeOptionalText(params.contentId), - entry_source: "at_voice_command", - target_language: params.parsedCommand.targetLanguage, - voice_style: params.parsedCommand.voiceStyle, - oem_runtime: { - scene_base_url: runtime.sceneBaseUrl, - tenant_id: runtime.tenantId, - session_token: runtime.sessionToken, - base_url: runtime.baseUrl, - gateway_base_url: runtime.gatewayBaseUrl, + dispatchText: composeServiceSkillPrompt({ + skill, + slotValues, + userInput: prompt, + }), + requestContext: { + kind: "local_service_skill", + service_scene_run: { + raw_text: params.rawText, + user_input: prompt, + entry_id: "command:voice_runtime", + scene_key: "voice_runtime", + command_prefix: params.parsedCommand.trigger, + linked_skill_id: skill.id, + skill_id: skill.id, + skill_key: skill.skillKey || undefined, + skill_title: skill.title, + skill_summary: skill.summary, + runner_type: skill.runnerType, + execution_kind: normalizeLocalServiceSkillExecutionKind( + skill.defaultExecutorBinding, + ), + execution_location: "client_default", + project_id: resolvedProjectId, + content_id: normalizeOptionalText(params.contentId), + entry_source: "at_voice_command", + target_language: params.parsedCommand.targetLanguage, + voice_style: params.parsedCommand.voiceStyle, + slot_values: Object.keys(slotValues).length > 0 ? slotValues : undefined, }, }, }; @@ -4056,24 +4139,25 @@ export function useWorkspaceSendActions({ ? parseVoiceWorkbenchCommand(sourceText) : null; if (parsedVoiceWorkbenchCommand) { - const requestContext = await resolveVoiceSkillLaunchRequestContext({ + const voiceSkillLaunch = await resolveVoiceSkillLaunchRequestContext({ rawText: sourceText, parsedCommand: parsedVoiceWorkbenchCommand, serviceSkills, projectId, contentId, }); - if (!requestContext) { + if (!voiceSkillLaunch) { clearSubmissionPreview(); return { kind: "done", result: false }; } ensureSubmissionPreview(); + dispatchText = voiceSkillLaunch.dispatchText; sendOptions = { ...(sendOptions || {}), requestMetadata: buildServiceSceneLaunchRequestMetadata( sendOptions?.requestMetadata, - requestContext, + voiceSkillLaunch.requestContext, ), }; markCompletedMentionCommand( @@ -4154,6 +4238,9 @@ export function useWorkspaceSendActions({ throw error; } if (sceneLaunchRequest) { + if (sceneLaunchRequest.dispatchText) { + dispatchText = sceneLaunchRequest.dispatchText; + } sendOptions = { ...(sendOptions || {}), requestMetadata: buildServiceSceneLaunchRequestMetadata( diff --git a/src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.test.tsx b/src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.test.tsx index 3dc7f1cc4..a97209732 100644 --- a/src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.test.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.test.tsx @@ -8,9 +8,6 @@ import type { ServiceSkillHomeItem } from "../service-skills/types"; import { useWorkspaceServiceSkillEntryActions } from "./useWorkspaceServiceSkillEntryActions"; const mockCreateAutomationJob = vi.fn(); -const mockCreateServiceSkillRun = vi.fn(); -const mockGetServiceSkillRun = vi.fn(); -const mockIsTerminalServiceSkillRunStatus = vi.fn(); const mockCreateContent = vi.fn(); const mockListProjects = vi.fn(); const mockGetOrCreateDefaultProject = vi.fn(); @@ -34,14 +31,6 @@ vi.mock("@/lib/api/automation", () => ({ createAutomationJob: (request: unknown) => mockCreateAutomationJob(request), })); -vi.mock("@/lib/api/serviceSkillRuns", () => ({ - createServiceSkillRun: (...args: unknown[]) => - mockCreateServiceSkillRun(...args), - getServiceSkillRun: (...args: unknown[]) => mockGetServiceSkillRun(...args), - isTerminalServiceSkillRunStatus: (status: unknown) => - mockIsTerminalServiceSkillRunStatus(status), -})); - vi.mock("@/lib/api/project", () => ({ createContent: (request: unknown) => mockCreateContent(request), getOrCreateDefaultProject: () => mockGetOrCreateDefaultProject(), @@ -217,12 +206,12 @@ function createScheduledServiceSkill(): ServiceSkillHomeItem { }; } -function createCloudServiceSkill(): ServiceSkillHomeItem { +function createLegacyCompatServiceSkill(): ServiceSkillHomeItem { return { id: "cloud-video-dubbing", skillKey: "campaign-launch", - title: "云端视频配音", - summary: "把视频文案与素材提交到云端,生成一版可继续加工的配音结果。", + title: "视频配音", + summary: "围绕视频文案与素材整理一版可继续加工的配音稿。", category: "视频创作", outputHint: "配音文案 + 结果摘要", source: "cloud_catalog", @@ -244,10 +233,10 @@ function createCloudServiceSkill(): ServiceSkillHomeItem { badge: "云目录", recentUsedAt: null, isRecent: false, - runnerLabel: "云端托管执行", + runnerLabel: "立即开始", runnerTone: "slate", - runnerDescription: "提交到 OEM 云端执行,结果由服务端异步返回。", - actionLabel: "提交云端", + runnerDescription: "直接在当前工作区整理首版配音稿。", + actionLabel: "对话内补参", automationStatus: null, }; } @@ -316,12 +305,6 @@ beforeEach(() => { id: "automation-job-1", name: "每日趋势摘要|定时执行", }); - mockCreateServiceSkillRun.mockReset(); - mockGetServiceSkillRun.mockReset(); - mockIsTerminalServiceSkillRunStatus.mockReset(); - mockIsTerminalServiceSkillRunStatus.mockImplementation((status: string) => - ["success", "failed", "canceled", "timeout"].includes(status), - ); mockCreateContent.mockResolvedValue({ id: "content-created-by-service-skill", }); @@ -691,6 +674,37 @@ describe("useWorkspaceServiceSkillEntryActions", () => { }); }); + it("显式透传的 launchUserInput 应随 recent usage 一起记录下来", async () => { + const onNavigate = vi.fn(); + const recordServiceSkillUsage = vi.fn(); + const { render, getValue } = renderHook({ + onNavigate, + recordServiceSkillUsage, + }); + await render(); + + await act(async () => { + await getValue().handleServiceSkillLaunch( + createBrowserServiceSkill(), + { + repository_query: "browser assist mcp", + }, + { + launchUserInput: "优先看最近 30 天仍在活跃更新的仓库", + }, + ); + }); + + expect(recordServiceSkillUsage).toHaveBeenCalledWith({ + skillId: "github-repo-radar", + runnerType: "instant", + slotValues: { + repository_query: "browser assist mcp", + }, + launchUserInput: "优先看最近 30 天仍在活跃更新的仓库", + }); + }); + it("站点型技能缺少附着会话时应留在入口层并提示先准备浏览器", async () => { const onNavigate = vi.fn(); const { render, getValue } = renderHook({ @@ -719,16 +733,9 @@ describe("useWorkspaceServiceSkillEntryActions", () => { expect(mockToastError).not.toHaveBeenCalled(); }); - it("cloud_required 服务型技能成功后应回流本地工作区", async () => { + it("legacy cloud_required 服务型技能当前也应按本地执行主链进入工作区", async () => { const onNavigate = vi.fn(); const recordServiceSkillUsage = vi.fn(); - mockCreateServiceSkillRun.mockResolvedValue({ - id: "service-skill-run-cloud-1", - status: "success", - outputSummary: "云端结果已生成", - outputText: "# 云端视频配音\n\n第一版成稿", - finishedAt: "2026-03-26T01:02:03.000Z", - }); const { render, getValue } = renderHook({ activeTheme: "general", @@ -738,48 +745,20 @@ describe("useWorkspaceServiceSkillEntryActions", () => { await render(); await act(async () => { - await getValue().handleServiceSkillLaunch(createCloudServiceSkill(), { - reference_video: "https://example.com/cloud-video", - }); + await getValue().handleServiceSkillLaunch( + createLegacyCompatServiceSkill(), + { + reference_video: "https://example.com/cloud-video", + }, + ); }); - expect(mockCreateServiceSkillRun).toHaveBeenCalledWith( - "cloud-video-dubbing", - expect.stringContaining("[技能任务] 云端视频配音"), - ); - expect(mockCreateServiceSkillRun).toHaveBeenCalledWith( - "cloud-video-dubbing", - expect.stringContaining( - "- 参考视频链接/素材: https://example.com/cloud-video", - ), - ); - expect(mockCreateContent).toHaveBeenCalledWith( - expect.objectContaining({ - project_id: "project-1", - title: "云端视频配音", - content_type: "post", - body: "# 云端视频配音\n\n第一版成稿", - metadata: expect.objectContaining({ - source: "service_skill", - serviceSkill: expect.objectContaining({ - id: "cloud-video-dubbing", - executionLocation: "cloud_required", - themeTarget: "general", - }), - cloudRun: expect.objectContaining({ - id: "service-skill-run-cloud-1", - status: "success", - outputSummary: "云端结果已生成", - finishedAt: "2026-03-26T01:02:03.000Z", - }), - }), - }), - ); + expect(mockCreateContent).not.toHaveBeenCalled(); expect(onNavigate).toHaveBeenCalledWith( "agent", expect.objectContaining({ projectId: "project-1", - contentId: "content-created-by-service-skill", + contentId: "content-current", theme: "general", initialCreationMode: "guided", initialRequestMetadata: { @@ -798,15 +777,8 @@ describe("useWorkspaceServiceSkillEntryActions", () => { reference_video: "https://example.com/cloud-video", }, }); - expect(mockToastLoading).toHaveBeenCalledWith( - "正在开始 云端视频配音 的云端执行...", - ); - expect(mockToastSuccess).toHaveBeenCalledWith( - "云端视频配音 云端运行完成:云端结果已生成,正在回流本地工作区。", - { - id: "toast-loading", - }, - ); + expect(mockToastLoading).not.toHaveBeenCalled(); + expect(mockToastSuccess).not.toHaveBeenCalled(); }); it("普通技能进入工作区时应在保留 seed metadata 的同时注入当前 Team", async () => { diff --git a/src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.ts b/src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.ts index 11599eca8..26804fbbd 100644 --- a/src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.ts +++ b/src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.ts @@ -2,12 +2,6 @@ import { useCallback, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { siteGetAdapterLaunchReadiness } from "@/lib/webview-api"; import { createAutomationJob } from "@/lib/api/automation"; -import { - createServiceSkillRun, - getServiceSkillRun, - isTerminalServiceSkillRunStatus, - type ServiceSkillRun, -} from "@/lib/api/serviceSkillRuns"; import { createContent, getOrCreateDefaultProject, @@ -40,7 +34,6 @@ import { supportsServiceSkillLocalAutomation, } from "../service-skills/automationDraft"; import { recordServiceSkillAutomationLink } from "../service-skills/automationLinkStorage"; -import { recordServiceSkillCloudRun } from "../service-skills/cloudRunStorage"; import { buildServiceSkillLaunchA2UIResponse, readServiceSkillLaunchSlotValuesFromFormData, @@ -68,21 +61,6 @@ import type { import type { TeamDefinition } from "../utils/teamDefinitions"; import { attachSelectedTeamToRequestMetadata } from "../utils/teamRequestMetadata"; -const SERVICE_SKILL_RUN_STATUS_LABELS: Record = { - queued: "排队中", - running: "运行中", - success: "已完成", - failed: "执行失败", - canceled: "已取消", - timeout: "已超时", -}; - -function sleep(ms: number): Promise { - return new Promise((resolve) => { - window.setTimeout(resolve, ms); - }); -} - function getErrorMessage(error: unknown): string { if (error instanceof Error) { return error.message; @@ -116,7 +94,6 @@ function siteSkillRequiresProject(skill: ServiceSkillHomeItem): boolean { interface ServiceSkillLaunchOptions { launchUserInput?: string | null; - fallbackToWorkspaceOnCloudSubmitFailure?: boolean; } interface ServiceSkillSelectionOptions { @@ -137,35 +114,6 @@ function resolveServiceSkillLaunchUserInput( return normalizeOptionalText(currentInput); } -function buildServiceSkillCloudResultBody( - skill: ServiceSkillHomeItem, - run: ServiceSkillRun, -): string { - return ( - normalizeOptionalText(run.outputText) || - normalizeOptionalText(run.outputSummary) || - `# ${skill.title}\n\n云端结果已生成。` - ); -} - -function buildServiceSkillCloudResultMetadata( - run: ServiceSkillRun, -): Record { - return { - cloudRun: { - id: run.id, - status: run.status, - executorKind: run.executorKind ?? null, - outputSummary: normalizeOptionalText(run.outputSummary) ?? null, - errorCode: run.errorCode ?? null, - errorMessage: run.errorMessage ?? null, - startedAt: run.startedAt ?? null, - finishedAt: run.finishedAt ?? null, - updatedAt: run.updatedAt ?? null, - }, - }; -} - function resolveFallbackProjectType(theme?: string): Project["workspaceType"] { return normalizeThemeType(theme); } @@ -210,22 +158,6 @@ function prioritizeAutomationWorkspaces( return [fallbackWorkspace, ...remaining]; } -function getServiceSkillRunStatusLabel(status: string): string { - return SERVICE_SKILL_RUN_STATUS_LABELS[status] ?? status; -} - -function buildServiceSkillRunSuccessMessage( - skill: ServiceSkillHomeItem, - run: ServiceSkillRun, -): string { - const summary = run.outputSummary || run.outputText || run.inputSummary; - if (summary) { - return `${skill.title} 云端运行完成:${summary},正在回流本地工作区。`; - } - - return `${skill.title} 云端运行完成,正在回流本地工作区。`; -} - interface PendingServiceSkillAutomationLaunch { skill: ServiceSkillHomeItem; prompt: string; @@ -553,42 +485,6 @@ export function useWorkspaceServiceSkillEntryActions({ ], ); - const prepareServiceSkillCloudResultWorkspacePayload = useCallback( - async ( - skill: ServiceSkillHomeItem, - run: ServiceSkillRun, - ): Promise => { - const seed = buildServiceSkillWorkspaceSeed( - skill, - skill.themeTarget ?? activeTheme, - ); - - if (!currentProjectId || !seed) { - return null; - } - - const created = await createServiceSkillSeededContent( - skill, - currentProjectId, - { - body: buildServiceSkillCloudResultBody(skill, run), - metadata: buildServiceSkillCloudResultMetadata(run), - }, - ); - - if (!created) { - return null; - } - - return { - contentId: created.id, - themeOverride: skill.themeTarget, - initialRequestMetadata: seed.requestMetadata, - }; - }, - [activeTheme, createServiceSkillSeededContent, currentProjectId], - ); - const handleServiceSkillBrowserRuntimeLaunch = useCallback( async ( skill: ServiceSkillHomeItem, @@ -702,6 +598,11 @@ export function useWorkspaceServiceSkillEntryActions({ slotValues: ServiceSkillSlotValues, options?: ServiceSkillLaunchOptions, ): Promise => { + const persistedLaunchUserInput = + options && "launchUserInput" in options + ? normalizeOptionalText(options.launchUserInput) + : undefined; + if (isServiceSkillExecutableAsSiteAdapter(skill)) { let resolvedCapability: ResolvedServiceSkillSiteCapabilityExecution; try { @@ -753,150 +654,23 @@ export function useWorkspaceServiceSkillEntryActions({ skillId: skill.id, runnerType: skill.runnerType, slotValues, + ...(persistedLaunchUserInput + ? { + launchUserInput: persistedLaunchUserInput, + } + : {}), }); setPendingServiceSkillLaunchInput(null); return true; } + const launchUserInput = resolveServiceSkillLaunchUserInput(input, options); const prompt = composeServiceSkillPrompt({ skill, slotValues, - userInput: resolveServiceSkillLaunchUserInput(input, options), + userInput: launchUserInput, }); - if (skill.executionLocation === "cloud_required") { - const toastId = toast.loading(`正在开始 ${skill.title} 的云端执行...`); - let runCreated = false; - - try { - setPendingServiceSkillLaunchInput(null); - - let run = await createServiceSkillRun(skill.id, prompt); - runCreated = true; - recordServiceSkillCloudRun(skill.id, run); - recordServiceSkillUsage({ - skillId: skill.id, - runnerType: skill.runnerType, - slotValues, - }); - - if (!isTerminalServiceSkillRunStatus(run.status)) { - toast.loading( - `${skill.title} ${getServiceSkillRunStatusLabel(run.status)},正在等待结果...`, - { - id: toastId, - }, - ); - - for (let attempt = 0; attempt < 12; attempt += 1) { - await sleep(2_000); - run = await getServiceSkillRun(run.id); - recordServiceSkillCloudRun(skill.id, run); - if (isTerminalServiceSkillRunStatus(run.status)) { - break; - } - } - } - - if (run.status === "success") { - let workspacePayload: WorkspaceEntryPayload | null = null; - let workspaceErrorMessage: string | null = null; - - try { - workspacePayload = - await prepareServiceSkillCloudResultWorkspacePayload( - skill, - run, - ); - } catch (error) { - workspaceErrorMessage = getErrorMessage(error); - } - - toast.success(buildServiceSkillRunSuccessMessage(skill, run), { - id: toastId, - }); - - if (workspacePayload) { - const entered = navigateToServiceSkillWorkspace(workspacePayload); - if (!entered) { - toast.error( - "云端结果已生成,但进入工作区失败,请稍后手动打开。", - ); - return false; - } - } else if (workspaceErrorMessage) { - toast.error( - `云端结果已生成,但回流本地工作区失败:${workspaceErrorMessage}`, - ); - } - return true; - } - - if (isTerminalServiceSkillRunStatus(run.status)) { - throw new Error( - run.errorMessage || - `${skill.title} ${getServiceSkillRunStatusLabel(run.status)}`, - ); - } - - toast.info( - `${skill.title} 已开始云端执行,当前仍在 ${getServiceSkillRunStatusLabel(run.status)}。`, - { - id: toastId, - }, - ); - return true; - } catch (error) { - if (options?.fallbackToWorkspaceOnCloudSubmitFailure && !runCreated) { - let workspacePayload: WorkspaceEntryPayload; - try { - workspacePayload = await prepareServiceSkillWorkspacePayload( - skill, - prompt, - ); - } catch (workspaceError) { - toast.error( - `云端执行失败:${getErrorMessage(error)};本地回退失败:${getErrorMessage(workspaceError)}`, - { - id: toastId, - }, - ); - return false; - } - - const entered = navigateToServiceSkillWorkspace(workspacePayload); - if (!entered) { - toast.error( - `云端执行失败:${getErrorMessage(error)};进入本地工作区失败,请稍后重试。`, - { - id: toastId, - }, - ); - return false; - } - - recordServiceSkillUsage({ - skillId: skill.id, - runnerType: skill.runnerType, - slotValues, - }); - toast.info( - `${skill.title} 云端暂不可用,已切换到本地工作区继续。`, - { - id: toastId, - }, - ); - setPendingServiceSkillLaunchInput(null); - return true; - } - - toast.error(`云端执行失败:${getErrorMessage(error)}`, { - id: toastId, - }); - return false; - } - } - if (skill.runnerType !== "instant") { toast.info( "当前先进入工作区生成首版方案,下一阶段再接本地自动化任务。", @@ -923,6 +697,11 @@ export function useWorkspaceServiceSkillEntryActions({ skillId: skill.id, runnerType: skill.runnerType, slotValues, + ...(persistedLaunchUserInput + ? { + launchUserInput: persistedLaunchUserInput, + } + : {}), }); setPendingServiceSkillLaunchInput(null); return true; @@ -931,7 +710,6 @@ export function useWorkspaceServiceSkillEntryActions({ input, navigateToServiceSkillWorkspace, setPendingServiceSkillLaunchInput, - prepareServiceSkillCloudResultWorkspacePayload, prepareServiceSkillSiteWorkspacePayload, prepareServiceSkillWorkspacePayload, recordServiceSkillUsage, @@ -1013,6 +791,9 @@ export function useWorkspaceServiceSkillEntryActions({ skill, creationReplay, }); + const resolvedLaunchUserInput = + normalizeOptionalText(options?.launchUserInput) ?? + replayPrefill?.launchUserInput; const initialSlotValues = { ...createDefaultServiceSkillSlotValues(skill), ...(replayPrefill?.slotValues || {}), @@ -1022,7 +803,7 @@ export function useWorkspaceServiceSkillEntryActions({ if (skill.slotSchema.length === 0 || validation.valid) { void handleServiceSkillLaunch(skill, initialSlotValues, { - launchUserInput: options?.launchUserInput, + launchUserInput: resolvedLaunchUserInput, }); return; } @@ -1036,7 +817,7 @@ export function useWorkspaceServiceSkillEntryActions({ skill, initialSlotValues, prefillHint: options?.prefillHint ?? replayPrefill?.hint, - launchUserInput: normalizeOptionalText(options?.launchUserInput), + launchUserInput: resolvedLaunchUserInput, }); }, [creationReplay, handleServiceSkillLaunch], diff --git a/src/components/agent/chat/workspace/useWorkspaceTeamSessionRuntime.test.tsx b/src/components/agent/chat/workspace/useWorkspaceTeamSessionRuntime.test.tsx index 29465fda6..d41bd2cf5 100644 --- a/src/components/agent/chat/workspace/useWorkspaceTeamSessionRuntime.test.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceTeamSessionRuntime.test.tsx @@ -28,7 +28,7 @@ function renderHook(props?: Partial) { topics: [ { id: "session-1", - title: "主任务", + title: "主助手", }, ], turns: [{ status: "completed" }], @@ -111,7 +111,7 @@ describe("useWorkspaceTeamSessionRuntime", () => { await harness.render(); expect(harness.getValue()).toMatchObject({ - currentSessionTitle: "主任务", + currentSessionTitle: "主助手", hasRuntimeSessions: true, teamWorkspaceEnabled: true, }); diff --git a/src/components/agent/chat/workspace/workspaceSendHelpers.test.ts b/src/components/agent/chat/workspace/workspaceSendHelpers.test.ts index dee034288..4fbbc68ce 100644 --- a/src/components/agent/chat/workspace/workspaceSendHelpers.test.ts +++ b/src/components/agent/chat/workspace/workspaceSendHelpers.test.ts @@ -185,7 +185,7 @@ describe("workspaceSendHelpers runtime team preview", () => { title: "任务分工已准备好", detail: "分析、执行、验证三段式推进。", checkpoints: [ - "当前方案:修复任务方案", + "当前方案:修复分工方案", "已安排 2 项任务", "主对话会持续同步关键进展", ], diff --git a/src/components/agent/chat/workspace/workspaceSendHelpers.ts b/src/components/agent/chat/workspace/workspaceSendHelpers.ts index ccfd342e4..6b79820a0 100644 --- a/src/components/agent/chat/workspace/workspaceSendHelpers.ts +++ b/src/components/agent/chat/workspace/workspaceSendHelpers.ts @@ -850,7 +850,7 @@ function buildRuntimeTeamAssistantDraft( const teamLabel = normalizeTeamWorkspaceDisplayValue(state.label || state.blueprint?.label) || - "当前任务方案"; + "当前分工方案"; const summary = normalizeTeamWorkspaceDisplayValue( state.summary || state.blueprint?.summary, @@ -907,7 +907,7 @@ export function buildRuntimeTeamDispatchPreviewMessages( normalizeTeamWorkspaceDisplayValue( snapshot.formationState?.label || snapshot.formationState?.blueprint?.label, ) || - "当前任务方案"; + "当前分工方案"; const formedSummary = normalizeTeamWorkspaceDisplayValue( snapshot.formationState?.summary || diff --git a/src/components/agent/chat/workspaceEntry.test.ts b/src/components/agent/chat/workspaceEntry.test.ts index b432971aa..ba0d3071c 100644 --- a/src/components/agent/chat/workspaceEntry.test.ts +++ b/src/components/agent/chat/workspaceEntry.test.ts @@ -153,7 +153,7 @@ describe("workspaceEntry", () => { }); }); - it("应透传创作场景执行摘要与自动发送 metadata", () => { + it("应透传做法执行摘要与自动发送 metadata", () => { expect( resolveWorkspaceEntry({ projectId: "project-1", @@ -168,7 +168,7 @@ describe("workspaceEntry", () => { summary: "把线框图、脚本与配乐压成同一条结果链。", businessLabel: "内容闭环", typeLabel: "多模态组合", - executionChainLabel: "创作场景 -> 生成 -> Project Pack", + executionChainLabel: "做法 -> 生成 -> Project Pack", deliveryContractLabel: "Project Pack", planningStatusLabel: "已就绪", planningSummary: "当前可直接进入生成。", diff --git a/src/components/memory/MemoryPage.test.tsx b/src/components/memory/MemoryPage.test.tsx index 74d40aea4..be5ca3274 100644 --- a/src/components/memory/MemoryPage.test.tsx +++ b/src/components/memory/MemoryPage.test.tsx @@ -448,7 +448,7 @@ describe("MemoryPage", () => { ); const sceneButton = Array.from(document.body.querySelectorAll("button")).find( - (element) => element.textContent?.includes("用于创作场景"), + (element) => element.textContent?.includes("去做法目录"), ); expect(sceneButton).toBeTruthy(); diff --git a/src/components/memory/MemoryPage.tsx b/src/components/memory/MemoryPage.tsx index b575f559d..fe012c9b2 100644 --- a/src/components/memory/MemoryPage.tsx +++ b/src/components/memory/MemoryPage.tsx @@ -1904,7 +1904,7 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { handleOpenInScene(sourceEntry); }} > - 去创作场景 + 去做法目录
@@ -1921,7 +1921,7 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) {
@@ -1932,7 +1932,7 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { {tasteSummary.summary}

- 这份摘要会优先服务创作场景 planning;如果后续你继续收藏、筛掉或认可结果,这里的关键词会继续变化。 + 这份摘要会优先服务做法 planning;如果后续你继续收藏、筛掉或认可结果,这里的关键词会继续变化。

@@ -3055,7 +3055,7 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) {
diff --git a/src/components/resources/ResourcesImageWorkbench.test.tsx b/src/components/resources/ResourcesImageWorkbench.test.tsx index f954a0e2f..d8b07a5f1 100644 --- a/src/components/resources/ResourcesImageWorkbench.test.tsx +++ b/src/components/resources/ResourcesImageWorkbench.test.tsx @@ -165,6 +165,7 @@ describe("ResourcesImageWorkbench", () => { canvasType: "document", source: "gallery", image: expect.objectContaining({ + previewUrl: "asset://city.jpg", contentUrl: "asset://city.jpg", title: "城市夜景", }), diff --git a/src/components/resources/ResourcesImageWorkbench.tsx b/src/components/resources/ResourcesImageWorkbench.tsx index 56d905108..7eebfb73f 100644 --- a/src/components/resources/ResourcesImageWorkbench.tsx +++ b/src/components/resources/ResourcesImageWorkbench.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from "react"; -import { ImagePlus, Images, LocateFixed } from "lucide-react"; +import { ImagePlus, Images } from "lucide-react"; import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -26,6 +26,26 @@ interface ResourcesImageWorkbenchProps { onUploadImage?: () => Promise | void; } +function normalizeImageSrc(src?: string | null): string | null { + const normalized = src?.trim(); + if (!normalized) { + return null; + } + + if ( + normalized.startsWith("data:") || + normalized.startsWith("http://") || + normalized.startsWith("https://") || + normalized.startsWith("blob:") || + normalized.startsWith("asset:") || + normalized.startsWith("file:") + ) { + return normalized; + } + + return convertLocalFileSrc(normalized); +} + function normalizeCanvasType( value: string | null | undefined, ): CanvasImageTargetType { @@ -128,9 +148,12 @@ export function ResourcesImageWorkbench({ return; } - const imageUrl = material.filePath - ? convertLocalFileSrc(material.filePath) - : material.metadata?.thumbnail || ""; + const imageUrl = + normalizeImageSrc(material.filePath) ?? + normalizeImageSrc(material.metadata?.thumbnail) ?? + ""; + const previewUrl = + normalizeImageSrc(material.metadata?.thumbnail) ?? imageUrl; if (!imageUrl) { toast.error("该素材缺少可用图片地址,无法插入"); return; @@ -151,7 +174,7 @@ export function ResourcesImageWorkbench({ source: "gallery", image: { id: material.id, - previewUrl: material.metadata?.thumbnail || imageUrl, + previewUrl, contentUrl: imageUrl, title: material.name, width: material.metadata?.width, @@ -210,21 +233,13 @@ export function ResourcesImageWorkbench({ return (
-
-
-
- - 图片工作台 - -
-

- 我的图片库 -

-

- 本地图片上传与图片库插图动作已经收口到资料库图片视图。双击图片可直接插入当前画布,也可以先选中后再执行插入。 -

-
+
+
+
+ + 图片视图 + - - 与当前画布联动 -
+

+ 双击图片可直接插入当前画布,也可以先选中后再执行插入。 +

@@ -254,17 +266,6 @@ export function ResourcesImageWorkbench({ 上传本地图片 - {recentInsertHistory[0] && ( - - )} - - - { - void handleCreateFolder(); - }} - > - - 新建文件夹 - - { - void handleCreateDocument(); - }} - > - - 新建文档 - - { - void handleUploadFile(); - }} - > - - 上传文件 - - - + 搜索:{searchQuery.trim()} + + ) : ( + + {sortFieldLabelMap[sortField]} ·{" "} + {sortDirectionLabelMap[sortDirection]} + + )}
-
- {summaryCards.map((card) => { - const CardIcon = card.icon; +
+ {headerStats.map((stat) => { + const StatIcon = stat.icon; return (
-
-
-

- {card.title} -

- -
+
+

+ {stat.title} +

+

+ {stat.value} +

- +
-

- {card.value} -

); })} @@ -1095,49 +839,21 @@ export function ResourcesPage({ onNavigate }: ResourcesPageProps) {
-
-
-
- 资料库 - -
+
+
+ 资料库 +
- +

+ 按项目切换当前资料库,避免在这里继续保留旧的创建入口。 +

- - - +
{projectsLoading ? (
@@ -1184,29 +900,50 @@ export function ResourcesPage({ onNavigate }: ResourcesPageProps) {
-
-
-
- - 当前浏览 -
-
-

- {currentFolder?.name ?? activeCategoryLabel} -

-

- {contentPanelDescription} -

-
+
+
+ + setSearchQuery(event.target.value)} + placeholder={ + isFolderMode + ? "按名称、描述或标签搜索" + : "搜索当前分类内容" + } + className="h-11 rounded-xl border-slate-200 bg-slate-50/80 pl-10 shadow-none focus-visible:ring-slate-300" + />
-
- + + + +
-
-
- - setSearchQuery(event.target.value)} - placeholder={ - isFolderMode - ? "按名称、描述或标签搜索" - : "搜索当前分类内容" - } - className="h-11 rounded-xl border-slate-200 bg-slate-50/80 pl-10 shadow-none focus-visible:ring-slate-300" - /> -
+
+
+
+
+ + 当前范围 + + {scopeModeLabel} + +
+

+ {scopeStatusDescription} +

+
- - - - -
- {headingDescription} +
+ {crossProjectMediaHint ? ( + + ) : isFolderMode ? ( + <> + + {breadcrumbs.map((folder) => ( + + ))} + + ) : ( + + {activeCategoryLabel}分类视图 + + )} +
- - {isFolderMode ? ( -
- - {breadcrumbs.map((folder) => ( - - ))} -
- ) : ( -
- 当前为「{activeCategoryLabel} - 」分类视图,展示整个资料库内该分类内容。 -
- )} - - {crossProjectMediaHint && ( -
- - 当前资料库暂无 - {mediaCategoryLabelMap[crossProjectMediaHint.category]} - ,检测到「{crossProjectMediaHint.projectName}」包含{" "} - {crossProjectMediaHint.count} 个 - {mediaCategoryLabelMap[crossProjectMediaHint.category]}。 - - -
- )}
{(error || projectError) && ( @@ -1356,25 +1090,15 @@ export function ResourcesPage({ onNavigate }: ResourcesPageProps) {
-
-
-
-

- 内容列表 -

- -
-
- - 最近更新:{latestVisibleUpdateLabel} - +
+

+ 内容列表 +

+
@@ -1388,43 +1112,16 @@ export function ResourcesPage({ onNavigate }: ResourcesPageProps) { 资料加载中...
) : showEmptyState ? ( -
+
- +
-

- 先把内容放进资料库 +

+ 当前范围内暂无内容

- 当前范围内还没有可展示的内容。可以先创建文件夹整理结构、写一份文档,或者把本地文件直接上传进来。 + 资料库页当前只保留切换、浏览和打开主链。若要补图片,请切到上方图片视图继续上传;其他内容请回对应项目或创作链路处理。

- -
- {emptyActions.map((item) => { - const ItemIcon = item.icon; - return ( - - ); - })} -
) : (
@@ -1449,7 +1146,7 @@ export function ResourcesPage({ onNavigate }: ResourcesPageProps) { - {displayItems.map((item) => { + {pagedDisplayItems.map((item) => { const Icon = getKindIcon(item); return ( + +
+
+ + 显示第 {pageRangeStart}-{pageRangeEnd} 条,共{" "} + {displayItems.length} 条 + + + 每页 {RESOURCE_PAGE_SIZE} 条 + +
+ +
+ + + 第 {safeCurrentPage} / {totalPages} 页 + + +
+
)}
diff --git a/src/components/resources/services/resourceAdapter.ts b/src/components/resources/services/resourceAdapter.ts index b4273f798..eef5fbadb 100644 --- a/src/components/resources/services/resourceAdapter.ts +++ b/src/components/resources/services/resourceAdapter.ts @@ -1,5 +1,4 @@ import { - createContent, deleteContent, getContent, listContents, @@ -246,39 +245,6 @@ export const fetchProjectResources = async ( return [...contentResources, ...materialResources]; }; -export const createFolderResource = async ( - projectId: string, - name: string, - parentId: string | null, -): Promise => { - await createContent({ - project_id: projectId, - title: name, - content_type: "document", - metadata: { - parentId, - resourceKind: "folder", - }, - }); -}; - -export const createDocumentResource = async ( - projectId: string, - name: string, - parentId: string | null, -): Promise => { - await createContent({ - project_id: projectId, - title: name, - content_type: "document", - body: "", - metadata: { - parentId, - resourceKind: "document", - }, - }); -}; - export const renameResource = async ( item: ResourceItem, name: string, diff --git a/src/components/resources/services/resourceQueries.test.ts b/src/components/resources/services/resourceQueries.test.ts new file mode 100644 index 000000000..bcb738a79 --- /dev/null +++ b/src/components/resources/services/resourceQueries.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from "vitest"; +import { + canNavigateResourceFolderUp, + getCategoryCounts, + getCategoryScopedResources, + getCurrentFolder, + getFolderBreadcrumbs, + getFolderScopedResources, + getResourceMediaType, +} from "./resourceQueries"; +import type { ResourceItem } from "./types"; + +function createResource(overrides: Partial): ResourceItem { + return { + id: "resource-id", + projectId: "project-1", + name: "资料", + kind: "document", + sourceType: "content", + parentId: null, + createdAt: 1, + updatedAt: 1, + ...overrides, + }; +} + +describe("resourceQueries", () => { + it("目录视图应共用同一套目录筛选与排序规则", () => { + const rootFolder = createResource({ + id: "folder-1", + kind: "folder", + name: "参考资料", + updatedAt: 3, + }); + const rootDocument = createResource({ + id: "doc-1", + name: "项目说明", + updatedAt: 2, + }); + const nestedDocument = createResource({ + id: "doc-2", + name: "目录内说明", + parentId: "folder-1", + updatedAt: 4, + }); + const rootFile = createResource({ + id: "file-1", + kind: "file", + sourceType: "material", + name: "封面图.png", + filePath: "/tmp/cover.png", + mimeType: "image/png", + updatedAt: 5, + }); + + const rootItems = getFolderScopedResources( + [rootDocument, nestedDocument, rootFolder, rootFile], + null, + "", + "updatedAt", + "desc", + ); + + expect(rootItems.map((item) => item.id)).toEqual([ + "folder-1", + "file-1", + "doc-1", + ]); + + const nestedItems = getFolderScopedResources( + [rootDocument, nestedDocument, rootFolder, rootFile], + "folder-1", + "", + "updatedAt", + "desc", + ); + + expect(nestedItems.map((item) => item.id)).toEqual(["doc-2"]); + expect( + getCurrentFolder([rootDocument, nestedDocument, rootFolder], "folder-1") + ?.id, + ).toBe("folder-1"); + expect( + getFolderBreadcrumbs( + [ + createResource({ + id: "folder-root", + kind: "folder", + name: "根目录子级", + }), + createResource({ + id: "folder-child", + kind: "folder", + name: "子目录", + parentId: "folder-root", + }), + ], + "folder-child", + ).map((item) => item.id), + ).toEqual(["folder-root", "folder-child"]); + expect(canNavigateResourceFolderUp("folder-1")).toBe(true); + expect(canNavigateResourceFolderUp(null)).toBe(false); + }); + + it("分类视图应共用同一套媒体识别与聚合规则", () => { + const imageItem = createResource({ + id: "image-1", + kind: "file", + sourceType: "material", + name: "封面", + filePath: "/tmp/cover.webp", + mimeType: "image/webp", + fileType: "image", + }); + const audioItem = createResource({ + id: "audio-1", + kind: "file", + sourceType: "material", + name: "旁白", + filePath: "/tmp/voice.mp3", + mimeType: "audio/mpeg", + }); + const videoItem = createResource({ + id: "video-1", + kind: "file", + sourceType: "material", + name: "样片", + filePath: "/tmp/demo.mp4", + mimeType: "video/mp4", + }); + const documentItem = createResource({ + id: "doc-1", + name: "脚本大纲", + }); + + expect(getResourceMediaType(imageItem)).toBe("image"); + expect(getResourceMediaType(audioItem)).toBe("audio"); + expect(getResourceMediaType(videoItem)).toBe("video"); + expect(getResourceMediaType(documentItem)).toBeNull(); + + const imageItems = getCategoryScopedResources( + [imageItem, audioItem, videoItem, documentItem], + "image", + "", + "updatedAt", + "desc", + ); + expect(imageItems.map((item) => item.id)).toEqual(["image-1"]); + + const documentItems = getCategoryScopedResources( + [imageItem, audioItem, videoItem, documentItem], + "document", + "", + "updatedAt", + "desc", + ); + expect(documentItems.map((item) => item.id)).toEqual(["doc-1"]); + + expect( + getCategoryCounts([imageItem, audioItem, videoItem, documentItem]), + ).toEqual({ + all: 4, + document: 1, + image: 1, + audio: 1, + video: 1, + }); + }); +}); diff --git a/src/components/resources/services/resourceQueries.ts b/src/components/resources/services/resourceQueries.ts new file mode 100644 index 000000000..13f6d502b --- /dev/null +++ b/src/components/resources/services/resourceQueries.ts @@ -0,0 +1,246 @@ +import type { ResourceItem } from "./types"; + +export type ResourceViewCategory = + | "all" + | "document" + | "image" + | "audio" + | "video"; + +export type ResourceSortField = "updatedAt" | "createdAt" | "name"; + +export type ResourceSortDirection = "asc" | "desc"; + +const imageExtensions = new Set([ + "png", + "jpg", + "jpeg", + "webp", + "gif", + "bmp", + "svg", + "ico", + "heic", +]); + +const audioExtensions = new Set(["mp3", "wav", "aac", "m4a", "ogg", "flac"]); + +const videoExtensions = new Set(["mp4", "mov", "avi", "mkv", "webm", "flv"]); + +function getFileExtension(filename: string): string { + const index = filename.lastIndexOf("."); + if (index < 0 || index === filename.length - 1) { + return ""; + } + return filename.slice(index + 1).toLowerCase(); +} + +function matchFolderScope( + item: ResourceItem, + currentFolderId: string | null, +): boolean { + if (item.kind === "file") { + return currentFolderId === null; + } + return (item.parentId ?? null) === currentFolderId; +} + +export function getResourceMediaType( + item: ResourceItem, +): "image" | "audio" | "video" | null { + if (item.kind !== "file") return null; + + const normalizedMimeType = item.mimeType?.toLowerCase() ?? ""; + if (normalizedMimeType.startsWith("image/")) return "image"; + if (normalizedMimeType.startsWith("audio/")) return "audio"; + if (normalizedMimeType.startsWith("video/")) return "video"; + + const normalizedFileType = item.fileType?.toLowerCase() ?? ""; + if (normalizedFileType === "image") return "image"; + if (normalizedFileType === "audio") return "audio"; + if (normalizedFileType === "video") return "video"; + + const extension = getFileExtension(item.filePath || item.name); + if (imageExtensions.has(extension)) return "image"; + if (audioExtensions.has(extension)) return "audio"; + if (videoExtensions.has(extension)) return "video"; + + return null; +} + +export function matchResourceCategory( + item: ResourceItem, + category: ResourceViewCategory, +): boolean { + if (category === "all") return true; + + const mediaType = getResourceMediaType(item); + if (category === "image") return mediaType === "image"; + if (category === "audio") return mediaType === "audio"; + if (category === "video") return mediaType === "video"; + + if (item.kind === "document") return true; + if (item.kind !== "file") return false; + return mediaType === null; +} + +export function matchResourceSearch( + item: ResourceItem, + keyword: string, +): boolean { + if (!keyword) return true; + + const normalizedKeyword = keyword.toLowerCase(); + if (item.name.toLowerCase().includes(normalizedKeyword)) { + return true; + } + + if (item.description?.toLowerCase().includes(normalizedKeyword)) { + return true; + } + + if (item.tags?.some((tag) => tag.toLowerCase().includes(normalizedKeyword))) { + return true; + } + + return false; +} + +export function compareResourcesBySortField( + a: ResourceItem, + b: ResourceItem, + field: ResourceSortField, + direction: ResourceSortDirection, +): number { + let compareValue = 0; + + if (field === "name") { + compareValue = a.name.localeCompare(b.name, "zh-CN"); + } else if (field === "createdAt") { + compareValue = a.createdAt - b.createdAt; + } else { + compareValue = a.updatedAt - b.updatedAt; + } + + return direction === "asc" ? compareValue : -compareValue; +} + +export function sortResources( + resources: ResourceItem[], + field: ResourceSortField, + direction: ResourceSortDirection, + options?: { foldersFirst?: boolean }, +): ResourceItem[] { + const foldersFirst = options?.foldersFirst ?? false; + + return [...resources].sort((a, b) => { + if (foldersFirst) { + if (a.kind === "folder" && b.kind !== "folder") return -1; + if (a.kind !== "folder" && b.kind === "folder") return 1; + } + + return compareResourcesBySortField(a, b, field, direction); + }); +} + +export function getFolderScopedResources( + items: ResourceItem[], + currentFolderId: string | null, + searchQuery: string, + sortField: ResourceSortField, + sortDirection: ResourceSortDirection, +): ResourceItem[] { + const scopedItems = items.filter((item) => + matchFolderScope(item, currentFolderId), + ); + const searchedItems = scopedItems.filter((item) => + matchResourceSearch(item, searchQuery), + ); + return sortResources(searchedItems, sortField, sortDirection, { + foldersFirst: true, + }); +} + +export function getCurrentFolder( + items: ResourceItem[], + currentFolderId: string | null, +): ResourceItem | null { + if (!currentFolderId) { + return null; + } + + return items.find((item) => item.id === currentFolderId) ?? null; +} + +export function getFolderBreadcrumbs( + items: ResourceItem[], + currentFolderId: string | null, +): ResourceItem[] { + const folderMap = new Map( + items + .filter((item) => item.kind === "folder") + .map((item) => [item.id, item]), + ); + const breadcrumbs: ResourceItem[] = []; + let pointer = currentFolderId; + + while (pointer) { + const folder = folderMap.get(pointer); + if (!folder) break; + breadcrumbs.push(folder); + pointer = folder.parentId; + } + + return breadcrumbs.reverse(); +} + +export function canNavigateResourceFolderUp( + currentFolderId: string | null, +): boolean { + return currentFolderId !== null; +} + +export function getCategoryScopedResources( + items: ResourceItem[], + category: ResourceViewCategory, + searchQuery: string, + sortField: ResourceSortField, + sortDirection: ResourceSortDirection, +): ResourceItem[] { + const filteredByCategory = items.filter((item) => + matchResourceCategory(item, category), + ); + const searchedItems = filteredByCategory.filter((item) => + matchResourceSearch(item, searchQuery), + ); + return sortResources(searchedItems, sortField, sortDirection); +} + +export function getCategoryCounts( + items: ResourceItem[], +): Record { + const counts: Record = { + all: items.length, + document: 0, + image: 0, + audio: 0, + video: 0, + }; + + for (const item of items) { + if (matchResourceCategory(item, "document")) { + counts.document += 1; + } + if (matchResourceCategory(item, "image")) { + counts.image += 1; + } + if (matchResourceCategory(item, "audio")) { + counts.audio += 1; + } + if (matchResourceCategory(item, "video")) { + counts.video += 1; + } + } + + return counts; +} diff --git a/src/components/resources/store/action.ts b/src/components/resources/store/action.ts index e91304d7d..3eb25386a 100644 --- a/src/components/resources/store/action.ts +++ b/src/components/resources/store/action.ts @@ -1,8 +1,6 @@ import { create } from "zustand"; import { toast } from "sonner"; import { - createDocumentResource, - createFolderResource, deleteSingleResource, fetchProjectResources, moveContentResource, @@ -52,11 +50,6 @@ export interface ResourcesActions { setSearchQuery: (query: string) => void; setSortField: (field: ResourceSortField) => void; setSortDirection: (direction: ResourceSortDirection) => void; - setSelectedIds: (ids: string[]) => void; - toggleSelectedId: (id: string) => void; - clearSelection: () => void; - createFolder: (name: string) => Promise; - createDocument: (name: string) => Promise; uploadFile: (filePath: string) => Promise; renameById: (id: string, name: string) => Promise; deleteById: (id: string) => Promise; @@ -72,7 +65,6 @@ export const useResourcesStore = create((set, get) => ({ set({ projectId, currentFolderId: null, - selectedIds: [], searchQuery: "", error: null, items: [], @@ -112,7 +104,7 @@ export const useResourcesStore = create((set, get) => ({ }, setCurrentFolderId: (currentFolderId) => { - set({ currentFolderId, selectedIds: [] }); + set({ currentFolderId }); }, setSearchQuery: (searchQuery) => { @@ -127,59 +119,6 @@ export const useResourcesStore = create((set, get) => ({ set({ sortDirection }); }, - setSelectedIds: (selectedIds) => { - set({ selectedIds }); - }, - - toggleSelectedId: (id) => { - const selectedIds = get().selectedIds; - if (selectedIds.includes(id)) { - set({ selectedIds: selectedIds.filter((itemId) => itemId !== id) }); - return; - } - set({ selectedIds: [...selectedIds, id] }); - }, - - clearSelection: () => { - set({ selectedIds: [] }); - }, - - createFolder: async (name) => { - const { projectId, currentFolderId } = get(); - if (!projectId) return; - - set({ saving: true, error: null }); - try { - await createFolderResource(projectId, name, currentFolderId); - await get().loadResources(); - toast.success("文件夹创建成功"); - } catch (error) { - const message = toErrorMessage(error); - set({ error: message }); - toast.error(message); - } finally { - set({ saving: false }); - } - }, - - createDocument: async (name) => { - const { projectId, currentFolderId } = get(); - if (!projectId) return; - - set({ saving: true, error: null }); - try { - await createDocumentResource(projectId, name, currentFolderId); - await get().loadResources(); - toast.success("文档创建成功"); - } catch (error) { - const message = toErrorMessage(error); - set({ error: message }); - toast.error(message); - } finally { - set({ saving: false }); - } - }, - uploadFile: async (filePath) => { const { projectId } = get(); if (!projectId) return; diff --git a/src/components/resources/store/index.ts b/src/components/resources/store/index.ts index 14cee1616..12e1f14c2 100644 --- a/src/components/resources/store/index.ts +++ b/src/components/resources/store/index.ts @@ -1,6 +1,5 @@ export { useResourcesStore } from "./action"; export type { ResourcesStore } from "./action"; -export { resourcesSelectors } from "./selectors"; export type { ResourceSortDirection, ResourceSortField, diff --git a/src/components/resources/store/initialState.ts b/src/components/resources/store/initialState.ts index 87b7b875b..aceb7689b 100644 --- a/src/components/resources/store/initialState.ts +++ b/src/components/resources/store/initialState.ts @@ -1,8 +1,12 @@ import type { ResourceItem } from "../services/types"; - -export type ResourceSortField = "updatedAt" | "createdAt" | "name"; - -export type ResourceSortDirection = "asc" | "desc"; +import type { + ResourceSortDirection, + ResourceSortField, +} from "../services/resourceQueries"; +export type { + ResourceSortDirection, + ResourceSortField, +} from "../services/resourceQueries"; export interface ResourcesState { projectId: string | null; @@ -12,7 +16,6 @@ export interface ResourcesState { error: string | null; currentFolderId: string | null; searchQuery: string; - selectedIds: string[]; sortField: ResourceSortField; sortDirection: ResourceSortDirection; } @@ -25,7 +28,6 @@ export const initialState: ResourcesState = { error: null, currentFolderId: null, searchQuery: "", - selectedIds: [], sortField: "updatedAt", sortDirection: "desc", }; diff --git a/src/components/resources/store/selectors.ts b/src/components/resources/store/selectors.ts deleted file mode 100644 index 404eeecb4..000000000 --- a/src/components/resources/store/selectors.ts +++ /dev/null @@ -1,117 +0,0 @@ -import type { ResourceItem } from "../services/types"; -import type { ResourcesStore } from "./action"; -import type { ResourceSortDirection, ResourceSortField } from "./initialState"; - -const createCachedSelector = ( - selector: (state: ResourcesStore) => T, -): ((state: ResourcesStore) => T) => { - let lastState: ResourcesStore | null = null; - let lastResult: T; - - return (state: ResourcesStore): T => { - if (lastState === state) { - return lastResult; - } - - const result = selector(state); - lastState = state; - lastResult = result; - return result; - }; -}; - -const compareBySortField = ( - a: ResourceItem, - b: ResourceItem, - field: ResourceSortField, - direction: ResourceSortDirection, -): number => { - let compareValue = 0; - - if (field === "name") { - compareValue = a.name.localeCompare(b.name, "zh-CN"); - } else if (field === "createdAt") { - compareValue = a.createdAt - b.createdAt; - } else { - compareValue = a.updatedAt - b.updatedAt; - } - - return direction === "asc" ? compareValue : -compareValue; -}; - -const matchSearch = (item: ResourceItem, keyword: string): boolean => { - if (!keyword) return true; - - const normalizedKeyword = keyword.toLowerCase(); - if (item.name.toLowerCase().includes(normalizedKeyword)) { - return true; - } - - if (item.description?.toLowerCase().includes(normalizedKeyword)) { - return true; - } - - if (item.tags?.some((tag) => tag.toLowerCase().includes(normalizedKeyword))) { - return true; - } - - return false; -}; - -const matchFolder = (item: ResourceItem, folderId: string | null): boolean => { - if (item.kind === "file") { - return folderId === null; - } - return (item.parentId ?? null) === folderId; -}; - -const sortResources = ( - resources: ResourceItem[], - field: ResourceSortField, - direction: ResourceSortDirection, -): ResourceItem[] => { - return [...resources].sort((a, b) => { - // 文件夹优先 - if (a.kind === "folder" && b.kind !== "folder") return -1; - if (a.kind !== "folder" && b.kind === "folder") return 1; - return compareBySortField(a, b, field, direction); - }); -}; - -export const resourcesSelectors = { - canNavigateUp: (state: ResourcesStore) => state.currentFolderId !== null, - - currentFolder: (state: ResourcesStore) => - state.currentFolderId - ? (state.items.find((item) => item.id === state.currentFolderId) ?? null) - : null, - - folderBreadcrumbs: createCachedSelector((state: ResourcesStore) => { - const folderMap = new Map( - state.items - .filter((item) => item.kind === "folder") - .map((item) => [item.id, item]), - ); - const breadcrumbs: ResourceItem[] = []; - let pointer = state.currentFolderId; - - while (pointer) { - const folder = folderMap.get(pointer); - if (!folder) break; - breadcrumbs.push(folder); - pointer = folder.parentId; - } - - return breadcrumbs.reverse(); - }), - - visibleItems: createCachedSelector((state: ResourcesStore) => { - const scopedItems = state.items.filter((item) => - matchFolder(item, state.currentFolderId), - ); - const searchedItems = scopedItems.filter((item) => - matchSearch(item, state.searchQuery), - ); - return sortResources(searchedItems, state.sortField, state.sortDirection); - }), -}; diff --git a/src/components/sceneapps/SceneAppDetailPanel.tsx b/src/components/sceneapps/SceneAppDetailPanel.tsx index ce878ffe2..7bb7e3f17 100644 --- a/src/components/sceneapps/SceneAppDetailPanel.tsx +++ b/src/components/sceneapps/SceneAppDetailPanel.tsx @@ -52,7 +52,7 @@ export function SceneAppDetailPanel({ if (!detailView) { return (
- 先回到场景目录选择一条创作场景,再补齐进入生成前的意图和项目工作区。 + 先回到做法目录选一套做法,再来补参考、启动信息和结果落点。
); } @@ -92,15 +92,15 @@ export function SceneAppDetailPanel({
- 产出: + 会拿到: {detailView.outputHint}
- 执行主链: + 推进方式: {detailView.executionChainLabel}
- 来源包: + 做法来源: {detailView.sourcePackageId}
@@ -112,7 +112,9 @@ export function SceneAppDetailPanel({
-
设计模式
+
+ 这套做法擅长 +
{detailView.patternLabels.map((patternLabel) => (
-
启动前置
+
+ 启动前先确认 +
{detailView.launchRequirements.length === 0 ? (

- 这条创作场景没有额外前置条件,可以直接进入生成。 + 这套做法没有额外前置条件,可以直接进入生成。

) : (
@@ -148,13 +152,15 @@ export function SceneAppDetailPanel({
-
交付合同
+
+ 默认会拿到 +

{detailView.deliveryNarrative}

- 主结果: + 默认主结果: {detailView.outputHint}
{detailView.deliveryPrimaryPart ? ( @@ -165,13 +171,13 @@ export function SceneAppDetailPanel({ ) : null} {detailView.deliveryViewerLabel ? (
- 查看方式: + 默认入口: {detailView.deliveryViewerLabel}
) : null} {detailView.artifactProfileRef ? (
- Artifact: + 结果说明: {detailView.artifactProfileRef}
) : null} @@ -192,20 +198,22 @@ export function SceneAppDetailPanel({
) : (

- 当前还没有显式声明必含交付部件,后续需要继续补齐。 + 当前这套做法还没有明确必含结果,后续需要继续补齐。

)}
-
组合步骤
+
+ 通常会这样推进 +
{detailView.compositionBlueprintRef ? (
- 蓝图: + 步骤说明: {detailView.compositionBlueprintRef}
@@ -224,7 +232,7 @@ export function SceneAppDetailPanel({
{step.bindingLabel ? (
- 执行面:{step.bindingLabel} + 承接面:{step.bindingLabel}
) : null}
@@ -234,13 +242,15 @@ export function SceneAppDetailPanel({
) : (

- 当前创作场景还没有显式组合蓝图,继续按单阶段 binding 运行。 + 当前这套做法还没有拆出明确步骤,先按当前默认路径继续推进。

)}
-
评分口径
+
+ 默认怎么判断 +

{detailView.scorecardNarrative}

@@ -249,7 +259,7 @@ export function SceneAppDetailPanel({ data-testid="sceneapp-detail-scorecard-ref" className="mt-3 text-sm leading-6 text-slate-600" > - Profile: + 判断说明: {detailView.scorecardProfileRef}
) : null} @@ -266,15 +276,12 @@ export function SceneAppDetailPanel({
) : (

- 当前 profile - 还没有显式暴露指标键,后续会继续和真实评分聚合对齐。 + 当前这套做法还没有整理出显式判断指标,后续会继续和真实结果对齐。

)} {detailView.scorecardFailureSignals.length ? (
-
- 重点关注 -
+
先盯这些
{detailView.scorecardFailureSignals.map((signal) => (
- 执行预规划 + 这轮准备情况
{detailView.planning.statusLabel} @@ -305,7 +312,7 @@ export function SceneAppDetailPanel({

{planLoading ? (

- 正在根据当前项目与输入刷新预规划… + 正在根据当前项目与输入刷新准备情况…

) : null} {planError ? ( @@ -315,9 +322,7 @@ export function SceneAppDetailPanel({ ) : null} {detailView.planning.unmetRequirements.length ? (
-
- 仍待补齐 -
+
还差这些
- 活跃层 + 这轮已带入
- 参考注入: + 参考对象: {detailView.contextPlan.referenceCount} 条
{detailView.contextPlan.scopeLabel ? ( @@ -369,14 +374,14 @@ export function SceneAppDetailPanel({ data-testid="sceneapp-detail-context-scope" className="text-sm leading-6 text-slate-600" > - 作用域: + 作用范围: {detailView.contextPlan.scopeLabel}
) : null} {detailView.contextPlan.skillRefs.length ? (
- 场景绑定 + 已接入做法
- 记忆引用 + 历史经验
{detailView.contextPlan.memoryRefs.map((memoryRef) => ( @@ -413,7 +418,7 @@ export function SceneAppDetailPanel({ {detailView.contextPlan.toolRefs.length ? (
- 工具开放面 + 可用能力
{detailView.contextPlan.toolRefs.map((toolRef) => ( @@ -488,7 +493,7 @@ export function SceneAppDetailPanel({ data-testid="sceneapp-detail-context-taste-summary" className="rounded-[18px] border border-lime-200 bg-lime-50/70 px-3 py-2 text-sm leading-6 text-lime-900" > - 风格摘要: + 当前风格方向: {detailView.contextPlan.tasteSummary}
) : null} @@ -497,7 +502,7 @@ export function SceneAppDetailPanel({ data-testid="sceneapp-detail-context-feedback-summary" className="rounded-[18px] border border-sky-200 bg-sky-50/70 px-3 py-2 text-sm leading-6 text-sky-900" > - 最近反馈: + 最近复盘: {detailView.contextPlan.feedbackSummary}
) : null} @@ -512,7 +517,7 @@ export function SceneAppDetailPanel({ {detailView.contextPlan.feedbackSignals.length ? (
- 反馈信号 + 复盘信号
) : (

- 当前还没有生成上下文覆盖层,继续补项目或启动输入后会自动刷新。 + 当前还没有整理出这轮带入对象,继续补项目或启动输入后会自动刷新。

)} {detailView.planning.warnings.length ? ( @@ -603,20 +608,18 @@ export function SceneAppDetailPanel({
- Project Pack 规划 + 默认结果去向
{detailView.projectPackPlan ? ( <>
- - 交付形态: - + 结果形态: {detailView.projectPackPlan.packKindLabel}
- 完成策略: + 完成口径: {detailView.projectPackPlan.completionStrategyLabel}
@@ -628,9 +631,7 @@ export function SceneAppDetailPanel({ ) : null} {detailView.projectPackPlan.viewerLabel ? (
- - 查看方式: - + 默认入口: {detailView.projectPackPlan.viewerLabel}
) : null} @@ -651,7 +652,7 @@ export function SceneAppDetailPanel({
) : (

- 当前规划还没有显式声明整包部件,继续沿现有结果回流主链执行。 + 当前这套做法还没有明确整套结果的必含部分,继续沿现有结果回流主链执行。

)} {detailView.projectPackPlan.notes.length ? ( @@ -669,16 +670,15 @@ export function SceneAppDetailPanel({ ) : (

- 当前规划还没有显式暴露 Project Pack - 结果,继续沿现有交付合同运行。 + 当前这套做法还没有明确结果去向,继续沿现有结果约定运行。

)}
-
启动输入
+
+ 开始这次生成 +

- 这一步只负责补齐场景意图和上下文基线;真正执行会进入生成主执行面,继续复用 Agent 或自动化主链。 + 这一步只负责补齐你这次的目标、参考和结果落点;真正执行会进入生成主执行面。

- 项目工作区 + 结果沉淀到
@@ -708,7 +710,7 @@ export function SceneAppDetailPanel({
- 启动意图 + 这次想让它做什么