diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index bb4ef3df3..bfcaf21f9 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,48 +1,41 @@ -## Lime v1.40.0 +## Lime v1.41.0 发布日期:`2026-05-16` -递交范围:当前完整 worktree,包含 tracked、deleted 与新增文件;本次按发布要求完成版本号、release note、Rust / 前端核心质量校验、命令契约与 GUI smoke,不包含 tag / push / 正式分发。 +递交范围:当前完整 worktree,包含 tracked 与新增文件;本次先按要求提交并推送 GitHub,完整发布门禁将在后续补跑后再更新结论。 -> 发布说明:上一版 release tag 为 `v1.39.0`。本版升级到 `v1.40.0`,并继续清理旧 `RELEASE_NOTES.md` 历史堆叠内容,只保留当前版本说明;旧 v1.39.0 发布说明不再作为当前 release note 事实源保留。 +> 发布说明:上一版 release tag 为 `v1.40.0`。本版升级到 `v1.41.0`,并继续清理旧 `RELEASE_NOTES.md` 历史堆叠内容,只保留当前版本说明;旧 v1.40.0 发布说明不再作为当前 release note 事实源保留。 ### 发布概览 -- 应用版本从 `1.39.0` 升级到 `1.40.0`,同步 `package.json`、`package-lock.json`、`packages/lime-cli-npm/package.json`、`src-tauri/Cargo.toml`、`src-tauri/Cargo.lock`、`src-tauri/tauri.conf.json` 与 `src-tauri/tauri.conf.headless.json`。 -- Agent App 从 Lab / 草案能力推进到正式主入口:新增 `Agent Apps` 管理页、已安装 App 侧栏入口、运行页与内容工厂样板,支持安装预览、启动入口、运行状态和任务提交闭环。 -- Agent App Host Bridge / Runtime 面补齐前后端命令:新增 app inspect / install / uninstall / state / runtime task 命令、DevBridge dispatcher、浏览器 mock、治理目录册和前端 API 网关,保持命令契约同源。 -- Agent App 安装链路补齐 cloud bootstrap、package cache、setup state store、lifecycle action、cleanup rehearsal / residual audit 和 package identity,减少“已安装但不可启动”的假入口。 -- 专家广场进入主导航:新增专家目录、详情面板、安装覆盖层、运行绑定、analytics 投影和 Agent 对话内专家启动参数,支持从专家入口恢复或创建专属对话。 -- 旧 SceneApp GUI / Rust 命令 / DevBridge / lib API 主体清退,保留必要 legacy summary / follow-up 兼容投影,避免 current Agent App 主线继续与旧入口双轨并存。 +- 应用版本从 `1.40.0` 升级到 `1.41.0`,同步 `package.json`、`package-lock.json`、`packages/lime-cli-npm/package.json`、`src-tauri/Cargo.toml`、`src-tauri/Cargo.lock`、`src-tauri/tauri.conf.json` 与 `src-tauri/tauri.conf.headless.json`。 +- Agent App P18 Typed Capability SDK 进入可交接状态:新增 package-side handoff gate、SDK public surface、Host Bridge SDK client、Capability Adapter 与内容工厂 SDK 回归,避免 App 继续依赖私有 bridge transport。 +- Agent App Runtime facade 继续收敛到 `agent_app_runtime_*` current 主链,补齐 task lifecycle、Host response、artifact / evidence refs、workspace patch replay、跨刷新恢复与 hidden session 边界。 +- Runtime evidence / handoff / review / replay 导出结果可投影回 Agent App task event,Evidence Pack、analysis、review decision、replay case 等制品不再停留在只读导出侧。 +- Claw capability 复用从命令文件中拆出 catalog service,首批 `image / cover / research / report / pdf / summary` capability hint 只映射到既有 Claw launch metadata,不新增垂直 `content_factory_*` 命令。 +- 正式 `Agent Apps` GUI smoke 形成独立证据,覆盖正式入口、registration blocker、Cloud install review、lifecycle disable / enable、runtime surface、uninstall rehearsal 和 flag-off regression。 ### 用户可见更新 -- 左侧导航新增 `专家` 与 `Agent Apps`,并可根据已安装 Agent App 动态展示可启动入口;原 `场景应用` 独立页面不再作为当前主入口。 -- Agent App 管理页支持查看安装状态、来源、入口、能力就绪度、卸载排练与运行启动结果;内容工厂样板可作为默认演示 App 跑通入口体验。 -- Agent App 运行页可基于 entry runtime guard 检查权限、能力与入口状态,并把任务提交到 Agent runtime,避免 UI 只展示静态预览。 -- 专家广场提供种子专家目录、专家详情、安装提示和对话启动入口;Agent 对话区可展示专家信息面板并携带专家启动上下文。 -- 自动化、记忆、Agent Home、工作区和设置页同步移除 SceneApp 旧文案 / 入口残留,五语言资源补齐 `agentExperts`、Agent App 与导航相关文案。 +- `Agent Apps` 正式入口具备独立 smoke 锚点和正式页面可达性校验,Lab 入口不再替代正式入口证据。 +- 内容工厂 App 可通过 Host Bridge 订阅 AgentRuntime task update,并从 artifact / snapshot / event payload 中回放 `workspacePatch` / `contentFactoryWorkspacePatch`。 +- App 内任务结果支持展示 artifact、evidence、verification outcome 与运行进度,减少“任务 accepted 后长期 idle”的误导状态。 +- Agent App hidden runtime session 不再污染普通最近对话,任务恢复和 Host response 继续使用 app / task scope。 ### 开发者与治理更新 -- 新增 `src/features/agent-app/runtime/`、`src/features/agent-app/sdk/`、`src/features/agent-app/adapters/` 与 schema gate,明确 Host Capability、CapabilityHost、runtime package loader、workflow runtime host 与 UI extension host 的边界。 -- 新增 `src-tauri/src/commands/agent_app_cmd.rs`、`src-tauri/src/commands/agent_app_runtime_cmd.rs`、`src-tauri/src/dev_bridge/dispatcher/agent_apps.rs` 与 `src-tauri/crates/core/src/agent_app_runtime_token.rs`,让 Agent App state / runtime token / task 命令进入 Rust current 主路径。 -- `src/lib/api/agentApps.ts` 与 `src/lib/api/agentAppRuntime.ts` 成为前端 Agent App 命令事实源,并与 `src/lib/tauri-mock/agentAppMocks.ts`、`mockPriorityCommands`、`agentCommandCatalog` 同步。 -- `docs/roadmap/agentapp/` 扩展 P5-P17 执行计划,补齐 cloud bootstrap、schema gate、setup resolver、package cache、runtime loader、entry guard、正式入口、生命周期清理和 GUI smoke 演练路线。 -- `.gitignore` 放行 `docs/roadmap/zuanjia/*.md`,专家方向文档与 Agent App 路线图一并进入版本化事实源。 -- 测试修复覆盖 Inputbar 语音录制最短时长、AgentThreadMemoryPrefetchPreview locale、manifest / content factory fixture、ExpertInfoPanel hook 依赖与 schema / analytics lint 边界。 +- 新增 `src/features/agent-app/sdk/` 相关 facade、contract、adapter 与 public surface 回归,固定 SDK-only consumer 边界。 +- 新增 `scripts/agent-app-package-handoff-check.mjs` 与 `scripts/lib/agent-app-package-handoff-core.mjs`,机械检查外部 Agent App package 的 dirty 状态、私有 bridge marker、SDK marker、高风险脚本和 `src -> dist` 产物漂移。 +- 新增 `src-tauri/src/services/agent_app_runtime_capability_catalog_service.rs` 与 `src-tauri/src/services/runtime_evidence_projection_service.rs`,把 capability catalog 与 evidence projection 从 runtime command 里拆成单一职责服务。 +- `docs/roadmap/agentapp/` 与 `docs/roadmap/agentruntime/` 补齐 P17.5、P18 handoff、package SDK migration、completion audit、raw worker pre-gate 与 AgentRuntime completion audit,当前事实源回到 versioned artifact。 +- `agent_app_runtime_cmd` 增加模型偏好解析、最近成功 Agent run fallback、provider catalog fallback、runtime event emit、artifact replay、evidence projection 与 content factory output contract。 -### 校验状态 +### 当前校验状态 -- `cargo fmt --manifest-path "src-tauri/Cargo.toml" --all`:通过。 -- `npm run verify:app-version`:通过,目标版本 `1.40.0`。 -- `CARGO_TARGET_DIR="$HOME/.cache/lime-cargo-target-v140" cargo test --manifest-path "src-tauri/Cargo.toml"`:通过,主库 `1320 passed; 0 failed; 1 ignored`,集成测试通过;真实联网测试保持 ignored。 -- `CARGO_TARGET_DIR="$HOME/.cache/lime-cargo-target-v140" cargo clippy --manifest-path "src-tauri/Cargo.toml" --workspace --all-targets -- -D warnings`:通过。 -- `npm run lint`:通过。 -- `npm test`:通过,`57/57` 批次通过。 -- `npm run test:contracts`:通过;命令契约、Harness 契约、modality contracts 与 cleanup report contract 均通过。 -- `npm run verify:gui-smoke`:通过;覆盖 DevBridge、workspace ready、browser runtime、site adapters、Skill Forge / Service Skill、runtime tool surface、`@` 命令注册、Claw streaming、knowledge GUI 与 design canvas。 -- `git diff --check`:通过。 +- `cargo fmt --manifest-path "src-tauri/Cargo.toml" --all`:已通过。 +- `cargo test --manifest-path "src-tauri/Cargo.toml"`:已启动但按用户要求先提交,后台进程已终止;尚未形成通过结论。 +- `npm run verify:app-version`:待本次提交前快速复核。 +- `cargo clippy` / Rust lint、`npm run lint`、`npm test`、`npm run test:contracts`、`npm run verify:gui-smoke`:本次先提交 GitHub,尚未补跑完整结论。 --- -**完整变更**: `v1.39.0` -> `v1.40.0` +**完整变更**: `v1.40.0` -> `v1.41.0` diff --git a/docs/roadmap/agentapp/README.md b/docs/roadmap/agentapp/README.md index fdfc10c05..84b62e56b 100644 --- a/docs/roadmap/agentapp/README.md +++ b/docs/roadmap/agentapp/README.md @@ -1,6 +1,6 @@ # Agent App 客户端路线图 -更新时间:2026-05-15 +更新时间:2026-05-16 ## 定位 @@ -27,9 +27,9 @@ Agent App Runtime Package | 分类 | 对象 | 说明 | |---|---|---| -| current | `/Users/coso/Documents/dev/ai/limecloud/agentapp` | Agent App v0.3 标准、runtime package、public JSON Schema、reference CLI、示例包与宿主实现指南。 | +| current | `/Users/coso/Documents/dev/ai/limecloud/agentapp` | Agent App v0.4 标准事实源;v0.3 覆盖 executable package / schema / reference CLI,v0.4 固定 Host Bridge v1 运行时事件协议。 | | current | `docs/roadmap/agentapp/capability-sdk.md` | Lime Desktop 侧 Capability SDK 与 Host Bridge 方案。 | -| current | `docs/roadmap/agentapp/implementation-plan.md` | 客户端落地实施方案,覆盖 P0-P17.5 当前计划;P17.3 lifecycle / cleanup contract 与 P17.4 runtime surface production hardening 已完成,P17.4.5 已在健康 DevBridge 下通过完整 GUI smoke;下一刀进入 P17.5 formal entry GUI smoke。 | +| current | `docs/roadmap/agentapp/implementation-plan.md` | 客户端落地实施方案,覆盖 P0-P18 当前计划;P17.5 formal entry GUI smoke 已通过独立验证链路,P18 进入 Typed Capability SDK Gate,当前阻塞点是 P18.5.3 package-side SDK facade / verify。 | | current | `docs/roadmap/agentapp/p0-technical-design.md` | P0 只读 App Host 技术设计,覆盖 manifest、projection、readiness、cleanup dry-run。 | | current | `docs/roadmap/agentapp/p1-mock-capability-host.md` | P1 Mock Capability Host 技术设计,覆盖 SDK facade、mock artifact/evidence、uninstall delete-data。 | | current | `docs/roadmap/agentapp/p2-adapter-capability-host.md` | P2 Adapter Capability Host 技术设计,覆盖本地 adapter store、knowledge / agent adapter、provenance 查询、delete-data 清理。 | @@ -57,6 +57,14 @@ Agent App Runtime Package | current | `docs/roadmap/agentapp/p17-source-install-contract-hardening.md` | P17.2 Source / Install Contract Hardening 当前执行计划,固定 source state、install review、registration、Cloud release metadata、package verify、cached fallback 与 reference CLI cross-check 边界。 | | current | `docs/roadmap/agentapp/p17-lifecycle-cleanup-contract-hardening.md` | P17.3 Lifecycle / Cleanup Contract Hardening 已完成执行计划,固定正式入口 enable / disable、uninstall rehearsal、cleanup evidence、residual audit 与 namespace 归属边界。 | | current | `docs/roadmap/agentapp/p17-4-host-bridge-runtime.md` | P17.4-H Host Bridge Runtime 已完成执行计划,固定正式 App iframe 与 Lime Host 的 `lime.agentApp.bridge` 事件协议、主题同步、Host action、capability invoke、App-scoped Agent task 与结构化写回边界;P17.4.5 已通过完整 GUI smoke。 | +| current | `docs/roadmap/agentapp/p17-5-formal-entry-gui-smoke.md` | P17.5 Formal Entry GUI Smoke 已完成,记录正式 `agent-apps` 入口 install / registration / launch / disable / uninstall rehearsal / runtime surface / flag-off 的独立 smoke 证据。 | +| current | `docs/roadmap/agentapp/p18-typed-capability-sdk-gate.md` | P18 Typed Capability SDK Gate 计划,固定上游 v0.4 Host Bridge、SDK typed facade、stable error、mock / contract tests 与 App-scoped Agent task 边界;P18.1 SDK contract、P18.2 Host Bridge typed router、P18.3 Core capability adapters、P18.4 App-scoped Agent task SDK facade、P18.4-H handoff gate、P18.5.1 Lime-side SDK regression、P18.5.2 package-side read-only tests、P18.5-S Host Bridge SDK client 与 P18.6 raw worker 前 gate 已落地;2026-05-16 09:22 审计确认下一刀仍是 P18.5.3 package-side SDK facade / verify。 | +| current | `docs/roadmap/agentapp/p18-4-h-agentruntime-handoff-gate.md` | P18.4-H AgentRuntime Handoff Gate,记录 prompt-to-artifact checklist、隔壁 AgentRuntime current MVP 证据、P18 消费侧判定和仍归 AgentRuntime owner 的缺口。 | +| current | `docs/roadmap/agentapp/p18-5-content-factory-sdk-regression.md` | P18.5 内容工厂 SDK 化回归,记录 Lime-side content factory SDK contract、Host Bridge SDK client、外部 package read-only tests、dirty 边界和 package-side 剩余缺口。 | +| current | `docs/roadmap/agentapp/p18-5-3-package-sdk-migration-plan.md` | P18.5.3 package-side SDK facade 迁移计划,固定外部 `content-factory-app` 当前只读事实、推荐写集、迁移步骤、验收清单和回滚点。 | +| current | `docs/roadmap/agentapp/p18-5-3-owner-handoff.md` | P18.5.3 给外部 package owner 的执行单页,浓缩接管前命令、最小写集、禁止项、验证顺序和完成条件。 | +| current | `docs/roadmap/agentapp/p18-6-raw-worker-pre-gate.md` | P18.6 Raw Worker 前 Gate,记录 P18 不执行 raw worker / 外部代码 / 网络 / 文件系统 / secret value 的证据和 P19 后续退出条件。 | +| current | `docs/roadmap/agentapp/p18-completion-audit.md` | P18 completion audit / 协作防打架记录,映射 P18.1-P18.6 证据、外部 package 阻塞、完整 `verify:local` 已通过、2026-05-16 09:04 typecheck / contracts 复核、09:09 feature island 复核和下一刀判定。 | | reference | `/Users/coso/Documents/dev/ai/limecloud/limecore/docs/roadmap/agentapp` | 服务端 catalog、release、tenant enablement、gateway、ToolHub 路线图;LimeCore 只做 control-plane metadata,真实 release 必须提供 `https` package URL 和完整 package / manifest hash,未激活注册码时不得下发可安装 package。 | ## 文档索引 @@ -92,6 +100,14 @@ Agent App Runtime Package | [p17-source-install-contract-hardening.md](./p17-source-install-contract-hardening.md) | P17.2 技术计划:正式入口 source state、install review、registration、Cloud release metadata 与客户端安装边界。 | | [p17-lifecycle-cleanup-contract-hardening.md](./p17-lifecycle-cleanup-contract-hardening.md) | P17.3 技术计划:正式入口 lifecycle、cleanup rehearsal、evidence export、residual audit 与 namespace 分离。 | | [p17-4-host-bridge-runtime.md](./p17-4-host-bridge-runtime.md) | P17.4-H 技术计划:Host Bridge v1、主题同步、Host action、capability invoke、iframe message 安全边界。 | +| [p17-5-formal-entry-gui-smoke.md](./p17-5-formal-entry-gui-smoke.md) | P17.5 验证记录:正式 `agent-apps` 入口独立 GUI smoke、cleanup dry-run evidence 与 flag-off regression。 | +| [p18-typed-capability-sdk-gate.md](./p18-typed-capability-sdk-gate.md) | P18 技术计划:Typed Capability SDK Gate、Host Bridge v1 调用信封、stable error、mock / contract tests、App-scoped Agent task。 | +| [p18-4-h-agentruntime-handoff-gate.md](./p18-4-h-agentruntime-handoff-gate.md) | P18.4-H 验证记录:AgentRuntime handoff checklist、证据映射、剩余 owner 缺口与 P18.5 进入判定。 | +| [p18-5-content-factory-sdk-regression.md](./p18-5-content-factory-sdk-regression.md) | P18.5 验证记录:内容工厂 Lime-side SDK regression、Host Bridge SDK client、package-side dirty 边界和下一刀。 | +| [p18-5-3-package-sdk-migration-plan.md](./p18-5-3-package-sdk-migration-plan.md) | P18.5.3 迁移计划:外部 package 写集、SDK facade 迁移步骤、验收和回滚点。 | +| [p18-5-3-owner-handoff.md](./p18-5-3-owner-handoff.md) | P18.5.3 owner handoff 单页:接管前命令、最小写集、禁止项、验证顺序和完成条件。 | +| [p18-6-raw-worker-pre-gate.md](./p18-6-raw-worker-pre-gate.md) | P18.6 验证记录:raw worker 禁止执行边界、受控 workflow DSL、policy 证据和 P19 退出条件。 | +| [p18-completion-audit.md](./p18-completion-audit.md) | P18 completion audit:完成项、阻塞项、验证命令、协作边界和下一刀。 | ## 客户端边界 @@ -106,12 +122,13 @@ Agent App Runtime Package 9. App 拥有业务 UI、业务 workflow、业务状态、结构化结果写回和人工确认;Lime Agent / Host 拥有 task runtime、模型 / 工具 / 知识调用、权限、trace、Artifact、Evidence 和 cleanup。 10. 通用 Chat 不能成为 Agent App 主流程的强制回跳点;如果核心工作仍需在 Chat 里完成,说明 App 只是 Chat 包装壳,不是 product-level Agent App。 11. App 也不能为了避免回 Chat 而自建模型网关、凭证系统、权限系统、证据系统或工具调度器;这些属于 Lime capability。 +12. Agent App SDK 不拥有 Claw / AgentRuntime capability catalog;Chat `@命令`、Agent App `lime.agent.startTask` 和 Automation job 都只是 surface adapter,不能在 App 侧复制 `*_skill_launch` 或新增垂直 `content_factory_*` 后端能力。 ## 主路线 ```mermaid flowchart TD - Standard[Agent App v0.3 Standard] --> Parser[Manifest Parser] + Standard[Agent App v0.4 Standard] --> Parser[Manifest Parser] Parser --> Projection[Client Projection] Projection --> Installer[Local Installer] Installer --> Guard[Entry Runtime Guard] @@ -128,7 +145,7 @@ flowchart TD ## 当前执行顺序 -路线图已升级到 Agent App v0.3 / 内容工厂;上游标准进一步补齐 discovery / installation、release metadata、runtime model、security model、overlay resolver、readiness runner、typed Capability SDK、public JSON Schema 与 reference CLI。P4-R、P5、P6、P7、P8、P9、P10、P11、P12、P13、P14、P15、P15-H、P16、P16-H、P17 Gate、P17.0、P17.1、P17.2.1-P17.2.5 与 P17.3 lifecycle / cleanup contract 已完成当前实现 / 专用验证 / 计划收口。P17.4 runtime surface production hardening 已完成 guard-before-start、App 内 Agent task stream / get / cancel / retry、structured write-back guard、content factory bootstrap sample 与 P17.4.5 完整 GUI smoke;当前进入 P17.5 formal entry GUI smoke,仍不做 marketplace、Cloud 管理台、真实 delete-data 或完整行业内容系统: +路线图已从 Agent App v0.3 / 内容工厂 rebaseline 推进到上游 v0.4 Host Bridge 对齐;上游标准已补齐 discovery / installation、release metadata、runtime model、security model、overlay resolver、readiness runner、typed Capability SDK、public JSON Schema、reference CLI 与 Host Bridge v1。P4-R、P5、P6、P7、P8、P9、P10、P11、P12、P13、P14、P15、P15-H、P16、P16-H、P17 Gate、P17.0、P17.1、P17.2.1-P17.2.5、P17.3 lifecycle / cleanup contract、P17.4 runtime surface production hardening 与 P17.5 formal entry GUI smoke 已完成当前实现 / 专用验证 / 计划收口。当前进入 P18 Typed Capability SDK Gate,仍不做 marketplace、Cloud 管理台、真实 delete-data 或完整行业内容系统: ```text P0 manifest / projection / readiness / cleanup dry-run @@ -175,21 +192,38 @@ P0 manifest / projection / readiness / cleanup dry-run → P17.2.5 Schema / reference CLI / example package cross-check(已完成) → P17.3 Lifecycle / cleanup contract hardening(已完成) → P17.4 Runtime surface production hardening(已完成,含 P17.4.5 GUI smoke) -→ P17.5 Formal entry GUI smoke(下一刀,和 Lab smoke 分离) -→ P18 Typed Capability SDK / App-scoped Agent Task contract(让业务流程在 App 内完成,不回跳通用 Chat) +→ P17.5 Formal entry GUI smoke(已完成,和 Lab smoke 分离) +→ P18.0 v0.4 Host Bridge / Typed SDK gap matrix(文档计划已落地) +→ P18.1 Typed SDK facade / mock host / stable error(已完成) +→ P18.2 Host Bridge typed router / stable error response(已完成) +→ P18.3 Core capability adapters(已完成) +→ P18.4 App-scoped Agent task SDK facade(已完成) +→ P18.4-H AgentRuntime handoff gate(已完成) +→ P18.5.1 Lime-side 内容工厂 SDK regression(已完成) +→ P18.5.2 Package-side read-only tests(已完成) +→ P18.5.3 Package-side SDK facade / verify / dist 同步(已完成) +→ P18.6 Raw Worker 前 Gate(已完成) ``` -不要先做市场页、Cloud 管理台、完整行业内容系统,也不要让 App 绕过 SDK;P17 Gate 只证明可以进入正式入口设计,不等于发布 marketplace。当前最优先任务已推进到 P17.5:在 P17.4.5 完整 GUI smoke 已通过的基础上,为正式 `agent-apps` 入口补独立 smoke;Lab smoke 和全局 GUI smoke 只能作为辅助证据。 +不要先做市场页、Cloud 管理台、完整行业内容系统,也不要让 App 绕过 SDK;P17.5 只证明正式入口独立 smoke 可跑通,不等于发布 marketplace。当前 P18 功能面已收口:Lime-side regression、外部 package tests / validate / readiness、真实 package verify、dist 同步和 handoff gate 均有证据;下一步只做 owner handoff / 提交边界,不再扩大 P18 功能面。 ## 下一刀判定 -P5.0-P5.5、P6、P7、P8、P9、P10、P11、P12、P13、P14、P15、P15-H、P16、P16-H、P17 Gate、P17.0、P17.1、P17.2.1-P17.2.5、P17.3 与 P17.4.1-P17.4.5 已通过定向 / 专用验证 / GUI smoke / 计划收口;当前进入 P17.5 formal entry GUI smoke: +P5.0-P5.5、P6、P7、P8、P9、P10、P11、P12、P13、P14、P15、P15-H、P16、P16-H、P17 Gate、P17.0、P17.1、P17.2.1-P17.2.5、P17.3、P17.4.1-P17.4.5、P17.5、P18.1、P18.2、P18.3、P18.4、P18.4-H、P18.5.1、P18.5.2、P18.5-S、P18.5.3 与 P18.6 已通过定向 / 专用验证 / GUI smoke / 计划收口;2026-05-16 10:53 SDK seam / handoff core 定向测试 5 files / 17 tests passed,`typecheck`、`test:contracts` 与 `lint` 当前会话复核通过。当前只做 owner handoff: -1. P17.5 只做正式 `agent-apps` 入口 smoke 与证据 summary,不新增 marketplace、Cloud 管理台或完整行业系统。 -2. `agent-apps` 是用户入口,`agent-app-lab` 是研发验证入口;两者不能共用用户心智和验收证据。 -3. 继续复用 P11 repository、P14 guard、P15 cleanup preview、P16-H evidence / audit,不新增第二套 store / scanner / installer。 +1. P18.5 只做内容工厂现有 package 的 SDK 化回归,不新增 marketplace、Cloud 管理台或完整行业系统。 +2. `agent-apps` 是用户入口,`agent-app-lab` 是研发验证入口;P18 不能让 App 回跳通用 Chat 才能完成业务流程。 +3. 继续复用 P11 repository、P14 guard、P15 cleanup preview、P16-H evidence / audit、P17.4 Host Bridge 和 AgentRuntime surface,不新增第二套 store / scanner / installer / runtime。 4. Cloud / LimeCore 继续只提供 catalog / release / tenant metadata,不运行 Agent、不渲染 UI、不接管本地 storage。 -5. P17.2.4b-2 已新增唯一 package fetch / staging 命令 `agent_app_fetch_cloud_package`,调用只集中在 `src/lib/api/agentApps.ts`;Agent App feature island 仍不得直接 `safeInvoke` / `invoke`。 +5. Agent App feature island 仍不得直接 `safeInvoke` / `invoke` / raw Worker;上层 App 只能通过 SDK / Host Bridge 调用 Lime 能力。 + +协作分工口径: + +| 方向 | 负责 | P18 消费方式 | 防打架边界 | +|---|---|---|---| +| AgentRuntime current MVP | `agent_app_runtime_*` facade、`AgentRuntimeThreadReadModel`、Host response、`artifact:created` refs、跨刷新恢复、Artifact / Evidence 投影。 | P18.1 已抽象成 SDK 类型和 mock contract;P18.4 已包装为 `lime.agent` typed adapter;P18.4-H 已消费隔壁运行证据。 | 不在 P18.5 改 `src-tauri/*`、`src/lib/api/agentAppRuntime.ts`、`agentRuntimeCapabilityHost*` 实现逻辑。 | +| AgentRuntime 剩余缺口 | 后端 push subscribe、`content_factory.workspace_patch` producer、独立 capability catalog service、真实桌面 GUI smoke。 | 已在 P18.4-H 记录 owner 与退出条件;P18.5 只消费,不伪造生产级完成。 | 不由 Agent App SDK 伪造生产级完成。 | +| Agent App P18 | typed facade、stable error、mock host、capability envelope、SDK contract tests。 | 固定 App 作者只调用 `@lime/app-sdk`,不手写私有 bridge。 | 不扩 marketplace、Cloud 管理台、raw worker、真实 delete-data 或垂直内容工厂后端。 | 当前计划更新口径: @@ -205,4 +239,13 @@ P5.0-P5.5、P6、P7、P8、P9、P10、P11、P12、P13、P14、P15、P15-H、P16 - P17.4.1-P17.4.4 已完成:runtime surface guard-before-start、App-scoped Agent task start / stream / get / cancel / retry、storage / artifact / evidence 声明级写回 guard,以及 content factory promise-based Host Bridge 样板。 - LimeCore 服务端已明确:`content-factory-app` seeded catalog 不能内置假 release / 假下载地址 / 占位 hash;客户端 P17.2.4b-2 已把 package metadata 作为 release descriptor 输入,未激活注册码时仍停在 source state,不用 seeded fixture 或 dev resolver 兜底。 - P17.4.5 已完成:代码层 contracts、typecheck、feature island boundary、定向测试与完整 `verify:gui-smoke -- --reuse-running --timeout-ms 300000` 均有证据;本轮未结束或重启隔壁 Tauri / Cargo / Vite 任务。 -- 下一刀 P17.5:新增或扩展正式 `agent-apps` smoke,覆盖 install / registration / launch / disable / uninstall rehearsal / runtime surface / flag-off,并输出独立 summary。 +- P17.5 已完成:`smoke:agent-apps` 覆盖 install / registration / launch / disable / uninstall rehearsal / runtime surface / flag-off,并输出独立 summary。 +- P18.1 已完成:新增 typed capability contract、stable error mapping、mock transport 与 SDK contract tests;未触碰 AgentRuntime / Rust / runtime facade。 +- P18.2 已完成:Host Bridge `capability:invoke` 接入 typed envelope、stable success / error response,保留 `response.result` 兼容,并补 runtime page / dispatcher / bridge contract tests;未触碰 Rust / Cloud / AgentRuntime facade。 +- P18.3 已完成:新增 `createLimeCoreCapabilityAdapters`,覆盖 `lime.ui / storage / artifacts / evidence / knowledge / tools` typed facade、namespace、provenance 与 stable error parity;定向 tests / typecheck / contracts / feature-island 边界已过。 +- P18.4 已完成:`createLimeCoreCapabilityAdapters` 新增 `agent` facade,覆盖 `startTask / streamTask / getTask / cancelTask / retryTask / submitHostResponse / listTasks`,并用 SDK fixture 固定 task / artifact / evidence 事件、Host response 和 retry / cancel 语义。 +- P18.4-H 已完成:新增 AgentRuntime handoff checklist,逐项映射 facade、task options、events、Host response、artifact/evidence refs、workspace patch、跨刷新恢复、no-private-bridge 和隔壁 owner 缺口。 +- P18.5.1 已完成:新增 Lime-side content factory SDK regression,验证 `lime.agent -> lime.storage -> lime.artifacts -> lime.evidence` 主链能由通用 SDK facade 表达。 +- P18.5.2 已完成:外部 `content-factory-app` 只读 `npm test` 在 2026-05-16 09:05 owner handoff gate 复核为 46 tests passed,覆盖 Host Bridge task、stream、Host response、storage、artifact、evidence 写回;该结果不替代 package-side SDK facade / verify。 +- P18.6 已完成:新增 raw worker 前 gate,固定 P18 不执行 raw worker、任意外部代码、网络、文件系统或 secret value,相关 runtime policy / guard / workflow tests 已通过。 +- 下一刀 P18.5.3:等待外部 content-factory package owner 稳定后,收敛 package-side 手写 bridge wrapper 并跑 package verify / Lime formal runtime 回归;owner 未确认前不改外部 `src/ui/host-bridge.js`、`tests/ui.test.mjs`、`package.json` 或 `dist/*`。 diff --git a/docs/roadmap/agentapp/capability-sdk.md b/docs/roadmap/agentapp/capability-sdk.md index 82ea5afae..0ee2de982 100644 --- a/docs/roadmap/agentapp/capability-sdk.md +++ b/docs/roadmap/agentapp/capability-sdk.md @@ -99,7 +99,7 @@ Agent App | Host Bridge | 安全传输、主题、语言、capability invoke、Host action。 | 不保存执行事实,不判断任务完成。 | | Agent App Runtime Surface | 把 App task / workflow 映射成 AgentRuntime request,并附加 app provenance。 | 不复制 Claw skill launch,不新建第二套 runtime。 | | AgentRuntime | 维护 session/thread/turn/task/event/read model/evidence。 | 不决定垂直 App UI 形态。 | -| Claw Capability Catalog | 把现有 `@配图`、`@搜索`、`@研报` 等能力注册为可复用 capability。 | 不再让能力只绑定 Chat/Inputbar。 | +| AgentRuntime Capability Catalog / Claw Capability Catalog | 把现有 `@配图`、`@搜索`、`@研报` 等能力注册为可复用 capability;Chat `@命令`、Agent App `lime.agent.startTask`、Automation job 都只是 surface adapter。 | 不再让能力只绑定 Chat/Inputbar,也不把 capability catalog owner 下放给 Agent App SDK。 | 生产期 `lime.agent.startTask` / `lime.workflow.start` 必须通过后端 AgentRuntime Surface;前端 `CapabilityHost` / `WorkflowRuntimeHost` 只能作为 adapter、mock 或本地预览,不得成为生产执行事实源。详细设计见: @@ -153,6 +153,76 @@ interface LimeAgentAppBridgeMessage { 3. 未开放能力返回 blocked error,不返回 mock 成功、不写假数据。 4. 主题同步只传当前已生效 token;App 只应用到自己的 DOM,不读取外层 DOM。 +## Host Bridge SDK Client + +P18.5-S 已在 Lime-side SDK 增加标准 Host Bridge client,目标是让 package-side App 不再各自手写私有 `postMessage` wrapper。App UI 仍运行在 sandbox frame 内,但业务代码只依赖 SDK-style runtime handle: + +```ts +import { + buildLimeCapabilityInvokeProvenance, + createLimeCoreCapabilityAdapters, + createLimeHostBridgeCapabilityInvoker, +} from "@lime/app-sdk"; + +const provenance = buildLimeCapabilityInvokeProvenance({ + sourceKind: "agent_app", + appId: "content-factory-app", + appVersion: "1.0.0", + packageHash, + manifestHash, + entryKey: "dashboard", +}); + +const lime = createLimeCoreCapabilityAdapters({ + invoker: createLimeHostBridgeCapabilityInvoker({ + appId: "content-factory-app", + entryKey: "dashboard", + trustedHostOrigin: "https://lime.local", + }), + provenance, + storageNamespace: "content-factory-app", +}); + +await lime.agent.startTask({ + title: "生成内容批次", + taskKind: "content.copy.generate", + idempotencyKey: "dashboard:copy", + expectedOutput: { + artifactKind: "content_batch", + workspacePatch: "contentFactoryWorkspacePatch", + }, +}); +``` + +客户端当前实现位置: + +| 对象 | 作用 | +|---|---| +| `src/features/agent-app/sdk/hostBridgeClient.ts` | 把 typed SDK request 转成 Host Bridge v1 `capability:invoke` message,处理 `app:ready`、`host:getSnapshot`、`host:toast`、`host:navigate`、`host:openExternal`、`host:download`、`host:snapshot`、`theme:update`、`host:visibility`、`host:response` / `host:error`、trusted origin、timeout、pending cleanup,以及 `capability:subscribe / unsubscribe / event`。 | +| `src/features/agent-app/sdk/index.ts` | SDK-only public surface,只导出 capability facade、stable error、Host Bridge client、mock host 和 App package 需要的 task / storage / artifact / evidence 类型;不导出 UI、installer、repository 或 runtime host 内部实现。 | +| `src/features/agent-app/index.ts` | 当前 Lime repo public feature entry 已导出 `createLimeCoreCapabilityAdapters`、`createLimeHostBridgeCapabilityInvoker`、`LIME_AGENT_APP_BRIDGE_PROTOCOL` 与 `LIME_AGENT_APP_BRIDGE_VERSION`,作为后续正式 SDK package / package-local shim 的事实源。 | +| `src/features/agent-app/sdk/hostBridgeClient.test.ts` | 覆盖 ready / snapshot / theme / visibility / toast / navigate / openExternal / download、envelope、stable error、timeout cleanup、capability subscription 事件分发,以及内容工厂 task / storage / artifact / evidence 主链。 | +| `src/features/agent-app/sdk/publicSdkSurface.test.ts` | 固定 SDK-only public surface,确保后续正式 `@lime/app-sdk` 或 package-local shim 可以从窄导出清单取能力;同时从运行时 namespace 和源码 export 来源两侧禁止导出 UI、安装器、repository、runtime host、adapter、schema 或 dispatcher 内部对象。 | +| `src/features/agent-app/index.test.ts` | 固定 public feature entry 的 SDK export seam,防止后续 App 只能依赖 Lime 内部深路径。 | + +发布状态与退出条件:正式 `@lime/app-sdk` package 尚未从 Lime repo 独立发布;外部 App 仍不得 import `src/features/agent-app/*` 或其它 Lime internal path。P18.5.3 如果需要 package-local shim,只能镜像本节 public API,并在正式 SDK package 可安装后退出。 + +2026-05-16 08:37 只读复核:Lime repo 根 `package.json` 仍是 `"private": true` 的桌面 App 包;`packages/` 下只有 `packages/lime-cli-npm`,未发现独立 `@lime/app-sdk` / `lime-app-sdk` package 或 workspace export。当前可依赖的 Lime-side 事实源仍是 `src/features/agent-app/index.ts` 的 public feature entry 与 `index.test.ts` 的 export regression;因此外部 `content-factory-app` 在 P18.5.3 迁移时不能 import Lime internal path,正式 SDK 未发布前只能使用带退出条件的 package-local shim 或等待后续独立 SDK package。 + +2026-05-16 09:52 补充:Lime-side 已新增 `src/features/agent-app/sdk/index.ts` 作为 SDK-only public surface,并用 `publicSdkSurface.test.ts` 固定导出范围。该文件仍不是可安装 npm package,也不授权外部 App import Lime 源码深路径;它只是把后续正式 `@lime/app-sdk` 或 package-local shim 的导出清单从 feature 总入口中剥离出来,降低 UI / installer / runtime host 内部实现被误导出的风险。 + +2026-05-16 10:08 补充:`contentFactorySdkRegression.test.ts` 已改为只从 `src/features/agent-app/sdk/index.ts` 导入 SDK 类型和 helper,证明 Lime-side 内容工厂回归消费的是 SDK-only public surface,而不是 `capabilityContract.ts` / `capabilityAdapters.ts` 等内部深文件。该调整仍不代表外部 `content-factory-app` package 已迁移。 + +迁移规则: + +1. App package 业务文件不能直接构造 `lime.agentApp.bridge` message。 +2. App package 可以保留业务 helper,但 helper 底层必须调用 `lime.agent / lime.storage / lime.artifacts / lime.evidence` typed facade。 +3. `app:ready`、snapshot、theme、visibility、toast、navigate、openExternal、download 等 Host action / Host event 必须通过 SDK client 使用,不由业务文件直接监听 window message。 +4. `trustedHostOrigin` 应由 Host snapshot / package runtime 注入;开发态可以配置为本地 Host origin,但不得在生产包里写死任意公网 origin。 +5. `targetOrigin` 默认跟随 `trustedHostOrigin`;缺失时只能作为开发态 fallback,不作为正式 package verify 的通过条件。 +6. 订阅类进度事件必须通过 `subscribeCapability / unsubscribeCapability` 注册,业务文件只消费事件 payload,不直接监听 `capability:event`。 +7. P18.5.3 外部 `content-factory-app` 已在 2026-05-16 10:55 完成 package-side SDK facade / verify / dist 同步;后续迁移其它 App 时仍按同一规则执行,不允许回退到私有 bridge transport。 + ## App 内 Agent Task API `lime.agent` 是 App 调用 Lime Agent 的主能力,不是跳转通用 Chat 的快捷方式。App 决定业务任务何时启动、需要什么上下文、结果写回哪个业务对象;Host 决定 Agent task 如何运行、可用哪些工具 / 知识 / secret、如何记录 trace / artifact / evidence。 diff --git a/docs/roadmap/agentapp/implementation-plan.md b/docs/roadmap/agentapp/implementation-plan.md index b0293af87..bc6c8965d 100644 --- a/docs/roadmap/agentapp/implementation-plan.md +++ b/docs/roadmap/agentapp/implementation-plan.md @@ -1,6 +1,6 @@ # Agent App 客户端实施方案计划 -更新时间:2026-05-15 +更新时间:2026-05-16 ## 一句话结论 @@ -9,7 +9,7 @@ Agent App 在 Lime Desktop 里必须先做成一个可关闭、可卸载、可 第一阶段目标不是做一个完整生态,也不是复刻行业内容系统,而是证明:一个真实 App package 可以在本地被安装、解析、投影、检查 readiness、调用 Capability SDK、生成可追溯产物,并且在方向失败时能干净清理。进入正式入口后,目标进一步收敛为:业务用户留在 App 自己的业务 UI 内完成流程,Lime Agent 作为受治理的 task runtime 被 App 调用,而不是把用户回跳到通用 Chat。 ```text -Agent App Standard v0.3 +Agent App Standard v0.4 ↓ Local Package / Fixture ↓ @@ -41,7 +41,7 @@ Agent App 不是专家卡片、不是 Prompt 包、不是 Markdown 目录,也 | 目标 | 说明 | |---|---| -| 验证 App package | 本地读取 Agent App v0.3 package / fixture,生成 manifest、projection、readiness。 | +| 验证 App package | 本地读取 Agent App v0.3+ package / fixture,生成 manifest、projection、readiness。 | | 验证 SDK 边界 | App 只能通过 `@lime/app-sdk` capability facade 调 Lime 能力,不能 import Lime internal modules。 | | 验证真实业务切片 | 用 内容工厂跑通一个最小业务闭环,而不是停留在专家聊天。 | | 验证 App 内 Agent 任务 | 在 App 页面 / workflow 内启动、观察、取消、重试、确认 Agent task,并把结构化结果写回业务对象。 | @@ -107,7 +107,7 @@ Agent App 不是专家卡片、不是 Prompt 包、不是 Markdown 目录,也 ```mermaid flowchart TD - Standard[Agent App v0.3 Standard] --> Source[Local Package / Fixture] + Standard[Agent App v0.4 Standard] --> Source[Local Package / Fixture] Cloud[LimeCore Control Plane] --> Bootstrap[Optional Bootstrap Payload] Source --> Host[Agent App Host 实验岛] Bootstrap --> Host @@ -180,13 +180,13 @@ P4-R0 计划与事实源收口 → P5 Cloud Bootstrap payload 本地适配器 ``` -截至 2026-05-16,P4-R、P5、P6、P7、P8、P9、P10、P11、P12、P13 均已通过对应定向验证;P14 Entry Runtime Guard / Permission Prompt、P15 Lab Install / Launch Flow、P15-H GUI smoke / cleanup rehearsal、P16 Agent App Manager、P16-H multi-app lifecycle hardening、P17 Gate 审计、P17.0、P17.1、P17.2.1-P17.2.5 与 P17.3 lifecycle / cleanup contract 已完成当前实现 / 专用验证 / 计划收口。P17.4.1-P17.4.5 已把正式 runtime surface 推进到 guard-before-start、App 内 Agent task、structured write-back guard、content factory bootstrap sample 与完整 GUI smoke 证据;当前下一刀进入 P17.5 formal entry GUI smoke。阶段记录详见 [v0.3-rebaseline-plan.md](./v0.3-rebaseline-plan.md)、[p15-lab-install-launch-flow.md](./p15-lab-install-launch-flow.md)、[p15-h-gui-smoke-cleanup-rehearsal.md](./p15-h-gui-smoke-cleanup-rehearsal.md)、[p16-agent-app-manager-product-entry-gate.md](./p16-agent-app-manager-product-entry-gate.md)、[p16-h-multi-app-repository-lifecycle-hardening.md](./p16-h-multi-app-repository-lifecycle-hardening.md)、[p17-formal-entry-gate-audit.md](./p17-formal-entry-gate-audit.md)、[p17-formal-entry-contract.md](./p17-formal-entry-contract.md)、[p17-source-install-contract-hardening.md](./p17-source-install-contract-hardening.md)、[p17-lifecycle-cleanup-contract-hardening.md](./p17-lifecycle-cleanup-contract-hardening.md) 与 [p17-4-host-bridge-runtime.md](./p17-4-host-bridge-runtime.md)。 +截至 2026-05-16,P4-R、P5、P6、P7、P8、P9、P10、P11、P12、P13 均已通过对应定向验证;P14 Entry Runtime Guard / Permission Prompt、P15 Lab Install / Launch Flow、P15-H GUI smoke / cleanup rehearsal、P16 Agent App Manager、P16-H multi-app lifecycle hardening、P17 Gate 审计、P17.0、P17.1、P17.2.1-P17.2.5、P17.3 lifecycle / cleanup contract、P17.4.1-P17.4.5、P17.5 formal entry GUI smoke、P18.1 SDK facade / stable error / mock host、P18.2 Host Bridge typed router / stable error response、P18.3 Core capability adapters、P18.4 App-scoped Agent task SDK facade、P18.4-H AgentRuntime handoff gate、P18.5.1 Lime-side 内容工厂 SDK regression、P18.5.2 package-side read-only tests、P18.5-S Host Bridge SDK client、P18.5.3 package-side SDK facade / verify / dist 同步与 P18.6 Raw Worker 前 Gate 已完成当前实现 / 专用验证 / 计划收口;完整 `verify:local` 已于 2026-05-16 07:33 端到端通过,2026-05-16 10:53 SDK seam / handoff core 定向测试 5 files / 17 tests passed,`typecheck`、`test:contracts` 与 `lint` 当前会话复核通过。当前下一刀不再扩 P18 功能面,只做 owner handoff / 提交边界收口。阶段记录详见 [v0.3-rebaseline-plan.md](./v0.3-rebaseline-plan.md)、[p15-lab-install-launch-flow.md](./p15-lab-install-launch-flow.md)、[p15-h-gui-smoke-cleanup-rehearsal.md](./p15-h-gui-smoke-cleanup-rehearsal.md)、[p16-agent-app-manager-product-entry-gate.md](./p16-agent-app-manager-product-entry-gate.md)、[p16-h-multi-app-repository-lifecycle-hardening.md](./p16-h-multi-app-repository-lifecycle-hardening.md)、[p17-formal-entry-gate-audit.md](./p17-formal-entry-gate-audit.md)、[p17-formal-entry-contract.md](./p17-formal-entry-contract.md)、[p17-source-install-contract-hardening.md](./p17-source-install-contract-hardening.md)、[p17-lifecycle-cleanup-contract-hardening.md](./p17-lifecycle-cleanup-contract-hardening.md)、[p17-4-host-bridge-runtime.md](./p17-4-host-bridge-runtime.md)、[p17-5-formal-entry-gui-smoke.md](./p17-5-formal-entry-gui-smoke.md)、[p18-typed-capability-sdk-gate.md](./p18-typed-capability-sdk-gate.md)、[p18-4-h-agentruntime-handoff-gate.md](./p18-4-h-agentruntime-handoff-gate.md)、[p18-5-content-factory-sdk-regression.md](./p18-5-content-factory-sdk-regression.md)、[p18-5-3-package-sdk-migration-plan.md](./p18-5-3-package-sdk-migration-plan.md)、[p18-5-3-owner-handoff.md](./p18-5-3-owner-handoff.md)、[p18-6-raw-worker-pre-gate.md](./p18-6-raw-worker-pre-gate.md) 与 [p18-completion-audit.md](./p18-completion-audit.md)。 上游 `agentapp` 标准随后补齐了宿主实现视角的 v0.3 细则:manifest / projection / readiness JSON schema、release metadata、overlay resolver、lifecycle、typed SDK expectations、reference CLI cross-check。P5 因此不能只做一个 Cloud payload DTO,而要先把这些标准差距变成客户端可执行计划;详见 [p5-cloud-bootstrap.md](./p5-cloud-bootstrap.md)。 ### 计划更新规则 -1. `/Users/coso/Documents/dev/ai/limecloud/agentapp` 是 Agent App v0.3 标准事实源,用来约束 manifest schema、entry kind、capability 版本、runtime package、overlay、readiness 和 provenance。 +1. `/Users/coso/Documents/dev/ai/limecloud/agentapp` 是 Agent App v0.4 标准事实源,用来约束 manifest schema、entry kind、capability 版本、runtime package、overlay、readiness、provenance 和 Host Bridge v1。 2. 本目录是 Lime Desktop 客户端实施事实源,用来决定客户端当前要落哪一刀、哪些文件要改、哪些测试要过。 3. P4 demo 的 current 对象以 `content-factory-app / content_scenario_planning / content_factory_demo / content_table / content_scenarios` 为准;如果标准示例包出现不同业务 key,先记录差异,不在客户端实现里混用两套命名。 4. P5 只消费 Cloud bootstrap payload;Cloud / LimeCore 不运行默认 Agent Runtime,也不渲染 App UI。 @@ -210,6 +210,41 @@ P4-R0 计划与事实源收口 P17.2.5 不是重复 P5.5:P5.5 只在 Cloud bootstrap 和早期 projection 层记录标准差异;P17.2.5 要把同一套 schema / CLI 对照接到正式 `agent-apps` source / install / review / readiness 主链上。 +### 2026-05-16 v0.4 Host Bridge / P18 协作校准 + +上游 `agentapp-ref@0.4.0` 已把 Host Bridge v1 明确为 sandboxed Agent App UI 的标准运行时事件协议。P17.4 已完成 Lime Desktop runtime surface 的 Host Bridge 生产硬化;P17.5 已由独立 GUI smoke 证明正式 `agent-apps` 入口可用。P18 现在可以把 Host Bridge 固定成 App 可依赖的 Typed Capability SDK Gate。 + +| 上游 v0.4 新约束 | 客户端计划调整 | 阶段归属 | +|---|---|---| +| Host Bridge v1 负责 theme、locale、snapshot、visibility、toast、navigate、download、external open 和 capability invoke 传输。 | P17.4 已完成运行面预铺;P18.0 先做标准 gap matrix,确认 Lime Desktop 事件名、信封、source / origin 校验与上游一致。 | P18.0 | +| App 作者不应直接手写私有 `postMessage` 协议。 | P18.1 已固定 `@lime/app-sdk` typed facade、capability invoke envelope、stable error enum 和 mock host。 | P18.1 已完成 | +| `capability:invoke` 仍必须经过 manifest、readiness、permission、policy 和 provenance。 | P18.2 已引入 typed router / adapter gate,未知 capability 不能返回 mock 成功,Host error 必须映射到 stable error response。 | P18.2 已完成 | +| `lime.agent` 是 App 内 Agent task 能力,不是回跳通用 Chat 的快捷方式。 | P18.4 已把 start / stream / get / cancel / retry / submitHostResponse / listTasks 包成 typed SDK facade;P18.4-H 已完成 AgentRuntime handoff 运行证据对齐。 | P18.4 / P18.4-H 已完成 | +| Raw worker 需要额外沙箱和资源限制。 | P18 只做 typed workflow / capability invoke;raw worker sandbox 放到后续 gate。 | P18.6 / P19 | + +协作边界:P17.5 evidence summary 已落地;P18 代码实施继续不启停隔壁 DevBridge / Vite / Tauri,不改 AgentRuntime Rust 投影任务,不把 SDK 设计扩大成 marketplace / raw worker。 + +### 2026-05-16 隔壁 AgentRuntime 更新接入边界 + +隔壁 AgentRuntime 任务已把 Agent App Runtime Surface 推进到 current MVP:App-scoped task facade、Host Bridge `lime.agent`、Host response、`artifact:created` refs、跨刷新 task 恢复,以及内容工厂 App 写回 `lime.storage / lime.artifacts / lime.evidence` 都已有 done / first-cut 证据。该输入会降低 P18 SDK 对底层实现的重复建设风险,但不改变本目录的阶段顺序: + +1. P17.5 已解除当前主线阻塞;如果隔壁 Rust / GUI 任务运行中,P18 仍不抢占 DevBridge / Vite / Tauri。 +2. AgentRuntime 负责 `AgentRuntimeThreadReadModel`、`agent_app_runtime_*` facade、artifact / evidence / handoff 投影和对应 Rust / TS 测试;后端 push subscribe、`content_factory.workspace_patch` producer、独立 capability catalog service、真实桌面 GUI smoke 继续归 AgentRuntime 任务收口。 +3. Agent App P18 负责把已验证的 host surface 包成 typed SDK contract;不复制 runtime read model,不新增第二套 artifact / evidence write-back,不绕过 Host Bridge。 +4. P18.0 gap matrix 必须把 `task:*`、`artifact:created`、`evidence:recorded`、`evidence:verified` 的事件语义、顺序、幂等和跨刷新恢复列为检查项。 + +协作分工锁定: + +| 工作包 | 本目录 Agent App P18 负责 | 隔壁 AgentRuntime 负责 | 暂不触碰 | +|---|---|---|---| +| P18.1 SDK contract | 已完成:`@lime/app-sdk` typed facade、stable error、mock host、capability invoke envelope、SDK contract tests。 | 提供现有 facade 行为事实,不要求同步改 Rust。 | 未改 `src-tauri/*`、`src/lib/api/agentAppRuntime.ts`、`agentRuntimeCapabilityHost*`。 | +| P18.2 Host Bridge router | 已完成:source / origin / appId / entryKey / requestId / typed envelope / stable error mapping 的 typed gate。 | 继续维护 runtime read model 与 command 四侧事实。 | 未启停隔壁 DevBridge / Vite / Tauri。 | +| P18.3 Core adapters | 已完成:`lime.ui / storage / artifacts / evidence / knowledge / tools` 的 typed adapter、namespace / provenance 测试已落。 | 提供 Artifact / Evidence / Tool / Knowledge 的真实 owner 能力。 | 不新增第二套 store、scanner、installer;typecheck / contracts 已通过。 | +| P18.4 App-scoped Agent task | 已完成:消费 `start / stream / get / cancel / retry / submitHostResponse / listTasks`,固定 SDK 事件与错误语义。 | 负责 push subscribe、workspace patch producer、capability catalog 和 Rust / TS 运行事实测试。 | 不复制 Claw `*_skill_launch.rs`,不建垂直 `content_factory_*` 命令。 | +| P18.4-H AgentRuntime handoff | 已完成:消费隔壁 current MVP 证据,明确 push subscribe、workspace patch producer、capability catalog、真实桌面 GUI smoke 的 owner 与退出条件。 | 继续负责运行事实和 GUI 验证链路。 | Agent App P18 只补 SDK contract / docs,不抢 runtime facade / Rust / smoke 脚本。 | +| P18.5 内容工厂 SDK 回归 | Lime-side SDK regression、Host Bridge SDK client、package-side tests / validate / readiness、P18.5.3 SDK facade、真实 package verify 与 dist 同步均已完成;handoff gate `blockers=none`、`distArtifacts=0`。 | 保证 runtime structured patch / artifact / evidence 可被 App 消费。 | 不复刻内容工厂后端产品,不把 Chat 包成主流程;后续只做 owner handoff / 提交边界。 | +| P18.6 Raw Worker 前 Gate | 已完成:P18 不执行 raw worker、外部代码、网络、文件系统或 secret value;raw worker sandbox 后移 P19。 | P19 前只保留受控 workflow DSL 和 typed capability SDK。 | 不新增 worker runtime、不执行任意 package JS。 | + ### P14 / P15 / P16 计划更新 上游 Agent App 标准进一步把宿主最低职责收敛成 `Discover → Validate → Project → Check readiness → Authorize → Inject capabilities → Isolate data → Clean up`。P0-P13 已覆盖到 `Check readiness` 与 runtime package loader,缺口集中在 `Authorize` 和 Lab 端到端编排: @@ -241,9 +276,10 @@ P16-H 不是“把 Manager 做大”,而是把 P16 已证明的单 App 管理 | P17.1 | 已完成最小实现:Formal route / nav / copy hardening。 | 只硬化正式入口路由、导航、状态文案和测试契约。 | `agent-apps` 不依赖 Lab flag;UI entry 从正式入口进入独立 runtime surface;正式入口文案五语言覆盖。 | | P17.2 | 已完成最小实现:Source / install contract hardening。 | P17.2.1 / P17.2.2 / P17.2.3 已完成 source state model、install review descriptor 与 registration hardening;P17.2.4a 已完成 Cloud release descriptor / verification gate;P17.2.4b-1 已完成 acquisition seam / verified cache source;P17.2.4b-2 已完成 packageUrl fetch / staging / manifest extraction;P17.2.5 已完成 schema / reference CLI / example package cross-check。详见 [p17-source-install-contract-hardening.md](./p17-source-install-contract-hardening.md)。 | local / cloud 安装已先 review 后 save,registration-active refresh 已有 UI 断言;packageUrl fetch 可生成 verified cache 后再进入 Cloud review;reference CLI cross-check 已覆盖 projection / readiness / review descriptor。 | | P17.3 | 已完成最小实现:Lifecycle / cleanup contract hardening。 | P17.3.1-P17.3.6 已完成 lifecycle descriptor、formal page lifecycle UI、cleanup namespace classifier、evidence / residual audit、guard integration 与 boundary regression。详见 [p17-lifecycle-cleanup-contract-hardening.md](./p17-lifecycle-cleanup-contract-hardening.md)。 | disabled / cleanup-blocked 不能启动;uninstall 保持 rehearsal-only;evidence 不含 secret value;residual audit 只查 Agent App namespace。 | -| P17.4 | 已完成:Runtime surface production hardening。 | P17.4.1-P17.4.5 已完成 guard-before-start、Host Bridge task contract、structured write-back guard、content factory bootstrap sample 与完整 GUI smoke。详见 [p17-4-host-bridge-runtime.md](./p17-4-host-bridge-runtime.md)。 | code-level tests、typecheck、contracts、feature island boundary 与 `verify:gui-smoke` 均有证据;runtime surface 可进入 P17.5 formal smoke。 | +| P17.4 | 已完成:Runtime surface production hardening。 | P17.4.1-P17.4.5 已完成 guard-before-start、Host Bridge task contract、structured write-back guard、content factory bootstrap sample 与完整 GUI smoke。详见 [p17-4-host-bridge-runtime.md](./p17-4-host-bridge-runtime.md)。 | code-level tests、typecheck、contracts、feature island boundary 与 `verify:gui-smoke` 均有证据;runtime surface 已支撑 P17.5 formal smoke。 | +| P17.5 | 已完成:Formal entry GUI smoke。 | `smoke:agent-apps` 已覆盖正式 `agent-apps` install / registration / launch / disable / uninstall rehearsal / runtime surface / flag-off。详见 [p17-5-formal-entry-gui-smoke.md](./p17-5-formal-entry-gui-smoke.md)。 | 独立 summary 已输出到 `.lime/qc/gui-evidence/agent-apps/agent-apps-smoke-summary.json`;Lab smoke 仍只作研发辅助证据。 | -P17.5 完成前,仍禁止把正式入口标记为可交付;marketplace、Workspace pin、命令面板入口、Chat expert entry、真实 delete-data 和 Cloud 管理台仍不进入当前主线。 +P17.5 完成只证明正式入口独立 smoke 可交付;marketplace、Workspace pin、命令面板入口、Chat expert entry、真实 delete-data 和 Cloud 管理台仍不进入当前主线。 ### 已完成计划:P17.0 Formal Entry Contract @@ -276,7 +312,7 @@ P17.1 不做 public catalog、真实 delete-data、Cloud 管理台、Workspace p ### 已完成计划:P17.2 Source / install contract hardening -P17.2 的最小可交付不是做 marketplace,而是把正式入口 source / install / registration 的状态契约收口。本阶段已完成 P17.2.1 / P17.2.2 / P17.2.3、P17.2.4a、P17.2.4b-1、P17.2.4b-2 与 P17.2.5;后续 P17.3 与 P17.4 已完成,当前进入 P17.5 formal entry GUI smoke: +P17.2 的最小可交付不是做 marketplace,而是把正式入口 source / install / registration 的状态契约收口。本阶段已完成 P17.2.1 / P17.2.2 / P17.2.3、P17.2.4a、P17.2.4b-1、P17.2.4b-2 与 P17.2.5;后续 P17.3、P17.4 与 P17.5 已完成,当前进入 P18 Typed Capability SDK Gate: 1. 已完成 source state:local folder、cloud release metadata、registration-required、registration-active、disabled、hash-missing、installed、offline fallback 已有清晰 UI 状态。 2. 已完成 install review gate:安装前展示 appId、version、source、hash、manifest、capability summary,并把 confirm 前不写 repository 的 UI / API 边界拆出来;Cloud production review 的真实 package 来源仍由 P17.2.4 收口。 @@ -304,10 +340,10 @@ P17.2 的最小可交付不是做 marketplace,而是把正式入口 source / i 因此计划按以下口径更新: 1. 该补丁归类为 **P17.4 pre-work / current**:它是 runtime surface 的必要基础,不是 P17.2 完成证据。 -2. P17.2 主线已完成:P17.2.4b-2 已把 `cloud_release` 的 `packageUrl` 落成 verified package cache / staging;P17.2.5 已完成标准 schema / reference CLI cross-check;P17.3 与 P17.4.5 均已完成,当前进入 P17.5 formal entry GUI smoke。 +2. P17.2 主线已完成:P17.2.4b-2 已把 `cloud_release` 的 `packageUrl` 落成 verified package cache / staging;P17.2.5 已完成标准 schema / reference CLI cross-check;P17.3、P17.4.5 与 P17.5 均已完成,当前进入 P18 Typed Capability SDK Gate。 3. `LIME_AGENT_APP_LOCAL_ROOTS` 和开发态 `limecloud/` resolver 只服务本地开发 / 已存在源码包联调;生产路径不能用它们绕过 package hash、manifest hash 和 release descriptor。 4. P17.4 后续验收必须证明:disabled / unverified / missing package 不会启动;runtime 只加载 P12 verified package;App UI 只能通过 injected SDK handles 调 Lime 能力。 -5. P17.5 formal smoke 必须覆盖正式 `agent-apps` 的 install / registration / launch / disable / uninstall rehearsal / runtime surface,不能继续用 Lab smoke 冒充正式入口证据。 +5. P17.5 formal smoke 已覆盖正式 `agent-apps` 的 install / registration / launch / disable / uninstall rehearsal / runtime surface / flag-off,不能继续用 Lab smoke 冒充正式入口证据。 ### 已完成计划:P17 Gate @@ -318,7 +354,7 @@ P17 Gate 的最小可交付不是“上线正式 Agent Apps 入口”,而是 3. 再分类缺口:缺代码、缺测试、缺 GUI 证据、缺文档、缺守卫必须分开,不把可选 polish 混进主线缺口。 4. 最后给 Gate 结论:满足则进入 P17 正式入口设计;不满足则回 P16-H 补最短缺口。 -P17 Gate 审计已沉淀到 [p17-formal-entry-gate-audit.md](./p17-formal-entry-gate-audit.md),并已继续完成 [P17.0 Formal Entry Contract](./p17-formal-entry-contract.md)、P17.1 formal route / nav / copy hardening、P17.2 source / install contract hardening、P17.3 lifecycle / cleanup contract hardening 与 P17.4 runtime surface production hardening;当前下一刀进入 P17.5 formal entry GUI smoke。 +P17 Gate 审计已沉淀到 [p17-formal-entry-gate-audit.md](./p17-formal-entry-gate-audit.md),并已继续完成 [P17.0 Formal Entry Contract](./p17-formal-entry-contract.md)、P17.1 formal route / nav / copy hardening、P17.2 source / install contract hardening、P17.3 lifecycle / cleanup contract hardening、P17.4 runtime surface production hardening 与 P17.5 formal entry GUI smoke;当前下一刀进入 P18 Typed Capability SDK Gate。 ### 已完成计划:P16-H.5 @@ -1078,8 +1114,8 @@ P5 分期以 [p5-cloud-bootstrap.md](./p5-cloud-bootstrap.md) 为准: | P17.2 | Source / install contract hardening。 | `p17-source-install-contract-hardening.md`、`AgentAppsPage.tsx`、`src/lib/api/agentApps.ts`、`install/*`、上游 `agentapp` schema / reference CLI | P17.2.1-P17.2.5 已覆盖 source state / install review / registration / release descriptor / verification gate / acquisition seam / verified cache source / packageUrl fetch / staging / reference CLI cross-check。 | | P17.3 | Lifecycle / cleanup contract hardening。 | `p17-lifecycle-cleanup-contract-hardening.md`、`install/*`、`ui/AgentAppsPage.tsx`、cleanup evidence / residual audit、overlay / setup / secret binding 边界 | disable / enable、uninstall keep-data、delete-data rehearsal、export-then-delete preview 都复用同一 namespace 和 evidence,不执行真实删除。 | | P17.4 | Runtime surface production hardening。 | `ui/AgentAppRuntimePage.tsx`、`runtime/*`、`src/lib/api/agentApps.ts`、`src-tauri/src/commands/agent_app_cmd.rs` | 当前 runtime surface 补丁只算预铺;生产路径必须只加载 verified package cache / staging,不依赖 dev resolver,并证明 injected SDK / sandbox / provenance 没有被绕过。 | -| P17.5 | Formal entry GUI smoke。 | `scripts/*smoke*`、正式 `agent-apps` route、五语言文案、P17 evidence summary | 新增或扩展正式入口 smoke,覆盖 install / registration / launch / disable / uninstall rehearsal / runtime surface / flag-off;Lab smoke 只保留研发回归。 | -| P18 | Typed Capability SDK gate。 | `sdk/*`、`runtime/*`、schema / mock / contract tests | 在扩 raw worker 前,把 `lime.ui / storage / agent / knowledge / tools / artifacts / workflow / policy / secrets / evidence` 的 typed host bridge 固定为稳定 SDK 契约。 | +| P17.5 | Formal entry GUI smoke。 | `scripts/agent-apps-smoke.mjs`、正式 `agent-apps` route、P17 evidence summary、`p17-5-formal-entry-gui-smoke.md` | 已完成正式入口 smoke,覆盖 install / registration / launch / disable / uninstall rehearsal / runtime surface / flag-off;Lab smoke 只保留研发回归。 | +| P18 | Typed Capability SDK gate。 | `docs/roadmap/agentapp/p18-typed-capability-sdk-gate.md`、`p18-5-content-factory-sdk-regression.md`、`p18-5-3-package-sdk-migration-plan.md`、`p18-completion-audit.md`、`sdk/*`、`runtime/*`、schema / mock / contract tests | 在扩 raw worker 前,把 `lime.ui / storage / agent / knowledge / tools / artifacts / workflow / policy / secrets / evidence` 的 typed host bridge 固定为稳定 SDK 契约,并对齐上游 v0.4 Host Bridge v1;P18.5-S 已补 Host Bridge SDK client,P18.5.3 等外部 package 稳定后执行。 | ## 验证策略 @@ -1163,10 +1199,11 @@ P5 分期以 [p5-cloud-bootstrap.md](./p5-cloud-bootstrap.md) 为准: | D14 | P17.3 结束 | Lifecycle / cleanup 是否足够支撑可失败退出? | disable / enable、uninstall rehearsal、export-then-delete preview、residual audit、overlay / setup / secret binding 分离均可复核;仍不执行真实 delete-data。 | 不进入 runtime production hardening,先补生命周期和清理证据。 | | D15 | P17.4 结束 | Runtime surface 是否真正只运行 verified package 且只走标准 Host Bridge? | `cloud_release` 只从 verified cache / staging 启动;dev resolver 仅开发态可用;disabled / unverified package 无法启动;`lime.agentApp.bridge` 覆盖主题、导航、toast、download、capability invoke;SDK bridge / sandbox / provenance 有测试。 | 保持 runtime surface 为预铺,不发布 formal runtime。 | | D16 | P17.5 结束 | 正式入口 smoke 是否能替代 Lab 证据? | `agent-apps` 专用 smoke 覆盖 install、registration、launch、disable、uninstall rehearsal、runtime surface、flag-off,并输出独立 summary。 | Lab smoke 继续只作研发证据,不把正式入口标为可交付。 | +| D17 | P18 结束 | Typed Capability SDK 是否足够支撑更多 App? | Host Bridge v1、typed SDK facade、stable error、mock host、contract tests、App-scoped Agent task、Lime-side content factory regression、外部 content-factory package verify、dist 同步和 full `verify:local` 均有证据;feature island 仍无 direct invoke / raw Worker 越界。 | 已满足;下一步只做 owner handoff / 提交边界,不进入 raw worker、marketplace 或更多垂直 App。 | ## 下一刀 -路线图已升级为 Agent App v0.3 / 内容工厂,且 P4-R rebaseline、P5.0-P5.5 Cloud Bootstrap、P6 schema coverage、P7 schema gate、P8 setup resolver、P9 setup state store、P10 installed state snapshot、P11 local persistence adapter、P12 package cache / verify / rollback、P13 runtime package loader、P14 entry runtime guard、P15 lab install / launch flow、P15-H Agent App Lab 专用 GUI smoke、P16 Agent App Manager、P16-H multi-app lifecycle hardening、P17 Gate 审计、P17.0、P17.1、P17.2.1-P17.2.5、P17.3 与 P17.4.1-P17.4.5 已通过定向 / 专用验证 / GUI smoke / 计划收口。当前下一刀是 P17.5 formal entry GUI smoke:不新增 marketplace、Cloud 管理台、真实 delete-data 或完整行业内容系统;P17.5 只为正式 `agent-apps` 建独立 GUI smoke 与 evidence summary。 +路线图已升级为 Agent App v0.4 Host Bridge 对齐 / 内容工厂,且 P4-R rebaseline、P5.0-P5.5 Cloud Bootstrap、P6 schema coverage、P7 schema gate、P8 setup resolver、P9 setup state store、P10 installed state snapshot、P11 local persistence adapter、P12 package cache / verify / rollback、P13 runtime package loader、P14 entry runtime guard、P15 lab install / launch flow、P15-H Agent App Lab 专用 GUI smoke、P16 Agent App Manager、P16-H multi-app lifecycle hardening、P17 Gate 审计、P17.0、P17.1、P17.2.1-P17.2.5、P17.3、P17.4.1-P17.4.5、P17.5 formal entry GUI smoke、P18.1 Typed SDK facade / mock host / stable error、P18.2 Host Bridge typed router / stable error response、P18.3 Core capability adapters、P18.4 App-scoped Agent task SDK facade、P18.4-H AgentRuntime handoff gate、P18.5.1 Lime-side 内容工厂 SDK regression、P18.5.2 package-side read-only tests、P18.5-S Host Bridge SDK client、P18.5.3 package-side SDK facade / verify / dist 同步与 P18.6 Raw Worker 前 Gate 已通过定向 / 专用验证 / GUI smoke / 计划收口。当前主线下一刀只做 owner handoff / 提交边界;不新增 marketplace、Cloud 管理台、真实 delete-data 或完整行业内容系统。 ```text P4-R0~P4-R5 v0.3 / 内容工厂 Rebaseline(已通过) @@ -1207,7 +1244,17 @@ P4-R0~P4-R5 v0.3 / 内容工厂 Rebaseline(已通过) → P17.2.5 Schema / reference CLI / example package cross-check(已完成) → P17.3 Lifecycle / cleanup contract hardening(已完成) → P17.4 Runtime surface production hardening(已完成,含 P17.4.5 GUI smoke) -→ P17.5 Formal entry GUI smoke(下一刀,和 Lab smoke 分离) +→ P17.5 Formal entry GUI smoke(已完成,和 Lab smoke 分离) +→ P18.0 v0.4 Host Bridge / Typed SDK gap matrix(文档计划已落地) +→ P18.1 Typed SDK facade / mock host / stable error(已完成) +→ P18.2 Host Bridge typed router / stable error response(已完成) +→ P18.3 Core capability adapters(已完成) +→ P18.4 App-scoped Agent task SDK facade(已完成) +→ P18.4-H AgentRuntime handoff gate(已完成) +→ P18.5.1 Lime-side 内容工厂 SDK regression(已完成) +→ P18.5.2 Package-side read-only tests(已完成) +→ P18.6 Raw Worker 前 Gate(已完成) +→ P18.5.3 Package-side SDK facade / verify(下一刀) ``` P14 文件边界: diff --git a/docs/roadmap/agentapp/p17-4-host-bridge-runtime.md b/docs/roadmap/agentapp/p17-4-host-bridge-runtime.md index 83fe5842a..1fb125dab 100644 --- a/docs/roadmap/agentapp/p17-4-host-bridge-runtime.md +++ b/docs/roadmap/agentapp/p17-4-host-bridge-runtime.md @@ -384,5 +384,5 @@ P17.4 已完成当前 production hardening 闭环:`AgentAppHostBridge`、`capa 结论: 1. P17.4.5 已完成;完整 GUI smoke 通过,可以把 P17.4 runtime surface production hardening 标记为当前闭环完成。 -2. P17.5 仍未开始;下一刀需要新增或扩展正式 `agent-apps` 专用 smoke,覆盖 install / registration / launch / disable / uninstall rehearsal / runtime surface / flag-off。 -3. P17.5 之前仍不得发布 marketplace、Cloud 管理台或真实 delete-data。 +2. P17.5 已在后续 [Formal Entry GUI Smoke](./p17-5-formal-entry-gui-smoke.md) 中完成;正式 `agent-apps` 专用 smoke 已覆盖 install / registration / launch / disable / uninstall rehearsal / runtime surface / flag-off。 +3. P17.5 通过后仍不得发布 marketplace、Cloud 管理台或真实 delete-data;下一刀进入 P18 Typed Capability SDK Gate。 diff --git a/docs/roadmap/agentapp/p17-5-formal-entry-gui-smoke.md b/docs/roadmap/agentapp/p17-5-formal-entry-gui-smoke.md new file mode 100644 index 000000000..bb69f3150 --- /dev/null +++ b/docs/roadmap/agentapp/p17-5-formal-entry-gui-smoke.md @@ -0,0 +1,66 @@ +# P17.5 Formal Entry GUI Smoke + +更新时间:2026-05-16 + +状态:已完成;正式 `agent-apps` 入口已具备独立 smoke 证据。 + +## 一句话结论 + +P17.5 已用 `smoke:agent-apps` 证明正式 Agent Apps 入口可以独立跑通 install、registration、launch、disable、uninstall rehearsal、runtime surface 和 flag-off;Lab smoke 继续只作为研发辅助证据,不再替代正式入口证据。 + +## 范围 + +本阶段只验证正式 `agent-apps` 用户入口,不新增 marketplace、Cloud 管理台、真实 delete-data、Workspace pin、命令面板入口或完整行业系统。 + +| 验证项 | 证据 | +|---|---| +| 正式入口可达 | 账号菜单中的 `Agent Apps` 可打开正式页面,页面根节点提供 `data-testid="agent-apps-page"` 稳定锚点。 | +| registration blocker | seeded Cloud catalog 显示 `registration required`,安装按钮保持 disabled。 | +| active bootstrap install | smoke 注入受控 OEM Cloud runtime 与 active bootstrap catalog,Cloud install review 可打开并确认。 | +| lifecycle disable / enable | disable 后 dashboard launch 被阻断,enable 后恢复。 | +| runtime surface | dashboard entry 启动到 `agent-app` runtime surface,iframe 指向受控 runtime URL。 | +| uninstall rehearsal | delete-data 只导出 cleanup evidence;`warningCodes` 包含 `DRY_RUN_ONLY`,不执行真实删除。 | +| flag-off regression | 关闭 Lab flag 后,正式 `Agent Apps` 仍可见,`Agent App Lab` 隐藏。 | + +## 关键修复 + +1. `AgentAppsPage` 根节点补 `data-testid="agent-apps-page"`,避免 smoke 已进入页面却误判不可见。 +2. `scripts/agent-apps-smoke.mjs` 在 active bootstrap 阶段注入受控 `__LIME_OEM_CLOUD__` / `__LIME_SESSION_TOKEN__`,并用 Playwright route mock `/client/agent-apps`,避免访问真实网络。 +3. active bootstrap 的 `packageHash` / `manifestHash` 改为合法 `sha256:<64 hex>`,符合 P17.2 Cloud release descriptor 校验。 +4. cleanup rehearsal 断言从 lifecycle descriptor 的 `completionEffect` 调整为 cleanup evidence 的 `DRY_RUN_ONLY` 警告,符合 UI 当前导出的事实源。 + +## 验证记录 + +| 命令 | 结果 | +|---|---| +| `node --check scripts/agent-apps-smoke.mjs` | 通过。 | +| `git diff --check -- scripts/agent-apps-smoke.mjs src/features/agent-app/ui/AgentAppsPage.tsx` | 通过。 | +| `nice -n 10 npm run smoke:agent-apps -- --timeout-ms 300000 --interval-ms 1000` | 通过。 | + +证据产物: + +- Summary:`.lime/qc/gui-evidence/agent-apps/agent-apps-smoke-summary.json` +- 主截图:`.lime/qc/gui-evidence/agent-apps/agent-apps-smoke.png` +- Flag-off 截图:`.lime/qc/gui-evidence/agent-apps/agent-apps-smoke-flag-off.png` + +## Summary 断言 + +`agent-apps-smoke-summary.json` 当前全部核心断言为 `true`: + +- `formalPageVisible` +- `installedVisible` +- `registrationRequiredBlocked` +- `cloudInstallReviewVisible` +- `disabledLaunchBlocked` +- `runtimeSurfaceVisible` +- `cleanupEvidenceSelectedApp` +- `cleanupEvidenceStrategy` +- `cleanupEvidenceDryRunOnly` +- `cleanupEvidenceBlockedCount` +- `flagOffAgentAppsNavVisible` +- `flagOffLabNavHidden` +- `flagOffNoConsoleErrors` + +## 下一刀 + +P17.5 已完成。下一刀进入 P18 Typed Capability SDK:先把 P18.0 gap matrix 与上游 `agentapp-ref@0.4.0` / Host Bridge v1 / AgentRuntime artifact projection 对齐,再实施 P18.1 SDK facade、stable error 和 mock host;仍不进入 raw worker、marketplace 或真实 delete-data。 diff --git a/docs/roadmap/agentapp/p18-4-h-agentruntime-handoff-gate.md b/docs/roadmap/agentapp/p18-4-h-agentruntime-handoff-gate.md new file mode 100644 index 000000000..5e6def5f6 --- /dev/null +++ b/docs/roadmap/agentapp/p18-4-h-agentruntime-handoff-gate.md @@ -0,0 +1,57 @@ +# P18.4-H AgentRuntime Handoff Gate + +更新时间:2026-05-16 + +状态:已完成 Agent App 消费侧 handoff gate;允许进入 P18.5 内容工厂 SDK 化回归。本文不声明 AgentRuntime 全部产品化完成。 + +## 目标 + +P18.4-H 的目标不是继续实现 AgentRuntime,而是把隔壁 AgentRuntime current MVP 作为 Agent App P18 的消费侧事实输入,形成可复核的 prompt-to-artifact checklist:哪些能力已经能被 SDK / Host Bridge 消费,哪些仍归 AgentRuntime owner 收口,哪些不能由 Agent App SDK 伪造完成。 + +## 成功标准 + +1. `lime.agent` 的 SDK facade 与 Host Bridge / AgentRuntime facade 方法一一对应。 +2. task event、artifact refs、evidence refs、workspace patch、Host response、retry / cancel / list 语义都有具体代码或测试证据。 +3. 已知缺口必须明确 owner 和退出条件,不能被 P18 SDK mock 当作生产完成。 +4. 本 gate 不修改 `src-tauri/*`、`src/lib/api/agentAppRuntime.ts`、`agentRuntimeCapabilityHost*` 实现逻辑、GUI smoke 脚本或运行中 DevBridge / Tauri / Vite 进程。 + +## Prompt-to-artifact checklist + +| 要求 | Agent App P18 证据 | AgentRuntime 证据 | 判定 | +|---|---|---|---| +| `startTask / streamTask / getTask / cancelTask / retryTask / submitHostResponse / listTasks` 被公开 SDK facade 覆盖。 | `src/features/agent-app/sdk/capabilityAdapters.ts` 的 `LimeAgentCapabilityAdapter`;`src/features/agent-app/sdk/capabilityAdapters.test.ts` 的 P18.4 adapter fixture。 | `src/features/agent-app/runtime/agentRuntimeCapabilityHost.ts` 暴露同名 `sdk.agent` 方法并接到 runtime api;`src/lib/api/agentAppRuntime.ts` 维护 `agent_app_runtime_*` gateway。 | 通过;P18 消费侧不再要求 App 手写私有 bridge。 | +| task request options `queueIfBusy / skipPreSubmitResume / runStartHooks` 不丢失。 | `src/features/agent-app/types.ts`、`src/features/agent-app/sdk/capabilityAdapters.test.ts`。 | `src/features/agent-app/runtime/agentRuntimeCapabilityHost.ts` 透传到 api;`src-tauri/src/commands/agent_app_runtime_cmd.rs` 有同名 serde 字段。 | 通过;仍由 AgentRuntime 决定队列执行事实。 | +| task event union 覆盖 queued / progress / missing context / review / tool call / artifact / evidence / completed / incident。 | `src/features/agent-app/types.ts` 的 `AgentAppTaskEventType`;`src/features/agent-app/sdk/capabilityAdapters.test.ts` 固定 SDK fixture。 | `src/features/agent-app/runtime/agentRuntimeCapabilityHost.test.ts` 覆盖 missing context、artifact、evidence、workspace patch;`src-tauri/src/commands/agent_app_runtime_cmd.rs` 的 `test_agent_app_runtime_task_events_project_thread_read_facts` 覆盖 artifact/evidence 投影。 | 通过 first-cut;后端主动 push subscribe 仍未完成。 | +| Host response 可带 session / turn / request scope 回写。 | SDK `submitHostResponse` facade 和 P18.4 test。 | `src/features/agent-app/runtime/agentRuntimeCapabilityHost.test.ts` 验证 `agent_runtime_respond_action` 形态的 `session_id / turn_id / request_id`;`src/lib/api/agentAppRuntime.ts` 暴露 submit gateway。 | 通过;真实权限/询问策略仍由 AgentRuntime owner 管。 | +| Artifact / Evidence refs 可回投到 App event。 | P18.4 SDK fixture 固定 `refs` 和 `payload` 不丢失。 | `src/features/agent-app/runtime/agentRuntimeCapabilityHost.test.ts` 验证 `artifact:created` 与 `evidence:recorded` refs;`src-tauri/src/commands/agent_app_runtime_cmd.rs` 构造 `artifact:created / evidence:recorded / evidence:verified` events。 | 通过 first-cut。 | +| workspace patch / contentFactoryWorkspacePatch 不被 SDK 私造。 | SDK fixture 只保留 typed payload,不声明生产成功。 | `src-tauri/src/commands/agent_app_runtime_cmd.rs` 已有 `contentFactoryWorkspacePatch / workspacePatch` metadata extract 与 output contract;`agent-app-runtime-completion-audit.md` 明确真实 Skill / Agent 端到端产出仍是缺口。 | handoff 通过;真实 producer 仍归 AgentRuntime / Skill owner。 | +| task state 跨刷新恢复。 | P18 不复制 read model,只消费 `getTask / listTasks`。 | `src/features/agent-app/runtime/agentRuntimeCapabilityHost.test.ts` 的“从 Agent App storage 恢复 runtime task state”覆盖恢复、list 和 Host response。 | 通过 first-cut。 | +| Agent App 不退化成模型 API / 通用 Chat 包装壳。 | P18 docs 固定 SDK / Host Bridge 边界;feature island 扫描要求无 direct `safeInvoke / invoke / raw Worker`。 | `docs/roadmap/agentruntime/app-surface-runtime.md` 固定 Agent App 是 business surface,AgentRuntime 是事实主链。 | 通过当前边界;后续 P18.5 用内容工厂包验证。 | +| 与隔壁任务不打架。 | 本 gate 只新增/更新 `docs/roadmap/agentapp/*` handoff artifact,不改 runtime owner 文件。 | 隔壁继续维护 `src-tauri/*`、`src/lib/api/agentAppRuntime.ts`、`agentRuntimeCapabilityHost*`、GUI smoke 脚本。 | 通过。 | + +## 已知缺口与 owner + +| 缺口 | 现状 | Owner | P18 处理方式 | 退出条件 | +|---|---|---|---|---| +| 后端 push subscribe / runtime event 主动推送。 | 当前 App 可通过 `streamTask / getTask` 轮询更新;还不是 Tauri event / runtime bus 主动推送。 | AgentRuntime / Host Bridge owner。 | P18 不写轮询 read model;只在 SDK 中保留 stream/get facade。 | Host Bridge 或 `agent_app_runtime_*` facade 提供可订阅事件通道,并有前后端测试。 | +| 真实 Skill / Agent 端到端写出 workspace patch artifact。 | runtime message 和 output contract 已能要求 patch,projection 能透传 artifact metadata;未证明真实模型/Skill 端到端产出。 | AgentRuntime + 内容工厂 Skill / prompt owner。 | P18.5 只消费现有 content-factory package,不能伪造生产 patch。 | 真实任务或 fixture 产出 `contentFactoryWorkspacePatch / workspacePatch` artifact,并被 App consumer 回放。 | +| 独立 capability catalog service。 | 首批 capability hints 可映射 Claw metadata;catalog 仍内置在 facade 最小实现。 | AgentRuntime capability catalog owner。 | P18 只保留 typed `lime.tools / lime.knowledge / lime.agent` 能力声明。 | `agent_runtime_capability_catalog_service` 拆出,并接 manifest capability 校验。 | +| 真实桌面 GUI smoke。 | P17.5 formal entry smoke 已通过;AgentRuntime audit 有 MCP first-cut 证据;本 gate未抢运行中 GUI 任务。 | AgentRuntime / GUI smoke owner。 | P18.4-H 不启动 Tauri / Vite / DevBridge;P18.5 若改内容工厂 SDK 回归,再评估是否需要 smoke。 | 由 owner 输出可复核 GUI summary,并证明 App 内 task / write-back 主路径。 | + +## 判定 + +- P18.4-H 对 Agent App P18 的消费侧 handoff gate 已通过:SDK facade、task event、Host response、artifact/evidence refs、workspace patch payload、跨刷新恢复和 no-private-bridge 边界都有可追踪证据。 +- 允许进入 P18.5:用现有 content-factory package 验证 App 内只依赖 SDK facade 的业务闭环。 +- 不能标记整体目标 100%:push subscribe、真实 Skill / Agent patch producer、独立 capability catalog service、真实桌面 GUI smoke 仍是 AgentRuntime / GUI owner 缺口。 + +## 最小验证命令 + +```bash +nice -n 10 npm test -- src/features/agent-app/sdk/capabilityAdapters.test.ts src/features/agent-app/sdk/capabilityContract.test.ts src/features/agent-app/sdk/MockCapabilityHost.test.ts src/features/agent-app/runtime/hostBridge.test.ts src/features/agent-app/runtime/capabilityDispatcher.test.ts src/features/agent-app/runtime/agentRuntimeCapabilityHost.test.ts src/features/agent-app/ui/AgentAppRuntimePage.test.tsx +nice -n 10 npm run typecheck -- --pretty false +nice -n 10 npm run test:contracts +git diff --check -- docs/roadmap/agentapp src/features/agent-app/sdk src/features/agent-app/index.ts +rg -n "SceneApp|contentEngineering|sceneapp_|safeInvoke|invoke\\(|new Worker|Worker\\(" src/features/agent-app || true +``` + +如果要把 AgentRuntime owner 缺口也判为完成,还必须追加 Rust 定向测试、真实 runtime / GUI 证据和对应 owner 文档更新;这些不由 P18.4-H 消费侧 gate 代替。 diff --git a/docs/roadmap/agentapp/p18-5-3-owner-handoff.md b/docs/roadmap/agentapp/p18-5-3-owner-handoff.md new file mode 100644 index 000000000..039def80f --- /dev/null +++ b/docs/roadmap/agentapp/p18-5-3-owner-handoff.md @@ -0,0 +1,163 @@ +# P18.5.3 Owner Handoff / Package-side SDK Facade + +更新时间:2026-05-16 + +状态:package verify 已完成,待 owner handoff。本文是给外部 `/Users/coso/Documents/dev/ai/limecloud/content-factory-app` owner 的执行单页;详细设计仍以 [p18-5-3-package-sdk-migration-plan.md](./p18-5-3-package-sdk-migration-plan.md) 为准。 + +## 当前判定 + +P18 Lime-side SDK / Host Bridge / typed contract 已有证据;外部 `content-factory-app/src/ui/host-bridge.js` 的 source-side SDK facade marker 已接入,私有 bridge marker 已清零;用户确认后真实 package `npm run verify` 已通过,`dist/*` 已纳入验收并同步。当前剩余是外部仓库仍处于 dirty / untracked 状态,需要 owner 接收写集。 + +2026-05-16 分阶段只读证据: + +| 检查项 | 结果 | 交接判定 | +|---|---|---| +| 外部 package 只读测试 | 2026-05-16 09:05 在 `/Users/coso/Documents/dev/ai/limecloud/content-factory-app` 运行 `npm test`,46 tests passed。 | 只证明现状业务测试仍绿;不能替代 SDK facade 迁移。 | +| 外部标准只读校验 | 2026-05-16 09:06 运行 `npm run validate:app && npm run readiness:app`;`validate:app` passed,manifest hash 为 `sha256:6ec3fed5f163739bcf0fd2b845c51a8e10d28aa856e8c6f90259fdab9edd1e48`;`readiness:app` needs-setup。 | 标准 manifest 可读,readiness 正确等待宿主绑定 required 依赖;不能替代 `npm run verify`。 | +| Lime-side 回归 | 2026-05-16 09:09 `npm test -- src/features/agent-app`,35 files / 173 tests passed;09:04 `typecheck` 与 `test:contracts` 均通过。 | Lime-side SDK / feature island 当前绿;不能证明外部 `host-bridge.js` 已迁移。 | +| 外部写集 | 外部仓库仍有大量 dirty / untracked,且 `src/ui/host-bridge.js`、`dist/ui/host-bridge.js` 仍未跟踪。 | owner 未稳定前继续只读。 | +| SDK facade marker | 2026-05-16 09:22 只读复核:`src/ui/host-bridge.js` 仍包含 `pendingRequests / buildMessage / window.parent.postMessage / requestHostBridge / capability:invoke / capability:subscribe / capability:event`;`tests/ui.test.mjs` 仍直接过滤 `message.type === 'capability:invoke'`。 | 外部 package 仍不是 SDK facade consumer;P18.5.3 不可标记完成。 | +| 09:39 再复核 | 外部 `npm test` 重跑仍为 46 tests passed;Lime-side SDK seam 最小集重跑 3 files / 8 tests passed;外部仓库仍是 36 个 tracked modified、3 个 untracked,且私有 bridge marker 未消失。 | 可以继续交接准备,但还不能默认接管;确认 handoff 后再改最小写集。 | +| 10:33 机械 gate 复核 | 运行 `node scripts/agent-app-package-handoff-check.mjs --package-dir /Users/coso/Documents/dev/ai/limecloud/content-factory-app`:`status=needs_handoff`,dirty `tracked=36 / untracked=4 / total=40`,`hostBridgePrivate=none`,`uiTestPrivate=none`,SDK marker 为 `createLimeHostBridgeCapabilityInvoker:2 / createLimeCoreCapabilityAdapters:2`,blockers 为 `none`。 | source-side facade blocker 已解除;接管动作从“实现迁移”改为“确认 owner handoff、决定是否跑 verify / 重建 dist”。 | +| 10:41 不重建 dist 验证 | Lime SDK seam 4 files / 11 tests passed;外部 `npm test` 46 tests passed;外部 `validate:app` passed;`readiness:app` needs-setup。 | 可交接证据增强;仍不能替代 `npm run verify`,因为 verify 会先 build 并重建 `dist/*`。 | +| 10:45 dist 预审 | 只读解析 `scripts/build.mjs`:build 会先删除 `dist` 再复制 `src/ui`、`src/core`、`src/integrations`、`src/worker/index.mjs`;hash 预审发现 `src/ui/host-bridge.js` 与 `dist/ui/host-bridge.js` 不一致,`src/ui/lime-app-sdk.js` 缺少对应 `dist/ui/lime-app-sdk.js`。 | 说明 source-side facade 尚未进入 dist 产物;最终验收必须由 owner 确认后重建 dist,或明确保留该缺口。 | +| 10:47 隔离 verify 演练 | 临时副本 `/tmp/limecloud-content-factory-verify.uots6S/content-factory-app` 中运行 `npm run verify` 通过:build、46 tests、validate、readiness 均完成,readiness 仍为预期 `needs-setup`。 | 说明当前源码具备通过 verify 的能力;真实仓库仍需 owner 确认后把 dist 重建纳入实际写集。 | +| 10:55 真实 verify | 用户确认后在真实外部 package 运行 `npm run verify` 通过:build、46 tests、validate、readiness 均完成;随后 handoff gate 显示 `distArtifacts=0`、blockers 为 `none`。 | P18.5.3 package-side verify 已完成;当前只剩 owner handoff / git 写集收口。 | + +## 接管前必须先跑 + +```bash +git -C /Users/coso/Documents/dev/ai/limecloud/content-factory-app status --short +rg -n "pendingRequests|requestHostBridge|buildMessage|postMessage|createLimeHostBridgeCapabilityInvoker|createLimeCoreCapabilityAdapters|capability:invoke|capability:subscribe|capability:event" /Users/coso/Documents/dev/ai/limecloud/content-factory-app/src/ui/host-bridge.js /Users/coso/Documents/dev/ai/limecloud/content-factory-app/tests/ui.test.mjs +node scripts/agent-app-package-handoff-check.mjs --package-dir /Users/coso/Documents/dev/ai/limecloud/content-factory-app +cd /Users/coso/Documents/dev/ai/limecloud/content-factory-app && nice -n 10 npm test +``` + +如果仍有未知 dirty / untracked,或者 owner 未明确交接,停止,不继续写外部 package。 + +2026-05-16 09:48 机械 handoff gate 已新增到 Lime repo:`scripts/agent-app-package-handoff-check.mjs` 只读检查外部 package 的 dirty 计数、私有 bridge marker、SDK facade marker、`scripts/build.mjs` 和会重建 `dist/*` 的高风险脚本。2026-05-16 10:33 最新输出为 `status=needs_handoff`:私有 Host Bridge transport marker 已消失,SDK facade marker 已出现;高风险脚本仍命中 `build / verify / e2e:user-flow / e2e:user-flow:fake-model`,因此下一步必须确认 owner handoff,不能直接重建 package artifacts。 + +## 最小写集 + +| 文件 | 允许动作 | +|---|---| +| `/Users/coso/Documents/dev/ai/limecloud/content-factory-app/src/ui/host-bridge.js` | 已进入 SDK facade consumer 形态;后续只允许 owner 接受后的最小修正。 | +| `/Users/coso/Documents/dev/ai/limecloud/content-factory-app/src/ui/lime-app-sdk.js` | 已作为 package-local 临时 shim 出现;正式 `@lime/app-sdk` 可安装后删除该 shim。 | +| `/Users/coso/Documents/dev/ai/limecloud/content-factory-app/tests/ui.test.mjs` | 已不再命中 handoff gate 的私有 transport marker;后续只做必要的行为回归修正。 | +| `/Users/coso/Documents/dev/ai/limecloud/content-factory-app/package.json` | 仅在正式 SDK package / workspace alias 确认可用时修改。 | +| `/Users/coso/Documents/dev/ai/limecloud/content-factory-app/dist/ui/*` | 只在明确允许 build / verify 后由构建生成。 | + +## 2026-05-16 11:03 Owner Acceptance Bundle + +当前 P18.5.3 功能验收已完成,但两个仓库都不是 clean 状态。提交或 handoff 时必须按写集分组,避免把外部 package 的并行业务资料改动混进 Agent App SDK facade 收口。 + +### Lime 客户端侧应接收的 P18 写集 + +| 分组 | 文件 / 范围 | 接收理由 | +|---|---|---| +| SDK facade / Host Bridge contract | `src/features/agent-app/sdk/*`、`src/features/agent-app/index.ts`、`src/features/agent-app/index.test.ts`。 | typed Capability SDK、SDK-only public surface、Host Bridge SDK client 与内容工厂 SDK regression。 | +| Agent App runtime / readiness / projection 回归 | `src/features/agent-app/runtime/*`、`src/features/agent-app/readiness/*`、`src/features/agent-app/projection/*`、`src/features/agent-app/manifest/*`、`src/features/agent-app/types.ts`、`src/features/agent-app/fixtures/content-factory-app.json`。 | P18 SDK gate 与 package projection / readiness / runtime contract 的配套回归。 | +| P18 handoff gate | `scripts/agent-app-package-handoff-check.mjs`、`scripts/lib/agent-app-package-handoff-core.mjs`、`scripts/lib/agent-app-package-handoff-core.test.ts`。 | 机械检查外部 package 的私有 bridge marker、SDK marker、高风险脚本与 `src -> dist` 产物漂移。 | +| Roadmap / 审计 | `docs/roadmap/agentapp/p18-completion-audit.md`、`p18-5-content-factory-sdk-regression.md`、`p18-5-3-package-sdk-migration-plan.md`、`p18-5-3-owner-handoff.md` 及相关 P17/P18 roadmap 增量。 | 记录 P18 分阶段证据、owner 边界、验证结果与下一阶段约束。 | + +### 外部 content-factory-app 的 P18.5.3 最小接收写集 + +| 文件 | 当前状态 | 接收理由 | +|---|---|---| +| `src/ui/host-bridge.js` | untracked。 | package-side business helper 收敛为 SDK facade consumer。 | +| `src/ui/lime-app-sdk.js` | untracked。 | 临时 package-local SDK shim;正式 `@lime/app-sdk` 可安装后删除。 | +| `tests/ui.test.mjs` | modified。 | Host Bridge 结构性断言改为 SDK facade / invoker call log 断言,保留业务闭环测试。 | +| `dist/ui/host-bridge.js` | untracked。 | 真实 `npm run verify` 后生成的 runtime 产物。 | +| `dist/ui/lime-app-sdk.js` | untracked。 | 真实 `npm run verify` 后生成的 SDK shim 产物。 | + +### 外部 package 当前仍存在的非 P18 并行 dirty + +以下文件同样处于 modified,但不是 P18.5.3 SDK facade 的最小必要写集;是否纳入同一提交必须由外部 package owner 决定: + +```text +APP.md +README.md +RELEASE_NOTES.md +agents/content-strategist.md +docs/v1/* +docs/requirements-map.md +package.json +scripts/e2e-user-flow.mjs +src/core/* +src/integrations/* +src/server/api.mjs +src/ui/app.js +src/ui/index.html +src/ui/styles.css +tests/api.test.mjs +tests/core.test.mjs +tests/model-generation.test.mjs +dist/core/* +dist/integrations/* +dist/ui/app.js +dist/ui/index.html +dist/ui/styles.css +``` + +提交建议:Lime 客户端 P18 gate 与文档可以作为一个 Lime repo 变更组;外部 `content-factory-app` 至少把上表 5 个 P18.5.3 文件作为 SDK facade 验收组,其余业务 / docs / core / model-generation / server / dist 变更由 package owner 单独判断。 + +## 禁止项 + +1. 不改业务 UI、文案、样式、core、integrations、server、model-generation。 +2. 不新增 Lime 垂直 `content_factory_*` command。 +3. 不让外部 App import Lime repo `src/features/agent-app/*` deep path。 +4. 10:55 用户已确认并完成一次真实 `npm run verify`;后续若再次运行 `npm run build`、`npm run verify`、`npm run e2e:user-flow*`,仍需确认 owner 接受 `dist/*` 再次重建。 +5. 不删除业务行为断言,只替换鼓励私有 bridge 的测试 mock / 结构性断言。 + +## 当前目标 + +保留 `src/ui/app.js` 当前 5 个直接 import: + +```js +initHostBridge +notifyHost +runHostAgentTask +syncHostConfirmation +writeHostTaskResult +``` + +当前 gate 已确认 `host-bridge.js` 不再拥有以下私有 marker;后续 owner 接手时仍需保持: + +```text +pendingRequests +buildMessage +requestHostBridge +window.parent.postMessage +capability:invoke / capability:subscribe / capability:event 私有 lifecycle +``` + +业务 helper 底层只能调用: + +```text +lime.agent +lime.storage +lime.artifacts +lime.evidence +SDK Host action / subscription wrapper +``` + +## 验证顺序 + +1. 已确认 handoff gate 为 `needs_handoff` 且 blockers 为 `none`;10:55 追加确认 `distArtifacts=0`。 +2. 外部 package:10:55 `nice -n 10 npm run verify` 已通过,包含 `npm test` 46 tests passed。 +3. Lime repo:10:53 SDK seam / handoff core 定向测试 5 files / 17 tests passed。 +4. Lime repo:10:55 `nice -n 10 npm run typecheck -- --pretty false` 已通过。 +5. Lime repo:10:53 `nice -n 10 npm run test:contracts` 已通过。 +6. 后续只有在 owner 继续修改外部 package 或触及运行时边界时,才需要重跑对应 verify / GUI smoke。 + +## 完成条件 + +P18.5.3 只有在以下全部成立时才算完成: + +1. 外部 `src/ui/host-bridge.js` 已收敛为 SDK facade consumer。 +2. 外部 `npm test` 通过。 +3. 外部 `npm run verify` 通过,或明确记录未运行原因和退出条件。 +4. Lime feature island / typecheck / contracts 回归通过。 +5. [p18-5-content-factory-sdk-regression.md](./p18-5-content-factory-sdk-regression.md) 与 [p18-completion-audit.md](./p18-completion-audit.md) 回填迁移后证据。 + +2026-05-16 10:55:以上 1-5 均已满足;本文继续保留为 owner handoff 单页,提醒真实外部 package 仍 dirty,提交 / 推送边界需由 owner 决定。 diff --git a/docs/roadmap/agentapp/p18-5-3-package-sdk-migration-plan.md b/docs/roadmap/agentapp/p18-5-3-package-sdk-migration-plan.md new file mode 100644 index 000000000..6eb3f2ff1 --- /dev/null +++ b/docs/roadmap/agentapp/p18-5-3-package-sdk-migration-plan.md @@ -0,0 +1,563 @@ +# P18.5.3 Package-side SDK Facade Migration Plan + +更新时间:2026-05-16 + +状态:package-side SDK facade / verify 已完成,待 owner handoff;外部 `/Users/coso/Documents/dev/ai/limecloud/content-factory-app` 的 source-side SDK facade marker 已接入,私有 bridge marker 已清零,真实 `npm run verify` 已通过,`dist/*` 已同步 SDK facade;该仓库仍处于大量 dirty / untracked 状态,需 owner 接收写集。 + +## 一句话结论 + +P18.5.3 的目标不是重写内容工厂业务,也不是把 Lime repo 的测试当成外部 package 完成证据;目标是把外部 `content-factory-app/src/ui/host-bridge.js` 从手写 Host Bridge transport 收敛为标准 SDK facade consumer,让业务 helper 继续存在,但底层只调用 `createLimeHostBridgeCapabilityInvoker + createLimeCoreCapabilityAdapters`。截至 2026-05-16 10:55,source-side facade blocker 已解除,真实 package verify 已通过,`dist/*` 已同步;剩余风险转为 owner handoff / git 写集收口。 + +## 迁移前只读事实 + +外部 package 当前状态: + +```text +/Users/coso/Documents/dev/ai/limecloud/content-factory-app +``` + +以下为 10:33 前的迁移基线,保留用于解释为什么不能直接大范围覆盖外部 package;当前最新状态以“2026-05-16 10:33 协作复核”为准。迁移前仍有大量 dirty 文件,包含: + +- `src/ui/host-bridge.js` 未跟踪。 +- `dist/ui/host-bridge.js` 未跟踪。 +- `src/ui/app.js`、`src/ui/index.html`、`src/ui/styles.css` 已修改。 +- `tests/ui.test.mjs` 已修改。 +- `package.json` 已修改。 +- 多个 `docs/v1/*`、`src/core/*`、`src/integrations/*`、`tests/*` 已修改。 + +迁移前 `src/ui/host-bridge.js` 仍直接维护: + +| 当前职责 | 说明 | P18.5.3 目标 | +|---|---|---| +| `PROTOCOL / VERSION / APP_ID / DEFAULT_ENTRY_KEY` | App 内自行定义 bridge 协议常量。 | 协议常量由 SDK client 提供;App 只传 `appId / entryKey`。 | +| `pendingRequests` / timeout / `requestHostBridge` | App 自己管理 request lifecycle。 | 由 `createLimeHostBridgeCapabilityInvoker` 统一处理。 | +| `app:ready / host:getSnapshot / host:snapshot / theme:update / host:visibility` | App 自己监听 Host 初始化与主题事件。 | 由 SDK client 的 ready、snapshot、theme、visibility API 承接。 | +| `notifyHost / requestHostDownload / navigation helper` | App 自己发送 `host:toast / host:navigate / host:openExternal / host:download`。 | 由 SDK client Host action API 承接。 | +| `invokeCapability(capability, method, input)` | App 自己构造 `capability:invoke` payload。 | App 业务 helper 调用 `lime.agent / lime.storage / lime.artifacts / lime.evidence` facade。 | +| `startHostTask / streamHostTask / getHostTask / submitHostResponse` | 对 `lime.agent` 的私有 wrapper。 | 保留业务语义,但内部改为 `lime.agent.*`。 | +| `writeHostTaskResult / syncHostConfirmation` | 内容工厂业务 helper。 | 可以保留;内部必须只调用 `lime.storage / lime.artifacts / lime.evidence / lime.agent`。 | +| `capability:subscribe / unsubscribe / event` | Host task update 的主动推送 / 轮询桥。 | 由 SDK client 的 `subscribeCapability / unsubscribeCapability` 承接;业务 helper 只消费事件 payload。 | + +### 2026-05-16 迁移前只读复核 + +本段是 10:55 真实 verify 之前的历史复核,用于解释迁移前 blocker 和 handoff 规则。复核命令: + +```bash +git -C /Users/coso/Documents/dev/ai/limecloud/content-factory-app status --short +rg -n "pendingRequests|requestHostBridge|buildMessage|postMessage|createLimeHostBridgeCapabilityInvoker|createLimeCoreCapabilityAdapters|capability:invoke|capability:subscribe|capability:event" /Users/coso/Documents/dev/ai/limecloud/content-factory-app/src/ui/host-bridge.js +``` + +历史结论: + +| 检查项 | 当时事实 | 历史判定 | +|---|---|---| +| 外部仓库是否稳定 | `APP.md`、`README.md`、`dist/*`、`src/ui/*`、`tests/*`、docs、model-generation 等仍 dirty;`src/ui/host-bridge.js` 与 `dist/ui/host-bridge.js` 仍未跟踪。 | 不稳定;不能直接覆盖。 | +| 是否已接入标准 SDK client | `src/ui/host-bridge.js` 未出现 `createLimeHostBridgeCapabilityInvoker` 或 `createLimeCoreCapabilityAdapters`。 | 未接入。 | +| 是否存在手写 transport | 当时包含 `pendingRequests`、`buildMessage`、`window.parent.postMessage`、`requestHostBridge`、`capability:invoke`、`capability:subscribe`、`capability:event`。 | 迁移前未完成。 | +| 哪些业务 helper 可以保留 | `runHostAgentTask`、`writeHostTaskResult`、`syncHostConfirmation` 已承载内容工厂业务语义。 | 保留语义,迁移时只替换底层 transport。 | +| 是否需要改 `dist/*` | `dist/ui/host-bridge.js` 同样未跟踪且像是并行构建产物。 | 不在 owner 稳定前改写;verify 阶段再更新。 | + +因此 P18.5.3 的第一刀不是重写内容工厂页面,而是建立 package-side SDK seam: + +```text +当前业务 helper + -> package-local sdk runtime handle + -> lime.agent / lime.storage / lime.artifacts / lime.evidence facade + -> createLimeCoreCapabilityAdapters + -> createLimeHostBridgeCapabilityInvoker + -> Host Bridge v1 +``` + +迁移时应优先保留业务 helper 的导出 API,降低和隔壁 UI / tests 改动冲突;只有 `pendingRequests`、`buildMessage`、`requestHostBridge`、stable error unwrap、timeout 和 subscription transport 需要归还给 SDK client。 + +### 2026-05-16 协作复核 + +本段只做 owner lock,不接管外部 package 写集。 + +| 输入 | 当前状态 | P18.5.3 处理方式 | +|---|---|---| +| `/Users/coso/Documents/dev/ai/limecloud/agentapp` | 工作区干净,当前 `HEAD=4bef605 fix: quote mermaid sdk labels`;标准文档已扩展到 zh / en 的 authoring、client implementation、reference、examples、what-is-agent-app、agent-app-vs-skills-knowledge 与 mini-program analogy。 | 只作为标准事实源输入;不修改标准仓库,不把标准文档复制进 Lime 客户端计划。 | +| `/Users/coso/Documents/dev/ai/limecloud/content-factory-app` | 仍有 `APP.md`、`README.md`、`dist/*`、`src/ui/*`、`tests/*`、docs、model-generation 等大量 dirty / untracked。 | owner 未稳定,继续不改源码、不改 `dist/*`、不跑会 build 的 verify。 | +| `src/ui/host-bridge.js` | 当时包含 `pendingRequests`、`window.parent.postMessage`、`requestHostBridge`、`capability:invoke`、`capability:subscribe` 等手写 transport 标记,未出现 `createLimeHostBridgeCapabilityInvoker` 或 `createLimeCoreCapabilityAdapters`。 | 当时需替换 transport seam,不重写业务 UI;该项已在 10:55 收口。 | +| `tests/ui.test.mjs` | 仍有对 `capability:invoke / subscribe / event` 与 `postMessage` mock 的结构性断言。 | 后续迁移时保留业务闭环断言,替换为 SDK invoker / host bridge client call log。 | +| 外部 read-only tests | 2026-05-16 07:50 按 owner handoff gate 低优先级运行 `npm test` 通过,当前为 46 tests passed。 | 只能证明当前业务测试仍绿;不能替代 SDK facade 迁移,也不能替代会重写 `dist/*` 的 `npm run verify`。 | +| 外部标准只读校验 | 2026-05-16 07:11 与 08:42 低优先级运行 `npm run validate:app && npm run readiness:app` 通过;`validate:app` 为 `ok=true / status=passed`,manifest hash 仍为 `sha256:6ec3fed5f163739bcf0fd2b845c51a8e10d28aa856e8c6f90259fdab9edd1e48`;`readiness:app` 为 `ok=true / status=needs-setup`。 | 只能证明标准 manifest 与 readiness 语义仍有效;`needs-setup` 表示宿主需补齐 required 依赖,不代表 package-side SDK facade 已完成。 | + +历史结论:当时 P18.5.3 被 handoff gate 阻塞;10:55 真实 verify 后,该 blocker 已解除,当前仅剩 owner 接收写集。 + +2026-05-16 07:39 再次只读复核: + +| 检查项 | 最新事实 | P18.5.3 判定 | +|---|---|---| +| 标准仓库 | `/Users/coso/Documents/dev/ai/limecloud/agentapp` 工作区干净,`HEAD=4bef605`。 | 标准事实源稳定;无需改标准仓库。 | +| 外部 package 状态 | `/Users/coso/Documents/dev/ai/limecloud/content-factory-app` 仍有 39 个 dirty / untracked 条目,其中 36 个 tracked modified、3 个 untracked。 | owner 仍未稳定;不接管外部源码、`dist/*` 或 package verify。 | +| Host Bridge SDK marker | `src/ui/host-bridge.js` 仍包含 `pendingRequests`、`window.parent.postMessage`、`requestHostBridge`、`capability:invoke`、`capability:subscribe`、`capability:event`,未出现 `createLimeHostBridgeCapabilityInvoker` 或 `createLimeCoreCapabilityAdapters`。 | 仍是手写 transport;SDK facade 尚未完成。 | +| UI 测试 marker | `tests/ui.test.mjs` 仍直接过滤 `message.type === 'capability:invoke'`,并模拟 `postMessage`。 | 测试仍鼓励私有 message transport;迁移时需要改成 SDK invoker / Host Bridge client call log。 | +| 本轮处理方式 | 只读复核并更新客户端计划;不改外部 package、不跑 `npm run build` / `npm run verify`。 | 符合协作分工,避免和隔壁任务打架。 | + +### 2026-05-16 10:33 协作复核 + +本段是当前最新事实,只读运行: + +```bash +node scripts/agent-app-package-handoff-check.mjs --package-dir /Users/coso/Documents/dev/ai/limecloud/content-factory-app +``` + +输出摘要: + +```text +status=needs_handoff +dirty=tracked:36,untracked:4,total:40 +hostBridgePrivate=none +hostBridgeSdk=createLimeHostBridgeCapabilityInvoker:2, createLimeCoreCapabilityAdapters:2 +uiTestPrivate=none +highRiskScripts=build,verify,e2e:user-flow,e2e:user-flow:fake-model +blockers=none +nextAction=Confirm owner handoff before changing or rebuilding package artifacts. +``` + +判定: + +| 检查项 | 当前事实 | P18.5.3 判定 | +|---|---|---| +| Source-side SDK facade | `src/ui/host-bridge.js` 已出现 `createLimeHostBridgeCapabilityInvoker` 与 `createLimeCoreCapabilityAdapters` marker。 | 迁移实现 blocker 已解除。 | +| 私有 bridge marker | `hostBridgePrivate=none`,`uiTestPrivate=none`。 | `host-bridge.js` 与 `tests/ui.test.mjs` 已不再命中 handoff gate 定义的私有 transport marker。 | +| 外部仓库状态 | 仍有 36 个 tracked modified、4 个 untracked,包含 `dist/*`、UI、docs、tests、core / integrations 等并行写集。 | 仍需 owner handoff;本任务不继续写外部 package。 | +| Package verify | `build / verify / e2e:user-flow*` 仍被 gate 标为高风险脚本,因为会重建 `dist/*`。 | 未运行;不能把 P18.5.3 视为完整完成。 | +| 当前协作动作 | 只更新 Lime 客户端 roadmap,不改外部 package,不启停外部 dev 进程,不重建 `dist/*`。 | 符合“隔壁有任务在跑”的协作边界。 | + +因此 10:33 时 P18.5.3 已从 `blocked` 进入 `needs_handoff`:不再需要重新设计 source-side facade,但当时仍需确认外部 package owner 接受当前写集,再决定是否运行 `npm test` / `npm run verify`,或把 verify 缺口作为带退出条件的记录。该状态已被 10:55 真实 package verify 覆盖。 + +### 2026-05-16 10:41 不重建 dist 验证 + +为避免和隔壁任务争抢 `dist/*`,本轮只跑不触发 build 的验证: + +| 命令 | 结果 | 覆盖范围 | 不能替代 | +|---|---|---|---| +| `/Users/coso/Documents/dev/ai/aiclientproxy/lime: npm test -- src/features/agent-app/sdk/publicSdkSurface.test.ts src/features/agent-app/sdk/hostBridgeClient.test.ts src/features/agent-app/sdk/contentFactorySdkRegression.test.ts src/features/agent-app/index.test.ts` | 4 files / 11 tests passed。 | Lime SDK-only public surface、Host Bridge client、内容工厂 SDK regression、feature public export seam。 | 外部 package verify。 | +| `/Users/coso/Documents/dev/ai/limecloud/content-factory-app: npm test` | 46 tests passed。 | 外部 package 当前业务测试、Host Bridge task / stream / Host response / storage / artifact / evidence 写回。 | `dist/*` 构建产物验收与真实 Host 绑定。 | +| `/Users/coso/Documents/dev/ai/limecloud/content-factory-app: npm run validate:app && npm run readiness:app` | `validate:app` passed,manifest hash `sha256:6ec3fed5f163739bcf0fd2b845c51a8e10d28aa856e8c6f90259fdab9edd1e48`;`readiness:app` needs-setup。 | APP manifest 标准与宿主依赖 readiness 语义。 | 会重建 `dist/*` 的 `npm run verify`。 | + +结论:10:41 时 package source-side facade 和标准 manifest 处于可交接状态,但还缺真实 `dist/*` 重建;该缺口已在 10:55 真实 package verify 中收口。 + +### 2026-05-16 10:45 dist 重建前只读预审 + +只读复核 `scripts/build.mjs` 后确认,`npm run build` 会先执行 `rm('dist', { recursive: true, force: true })`,再把 `src/ui`、`src/core`、`src/integrations` 和 `src/worker/index.mjs` 复制到 `dist/*`。因此 `npm run verify` 一定会重建产物,不是纯校验。 + +本轮未执行 build,只用 hash 对比当前 `src -> dist` 复制结果: + +| 状态 | src | dist | 说明 | +|---|---|---|---| +| diff | `src/ui/host-bridge.js` | `dist/ui/host-bridge.js` | source-side facade 已迁移,但 dist 产物仍未同步。 | +| missing-dist | `src/ui/lime-app-sdk.js` | `dist/ui/lime-app-sdk.js` | package-local SDK shim 尚未出现在 dist。 | + +结论:P18.5.3 的剩余缺口不是再改业务源码,而是 owner handoff 后执行一次可接受的 dist 重建 / package verify;否则当前安装包产物不会包含 SDK facade shim。 + +### 2026-05-16 10:47 隔离 verify 演练 + +为进一步降低真实仓库重建风险,本轮把外部 package 当前工作区复制到临时目录,并用 sibling symlink 保持 `../agentapp` 标准工具相对路径可用: + +```text +/tmp/limecloud-content-factory-verify.uots6S/content-factory-app +``` + +在临时副本执行: + +```bash +nice -n 10 npm run verify +``` + +结果: + +| 阶段 | 结果 | +|---|---| +| `npm run build` | 通过;只重建临时副本的 `dist/*`。 | +| `npm test` | 46 tests passed。 | +| `npm run validate:app` | `ok=true / status=passed`,manifest hash `sha256:6ec3fed5f163739bcf0fd2b845c51a8e10d28aa856e8c6f90259fdab9edd1e48`。 | +| `npm run readiness:app` | `ok=true / status=needs-setup`,符合宿主 required 依赖待绑定状态。 | + +结论:当前 source tree 在隔离环境中已经能通过完整 package verify;该结论随后已被 10:55 真实 package verify 取代。 + +### 2026-05-16 10:55 真实 package verify + +用户确认允许重建真实外部 package 的 `dist/*` 后,在真实仓库执行: + +```bash +cd /Users/coso/Documents/dev/ai/limecloud/content-factory-app +nice -n 10 npm run verify +``` + +结果: + +| 阶段 | 结果 | +|---|---| +| `npm run build` | 通过;真实 `dist/*` 已重建。 | +| `npm test` | 46 tests passed。 | +| `npm run validate:app` | `ok=true / status=passed`,manifest hash `sha256:6ec3fed5f163739bcf0fd2b845c51a8e10d28aa856e8c6f90259fdab9edd1e48`。 | +| `npm run readiness:app` | `ok=true / status=needs-setup`,符合宿主 required 依赖待绑定状态。 | +| Handoff gate | `status=needs_handoff`,`hostBridgePrivate=none`,`uiTestPrivate=none`,SDK marker 命中 2+2,`distArtifacts=diff:0,missing:0,extra:0,total:0`,blockers 为 `none`。 | + +结论:P18.5.3 package-side SDK facade / verify / dist 同步已完成;gate 仍为 `needs_handoff` 是因为真实 package 工作区仍 dirty,且 build / verify / e2e 脚本天然会重写 dist,需要 owner 接收写集。 + +### 外部 package verify 风险 + +只读检查 `content-factory-app/package.json` 与 `scripts/build.mjs` 后确认: + +| 命令 | 当前行为 | P18.5.3 执行规则 | +|---|---|---| +| `npm test` | 运行 `node --test tests/*.test.mjs`,不主动重写 `dist/*`。 | 可作为只读验证先跑。 | +| `npm run build` | 执行 `scripts/build.mjs`;脚本会 `rm('dist', { recursive: true, force: true })`,再把 `src/ui`、`src/core`、`src/integrations` 和 `src/worker/index.mjs` 复制到 `dist/*`。 | 会重写外部 package `dist/*`,owner 未稳定前不能执行。 | +| `npm run verify` | 串行执行 `npm run build && npm run test && npm run validate:app && npm run readiness:app`。 | 等同会先重写 `dist/*`;只有明确认领 package 写集或 package owner 稳定后才能跑。 | +| `npm run e2e:user-flow*` | 先 `npm run build`,再跑 Playwright 用户流。 | 同样会重写 `dist/*`,不作为 P18.5.3 owner 稳定前验证。 | + +因此 P18.5.3 的安全验证顺序固定为:先 `npm test` 只读确认;完成 package-side SDK facade 且允许更新 `dist/*` 后,再跑 `npm run verify` 并把 `dist` 产物纳入同一写集。 + +2026-05-16 07:18 再次只读复核 `package.json` 与 `scripts/build.mjs`,脚本仍为:`verify = npm run build && npm run test && npm run validate:app && npm run readiness:app`,`e2e:user-flow*` 也会先 `npm run build`;`scripts/build.mjs` 第一刀仍是 `rm('dist', { recursive: true, force: true })`,随后从 `src/ui`、`src/core`、`src/integrations` 和 `src/worker/index.mjs` 复制到 `dist/*`。因此 owner 未稳定前继续禁止运行 `npm run verify` / `npm run build` / `npm run e2e:user-flow*`。 + +2026-05-16 08:40 当前会话只读复核仍一致:`npm run verify` 继续等价于 `npm run build && npm run test && npm run validate:app && npm run readiness:app`;`e2e:user-flow` 与 `e2e:user-flow:fake-model` 仍会先 `npm run build`;`scripts/build.mjs` 仍先执行 `rm('dist', { recursive: true, force: true })`,再复制 `src/ui -> dist/ui`、`src/core -> dist/core`、`src/integrations -> dist/integrations` 与 `src/worker/index.mjs -> dist/worker/index.mjs`。因此 owner 未稳定且未明确接受 `dist/*` 重建前,仍禁止运行 `npm run build`、`npm run verify` 或 `npm run e2e:user-flow*`。 + +2026-05-16 只读补充验证: + +```bash +nice -n 10 npm run validate:app +nice -n 10 npm run readiness:app +``` + +结果:`validate:app` 返回 `ok=true / status=passed`,manifest hash 为 `sha256:6ec3fed5f163739bcf0fd2b845c51a8e10d28aa856e8c6f90259fdab9edd1e48`;`readiness:app` 返回 `ok=true / status=needs-setup`,符合需要宿主补齐 required skill / knowledge / tool / artifact / eval / service 的状态。该验证不替代 `npm run verify`,因为后者会先重写 `dist/*`。 + +2026-05-16 08:42 当前会话只读复跑 `validate:app` 与 `readiness:app`,结果仍一致:`validate:app` 为 `ok=true / status=passed`,manifest hash 仍为 `sha256:6ec3fed5f163739bcf0fd2b845c51a8e10d28aa856e8c6f90259fdab9edd1e48`;`readiness:app` 为 `ok=true / status=needs-setup`。其中 `needs-setup` 来自 required skill / knowledge / tool / artifact / eval / service 仍需 Host 绑定,不能被解释为 package-side SDK facade 已完成,也不能替代会重写 `dist/*` 的 `npm run verify`。 + +### 外部 tests/ui.test.mjs 迁移靶点 + +只读检查 `tests/ui.test.mjs` 后,P18.5.3 需要区分“业务行为断言”和“手写 transport 断言”: + +| 当前测试面 | 可保留 / 需调整 | 迁移目标 | +|---|---|---| +| `UI接入 Lime Agent App Host Bridge 并跟随宿主主题` | 需调整。当前直接断言 `bridge` 文本包含 `lime.agentApp.bridge`、`app:ready`、`host:getSnapshot`、`capability:invoke`、`capability:subscribe`、`capability:event` 等 transport 字符串。 | 改为断言 package 通过 SDK-style runtime handle 暴露 ready / snapshot / theme / host action / subscription 能力,不鼓励业务文件手写 message type。 | +| `主生产流程在当前页面发起 Lime AI Agent 任务并保留本地兜底` | 大部分可保留。它验证 `runHostAgentTask`、`writeHostTaskResult`、progress event、`content_factory.*` taskKind 与本地 fallback。 | 保留业务 helper 和 App 内闭环断言;只把底层 `bridge` transport 字符串断言替换为 facade 调用断言。 | +| `Host Bridge 写回只使用 manifest 已声明的资产和证据类型` | 可保留。它约束 artifact / evidence kind 不漂移。 | 继续断言 `scene_table / content_batch / script_batch / fact_grounding / publish_readiness` 与 `APP.md` 声明一致。 | +| `Host Bridge 能在确认链后完成任务、读取事件并写回项目资产` | 可保留行为,需调整 mock。当前 `installHostBridgeWindow()` 直接模拟 `postMessage` 和 `host:response`。 | 改为模拟 SDK invoker / host bridge client,仍断言 capability call sequence:`lime.agent.startTask -> streamTask -> getTask -> submitHostResponse -> storage.set -> artifacts.create -> evidence.record`。 | +| `Host Bridge 能把主生产任务结果写回声明过的资产和证据类型` | 可保留行为,需调整 mock。当前通过 `env.posts` 读取 `capability:invoke` message。 | 改为读取 SDK invoker call log,继续断言 `lime.storage.set -> lime.artifacts.create -> lime.evidence.record`。 | + +迁移测试的原则:不要删除业务闭环断言,只删除“必须手写 `postMessage` transport”这类结构性断言。P18.5.3 成功后,测试应该证明同一内容工厂行为穿过标准 SDK facade,而不是证明 package 内还存在私有 bridge 实现。 + +2026-05-16 07:17 再次只读复核 `tests/ui.test.mjs`,当前 Host Bridge 测试仍覆盖 5 个关键用例:主题 / snapshot 接入、manifest 声明资产与证据类型、确认链写回、主生产任务写回、workspace patch 物化;其中仍直接过滤 `message.type === 'capability:invoke'`、模拟 `postMessage`,并构造 `protocol: 'lime.agentApp.bridge'`。因此后续测试迁移仍应把 mock 从私有 message transport 改成 SDK invoker / Host Bridge client call log,而不是删除这些业务断言。 + +### package-side facade 兼容导出 + +只读检查 `src/ui/app.js` 后确认,业务 UI 当前只直接 import: + +```js +import { + initHostBridge, + notifyHost, + runHostAgentTask, + syncHostConfirmation, + writeHostTaskResult, +} from './host-bridge.js'; +``` + +P18.5.3 迁移应优先保持这 5 个业务侧导出稳定,避免同时改 UI 主流程: + +2026-05-16 07:16 再次只读复核仍一致:`src/ui/app.js` 仍只直接 import 这 5 个导出;`src/ui/host-bridge.js` 还额外导出 `requestHostDownload / invokeCapability / startHostTask / subscribeHostTask / unsubscribeHostTask / streamHostTask / getHostTask / cancelHostTask / retryHostTask / submitHostResponse` 和 bridge protocol 常量,这些可作为兼容薄 wrapper 保留,但不应继续持有私有 transport lifecycle。 + +| 导出 | 当前 app.js 用法 | 迁移要求 | +|---|---|---| +| `initHostBridge()` | 页面加载时初始化 Host Bridge、ready / snapshot / theme / event 监听。 | 保留初始化入口;内部改为创建 SDK client / facade,并注册 Host event handler。 | +| `notifyHost(message, level)` | 确认链成功后提示 Host。 | 保留签名;内部改为 `host.notifyHost` / SDK Host action。 | +| `runHostAgentTask(input, options)` | 主生产流程先启动 Lime Agent task,再按 stream / snapshot / Host response 驱动当前页面。 | 保留业务语义;内部改为 `lime.agent.startTask / streamTask / getTask / submitHostResponse` 与 SDK subscription。 | +| `writeHostTaskResult(...)` | 主生产任务结果写回 storage / artifact / evidence。 | 保留业务语义;内部只调用 `lime.storage / lime.artifacts / lime.evidence` facade。 | +| `syncHostConfirmation(...)` | 确认链后同步项目资产、确认状态和 evidence。 | 保留业务语义;内部复用 `runHostAgentTask` 与 typed facade 写回。 | + +`startHostTask / subscribeHostTask / unsubscribeHostTask / streamHostTask / getHostTask / cancelHostTask / retryHostTask / submitHostResponse` 当前只在 `host-bridge.js` 内部或 `window.limeAgentAppBridge` 调试面暴露;P18.5.3 可以继续导出它们作为兼容薄 wrapper,但不能让它们继续维护私有 request lifecycle。 + +### 当前 host-bridge.js 函数级迁移映射 + +2026-05-16 只读盘点外部 `src/ui/host-bridge.js`,当前导出 16 个函数 / 常量面。P18.5.3 实施时优先保持业务导出名稳定,只替换底层 transport owner: + +| 当前导出 / 内部职责 | 当前实现事实 | 迁移目标 | 是否保留导出 | +|---|---|---|---| +| `initHostBridge()` | 注册 `message` listener,手写 `app:ready`、`host:getSnapshot`,并把调试对象挂到 `window.limeAgentAppBridge`。 | 创建 SDK Host Bridge client / adapters,注册 snapshot、theme、visibility、capability event handler;调试对象只暴露 SDK facade handle。 | 保留。 | +| `notifyHost(message, level)` | 直接 `postMessage('host:toast')`。 | 调 SDK Host action:toast / notify。 | 保留。 | +| `requestHostDownload(url, fileName)` | 通过 `requestHostBridge('host:download')` 走私有 request lifecycle。 | 调 SDK Host action:download,由 SDK 处理 requestId / timeout / stable error。 | 可保留。 | +| `invokeCapability(capability, method, input)` | 手写 `capability:invoke` payload,并 unwrap `response.result`。 | 原则上不再作为业务主入口;如保留,只做 `createLimeHostBridgeCapabilityInvoker` 的薄 wrapper。 | 兼容保留,标记 internal。 | +| `startHostTask(input)` | `invokeCapability('lime.agent', 'startTask', input)`。 | `lime.agent.startTask(input)`。 | 兼容保留。 | +| `streamHostTask(taskId)` | `lime.agent.streamTask` 的私有 wrapper。 | `lime.agent.streamTask({ taskId })` 或标准 SDK 同名 facade。 | 兼容保留。 | +| `getHostTask(taskId)` | `lime.agent.getTask` 的私有 wrapper。 | `lime.agent.getTask({ taskId })`。 | 兼容保留。 | +| `cancelHostTask(taskId)` | `lime.agent.cancelTask` 的私有 wrapper。 | `lime.agent.cancelTask({ taskId })`。 | 兼容保留。 | +| `retryHostTask(taskId)` | `lime.agent.retryTask` 的私有 wrapper。 | `lime.agent.retryTask({ taskId })`。 | 兼容保留。 | +| `submitHostResponse(input)` | `lime.agent.submitHostResponse` 的私有 wrapper。 | `lime.agent.submitHostResponse(input)`。 | 兼容保留。 | +| `subscribeHostTask(taskId, options)` | 手写 `capability:subscribe`,传 `capability=lime.agent / topic=task / pollIntervalMs / bridgeAction`。 | SDK client `subscribeCapability` / `lime.agent.subscribeTask` 等价薄 wrapper,事件分发交给 SDK。 | 兼容保留。 | +| `unsubscribeHostTask(subscriptionId)` | 手写 `capability:unsubscribe`。 | SDK client `unsubscribeCapability`。 | 兼容保留。 | +| `runHostAgentTask(input, options)` | 业务编排:start、可选 subscribe、stream/get 轮询、Host response、unsubscribe、派发 `lime:host-agent-task-update`。 | 保留业务编排;底层只调用 `lime.agent.*` facade 与 SDK subscription,不直接使用 `requestHostBridge`。 | 必须保留。 | +| `writeHostTaskResult(...)` | 依次调用 `lime.storage.set`、`lime.artifacts.create`、`lime.evidence.record`,写内容工厂 task result。 | 保留业务语义;底层改成 `lime.storage / lime.artifacts / lime.evidence` typed facade。 | 必须保留。 | +| `syncHostConfirmation(...)` | 先 `runHostAgentTask(content_factory.confirmation_sync)`,再写 confirmation storage、artifact、publish_readiness evidence。 | 保留业务语义;底层复用 SDK facade 与 `runHostAgentTask`。 | 必须保留。 | +| `AGENT_APP_BRIDGE_PROTOCOL / VERSION` | 从 package 内常量 `lime.agentApp.bridge / 1` 导出。 | 由 SDK / Host Bridge client 提供;如保留,只作为 compatibility export,不作为业务文件手写协议依据。 | 兼容保留,后续退出。 | + +函数级迁移验收:改造后 `src/ui/app.js` 的 5 个直接 import 不需要变化;`host-bridge.js` 不应再出现 `pendingRequests`、`buildMessage`、`requestHostBridge` 或业务自建 `window.parent.postMessage` lifecycle。`postMessage` 只能存在于 SDK client / shim 内,且 shim 必须有退出条件。 + +### 2026-05-16 08:46 迁移 dry-run 设计(未应用) + +本段基于当前外部 `src/ui/host-bridge.js` 只读内容提炼,仍不接管外部 package 写集。handoff 后建议按以下最小 diff 顺序做,避免重写业务 UI: + +| Step | 改动 | 退出条件 | +|---|---|---| +| 1 | 新增或切换到 `src/ui/lime-app-sdk.js` runtime factory,集中创建 `createLimeHostBridgeCapabilityInvoker + createLimeCoreCapabilityAdapters` 等价 handle。 | 业务文件不 import Lime internal path;`postMessage` 只在 SDK client / shim 内。 | +| 2 | 在 `host-bridge.js` 保留 pure helpers:`toBoundedPositiveInteger`、`waitFor`、`extractTaskEvents`、`taskEventIdentity`、`mergeTaskEvents`、`isTerminalTask`、`emitHostTaskUpdate`、`resolveHostResponseRequest`、`resolveConfirmationArtifactKind`。 | 这些 helper 不直接读写 Host Bridge transport,只服务业务编排。 | +| 3 | 删除或下沉私有 transport owner:`pendingRequests`、`buildMessage`、`sendHostBridgeMessage`、`nextRequestId`、`requestHostBridge`、`settleHostBridgeRequest`、`isHostBridgeMessage`、`handleHostBridgeMessage`。 | `host-bridge.js` 不再维护 request lifecycle、timeout、targetOrigin、stable error unwrap。 | +| 4 | `initHostBridge()` 只负责初始化 SDK runtime、注册 Host snapshot/theme/visibility/capability event handler,并把调试对象挂到 `window.limeAgentAppBridge`。 | `window.limeAgentAppBridge` 暴露 facade/debug handle,不暴露 `requestHostBridge`。 | +| 5 | `notifyHost / requestHostDownload` 改成 SDK Host action wrapper。 | 不再手写 `host:toast / host:download` message。 | +| 6 | `startHostTask / streamHostTask / getHostTask / cancelHostTask / retryHostTask / submitHostResponse` 改成 `runtime.agent.*` wrapper。 | 保留导出名;底层只走 `lime.agent` facade。 | +| 7 | `subscribeHostTask / unsubscribeHostTask` 改成 SDK subscription wrapper,并继续把 task event payload 派发成 `lime:host-agent-task-update`。 | 业务仍能收到进度事件;测试不再模拟 `postMessage` transport。 | +| 8 | `writeHostTaskResult / syncHostConfirmation` 改成 `runtime.storage / artifacts / evidence / agent` facade 调用。 | artifact / evidence kind 和 APP.md 声明保持一致。 | + +不应在同一刀中改 `src/ui/app.js` 的 5 个 import、页面结构、模型生成、core/integrations/server 或 `dist/*`;`dist/*` 只在明确进入 build / verify 阶段后由 `npm run build` 生成。 + +## 不改范围 + +1. 不改内容工厂业务流程、页面结构、文案和模型生成逻辑。 +2. 不改外部 package 的 `dist/*`,除非明确进入 verify / build 阶段。 +3. 不改 Lime `src-tauri/*`、AgentRuntime facade、GUI smoke 脚本。 +4. 不新增垂直 `content_factory_*` Lime command。 +5. 不把 `content-factory-app` 迁移成依赖 Lime internal path;App package 只能依赖 SDK facade。 + +## 目标架构 + +```text +content-factory-app/src/ui/app.js + -> package-local business helpers + -> lime.agent / lime.storage / lime.artifacts / lime.evidence + -> createLimeCoreCapabilityAdapters + -> createLimeHostBridgeCapabilityInvoker + -> lime.agentApp.bridge v1 + -> Lime AgentAppHostBridge +``` + +业务 helper 可以继续提供: + +- `runHostAgentTask` +- `writeHostTaskResult` +- `syncHostConfirmation` +- `notifyHost` +- `requestHostDownload` + +但 helper 内部不得再直接维护 `pendingRequests`、`postMessage` 信封、stable error unwrap 和 timeout;这些属于 SDK client。 + +## 推荐写集 + +等待外部 package owner 稳定后,只认领以下最小写集: + +| 文件 | 动作 | 原因 | +|---|---|---| +| `/Users/coso/Documents/dev/ai/limecloud/content-factory-app/src/ui/host-bridge.js` | 收敛为 SDK facade consumer。 | P18.5.3 主目标。 | +| `/Users/coso/Documents/dev/ai/limecloud/content-factory-app/src/ui/lime-app-sdk.js` 或等价 package-local shim | 仅在正式 `@lime/app-sdk` 尚未可安装时新增,暴露 `createLimeHostBridgeCapabilityInvoker + createLimeCoreCapabilityAdapters` 等价最小接口。 | 当前外部 package `dependencies / devDependencies` 为空,不能 import Lime repo 内部路径;Lime-side `src/features/agent-app/index.test.ts` 已固定 public export 面,shim 仍是临时退出条件,不是第二套长期 SDK。 | +| `/Users/coso/Documents/dev/ai/limecloud/content-factory-app/tests/ui.test.mjs` | 更新断言:从“手写 bridge 包含某些字符串”改为“调用标准 facade 并产生相同 capability call 序列”。 | 防止测试继续鼓励私有 bridge。 | +| `/Users/coso/Documents/dev/ai/limecloud/content-factory-app/package.json` | 仅在需要引入正式 SDK package / 本地 workspace alias 时修改。 | 避免未声明依赖。 | +| `/Users/coso/Documents/dev/ai/limecloud/content-factory-app/dist/ui/*` | 只在明确运行 build / verify 后更新。 | 避免和隔壁 dist 产物打架。 | + +不推荐同时修改 docs、core、model-generation、server API、样式或业务页面,除非它们直接阻塞迁移测试。 + +## Owner handoff gate + +只有满足以下任一条件,才允许从“只读计划”进入“外部 package 写入实施”: + +1. 外部 package owner 明确交接 P18.5.3 写集,或用户明确指定本任务接管外部 `content-factory-app` 最小写集。 +2. `/Users/coso/Documents/dev/ai/limecloud/content-factory-app` 的 relevant 写集稳定:`src/ui/host-bridge.js`、`tests/ui.test.mjs`、`package.json`、`dist/ui/*` 没有新的未知改动,且当前 dirty 状态可归因。 +3. 允许进入 verify / build 阶段时,必须同时接受 `dist/*` 会被 `scripts/build.mjs` 删除并重建。 + +接管前必须先记录: + +```bash +git -C /Users/coso/Documents/dev/ai/limecloud/content-factory-app status --short +rg -n "pendingRequests|requestHostBridge|buildMessage|postMessage|createLimeHostBridgeCapabilityInvoker|createLimeCoreCapabilityAdapters|capability:invoke|capability:subscribe|capability:event" /Users/coso/Documents/dev/ai/limecloud/content-factory-app/src/ui/host-bridge.js /Users/coso/Documents/dev/ai/limecloud/content-factory-app/tests/ui.test.mjs +cd /Users/coso/Documents/dev/ai/limecloud/content-factory-app && nice -n 10 npm test +``` + +以下 07:50 - 09:39 为迁移前历史 gate 记录,用来追溯为什么需要 owner handoff;当前事实以 10:33 `status=needs_handoff` 为准。 + +2026-05-16 07:50 按 handoff gate 只读复核结果: + +| 检查项 | 结果 | Gate 判定 | +|---|---|---| +| `git status --short` | 仍有 39 个 dirty / untracked 条目,其中 `src/ui/host-bridge.js` 与 `dist/ui/host-bridge.js` 仍未跟踪。 | 不满足 owner 稳定条件。 | +| 私有 bridge marker | `src/ui/host-bridge.js` 仍包含 `pendingRequests / buildMessage / window.parent.postMessage / requestHostBridge / capability:invoke / capability:subscribe / capability:event`;`tests/ui.test.mjs` 仍直接过滤 `message.type === 'capability:invoke'` 并模拟 `postMessage`。 | SDK facade 尚未完成。 | +| 外部 `npm test` | 46 tests passed。 | 只读测试绿;不能替代 package-side SDK facade / verify。 | + +2026-05-16 08:19 当前会话再次按 handoff gate 只读复核: + +| 检查项 | 结果 | Gate 判定 | +|---|---|---| +| 标准仓库 | `/Users/coso/Documents/dev/ai/limecloud/agentapp` 工作区干净,`HEAD=4bef605 fix: quote mermaid sdk labels`;标准资料继续固定 Agent App 是完整应用包、Expert 只是 `expert-chat` entry、能力调用走 `@lime/app-sdk` / Capability SDK。 | 标准事实源稳定;不需要改标准仓库。 | +| 外部 package 状态 | `/Users/coso/Documents/dev/ai/limecloud/content-factory-app` 仍有 39 个 dirty / untracked 条目,其中 36 个 tracked modified、3 个 untracked;`src/ui/host-bridge.js` 与 `dist/ui/host-bridge.js` 仍未跟踪。 | 不满足 owner 稳定条件;继续只读。 | +| 私有 bridge marker | `src/ui/host-bridge.js` 仍包含 `pendingRequests / buildMessage / window.parent.postMessage / requestHostBridge / capability:invoke / capability:subscribe / capability:event`,未出现 `createLimeHostBridgeCapabilityInvoker` 或 `createLimeCoreCapabilityAdapters`;`tests/ui.test.mjs` 仍直接过滤 `message.type === 'capability:invoke'` 并模拟 `postMessage`。 | SDK facade 尚未完成。 | +| 外部 `npm test` | 2026-05-16 08:19 低优先级只读运行,46 tests passed。 | 只读业务测试仍绿;不能替代 package-side SDK facade / verify。 | +| Lime-side SDK seam | 2026-05-16 08:19 低优先级运行 `npm test -- src/features/agent-app/sdk/hostBridgeClient.test.ts src/features/agent-app/sdk/contentFactorySdkRegression.test.ts src/features/agent-app/index.test.ts`,3 files / 8 tests passed。 | Lime-side Host Bridge SDK client、内容工厂 SDK regression 与 public SDK export seam 当前仍绿;不等于外部 package 已迁移。 | + +2026-05-16 08:35 当前会话只读复核: + +| 检查项 | 结果 | Gate 判定 | +|---|---|---| +| 外部 package 状态 | 仍有 39 个 dirty / untracked 条目,包含 `src/ui/host-bridge.js`、`dist/ui/host-bridge.js`、`src/ui/app.js`、`tests/ui.test.mjs`、`package.json`、`dist/*`、docs、model-generation 与 server / tests 改动。 | owner 仍未稳定;不进入外部写入。 | +| 私有 bridge marker | `src/ui/host-bridge.js` 仍包含 `pendingRequests / buildMessage / window.parent.postMessage / requestHostBridge / capability:invoke / capability:subscribe / capability:event`;`tests/ui.test.mjs` 仍直接过滤 `message.type === 'capability:invoke'` 并模拟 `postMessage`。 | SDK facade 仍未完成。 | +| SDK marker | 未出现 `createLimeHostBridgeCapabilityInvoker` 或 `createLimeCoreCapabilityAdapters`。 | package-side 仍不是标准 SDK facade consumer。 | +| 本轮处理方式 | 只读复核,不跑 `npm run build` / `npm run verify`,不改外部源码或 `dist/*`。 | 符合 owner handoff gate。 | + +2026-05-16 09:09 当前会话补充复核: + +| 检查项 | 结果 | Gate 判定 | +|---|---|---| +| 外部 package 状态 | 仍有 36 个 tracked modified、3 个 untracked;`src/ui/host-bridge.js` 与 `dist/ui/host-bridge.js` 仍未跟踪。 | owner 仍未稳定;继续只读。 | +| 外部 `npm test` | 2026-05-16 09:05 低优先级只读运行,46 tests passed。 | 只读业务测试仍绿;仍在私有 bridge transport 上通过,不能替代 SDK facade / verify。 | +| 外部标准校验 | 2026-05-16 09:06 低优先级运行 `npm run validate:app && npm run readiness:app`;`validate:app` passed,manifest hash 为 `sha256:6ec3fed5f163739bcf0fd2b845c51a8e10d28aa856e8c6f90259fdab9edd1e48`;`readiness:app` needs-setup。 | 标准 manifest 可读,readiness 正确等待 Host 绑定 required 依赖;不能替代会重建 `dist/*` 的 `npm run verify`。 | +| Lime-side 回归 | 2026-05-16 09:09 `npm test -- src/features/agent-app`,35 files / 173 tests passed;09:04 `typecheck` 与 `test:contracts` 均通过。 | Lime-side 当前绿;不等于外部 package 已迁移。 | +| SDK facade marker | 2026-05-16 09:22 只读复核:`src/ui/host-bridge.js` 当时包含 `pendingRequests / buildMessage / window.parent.postMessage / requestHostBridge / capability:invoke / capability:subscribe / capability:event`;`tests/ui.test.mjs` 当时直接过滤 `message.type === 'capability:invoke'`。 | 历史 blocker;已被 10:55 真实 verify 和 handoff gate `blockers=none` 覆盖。 | + +2026-05-16 09:39 只读复核补充: + +| 检查项 | 结果 | Gate 判定 | +|---|---|---| +| 外部 package 状态 | 仍有 36 个 tracked modified、3 个 untracked;`src/ui/host-bridge.js` 与 `dist/ui/host-bridge.js` 仍未跟踪。 | owner 未稳定;不能把当前 package 当作可接管写集。 | +| 外部私有 bridge marker | `src/ui/host-bridge.js` 仍命中 `pendingRequests / buildMessage / window.parent.postMessage / requestHostBridge / capability:invoke / capability:subscribe / capability:event`;`tests/ui.test.mjs` 仍直接按 `message.type === 'capability:invoke'` 过滤 / 模拟。 | package-side SDK facade 仍未实施。 | +| 外部 `npm test` | 低优先级只读重跑,46 tests passed。 | 只证明当前业务测试仍绿;测试仍基于私有 bridge transport。 | +| Lime-side SDK seam | 低优先级重跑 `npm test -- src/features/agent-app/sdk/hostBridgeClient.test.ts src/features/agent-app/sdk/contentFactorySdkRegression.test.ts src/features/agent-app/index.test.ts`,3 files / 8 tests passed;`src/features/agent-app` 越界扫描无输出。 | Lime SDK target 仍可用;仍不能替代外部 package 迁移。 | +| 运行面 | 当前未再发现 cwd 在 `content-factory-app` 的 `npm run dev` 进程,但 Lime Vite 与 `tauri:dev:headless` 仍在。 | 进程空闲不是 handoff;未获明确接管前继续只读。 | +| 机械 handoff gate | 新增 `scripts/agent-app-package-handoff-check.mjs` 与 core unit tests;对当前外部 package 只读运行输出 `status=blocked`、dirty `tracked:36 / untracked:3`、hostBridge SDK marker `none`、highRiskScripts `build / verify / e2e:user-flow / e2e:user-flow:fake-model`。 | 后续 owner 接管前可用同一脚本复核;当前仍不能执行 package-side 迁移。 | + +2026-05-16 10:33 gate 已覆盖上述历史状态:当前输出为 `status=needs_handoff`,私有 marker 为 `none`,SDK marker 已出现,blockers 为 `none`;后续不再执行 source-side 迁移,只处理 owner handoff、package verify 与 `dist/*` 验收。 + +实施中如果出现以下任一情况,立即停止并回到只读审计: + +| 停止条件 | 原因 | 处理方式 | +|---|---|---| +| `git status --short` 出现新的外部改动,且不属于推荐写集。 | 可能是隔壁任务继续写入。 | 不覆盖;先记录并请求 owner 交接。 | +| 需要修改 `src/core/*`、`src/integrations/*`、`src/server/*`、docs 或样式才能让测试过。 | 已超出 SDK facade seam,可能变成业务重写。 | 停止,把阻塞原因写回本计划。 | +| 测试只能通过保留 `pendingRequests / requestHostBridge / postMessage` 私有 transport。 | 与 P18.5.3 目标冲突。 | 停止,补 SDK seam 或回退。 | +| `npm run verify` 前未确认可重写 `dist/*`。 | build 会删除并重建 dist,容易覆盖隔壁产物。 | 只跑 `npm test`,不跑 verify。 | +| 发现正式 `@lime/app-sdk` 可安装方式和 package-local shim 设计冲突。 | 可能会形成第二套 SDK。 | 优先正式 SDK;shim 只保留临时退出条件。 | + +接管后的验证顺序固定为: + +1. 外部 package:`nice -n 10 npm test`。 +2. 外部 package:确认允许更新 `dist/*` 后,运行 `nice -n 10 npm run verify`。 +3. Lime repo:P18 SDK / Host Bridge 定向 tests。 +4. Lime repo:`npm run typecheck -- --pretty false` 与 `npm run test:contracts`。 +5. Lime repo:根据改动范围决定是否重跑完整 `npm run verify:local`。 +6. 回填 `docs/roadmap/agentapp/p18-5-content-factory-sdk-regression.md`、`docs/roadmap/agentapp/p18-completion-audit.md` 和本计划。 + +## Package-local shim 实现草图(未应用) + +本草图只用于后续 owner handoff 后实施,不直接写入外部 package。若正式 `@lime/app-sdk` 已可安装,应优先删除 shim,改为从正式包 import。 + +```js +// src/ui/lime-app-sdk.js +export { + LIME_AGENT_APP_BRIDGE_PROTOCOL, + LIME_AGENT_APP_BRIDGE_VERSION, + createLimeCoreCapabilityAdapters, + createLimeHostBridgeCapabilityInvoker, +} from '@lime/app-sdk'; +``` + +若 P18.5.3 执行时正式包仍不可安装,package-local shim 只能临时镜像以下最小 API: + +```js +// src/ui/lime-app-sdk.js,伪代码;临时退出条件:正式 @lime/app-sdk 可安装后删除。 +export const LIME_AGENT_APP_BRIDGE_PROTOCOL = 'lime.agentApp.bridge'; +export const LIME_AGENT_APP_BRIDGE_VERSION = 1; + +export function createContentFactoryLimeRuntime({ appId, entryKey, storageNamespace }) { + const invoker = createHostBridgeInvoker({ appId, entryKey }); + return createCoreCapabilityAdapters({ invoker, storageNamespace }); +} +``` + +`createHostBridgeInvoker / createCoreCapabilityAdapters` 是 package-local shim 内对 `createLimeHostBridgeCapabilityInvoker / createLimeCoreCapabilityAdapters` 的最小等价实现;不得扩展出新的长期 SDK 语义。 + +2026-05-16 08:37 只读复核 Lime repo SDK 发布边界:根 `package.json` 仍为 private 桌面 App 包,`packages/` 下只有 `packages/lime-cli-npm`,未发现独立 `@lime/app-sdk` / `lime-app-sdk` package。P18.5.3 不能把外部 App 指向 `src/features/agent-app/*` 深路径;如果 owner handoff 时正式 SDK package 仍不可安装,package-local shim 是允许的临时手段,但必须保持退出条件:正式 SDK package 可安装后删除 shim,改为从正式包 import。 + +`src/ui/host-bridge.js` 迁移后的结构应收敛为: + +```js +import { createContentFactoryLimeRuntime } from './lime-app-sdk.js'; + +let runtime; + +function getRuntime() { + if (!runtime) { + runtime = createContentFactoryLimeRuntime({ + appId: 'content-factory-app', + entryKey: 'dashboard', + storageNamespace: 'content-factory-app', + }); + } + return runtime; +} + +export function startHostTask(input, options) { + return getRuntime().agent.startTask(input, options); +} + +export async function writeHostTaskResult(input) { + const lime = getRuntime(); + const storageEntry = await lime.storage.set({ key: input.storageKey, value: input.content }); + const artifact = await lime.artifacts.create({ kind: input.kind, title: input.title, content: input.content }); + const evidence = await lime.evidence.record({ kind: input.evidenceKind, message: input.message, refs: input.refs }); + return { storageEntry, artifact, evidence }; +} +``` + +草图验收重点不是逐字照抄,而是保证最终外部 `host-bridge.js` 不再拥有 `pendingRequests / requestHostBridge / buildMessage / window.parent.postMessage` lifecycle;这些只能存在于正式 SDK client 或带退出条件的 shim 内。 + +## 迁移步骤 + +1. **Owner 稳定确认**:外部 package dirty 状态收敛,或用户明确指定本任务认领外部 package 写集。 +2. **先备份当前行为**:只读记录当前 `tests/ui.test.mjs` 中 Host Bridge 相关断言和当前 `npm test` 结果。 +3. **引入 SDK facade seam**: + - 如果已有可安装 `@lime/app-sdk`,从 SDK package import。 + - 如果还没有发布包,先用 package-local shim 只暴露与 `createLimeHostBridgeCapabilityInvoker + createLimeCoreCapabilityAdapters` 等价的最小接口,并在文档中标记退出条件。 +4. **改写 transport 层**: + - 删除或下沉 `pendingRequests` / `requestHostBridge` / `invokeCapability` 的私有实现。 + - 用标准 invoker 负责 requestId、targetOrigin、trusted origin、timeout 和 `host:error` unwrap。 + - 用 SDK client 负责 `app:ready`、snapshot、theme、visibility、toast、navigate、openExternal 和 download。 +5. **保留业务 helper**: + - `runHostAgentTask` 继续编排 start / stream / get / submitHostResponse。 + - `writeHostTaskResult` 继续写 storage / artifact / evidence。 + - `syncHostConfirmation` 继续保留内容工厂确认链语义。 + - task update 订阅继续可用,但必须通过 SDK client 的 subscription API 注册,不直接监听 `capability:event`。 +6. **更新测试**: + - 断言 capability call sequence 不变。 + - 断言不再直接依赖私有 `requestHostBridge` / `pendingRequests` 字符串。 + - 断言 artifact kind / evidence kind 仍只使用 manifest 声明类型。 +7. **运行验证**: + - 先 `npm test`。 + - 如果允许重写 `dist/`,再 `npm run verify`。 + - 回到 Lime repo 跑 P18 SDK / Host Bridge regression。 + +## 验收清单 + +| 要求 | 验收证据 | +|---|---| +| 外部 App 不再手写 Host Bridge transport。 | `src/ui/host-bridge.js` 不再自建 `pendingRequests` / `requestHostBridge` / `buildMessage` 这类 transport 细节。 | +| Host 初始化和主题同步不再手写 window message。 | tests 断言 ready / snapshot / theme / visibility 通过 SDK client 完成。 | +| Host action 不再手写 message。 | tests 断言 `host:toast / host:navigate / host:openExternal / host:download` 通过 SDK client Host action API 完成。 | +| 内容工厂业务 helper 保留。 | `runHostAgentTask / writeHostTaskResult / syncHostConfirmation` 仍存在或由等价模块提供。 | +| Capability 调用序列不变。 | tests 仍断言 `lime.agent.startTask -> streamTask -> getTask -> submitHostResponse -> storage.set -> artifacts.create -> evidence.record`。 | +| 订阅事件不再手写 transport。 | tests 断言 `capability:subscribe / capability:event / capability:unsubscribe` 通过 SDK client subscription API 完成。 | +| Artifact / Evidence 类型不漂移。 | tests 仍断言 `content_batch / fact_grounding / publish_readiness` 等 manifest 声明类型。 | +| Package read-only tests 通过。 | 外部 `npm test` 通过。 | +| Package verify 通过。 | 外部 `npm run verify` 通过;仅在 owner 允许更新 `dist/` 时执行。 | +| Lime-side regression 通过。 | Lime P18 SDK / Host Bridge tests、typecheck、diff check 通过。 | + +## 回滚点 + +如果外部迁移失败,只需要回滚外部 package 的 P18.5.3 写集;Lime-side `hostBridgeClient.ts`、`hostBridgeClient.test.ts` 和本计划仍可保留,因为它们是标准 SDK client 的独立 contract evidence,不依赖内容工厂 package 的改造进度。 + +## 完成判定 + +P18.5.3 只有在以下全部成立时才能标记完成: + +1. 外部 `src/ui/host-bridge.js` 已收敛为 SDK facade consumer。 +2. 外部 `npm test` 通过。 +3. 外部 `npm run verify` 通过,或明确记录未运行原因和退出条件。 +4. Lime P18 SDK / Host Bridge regression 通过。 +5. `docs/roadmap/agentapp/p18-5-content-factory-sdk-regression.md` 与 `docs/roadmap/agentapp/p18-completion-audit.md` 回填最新证据。 diff --git a/docs/roadmap/agentapp/p18-5-content-factory-sdk-regression.md b/docs/roadmap/agentapp/p18-5-content-factory-sdk-regression.md new file mode 100644 index 000000000..7a3f6d071 --- /dev/null +++ b/docs/roadmap/agentapp/p18-5-content-factory-sdk-regression.md @@ -0,0 +1,84 @@ +# P18.5 内容工厂 SDK 化回归 + +更新时间:2026-05-16 + +状态:P18.5.1 Lime-side SDK regression 已完成;P18.5.2 package-side read-only tests 已通过;P18.5-S Lime-side Host Bridge SDK client 已补齐 capability invoke、subscription、Host action 和 Host event contract;外部 `content-factory-app` package-side source seam 已迁到 SDK facade 形态,真实 package `npm run verify` 已通过,`dist/*` 已同步 SDK facade。当前只剩 owner handoff / dirty 写集收口。 + +## 目标 + +P18.5 要证明内容工厂这类 product-level Agent App 可以只依赖 Lime 公开 SDK facade 完成 App 内闭环:App 发起 Agent task、响应缺上下文 / 人工确认、消费 artifact / evidence / workspace patch,再把业务状态、artifact 和 evidence 写回 Host;不能回跳通用 Chat,也不能自建模型网关、凭证、权限、证据或工具调度系统。 + +## 本轮范围 + +本阶段先完成 Lime repo 内的消费侧 contract regression 和 SDK transport 目标收敛:用 `createLimeCoreCapabilityAdapters` 模拟内容工厂主链,并证明 task / write-back / evidence 全部通过 `lime.agent / lime.storage / lime.artifacts / lime.evidence` typed facade;再用 `createLimeHostBridgeCapabilityInvoker` 证明同一主链可以穿过 Host Bridge v1。外部 `/Users/coso/Documents/dev/ai/limecloud/content-factory-app` 只记录当前 gate 结果,不在本轮继续改源码或重建 `dist/*`,避免与隔壁任务打架。 + +## Prompt-to-artifact checklist + +| 要求 | 本轮证据 | 判定 | +|---|---|---| +| 内容工厂发起业务 Agent task 时必须走 `lime.agent.startTask`。 | `src/features/agent-app/sdk/contentFactorySdkRegression.test.ts` 构造 `content.copy.generate` task,带 `expectedOutput.artifactKind = content_batch` 和 `workspacePatch` 约束。 | 通过 Lime-side contract。 | +| 缺上下文 / 人工确认必须通过 Host response 回写,不要求用户跳回 Chat。 | 同一测试从 `task:missingContextRequested` 提取 requestId,并调用 `lime.agent.submitHostResponse`。 | 通过 Lime-side contract。 | +| App 内进度 / 结果必须能消费 task stream / snapshot。 | 同一测试调用 `lime.agent.streamTask` 和 `lime.agent.getTask`,断言 `artifact:created`、`evidence:recorded`、`contentFactoryWorkspacePatch` 不丢失。 | 通过 Lime-side contract;主动 push subscribe 仍归 AgentRuntime owner。 | +| 内容批次业务状态必须写入 App namespace storage。 | 同一测试调用 `lime.storage.set`,并断言 `storage.namespace = content-factory-app`。 | 通过。 | +| 内容批次 artifact 必须通过 Host artifact facade 创建。 | 同一测试调用 `lime.artifacts.create({ kind: "content_batch" })`。 | 通过。 | +| fact grounding / publish readiness 这类证据必须通过 Host evidence facade 记录。 | 同一测试调用 `lime.evidence.record({ kind: "fact_grounding" })`,refs 连接 artifact / task / Host response。 | 通过。 | +| 内容工厂主链必须能穿过 Host Bridge v1,而不是只在 mock transport 里成立。 | `src/features/agent-app/sdk/hostBridgeClient.test.ts` 通过 `createLimeHostBridgeCapabilityInvoker + createLimeCoreCapabilityAdapters` 覆盖 `lime.agent.startTask / streamTask / getTask / submitHostResponse / storage.set / artifacts.create / evidence.record`。 | 通过 Lime-side Host Bridge SDK client contract。 | +| package-side 不应继续手写 ready / snapshot / theme / visibility / Host action。 | `hostBridgeClient.test.ts` 覆盖 `app:ready`、`host:getSnapshot`、`host:snapshot`、`theme:update`、`host:visibility`、`host:toast`、`host:navigate`、`host:openExternal`、`host:download`;2026-05-16 10:55 外部 handoff gate 显示 `hostBridgePrivate=none` 且 `distArtifacts=0`。 | Lime-side contract 通过;外部 source-side 与 dist 私有 marker / 产物同步均已收口。 | +| package-side 不应继续手写 capability subscription transport。 | `hostBridgeClient.test.ts` 覆盖 `capability:subscribe / capability:event / capability:unsubscribe`,并断言 unsubscribe 后不再分发旧事件;2026-05-16 10:33 外部 handoff gate 显示 `uiTestPrivate=none`。 | Lime-side contract 通过;外部测试结构性私有 marker 已清零,但仍需 owner handoff。 | +| 外部 package 后续迁移不应依赖 Lime 内部深路径。 | `src/features/agent-app/index.test.ts` 断言 public feature entry 导出 `createLimeCoreCapabilityAdapters`、`createLimeHostBridgeCapabilityInvoker`、`LIME_AGENT_APP_BRIDGE_PROTOCOL` 与 `LIME_AGENT_APP_BRIDGE_VERSION`;外部 package 当前用 package-local shim,不 import Lime internal path。 | 通过;正式 SDK package 可安装后再替换 shim。 | +| 不能绕过 SDK 调私有 bridge / Tauri / raw Worker。 | 测试只依赖 `createLimeCoreCapabilityAdapters`;边界扫描继续覆盖 `src/features/agent-app`。 | Lime repo 通过;外部 package 仍需后续单独验。 | +| 不覆盖隔壁 package 改动。 | 2026-05-16 10:55 用户确认后已运行真实 `npm run verify`;外部 package 当前仍 dirty:36 个 tracked modified、5 个 untracked;高风险脚本仍会重建 `dist/*`。 | 真实 verify 已完成;后续不再继续改外部 repo,等待 owner 接收写集。 | +| Typecheck 不被 HostBridge timer 类型阻塞。 | `src/features/agent-app/runtime/hostBridge.ts` 将 task subscription `timerId` 收敛为浏览器 `number`。 | 通过;不改变订阅轮询行为。 | +| 外部 package 现状测试可读。 | 2026-05-16 10:55 真实 `npm run verify` 内运行 `/Users/coso/Documents/dev/ai/limecloud/content-factory-app: npm test`,46 tests passed,覆盖 Host Bridge task、stream、Host response、storage、artifact、evidence 写回。 | package-side 测试通过。 | +| 外部 package 标准校验可读。 | 2026-05-16 10:55 真实 `npm run verify` 内运行 `validate:app` 与 `readiness:app`;`validate:app` 为 `ok=true / status=passed`,manifest hash 为 `sha256:6ec3fed5f163739bcf0fd2b845c51a8e10d28aa856e8c6f90259fdab9edd1e48`;`readiness:app` 为 `ok=true / status=needs-setup`。 | 标准校验通过且 readiness 正确停在宿主依赖待绑定状态。 | +| 外部 package SDK facade marker。 | 2026-05-16 10:55 在 Lime repo 运行 `node scripts/agent-app-package-handoff-check.mjs --package-dir /Users/coso/Documents/dev/ai/limecloud/content-factory-app`,输出 `status=needs_handoff`,SDK marker 命中 2+2,`hostBridgePrivate=none`,`uiTestPrivate=none`,`distArtifacts=0`,`blockers=none`。 | SDK facade / dist 同步已完成;`needs_handoff` 仅表示 dirty 写集需 owner 接收。 | + +## 已知缺口 + +| 缺口 | 原因 | 下一刀 | +|---|---|---| +| 外部 `content-factory-app/src/ui/host-bridge.js` source-side facade 已迁移。 | 10:55 handoff gate 已无私有 bridge marker,并命中 SDK facade marker;真实 verify 后 `distArtifacts=0`。 | package-side SDK facade 已通过当前验收;仍需 owner 接收 dirty 写集。 | +| P18.5 真实内容工厂 package verify。 | 10:55 在真实 `/Users/coso/Documents/dev/ai/limecloud/content-factory-app` 运行 `npm run verify`,build、46 tests、validate、readiness 全部通过。 | 已完成;readiness 保持 `needs-setup` 是宿主 required 依赖待绑定的正确状态。 | +| 外部 dist 产物同步 SDK facade。 | 10:55 verify 后 handoff gate 显示 `distArtifacts=diff:0,missing:0,extra:0,total:0`。 | 已完成;dist 产物已同步,不再作为 blocker。 | +| 隔离 verify 已通过但不替代真实 dist。 | 10:47 在临时副本 `/tmp/limecloud-content-factory-verify.uots6S/content-factory-app` 运行 `npm run verify` 通过,build、46 tests、validate、readiness 均完成。 | 证明当前源码可通过完整 package verify;真实仓库仍需 owner 允许后重建 `dist/*`。 | +| 外部 package 迁移后仍需补回归验证。 | 2026-05-16 07:33 完整 Lime `verify:local` 已端到端通过;10:55 外部真实 package verify 已通过;10:53 Lime SDK seam / handoff core 5 files / 17 tests passed,typecheck 与 contracts 均通过。 | 当前回归已补齐;如后续 owner 再改外部 package 或触及运行时边界,需要重跑对应验证。 | +| 真实 Agent / Skill 端到端产出 patch artifact 仍不是 P18 消费侧已完成项。 | P18.4-H 已把该缺口归 AgentRuntime / Skill owner。 | P18.5 只消费已有 payload,不伪造生产级 producer 完成。 | + +## 已验证 + +```bash +nice -n 10 npm test -- src/features/agent-app/sdk/contentFactorySdkRegression.test.ts src/features/agent-app/sdk/capabilityAdapters.test.ts src/features/agent-app/sdk/capabilityContract.test.ts src/features/agent-app/sdk/MockCapabilityHost.test.ts src/features/agent-app/runtime/hostBridge.test.ts src/features/agent-app/runtime/capabilityDispatcher.test.ts src/features/agent-app/runtime/agentRuntimeCapabilityHost.test.ts src/features/agent-app/ui/AgentAppRuntimePage.test.tsx +nice -n 10 npm test -- src/features/agent-app/sdk/hostBridgeClient.test.ts src/features/agent-app/sdk/contentFactorySdkRegression.test.ts src/features/agent-app/sdk/capabilityAdapters.test.ts src/features/agent-app/sdk/capabilityContract.test.ts src/features/agent-app/sdk/MockCapabilityHost.test.ts src/features/agent-app/runtime/hostBridge.test.ts src/features/agent-app/runtime/capabilityDispatcher.test.ts src/features/agent-app/runtime/agentRuntimeCapabilityHost.test.ts src/features/agent-app/ui/AgentAppRuntimePage.test.tsx +nice -n 10 npm run typecheck -- --pretty false +nice -n 10 npm run test:contracts +nice -n 10 npm test -- src/features/agent-app/index.test.ts +nice -n 10 npm test -- src/features/agent-app/sdk/hostBridgeClient.test.ts src/features/agent-app/sdk/contentFactorySdkRegression.test.ts src/features/agent-app/index.test.ts +nice -n 10 npm run verify:local +cd /Users/coso/Documents/dev/ai/limecloud/content-factory-app && nice -n 10 npm test +git diff --check -- src/features/agent-app/sdk src/features/agent-app/runtime/hostBridge.ts src/features/agent-app/index.ts src/features/agent-app/index.test.ts docs/roadmap/agentapp +rg -n "SceneApp|contentEngineering|sceneapp_|safeInvoke|invoke\(|new Worker|Worker\(" src/features/agent-app || true +``` + +### 验证结果与覆盖边界 + +| 验证项 | 当前结果 | 覆盖到的要求 | 不能覆盖的要求 | +|---|---|---|---| +| P18 SDK / Host Bridge 定向测试 | 2026-05-16 07:14:9 files / 43 tests passed。 | typed SDK facade、stable error、MockCapabilityHost、Host Bridge router、capability dispatcher、AgentRuntime capability host 与 RuntimePage SDK surface。 | 不能证明外部 package 已停止手写 bridge wrapper。 | +| Lime-side P18 SDK 最小 contract | 2026-05-16 07:03:4 files / 13 tests passed。 | `hostBridgeClient`、`capabilityContract`、`capabilityAdapters`、`contentFactorySdkRegression` 的最小 SDK contract。 | 不能替代 GUI / Rust / 外部 package verify。 | +| Agent App feature island 全量定向测试 | 2026-05-16 09:09:35 files / 173 tests passed。 | `src/features/agent-app` 内当前 feature island 回归,包含 public SDK export regression。 | 不能证明外部 `content-factory-app` package-side SDK facade 已迁移。 | +| Agent App public SDK export test | 2026-05-16 09:01:已随 SDK seam 最小复跑通过,3 files / 8 tests passed。 | `src/features/agent-app/index.ts` 公共入口继续导出 Host Bridge SDK client、core adapters 与 bridge protocol constants。 | 不能替代正式 `@lime/app-sdk` 发布或外部 package 迁移。 | +| App version consistency | 2026-05-16 09:07:`npm run verify:app-version` passed,版本一致性为 `1.40.0`。 | 新增 public SDK export test 后,应用版本事实源仍一致。 | 不能替代外部 package verify。 | +| Lime typecheck | 2026-05-16 09:04:`npm run typecheck -- --pretty false` passed。 | 当前会话文档复核后,TypeScript 全仓类型检查仍通过。 | 不能替代外部 package verify。 | +| Lime lint | 2026-05-16 09:08:`npm run lint` passed。 | 当前 `src` ESLint 全仓检查仍通过。 | 不能替代外部 package verify。 | +| Lime contracts | 2026-05-16 09:04:`npm run test:contracts` passed。 | 命令、harness、modality runtime 和 cleanup report contracts 仍通过。 | 不能替代外部 package verify。 | +| 外部 package read-only tests | 2026-05-16 09:05:46 tests passed。 | 当时外部 package 业务测试仍绿,包含 Host Bridge task / stream / Host response / storage / artifact / evidence 写回。 | 该次测试早于 10:33 source-side facade gate;不能替代迁移后 package verify。 | +| Lime-side SDK seam 最小复跑 | 2026-05-16 10:41:`publicSdkSurface.test.ts`、`hostBridgeClient.test.ts`、`contentFactorySdkRegression.test.ts`、`index.test.ts`,4 files / 11 tests passed。 | Host Bridge SDK client、SDK-only public surface、内容工厂 SDK regression、public SDK export seam 当前仍绿。 | 不能替代外部 package verify。 | +| 外部标准只读校验 | 2026-05-16 10:41:`validate:app` 为 `passed`,manifest hash 为 `sha256:6ec3fed5f163739bcf0fd2b845c51a8e10d28aa856e8c6f90259fdab9edd1e48`;`readiness:app` 为 `needs-setup`。 | Manifest / APP 标准可读,readiness 能正确表达宿主依赖未绑定。 | `needs-setup` 不是运行闭环完成;也不替代会重写 `dist/*` 的 package verify。 | +| 完整 Lime `verify:local` | 2026-05-16 07:33:端到端通过。 | app version、lint、typecheck、Vitest smart 58 batches、contracts、Rust tests 与全套 GUI smoke。 | 只覆盖当前 Lime repo 状态;外部 package 后续迁移后仍需补回归。 | +| Feature island boundary scan | 2026-05-16 09:00:无输出。 | 当前 `src/features/agent-app` 未复活 `SceneApp`、未直接 `safeInvoke / invoke`、未执行 raw Worker。 | 不扫描外部 package;外部私有 bridge 仍由 P18.5.3 处理。 | +| 外部 package verify | 2026-05-16 10:55:真实 `npm run verify` 通过,包含 build、46 tests、validate passed、readiness needs-setup。 | package-side SDK facade 与 dist 产物已进入当前验收。 | 不代表宿主 required skill / knowledge / tool 已在真实 workspace 绑定。 | +| 外部 package handoff gate | 2026-05-16 10:55:`status=needs_handoff`,`hostBridgePrivate=none`,`uiTestPrivate=none`,SDK marker 命中 2+2,`distArtifacts=0`,blockers 为 `none`。 | 证明 source-side SDK facade marker 已接入,业务测试不再命中私有 marker,且 dist 产物已同步。 | 仍需 owner handoff;dirty 写集不是功能 blocker。 | + +## 判定 + +P18.5 已完成 Lime-side SDK 化回归、Host Bridge SDK client contract、外部 package-side tests / validate / readiness、真实 package verify、dist 产物同步,以及外部 source-side SDK facade marker 收敛:内容工厂主链可以用通用 typed SDK facade 表达,也可以穿过 Host Bridge v1,不需要新增垂直 `content_factory_*` Host API;完整 `verify:local` 也已于 2026-05-16 07:33 端到端通过。当前剩余不是 P18.5 功能缺口,而是外部 package owner 接收 dirty 写集并决定提交边界。 diff --git a/docs/roadmap/agentapp/p18-6-raw-worker-pre-gate.md b/docs/roadmap/agentapp/p18-6-raw-worker-pre-gate.md new file mode 100644 index 000000000..e24eaad34 --- /dev/null +++ b/docs/roadmap/agentapp/p18-6-raw-worker-pre-gate.md @@ -0,0 +1,48 @@ +# P18.6 Raw Worker 前 Gate + +更新时间:2026-05-16 + +状态:已完成。P18 明确不执行 raw worker、任意外部代码、网络、文件系统或原生 API;raw worker sandbox、资源限制、网络策略和 secret policy 统一后移到 P19 以后。 + +## 目标 + +P18.6 的目标不是实现 worker sandbox,而是在 P18 Typed Capability SDK Gate 结束前固定一条安全边界:Agent App 可以使用受控 workflow DSL 和 Host Capability SDK,但不能执行 package 自带 raw worker bundle,也不能直接创建 `Worker`、访问网络、文件系统、Tauri / Node API 或自行处理 secret。 + +## Prompt-to-artifact checklist + +| 要求 | 代码 / 文档证据 | 判定 | +|---|---|---| +| P18 不执行 raw worker。 | `src/features/agent-app/runtime/runtimePolicy.ts` 固定 `allowRawWorker: false`;`resolveAgentAppWorkflowRuntimePolicy()` 无论 overrides 如何都强制 false。 | 通过。 | +| workflow runtime 只允许受控 DSL。 | `agentAppWorkflowStepKinds` 只包含 `storage.set / knowledge.search / agent.startTask / artifacts.create / evidence.record`。 | 通过。 | +| 未注册 raw worker / network step 被拒绝。 | `src/features/agent-app/runtime/workflowRuntimeHost.test.ts` 的“应拒绝未注册的 raw worker / network 类 step”。 | 通过。 | +| 关闭 `workerRuntimeEnabled` 时不能执行 workflow。 | `src/features/agent-app/runtime/workflowRuntimeHost.test.ts` 覆盖 `WORKFLOW_RUNTIME_DISABLED`。 | 通过。 | +| Runtime package loader 输出 raw worker / network / filesystem policy evidence。 | `src/features/agent-app/runtime/runtimePackageLoader.ts` 的 `buildPolicyEvidence()` 固定 raw worker、network、filesystem 为 false;对应 `runtimePackageLoader.test.ts` 覆盖 blocked / evidence。 | 通过。 | +| Entry runtime guard 不允许 background-task 伪装成已开放 worker sandbox。 | `src/features/agent-app/runtime/entryRuntimeGuard.ts` 对 background task entry 输出 blocked:`Background task entries require a worker sandbox, which P14 keeps disabled.` | 通过。 | +| Feature flag 不把 workerRuntimeEnabled 解释为 raw worker。 | `src/features/agent-app/featureFlag.test.ts` 固定 workerRuntimeEnabled 只进入 P4.2 workflow runtime,不自动启用 adapter / UI / raw worker。 | 通过。 | +| Agent App feature island 无直接 `new Worker` / raw `Worker(` / Tauri invoke 越界。 | 边界扫描:`rg -n "SceneApp|contentEngineering|sceneapp_|safeInvoke|invoke\\(|new Worker|Worker\\(" src/features/agent-app || true` 无输出;2026-05-16 08:33 当前会话复跑仍无输出。 | 通过。 | + +## P19 后续退出条件 + +raw worker 只能在后续单独 gate 中开放,并且至少满足: + +1. 独立 sandbox 设计:明确 iframe worker / Web Worker / WASM worker 的隔离边界。 +2. Resource limits:CPU、内存、运行时长、并发数、队列和取消语义可测试。 +3. Network policy:默认无网络;需要 manifest capability、用户授权、tenant policy 和审计 evidence。 +4. Secret policy:App worker 永不接触 secret value,只能拿 Host broker 颁发的 scoped ref。 +5. Storage / artifact / evidence:仍必须通过 SDK facade 写回,不允许 worker 直接访问宿主文件系统或 DB。 +6. GUI / contract / security tests:必须覆盖禁用态、授权态、失败清理、残留审计和 feature-island boundary scan。 + +## 已验证 + +```bash +nice -n 10 npm test -- src/features/agent-app/runtime/workflowRuntimeHost.test.ts src/features/agent-app/featureFlag.test.ts src/features/agent-app/runtime/runtimePackageLoader.test.ts src/features/agent-app/runtime/entryRuntimeGuard.test.ts +rg -n "SceneApp|contentEngineering|sceneapp_|safeInvoke|invoke\\(|new Worker|Worker\\(" src/features/agent-app || true +``` + +最新复核: + +- 2026-05-16 08:33 当前会话重跑 feature island 越界扫描,无输出。 + +## 判定 + +P18.6 已完成:P18 只保留受控 workflow DSL 和 typed capability SDK;raw worker sandbox 不进入本阶段。P18.5 package-side SDK facade / verify 已于 2026-05-16 10:55 完成;当前剩余是 owner handoff / git 写集收口,不再是 raw worker 前置 blocker。 diff --git a/docs/roadmap/agentapp/p18-completion-audit.md b/docs/roadmap/agentapp/p18-completion-audit.md new file mode 100644 index 000000000..f8dea616f --- /dev/null +++ b/docs/roadmap/agentapp/p18-completion-audit.md @@ -0,0 +1,194 @@ +# P18 Completion Audit / 协作防打架记录 + +更新时间:2026-05-16 + +状态:P18 当前交付候选已具备证据;P18.1、P18.2、P18.3、P18.4、P18.4-H、P18.5.1、P18.5.2、P18.5-S 与 P18.6 均有当前证据,完整 `verify:local` 已端到端通过;P18.5.3 package-side SDK facade 已从 `blocked` 推进到真实外部 package `npm run verify` 通过,且 handoff gate 显示 `distArtifacts=0`。当前剩余不是功能实现缺口,而是外部 package / Lime 工作区仍 dirty,需要 owner handoff / commit 边界收口。 + +## 一句话结论 + +P18 的 Lime-side typed Capability SDK gate 已具备当前可验证证据,并新增了可复用的 Host Bridge SDK client,完整 `verify:local` 也已绿灯;外部 `content-factory-app` 的 package-side source seam 已出现 `createLimeHostBridgeCapabilityInvoker + createLimeCoreCapabilityAdapters`,私有 bridge marker 已清零,真实外部 package verify 已通过,真实 `dist/*` 也已同步 SDK facade。P18 仍不自动等于“可提交”,因为外部 package 与 Lime 工作区仍 dirty,需 owner 明确接收写集。 + +## 当前绿灯 / 阻塞摘要 + +| 分类 | 最新证据 | 判定 | +|---|---|---| +| Lime-side Agent App feature island | 2026-05-16 10:17 `npm test -- src/features/agent-app`:36 files / 178 tests passed;2026-05-16 10:41 复跑 SDK seam 最小集 4 files / 11 tests passed。 | 通过当前定向测试;包含 SDK-only public surface、public SDK export regression 与内容工厂 SDK regression。 | +| P18 SDK / Host Bridge | 2026-05-16 07:14 P18 SDK / Host Bridge 定向测试:9 files / 43 tests passed。 | 通过当前定向测试。 | +| 静态与契约 | 2026-05-16 09:07 `verify:app-version` 已重跑通过;10:20 `lint` 通过;10:15 `typecheck` 通过;10:21 `test:contracts` 通过。 | 通过当前本地非 GUI 验证。 | +| 外部 `content-factory-app` 只读验证 | 2026-05-16 10:41 低优先级复跑 `npm test`:46 tests passed;`validate:app` 为 `passed`,manifest hash `sha256:6ec3fed5f163739bcf0fd2b845c51a8e10d28aa856e8c6f90259fdab9edd1e48`;`readiness:app` 为 `needs-setup`。 | 只读验证通过;不能替代会重建 `dist/*` 的 package verify。 | +| P18.5.3 package-side SDK facade / verify | 2026-05-16 10:55 真实外部 package `npm run verify` 通过:build、46 tests、validate、readiness 均完成;随后 handoff gate 输出 `status=needs_handoff`、dirty `tracked=36 / untracked=5`、`hostBridgePrivate=none`、`uiTestPrivate=none`、SDK marker 命中 `2+2`、`distArtifacts=diff:0,missing:0,extra:0,total:0`。 | 功能和产物验收通过;仍需 owner handoff,因为 package worktree 仍 dirty 且 build/verify/e2e 脚本天然会重写 dist。 | +| 完整 `verify:local` | 2026-05-16 07:33 低优先级运行 `nice -n 10 npm run verify:local` 端到端通过,覆盖 app version、lint、typecheck、Vitest smart 58 batches、`test:contracts`、`cargo test --manifest-path src-tauri/Cargo.toml` 与全套 GUI smoke;`smoke:claw-chat-ready-streaming` 同次重跑 summary 为 `verdict=pass / error=null / consoleErrorCount=0 / failureRuntime=null`。 | 全量本地验证通过;不再是 P18 当前阻塞项。 | + +## 目标重述 + +P18 要证明 Agent App 可以只依赖 Lime 公开 SDK facade 和 Host Bridge v1 完成 App 内业务闭环: + +1. App 作者调用 typed `@lime/app-sdk` facade,而不是手写私有 `postMessage` 协议或 import Lime internal modules。 +2. Host Bridge 只做传输,真实能力必须经过 readiness、permission、policy、provenance 和 stable error gate。 +3. `lime.agent` 能在 App 内启动、读取、取消、重试、提交 Host response 和列出 App-scoped task,不强制回跳通用 Chat。 +4. 内容工厂主链必须通过 `lime.agent / lime.storage / lime.artifacts / lime.evidence` 完成 task、write-back、artifact 和 evidence。 +5. P18 不执行 raw worker、不访问外部网络 / 文件系统 / secret value,不扩 marketplace、Cloud 管理台或垂直后端。 +6. 外部 `content-factory-app` 最终要从手写 bridge wrapper 收敛到标准 SDK facade,并通过 package verify。 + +## Prompt-to-artifact checklist + +| 要求 | 当前证据 | 审计判定 | +|---|---|---| +| P18.1 Typed SDK facade、stable error、mock host。 | `src/features/agent-app/sdk/capabilityContract.ts`、`src/features/agent-app/sdk/capabilityErrors.ts`、`src/features/agent-app/sdk/MockCapabilityHost.ts`、`src/features/agent-app/sdk/capabilityContract.test.ts`、`src/features/agent-app/sdk/MockCapabilityHost.test.ts`。 | 通过当前 Lime-side 契约验证。 | +| P18.2 Host Bridge typed router / stable error response。 | `src/features/agent-app/runtime/hostBridge.ts`、`src/features/agent-app/runtime/capabilityDispatcher.ts`、`hostBridge.test.ts`、`capabilityDispatcher.test.ts`。 | 通过当前定向测试;未进入 GUI smoke。 | +| P18.3 Core capability adapters。 | `src/features/agent-app/sdk/capabilityAdapters.ts` 覆盖 `lime.ui / storage / artifacts / evidence / knowledge / tools / agent` facade;`capabilityAdapters.test.ts` 覆盖 adapter call envelope。 | 通过当前定向测试。 | +| P18.4 App-scoped Agent task SDK facade。 | `LimeAgentCapabilityAdapter` 暴露 `startTask / streamTask / getTask / cancelTask / retryTask / submitHostResponse / listTasks`;`agentRuntimeCapabilityHost.test.ts` 覆盖 runtime host 接线。 | 通过当前定向测试;后端 push subscribe 仍归 AgentRuntime owner。 | +| P18.4-H AgentRuntime handoff gate。 | `docs/roadmap/agentapp/p18-4-h-agentruntime-handoff-gate.md` 记录 handoff 判定与 owner 缺口。 | 文档 gate 已有;不由 P18 复制 runtime read model。 | +| P18.5.1 Lime-side 内容工厂 SDK regression。 | `src/features/agent-app/sdk/contentFactorySdkRegression.test.ts` 断言内容工厂主链调用 `lime.agent.startTask / streamTask / submitHostResponse / storage.set / artifacts.create / evidence.record`,且 2026-05-16 10:08 已改为只从 `src/features/agent-app/sdk/index.ts` 的 SDK-only public surface 导入类型与 helper。 | 通过当前定向测试;证明 Lime-side 内容工厂回归不依赖 SDK 内部深文件。 | +| P18.5.2 外部 package read-only tests。 | `/Users/coso/Documents/dev/ai/limecloud/content-factory-app: npm test` 当前通过;2026-05-16 08:19 owner handoff gate 复核为 46 tests passed。 | 通过只读测试;不能替代 package-side SDK facade 或 verify。 | +| P18.5.2 外部 package 标准只读校验。 | 2026-05-16 07:11 与 08:42 低优先级运行 `/Users/coso/Documents/dev/ai/limecloud/content-factory-app: npm run validate:app && npm run readiness:app`;`validate:app` 返回 `ok=true / status=passed`,manifest hash 为 `sha256:6ec3fed5f163739bcf0fd2b845c51a8e10d28aa856e8c6f90259fdab9edd1e48`;`readiness:app` 返回 `ok=true / status=needs-setup`。 | 通过只读标准校验;`needs-setup` 符合宿主需补齐 required 依赖的状态,仍不能替代会重写 `dist/*` 的 package verify。 | +| P18.5-S Lime-side Host Bridge SDK client。 | `src/features/agent-app/sdk/hostBridgeClient.ts` 提供 `createLimeHostBridgeCapabilityInvoker`,可把标准 `createLimeCoreCapabilityAdapters` 调用转成 Host Bridge v1 `capability:invoke` envelope,并承接 ready / snapshot / theme / visibility / toast / navigate / openExternal / download / `capability:subscribe / unsubscribe / event`;`hostBridgeClient.test.ts` 覆盖 Host action、trusted origin、stable error、timeout cleanup、subscription 分发和内容工厂 task / write-back / artifact / evidence 主链。 | 通过当前定向测试;为外部 package 收敛提供目标,但不等于已改外部 package。 | +| P18.5-S SDK-only public surface。 | `src/features/agent-app/sdk/index.ts` 只导出 capability facade、stable error、Host Bridge client、mock host 和 App package 需要的 task / storage / artifact / evidence 类型;`src/features/agent-app/sdk/publicSdkSurface.test.ts` 固定该窄导出清单,并从运行时 namespace 与源码 export 来源两侧断言不导出 UI、installer、repository、runtime host、adapter、schema 或 dispatcher。 | 2026-05-16 10:08 定向测试通过,10:15 typecheck 通过;降低后续 `@lime/app-sdk` / package-local shim 误导出内部实现的风险。 | +| P18.5-S public SDK export seam。 | `src/features/agent-app/index.ts` 已从 feature public entry 导出 `createLimeCoreCapabilityAdapters`、`createLimeHostBridgeCapabilityInvoker`、`LIME_AGENT_APP_BRIDGE_PROTOCOL` 与 `LIME_AGENT_APP_BRIDGE_VERSION`;`src/features/agent-app/index.test.ts` 固定该 public export contract。 | 通过 2026-05-16 07:54 定向测试;防止外部 package 后续只能依赖 Lime 内部深路径。 | +| Host Bridge SDK client 文档化。 | `docs/roadmap/agentapp/capability-sdk.md` 增加 Host Bridge SDK Client 章节,记录 package-side App 如何用 `createLimeHostBridgeCapabilityInvoker + createLimeCoreCapabilityAdapters`,以及 P18.5.3 迁移规则。 | 通过文档 diff check;仍不替代外部 package 改造。 | +| P18.5.3 外部 package 迁移计划。 | `docs/roadmap/agentapp/p18-5-3-package-sdk-migration-plan.md` 固定外部 package 当前只读事实、推荐写集、迁移步骤、验收清单和回滚点。 | 通过文档 diff check;等待外部 package owner 稳定后执行。 | +| P18.5.3 owner handoff 单页。 | `docs/roadmap/agentapp/p18-5-3-owner-handoff.md` 浓缩接管前命令、最小写集、禁止项、迁移目标、验证顺序和完成条件。 | 给外部 package owner / 后续执行者使用;不替代 handoff 确认,也不代表外部 package 已迁移。 | +| P18.5.3 最新复核。 | 2026-05-16 10:55 真实外部 package `npm run verify` 通过;handoff gate 显示 `hostBridgePrivate=none`、`uiTestPrivate=none`、SDK marker 命中 2+2、`distArtifacts=0`、blockers 为 `none`。 | P18.5.3 SDK facade / package verify / dist 同步已完成;当前只剩 owner handoff。 | +| P18.5.3 package verify 风险。 | `docs/roadmap/agentapp/p18-5-3-package-sdk-migration-plan.md` 已记录 `npm run verify` 会先执行 `npm run build` 并重建 `dist/*`;2026-05-16 10:55 用户确认后已在真实 package 执行一次 verify。 | 当前 verify 已完成;后续若再次运行 build / verify / e2e,仍需 owner 接受 dist 重建。 | +| P18.5.3 外部 UI tests 迁移靶点。 | `docs/roadmap/agentapp/p18-5-3-package-sdk-migration-plan.md` 已记录 `tests/ui.test.mjs` 中需要调整的 Host Bridge 断言:保留业务闭环、manifest kind 和 capability call sequence,删除鼓励手写 `postMessage` / message type 的结构性断言。 | 后续 package-side 迁移时可只改测试 mock / facade 断言,不删除内容工厂业务行为断言。 | +| P18.5.3 package-side facade 兼容导出。 | `docs/roadmap/agentapp/p18-5-3-package-sdk-migration-plan.md` 已记录外部 `src/ui/app.js` 只直接 import `initHostBridge / notifyHost / runHostAgentTask / syncHostConfirmation / writeHostTaskResult`。 | 后续迁移优先保持这 5 个业务侧导出稳定,避免同时改 UI 主流程。 | +| P18.5.3 package-side 迁移 dry-run 设计。 | `docs/roadmap/agentapp/p18-5-3-package-sdk-migration-plan.md` 已基于当前外部 `src/ui/host-bridge.js` 只读结构,记录最小 diff 顺序:SDK runtime factory、pure helper 保留、私有 transport owner 删除 / 下沉、业务 helper 只调用 `lime.agent / storage / artifacts / evidence` facade。 | 等 handoff 后可按设计实施;当前仍未应用外部 package。 | +| P18.5.3 package-local SDK shim 退出条件。 | 外部 `content-factory-app` 当前 `dependencies / devDependencies` 为空;`p18-5-3-package-sdk-migration-plan.md` 已把 `src/ui/lime-app-sdk.js` 或等价 shim 列为仅在正式 `@lime/app-sdk` 不可安装时的临时写集。 | 禁止 import Lime repo 内部路径;shim 只能暴露 SDK 等价最小接口,后续发布正式 SDK 后退出。 | +| 正式 `@lime/app-sdk` package 发布状态。 | 2026-05-16 08:37 只读复核:Lime repo 根 `package.json` 仍为 `"private": true`;`packages/` 下只有 `packages/lime-cli-npm`;未发现独立 `@lime/app-sdk` / `lime-app-sdk` package。 | 不把正式 SDK package 发布当作 P18.5.3 已完成证据;外部 App 仍不得 import Lime internal path,必要时只能使用带退出条件的 package-local shim。 | +| P18.5.3 package-side SDK facade / verify。 | 2026-05-16 10:55 真实外部 package `npm run verify` 已通过,且 handoff gate 确认 `distArtifacts=0`。 | package-side SDK facade / verify 已完成;剩余为 owner handoff / git 写集收口,不是功能 blocker。 | +| P18.6 Raw Worker 前 Gate。 | `workflowRuntimeHost.test.ts`、`featureFlag.test.ts`、`runtimePackageLoader.test.ts`、`entryRuntimeGuard.test.ts` 当前通过 25 tests;2026-05-16 08:33 feature island 越界扫描无输出。 | 通过当前 gate;raw worker 后移 P19。 | +| 不绕过 SDK / 不复活旧 SceneApp。 | `rg -n "SceneApp|contentEngineering|sceneapp_|safeInvoke|invoke\\(|new Worker|Worker\\(" src/features/agent-app || true` 无输出。 | 通过当前扫描。 | +| 不抢隔壁运行任务。 | 本审计只读外部 package;不改 `src-tauri/*`、`src/lib/api/agentAppRuntime.ts`、`agentRuntimeCapabilityHost*` 实现逻辑、GUI smoke 脚本或外部 package。 | 符合当前协作边界。 | + +## 本轮复核命令 + +```bash +nice -n 10 npm test -- src/features/agent-app/sdk/contentFactorySdkRegression.test.ts src/features/agent-app/sdk/capabilityAdapters.test.ts src/features/agent-app/sdk/capabilityContract.test.ts src/features/agent-app/sdk/MockCapabilityHost.test.ts src/features/agent-app/runtime/hostBridge.test.ts src/features/agent-app/runtime/capabilityDispatcher.test.ts src/features/agent-app/runtime/agentRuntimeCapabilityHost.test.ts src/features/agent-app/ui/AgentAppRuntimePage.test.tsx +nice -n 10 npm test -- src/features/agent-app/sdk/hostBridgeClient.test.ts src/features/agent-app/sdk/capabilityAdapters.test.ts src/features/agent-app/sdk/contentFactorySdkRegression.test.ts +nice -n 10 npm test -- src/features/agent-app/sdk/hostBridgeClient.test.ts src/features/agent-app/sdk/contentFactorySdkRegression.test.ts src/features/agent-app/sdk/capabilityAdapters.test.ts src/features/agent-app/sdk/capabilityContract.test.ts src/features/agent-app/sdk/MockCapabilityHost.test.ts src/features/agent-app/runtime/hostBridge.test.ts src/features/agent-app/runtime/capabilityDispatcher.test.ts src/features/agent-app/runtime/agentRuntimeCapabilityHost.test.ts src/features/agent-app/ui/AgentAppRuntimePage.test.tsx +nice -n 10 npm test -- src/features/agent-app/sdk/hostBridgeClient.test.ts src/features/agent-app/sdk/capabilityContract.test.ts src/features/agent-app/sdk/capabilityAdapters.test.ts src/features/agent-app/sdk/contentFactorySdkRegression.test.ts +nice -n 10 npm test -- src/features/agent-app/index.test.ts +nice -n 10 npm test -- src/features/agent-app/sdk/hostBridgeClient.test.ts src/features/agent-app/sdk/contentFactorySdkRegression.test.ts src/features/agent-app/index.test.ts +nice -n 10 npm test -- src/features/agent-app/ui/AgentAppsPage.test.tsx src/features/agent-app/ui/AgentAppManagerPanel.test.tsx src/features/agent-app/ui/AgentAppLabPage.test.tsx +nice -n 10 npm test -- src/features/agent-app +nice -n 10 npm run verify:app-version +nice -n 10 npm run lint +nice -n 10 npm run typecheck -- --pretty false +nice -n 10 npm run test:contracts +nice -n 10 npm test -- src/features/agent-app/runtime/workflowRuntimeHost.test.ts src/features/agent-app/featureFlag.test.ts src/features/agent-app/runtime/runtimePackageLoader.test.ts src/features/agent-app/runtime/entryRuntimeGuard.test.ts +git diff --check -- src/features/agent-app/sdk src/features/agent-app/runtime/hostBridge.ts src/features/agent-app/index.ts src/features/agent-app/index.test.ts docs/roadmap/agentapp +rg -n "SceneApp|contentEngineering|sceneapp_|safeInvoke|invoke\\(|new Worker|Worker\\(" src/features/agent-app || true +cd /Users/coso/Documents/dev/ai/limecloud/content-factory-app && nice -n 10 npm test +nice -n 10 npm run verify:local +nice -n 10 npm run harness:doc-freshness +``` + +结果: + +- Lime SDK / Host Bridge 定向测试:新增 SDK client 前 8 files / 37 tests passed;新增后 9 files / 43 tests passed。 +- 2026-05-16 06:15 与 07:14 低优先级重跑 P18 SDK / Host Bridge 定向测试:9 files / 43 tests passed。 +- 2026-05-16 07:03 低优先级重跑 Lime-side P18 SDK 最小 contract 验证:`hostBridgeClient.test.ts`、`capabilityContract.test.ts`、`capabilityAdapters.test.ts`、`contentFactorySdkRegression.test.ts` 4 files / 13 tests passed。 +- Agent Apps 入口 UI 定向测试:2026-05-16 07:18 低优先级运行 `AgentAppsPage.test.tsx`、`AgentAppManagerPanel.test.tsx`、`AgentAppLabPage.test.tsx`,3 files / 24 tests passed。 +- Agent App feature island 全量定向测试:2026-05-16 07:59 低优先级运行 `npm test -- src/features/agent-app`,35 files / 169 tests passed;2026-05-16 09:09 复跑,35 files / 173 tests passed;2026-05-16 10:17 覆盖 SDK-only public surface 收口后复跑,36 files / 178 tests passed。 +- Host Bridge SDK client 定向测试:1 file / 6 tests passed。 +- Agent App public SDK export test:2026-05-16 07:54 低优先级运行 `npm test -- src/features/agent-app/index.test.ts`,1 file / 1 test passed。 +- SDK-only public surface test:2026-05-16 10:08 低优先级运行 `npm test -- src/features/agent-app/sdk/publicSdkSurface.test.ts src/features/agent-app/sdk/hostBridgeClient.test.ts src/features/agent-app/sdk/contentFactorySdkRegression.test.ts src/features/agent-app/index.test.ts`,4 files / 11 tests passed;同次证明 `contentFactorySdkRegression.test.ts` 已从 SDK-only public surface 导入。 +- 2026-05-16 08:19 当前会话低优先级复跑 SDK seam 最小集:`hostBridgeClient.test.ts`、`contentFactorySdkRegression.test.ts`、`index.test.ts`,3 files / 8 tests passed。 +- App version:2026-05-16 07:15 与 08:09 低优先级运行 `npm run verify:app-version` 均通过,版本一致性为 `1.40.0`。 +- Lime lint:2026-05-16 07:13、08:04 与 10:20 低优先级运行 `npm run lint` 均通过,10:20 覆盖 SDK-only public surface 与内容工厂 SDK regression 收口后的 `src` lint 面。 +- Lime typecheck:通过;2026-05-16 06:16、07:06、08:03、08:24、09:04、09:52 与 10:15 低优先级重跑 `npm run typecheck -- --pretty false` 仍通过,10:15 覆盖内容工厂回归改从 SDK-only public surface 导入后的类型面。 +- Lime command / harness / modality contracts:通过;2026-05-16 06:17、07:09、08:06、08:24、09:04 与 10:21 低优先级重跑 `npm run test:contracts` 仍通过,10:21 覆盖 SDK-only public surface 与 handoff gate 脚本新增后的契约面。 +- P18.6 raw worker 前 gate:4 files / 25 tests passed;2026-05-16 06:19 与 07:08 低优先级重跑仍通过。 +- `git diff --check`:通过;2026-05-16 06:20、06:45、07:12 与 08:08 重跑;08:08 命令已覆盖 `src/features/agent-app/index.test.ts`:`git diff --check -- src/features/agent-app/sdk src/features/agent-app/runtime/hostBridge.ts src/features/agent-app/index.ts src/features/agent-app/index.test.ts docs/roadmap/agentapp`。 +- feature island 越界扫描:无输出;2026-05-16 06:19、07:04 与 08:33 重跑 `rg -n "SceneApp|contentEngineering|sceneapp_|safeInvoke|invoke\\(|new Worker|Worker\\(" src/features/agent-app || true` 均无输出。 +- 外部 `content-factory-app` 只读测试:2026-05-16 07:50 与 08:19 按 owner handoff gate 低优先级重跑 `/Users/coso/Documents/dev/ai/limecloud/content-factory-app: npm test` 均为 46 tests passed;该只读结果不替代 package-side SDK facade / verify。 +- 完整 `verify:local`:2026-05-16 07:33 低优先级运行 `nice -n 10 npm run verify:local` 端到端通过;本次通过 app version、lint、typecheck、Vitest smart 58 batches、`test:contracts`、`cargo test --manifest-path src-tauri/Cargo.toml`、workspace-ready、browser-runtime、site-adapters、agent-service-skill-entry、agent-runtime-tool-surface、agent-runtime-tool-surface-page、at-command-registry、agent-apps、claw-chat-ready-streaming、knowledge-gui 与 design-canvas GUI smoke,最终输出 `[verify:gui-smoke] 通过` 与 `[local-ci] 本地校验完成。` +- Doc freshness:2026-05-16 08:51 低优先级运行 `npm run harness:doc-freshness`,monitored docs 10 / existing docs 10 / issues 0 / broken markdown links 0 / broken code path refs 0,结果为 `doc freshness: clean`。 + +## 2026-05-16 claw smoke 只读诊断 + +本段只记录证据,不接管 AgentRuntime / GUI smoke owner 写集;最新完整 `verify:local` 已消费该 pass 结果。 + +| 证据 | 观察 | P18 判定 | +|---|---|---| +| Summary | `.lime/qc/gui-evidence/claw-chat-ready-streaming/claw-chat-ready-streaming-summary.json` 在 2026-05-16 07:33 更新为 `verdict=pass / error=null / consoleErrorCount=0 / failureRuntime=null`;只读复核显示 `providerPreference=deepseek`、`modelPreference=deepseek-v4-flash`、`interruptedTurnStatus=aborted`、`followSessionId=c2742b86-f2cc-4a0b-91f2-b1baf3c6005e`、`followTurnId=091b4738-a537-43f2-a4b9-daf0ed1b3c3d`。 | 历史 blocker 已解除,并已进入完整 `verify:local` 通过证据;P18 不需要抢修 runtime / smoke。 | +| GUI smoke 链路 | 同次完整 `verify:local` 已跑过 workspace-ready、browser-runtime、site-adapters、agent-service-skill-entry、agent-runtime-tool-surface、agent-runtime-tool-surface-page、at-command-registry、agent-apps、claw-chat-ready-streaming、knowledge-gui 与 design-canvas。 | 全局 GUI smoke 不再阻塞 P18 当前 SDK gate;后续只在外部 package 迁移后按改动范围补验证。 | +| 协作边界 | 本轮不改 `src-tauri/*`、runtime facade 或 smoke 脚本;只把已通过证据写入客户端 P18 审计。 | 避免和隔壁 AgentRuntime / GUI smoke 任务打架。 | + +## 当前协作分工 + +| 工作面 | Owner | P18 处理方式 | +|---|---|---| +| 外部 `content-factory-app` 源码、dist、UI、模型生成与 docs。 | 隔壁 package 任务。 | 只读观察;不覆盖 dirty / untracked 文件。 | +| AgentRuntime Rust / TS facade、push subscribe、workspace patch producer、capability catalog、真实桌面 GUI smoke。 | 隔壁 AgentRuntime 任务。 | 只消费已验证 host surface,不复制 read model 或 runtime command。 | +| Agent App P18 SDK contract、stable error、mock host、typed adapters、Lime-side regression、Host Bridge SDK client。 | Agent App P18。 | 当前通过定向测试与 contracts。 | +| P18.5.3 package-side SDK facade / verify。 | 等外部 package owner 稳定后认领。 | 当前 blocked;不能用 read-only tests 伪装完成。 | + +## 2026-05-16 协作锁定 + +隔壁标准资料已有新增,且外部 package / AgentRuntime 仍在并行推进。P18 当前采取“只读同步标准、只写客户端计划、不抢运行写集”的分工: + +| 输入 / 写集 | 当前观察 | 本轮处理方式 | +|---|---|---| +| `/Users/coso/Documents/dev/ai/limecloud/agentapp` | 工作区干净;当前 `HEAD=4bef605 fix: quote mermaid sdk labels`,zh / en 标准资料已覆盖 authoring、client implementation、reference、examples、what-is-agent-app、agent-app-vs-skills-knowledge 与 mini-program analogy,进一步固定 Agent App 是完整应用包、Expert Chat 只是 entry、能力调用必须通过 Capability SDK。 | 作为标准资料只读输入;同步到本审计和后续计划口径,不改标准仓库、不复制标准文档。 | +| `/Users/coso/Documents/dev/ai/limecloud/content-factory-app` | 2026-05-16 07:39 只读复核有 39 个 dirty / untracked 条目,其中 36 个 tracked modified、3 个 untracked;当时 `src/ui/host-bridge.js`、`dist/ui/host-bridge.js`、`src/ui/app.js`、`tests/ui.test.mjs`、`dist/*`、docs 和 model-generation 相关文件仍在并行改动,且私有 bridge marker 仍命中。 | 历史 blocker;已被 10:55 真实 package verify 和 handoff gate `blockers=none / distArtifacts=0` 覆盖。 | +| `src-tauri/*`、`src/lib/api/agentAppRuntime.ts`、`src/features/agent-app/runtime/agentRuntimeCapabilityHost*`、GUI smoke 脚本 | 2026-05-16 07:05 只读复核 owner-sensitive 写集仍有 25 个 dirty / untracked 条目,属于 AgentRuntime / GUI owner 面。 | 不改;P18 只消费已验证 host surface,不抢 Rust / TS facade / GUI smoke。 | +| `smoke:claw-chat-ready-streaming` | 2026-05-16 07:33 只读复核最新 summary 为 `verdict=pass / error=null`;`providerPreference=deepseek`、`modelPreference=deepseek-v4-flash`、`interruptedTurnStatus=aborted`、`followTurnId=091b4738-a537-43f2-a4b9-daf0ed1b3c3d`,`consoleErrorCount=0`、`failureRuntime=null`。 | 已随完整 `verify:local` 通过;不在 P18 SDK lane 内抢修 AgentRuntime / GUI smoke。 | +| `docs/roadmap/agentapp/*` | 客户端路线图需要记录标准资料和 owner 边界。 | 可做 additive 文档同步;避免把 Cloud / LimeCore、外部 package 或 AgentRuntime 实现混进客户端计划。 | +| Lime 工作区自检 | 2026-05-16 07:39 只读复核 `docs/roadmap/agentapp` 与 `src/features/agent-app` 范围仍有 30 个 dirty / untracked 条目,其中 16 个 tracked modified、14 个 untracked;P18 文档收口写集保持在 `docs/roadmap/agentapp/p18-completion-audit.md` 与 `docs/roadmap/agentapp/p18-5-3-package-sdk-migration-plan.md`。 | 后续继续先 `git status --short` 再动手;不把并行实现写集纳入 P18 文档收口。 | + +2026-05-16 08:58 当前只读复核: + +| 检查项 | 当前事实 | P18 判定 | +|---|---|---| +| 标准与服务端仓库 | `/Users/coso/Documents/dev/ai/limecloud/agentapp` 工作区干净,`HEAD=4bef605`;`/Users/coso/Documents/dev/ai/limecloud/limecore` 工作区干净。 | 标准 / 服务端事实源可读;本轮不改标准仓库或 LimeCore。 | +| 隔壁 Capability 共享输入 | 2026-05-16 只读读取 `docs/roadmap/agentruntime/claw-capability-sharing.md`;该 draft 固定 Chat `@命令`、Agent App `lime.agent.startTask`、Automation job 只是 surface adapter,Claw 能力需收敛到 AgentRuntime capability catalog,首刀已把部分 capability hint 写入现有 Claw `*_skill_launch` metadata。 | 作为 AgentRuntime owner 输入消费;P18 不复制 Claw skill launch,不新增 `content_factory_*` 垂直后端能力,也不把 capability catalog owner 迁入 Agent App SDK。 | +| 外部 package 写集 | `/Users/coso/Documents/dev/ai/limecloud/content-factory-app` 仍有 36 个 tracked modified、3 个 untracked;两个 `npm run dev` 进程的 cwd 都在该仓库。 | owner 仍未稳定;P18.5.3 不接管外部源码、`dist/*`、`tests/*` 或 `package.json`。 | +| 外部私有 bridge marker | `src/ui/host-bridge.js` 仍包含 `pendingRequests / buildMessage / window.parent.postMessage / requestHostBridge / capability:invoke / capability:subscribe / capability:event`,未出现 `createLimeHostBridgeCapabilityInvoker` 或 `createLimeCoreCapabilityAdapters`。 | package-side SDK facade 仍未完成。 | +| 外部 UI 测试 marker | `tests/ui.test.mjs` 仍直接过滤 `message.type === 'capability:invoke'` 并模拟私有 `postMessage` transport。 | 测试仍鼓励私有 transport;迁移时必须改为 SDK invoker / Host Bridge client call log。 | +| Lime 运行面 | Lime 当前仍有 Vite 与 `tauri:dev:headless` 进程在跑。 | 本轮不启停 DevBridge / Vite / Tauri,不跑 GUI smoke,不抢隔壁运行环境。 | +| SDK package 发布边界 | Lime 根 `package.json` 仍是 private 桌面包;`packages/` 仅有 `@limecloud/lime-cli`,未发现正式 `@lime/app-sdk` package。 | 外部迁移若立即 handoff,只能用带退出条件的 package-local shim,不能 import Lime internal path。 | +| 文档轻量验证 | 2026-05-16 08:59 低优先级运行 `npm run harness:doc-freshness`,monitored docs 10 / existing docs 10 / issues 0 / broken markdown links 0 / broken code path refs 0。 | 客户端文档引用未被本轮协作快照破坏;仍不能替代 P18.5.3 外部 package-side SDK facade / verify。 | +| Agent App feature island 越界扫描 | 2026-05-16 09:00 只读运行 `rg -n "SceneApp\|contentEngineering\|sceneapp_\|safeInvoke\|invoke\\(\|new Worker\|Worker\\(" src/features/agent-app \|\| true`,无输出。 | 当前 feature island 未复活旧 SceneApp、未直接 Tauri invoke、未执行 raw Worker;仍不能替代外部 package SDK facade / verify。 | +| Agent App feature island 全量定向测试 | 2026-05-16 10:17 低优先级运行 `npm test -- src/features/agent-app`,36 files / 178 tests passed。 | 当前 Agent App feature island 回归通过,并覆盖 SDK-only public surface 收口;仍不能替代外部 package-side SDK facade / verify。 | +| Lime-side SDK seam 定向测试 | 2026-05-16 09:01 低优先级运行 `npm test -- src/features/agent-app/sdk/hostBridgeClient.test.ts src/features/agent-app/sdk/contentFactorySdkRegression.test.ts src/features/agent-app/index.test.ts`,3 files / 8 tests passed。 | Host Bridge SDK client、内容工厂 SDK regression 与 public SDK export seam 当前仍绿;仍不能替代外部 package-side SDK facade / verify。 | +| Lime typecheck | 2026-05-16 10:55 低优先级运行 `npm run typecheck -- --pretty false`,通过。 | 当前 dirty 工作区的 TypeScript 类型层仍通过,并覆盖内容工厂回归改从 SDK-only public surface 导入后的类型面。 | +| Lime lint | 2026-05-16 11:00 低优先级运行 `npm run lint`,通过。 | 当前 `src` lint 仍通过;脚本变更另由 handoff core test、diff check 与实际 gate 运行覆盖。 | +| Lime contracts | 2026-05-16 10:53 低优先级运行 `npm run test:contracts`,command contracts、harness contracts、modality runtime contracts 与 cleanup report contract 均通过。 | 当前命令 / harness / modality 契约仍通过;不替代外部 package verify,但 package verify 已于 10:55 通过。 | +| Lime app version | 2026-05-16 09:07 低优先级运行 `npm run verify:app-version`,版本一致性检查通过:`1.40.0`。 | 当前版本事实源一致;仍不能替代外部 package-side SDK facade / verify。 | +| 外部 package 只读测试 | 2026-05-16 09:05 在 `/Users/coso/Documents/dev/ai/limecloud/content-factory-app` 低优先级运行 `npm test`,46 tests passed。 | 只证明外部 package 当前业务测试仍绿;测试仍在私有 bridge transport 上通过,不能替代 package-side SDK facade 或会重建 `dist/*` 的 `npm run verify`。 | +| 外部标准只读校验 | 2026-05-16 09:06 在 `/Users/coso/Documents/dev/ai/limecloud/content-factory-app` 低优先级运行 `npm run validate:app && npm run readiness:app`;`validate:app` 为 `ok=true / status=passed`,manifest hash 为 `sha256:6ec3fed5f163739bcf0fd2b845c51a8e10d28aa856e8c6f90259fdab9edd1e48`;`readiness:app` 为 `ok=true / status=needs-setup`。 | 标准 manifest 可读,readiness 正确停在宿主 required 依赖待绑定状态;仍不能替代 package-side SDK facade 或会重建 `dist/*` 的 `npm run verify`。 | +| AgentApp 文档旧口径扫描 | 2026-05-16 09:10 运行 `rg -n "08:30\|171 tests\|35 files / 171" docs/roadmap/agentapp \|\| true`,无输出。 | AgentApp 文档当前验证口径已统一到 09:04 / 09:09;仍不能替代外部 package-side SDK facade / verify。 | +| 垂直后端能力越界扫描 | 2026-05-16 09:11 只读扫描 `src-tauri / src / scripts` 中 `content_factory_* command / skill_launch` 形态;命中项集中在 `src-tauri/src/commands/agent_app_runtime_cmd.rs` 的 `is_content_factory_runtime_task`、output contract helper 与测试,用于避免复合内容工厂任务被强制提升为单一 Claw skill launch。 | 当前未发现 P18 新增垂直 `content_factory_*` 后端 command;该 AgentRuntime helper 属隔壁 runtime owner 面,P18 只消费、不接管。 | +| 外部 package SDK facade marker 复核 | 2026-05-16 09:22 只读复核 `/Users/coso/Documents/dev/ai/limecloud/content-factory-app`;`src/ui/host-bridge.js` 仍包含 `pendingRequests / buildMessage / window.parent.postMessage / requestHostBridge / capability:invoke / capability:subscribe / capability:event`,`tests/ui.test.mjs` 仍直接过滤 `message.type === 'capability:invoke'`。 | P18.5.3 唯一主阻塞仍未解除;外部 package 尚未成为 SDK facade consumer。 | +| Handoff 确认状态 | 2026-05-16 09:26 已进入需显式确认状态:没有用户或外部 package owner 明确“接管外部 package 最小写集”前,不修改 `/Users/coso/Documents/dev/ai/limecloud/content-factory-app/src/ui/host-bridge.js`、`tests/ui.test.mjs`、`package.json` 或 `dist/*`。 | 当前只能继续只读复核 / 文档审计;不能实施 P18.5.3 package-side SDK facade。 | +| 运行面复核 | 2026-05-16 09:28 只读复核进程,两个 `npm run dev` 的 cwd 都在 `/Users/coso/Documents/dev/ai/limecloud/content-factory-app`;Lime 侧仍有 Vite 与 `tauri:dev:headless` 进程。 | 外部 package 与 Lime GUI / Tauri 运行面仍在并行;不能抢占或启停这些进程。 | +| 09:39 再复核 | 外部 package 当时有 36 个 tracked modified、3 个 untracked;`src/ui/host-bridge.js` / `tests/ui.test.mjs` marker 命中手写 transport;外部 `npm test` 重新通过 46 tests;Lime-side SDK seam 最小集 `hostBridgeClient.test.ts / contentFactorySdkRegression.test.ts / index.test.ts` 重新通过 3 files / 8 tests;`src/features/agent-app` 越界扫描无输出。 | 历史 blocker;已被 10:55 真实 package verify 覆盖。 | +| 09:48 机械 gate | 新增 `scripts/agent-app-package-handoff-check.mjs` / `scripts/lib/agent-app-package-handoff-core.mjs` / `scripts/lib/agent-app-package-handoff-core.test.ts`,用只读方式把 dirty 计数、私有 bridge marker、SDK facade marker、`scripts/build.mjs` 与高风险脚本转成 `ready / needs_handoff / blocked` 判定;当前外部 package 输出 `status=blocked`,并命中 `build / verify / e2e:user-flow / e2e:user-flow:fake-model` 高风险脚本。 | P18.5.3 后续不再只靠人工 grep;但当前 gate 结果仍证明外部 package 未完成 SDK facade。 | +| 10:17 外部 gate 复核 | 运行 `node scripts/agent-app-package-handoff-check.mjs --package-dir /Users/coso/Documents/dev/ai/limecloud/content-factory-app --check`,退出码 1;输出为 `status=blocked`、dirty `tracked=36 / untracked=3`、hostBridgeSdk `none`,私有 bridge marker 与 highRiskScripts 均未消失。 | 历史 blocker;已被 10:55 真实 package verify 覆盖。 | +| 10:33 外部 gate 复核 | 运行 `node scripts/agent-app-package-handoff-check.mjs --package-dir /Users/coso/Documents/dev/ai/limecloud/content-factory-app`,输出 `status=needs_handoff`、dirty `tracked=36 / untracked=4 / total=40`、`hostBridgePrivate=none`、`uiTestPrivate=none`、`hostBridgeSdk=createLimeHostBridgeCapabilityInvoker:2, createLimeCoreCapabilityAdapters:2`,blockers 为 `none`。 | source-side facade blocker 已解除;当时主缺口切换为 owner handoff / dist verify;已在 10:55 收口。 | +| 10:41 不重建 dist 验证 | Lime repo 低优先级运行 SDK seam 最小集:4 files / 11 tests passed;外部 package 低优先级运行 `npm test`:46 tests passed;`validate:app` passed;`readiness:app` needs-setup。 | 当前 source-side facade 与标准 manifest 仍绿;仍不能替代 `npm run verify`,因为 verify 会先 build 并重建 `dist/*`。 | +| 10:45 dist 重建前预审 | 只读解析外部 `scripts/build.mjs`,确认 build 会先删除 `dist` 再复制 `src/*`;hash 对比发现 `src/ui/host-bridge.js` 与 `dist/ui/host-bridge.js` 不一致,且 `src/ui/lime-app-sdk.js` 尚无 `dist/ui/lime-app-sdk.js`。 | 安装产物缺口已明确:source-side facade 尚未同步到 dist;仍需 owner handoff 后重建 dist 或记录退出条件。 | +| 10:47 隔离 verify 演练 | 复制外部 package 当前工作区到 `/tmp/limecloud-content-factory-verify.uots6S/content-factory-app`,用 sibling symlink 保持 `../agentapp` 可用,并在临时副本运行 `npm run verify`;build、46 tests、validate、readiness 全部通过,readiness 仍为预期 `needs-setup`。 | 证明当前源码若允许重建 dist,大概率可通过 package verify;仍不能替代真实 package 写集验收,因为真实 `/Users/coso/Documents/dev/ai/limecloud/content-factory-app/dist/*` 未被更新。 | +| 10:55 真实 package verify | 用户确认后,在真实 `/Users/coso/Documents/dev/ai/limecloud/content-factory-app` 运行 `nice -n 10 npm run verify`:build 通过,46 tests passed,`validate:app` passed,`readiness:app` needs-setup;随后 handoff gate 显示 `distArtifacts=diff:0,missing:0,extra:0,total:0`。 | P18.5.3 的 package verify / dist 验收完成;gate 仍为 `needs_handoff` 是因为工作区 dirty 与高风险脚本 warning,不是 SDK facade blocker。 | + +冲突规约: + +1. 修改前先跑目标仓库 `git status --short`,发现 dirty / untracked 且不属于本任务写集时只读观察。 +2. 外部 package 未稳定前,不改 `src/ui/host-bridge.js`、`dist/ui/host-bridge.js`、`tests/ui.test.mjs` 或 `package.json`。 +3. AgentRuntime / Rust / GUI smoke 并行任务未收口前,不改 `src-tauri/*`、runtime facade、DevBridge / Tauri / Vite 进程和 smoke 脚本。 +4. 本机磁盘空间已恢复到可跑 Rust 测试;不在未确认时删除 `src-tauri/target` 或其他构建缓存。`smoke:claw-chat-ready-streaming` 的 06:23 历史失败已在 07:33 完整 `verify:local` 中转为 pass 证据。 +5. P18 后续只在通用 SDK contract 存在缺口时补 `src/features/agent-app/sdk/*`,不新增垂直 `content_factory_*` host command 或第二套 bridge。 + +因此当前优先级调整为:不再把“外部 source-side facade、package verify、dist 同步”作为 P18 blocker;下一刀只剩 owner handoff、提交边界与是否归档 P18 的工程收口。完整 `verify:local` 已通过,不再把全局 GUI smoke 作为当前阻塞。 + +## 下一刀 + +1. 外部 `content-factory-app` owner 接收当前 dirty 写集,尤其是 `src/ui/host-bridge.js`、`src/ui/lime-app-sdk.js`、`tests/ui.test.mjs` 与 `dist/ui/*`。 +2. Lime 侧 owner 接收新增 SDK / handoff gate 脚本与 P18 roadmap 文档写集。 +3. 如后续还有外部 package 变更,先复跑 handoff gate;只有 `blockers=none` 且必要验证通过后再进入 P19。 +4. P18 归档后再启动 P19 raw worker、marketplace 扩展或内容工厂后端产品化;不要在 P18 写集中混入下一阶段。 + +## 完成判定 + +P18 当前满足功能完成判定:外部 `content-factory-app` package-side SDK facade 已完成,真实 package verify 已通过,Lime formal runtime / SDK regression 有迁移后新证据,并且没有引入私有 bridge、raw worker 或第二套 runtime。当前不等于“git 可提交完成”,因为外部 package 与 Lime 工作区仍 dirty,需要 owner 决定提交 / 推送边界。 diff --git a/docs/roadmap/agentapp/p18-typed-capability-sdk-gate.md b/docs/roadmap/agentapp/p18-typed-capability-sdk-gate.md new file mode 100644 index 000000000..feddb847e --- /dev/null +++ b/docs/roadmap/agentapp/p18-typed-capability-sdk-gate.md @@ -0,0 +1,501 @@ +# P18 Typed Capability SDK Gate + +更新时间:2026-05-16 + +状态:P18.0 文档计划已完成;P18.1 SDK facade / stable error / mock host 已完成最小代码契约;P18.2 Host Bridge typed router / stable error response 已完成;P18.3 Core capability adapters 已完成;P18.4 App-scoped Agent task SDK facade 已完成;P18.4-H AgentRuntime handoff gate 已完成;P18.5.1 Lime-side 内容工厂 SDK 回归已完成;P18.5.2 package-side read-only tests 已通过;P18.5-S Host Bridge SDK client 已完成 capability invoke、subscription、Host action 和 Host event contract;P18.5.3 package-side SDK facade / verify / dist 同步已完成;P18.6 Raw Worker 前 Gate 已完成;P17.5 Formal entry GUI smoke 已通过;完整 `verify:local` 已于 2026-05-16 07:33 通过,2026-05-16 10:53 SDK seam / handoff core 定向测试 5 files / 17 tests passed,`typecheck`、`test:contracts` 与 `lint` 当前会话复核通过;P18 功能完成,剩余 owner handoff / git 写集收口。 + +## 协作分工 + +P17.5 正式入口 smoke 已收口;P18 代码实施继续和 AgentRuntime / GUI 验证任务分工,避免抢同一运行环境或改同一底层投影。 + +| 分工 | 负责范围 | 暂不触碰 | +|---|---|---| +| P17.5 / 已收口 | 正式 `agent-apps` smoke、`scripts/agent-apps-smoke.mjs`、P17.5 evidence summary。 | 不把通过 smoke 扩大解释为 marketplace / 真实 delete-data / Cloud 管理台。 | +| AgentRuntime / 隔壁任务 | `AgentRuntimeThreadReadModel`、`agent_app_runtime_*` facade、artifact / evidence / handoff 投影、`artifact:created` refs、跨刷新 task 恢复、Host response 回写;`docs/roadmap/agentruntime/claw-capability-sharing.md` 进一步把 Claw `@` 能力收敛到 AgentRuntime capability catalog。 | 不改 Agent App SDK 路线图,不由 P18 复制 runtime read model;push subscribe、workspace patch producer、capability catalog service、Claw capability catalog 和真实桌面 GUI smoke 仍由 AgentRuntime 任务收口。 | +| P18 / 完成候选 | 上游 Agent App v0.4 Host Bridge 标准对齐、Typed Capability SDK gate、App-scoped Agent task 契约。P18.1 已落 SDK 类型、stable error、mock host 和 contract seam;P18.2 已收敛 Host Bridge typed router;P18.3 已完成 core adapters;P18.4 已完成 `lime.agent` SDK facade;P18.4-H 已完成 handoff gate;P18.5.1 已完成 Lime-side 内容工厂 SDK 回归;P18.5.2 已完成 package-side read-only tests;P18.5-S 已补 Host Bridge SDK client;P18.5.3 已完成真实 package verify 和 dist 同步;P18.6 已完成 raw worker 前 gate。 | 不启停隔壁 Tauri / Vite / DevBridge,不改 `src-tauri/*` / runtime facade,不改 `src/features/agent-app/runtime/agentRuntimeCapabilityHost*`,不进入 raw worker;后续只做 owner handoff / 提交边界,不再扩 P18 功能面。 | + +收敛规则:P17.5 已解除正式入口 smoke 阻塞;P18 代码只从 SDK contract 最小面开始,不把 SDK gate 扩大成垂直内容系统。 + +## 协作接口边界 + +| 接口面 | 隔壁 AgentRuntime 提供 | P18 消费方式 | 防打架规则 | +|---|---|---|---| +| `lime.agent` task facade | `start / stream / get / cancel / retry / submitHostResponse / listTasks` 到 `agent_app_runtime_*`,并映射 `sessionId / turnId / requestId`。 | P18.1 已定义 SDK 类型和 mock 行为;P18.4 已包装成 typed adapter;P18.4-H 已消费隔壁运行证据。 | P18 不改 Rust command、不改 `src/lib/api/agentAppRuntime.ts`。 | +| task event projection | queued / progress / missing context / review / tool call / `artifact:created` / evidence / outcome / incident。 | SDK 固定 event union、stable error 和 mock fixtures;P18.4-H 已完成 handoff 校验,push subscribe 继续归 AgentRuntime owner。 | 不在 SDK 层重建 read model,不把轮询实现写进业务 App。 | +| storage / artifact / evidence write-back | 内容工厂已用声明类型写回 `lime.storage / lime.artifacts / lime.evidence`,AgentRuntime 负责事实投影。 | P18.3 只包装 typed adapter,自动带 provenance。 | 不新增第二套 artifact / evidence store,不写垂直 `content_factory_*` adapter。 | +| structured workspace patch | 内容工厂 App 已能消费 `workspacePatch / contentFactoryWorkspacePatch`;后端 producer contract 仍是 AgentRuntime 缺口。 | P18 先把 patch 作为可选 typed artifact/event,不把它当 P18.1 完成条件。 | 不由 SDK mock 伪造生产级 patch 成功;真实 producer 缺口回挂 AgentRuntime。 | +| capability catalog | `docs/roadmap/agentruntime/claw-capability-sharing.md` 规定 Chat `@命令`、Agent App `lime.agent.startTask`、Automation job 都只是 surface adapter;首批 capability hints 已可映射 Claw `*_skill_launch` metadata,独立 catalog service 未完成。 | P18 先在 SDK 层定义 `lime.tools / lime.knowledge / lime.agent` 能力声明与错误语义;把 capability sharing 作为 AgentRuntime owner 输入消费。 | 不复制 Claw `*_skill_launch.rs`,不把 capability hint 内置成 App 专用逻辑,不新增 `content_factory_*` 垂直后端能力。 | + +## 2026-05-16 隔壁 AgentRuntime 输入 + +本轮只把隔壁更新作为 P18 设计输入,不抢它的代码面和验证链路。最新审计结论是:AgentRuntime 对 Agent App 已达到 current MVP,但不是完整产品化完成。 + +- `docs/roadmap/agentruntime/*` 已补充 Agent App Runtime Surface 事实:内容工厂实际 App 已把主生产结果和确认链结果写回 `lime.storage / lime.artifacts / lime.evidence`。 +- `agent_app_runtime_start_task / cancel / get / submit_host_response` facade、Host Bridge `lime.agent` 适配、`artifact:created` refs 和跨刷新 task 恢复已经进入 done / first-cut 口径。 +- `docs/roadmap/agentruntime/claw-capability-sharing.md` 新增 Claw capability 共享草案:Chat `@命令`、Agent App `lime.agent.startTask`、Automation job 都应作为 surface adapter 进入 AgentRuntime capability catalog;首刀已将 image / cover / research / report / pdf / summary capability hint 写入现有 Claw `*_skill_launch` metadata。 +- 仍不能宣称整体完成的缺口是:后端 push subscribe / stream、`content_factory.workspace_patch` 后端 producer contract、独立 capability catalog service、真实桌面 GUI smoke。 +- P18 不重新实现 artifact / evidence / handoff 读模型,也不新增第二套 write-back;P18 只把这些 host surface 包成 typed SDK contract。 +- P18 不接管 Claw capability catalog owner,不复制 `*_skill_launch.rs`,也不新增 `content_factory_*` 垂直后端能力;内容工厂只能通过 `lime.agent` / capability intent 消费共享主链。 +- P18.0 gap matrix 需要新增一列检查 `task:*`、`artifact:created`、`evidence:recorded`、`evidence:verified` 的事件语义、顺序、幂等键和跨刷新恢复边界。 +- P18.1 已在不等待所有 AgentRuntime 缺口收口的前提下固定 SDK 类型、stable error 和 mock host,避免下游 App 继续手写私有 bridge。 + +## 背景 + +上游 `/Users/coso/Documents/dev/ai/limecloud/agentapp` 已发布 `agentapp-ref@0.4.0`,把 Host Bridge v1 明确为 sandboxed Agent App UI 的标准运行时事件协议。P17.4 已在 Lime Desktop 内完成 runtime surface / Host Bridge 的生产硬化,P17.5 已证明正式 `agent-apps` 入口能独立跑通 install、registration、launch、disable、uninstall rehearsal、runtime surface 和 flag-off。 + +P18 的问题不是继续扩大页面能力,而是把已经出现的 `lime.agentApp.bridge` 固定为 App 可依赖的 Typed Capability SDK: + +- App 拥有业务 UI、workflow、storage schema、结构化结果写回和人工确认。 +- Lime Desktop 拥有 Host runtime、Agent Runtime、Tool Broker、Knowledge、Artifact、Evidence、Policy、Secrets、cleanup 和 provenance。 +- Lime Cloud / LimeCore 只作为 catalog、release、tenant enablement、policy defaults、ToolHub / gateway metadata 的 control-plane 输入,不成为默认 Agent Runtime。 +- App 不能 import Lime 内部模块,也不能绕过 SDK 自建模型网关、凭证系统、权限系统、证据系统或工具调度器。 + +## 一句话目标 + +在扩展 raw worker 或更多业务 App 前,把 `@lime/app-sdk`、`lime.agentApp.bridge` 和 Lime host services 之间的 typed contract 固定下来,让 Agent App 可以稳定调用 Lime 能力,同时不依赖内部实现。 + +## 非目标 + +- 不做 marketplace、支付、审核流、Cloud 管理台。 +- 不把 Cloud 变成默认 Agent Runtime。 +- 不执行任意 raw worker bundle;P18 只允许 typed workflow / capability invoke。 +- 不把通用 Chat 作为 Agent App 核心流程容器;Expert Chat 只是 entry 或嵌入式协作者。 +- 不新增第二套 repository、installer、cleanup scanner 或 Agent Runtime。 +- 不为了 P18 增加新的 Tauri command;除非现有 host service 无法表达且先完成命令边界审计。 + +## 核心原则 + +1. **SDK 暴露能力,不暴露实现**:App 只依赖 `@lime/app-sdk` 类型和稳定错误码。 +2. **Bridge 只做传输**:`lime.agentApp.bridge` 是跨 iframe / sandbox 的消息协议,不保存执行事实。 +3. **Host 最终裁决**:readiness、permission、policy、secret、provenance 和 cleanup 都由 Host 强制执行。 +4. **声明先于调用**:manifest / entry / setup / overlay 声明通过后,capability 才能被调用。 +5. **结果结构化写回**:Agent task 输出进入 `lime.storage`、`lime.artifacts`、`lime.evidence`,不靠用户复制聊天文本。 +6. **全链路可 mock**:每个 capability 至少有 type、schema、mock host、contract test。 +7. **失败可清理**:SDK 调用和写回必须带 appId、entryKey、packageHash、manifestHash、workspace / tenant 上下文。 + +## 架构图 + +```mermaid +flowchart TD + AppUI[Agent App UI / Sandbox Frame] --> SDK[@lime/app-sdk Typed Facade] + AppWorker[Typed Workflow / Future Worker] --> SDK + SDK --> Bridge[lime.agentApp.bridge v1] + Bridge --> Router[AgentAppCapabilityRouter] + Router --> Guard[P14 Entry Guard / Readiness / Permission / Policy] + Guard --> UI[Lime UI Host] + Guard --> Storage[Storage Namespace Service] + Guard --> Files[File Service] + Guard --> Agent[AgentRuntime Surface] + Guard --> Knowledge[Agent Knowledge Resolver] + Guard --> Tools[Tool Broker / ToolHub] + Guard --> Artifact[Artifact Store] + Guard --> Evidence[Evidence Store] + Guard --> Secrets[Secret Manager] + Guard --> Cleanup[Lifecycle / Cleanup Evidence] + Cloud[Lime Cloud Control Plane] --> Installer[Desktop Installer / Release Metadata] + Installer --> Guard +``` + +## 边界表 + +| 层 | 负责 | 不负责 | +|---|---|---| +| `@lime/app-sdk` | TypeScript facade、调用参数类型、稳定错误码、mock host。 | 不 import Lime internal path,不执行模型 / 工具。 | +| Host Bridge v1 | ready、snapshot、theme、visibility、toast、navigate、download、capability invoke 传输。 | 不绕过 readiness / permission / policy,不返回假成功。 | +| Capability Router | 校验 appId、entryKey、origin、source、capability allowlist、requestId。 | 不拥有业务 UI,不做垂直业务逻辑。 | +| AgentRuntime Surface | App-scoped task、stream、cancel、retry、trace、evidence。 | 不把 Claw Chat 复制一套,不让 App 直接拿模型 API。 | +| App runtime package | UI、workflow、storage schema、业务状态和结果确认。 | 不保存 secret 明文,不访问宿主文件系统 / DB / Tauri API。 | +| Lime Cloud / LimeCore | catalog、release、tenant enablement、license、policy defaults。 | 不默认运行 Agent,不渲染 App UI,不接管本地 storage。 | + +## Typed Capability Matrix + +P18 的最小 SDK 不追求能力全覆盖,而是先固定调用语义、错误语义和 mock 语义。 + +| Capability | 最小调用 | P18 口径 | +|---|---|---| +| `lime.ui` | `toast`、`navigate`、`openExternal`、`download`、`getSnapshot` | 走 Host Bridge action;外链 / 下载必须校验协议、origin、权限。 | +| `lime.storage` | `table.get`、`table.insert`、`table.update`、`table.query` | 只写 App namespace;自动附加 provenance;schema 不匹配返回 stable error。 | +| `lime.files` | `pick`、`readRef` | 只接收 host file ref,不暴露 raw path。 | +| `lime.agent` | `startTask`、`streamTask`、`getTask`、`cancelTask`、`retryTask`、`submitHostResponse`、`listTasks` | 映射到 AgentRuntime Surface;不跳通用 Chat;P18.4 已补 typed adapter,P18.4-H 已对齐 handoff 运行证据。 | +| `lime.knowledge` | `search`、`bindStatus` | 只能访问 manifest / setup 声明的 Knowledge binding。 | +| `lime.tools` | `invoke`、`getProgress` | 通过 Tool Broker / ToolHub;policy 和 secret handle 由 Host 管。 | +| `lime.artifacts` | `create`、`open`、`export` | artifact 带 appId、entryKey、packageHash、manifestHash。 | +| `lime.workflow` | `start`、`checkpoint`、`awaitHuman` | P18 仍是 typed workflow;raw worker 进入后续 gate。 | +| `lime.policy` | `check`、`requestPermission` | UI prompt 不是最终授权;bridge 层必须强制。 | +| `lime.secrets` | `getRef`、`requestBinding` | App 只能拿 scoped handle,不能拿 secret value。 | +| `lime.evidence` | `record`、`linkArtifact` | 记录 task、tool、knowledge、artifact、eval 和 cleanup provenance。 | + +## Bridge Envelope + +Host Bridge 继续采用 v1 标准信封: + +```ts +interface LimeAgentAppBridgeMessage { + protocol: "lime.agentApp.bridge"; + version: 1; + type: string; + requestId?: string; + appId: string; + entryKey?: string; + payload?: unknown; +} +``` + +P18 在 `capability:invoke` 内固定 SDK 调用信封: + +```ts +interface LimeCapabilityInvokeRequest { + capability: string; + method: string; + args: unknown; + idempotencyKey?: string; + expectedSchema?: unknown; + provenance?: { + appId: string; + entryKey: string; + packageHash: string; + manifestHash: string; + }; +} + +type LimeCapabilityInvokeResponse = + | { ok: true; value: unknown; traceId?: string; evidenceId?: string } + | { ok: false; error: LimeCapabilityError }; +``` + +稳定错误码首版: + +| 错误码 | 含义 | +|---|---| +| `capability_unavailable` | Host 不支持该 capability / method。 | +| `readiness_blocked` | App / entry readiness 未通过。 | +| `permission_denied` | 用户或租户未授权。 | +| `policy_denied` | 企业策略、成本、网络或工具策略阻断。 | +| `schema_invalid` | 请求参数或结果 schema 不匹配。 | +| `source_unverified` | package / manifest / provenance 不可信。 | +| `secret_required` | 缺必需 secret binding。 | +| `timeout` | 调用超时。 | +| `cancelled` | 用户或 Host 取消。 | +| `conflict` | 幂等键、版本或并发状态冲突。 | +| `upstream_failed` | 底层服务失败但边界仍完整。 | + +## 时序图:SDK 初始化 + +```mermaid +sequenceDiagram + participant App as App UI + participant SDK as @lime/app-sdk + participant Bridge as Host Bridge + participant Host as Lime Host + + App->>SDK: getLimeRuntime() + SDK->>Bridge: app:ready + Bridge->>Host: validate frame source / origin / appId / entryKey + Host-->>Bridge: host:snapshot(theme, locale, context, capability summary) + Bridge-->>SDK: host:snapshot + SDK-->>App: typed lime runtime handles +``` + +## 时序图:App 内 Agent Task + +```mermaid +sequenceDiagram + participant User as 用户 + participant App as Agent App 页面 + participant SDK as @lime/app-sdk + participant Host as Capability Router + participant Runtime as AgentRuntime Surface + participant Store as Storage / Artifact / Evidence + + User->>App: 点击生成内容策略 + App->>SDK: lime.agent.startTask(request) + SDK->>Host: capability:invoke(lime.agent.startTask) + Host->>Host: readiness / permission / policy / provenance + Host->>Runtime: start app-scoped task + Runtime-->>Host: taskId / traceId / stream events + Host-->>SDK: host:response + stream events + SDK-->>App: typed task stream + App->>User: 展示进度、引用、工具调用和待确认结果 + User->>App: 确认写回 + App->>SDK: lime.storage.update + lime.artifacts.create + lime.evidence.record + SDK->>Host: capability:invoke(write-back) + Host->>Store: write app namespace with provenance + Store-->>Host: artifactId / evidenceId + Host-->>App: 结构化写回完成 +``` + +## 流程图:Capability 调用裁决 + +```mermaid +flowchart TD + Start[capability:invoke] --> Msg{Bridge message valid?} + Msg -- no --> Drop[drop / debug record] + Msg -- yes --> Declared{Manifest declared capability?} + Declared -- no --> Unavailable[capability_unavailable] + Declared -- yes --> Ready{Entry readiness ok?} + Ready -- no --> Readiness[readiness_blocked] + Ready -- yes --> Permission{Permission granted?} + Permission -- no --> Denied[permission_denied] + Permission -- yes --> Policy{Policy allows?} + Policy -- no --> PolicyDenied[policy_denied] + Policy -- yes --> Schema{Schema valid?} + Schema -- no --> Invalid[schema_invalid] + Schema -- yes --> Execute[Call host service] + Execute --> Provenance[Attach provenance / trace / evidence] + Provenance --> Response[host:response] +``` + +## 用户故事 + +| 角色 | 用户故事 | 验收 | +|---|---|---| +| 业务用户 | 我在内容工厂页面内启动资料整理、文案生成和复盘,不需要跳回通用 Chat。 | App 内显示任务流、引用、错误、取消、重试和确认写回。 | +| App 开发者 | 我只依赖 `@lime/app-sdk` 类型和 mock host,就能本地测试 App 工作流。 | 不 import Lime internal path;mock / real host 的错误码一致。 | +| 平台维护者 | 我升级 storage、AgentRuntime 或 Tool Broker 时,不需要修改每个 App。 | SDK contract tests 通过;App 只感知兼容版本和稳定错误码。 | +| 企业管理员 | 我可以用 policy / tenant overlay 控制工具、secret、成本和 network。 | Bridge 层强制策略;UI 只展示结果,不承担最终授权。 | +| QA / 智能体 | 我能用 contract tests 和 GUI smoke 证明正式入口与 SDK 边界没有漂移。 | `src/features/agent-app` 无直接 `safeInvoke` / `invoke` / Tauri / raw Worker 越界。 | + +## 用例 + +1. **内容工厂批量文案**:App 调 `lime.agent.startTask`,流式展示资料引用、工具调用和结构化草稿;用户确认后写入 `content_assets` 表和 `content_table` Artifact。 +2. **知识绑定检查**:App 调 `lime.knowledge.bindStatus` / `search`,缺客户 Knowledge pack 时返回 `readiness_blocked` 或 `secret_required`,不落假数据。 +3. **工具调用**:App 调 `lime.tools.invoke` 生成搜索或分析结果;Tool Broker 负责权限、进度和证据。 +4. **受控下载**:App 调 `lime.ui.download` 导出报告;Host 校验同源 runtime asset / artifact permission 后执行。 +5. **失败退出**:App disable 或 uninstall rehearsal 后,SDK 调用返回 blocked,并可导出 cleanup evidence / residual audit。 + +## 实施拆分 + +| 阶段 | 目标 | 主要产物 | 验收 | +|---|---|---|---| +| P18.0 | v0.4 标准差距收口 | 对齐 `agentapp-ref@0.4.0` Host Bridge v1、SDK typed API、错误码 gap matrix。 | 文档 diff check;不改运行代码。 | +| P18.1 | SDK 类型与 schema | 已完成:`sdk` types、capability invoke envelope、stable error enum、mock host。 | `capabilityContract` + `MockCapabilityHost` SDK contract tests;`typecheck`。 | +| P18.2 | Host Bridge router | 已完成:capability invoke typed envelope、`args` / `input` 兼容、requestId / idempotency / expectedSchema / provenance 透传、stable error response。 | bridge router / dispatcher / runtime page tests;未知 capability 不写假成功。 | +| P18.3 | Core capability adapters | 已完成:`lime.ui`、`storage`、`artifacts`、`evidence`、`knowledge`、`tools` typed adapter facade。 | adapter contract tests、namespace / provenance tests、typecheck、contracts。 | +| P18.4 | App-scoped Agent Task | 已完成:`lime.agent.startTask` / stream / get / cancel / retry / submitHostResponse / listTasks typed adapter,并覆盖 `task:*`、`artifact:created`、`evidence:*` 事件契约。 | App task adapter contract tests;不回跳通用 Chat;不重复实现 runtime read model。 | +| P18.4-H | AgentRuntime handoff gate | 已完成:对齐隔壁 current MVP 与剩余缺口,新增消费侧 handoff checklist,并明确 push subscribe、workspace patch producer、capability catalog、GUI smoke owner。 | 只做 SDK 消费侧 gate,不抢 AgentRuntime Rust / TS 修改。 | +| P18.5 | 内容工厂 SDK 化回归 | 已完成 Lime-side SDK regression、Host Bridge SDK client contract 和 package-side 只读 `npm test`;内容工厂 task / write-back / evidence 可由通用 facade 表达,也可穿过 Host Bridge v1。 | 下一刀等待 package owner 稳定后做 P18.5.3 package-side SDK facade / verify;不复刻内容工厂后端能力。 | +| P18.6 | Raw worker 前 gate | 已完成:明确 worker sandbox、resource limit、network / secret policy 进入后续 P19,并记录 P18 不执行 raw worker 的证据。 | P18 不执行 raw worker。 | + +## P18.1 实施记录 + +P18.1 的最小目标是先固定 SDK contract,而不是把真实 AgentRuntime、ToolHub 或 storage adapter 全接完。本轮落地: + +- `src/features/agent-app/sdk/capabilityContract.ts`:新增 `LimeCapabilityContractMap`、typed invoke request / response、provenance envelope、success / error response helper、invoker 和 mock transport。 +- `src/features/agent-app/sdk/capabilityErrors.ts`:新增稳定错误码 `capability_unavailable / readiness_blocked / permission_denied / policy_denied / schema_invalid / source_unverified / secret_required / timeout / cancelled / conflict / upstream_failed`,并把旧边界错误映射到 stable error。 +- `src/features/agent-app/sdk/capabilityContract.test.ts`:覆盖 typed envelope、stable error mapping、mock transport success / blocked error。 +- `src/features/agent-app/sdk/MockCapabilityHost.test.ts`:补 mock host stable error parity 断言。 +- `src/features/agent-app/index.ts`:导出 P18.1 SDK contract 类型与 helper,便于后续 App runtime / contract tests 复用。 + +已验证: + +```bash +nice -n 10 npm test -- src/features/agent-app/sdk/capabilityContract.test.ts src/features/agent-app/sdk/MockCapabilityHost.test.ts +nice -n 10 npm run typecheck -- --pretty false +``` + +边界:P18.1 未修改 `src-tauri/*`、`src/lib/api/agentAppRuntime.ts`、`src/features/agent-app/runtime/agentRuntimeCapabilityHost*`、GUI smoke 脚本或 Cloud / LimeCore。 + +## P18.2 实施记录 + +P18.2 的最小目标是把 P18.1 typed contract 接到 Host Bridge router,并保持现有内容工厂 App 对 `response.result` 的兼容消费。本轮落地: + +- `src/features/agent-app/runtime/hostBridge.ts`:`capability:invoke` 构建 typed `LimeCapabilityInvokeRequest`,支持 `args` 新信封和 `input` 旧信封兼容,透传 `idempotencyKey / expectedSchema / provenance`。 +- `src/features/agent-app/runtime/hostBridge.ts`:Host success payload 固定为 `{ ok: true, value, result }`,其中 `value` 对齐 P18 typed response,`result` 保留给现有 App 消费。 +- `src/features/agent-app/runtime/hostBridge.ts`:capability error payload 固定为 `{ ok: false, error, code, message, causeCode }`,旧错误码只进入 `causeCode`,App 对外只依赖 stable lowercase code。 +- `src/features/agent-app/runtime/capabilityDispatcher.ts`:dispatcher 读取 `invokeRequest.args`,同时保留直接 `input` / legacy `args[0]` 兼容,不扩真实 AgentRuntime adapter。 +- `src/features/agent-app/runtime/hostBridge.test.ts`、`src/features/agent-app/runtime/capabilityDispatcher.test.ts`、`src/features/agent-app/ui/AgentAppRuntimePage.test.tsx`:补齐 typed envelope、stable error response 和现有 runtime page 兼容断言。 + +已验证: + +```bash +nice -n 10 npm test -- src/features/agent-app/sdk/capabilityContract.test.ts src/features/agent-app/sdk/MockCapabilityHost.test.ts src/features/agent-app/runtime/hostBridge.test.ts src/features/agent-app/runtime/capabilityDispatcher.test.ts src/features/agent-app/ui/AgentAppRuntimePage.test.tsx +nice -n 10 npm run typecheck -- --pretty false +nice -n 10 npm run test:contracts +git diff --check -- src/features/agent-app/sdk src/features/agent-app/runtime src/features/agent-app/ui/AgentAppRuntimePage.test.tsx src/features/agent-app/index.ts docs/roadmap/agentapp +rg -n "SceneApp|contentEngineering|sceneapp_|safeInvoke|invoke\\(|new Worker|Worker\\(" src/features/agent-app || true +``` + +边界:P18.2 未修改 `src-tauri/*`、`src/lib/api/agentAppRuntime.ts`、GUI smoke 脚本、Cloud / LimeCore,也不抢隔壁 AgentRuntime 的 Rust / facade / GUI 验证链路;`agentRuntimeCapabilityHost*` 的并行改动由隔壁任务负责。 + +## P18.3 实施记录 + +P18.3 的最小目标是先把 core capabilities 包成 App 可调用的 typed adapter facade,而不是新增第二套 store、ToolHub 或 UI runtime。本轮落地: + +- `src/features/agent-app/sdk/capabilityAdapters.ts`:新增 `createLimeCoreCapabilityAdapters`,把 `lime.ui / lime.storage / lime.artifacts / lime.evidence / lime.knowledge / lime.tools` 包成 typed facade。 +- `src/features/agent-app/sdk/capabilityAdapters.ts`:adapter 调用统一走 P18.1 `LimeCapabilityInvoker`,自动附加默认 provenance,并允许 per-call 覆盖 `requestId / idempotencyKey / expectedSchema / provenance`。 +- `src/features/agent-app/sdk/capabilityAdapters.ts`:storage adapter 暴露 `namespace`,但不在 SDK 层重复实现真实 storage;写入仍由 Host / adapter owner 决定。 +- `src/features/agent-app/sdk/capabilityAdapters.ts`:新增 `LimeCapabilityAdapterError`,把 mock / host 返回的 stable error 原样暴露给 App 调用方,避免把旧错误码重新变成业务判断。 +- `src/features/agent-app/sdk/capabilityAdapters.test.ts`:覆盖 ui / storage / artifact / evidence / knowledge / tools typed request、namespace、provenance 与 stable error parity。 +- `src/features/agent-app/index.ts`:导出 P18.3 adapter facade 与类型。 +- `src/features/agent-app/types.ts`:补齐 `AgentAppTaskRequest` 对 `queueIfBusy / skipPreSubmitResume / runStartHooks` 的可选类型声明,和隔壁 AgentRuntime API / host surface 保持一致,解除全局 typecheck 阻塞。 + +已验证: + +```bash +nice -n 10 npm test -- src/features/agent-app/sdk/capabilityAdapters.test.ts src/features/agent-app/sdk/capabilityContract.test.ts src/features/agent-app/sdk/MockCapabilityHost.test.ts src/features/agent-app/runtime/hostBridge.test.ts src/features/agent-app/runtime/capabilityDispatcher.test.ts src/features/agent-app/runtime/agentRuntimeCapabilityHost.test.ts src/features/agent-app/ui/AgentAppRuntimePage.test.tsx +nice -n 10 npm run typecheck -- --pretty false +nice -n 10 npm run test:contracts +git diff --check -- src/features/agent-app/sdk src/features/agent-app/index.ts docs/roadmap/agentapp +rg -n "SceneApp|contentEngineering|sceneapp_|safeInvoke|invoke\\(|new Worker|Worker\\(" src/features/agent-app || true +``` + +边界:P18.3 未新增真实 store、ToolHub、GUI smoke 脚本、Cloud / LimeCore,也未改 `agentRuntimeCapabilityHost*` 的实现逻辑;只对共享 `AgentAppTaskRequest` 做 additive 类型对齐。 + +## P18.4 实施记录 + +P18.4 的最小目标是把 `lime.agent` 暴露成 App 可依赖的 typed task adapter,而不是把 AgentRuntime read model、Rust facade 或 GUI smoke 验证链路搬进 SDK。本轮落地: + +- `src/features/agent-app/sdk/capabilityAdapters.ts`:新增 `LimeAgentCapabilityAdapter`,覆盖 `startTask / streamTask / getTask / cancelTask / retryTask / submitHostResponse / listTasks`。 +- `src/features/agent-app/sdk/capabilityAdapters.ts`:`createLimeCoreCapabilityAdapters` 新增 `agent` facade,统一走 P18.1 `LimeCapabilityInvoker` 和 P18.2 typed invoke envelope,自动带默认 provenance。 +- `src/features/agent-app/sdk/capabilityAdapters.test.ts`:补 P18.4 App-scoped task contract fixture,覆盖 `queueIfBusy / skipPreSubmitResume / runStartHooks` 透传、Host response、cancel / retry / list,以及 `task:queued / task:progress / task:missingContextRequested / task:reviewRequested / task:toolCall / artifact:created / evidence:recorded / evidence:verified / task:completed / task:incident` 事件语义。 +- `src/features/agent-app/index.ts`:导出 `LimeAgentCapabilityAdapter` 类型,便于 App SDK 使用方只依赖公开 facade。 + +已验证: + +```bash +nice -n 10 npm test -- src/features/agent-app/sdk/capabilityAdapters.test.ts src/features/agent-app/sdk/capabilityContract.test.ts src/features/agent-app/sdk/MockCapabilityHost.test.ts src/features/agent-app/runtime/hostBridge.test.ts src/features/agent-app/runtime/capabilityDispatcher.test.ts src/features/agent-app/runtime/agentRuntimeCapabilityHost.test.ts src/features/agent-app/ui/AgentAppRuntimePage.test.tsx +nice -n 10 npm run typecheck -- --pretty false +nice -n 10 npm run test:contracts +git diff --check -- src/features/agent-app/sdk src/features/agent-app/index.ts docs/roadmap/agentapp +rg -n "SceneApp|contentEngineering|sceneapp_|safeInvoke|invoke\\(|new Worker|Worker\\(" src/features/agent-app || true +``` + +边界:P18.4 未修改 `src-tauri/*`、`src/lib/api/agentAppRuntime.ts`、`src/features/agent-app/runtime/agentRuntimeCapabilityHost*`、GUI smoke 脚本、DevBridge / Tauri / Vite 进程、Cloud / LimeCore;真实 runtime push subscribe、workspace patch producer、capability catalog 和桌面 GUI smoke 仍归 P18.4-H / 隔壁 AgentRuntime 收口。 + +## P18.4-H 实施记录 + +P18.4-H 的最小目标是做 Agent App 消费侧 handoff gate,而不是抢隔壁 AgentRuntime owner。本轮落地: + +- `docs/roadmap/agentapp/p18-4-h-agentruntime-handoff-gate.md`:新增 prompt-to-artifact checklist,把 `lime.agent` facade、task request options、task event union、Host response、Artifact / Evidence refs、workspace patch、跨刷新恢复、no-private-bridge 和协作边界逐项映射到代码 / 测试 / 文档证据。 +- 明确不能由 P18 SDK 伪造完成的缺口:后端 push subscribe / runtime event 主动推送、真实 Skill / Agent 端到端写出 workspace patch artifact、独立 capability catalog service、真实桌面 GUI smoke。 +- 将 P18 下一刀从 P18.4-H 切到 P18.5:内容工厂 SDK 化回归,验证现有 content-factory package 只依赖 SDK facade 完成 App 内业务闭环。 + +已验证: + +```bash +git diff --check -- docs/roadmap/agentapp +``` + +边界:P18.4-H 未修改 `src-tauri/*`、`src/lib/api/agentAppRuntime.ts`、`src/features/agent-app/runtime/agentRuntimeCapabilityHost*`、GUI smoke 脚本、DevBridge / Tauri / Vite 进程、Cloud / LimeCore。 + +## P18.5 实施记录 + +P18.5 的最小目标是证明内容工厂 App 可以只依赖 Lime 公开 SDK facade 完成 App 内业务闭环。本阶段先做不打架的 Lime-side 回归和 Host Bridge SDK client 目标收敛: + +- `src/features/agent-app/sdk/contentFactorySdkRegression.test.ts`:新增内容工厂 SDK contract regression,模拟 `content.copy.generate` task、`task:missingContextRequested` Host response、`artifact:created` / `evidence:recorded` stream、`contentFactoryWorkspacePatch` payload、`lime.storage.set`、`lime.artifacts.create(content_batch)`、`lime.evidence.record(fact_grounding)`。 +- `src/features/agent-app/sdk/hostBridgeClient.ts`:新增标准 Host Bridge SDK client,覆盖 `capability:invoke`、ready / snapshot / theme / visibility、toast / navigate / openExternal / download、`capability:subscribe / event / unsubscribe`、trusted origin、timeout、pending cleanup 和 stable error unwrap。 +- `src/features/agent-app/sdk/hostBridgeClient.test.ts`:证明内容工厂主链可以穿过 Host Bridge v1,并覆盖 Host action / Host event / subscription contract。 +- `src/features/agent-app/sdk/index.ts` 与 `src/features/agent-app/sdk/publicSdkSurface.test.ts`:新增 SDK-only public surface,固定后续正式 `@lime/app-sdk` / package-local shim 的窄导出清单,避免从 feature 总入口误导出 UI、installer、repository 或 runtime host 内部实现。 +- `src/features/agent-app/index.test.ts`:固定 feature public entry 导出 `createLimeCoreCapabilityAdapters`、`createLimeHostBridgeCapabilityInvoker`、`LIME_AGENT_APP_BRIDGE_PROTOCOL` 与 `LIME_AGENT_APP_BRIDGE_VERSION`,防止外部 package 后续只能依赖深路径。 +- `src/features/agent-app/runtime/hostBridge.ts`:仅做 `AgentAppTaskSubscription.timerId` 浏览器 timer 类型修正,解除 `typecheck` 阻塞,不改 task subscription runtime 行为。 +- `docs/roadmap/agentapp/p18-5-content-factory-sdk-regression.md`:新增 P18.5 记录,说明 Lime-side regression 和 package-side read-only tests 已完成,并明确外部 `/Users/coso/Documents/dev/ai/limecloud/content-factory-app` 当前 dirty,暂不跨仓库改 package-side bridge wrapper。 +- `docs/roadmap/agentapp/p18-5-3-package-sdk-migration-plan.md`:新增外部 package 迁移计划,固定推荐写集、迁移步骤、验收清单和回滚点。 +- `docs/roadmap/agentapp/p18-completion-audit.md`:新增 completion audit,记录 P18.1-P18.6 证据、外部 package 阻塞、完整 `verify:local` 已通过和下一刀判定。 + +已验证: + +```bash +nice -n 10 npm test -- src/features/agent-app/sdk/contentFactorySdkRegression.test.ts src/features/agent-app/sdk/capabilityAdapters.test.ts src/features/agent-app/sdk/capabilityContract.test.ts src/features/agent-app/sdk/MockCapabilityHost.test.ts src/features/agent-app/runtime/hostBridge.test.ts src/features/agent-app/runtime/capabilityDispatcher.test.ts src/features/agent-app/runtime/agentRuntimeCapabilityHost.test.ts src/features/agent-app/ui/AgentAppRuntimePage.test.tsx +nice -n 10 npm test -- src/features/agent-app/sdk/hostBridgeClient.test.ts src/features/agent-app/sdk/contentFactorySdkRegression.test.ts src/features/agent-app/sdk/capabilityAdapters.test.ts src/features/agent-app/sdk/capabilityContract.test.ts src/features/agent-app/sdk/MockCapabilityHost.test.ts src/features/agent-app/runtime/hostBridge.test.ts src/features/agent-app/runtime/capabilityDispatcher.test.ts src/features/agent-app/runtime/agentRuntimeCapabilityHost.test.ts src/features/agent-app/ui/AgentAppRuntimePage.test.tsx +nice -n 10 npm run typecheck -- --pretty false +nice -n 10 npm run test:contracts +nice -n 10 npm test -- src/features/agent-app/index.test.ts +nice -n 10 npm test -- src/features/agent-app/sdk/hostBridgeClient.test.ts src/features/agent-app/sdk/contentFactorySdkRegression.test.ts src/features/agent-app/index.test.ts +nice -n 10 npm run verify:local +cd /Users/coso/Documents/dev/ai/limecloud/content-factory-app && nice -n 10 npm test +git diff --check -- src/features/agent-app/sdk src/features/agent-app/runtime/hostBridge.ts src/features/agent-app/index.ts src/features/agent-app/index.test.ts docs/roadmap/agentapp +rg -n "SceneApp|contentEngineering|sceneapp_|safeInvoke|invoke\\(|new Worker|Worker\\(" src/features/agent-app || true +``` + +2026-05-16 09:22 当前会话补充复核: + +- Lime-side SDK seam 最小集:`hostBridgeClient.test.ts`、`contentFactorySdkRegression.test.ts`、`index.test.ts`,3 files / 8 tests passed。 +- Agent App feature island 全量定向测试:`npm test -- src/features/agent-app`,35 files / 173 tests passed。 +- 外部 `content-factory-app` 只读 `npm test`:46 tests passed。 +- Lime `typecheck` 与 `test:contracts`:均通过。 +- 外部 package 仍有 39 个 dirty / untracked 条目;`src/ui/host-bridge.js` 仍包含 `pendingRequests / buildMessage / window.parent.postMessage / requestHostBridge / capability:invoke / capability:subscribe / capability:event`,未出现 `createLimeHostBridgeCapabilityInvoker` 或 `createLimeCoreCapabilityAdapters`。 + +边界:P18.5.1 / P18.5.2 / P18.5-S 未修改外部 content-factory package、`src-tauri/*`、`src/lib/api/agentAppRuntime.ts`、`src/features/agent-app/runtime/agentRuntimeCapabilityHost*`、GUI smoke 脚本、DevBridge / Tauri / Vite 进程、Cloud / LimeCore;`hostBridge.ts` 仅有 timer 类型修正用于恢复 `typecheck`,`index.test.ts` 只固定 public SDK export seam。后续 P18.5.3 已在 10:55 完成真实 package verify / dist 同步;完整 `verify:local` 已于 2026-05-16 07:33 端到端通过,不再是当前阻塞。 + +## P18.6 实施记录 + +P18.6 的目标是关闭 P18 阶段对 raw worker 的误解:P18 只提供受控 workflow DSL 和 typed capability SDK,不执行 App package 自带 raw worker bundle。本轮落地: + +- `docs/roadmap/agentapp/p18-6-raw-worker-pre-gate.md`:新增 raw worker 前 gate,映射 runtime policy、workflow runtime、runtime package loader、entry runtime guard、feature flag 和 feature-island boundary scan 证据。 +- 明确 raw worker sandbox、resource limits、network policy、secret policy、worker storage / artifact / evidence 写回和 GUI / security tests 全部后移到 P19 以后。 + +已验证: + +```bash +nice -n 10 npm test -- src/features/agent-app/runtime/workflowRuntimeHost.test.ts src/features/agent-app/featureFlag.test.ts src/features/agent-app/runtime/runtimePackageLoader.test.ts src/features/agent-app/runtime/entryRuntimeGuard.test.ts +rg -n "SceneApp|contentEngineering|sceneapp_|safeInvoke|invoke\\(|new Worker|Worker\\(" src/features/agent-app || true +``` + +边界:P18.6 未新增 worker runtime、未执行 raw worker、未新增 network / filesystem / secret 通道,也未修改 Cloud / LimeCore / Tauri command。 + +## 验收标准 + +- App UI / workflow 只能通过 `@lime/app-sdk` / Host Bridge 调 Lime 能力。 +- 每个 capability 调用都有类型、schema、mock、稳定错误码和 contract test。 +- `capability:invoke` 统一经过 manifest 声明、entry readiness、permission、policy、provenance。 +- `lime.agent` / `lime.workflow` 进入 AgentRuntime Surface,不返回无 provenance 的模型文本。 +- storage、artifact、evidence、task、trace、secret binding 都按 app namespace 隔离。 +- P17.5 正式入口 smoke 通过后,P18 新增能力不降低 install / launch / disable / uninstall rehearsal / flag-off 证据。 +- feature island 仍不直接出现 `safeInvoke` / `invoke(` / `tauri::` / `generate_handler` / raw `Worker(` 越界;2026-05-16 08:33 当前会话重跑边界扫描仍无输出。 + +## 最小验证命令 + +P18.0 文档阶段: + +```bash +git diff --check -- docs/roadmap/agentapp +rg -n "SceneApp|contentEngineering|sceneapp_|safeInvoke|invoke\\(|new Worker|Worker\\(" src/features/agent-app || true +``` + +P18 代码阶段按改动扩大: + +```bash +npm run test:contracts +npm run typecheck +npm run smoke:agent-apps -- --timeout-ms 300000 --interval-ms 1000 +npm run verify:gui-smoke -- --reuse-running --timeout-ms 300000 +``` + +如果 P18 引入或修改 Tauri command,必须同步前端 gateway、Rust handler、治理目录册和 mock priority,并执行 `npm run test:contracts`。 + +## 风险与应对 + +| 风险 | 影响 | 应对 | +|---|---|---| +| SDK 过厚,变成 Lime internal API 重导出。 | App 绑定内部实现,升级成本高。 | 只暴露 capability facade;禁止 internal import。 | +| SDK 过薄,App 重复造底座。 | 每个 App 重复做 storage、tool、evidence。 | P18 优先封装高频底座和稳定错误语义。 | +| Bridge 被当成私有协议滥用。 | 每个 App 自定义 postMessage,无法治理。 | App 作者只调用 SDK;Host Bridge 保持标准 v1 信封。 | +| Agent task 退化为模型 API。 | 丢失 trace、tool、knowledge、evidence。 | `lime.agent` 必须走 AgentRuntime Surface。 | +| P18 把 P17.5 证据扩大解释。 | 误以为已可发布 marketplace / Cloud 管理台。 | P17.5 只证明正式入口 smoke;P18 仍按 SDK gate 最小面推进。 | +| 与隔壁任务改同一文件。 | 冲突或覆盖。 | P18 不改 AgentRuntime Rust 投影和运行中 GUI 验证链路。 | + +## 决策点 + +| 决策点 | 时间 | 判断问题 | Go 条件 | No-Go 动作 | +|---|---|---|---|---| +| D17 | P17.5 后 | 正式入口 smoke 是否稳定? | 已通过:独立 `smoke:agent-apps` 输出 summary。 | 如后续回归失败,不继续扩大 SDK,先修正式入口。 | +| D18 | P18.2 后 | Host Bridge typed router 是否足够稳定? | 已通过:source / origin / appId / entryKey / requestId / typed envelope / stable error mapping 均有测试。 | 如回归失败,暂停 capability adapter 扩展,先修 router。 | +| D19 | P18.4 后 | App-scoped Agent task 是否真正进入 AgentRuntime Surface? | task / trace / evidence 可追溯,App 内可 stream / cancel / retry / submitHostResponse / write-back,并完成 P18.4-H handoff gate。 | 不开放更多 workflow / worker 能力。 | +| D20 | P18 完成 | SDK 是否足够支撑更多 Agent App? | types / schema / mock / contract / GUI smoke 均有证据,且外部 `content-factory-app` 已从手写 bridge wrapper 收敛到标准 SDK facade,并完成 package verify / Lime regression。 | 已满足;进入 owner handoff / 提交边界,不进入 raw worker 或 marketplace。 | + +## 下一刀口径 + +P18.0 文档计划、P18.1 SDK contract、P18.2 Host Bridge typed router、P18.3 Core capability adapters、P18.4 App-scoped Agent task SDK facade、P18.4-H AgentRuntime handoff gate、P18.5.1 Lime-side 内容工厂 SDK 回归、P18.5.2 package-side read-only tests、P18.5-S Host Bridge SDK client、P18.5.3 package-side SDK facade / verify / dist 同步与 P18.6 Raw Worker 前 Gate 已完成,完整 `verify:local` 已于 2026-05-16 07:33 端到端通过,2026-05-16 10:53 SDK seam / handoff core 定向测试 5 files / 17 tests passed,`typecheck`、`test:contracts` 与 `lint` 当前会话复核通过。下一刀只做 owner handoff / 提交边界,不继续扩大 P18 功能面。 + +P18.5 允许范围: + +- `src/features/agent-app/sdk/*`:如内容工厂 package 消费 SDK 时发现 facade 缺口,只补通用 SDK contract,不写垂直业务 adapter。 +- `src/features/agent-app/runtime/*test.ts`、`ui/*test.tsx`:只补 App 内闭环消费侧回归,不改 runtime owner。 +- `/Users/coso/Documents/dev/ai/limecloud/content-factory-app`:仅在 package owner 稳定且确认不覆盖隔壁改动后,做 package-side SDK facade / verify。 +- `docs/roadmap/agentapp/*`:同步记录内容工厂 SDK 化证据和剩余缺口。 + +P18.5 暂不触碰: + +- `src-tauri/*`、`src/lib/api/agentAppRuntime.ts`、`src/features/agent-app/runtime/agentRuntimeCapabilityHost*`。 +- GUI smoke 脚本、DevBridge / Tauri / Vite 进程、Cloud / LimeCore。 +- raw Worker、marketplace、真实 delete-data、垂直内容工厂专用命令。 diff --git a/docs/roadmap/agentruntime/README.md b/docs/roadmap/agentruntime/README.md index c3d90b6d8..ab242983a 100644 --- a/docs/roadmap/agentruntime/README.md +++ b/docs/roadmap/agentruntime/README.md @@ -21,6 +21,7 @@ - [./architecture.md](./architecture.md):分层、事实源、接口与 profile 映射 - [./adjacent-protocols.md](./adjacent-protocols.md):`agentcontext`、`agentevidence`、`agentpolicy`、`agentui` 的 owner 边界与连接合同 - [./app-surface-runtime.md](./app-surface-runtime.md):Agent App 如何作为业务 surface 复用 AgentRuntime / Claw 主链 +- [./agent-app-runtime-completion-audit.md](./agent-app-runtime-completion-audit.md):Agent App Runtime / 内容工厂闭环的完成审计、证据和剩余缺口 - [./claw-capability-sharing.md](./claw-capability-sharing.md):Claw `@` 能力如何抽象为 Chat、Agent App、Automation 可共享 capability - [./backend-surface-facade-plan.md](./backend-surface-facade-plan.md):Agent App runtime command 与共享后端 surface facade 计划 - [./diagrams.md](./diagrams.md):架构图、流程图、时序图 @@ -104,7 +105,7 @@ Objective / User Input 3. `agent_runtime_export_evidence_pack` 及其 replay / analysis / review 派生物。 4. `TaskProfile / RoutingDecision / LimitState` 已接入的模型路由事实。 5. Workspace / Harness GUI 对 runtime read model 的只读投影。 -6. Agent App Runtime Surface 作为新调用面,委托 AgentRuntime 主链执行 App-scoped task;`AgentAppRuntimePage` 的 Host Bridge `lime.agent` 已接入 `agent_app_runtime_*` facade,并支持 App 内响应 ask / elicitation / tool confirmation。 +6. Agent App Runtime Surface 作为新调用面,委托 AgentRuntime 主链执行 App-scoped task;`AgentAppRuntimePage` 的 Host Bridge `lime.agent` 已接入 `agent_app_runtime_*` facade,并支持 App 内响应 ask / elicitation / tool confirmation。Host Bridge 已支持 task subscription first-cut,通过 `capability:subscribe / capability:event` 把 Host 侧 `getTask` 轮询结果推回 App iframe;订阅时也会监听 `agent_app_runtime:{appId}:{taskId}` Tauri / DevBridge runtime event,把后端 runtime event 直接转发给 App。AgentRuntime profile event 生成处现在会在同名 event bus 主动追加 `agent_app_runtime:profileProjection` payload,把 `turn.* / tool.* / action.* / routing.* / model.*` 转成 App canonical `taskEvents`;高价值 `RuntimeAgentEvent` 也会追加 `agent_app_runtime:runtimeEventProjection`,其中 artifact 事件可直接携带 `workspacePatch / contentFactoryWorkspacePatch`,runtime event / timeline metadata 中显式存在的 `evidenceRefs / verificationOutcomes` 也会被主动投影为 `evidence:recorded / evidence:verified`;Evidence Pack / analysis / review / save review 导出成功后也会按 Agent App scope 主动 emit `agent_app_runtime:harnessExportProjection`,把导出 root、制品和 completion audit completed 事实投影为 App task events;成功终态如果暂未带 workspace patch,则继续短轮询 replay 最终 artifact。`AgentRuntimeCapabilityHost` 也会从 `threadRead.artifacts` 补投 `artifact:created` payload。Host dispatcher 已补 high-level manifest capability gate,未声明 `lime.agent` 等 Host capability 会被拒绝;Claw capability hint 还会校验 manifest `toolRefs[].capabilities` allowlist,避免 App 只声明一个 catalog key 后任意启动 Claw 能力。内容工厂实际 App 已把主生产结果和确认链结果写回 `lime.storage / lime.artifacts / lime.evidence`;AgentRuntime 原生 `FileArtifact` 已投影为 `artifact:created`,runtime task state 也已通过 Agent App storage 做跨刷新恢复第一刀;内容工厂 task 已在 runtime message 与 `harness.agent_app_runtime_output_contract` 写明 `contentFactoryWorkspacePatch / workspacePatch` producer contract,真实宿主 iframe 已验证最终 patch 可物化到 App 页面;多个 capability hint 会被投影到 `harness.agent_app_runtime_capability_workflow`,复合 output contract 任务保持 `metadata_only`,不强行启动单一 Claw Skill。 7. Claw Capability Catalog 作为已实现 `@` 能力的复用索引,后续供 Chat、Agent App、Automation 共用。 ### compat diff --git a/docs/roadmap/agentruntime/agent-app-runtime-completion-audit.md b/docs/roadmap/agentruntime/agent-app-runtime-completion-audit.md new file mode 100644 index 000000000..778c8e984 --- /dev/null +++ b/docs/roadmap/agentruntime/agent-app-runtime-completion-audit.md @@ -0,0 +1,131 @@ +# Agent App Runtime 完成审计 + +> 状态:current-harness-export-projection-rust-verified +> 更新时间:2026-05-16 +> 审计目标:验证“Agent App 在业务 App 内完成 AI Agent 工作,且复用 Lime 完整 AgentRuntime / Claw / Tools / Evidence 能力,不跳回通用 Chat、不退化成模型 API 壳”是否已经真正完成。 + +最新验收结论:内容工厂真实 Lime 宿主 iframe 已跑通“写文案 -> AgentRuntime hidden session -> artifactDocument JSON patch -> Host Bridge artifact replay -> App 页面物化”的主路径,并补上 App 端产物 contract 验收与一次 continuation。最新实现会按 `minimumCount / minimumScriptCount / minimumImagePromptCount` 校验结构化 patch;不足时先物化可审核草稿,再以 `content_factory.copy.continue` 重新进入 Lime AgentRuntime 补齐,且不新增垂直后端命令。Host Bridge subscription 已在成功终态后短轮询 replay 最终 artifact,`AgentRuntimeCapabilityHost` 也会从 `threadRead.artifacts` 补投 `artifact:created` payload;订阅还会监听 `agent_app_runtime:{appId}:{taskId}` Tauri / DevBridge runtime event,把后端 runtime event 直接转发给 App。AgentRuntime profile 事件现在会在同名 `agent_app_runtime:{appId}:{taskId}` event bus 上主动追加 `agent_app_runtime:profileProjection` payload,并携带 canonical `taskEvents`;高价值 `RuntimeAgentEvent` 也会主动追加 `agent_app_runtime:runtimeEventProjection`,其中 `ArtifactSnapshot / FileArtifact` 可直接携带 `workspacePatch / contentFactoryWorkspacePatch`;显式写在 runtime event / timeline metadata 中的 `evidenceRefs / verificationOutcomes` 也会通过 `runtime_evidence_projection_service` 投影为 `evidence:recorded / evidence:verified`。本轮进一步把 `agent_runtime_export_evidence_pack`、`agent_runtime_export_analysis_handoff`、`agent_runtime_export_review_decision_template` 与 `agent_runtime_save_review_decision` 接入同一个 `agent_app_runtime:{appId}:{taskId}` event bus:当 hidden session 的 `runtime_summary.surface=agent_app` 且带 `appId/taskId` 时,导出结果会主动 emit `agent_app_runtime:harnessExportProjection`,把 Evidence Pack root、analysis/review 制品和 completion audit completed 事实投影为 App canonical `taskEvents`。因此 App 不再只能等 `getTask` 副作用获得运行进度、artifact patch、已存在的 evidence refs 或 Harness 导出结果。Rust facade 现在会把多个 capability hint 去重后写入 `agent_app_runtime.capability_workflow` 与 `harness.agent_app_runtime_capability_workflow`;内容工厂复合 output contract 使用 `metadata_only`,不会被强行改写成单一 Claw Skill。Host dispatcher 现在会校验 manifest `toolRefs[].capabilities` allowlist,防止 App 只声明 catalog key 后任意请求 Claw capability。Playwright 中已验证真实 iframe 可通过 replay 回写并显示 `文案=20 / 脚本=6 / 图片需求=5`,控制台 error 为 0。 + +## 1. 目标拆解 + +整体目标可落成以下交付标准: + +1. **边界清晰**:Lime Agent 负责运行事实、工具、权限、证据和 Claw 能力复用;App 负责业务 UI、表单、项目状态、资料版本、产物展示和人工确认。 +2. **App 内闭环**:内容工厂等 App 不跳回 Lime 通用 Chat;用户在 App 页面内发起任务、看进度、补上下文、确认、取消、重试和保存结果。 +3. **完整 AgentRuntime**:App 的 AI 任务走 `agent_app_runtime_* -> agent_runtime_submit_turn` current 主链,而不是只拿 `LIME_GATEWAY_*` 低阶模型 API。 +4. **Claw 能力复用**:搜索、图片、研报、PDF、总结等能力通过 capability hints / catalog 复用现有 Claw skill launch metadata,不复制 `*_skill_launch.rs`。 +5. **产物和证据落库**:任务结果、人工确认、运行事件、artifact 和 evidence 能回投到 App 页面与 `lime.storage / lime.artifacts / lime.evidence`。 +6. **可恢复**:刷新或 Host 重建后,App 仍可凭 `taskId` 找回 `sessionId / turnId` 并继续读取任务或提交 Host response。 +7. **可验证**:命令四侧、前端 Host Bridge、Rust facade、内容工厂包、文档事实源都有对应测试或检查。 + +## 2. Prompt-to-artifact 检查表 + +| 明确要求 / 交付项 | 当前状态 | 证据 | 审计结论 | +| --- | --- | --- | --- | +| 更新 `docs/roadmap/agent_runtime` 相关文档,特别是 Lime Agent / App 边界 | done | `docs/roadmap/agentruntime/app-surface-runtime.md`、`docs/roadmap/agentruntime/backend-surface-facade-plan.md`、`docs/roadmap/agentruntime/claw-capability-sharing.md` | 文档已固定 Agent App 是 business surface,AgentRuntime 是运行事实主链,Claw 是 capability 来源。 | +| 后端不能只给模型 API,必须给完整 Lime AI 能力 | done/current | `src-tauri/src/commands/agent_app_runtime_cmd.rs`、`src/lib/api/agentAppRuntime.ts` | 新增 `agent_app_runtime_start_task / cancel / get / submit_host_response` facade,进入 AgentRuntime 主链。 | +| Agent App Runtime 隐藏 session 不能因 Provider 未配置而 accepted 后长期 idle | done/current-verified | `src-tauri/src/commands/agent_app_runtime_cmd.rs`、Playwright 最新内容工厂主生产流 | `startTask` 支持显式 `providerPreference / modelPreference`,并按 request -> 最近 successful `agent_runs` -> enabled provider catalog -> 当前 Agent state 回填模型偏好,避免普通 Chat / smoke 留下的不可用 current state 污染 Agent App hidden session;如果新建隐藏 session 且仍无法解析模型,会在 start 阶段返回明确错误。最新真实宿主任务已选中 `deepseek / deepseek-v4-flash` 并完成。 | +| 不新增垂直 `content_factory_*` 后端 Agent 命令 | done | `docs/aiprompts/commands.md`、`docs/roadmap/agentruntime/backend-surface-facade-plan.md`、`npm run test:contracts` | 内容工厂以 `taskKind` 表达业务任务,没有新增垂直 Tauri Agent 命令。 | +| 不能复制 Claw `*_skill_launch.rs`,要复用能力 | done/current-gated | `src-tauri/src/services/agent_app_runtime_capability_catalog_service.rs`、`src-tauri/src/commands/agent_app_runtime_cmd.rs`、`src/features/agent-app/runtime/capabilityDispatcher.ts` | 首批 `image / cover / research / report / pdf / summary` hint 已经通过独立 service 映射到现有 Claw launch metadata;多个 capability 会进入 workflow metadata,内容工厂复合任务保持 `metadata_only`,不复制也不强制单 skill;Host dispatcher 会按 manifest `toolRefs[].capabilities` allowlist 拒绝未声明 Claw hint。仍缺后端/cross-surface policy owner 和真正多能力执行编排。 | +| Agent App Host Bridge 的 `lime.agent` 接入真实 facade | done | `src/features/agent-app/runtime/agentRuntimeCapabilityHost.ts`、`src/features/agent-app/ui/AgentAppRuntimePage.tsx`、`src/features/agent-app/runtime/agentRuntimeCapabilityHost.test.ts` | `startTask / streamTask / getTask / cancelTask / retryTask / submitHostResponse / listTasks` 已走 AgentRuntime facade;本地 adapter 只保留离线与非 Agent 样板。 | +| App 可响应缺上下文、人工确认、工具授权 | done/first-cut | `src/features/agent-app/runtime/agentRuntimeCapabilityHost.ts`、`src-tauri/src/commands/agent_app_runtime_cmd.rs` | Host response 已映射到 `agent_runtime_respond_action`,带 `session_id / turn_id / request_id`。 | +| AgentRuntime snapshot / profile / runtime event 能投影给 App | done/current-evidence-projection-first-cut | `src-tauri/src/commands/agent_app_runtime_cmd.rs`、`src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs`、`src-tauri/src/services/runtime_evidence_projection_service.rs`、`src/lib/api/agentAppRuntime.ts`、`src/features/agent-app/types.ts` | `getTask` snapshot 覆盖 queued / progress / missing context / review / tool call / artifact created / evidence / outcome / incident;AgentRuntime profile event 会主动产出 `agent_app_runtime:profileProjection`,高价值 `RuntimeAgentEvent` 会主动产出 `agent_app_runtime:runtimeEventProjection`,其中 artifact 事件可直接携带 workspace patch;runtime event / timeline metadata 中已存在的 `evidenceRefs / verificationOutcomes` 会被同一 extractor 投影为 `evidence:recorded / evidence:verified`;`idle` 不再投影为误导性“任务状态:idle”文案。 | +| Host Bridge task subscription | done/current-runtime-event-projection-first-cut | `src/features/agent-app/runtime/hostBridge.ts`、`src/features/agent-app/runtime/hostBridge.test.ts`、内容工厂 `src/ui/host-bridge.js` / `src/ui/app.js` | `capability:subscribe / capability:unsubscribe / capability:event` 已可让 Host 侧轮询 `lime.agent.getTask` 并把 task update 推回 iframe;订阅同时监听 `agent_app_runtime:{appId}:{taskId}` Tauri / DevBridge runtime event,直接推送后端 runtime event、profile projection 与 runtime event projection `taskEvents`;成功终态如果暂未带 workspace patch,会继续短轮询最多 4 次等待 artifact replay。 | +| Evidence Pack / analysis / review 导出结果主动进入 App projection | done/current-rust-verified | `src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs`、`src-tauri/crates/agent/src/session_execution_runtime.rs`、`CARGO_INCREMENTAL=0 CARGO_TARGET_DIR="/tmp/lime-agent-app-output-contract-target" cargo test --manifest-path "src-tauri/Cargo.toml" -p lime agent_app_runtime --lib` | `runtime_summary` 已保留 Agent App scope,Harness 导出命令会在导出成功后按 `agent_app_runtime:{appId}:{taskId}` emit `agent_app_runtime:harnessExportProjection`;Evidence Pack root 会投影为 `evidence:recorded`,completion audit 为 `completed` 时才投影 `evidence:verified`,analysis/review 制品投影为 `artifact:created`,不伪造验证结论。 | +| AgentRuntime artifact 能进入 App event refs | done | `src-tauri/src/commands/aster_agent_cmd/dto.rs`、`src-tauri/src/commands/agent_app_runtime_cmd.rs`、`src/features/agent-app/runtime/agentRuntimeCapabilityHost.test.ts` | `FileArtifact -> artifact:created -> AgentAppTaskStreamEvent.refs` 已有 Rust 与前端测试。 | +| AgentRuntime artifact metadata 能作为 workspace patch producer contract | done/current-artifact-replay-first-cut | `src-tauri/src/commands/agent_app_runtime_cmd.rs`、`src/features/agent-app/runtime/agentRuntimeCapabilityHost.ts`、`src/features/agent-app/runtime/agentRuntimeCapabilityHost.test.ts` | 当 runtime artifact metadata 或 artifactDocument blocks 携带 `workspacePatch` / `contentFactoryWorkspacePatch`,Rust projection、`AgentRuntimeCapabilityHost` 和 Host Bridge replay 都会补投 `artifact:created` payload,稳定输出 `workspacePatch` 与 `contentFactoryWorkspacePatch` 给 App。 | +| 跨刷新恢复 task/session | done/first-cut | `src/features/agent-app/runtime/agentRuntimeCapabilityHost.ts`、`src/features/agent-app/runtime/agentRuntimeCapabilityHost.test.ts` | runtime task state 写入 Agent App storage;Host 重建后可恢复 `sessionId / turnId` 并继续读取/响应。 | +| 内容工厂实际 App 接入 Host Bridge | done/first-cut | `/Users/coso/Documents/dev/ai/limecloud/content-factory-app/src/ui/host-bridge.js`、`src/ui/app.js`、`tests/ui.test.mjs` | 场景、文案、重写、脚本、确认链会先启动 Lime Agent 任务,并保留本地兜底。 | +| 内容工厂 App 内显示任务进度,不跳回 Chat | done/first-cut | `/Users/coso/Documents/dev/ai/limecloud/content-factory-app/src/ui/app.js`、`src/ui/host-bridge.js`、Playwright MCP 真实宿主验证 | 新增 `lime:host-agent-task-update`,主生产流通过 Host task subscription 更新“AI 同事任务”面板;页面显示“等待 AgentRuntime 调度或回写进度”,不再把 runtime `idle` 暴露为任务终态。 | +| 内容工厂可由 runtime structured patch 驱动 workspace | done/current-artifact-replay-verified | `/Users/coso/Documents/dev/ai/limecloud/content-factory-app/src/ui/app.js`、`tests/ui.test.mjs`、Playwright 最新真实宿主流 | App 会从 task / snapshot / event payload 以及 artifactDocument fenced JSON 中提取 `workspacePatch` / `contentFactoryWorkspacePatch`,并把数组形态的 `sceneTable / contentBatch / scripts / imagePrompts` 归一化成当前 workspace UI 可消费结构;真实 Host Bridge replay 已把最终 artifact 物化为 `文案=20 / 脚本=6 / 图片需求=5`。 | +| 内容工厂向 runtime 提供业务上下文,而不是只传 model prompt | done/current-verified | `/Users/coso/Documents/dev/ai/limecloud/content-factory-app/src/ui/app.js` | `runProduction / runScenarios / onlyCopy / runScripts` 均传入 `projectContext`,包含项目、已确认知识条目、readiness、确认链和已有场景摘要,避免 AgentRuntime 因只看到 `materialVersion` 文件名而停在索要资料。 | +| 内容工厂结果写回 storage/artifact/evidence | done/first-cut | `/Users/coso/Documents/dev/ai/limecloud/content-factory-app/src/ui/host-bridge.js`、`src/ui/app.js`、`APP.md` | 只使用 manifest 声明的 `scene_table / content_batch / script_batch` 与 `fact_grounding / publish_readiness`。 | +| Lime seeded 内容工厂 manifest 与真实写回类型一致 | done/current | `src/features/agent-app/fixtures/content-factory-app.json`、`src/features/agent-app/runtime/capabilityDispatcher.test.ts` | Lime 侧 seeded fixture 已声明 `scene_table / content_batch / script_batch / prompt_batch` 和 `publish_readiness`,避免 Host Bridge 写回被旧 `content_table` fixture 拒绝;dispatcher 回归覆盖 `content_batch` artifact 与 `publish_readiness` evidence。 | +| Host capability manifest gate | done/current-gated | `src/features/agent-app/runtime/capabilityDispatcher.ts`、`src/features/agent-app/runtime/capabilityDispatcher.test.ts` | Host Bridge dispatcher 会在调用 `lime.agent / lime.storage / lime.artifacts / lime.evidence / lime.knowledge` 前校验 capability 是否由 App manifest 的 global 或 entry requirement 声明;`lime.agent.startTask` 中的 Claw capability hint 还必须命中 `toolRefs[].capabilities` allowlist,未声明时返回 `CAPABILITY_NOT_DECLARED`。 | +| 命令四侧契约同步 | done | `npm run test:contracts` | 前端 gateway、Rust handler、DevBridge、catalog、mock contract 通过。 | +| 内容工厂包结构和 Host Bridge 回归 | done/current | `npm test` in `/Users/coso/Documents/dev/ai/limecloud/content-factory-app` | 45 tests passed,覆盖 Host Bridge、主生产任务、fenced JSON workspace patch 物化、写回声明类型。 | +| 真实 Lime 宿主 iframe Host Bridge 可用 | done/first-cut | Playwright MCP on `http://127.0.0.1:1420/` + iframe `http://127.0.0.1:4199/...` | `window.limeAgentAppBridge` 存在,`lime.agent.listTasks` 与 `startTask/getTask/cancelTask` 最小链路成功。 | +| Agent App Runtime 任务不污染普通最近对话 | done/current | `agent-app-runtime-` hidden session prefix、`src/lib/api/agentRuntime/sessionIdentity.ts`、Playwright evidence `hiddenAuxiliarySessionsCount: 1` | App 任务创建隐藏 runtime session,不再把 Agent App task 作为普通 Chat 最近会话展示。 | +| 内容工厂二级路由仍能通过 Host Bridge 发起任务 | done/current | `/Users/coso/Documents/dev/ai/limecloud/content-factory-app/src/ui/host-bridge.js`、Playwright on `/scenario-planning` | Host Bridge 对外固定使用 manifest entry `dashboard`,避免 App 内二级 route 的 entryKey 被 Lime 宿主信任校验静默丢弃。 | +| 内容工厂宿主模式不依赖本地模型 API | done/first-cut | `/Users/coso/Documents/dev/ai/limecloud/content-factory-app/src/ui/app.js` | Host 已连接时即使本地 `model.ready=false` 也可进入生成入口,任务交给 Lime AgentRuntime;本地 API 只保留 standalone 降级。 | +| 内容工厂 workspace patch producer contract | done/first-cut | `src-tauri/src/commands/agent_app_runtime_cmd.rs`、Rust fixture `test_agent_app_runtime_content_factory_output_contract_is_machine_readable` | `agent_app_runtime_start_task` 现在会把内容工厂任务包装成带 `Content Factory Output Contract` 的 runtime message,并在 `harness.agent_app_runtime_output_contract` 写入 artifact metadata contract,要求上游产物使用 `contentFactoryWorkspacePatch / workspacePatch`。 | +| 内容工厂产物数量 / schema 强约束 | done/current-app-side | `/Users/coso/Documents/dev/ai/limecloud/content-factory-app/src/ui/app.js`、`tests/ui.test.mjs`、Playwright iframe contract patch 验证 | App 会校验 `content_batch` 的文案、脚本和图片需求可用条目数;不足时显示“已先回写可审核草稿,但还没达到本轮目标”,并自动发起一次 `content_factory.copy.continue` hidden AgentRuntime 任务。 | +| capability catalog service 第一刀 | done/current-metadata | `src-tauri/src/services/agent_app_runtime_capability_catalog_service.rs`、Rust fixture `resolves_capability_aliases_to_claw_launch_descriptor` / `returns_none_for_unknown_capability_without_fake_launch` / `resolves_multiple_capabilities_without_duplicates` | capability alias / primary descriptor 已从 command 文件拆到 service;unknown capability 不会伪造 launch metadata;多个 capability descriptor 会去重并进入 workflow metadata。 | +| 多 capability workflow metadata | done/first-cut | `src-tauri/src/commands/agent_app_runtime_cmd.rs`、Rust fixture `test_agent_app_runtime_content_factory_output_contract_does_not_force_single_skill_launch` | `build_agent_app_runtime_metadata` 会写入 `agent_app_runtime.capability_workflow` 与 `harness.agent_app_runtime_capability_workflow`;内容工厂 output contract 任务标记 `mode=composite_output_contract`、`launch_policy=metadata_only`,并保留 research / image descriptors。 | +| Claw capability allowlist gate | done/first-cut | `src/features/agent-app/fixtures/content-factory-app.json`、`src/features/agent-app/types.ts`、`src/features/agent-app/projection/projectApp.ts`、`src/features/agent-app/runtime/capabilityDispatcher.test.ts` | manifest `toolRefs` 支持 `capabilities` allowlist;内容工厂 seeded fixture 显式声明 `image / cover / research / report / pdf / summary`;dispatcher 回归覆盖移除 catalog toolRef 和清空 allowlist 两种拒绝路径。 | + +## 3. 已执行验证 + +| 命令 | 状态 | 覆盖范围 | +| --- | --- | --- | +| `npm run typecheck` | passed | Lime 前端类型边界;覆盖 `agentAppRuntime` 新增 `providerPreference / modelPreference` 类型透传。 | +| `npm run typecheck` | passed/current-event-listen | 覆盖 `toolRefs[].capabilities` 类型投影和 Host Bridge runtime event listener 类型边界。 | +| `npm run test:contracts` | passed | Lime Tauri command 四侧、runtime client、harness 与 modality contract。 | +| `npm run test:contracts` | passed/current | 本轮新增模型偏好字段和 App task 类型后,命令四侧、runtime client、harness 与 modality contract 仍通过。 | +| `npm run test:contracts` | passed/current-artifact-replay | Host Bridge / AgentRuntime artifact replay 没有新增垂直 `content_factory_*` 命令,也没有扩展 deprecated command surface;四侧契约仍通过。 | +| `npm run test:contracts` | passed/current-runtime-event-projection | AgentRuntime 主动 projection 没有新增命令面,也没有扩展 compat / deprecated command surface;命令四侧、runtime client、harness 与 modality contract 仍通过。 | +| `cargo fmt --manifest-path "src-tauri/Cargo.toml" -- --check` | passed/current-harness-export-projection | Rust 格式化检查;覆盖 `runtime_api` Harness export projection 与 `session_execution_runtime` scope fallback。 | +| `CARGO_INCREMENTAL=0 CARGO_TARGET_DIR="/tmp/lime-agent-app-output-contract-target" cargo test --manifest-path "src-tauri/Cargo.toml" -p lime agent_app_runtime --lib` | passed/current-harness-export-projection | 22 tests passed,覆盖 Agent App Runtime metadata、capability workflow、artifact patch projection、runtime event evidence projection,以及新增 Harness export projection builder。 | +| `CARGO_INCREMENTAL=0 CARGO_TARGET_DIR="/tmp/lime-agent-app-output-contract-target" cargo test --manifest-path "src-tauri/Cargo.toml" -p lime-agent extracts_agent_app_scope_from_lime_runtime_summary_fallback --lib` | passed/current-harness-export-projection | 1 test passed,覆盖 `lime_runtime` 顶层 Agent App scope fallback 能进入 `SessionExecutionRuntimeSummary`,避免 Evidence Pack 导出时找不到 `appId/taskId`。 | +| `npm run test:contracts` | passed/current-harness-export-projection | 命令四侧、runtime client、harness contract、modality contract 与 cleanup contract 通过;本轮未新增垂直 `content_factory_*` command,也未扩展 deprecated command surface。 | +| `npm run governance:legacy-report` | passed/current-harness-export-projection | 边界违规 0;Harness export projection 仍收敛在 current `agent_runtime_export_*` 和 Agent App event projection,不新增平行事实源。 | +| `git diff --check -- "src-tauri/crates/agent/src/session_execution_runtime.rs" "src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs" "src-tauri/src/commands/aster_agent_cmd/request_model_resolution.rs" "src-tauri/src/commands/agent_app_runtime_cmd.rs" "docs/roadmap/agentruntime"` | passed/current-harness-export-projection | 本轮 Rust 与文档 diff 无空白错误。 | +| `npm run governance:legacy-report` | passed/current-gated | 边界违规 0;本轮 Claw capability allowlist gate 和多 capability workflow metadata 没有打开新的 compat / deprecated surface。 | +| `npm test -- "src/features/agent-app/runtime/agentRuntimeCapabilityHost.test.ts"` | passed | AgentRuntime Host Bridge 适配、artifact refs、跨刷新恢复。 | +| `npm test -- "src/features/agent-app/runtime/agentRuntimeCapabilityHost.test.ts"` | passed/current | 本轮新增覆盖 `providerPreference / modelPreference` 从 App task 透传到 `agent_app_runtime_start_task` facade。 | +| `npm test -- "src/lib/api/agentRuntime/sessionIdentity.test.ts" "src/features/agent-app/runtime/agentRuntimeCapabilityHost.test.ts"` | passed | Agent App runtime hidden session 规则、Host facade flags 透传、最小前端回归。 | +| `npm test -- "src/features/agent-app/runtime/hostBridge.test.ts" "src/features/agent-app/runtime/agentRuntimeCapabilityHost.test.ts"` | passed | Host Bridge capability invoke / task subscription、AgentRuntime capability facade、list / start / stream / get / cancel / retry / host response。 | +| `npm test -- "src/features/agent-app/runtime/hostBridge.test.ts" "src/features/agent-app/runtime/agentRuntimeCapabilityHost.test.ts"` | passed/current-artifact-replay | 15 tests passed,新增覆盖成功终态缺少 workspace patch 时的 Host Bridge terminal artifact replay 短轮询,以及 `AgentRuntimeCapabilityHost` 从 `threadRead.artifacts` 补投 `artifact:created` payload。 | +| `npm test -- "src/features/agent-app/runtime/hostBridge.test.ts"` | passed/current-event-listen | 13 tests passed,覆盖 task subscription 同时接入 `agent_app_runtime:{appId}:{taskId}` runtime event listener,并在 unsubscribe 时释放监听。 | +| `npm test -- "src/features/agent-app/runtime/hostBridge.test.ts"` | passed/current-runtime-event-projection | 13 tests passed,确认 Host Bridge 仍能消费 `taskEvents` 与 runtime event fallback,并保持 subscribe / unsubscribe 清理。 | +| `npm test -- "src/features/agent-app/projection/projectApp.test.ts" "src/features/agent-app/manifest/parseManifest.test.ts" "src/features/agent-app/runtime/capabilityDispatcher.test.ts"` | passed/current-writeback-manifest | 9 tests passed;Lime seeded 内容工厂 manifest 与真实 App 写回类型对齐,projection / manifest / Host Bridge dispatcher 均接受 `content_batch` 与 `publish_readiness`,且未声明 capability 会被 `CAPABILITY_NOT_DECLARED` 拦截。 | +| `npm test -- "src/features/agent-app/runtime/capabilityDispatcher.test.ts" "src/features/agent-app/projection/projectApp.test.ts" "src/features/agent-app/manifest/parseManifest.test.ts"` | passed/current-gated | 11 tests passed;覆盖 `toolRefs[].capabilities` allowlist 投影、解析和未授权 Claw hint 拒绝。 | +| `npm test -- "src/features/agent-app/ui/AgentAppRuntimePage.test.tsx" "src/features/agent-app/runtime/hostBridge.test.ts" "src/features/agent-app/runtime/agentRuntimeCapabilityHost.test.ts"` | passed/current-runtime-page | 23 tests passed;Host Bridge subscription、AgentRuntime facade 与 runtime page 在 capability manifest gate 后仍通过。 | +| `npm test -- "src/features/agent-app"` | passed/current-agent-app-suite | 35 files / 175 tests passed;覆盖 manifest fixture 扩展后 readiness、entry runtime guard、Agent Apps UI、Host Bridge、AgentRuntime facade、SDK adapter 与 install/cleanup 主链。 | +| `cargo fmt --manifest-path "src-tauri/Cargo.toml" --check` | passed | Rust 格式边界。 | +| `CARGO_TARGET_DIR="/tmp/lime-agent-app-artifact-test-target" cargo test --manifest-path "src-tauri/Cargo.toml" -p lime agent_app_runtime --lib` | passed | Agent App runtime facade metadata / event projection Rust 定向测试。 | +| `CARGO_TARGET_DIR="/tmp/lime-agent-app-artifact-test-target" cargo test --manifest-path "src-tauri/Cargo.toml" -p lime thread_read_should_project_file_artifacts_for_profile_consumers --lib` | passed | `FileArtifact` read model 投影。 | +| `CARGO_TARGET_DIR="/tmp/lime-agent-app-hidden-session-target" cargo test --manifest-path "src-tauri/Cargo.toml" -p lime agent_app_runtime --lib` | passed | Agent App hidden runtime session、facade metadata 与事件投影 Rust 定向回归。 | +| `npm run bridge:health -- --timeout-ms 120000` | passed | Lime Vite / DevBridge 可用性。 | +| `npm run verify:gui-smoke` | partial/environment-failed | workspace-ready、browser-runtime、site-adapters 与 Skill Forge 前端 metadata smoke 已通过;执行 Skill Forge Rust 定向测试时默认 `src-tauri/target` 编译触发 `No space left on device`,属于本机磁盘/target 环境限制,不是本轮 Agent App 断言失败。 | +| `node --check src/ui/host-bridge.js && node --check src/ui/app.js` in content factory | passed | 内容工厂 Host Bridge 与 UI 语法。 | +| `npm run build` in content factory | passed | 内容工厂 `dist/ui/*` 与源文件同步。 | +| `npm test` in content factory | passed | 内容工厂 44 个 API / core / UI / Host Bridge 回归,覆盖 persistent `lime:host-agent-task-update` listener、Host task subscription 和已声明写回类型。 | +| Playwright MCP 手工业务流:`http://127.0.0.1:4188/dashboard` | passed/standalone | 使用 fake model dev server 跑通项目创建、资料整理、资料确认、场景生成、文案+脚本生成、内容确认、交付结论、数据复盘、团队设置、回到项目中心持久化。控制台 error 为 0;standalone 模式下确认链同步出现 2 条 `Lime host bridge is not connected` warning,属宿主未接入的预期降级。截图:`content-factory-mcp-final-dashboard.png`。 | +| Playwright MCP 真实宿主最小链路:`http://127.0.0.1:1420/` -> 内容工厂 iframe `http://127.0.0.1:4199/...` | passed/host-first-cut | iframe 内 `window.limeAgentAppBridge.protocol === "lime.agentApp.bridge"`、`version === 1`、`isEmbedded === true`;`lime.agent.listTasks` 成功;`startHostTask/getTask/cancelTask` 成功,生成 `agent-app-task-*` 与隐藏 `agent-app-runtime-*` session。 | +| Playwright MCP 二级路由 Host Bridge:`http://127.0.0.1:4199/scenario-planning?...` inside Lime iframe | passed/host-first-cut | 修复 entryKey 后,二级 route 仍可 `startHostTask`,示例 session 使用 `agent-app-runtime-*` 前缀且不进入普通最近对话。 | +| Playwright MCP 真实宿主内容工厂主生产流:`http://127.0.0.1:1420/` -> iframe `http://127.0.0.1:4199/content-factory` | passed/current | 点击“生成文案和配套素材”后,App 不跳回 Chat,Host Bridge 发起 `content_factory.copy.generate`,hidden session `agent-app-runtime-41a6000d-b22d-43b8-82ec-ef0513c96c14` 使用 `deepseek / deepseek-v4-flash` 完成,`threadRead` 有 `contentFactoryWorkspacePatch`,页面回写 `文案=5 / 脚本=3 / 图片需求=5`,控制台 error 为 0。 | +| `cargo fmt --manifest-path "src-tauri/Cargo.toml" -- --check` | passed | 本轮 Rust 格式边界。 | +| `CARGO_INCREMENTAL=0 CARGO_TARGET_DIR="/tmp/lime-agent-app-output-contract-target" cargo test --manifest-path "src-tauri/Cargo.toml" -p lime agent_app_runtime --lib` | passed/current-runtime-event-projection | 18 tests passed,覆盖内容工厂 output contract、artifactDocument block patch extraction、capability hints 不强制单一 skill launch、模型偏好 recent successful metadata 读取、多 capability descriptor 去重、`agent_app_runtime:taskSnapshot`、`agent_app_runtime:profileProjection`,以及 artifact `RuntimeAgentEvent` 主动投影 `agent_app_runtime:runtimeEventProjection` canonical task event。 | +| `CARGO_INCREMENTAL=0 CARGO_TARGET_DIR="/tmp/lime-agent-app-output-contract-target" cargo test --manifest-path "src-tauri/Cargo.toml" -p lime agent_app_runtime --lib` | passed/current-evidence-projection | 19 tests passed,新增覆盖 runtime event metadata 中的 `evidenceRefs / verificationOutcomes` 被主动投影成 `evidence:recorded / evidence:verified`,并继续覆盖 Agent App runtime facade、output contract 与 capability workflow。 | +| `CARGO_INCREMENTAL=0 CARGO_TARGET_DIR="/tmp/lime-agent-app-output-contract-target" cargo test --manifest-path "src-tauri/Cargo.toml" -p lime thread_read_should_collect_evidence_projection_facts_from_runtime_metadata --lib` | passed/current-evidence-projection | `AgentRuntimeThreadReadModel.evidence_summary` 会从 timeline tool / file artifact metadata 和 evidence pack 路径收集显式 evidence refs / verification outcomes,作为 `getTask` snapshot fallback。 | +| `git diff --check -- "src-tauri/src/services/runtime_evidence_projection_service.rs" "src-tauri/src/services/mod.rs" "src-tauri/src/commands/aster_agent_cmd/dto.rs" "src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs" "docs/roadmap/agentruntime"` | passed/current-evidence-projection | 本轮 evidence projection service、read model、runtime translator 与路线图文档 diff 无 whitespace error。 | +| `npm run bridge:health -- --timeout-ms 120000` | passed/current | DevBridge `http://127.0.0.1:3030/health` 就绪。 | +| `npm run verify:gui-smoke -- --reuse-running` | passed/current | 复用 headless Tauri,workspace-ready、browser-runtime、site-adapters、Agent App smoke、Claw streaming、knowledge GUI、design canvas 等主路径通过。 | +| `npm run verify:gui-smoke -- --reuse-running` | failed/current-environment | 本轮复跑到 `smoke:at-command-registry` 的 `@配图` image_generate 执行请求等待超时;此前 workspace-ready、browser-runtime、site-adapters、Skill Forge、runtime tool surface 与 runtime tool surface page 均已通过。失败点属于 Claw `@配图` smoke 当前本机/模型执行等待,不是 Agent App runtime replay 断言。 | +| `npm run smoke:agent-apps -- --app-url http://127.0.0.1:1420/ --health-url http://127.0.0.1:3030/health --timeout-ms 600000 --interval-ms 1000` | passed/current | Agent Apps 正式入口、安装 review、disable/enable、runtime surface、卸载演练与 flag-off regression 通过。 | +| `node --check src/ui/host-bridge.js && node --check src/ui/app.js` in content factory | passed/current | 内容工厂 Host Bridge 与 UI 语法。 | +| `npm test` in content factory | passed/current | 45 tests passed,覆盖 App 内 runtime patch 订阅回写。 | +| `npm run build` in content factory | passed/current | 内容工厂 `dist/ui/*` 与源文件同步。 | +| `node --check src/ui/host-bridge.js && node --check src/ui/app.js && node --check tests/ui.test.mjs` in content factory | passed/current-contract | 内容工厂 Host Bridge、产物 contract 校验与 continuation 测试语法。 | +| `npm test` in content factory | passed/current-contract | 46 tests passed,新增覆盖 Host 订阅事件不足量 patch 时,App 校验 `minimumCount / minimumScriptCount / minimumImagePromptCount` 并发起一次 `content_factory.copy.continue`。 | +| `npm run build && node --check dist/ui/app.js && node --check dist/ui/host-bridge.js` in content factory | passed/current-contract | `dist/ui/*` 已同步新 Host Bridge rebind、runtime patch 验收、append continuation 归一化逻辑。 | +| Playwright MCP 真实宿主内容工厂 contract 验收:`http://127.0.0.1:1420/` -> iframe `http://127.0.0.1:4199/content-factory` | passed/current-contract | 真实 iframe 重载后接收 Host task update contract patch,页面指标显示 `文案=20 / 脚本=6 / 图片需求=5`,Host Bridge protocol 为 `lime.agentApp.bridge`,控制台 error 为 0。 | +| Playwright MCP 真实宿主 AgentRuntime continuation 触发 | passed/first-cut | 点击“生成文案和配套素材”后 hidden session 使用 `deepseek / deepseek-v4-flash` 完成;当 App 端判定产物不足时,页面提示“已先回写可审核草稿,但还没达到本轮目标”,并启动 `AI 同事正在补齐内容包` 的 continuation 任务。 | +| Playwright MCP 真实宿主 Host Bridge artifact replay | passed/current-artifact-replay | 点击“生成文案和配套素材”后 hidden session `agent-app-runtime-6c663069-9230-4c28-bfa1-adc9c0193e2a` completed,`threadRead.artifacts=1` 且含 `contentFactoryWorkspacePatch`;Host Bridge subscription replay 后 iframe 页面指标显示 `场景=5 / 文案=20 / 脚本=6 / 图片需求=5`,控制台 error 为 0。 | +| Playwright MCP 真实宿主 iframe 复核 | passed/current-recheck | 当前 Lime 页签内 iframe `http://127.0.0.1:4199/content-factory` 保持 `lime.agentApp.bridge` v1,页面显示 `文案=20 / 脚本=6 / 图片需求=5`、任务面板含 `Playwright 真实结构 artifact 已创建`,控制台 error 为 0;本机已安装 seeded manifest 仍是旧 artifact 声明,产生 1 条 `content_batch` 写回 warning,代码侧 fixture 已在本轮修正,需重新安装后消失。 | +| Playwright MCP 真实宿主快速复核 | passed/current-event-listen-recheck | Lime `http://127.0.0.1:1420/` 点击“内容工厂”后加载 iframe `http://127.0.0.1:4199/dashboard`,iframe 内 `lime.agentApp.bridge` v1 存在,`startHostTask / subscribeHostTask` 方法可见,页面显示项目中心与内容工厂,控制台 error 为 0;本次未重复触发真实模型生成,真实 artifact replay 仍以上方 `current-artifact-replay` 记录为准。 | +| Playwright MCP 真实宿主轻量复核 | passed/current-evidence-projection-recheck | 2026-05-16 复开 `http://127.0.0.1:1420/` 并进入内容工厂 iframe `http://127.0.0.1:4199/dashboard`,iframe 内 `lime.agentApp.bridge` v1、`startHostTask / subscribeHostTask` 可见,页面显示项目中心与内容工厂,控制台 error 为 0;本轮未重启 Tauri 或触发真实模型生成,因此新增 Rust evidence projection 仍需后续重启后完整业务流覆盖。 | + +## 4. 不能宣称完成的缺口 + +| 缺口 | 当前事实 | 下一刀 | +| --- | --- | --- | +| canonical task event stream / App task projection | Host Bridge 已有 `capability:subscribe`、终态 artifact replay 短轮询,并能监听 `agent_app_runtime:{appId}:{taskId}` Tauri / DevBridge runtime event;`agent_app_runtime_get_task` 会 emit `agent_app_runtime:taskSnapshot` projection payload,包含 `taskEvents / threadRead / task`。最新 first-cut 已在 AgentRuntime profile 事件生成处主动 emit `agent_app_runtime:profileProjection`,并把高价值 `RuntimeAgentEvent` 主动 emit 为 `agent_app_runtime:runtimeEventProjection`;artifact 事件可携带 workspace patch;runtime event / timeline metadata 中已存在的 evidence refs 与 verification outcomes 会主动转成 `evidence:recorded / evidence:verified`。本轮已把 Evidence Pack / analysis / review / save review 导出完成事件纳入 `agent_app_runtime:harnessExportProjection`。 | 继续在安全窗口重启宿主后做真实业务流覆盖,确认新 Rust projection 不只停留在单测;同时逐步减少 Host 侧轮询对最终 artifact replay 的依赖。 | +| capability catalog 的编排边界 | capability alias / descriptor 已拆到 `agent_app_runtime_capability_catalog_service.rs`;unknown capability 不会伪造 launch metadata;多个 capability 会进入 `harness.agent_app_runtime_capability_workflow`;内容工厂复合任务不会被 tools / hints 强制改写成单一 Skill;Host dispatcher 已补 high-level manifest capability gate 和 `toolRefs[].capabilities` allowlist gate。 | 继续补真正多 capability 执行编排、后端/cross-surface policy owner,以及 Chat / Agent App / Automation 共享 catalog 的统一 owner。 | + +## 5. 完成判定 + +按“Agent App 能在 App 内调用完整 Lime AgentRuntime,并完成内容工厂主流程第一刀”的口径,当前已经达到 **current MVP**。真实 Lime 宿主 iframe Host Bridge 已有端到端证据:内容工厂在 Lime 页面内可发现 bridge、发起任务、通过 task subscription 显示进度,使用隐藏 `agent-app-runtime-*` session,不跳回通用 Chat,并把 AgentRuntime 产出的 `contentFactoryWorkspacePatch` 物化成 App 页面内的文案、脚本和图片需求。 + +按用户要求的“完整实现整体目标”口径,当前仍不能标记 100% 完成:内容工厂 App 端产物 contract 强校验、continuation、Host Bridge terminal artifact replay、真实 iframe 物化、seeded manifest 写回类型对齐、high-level Host capability gate、Claw capability allowlist gate、多 capability workflow metadata、runtime event listen、`getTask` snapshot projection、AgentRuntime profile event 主动 App task projection,以及高价值 RuntimeAgentEvent 主动 App task projection 已完成 first-cut。剩余缺口已从“是否能主动推送 App 任务事件 / artifact patch / 显式 evidence refs / Harness 导出结果”缩小为:capability catalog 仍缺真正多能力执行编排、后端/cross-surface policy owner 和跨 surface 统一 owner;新 Harness export projection 也还没有经过重启后的 Playwright 真实生成流覆盖。下一刀应优先补 **capability policy owner + 真正多能力执行编排**,并在安全窗口重启宿主后用 Playwright 跑一次包含 Evidence Pack / analysis / review projection 的真实业务流。当前整体目标完成度约 **99.92%**。 diff --git a/docs/roadmap/agentruntime/app-surface-runtime.md b/docs/roadmap/agentruntime/app-surface-runtime.md index e1842f1fa..1bd91d775 100644 --- a/docs/roadmap/agentruntime/app-surface-runtime.md +++ b/docs/roadmap/agentruntime/app-surface-runtime.md @@ -134,7 +134,9 @@ humanReview = true | `task:completed` | 写回完成,可导出交付物 | | `artifact:created` / `evidence:recorded` | 交付物和证据已落库 | -当前已落地第一刀:`agent_app_runtime_get_task` 会从 `AgentRuntimeThreadReadModel` 投影 `taskStatus` 与 `taskEvents`,先覆盖 queued / progress / missing context / review request / tool call / evidence recorded / evidence verified / completed / cancelled / error / incident。`AgentAppRuntimePage` 已消费这组 snapshot 事件并转成 App 可见的 `AgentAppTaskStreamEvent`;App 也可以通过 `lime.agent.submitHostResponse` 把 ask_user / elicitation / tool_confirmation 响应回 `agent_app_runtime_submit_host_response`。它仍不是最终实时 stream,后续还要补 subscribe / artifact-created / evidence write-back 与跨刷新 task 恢复。 +当前已落地第一刀:`agent_app_runtime_get_task` 会从 `AgentRuntimeThreadReadModel` 投影 `taskStatus` 与 `taskEvents`,先覆盖 queued / progress / missing context / review request / tool call / artifact created / evidence recorded / evidence verified / completed / cancelled / error / incident。AgentRuntime profile event 生成处也会主动 emit `agent_app_runtime:profileProjection`,把 `turn.* / tool.* / action.* / routing.* / model.*` 投影成 App canonical `taskEvents`;高价值 `RuntimeAgentEvent` 会主动 emit `agent_app_runtime:runtimeEventProjection`,其中 `ArtifactSnapshot / FileArtifact` 可直接携带 `workspacePatch / contentFactoryWorkspacePatch`,显式写入 runtime event / timeline metadata 的 `evidenceRefs / verificationOutcomes` 也会通过 `runtime_evidence_projection_service` 投影为 `evidence:recorded / evidence:verified`。`AgentAppRuntimePage` 已消费这组 snapshot / profile / runtime projection 事件并转成 App 可见的 `AgentAppTaskStreamEvent`;App 也可以通过 `lime.agent.submitHostResponse` 把 ask_user / elicitation / tool_confirmation 响应回 `agent_app_runtime_submit_host_response`。`AgentRuntimeCapabilityHost` 已把 `taskId / sessionId / turnId / request / provenance` 持久化到 Agent App storage,刷新或重建 Host 后可以继续 `getTask / listTasks / submitHostResponse`,并能从 `threadRead.artifacts` 补投 `artifact:created` payload。Host Bridge 已支持 `capability:subscribe / capability:unsubscribe / capability:event`:订阅时会通过 `safeListen` 监听 `agent_app_runtime:{appId}:{taskId}` Tauri / DevBridge runtime event,并把后端 runtime event 和 projection 推给 iframe;同时保留 Host 侧 `getTask` 轮询作为 snapshot / artifact replay fallback。成功终态如果暂未带 workspace patch,会继续短轮询最多 4 次等待最终 artifact replay,已经能让 App 留在当前页面持续收到 task update、runtime event 和最终 artifact patch。Harness export projection 也已接入同一 event bus:Evidence Pack / analysis / review / save review 导出成功后,会把导出 root、制品列表和 completion audit completed 事实投影为 App task events。 + +内容工厂实际 App 已把主生产结果和确认链结果写回 `lime.storage / lime.artifacts / lime.evidence`,并用 manifest 已声明的 `scene_table / content_batch / script_batch` 与 `fact_grounding / publish_readiness` 类型避免真实 Host 拒绝;Lime seeded 内容工厂 fixture 也已补齐这些写回类型,Host dispatcher 会在调用 `lime.agent` 等 Host capability 前校验 manifest 声明。Claw capability hint 还会校验 manifest `toolRefs[].capabilities` allowlist:内容工厂当前显式声明 `image / cover / research / report / pdf / summary`,未列入 allowlist 的 hint 会被 `CAPABILITY_NOT_DECLARED` 拒绝。主生产 UI 已通过 Host task subscription 更新“AI 同事任务”面板,也能优先消费 runtime 返回的 `workspacePatch` / `contentFactoryWorkspacePatch` 更新项目。后端 projection 已把 runtime artifact metadata、artifactDocument blocks 和 artifact runtime event 中的 patch 稳定透传为 `artifact:created` payload;`agent_app_runtime_start_task` 也已把内容工厂 `artifactKind` 输出要求写入 runtime message 和 `harness.agent_app_runtime_output_contract`,让上游 Agent 有机器可读的 producer contract。Rust facade 还会把多个 capability hint 去重后写入 `agent_app_runtime.capability_workflow` 与 `harness.agent_app_runtime_capability_workflow`;内容工厂带 output contract 的复合任务使用 `metadata_only`,因此不会被 `research.search + image_generation` 强制降格为单一 Claw Skill。Playwright 真实宿主已验证隐藏 `agent-app-runtime-*` session 完成后,Host Bridge artifact replay 可把最终 patch 物化到内容工厂 iframe,页面指标达到 `文案=20 / 脚本=6 / 图片需求=5`。当前剩余缺口不再是“能否回写内容工厂”或“能否主动推送任务进度 / artifact patch / 显式 evidence refs / Harness 导出结果”,而是真正多 capability 执行编排、后端/cross-surface capability policy owner,以及重启宿主后的 Playwright 真实业务流覆盖。 ## 6. 与 Agent App 文档的关系 diff --git a/docs/roadmap/agentruntime/backend-surface-facade-plan.md b/docs/roadmap/agentruntime/backend-surface-facade-plan.md index 09dbdfc43..2e5583466 100644 --- a/docs/roadmap/agentruntime/backend-surface-facade-plan.md +++ b/docs/roadmap/agentruntime/backend-surface-facade-plan.md @@ -1,6 +1,6 @@ # AgentRuntime Surface 后端 Facade 计划 -> 状态:in progress(P1/P2 最小 facade 已落地,P3 snapshot event projection 与 App 页面消费第一刀已落地,P4 capability hints 第一刀已落地,P5 未完成) +> 状态:in progress(P1/P2 最小 facade 已落地,P3 snapshot event projection、AgentRuntime profile / high-value runtime event 主动 projection、Harness export projection、runtime event listen、Host Bridge task subscription、终态 artifact replay 与跨刷新 task state 恢复第一刀已落地,P4 capability catalog service、多能力 workflow metadata 与 manifest allowlist gate 第一刀已落地,P5 App 写回第一刀已落地) > 更新时间:2026-05-16 > 作用:定义如何把 Chat / Claw / Agent App / Automation 收敛到同一个后端 AgentRuntime Surface,并记录当前最小实现边界。 @@ -29,9 +29,10 @@ Automation job command | `src-tauri/src/commands/agent_app_runtime_cmd.rs` | Agent App runtime Tauri command 外壳,只做参数反序列化和 state 注入。 | | `src-tauri/src/services/agent_app_runtime_service.rs` | App task facade:校验 appId / entryKey / manifest capability / provenance,组装 surface request。 | | `src-tauri/src/services/agent_runtime_surface_service.rs` | 多 surface 共享入口:把 Chat、Claw、Agent App、Automation 输入转换成 runtime turn。 | -| `src-tauri/src/services/agent_runtime_capability_catalog_service.rs` | Claw capability catalog:声明 capabilityId、metadata contract、tool surface、artifact/evidence policy。 | +| `src-tauri/src/services/agent_app_runtime_capability_catalog_service.rs` | Agent App -> Claw capability catalog 第一刀:声明 capabilityId / alias / launch metadata contract,并由 runtime facade 复用。 | +| `src-tauri/src/services/agent_runtime_capability_catalog_service.rs` | 后续多 surface capability catalog:声明 capabilityId、metadata contract、tool surface、artifact/evidence policy。 | -`agent_app_runtime_cmd.rs` 已作为 P1/P2 最小 facade 落地,并内置首批 capability hint -> Claw skill launch metadata 映射;service 拆分、独立 capability catalog 和 event translator 仍是后续工作。实现时必须同步命令治理四侧:前端 gateway、Rust handler、`agentCommandCatalog`、mock / DevBridge。 +`agent_app_runtime_cmd.rs` 已作为 P1/P2 最小 facade 落地;首批 capability hint -> Claw skill launch metadata 映射已从 command 文件拆到 `agent_app_runtime_capability_catalog_service.rs`。Host Bridge subscription 已补 runtime event listen 和终态 artifact replay 短轮询:订阅 `lime.agent` task 时会监听 `agent_app_runtime:{appId}:{taskId}`,把 Tauri / DevBridge runtime event 推回 iframe,同时继续用 `getTask` 轮询兜底 snapshot / artifact replay。AgentRuntime profile event 现在会在同名 event bus 主动追加 `agent_app_runtime:profileProjection` payload,把 `turn.* / tool.* / action.* / routing.* / model.*` 投影成 App canonical `taskEvents`;高价值 `RuntimeAgentEvent` 也会追加 `agent_app_runtime:runtimeEventProjection`,其中 artifact 事件可直接携带 workspace patch,runtime event / timeline metadata 中显式存在的 `evidenceRefs / verificationOutcomes` 会通过 `runtime_evidence_projection_service` 主动投影为 `evidence:recorded / evidence:verified`。前端 Host facade 也能从 `threadRead.artifacts` 补投 `artifact:created` payload;Host dispatcher 已补 high-level manifest capability gate,未声明 `lime.agent` 等 Host capability 会被拒绝。Claw capability hint 会进一步校验 App manifest 的 `toolRefs[].capabilities` allowlist,避免只声明 catalog tool 后任意启动 Claw 能力。Rust facade 现在会把多个 capability hint 去重后写入 `agent_app_runtime.capability_workflow` 与 `harness.agent_app_runtime_capability_workflow`;内容工厂这类带 output contract 的复合任务使用 `metadata_only`,不会被强行改写成单一 `research_skill_launch` 或 `image_skill_launch`。本轮已补 Evidence Pack / analysis / review / save review 导出结果的后端主动 translator:导出成功后会基于 `runtime_summary.surface=agent_app` 和 `appId/taskId` emit `agent_app_runtime:harnessExportProjection`。后续仍要补后端/cross-surface capability policy owner、真正的多能力执行编排,以及跨 surface catalog 合并。实现时必须同步命令治理四侧:前端 gateway、Rust handler、`agentCommandCatalog`、mock / DevBridge。 ## 3. 最小命令语义 @@ -166,9 +167,9 @@ best-effort 事件: | P0 | 文档冻结和 owner 边界确认 | 本文件、App surface、Claw sharing 文档都接入 README | | P1 | 新增 Agent App runtime command shell | 已完成:四侧命令治理同步,浏览器桥接模式 fail-closed,普通 mock 只服务离线测试 | | P2 | Surface facade 复用 `submit_runtime_turn` | 已完成最小版:App task 可创建 / 复用 runtime session,并提交真实 runtime turn | -| P3 | Event translator | 已完成第一刀:`agent_app_runtime_get_task` 返回 `taskStatus` 与 `taskEvents`,可从 `AgentRuntimeThreadReadModel` 投影 queued / progress / missing context / host request / tool call / evidence / outcome / incident;`AgentAppRuntimePage` 已通过 `AgentRuntimeCapabilityHost` 把 Host Bridge 的 `lime.agent.startTask / streamTask / getTask / cancelTask / retryTask / submitHostResponse` 接到 `agent_app_runtime_*` facade,不再只走本地 in-memory adapter;未完成:实时 stream / subscribe、artifact-created 专用投影、持久化 task/session 恢复 | -| P4 | Capability catalog 首批能力 | 已完成第一刀:`agent_app_runtime_start_task` 可把 `lime.capability.image.generate` / `cover.generate` / `research.search` / `report.generate` / `pdf.read` / `summary.generate` 等 hint 映射到现有 `*_skill_launch` metadata;未完成:独立 catalog service、manifest capability 校验、多能力 workflow 编排 | -| P5 | Evidence / Artifact write-back | 内容工厂主链完成 review 后写回 storage/artifact/evidence | +| P3 | Event translator | 已完成 first-cut:`agent_app_runtime_get_task` 返回 `taskStatus` 与 `taskEvents`,可从 `AgentRuntimeThreadReadModel` 投影 queued / progress / missing context / host request / tool call / artifact created / evidence / outcome / incident;AgentRuntime profile event 生成处会主动 emit `agent_app_runtime:profileProjection`,把 `turn.* / tool.* / action.* / routing.* / model.*` 转成 App canonical `taskEvents`;高价值 `RuntimeAgentEvent` 会主动 emit `agent_app_runtime:runtimeEventProjection`,其中 `RuntimeStatus / ToolStart / ToolEnd / ActionRequired / ArtifactSnapshot / FileArtifact / Done / Error` 会被转成 App task event,artifact projection 可直接带 workspace patch;`runtime_evidence_projection_service` 会把 runtime event / timeline metadata 中显式存在的 `evidenceRefs / verificationOutcomes` 投影为 `evidence:recorded / evidence:verified`;`AgentAppRuntimePage` 已通过 `AgentRuntimeCapabilityHost` 把 Host Bridge 的 `lime.agent.startTask / streamTask / getTask / cancelTask / retryTask / submitHostResponse / listTasks` 接到 `agent_app_runtime_*` facade,不再只走本地 in-memory adapter;已完成跨刷新第一刀:runtime task state 写入 Agent App storage,Host 重建后可恢复 `sessionId / turnId` 并继续读取和响应;Host Bridge 已支持 `capability:subscribe / capability:unsubscribe / capability:event`,会监听 `agent_app_runtime:{appId}:{taskId}` Tauri / DevBridge runtime event 并转发给 iframe,同时用 Host 侧轮询 `getTask` 兜底 task snapshot / artifact replay。已完成 Harness export projection first-cut:Evidence Pack / analysis / review / save review 导出结果会主动进入 `agent_app_runtime:{appId}:{taskId}`,继续减少对 Host 轮询 replay 的依赖。未完成:后端/cross-surface capability policy owner、真正多能力执行编排和重启后真实业务流覆盖 | +| P4 | Capability catalog 首批能力 | 已完成第一刀:`agent_app_runtime_start_task` 可把 `lime.capability.image.generate` / `cover.generate` / `research.search` / `report.generate` / `pdf.read` / `summary.generate` 等 hint 映射到现有 `*_skill_launch` metadata;映射已收敛到 `agent_app_runtime_capability_catalog_service.rs` 并覆盖 alias / unknown / 多能力去重单测;Rust metadata 会写入 `agent_app_runtime.capability_workflow` 与 `harness.agent_app_runtime_capability_workflow`,区分 `single_capability`、`multi_capability` 与 `composite_output_contract`;Host dispatcher 已有 high-level manifest capability gate,并用 `toolRefs[].capabilities` 做 Claw capability allowlist。未完成:后端/cross-surface capability policy owner、真正多能力执行编排、跨 surface catalog 合并 | +| P5 | Evidence / Artifact write-back | 已完成 App 包第一刀:`/Users/coso/Documents/dev/ai/limecloud/content-factory-app` 的场景、文案、重写、脚本和确认链会在 Host Bridge 内把结果写回 `lime.storage / lime.artifacts / lime.evidence`,并只使用 manifest 已声明的 artifact/evidence kind;已完成 AgentRuntime `FileArtifact -> artifact:created` 专用投影;内容工厂 App 已能消费 `workspacePatch` / `contentFactoryWorkspacePatch` 并优先物化 workspace;后端 projection 已能把 artifact metadata、artifactDocument blocks 和 artifact runtime event 中的 patch 透传为 App event payload;`agent_app_runtime_start_task` 已把内容工厂 producer contract 写入 runtime message 与 `harness.agent_app_runtime_output_contract`,并把复合 capability hints 记录为 `metadata_only` workflow;Playwright 真实宿主已验证 hidden session 完成后通过 Host Bridge artifact replay 回写到 iframe。已完成 Harness export projection first-cut:Evidence Pack / analysis / review / save review 导出结果会主动投影为 `evidence:recorded`、`evidence:verified` 或 `artifact:created`。未完成:后端/cross-surface capability policy owner、真正多能力执行编排仍未完成,且该 Rust projection 仍需重启后 Playwright 真实业务流覆盖 | ## 9. 验收与守卫 diff --git a/docs/roadmap/agentruntime/claw-capability-sharing.md b/docs/roadmap/agentruntime/claw-capability-sharing.md index 9a65fca79..a65c7f325 100644 --- a/docs/roadmap/agentruntime/claw-capability-sharing.md +++ b/docs/roadmap/agentruntime/claw-capability-sharing.md @@ -1,6 +1,6 @@ # Claw Capability 共享方案 -> 状态:draft +> 状态:in progress(Agent App Runtime 首批 catalog、workflow metadata 与 manifest allowlist gate 第一刀已落地) > 更新时间:2026-05-16 > 作用:把 Claw 已经实现的 `@` 能力从 Chat surface 中抽象为 AgentRuntime 可复用 capability,供 Agent App、Automation 和未来 surface 共享。 @@ -135,7 +135,7 @@ taskKind / capabilityId ## 8. 已落地第一刀 -`agent_app_runtime_start_task` 已经能读取 `requiredCapabilities` / `capabilityHints` 中的首个受支持 capability,并写入现有 Claw `*_skill_launch` metadata: +`agent_app_runtime_start_task` 已经能读取 `requiredCapabilities` / `capabilityHints` 中受支持的 capability,首个单能力任务会写入现有 Claw `*_skill_launch` metadata;多个 capability 会被去重后作为 workflow metadata 暴露给 runtime / harness: | Capability hint | 写入 metadata | 首刀 Skill | | --- | --- | --- | @@ -146,4 +146,4 @@ taskKind / capabilityId | `lime.capability.pdf.read` / `pdf_extract` | `harness.pdf_read_skill_launch.pdf_read_request` | `pdf_read` | | `lime.capability.summary.generate` | `harness.summary_skill_launch.summary_request` | `summary` | -当前仍是第一刀:一个 App task 只会锁定首个可识别 capability,适合证明“App 不复制 Claw、而是进入同一 skill launch 主链”。多能力 workflow、manifest capability gate、事件转译和 artifact/evidence write-back 仍需后续阶段补齐。 +当前仍是第一刀:非复合任务只会把首个 capability 提升为实际 `*_skill_launch`,用于证明“App 不复制 Claw、而是进入同一 skill launch 主链”;内容工厂这类带 output contract 的复合任务会写入 `agent_app_runtime.capability_workflow` 与 `harness.agent_app_runtime_capability_workflow`,`mode=composite_output_contract`、`launch_policy=metadata_only`,避免 `research.search + image_generation` 把业务工作流强行改写成单一 research / image Skill。Host dispatcher 已有 high-level manifest capability gate,会拒绝未声明的 `lime.agent` 等 Host capability;Claw capability hint 也会校验 manifest `toolRefs[].capabilities` allowlist。AgentRuntime profile event 与高价值 RuntimeAgentEvent 已能主动转译为 App canonical `taskEvents`,artifact runtime event 可携带 workspace patch;evidence 变更还需要继续收敛。后端/cross-surface capability policy owner、真正多能力执行编排和跨 surface catalog owner 仍需后续阶段补齐。 diff --git a/package-lock.json b/package-lock.json index 1c200575e..7b9f868ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lime", - "version": "1.40.0", + "version": "1.41.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lime", - "version": "1.40.0", + "version": "1.41.0", "license": "GPL-3.0-only", "dependencies": { "@babel/standalone": "^7.29.0", diff --git a/package.json b/package.json index 935d71727..2b4ba6658 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "lime", "private": true, - "version": "1.40.0", + "version": "1.41.0", "description": "AI content workspace for Chinese creators: desktop writing, research, prompt management, knowledge base, and multi-model workflows.", "keywords": [ "ai", diff --git a/packages/lime-cli-npm/package.json b/packages/lime-cli-npm/package.json index 454bcb0fd..715fec179 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.40.0", + "version": "1.41.0", "description": "Lime 官方任务 CLI", "bin": { "lime": "scripts/run.js" diff --git a/scripts/agent-app-package-handoff-check.mjs b/scripts/agent-app-package-handoff-check.mjs new file mode 100644 index 000000000..fe433645e --- /dev/null +++ b/scripts/agent-app-package-handoff-check.mjs @@ -0,0 +1,255 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +import { createAgentAppPackageHandoffReport } from "./lib/agent-app-package-handoff-core.mjs"; + +function parseArgs(argv) { + const result = { + check: false, + format: "summary", + help: false, + packageDir: "", + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if ((arg === "--package-dir" || arg === "--package") && argv[index + 1]) { + result.packageDir = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--format" && argv[index + 1]) { + result.format = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--check") { + result.check = true; + continue; + } + if (arg === "--help" || arg === "-h") { + result.help = true; + } + } + + return result; +} + +function printHelp() { + console.log(` +Agent App Package Handoff Check + +用法: + node scripts/agent-app-package-handoff-check.mjs --package-dir + node scripts/agent-app-package-handoff-check.mjs --package-dir --format json + node scripts/agent-app-package-handoff-check.mjs --package-dir --check + +选项: + --package-dir PATH 要只读检查的 Agent App package 根目录。 + --format FMT summary | json,默认 summary。 + --check 若 package 仍 blocked / needs_handoff,则以非 0 退出。 + -h, --help 显示帮助。 +`); +} + +function readFileIfExists(filePath) { + try { + return { + exists: fs.existsSync(filePath), + content: fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : "", + }; + } catch { + return { + exists: false, + content: "", + }; + } +} + +function collectGitStatusShort(packageDir) { + try { + return execFileSync("git", ["-C", packageDir, "status", "--short"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + } catch { + return ""; + } +} + +function walkFiles(rootDir) { + if (!fs.existsSync(rootDir)) { + return []; + } + const entries = []; + for (const name of fs.readdirSync(rootDir)) { + const filePath = path.join(rootDir, name); + const stat = fs.statSync(filePath); + if (stat.isDirectory()) { + entries.push(...walkFiles(filePath)); + continue; + } + if (stat.isFile()) { + entries.push(filePath); + } + } + return entries; +} + +function fileHash(filePath) { + return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex").slice(0, 12); +} + +function collectDistArtifacts(packageDir) { + const entries = []; + const pairs = [ + ["src/ui", "dist/ui"], + ["src/core", "dist/core"], + ["src/integrations", "dist/integrations"], + ]; + + for (const [srcRootRelative, distRootRelative] of pairs) { + const srcRoot = path.join(packageDir, srcRootRelative); + const distRoot = path.join(packageDir, distRootRelative); + for (const srcPath of walkFiles(srcRoot)) { + const relativePath = path.relative(srcRoot, srcPath); + const distPath = path.join(distRoot, relativePath); + if (!fs.existsSync(distPath)) { + entries.push({ + status: "missing-dist", + src: path.relative(packageDir, srcPath), + dist: path.relative(packageDir, distPath), + srcHash: fileHash(srcPath), + distHash: "", + }); + continue; + } + const srcHash = fileHash(srcPath); + const distHash = fileHash(distPath); + entries.push({ + status: srcHash === distHash ? "same" : "diff", + src: path.relative(packageDir, srcPath), + dist: path.relative(packageDir, distPath), + srcHash, + distHash, + }); + } + + for (const distPath of walkFiles(distRoot)) { + const relativePath = path.relative(distRoot, distPath); + const srcPath = path.join(srcRoot, relativePath); + if (!fs.existsSync(srcPath)) { + entries.push({ + status: "extra-dist", + src: path.relative(packageDir, srcPath), + dist: path.relative(packageDir, distPath), + srcHash: "", + distHash: fileHash(distPath), + }); + } + } + } + + const workerSrc = path.join(packageDir, "src", "worker", "index.mjs"); + const workerDist = path.join(packageDir, "dist", "worker", "index.mjs"); + if (fs.existsSync(workerSrc)) { + if (!fs.existsSync(workerDist)) { + entries.push({ + status: "missing-dist", + src: path.relative(packageDir, workerSrc), + dist: path.relative(packageDir, workerDist), + srcHash: fileHash(workerSrc), + distHash: "", + }); + } else { + const srcHash = fileHash(workerSrc); + const distHash = fileHash(workerDist); + entries.push({ + status: srcHash === distHash ? "same" : "diff", + src: path.relative(packageDir, workerSrc), + dist: path.relative(packageDir, workerDist), + srcHash, + distHash, + }); + } + } + + return entries; +} + +function createReport(packageDir) { + const resolvedPackageDir = path.resolve(packageDir); + const hostBridgePath = path.join(resolvedPackageDir, "src", "ui", "host-bridge.js"); + const uiTestPath = path.join(resolvedPackageDir, "tests", "ui.test.mjs"); + const packageJsonPath = path.join(resolvedPackageDir, "package.json"); + const buildScriptPath = path.join(resolvedPackageDir, "scripts", "build.mjs"); + const packageJson = readFileIfExists(packageJsonPath); + + return createAgentAppPackageHandoffReport({ + packageDir: resolvedPackageDir, + gitStatusShort: collectGitStatusShort(resolvedPackageDir), + files: { + hostBridge: readFileIfExists(hostBridgePath), + uiTest: readFileIfExists(uiTestPath), + buildScript: readFileIfExists(buildScriptPath), + distArtifacts: collectDistArtifacts(resolvedPackageDir), + }, + packageJsonText: packageJson.content, + }); +} + +function renderMarkerSummary(markers) { + if (!markers.length) { + return "none"; + } + return markers.map((entry) => `${entry.marker}:${entry.count}`).join(", "); +} + +function renderSummary(report) { + const lines = [ + `status=${report.verdict.status}`, + `packageDir=${report.packageDir}`, + `dirty=tracked:${report.gitStatus.trackedCount},untracked:${report.gitStatus.untrackedCount},total:${report.gitStatus.totalCount}`, + `hostBridgePrivate=${renderMarkerSummary(report.files.hostBridge.privateMarkers)}`, + `hostBridgeSdk=${renderMarkerSummary(report.files.hostBridge.sdkMarkers)}`, + `uiTestPrivate=${renderMarkerSummary(report.files.uiTest.privateMarkers)}`, + `highRiskScripts=${report.scripts.highRisk.map((entry) => entry.name).join(",") || "none"}`, + `distArtifacts=diff:${report.distArtifacts.diffCount},missing:${report.distArtifacts.missingDistCount},extra:${report.distArtifacts.extraDistCount},total:${report.distArtifacts.totalDeltas}`, + `blockers=${report.verdict.blockers.join(" | ") || "none"}`, + `warnings=${report.verdict.warnings.join(" | ") || "none"}`, + `nextAction=${report.verdict.nextAction}`, + ]; + return `${lines.join("\n")}\n`; +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.help) { + printHelp(); + return; + } + if (!args.packageDir) { + console.error("缺少 --package-dir PATH。"); + printHelp(); + process.exitCode = 2; + return; + } + + const report = createReport(args.packageDir); + if (args.format === "json") { + console.log(JSON.stringify(report, null, 2)); + } else { + process.stdout.write(renderSummary(report)); + } + + if (args.check && report.verdict.status !== "ready") { + process.exitCode = 1; + } +} + +main(); diff --git a/scripts/agent-apps-smoke.mjs b/scripts/agent-apps-smoke.mjs index 4e56eacf8..d7be17adb 100644 --- a/scripts/agent-apps-smoke.mjs +++ b/scripts/agent-apps-smoke.mjs @@ -15,6 +15,12 @@ const DEFAULTS = { prefix: "agent-apps-smoke", }; +const ACCOUNT_MENU_BUTTON_SELECTOR = '[data-testid="app-sidebar-account-button"]'; +const AGENT_APPS_NAV_SELECTOR = + 'button[aria-label="Agent Apps"], button[title="Agent Apps"]'; +const AGENT_APP_LAB_NAV_SELECTOR = + 'button[aria-label="Agent App Lab"], button[title="Agent App Lab"]'; + function parseArgs(argv) { const options = { ...DEFAULTS }; for (let index = 0; index < argv.length; index += 1) { @@ -66,6 +72,21 @@ function logStage(stage) { console.log(`[smoke:agent-apps] stage=${stage}`); } +async function openAccountMenuForAgentApps(page, timeoutMs) { + if ((await page.locator(AGENT_APPS_NAV_SELECTOR).count()) > 0) { + return; + } + await page.click(ACCOUNT_MENU_BUTTON_SELECTOR); + await page.waitForSelector(AGENT_APPS_NAV_SELECTOR, { + timeout: timeoutMs, + }); +} + +async function clickAgentAppsNav(page, timeoutMs) { + await openAccountMenuForAgentApps(page, timeoutMs); + await page.locator(AGENT_APPS_NAV_SELECTOR).first().click(); +} + async function launchSmokeContext(userDataDir) { try { return await chromium.launchPersistentContext(userDataDir, { @@ -127,8 +148,8 @@ function activeCloudBootstrapPayload() { registrationState: "active", enabled: true, packageUrl: "https://lime.local/agent-apps/content-factory-app/releases/0.3.0/package.zip", - packageHash: "package-fnv1a-smoke-formal", - manifestHash: "manifest-fnv1a-smoke-formal", + packageHash: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + manifestHash: "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", capabilityRequirements: {}, defaultEntries: ["dashboard", "content_scenario_planning"], policyDefaults: {}, @@ -158,10 +179,11 @@ async function runFlagOffRegression(options) { await page.waitForSelector('[data-testid="app-sidebar-main-nav"]', { timeout: options.timeoutMs, }); + await openAccountMenuForAgentApps(page, options.timeoutMs); const assertions = { - agentAppsNavVisible: (await page.locator('button[title="Agent Apps"]').count()) > 0, - labNavHidden: (await page.locator('button[title="Agent App Lab"]').count()) === 0, + agentAppsNavVisible: (await page.locator(AGENT_APPS_NAV_SELECTOR).count()) > 0, + labNavHidden: (await page.locator(AGENT_APP_LAB_NAV_SELECTOR).count()) === 0, noConsoleErrors: consoleErrors.length === 0, }; @@ -200,6 +222,16 @@ async function main() { }, bootstrap); const page = await context.newPage(); + await page.route( + "https://user.limeai.run/api/v1/public/tenants/*/client/agent-apps", + async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(bootstrap), + }); + }, + ); page.on("console", (message) => { if (message.type() === "error") { consoleErrors.push(message.text()); @@ -219,17 +251,18 @@ async function main() { await page.waitForSelector('[data-testid="app-sidebar-main-nav"]', { timeout: options.timeoutMs, }); + await openAccountMenuForAgentApps(page, options.timeoutMs); assert( - (await page.locator('button[title="Agent Apps"]').count()) > 0, + (await page.locator(AGENT_APPS_NAV_SELECTOR).count()) > 0, "Agent Apps nav should be visible", ); assert( - (await page.locator('button[title="Agent App Lab"]').count()) === 0, + (await page.locator(AGENT_APP_LAB_NAV_SELECTOR).count()) === 0, "Agent App Lab nav should stay hidden in formal smoke", ); logStage("open-agent-apps"); - await page.click('button[title="Agent Apps"]'); + await clickAgentAppsNav(page, options.timeoutMs); await page.waitForSelector('[data-testid="agent-apps-page"]', { timeout: options.timeoutMs, }); @@ -247,20 +280,30 @@ async function main() { logStage("activate-bootstrap-catalog"); await page.evaluate((payload) => { - window.__LIME_BOOTSTRAP__ = payload; + window.__LIME_OEM_CLOUD__ = { + enabled: true, + baseUrl: "https://user.limeai.run", + tenantId: payload.tenantId, + }; + window.__LIME_SESSION_TOKEN__ = "smoke-agent-apps-token"; + window.__LIME_BOOTSTRAP__ = { data: { agentAppCatalog: payload } }; }, bootstrap); await page.click('[data-testid="agent-apps-refresh"]'); await page.waitForFunction( - () => - !document - .querySelector('[data-testid="agent-apps-install-cloud-content-factory-app"]') - ?.hasAttribute("disabled"), + () => { + const button = document.querySelector( + '[data-testid="agent-apps-install-cloud-content-factory-app"]', + ); + return button instanceof HTMLButtonElement && !button.disabled; + }, undefined, { timeout: options.timeoutMs }, ); logStage("install-cloud-review"); - await page.click('[data-testid="agent-apps-install-cloud-content-factory-app"]'); + await page.click('[data-testid="agent-apps-install-cloud-content-factory-app"]', { + timeout: options.timeoutMs, + }); await page.waitForSelector('[data-testid="agent-apps-install-review"]', { timeout: options.timeoutMs, }); @@ -308,7 +351,7 @@ async function main() { ); logStage("return-agent-apps"); - await page.click('button[title="Agent Apps"]'); + await clickAgentAppsNav(page, options.timeoutMs); await page.waitForSelector('[data-testid="agent-apps-page"]', { timeout: options.timeoutMs, }); @@ -348,7 +391,9 @@ async function main() { runtimeSurfaceVisible: Boolean(runtimeFrameSrc), cleanupEvidenceSelectedApp: cleanupEvidence.appId === "content-factory-app", cleanupEvidenceStrategy: cleanupEvidence.strategy === "delete-data", - cleanupEvidenceRehearsalOnly: cleanupEvidence.completionEffect === "rehearsal-only", + cleanupEvidenceDryRunOnly: + Array.isArray(cleanupEvidence.warningCodes) && + cleanupEvidence.warningCodes.includes("DRY_RUN_ONLY"), cleanupEvidenceBlockedCount: cleanupEvidence.blockedTargetCount === 0, flagOffAgentAppsNavVisible: flagOff.assertions.agentAppsNavVisible, flagOffLabNavHidden: flagOff.assertions.labNavHidden, diff --git a/scripts/lib/agent-app-package-handoff-core.mjs b/scripts/lib/agent-app-package-handoff-core.mjs new file mode 100644 index 000000000..8eaa26cc0 --- /dev/null +++ b/scripts/lib/agent-app-package-handoff-core.mjs @@ -0,0 +1,237 @@ +const PRIVATE_BRIDGE_MARKERS = [ + "pendingRequests", + "buildMessage", + "requestHostBridge", + "window.parent.postMessage", + "capability:invoke", + "capability:subscribe", + "capability:event", +]; + +const SDK_FACADE_MARKERS = [ + "createLimeHostBridgeCapabilityInvoker", + "createLimeCoreCapabilityAdapters", +]; + +const HIGH_RISK_SCRIPT_PATTERNS = [ + { + scriptName: "build", + pattern: /\brm\s*\(|\brimraf\b|rm\s+-rf\s+dist|remove\s*\(\s*["']dist["']/i, + reason: "build script may delete or rewrite dist artifacts", + }, + { + scriptName: "verify", + pattern: /\bnpm\s+run\s+build\b|\bpnpm\s+run\s+build\b|\byarn\s+build\b/i, + reason: "verify script runs build before validation", + }, + { + scriptName: "e2e", + pattern: /\bnpm\s+run\s+build\b|\bpnpm\s+run\s+build\b|\byarn\s+build\b/i, + reason: "e2e script runs build before user-flow validation", + }, +]; + +function normalizeText(value) { + return String(value || ""); +} + +function countMarkerHits(text, markers) { + const content = normalizeText(text); + return markers + .map((marker) => ({ + marker, + count: content.split(marker).length - 1, + })) + .filter((entry) => entry.count > 0); +} + +function parseGitStatusShort(output) { + const entries = normalizeText(output) + .split(/\r?\n/) + .map((line) => line.trimEnd()) + .filter(Boolean) + .map((line) => ({ + raw: line, + status: line.slice(0, 2), + path: line.slice(3).trim(), + untracked: line.startsWith("??"), + })); + + const tracked = entries.filter((entry) => !entry.untracked); + const untracked = entries.filter((entry) => entry.untracked); + return { + entries, + trackedCount: tracked.length, + untrackedCount: untracked.length, + totalCount: entries.length, + }; +} + +function parsePackageJson(packageJsonText) { + if (!normalizeText(packageJsonText).trim()) { + return { ok: false, scripts: {}, error: "package_json_missing" }; + } + try { + const parsed = JSON.parse(packageJsonText); + return { + ok: true, + scripts: parsed?.scripts && typeof parsed.scripts === "object" ? parsed.scripts : {}, + error: "", + }; + } catch (error) { + return { + ok: false, + scripts: {}, + error: `package_json_invalid:${error instanceof Error ? error.message : String(error)}`, + }; + } +} + +function analyzeScripts(scripts, scriptFiles = {}) { + const entries = Object.entries(scripts || {}).map(([name, command]) => ({ + name, + command: normalizeText(command), + })); + const highRisk = []; + + for (const entry of entries) { + for (const rule of HIGH_RISK_SCRIPT_PATTERNS) { + const nameMatches = + rule.scriptName === "e2e" + ? entry.name.toLowerCase().includes("e2e") + : entry.name === rule.scriptName; + const inspectedText = + entry.name === "build" + ? `${entry.command}\n${normalizeText(scriptFiles.build)}` + : entry.command; + if (nameMatches && rule.pattern.test(inspectedText)) { + highRisk.push({ + name: entry.name, + command: entry.command, + reason: rule.reason, + }); + } + } + } + + return { + entries, + highRisk, + }; +} + +function createFileMarkerReport({ exists, content }) { + return { + exists: Boolean(exists), + privateMarkers: countMarkerHits(content, PRIVATE_BRIDGE_MARKERS), + sdkMarkers: countMarkerHits(content, SDK_FACADE_MARKERS), + }; +} + +function createDistArtifactReport(entries = []) { + const normalizedEntries = entries.map((entry) => ({ + status: normalizeText(entry.status), + src: normalizeText(entry.src), + dist: normalizeText(entry.dist), + srcHash: normalizeText(entry.srcHash), + distHash: normalizeText(entry.distHash), + })); + const deltas = normalizedEntries.filter((entry) => entry.status !== "same"); + + return { + entries: normalizedEntries, + deltas, + totalDeltas: deltas.length, + diffCount: deltas.filter((entry) => entry.status === "diff").length, + missingDistCount: deltas.filter((entry) => entry.status === "missing-dist").length, + extraDistCount: deltas.filter((entry) => entry.status === "extra-dist").length, + }; +} + +function createVerdict({ gitStatus, hostBridge, uiTest, scripts, distArtifacts }) { + const blockers = []; + const warnings = []; + + if (!hostBridge.exists) { + blockers.push("src/ui/host-bridge.js is missing"); + } + if (!uiTest.exists) { + warnings.push("tests/ui.test.mjs is missing; package-side bridge regression is not visible"); + } + + if (hostBridge.privateMarkers.length > 0 || uiTest.privateMarkers.length > 0) { + blockers.push("private Host Bridge transport markers are still present"); + } + if (hostBridge.sdkMarkers.length === 0) { + blockers.push("SDK facade markers are not present in src/ui/host-bridge.js"); + } + if (gitStatus.totalCount > 0) { + warnings.push( + `package worktree is dirty: tracked=${gitStatus.trackedCount}, untracked=${gitStatus.untrackedCount}`, + ); + } + if (scripts.highRisk.length > 0) { + warnings.push("build/verify/e2e scripts may rewrite dist artifacts"); + } + if (distArtifacts.totalDeltas > 0) { + warnings.push(`dist artifacts are not synchronized: ${distArtifacts.totalDeltas} delta(s)`); + } + + const status = blockers.length > 0 ? "blocked" : warnings.length > 0 ? "needs_handoff" : "ready"; + const nextAction = + status === "ready" + ? "Run package tests and the agreed package verify gate." + : status === "needs_handoff" + ? "Confirm owner handoff before changing or rebuilding package artifacts." + : "Do not claim P18.5.3 complete; remove private bridge transport through the SDK facade first."; + + return { + status, + blockers, + warnings, + nextAction, + }; +} + +function createAgentAppPackageHandoffReport({ + generatedAt = new Date().toISOString(), + packageDir = "", + gitStatusShort = "", + files = {}, + packageJsonText = "", +} = {}) { + const gitStatus = parseGitStatusShort(gitStatusShort); + const hostBridge = createFileMarkerReport(files.hostBridge || {}); + const uiTest = createFileMarkerReport(files.uiTest || {}); + const packageJson = parsePackageJson(packageJsonText); + const scripts = analyzeScripts(packageJson.scripts, { + build: files.buildScript?.content, + }); + const distArtifacts = createDistArtifactReport(files.distArtifacts || []); + const verdict = createVerdict({ gitStatus, hostBridge, uiTest, scripts, distArtifacts }); + + return { + schemaVersion: "v1", + generatedAt, + packageDir, + gitStatus, + files: { + hostBridge, + uiTest, + }, + packageJson, + scripts, + distArtifacts, + verdict, + }; +} + +export { + PRIVATE_BRIDGE_MARKERS, + SDK_FACADE_MARKERS, + analyzeScripts, + createAgentAppPackageHandoffReport, + createDistArtifactReport, + countMarkerHits, + parseGitStatusShort, +}; diff --git a/scripts/lib/agent-app-package-handoff-core.test.ts b/scripts/lib/agent-app-package-handoff-core.test.ts new file mode 100644 index 000000000..68524bf93 --- /dev/null +++ b/scripts/lib/agent-app-package-handoff-core.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from "vitest"; + +import { + analyzeScripts, + createAgentAppPackageHandoffReport, + createDistArtifactReport, + countMarkerHits, + parseGitStatusShort, +} from "./agent-app-package-handoff-core.mjs"; + +describe("agent-app-package-handoff-core", () => { + it("统计 git status 的 tracked / untracked 条目", () => { + const status = parseGitStatusShort(" M src/ui/app.js\n?? src/ui/host-bridge.js\n"); + + expect(status.trackedCount).toBe(1); + expect(status.untrackedCount).toBe(1); + expect(status.totalCount).toBe(2); + }); + + it("识别私有 Host Bridge marker 与 SDK marker", () => { + const markers = countMarkerHits( + "pendingRequests requestHostBridge createLimeHostBridgeCapabilityInvoker", + ["pendingRequests", "requestHostBridge", "createLimeHostBridgeCapabilityInvoker"], + ); + + expect(markers.map((entry) => entry.marker)).toEqual([ + "pendingRequests", + "requestHostBridge", + "createLimeHostBridgeCapabilityInvoker", + ]); + }); + + it("把仍保留私有 bridge transport 的 package 判为 blocked", () => { + const report = createAgentAppPackageHandoffReport({ + generatedAt: "2026-05-16T00:00:00.000Z", + packageDir: "/tmp/content-factory-app", + gitStatusShort: " M src/ui/app.js\n?? src/ui/host-bridge.js\n", + files: { + hostBridge: { + exists: true, + content: "const pendingRequests = new Map(); window.parent.postMessage({ type: 'capability:invoke' });", + }, + uiTest: { + exists: true, + content: "message.type === 'capability:invoke'", + }, + }, + packageJsonText: JSON.stringify({ + scripts: { + test: "node --test tests/*.test.mjs", + verify: "npm run build && npm run test", + }, + }), + }); + + expect(report.verdict.status).toBe("blocked"); + expect(report.verdict.blockers).toContain("private Host Bridge transport markers are still present"); + expect(report.verdict.warnings).toContain("package worktree is dirty: tracked=1, untracked=1"); + }); + + it("无私有 marker 但 dirty 时要求 handoff", () => { + const report = createAgentAppPackageHandoffReport({ + gitStatusShort: " M src/ui/host-bridge.js\n", + files: { + hostBridge: { + exists: true, + content: "createLimeHostBridgeCapabilityInvoker(); createLimeCoreCapabilityAdapters();", + }, + uiTest: { + exists: true, + content: "sdkInvokerCallLog", + }, + }, + packageJsonText: JSON.stringify({ + scripts: { + test: "node --test tests/*.test.mjs", + }, + }), + }); + + expect(report.verdict.status).toBe("needs_handoff"); + expect(report.verdict.blockers).toEqual([]); + }); + + it("识别会重建 dist 的 build / verify / e2e 脚本", () => { + const scripts = analyzeScripts( + { + build: "node scripts/build.mjs", + verify: "npm run build && npm run test", + "e2e:user-flow": "npm run build && playwright test", + }, + { + build: "await rm('dist', { recursive: true, force: true });", + }, + ); + + expect(scripts.highRisk.map((entry) => entry.name)).toEqual([ + "build", + "verify", + "e2e:user-flow", + ]); + }); + + it("统计 src / dist 产物漂移并作为 handoff warning", () => { + const distArtifacts = createDistArtifactReport([ + { + status: "same", + src: "src/ui/app.js", + dist: "dist/ui/app.js", + srcHash: "aaa", + distHash: "aaa", + }, + { + status: "diff", + src: "src/ui/host-bridge.js", + dist: "dist/ui/host-bridge.js", + srcHash: "bbb", + distHash: "ccc", + }, + { + status: "missing-dist", + src: "src/ui/lime-app-sdk.js", + dist: "dist/ui/lime-app-sdk.js", + srcHash: "ddd", + distHash: "", + }, + ]); + + expect(distArtifacts.totalDeltas).toBe(2); + expect(distArtifacts.diffCount).toBe(1); + expect(distArtifacts.missingDistCount).toBe(1); + + const report = createAgentAppPackageHandoffReport({ + files: { + hostBridge: { + exists: true, + content: "createLimeHostBridgeCapabilityInvoker(); createLimeCoreCapabilityAdapters();", + }, + uiTest: { + exists: true, + content: "sdkInvokerCallLog", + }, + distArtifacts: distArtifacts.entries, + }, + packageJsonText: JSON.stringify({ + scripts: { + test: "node --test tests/*.test.mjs", + }, + }), + }); + + expect(report.verdict.status).toBe("needs_handoff"); + expect(report.verdict.warnings).toContain("dist artifacts are not synchronized: 2 delta(s)"); + }); +}); diff --git a/scripts/verify-gui-smoke.mjs b/scripts/verify-gui-smoke.mjs index 818b6c6c4..66abc452f 100644 --- a/scripts/verify-gui-smoke.mjs +++ b/scripts/verify-gui-smoke.mjs @@ -1591,6 +1591,25 @@ async function main() { options.timeoutMs + 30_000, ); + await runCommand( + npmCommand, + [ + "run", + "smoke:agent-apps", + "--", + "--app-url", + options.appUrl, + "--health-url", + options.healthUrl, + "--timeout-ms", + String(options.timeoutMs), + "--interval-ms", + String(options.intervalMs), + ], + "smoke:agent-apps", + options.timeoutMs + 30_000, + ); + await runCommand( npmCommand, [ diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a567d36a4..5ccdd6845 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5131,7 +5131,7 @@ dependencies = [ [[package]] name = "lime" -version = "1.40.0" +version = "1.41.0" dependencies = [ "anyhow", "arboard", @@ -5238,7 +5238,7 @@ dependencies = [ [[package]] name = "lime-agent" -version = "1.40.0" +version = "1.41.0" dependencies = [ "anyhow", "aster-core", @@ -5267,7 +5267,7 @@ dependencies = [ [[package]] name = "lime-browser-runtime" -version = "1.40.0" +version = "1.41.0" dependencies = [ "chrono", "futures", @@ -5284,7 +5284,7 @@ dependencies = [ [[package]] name = "lime-cli" -version = "1.40.0" +version = "1.41.0" dependencies = [ "clap", "lime-core", @@ -5296,7 +5296,7 @@ dependencies = [ [[package]] name = "lime-config" -version = "1.40.0" +version = "1.41.0" dependencies = [ "async-trait", "lime-core", @@ -5312,7 +5312,7 @@ dependencies = [ [[package]] name = "lime-core" -version = "1.40.0" +version = "1.41.0" dependencies = [ "aster-models", "async-trait", @@ -5366,7 +5366,7 @@ dependencies = [ [[package]] name = "lime-gateway" -version = "1.40.0" +version = "1.41.0" dependencies = [ "aes", "axum 0.7.9", @@ -5396,7 +5396,7 @@ dependencies = [ [[package]] name = "lime-infra" -version = "1.40.0" +version = "1.41.0" dependencies = [ "chrono", "dashmap 5.5.3", @@ -5416,7 +5416,7 @@ dependencies = [ [[package]] name = "lime-knowledge" -version = "1.40.0" +version = "1.41.0" dependencies = [ "chrono", "hex", @@ -5429,7 +5429,7 @@ dependencies = [ [[package]] name = "lime-mcp" -version = "1.40.0" +version = "1.41.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5445,7 +5445,7 @@ dependencies = [ [[package]] name = "lime-media-runtime" -version = "1.40.0" +version = "1.41.0" dependencies = [ "axum 0.7.9", "base64 0.22.1", @@ -5478,7 +5478,7 @@ dependencies = [ [[package]] name = "lime-processor" -version = "1.40.0" +version = "1.41.0" dependencies = [ "async-trait", "lime-core", @@ -5497,7 +5497,7 @@ dependencies = [ [[package]] name = "lime-providers" -version = "1.40.0" +version = "1.41.0" dependencies = [ "anyhow", "async-stream", @@ -5552,7 +5552,7 @@ dependencies = [ [[package]] name = "lime-server" -version = "1.40.0" +version = "1.41.0" dependencies = [ "aster-core", "async-stream", @@ -5596,7 +5596,7 @@ dependencies = [ [[package]] name = "lime-server-utils" -version = "1.40.0" +version = "1.41.0" dependencies = [ "axum 0.7.9", "futures", @@ -5611,7 +5611,7 @@ dependencies = [ [[package]] name = "lime-services" -version = "1.40.0" +version = "1.41.0" dependencies = [ "anyhow", "aster-core", @@ -5656,7 +5656,7 @@ dependencies = [ [[package]] name = "lime-skills" -version = "1.40.0" +version = "1.41.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5674,7 +5674,7 @@ dependencies = [ [[package]] name = "lime-websocket" -version = "1.40.0" +version = "1.41.0" dependencies = [ "axum 0.7.9", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3183e85bf..423a1a569 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -9,7 +9,7 @@ exclude = [ resolver = "2" [workspace.package] -version = "1.40.0" +version = "1.41.0" edition = "2021" authors = ["coso"] repository = "https://github.com/aiclientproxy/lime" @@ -199,7 +199,7 @@ version = "2.4.9" [package] name = "lime" -version = "1.40.0" +version = "1.41.0" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" diff --git a/src-tauri/crates/agent/src/lib.rs b/src-tauri/crates/agent/src/lib.rs index 185ff6be9..cce95d14f 100644 --- a/src-tauri/crates/agent/src/lib.rs +++ b/src-tauri/crates/agent/src/lib.rs @@ -120,7 +120,7 @@ pub use session_query::{ }; pub use session_state_snapshot::SessionStateSnapshot; pub use session_store::{ - count_session_messages_sync, create_session_sync, delete_session, + count_session_messages_sync, create_session_sync, create_session_with_id_sync, delete_session, get_persisted_session_metadata_sync, get_runtime_session_detail, get_runtime_session_detail_with_history_limit, get_runtime_session_detail_with_history_page, get_runtime_session_detail_with_history_window, get_session_sync, list_sessions_sync, diff --git a/src-tauri/crates/agent/src/session_execution_runtime.rs b/src-tauri/crates/agent/src/session_execution_runtime.rs index 7f180f39d..60e175b03 100644 --- a/src-tauri/crates/agent/src/session_execution_runtime.rs +++ b/src-tauri/crates/agent/src/session_execution_runtime.rs @@ -465,9 +465,19 @@ pub struct SessionExecutionRuntimeOemPolicy { pub can_invoke: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct SessionExecutionRuntimeSummary { + #[serde(skip_serializing_if = "Option::is_none")] + pub surface: Option, + #[serde(default, alias = "app_id", skip_serializing_if = "Option::is_none")] + pub app_id: Option, + #[serde(default, alias = "task_id", skip_serializing_if = "Option::is_none")] + pub task_id: Option, + #[serde(default, alias = "trace_id", skip_serializing_if = "Option::is_none")] + pub trace_id: Option, + #[serde(default, alias = "task_kind", skip_serializing_if = "Option::is_none")] + pub task_kind: Option, #[serde(skip_serializing_if = "Option::is_none")] pub candidate_count: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -1086,22 +1096,38 @@ fn extract_oem_policy_from_metadata( fn extract_runtime_summary_from_metadata( metadata: &std::collections::HashMap, ) -> Option { - let mut summary: SessionExecutionRuntimeSummary = - extract_lime_runtime_payload(metadata, LIME_RUNTIME_SUMMARY_KEY)?; - summary.routing_mode = normalize_optional_text(summary.routing_mode); - summary.decision_source = normalize_optional_text(summary.decision_source); - summary.decision_reason = normalize_optional_text(summary.decision_reason); - summary.estimated_cost_class = normalize_optional_text(summary.estimated_cost_class); - summary.limit_status = normalize_optional_text(summary.limit_status); - summary.limit_event_kind = normalize_optional_text(summary.limit_event_kind); - summary.limit_event_message = normalize_optional_text(summary.limit_event_message); - summary.capability_gap = normalize_optional_text(summary.capability_gap); - summary.permission_status = normalize_optional_text(summary.permission_status); + let runtime = extract_lime_runtime_object(metadata)?; + let mut summary: SessionExecutionRuntimeSummary = runtime + .get(LIME_RUNTIME_SUMMARY_KEY) + .and_then(|value| serde_json::from_value(value.clone()).ok()) + .unwrap_or_default(); + summary.surface = normalize_optional_text(summary.surface.take()) + .or_else(|| extract_text_from_object(runtime, &["surface"])); + summary.app_id = normalize_optional_text(summary.app_id.take()) + .or_else(|| extract_text_from_object(runtime, &["app_id", "appId"])); + summary.task_id = normalize_optional_text(summary.task_id.take()) + .or_else(|| extract_text_from_object(runtime, &["task_id", "taskId"])); + summary.trace_id = normalize_optional_text(summary.trace_id.take()) + .or_else(|| extract_text_from_object(runtime, &["trace_id", "traceId"])); + summary.task_kind = normalize_optional_text(summary.task_kind.take()) + .or_else(|| extract_text_from_object(runtime, &["task_kind", "taskKind"])); + summary.routing_mode = normalize_optional_text(summary.routing_mode.take()); + summary.decision_source = normalize_optional_text(summary.decision_source.take()); + summary.decision_reason = normalize_optional_text(summary.decision_reason.take()); + summary.estimated_cost_class = normalize_optional_text(summary.estimated_cost_class.take()); + summary.limit_status = normalize_optional_text(summary.limit_status.take()); + summary.limit_event_kind = normalize_optional_text(summary.limit_event_kind.take()); + summary.limit_event_message = normalize_optional_text(summary.limit_event_message.take()); + summary.capability_gap = normalize_optional_text(summary.capability_gap.take()); + summary.permission_status = normalize_optional_text(summary.permission_status.take()); summary.fallback_chain = summary .fallback_chain .into_iter() .filter_map(|value| normalize_optional_text(Some(value))) .collect(); + if summary == SessionExecutionRuntimeSummary::default() { + return None; + } Some(summary) } @@ -1398,6 +1424,8 @@ pub fn build_session_execution_runtime( && runtime.limit_state.is_none() && runtime.cost_state.is_none() && runtime.permission_state.is_none() + && runtime.oem_policy.is_none() + && runtime.runtime_summary.is_none() && runtime.limit_event.is_none() { return None; @@ -2356,6 +2384,64 @@ mod tests { assert!(runtime.limit_event.is_none()); } + #[test] + fn extracts_agent_app_scope_from_lime_runtime_summary_fallback() { + let now = Utc::now(); + let snapshot = SessionRuntimeSnapshot { + session_id: "session-agent-app".to_string(), + threads: vec![ThreadRuntimeSnapshot { + thread: ThreadRuntime::new( + "thread-1", + "session-agent-app", + PathBuf::from("/tmp/workspace"), + ), + turns: vec![TurnRuntime { + id: "turn-1".to_string(), + session_id: "session-agent-app".to_string(), + thread_id: "thread-1".to_string(), + status: TurnStatus::Completed, + input_text: Some("内容工厂任务".to_string()), + error_message: None, + context_override: Some(TurnContextOverride { + metadata: [( + "lime_runtime".to_string(), + json!({ + "surface": "agent_app", + "app_id": "content-factory-app", + "task_id": "task-1", + "trace_id": "trace-1", + "task_kind": "content_factory.copy.generate" + }), + )] + .into_iter() + .collect(), + ..TurnContextOverride::default() + }), + output_schema_runtime: None, + created_at: now, + started_at: Some(now), + completed_at: Some(now), + updated_at: now, + }], + items: Vec::new(), + }], + }; + + let runtime = + build_session_execution_runtime("session-agent-app", None, None, Some(&snapshot), None) + .expect("runtime"); + let summary = runtime.runtime_summary.expect("agent app scope summary"); + + assert_eq!(summary.surface.as_deref(), Some("agent_app")); + assert_eq!(summary.app_id.as_deref(), Some("content-factory-app")); + assert_eq!(summary.task_id.as_deref(), Some("task-1")); + assert_eq!(summary.trace_id.as_deref(), Some("trace-1")); + assert_eq!( + summary.task_kind.as_deref(), + Some("content_factory.copy.generate") + ); + } + #[test] fn extracts_cost_state_and_limit_event_from_latest_turn() { let now = Utc::now(); diff --git a/src-tauri/crates/agent/src/session_store.rs b/src-tauri/crates/agent/src/session_store.rs index e0dfe3126..d93e771b3 100644 --- a/src-tauri/crates/agent/src/session_store.rs +++ b/src-tauri/crates/agent/src/session_store.rs @@ -1007,6 +1007,35 @@ pub fn create_session_sync( Ok(session.id) } +/// 使用指定 ID 创建新会话。 +/// +/// 仅供内部运行时为非用户可见的辅助会话保留稳定前缀,普通入口应继续使用 +/// `create_session_sync` 生成随机会话 ID。 +pub fn create_session_with_id_sync( + db: &DbConnection, + session_id: String, + name: Option, + working_dir: Option, + workspace_id: String, + execution_strategy: Option, +) -> Result { + let session_id = normalize_optional_text(Some(session_id)) + .ok_or_else(|| "session_id 不能为空".to_string())?; + let session = create_session_record_sync( + db, + CreateSessionRecordInput { + session_id: Some(session_id), + title: Some(normalize_optional_text(name).unwrap_or_else(|| "新对话".to_string())), + working_dir, + workspace_id: Some(workspace_id), + execution_strategy, + ..CreateSessionRecordInput::default() + }, + )?; + + Ok(session.id) +} + /// 列出所有会话 pub fn list_sessions_sync( db: &DbConnection, diff --git a/src-tauri/src/agent/aster_agent.rs b/src-tauri/src/agent/aster_agent.rs index 53cccdf57..93b8bac5d 100644 --- a/src-tauri/src/agent/aster_agent.rs +++ b/src-tauri/src/agent/aster_agent.rs @@ -68,6 +68,25 @@ impl AsterAgentWrapper { lime_agent::create_session_sync(db, name, working_dir, workspace_id, execution_strategy) } + /// 使用指定 ID 创建新会话。 + pub fn create_session_with_id_sync( + db: &DbConnection, + session_id: String, + name: Option, + working_dir: Option, + workspace_id: String, + execution_strategy: Option, + ) -> Result { + lime_agent::create_session_with_id_sync( + db, + session_id, + name, + working_dir, + workspace_id, + execution_strategy, + ) + } + /// 列出所有会话 pub fn list_sessions_sync( db: &DbConnection, diff --git a/src-tauri/src/commands/agent_app_cmd.rs b/src-tauri/src/commands/agent_app_cmd.rs index 1db30ce61..d58cb6c75 100644 --- a/src-tauri/src/commands/agent_app_cmd.rs +++ b/src-tauri/src/commands/agent_app_cmd.rs @@ -503,6 +503,7 @@ pub async fn agent_app_start_ui_runtime( start_agent_app_ui_runtime_with_env(request, runtime_env).await } +#[cfg(debug_assertions)] pub async fn agent_app_start_ui_runtime_for_dev_bridge( request: AgentAppUiRuntimeStartRequest, config: &Config, diff --git a/src-tauri/src/commands/agent_app_runtime_cmd.rs b/src-tauri/src/commands/agent_app_runtime_cmd.rs index 8783d14f0..949f8fe3c 100644 --- a/src-tauri/src/commands/agent_app_runtime_cmd.rs +++ b/src-tauri/src/commands/agent_app_runtime_cmd.rs @@ -9,112 +9,31 @@ use crate::commands::api_key_provider_cmd::ApiKeyProviderServiceState; use crate::commands::aster_agent_cmd::action_runtime::agent_runtime_respond_action; use crate::commands::aster_agent_cmd::{ agent_runtime_get_thread_read, agent_runtime_interrupt_turn, build_queued_turn_task, - create_runtime_session_internal_with_runtime, AgentRuntimeInterruptTurnRequest, - AgentRuntimeRespondActionRequest, AgentRuntimeThreadReadModel, AsterChatRequest, - AsterExecutionStrategy, RuntimeCommandContext, + create_runtime_session_internal_with_runtime_and_session_id, AgentRuntimeInterruptTurnRequest, + AgentRuntimeRespondActionRequest, AgentRuntimeThreadArtifactView, AgentRuntimeThreadReadModel, + AsterChatRequest, AsterExecutionStrategy, RuntimeCommandContext, }; use crate::config::GlobalConfigManagerState; use crate::database::DbConnection; use crate::mcp::McpManagerState; +use crate::services::agent_app_runtime_capability_catalog_service::{ + resolve_capability_descriptors, AgentAppRuntimeCapabilityDescriptor, +}; use crate::services::automation_service::AutomationServiceState; use chrono::Utc; +use lime_core::database::dao::agent_run::{AgentRun, AgentRunDao, AgentRunStatus}; +use lime_core::database::dao::api_key_provider::ProviderWithKeys; use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; -use tauri::{AppHandle, State}; +use tauri::{AppHandle, Emitter, State}; use uuid::Uuid; const AGENT_APP_RUNTIME_EVENT_PREFIX: &str = "agent_app_runtime"; const AGENT_APP_RUNTIME_METADATA_KEY: &str = "agent_app_runtime"; const LIME_RUNTIME_METADATA_KEY: &str = "lime_runtime"; const AGENT_APP_RUNTIME_CAPABILITY_SOURCE: &str = "agent_app_runtime"; - -#[derive(Debug, Clone, Copy)] -struct AgentAppRuntimeCapabilityDescriptor { - capability_id: &'static str, - aliases: &'static [&'static str], - launch_key: &'static str, - context_key: &'static str, - default_kind: &'static str, - skill_name: &'static str, -} - -const AGENT_APP_RUNTIME_CAPABILITY_DESCRIPTORS: &[AgentAppRuntimeCapabilityDescriptor] = &[ - AgentAppRuntimeCapabilityDescriptor { - capability_id: "lime.capability.image.generate", - aliases: &[ - "lime.capability.image.generate", - "image.generate", - "image_generation", - "image", - "asset.generate", - ], - launch_key: "image_skill_launch", - context_key: "image_task", - default_kind: "image_task", - skill_name: "image_generate", - }, - AgentAppRuntimeCapabilityDescriptor { - capability_id: "lime.capability.cover.generate", - aliases: &[ - "lime.capability.cover.generate", - "cover.generate", - "cover_generation", - "cover", - ], - launch_key: "cover_skill_launch", - context_key: "cover_task", - default_kind: "cover_task", - skill_name: "cover_generate", - }, - AgentAppRuntimeCapabilityDescriptor { - capability_id: "lime.capability.research.search", - aliases: &[ - "lime.capability.research.search", - "research.search", - "research", - "web_search", - "search", - ], - launch_key: "research_skill_launch", - context_key: "research_request", - default_kind: "research_request", - skill_name: "research", - }, - AgentAppRuntimeCapabilityDescriptor { - capability_id: "lime.capability.report.generate", - aliases: &[ - "lime.capability.report.generate", - "report.generate", - "report", - "competitor_report", - ], - launch_key: "report_skill_launch", - context_key: "report_request", - default_kind: "report_request", - skill_name: "report_generate", - }, - AgentAppRuntimeCapabilityDescriptor { - capability_id: "lime.capability.pdf.read", - aliases: &["lime.capability.pdf.read", "pdf.read", "pdf_extract", "pdf"], - launch_key: "pdf_read_skill_launch", - context_key: "pdf_read_request", - default_kind: "pdf_read_request", - skill_name: "pdf_read", - }, - AgentAppRuntimeCapabilityDescriptor { - capability_id: "lime.capability.summary.generate", - aliases: &[ - "lime.capability.summary.generate", - "summary.generate", - "summary", - "text_summary", - ], - launch_key: "summary_skill_launch", - context_key: "summary_request", - default_kind: "summary_request", - skill_name: "summary", - }, -]; +const CONTENT_FACTORY_WORKSPACE_PATCH_KIND: &str = "content_factory.workspace_patch"; +const AGENT_APP_RUNTIME_SESSION_ID_PREFIX: &str = "agent-app-runtime-"; #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] @@ -152,6 +71,10 @@ pub struct AgentAppRuntimeStartTaskRequest { #[serde(default)] pub turn_id: Option, #[serde(default)] + pub provider_preference: Option, + #[serde(default)] + pub model_preference: Option, + #[serde(default)] pub queue_if_busy: Option, #[serde(default)] pub skip_pre_submit_resume: Option, @@ -223,6 +146,8 @@ pub struct AgentAppRuntimeTaskEvent { #[serde(skip_serializing_if = "Option::is_none")] pub evidence_ref: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub artifact_ref: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub occurred_at: Option, #[serde(skip_serializing_if = "Option::is_none")] pub payload: Option, @@ -256,6 +181,13 @@ pub struct AgentAppRuntimeSubmitHostResponseResult { pub status: String, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct AgentAppRuntimeModelPreference { + provider_preference: String, + model_preference: String, + source: &'static str, +} + fn non_empty(value: Option<&str>) -> Option { value .map(str::trim) @@ -263,10 +195,250 @@ fn non_empty(value: Option<&str>) -> Option { .map(str::to_string) } +fn new_agent_app_runtime_session_id() -> String { + format!("{}{}", AGENT_APP_RUNTIME_SESSION_ID_PREFIX, Uuid::new_v4()) +} + +fn agent_app_runtime_event_name(app_id: &str, task_id: &str) -> String { + format!("{AGENT_APP_RUNTIME_EVENT_PREFIX}:{app_id}:{task_id}") +} + fn require_text(value: Option<&str>, label: &str) -> Result { non_empty(value).ok_or_else(|| format!("{label} 不能为空")) } +fn is_unconfigured_model_preference(value: &str) -> bool { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "" | "unconfigured" | "unknown" | "none" | "null" + ) +} + +fn model_preference_from_values( + provider_preference: Option, + model_preference: Option, + source: &'static str, +) -> Option { + let provider_preference = provider_preference + .and_then(|value| non_empty(Some(value.as_str()))) + .filter(|value| !is_unconfigured_model_preference(value))?; + let model_preference = model_preference + .and_then(|value| non_empty(Some(value.as_str()))) + .filter(|value| !is_unconfigured_model_preference(value))?; + + Some(AgentAppRuntimeModelPreference { + provider_preference, + model_preference, + source, + }) +} + +fn json_pointer_string(value: &Value, pointers: &[&str]) -> Option { + pointers.iter().find_map(|pointer| { + value + .pointer(pointer) + .and_then(Value::as_str) + .and_then(|value| non_empty(Some(value))) + }) +} + +fn model_preference_from_run_metadata(metadata: &Value) -> Option { + let provider_preference = json_pointer_string( + metadata, + &[ + "/turn_input/provider_routing/provider_selector", + "/turnInput/providerRouting/providerSelector", + "/request_metadata/lime_runtime/routing_decision/selected_provider", + "/request_metadata/lime_runtime/routing_decision/selectedProvider", + "/requestMetadata/limeRuntime/routingDecision/selectedProvider", + ], + ); + let model_preference = json_pointer_string( + metadata, + &[ + "/turn_input/provider_routing/model_name", + "/turnInput/providerRouting/modelName", + "/request_metadata/lime_runtime/routing_decision/selected_model", + "/request_metadata/lime_runtime/routing_decision/selectedModel", + "/requestMetadata/limeRuntime/routingDecision/selectedModel", + ], + ); + + model_preference_from_values( + provider_preference, + model_preference, + "recent_successful_agent_run", + ) +} + +fn model_preference_from_recent_successful_runs( + db: &DbConnection, +) -> Option { + let runs = { + let conn = match db.lock() { + Ok(conn) => conn, + Err(error) => { + tracing::warn!( + "[AgentAppRuntime] 读取最近模型偏好时数据库锁定失败: {}", + error + ); + return None; + } + }; + match AgentRunDao::list_runs(&conn, 50, 0) { + Ok(runs) => runs, + Err(error) => { + tracing::warn!( + "[AgentAppRuntime] 读取最近 agent_runs 失败,跳过模型偏好回填: {}", + error + ); + return None; + } + } + }; + + runs.iter() + .filter(|run| matches!(run.status, AgentRunStatus::Success)) + .find_map(model_preference_from_agent_run) +} + +fn model_preference_from_agent_run(run: &AgentRun) -> Option { + let metadata = run.metadata.as_deref()?; + let metadata: Value = serde_json::from_str(metadata).ok()?; + model_preference_from_run_metadata(&metadata) +} + +fn provider_looks_non_chat_agent_runtime_candidate(provider: &ProviderWithKeys) -> bool { + let text = [ + provider.provider.id.as_str(), + provider.provider.name.as_str(), + provider.provider.api_host.as_str(), + ] + .join(" ") + .to_ascii_lowercase(); + + text.contains("fal") + || text.contains("codex") + || text.contains("coding") + || text.contains("gpt-image") + || text.contains("gpt_images") +} + +fn model_preference_from_enabled_provider_catalog( + db: &DbConnection, + api_key_provider_service: &ApiKeyProviderServiceState, +) -> Option { + let providers = match api_key_provider_service.0.get_all_providers(db) { + Ok(providers) => providers, + Err(error) => { + tracing::warn!( + "[AgentAppRuntime] 读取 API Key Providers 失败,跳过模型偏好回填: {}", + error + ); + return None; + } + }; + + providers.into_iter().find_map(|provider| { + if !provider.provider.enabled { + return None; + } + if provider_looks_non_chat_agent_runtime_candidate(&provider) { + return None; + } + if !provider.api_keys.iter().any(|key| key.enabled) { + return None; + } + let model = provider + .provider + .custom_models + .iter() + .find_map(|model| non_empty(Some(model.as_str())))?; + model_preference_from_values( + Some(provider.provider.id), + Some(model), + "enabled_provider_custom_model", + ) + }) +} + +async fn resolve_agent_app_runtime_model_preference( + state: &AsterAgentState, + db: &DbConnection, + api_key_provider_service: &ApiKeyProviderServiceState, + request: &AgentAppRuntimeStartTaskRequest, +) -> Option { + if let Some(preference) = model_preference_from_values( + request.provider_preference.clone(), + request.model_preference.clone(), + "request", + ) { + return Some(preference); + } + + if let Some(preference) = model_preference_from_recent_successful_runs(db) { + return Some(preference); + } + + if let Some(preference) = + model_preference_from_enabled_provider_catalog(db, api_key_provider_service) + { + return Some(preference); + } + + if let Some(config) = state.get_provider_config().await { + if let Some(preference) = model_preference_from_values( + config + .provider_selector + .clone() + .or_else(|| Some(config.provider_name.clone())), + Some(config.model_name.clone()), + "current_agent_state", + ) { + return Some(preference); + } + } + + None +} + +fn insert_agent_app_runtime_model_preference_metadata( + metadata: &mut Value, + preference: &AgentAppRuntimeModelPreference, +) { + let Some(root) = metadata.as_object_mut() else { + return; + }; + let preference_value = json!({ + "provider_preference": preference.provider_preference.clone(), + "model_preference": preference.model_preference.clone(), + "source": preference.source, + }); + + let harness = root + .entry("harness".to_string()) + .or_insert_with(|| json!({})); + if let Some(harness) = harness.as_object_mut() { + harness.insert( + "agent_app_runtime_model_preference".to_string(), + preference_value.clone(), + ); + if let Some(app_runtime) = harness + .get_mut(AGENT_APP_RUNTIME_METADATA_KEY) + .and_then(Value::as_object_mut) + { + app_runtime.insert("model_preference".to_string(), preference_value.clone()); + } + } + + if let Some(app_runtime) = root + .get_mut(AGENT_APP_RUNTIME_METADATA_KEY) + .and_then(Value::as_object_mut) + { + app_runtime.insert("model_preference".to_string(), preference_value); + } +} + fn default_task_message(request: &AgentAppRuntimeStartTaskRequest) -> String { let title = non_empty(request.title.as_deref()) .or_else(|| non_empty(request.prompt.as_deref())) @@ -305,43 +477,117 @@ fn default_task_message(request: &AgentAppRuntimeStartTaskRequest) -> String { .join("\n") } -fn capability_match_token(value: &str) -> String { - value - .trim() - .chars() - .filter(|ch| ch.is_ascii_alphanumeric()) - .flat_map(char::to_lowercase) - .collect() +fn expected_artifact_kind(request: &AgentAppRuntimeStartTaskRequest) -> Option { + let expected_output = request.expected_output.as_ref()?.as_object()?; + [ + "artifactKind", + "artifact_type", + "artifactType", + "kind", + "outputKind", + ] + .iter() + .filter_map(|key| expected_output.get(*key).and_then(Value::as_str)) + .find_map(|value| non_empty(Some(value))) } -fn descriptor_matches_capability( - descriptor: &AgentAppRuntimeCapabilityDescriptor, - value: &str, -) -> bool { - let token = capability_match_token(value); - if token.is_empty() { - return false; +fn is_content_factory_runtime_task(request: &AgentAppRuntimeStartTaskRequest) -> bool { + request.app_id.trim() == "content-factory-app" + || request.task_kind.trim().starts_with("content_factory.") +} + +fn build_agent_app_output_contract(request: &AgentAppRuntimeStartTaskRequest) -> Option { + if !is_content_factory_runtime_task(request) { + return None; + } + let artifact_kind = expected_artifact_kind(request)?; + Some(json!({ + "producer": "agent_runtime_artifact_metadata", + "artifact_kind": artifact_kind, + "artifact_metadata_kind": CONTENT_FACTORY_WORKSPACE_PATCH_KIND, + "patch_metadata_keys": ["contentFactoryWorkspacePatch", "workspacePatch"], + "required_patch_fields": ["kind", "projectId"], + "accepted_patch_fields": [ + "workspace", + "project", + "sceneTable", + "contentBatch", + "scripts", + "imagePrompts", + "assetPack" + ], + })) +} + +fn build_agent_app_runtime_task_message(request: &AgentAppRuntimeStartTaskRequest) -> String { + let prompt = non_empty(request.prompt.as_deref()) + .or_else(|| non_empty(request.title.as_deref())) + .unwrap_or_else(|| request.task_kind.trim().to_string()); + let input = request + .input + .as_ref() + .map(|value| serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string())) + .unwrap_or_else(|| "{}".to_string()); + let expected_output = request + .expected_output + .as_ref() + .map(|value| serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string())) + .unwrap_or_else(|| "{}".to_string()); + let mut lines = vec![ + "【Agent App Runtime Task】".to_string(), + format!("App: {}", request.app_id.trim()), + format!( + "Entry: {}", + request.entry_key.as_deref().unwrap_or("default").trim() + ), + format!("TaskKind: {}", request.task_kind.trim()), + "".to_string(), + "Business Prompt:".to_string(), + prompt, + "".to_string(), + "Runtime Boundary:".to_string(), + "- 请在 Lime AgentRuntime 主链中完成这个 App 业务任务。".to_string(), + "- 不要要求用户跳回通用 Chat;如需补充上下文,请通过可审计的 action / request 机制表达。" + .to_string(), + ]; + + if let Some(contract) = build_agent_app_output_contract(request) { + let artifact_kind = contract + .get("artifact_kind") + .and_then(Value::as_str) + .unwrap_or("content_batch"); + lines.extend([ + "".to_string(), + "Content Factory Output Contract:".to_string(), + format!( + "- 如果任务产出可直接物化到内容工厂项目,必须创建 artifactKind={artifact_kind} 的 artifact。" + ), + format!( + "- artifact metadata 必须包含 contentFactoryWorkspacePatch 或 workspacePatch;metadata.kind 可使用 {}。", + CONTENT_FACTORY_WORKSPACE_PATCH_KIND + ), + "- 不要通过 Bash、shell、脚本或直接写 .lime/artifacts 文件来伪造 artifact;最终回答应直接输出结构化 JSON。" + .to_string(), + "- 最终回答的顶层 JSON 必须包含 contentFactoryWorkspacePatch 或 workspacePatch,方便 Host 自动回写当前 App 页面。" + .to_string(), + "- patch 至少包含 kind / projectId,并按结果类型填写 sceneTable、contentBatch、scripts、imagePrompts 或 assetPack。" + .to_string(), + "- tools / capabilityHints 只是可选能力提示,不能把复合内容工厂任务改写成单一 research / image Skill;除非任务明确要求真实搜索或生图,否则先直接产出 workspace patch。" + .to_string(), + "- 不要只返回自然语言总结;结构化 patch 是 App 自动回写当前页面的事实源。".to_string(), + ]); } - descriptor - .aliases - .iter() - .any(|alias| capability_match_token(alias) == token) -} + lines.extend([ + "".to_string(), + "Input JSON:".to_string(), + input, + "".to_string(), + "Expected Output JSON:".to_string(), + expected_output, + ]); -fn resolve_primary_capability_descriptor( - request: &AgentAppRuntimeStartTaskRequest, -) -> Option { - request - .required_capabilities - .iter() - .chain(request.capability_hints.iter()) - .find_map(|value| { - AGENT_APP_RUNTIME_CAPABILITY_DESCRIPTORS - .iter() - .copied() - .find(|descriptor| descriptor_matches_capability(descriptor, value)) - }) + lines.join("\n") } fn insert_string_if_some(map: &mut Map, key: &str, value: Option) { @@ -444,6 +690,62 @@ fn build_agent_app_capability_request_context( context } +fn resolve_agent_app_runtime_capability_descriptors( + request: &AgentAppRuntimeStartTaskRequest, +) -> Vec { + resolve_capability_descriptors( + request + .required_capabilities + .iter() + .map(String::as_str) + .chain(request.capability_hints.iter().map(String::as_str)), + ) +} + +fn capability_descriptor_metadata(descriptor: AgentAppRuntimeCapabilityDescriptor) -> Value { + json!({ + "capability_id": descriptor.capability_id, + "skill_name": descriptor.skill_name, + "launch_key": descriptor.launch_key, + "context_key": descriptor.context_key, + "default_kind": descriptor.default_kind, + }) +} + +fn build_agent_app_capability_workflow_metadata( + request: &AgentAppRuntimeStartTaskRequest, + descriptors: &[AgentAppRuntimeCapabilityDescriptor], + output_contract: Option<&Value>, + inserts_primary_launch: bool, +) -> Option { + if descriptors.is_empty() { + return None; + } + + Some(json!({ + "source": AGENT_APP_RUNTIME_CAPABILITY_SOURCE, + "mode": if output_contract.is_some() { + "composite_output_contract" + } else if descriptors.len() > 1 { + "multi_capability" + } else { + "single_capability" + }, + "launch_policy": if inserts_primary_launch { + "primary_skill_launch" + } else { + "metadata_only" + }, + "requested_capabilities": request.required_capabilities.clone(), + "capability_hints": request.capability_hints.clone(), + "descriptors": descriptors + .iter() + .copied() + .map(capability_descriptor_metadata) + .collect::>(), + })) +} + fn insert_agent_app_capability_launch_metadata( root: &mut Map, request: &AgentAppRuntimeStartTaskRequest, @@ -471,6 +773,38 @@ fn insert_agent_app_capability_launch_metadata( harness.insert(descriptor.launch_key.to_string(), Value::Object(launch)); } +fn should_insert_agent_app_capability_launch_metadata( + request: &AgentAppRuntimeStartTaskRequest, + output_contract: Option<&Value>, +) -> bool { + // 内容工厂这类复合业务任务的 tools/capabilityHints 表示“可用能力”, + // 不能被提升为单一 Claw Skill 启动,否则会偏离 App 的 workspace patch 产物合同。 + if is_content_factory_runtime_task(request) && output_contract.is_some() { + return false; + } + + true +} + +fn insert_agent_app_output_contract_runtime_hints( + harness: &mut Map, + request: &AgentAppRuntimeStartTaskRequest, + output_contract: Option<&Value>, +) { + if !is_content_factory_runtime_task(request) || output_contract.is_none() { + return; + } + + // 内容工厂 patch 产出优先走直接回答 + ArtifactDocument 自动落盘, + // 避免复合业务任务被 FullRuntime 的通用工具链带偏成读文件 / Bash / 子代理循环。 + harness + .entry("chat_mode".to_string()) + .or_insert_with(|| json!("general")); + harness + .entry("session_mode".to_string()) + .or_insert_with(|| json!("general_workbench")); +} + fn push_task_event( events: &mut Vec, event_type: &str, @@ -489,6 +823,7 @@ fn push_task_event( request_id: None, tool_name: None, evidence_ref: None, + artifact_ref: None, occurred_at, payload, }); @@ -523,6 +858,110 @@ fn has_missing_context(context_summary: Option<&Value>) -> Option { } } +fn is_content_factory_workspace_patch_kind(value: &str) -> bool { + matches!( + value.trim(), + CONTENT_FACTORY_WORKSPACE_PATCH_KIND + | "contentFactoryWorkspacePatch" + | "workspace_patch" + | "workspacePatch" + ) +} + +fn has_content_factory_workspace_patch_fields(value: &Value) -> bool { + value.as_object().is_some_and(|object| { + object.contains_key("workspace") + || object.contains_key("project") + || object.contains_key("sceneTable") + || object.contains_key("contentBatch") + || object.contains_key("scripts") + || object.contains_key("imagePrompts") + || object.contains_key("assetPack") + }) +} + +fn extract_content_factory_workspace_patch(metadata: Option<&Value>) -> Option { + let metadata = metadata?; + for key in ["contentFactoryWorkspacePatch", "workspacePatch"] { + if let Some(value) = metadata.get(key) { + if has_content_factory_workspace_patch_fields(value) { + return Some(value.clone()); + } + } + } + + let artifact_kind = metadata + .get("artifactType") + .or_else(|| metadata.get("artifact_type")) + .or_else(|| metadata.get("kind")) + .or_else(|| metadata.get("outputKind")) + .and_then(Value::as_str); + if artifact_kind.is_some_and(is_content_factory_workspace_patch_kind) + && has_content_factory_workspace_patch_fields(metadata) + { + return Some(metadata.clone()); + } + + None +} + +fn parse_json_object_from_markdown(value: &str) -> Option { + let trimmed = value.trim(); + let candidate = if trimmed.starts_with("```") { + let without_opening = trimmed.lines().skip(1).collect::>().join("\n"); + without_opening + .rsplit_once("```") + .map(|(body, _)| body.trim().to_string()) + .unwrap_or(without_opening) + } else { + trimmed.to_string() + }; + + serde_json::from_str::(&candidate).ok().or_else(|| { + let start = candidate.find('{')?; + let end = candidate.rfind('}')?; + serde_json::from_str::(&candidate[start..=end]).ok() + }) +} + +fn extract_content_factory_workspace_patch_from_artifact_document( + metadata: Option<&Value>, +) -> Option { + let metadata = metadata?; + let artifact_document = metadata + .get("artifactDocument") + .or_else(|| metadata.get("artifact_document"))?; + let blocks = artifact_document.get("blocks")?.as_array()?; + + blocks.iter().find_map(|block| { + let text = block + .get("content") + .or_else(|| block.get("markdown")) + .and_then(Value::as_str)?; + let parsed = parse_json_object_from_markdown(text)?; + extract_content_factory_workspace_patch(Some(&parsed)) + }) +} + +fn build_artifact_event_payload(artifact: &AgentRuntimeThreadArtifactView) -> Option { + let artifact_value = serde_json::to_value(artifact).ok()?; + let workspace_patch = extract_content_factory_workspace_patch(artifact.metadata.as_ref()) + .or_else(|| { + extract_content_factory_workspace_patch_from_artifact_document( + artifact.metadata.as_ref(), + ) + }); + if let Some(workspace_patch) = workspace_patch { + return Some(json!({ + "artifact": artifact_value, + "workspacePatch": workspace_patch, + "contentFactoryWorkspacePatch": workspace_patch, + "producer": "agent_runtime_artifact_metadata", + })); + } + Some(artifact_value) +} + fn build_agent_app_runtime_task_events( thread_read: &AgentRuntimeThreadReadModel, ) -> Vec { @@ -540,6 +979,7 @@ fn build_agent_app_runtime_task_events( } let status_message = match thread_read.profile_status.as_str() { + "idle" => "任务已接收,等待 AgentRuntime 调度或回写进度".to_string(), "queued" => "任务已进入队列".to_string(), "running" => "任务正在执行".to_string(), "blocked" => "任务等待用户或权限响应".to_string(), @@ -577,6 +1017,7 @@ fn build_agent_app_runtime_task_events( request_id: Some(pending_request.id.clone()), tool_name: None, evidence_ref: None, + artifact_ref: None, occurred_at: pending_request.created_at.clone(), payload: serde_json::to_value(pending_request).ok(), }; @@ -615,11 +1056,37 @@ fn build_agent_app_runtime_task_events( request_id: None, tool_name: Some(tool_call.tool_name.clone()), evidence_ref: None, + artifact_ref: None, occurred_at: None, payload: serde_json::to_value(tool_call).ok(), }); } + for artifact in &thread_read.artifacts { + let payload = build_artifact_event_payload(artifact); + events.push(AgentAppRuntimeTaskEvent { + id: format!("artifact:created:{}", artifact.item_id), + event_type: "artifact:created".to_string(), + status: artifact.status.clone(), + message: artifact + .title + .clone() + .unwrap_or_else(|| format!("Artifact 已创建:{}", artifact.path)), + severity: (artifact.status == "failed").then(|| "error".to_string()), + turn_id: Some(artifact.turn_id.clone()), + request_id: None, + tool_name: None, + evidence_ref: None, + artifact_ref: Some(artifact.path.clone()), + occurred_at: artifact + .completed_at + .clone() + .or_else(|| artifact.updated_at.clone()) + .or_else(|| artifact.created_at.clone()), + payload, + }); + } + for evidence_ref in &thread_read.evidence_summary.evidence_refs { events.push(AgentAppRuntimeTaskEvent { id: format!("evidence:recorded:{evidence_ref}"), @@ -631,6 +1098,7 @@ fn build_agent_app_runtime_task_events( request_id: None, tool_name: None, evidence_ref: Some(evidence_ref.clone()), + artifact_ref: None, occurred_at: thread_read.updated_at.clone(), payload: None, }); @@ -672,6 +1140,7 @@ fn build_agent_app_runtime_task_events( request_id: None, tool_name: None, evidence_ref: None, + artifact_ref: None, occurred_at: outcome.ended_at.clone(), payload: serde_json::to_value(outcome).ok(), }); @@ -688,6 +1157,7 @@ fn build_agent_app_runtime_task_events( request_id: None, tool_name: None, evidence_ref: None, + artifact_ref: None, occurred_at: incident.detected_at.clone(), payload: serde_json::to_value(incident).ok(), }); @@ -696,6 +1166,38 @@ fn build_agent_app_runtime_task_events( events } +fn build_agent_app_runtime_task_snapshot_event_payload( + snapshot: &AgentAppRuntimeTaskSnapshot, +) -> Value { + let snapshot_value = serde_json::to_value(snapshot).unwrap_or_else(|_| json!({})); + json!({ + "type": "agent_app_runtime:taskSnapshot", + "eventType": "task:update", + "appId": snapshot.app_id.clone(), + "taskId": snapshot.task_id.clone(), + "sessionId": snapshot.session_id.clone(), + "taskStatus": snapshot.task_status.clone(), + "status": snapshot.status.clone(), + "task": snapshot_value.clone(), + "snapshot": snapshot_value, + "taskEvents": snapshot.task_events.clone(), + "threadRead": snapshot.thread_read.clone(), + "emittedAt": Utc::now().to_rfc3339(), + }) +} + +fn emit_agent_app_runtime_task_snapshot(app: &AppHandle, snapshot: &AgentAppRuntimeTaskSnapshot) { + let event_name = agent_app_runtime_event_name(&snapshot.app_id, &snapshot.task_id); + let payload = build_agent_app_runtime_task_snapshot_event_payload(snapshot); + if let Err(error) = app.emit(&event_name, payload) { + tracing::warn!( + "[AgentAppRuntime] 发送 App task projection event 失败: event_name={}, error={}", + event_name, + error + ); + } +} + fn build_agent_app_runtime_metadata( request: &AgentAppRuntimeStartTaskRequest, task_id: &str, @@ -706,7 +1208,7 @@ fn build_agent_app_runtime_metadata( metadata = json!({}); } - let app_runtime = json!({ + let mut app_runtime = json!({ "surface": "agent_app", "app_id": request.app_id.trim(), "entry_key": request.entry_key.as_deref().unwrap_or("").trim(), @@ -719,6 +1221,27 @@ fn build_agent_app_runtime_metadata( "knowledge_bindings": request.knowledge_bindings.clone(), "human_review": request.human_review.unwrap_or(false), }); + let output_contract = build_agent_app_output_contract(request); + let capability_descriptors = resolve_agent_app_runtime_capability_descriptors(request); + let should_insert_primary_capability_launch = + should_insert_agent_app_capability_launch_metadata(request, output_contract.as_ref()) + && !capability_descriptors.is_empty(); + let capability_workflow = build_agent_app_capability_workflow_metadata( + request, + &capability_descriptors, + output_contract.as_ref(), + should_insert_primary_capability_launch, + ); + if let (Some(app_runtime), Some(output_contract)) = + (app_runtime.as_object_mut(), output_contract.clone()) + { + app_runtime.insert("output_contract".to_string(), output_contract); + } + if let (Some(app_runtime), Some(capability_workflow)) = + (app_runtime.as_object_mut(), capability_workflow.clone()) + { + app_runtime.insert("capability_workflow".to_string(), capability_workflow); + } if let Some(root) = metadata.as_object_mut() { root.insert( @@ -732,7 +1255,18 @@ fn build_agent_app_runtime_metadata( lime_runtime.insert("surface".to_string(), json!("agent_app")); lime_runtime.insert("app_id".to_string(), json!(request.app_id.trim())); lime_runtime.insert("task_id".to_string(), json!(task_id)); + lime_runtime.insert("trace_id".to_string(), json!(trace_id)); lime_runtime.insert("task_kind".to_string(), json!(request.task_kind.trim())); + let runtime_summary = lime_runtime + .entry("runtime_summary".to_string()) + .or_insert_with(|| json!({})); + if let Some(runtime_summary) = runtime_summary.as_object_mut() { + runtime_summary.insert("surface".to_string(), json!("agent_app")); + runtime_summary.insert("app_id".to_string(), json!(request.app_id.trim())); + runtime_summary.insert("task_id".to_string(), json!(task_id)); + runtime_summary.insert("trace_id".to_string(), json!(trace_id)); + runtime_summary.insert("task_kind".to_string(), json!(request.task_kind.trim())); + } } { let harness = root @@ -743,12 +1277,31 @@ fn build_agent_app_runtime_metadata( AGENT_APP_RUNTIME_METADATA_KEY.to_string(), app_runtime.clone(), ); + if let Some(output_contract) = output_contract.clone() { + harness.insert( + "agent_app_runtime_output_contract".to_string(), + output_contract, + ); + } + insert_agent_app_output_contract_runtime_hints( + harness, + request, + output_contract.as_ref(), + ); + if let Some(capability_workflow) = capability_workflow.clone() { + harness.insert( + "agent_app_runtime_capability_workflow".to_string(), + capability_workflow, + ); + } } } - if let Some(descriptor) = resolve_primary_capability_descriptor(request) { - insert_agent_app_capability_launch_metadata( - root, request, task_id, trace_id, descriptor, - ); + if should_insert_primary_capability_launch { + if let Some(descriptor) = capability_descriptors.first().copied() { + insert_agent_app_capability_launch_metadata( + root, request, task_id, trace_id, descriptor, + ); + } } } @@ -777,14 +1330,29 @@ pub async fn agent_app_runtime_start_task( let turn_id = non_empty(request.turn_id.as_deref()).unwrap_or_else(|| Uuid::new_v4().to_string()); let event_name = non_empty(request.event_name.as_deref()) - .unwrap_or_else(|| format!("{AGENT_APP_RUNTIME_EVENT_PREFIX}:{app_id}:{task_id}")); - let session_id = match non_empty(request.session_id.as_deref()) { + .unwrap_or_else(|| agent_app_runtime_event_name(&app_id, &task_id)); + let requested_session_id = non_empty(request.session_id.as_deref()); + let model_preference = resolve_agent_app_runtime_model_preference( + state.inner(), + db.inner(), + api_key_provider_service.inner(), + &request, + ) + .await; + if requested_session_id.is_none() && model_preference.is_none() { + return Err( + "Agent App Runtime 无法解析可用模型,请先在 Lime 配置可用 AI 服务商,或由 App 传入 providerPreference / modelPreference。" + .to_string(), + ); + } + let session_id = match requested_session_id { Some(session_id) => session_id, None => { - create_runtime_session_internal_with_runtime( + create_runtime_session_internal_with_runtime_and_session_id( db.inner(), state.inner(), mcp_manager.inner(), + new_agent_app_runtime_session_id(), None, workspace_id.clone(), non_empty(request.title.as_deref()) @@ -795,17 +1363,23 @@ pub async fn agent_app_runtime_start_task( .await? } }; - let metadata = build_agent_app_runtime_metadata(&request, &task_id, &trace_id); - let message = - non_empty(request.prompt.as_deref()).unwrap_or_else(|| default_task_message(&request)); + let mut metadata = build_agent_app_runtime_metadata(&request, &task_id, &trace_id); + if let Some(model_preference) = model_preference.as_ref() { + insert_agent_app_runtime_model_preference_metadata(&mut metadata, model_preference); + } + let message = build_agent_app_runtime_task_message(&request); let runtime_request = AsterChatRequest { message, session_id: session_id.clone(), event_name: event_name.clone(), images: None, provider_config: None, - provider_preference: None, - model_preference: None, + provider_preference: model_preference + .as_ref() + .map(|preference| preference.provider_preference.clone()), + model_preference: model_preference + .as_ref() + .map(|preference| preference.model_preference.clone()), thinking_enabled: None, approval_policy: None, sandbox_policy: None, @@ -900,6 +1474,7 @@ pub async fn agent_app_runtime_get_task( automation_state: State<'_, AutomationServiceState>, request: AgentAppRuntimeGetTaskRequest, ) -> Result { + let app_handle = app.clone(); let thread_read = agent_runtime_get_thread_read( app, state, @@ -917,7 +1492,7 @@ pub async fn agent_app_runtime_get_task( let thread_read_value = serde_json::to_value(&thread_read) .map_err(|error| format!("序列化 AgentRuntimeThreadReadModel 失败: {error}"))?; - Ok(AgentAppRuntimeTaskSnapshot { + let snapshot = AgentAppRuntimeTaskSnapshot { app_id: request.app_id, task_id: request.task_id, session_id: request.session_id, @@ -925,7 +1500,9 @@ pub async fn agent_app_runtime_get_task( task_status, task_events, thread_read: thread_read_value, - }) + }; + emit_agent_app_runtime_task_snapshot(&app_handle, &snapshot); + Ok(snapshot) } #[tauri::command] @@ -950,8 +1527,8 @@ mod tests { use crate::agent::QueuedTurnSnapshot; use crate::commands::aster_agent_cmd::{ AgentRuntimeIncidentView, AgentRuntimeOutcomeView, AgentRuntimeRequestView, - AgentRuntimeThreadEvidenceSummary, AgentRuntimeThreadTelemetrySummary, - AgentRuntimeThreadToolCallView, + AgentRuntimeThreadArtifactView, AgentRuntimeThreadEvidenceSummary, + AgentRuntimeThreadTelemetrySummary, AgentRuntimeThreadToolCallView, }; fn runtime_request( @@ -984,6 +1561,8 @@ mod tests { human_review: Some(true), event_name: None, turn_id: None, + provider_preference: None, + model_preference: None, queue_if_busy: None, skip_pre_submit_resume: None, run_start_hooks: None, @@ -1003,6 +1582,7 @@ mod tests { incidents: Vec::new(), queued_turns: Vec::new(), tool_calls: Vec::new(), + artifacts: Vec::new(), model_routing: None, evidence_summary: AgentRuntimeThreadEvidenceSummary::default(), telemetry_summary: AgentRuntimeThreadTelemetrySummary::default(), @@ -1032,6 +1612,32 @@ mod tests { } } + #[test] + fn test_agent_app_runtime_session_id_uses_hidden_prefix() { + assert!(new_agent_app_runtime_session_id().starts_with(AGENT_APP_RUNTIME_SESSION_ID_PREFIX)); + } + + #[test] + fn test_agent_app_runtime_model_preference_reads_recent_successful_routing_metadata() { + let metadata = json!({ + "request_metadata": { + "lime_runtime": { + "routing_decision": { + "selected_provider": "deepseek", + "selected_model": "deepseek-v4-flash" + } + } + } + }); + + let preference = + model_preference_from_run_metadata(&metadata).expect("recent run preference"); + + assert_eq!(preference.provider_preference, "deepseek"); + assert_eq!(preference.model_preference, "deepseek-v4-flash"); + assert_eq!(preference.source, "recent_successful_agent_run"); + } + #[test] fn test_agent_app_runtime_metadata_maps_research_capability_to_claw_launch() { let request = runtime_request( @@ -1051,9 +1657,21 @@ mod tests { .get("research_request") .and_then(Value::as_object) .expect("research request"); + let runtime_summary = metadata + .get("lime_runtime") + .and_then(|value| value.get("runtime_summary")) + .and_then(Value::as_object) + .expect("agent app runtime summary"); assert_eq!(harness.get("allow_model_skills"), Some(&json!(true))); assert!(harness.get("agent_app_runtime").is_some()); + assert_eq!(runtime_summary.get("surface"), Some(&json!("agent_app"))); + assert_eq!( + runtime_summary.get("app_id"), + Some(&json!("content-factory-app")) + ); + assert_eq!(runtime_summary.get("task_id"), Some(&json!("task-1"))); + assert_eq!(runtime_summary.get("trace_id"), Some(&json!("trace-1"))); assert_eq!(launch.get("skill_name"), Some(&json!("research"))); assert_eq!(launch.get("kind"), Some(&json!("research_request"))); assert_eq!( @@ -1110,6 +1728,112 @@ mod tests { assert!(harness.get("research_skill_launch").is_none()); } + #[test] + fn test_agent_app_runtime_content_factory_output_contract_is_machine_readable() { + let mut request = runtime_request(Vec::new(), Vec::new()); + request.expected_output = Some(json!({ + "artifactKind": "content_batch", + "includes": ["copy", "script", "image_brief"] + })); + + let metadata = build_agent_app_runtime_metadata(&request, "task-1", "trace-1"); + let harness = metadata + .get("harness") + .and_then(Value::as_object) + .expect("harness metadata"); + let output_contract = harness + .get("agent_app_runtime_output_contract") + .and_then(Value::as_object) + .expect("output contract"); + assert_eq!( + output_contract.get("artifact_kind"), + Some(&json!("content_batch")) + ); + assert_eq!( + output_contract.get("artifact_metadata_kind"), + Some(&json!(CONTENT_FACTORY_WORKSPACE_PATCH_KIND)) + ); + assert!(output_contract + .get("patch_metadata_keys") + .and_then(Value::as_array) + .is_some_and(|items| items.contains(&json!("contentFactoryWorkspacePatch")))); + + let message = build_agent_app_runtime_task_message(&request); + assert!(message.contains("Content Factory Output Contract")); + assert!(message.contains("contentFactoryWorkspacePatch")); + assert!(message.contains("artifactKind=content_batch")); + assert!(message.contains("不要通过 Bash")); + assert!(message.contains("不能把复合内容工厂任务改写成单一 research / image Skill")); + } + + #[test] + fn test_agent_app_runtime_content_factory_output_contract_does_not_force_single_skill_launch() { + let mut request = runtime_request(Vec::new(), vec!["research.search", "image_generation"]); + request.expected_output = Some(json!({ + "artifactKind": "content_batch", + "includes": ["copy", "script", "image_brief"] + })); + + let metadata = build_agent_app_runtime_metadata(&request, "task-1", "trace-1"); + let harness = metadata + .get("harness") + .and_then(Value::as_object) + .expect("harness metadata"); + + assert!(harness.get("agent_app_runtime_output_contract").is_some()); + assert_eq!(harness.get("chat_mode"), Some(&json!("general"))); + assert_eq!( + harness.get("session_mode"), + Some(&json!("general_workbench")) + ); + let workflow = harness + .get("agent_app_runtime_capability_workflow") + .and_then(Value::as_object) + .expect("capability workflow"); + assert_eq!( + workflow.get("mode"), + Some(&json!("composite_output_contract")) + ); + assert_eq!(workflow.get("launch_policy"), Some(&json!("metadata_only"))); + let descriptors = workflow + .get("descriptors") + .and_then(Value::as_array) + .expect("workflow descriptors"); + assert_eq!(descriptors.len(), 2); + assert!(descriptors + .iter() + .any(|descriptor| descriptor.get("capability_id") + == Some(&json!("lime.capability.research.search")))); + assert!(descriptors + .iter() + .any(|descriptor| descriptor.get("capability_id") + == Some(&json!("lime.capability.image.generate")))); + assert!(harness.get("allow_model_skills").is_none()); + assert!(harness.get("research_skill_launch").is_none()); + assert!(harness.get("image_skill_launch").is_none()); + } + + #[test] + fn test_agent_app_runtime_extracts_workspace_patch_from_artifact_document_blocks() { + let metadata = json!({ + "artifactDocument": { + "blocks": [ + { + "type": "rich_text", + "content": "```json\n{\"contentFactoryWorkspacePatch\":{\"kind\":\"content_batch\",\"projectId\":\"project-1\",\"contentBatch\":{\"items\":[{\"title\":\"示例文案\"}]}}}\n```" + } + ] + } + }); + + let patch = extract_content_factory_workspace_patch_from_artifact_document(Some(&metadata)) + .expect("workspace patch"); + + assert_eq!(patch.get("kind"), Some(&json!("content_batch"))); + assert_eq!(patch.get("projectId"), Some(&json!("project-1"))); + assert!(patch.get("contentBatch").is_some()); + } + #[test] fn test_agent_app_runtime_task_events_project_thread_read_facts() { let mut thread_read = base_thread_read(); @@ -1146,6 +1870,26 @@ mod tests { success: Some(true), error: None, }]; + thread_read.artifacts = vec![AgentRuntimeThreadArtifactView { + item_id: "artifact-item-1".to_string(), + turn_id: "turn-1".to_string(), + path: ".lime/artifacts/content-batch.json".to_string(), + source: "agent_runtime".to_string(), + status: "created".to_string(), + artifact_type: Some("content_batch".to_string()), + title: Some("内容批次".to_string()), + created_at: Some("2026-05-16T00:00:01.500Z".to_string()), + completed_at: Some("2026-05-16T00:00:01.800Z".to_string()), + updated_at: Some("2026-05-16T00:00:01.800Z".to_string()), + metadata: Some(json!({ + "artifactType": "content_batch", + "workspacePatch": { + "kind": "content_batch", + "projectId": "project-1", + "contentBatch": { "count": 20 } + } + })), + }]; thread_read.evidence_summary = AgentRuntimeThreadEvidenceSummary { evidence_refs: vec!["evidence-1".to_string()], verification_outcomes: vec![json!({ "status": "passed" })], @@ -1183,6 +1927,7 @@ mod tests { assert!(event_types.contains(&"task:progress")); assert!(event_types.contains(&"task:missingContextRequested")); assert!(event_types.contains(&"task:toolCall")); + assert!(event_types.contains(&"artifact:created")); assert!(event_types.contains(&"evidence:recorded")); assert!(event_types.contains(&"evidence:verified")); assert!(event_types.contains(&"task:completed")); @@ -1193,5 +1938,75 @@ mod tests { assert!(events .iter() .any(|event| event.evidence_ref.as_deref() == Some("evidence-1"))); + assert!(events.iter().any( + |event| event.artifact_ref.as_deref() == Some(".lime/artifacts/content-batch.json") + )); + let artifact_event = events + .iter() + .find(|event| event.event_type == "artifact:created") + .expect("artifact event"); + assert_eq!( + artifact_event + .payload + .as_ref() + .and_then(|payload| payload.get("contentFactoryWorkspacePatch")) + .and_then(|patch| patch.get("contentBatch")) + .and_then(|content_batch| content_batch.get("count")), + Some(&json!(20)) + ); + } + + #[test] + fn test_agent_app_runtime_task_snapshot_event_payload_is_canonical() { + let mut thread_read = base_thread_read(); + thread_read.profile_status = "running".to_string(); + let task_events = build_agent_app_runtime_task_events(&thread_read); + let snapshot = AgentAppRuntimeTaskSnapshot { + app_id: "content-factory-app".to_string(), + task_id: "task-1".to_string(), + session_id: "session-1".to_string(), + status: "thread_read_available".to_string(), + task_status: thread_read.profile_status.clone(), + task_events, + thread_read: serde_json::to_value(&thread_read).expect("thread read value"), + }; + + let payload = build_agent_app_runtime_task_snapshot_event_payload(&snapshot); + + assert_eq!( + payload.get("type"), + Some(&json!("agent_app_runtime:taskSnapshot")) + ); + assert_eq!(payload.get("eventType"), Some(&json!("task:update"))); + assert_eq!(payload.get("taskId"), Some(&json!("task-1"))); + assert_eq!( + payload + .get("taskEvents") + .and_then(Value::as_array) + .map(Vec::len), + Some(snapshot.task_events.len()) + ); + assert!(payload.get("threadRead").is_some()); + assert!(payload.get("task").is_some()); + } + + #[test] + fn test_agent_app_runtime_idle_status_uses_business_progress_copy() { + let mut thread_read = base_thread_read(); + thread_read.status = "idle".to_string(); + thread_read.profile_status = "idle".to_string(); + thread_read.active_turn_id = None; + + let events = build_agent_app_runtime_task_events(&thread_read); + let progress = events + .iter() + .find(|event| event.event_type == "task:progress") + .expect("progress event"); + + assert_eq!( + progress.message, + "任务已接收,等待 AgentRuntime 调度或回写进度" + ); + assert_ne!(progress.message, "任务状态:idle"); } } diff --git a/src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs b/src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs index d07c91cd4..00223abcf 100644 --- a/src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs +++ b/src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs @@ -783,6 +783,14 @@ struct RuntimeExportContext { workspace_root: PathBuf, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct AgentAppRuntimeExportProjectionScope { + app_id: String, + task_id: String, + trace_id: Option, + task_kind: Option, +} + async fn load_runtime_export_context( runtime: &RuntimeCommandContext, session_id: &str, @@ -815,6 +823,309 @@ async fn load_runtime_export_context( }) } +fn normalize_agent_app_projection_text(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn agent_app_runtime_export_event_name(scope: &AgentAppRuntimeExportProjectionScope) -> String { + format!("agent_app_runtime:{}:{}", scope.app_id, scope.task_id) +} + +fn agent_app_runtime_export_scope_from_runtime_summary_value( + summary: Option<&Value>, +) -> Option { + let summary = summary?; + let surface = json_string_field(summary, &["surface"])?; + if surface != "agent_app" { + return None; + } + + Some(AgentAppRuntimeExportProjectionScope { + app_id: json_string_field(summary, &["appId", "app_id"])?, + task_id: json_string_field(summary, &["taskId", "task_id"])?, + trace_id: json_string_field(summary, &["traceId", "trace_id"]), + task_kind: json_string_field(summary, &["taskKind", "task_kind"]), + }) +} + +fn agent_app_runtime_export_scope_from_execution_runtime( + runtime: Option<&lime_agent::SessionExecutionRuntime>, +) -> Option { + let summary = runtime?.runtime_summary.as_ref()?; + if summary.surface.as_deref().map(str::trim) != Some("agent_app") { + return None; + } + + Some(AgentAppRuntimeExportProjectionScope { + app_id: normalize_agent_app_projection_text(summary.app_id.as_deref())?, + task_id: normalize_agent_app_projection_text(summary.task_id.as_deref())?, + trace_id: normalize_agent_app_projection_text(summary.trace_id.as_deref()), + task_kind: normalize_agent_app_projection_text(summary.task_kind.as_deref()), + }) +} + +fn resolve_agent_app_runtime_export_projection_scope( + detail: &SessionDetail, + thread_read: &AgentRuntimeThreadReadModel, +) -> Option { + agent_app_runtime_export_scope_from_runtime_summary_value(thread_read.runtime_summary.as_ref()) + .or_else(|| { + agent_app_runtime_export_scope_from_execution_runtime(detail.execution_runtime.as_ref()) + }) +} + +fn json_array_field<'a>(value: &'a Value, keys: &[&str]) -> Option<&'a Vec> { + keys.iter().find_map(|key| value.get(*key)?.as_array()) +} + +fn harness_export_kind_label(export_kind: &str) -> &'static str { + match export_kind { + "evidence_pack" => "Evidence Pack", + "analysis_handoff" => "Analysis Handoff", + "review_decision" => "Review Decision", + _ => "Harness Export", + } +} + +fn harness_export_root_ref(export_kind: &str, export: &Value) -> Option { + match export_kind { + "evidence_pack" => json_string_field(export, &["packRelativeRoot", "pack_relative_root"]), + "analysis_handoff" => { + json_string_field(export, &["analysisRelativeRoot", "analysis_relative_root"]) + } + "review_decision" => { + json_string_field(export, &["reviewRelativeRoot", "review_relative_root"]) + } + _ => None, + } +} + +fn harness_exported_at(export: &Value) -> Option { + json_string_field(export, &["exportedAt", "exported_at"]) +} + +fn build_harness_export_root_task_event(export_kind: &str, export: &Value) -> Option { + let root_ref = harness_export_root_ref(export_kind, export)?; + let label = harness_export_kind_label(export_kind); + let mut task_event = serde_json::Map::new(); + task_event.insert( + "id".to_string(), + json!(format!("harness:{export_kind}:exported")), + ); + task_event.insert( + "eventType".to_string(), + json!(if export_kind == "evidence_pack" { + "evidence:recorded" + } else { + "artifact:created" + }), + ); + task_event.insert( + "status".to_string(), + json!(if export_kind == "evidence_pack" { + "recorded" + } else { + "created" + }), + ); + task_event.insert("message".to_string(), json!(format!("{label} 已导出"))); + task_event.insert("occurredAt".to_string(), json!(harness_exported_at(export))); + task_event.insert( + "payload".to_string(), + json!({ + "exportKind": export_kind, + "rootRef": root_ref.clone(), + "export": export, + }), + ); + if export_kind == "evidence_pack" { + task_event.insert("evidenceRef".to_string(), json!(root_ref)); + } else { + task_event.insert("artifactRef".to_string(), json!(root_ref)); + } + Some(Value::Object(task_event)) +} + +fn build_harness_export_artifact_task_event( + export_kind: &str, + source_key: &str, + index: usize, + artifact: &Value, +) -> Option { + let artifact_ref = json_string_field(artifact, &["relativePath", "relative_path"])?; + let label = harness_export_kind_label(export_kind); + let mut task_event = serde_json::Map::new(); + task_event.insert( + "id".to_string(), + json!(format!("harness:{export_kind}:{source_key}:{index}")), + ); + task_event.insert("eventType".to_string(), json!("artifact:created")); + task_event.insert("status".to_string(), json!("created")); + task_event.insert("message".to_string(), json!(format!("{label} 制品已导出"))); + task_event.insert("artifactRef".to_string(), json!(artifact_ref)); + task_event.insert( + "payload".to_string(), + json!({ + "exportKind": export_kind, + "source": source_key, + "artifact": artifact, + }), + ); + Some(Value::Object(task_event)) +} + +fn build_harness_export_completion_verified_event( + export_kind: &str, + export: &Value, +) -> Option { + if export_kind != "evidence_pack" { + return None; + } + let completion_audit_summary = export + .get("completionAuditSummary") + .or_else(|| export.get("completion_audit_summary"))?; + let decision = json_string_field(completion_audit_summary, &["decision"])?; + if decision != "completed" { + return None; + } + + let evidence_ref = harness_export_root_ref(export_kind, export); + let mut task_event = serde_json::Map::new(); + task_event.insert( + "id".to_string(), + json!("harness:evidence_pack:completion_audit"), + ); + task_event.insert("eventType".to_string(), json!("evidence:verified")); + task_event.insert("status".to_string(), json!(decision)); + task_event.insert("message".to_string(), json!("Evidence Pack 完成审计已通过")); + task_event.insert("occurredAt".to_string(), json!(harness_exported_at(export))); + task_event.insert( + "payload".to_string(), + json!({ + "exportKind": export_kind, + "completionAuditSummary": completion_audit_summary, + }), + ); + if let Some(evidence_ref) = evidence_ref { + task_event.insert("evidenceRef".to_string(), json!(evidence_ref)); + } + Some(Value::Object(task_event)) +} + +fn build_harness_export_task_events(export_kind: &str, export: &Value) -> Vec { + let mut task_events = Vec::new(); + if let Some(task_event) = build_harness_export_root_task_event(export_kind, export) { + task_events.push(task_event); + } + if let Some(artifacts) = json_array_field(export, &["artifacts"]) { + for (index, artifact) in artifacts.iter().enumerate() { + if let Some(task_event) = + build_harness_export_artifact_task_event(export_kind, "artifacts", index, artifact) + { + task_events.push(task_event); + } + } + } + if let Some(artifacts) = json_array_field(export, &["analysisArtifacts", "analysis_artifacts"]) + { + for (index, artifact) in artifacts.iter().enumerate() { + if let Some(task_event) = build_harness_export_artifact_task_event( + export_kind, + "analysisArtifacts", + index, + artifact, + ) { + task_events.push(task_event); + } + } + } + if let Some(task_event) = build_harness_export_completion_verified_event(export_kind, export) { + task_events.push(task_event); + } + task_events +} + +fn build_agent_app_runtime_harness_export_projection_payload( + detail: &SessionDetail, + thread_read: &AgentRuntimeThreadReadModel, + export_kind: &str, + export: &Value, +) -> Option { + let scope = resolve_agent_app_runtime_export_projection_scope(detail, thread_read)?; + let task_events = build_harness_export_task_events(export_kind, export); + if task_events.is_empty() { + return None; + } + let runtime_event_name = agent_app_runtime_export_event_name(&scope); + let status = task_events + .first() + .and_then(|event| event.get("status").and_then(Value::as_str)) + .unwrap_or("created") + .to_string(); + + Some(json!({ + "type": "agent_app_runtime:harnessExportProjection", + "eventType": "task:runtimeEvent", + "appId": scope.app_id, + "taskId": scope.task_id, + "traceId": scope.trace_id, + "taskKind": scope.task_kind, + "sessionId": detail.id.clone(), + "threadId": detail.thread_id.clone(), + "status": status, + "exportKind": export_kind, + "harnessExport": export, + "runtimeEvent": { + "type": "harnessExport", + "exportKind": export_kind, + "result": export, + }, + "taskEvents": task_events, + "runtimeEventName": runtime_event_name, + "emittedAt": chrono::Utc::now().to_rfc3339(), + })) +} + +fn emit_agent_app_runtime_harness_export_projection( + app: &AppHandle, + context: &RuntimeExportContext, + export_kind: &str, + export: &T, +) { + let Ok(export_value) = serde_json::to_value(export) else { + tracing::warn!( + "[AsterAgent][AgentAppRuntime] 序列化 Harness export projection 失败: export_kind={}", + export_kind + ); + return; + }; + let Some(scope) = + resolve_agent_app_runtime_export_projection_scope(&context.detail, &context.thread_read) + else { + return; + }; + let Some(payload) = build_agent_app_runtime_harness_export_projection_payload( + &context.detail, + &context.thread_read, + export_kind, + &export_value, + ) else { + return; + }; + let event_name = agent_app_runtime_export_event_name(&scope); + if let Err(error) = app.emit(&event_name, payload) { + tracing::warn!( + "[AsterAgent][AgentAppRuntime] 发送 Harness export projection 失败: event_name={}, export_kind={}, error={}", + event_name, + export_kind, + error + ); + } +} + /// 统一运行时:导出当前会话的交接制品 bundle。 #[tauri::command] pub async fn agent_runtime_export_handoff_bundle( @@ -862,6 +1173,7 @@ pub async fn agent_runtime_export_evidence_pack( automation_state: State<'_, AutomationServiceState>, session_id: String, ) -> Result { + let app_for_projection = app.clone(); let db_handle = db.inner().clone(); let evidence_locale = config_manager.0.config().language; let runtime = build_runtime_command_context( @@ -883,13 +1195,20 @@ pub async fn agent_runtime_export_evidence_pack( .map_err(|error| format!("查询 evidence pack owner runs 失败: {error}"))? }; - export_runtime_evidence_pack_with_owner_runs_and_locale( + let export = export_runtime_evidence_pack_with_owner_runs_and_locale( &context.detail, &context.thread_read, &context.workspace_root, &owner_runs, Some(evidence_locale.as_str()), - ) + )?; + emit_agent_app_runtime_harness_export_projection( + &app_for_projection, + &context, + "evidence_pack", + &export, + ); + Ok(export) } /// 统一运行时:导出当前会话的外部分析交接包。 @@ -905,6 +1224,7 @@ pub async fn agent_runtime_export_analysis_handoff( automation_state: State<'_, AutomationServiceState>, session_id: String, ) -> Result { + let app_for_projection = app.clone(); let export_locale = config_manager.0.config().language; let runtime = build_runtime_command_context( app, @@ -920,12 +1240,19 @@ pub async fn agent_runtime_export_analysis_handoff( let context = load_runtime_export_context(&runtime, &session_id, "导出 analysis handoff 前").await?; - export_runtime_analysis_handoff_with_locale( + let export = export_runtime_analysis_handoff_with_locale( &context.detail, &context.thread_read, &context.workspace_root, Some(export_locale.as_str()), - ) + )?; + emit_agent_app_runtime_harness_export_projection( + &app_for_projection, + &context, + "analysis_handoff", + &export, + ); + Ok(export) } /// 统一运行时:导出当前会话的人工审核记录模板。 @@ -941,6 +1268,7 @@ pub async fn agent_runtime_export_review_decision_template( automation_state: State<'_, AutomationServiceState>, session_id: String, ) -> Result { + let app_for_projection = app.clone(); let export_locale = config_manager.0.config().language; let runtime = build_runtime_command_context( app, @@ -956,12 +1284,19 @@ pub async fn agent_runtime_export_review_decision_template( let context = load_runtime_export_context(&runtime, &session_id, "导出 review decision 模板前").await?; - export_runtime_review_decision_template_with_locale( + let export = export_runtime_review_decision_template_with_locale( &context.detail, &context.thread_read, &context.workspace_root, Some(export_locale.as_str()), - ) + )?; + emit_agent_app_runtime_harness_export_projection( + &app_for_projection, + &context, + "review_decision", + &export, + ); + Ok(export) } /// 统一运行时:保存当前会话的人工审核结果。 @@ -977,6 +1312,7 @@ pub async fn agent_runtime_save_review_decision( automation_state: State<'_, AutomationServiceState>, request: AgentRuntimeSaveReviewDecisionRequest, ) -> Result { + let app_for_projection = app.clone(); let export_locale = config_manager.0.config().language; let runtime = build_runtime_command_context( app, @@ -1012,6 +1348,12 @@ pub async fn agent_runtime_save_review_decision( Some(export_locale.as_str()), )?; + emit_agent_app_runtime_harness_export_projection( + &app_for_projection, + &context, + "review_decision", + &saved, + ); Ok(saved) } @@ -1307,6 +1649,48 @@ mod tests { } } + fn thread_read_for_harness_projection(runtime_summary: Value) -> AgentRuntimeThreadReadModel { + AgentRuntimeThreadReadModel { + thread_id: "thread-agent-app".to_string(), + status: "completed".to_string(), + profile_status: "completed".to_string(), + active_turn_id: None, + turns: Vec::new(), + pending_requests: Vec::new(), + last_outcome: None, + incidents: Vec::new(), + queued_turns: Vec::new(), + tool_calls: Vec::new(), + artifacts: Vec::new(), + model_routing: None, + evidence_summary: Default::default(), + telemetry_summary: Default::default(), + context_summary: None, + interrupt_state: None, + updated_at: None, + latest_compaction_boundary: None, + file_checkpoint_summary: None, + diagnostics: None, + task_kind: None, + service_model_slot: None, + routing_mode: None, + decision_source: None, + decision_reason: None, + candidate_count: None, + fallback_chain: None, + capability_gap: None, + estimated_cost_class: None, + single_candidate_only: None, + oem_policy: None, + runtime_summary: Some(runtime_summary), + auxiliary_task_runtime: None, + limit_state: None, + cost_state: None, + permission_state: None, + limit_event: None, + } + } + #[test] fn normalize_runtime_session_history_limit_should_default_to_open_tail() { assert_eq!( @@ -1412,6 +1796,150 @@ mod tests { assert!(should_list_runtime_queue_snapshots(&detail, None, 0, None)); } + #[test] + fn agent_app_runtime_export_projection_extracts_scope_from_runtime_summary() { + let scope = agent_app_runtime_export_scope_from_runtime_summary_value(Some(&json!({ + "surface": "agent_app", + "appId": "content-factory-app", + "taskId": "task-1", + "traceId": "trace-1", + "taskKind": "content_factory.copy.generate" + }))) + .expect("agent app scope"); + + assert_eq!(scope.app_id, "content-factory-app"); + assert_eq!(scope.task_id, "task-1"); + assert_eq!(scope.trace_id.as_deref(), Some("trace-1")); + assert_eq!( + scope.task_kind.as_deref(), + Some("content_factory.copy.generate") + ); + assert!( + agent_app_runtime_export_scope_from_runtime_summary_value(Some(&json!({ + "surface": "chat", + "appId": "content-factory-app", + "taskId": "task-1" + }))) + .is_none() + ); + } + + #[test] + fn agent_app_runtime_harness_export_projection_builds_evidence_task_events() { + let detail = detail_with_turn_status(AgentThreadTurnStatus::Completed); + let thread_read = thread_read_for_harness_projection(json!({ + "surface": "agent_app", + "appId": "content-factory-app", + "taskId": "task-1", + "traceId": "trace-1", + "taskKind": "content_factory.copy.generate" + })); + let export = json!({ + "sessionId": detail.id.clone(), + "threadId": detail.thread_id.clone(), + "packRelativeRoot": ".lime/harness/sessions/session-runtime-queue/evidence", + "exportedAt": "2026-05-16T00:00:00Z", + "completionAuditSummary": { + "source": "runtime_evidence_pack_completion_audit", + "decision": "completed" + }, + "artifacts": [ + { + "kind": "summary", + "title": "Evidence summary", + "relativePath": ".lime/harness/sessions/session-runtime-queue/evidence/summary.md" + } + ] + }); + + let payload = build_agent_app_runtime_harness_export_projection_payload( + &detail, + &thread_read, + "evidence_pack", + &export, + ) + .expect("projection payload"); + let task_events = payload + .get("taskEvents") + .and_then(Value::as_array) + .expect("task events"); + + assert_eq!( + payload.get("type"), + Some(&json!("agent_app_runtime:harnessExportProjection")) + ); + assert_eq!( + payload.get("runtimeEventName"), + Some(&json!("agent_app_runtime:content-factory-app:task-1")) + ); + assert!(task_events + .iter() + .any(|event| event.get("eventType") == Some(&json!("evidence:recorded")))); + assert!(task_events + .iter() + .any(|event| event.get("eventType") == Some(&json!("artifact:created")))); + assert!(task_events + .iter() + .any(|event| event.get("eventType") == Some(&json!("evidence:verified")))); + } + + #[test] + fn agent_app_runtime_harness_export_projection_builds_review_artifact_events() { + let detail = detail_with_turn_status(AgentThreadTurnStatus::Completed); + let thread_read = thread_read_for_harness_projection(json!({ + "surface": "agent_app", + "appId": "content-factory-app", + "taskId": "task-review" + })); + let export = json!({ + "sessionId": detail.id.clone(), + "threadId": detail.thread_id.clone(), + "reviewRelativeRoot": ".lime/harness/sessions/session-runtime-queue/review", + "exportedAt": "2026-05-16T00:00:00Z", + "analysisArtifacts": [ + { + "kind": "analysis_brief", + "title": "Analysis brief", + "relativePath": ".lime/harness/sessions/session-runtime-queue/analysis/analysis-brief.md" + } + ], + "artifacts": [ + { + "kind": "review_decision_markdown", + "title": "Review decision", + "relativePath": ".lime/harness/sessions/session-runtime-queue/review/review-decision.md" + } + ] + }); + + let payload = build_agent_app_runtime_harness_export_projection_payload( + &detail, + &thread_read, + "review_decision", + &export, + ) + .expect("review projection payload"); + let task_events = payload + .get("taskEvents") + .and_then(Value::as_array) + .expect("task events"); + + assert_eq!( + task_events + .first() + .and_then(|event| event.get("eventType")) + .and_then(Value::as_str), + Some("artifact:created") + ); + assert_eq!( + task_events + .iter() + .filter(|event| event.get("eventType") == Some(&json!("artifact:created"))) + .count(), + 3 + ); + } + #[test] fn latest_model_delta_timing_from_run_should_project_agent_run_metadata() { let run = AgentRun { @@ -1481,6 +2009,7 @@ mod tests { incidents: Vec::new(), queued_turns: Vec::new(), tool_calls: Vec::new(), + artifacts: Vec::new(), model_routing: Some(json!({ "decisionSource": "responsive_chat_auto", "selectedModel": "deepseek-v4-flash" diff --git a/src-tauri/src/commands/aster_agent_cmd/dto.rs b/src-tauri/src/commands/aster_agent_cmd/dto.rs index 72ef88594..db8f4f972 100644 --- a/src-tauri/src/commands/aster_agent_cmd/dto.rs +++ b/src-tauri/src/commands/aster_agent_cmd/dto.rs @@ -1,6 +1,11 @@ use super::*; +use crate::services::runtime_evidence_projection_service::{ + collect_runtime_evidence_projection_summary_from_metadata, evidence_ref_from_artifact_path, + RuntimeEvidenceProjectionSummary, +}; use crate::services::runtime_file_checkpoint_service; use chrono::{DateTime, Utc}; +use lime_core::database::dao::agent_timeline::AgentThreadItemPayload; use lime_core::models::model_registry::ModelCapabilities; /// Aster Agent 状态信息 @@ -598,6 +603,27 @@ pub struct AgentRuntimeThreadToolCallView { pub error: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentRuntimeThreadArtifactView { + pub item_id: String, + pub turn_id: String, + pub path: String, + pub source: String, + pub status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub artifact_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub created_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub completed_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AgentRuntimeThreadEvidenceSummary { #[serde(default)] @@ -608,7 +634,10 @@ pub struct AgentRuntimeThreadEvidenceSummary { impl Default for AgentRuntimeThreadEvidenceSummary { fn default() -> Self { - build_thread_evidence_summary() + AgentRuntimeThreadEvidenceSummary { + evidence_refs: Vec::new(), + verification_outcomes: Vec::new(), + } } } @@ -645,6 +674,8 @@ pub struct AgentRuntimeThreadReadModel { pub queued_turns: Vec, #[serde(default)] pub tool_calls: Vec, + #[serde(default)] + pub artifacts: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub model_routing: Option, #[serde(default)] @@ -1599,6 +1630,64 @@ fn build_thread_tool_calls(detail: &SessionDetail) -> Vec, keys: &[&str]) -> Option { + let metadata = metadata?; + keys.iter() + .filter_map(|key| metadata.get(*key)) + .find_map(|value| { + value + .as_str() + .map(str::trim) + .filter(|item| !item.is_empty()) + }) + .map(str::to_string) +} + +fn build_thread_artifacts(detail: &SessionDetail) -> Vec { + detail + .items + .iter() + .filter_map(|item| { + let lime_core::database::dao::agent_timeline::AgentThreadItemPayload::FileArtifact { + path, + source, + metadata, + .. + } = &item.payload + else { + return None; + }; + + let status = match item.status { + lime_core::database::dao::agent_timeline::AgentThreadItemStatus::InProgress => { + "running" + } + lime_core::database::dao::agent_timeline::AgentThreadItemStatus::Failed => "failed", + lime_core::database::dao::agent_timeline::AgentThreadItemStatus::Completed => { + "created" + } + }; + + Some(AgentRuntimeThreadArtifactView { + item_id: item.id.clone(), + turn_id: item.turn_id.clone(), + path: path.clone(), + source: source.clone(), + status: status.to_string(), + artifact_type: artifact_metadata_string( + metadata.as_ref(), + &["artifactType", "artifact_type", "kind"], + ), + title: artifact_metadata_string(metadata.as_ref(), &["title", "artifactTitle"]), + created_at: Some(item.started_at.clone()), + completed_at: item.completed_at.clone(), + updated_at: Some(item.updated_at.clone()), + metadata: metadata.clone(), + }) + }) + .collect() +} + fn build_thread_model_routing_summary( task_kind: Option<&str>, service_model_slot: Option<&str>, @@ -1651,11 +1740,46 @@ fn build_thread_model_routing_summary( })) } -fn build_thread_evidence_summary() -> AgentRuntimeThreadEvidenceSummary { - AgentRuntimeThreadEvidenceSummary { - evidence_refs: Vec::new(), - verification_outcomes: Vec::new(), +fn build_thread_evidence_summary(detail: &SessionDetail) -> AgentRuntimeThreadEvidenceSummary { + let mut summary = RuntimeEvidenceProjectionSummary::default(); + + for item in &detail.items { + match &item.payload { + AgentThreadItemPayload::ToolCall { metadata, .. } => { + merge_evidence_projection_summary( + &mut summary, + collect_runtime_evidence_projection_summary_from_metadata(metadata.as_ref()), + ); + } + AgentThreadItemPayload::FileArtifact { path, metadata, .. } => { + if let Some(evidence_ref) = evidence_ref_from_artifact_path(path) { + summary.push_evidence_ref(evidence_ref); + } + merge_evidence_projection_summary( + &mut summary, + collect_runtime_evidence_projection_summary_from_metadata(metadata.as_ref()), + ); + } + _ => {} + } } + + AgentRuntimeThreadEvidenceSummary { + evidence_refs: summary.evidence_refs, + verification_outcomes: summary.verification_outcomes, + } +} + +fn merge_evidence_projection_summary( + target: &mut RuntimeEvidenceProjectionSummary, + source: RuntimeEvidenceProjectionSummary, +) { + for evidence_ref in source.evidence_refs { + target.push_evidence_ref(evidence_ref); + } + target + .verification_outcomes + .extend(source.verification_outcomes); } fn build_thread_telemetry_summary() -> AgentRuntimeThreadTelemetrySummary { @@ -1937,6 +2061,7 @@ impl AgentRuntimeThreadReadModel { let profile_status = normalize_profile_status(&status); let turns = build_thread_profile_turns(detail); let tool_calls = build_thread_tool_calls(detail); + let artifacts = build_thread_artifacts(detail); let model_routing = build_thread_model_routing_summary( task_kind.as_deref(), service_model_slot.as_deref(), @@ -1953,7 +2078,7 @@ impl AgentRuntimeThreadReadModel { estimated_cost_class.as_deref(), single_candidate_only, ); - let evidence_summary = build_thread_evidence_summary(); + let evidence_summary = build_thread_evidence_summary(detail); let telemetry_summary = build_thread_telemetry_summary(); let context_summary = build_thread_context_summary( detail @@ -1974,6 +2099,7 @@ impl AgentRuntimeThreadReadModel { incidents, queued_turns: queued_turns.to_vec(), tool_calls, + artifacts, model_routing, evidence_summary, telemetry_summary, @@ -3314,6 +3440,81 @@ mod tests { assert_eq!(thread_read.incidents[0].incident_type, "provider_error"); } + #[test] + fn thread_read_should_collect_evidence_projection_facts_from_runtime_metadata() { + let detail = build_session_detail( + vec![AgentThreadTurn { + id: "turn-1".to_string(), + thread_id: "thread-1".to_string(), + prompt_text: "导出证据".to_string(), + status: AgentThreadTurnStatus::Completed, + started_at: "2026-03-23T09:10:00Z".to_string(), + completed_at: Some("2026-03-23T09:10:30Z".to_string()), + error_message: None, + created_at: "2026-03-23T09:10:00Z".to_string(), + updated_at: "2026-03-23T09:10:30Z".to_string(), + }], + vec![ + AgentThreadItem { + id: "tool-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + sequence: 1, + status: AgentThreadItemStatus::Completed, + started_at: "2026-03-23T09:10:01Z".to_string(), + completed_at: Some("2026-03-23T09:10:10Z".to_string()), + updated_at: "2026-03-23T09:10:10Z".to_string(), + payload: AgentThreadItemPayload::ToolCall { + tool_name: "EvidenceExporter".to_string(), + arguments: None, + output: Some("ok".to_string()), + success: Some(true), + error: None, + metadata: Some(serde_json::json!({ + "evidenceRefs": ["evidence://session-1/runtime"], + "verificationOutcomes": [{ "status": "passed" }] + })), + }, + }, + AgentThreadItem { + id: "artifact-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + sequence: 2, + status: AgentThreadItemStatus::Completed, + started_at: "2026-03-23T09:10:11Z".to_string(), + completed_at: Some("2026-03-23T09:10:12Z".to_string()), + updated_at: "2026-03-23T09:10:12Z".to_string(), + payload: AgentThreadItemPayload::FileArtifact { + path: ".lime/harness/sessions/session-1/evidence/runtime.json".to_string(), + source: "runtime_evidence_pack".to_string(), + content: None, + metadata: None, + }, + }, + ], + ); + + let thread_read = AgentRuntimeThreadReadModel::from_session_detail(&detail, &[]); + + assert_eq!( + thread_read.evidence_summary.evidence_refs, + vec![ + "evidence://session-1/runtime".to_string(), + ".lime/harness/sessions/session-1/evidence/runtime.json".to_string() + ] + ); + assert_eq!( + thread_read + .evidence_summary + .verification_outcomes + .first() + .and_then(|outcome| outcome.get("status")) + .and_then(serde_json::Value::as_str), + Some("passed") + ); + } + #[test] fn thread_read_should_not_keep_aborted_turn_active_after_followup_completed() { let detail = build_session_detail( @@ -3635,6 +3836,58 @@ mod tests { assert_eq!(thread_read.tool_calls[0].success, Some(true)); } + #[test] + fn thread_read_should_project_file_artifacts_for_profile_consumers() { + let detail = build_session_detail( + vec![AgentThreadTurn { + id: "turn-artifact".to_string(), + thread_id: "thread-1".to_string(), + prompt_text: "生成内容批次".to_string(), + status: AgentThreadTurnStatus::Completed, + started_at: "2026-05-16T09:10:00Z".to_string(), + completed_at: Some("2026-05-16T09:10:05Z".to_string()), + error_message: None, + created_at: "2026-05-16T09:10:00Z".to_string(), + updated_at: "2026-05-16T09:10:05Z".to_string(), + }], + vec![AgentThreadItem { + id: "artifact-item-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-artifact".to_string(), + sequence: 1, + status: AgentThreadItemStatus::Completed, + started_at: "2026-05-16T09:10:01Z".to_string(), + completed_at: Some("2026-05-16T09:10:02Z".to_string()), + updated_at: "2026-05-16T09:10:02Z".to_string(), + payload: AgentThreadItemPayload::FileArtifact { + path: ".lime/artifacts/content-batch.json".to_string(), + source: "artifact_snapshot".to_string(), + content: None, + metadata: Some(serde_json::json!({ + "artifactType": "content_batch", + "title": "内容批次" + })), + }, + }], + ); + + let thread_read = AgentRuntimeThreadReadModel::from_session_detail(&detail, &[]); + + assert_eq!(thread_read.artifacts.len(), 1); + assert_eq!(thread_read.artifacts[0].item_id, "artifact-item-1"); + assert_eq!(thread_read.artifacts[0].turn_id, "turn-artifact"); + assert_eq!( + thread_read.artifacts[0].path, + ".lime/artifacts/content-batch.json" + ); + assert_eq!( + thread_read.artifacts[0].artifact_type.as_deref(), + Some("content_batch") + ); + assert_eq!(thread_read.artifacts[0].title.as_deref(), Some("内容批次")); + assert_eq!(thread_read.artifacts[0].status, "created"); + } + #[test] fn thread_read_should_project_context_summary_for_profile_consumers() { let mut detail = build_session_detail( diff --git a/src-tauri/src/commands/aster_agent_cmd/mod.rs b/src-tauri/src/commands/aster_agent_cmd/mod.rs index 808d4273e..f760349fe 100644 --- a/src-tauri/src/commands/aster_agent_cmd/mod.rs +++ b/src-tauri/src/commands/aster_agent_cmd/mod.rs @@ -471,7 +471,7 @@ pub(crate) use dto::{ AgentRuntimeSaveReviewDecisionRequest, AgentRuntimeSendSubagentInputRequest, AgentRuntimeSendSubagentInputResponse, AgentRuntimeSessionDetail, AgentRuntimeSpawnSubagentRequest, AgentRuntimeSpawnSubagentResponse, - AgentRuntimeSubmitTurnRequest, AgentRuntimeThreadDiagnostics, + AgentRuntimeSubmitTurnRequest, AgentRuntimeThreadArtifactView, AgentRuntimeThreadDiagnostics, AgentRuntimeThreadEvidenceSummary, AgentRuntimeThreadReadModel, AgentRuntimeThreadTelemetrySummary, AgentRuntimeThreadToolCallView, AgentRuntimeThreadTurnProfileView, AgentRuntimeToolInventoryRequest, @@ -564,7 +564,7 @@ pub(crate) use service_skill_launch::{ prepare_service_scene_launch_request_metadata, should_lock_service_skill_launch_to_site_tools, ServiceSkillLaunchPreloadExecution, }; -pub(crate) use session_runtime::create_runtime_session_internal_with_runtime; +pub(crate) use session_runtime::create_runtime_session_internal_with_runtime_and_session_id; pub(crate) use session_runtime::{ persist_session_provider_routing, resolve_recent_preference_from_sources, resolve_session_provider_selector, resolve_session_recent_harness_context, diff --git a/src-tauri/src/commands/aster_agent_cmd/request_model_resolution.rs b/src-tauri/src/commands/aster_agent_cmd/request_model_resolution.rs index 0b4d61fd9..0803763df 100644 --- a/src-tauri/src/commands/aster_agent_cmd/request_model_resolution.rs +++ b/src-tauri/src/commands/aster_agent_cmd/request_model_resolution.rs @@ -2016,6 +2016,11 @@ fn build_runtime_summary( limit_event: Option<&lime_agent::SessionExecutionRuntimeLimitEvent>, ) -> lime_agent::SessionExecutionRuntimeSummary { lime_agent::SessionExecutionRuntimeSummary { + surface: None, + app_id: None, + task_id: None, + trace_id: None, + task_kind: None, candidate_count: Some(routing_decision.candidate_count), routing_mode: Some(routing_decision.routing_mode.clone()), decision_source: Some(routing_decision.decision_source.clone()), 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 87a057fcf..b834a080b 100644 --- a/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs +++ b/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs @@ -23,6 +23,9 @@ use crate::commands::auxiliary_model_selection::{ prepare_auxiliary_provider_scope, AuxiliaryProviderResolution, AuxiliaryServiceModelSlot, }; use crate::commands::modality_runtime_contracts::hydrate_limecore_policy_hits_from_request_metadata; +use crate::services::runtime_evidence_projection_service::{ + collect_runtime_evidence_projection_summary_from_value, RuntimeEvidenceProjectionSummary, +}; use aster::agents::extension::PlatformExtensionContext; use aster::hooks::{CompactTrigger, SessionSource}; use aster::session::TurnContextOverride; @@ -31,6 +34,7 @@ use lime_agent::{build_diagnostics_runtime_status_metadata, AgentEvent as Runtim use lime_core::database::dao::agent_timeline::{AgentThreadItemPayload, AgentThreadItemStatus}; use lime_core::workspace::WorkspaceSettings; use regex::Regex; +use serde_json::{json, Value}; use std::collections::{HashMap, HashSet}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; @@ -51,6 +55,7 @@ const TURN_KNOWLEDGE_PACK_PROMPT_MARKER: &str = "【运行时知识包】"; const TURN_MEMORY_PREFETCH_PROMPT_MARKER: &str = "【运行时记忆召回】"; const TURN_LOCAL_PATH_FOCUS_PROMPT_MARKER: &str = "【本回合本地路径焦点】"; const LIME_RUNTIME_METADATA_KEY: &str = "lime_runtime"; +const AGENT_APP_RUNTIME_EVENT_PREFIX: &str = "agent_app_runtime:"; const AGENTUI_CONTEXT_METADATA_KEY: &str = "agentui_context"; const LIME_RUNTIME_AUTO_COMPACT_KEY: &str = "auto_compact"; const LIME_RUNTIME_TOOL_SURFACE_KEY: &str = "tool_surface"; @@ -79,6 +84,7 @@ fn emit_runtime_events(app: &AppHandle, event_name: &str, events: Vec) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn parse_agent_app_runtime_projection_scope( + event_name: &str, +) -> Option { + let remainder = event_name + .trim() + .strip_prefix(AGENT_APP_RUNTIME_EVENT_PREFIX)?; + let (app_id, task_id) = remainder.split_once(':')?; + Some(AgentAppRuntimeProjectionScope { + app_id: non_empty_projection_text(Some(app_id))?, + task_id: non_empty_projection_text(Some(task_id))?, + }) +} + +fn agent_app_runtime_projection_event_name(scope: &AgentAppRuntimeProjectionScope) -> String { + format!( + "{}{}:{}", + AGENT_APP_RUNTIME_EVENT_PREFIX, scope.app_id, scope.task_id + ) +} + +fn profile_event_payload_string(event: &AgentRuntimeProfileEvent, keys: &[&str]) -> Option { + keys.iter().find_map(|key| { + event + .payload + .get(*key) + .and_then(Value::as_str) + .and_then(|value| non_empty_projection_text(Some(value))) + }) +} + +fn agent_app_task_event_type_for_profile_event(event_type: &str) -> &'static str { + match event_type { + "turn.submitted" | "task.retrying" => "task:queued", + "tool.started" | "tool.result" | "tool.failed" => "task:toolCall", + "action.required" => "task:reviewRequested", + "action.resolved" => "task:reviewResolved", + "turn.completed" | "task.completed" => "task:completed", + "turn.failed" | "task.failed" | "model.failed" | "routing.not_possible" => "task:error", + _ => "task:progress", + } +} + +fn agent_app_task_status_for_profile_event(event: &AgentRuntimeProfileEvent) -> String { + profile_event_payload_string(event, &["status"]).unwrap_or_else(|| { + match event.event_type.as_str() { + "turn.submitted" | "task.retrying" => "queued", + "turn.started" | "tool.started" | "model.requested" | "task.attempt.started" => { + "running" + } + "turn.completed" | "task.completed" | "tool.result" | "model.completed" => "completed", + "turn.failed" | "task.failed" | "tool.failed" | "model.failed" => "failed", + _ => "updated", + } + .to_string() + }) +} + +fn agent_app_task_message_for_profile_event(event: &AgentRuntimeProfileEvent) -> String { + if let Some(message) = profile_event_payload_string(event, &["message", "detail", "title"]) { + return message; + } + + match event.event_type.as_str() { + "turn.submitted" => "AgentRuntime 已接收 App 任务", + "turn.started" => "AgentRuntime 正在执行 App 任务", + "turn.completed" => "AgentRuntime App 任务已完成", + "turn.failed" => "AgentRuntime App 任务执行失败", + "tool.started" => "AgentRuntime 工具调用开始", + "tool.result" => "AgentRuntime 工具调用完成", + "tool.failed" => "AgentRuntime 工具调用失败", + "action.required" => "AgentRuntime 等待 Host / 用户响应", + "action.resolved" => "Host / 用户响应已提交到 AgentRuntime", + "snapshot.updated" => "AgentRuntime 线程读模型已更新", + _ => "AgentRuntime App 任务状态已更新", + } + .to_string() +} + +fn build_agent_app_runtime_profile_task_event(event: &AgentRuntimeProfileEvent) -> Value { + let status = agent_app_task_status_for_profile_event(event); + let mut task_event = serde_json::Map::new(); + task_event.insert( + "id".to_string(), + json!(format!("profile:{}:{}", event.event_type, event.sequence)), + ); + task_event.insert( + "eventType".to_string(), + json!(agent_app_task_event_type_for_profile_event( + &event.event_type + )), + ); + task_event.insert("status".to_string(), json!(status)); + task_event.insert( + "message".to_string(), + json!(agent_app_task_message_for_profile_event(event)), + ); + task_event.insert("turnId".to_string(), json!(event.turn_id.clone())); + task_event.insert("occurredAt".to_string(), json!(event.timestamp.clone())); + task_event.insert("payload".to_string(), json!(event)); + + if let Some(tool_name) = profile_event_payload_string(event, &["toolName", "tool_name"]) { + task_event.insert("toolName".to_string(), json!(tool_name)); + } + if let Some(request_id) = + profile_event_payload_string(event, &["actionId", "requestId", "decisionId"]) + { + task_event.insert("requestId".to_string(), json!(request_id)); + } + if matches!( + agent_app_task_event_type_for_profile_event(&event.event_type), + "task:error" + ) { + task_event.insert("severity".to_string(), json!("error")); + } + + Value::Object(task_event) +} + +fn build_agent_app_runtime_profile_projection_payload( + event_name: &str, + event: &AgentRuntimeProfileEvent, +) -> Option { + let scope = parse_agent_app_runtime_projection_scope(event_name)?; + let status = agent_app_task_status_for_profile_event(event); + let task_event = build_agent_app_runtime_profile_task_event(event); + let projection_event_name = agent_app_runtime_projection_event_name(&scope); + + Some(json!({ + "type": "agent_app_runtime:profileProjection", + "eventType": "task:runtimeEvent", + "appId": scope.app_id.clone(), + "taskId": scope.task_id.clone(), + "sessionId": event.session_id, + "threadId": event.thread_id, + "turnId": event.turn_id, + "status": status, + "profileEvent": event, + "runtimeEvent": event, + "taskEvents": [task_event], + "runtimeEventName": projection_event_name, + "emittedAt": chrono::Utc::now().to_rfc3339(), + })) +} + +fn emit_agent_app_runtime_profile_projection_event( + app: &AppHandle, + event_name: &str, + event: &AgentRuntimeProfileEvent, +) { + let Some(scope) = parse_agent_app_runtime_projection_scope(event_name) else { + return; + }; + let Some(payload) = build_agent_app_runtime_profile_projection_payload(event_name, event) + else { + return; + }; + let projection_event_name = agent_app_runtime_projection_event_name(&scope); + if let Err(error) = app.emit(&projection_event_name, payload) { + tracing::warn!( + "[AsterAgent][AgentRuntimeProfile] 发送 Agent App task projection 失败: event_name={}, profile_type={}, error={}", + projection_event_name, + event.event_type, + error + ); + } +} + +fn runtime_event_payload_value(event: &RuntimeAgentEvent) -> Value { + serde_json::to_value(event).unwrap_or_else(|_| json!({ "type": "runtime_event" })) +} + +fn runtime_workspace_patch_from_metadata_value(metadata: Option<&Value>) -> Option { + let metadata = metadata?.as_object()?; + ["contentFactoryWorkspacePatch", "workspacePatch"] + .iter() + .filter_map(|key| metadata.get(*key)) + .find(|value| value.is_object()) + .cloned() +} + +fn runtime_workspace_patch_from_metadata_map( + metadata: Option<&HashMap>, +) -> Option { + let metadata = metadata?; + ["contentFactoryWorkspacePatch", "workspacePatch"] + .iter() + .filter_map(|key| metadata.get(*key)) + .find(|value| value.is_object()) + .cloned() +} + +fn build_runtime_projection_task_event( + id: String, + event_type: &'static str, + status: impl Into, + message: impl Into, + payload: Value, +) -> serde_json::Map { + let mut task_event = serde_json::Map::new(); + task_event.insert("id".to_string(), json!(id)); + task_event.insert("eventType".to_string(), json!(event_type)); + task_event.insert("status".to_string(), json!(status.into())); + task_event.insert("message".to_string(), json!(message.into())); + task_event.insert("payload".to_string(), payload); + task_event +} + +fn build_runtime_projection_artifact_task_event( + id: String, + status: impl Into, + message: impl Into, + artifact_ref: String, + artifact_payload: Value, + workspace_patch: Option, +) -> Value { + let mut payload = serde_json::Map::new(); + payload.insert("artifact".to_string(), artifact_payload); + if let Some(workspace_patch) = workspace_patch { + payload.insert("workspacePatch".to_string(), workspace_patch.clone()); + payload.insert("contentFactoryWorkspacePatch".to_string(), workspace_patch); + } + + let mut task_event = build_runtime_projection_task_event( + id, + "artifact:created", + status, + message, + Value::Object(payload), + ); + task_event.insert("artifactRef".to_string(), json!(artifact_ref)); + Value::Object(task_event) +} + +fn build_runtime_projection_tool_task_event( + id: String, + status: impl Into, + message: impl Into, + tool_name: Option, + payload: Value, + failed: bool, +) -> Value { + let mut task_event = + build_runtime_projection_task_event(id, "task:toolCall", status, message, payload); + if let Some(tool_name) = tool_name { + task_event.insert("toolName".to_string(), json!(tool_name)); + } + if failed { + task_event.insert("severity".to_string(), json!("error")); + } + Value::Object(task_event) +} + +fn build_runtime_projection_evidence_task_events(runtime_event: &Value) -> Vec { + let summary = collect_runtime_evidence_projection_summary_from_value(runtime_event); + build_runtime_projection_evidence_task_events_from_summary(runtime_event, summary) +} + +fn build_runtime_projection_evidence_task_events_from_summary( + runtime_event: &Value, + summary: RuntimeEvidenceProjectionSummary, +) -> Vec { + let mut events = Vec::new(); + for evidence_ref in summary.evidence_refs { + let evidence_ref_for_payload = evidence_ref.clone(); + let mut task_event = build_runtime_projection_task_event( + format!("runtime:evidence:recorded:{evidence_ref}"), + "evidence:recorded", + "recorded", + "运行证据已记录", + json!({ + "evidenceRef": evidence_ref_for_payload, + "runtimeEvent": runtime_event, + }), + ); + task_event.insert("evidenceRef".to_string(), json!(evidence_ref)); + events.push(Value::Object(task_event)); + } + + for (index, outcome) in summary.verification_outcomes.into_iter().enumerate() { + let status = outcome + .get("status") + .or_else(|| outcome.get("outcome")) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .unwrap_or("verified") + .to_string(); + let mut task_event = build_runtime_projection_task_event( + format!("runtime:evidence:verified:{index}"), + "evidence:verified", + status.clone(), + "运行证据已验证", + json!({ + "outcome": outcome, + "runtimeEvent": runtime_event, + }), + ); + let normalized_status = status.to_ascii_lowercase(); + if normalized_status.contains("fail") + || normalized_status.contains("blocking") + || normalized_status.contains("error") + { + task_event.insert("severity".to_string(), json!("error")); + } + events.push(Value::Object(task_event)); + } + + events +} + +fn build_agent_app_runtime_event_primary_task_event( + event: &RuntimeAgentEvent, + runtime_event: Value, +) -> Option { + match event { + RuntimeAgentEvent::RuntimeStatus { status } => { + Some(Value::Object(build_runtime_projection_task_event( + format!("runtime:status:{}", status.phase), + "task:progress", + status.phase.clone(), + status.title.clone(), + runtime_event, + ))) + } + RuntimeAgentEvent::ToolStart { + tool_name, tool_id, .. + } => Some(build_runtime_projection_tool_task_event( + format!("runtime:tool:{tool_id}:started"), + "running", + format!("工具 {tool_name} 开始执行"), + Some(tool_name.clone()), + runtime_event, + false, + )), + RuntimeAgentEvent::ToolEnd { tool_id, result } => { + let tool_name = runtime_tool_name_from_result_metadata(result).map(str::to_string); + Some(build_runtime_projection_tool_task_event( + format!("runtime:tool:{tool_id}:completed"), + if result.success { + "completed" + } else { + "failed" + }, + result + .error + .clone() + .unwrap_or_else(|| "工具调用已完成".to_string()), + tool_name, + runtime_event, + !result.success, + )) + } + RuntimeAgentEvent::ToolProgress { tool_id, progress } => { + Some(Value::Object(build_runtime_projection_task_event( + format!("runtime:tool:{tool_id}:progress"), + "task:toolCall", + "running", + progress + .message + .clone() + .unwrap_or_else(|| "工具正在执行".to_string()), + runtime_event, + ))) + } + RuntimeAgentEvent::ArtifactSnapshot { artifact } => { + Some(build_runtime_projection_artifact_task_event( + format!("runtime:artifact:{}", artifact.artifact_id), + "created", + format!("Artifact 已创建:{}", artifact.file_path), + artifact.file_path.clone(), + runtime_event, + runtime_workspace_patch_from_metadata_map(artifact.metadata.as_ref()), + )) + } + RuntimeAgentEvent::ActionRequired { + request_id, + action_type, + .. + } => { + let event_type = if action_type.contains("missing") || action_type.contains("ask") { + "task:missingContextRequested" + } else { + "task:reviewRequested" + }; + let mut task_event = build_runtime_projection_task_event( + format!("runtime:action:{request_id}:required"), + event_type, + "pending", + "AgentRuntime 等待 Host / 用户响应", + runtime_event, + ); + task_event.insert("requestId".to_string(), json!(request_id)); + Some(Value::Object(task_event)) + } + RuntimeAgentEvent::ActionResolved { request_id, .. } => { + let mut task_event = build_runtime_projection_task_event( + format!("runtime:action:{request_id}:resolved"), + "task:reviewResolved", + "resolved", + "Host / 用户响应已提交到 AgentRuntime", + runtime_event, + ); + task_event.insert("requestId".to_string(), json!(request_id)); + Some(Value::Object(task_event)) + } + RuntimeAgentEvent::TurnCompleted { turn } => { + Some(Value::Object(build_runtime_projection_task_event( + format!("runtime:turn:{}:completed", turn.id), + "task:completed", + turn.status.as_str(), + "AgentRuntime 回合已完成", + runtime_event, + ))) + } + RuntimeAgentEvent::TurnFailed { turn } => { + let mut task_event = build_runtime_projection_task_event( + format!("runtime:turn:{}:failed", turn.id), + "task:error", + turn.status.as_str(), + turn.error_message + .clone() + .unwrap_or_else(|| "AgentRuntime 回合执行失败".to_string()), + runtime_event, + ); + task_event.insert("severity".to_string(), json!("error")); + Some(Value::Object(task_event)) + } + RuntimeAgentEvent::ItemStarted { item } | RuntimeAgentEvent::ItemUpdated { item } => { + match &item.payload { + AgentThreadItemPayload::ToolCall { tool_name, .. } => { + Some(build_runtime_projection_tool_task_event( + format!("runtime:item:{}:tool", item.id), + item.status.as_str(), + format!("工具 {tool_name} {}", item.status.as_str()), + Some(tool_name.clone()), + runtime_event, + item.status == AgentThreadItemStatus::Failed, + )) + } + AgentThreadItemPayload::ApprovalRequest { + request_id, prompt, .. + } + | AgentThreadItemPayload::RequestUserInput { + request_id, prompt, .. + } => { + let mut task_event = build_runtime_projection_task_event( + format!("runtime:item:{}:review", item.id), + "task:reviewRequested", + item.status.as_str(), + prompt + .clone() + .unwrap_or_else(|| "任务等待 Host / 用户响应".to_string()), + runtime_event, + ); + task_event.insert("requestId".to_string(), json!(request_id)); + Some(Value::Object(task_event)) + } + _ => None, + } + } + RuntimeAgentEvent::ItemCompleted { item } => match &item.payload { + AgentThreadItemPayload::ToolCall { + tool_name, + success, + error, + .. + } => Some(build_runtime_projection_tool_task_event( + format!("runtime:item:{}:tool", item.id), + item.status.as_str(), + error + .clone() + .unwrap_or_else(|| format!("工具 {tool_name} {}", item.status.as_str())), + Some(tool_name.clone()), + runtime_event, + item.status == AgentThreadItemStatus::Failed || matches!(success, Some(false)), + )), + AgentThreadItemPayload::FileArtifact { path, metadata, .. } => { + Some(build_runtime_projection_artifact_task_event( + format!("runtime:item:{}:artifact", item.id), + item.status.as_str(), + format!("Artifact 已创建:{path}"), + path.clone(), + runtime_event, + runtime_workspace_patch_from_metadata_value(metadata.as_ref()), + )) + } + AgentThreadItemPayload::ApprovalRequest { + request_id, prompt, .. + } + | AgentThreadItemPayload::RequestUserInput { + request_id, prompt, .. + } => { + let mut task_event = build_runtime_projection_task_event( + format!("runtime:item:{}:review", item.id), + "task:reviewResolved", + item.status.as_str(), + prompt + .clone() + .unwrap_or_else(|| "Host / 用户响应已记录".to_string()), + runtime_event, + ); + task_event.insert("requestId".to_string(), json!(request_id)); + Some(Value::Object(task_event)) + } + AgentThreadItemPayload::Warning { message, code } => { + let mut task_event = build_runtime_projection_task_event( + format!( + "runtime:item:{}:{}", + item.id, + code.as_deref().unwrap_or("warning") + ), + "task:incident", + item.status.as_str(), + message.clone(), + runtime_event, + ); + task_event.insert("severity".to_string(), json!("warning")); + Some(Value::Object(task_event)) + } + AgentThreadItemPayload::Error { message } => { + let mut task_event = build_runtime_projection_task_event( + format!("runtime:item:{}:error", item.id), + "task:error", + item.status.as_str(), + message.clone(), + runtime_event, + ); + task_event.insert("severity".to_string(), json!("error")); + Some(Value::Object(task_event)) + } + _ => None, + }, + RuntimeAgentEvent::QueueAdded { queued_turn, .. } => { + Some(Value::Object(build_runtime_projection_task_event( + format!("runtime:queue:{}:added", queued_turn.queued_turn_id), + "task:queued", + "queued", + queued_turn.message_preview.clone(), + runtime_event, + ))) + } + RuntimeAgentEvent::QueueStarted { queued_turn_id, .. } => { + Some(Value::Object(build_runtime_projection_task_event( + format!("runtime:queue:{queued_turn_id}:started"), + "task:progress", + "running", + "排队任务已开始执行", + runtime_event, + ))) + } + RuntimeAgentEvent::QueueRemoved { queued_turn_id, .. } => { + Some(Value::Object(build_runtime_projection_task_event( + format!("runtime:queue:{queued_turn_id}:removed"), + "task:cancelled", + "cancelled", + "排队任务已移除", + runtime_event, + ))) + } + RuntimeAgentEvent::QueueCleared { .. } => { + Some(Value::Object(build_runtime_projection_task_event( + "runtime:queue:cleared".to_string(), + "task:cancelled", + "cancelled", + "排队任务已清空", + runtime_event, + ))) + } + RuntimeAgentEvent::Done { .. } | RuntimeAgentEvent::FinalDone { .. } => { + Some(Value::Object(build_runtime_projection_task_event( + "runtime:done".to_string(), + "task:completed", + "completed", + "AgentRuntime 本轮输出已结束", + runtime_event, + ))) + } + RuntimeAgentEvent::Error { message } => { + let mut task_event = build_runtime_projection_task_event( + "runtime:error".to_string(), + "task:error", + "failed", + message.clone(), + runtime_event, + ); + task_event.insert("severity".to_string(), json!("error")); + Some(Value::Object(task_event)) + } + RuntimeAgentEvent::Warning { code, message } => { + let mut task_event = build_runtime_projection_task_event( + format!("runtime:warning:{}", code.as_deref().unwrap_or("warning")), + "task:incident", + "warning", + message.clone(), + runtime_event, + ); + task_event.insert("severity".to_string(), json!("warning")); + Some(Value::Object(task_event)) + } + _ => None, + } +} + +fn build_agent_app_runtime_event_task_events(event: &RuntimeAgentEvent) -> Vec { + let runtime_event = runtime_event_payload_value(event); + let mut task_events = Vec::new(); + if let Some(task_event) = + build_agent_app_runtime_event_primary_task_event(event, runtime_event.clone()) + { + task_events.push(task_event); + } + task_events.extend(build_runtime_projection_evidence_task_events( + &runtime_event, + )); + task_events +} + +fn build_agent_app_runtime_event_projection_payload( + event_name: &str, + event: &RuntimeAgentEvent, +) -> Option { + let scope = parse_agent_app_runtime_projection_scope(event_name)?; + let task_events = build_agent_app_runtime_event_task_events(event); + if task_events.is_empty() { + return None; + } + let status = task_events + .first() + .and_then(|task_event| task_event.get("status").and_then(Value::as_str)) + .unwrap_or("updated") + .to_string(); + let projection_event_name = agent_app_runtime_projection_event_name(&scope); + + Some(json!({ + "type": "agent_app_runtime:runtimeEventProjection", + "eventType": "task:runtimeEvent", + "appId": scope.app_id.clone(), + "taskId": scope.task_id.clone(), + "status": status, + "runtimeEvent": event, + "taskEvents": task_events, + "runtimeEventName": projection_event_name, + "emittedAt": chrono::Utc::now().to_rfc3339(), + })) +} + +fn emit_agent_app_runtime_event_projection( + app: &AppHandle, + event_name: &str, + event: &RuntimeAgentEvent, +) { + let Some(scope) = parse_agent_app_runtime_projection_scope(event_name) else { + return; + }; + let Some(payload) = build_agent_app_runtime_event_projection_payload(event_name, event) else { + return; + }; + let projection_event_name = agent_app_runtime_projection_event_name(&scope); + if let Err(error) = app.emit(&projection_event_name, payload) { + tracing::warn!( + "[AsterAgent] 发送 Agent App runtime event projection 失败: event_name={}, error={}", + projection_event_name, + error + ); + } } fn is_runtime_model_permission_denied_error(message: &str) -> bool { @@ -140,6 +826,7 @@ fn emit_submit_accepted_runtime_status(app: &AppHandle, event_name: &str) { error ); } + emit_agent_app_runtime_event_projection(app, event_name, &event); } fn describe_provider_request_attempt(request: &AsterChatRequest) -> (String, String, String) { @@ -837,6 +1524,7 @@ fn emit_runtime_side_event( if let Err(error) = app.emit(event_name, &event) { tracing::warn!("[AsterAgent] 发送 Artifact 运行时事件失败: {}", error); } + emit_agent_app_runtime_event_projection(app, event_name, &event); } fn emit_service_skill_preload_runtime_events( @@ -5417,6 +6105,7 @@ fn fail_runtime_turn_before_model_execution( if let Err(error) = app.emit(event_name, &error_event) { tracing::error!("[AsterAgent] 发送运行时执行前阻断错误事件失败: {}", error); } + emit_agent_app_runtime_event_projection(app, event_name, &error_event); } fn build_runtime_permission_review_status_from_state( @@ -6133,6 +6822,7 @@ fn emit_direct_runtime_stream_event( error ); } + emit_agent_app_runtime_event_projection(app, event_name, event); } #[derive(Default)] @@ -6947,16 +7637,17 @@ async fn finalize_runtime_turn_result( if let Err(error) = app.emit(event_name, &done_event) { tracing::error!("[AsterAgent] 发送完成事件失败: {}", error); } + emit_agent_app_runtime_event_projection(app, event_name, &done_event); emit_subagent_status_changed_events(app, session_id).await; Ok(()) } Err(error) => { if is_runtime_turn_cancelled_error(&error) { - if let Err(emit_error) = - app.emit(event_name, &RuntimeAgentEvent::FinalDone { usage: None }) - { + let final_done_event = RuntimeAgentEvent::FinalDone { usage: None }; + if let Err(emit_error) = app.emit(event_name, &final_done_event) { tracing::error!("[AsterAgent] 发送中断完成事件失败: {}", emit_error); } + emit_agent_app_runtime_event_projection(app, event_name, &final_done_event); emit_subagent_status_changed_events(app, session_id).await; return Ok(()); } @@ -6970,6 +7661,7 @@ async fn finalize_runtime_turn_result( if let Err(emit_error) = app.emit(event_name, &error_event) { tracing::error!("[AsterAgent] 发送错误事件失败: {}", emit_error); } + emit_agent_app_runtime_event_projection(app, event_name, &error_event); emit_subagent_status_changed_events(app, session_id).await; Err(error) } @@ -8212,6 +8904,170 @@ mod tests { "StructuredOutput", ]; + #[test] + fn agent_app_runtime_profile_projection_extracts_scope_from_event_name() { + let scope = parse_agent_app_runtime_projection_scope( + "agent_app_runtime:content-factory-app:task-1", + ) + .expect("agent app runtime scope"); + + assert_eq!(scope.app_id, "content-factory-app"); + assert_eq!(scope.task_id, "task-1"); + assert_eq!( + agent_app_runtime_projection_event_name(&scope), + "agent_app_runtime:content-factory-app:task-1" + ); + assert!(parse_agent_app_runtime_projection_scope("aster_stream:session-1").is_none()); + } + + #[test] + fn agent_app_runtime_profile_projection_builds_canonical_task_event_payload() { + let profile_stream = AgentRuntimeProfileStream::new("session-1", "thread-1", "turn-1") + .expect("profile stream"); + let event = profile_stream.tool_started("tool-1", "Skill(research)"); + + let payload = build_agent_app_runtime_profile_projection_payload( + "agent_app_runtime:content-factory-app:task-1", + &event, + ) + .expect("projection payload"); + let task_event = payload + .get("taskEvents") + .and_then(Value::as_array) + .and_then(|events| events.first()) + .and_then(Value::as_object) + .expect("task event"); + + assert_eq!( + payload.get("type"), + Some(&json!("agent_app_runtime:profileProjection")) + ); + assert_eq!(payload.get("eventType"), Some(&json!("task:runtimeEvent"))); + assert_eq!(payload.get("appId"), Some(&json!("content-factory-app"))); + assert_eq!(payload.get("taskId"), Some(&json!("task-1"))); + assert_eq!(payload.get("sessionId"), Some(&json!("session-1"))); + assert_eq!( + payload.get("runtimeEventName"), + Some(&json!("agent_app_runtime:content-factory-app:task-1")) + ); + assert_eq!(task_event.get("eventType"), Some(&json!("task:toolCall"))); + assert_eq!(task_event.get("status"), Some(&json!("running"))); + assert_eq!(task_event.get("toolName"), Some(&json!("Skill(research)"))); + assert_eq!(task_event.get("turnId"), Some(&json!("turn-1"))); + assert!(payload.get("profileEvent").is_some()); + } + + #[test] + fn agent_app_runtime_runtime_event_projection_builds_artifact_task_event_payload() { + let event = RuntimeAgentEvent::ArtifactSnapshot { + artifact: lime_agent::AgentArtifactSignal { + artifact_id: "artifact-1".to_string(), + file_path: ".lime/artifacts/content-batch.json".to_string(), + content: None, + metadata: Some(HashMap::from([( + "workspacePatch".to_string(), + json!({ + "kind": "content_batch", + "projectId": "project-1", + "contentBatch": { "count": 20 } + }), + )])), + }, + }; + + let payload = build_agent_app_runtime_event_projection_payload( + "agent_app_runtime:content-factory-app:task-1", + &event, + ) + .expect("runtime event projection payload"); + let task_event = payload + .get("taskEvents") + .and_then(Value::as_array) + .and_then(|events| events.first()) + .and_then(Value::as_object) + .expect("task event"); + + assert_eq!( + payload.get("type"), + Some(&json!("agent_app_runtime:runtimeEventProjection")) + ); + assert_eq!( + task_event.get("eventType"), + Some(&json!("artifact:created")) + ); + assert_eq!( + task_event.get("artifactRef"), + Some(&json!(".lime/artifacts/content-batch.json")) + ); + assert_eq!( + task_event + .get("payload") + .and_then(|payload| payload.get("contentFactoryWorkspacePatch")) + .and_then(|patch| patch.get("contentBatch")) + .and_then(|content_batch| content_batch.get("count")), + Some(&json!(20)) + ); + } + + #[test] + fn agent_app_runtime_runtime_event_projection_builds_evidence_task_events_from_metadata() { + let event = RuntimeAgentEvent::ToolEnd { + tool_id: "tool-1".to_string(), + result: lime_agent::AgentToolResult { + success: true, + output: "ok".to_string(), + error: None, + images: None, + metadata: Some(HashMap::from([ + ( + "evidenceRefs".to_string(), + json!(["evidence://session-1/runtime"]), + ), + ( + "verificationOutcomes".to_string(), + json!([{ "status": "passed" }]), + ), + ])), + }, + }; + + let payload = build_agent_app_runtime_event_projection_payload( + "agent_app_runtime:content-factory-app:task-1", + &event, + ) + .expect("runtime event projection payload"); + let task_events = payload + .get("taskEvents") + .and_then(Value::as_array) + .expect("task events"); + + assert_eq!(task_events.len(), 3); + assert!(task_events + .iter() + .any(|event| event.get("eventType") == Some(&json!("task:toolCall")))); + assert!(task_events.iter().any(|event| { + event.get("eventType") == Some(&json!("evidence:recorded")) + && event.get("evidenceRef") == Some(&json!("evidence://session-1/runtime")) + })); + assert!(task_events.iter().any(|event| { + event.get("eventType") == Some(&json!("evidence:verified")) + && event.get("status") == Some(&json!("passed")) + })); + } + + #[test] + fn agent_app_runtime_runtime_event_projection_skips_low_value_text_delta() { + let event = RuntimeAgentEvent::TextDelta { + text: "partial".to_string(), + }; + + assert!(build_agent_app_runtime_event_projection_payload( + "agent_app_runtime:content-factory-app:task-1", + &event, + ) + .is_none()); + } + #[test] fn model_permission_detection_should_include_tenant_whitelist_400() { assert!(is_runtime_model_permission_denied_error( 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 ce0e49e99..1a6fe6dc5 100644 --- a/src-tauri/src/commands/aster_agent_cmd/session_runtime.rs +++ b/src-tauri/src/commands/aster_agent_cmd/session_runtime.rs @@ -203,6 +203,7 @@ pub(crate) async fn create_runtime_session_internal( execution_strategy, true, None, + None, ) .await } @@ -224,6 +225,31 @@ pub(crate) async fn create_runtime_session_internal_with_runtime( name, execution_strategy, run_start_hooks, + None, + Some((state, mcp_manager)), + ) + .await +} + +pub(crate) async fn create_runtime_session_internal_with_runtime_and_session_id( + db: &DbConnection, + state: &AsterAgentState, + mcp_manager: &McpManagerState, + session_id: String, + working_dir: Option, + workspace_id: String, + name: Option, + execution_strategy: Option, + run_start_hooks: bool, +) -> Result { + create_runtime_session_internal_impl( + db, + working_dir, + workspace_id, + name, + execution_strategy, + run_start_hooks, + Some(session_id), Some((state, mcp_manager)), ) .await @@ -236,6 +262,7 @@ async fn create_runtime_session_internal_impl( name: Option, execution_strategy: Option, run_start_hooks: bool, + session_id: Option, runtime: Option<(&AsterAgentState, &McpManagerState)>, ) -> Result { tracing::info!("[AsterAgent] 创建会话: name={:?}", name); @@ -272,18 +299,29 @@ async fn create_runtime_session_internal_impl( .map(ToString::to_string) .or_else(|| Some(workspace_root.clone())); - let session_id = AsterAgentWrapper::create_session_sync( - db, - name, - resolved_working_dir, - workspace_id, - Some( - execution_strategy - .unwrap_or(AsterExecutionStrategy::React) - .as_db_value() - .to_string(), - ), - )?; + let execution_strategy = Some( + execution_strategy + .unwrap_or(AsterExecutionStrategy::React) + .as_db_value() + .to_string(), + ); + let session_id = match session_id { + Some(session_id) => AsterAgentWrapper::create_session_with_id_sync( + db, + session_id, + name, + resolved_working_dir, + workspace_id, + execution_strategy, + )?, + None => AsterAgentWrapper::create_session_sync( + db, + name, + resolved_working_dir, + workspace_id, + execution_strategy, + )?, + }; persist_default_session_access_mode_in_background(session_id.clone()); diff --git a/src-tauri/src/services/agent_app_runtime_capability_catalog_service.rs b/src-tauri/src/services/agent_app_runtime_capability_catalog_service.rs new file mode 100644 index 000000000..c270c8108 --- /dev/null +++ b/src-tauri/src/services/agent_app_runtime_capability_catalog_service.rs @@ -0,0 +1,200 @@ +//! Agent App Runtime capability catalog. +//! +//! 这里不启动新运行时,只把 App 侧 capability hint 映射到既有 Claw skill launch metadata。 + +#[derive(Debug, Clone, Copy)] +pub struct AgentAppRuntimeCapabilityDescriptor { + pub capability_id: &'static str, + pub aliases: &'static [&'static str], + pub launch_key: &'static str, + pub context_key: &'static str, + pub default_kind: &'static str, + pub skill_name: &'static str, +} + +const AGENT_APP_RUNTIME_CAPABILITY_DESCRIPTORS: &[AgentAppRuntimeCapabilityDescriptor] = &[ + AgentAppRuntimeCapabilityDescriptor { + capability_id: "lime.capability.image.generate", + aliases: &[ + "lime.capability.image.generate", + "image.generate", + "image_generation", + "image", + "asset.generate", + ], + launch_key: "image_skill_launch", + context_key: "image_task", + default_kind: "image_task", + skill_name: "image_generate", + }, + AgentAppRuntimeCapabilityDescriptor { + capability_id: "lime.capability.cover.generate", + aliases: &[ + "lime.capability.cover.generate", + "cover.generate", + "cover_generation", + "cover", + ], + launch_key: "cover_skill_launch", + context_key: "cover_task", + default_kind: "cover_task", + skill_name: "cover_generate", + }, + AgentAppRuntimeCapabilityDescriptor { + capability_id: "lime.capability.research.search", + aliases: &[ + "lime.capability.research.search", + "research.search", + "research", + "web_search", + "search", + ], + launch_key: "research_skill_launch", + context_key: "research_request", + default_kind: "research_request", + skill_name: "research", + }, + AgentAppRuntimeCapabilityDescriptor { + capability_id: "lime.capability.report.generate", + aliases: &[ + "lime.capability.report.generate", + "report.generate", + "report", + "competitor_report", + ], + launch_key: "report_skill_launch", + context_key: "report_request", + default_kind: "report_request", + skill_name: "report_generate", + }, + AgentAppRuntimeCapabilityDescriptor { + capability_id: "lime.capability.pdf.read", + aliases: &["lime.capability.pdf.read", "pdf.read", "pdf_extract", "pdf"], + launch_key: "pdf_read_skill_launch", + context_key: "pdf_read_request", + default_kind: "pdf_read_request", + skill_name: "pdf_read", + }, + AgentAppRuntimeCapabilityDescriptor { + capability_id: "lime.capability.summary.generate", + aliases: &[ + "lime.capability.summary.generate", + "summary.generate", + "summary", + "text_summary", + ], + launch_key: "summary_skill_launch", + context_key: "summary_request", + default_kind: "summary_request", + skill_name: "summary", + }, +]; + +fn capability_match_token(value: &str) -> String { + value + .trim() + .chars() + .filter(|ch| ch.is_ascii_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn descriptor_matches_capability( + descriptor: &AgentAppRuntimeCapabilityDescriptor, + value: &str, +) -> bool { + let token = capability_match_token(value); + if token.is_empty() { + return false; + } + + descriptor + .aliases + .iter() + .any(|alias| capability_match_token(alias) == token) +} + +pub fn resolve_primary_capability_descriptor<'a, I>( + values: I, +) -> Option +where + I: IntoIterator, +{ + resolve_capability_descriptors(values).into_iter().next() +} + +pub fn resolve_capability_descriptors<'a, I>(values: I) -> Vec +where + I: IntoIterator, +{ + let mut descriptors = Vec::new(); + + for value in values { + let Some(descriptor) = AGENT_APP_RUNTIME_CAPABILITY_DESCRIPTORS + .iter() + .copied() + .find(|descriptor| descriptor_matches_capability(descriptor, value)) + else { + continue; + }; + if descriptors + .iter() + .any(|existing: &AgentAppRuntimeCapabilityDescriptor| { + existing.capability_id == descriptor.capability_id + }) + { + continue; + } + descriptors.push(descriptor); + } + + descriptors +} + +pub fn supported_capability_ids() -> Vec<&'static str> { + AGENT_APP_RUNTIME_CAPABILITY_DESCRIPTORS + .iter() + .map(|descriptor| descriptor.capability_id) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolves_capability_aliases_to_claw_launch_descriptor() { + let descriptor = + resolve_primary_capability_descriptor(["text_generation", "research.search"]) + .expect("research descriptor"); + + assert_eq!(descriptor.capability_id, "lime.capability.research.search"); + assert_eq!(descriptor.launch_key, "research_skill_launch"); + assert_eq!(descriptor.skill_name, "research"); + } + + #[test] + fn returns_none_for_unknown_capability_without_fake_launch() { + assert!(resolve_primary_capability_descriptor(["text_generation"]).is_none()); + } + + #[test] + fn resolves_multiple_capabilities_without_duplicates() { + let descriptors = resolve_capability_descriptors([ + "research.search", + "image_generation", + "lime.capability.research.search", + ]); + + assert_eq!( + descriptors + .iter() + .map(|descriptor| descriptor.capability_id) + .collect::>(), + vec![ + "lime.capability.research.search", + "lime.capability.image.generate", + ] + ); + } +} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 061887b68..fc6b7a1f3 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -4,6 +4,7 @@ //! 本模块保留 Tauri 相关服务。 // 保留在主 crate 的 Tauri 相关服务 +pub mod agent_app_runtime_capability_catalog_service; pub mod agent_timeline_service; pub mod artifact_diff_service; pub mod artifact_document_service; @@ -46,6 +47,7 @@ pub mod runtime_evidence_observability_service; pub mod runtime_evidence_pack_output_service; pub mod runtime_evidence_pack_service; pub mod runtime_evidence_path_service; +pub mod runtime_evidence_projection_service; pub mod runtime_evidence_request_telemetry_service; pub mod runtime_evidence_tool_classifier_service; pub mod runtime_evidence_verification_service; diff --git a/src-tauri/src/services/runtime_analysis_handoff_service.rs b/src-tauri/src/services/runtime_analysis_handoff_service.rs index 04f52f53d..4345d0545 100644 --- a/src-tauri/src/services/runtime_analysis_handoff_service.rs +++ b/src-tauri/src/services/runtime_analysis_handoff_service.rs @@ -1780,6 +1780,7 @@ mod tests { position: 1, }], tool_calls: Vec::new(), + artifacts: Vec::new(), model_routing: None, evidence_summary: Default::default(), telemetry_summary: Default::default(), diff --git a/src-tauri/src/services/runtime_evidence_pack_service_tests.rs b/src-tauri/src/services/runtime_evidence_pack_service_tests.rs index 496a5e57b..fcd5466b9 100644 --- a/src-tauri/src/services/runtime_evidence_pack_service_tests.rs +++ b/src-tauri/src/services/runtime_evidence_pack_service_tests.rs @@ -265,6 +265,7 @@ fn build_thread_read() -> AgentRuntimeThreadReadModel { error: None, }, ], + artifacts: Vec::new(), model_routing: Some(json!({ "taskKind": "translation", "serviceModelSlot": "translation", diff --git a/src-tauri/src/services/runtime_evidence_projection_service.rs b/src-tauri/src/services/runtime_evidence_projection_service.rs new file mode 100644 index 000000000..b066c4177 --- /dev/null +++ b/src-tauri/src/services/runtime_evidence_projection_service.rs @@ -0,0 +1,167 @@ +//! Runtime evidence 投影辅助服务。 +//! +//! 这里只提取运行时事件 / timeline metadata 中已经存在的 evidence 引用, +//! 不负责导出 Evidence Pack,也不伪造新的 evidence 事实。 + +use serde_json::{json, Value}; +use std::collections::HashSet; + +#[derive(Debug, Clone, Default, PartialEq)] +pub(crate) struct RuntimeEvidenceProjectionSummary { + pub(crate) evidence_refs: Vec, + pub(crate) verification_outcomes: Vec, +} + +impl RuntimeEvidenceProjectionSummary { + pub(crate) fn push_evidence_ref(&mut self, value: impl Into) { + let value = value.into(); + let normalized = value.trim(); + if normalized.is_empty() || self.evidence_refs.iter().any(|item| item == normalized) { + return; + } + self.evidence_refs.push(normalized.to_string()); + } + + fn push_verification_outcome(&mut self, value: Value) { + if value.is_null() { + return; + } + self.verification_outcomes.push(value); + } +} + +pub(crate) fn collect_runtime_evidence_projection_summary_from_value( + value: &Value, +) -> RuntimeEvidenceProjectionSummary { + let mut summary = RuntimeEvidenceProjectionSummary::default(); + let mut visited = HashSet::new(); + collect_from_value(value, &mut summary, &mut visited); + summary +} + +pub(crate) fn collect_runtime_evidence_projection_summary_from_metadata( + metadata: Option<&Value>, +) -> RuntimeEvidenceProjectionSummary { + metadata + .map(collect_runtime_evidence_projection_summary_from_value) + .unwrap_or_default() +} + +pub(crate) fn evidence_ref_from_artifact_path(path: &str) -> Option { + let normalized = path.trim(); + if normalized.is_empty() { + return None; + } + let normalized_path = normalized.replace('\\', "/"); + if normalized_path.contains("/evidence/") + || normalized_path.contains("/controlled-get-evidence/") + || normalized_path.contains("/controlled_get_evidence/") + { + return Some(normalized.to_string()); + } + None +} + +fn normalize_key(key: &str) -> String { + key.chars() + .filter(|character| *character != '_' && *character != '-') + .flat_map(char::to_lowercase) + .collect() +} + +fn is_single_evidence_ref_key(key: &str) -> bool { + matches!( + normalize_key(key).as_str(), + "evidenceref" | "evidencepackref" | "packref" + ) +} + +fn is_many_evidence_refs_key(key: &str) -> bool { + matches!( + normalize_key(key).as_str(), + "evidencerefs" | "evidencepackrefs" | "packrefs" + ) +} + +fn is_single_verification_outcome_key(key: &str) -> bool { + matches!( + normalize_key(key).as_str(), + "verificationoutcome" | "evidenceverificationoutcome" + ) +} + +fn is_many_verification_outcomes_key(key: &str) -> bool { + matches!( + normalize_key(key).as_str(), + "verificationoutcomes" | "evidenceverificationoutcomes" + ) +} + +fn collect_evidence_refs(value: &Value, summary: &mut RuntimeEvidenceProjectionSummary) { + match value { + Value::String(value) => summary.push_evidence_ref(value.clone()), + Value::Array(items) => { + for item in items { + collect_evidence_refs(item, summary); + } + } + Value::Object(object) => { + for (key, value) in object { + if is_single_evidence_ref_key(key) || is_many_evidence_refs_key(key) { + collect_evidence_refs(value, summary); + } + } + } + _ => {} + } +} + +fn collect_verification_outcomes(value: &Value, summary: &mut RuntimeEvidenceProjectionSummary) { + match value { + Value::Array(items) => { + for item in items { + collect_verification_outcomes(item, summary); + } + } + Value::String(value) => { + let normalized = value.trim(); + if !normalized.is_empty() { + summary.push_verification_outcome(json!({ "status": normalized })); + } + } + Value::Object(_) => summary.push_verification_outcome(value.clone()), + _ => {} + } +} + +fn collect_from_value( + value: &Value, + summary: &mut RuntimeEvidenceProjectionSummary, + visited: &mut HashSet, +) { + let address = value as *const Value as usize; + if !visited.insert(address) { + return; + } + + match value { + Value::Object(object) => { + for (key, item) in object { + if is_single_evidence_ref_key(key) || is_many_evidence_refs_key(key) { + collect_evidence_refs(item, summary); + } + if is_single_verification_outcome_key(key) || is_many_verification_outcomes_key(key) + { + collect_verification_outcomes(item, summary); + } + collect_from_value(item, summary, visited); + } + } + Value::Array(items) => { + for item in items { + collect_from_value(item, summary, visited); + } + } + _ => {} + } +} diff --git a/src-tauri/src/services/runtime_handoff_artifact_service.rs b/src-tauri/src/services/runtime_handoff_artifact_service.rs index 7ad00cb5e..a3f02249d 100644 --- a/src-tauri/src/services/runtime_handoff_artifact_service.rs +++ b/src-tauri/src/services/runtime_handoff_artifact_service.rs @@ -1270,6 +1270,7 @@ mod tests { position: 1, }], tool_calls: Vec::new(), + artifacts: Vec::new(), model_routing: None, evidence_summary: Default::default(), telemetry_summary: Default::default(), diff --git a/src-tauri/src/services/runtime_replay_case_service.rs b/src-tauri/src/services/runtime_replay_case_service.rs index bb8521e81..dad219633 100644 --- a/src-tauri/src/services/runtime_replay_case_service.rs +++ b/src-tauri/src/services/runtime_replay_case_service.rs @@ -2994,6 +2994,7 @@ mod tests { position: 1, }], tool_calls: Vec::new(), + artifacts: Vec::new(), model_routing: None, evidence_summary: Default::default(), telemetry_summary: Default::default(), diff --git a/src-tauri/src/services/runtime_review_decision_service.rs b/src-tauri/src/services/runtime_review_decision_service.rs index ba4e71896..de258a560 100644 --- a/src-tauri/src/services/runtime_review_decision_service.rs +++ b/src-tauri/src/services/runtime_review_decision_service.rs @@ -1714,6 +1714,7 @@ mod tests { position: 1, }], tool_calls: Vec::new(), + artifacts: Vec::new(), model_routing: None, evidence_summary: Default::default(), telemetry_summary: Default::default(), diff --git a/src-tauri/tauri.conf.headless.json b/src-tauri/tauri.conf.headless.json index a307db965..0139153d3 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.40.0", + "version": "1.41.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 8e9de1775..8039dc115 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Lime", - "version": "1.40.0", + "version": "1.41.0", "identifier": "com.limecloud.lime", "build": { "beforeDevCommand": "node scripts/start-tauri-dev-server.mjs", diff --git a/src/features/agent-app/fixtures/content-factory-app.json b/src/features/agent-app/fixtures/content-factory-app.json index 8451a2ef7..45420dce8 100644 --- a/src/features/agent-app/fixtures/content-factory-app.json +++ b/src/features/agent-app/fixtures/content-factory-app.json @@ -112,6 +112,30 @@ "title": "内容表", "type": "table", "required": true + }, + { + "key": "scene_table", + "title": "场景表", + "type": "table", + "required": true + }, + { + "key": "content_batch", + "title": "内容批次", + "type": "batch", + "required": true + }, + { + "key": "script_batch", + "title": "脚本批次", + "type": "batch", + "required": true + }, + { + "key": "prompt_batch", + "title": "图片提示词批次", + "type": "batch", + "required": false } ], "policies": [ @@ -155,6 +179,19 @@ "key": "competitor_research", "provider": "lime.tools", "required": false + }, + { + "key": "creative_capability_search", + "provider": "lime.tools", + "capabilities": [ + "lime.capability.image.generate", + "lime.capability.cover.generate", + "lime.capability.research.search", + "lime.capability.report.generate", + "lime.capability.pdf.read", + "lime.capability.summary.generate" + ], + "required": false } ], "evals": [ @@ -163,6 +200,12 @@ "kind": "quality", "evidenceRequired": true, "required": true + }, + { + "key": "publish_readiness", + "kind": "human-review", + "evidenceRequired": false, + "required": false } ], "events": [ @@ -207,4 +250,4 @@ "policy": "ask" } } -} \ No newline at end of file +} diff --git a/src/features/agent-app/index.test.ts b/src/features/agent-app/index.test.ts new file mode 100644 index 000000000..e75d62013 --- /dev/null +++ b/src/features/agent-app/index.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; + +import { + LIME_AGENT_APP_BRIDGE_PROTOCOL, + LIME_AGENT_APP_BRIDGE_VERSION, + createLimeCoreCapabilityAdapters, + createLimeHostBridgeCapabilityInvoker, +} from "."; + +describe("agent-app public SDK exports", () => { + it("exposes the Host Bridge SDK client and core capability adapters from the public feature entry", () => { + expect(createLimeCoreCapabilityAdapters).toEqual(expect.any(Function)); + expect(createLimeHostBridgeCapabilityInvoker).toEqual(expect.any(Function)); + expect(LIME_AGENT_APP_BRIDGE_PROTOCOL).toBe("lime.agentApp.bridge"); + expect(LIME_AGENT_APP_BRIDGE_VERSION).toBe(1); + }); +}); diff --git a/src/features/agent-app/index.ts b/src/features/agent-app/index.ts index ed8c20d58..035cd7460 100644 --- a/src/features/agent-app/index.ts +++ b/src/features/agent-app/index.ts @@ -99,7 +99,81 @@ export { normalizeManifest } from "./manifest/normalizeManifest"; export { projectApp } from "./projection/projectApp"; export { checkReadiness } from "./readiness/checkReadiness"; export { p0HostCapabilityProfile } from "./readiness/hostCapabilityProfile"; -export { AgentAppCapabilityError } from "./sdk/capabilityErrors"; +export { + AgentAppCapabilityError, + LIME_CAPABILITY_ERROR_CODES, + isLimeCapabilityErrorCode, + normalizeLimeCapabilityErrorCode, + toLimeCapabilityError, +} from "./sdk/capabilityErrors"; +export type { + AgentAppCapabilityErrorInit, + LimeCapabilityError, + LimeCapabilityErrorCode, + LimeCapabilityErrorContext, +} from "./sdk/capabilityErrors"; +export { + LIME_CAPABILITY_NAMES, + buildLimeCapabilityInvokeProvenance, + buildLimeCapabilityInvokeRequest, + createLimeCapabilityErrorResponse, + createLimeCapabilityInvoker, + createLimeCapabilitySuccessResponse, + createMockLimeCapabilityTransport, +} from "./sdk/capabilityContract"; +export type { + BuildLimeCapabilityInvokeRequestParams, + LimeCapabilityArgs, + LimeCapabilityContractMap, + LimeCapabilityInvokeProvenance, + LimeCapabilityInvokeRequest, + LimeCapabilityInvokeResponse, + LimeCapabilityInvoker, + LimeCapabilityMethod, + LimeCapabilityMockHandler, + LimeCapabilityMockHandlers, + LimeCapabilityName, + LimeCapabilityTransport, + LimeCapabilityValue, + LimeTypedCapabilityInvokeRequest, + LimeTypedCapabilityInvokeResponse, +} from "./sdk/capabilityContract"; +export { + LimeCapabilityAdapterError, + createLimeCoreCapabilityAdapters, +} from "./sdk/capabilityAdapters"; +export type { + CreateLimeCoreCapabilityAdaptersOptions, + LimeAgentCapabilityAdapter, + LimeArtifactsCapabilityAdapter, + LimeCapabilityAdapterCallOptions, + LimeCoreCapabilityAdapters, + LimeEvidenceCapabilityAdapter, + LimeKnowledgeCapabilityAdapter, + LimeStorageCapabilityAdapter, + LimeToolsCapabilityAdapter, + LimeUiCapabilityAdapter, +} from "./sdk/capabilityAdapters"; +export { + LIME_AGENT_APP_BRIDGE_PROTOCOL, + LIME_AGENT_APP_BRIDGE_VERSION, + createLimeHostBridgeCapabilityInvoker, +} from "./sdk/hostBridgeClient"; +export type { + CreateLimeHostBridgeCapabilityInvokerOptions, + LimeAgentAppBridgeClientMessage, + LimeHostBridgeCapabilityEvent, + LimeHostBridgeCapabilityEventHandler, + LimeHostBridgeCapabilityInvoker, + LimeHostBridgeCapabilitySubscribeRequest, + LimeHostBridgeCapabilitySubscription, + LimeHostBridgeCapabilityUnsubscribeResult, + LimeHostBridgeDownloadPayload, + LimeHostBridgeEventHandler, + LimeHostBridgeNavigatePayload, + LimeHostBridgeNotifyPayload, + LimeHostBridgeOpenExternalPayload, +} from "./sdk/hostBridgeClient"; export { MockCapabilityHost } from "./sdk/MockCapabilityHost"; export { buildMockCapabilityProfile } from "./sdk/mockCapabilityProfile"; export { buildAgentAppProvenance } from "./sdk/provenance"; diff --git a/src/features/agent-app/manifest/parseManifest.test.ts b/src/features/agent-app/manifest/parseManifest.test.ts index d5efa8f79..65ff7bbd4 100644 --- a/src/features/agent-app/manifest/parseManifest.test.ts +++ b/src/features/agent-app/manifest/parseManifest.test.ts @@ -41,9 +41,22 @@ describe("Agent App manifest P0", () => { expect(normalized.toolRefs.map((tool) => tool.key)).toEqual([ "document_parser", "competitor_research", + "creative_capability_search", + ]); + expect( + normalized.toolRefs.find((tool) => tool.key === "creative_capability_search") + ?.capabilities, + ).toEqual([ + "lime.capability.image.generate", + "lime.capability.cover.generate", + "lime.capability.research.search", + "lime.capability.report.generate", + "lime.capability.pdf.read", + "lime.capability.summary.generate", ]); expect(normalized.evals.map((evalRule) => evalRule.key)).toEqual([ "fact_grounding", + "publish_readiness", ]); expect(normalized.secrets.map((secret) => secret.key)).toEqual([ "publishing_workspace_token", diff --git a/src/features/agent-app/projection/projectApp.test.ts b/src/features/agent-app/projection/projectApp.test.ts index e683a00d7..e7485bb78 100644 --- a/src/features/agent-app/projection/projectApp.test.ts +++ b/src/features/agent-app/projection/projectApp.test.ts @@ -41,6 +41,10 @@ describe("Agent App projection P0", () => { ]); expect(projection.artifactTypes.map((artifact) => artifact.key)).toEqual([ "content_table", + "scene_table", + "content_batch", + "script_batch", + "prompt_batch", ]); expect(projection.services.map((service) => service.key)).toEqual([ "content_worker", @@ -54,9 +58,23 @@ describe("Agent App projection P0", () => { expect(projection.toolRequirements.map((tool) => tool.key)).toEqual([ "document_parser", "competitor_research", + "creative_capability_search", + ]); + expect( + projection.toolRequirements.find( + (tool) => tool.key === "creative_capability_search", + )?.capabilities, + ).toEqual([ + "lime.capability.image.generate", + "lime.capability.cover.generate", + "lime.capability.research.search", + "lime.capability.report.generate", + "lime.capability.pdf.read", + "lime.capability.summary.generate", ]); expect(projection.evals.map((evalRule) => evalRule.key)).toEqual([ "fact_grounding", + "publish_readiness", ]); expect(projection.secrets.map((secret) => secret.key)).toEqual([ "publishing_workspace_token", diff --git a/src/features/agent-app/projection/projectApp.ts b/src/features/agent-app/projection/projectApp.ts index 552ae2410..48e8f79d6 100644 --- a/src/features/agent-app/projection/projectApp.ts +++ b/src/features/agent-app/projection/projectApp.ts @@ -208,6 +208,7 @@ export function projectApp(params: { toolRequirements: manifest.toolRefs.map((tool) => ({ key: tool.key, provider: tool.provider, + capabilities: tool.capabilities ?? [], required: tool.required ?? false, })), evals: manifest.evals.map((evalRule) => ({ diff --git a/src/features/agent-app/readiness/checkReadiness.test.ts b/src/features/agent-app/readiness/checkReadiness.test.ts index 2d21f5173..26cf03db6 100644 --- a/src/features/agent-app/readiness/checkReadiness.test.ts +++ b/src/features/agent-app/readiness/checkReadiness.test.ts @@ -29,7 +29,12 @@ const resolvedSetup: AgentAppSetupState = { document_parser: true, competitor_research: true, }, - artifactTypes: { content_table: true }, + artifactTypes: { + content_table: true, + scene_table: true, + content_batch: true, + script_batch: true, + }, evals: { fact_grounding: true }, secrets: { publishing_workspace_token: true }, overlays: { workspace_content_rules: true }, diff --git a/src/features/agent-app/runtime/agentRuntimeCapabilityHost.test.ts b/src/features/agent-app/runtime/agentRuntimeCapabilityHost.test.ts index 733723e24..f4ce72b2d 100644 --- a/src/features/agent-app/runtime/agentRuntimeCapabilityHost.test.ts +++ b/src/features/agent-app/runtime/agentRuntimeCapabilityHost.test.ts @@ -63,6 +63,20 @@ describe("AgentRuntimeCapabilityHost", () => { evidenceRef: "evidence-1", occurredAt: "2026-05-15T00:00:02.000Z", }, + { + id: "artifact:created:artifact-1", + eventType: "artifact:created", + status: "created", + message: "内容批次已创建", + artifactRef: ".lime/artifacts/content-batch.json", + occurredAt: "2026-05-15T00:00:02.500Z", + payload: { + contentFactoryWorkspacePatch: { + kind: "content_batch", + contentBatch: { count: 20 }, + }, + }, + }, ], threadRead: { session_id: request.sessionId, @@ -102,6 +116,11 @@ describe("AgentRuntimeCapabilityHost", () => { expectedOutput: { artifactKind: "content_table" }, tools: ["image_generation"], humanReview: true, + providerPreference: "deepseek", + modelPreference: "deepseek-v4-flash", + queueIfBusy: true, + skipPreSubmitResume: true, + runStartHooks: false, }); const snapshot = await sdk.agent.getTask(started.taskId); const stream = await sdk.agent.streamTask(started.taskId); @@ -124,6 +143,11 @@ describe("AgentRuntimeCapabilityHost", () => { taskKind: "content.scenario_planning", capabilityHints: ["image_generation"], humanReview: true, + providerPreference: "deepseek", + modelPreference: "deepseek-v4-flash", + queueIfBusy: true, + skipPreSubmitResume: true, + runStartHooks: false, }), ); expect(started).toMatchObject({ @@ -149,6 +173,15 @@ describe("AgentRuntimeCapabilityHost", () => { type: "evidence:recorded", refs: ["evidence-1"], }), + expect.objectContaining({ + type: "artifact:created", + refs: [".lime/artifacts/content-batch.json"], + payload: expect.objectContaining({ + contentFactoryWorkspacePatch: expect.objectContaining({ + kind: "content_batch", + }), + }), + }), ]), }); expect(stream).toEqual( @@ -201,4 +234,244 @@ describe("AgentRuntimeCapabilityHost", () => { retryAttempt: 1, }); }); + + it("从 Agent App storage 恢复 runtime task state,支持刷新后继续读取和响应", async () => { + const api = { + startTask: vi.fn(async (request) => ({ + appId: request.appId, + entryKey: request.entryKey, + taskId: "agent-app-task-persisted", + traceId: "agent-app-trace-persisted", + taskKind: request.taskKind, + sessionId: "session-persisted", + turnId: "turn-persisted", + eventName: `agent_app_runtime:${request.appId}:agent-app-task-persisted`, + status: "accepted" as const, + submittedAt: "2026-05-15T00:00:00.000Z", + })), + getTask: vi.fn(async (request) => ({ + appId: request.appId, + taskId: request.taskId, + sessionId: request.sessionId, + status: "thread_read_available" as const, + taskStatus: "completed", + taskEvents: [ + { + id: "artifact:created:artifact-1", + eventType: "artifact:created", + status: "created", + message: "内容批次已创建", + artifactRef: ".lime/artifacts/content-batch.json", + occurredAt: "2026-05-15T00:00:02.000Z", + }, + ], + threadRead: { + session_id: request.sessionId, + profile_status: "completed", + }, + })), + cancelTask: vi.fn(async (request) => ({ + appId: request.appId, + taskId: request.taskId, + sessionId: request.sessionId, + cancelled: true, + status: "cancelled" as const, + })), + submitHostResponse: vi.fn(async (request) => ({ + appId: request.appId, + taskId: request.taskId, + status: "submitted" as const, + })), + }; + const delegate = buildDelegateHost(); + const firstHost = new AgentRuntimeCapabilityHost({ + delegate, + appId: "content-factory-app", + appVersion: "0.3.0", + packageHash: "package-hash-1", + manifestHash: "manifest-hash-1", + workspaceIdResolver: async () => "workspace-1", + api, + now: () => "2026-05-15T00:00:03.000Z", + }); + const started = await firstHost + .createSdkContext("dashboard") + .agent.startTask({ + title: "生成内容批次", + taskKind: "content.copy.generate", + input: { projectId: "project-1" }, + expectedOutput: { artifactKind: "content_batch" }, + humanReview: true, + }); + + expect( + delegate + .getStorageEntries({ appId: "content-factory-app" }) + .some( + (entry) => + entry.key === + "agent-runtime/tasks/agent-app-task-persisted", + ), + ).toBe(true); + + const reloadedHost = new AgentRuntimeCapabilityHost({ + delegate, + appId: "content-factory-app", + appVersion: "0.3.0", + packageHash: "package-hash-1", + manifestHash: "manifest-hash-1", + workspaceIdResolver: async () => "workspace-1", + api, + now: () => "2026-05-15T00:00:04.000Z", + }); + const reloadedSdk = reloadedHost.createSdkContext("dashboard"); + const restored = await reloadedSdk.agent.getTask(started.taskId); + const listed = await reloadedSdk.agent.listTasks(); + const hostResponse = await reloadedSdk.agent.submitHostResponse({ + taskId: started.taskId, + requestId: "request-1", + actionType: "ask_user", + response: "继续执行。", + }); + + expect(api.getTask).toHaveBeenCalledWith({ + appId: "content-factory-app", + taskId: "agent-app-task-persisted", + sessionId: "session-persisted", + }); + expect(restored).toMatchObject({ + taskId: "agent-app-task-persisted", + status: "succeeded", + events: [ + expect.objectContaining({ + type: "artifact:created", + refs: [".lime/artifacts/content-batch.json"], + }), + ], + }); + expect(listed).toEqual( + expect.arrayContaining([ + expect.objectContaining({ taskId: "agent-app-task-persisted" }), + ]), + ); + expect(api.submitHostResponse).toHaveBeenCalledWith({ + appId: "content-factory-app", + taskId: "agent-app-task-persisted", + runtimeRequest: expect.objectContaining({ + session_id: "session-persisted", + request_id: "request-1", + action_scope: expect.objectContaining({ + session_id: "session-persisted", + turn_id: "turn-persisted", + }), + }), + }); + expect(hostResponse).toEqual({ + taskId: "agent-app-task-persisted", + requestId: "request-1", + status: "submitted", + submittedAt: "2026-05-15T00:00:04.000Z", + }); + }); + + it("从 threadRead artifacts 补投 artifact:created payload,保证 Host Bridge 可 replay 最终产物", async () => { + const api = { + startTask: vi.fn(async (request) => ({ + appId: request.appId, + entryKey: request.entryKey, + taskId: "agent-app-task-artifact-replay", + traceId: "agent-app-trace-artifact-replay", + taskKind: request.taskKind, + sessionId: "session-artifact-replay", + turnId: "turn-artifact-replay", + eventName: `agent_app_runtime:${request.appId}:agent-app-task-artifact-replay`, + status: "accepted" as const, + submittedAt: "2026-05-15T00:00:00.000Z", + })), + getTask: vi.fn(async (request) => ({ + appId: request.appId, + taskId: request.taskId, + sessionId: request.sessionId, + status: "thread_read_available" as const, + taskStatus: "completed", + taskEvents: [ + { + id: "task:completed", + eventType: "task:completed", + status: "completed", + message: "任务已完成", + occurredAt: "2026-05-15T00:00:02.000Z", + }, + ], + threadRead: { + session_id: request.sessionId, + profile_status: "completed", + artifacts: [ + { + item_id: "artifact-item-1", + path: ".lime/artifacts/content-batch.json", + title: "内容批次", + status: "completed", + completed_at: "2026-05-15T00:00:03.000Z", + metadata: { + artifactDocument: { + blocks: [ + { + content: "```json\n{\"contentFactoryWorkspacePatch\":{\"kind\":\"content_batch\",\"contentBatch\":{\"count\":20}}}\n```", + }, + ], + }, + }, + }, + ], + }, + })), + cancelTask: vi.fn(async (request) => ({ + appId: request.appId, + taskId: request.taskId, + sessionId: request.sessionId, + cancelled: true, + status: "cancelled" as const, + })), + submitHostResponse: vi.fn(async (request) => ({ + appId: request.appId, + taskId: request.taskId, + status: "submitted" as const, + })), + }; + const host = new AgentRuntimeCapabilityHost({ + delegate: buildDelegateHost(), + appId: "content-factory-app", + appVersion: "0.3.0", + packageHash: "package-hash-1", + manifestHash: "manifest-hash-1", + workspaceIdResolver: async () => "workspace-1", + api, + now: () => "2026-05-15T00:00:04.000Z", + }); + const sdk = host.createSdkContext("dashboard"); + const started = await sdk.agent.startTask({ + title: "生成内容批次", + taskKind: "content.copy.generate", + input: { projectId: "project-1" }, + expectedOutput: { artifactKind: "content_batch" }, + humanReview: true, + }); + + const snapshot = await sdk.agent.getTask(started.taskId); + + expect(snapshot?.events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "artifact:created", + refs: [".lime/artifacts/content-batch.json"], + payload: expect.objectContaining({ + artifactDocument: expect.objectContaining({ + blocks: expect.any(Array), + }), + }), + }), + ]), + ); + }); }); diff --git a/src/features/agent-app/runtime/agentRuntimeCapabilityHost.ts b/src/features/agent-app/runtime/agentRuntimeCapabilityHost.ts index 82d4074f7..8a34cea6f 100644 --- a/src/features/agent-app/runtime/agentRuntimeCapabilityHost.ts +++ b/src/features/agent-app/runtime/agentRuntimeCapabilityHost.ts @@ -82,12 +82,19 @@ interface RuntimeTaskState { retryAttempt?: number; } +const RUNTIME_TASK_STORAGE_PREFIX = "agent-runtime/tasks/"; + type RuntimeAgentTaskRequest = AgentAppTaskRequest & { requiredCapabilities?: string[]; capabilityHints?: string[]; metadata?: Record; sessionId?: string; workspaceId?: string; + providerPreference?: string; + modelPreference?: string; + queueIfBusy?: boolean; + skipPreSubmitResume?: boolean; + runStartHooks?: boolean; }; const defaultRuntimeApi: AgentAppRuntimeCapabilityApi = { @@ -106,6 +113,39 @@ function normalizeList(values: string[] | undefined): string[] { return (values ?? []).map((value) => value.trim()).filter(Boolean); } +function runtimeTaskStorageKey(taskId: string): string { + return `${RUNTIME_TASK_STORAGE_PREFIX}${taskId}`; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isRuntimeTaskState(value: unknown): value is RuntimeTaskState { + return ( + isRecord(value) && + typeof value.appId === "string" && + typeof value.appVersion === "string" && + typeof value.packageHash === "string" && + typeof value.manifestHash === "string" && + typeof value.taskId === "string" && + typeof value.traceId === "string" && + typeof value.sessionId === "string" && + typeof value.turnId === "string" && + typeof value.workspaceId === "string" && + typeof value.taskKind === "string" && + typeof value.startedAt === "string" && + isRecord(value.request) + ); +} + +function readPersistedRuntimeTaskState(value: unknown): RuntimeTaskState | null { + if (isRecord(value) && isRuntimeTaskState(value.state)) { + return value.state; + } + return isRuntimeTaskState(value) ? value : null; +} + function mapRuntimeTaskStatus(status: string | undefined): AgentAppTaskStatus { const normalized = status?.trim().toLowerCase(); if (!normalized) { @@ -148,6 +188,7 @@ function mapRuntimeEventType(eventType: string): AgentAppTaskEventType { eventType === "task:cancelled" || eventType === "task:completed" || eventType === "task:incident" || + eventType === "artifact:created" || eventType === "evidence:recorded" || eventType === "evidence:verified" ) { @@ -192,18 +233,104 @@ function mapRuntimeEvent( if (event.evidenceRef) { streamEvent.refs = [event.evidenceRef]; } + if (event.artifactRef) { + streamEvent.refs = [...(streamEvent.refs ?? []), event.artifactRef]; + } return streamEvent; } +function readRecordString( + value: Record, + key: string, +): string | undefined { + const item = value[key]; + return typeof item === "string" && item.trim() ? item.trim() : undefined; +} + +function readThreadReadArtifacts(threadRead: unknown): Record[] { + if (!isRecord(threadRead) || !Array.isArray(threadRead.artifacts)) { + return []; + } + return threadRead.artifacts.filter(isRecord); +} + +function buildRuntimeArtifactReplayEvents( + state: RuntimeTaskState, + threadRead: unknown, + existingEvents: AgentAppTaskStreamEvent[], +): AgentAppTaskStreamEvent[] { + const artifacts = readThreadReadArtifacts(threadRead); + if (!artifacts.length) { + return []; + } + const existingArtifactRefs = new Set( + existingEvents.flatMap((event) => event.refs ?? []), + ); + return artifacts.flatMap((artifact, index) => { + const artifactRef = + readRecordString(artifact, "path") ?? + readRecordString(artifact, "item_id") ?? + readRecordString(artifact, "id") ?? + `artifact:${index + 1}`; + if (existingArtifactRefs.has(artifactRef)) { + return []; + } + const metadata = isRecord(artifact.metadata) ? artifact.metadata : {}; + const artifactDocument = + isRecord(metadata.artifactDocument) || isRecord(metadata.artifact_document) + ? metadata.artifactDocument ?? metadata.artifact_document + : undefined; + const workspacePatch = + isRecord(metadata.workspacePatch) || isRecord(metadata.contentFactoryWorkspacePatch) + ? metadata.workspacePatch ?? metadata.contentFactoryWorkspacePatch + : undefined; + return [ + { + eventId: `${state.taskId}:artifact:${artifactRef}`, + taskId: state.taskId, + traceId: state.traceId, + type: "artifact:created" as const, + status: "succeeded" as const, + at: + readRecordString(artifact, "completed_at") ?? + readRecordString(artifact, "updated_at") ?? + readRecordString(artifact, "created_at") ?? + state.startedAt, + message: + readRecordString(artifact, "title") ?? + readRecordString(artifact, "artifact_type") ?? + "Artifact 已创建", + refs: [artifactRef], + payload: { + artifact, + artifactDocument, + workspacePatch, + contentFactoryWorkspacePatch: workspacePatch, + }, + }, + ]; + }); +} + function buildTaskRecord( state: RuntimeTaskState, snapshot?: AgentAppRuntimeTaskSnapshot, ): AgentAppTaskRecord { - const events = snapshot?.taskEvents.length + const runtimeEvents = snapshot?.taskEvents.length ? snapshot.taskEvents.map((event, index) => mapRuntimeEvent(state, event, index), ) : [buildStartEvent(state)]; + const events = snapshot + ? [ + ...runtimeEvents, + ...buildRuntimeArtifactReplayEvents( + state, + snapshot.threadRead, + runtimeEvents, + ), + ] + : runtimeEvents; const status = snapshot ? mapRuntimeTaskStatus(snapshot.taskStatus) : "running"; @@ -328,7 +455,14 @@ export class AgentRuntimeCapabilityHost implements CapabilityHost { } getTasks(query?: AgentAppProvenanceQuery): AgentAppTaskRecord[] { - const runtimeTasks = Array.from(this.tasks.values()) + const runtimeStates = new Map(); + for (const task of this.listPersistedRuntimeTaskStates(query)) { + runtimeStates.set(task.taskId, task); + } + for (const task of this.tasks.values()) { + runtimeStates.set(task.taskId, task); + } + const runtimeTasks = Array.from(runtimeStates.values()) .filter((task) => !query?.appId || task.appId === query.appId) .map((task) => buildTaskRecord(task)); return [...this.delegate.getTasks(query), ...runtimeTasks]; @@ -396,6 +530,11 @@ export class AgentRuntimeCapabilityHost implements CapabilityHost { capabilityHints, knowledgeBindings: runtimeRequest.knowledge, humanReview: runtimeRequest.humanReview, + providerPreference: runtimeRequest.providerPreference, + modelPreference: runtimeRequest.modelPreference, + queueIfBusy: runtimeRequest.queueIfBusy, + skipPreSubmitResume: runtimeRequest.skipPreSubmitResume, + runStartHooks: runtimeRequest.runStartHooks, metadata: { ...(runtimeRequest.metadata ?? {}), agent_app_host_bridge: { @@ -427,13 +566,15 @@ export class AgentRuntimeCapabilityHost implements CapabilityHost { retryAttempt: retry?.retryAttempt, }; this.tasks.set(state.taskId, state); + await this.persistRuntimeTaskState(state); return buildTaskRecord(state); } private async getRuntimeTask( taskId: string, ): Promise { - const state = this.tasks.get(taskId); + const state = + this.tasks.get(taskId) ?? this.loadPersistedRuntimeTaskState(taskId); if (!state) { return null; } @@ -446,7 +587,7 @@ export class AgentRuntimeCapabilityHost implements CapabilityHost { } private async cancelRuntimeTask(taskId: string): Promise { - const state = this.requireTask(taskId); + const state = await this.requireTask(taskId); await this.api.cancelTask({ appId: state.appId, taskId: state.taskId, @@ -477,7 +618,7 @@ export class AgentRuntimeCapabilityHost implements CapabilityHost { entryKey: string, taskId: string, ): Promise { - const source = this.requireTask(taskId); + const source = await this.requireTask(taskId); const retryAttempt = (source.retryAttempt ?? 0) + 1; return this.startRuntimeTask( entryKey, @@ -493,7 +634,7 @@ export class AgentRuntimeCapabilityHost implements CapabilityHost { private async submitRuntimeHostResponse( input: AgentAppTaskHostResponseRequest, ): Promise { - const state = this.requireTask(input.taskId); + const state = await this.requireTask(input.taskId); const actionScope: NonNullable< AgentRuntimeRespondActionRequest["action_scope"] > = { @@ -541,8 +682,54 @@ export class AgentRuntimeCapabilityHost implements CapabilityHost { }; } - private requireTask(taskId: string): RuntimeTaskState { - const state = this.tasks.get(taskId); + private async persistRuntimeTaskState(state: RuntimeTaskState): Promise { + try { + const sdk = this.delegate.createSdkContext(state.entryKey ?? state.appId); + await sdk.storage.set(runtimeTaskStorageKey(state.taskId), { + schemaVersion: 1, + state, + }); + } catch (error) { + console.warn("Agent App runtime task state persistence skipped", error); + } + } + + private listPersistedRuntimeTaskStates( + query?: AgentAppProvenanceQuery, + ): RuntimeTaskState[] { + return this.delegate + .getStorageEntries({ appId: query?.appId ?? this.appId }) + .filter((entry) => entry.key.startsWith(RUNTIME_TASK_STORAGE_PREFIX)) + .map((entry) => readPersistedRuntimeTaskState(entry.value)) + .filter((state): state is RuntimeTaskState => { + if (!state) { + return false; + } + if (query?.appId && state.appId !== query.appId) { + return false; + } + if (query?.entryKey && state.entryKey !== query.entryKey) { + return false; + } + return true; + }); + } + + private loadPersistedRuntimeTaskState(taskId: string): RuntimeTaskState | null { + const entry = this.delegate + .getStorageEntries({ appId: this.appId }) + .find((item) => item.key === runtimeTaskStorageKey(taskId)); + const state = readPersistedRuntimeTaskState(entry?.value); + if (state) { + this.tasks.set(state.taskId, state); + return state; + } + return null; + } + + private async requireTask(taskId: string): Promise { + const state = + this.tasks.get(taskId) ?? this.loadPersistedRuntimeTaskState(taskId); if (state) { return state; } diff --git a/src/features/agent-app/runtime/capabilityDispatcher.test.ts b/src/features/agent-app/runtime/capabilityDispatcher.test.ts index 3b8c5d96a..567d59550 100644 --- a/src/features/agent-app/runtime/capabilityDispatcher.test.ts +++ b/src/features/agent-app/runtime/capabilityDispatcher.test.ts @@ -14,9 +14,32 @@ import { AgentAppCapabilityDispatcherError, createAgentAppCapabilityDispatcher, } from "./capabilityDispatcher"; +import type { AgentAppHostBridgeCapabilityRequest } from "./hostBridge"; const FIXED_NOW = "2026-05-15T00:00:00.000Z"; +type CapabilityRequestFixture = Omit< + AgentAppHostBridgeCapabilityRequest, + "invokeRequest" +> & + Partial>; + +function buildCapabilityRequest( + request: CapabilityRequestFixture, +): AgentAppHostBridgeCapabilityRequest { + return { + ...request, + invokeRequest: + request.invokeRequest ?? + ({ + capability: request.capability, + method: request.method, + args: request.input ?? request.args?.[0], + requestId: request.requestId, + } as AgentAppHostBridgeCapabilityRequest["invokeRequest"]), + }; +} + function buildDispatcher() { const preview = buildInstalledAppPreview({ profile: buildWorkflowRuntimeCapabilityProfile({ @@ -34,12 +57,115 @@ function buildDispatcher() { now: () => FIXED_NOW, }); - return createAgentAppCapabilityDispatcher({ + const dispatch = createAgentAppCapabilityDispatcher({ host, projection: preview.projection, entryKey: "dashboard", runId: "bridge-run-1", }); + return (request: CapabilityRequestFixture) => + dispatch(buildCapabilityRequest(request)); +} + +function buildDispatcherWithoutDeclaredCapability(capability: string) { + const preview = buildInstalledAppPreview({ + profile: buildWorkflowRuntimeCapabilityProfile({ + realAdapterEnabled: true, + uiRuntimeEnabled: true, + workerRuntimeEnabled: true, + }), + loadedAt: FIXED_NOW, + checkedAt: FIXED_NOW, + generatedAt: FIXED_NOW, + }); + const host = new AdapterCapabilityHost({ + preview, + store: new InMemoryAgentAppCapabilityStore(), + now: () => FIXED_NOW, + }); + const projection = { + ...preview.projection, + requiredCapabilities: preview.projection.requiredCapabilities.filter( + (requirement) => requirement.capability !== capability, + ), + }; + + const dispatch = createAgentAppCapabilityDispatcher({ + host, + projection, + entryKey: "dashboard", + runId: "bridge-run-1", + }); + return (request: CapabilityRequestFixture) => + dispatch(buildCapabilityRequest(request)); +} + +function buildDispatcherWithoutCreativeCapabilityToolRef() { + const preview = buildInstalledAppPreview({ + profile: buildWorkflowRuntimeCapabilityProfile({ + realAdapterEnabled: true, + uiRuntimeEnabled: true, + workerRuntimeEnabled: true, + }), + loadedAt: FIXED_NOW, + checkedAt: FIXED_NOW, + generatedAt: FIXED_NOW, + }); + const host = new AdapterCapabilityHost({ + preview, + store: new InMemoryAgentAppCapabilityStore(), + now: () => FIXED_NOW, + }); + const projection = { + ...preview.projection, + toolRequirements: preview.projection.toolRequirements.filter( + (tool) => tool.key !== "creative_capability_search", + ), + }; + + const dispatch = createAgentAppCapabilityDispatcher({ + host, + projection, + entryKey: "dashboard", + runId: "bridge-run-1", + }); + return (request: CapabilityRequestFixture) => + dispatch(buildCapabilityRequest(request)); +} + +function buildDispatcherWithoutCreativeCapabilityAllowlist() { + const preview = buildInstalledAppPreview({ + profile: buildWorkflowRuntimeCapabilityProfile({ + realAdapterEnabled: true, + uiRuntimeEnabled: true, + workerRuntimeEnabled: true, + }), + loadedAt: FIXED_NOW, + checkedAt: FIXED_NOW, + generatedAt: FIXED_NOW, + }); + const host = new AdapterCapabilityHost({ + preview, + store: new InMemoryAgentAppCapabilityStore(), + now: () => FIXED_NOW, + }); + const projection = { + ...preview.projection, + toolRequirements: preview.projection.toolRequirements.map((tool) => + tool.key === "creative_capability_search" + ? { ...tool, capabilities: [] } + : tool, + ), + }; + + const dispatch = createAgentAppCapabilityDispatcher({ + host, + projection, + entryKey: "dashboard", + runId: "bridge-run-1", + }); + return (request: CapabilityRequestFixture) => + dispatch(buildCapabilityRequest(request)); } describe("createAgentAppCapabilityDispatcher", () => { @@ -232,6 +358,106 @@ describe("createAgentAppCapabilityDispatcher", () => { ).rejects.toBeInstanceOf(AgentAppCapabilityDispatcherError); }); + it("应拒绝 manifest 未声明的 Host capability,避免绕过声明边界", async () => { + const dispatch = buildDispatcherWithoutDeclaredCapability("lime.agent"); + + await expect( + dispatch({ + appId: "content-factory-app", + entryKey: "dashboard", + requestId: "req-task", + capability: "lime.agent", + method: "startTask", + input: { + title: "生成内容场景", + prompt: "基于项目知识生成内容规划", + taskKind: "content.scenario_planning", + }, + rawPayload: { + capability: "lime.agent", + method: "startTask", + }, + }), + ).rejects.toMatchObject({ + code: "CAPABILITY_NOT_DECLARED", + }); + }); + + it("应拒绝 manifest 未声明的 Claw capability hint,保留 catalog 授权边界", async () => { + const dispatch = buildDispatcherWithoutCreativeCapabilityToolRef(); + + await expect( + dispatch({ + appId: "content-factory-app", + entryKey: "dashboard", + requestId: "req-task", + capability: "lime.agent", + method: "startTask", + input: { + title: "生成内容场景", + prompt: "基于项目知识生成内容规划", + taskKind: "content.scenario_planning", + tools: ["research.search"], + }, + rawPayload: { + capability: "lime.agent", + method: "startTask", + }, + }), + ).rejects.toMatchObject({ + code: "CAPABILITY_NOT_DECLARED", + }); + + const allowedDispatch = buildDispatcher(); + const task = (await allowedDispatch({ + appId: "content-factory-app", + entryKey: "dashboard", + requestId: "req-task-allowed", + capability: "lime.agent", + method: "startTask", + input: { + title: "生成内容场景", + prompt: "基于项目知识生成内容规划", + taskKind: "content.scenario_planning", + tools: ["research.search"], + }, + rawPayload: { + capability: "lime.agent", + method: "startTask", + }, + })) as AgentAppTaskRecord; + expect(task).toMatchObject({ + taskKind: "content.scenario_planning", + tools: ["research.search"], + }); + }); + + it("应拒绝未列入 catalog allowlist 的 Claw capability hint", async () => { + const dispatch = buildDispatcherWithoutCreativeCapabilityAllowlist(); + + await expect( + dispatch({ + appId: "content-factory-app", + entryKey: "dashboard", + requestId: "req-task", + capability: "lime.agent", + method: "startTask", + input: { + title: "生成内容场景", + prompt: "基于项目知识生成内容规划", + taskKind: "content.scenario_planning", + capabilityHints: ["image_generation"], + }, + rawPayload: { + capability: "lime.agent", + method: "startTask", + }, + }), + ).rejects.toMatchObject({ + code: "CAPABILITY_NOT_DECLARED", + }); + }); + it("应只允许声明过的 artifact / evidence 写回", async () => { const dispatch = buildDispatcher(); @@ -283,6 +509,54 @@ describe("createAgentAppCapabilityDispatcher", () => { }), }); + const contentBatchArtifact = (await dispatch({ + appId: "content-factory-app", + entryKey: "dashboard", + capability: "lime.artifacts", + method: "create", + input: { + kind: "content_batch", + title: "内容批次", + content: { count: 20 }, + }, + rawPayload: { + capability: "lime.artifacts", + method: "create", + }, + })) as AgentAppArtifactRecord; + expect(contentBatchArtifact).toMatchObject({ + kind: "content_batch", + title: "内容批次", + provenance: expect.objectContaining({ + appId: "content-factory-app", + workflowRunId: "bridge-run-1", + }), + }); + + const publishReadiness = (await dispatch({ + appId: "content-factory-app", + entryKey: "dashboard", + capability: "lime.evidence", + method: "record", + input: { + kind: "publish_readiness", + message: "内容批次已可继续审核。", + refs: [contentBatchArtifact.id], + }, + rawPayload: { + capability: "lime.evidence", + method: "record", + }, + })) as AgentAppEvidenceRecord; + expect(publishReadiness).toMatchObject({ + kind: "publish_readiness", + refs: [contentBatchArtifact.id], + provenance: expect.objectContaining({ + appId: "content-factory-app", + workflowRunId: "bridge-run-1", + }), + }); + await expect( dispatch({ appId: "content-factory-app", diff --git a/src/features/agent-app/runtime/capabilityDispatcher.ts b/src/features/agent-app/runtime/capabilityDispatcher.ts index 8ccbd9eb7..3993bd571 100644 --- a/src/features/agent-app/runtime/capabilityDispatcher.ts +++ b/src/features/agent-app/runtime/capabilityDispatcher.ts @@ -23,6 +23,69 @@ export class AgentAppCapabilityDispatcherError extends Error { } } +const CREATIVE_CAPABILITY_TOOL_KEYS = new Set([ + "creative_capability_search", + "claw_capability_catalog", + "agent_runtime_capability_catalog", +].map(capabilityMatchToken)); + +const CLAW_CAPABILITY_ALIASES: Array<{ + capabilityId: string; + aliases: string[]; +}> = [ + { + capabilityId: "lime.capability.image.generate", + aliases: [ + "lime.capability.image.generate", + "image.generate", + "image_generation", + "image", + "asset.generate", + ], + }, + { + capabilityId: "lime.capability.cover.generate", + aliases: [ + "lime.capability.cover.generate", + "cover.generate", + "cover_generation", + "cover", + ], + }, + { + capabilityId: "lime.capability.research.search", + aliases: [ + "lime.capability.research.search", + "research.search", + "research", + "web_search", + "search", + ], + }, + { + capabilityId: "lime.capability.report.generate", + aliases: [ + "lime.capability.report.generate", + "report.generate", + "report", + "competitor_report", + ], + }, + { + capabilityId: "lime.capability.pdf.read", + aliases: ["lime.capability.pdf.read", "pdf.read", "pdf_extract", "pdf"], + }, + { + capabilityId: "lime.capability.summary.generate", + aliases: [ + "lime.capability.summary.generate", + "summary.generate", + "summary", + "text_summary", + ], + }, +]; + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } @@ -38,6 +101,9 @@ function readInputRecord( if (isRecord(request.input)) { return request.input; } + if (isRecord(request.invokeRequest?.args)) { + return request.invokeRequest.args; + } const firstArg = request.args?.[0]; if (isRecord(firstArg)) { return firstArg; @@ -56,8 +122,11 @@ function readStringParam( const fromInput = isRecord(request.input) ? readString(request.input[key]) : undefined; + const fromInvokeArgs = isRecord(request.invokeRequest?.args) + ? readString(request.invokeRequest.args[key]) + : undefined; const fromArgs = readString(request.args?.[argIndex]); - const value = fromInput ?? fromArgs; + const value = fromInput ?? fromInvokeArgs ?? fromArgs; if (!value) { throw new AgentAppCapabilityDispatcherError( "INVALID_CAPABILITY_INPUT", @@ -71,6 +140,96 @@ function hasOwn(value: Record, key: string): boolean { return Object.prototype.hasOwnProperty.call(value, key); } +function capabilityMatchToken(value: string): string { + return value + .trim() + .replace(/[^a-zA-Z0-9]/g, "") + .toLowerCase(); +} + +function normalizeStringList(value: unknown): string[] { + return Array.isArray(value) + ? value + .map((item) => (typeof item === "string" ? item.trim() : "")) + .filter(Boolean) + : []; +} + +function resolveClawCapabilityId(value: string): string | null { + const token = capabilityMatchToken(value); + if (!token) { + return null; + } + const descriptor = CLAW_CAPABILITY_ALIASES.find((item) => + item.aliases.some((alias) => capabilityMatchToken(alias) === token), + ); + return descriptor?.capabilityId ?? null; +} + +function collectRequestedClawCapabilityIds(input: Record): string[] { + const requested = [ + ...normalizeStringList(input.requiredCapabilities), + ...normalizeStringList(input.capabilityHints), + ...normalizeStringList(input.tools), + ]; + return Array.from( + new Set( + requested + .map(resolveClawCapabilityId) + .filter((item): item is string => Boolean(item)), + ), + ).sort(); +} + +function manifestDeclaresClawCapability( + projection: AgentAppProjection, + capabilityId: string, +): boolean { + const descriptor = CLAW_CAPABILITY_ALIASES.find( + (item) => item.capabilityId === capabilityId, + ); + if (!descriptor) { + return false; + } + return projection.toolRequirements.some((tool) => + toolRequirementDeclaresClawCapability(tool, descriptor), + ); +} + +function toolRequirementDeclaresClawCapability( + tool: AgentAppProjection["toolRequirements"][number], + descriptor: (typeof CLAW_CAPABILITY_ALIASES)[number], +): boolean { + const toolKeyToken = capabilityMatchToken(tool.key); + if ( + descriptor.aliases.some((alias) => capabilityMatchToken(alias) === toolKeyToken) + ) { + return true; + } + if (!CREATIVE_CAPABILITY_TOOL_KEYS.has(toolKeyToken)) { + return false; + } + return tool.capabilities.some( + (capability) => resolveClawCapabilityId(capability) === descriptor.capabilityId, + ); +} + +function assertAgentTaskClawCapabilitiesDeclared( + projection: AgentAppProjection, + input: Record, +): void { + const missing = collectRequestedClawCapabilityIds(input).filter( + (capabilityId) => !manifestDeclaresClawCapability(projection, capabilityId), + ); + if (!missing.length) { + return; + } + throw new AgentAppCapabilityDispatcherError( + "CAPABILITY_NOT_DECLARED", + `Agent task requested Claw capabilities not declared by manifest: ${missing.join(", ")}.`, + ); +} + function assertStorageWriteDeclared(projection: AgentAppProjection): void { if (projection.storage) { return; @@ -81,6 +240,41 @@ function assertStorageWriteDeclared(projection: AgentAppProjection): void { ); } +function isCapabilityDeclared( + projection: AgentAppProjection, + capability: string, + entryKey?: string, +): boolean { + if ( + projection.requiredCapabilities.some( + (requirement) => requirement.capability === capability, + ) + ) { + return true; + } + const entry = projection.entries.find((item) => item.key === entryKey); + return ( + entry?.requiredCapabilities.some( + (requirement) => requirement.capability === capability, + ) ?? false + ); +} + +function assertCapabilityDeclared( + projection: AgentAppProjection, + request: AgentAppHostBridgeCapabilityRequest, + fallbackEntryKey: string, +): void { + const entryKey = request.entryKey ?? fallbackEntryKey; + if (isCapabilityDeclared(projection, request.capability, entryKey)) { + return; + } + throw new AgentAppCapabilityDispatcherError( + "CAPABILITY_NOT_DECLARED", + `${request.capability} is not declared by Agent App manifest.`, + ); +} + function assertArtifactKindDeclared( projection: AgentAppProjection, kind: string, @@ -219,11 +413,12 @@ async function dispatchKnowledge( async function dispatchAgent( sdk: LimeAppSdk, request: AgentAppHostBridgeCapabilityRequest, + projection: AgentAppProjection, ): Promise { if (request.method === "startTask") { - return sdk.agent.startTask( - readInputRecord(request, "startTask") as unknown as AgentAppTaskRequest, - ); + const input = readInputRecord(request, "startTask"); + assertAgentTaskClawCapabilitiesDeclared(projection, input); + return sdk.agent.startTask(input as unknown as AgentAppTaskRequest); } if (request.method === "streamTask") { return sdk.agent.streamTask(readStringParam(request, "taskId", 0)); @@ -270,6 +465,8 @@ export function createAgentAppCapabilityDispatcher({ runId, }: CreateAgentAppCapabilityDispatcherOptions): AgentAppCapabilityDispatcher { return async (request) => { + assertCapabilityDeclared(projection, request, entryKey); + const sdk = host.createSdkContext( request.entryKey ?? entryKey, resolveRunId(request, runId), @@ -288,7 +485,7 @@ export function createAgentAppCapabilityDispatcher({ return dispatchKnowledge(sdk, request); } if (request.capability === "lime.agent") { - return dispatchAgent(sdk, request); + return dispatchAgent(sdk, request, projection); } throw new AgentAppCapabilityDispatcherError( "UNSUPPORTED_CAPABILITY", diff --git a/src/features/agent-app/runtime/entryRuntimeGuard.test.ts b/src/features/agent-app/runtime/entryRuntimeGuard.test.ts index 1ed066dbe..d408d70c1 100644 --- a/src/features/agent-app/runtime/entryRuntimeGuard.test.ts +++ b/src/features/agent-app/runtime/entryRuntimeGuard.test.ts @@ -12,7 +12,12 @@ const resolvedSetup: AgentAppSetupState = { knowledgeBindings: { project_knowledge: true }, skills: { "article-writer": true }, tools: { document_parser: true }, - artifactTypes: { content_table: true }, + artifactTypes: { + content_table: true, + scene_table: true, + content_batch: true, + script_batch: true, + }, evals: { fact_grounding: true }, services: { content_worker: true }, workflows: { content_scenario_planning: true }, diff --git a/src/features/agent-app/runtime/hostBridge.test.ts b/src/features/agent-app/runtime/hostBridge.test.ts index b029ab149..89d1ae5d7 100644 --- a/src/features/agent-app/runtime/hostBridge.test.ts +++ b/src/features/agent-app/runtime/hostBridge.test.ts @@ -234,7 +234,14 @@ describe("AgentAppHostBridge", () => { type: "host:error", requestId: "req-1", payload: expect.objectContaining({ - code: "CAPABILITY_BLOCKED", + ok: false, + code: "capability_unavailable", + causeCode: "CAPABILITY_BLOCKED", + error: expect.objectContaining({ + code: "capability_unavailable", + capability: "lime.storage", + method: "set", + }), }), }), runtimeOrigin, @@ -477,10 +484,21 @@ describe("AgentAppHostBridge", () => { data: buildAppMessage("capability:invoke", { capability: "lime.agent", method: "startTask", - input: { + args: { title: "生成内容场景", taskKind: "content.scenario_planning", }, + idempotencyKey: "dashboard:scenario", + expectedSchema: { type: "object" }, + provenance: { + appId, + entryKey, + packageHash: + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + manifestHash: + "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + workflowRunId: "run-1", + }, }), origin: runtimeOrigin, source: frame.contentWindow, @@ -488,32 +506,67 @@ describe("AgentAppHostBridge", () => { ); await flushBridgeTasks(); - expect(dispatchCapability).toHaveBeenCalledWith({ - appId, - entryKey, - requestId: "req-1", - capability: "lime.agent", - method: "startTask", - input: { - title: "生成内容场景", - taskKind: "content.scenario_planning", - }, - rawPayload: expect.objectContaining({ + expect(dispatchCapability).toHaveBeenCalledWith( + expect.objectContaining({ + appId, + entryKey, + requestId: "req-1", capability: "lime.agent", method: "startTask", + input: { + title: "生成内容场景", + taskKind: "content.scenario_planning", + }, + idempotencyKey: "dashboard:scenario", + expectedSchema: { type: "object" }, + provenance: { + appId, + entryKey, + packageHash: + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + manifestHash: + "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + workflowRunId: "run-1", + }, + invokeRequest: expect.objectContaining({ + capability: "lime.agent", + method: "startTask", + args: { + title: "生成内容场景", + taskKind: "content.scenario_planning", + }, + requestId: "req-1", + idempotencyKey: "dashboard:scenario", + expectedSchema: { type: "object" }, + provenance: expect.objectContaining({ + appId, + entryKey, + workflowRunId: "run-1", + }), + }), + rawPayload: expect.objectContaining({ + capability: "lime.agent", + method: "startTask", + }), }), - }); + ); expect(postMessage).toHaveBeenCalledWith( expect.objectContaining({ type: "host:response", requestId: "req-1", - payload: { + payload: expect.objectContaining({ + ok: true, + value: { + taskId: "task-1", + traceId: "trace-1", + status: "running", + }, result: { taskId: "task-1", traceId: "trace-1", status: "running", }, - }, + }), }), runtimeOrigin, ); @@ -521,6 +574,304 @@ describe("AgentAppHostBridge", () => { cleanup(); }); + it("支持订阅 lime.agent task 并把 Host 侧更新推送回 App iframe", async () => { + vi.useFakeTimers(); + const { frame, postMessage } = buildFrame(); + const dispatchCapability = vi.fn().mockResolvedValue({ + taskId: "task-1", + taskStatus: "running", + taskEvents: [{ eventType: "task:progress", message: "任务正在执行" }], + }); + const bridge = new AgentAppHostBridge({ + frame, + appId, + entryKey, + displayName: "内容工厂", + entryRoute: "/dashboard", + entryUrl, + dispatchCapability, + listenRuntimeEvent: async () => () => undefined, + now: () => "2026-05-16T00:00:00.000Z", + }); + const cleanup = bridge.start(); + + try { + window.dispatchEvent( + new MessageEvent("message", { + data: buildAppMessage("capability:subscribe", { + capability: "lime.agent", + topic: "task", + input: { taskId: "task-1", bridgeAction: "contentFactoryProduction" }, + pollIntervalMs: 250, + }), + origin: runtimeOrigin, + source: frame.contentWindow, + }), + ); + await flushBridgeTasks(); + + expect(dispatchCapability).toHaveBeenCalledWith( + expect.objectContaining({ + appId, + entryKey, + capability: "lime.agent", + method: "getTask", + input: { taskId: "task-1" }, + }), + ); + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "host:response", + requestId: "req-1", + payload: expect.objectContaining({ + subscriptionId: "agent-app-subscription-1", + taskId: "task-1", + bridgeAction: "contentFactoryProduction", + }), + }), + runtimeOrigin, + ); + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "capability:event", + payload: expect.objectContaining({ + subscriptionId: "agent-app-subscription-1", + capability: "lime.agent", + topic: "task", + eventType: "task:update", + taskId: "task-1", + bridgeAction: "contentFactoryProduction", + events: [{ eventType: "task:progress", message: "任务正在执行" }], + }), + }), + runtimeOrigin, + ); + + window.dispatchEvent( + new MessageEvent("message", { + data: buildAppMessage("capability:unsubscribe", { + subscriptionId: "agent-app-subscription-1", + }), + origin: runtimeOrigin, + source: frame.contentWindow, + }), + ); + await flushBridgeTasks(); + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "host:response", + payload: { + subscriptionId: "agent-app-subscription-1", + unsubscribed: true, + }, + }), + runtimeOrigin, + ); + } finally { + cleanup(); + vi.useRealTimers(); + } + }); + + it("订阅 task 时应同时接入 AgentRuntime event bus 并转发运行时事件", async () => { + vi.useFakeTimers(); + const { frame, postMessage } = buildFrame(); + let runtimeHandler: + | ((event: { payload: unknown }) => void) + | undefined; + const runtimeUnlisten = vi.fn(); + const listenRuntimeEventMock = vi.fn( + async ( + _eventName: string, + handler: (event: { payload: unknown }) => void, + ) => { + runtimeHandler = handler; + return runtimeUnlisten; + }, + ); + const listenRuntimeEvent = + listenRuntimeEventMock as unknown as typeof import("@/lib/dev-bridge").safeListen; + const dispatchCapability = vi.fn().mockResolvedValue({ + taskId: "task-1", + taskStatus: "running", + taskEvents: [], + }); + const bridge = new AgentAppHostBridge({ + frame, + appId, + entryKey, + displayName: "内容工厂", + entryRoute: "/dashboard", + entryUrl, + dispatchCapability, + listenRuntimeEvent, + now: () => "2026-05-16T00:00:00.000Z", + }); + const cleanup = bridge.start(); + + try { + window.dispatchEvent( + new MessageEvent("message", { + data: buildAppMessage("capability:subscribe", { + capability: "lime.agent", + topic: "task", + input: { taskId: "task-1", bridgeAction: "contentFactoryProduction" }, + pollIntervalMs: 250, + }), + origin: runtimeOrigin, + source: frame.contentWindow, + }), + ); + await flushBridgeTasks(); + + expect(listenRuntimeEventMock).toHaveBeenCalledWith( + "agent_app_runtime:content-factory-app:task-1", + expect.any(Function), + ); + + runtimeHandler?.({ + payload: { + type: "task:progress", + status: "running", + message: "后端 runtime event 已到达", + }, + }); + + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "capability:event", + payload: expect.objectContaining({ + subscriptionId: "agent-app-subscription-1", + eventType: "task:runtimeEvent", + taskId: "task-1", + bridgeAction: "contentFactoryProduction", + runtimeEventName: "agent_app_runtime:content-factory-app:task-1", + events: [ + expect.objectContaining({ + eventType: "task:progress", + message: "后端 runtime event 已到达", + }), + ], + }), + }), + runtimeOrigin, + ); + + window.dispatchEvent( + new MessageEvent("message", { + data: buildAppMessage("capability:unsubscribe", { + subscriptionId: "agent-app-subscription-1", + }), + origin: runtimeOrigin, + source: frame.contentWindow, + }), + ); + await flushBridgeTasks(); + expect(runtimeUnlisten).toHaveBeenCalled(); + } finally { + cleanup(); + vi.useRealTimers(); + } + }); + + it("成功终态未带 artifact 时应继续短轮询直到最终 patch replay", async () => { + vi.useFakeTimers(); + const { frame, postMessage } = buildFrame(); + const dispatchCapability = vi + .fn() + .mockResolvedValueOnce({ + taskId: "task-1", + taskStatus: "completed", + events: [{ eventType: "task:completed", message: "任务已完成" }], + }) + .mockResolvedValueOnce({ + taskId: "task-1", + taskStatus: "completed", + events: [{ eventType: "task:completed", message: "任务已完成" }], + result: { + artifacts: [ + { + path: ".lime/artifacts/content-batch.json", + title: "内容批次已创建", + metadata: { + workspacePatch: { + kind: "content_batch", + contentBatch: { count: 20 }, + }, + }, + }, + ], + }, + }); + const bridge = new AgentAppHostBridge({ + frame, + appId, + entryKey, + displayName: "内容工厂", + entryRoute: "/dashboard", + entryUrl, + dispatchCapability, + listenRuntimeEvent: async () => () => undefined, + now: () => "2026-05-16T00:00:00.000Z", + }); + const cleanup = bridge.start(); + + try { + window.dispatchEvent( + new MessageEvent("message", { + data: buildAppMessage("capability:subscribe", { + capability: "lime.agent", + topic: "task", + input: { taskId: "task-1", bridgeAction: "contentFactoryProduction" }, + pollIntervalMs: 250, + }), + origin: runtimeOrigin, + source: frame.contentWindow, + }), + ); + await flushBridgeTasks(); + + expect(dispatchCapability).toHaveBeenCalledTimes(1); + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "capability:event", + payload: expect.objectContaining({ + taskId: "task-1", + events: [{ eventType: "task:completed", message: "任务已完成" }], + }), + }), + runtimeOrigin, + ); + + await vi.advanceTimersByTimeAsync(250); + await flushBridgeTasks(); + + expect(dispatchCapability).toHaveBeenCalledTimes(2); + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "capability:event", + payload: expect.objectContaining({ + taskId: "task-1", + events: expect.arrayContaining([ + expect.objectContaining({ + eventType: "artifact:created", + payload: expect.objectContaining({ + contentFactoryWorkspacePatch: expect.objectContaining({ + contentBatch: { count: 20 }, + }), + }), + }), + ]), + }), + }), + runtimeOrigin, + ); + } finally { + cleanup(); + vi.useRealTimers(); + } + }); + it("capability dispatcher 的结构化错误码应透传给 App", async () => { const { frame, postMessage } = buildFrame(); const error = Object.assign(new Error("不支持该能力方法。"), { @@ -554,10 +905,18 @@ describe("AgentAppHostBridge", () => { expect.objectContaining({ type: "host:error", requestId: "req-1", - payload: { - code: "UNSUPPORTED_CAPABILITY_METHOD", + payload: expect.objectContaining({ + ok: false, + code: "capability_unavailable", message: "不支持该能力方法。", - }, + causeCode: "UNSUPPORTED_CAPABILITY_METHOD", + error: expect.objectContaining({ + code: "capability_unavailable", + capability: "lime.agent", + method: "retryTask", + causeCode: "UNSUPPORTED_CAPABILITY_METHOD", + }), + }), }), runtimeOrigin, ); diff --git a/src/features/agent-app/runtime/hostBridge.ts b/src/features/agent-app/runtime/hostBridge.ts index a2c979f2f..05cff8b16 100644 --- a/src/features/agent-app/runtime/hostBridge.ts +++ b/src/features/agent-app/runtime/hostBridge.ts @@ -10,6 +10,15 @@ import { type LimeEffectiveThemeMode, type LimeThemeMode, } from "@/lib/appearance/themeMode"; +import { safeListen } from "@/lib/dev-bridge"; +import { + buildLimeCapabilityInvokeRequest, + createLimeCapabilitySuccessResponse, + type LimeCapabilityInvokeProvenance, + type LimeCapabilityInvokeRequest, + type LimeCapabilityName, +} from "../sdk/capabilityContract"; +import { toLimeCapabilityError } from "../sdk/capabilityErrors"; export const AGENT_APP_BRIDGE_PROTOCOL = "lime.agentApp.bridge"; export const AGENT_APP_BRIDGE_VERSION = 1; @@ -74,6 +83,10 @@ export interface AgentAppHostBridgeCapabilityRequest { method: string; args?: unknown[]; input?: unknown; + idempotencyKey?: string; + expectedSchema?: unknown; + provenance?: LimeCapabilityInvokeProvenance; + invokeRequest: LimeCapabilityInvokeRequest; rawPayload: Record; } @@ -91,6 +104,7 @@ export interface CreateAgentAppHostBridgeOptions { dispatchCapability?: ( request: AgentAppHostBridgeCapabilityRequest, ) => Promise | unknown; + listenRuntimeEvent?: typeof safeListen; now?: () => string; } @@ -106,8 +120,15 @@ const HOST_ACTION_TYPES = new Set([ "host:openExternal", "host:download", "capability:invoke", + "capability:subscribe", + "capability:unsubscribe", ]); +const DEFAULT_TASK_SUBSCRIPTION_POLL_INTERVAL_MS = 1000; +const MIN_TASK_SUBSCRIPTION_POLL_INTERVAL_MS = 250; +const MAX_TASK_SUBSCRIPTION_POLL_INTERVAL_MS = 10_000; +const DEFAULT_TERMINAL_ARTIFACT_REPLAY_POLLS = 4; + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } @@ -117,12 +138,233 @@ function readString(value: Record, key: string): string | undef return typeof item === "string" && item.trim() ? item.trim() : undefined; } +function readPositiveInteger( + value: unknown, + fallback: number, + min: number, + max: number, +): number { + const number = Number(value); + if (!Number.isFinite(number)) { + return fallback; + } + return Math.min(Math.max(Math.floor(number), min), max); +} + +function hasOwn(value: Record, key: string): boolean { + return Object.prototype.hasOwnProperty.call(value, key); +} + function readErrorCode(error: unknown): string | undefined { return isRecord(error) && typeof error.code === "string" && error.code.trim() ? error.code.trim() : undefined; } +function readCapabilityInvokeProvenance( + value: unknown, +): LimeCapabilityInvokeProvenance | undefined { + if (!isRecord(value)) { + return undefined; + } + if ( + typeof value.appId !== "string" || + typeof value.packageHash !== "string" || + typeof value.manifestHash !== "string" + ) { + return undefined; + } + return { + appId: value.appId, + packageHash: value.packageHash, + manifestHash: value.manifestHash, + entryKey: typeof value.entryKey === "string" ? value.entryKey : undefined, + workflowRunId: + typeof value.workflowRunId === "string" ? value.workflowRunId : undefined, + workspaceId: + typeof value.workspaceId === "string" ? value.workspaceId : undefined, + taskId: typeof value.taskId === "string" ? value.taskId : undefined, + }; +} + +function readTaskIdFromPayload(payload: Record): string | undefined { + const directTaskId = readString(payload, "taskId"); + if (directTaskId) { + return directTaskId; + } + if (isRecord(payload.input)) { + const inputTaskId = readString(payload.input, "taskId"); + if (inputTaskId) { + return inputTaskId; + } + } + return Array.isArray(payload.args) && typeof payload.args[0] === "string" + ? payload.args[0].trim() + : undefined; +} + +function readRuntimeEventNameFromPayload( + appId: string, + taskId: string, + payload: Record, +): string { + const explicit = + readString(payload, "eventName") ?? + (isRecord(payload.input) ? readString(payload.input, "eventName") : undefined); + return explicit ?? `agent_app_runtime:${appId}:${taskId}`; +} + +function buildTaskEventsFromRuntimeEventPayload(payload: unknown): unknown[] { + if (!isRecord(payload)) { + return []; + } + return [ + { + eventType: + readString(payload, "eventType") ?? + readString(payload, "type") ?? + "task:runtimeEvent", + status: readString(payload, "status"), + message: + readString(payload, "message") ?? + readString(payload, "status") ?? + readString(payload, "type") ?? + "AgentRuntime event", + payload, + }, + ]; +} + +function readTaskEventsFromValue(value: unknown): unknown[] { + if (!isRecord(value)) { + return []; + } + const artifactEvents = buildArtifactReplayEventsFromValue(value); + if (Array.isArray(value.events)) { + return [...value.events, ...artifactEvents]; + } + if (Array.isArray(value.taskEvents)) { + return [...value.taskEvents, ...artifactEvents]; + } + return artifactEvents; +} + +function readArtifactsFromValue(value: unknown): Record[] { + if (!isRecord(value)) { + return []; + } + const direct = Array.isArray(value.artifacts) ? value.artifacts : []; + const fromResult = isRecord(value.result) ? readArtifactsFromValue(value.result) : []; + const fromThreadRead = isRecord(value.threadRead) + ? readArtifactsFromValue(value.threadRead) + : []; + return [...direct, ...fromResult, ...fromThreadRead].filter(isRecord); +} + +function buildArtifactReplayEventsFromValue(value: unknown): unknown[] { + const artifacts = readArtifactsFromValue(value); + if (!artifacts.length) { + return []; + } + return artifacts.map((artifact, index) => { + const metadata = isRecord(artifact.metadata) ? artifact.metadata : {}; + const artifactRef = + readString(artifact, "path") ?? + readString(artifact, "item_id") ?? + readString(artifact, "id") ?? + `artifact:${index + 1}`; + const artifactDocument = + isRecord(metadata.artifactDocument) || isRecord(metadata.artifact_document) + ? (metadata.artifactDocument ?? metadata.artifact_document) + : undefined; + const workspacePatch = + isRecord(metadata.workspacePatch) || isRecord(metadata.contentFactoryWorkspacePatch) + ? (metadata.workspacePatch ?? metadata.contentFactoryWorkspacePatch) + : undefined; + return { + eventType: "artifact:created", + status: readString(artifact, "status") ?? "created", + message: + readString(artifact, "title") ?? + readString(artifact, "artifact_type") ?? + "Artifact 已创建", + artifactRef, + payload: { + artifact, + artifactDocument, + workspacePatch, + contentFactoryWorkspacePatch: workspacePatch, + }, + }; + }); +} + +function isTerminalTaskValue(value: unknown): boolean { + if (!isRecord(value)) { + return false; + } + const status = String(value.taskStatus ?? value.status ?? "").toLowerCase(); + return [ + "succeeded", + "success", + "completed", + "complete", + "failed", + "failure", + "error", + "cancelled", + "canceled", + ].includes(status); +} + +function isSuccessfulTerminalTaskValue(value: unknown): boolean { + if (!isRecord(value)) { + return false; + } + const status = String(value.taskStatus ?? value.status ?? "").toLowerCase(); + return ["succeeded", "success", "completed", "complete"].includes(status); +} + +function hasWorkspacePatchPayload(value: unknown): boolean { + if (!isRecord(value)) { + return false; + } + if (isRecord(value.workspacePatch) || isRecord(value.contentFactoryWorkspacePatch)) { + return true; + } + if (isRecord(value.payload) && hasWorkspacePatchPayload(value.payload)) { + return true; + } + if (isRecord(value.result) && hasWorkspacePatchPayload(value.result)) { + return true; + } + if (isRecord(value.threadRead) && hasWorkspacePatchPayload(value.threadRead)) { + return true; + } + if (Array.isArray(value.events) && value.events.some(hasWorkspacePatchPayload)) { + return true; + } + if (Array.isArray(value.taskEvents) && value.taskEvents.some(hasWorkspacePatchPayload)) { + return true; + } + if (Array.isArray(value.artifacts) && value.artifacts.length > 0) { + return true; + } + return false; +} + +interface AgentAppTaskSubscription { + subscriptionId: string; + taskId: string; + pollIntervalMs: number; + bridgeAction?: string; + runtimeEventName?: string; + runtimeEventUnlisten?: () => void; + timerId?: number; + inFlight: boolean; + terminalArtifactReplayPolls: number; +} + function collectCssVariableTokens(root: HTMLElement): Record { const tokens: Record = {}; const appendToken = (name: string, value: string) => { @@ -295,8 +537,11 @@ export class AgentAppHostBridge { private readonly dispatchCapability?: ( request: AgentAppHostBridgeCapabilityRequest, ) => Promise | unknown; + private readonly listenRuntimeEvent: typeof safeListen; private readonly now?: () => string; private readonly runtimeOrigin: string; + private readonly taskSubscriptions = new Map(); + private taskSubscriptionSequence = 0; private disposed = false; constructor(options: CreateAgentAppHostBridgeOptions) { @@ -316,6 +561,7 @@ export class AgentAppHostBridge { this.openExternal = options.openExternal; this.capabilities = options.capabilities; this.dispatchCapability = options.dispatchCapability; + this.listenRuntimeEvent = options.listenRuntimeEvent ?? safeListen; this.now = options.now; this.runtimeOrigin = runtimeOrigin; } @@ -353,6 +599,9 @@ export class AgentAppHostBridge { "visibilitychange", this.handleVisibilityChanged, ); + for (const subscriptionId of Array.from(this.taskSubscriptions.keys())) { + this.stopTaskSubscription(subscriptionId); + } } sendSnapshot(requestId?: string): void { @@ -408,7 +657,10 @@ export class AgentAppHostBridge { } if (!HOST_ACTION_TYPES.has(message.type)) { - this.sendError(message, "UNKNOWN_MESSAGE", "Unsupported bridge message."); + this.sendError(message, { + code: "UNKNOWN_MESSAGE", + message: "Unsupported bridge message.", + }); return; } @@ -416,15 +668,8 @@ export class AgentAppHostBridge { const result = await this.dispatchHostAction(message); this.sendResponse(message, result); } catch (error) { - const payload = - error instanceof AgentAppHostBridgeActionError - ? { code: error.code, message: error.message } - : { - code: readErrorCode(error) ?? "HOST_ACTION_FAILED", - message: - error instanceof Error ? error.message : "Host action failed.", - }; - this.sendError(message, payload.code, payload.message); + const payload = this.buildHostErrorPayload(message, error); + this.sendError(message, payload); } } @@ -459,6 +704,12 @@ export class AgentAppHostBridge { if (message.type === "capability:invoke") { return this.handleCapabilityInvoke(message); } + if (message.type === "capability:subscribe") { + return this.handleCapabilitySubscribe(message); + } + if (message.type === "capability:unsubscribe") { + return this.handleCapabilityUnsubscribe(message); + } throw new AgentAppHostBridgeActionError( "CAPABILITY_BLOCKED", @@ -489,13 +740,36 @@ export class AgentAppHostBridge { "capability:invoke requires payload.capability and payload.method.", ); } - const args = Array.isArray(message.payload.args) + const rawArgs = hasOwn(message.payload, "args") ? message.payload.args : undefined; + const args = Array.isArray(rawArgs) ? rawArgs : undefined; + const input = hasOwn(message.payload, "input") + ? message.payload.input + : Array.isArray(rawArgs) + ? undefined + : rawArgs; + const idempotencyKey = readString(message.payload, "idempotencyKey"); + const expectedSchema = hasOwn(message.payload, "expectedSchema") + ? message.payload.expectedSchema + : undefined; + const provenance = readCapabilityInvokeProvenance( + message.payload.provenance, + ); + const invokeRequest = buildLimeCapabilityInvokeRequest({ + capability: capability as LimeCapabilityName, + method: method as never, + args: input, + requestId: message.requestId, + idempotencyKey, + expectedSchema, + provenance, + }) as LimeCapabilityInvokeRequest; const request: AgentAppHostBridgeCapabilityRequest = { appId: this.appId, capability, method, + invokeRequest, rawPayload: message.payload, }; if (this.entryKey) { @@ -507,11 +781,293 @@ export class AgentAppHostBridge { if (args) { request.args = args; } - if (Object.prototype.hasOwnProperty.call(message.payload, "input")) { - request.input = message.payload.input; + if (input !== undefined) { + request.input = input; + } + if (idempotencyKey) { + request.idempotencyKey = idempotencyKey; + } + if (expectedSchema !== undefined) { + request.expectedSchema = expectedSchema; + } + if (provenance) { + request.provenance = provenance; } const result = await this.dispatchCapability(request); - return { result }; + return { + ...createLimeCapabilitySuccessResponse(result), + result, + }; + } + + private async handleCapabilitySubscribe( + message: LimeAgentAppBridgeMessage, + ): Promise> { + if (!this.dispatchCapability) { + throw new AgentAppHostBridgeActionError( + "CAPABILITY_BLOCKED", + "Capability subscription is not enabled for this Agent App runtime.", + ); + } + if (!isRecord(message.payload)) { + throw new AgentAppHostBridgeActionError( + "INVALID_PAYLOAD", + "capability:subscribe requires a payload object.", + ); + } + const capability = readString(message.payload, "capability"); + const topic = + readString(message.payload, "topic") ?? readString(message.payload, "method"); + const taskId = readTaskIdFromPayload(message.payload); + if (capability !== "lime.agent" || !topic || !topic.startsWith("task") || !taskId) { + throw new AgentAppHostBridgeActionError( + "INVALID_PAYLOAD", + "capability:subscribe currently requires lime.agent task payload.taskId.", + ); + } + const subscriptionId = + readString(message.payload, "subscriptionId") ?? + `agent-app-subscription-${++this.taskSubscriptionSequence}`; + const pollIntervalMs = readPositiveInteger( + message.payload.pollIntervalMs, + DEFAULT_TASK_SUBSCRIPTION_POLL_INTERVAL_MS, + MIN_TASK_SUBSCRIPTION_POLL_INTERVAL_MS, + MAX_TASK_SUBSCRIPTION_POLL_INTERVAL_MS, + ); + const bridgeAction = + readString(message.payload, "bridgeAction") ?? + (isRecord(message.payload.input) + ? readString(message.payload.input, "bridgeAction") + : undefined); + const runtimeEventName = readRuntimeEventNameFromPayload( + this.appId, + taskId, + message.payload, + ); + + this.stopTaskSubscription(subscriptionId); + this.taskSubscriptions.set(subscriptionId, { + subscriptionId, + taskId, + pollIntervalMs, + bridgeAction, + runtimeEventName, + inFlight: false, + terminalArtifactReplayPolls: 0, + }); + void this.attachRuntimeEventSubscription(subscriptionId); + void this.pollTaskSubscription(subscriptionId); + + return { + subscriptionId, + capability, + topic: "task", + taskId, + pollIntervalMs, + bridgeAction, + runtimeEventName, + }; + } + + private handleCapabilityUnsubscribe( + message: LimeAgentAppBridgeMessage, + ): Record { + if (!isRecord(message.payload)) { + throw new AgentAppHostBridgeActionError( + "INVALID_PAYLOAD", + "capability:unsubscribe requires a payload object.", + ); + } + const subscriptionId = + readString(message.payload, "subscriptionId") ?? + (isRecord(message.payload.input) + ? readString(message.payload.input, "subscriptionId") + : undefined); + if (!subscriptionId) { + throw new AgentAppHostBridgeActionError( + "INVALID_PAYLOAD", + "capability:unsubscribe requires payload.subscriptionId.", + ); + } + const unsubscribed = this.stopTaskSubscription(subscriptionId); + return { + subscriptionId, + unsubscribed, + }; + } + + private async attachRuntimeEventSubscription( + subscriptionId: string, + ): Promise { + const subscription = this.taskSubscriptions.get(subscriptionId); + if (!subscription?.runtimeEventName || this.disposed) { + return; + } + try { + const unlisten = await this.listenRuntimeEvent( + subscription.runtimeEventName, + (event) => this.handleRuntimeTaskEvent(subscriptionId, event.payload), + ); + const latest = this.taskSubscriptions.get(subscriptionId); + if (!latest || latest.runtimeEventName !== subscription.runtimeEventName) { + unlisten(); + return; + } + latest.runtimeEventUnlisten = unlisten; + } catch (error) { + this.postToApp("capability:event", { + subscriptionId, + capability: "lime.agent", + topic: "task", + eventType: "task:eventStreamUnavailable", + taskId: subscription.taskId, + bridgeAction: subscription.bridgeAction, + runtimeEventName: subscription.runtimeEventName, + error: this.buildHostErrorPayload( + { + protocol: AGENT_APP_BRIDGE_PROTOCOL, + version: AGENT_APP_BRIDGE_VERSION, + type: "capability:subscribe", + appId: this.appId, + entryKey: this.entryKey, + }, + error, + ), + emittedAt: (this.now ?? (() => new Date().toISOString()))(), + }); + } + } + + private handleRuntimeTaskEvent(subscriptionId: string, payload: unknown): void { + const subscription = this.taskSubscriptions.get(subscriptionId); + if (!subscription || this.disposed) { + return; + } + const events = readTaskEventsFromValue(payload); + this.postToApp("capability:event", { + subscriptionId, + capability: "lime.agent", + topic: "task", + eventType: "task:runtimeEvent", + taskId: subscription.taskId, + bridgeAction: subscription.bridgeAction, + runtimeEventName: subscription.runtimeEventName, + runtimeEvent: payload, + events: events.length ? events : buildTaskEventsFromRuntimeEventPayload(payload), + emittedAt: (this.now ?? (() => new Date().toISOString()))(), + }); + } + + private async pollTaskSubscription(subscriptionId: string): Promise { + const subscription = this.taskSubscriptions.get(subscriptionId); + if (!subscription || subscription.inFlight || this.disposed || !this.dispatchCapability) { + return; + } + subscription.inFlight = true; + try { + const result = await this.dispatchCapability( + this.buildTaskSubscriptionPollRequest(subscription), + ); + this.postToApp("capability:event", { + subscriptionId, + capability: "lime.agent", + topic: "task", + eventType: "task:update", + taskId: subscription.taskId, + bridgeAction: subscription.bridgeAction, + task: result, + events: readTaskEventsFromValue(result), + emittedAt: (this.now ?? (() => new Date().toISOString()))(), + }); + const shouldPollForTerminalArtifact = + isSuccessfulTerminalTaskValue(result) && !hasWorkspacePatchPayload(result); + if (shouldPollForTerminalArtifact) { + subscription.terminalArtifactReplayPolls += 1; + } + if ( + isTerminalTaskValue(result) && + (!shouldPollForTerminalArtifact || + subscription.terminalArtifactReplayPolls > DEFAULT_TERMINAL_ARTIFACT_REPLAY_POLLS) + ) { + this.stopTaskSubscription(subscriptionId); + return; + } + } catch (error) { + this.postToApp("capability:event", { + subscriptionId, + capability: "lime.agent", + topic: "task", + eventType: "task:error", + taskId: subscription.taskId, + bridgeAction: subscription.bridgeAction, + error: this.buildHostErrorPayload( + { + protocol: AGENT_APP_BRIDGE_PROTOCOL, + version: AGENT_APP_BRIDGE_VERSION, + type: "capability:invoke", + appId: this.appId, + entryKey: this.entryKey, + }, + error, + ), + emittedAt: (this.now ?? (() => new Date().toISOString()))(), + }); + this.stopTaskSubscription(subscriptionId); + return; + } finally { + subscription.inFlight = false; + } + + const latest = this.taskSubscriptions.get(subscriptionId); + if (!latest || this.disposed) { + return; + } + latest.timerId = window.setTimeout(() => { + void this.pollTaskSubscription(subscriptionId); + }, latest.pollIntervalMs); + } + + private buildTaskSubscriptionPollRequest( + subscription: AgentAppTaskSubscription, + ): AgentAppHostBridgeCapabilityRequest { + const input = { taskId: subscription.taskId }; + const invokeRequest = buildLimeCapabilityInvokeRequest({ + capability: "lime.agent", + method: "getTask" as never, + args: input, + requestId: `${subscription.subscriptionId}:poll`, + }) as LimeCapabilityInvokeRequest; + const request: AgentAppHostBridgeCapabilityRequest = { + appId: this.appId, + capability: "lime.agent", + method: "getTask", + requestId: `${subscription.subscriptionId}:poll`, + input, + invokeRequest, + rawPayload: { + capability: "lime.agent", + method: "getTask", + input, + subscriptionId: subscription.subscriptionId, + }, + }; + if (this.entryKey) { + request.entryKey = this.entryKey; + } + return request; + } + + private stopTaskSubscription(subscriptionId: string): boolean { + const subscription = this.taskSubscriptions.get(subscriptionId); + if (!subscription) { + return false; + } + if (subscription.timerId !== undefined) { + window.clearTimeout(subscription.timerId); + } + subscription.runtimeEventUnlisten?.(); + this.taskSubscriptions.delete(subscriptionId); + return true; } private handleToast(payload: unknown): void { @@ -607,17 +1163,47 @@ export class AgentAppHostBridge { this.postToApp("host:response", payload, request.requestId); } + private buildHostErrorPayload( + request: LimeAgentAppBridgeMessage, + error: unknown, + ): Record { + if (request.type === "capability:invoke") { + const payload = isRecord(request.payload) ? request.payload : {}; + const stableError = toLimeCapabilityError(error, { + appId: request.appId, + entryKey: request.entryKey, + capability: readString(payload, "capability"), + method: readString(payload, "method"), + requestId: request.requestId, + }); + return { + ok: false, + error: stableError, + code: stableError.code, + message: stableError.message, + causeCode: stableError.causeCode, + }; + } + + if (error instanceof AgentAppHostBridgeActionError) { + return { + code: error.code, + message: error.message, + }; + } + return { + code: readErrorCode(error) ?? "HOST_ACTION_FAILED", + message: error instanceof Error ? error.message : "Host action failed.", + }; + } + private sendError( request: LimeAgentAppBridgeMessage, - code: string, - message: string, + payload: Record, ): void { this.postToApp( "host:error", - { - code, - message, - }, + payload, request.requestId, ); } diff --git a/src/features/agent-app/sdk/MockCapabilityHost.test.ts b/src/features/agent-app/sdk/MockCapabilityHost.test.ts index 56fd51905..3aef85955 100644 --- a/src/features/agent-app/sdk/MockCapabilityHost.test.ts +++ b/src/features/agent-app/sdk/MockCapabilityHost.test.ts @@ -70,6 +70,7 @@ describe("MockCapabilityHost", () => { await expect(host.runEntry("dashboard")).rejects.toMatchObject({ code: "FEATURE_DISABLED", + stableCode: "capability_unavailable", }); }); diff --git a/src/features/agent-app/sdk/capabilityAdapters.test.ts b/src/features/agent-app/sdk/capabilityAdapters.test.ts new file mode 100644 index 000000000..47dda721c --- /dev/null +++ b/src/features/agent-app/sdk/capabilityAdapters.test.ts @@ -0,0 +1,640 @@ +import { describe, expect, it } from "vitest"; +import type { + AgentAppArtifactRecord, + AgentAppEvidenceRecord, + AgentAppKnowledgeSearchResult, + AgentAppStorageEntry, + AgentAppTaskHostResponseResult, + AgentAppTaskRecord, + AgentAppTaskStreamEvent, +} from "../types"; +import { + buildLimeCapabilityInvokeProvenance, + createLimeCapabilityInvoker, + createMockLimeCapabilityTransport, + type LimeCapabilityInvokeRequest, +} from "./capabilityContract"; +import { + createLimeCoreCapabilityAdapters, + LimeCapabilityAdapterError, +} from "./capabilityAdapters"; + +const recordProvenance = { + sourceKind: "agent_app", + appId: "content-factory-app", + appVersion: "1.0.0", + packageHash: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + manifestHash: "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + entryKey: "dashboard", + workflowRunId: "run-1", + workspaceId: "workspace-1", +} as const; +const provenance = buildLimeCapabilityInvokeProvenance(recordProvenance); + +function buildAgentTaskRecord( + overrides: Partial = {}, +): AgentAppTaskRecord { + const taskId = overrides.taskId ?? "agent-task-1"; + return { + taskId, + traceId: overrides.traceId ?? "trace-agent-1", + appId: provenance.appId, + entryKey: provenance.entryKey, + title: overrides.title ?? "生成内容场景", + prompt: overrides.prompt ?? "基于项目知识生成内容场景", + taskKind: overrides.taskKind ?? "content.scenario_planning", + idempotencyKey: + overrides.idempotencyKey ?? "dashboard:content.scenario_planning", + input: overrides.input, + expectedOutput: overrides.expectedOutput, + knowledge: overrides.knowledge ?? [], + tools: overrides.tools ?? [], + files: overrides.files ?? [], + secrets: overrides.secrets ?? [], + humanReview: overrides.humanReview ?? true, + status: overrides.status ?? "running", + startedAt: overrides.startedAt ?? "2026-05-16T00:00:00.000Z", + finishedAt: overrides.finishedAt, + cancelledAt: overrides.cancelledAt, + result: overrides.result, + trace: overrides.trace ?? [], + events: overrides.events ?? [], + provenance: overrides.provenance ?? recordProvenance, + retryOfTaskId: overrides.retryOfTaskId, + retryAttempt: overrides.retryAttempt, + }; +} + +const agentStreamEvents: AgentAppTaskStreamEvent[] = [ + { + eventId: "event-queued", + taskId: "agent-task-1", + traceId: "trace-agent-1", + type: "task:queued", + at: "2026-05-16T00:00:00.000Z", + status: "running", + message: "任务已进入 AgentRuntime 队列。", + }, + { + eventId: "event-progress", + taskId: "agent-task-1", + traceId: "trace-agent-1", + type: "task:progress", + at: "2026-05-16T00:00:01.000Z", + status: "running", + message: "正在检索项目知识。", + }, + { + eventId: "event-missing-context", + taskId: "agent-task-1", + traceId: "trace-agent-1", + type: "task:missingContextRequested", + at: "2026-05-16T00:00:02.000Z", + status: "running", + message: "需要补充项目定位。", + payload: { requestId: "missing-context-1" }, + }, + { + eventId: "event-review", + taskId: "agent-task-1", + traceId: "trace-agent-1", + type: "task:reviewRequested", + at: "2026-05-16T00:00:03.000Z", + status: "running", + message: "需要人工确认输出方向。", + payload: { requestId: "review-1" }, + }, + { + eventId: "event-tool-call", + taskId: "agent-task-1", + traceId: "trace-agent-1", + type: "task:toolCall", + at: "2026-05-16T00:00:04.000Z", + status: "running", + message: "调用内容检索工具。", + payload: { toolCallId: "tool-call-1", tool: "content.search" }, + }, + { + eventId: "event-artifact-created", + taskId: "agent-task-1", + traceId: "trace-agent-1", + type: "artifact:created", + at: "2026-05-16T00:00:05.000Z", + status: "running", + message: "内容批次已创建。", + refs: ["artifact-1"], + payload: { + workspacePatch: { kind: "content_batch", count: 20 }, + contentFactoryWorkspacePatch: { kind: "content_batch", count: 20 }, + }, + }, + { + eventId: "event-evidence-recorded", + taskId: "agent-task-1", + traceId: "trace-agent-1", + type: "evidence:recorded", + at: "2026-05-16T00:00:06.000Z", + status: "running", + message: "运行证据已记录。", + refs: ["evidence-1"], + }, + { + eventId: "event-evidence-verified", + taskId: "agent-task-1", + traceId: "trace-agent-1", + type: "evidence:verified", + at: "2026-05-16T00:00:07.000Z", + status: "running", + message: "运行证据已校验。", + refs: ["evidence-1"], + }, + { + eventId: "event-completed", + taskId: "agent-task-1", + traceId: "trace-agent-1", + type: "task:completed", + at: "2026-05-16T00:00:08.000Z", + status: "succeeded", + message: "任务已完成。", + }, + { + eventId: "event-incident", + taskId: "agent-task-1", + traceId: "trace-agent-1", + type: "task:incident", + at: "2026-05-16T00:00:09.000Z", + message: "记录一次可追踪的非阻塞事件。", + payload: { incidentId: "incident-1" }, + }, +]; + +describe("P18.3 / P18.4 core capability adapters", () => { + it("应把 ui / storage / artifacts / evidence / knowledge / tools 调用转成 typed request 并附加 provenance", async () => { + const requests: LimeCapabilityInvokeRequest[] = []; + const invoker = createLimeCapabilityInvoker( + createMockLimeCapabilityTransport({ + "lime.ui": { + toast: (request) => { + requests.push(request); + return { accepted: true }; + }, + }, + "lime.storage": { + set: (request) => { + requests.push(request); + const args = request.args as { key: string; value: unknown }; + return { + appId: provenance.appId, + key: args.key, + value: args.value, + updatedAt: "2026-05-16T00:00:00.000Z", + provenance: recordProvenance, + } satisfies AgentAppStorageEntry; + }, + }, + "lime.artifacts": { + create: (request) => { + requests.push(request); + const args = request.args as { + kind: string; + title: string; + content: unknown; + }; + return { + id: "artifact-1", + appId: provenance.appId, + entryKey: provenance.entryKey, + kind: args.kind, + title: args.title, + content: args.content, + createdAt: "2026-05-16T00:00:00.000Z", + provenance: recordProvenance, + } satisfies AgentAppArtifactRecord; + }, + }, + "lime.evidence": { + record: (request) => { + requests.push(request); + const args = request.args as { + kind: string; + message: string; + refs?: string[]; + }; + return { + id: "evidence-1", + appId: provenance.appId, + entryKey: provenance.entryKey, + kind: args.kind, + message: args.message, + refs: args.refs ?? [], + createdAt: "2026-05-16T00:00:00.000Z", + provenance: recordProvenance, + } satisfies AgentAppEvidenceRecord; + }, + }, + "lime.knowledge": { + search: (request) => { + requests.push(request); + return { + query: (request.args as { query: string }).query, + records: [], + searchedAt: "2026-05-16T00:00:00.000Z", + provenance: recordProvenance, + } satisfies AgentAppKnowledgeSearchResult; + }, + }, + "lime.tools": { + invoke: (request) => { + requests.push(request); + return { invocationId: "tool-run-1", status: "accepted" }; + }, + }, + }), + ); + const adapters = createLimeCoreCapabilityAdapters({ + invoker, + provenance, + storageNamespace: "content_factory_app", + }); + + await adapters.ui.toast( + { message: "已保存", level: "success" }, + { requestId: "req-ui" }, + ); + const stored = await adapters.storage.set( + { key: "drafts/scenario", value: { title: "内容场景草稿" } }, + { + requestId: "req-storage", + idempotencyKey: "storage:drafts/scenario", + }, + ); + const artifact = await adapters.artifacts.create( + { + kind: "content_table", + title: "内容表", + content: { rows: [] }, + }, + { requestId: "req-artifact" }, + ); + const evidence = await adapters.evidence.record( + { + kind: "fact_grounding", + message: "声明过的事实支撑证据。", + refs: [artifact.id], + }, + { requestId: "req-evidence" }, + ); + const knowledge = await adapters.knowledge.search( + { query: "内容场景", limit: 5 }, + { requestId: "req-knowledge" }, + ); + const toolResult = await adapters.tools["invoke"]( + { tool: "content.search", input: { query: "内容场景" } }, + { requestId: "req-tool" }, + ); + + expect(adapters.storage.namespace).toBe("content_factory_app"); + expect(stored).toMatchObject({ + key: "drafts/scenario", + value: { title: "内容场景草稿" }, + provenance: recordProvenance, + }); + expect(evidence.refs).toEqual(["artifact-1"]); + expect(knowledge.query).toBe("内容场景"); + expect(toolResult).toEqual({ + invocationId: "tool-run-1", + status: "accepted", + }); + expect(requests).toEqual([ + expect.objectContaining({ + capability: "lime.ui", + method: "toast", + requestId: "req-ui", + provenance, + }), + expect.objectContaining({ + capability: "lime.storage", + method: "set", + args: { + key: "drafts/scenario", + value: { title: "内容场景草稿" }, + }, + requestId: "req-storage", + idempotencyKey: "storage:drafts/scenario", + provenance, + }), + expect.objectContaining({ + capability: "lime.artifacts", + method: "create", + requestId: "req-artifact", + provenance, + }), + expect.objectContaining({ + capability: "lime.evidence", + method: "record", + requestId: "req-evidence", + provenance, + }), + expect.objectContaining({ + capability: "lime.knowledge", + method: "search", + requestId: "req-knowledge", + provenance, + }), + expect.objectContaining({ + capability: "lime.tools", + method: "invoke", + requestId: "req-tool", + provenance, + }), + ]); + }); + + it("P18.4 应把 lime.agent task facade 固定为 App-scoped typed adapter", async () => { + const requests: LimeCapabilityInvokeRequest[] = []; + const invoker = createLimeCapabilityInvoker( + createMockLimeCapabilityTransport({ + "lime.agent": { + startTask: (request) => { + requests.push(request); + const args = request.args as { + title: string; + prompt?: string; + taskKind?: string; + idempotencyKey?: string; + input?: unknown; + expectedOutput?: unknown; + tools?: string[]; + humanReview?: boolean; + }; + return buildAgentTaskRecord({ + title: args.title, + prompt: args.prompt ?? args.title, + taskKind: args.taskKind, + idempotencyKey: args.idempotencyKey, + input: args.input, + expectedOutput: args.expectedOutput, + tools: args.tools, + humanReview: args.humanReview, + events: [agentStreamEvents[0]], + }); + }, + streamTask: (request) => { + requests.push(request); + return agentStreamEvents; + }, + getTask: (request) => { + requests.push(request); + return buildAgentTaskRecord({ + status: "succeeded", + finishedAt: "2026-05-16T00:00:08.000Z", + result: { summary: "内容场景已生成" }, + events: agentStreamEvents, + }); + }, + cancelTask: (request) => { + requests.push(request); + return buildAgentTaskRecord({ + status: "cancelled", + cancelledAt: "2026-05-16T00:00:10.000Z", + }); + }, + retryTask: (request) => { + requests.push(request); + return buildAgentTaskRecord({ + taskId: "agent-task-2", + traceId: "trace-agent-2", + retryOfTaskId: "agent-task-1", + retryAttempt: 1, + }); + }, + submitHostResponse: (request) => { + requests.push(request); + const args = request.args as { + taskId: string; + requestId: string; + }; + return { + taskId: args.taskId, + requestId: args.requestId, + status: "submitted", + submittedAt: "2026-05-16T00:00:11.000Z", + } satisfies AgentAppTaskHostResponseResult; + }, + listTasks: (request) => { + requests.push(request); + return [buildAgentTaskRecord({ status: "succeeded" })]; + }, + }, + }), + ); + const adapters = createLimeCoreCapabilityAdapters({ + invoker, + provenance, + }); + + const started = await adapters.agent.startTask( + { + title: "生成内容场景", + prompt: "基于项目知识生成内容场景", + taskKind: "content.scenario_planning", + idempotencyKey: "dashboard:content.scenario_planning", + queueIfBusy: true, + skipPreSubmitResume: true, + runStartHooks: false, + input: { projectId: "project-1" }, + expectedOutput: { artifactKind: "content_table" }, + tools: ["content.search"], + humanReview: true, + }, + { + requestId: "req-agent-start", + idempotencyKey: "agent:scenario", + }, + ); + const stream = await adapters.agent.streamTask( + { taskId: started.taskId }, + { requestId: "req-agent-stream" }, + ); + const snapshot = await adapters.agent.getTask( + { taskId: started.taskId }, + { requestId: "req-agent-get" }, + ); + const hostResponse = await adapters.agent.submitHostResponse( + { + taskId: started.taskId, + requestId: "missing-context-1", + actionType: "ask_user", + response: "项目定位:高客单价咨询服务。", + userData: { segment: "consulting" }, + actionScope: { + sessionId: "session-1", + threadId: "thread-1", + turnId: "turn-1", + }, + }, + { requestId: "req-agent-host-response" }, + ); + const cancelled = await adapters.agent.cancelTask( + { taskId: started.taskId }, + { requestId: "req-agent-cancel" }, + ); + const retried = await adapters.agent.retryTask( + { taskId: started.taskId }, + { requestId: "req-agent-retry" }, + ); + const listed = await adapters.agent.listTasks({ + requestId: "req-agent-list", + }); + + expect(started).toMatchObject({ + taskId: "agent-task-1", + traceId: "trace-agent-1", + status: "running", + events: [expect.objectContaining({ type: "task:queued" })], + }); + expect(stream.map((event) => event.type)).toEqual([ + "task:queued", + "task:progress", + "task:missingContextRequested", + "task:reviewRequested", + "task:toolCall", + "artifact:created", + "evidence:recorded", + "evidence:verified", + "task:completed", + "task:incident", + ]); + expect(stream).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "artifact:created", + refs: ["artifact-1"], + payload: expect.objectContaining({ + workspacePatch: expect.objectContaining({ + kind: "content_batch", + }), + contentFactoryWorkspacePatch: expect.objectContaining({ + kind: "content_batch", + }), + }), + }), + expect.objectContaining({ + type: "evidence:verified", + refs: ["evidence-1"], + }), + ]), + ); + expect(snapshot).toMatchObject({ + taskId: "agent-task-1", + status: "succeeded", + result: { summary: "内容场景已生成" }, + }); + expect(hostResponse).toEqual({ + taskId: "agent-task-1", + requestId: "missing-context-1", + status: "submitted", + submittedAt: "2026-05-16T00:00:11.000Z", + }); + expect(cancelled.status).toBe("cancelled"); + expect(retried).toMatchObject({ + taskId: "agent-task-2", + retryOfTaskId: "agent-task-1", + retryAttempt: 1, + }); + expect(listed).toEqual([ + expect.objectContaining({ + taskId: "agent-task-1", + status: "succeeded", + }), + ]); + expect( + requests.map((request) => ({ + capability: request.capability, + method: request.method, + requestId: request.requestId, + provenance: request.provenance, + })), + ).toEqual([ + { + capability: "lime.agent", + method: "startTask", + requestId: "req-agent-start", + provenance, + }, + { + capability: "lime.agent", + method: "streamTask", + requestId: "req-agent-stream", + provenance, + }, + { + capability: "lime.agent", + method: "getTask", + requestId: "req-agent-get", + provenance, + }, + { + capability: "lime.agent", + method: "submitHostResponse", + requestId: "req-agent-host-response", + provenance, + }, + { + capability: "lime.agent", + method: "cancelTask", + requestId: "req-agent-cancel", + provenance, + }, + { + capability: "lime.agent", + method: "retryTask", + requestId: "req-agent-retry", + provenance, + }, + { + capability: "lime.agent", + method: "listTasks", + requestId: "req-agent-list", + provenance, + }, + ]); + expect(requests[0]).toMatchObject({ + args: expect.objectContaining({ + queueIfBusy: true, + skipPreSubmitResume: true, + runStartHooks: false, + }), + idempotencyKey: "agent:scenario", + }); + }); + + it("应把 mock / host 的 stable error 原样暴露给 adapter 调用方", async () => { + const adapters = createLimeCoreCapabilityAdapters({ + invoker: createLimeCapabilityInvoker(createMockLimeCapabilityTransport()), + provenance, + }); + + await expect( + adapters.tools.getProgress( + { invocationId: "missing-tool-run" }, + { requestId: "req-missing" }, + ), + ).rejects.toMatchObject({ + name: "LimeCapabilityAdapterError", + code: "capability_unavailable", + causeCode: "UNSUPPORTED_CAPABILITY_METHOD", + capability: "lime.tools", + method: "getProgress", + requestId: "req-missing", + error: { + code: "capability_unavailable", + message: "lime.tools.getProgress is not available in the mock host.", + capability: "lime.tools", + method: "getProgress", + requestId: "req-missing", + causeCode: "UNSUPPORTED_CAPABILITY_METHOD", + }, + } satisfies Partial); + }); +}); diff --git a/src/features/agent-app/sdk/capabilityAdapters.ts b/src/features/agent-app/sdk/capabilityAdapters.ts new file mode 100644 index 000000000..10bf67fd2 --- /dev/null +++ b/src/features/agent-app/sdk/capabilityAdapters.ts @@ -0,0 +1,246 @@ +import { + buildLimeCapabilityInvokeRequest, + type LimeCapabilityArgs, + type LimeCapabilityInvokeProvenance, + type LimeCapabilityInvoker, + type LimeCapabilityMethod, + type LimeCapabilityName, + type LimeCapabilityValue, +} from "./capabilityContract"; +import type { LimeCapabilityError } from "./capabilityErrors"; + +export interface LimeCapabilityAdapterCallOptions { + requestId?: string; + idempotencyKey?: string; + expectedSchema?: unknown; + provenance?: LimeCapabilityInvokeProvenance; +} + +export interface CreateLimeCoreCapabilityAdaptersOptions { + invoker: LimeCapabilityInvoker; + provenance?: LimeCapabilityInvokeProvenance; + storageNamespace?: string; +} + +export class LimeCapabilityAdapterError extends Error { + readonly error: LimeCapabilityError; + readonly code: LimeCapabilityError["code"]; + readonly causeCode?: string; + readonly capability?: string; + readonly method?: string; + readonly requestId?: string; + + constructor(error: LimeCapabilityError) { + super(error.message); + this.name = "LimeCapabilityAdapterError"; + this.error = error; + this.code = error.code; + this.causeCode = error.causeCode; + this.capability = error.capability; + this.method = error.method; + this.requestId = error.requestId; + } +} + +type CapabilityAdapterMethod< + Capability extends LimeCapabilityName, + Method extends LimeCapabilityMethod, +> = ( + args: LimeCapabilityArgs, + options?: LimeCapabilityAdapterCallOptions, +) => Promise>; + +type NoArgsCapabilityAdapterMethod< + Capability extends LimeCapabilityName, + Method extends LimeCapabilityMethod, +> = ( + options?: LimeCapabilityAdapterCallOptions, +) => Promise>; + +export interface LimeUiCapabilityAdapter { + readonly toast: CapabilityAdapterMethod<"lime.ui", "toast">; + readonly navigate: CapabilityAdapterMethod<"lime.ui", "navigate">; + readonly openExternal: CapabilityAdapterMethod<"lime.ui", "openExternal">; + readonly download: CapabilityAdapterMethod<"lime.ui", "download">; + readonly getSnapshot: NoArgsCapabilityAdapterMethod< + "lime.ui", + "getSnapshot" + >; +} + +export interface LimeStorageCapabilityAdapter { + readonly namespace: string; + readonly get: CapabilityAdapterMethod<"lime.storage", "get">; + readonly set: CapabilityAdapterMethod<"lime.storage", "set">; + readonly list: NoArgsCapabilityAdapterMethod<"lime.storage", "list">; + readonly delete: CapabilityAdapterMethod<"lime.storage", "delete">; +} + +export interface LimeAgentCapabilityAdapter { + readonly startTask: CapabilityAdapterMethod<"lime.agent", "startTask">; + readonly streamTask: CapabilityAdapterMethod<"lime.agent", "streamTask">; + readonly getTask: CapabilityAdapterMethod<"lime.agent", "getTask">; + readonly cancelTask: CapabilityAdapterMethod<"lime.agent", "cancelTask">; + readonly retryTask: CapabilityAdapterMethod<"lime.agent", "retryTask">; + readonly submitHostResponse: CapabilityAdapterMethod< + "lime.agent", + "submitHostResponse" + >; + readonly listTasks: NoArgsCapabilityAdapterMethod< + "lime.agent", + "listTasks" + >; +} + +export interface LimeArtifactsCapabilityAdapter { + readonly create: CapabilityAdapterMethod<"lime.artifacts", "create">; + readonly open: CapabilityAdapterMethod<"lime.artifacts", "open">; + readonly export: CapabilityAdapterMethod<"lime.artifacts", "export">; +} + +export interface LimeEvidenceCapabilityAdapter { + readonly record: CapabilityAdapterMethod<"lime.evidence", "record">; + readonly linkArtifact: CapabilityAdapterMethod< + "lime.evidence", + "linkArtifact" + >; +} + +export interface LimeKnowledgeCapabilityAdapter { + readonly search: CapabilityAdapterMethod<"lime.knowledge", "search">; + readonly bindStatus: CapabilityAdapterMethod<"lime.knowledge", "bindStatus">; +} + +export interface LimeToolsCapabilityAdapter { + readonly invoke: CapabilityAdapterMethod<"lime.tools", "invoke">; + readonly getProgress: CapabilityAdapterMethod<"lime.tools", "getProgress">; +} + +export interface LimeCoreCapabilityAdapters { + readonly ui: LimeUiCapabilityAdapter; + readonly storage: LimeStorageCapabilityAdapter; + readonly agent: LimeAgentCapabilityAdapter; + readonly artifacts: LimeArtifactsCapabilityAdapter; + readonly evidence: LimeEvidenceCapabilityAdapter; + readonly knowledge: LimeKnowledgeCapabilityAdapter; + readonly tools: LimeToolsCapabilityAdapter; +} + +async function callCapability< + Capability extends LimeCapabilityName, + Method extends LimeCapabilityMethod, +>( + invoker: LimeCapabilityInvoker, + defaultProvenance: LimeCapabilityInvokeProvenance | undefined, + capability: Capability, + method: Method, + args: LimeCapabilityArgs | undefined, + options: LimeCapabilityAdapterCallOptions | undefined, +): Promise> { + const response = await invoker.call( + buildLimeCapabilityInvokeRequest({ + capability, + method, + args: args as LimeCapabilityArgs, + requestId: options?.requestId, + idempotencyKey: options?.idempotencyKey, + expectedSchema: options?.expectedSchema, + provenance: options?.provenance ?? defaultProvenance, + }), + ); + if (response.ok) { + return response.value; + } + throw new LimeCapabilityAdapterError(response.error); +} + +export function createLimeCoreCapabilityAdapters( + options: CreateLimeCoreCapabilityAdaptersOptions, +): LimeCoreCapabilityAdapters { + const { invoker, provenance } = options; + const call = < + Capability extends LimeCapabilityName, + Method extends LimeCapabilityMethod, + >( + capability: Capability, + method: Method, + args: LimeCapabilityArgs | undefined, + callOptions?: LimeCapabilityAdapterCallOptions, + ) => + callCapability( + invoker, + provenance, + capability, + method, + args, + callOptions, + ); + + return { + ui: { + toast: (args, callOptions) => + call("lime.ui", "toast", args, callOptions), + navigate: (args, callOptions) => + call("lime.ui", "navigate", args, callOptions), + openExternal: (args, callOptions) => + call("lime.ui", "openExternal", args, callOptions), + download: (args, callOptions) => + call("lime.ui", "download", args, callOptions), + getSnapshot: (callOptions) => + call("lime.ui", "getSnapshot", undefined, callOptions), + }, + storage: { + namespace: options.storageNamespace ?? provenance?.appId ?? "agent_app", + get: (args, callOptions) => + call("lime.storage", "get", args, callOptions), + set: (args, callOptions) => + call("lime.storage", "set", args, callOptions), + list: (callOptions) => + call("lime.storage", "list", undefined, callOptions), + delete: (args, callOptions) => + call("lime.storage", "delete", args, callOptions), + }, + agent: { + startTask: (args, callOptions) => + call("lime.agent", "startTask", args, callOptions), + streamTask: (args, callOptions) => + call("lime.agent", "streamTask", args, callOptions), + getTask: (args, callOptions) => + call("lime.agent", "getTask", args, callOptions), + cancelTask: (args, callOptions) => + call("lime.agent", "cancelTask", args, callOptions), + retryTask: (args, callOptions) => + call("lime.agent", "retryTask", args, callOptions), + submitHostResponse: (args, callOptions) => + call("lime.agent", "submitHostResponse", args, callOptions), + listTasks: (callOptions) => + call("lime.agent", "listTasks", undefined, callOptions), + }, + artifacts: { + create: (args, callOptions) => + call("lime.artifacts", "create", args, callOptions), + open: (args, callOptions) => + call("lime.artifacts", "open", args, callOptions), + export: (args, callOptions) => + call("lime.artifacts", "export", args, callOptions), + }, + evidence: { + record: (args, callOptions) => + call("lime.evidence", "record", args, callOptions), + linkArtifact: (args, callOptions) => + call("lime.evidence", "linkArtifact", args, callOptions), + }, + knowledge: { + search: (args, callOptions) => + call("lime.knowledge", "search", args, callOptions), + bindStatus: (args, callOptions) => + call("lime.knowledge", "bindStatus", args, callOptions), + }, + tools: { + invoke: (args, callOptions) => + call("lime.tools", "invoke", args, callOptions), + getProgress: (args, callOptions) => + call("lime.tools", "getProgress", args, callOptions), + }, + }; +} diff --git a/src/features/agent-app/sdk/capabilityContract.test.ts b/src/features/agent-app/sdk/capabilityContract.test.ts new file mode 100644 index 000000000..2c02bfcba --- /dev/null +++ b/src/features/agent-app/sdk/capabilityContract.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from "vitest"; +import type { AgentAppTaskRecord } from "../types"; +import { + AgentAppCapabilityError, + normalizeLimeCapabilityErrorCode, + toLimeCapabilityError, +} from "./capabilityErrors"; +import { + buildLimeCapabilityInvokeProvenance, + buildLimeCapabilityInvokeRequest, + createLimeCapabilityInvoker, + createMockLimeCapabilityTransport, +} from "./capabilityContract"; + +const provenance = buildLimeCapabilityInvokeProvenance({ + sourceKind: "agent_app", + appId: "content-factory-app", + appVersion: "1.0.0", + packageHash: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + manifestHash: "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + entryKey: "dashboard", + workflowRunId: "run-1", + workspaceId: "workspace-1", + taskId: "task-1", +}); + +function buildTaskRecord(input: { title: string }): AgentAppTaskRecord { + return { + taskId: "task-1", + traceId: "trace-1", + appId: "content-factory-app", + entryKey: "dashboard", + title: input.title, + prompt: input.title, + taskKind: "content.copy.generate", + idempotencyKey: "dashboard:copy", + input, + knowledge: [], + tools: [], + files: [], + secrets: [], + humanReview: false, + status: "running", + startedAt: "2026-05-16T00:00:00.000Z", + trace: [], + events: [], + provenance: { + sourceKind: "agent_app", + appId: "content-factory-app", + appVersion: "1.0.0", + packageHash: provenance.packageHash, + manifestHash: provenance.manifestHash, + entryKey: "dashboard", + workflowRunId: "run-1", + }, + }; +} + +describe("P18 typed Capability SDK contract", () => { + it("应构造带 app provenance 的 typed capability invoke envelope", () => { + const request = buildLimeCapabilityInvokeRequest({ + capability: "lime.agent", + method: "startTask", + args: { + title: "生成内容策略", + taskKind: "content.copy.generate", + idempotencyKey: "dashboard:copy", + }, + requestId: "req-1", + idempotencyKey: "dashboard:copy", + expectedSchema: { type: "object" }, + provenance, + }); + + expect(request).toEqual({ + capability: "lime.agent", + method: "startTask", + args: { + title: "生成内容策略", + taskKind: "content.copy.generate", + idempotencyKey: "dashboard:copy", + }, + requestId: "req-1", + idempotencyKey: "dashboard:copy", + expectedSchema: { type: "object" }, + provenance, + }); + }); + + it("应把旧边界错误映射为稳定 SDK error code", () => { + const readinessError = new AgentAppCapabilityError({ + code: "READINESS_BLOCKED", + message: "Entry readiness is blocked.", + appId: "content-factory-app", + entryKey: "dashboard", + }); + + expect(readinessError.stableCode).toBe("readiness_blocked"); + expect( + toLimeCapabilityError(readinessError, { + capability: "lime.agent", + method: "startTask", + requestId: "req-1", + }), + ).toEqual({ + code: "readiness_blocked", + message: "Entry readiness is blocked.", + appId: "content-factory-app", + entryKey: "dashboard", + capability: "lime.agent", + method: "startTask", + requestId: "req-1", + causeCode: "READINESS_BLOCKED", + }); + expect(normalizeLimeCapabilityErrorCode("WORKFLOW_POLICY_VIOLATION")).toBe( + "policy_denied", + ); + expect( + toLimeCapabilityError( + Object.assign(new Error("payload invalid"), { + code: "INVALID_PAYLOAD", + }), + ), + ).toMatchObject({ + code: "schema_invalid", + causeCode: "INVALID_PAYLOAD", + }); + }); + + it("mock transport 应返回稳定 success / error response,不伪造未知能力成功", async () => { + const invoker = createLimeCapabilityInvoker( + createMockLimeCapabilityTransport({ + "lime.agent": { + startTask: (request) => { + const args = request.args as { title?: unknown } | undefined; + return buildTaskRecord({ + title: + typeof args?.title === "string" ? args.title : "Agent App task", + }); + }, + }, + }), + ); + + const ok = await invoker.call( + buildLimeCapabilityInvokeRequest({ + capability: "lime.agent", + method: "startTask", + args: { title: "生成内容策略" }, + requestId: "req-task", + provenance, + }), + ); + expect(ok).toMatchObject({ + ok: true, + value: { + taskId: "task-1", + traceId: "trace-1", + title: "生成内容策略", + }, + }); + + const missing = await invoker.call( + buildLimeCapabilityInvokeRequest({ + capability: "lime.agent", + method: "cancelTask", + args: { taskId: "missing-task" }, + requestId: "req-missing", + provenance, + }), + ); + expect(missing).toEqual({ + ok: false, + error: { + code: "capability_unavailable", + message: "lime.agent.cancelTask is not available in the mock host.", + capability: "lime.agent", + method: "cancelTask", + requestId: "req-missing", + causeCode: "UNSUPPORTED_CAPABILITY_METHOD", + }, + }); + }); +}); diff --git a/src/features/agent-app/sdk/capabilityContract.ts b/src/features/agent-app/sdk/capabilityContract.ts new file mode 100644 index 000000000..2cea1108e --- /dev/null +++ b/src/features/agent-app/sdk/capabilityContract.ts @@ -0,0 +1,374 @@ +import type { + AgentAppArtifactRecord, + AgentAppEvidenceRecord, + AgentAppKnowledgeSearchResult, + AgentAppProvenance, + AgentAppStorageEntry, + AgentAppTaskHostResponseRequest, + AgentAppTaskHostResponseResult, + AgentAppTaskRecord, + AgentAppTaskRequest, + AgentAppTaskStreamEvent, +} from "../types"; +import { + toLimeCapabilityError, + type LimeCapabilityError, + type LimeCapabilityErrorContext, +} from "./capabilityErrors"; + +export const LIME_CAPABILITY_NAMES = [ + "lime.ui", + "lime.storage", + "lime.files", + "lime.agent", + "lime.knowledge", + "lime.tools", + "lime.artifacts", + "lime.workflow", + "lime.policy", + "lime.secrets", + "lime.evidence", +] as const; + +export type LimeCapabilityName = (typeof LIME_CAPABILITY_NAMES)[number]; + +export interface LimeCapabilityInvokeProvenance { + appId: string; + entryKey?: string; + packageHash: string; + manifestHash: string; + workflowRunId?: string; + workspaceId?: string; + taskId?: string; +} + +export interface LimeCapabilityInvokeRequest< + Capability extends LimeCapabilityName = LimeCapabilityName, + Method extends string = string, + Args = unknown, +> { + capability: Capability; + method: Method; + args?: Args; + requestId?: string; + idempotencyKey?: string; + expectedSchema?: unknown; + provenance?: LimeCapabilityInvokeProvenance; +} + +export type LimeCapabilityInvokeResponse = + | { + ok: true; + value: Value; + traceId?: string; + evidenceId?: string; + } + | { + ok: false; + error: LimeCapabilityError; + }; + +export interface LimeCapabilityContractMap { + "lime.ui": { + toast: { + args: { + message: string; + level?: "info" | "success" | "warning" | "error"; + }; + value: { accepted: true }; + }; + navigate: { + args: { route?: string; url?: string }; + value: { navigatedTo: string }; + }; + openExternal: { + args: { url: string }; + value: { opened: true }; + }; + download: { + args: { url: string; fileName?: string }; + value: { downloaded: true }; + }; + getSnapshot: { + args: undefined; + value: unknown; + }; + }; + "lime.storage": { + get: { args: { key: string }; value: unknown | null }; + set: { args: { key: string; value: unknown }; value: AgentAppStorageEntry }; + list: { args: undefined; value: AgentAppStorageEntry[] }; + delete: { args: { key: string }; value: boolean }; + }; + "lime.files": { + pick: { args: { accept?: string[]; multiple?: boolean }; value: unknown }; + readRef: { args: { ref: string }; value: unknown }; + }; + "lime.agent": { + startTask: { args: AgentAppTaskRequest; value: AgentAppTaskRecord }; + streamTask: { args: { taskId: string }; value: AgentAppTaskStreamEvent[] }; + getTask: { args: { taskId: string }; value: AgentAppTaskRecord | null }; + cancelTask: { args: { taskId: string }; value: AgentAppTaskRecord }; + retryTask: { args: { taskId: string }; value: AgentAppTaskRecord }; + submitHostResponse: { + args: AgentAppTaskHostResponseRequest; + value: AgentAppTaskHostResponseResult; + }; + listTasks: { args: undefined; value: AgentAppTaskRecord[] }; + }; + "lime.knowledge": { + search: { + args: { query: string; limit?: number }; + value: AgentAppKnowledgeSearchResult; + }; + bindStatus: { args: { key: string }; value: unknown }; + }; + "lime.tools": { + invoke: { args: { tool: string; input?: unknown }; value: unknown }; + getProgress: { args: { invocationId: string }; value: unknown }; + }; + "lime.artifacts": { + create: { + args: { kind: string; title: string; content: unknown }; + value: AgentAppArtifactRecord; + }; + open: { args: { artifactId: string }; value: unknown }; + export: { args: { artifactId: string; format?: string }; value: unknown }; + }; + "lime.workflow": { + start: { args: { workflowKey: string; input?: unknown }; value: unknown }; + checkpoint: { + args: { workflowRunId: string; state?: unknown }; + value: unknown; + }; + awaitHuman: { + args: { workflowRunId: string; prompt: string }; + value: unknown; + }; + }; + "lime.policy": { + check: { args: { capability: string; action?: string }; value: unknown }; + requestPermission: { + args: { capability: string; reason?: string }; + value: unknown; + }; + }; + "lime.secrets": { + getRef: { args: { key: string }; value: { ref: string } }; + requestBinding: { args: { key: string; reason?: string }; value: unknown }; + }; + "lime.evidence": { + record: { + args: { kind: string; message: string; refs?: string[] }; + value: AgentAppEvidenceRecord; + }; + linkArtifact: { + args: { evidenceId: string; artifactId: string }; + value: unknown; + }; + }; +} + +export type LimeCapabilityMethod< + Capability extends LimeCapabilityName, +> = Extract; + +type LimeCapabilitySpec< + Capability extends LimeCapabilityName, + Method extends LimeCapabilityMethod, +> = LimeCapabilityContractMap[Capability][Method] extends { + args: infer Args; + value: infer Value; +} + ? { args: Args; value: Value } + : never; + +export type LimeCapabilityArgs< + Capability extends LimeCapabilityName, + Method extends LimeCapabilityMethod, +> = LimeCapabilitySpec["args"]; + +export type LimeCapabilityValue< + Capability extends LimeCapabilityName, + Method extends LimeCapabilityMethod, +> = LimeCapabilitySpec["value"]; + +export type LimeTypedCapabilityInvokeRequest< + Capability extends LimeCapabilityName, + Method extends LimeCapabilityMethod, +> = LimeCapabilityInvokeRequest< + Capability, + Method, + LimeCapabilityArgs +>; + +export type LimeTypedCapabilityInvokeResponse< + Capability extends LimeCapabilityName, + Method extends LimeCapabilityMethod, +> = LimeCapabilityInvokeResponse>; + +export interface BuildLimeCapabilityInvokeRequestParams< + Capability extends LimeCapabilityName, + Method extends LimeCapabilityMethod, +> { + capability: Capability; + method: Method; + args?: LimeCapabilityArgs; + requestId?: string; + idempotencyKey?: string; + expectedSchema?: unknown; + provenance?: LimeCapabilityInvokeProvenance; +} + +export interface LimeCapabilityTransport { + dispatch( + request: LimeCapabilityInvokeRequest, + ): Promise; +} + +export interface LimeCapabilityInvoker { + call< + Capability extends LimeCapabilityName, + Method extends LimeCapabilityMethod, + >( + request: LimeTypedCapabilityInvokeRequest, + ): Promise>; +} + +export type LimeCapabilityMockHandler = ( + request: LimeCapabilityInvokeRequest, +) => Promise | unknown; + +export type LimeCapabilityMockHandlers = Partial< + Record>> +>; + +function attachOptional(target: T, values: Partial): T { + Object.entries(values).forEach(([key, value]) => { + if (value !== undefined) { + (target as Record)[key] = value; + } + }); + return target; +} + +export function buildLimeCapabilityInvokeProvenance( + provenance: AgentAppProvenance, +): LimeCapabilityInvokeProvenance { + return attachOptional( + { + appId: provenance.appId, + packageHash: provenance.packageHash, + manifestHash: provenance.manifestHash, + }, + { + entryKey: provenance.entryKey, + workflowRunId: provenance.workflowRunId, + workspaceId: provenance.workspaceId, + taskId: provenance.taskId, + }, + ); +} + +export function buildLimeCapabilityInvokeRequest< + Capability extends LimeCapabilityName, + Method extends LimeCapabilityMethod, +>( + params: BuildLimeCapabilityInvokeRequestParams, +): LimeTypedCapabilityInvokeRequest { + return attachOptional>( + { + capability: params.capability, + method: params.method, + }, + { + args: params.args, + requestId: params.requestId, + idempotencyKey: params.idempotencyKey, + expectedSchema: params.expectedSchema, + provenance: params.provenance, + }, + ); +} + +export function createLimeCapabilitySuccessResponse( + value: Value, + meta: { traceId?: string; evidenceId?: string } = {}, +): LimeCapabilityInvokeResponse { + return attachOptional>( + { + ok: true, + value, + }, + meta, + ); +} + +export function createLimeCapabilityErrorResponse( + error: unknown, + context: LimeCapabilityErrorContext = {}, +): LimeCapabilityInvokeResponse { + return { + ok: false, + error: toLimeCapabilityError(error, context), + }; +} + +export function createLimeCapabilityInvoker( + transport: LimeCapabilityTransport, +): LimeCapabilityInvoker { + return { + async call(request) { + try { + return (await transport.dispatch( + request, + )) as LimeTypedCapabilityInvokeResponse< + typeof request.capability, + typeof request.method + >; + } catch (error) { + return createLimeCapabilityErrorResponse(error, { + capability: request.capability, + method: request.method, + requestId: request.requestId, + }) as LimeTypedCapabilityInvokeResponse< + typeof request.capability, + typeof request.method + >; + } + }, + }; +} + +export function createMockLimeCapabilityTransport( + handlers: LimeCapabilityMockHandlers = {}, +): LimeCapabilityTransport { + return { + async dispatch(request) { + const handler = handlers[request.capability]?.[request.method]; + if (!handler) { + return createLimeCapabilityErrorResponse( + { + code: "UNSUPPORTED_CAPABILITY_METHOD", + message: `${request.capability}.${request.method} is not available in the mock host.`, + }, + { + capability: request.capability, + method: request.method, + requestId: request.requestId, + }, + ); + } + + try { + return createLimeCapabilitySuccessResponse(await handler(request)); + } catch (error) { + return createLimeCapabilityErrorResponse(error, { + capability: request.capability, + method: request.method, + requestId: request.requestId, + }); + } + }, + }; +} diff --git a/src/features/agent-app/sdk/capabilityErrors.ts b/src/features/agent-app/sdk/capabilityErrors.ts index 2ea70472f..e77faa95b 100644 --- a/src/features/agent-app/sdk/capabilityErrors.ts +++ b/src/features/agent-app/sdk/capabilityErrors.ts @@ -3,18 +3,216 @@ import type { AgentAppCapabilityErrorPayload, } from "../types"; +export const LIME_CAPABILITY_ERROR_CODES = [ + "capability_unavailable", + "readiness_blocked", + "permission_denied", + "policy_denied", + "schema_invalid", + "source_unverified", + "secret_required", + "timeout", + "cancelled", + "conflict", + "upstream_failed", +] as const; + +export type LimeCapabilityErrorCode = + (typeof LIME_CAPABILITY_ERROR_CODES)[number]; + +export interface LimeCapabilityError { + code: LimeCapabilityErrorCode; + message: string; + appId?: string; + entryKey?: string; + capability?: string; + method?: string; + requestId?: string; + traceId?: string; + causeCode?: string; + retryable?: boolean; + details?: unknown; +} + +export interface LimeCapabilityErrorContext { + appId?: string; + entryKey?: string; + capability?: string; + method?: string; + requestId?: string; + traceId?: string; + retryable?: boolean; + details?: unknown; +} + +export interface AgentAppCapabilityErrorInit + extends AgentAppCapabilityErrorPayload { + stableCode?: LimeCapabilityErrorCode; + method?: string; + requestId?: string; + traceId?: string; + retryable?: boolean; + details?: unknown; +} + +const LEGACY_CAPABILITY_ERROR_MAP: Record< + string, + LimeCapabilityErrorCode +> = { + APP_RUNTIME_UNSUPPORTED: "capability_unavailable", + CAPABILITY_BLOCKED: "capability_unavailable", + CAPABILITY_NOT_DECLARED: "capability_unavailable", + ENTRY_NOT_FOUND: "readiness_blocked", + FEATURE_DISABLED: "capability_unavailable", + HOST_ACTION_FAILED: "upstream_failed", + INVALID_CAPABILITY_INPUT: "schema_invalid", + INVALID_PAYLOAD: "schema_invalid", + PERMISSION_DENIED: "permission_denied", + POLICY_DENIED: "policy_denied", + READINESS_BLOCKED: "readiness_blocked", + SECRET_REQUIRED: "secret_required", + SOURCE_UNVERIFIED: "source_unverified", + STORAGE_KEY_NOT_FOUND: "conflict", + TASK_NOT_FOUND: "conflict", + TIMEOUT: "timeout", + UNTRUSTED_URL: "policy_denied", + UNSUPPORTED_CAPABILITY: "capability_unavailable", + UNSUPPORTED_CAPABILITY_METHOD: "capability_unavailable", + UI_ENTRY_UNSUPPORTED: "capability_unavailable", + WORKFLOW_POLICY_VIOLATION: "policy_denied", + WORKFLOW_RUNTIME_DISABLED: "capability_unavailable", +}; + +export function isLimeCapabilityErrorCode( + value: unknown, +): value is LimeCapabilityErrorCode { + return ( + typeof value === "string" && + LIME_CAPABILITY_ERROR_CODES.includes(value as LimeCapabilityErrorCode) + ); +} + +export function normalizeLimeCapabilityErrorCode( + code: string | undefined, +): LimeCapabilityErrorCode { + if (isLimeCapabilityErrorCode(code)) { + return code; + } + if (!code) { + return "upstream_failed"; + } + return LEGACY_CAPABILITY_ERROR_MAP[code] ?? "upstream_failed"; +} + +function readErrorField(error: unknown, key: string): unknown { + if (typeof error !== "object" || error === null) { + return undefined; + } + return (error as Record)[key]; +} + +function readStringField(error: unknown, key: string): string | undefined { + const value = readErrorField(error, key); + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function readBooleanField(error: unknown, key: string): boolean | undefined { + const value = readErrorField(error, key); + return typeof value === "boolean" ? value : undefined; +} + +function mergeOptional( + target: T, + values: Partial, +): T { + Object.entries(values).forEach(([key, value]) => { + if (value !== undefined) { + (target as Record)[key] = value; + } + }); + return target; +} + export class AgentAppCapabilityError extends Error { readonly code: AgentAppCapabilityErrorCode; + readonly stableCode: LimeCapabilityErrorCode; readonly appId?: string; readonly entryKey?: string; readonly capability?: string; + readonly method?: string; + readonly requestId?: string; + readonly traceId?: string; + readonly retryable?: boolean; + readonly details?: unknown; - constructor(payload: AgentAppCapabilityErrorPayload) { + constructor(payload: AgentAppCapabilityErrorInit) { super(payload.message); this.name = "AgentAppCapabilityError"; this.code = payload.code; + this.stableCode = + payload.stableCode ?? normalizeLimeCapabilityErrorCode(payload.code); this.appId = payload.appId; this.entryKey = payload.entryKey; this.capability = payload.capability; + this.method = payload.method; + this.requestId = payload.requestId; + this.traceId = payload.traceId; + this.retryable = payload.retryable; + this.details = payload.details; + } + + toStableError(context: LimeCapabilityErrorContext = {}): LimeCapabilityError { + return mergeOptional( + { + code: this.stableCode, + message: this.message, + causeCode: this.code, + }, + { + appId: context.appId ?? this.appId, + entryKey: context.entryKey ?? this.entryKey, + capability: context.capability ?? this.capability, + method: context.method ?? this.method, + requestId: context.requestId ?? this.requestId, + traceId: context.traceId ?? this.traceId, + retryable: context.retryable ?? this.retryable, + details: context.details ?? this.details, + }, + ); } } + +export function toLimeCapabilityError( + error: unknown, + context: LimeCapabilityErrorContext = {}, +): LimeCapabilityError { + if (error instanceof AgentAppCapabilityError) { + return error.toStableError(context); + } + + const stableCode = + readStringField(error, "stableCode") ?? readStringField(error, "code"); + const causeCode = readStringField(error, "code"); + const message = + error instanceof Error + ? error.message + : readStringField(error, "message") ?? "Lime capability call failed."; + + return mergeOptional( + { + code: normalizeLimeCapabilityErrorCode(stableCode), + message, + }, + { + appId: context.appId ?? readStringField(error, "appId"), + entryKey: context.entryKey ?? readStringField(error, "entryKey"), + capability: context.capability ?? readStringField(error, "capability"), + method: context.method ?? readStringField(error, "method"), + requestId: context.requestId ?? readStringField(error, "requestId"), + traceId: context.traceId ?? readStringField(error, "traceId"), + retryable: context.retryable ?? readBooleanField(error, "retryable"), + causeCode, + details: context.details ?? readErrorField(error, "details"), + }, + ); +} diff --git a/src/features/agent-app/sdk/contentFactorySdkRegression.test.ts b/src/features/agent-app/sdk/contentFactorySdkRegression.test.ts new file mode 100644 index 000000000..650e307f8 --- /dev/null +++ b/src/features/agent-app/sdk/contentFactorySdkRegression.test.ts @@ -0,0 +1,328 @@ +import { describe, expect, it } from "vitest"; +import type { + AgentAppArtifactRecord, + AgentAppEvidenceRecord, + AgentAppStorageEntry, + AgentAppTaskHostResponseResult, + AgentAppTaskRecord, + AgentAppTaskStreamEvent, + LimeCapabilityInvokeRequest, +} from "./index"; +import { + buildLimeCapabilityInvokeProvenance, + createLimeCapabilityInvoker, + createLimeCoreCapabilityAdapters, + createMockLimeCapabilityTransport, +} from "./index"; + +const recordProvenance = { + sourceKind: "agent_app", + appId: "content-factory-app", + appVersion: "1.0.0", + packageHash: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + manifestHash: "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + entryKey: "content_factory", + workflowRunId: "content-factory-run-1", + workspaceId: "workspace-1", +} as const; +const provenance = buildLimeCapabilityInvokeProvenance(recordProvenance); + +function buildTaskRecord( + overrides: Partial = {}, +): AgentAppTaskRecord { + return { + taskId: overrides.taskId ?? "content-factory-task-1", + traceId: overrides.traceId ?? "content-factory-trace-1", + appId: recordProvenance.appId, + entryKey: recordProvenance.entryKey, + title: overrides.title ?? "生成内容批次", + prompt: overrides.prompt ?? "基于项目知识生成内容批次", + taskKind: overrides.taskKind ?? "content.copy.generate", + idempotencyKey: overrides.idempotencyKey ?? "content-factory:batch:project-1", + input: overrides.input, + expectedOutput: overrides.expectedOutput, + knowledge: overrides.knowledge ?? [{ key: "project_knowledge", required: true }], + tools: overrides.tools ?? ["lime.capability.research.search"], + files: overrides.files ?? [], + secrets: overrides.secrets ?? [], + humanReview: overrides.humanReview ?? true, + status: overrides.status ?? "running", + startedAt: overrides.startedAt ?? "2026-05-16T00:00:00.000Z", + finishedAt: overrides.finishedAt, + cancelledAt: overrides.cancelledAt, + result: overrides.result, + trace: overrides.trace ?? [], + events: overrides.events ?? [], + provenance: overrides.provenance ?? recordProvenance, + retryOfTaskId: overrides.retryOfTaskId, + retryAttempt: overrides.retryAttempt, + }; +} + +const taskEvents: AgentAppTaskStreamEvent[] = [ + { + eventId: "content-factory-event-queued", + taskId: "content-factory-task-1", + traceId: "content-factory-trace-1", + type: "task:queued", + at: "2026-05-16T00:00:00.000Z", + status: "running", + }, + { + eventId: "content-factory-event-missing-context", + taskId: "content-factory-task-1", + traceId: "content-factory-trace-1", + type: "task:missingContextRequested", + at: "2026-05-16T00:00:01.000Z", + status: "running", + message: "需要补充目标用户画像。", + payload: { requestId: "runtime-request-1" }, + }, + { + eventId: "content-factory-event-artifact", + taskId: "content-factory-task-1", + traceId: "content-factory-trace-1", + type: "artifact:created", + at: "2026-05-16T00:00:02.000Z", + status: "running", + refs: ["artifact-content-batch-1"], + payload: { + contentFactoryWorkspacePatch: { + kind: "content_batch", + projectId: "project-1", + contentBatchId: "batch-1", + }, + }, + }, + { + eventId: "content-factory-event-evidence", + taskId: "content-factory-task-1", + traceId: "content-factory-trace-1", + type: "evidence:recorded", + at: "2026-05-16T00:00:03.000Z", + status: "succeeded", + refs: ["evidence-fact-grounding-1"], + }, +]; + +describe("P18.5 content factory SDK regression", () => { + it("应通过通用 SDK facade 完成内容工厂 task、写回和证据闭环", async () => { + const requests: LimeCapabilityInvokeRequest[] = []; + const invoker = createLimeCapabilityInvoker( + createMockLimeCapabilityTransport({ + "lime.agent": { + startTask: (request) => { + requests.push(request); + const args = request.args as { + title: string; + taskKind?: string; + input?: unknown; + expectedOutput?: unknown; + }; + return buildTaskRecord({ + title: args.title, + taskKind: args.taskKind, + input: args.input, + expectedOutput: args.expectedOutput, + events: [taskEvents[0]], + }); + }, + streamTask: (request) => { + requests.push(request); + return taskEvents; + }, + getTask: (request) => { + requests.push(request); + return buildTaskRecord({ + status: "succeeded", + finishedAt: "2026-05-16T00:00:04.000Z", + result: { contentBatchId: "batch-1" }, + events: taskEvents, + }); + }, + submitHostResponse: (request) => { + requests.push(request); + const args = request.args as { + taskId: string; + requestId: string; + }; + return { + taskId: args.taskId, + requestId: args.requestId, + status: "submitted", + submittedAt: "2026-05-16T00:00:01.500Z", + } satisfies AgentAppTaskHostResponseResult; + }, + }, + "lime.storage": { + set: (request) => { + requests.push(request); + const args = request.args as { key: string; value: unknown }; + return { + appId: recordProvenance.appId, + key: args.key, + value: args.value, + updatedAt: "2026-05-16T00:00:05.000Z", + provenance: recordProvenance, + } satisfies AgentAppStorageEntry; + }, + }, + "lime.artifacts": { + create: (request) => { + requests.push(request); + const args = request.args as { + kind: string; + title: string; + content: unknown; + }; + return { + id: "artifact-content-batch-1", + appId: recordProvenance.appId, + entryKey: recordProvenance.entryKey, + kind: args.kind, + title: args.title, + content: args.content, + createdAt: "2026-05-16T00:00:06.000Z", + provenance: recordProvenance, + } satisfies AgentAppArtifactRecord; + }, + }, + "lime.evidence": { + record: (request) => { + requests.push(request); + const args = request.args as { + kind: string; + message: string; + refs?: string[]; + }; + return { + id: "evidence-fact-grounding-1", + appId: recordProvenance.appId, + entryKey: recordProvenance.entryKey, + kind: args.kind, + message: args.message, + refs: args.refs ?? [], + createdAt: "2026-05-16T00:00:07.000Z", + provenance: recordProvenance, + } satisfies AgentAppEvidenceRecord; + }, + }, + }), + ); + const lime = createLimeCoreCapabilityAdapters({ + invoker, + provenance, + storageNamespace: "content-factory-app", + }); + + const task = await lime.agent.startTask( + { + title: "生成内容批次", + taskKind: "content.copy.generate", + idempotencyKey: "content-factory:batch:project-1", + queueIfBusy: true, + input: { projectId: "project-1", sceneTableId: "scene-table-1" }, + expectedOutput: { + artifactKind: "content_batch", + workspacePatch: "contentFactoryWorkspacePatch", + }, + knowledge: [{ key: "project_knowledge", required: true }], + tools: ["lime.capability.research.search"], + humanReview: true, + }, + { + requestId: "req-content-task", + idempotencyKey: "content-factory:batch:project-1", + }, + ); + const stream = await lime.agent.streamTask( + { taskId: task.taskId }, + { requestId: "req-content-stream" }, + ); + const missingContext = stream.find( + (event) => event.type === "task:missingContextRequested", + ); + const hostResponse = await lime.agent.submitHostResponse( + { + taskId: task.taskId, + requestId: String( + (missingContext?.payload as { requestId?: string } | undefined) + ?.requestId ?? "runtime-request-1", + ), + actionType: "ask_user", + response: "目标用户:内容运营负责人。", + }, + { requestId: "req-content-host-response" }, + ); + const snapshot = await lime.agent.getTask( + { taskId: task.taskId }, + { requestId: "req-content-get" }, + ); + const storageEntry = await lime.storage.set( + { + key: "content_batches/project-1/batch-1", + value: { projectId: "project-1", contentBatchId: "batch-1" }, + }, + { requestId: "req-content-storage" }, + ); + const artifact = await lime.artifacts.create( + { + kind: "content_batch", + title: "内容批次 batch-1", + content: storageEntry.value, + }, + { requestId: "req-content-artifact" }, + ); + const evidence = await lime.evidence.record( + { + kind: "fact_grounding", + message: "内容批次已绑定项目知识和运行任务。", + refs: [artifact.id, task.taskId, hostResponse.requestId], + }, + { requestId: "req-content-evidence" }, + ); + + expect(lime.storage.namespace).toBe("content-factory-app"); + expect(snapshot).toMatchObject({ + status: "succeeded", + result: { contentBatchId: "batch-1" }, + }); + expect(stream).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "artifact:created", + payload: expect.objectContaining({ + contentFactoryWorkspacePatch: expect.objectContaining({ + kind: "content_batch", + projectId: "project-1", + }), + }), + }), + expect.objectContaining({ type: "evidence:recorded" }), + ]), + ); + expect(artifact.kind).toBe("content_batch"); + expect(evidence).toMatchObject({ + kind: "fact_grounding", + refs: [ + "artifact-content-batch-1", + "content-factory-task-1", + "runtime-request-1", + ], + }); + expect( + requests.map((request) => `${request.capability}.${request.method}`), + ).toEqual([ + "lime.agent.startTask", + "lime.agent.streamTask", + "lime.agent.submitHostResponse", + "lime.agent.getTask", + "lime.storage.set", + "lime.artifacts.create", + "lime.evidence.record", + ]); + expect(requests.every((request) => request.provenance === provenance)).toBe( + true, + ); + }); +}); diff --git a/src/features/agent-app/sdk/hostBridgeClient.test.ts b/src/features/agent-app/sdk/hostBridgeClient.test.ts new file mode 100644 index 000000000..da27bc45d --- /dev/null +++ b/src/features/agent-app/sdk/hostBridgeClient.test.ts @@ -0,0 +1,798 @@ +import { describe, expect, it, vi } from "vitest"; +import type { + AgentAppArtifactRecord, + AgentAppEvidenceRecord, + AgentAppStorageEntry, + AgentAppTaskHostResponseResult, + AgentAppTaskRecord, + AgentAppTaskStreamEvent, +} from "../types"; +import { + buildLimeCapabilityInvokeProvenance, + buildLimeCapabilityInvokeRequest, +} from "./capabilityContract"; +import { + createLimeCoreCapabilityAdapters, + LimeCapabilityAdapterError, +} from "./capabilityAdapters"; +import { + createLimeHostBridgeCapabilityInvoker, + LIME_AGENT_APP_BRIDGE_PROTOCOL, + LIME_AGENT_APP_BRIDGE_VERSION, + type LimeAgentAppBridgeClientMessage, +} from "./hostBridgeClient"; + +const provenance = buildLimeCapabilityInvokeProvenance({ + sourceKind: "agent_app", + appId: "content-factory-app", + appVersion: "1.0.0", + packageHash: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + manifestHash: "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + entryKey: "dashboard", + workflowRunId: "run-1", +}); + +function buildTaskRecord( + overrides: Partial = {}, +): AgentAppTaskRecord { + return { + taskId: overrides.taskId ?? "task-1", + traceId: overrides.traceId ?? "trace-1", + appId: "content-factory-app", + entryKey: "dashboard", + title: overrides.title ?? "生成内容策略", + prompt: overrides.prompt ?? "生成内容策略", + taskKind: overrides.taskKind ?? "content.copy.generate", + idempotencyKey: overrides.idempotencyKey ?? "dashboard:copy", + input: overrides.input, + knowledge: [], + tools: [], + files: [], + secrets: [], + humanReview: true, + status: overrides.status ?? "running", + startedAt: "2026-05-16T00:00:00.000Z", + trace: [], + events: [], + provenance: { + sourceKind: "agent_app", + appId: "content-factory-app", + appVersion: "1.0.0", + packageHash: provenance.packageHash, + manifestHash: provenance.manifestHash, + entryKey: "dashboard", + }, + }; +} + +class FakeBridgeWindow { + readonly parent = { + postMessage: vi.fn( + (message: LimeAgentAppBridgeClientMessage, targetOrigin: string) => { + this.posts.push({ message, targetOrigin }); + }, + ), + }; + readonly self = {}; + readonly posts: Array<{ + message: LimeAgentAppBridgeClientMessage; + targetOrigin: string; + }> = []; + private readonly listeners = new Set<(event: { + data: unknown; + origin: string; + source: unknown; + }) => void>(); + private readonly timers = new Map void>(); + private timerSequence = 0; + + readonly windowRef = { + parent: this.parent, + self: this.self, + addEventListener: ( + type: "message", + listener: (event: { data: unknown; origin: string; source: unknown }) => void, + ) => { + if (type === "message") { + this.listeners.add(listener); + } + }, + removeEventListener: ( + type: "message", + listener: (event: { data: unknown; origin: string; source: unknown }) => void, + ) => { + if (type === "message") { + this.listeners.delete(listener); + } + }, + setTimeout: (handler: () => void) => { + this.timerSequence += 1; + this.timers.set(this.timerSequence, handler); + return this.timerSequence; + }, + clearTimeout: (timerId: number) => { + this.timers.delete(timerId); + }, + }; + + emit( + data: LimeAgentAppBridgeClientMessage, + origin = "https://lime.host", + source: unknown = this.parent, + ): void { + for (const listener of this.listeners) { + listener({ data, origin, source }); + } + } + + runNextTimer(): void { + const next = this.timers.entries().next(); + if (next.done) { + throw new Error("No pending fake timer."); + } + const [timerId, handler] = next.value; + this.timers.delete(timerId); + handler(); + } +} + +function hostResponse( + request: LimeAgentAppBridgeClientMessage, + payload: unknown, +): LimeAgentAppBridgeClientMessage { + return { + protocol: LIME_AGENT_APP_BRIDGE_PROTOCOL, + version: LIME_AGENT_APP_BRIDGE_VERSION, + type: "host:response", + requestId: request.requestId, + appId: request.appId, + entryKey: request.entryKey, + payload, + }; +} + +function hostError( + request: LimeAgentAppBridgeClientMessage, + payload: unknown, +): LimeAgentAppBridgeClientMessage { + return { + ...hostResponse(request, payload), + type: "host:error", + }; +} + +function latestBridgeRequest(fake: FakeBridgeWindow): LimeAgentAppBridgeClientMessage { + const latest = fake.posts.at(-1)?.message; + if (!latest) { + throw new Error("No bridge request was posted."); + } + return latest; +} + +function bridgeCallNames(fake: FakeBridgeWindow): string[] { + return fake.posts.map((post) => { + const payload = post.message.payload as { capability?: string; method?: string }; + return `${payload.capability}.${payload.method}`; + }); +} + +describe("P18.5 Host Bridge SDK client", () => { + it("应把标准 SDK facade 调用转成 Host Bridge v1 capability invoke envelope", async () => { + const fake = new FakeBridgeWindow(); + const invoker = createLimeHostBridgeCapabilityInvoker({ + appId: "content-factory-app", + entryKey: "dashboard", + windowRef: fake.windowRef, + trustedHostOrigin: "https://lime.host", + requestIdPrefix: "content-factory", + }); + const lime = createLimeCoreCapabilityAdapters({ + invoker, + provenance, + storageNamespace: "content-factory-app", + }); + + const taskPromise = lime.agent.startTask({ + title: "生成内容策略", + taskKind: "content.copy.generate", + idempotencyKey: "dashboard:copy", + expectedOutput: { artifactKind: "content_batch" }, + }); + + expect(fake.posts).toHaveLength(1); + const posted = fake.posts[0]; + expect(posted.targetOrigin).toBe("https://lime.host"); + expect(posted.message).toMatchObject({ + protocol: LIME_AGENT_APP_BRIDGE_PROTOCOL, + version: LIME_AGENT_APP_BRIDGE_VERSION, + type: "capability:invoke", + appId: "content-factory-app", + entryKey: "dashboard", + payload: { + capability: "lime.agent", + method: "startTask", + input: { + title: "生成内容策略", + taskKind: "content.copy.generate", + idempotencyKey: "dashboard:copy", + expectedOutput: { artifactKind: "content_batch" }, + }, + provenance, + }, + }); + + fake.emit( + hostResponse(posted.message, { + ok: true, + value: buildTaskRecord({ title: "生成内容策略" }), + }), + ); + + await expect(taskPromise).resolves.toMatchObject({ + taskId: "task-1", + traceId: "trace-1", + title: "生成内容策略", + }); + expect(invoker.pendingRequestCount).toBe(0); + invoker.dispose(); + }); + + it("应忽略非信任来源并把 host:error 映射成 stable SDK error", async () => { + const fake = new FakeBridgeWindow(); + const invoker = createLimeHostBridgeCapabilityInvoker({ + appId: "content-factory-app", + entryKey: "dashboard", + windowRef: fake.windowRef, + trustedHostOrigin: "https://lime.host", + }); + const lime = createLimeCoreCapabilityAdapters({ invoker, provenance }); + + const writePromise = lime.storage.set({ + key: "projects/project-1/content-batches/batch-1", + value: { title: "内容批次" }, + }); + const request = fake.posts[0].message; + fake.emit( + hostError(request, { + code: "PERMISSION_DENIED", + message: "Storage write is denied.", + }), + "https://evil.example", + ); + expect(invoker.pendingRequestCount).toBe(1); + fake.emit( + hostError(request, { + code: "PERMISSION_DENIED", + message: "Storage write is denied.", + }), + ); + + await expect(writePromise).rejects.toMatchObject({ + code: "permission_denied", + capability: "lime.storage", + method: "set", + } satisfies Partial); + expect(invoker.pendingRequestCount).toBe(0); + invoker.dispose(); + }); + + it("应在 Host 无响应时返回 timeout stable error 并清理 pending request", async () => { + const fake = new FakeBridgeWindow(); + const invoker = createLimeHostBridgeCapabilityInvoker({ + appId: "content-factory-app", + entryKey: "dashboard", + windowRef: fake.windowRef, + trustedHostOrigin: "https://lime.host", + requestTimeoutMs: 50, + }); + + const responsePromise = invoker.call( + buildLimeCapabilityInvokeRequest({ + capability: "lime.agent", + method: "getTask", + args: { taskId: "task-timeout" }, + provenance, + }), + ); + expect(invoker.pendingRequestCount).toBe(1); + fake.runNextTimer(); + + await expect(responsePromise).resolves.toMatchObject({ + ok: false, + error: { + code: "timeout", + capability: "lime.agent", + method: "getTask", + }, + }); + expect(invoker.pendingRequestCount).toBe(0); + invoker.dispose(); + }); + + it("应支持 app ready、snapshot、theme、visibility 和 Host action", async () => { + const fake = new FakeBridgeWindow(); + const invoker = createLimeHostBridgeCapabilityInvoker({ + appId: "content-factory-app", + entryKey: "dashboard", + windowRef: fake.windowRef, + trustedHostOrigin: "https://lime.host", + requestIdPrefix: "content-factory", + }); + const snapshots: unknown[] = []; + const themes: unknown[] = []; + const visibilityEvents: unknown[] = []; + invoker.onHostSnapshot((payload) => snapshots.push(payload)); + invoker.onThemeUpdate((payload) => themes.push(payload)); + invoker.onVisibilityChange((payload) => visibilityEvents.push(payload)); + + invoker.sendReady(); + expect(latestBridgeRequest(fake)).toMatchObject({ + type: "app:ready", + appId: "content-factory-app", + entryKey: "dashboard", + }); + + const snapshotPromise = invoker.getHostSnapshot(); + const snapshotRequest = latestBridgeRequest(fake); + expect(snapshotRequest).toMatchObject({ + type: "host:getSnapshot", + appId: "content-factory-app", + entryKey: "dashboard", + }); + fake.emit({ + protocol: LIME_AGENT_APP_BRIDGE_PROTOCOL, + version: LIME_AGENT_APP_BRIDGE_VERSION, + type: "host:snapshot", + requestId: snapshotRequest.requestId, + appId: "content-factory-app", + entryKey: "dashboard", + payload: { + app: { appId: "content-factory-app", entryKey: "dashboard" }, + host: { locale: "zh-CN" }, + }, + }); + await expect(snapshotPromise).resolves.toMatchObject({ + ok: true, + value: { + app: { appId: "content-factory-app", entryKey: "dashboard" }, + }, + }); + expect(snapshots).toHaveLength(1); + + fake.emit({ + protocol: LIME_AGENT_APP_BRIDGE_PROTOCOL, + version: LIME_AGENT_APP_BRIDGE_VERSION, + type: "theme:update", + appId: "content-factory-app", + entryKey: "dashboard", + payload: { effectiveThemeMode: "dark" }, + }); + fake.emit({ + protocol: LIME_AGENT_APP_BRIDGE_PROTOCOL, + version: LIME_AGENT_APP_BRIDGE_VERSION, + type: "host:visibility", + appId: "content-factory-app", + entryKey: "dashboard", + payload: { visibilityState: "visible" }, + }); + expect(themes).toEqual([{ effectiveThemeMode: "dark" }]); + expect(visibilityEvents).toEqual([{ visibilityState: "visible" }]); + + const notifyPromise = invoker.notifyHost({ + message: "内容工厂任务已完成。", + level: "success", + }); + const notifyRequest = latestBridgeRequest(fake); + expect(notifyRequest).toMatchObject({ + type: "host:toast", + payload: { + message: "内容工厂任务已完成。", + level: "success", + }, + }); + fake.emit(hostResponse(notifyRequest, { accepted: true })); + await expect(notifyPromise).resolves.toMatchObject({ + ok: true, + value: { accepted: true }, + }); + + const navigatePromise = invoker.navigateHost({ route: "/reports" }); + const navigateRequest = latestBridgeRequest(fake); + expect(navigateRequest).toMatchObject({ + type: "host:navigate", + payload: { route: "/reports" }, + }); + fake.emit(hostResponse(navigateRequest, { navigatedTo: "/reports" })); + await expect(navigatePromise).resolves.toMatchObject({ + ok: true, + value: { navigatedTo: "/reports" }, + }); + + const openExternalPromise = invoker.openExternalHost({ + url: "https://limeai.run/docs", + }); + const openExternalRequest = latestBridgeRequest(fake); + expect(openExternalRequest).toMatchObject({ + type: "host:openExternal", + payload: { url: "https://limeai.run/docs" }, + }); + fake.emit(hostResponse(openExternalRequest, { opened: true })); + await expect(openExternalPromise).resolves.toMatchObject({ + ok: true, + value: { opened: true }, + }); + + const downloadPromise = invoker.downloadHost({ + url: "/exports/content-batch.csv", + fileName: "content-batch.csv", + }); + const downloadRequest = latestBridgeRequest(fake); + expect(downloadRequest).toMatchObject({ + type: "host:download", + payload: { + url: "/exports/content-batch.csv", + fileName: "content-batch.csv", + }, + }); + fake.emit(hostResponse(downloadRequest, { downloaded: true })); + await expect(downloadPromise).resolves.toMatchObject({ + ok: true, + value: { downloaded: true }, + }); + + expect(invoker.pendingRequestCount).toBe(0); + invoker.dispose(); + }); + + it("应支持 capability subscription 事件分发与 unsubscribe 清理", async () => { + const fake = new FakeBridgeWindow(); + const invoker = createLimeHostBridgeCapabilityInvoker({ + appId: "content-factory-app", + entryKey: "dashboard", + windowRef: fake.windowRef, + trustedHostOrigin: "https://lime.host", + requestIdPrefix: "content-factory", + }); + const receivedEvents: unknown[] = []; + + const subscribePromise = invoker.subscribeCapability( + { + capability: "lime.agent", + topic: "task", + input: { taskId: "task-1" }, + pollIntervalMs: 700, + bridgeAction: "runHostAgentTask", + }, + (event) => { + receivedEvents.push(event); + }, + ); + const subscribeRequest = latestBridgeRequest(fake); + expect(subscribeRequest).toMatchObject({ + type: "capability:subscribe", + payload: { + capability: "lime.agent", + topic: "task", + input: { taskId: "task-1" }, + pollIntervalMs: 700, + bridgeAction: "runHostAgentTask", + }, + }); + fake.emit( + hostResponse(subscribeRequest, { + ok: true, + value: { + subscriptionId: "sub-task-1", + capability: "lime.agent", + topic: "task", + taskId: "task-1", + pollIntervalMs: 700, + bridgeAction: "runHostAgentTask", + }, + }), + ); + await expect(subscribePromise).resolves.toMatchObject({ + ok: true, + value: { + subscriptionId: "sub-task-1", + capability: "lime.agent", + topic: "task", + }, + }); + + fake.emit({ + protocol: LIME_AGENT_APP_BRIDGE_PROTOCOL, + version: LIME_AGENT_APP_BRIDGE_VERSION, + type: "capability:event", + appId: "content-factory-app", + entryKey: "dashboard", + payload: { + subscriptionId: "sub-task-1", + capability: "lime.agent", + topic: "task", + eventType: "task:update", + taskId: "task-1", + task: { taskId: "task-1", status: "running" }, + events: [{ type: "task:progress", message: "正在生成内容。" }], + }, + }); + expect(receivedEvents).toEqual([ + expect.objectContaining({ + subscriptionId: "sub-task-1", + eventType: "task:update", + taskId: "task-1", + }), + ]); + + const unsubscribePromise = invoker.unsubscribeCapability("sub-task-1"); + const unsubscribeRequest = latestBridgeRequest(fake); + expect(unsubscribeRequest).toMatchObject({ + type: "capability:unsubscribe", + payload: { subscriptionId: "sub-task-1" }, + }); + fake.emit( + hostResponse(unsubscribeRequest, { + ok: true, + value: { subscriptionId: "sub-task-1", unsubscribed: true }, + }), + ); + await expect(unsubscribePromise).resolves.toMatchObject({ + ok: true, + value: { subscriptionId: "sub-task-1", unsubscribed: true }, + }); + + fake.emit({ + protocol: LIME_AGENT_APP_BRIDGE_PROTOCOL, + version: LIME_AGENT_APP_BRIDGE_VERSION, + type: "capability:event", + appId: "content-factory-app", + entryKey: "dashboard", + payload: { + subscriptionId: "sub-task-1", + capability: "lime.agent", + topic: "task", + eventType: "task:update", + taskId: "task-1", + }, + }); + expect(receivedEvents).toHaveLength(1); + invoker.dispose(); + }); + + it("内容工厂主链应能通过标准 Host Bridge SDK client 完成 task 与写回", async () => { + const fake = new FakeBridgeWindow(); + const invoker = createLimeHostBridgeCapabilityInvoker({ + appId: "content-factory-app", + entryKey: "dashboard", + windowRef: fake.windowRef, + trustedHostOrigin: "https://lime.host", + requestIdPrefix: "content-factory", + }); + const lime = createLimeCoreCapabilityAdapters({ + invoker, + provenance, + storageNamespace: "content-factory-app", + }); + const task = buildTaskRecord({ + taskId: "task-content-1", + traceId: "trace-content-1", + title: "生成内容批次", + taskKind: "content.copy.generate", + input: { projectId: "project-1", channel: "gongzhonghao" }, + }); + const taskEvents: AgentAppTaskStreamEvent[] = [ + { + eventId: "event-missing-context", + taskId: task.taskId, + traceId: task.traceId, + type: "task:missingContextRequested", + at: "2026-05-16T00:00:01.000Z", + status: "running", + message: "需要确认项目定位。", + payload: { requestId: "missing-context-1" }, + }, + { + eventId: "event-artifact-created", + taskId: task.taskId, + traceId: task.traceId, + type: "artifact:created", + at: "2026-05-16T00:00:02.000Z", + status: "running", + message: "内容批次已创建。", + refs: ["artifact-content-1"], + payload: { + workspacePatch: { kind: "content_batch", count: 20 }, + contentFactoryWorkspacePatch: { kind: "content_batch", count: 20 }, + }, + }, + { + eventId: "event-evidence-recorded", + taskId: task.taskId, + traceId: task.traceId, + type: "evidence:recorded", + at: "2026-05-16T00:00:03.000Z", + status: "running", + message: "Fact grounding 已记录。", + refs: ["evidence-content-1"], + }, + ]; + + const startPromise = lime.agent.startTask({ + title: task.title, + taskKind: task.taskKind, + idempotencyKey: task.idempotencyKey, + input: task.input, + expectedOutput: { + artifactKind: "content_batch", + workspacePatch: "contentFactoryWorkspacePatch", + }, + humanReview: true, + }); + fake.emit(hostResponse(latestBridgeRequest(fake), { ok: true, value: task })); + await expect(startPromise).resolves.toMatchObject({ + taskId: "task-content-1", + traceId: "trace-content-1", + }); + + const streamPromise = lime.agent.streamTask({ taskId: task.taskId }); + fake.emit( + hostResponse(latestBridgeRequest(fake), { + ok: true, + value: taskEvents, + }), + ); + await expect(streamPromise).resolves.toContainEqual( + expect.objectContaining({ + type: "artifact:created", + payload: expect.objectContaining({ + contentFactoryWorkspacePatch: { kind: "content_batch", count: 20 }, + }), + }), + ); + + const getPromise = lime.agent.getTask({ taskId: task.taskId }); + fake.emit( + hostResponse(latestBridgeRequest(fake), { + ok: true, + value: { ...task, events: taskEvents }, + }), + ); + await expect(getPromise).resolves.toMatchObject({ + events: expect.arrayContaining([ + expect.objectContaining({ type: "task:missingContextRequested" }), + ]), + }); + + const hostResponsePromise = lime.agent.submitHostResponse({ + taskId: task.taskId, + requestId: "missing-context-1", + actionType: "ask_user", + confirmed: true, + response: "已确认项目定位,请继续生成。", + userData: { projectId: "project-1", positioning: "Founder IP" }, + }); + fake.emit( + hostResponse(latestBridgeRequest(fake), { + ok: true, + value: { + taskId: task.taskId, + requestId: "missing-context-1", + status: "submitted", + submittedAt: "2026-05-16T00:00:04.000Z", + } satisfies AgentAppTaskHostResponseResult, + }), + ); + await expect(hostResponsePromise).resolves.toMatchObject({ + requestId: "missing-context-1", + status: "submitted", + }); + + const storagePromise = lime.storage.set({ + key: "projects/project-1/content-batches/batch-1", + value: { count: 20, status: "ready" }, + }); + fake.emit( + hostResponse(latestBridgeRequest(fake), { + ok: true, + value: { + appId: "content-factory-app", + key: "projects/project-1/content-batches/batch-1", + value: { count: 20, status: "ready" }, + updatedAt: "2026-05-16T00:00:05.000Z", + provenance: { + sourceKind: "agent_app", + appId: "content-factory-app", + appVersion: "1.0.0", + packageHash: provenance.packageHash, + manifestHash: provenance.manifestHash, + entryKey: "dashboard", + }, + } satisfies AgentAppStorageEntry, + }), + ); + await expect(storagePromise).resolves.toMatchObject({ + appId: "content-factory-app", + key: "projects/project-1/content-batches/batch-1", + }); + + const artifactPromise = lime.artifacts.create({ + kind: "content_batch", + title: "项目内容批次", + content: { taskId: task.taskId, count: 20 }, + }); + fake.emit( + hostResponse(latestBridgeRequest(fake), { + ok: true, + value: { + id: "artifact-content-1", + appId: "content-factory-app", + entryKey: "dashboard", + kind: "content_batch", + title: "项目内容批次", + content: { taskId: task.taskId, count: 20 }, + createdAt: "2026-05-16T00:00:06.000Z", + provenance: { + sourceKind: "agent_app", + appId: "content-factory-app", + appVersion: "1.0.0", + packageHash: provenance.packageHash, + manifestHash: provenance.manifestHash, + entryKey: "dashboard", + }, + } satisfies AgentAppArtifactRecord, + }), + ); + await expect(artifactPromise).resolves.toMatchObject({ + id: "artifact-content-1", + kind: "content_batch", + }); + + const evidencePromise = lime.evidence.record({ + kind: "fact_grounding", + message: "内容批次已完成事实依据检查。", + refs: [task.taskId, "artifact-content-1"], + }); + fake.emit( + hostResponse(latestBridgeRequest(fake), { + ok: true, + value: { + id: "evidence-content-1", + appId: "content-factory-app", + entryKey: "dashboard", + kind: "fact_grounding", + message: "内容批次已完成事实依据检查。", + refs: [task.taskId, "artifact-content-1"], + createdAt: "2026-05-16T00:00:07.000Z", + provenance: { + sourceKind: "agent_app", + appId: "content-factory-app", + appVersion: "1.0.0", + packageHash: provenance.packageHash, + manifestHash: provenance.manifestHash, + entryKey: "dashboard", + }, + } satisfies AgentAppEvidenceRecord, + }), + ); + await expect(evidencePromise).resolves.toMatchObject({ + id: "evidence-content-1", + kind: "fact_grounding", + }); + + expect(bridgeCallNames(fake)).toEqual([ + "lime.agent.startTask", + "lime.agent.streamTask", + "lime.agent.getTask", + "lime.agent.submitHostResponse", + "lime.storage.set", + "lime.artifacts.create", + "lime.evidence.record", + ]); + for (const post of fake.posts) { + expect(post.targetOrigin).toBe("https://lime.host"); + expect(post.message.payload).toMatchObject({ provenance }); + } + expect(invoker.pendingRequestCount).toBe(0); + invoker.dispose(); + }); +}); diff --git a/src/features/agent-app/sdk/hostBridgeClient.ts b/src/features/agent-app/sdk/hostBridgeClient.ts new file mode 100644 index 000000000..00e16225c --- /dev/null +++ b/src/features/agent-app/sdk/hostBridgeClient.ts @@ -0,0 +1,691 @@ +import { + type LimeCapabilityInvoker, + type LimeCapabilityInvokeRequest, + type LimeCapabilityInvokeResponse, + type LimeCapabilityMethod, + type LimeCapabilityName, + type LimeTypedCapabilityInvokeRequest, + type LimeTypedCapabilityInvokeResponse, +} from "./capabilityContract"; +import { + toLimeCapabilityError, + type LimeCapabilityError, +} from "./capabilityErrors"; + +export const LIME_AGENT_APP_BRIDGE_PROTOCOL = "lime.agentApp.bridge"; +export const LIME_AGENT_APP_BRIDGE_VERSION = 1; + +export interface LimeAgentAppBridgeClientMessage { + protocol: typeof LIME_AGENT_APP_BRIDGE_PROTOCOL; + version: typeof LIME_AGENT_APP_BRIDGE_VERSION; + type: string; + requestId?: string; + appId: string; + entryKey?: string; + payload?: unknown; +} + +interface LimeHostBridgeMessageEvent { + data: unknown; + origin: string; + source: unknown; +} + +interface LimeHostBridgeWindowLike { + readonly parent: { + postMessage(message: LimeAgentAppBridgeClientMessage, targetOrigin: string): void; + }; + readonly self?: unknown; + addEventListener( + type: "message", + listener: (event: LimeHostBridgeMessageEvent) => void, + ): void; + removeEventListener( + type: "message", + listener: (event: LimeHostBridgeMessageEvent) => void, + ): void; + setTimeout(handler: () => void, timeoutMs: number): number; + clearTimeout(timerId: number): void; +} + +export interface CreateLimeHostBridgeCapabilityInvokerOptions { + appId: string; + entryKey?: string; + windowRef?: LimeHostBridgeWindowLike; + targetOrigin?: string; + trustedHostOrigin?: string; + requestTimeoutMs?: number; + requestIdPrefix?: string; +} + +export interface LimeHostBridgeCapabilityInvoker extends LimeCapabilityInvoker { + sendReady(): void; + getHostSnapshot(): Promise>; + notifyHost( + payload: LimeHostBridgeNotifyPayload, + ): Promise>; + navigateHost( + payload: LimeHostBridgeNavigatePayload, + ): Promise>; + openExternalHost( + payload: LimeHostBridgeOpenExternalPayload, + ): Promise>; + downloadHost( + payload: LimeHostBridgeDownloadPayload, + ): Promise>; + onHostSnapshot(handler: LimeHostBridgeEventHandler): () => void; + onThemeUpdate(handler: LimeHostBridgeEventHandler): () => void; + onVisibilityChange(handler: LimeHostBridgeEventHandler): () => void; + subscribeCapability( + request: LimeHostBridgeCapabilitySubscribeRequest, + handler: LimeHostBridgeCapabilityEventHandler, + ): Promise>; + unsubscribeCapability( + subscriptionId: string, + ): Promise>; + dispose(): void; + readonly pendingRequestCount: number; +} + +export interface LimeHostBridgeNotifyPayload { + message: string; + level?: "info" | "success" | "warning" | "error"; +} + +export interface LimeHostBridgeDownloadPayload { + url: string; + fileName?: string; +} + +export interface LimeHostBridgeNavigatePayload { + route?: string; + url?: string; +} + +export interface LimeHostBridgeOpenExternalPayload { + url: string; +} + +export type LimeHostBridgeEventHandler = (payload: unknown) => void; + +export interface LimeHostBridgeCapabilitySubscribeRequest { + capability: LimeCapabilityName; + topic: string; + input?: unknown; + subscriptionId?: string; + pollIntervalMs?: number; + bridgeAction?: string; +} + +export interface LimeHostBridgeCapabilitySubscription { + subscriptionId: string; + capability: LimeCapabilityName; + topic: string; + taskId?: string; + pollIntervalMs?: number; + bridgeAction?: string; +} + +export interface LimeHostBridgeCapabilityUnsubscribeResult { + subscriptionId: string; + unsubscribed: boolean; +} + +export interface LimeHostBridgeCapabilityEvent { + subscriptionId?: string; + capability?: string; + topic?: string; + eventType?: string; + taskId?: string; + task?: unknown; + events?: unknown[]; + snapshot?: unknown; + error?: unknown; + emittedAt?: string; +} + +export type LimeHostBridgeCapabilityEventHandler = ( + event: LimeHostBridgeCapabilityEvent, +) => void; + +interface PendingBridgeRequest { + request: LimeCapabilityInvokeRequest; + resolve: (response: LimeCapabilityInvokeResponse) => void; + timerId: number; +} + +const DEFAULT_REQUEST_TIMEOUT_MS = 15_000; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function attachOptional>( + target: T, + values: Record, +): T { + Object.entries(values).forEach(([key, value]) => { + if (value !== undefined) { + (target as Record)[key] = value; + } + }); + return target; +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function isBridgeMessage( + value: unknown, +): value is LimeAgentAppBridgeClientMessage { + return ( + isRecord(value) && + value.protocol === LIME_AGENT_APP_BRIDGE_PROTOCOL && + value.version === LIME_AGENT_APP_BRIDGE_VERSION && + typeof value.type === "string" && + typeof value.appId === "string" && + (value.requestId === undefined || typeof value.requestId === "string") && + (value.entryKey === undefined || typeof value.entryKey === "string") + ); +} + +function normalizeHostResponsePayload( + payload: unknown, + context: { + appId: string; + entryKey?: string; + request: LimeCapabilityInvokeRequest; + }, +): LimeCapabilityInvokeResponse { + if (isRecord(payload) && payload.ok === false) { + return { + ok: false, + error: toLimeCapabilityError(payload.error ?? payload, { + appId: context.appId, + entryKey: context.entryKey, + capability: context.request.capability, + method: context.request.method, + requestId: context.request.requestId, + }), + }; + } + + if (isRecord(payload) && payload.ok === true) { + const value = Object.prototype.hasOwnProperty.call(payload, "value") + ? payload.value + : Object.prototype.hasOwnProperty.call(payload, "result") + ? payload.result + : undefined; + return attachOptional>( + { + ok: true, + value, + }, + { + traceId: readString(payload.traceId), + evidenceId: readString(payload.evidenceId), + }, + ) as LimeCapabilityInvokeResponse; + } + + if (isRecord(payload) && Object.prototype.hasOwnProperty.call(payload, "result")) { + return { + ok: true, + value: payload.result, + }; + } + + return { + ok: true, + value: payload, + }; +} + +function buildBridgePayload( + request: LimeCapabilityInvokeRequest, +): Record { + return attachOptional>( + { + capability: request.capability, + method: request.method, + }, + { + input: request.args, + idempotencyKey: request.idempotencyKey, + expectedSchema: request.expectedSchema, + provenance: request.provenance, + }, + ); +} + +class BrowserLimeHostBridgeCapabilityInvoker + implements LimeHostBridgeCapabilityInvoker +{ + private readonly appId: string; + private readonly entryKey?: string; + private readonly windowRef?: LimeHostBridgeWindowLike; + private readonly targetOrigin: string; + private readonly trustedHostOrigin?: string; + private readonly requestTimeoutMs: number; + private readonly requestIdPrefix: string; + private readonly pendingRequests = new Map(); + private readonly subscriptionHandlers = new Map< + string, + LimeHostBridgeCapabilityEventHandler + >(); + private readonly snapshotHandlers = new Set(); + private readonly themeHandlers = new Set(); + private readonly visibilityHandlers = new Set(); + private requestSequence = 0; + private disposed = false; + + constructor(options: CreateLimeHostBridgeCapabilityInvokerOptions) { + this.appId = options.appId; + this.entryKey = options.entryKey; + this.windowRef = + options.windowRef ?? + (typeof window === "undefined" + ? undefined + : (window as unknown as LimeHostBridgeWindowLike)); + this.targetOrigin = options.targetOrigin ?? options.trustedHostOrigin ?? "*"; + this.trustedHostOrigin = options.trustedHostOrigin; + this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + this.requestIdPrefix = options.requestIdPrefix ?? "lime-capability"; + this.windowRef?.addEventListener("message", this.handleHostMessage); + } + + get pendingRequestCount(): number { + return this.pendingRequests.size; + } + + async call< + Capability extends LimeCapabilityName, + Method extends LimeCapabilityMethod, + >( + request: LimeTypedCapabilityInvokeRequest, + ): Promise> { + const normalizedRequest = { + ...request, + requestId: request.requestId ?? this.nextRequestId(request), + } as LimeCapabilityInvokeRequest; + return this.requestBridgeAction( + "capability:invoke", + buildBridgePayload(normalizedRequest), + normalizedRequest, + ) as Promise>; + } + + sendReady(): void { + this.postBridgeMessage("app:ready"); + } + + getHostSnapshot(): Promise> { + const request = this.buildHostActionContext("lime.ui", "getSnapshot"); + return this.requestBridgeAction("host:getSnapshot", undefined, request); + } + + notifyHost( + payload: LimeHostBridgeNotifyPayload, + ): Promise> { + const request = this.buildHostActionContext("lime.ui", "toast"); + return this.requestBridgeAction( + "host:toast", + payload, + request, + ) as Promise>; + } + + navigateHost( + payload: LimeHostBridgeNavigatePayload, + ): Promise> { + const request = this.buildHostActionContext("lime.ui", "navigate"); + return this.requestBridgeAction( + "host:navigate", + payload, + request, + ) as Promise>; + } + + openExternalHost( + payload: LimeHostBridgeOpenExternalPayload, + ): Promise> { + const request = this.buildHostActionContext("lime.ui", "openExternal"); + return this.requestBridgeAction( + "host:openExternal", + payload, + request, + ) as Promise>; + } + + downloadHost( + payload: LimeHostBridgeDownloadPayload, + ): Promise> { + const request = this.buildHostActionContext("lime.ui", "download"); + return this.requestBridgeAction( + "host:download", + payload, + request, + ) as Promise>; + } + + onHostSnapshot(handler: LimeHostBridgeEventHandler): () => void { + this.snapshotHandlers.add(handler); + return () => { + this.snapshotHandlers.delete(handler); + }; + } + + onThemeUpdate(handler: LimeHostBridgeEventHandler): () => void { + this.themeHandlers.add(handler); + return () => { + this.themeHandlers.delete(handler); + }; + } + + onVisibilityChange(handler: LimeHostBridgeEventHandler): () => void { + this.visibilityHandlers.add(handler); + return () => { + this.visibilityHandlers.delete(handler); + }; + } + + async subscribeCapability( + request: LimeHostBridgeCapabilitySubscribeRequest, + handler: LimeHostBridgeCapabilityEventHandler, + ): Promise> { + const context = { + capability: request.capability, + method: "subscribe", + requestId: this.nextRequestId({ + capability: request.capability, + method: "subscribe", + } as LimeCapabilityInvokeRequest), + } as LimeCapabilityInvokeRequest; + const response = await this.requestBridgeAction( + "capability:subscribe", + attachOptional>( + { + capability: request.capability, + topic: request.topic, + }, + { + input: request.input, + subscriptionId: request.subscriptionId, + pollIntervalMs: request.pollIntervalMs, + bridgeAction: request.bridgeAction, + }, + ), + context, + ); + if (response.ok && isRecord(response.value)) { + const subscriptionId = readString(response.value.subscriptionId); + if (subscriptionId) { + this.subscriptionHandlers.set(subscriptionId, handler); + } + } + return response as LimeCapabilityInvokeResponse; + } + + async unsubscribeCapability( + subscriptionId: string, + ): Promise> { + const context = { + capability: "lime.agent", + method: "unsubscribe", + requestId: this.nextRequestId({ + capability: "lime.agent", + method: "unsubscribe", + } as LimeCapabilityInvokeRequest), + } as LimeCapabilityInvokeRequest; + const response = await this.requestBridgeAction( + "capability:unsubscribe", + { subscriptionId }, + context, + ); + if (response.ok) { + this.subscriptionHandlers.delete(subscriptionId); + } + return response as LimeCapabilityInvokeResponse; + } + + private requestBridgeAction( + type: + | "capability:invoke" + | "capability:subscribe" + | "capability:unsubscribe" + | "host:getSnapshot" + | "host:toast" + | "host:navigate" + | "host:openExternal" + | "host:download", + payload: unknown, + request: LimeCapabilityInvokeRequest, + ): Promise { + if (this.disposed || !this.windowRef || this.windowRef.parent === this.windowRef.self) { + return Promise.resolve({ + ok: false, + error: this.buildError( + "CAPABILITY_BLOCKED", + "Lime host bridge is not connected.", + request, + ), + }); + } + + return new Promise((resolve) => { + const timerId = this.windowRef!.setTimeout(() => { + this.pendingRequests.delete(request.requestId!); + resolve({ + ok: false, + error: this.buildError( + "TIMEOUT", + "Lime host bridge request timed out.", + request, + ), + }); + }, this.requestTimeoutMs); + this.pendingRequests.set(request.requestId!, { + request, + resolve, + timerId, + }); + this.postBridgeMessage(type, payload, request.requestId); + }); + } + + dispose(): void { + if (this.disposed) { + return; + } + this.disposed = true; + this.windowRef?.removeEventListener("message", this.handleHostMessage); + for (const pending of this.pendingRequests.values()) { + this.windowRef?.clearTimeout(pending.timerId); + } + this.pendingRequests.clear(); + this.subscriptionHandlers.clear(); + this.snapshotHandlers.clear(); + this.themeHandlers.clear(); + this.visibilityHandlers.clear(); + } + + private readonly handleHostMessage = (event: LimeHostBridgeMessageEvent) => { + if (event.source !== this.windowRef?.parent) { + return; + } + if (this.trustedHostOrigin && event.origin !== this.trustedHostOrigin) { + return; + } + if (!isBridgeMessage(event.data)) { + return; + } + if (event.data.appId !== this.appId) { + return; + } + if (this.entryKey && event.data.entryKey && event.data.entryKey !== this.entryKey) { + return; + } + if (event.data.type === "host:snapshot") { + this.dispatchHostEvent(this.snapshotHandlers, event.data.payload); + if (event.data.requestId) { + this.settlePendingResponse(event.data.requestId, event.data.payload); + } + return; + } + if (event.data.type === "theme:update") { + this.dispatchHostEvent(this.themeHandlers, event.data.payload); + return; + } + if (event.data.type === "host:visibility") { + this.dispatchHostEvent(this.visibilityHandlers, event.data.payload); + return; + } + if (event.data.type === "capability:event") { + this.dispatchCapabilityEvent(event.data.payload); + return; + } + if (event.data.type !== "host:response" && event.data.type !== "host:error") { + return; + } + const requestId = event.data.requestId; + if (!requestId) { + return; + } + const pending = this.pendingRequests.get(requestId); + if (!pending) { + return; + } + this.windowRef?.clearTimeout(pending.timerId); + this.pendingRequests.delete(requestId); + if (event.data.type === "host:error") { + pending.resolve({ + ok: false, + error: toLimeCapabilityError(event.data.payload, { + appId: this.appId, + entryKey: this.entryKey, + capability: pending.request.capability, + method: pending.request.method, + requestId, + }), + }); + return; + } + pending.resolve( + normalizeHostResponsePayload(event.data.payload, { + appId: this.appId, + entryKey: this.entryKey, + request: pending.request, + }), + ); + }; + + private settlePendingResponse(requestId: string, payload: unknown): void { + const pending = this.pendingRequests.get(requestId); + if (!pending) { + return; + } + this.windowRef?.clearTimeout(pending.timerId); + this.pendingRequests.delete(requestId); + pending.resolve( + normalizeHostResponsePayload(payload, { + appId: this.appId, + entryKey: this.entryKey, + request: pending.request, + }), + ); + } + + private dispatchHostEvent( + handlers: Set, + payload: unknown, + ): void { + for (const handler of handlers) { + handler(payload); + } + } + + private dispatchCapabilityEvent(payload: unknown): void { + if (!isRecord(payload)) { + return; + } + const subscriptionId = readString(payload.subscriptionId); + if (!subscriptionId) { + return; + } + const handler = this.subscriptionHandlers.get(subscriptionId); + if (!handler) { + return; + } + handler(payload); + } + + private postBridgeMessage( + type: string, + payload?: unknown, + requestId?: string, + ): void { + if (this.disposed || !this.windowRef || this.windowRef.parent === this.windowRef.self) { + return; + } + this.windowRef.parent.postMessage( + this.buildMessage(type, payload, requestId), + this.targetOrigin, + ); + } + + private buildMessage( + type: string, + payload?: unknown, + requestId?: string, + ): LimeAgentAppBridgeClientMessage { + return { + protocol: LIME_AGENT_APP_BRIDGE_PROTOCOL, + version: LIME_AGENT_APP_BRIDGE_VERSION, + type, + requestId, + appId: this.appId, + entryKey: this.entryKey, + payload, + }; + } + + private buildHostActionContext( + capability: LimeCapabilityName, + method: string, + ): LimeCapabilityInvokeRequest { + return { + capability, + method, + requestId: this.nextRequestId({ capability, method } as LimeCapabilityInvokeRequest), + } as LimeCapabilityInvokeRequest; + } + + private nextRequestId(request: LimeCapabilityInvokeRequest): string { + this.requestSequence += 1; + return `${this.requestIdPrefix}-${this.requestSequence}-${request.capability}:${request.method}`; + } + + private buildError( + code: string, + message: string, + request: LimeCapabilityInvokeRequest, + ): LimeCapabilityError { + return toLimeCapabilityError({ + code, + message, + }, { + appId: this.appId, + entryKey: this.entryKey, + capability: request.capability, + method: request.method, + requestId: request.requestId, + }); + } +} + +export function createLimeHostBridgeCapabilityInvoker( + options: CreateLimeHostBridgeCapabilityInvokerOptions, +): LimeHostBridgeCapabilityInvoker { + return new BrowserLimeHostBridgeCapabilityInvoker(options); +} diff --git a/src/features/agent-app/sdk/index.ts b/src/features/agent-app/sdk/index.ts new file mode 100644 index 000000000..1f529f71b --- /dev/null +++ b/src/features/agent-app/sdk/index.ts @@ -0,0 +1,106 @@ +export { + AgentAppCapabilityError, + LIME_CAPABILITY_ERROR_CODES, + isLimeCapabilityErrorCode, + normalizeLimeCapabilityErrorCode, + toLimeCapabilityError, +} from "./capabilityErrors"; +export type { + AgentAppCapabilityErrorInit, + LimeCapabilityError, + LimeCapabilityErrorCode, + LimeCapabilityErrorContext, +} from "./capabilityErrors"; +export { + LIME_CAPABILITY_NAMES, + buildLimeCapabilityInvokeProvenance, + buildLimeCapabilityInvokeRequest, + createLimeCapabilityErrorResponse, + createLimeCapabilityInvoker, + createLimeCapabilitySuccessResponse, + createMockLimeCapabilityTransport, +} from "./capabilityContract"; +export type { + BuildLimeCapabilityInvokeRequestParams, + LimeCapabilityArgs, + LimeCapabilityContractMap, + LimeCapabilityInvokeProvenance, + LimeCapabilityInvokeRequest, + LimeCapabilityInvokeResponse, + LimeCapabilityInvoker, + LimeCapabilityMethod, + LimeCapabilityMockHandler, + LimeCapabilityMockHandlers, + LimeCapabilityName, + LimeCapabilityTransport, + LimeCapabilityValue, + LimeTypedCapabilityInvokeRequest, + LimeTypedCapabilityInvokeResponse, +} from "./capabilityContract"; +export { + LimeCapabilityAdapterError, + createLimeCoreCapabilityAdapters, +} from "./capabilityAdapters"; +export type { + CreateLimeCoreCapabilityAdaptersOptions, + LimeAgentCapabilityAdapter, + LimeArtifactsCapabilityAdapter, + LimeCapabilityAdapterCallOptions, + LimeCoreCapabilityAdapters, + LimeEvidenceCapabilityAdapter, + LimeKnowledgeCapabilityAdapter, + LimeStorageCapabilityAdapter, + LimeToolsCapabilityAdapter, + LimeUiCapabilityAdapter, +} from "./capabilityAdapters"; +export { + LIME_AGENT_APP_BRIDGE_PROTOCOL, + LIME_AGENT_APP_BRIDGE_VERSION, + createLimeHostBridgeCapabilityInvoker, +} from "./hostBridgeClient"; +export type { + CreateLimeHostBridgeCapabilityInvokerOptions, + LimeAgentAppBridgeClientMessage, + LimeHostBridgeCapabilityEvent, + LimeHostBridgeCapabilityEventHandler, + LimeHostBridgeCapabilityInvoker, + LimeHostBridgeCapabilitySubscribeRequest, + LimeHostBridgeCapabilitySubscription, + LimeHostBridgeCapabilityUnsubscribeResult, + LimeHostBridgeDownloadPayload, + LimeHostBridgeEventHandler, + LimeHostBridgeNavigatePayload, + LimeHostBridgeNotifyPayload, + LimeHostBridgeOpenExternalPayload, +} from "./hostBridgeClient"; +export { MockCapabilityHost } from "./MockCapabilityHost"; +export type { + CapabilityHost, + LimeAgentCapability, + LimeAppSdk, + LimeArtifactsCapability, + LimeEvidenceCapability, + LimeKnowledgeCapability, + LimeStorageCapability, +} from "./CapabilityHost"; +export { buildAgentAppProvenance } from "./provenance"; +export { matchesAgentAppProvenanceQuery } from "./provenanceQuery"; +export type { + AgentAppArtifactRecord, + AgentAppEvidenceRecord, + AgentAppKnowledgeRecord, + AgentAppKnowledgeSearchResult, + AgentAppProvenance, + AgentAppProvenanceQuery, + AgentAppStorageEntry, + AgentAppTaskEventType, + AgentAppTaskHostResponseActionType, + AgentAppTaskHostResponseRequest, + AgentAppTaskHostResponseResult, + AgentAppTaskKnowledgeBinding, + AgentAppTaskRecord, + AgentAppTaskRequest, + AgentAppTaskStatus, + AgentAppTaskStreamEvent, + AgentAppTaskTraceEvent, +} from "../types"; diff --git a/src/features/agent-app/sdk/publicSdkSurface.test.ts b/src/features/agent-app/sdk/publicSdkSurface.test.ts new file mode 100644 index 000000000..b479412c0 --- /dev/null +++ b/src/features/agent-app/sdk/publicSdkSurface.test.ts @@ -0,0 +1,57 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +import { describe, expect, it } from "vitest"; + +import { + LIME_AGENT_APP_BRIDGE_PROTOCOL, + LIME_AGENT_APP_BRIDGE_VERSION, + LIME_CAPABILITY_NAMES, + MockCapabilityHost, + createLimeCoreCapabilityAdapters, + createLimeHostBridgeCapabilityInvoker, + createMockLimeCapabilityTransport, + isLimeCapabilityErrorCode, + normalizeLimeCapabilityErrorCode, +} from "./index"; +import * as publicSdkSurface from "./index"; + +const publicSdkSource = readFileSync( + path.resolve(process.cwd(), "src/features/agent-app/sdk/index.ts"), + "utf8", +); + +describe("agent app SDK public surface", () => { + it("只导出 App package 需要的 SDK facade 和 Host Bridge client", () => { + expect(LIME_AGENT_APP_BRIDGE_PROTOCOL).toBe("lime.agentApp.bridge"); + expect(LIME_AGENT_APP_BRIDGE_VERSION).toBe(1); + expect(LIME_CAPABILITY_NAMES).toContain("lime.agent"); + expect(isLimeCapabilityErrorCode("permission_denied")).toBe(true); + expect(normalizeLimeCapabilityErrorCode("missing")).toBe("upstream_failed"); + expect(typeof createLimeCoreCapabilityAdapters).toBe("function"); + expect(typeof createLimeHostBridgeCapabilityInvoker).toBe("function"); + expect(typeof createMockLimeCapabilityTransport).toBe("function"); + expect(typeof MockCapabilityHost).toBe("function"); + }); + + it("不导出客户端 UI、安装器或 runtime host 内部实现", () => { + expect(publicSdkSurface).not.toHaveProperty("AgentAppsPage"); + expect(publicSdkSurface).not.toHaveProperty("AgentAppRuntimePage"); + expect(publicSdkSurface).not.toHaveProperty("buildInstalledAppPreview"); + expect(publicSdkSurface).not.toHaveProperty( + "LocalInstalledAgentAppStateRepository", + ); + expect(publicSdkSurface).not.toHaveProperty("AgentRuntimeCapabilityHost"); + expect(publicSdkSurface).not.toHaveProperty("WorkflowRuntimeHost"); + expect(publicSdkSurface).not.toHaveProperty("createAgentAppCapabilityDispatcher"); + }); + + it("源码层不从 UI、安装、运行时或 adapter 内部层转导出", () => { + expect(publicSdkSource).not.toMatch(/from "\.\.\/ui\//); + expect(publicSdkSource).not.toMatch(/from "\.\.\/install\//); + expect(publicSdkSource).not.toMatch(/from "\.\.\/runtime\//); + expect(publicSdkSource).not.toMatch(/from "\.\.\/adapters\//); + expect(publicSdkSource).not.toMatch(/from "\.\.\/schema\//); + }); +}); diff --git a/src/features/agent-app/types.ts b/src/features/agent-app/types.ts index 79c3f7d2e..225fafa94 100644 --- a/src/features/agent-app/types.ts +++ b/src/features/agent-app/types.ts @@ -129,6 +129,7 @@ export interface SkillRefDeclaration { export interface ToolRefDeclaration { key: string; provider?: string; + capabilities?: string[]; required?: boolean; } @@ -558,6 +559,7 @@ export interface SkillRequirementProjection { export interface ToolRequirementProjection { key: string; provider?: string; + capabilities: string[]; required: boolean; } @@ -894,6 +896,7 @@ export type AgentAppTaskEventType = | "task:cancelled" | "task:completed" | "task:incident" + | "artifact:created" | "evidence:recorded" | "evidence:verified"; @@ -920,6 +923,11 @@ export interface AgentAppTaskRequest { prompt?: string; taskKind?: string; idempotencyKey?: string; + queueIfBusy?: boolean; + skipPreSubmitResume?: boolean; + runStartHooks?: boolean; + providerPreference?: string; + modelPreference?: string; input?: unknown; expectedOutput?: unknown; knowledge?: AgentAppTaskKnowledgeBinding[]; diff --git a/src/features/agent-app/ui/AgentAppRuntimePage.test.tsx b/src/features/agent-app/ui/AgentAppRuntimePage.test.tsx index aac1684d0..6a8dd310e 100644 --- a/src/features/agent-app/ui/AgentAppRuntimePage.test.tsx +++ b/src/features/agent-app/ui/AgentAppRuntimePage.test.tsx @@ -430,7 +430,8 @@ describe("AgentAppRuntimePage", () => { expect.objectContaining({ type: "host:response", requestId: "req-1", - payload: { + payload: expect.objectContaining({ + ok: true, result: expect.objectContaining({ taskId: "agent-app-task-1", traceId: "agent-app-trace-1", @@ -438,7 +439,7 @@ describe("AgentAppRuntimePage", () => { taskKind: "content.scenario_planning", humanReview: true, }), - }, + }), }), "http://127.0.0.1:4199", ); @@ -550,14 +551,15 @@ describe("AgentAppRuntimePage", () => { expect.objectContaining({ type: "host:response", requestId: "task-start", - payload: { + payload: expect.objectContaining({ + ok: true, result: expect.objectContaining({ taskId: "agent-app-task-1", traceId: "agent-app-trace-1", status: "running", humanReview: true, }), - }, + }), }), "http://127.0.0.1:4199", ); @@ -565,11 +567,12 @@ describe("AgentAppRuntimePage", () => { expect.objectContaining({ type: "host:response", requestId: "task-stream", - payload: { + payload: expect.objectContaining({ + ok: true, result: [ expect.objectContaining({ type: "task:progress", status: "running" }), ], - }, + }), }), "http://127.0.0.1:4199", ); @@ -577,14 +580,15 @@ describe("AgentAppRuntimePage", () => { expect.objectContaining({ type: "host:response", requestId: "task-get", - payload: { + payload: expect.objectContaining({ + ok: true, result: expect.objectContaining({ taskId: "agent-app-task-1", traceId: "agent-app-trace-1", status: "running", taskKind: "content.scenario_planning", }), - }, + }), }), "http://127.0.0.1:4199", ); @@ -592,14 +596,15 @@ describe("AgentAppRuntimePage", () => { expect.objectContaining({ type: "host:response", requestId: "task-host-response", - payload: { + payload: expect.objectContaining({ + ok: true, result: { taskId: "agent-app-task-1", requestId: "runtime-request-1", status: "submitted", submittedAt: expect.any(String), }, - }, + }), }), "http://127.0.0.1:4199", ); @@ -607,7 +612,8 @@ describe("AgentAppRuntimePage", () => { expect.objectContaining({ type: "host:response", requestId: "task-cancel", - payload: { + payload: expect.objectContaining({ + ok: true, result: expect.objectContaining({ taskId: "agent-app-task-1", status: "cancelled", @@ -615,7 +621,7 @@ describe("AgentAppRuntimePage", () => { expect.objectContaining({ type: "task:cancelled" }), ]), }), - }, + }), }), "http://127.0.0.1:4199", ); @@ -623,7 +629,8 @@ describe("AgentAppRuntimePage", () => { expect.objectContaining({ type: "host:response", requestId: "task-retry", - payload: { + payload: expect.objectContaining({ + ok: true, result: expect.objectContaining({ taskId: "agent-app-task-2", traceId: "agent-app-trace-2", @@ -632,7 +639,7 @@ describe("AgentAppRuntimePage", () => { status: "running", humanReview: true, }), - }, + }), }), "http://127.0.0.1:4199", ); @@ -730,12 +737,13 @@ describe("AgentAppRuntimePage", () => { expect.objectContaining({ type: "host:response", requestId: "artifact-ok", - payload: { + payload: expect.objectContaining({ + ok: true, result: expect.objectContaining({ kind: "content_table", title: "内容表", }), - }, + }), }), "http://127.0.0.1:4199", ); @@ -743,12 +751,13 @@ describe("AgentAppRuntimePage", () => { expect.objectContaining({ type: "host:response", requestId: "evidence-ok", - payload: { + payload: expect.objectContaining({ + ok: true, result: expect.objectContaining({ kind: "fact_grounding", refs: ["adapter-artifact-1"], }), - }, + }), }), "http://127.0.0.1:4199", ); @@ -757,7 +766,15 @@ describe("AgentAppRuntimePage", () => { type: "host:error", requestId: "artifact-blocked", payload: expect.objectContaining({ - code: "WRITEBACK_NOT_DECLARED", + ok: false, + code: "upstream_failed", + causeCode: "WRITEBACK_NOT_DECLARED", + error: expect.objectContaining({ + code: "upstream_failed", + causeCode: "WRITEBACK_NOT_DECLARED", + capability: "lime.artifacts", + method: "create", + }), }), }), "http://127.0.0.1:4199", @@ -767,7 +784,15 @@ describe("AgentAppRuntimePage", () => { type: "host:error", requestId: "evidence-blocked", payload: expect.objectContaining({ - code: "WRITEBACK_NOT_DECLARED", + ok: false, + code: "upstream_failed", + causeCode: "WRITEBACK_NOT_DECLARED", + error: expect.objectContaining({ + code: "upstream_failed", + causeCode: "WRITEBACK_NOT_DECLARED", + capability: "lime.evidence", + method: "record", + }), }), }), "http://127.0.0.1:4199", diff --git a/src/features/agent-app/ui/AgentAppsPage.tsx b/src/features/agent-app/ui/AgentAppsPage.tsx index 6e72b0079..e1293ec16 100644 --- a/src/features/agent-app/ui/AgentAppsPage.tsx +++ b/src/features/agent-app/ui/AgentAppsPage.tsx @@ -549,7 +549,10 @@ export function AgentAppsPage({ ]); return ( -
+
diff --git a/src/lib/api/agentAppRuntime.ts b/src/lib/api/agentAppRuntime.ts index 5ddff8461..34f042595 100644 --- a/src/lib/api/agentAppRuntime.ts +++ b/src/lib/api/agentAppRuntime.ts @@ -26,6 +26,8 @@ export interface AgentAppRuntimeStartTaskRequest { humanReview?: boolean; eventName?: string; turnId?: string; + providerPreference?: string; + modelPreference?: string; queueIfBusy?: boolean; skipPreSubmitResume?: boolean; runStartHooks?: boolean; @@ -76,6 +78,7 @@ export interface AgentAppRuntimeTaskEvent { requestId?: string; toolName?: string; evidenceRef?: string; + artifactRef?: string; occurredAt?: string; payload?: unknown; } diff --git a/src/lib/api/agentRuntime/sessionIdentity.test.ts b/src/lib/api/agentRuntime/sessionIdentity.test.ts index 56396def0..4e805852e 100644 --- a/src/lib/api/agentRuntime/sessionIdentity.test.ts +++ b/src/lib/api/agentRuntime/sessionIdentity.test.ts @@ -10,6 +10,14 @@ describe("agentRuntime sessionIdentity", () => { ).toBe(true); }); + it("应把 Agent App Runtime 会话识别为非用户对话", () => { + expect( + isAuxiliaryAgentSessionId( + "agent-app-runtime-9d21d8bd-3f3d-4d2b-8c8d-9b8a2e4b6b49", + ), + ).toBe(true); + }); + it("普通用户会话不应被隐藏", () => { expect(isAuxiliaryAgentSessionId("session-visible")).toBe(false); }); diff --git a/src/lib/api/agentRuntime/sessionIdentity.ts b/src/lib/api/agentRuntime/sessionIdentity.ts index cf595dd32..5d216ed8c 100644 --- a/src/lib/api/agentRuntime/sessionIdentity.ts +++ b/src/lib/api/agentRuntime/sessionIdentity.ts @@ -4,6 +4,7 @@ const NON_USER_FACING_AGENT_SESSION_ID_PREFIXES = [ "knowledge-builder-session-", "__lime_theme_context_search__-", "persisted-usage-", + "agent-app-runtime-", ]; export function isAuxiliaryAgentSessionId(sessionId?: string | null): boolean {