Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f5ea6eb4a | |||
| ca9e8b9791 | |||
| fec04a90d7 | |||
| e91d07b389 | |||
| 94817f798c | |||
| b80af9679d | |||
| 44b6a83a30 | |||
| b81a0edcca | |||
| aabf58a289 | |||
| c7c18168b6 | |||
| c4613ce14e | |||
| 180d03896f | |||
| 20e63dc0dd | |||
| 75529cdd49 | |||
| 0b7257f3d3 | |||
| 6fa350267a | |||
| 309dd6161d | |||
| 823e71a162 | |||
| c77a0cf69b | |||
| 58e77462ad | |||
| a68b7cf3bf | |||
| 7cac422a31 | |||
| 121ee6ac73 | |||
| d2a2fd0aff | |||
| 486280bec0 | |||
| 9fb8cd1598 | |||
| d8aa087bc8 | |||
| 4fc83e5b13 | |||
| ccff57b2a1 | |||
| 9fd321c41c | |||
| ffda402861 |
+593
@@ -0,0 +1,593 @@
|
||||
# LineUp App 架构设计
|
||||
|
||||
**状态:** 当前权威设计基线
|
||||
**更新日期:** 2026-08-04
|
||||
**适用实现:** Tauri 2 Desktop Host 与同代码 Web Reference Host
|
||||
**本文取代:** `DESIGN.md`、`M4_COMPATIBILITY_AND_RELEASE.md`、`TECHNOLOGY_SELECTION.md`
|
||||
|
||||
## 1. 设计结论
|
||||
|
||||
**LineUp 是用户安装和使用的主应用;LineUp Runtime 是 LineUp App 内部提供给小程序的核心
|
||||
运行环境。** Runtime 不是与小程序平级的产品 App,也不是一个独立客户端。
|
||||
|
||||
```text
|
||||
LineUp App
|
||||
│
|
||||
├── App Shell / Host
|
||||
│ ├── 当前前台小程序的挂载与切换
|
||||
│ ├── 连接状态、通知和安全恢复入口
|
||||
│ └── Tauri / Web Host Provider
|
||||
│
|
||||
├── LineUp Runtime
|
||||
│ ├── AppServer / Remote Agent 通信
|
||||
│ ├── Conversation、Store、sync loop、outbox
|
||||
│ ├── MiniApp Registry、Instance、Focus、Lifecycle
|
||||
│ ├── Tool Router、Inbox、Inventory
|
||||
│ ├── Surface、Capability、Artifact、Audit
|
||||
│ └── LineUp MiniApp SDK
|
||||
│
|
||||
└── MiniApps
|
||||
├── Interact(系统内建:IM / Audio / Video)
|
||||
├── Task Dashboard(参考 / 已安装小程序)
|
||||
├── Whiteboard(参考 / 已安装小程序)
|
||||
├── Draw-and-Guess(参考 / 已安装小程序)
|
||||
└── 未来第三方小程序
|
||||
```
|
||||
|
||||
任何 MiniApp 都必须通过 Runtime SDK 使用通信、Tool、生命周期、Surface 和系统能力;不得
|
||||
建立独立 Agent 通信链路,或直接访问 Host 特权、Store、认证信息及其他 MiniApp 数据。
|
||||
|
||||
## 2. 产品边界与术语
|
||||
|
||||
| 术语 | 含义 | 不能做什么 |
|
||||
|---|---|---|
|
||||
| **LineUp App** | 用户实际使用的主应用、产品容器和交付单位。 | 不能把业务规则重新堆进 Shell。 |
|
||||
| **App Shell / Host** | Tauri 或 Web 中的挂载、切换、通知、恢复和受限 Host Provider。 | 不解释 Agent 协议,不决定 Tool/App 路由。 |
|
||||
| **LineUp Runtime** | App 内部的长期协调环境;唯一拥有通信、可靠性、MiniApp 调度与安全决策。 | 不负责任意 MiniApp 的具体 DOM 或业务 UX。 |
|
||||
| **MiniApp** | 运行在 Runtime 上的功能单元。 | 不直连 Agent/AppServer,不直写 Runtime 状态。 |
|
||||
| **System MiniApp** | 随 LineUp 发布、代码可信的内建 MiniApp,如 Interact。 | 仍不能绕过 Runtime 调度或 Capability Policy。 |
|
||||
| **Installed MiniApp** | 经验证后在受限 Surface 中运行的小程序。 | 不访问 Host DOM、Tauri invoke、token、任意网络或其他 MiniApp。 |
|
||||
| **MiniApp SDK** | Runtime 向 MiniApp 开放的受控能力集合。 | 不是通用 Web、Tauri 或 Agent SDK。 |
|
||||
| **Tool** | Agent 通过 Runtime 调用的、Manifest 已声明的 MiniApp 功能。 | 不是 App 内部任意函数。 |
|
||||
| **Capability** | Runtime/Host 保管的录音、文件、通知等系统能力。 | 不是 MiniApp 自动拥有的权限。 |
|
||||
| **Surface** | 复杂交互使用的受限 UI 容器。 | 不承载未验证远端脚本或 Host 特权。 |
|
||||
|
||||
### 2.1 Interact MiniApp
|
||||
|
||||
`Interact` 是第一个系统级 MiniApp,也是用户与 Agent 的默认交互入口:
|
||||
|
||||
```text
|
||||
Interact MiniApp
|
||||
├── IM Mode:文字、图片、短消息、任务和结果投影
|
||||
├── Audio Mode:实时语音(后续)
|
||||
├── Video Mode:实时视频(后续)
|
||||
└── IM 标准交互的默认 Renderer Provider:notice / choice / confirm / input
|
||||
```
|
||||
|
||||
当前实现仍保留兼容名称:
|
||||
|
||||
```text
|
||||
产品概念:Interact MiniApp
|
||||
当前 app_scope:chat
|
||||
当前目录:tauri/src/core-apps/chat/
|
||||
```
|
||||
|
||||
目录和作用域改名属于独立迁移任务,不能和 Runtime/MiniApp SDK 重构混在一起。
|
||||
|
||||
Interact 是可信内建代码,因此可以使用 LineUp 的可信 DOM 组件;但它和所有 MiniApp 一样,
|
||||
必须经 SDK 请求 Runtime 行为,不能直接访问 Transport、Conversation Store、焦点栈、App
|
||||
Registry、原始 Agent Envelope 或系统能力。
|
||||
|
||||
Interact 的 SDK 采用“通用 Runtime 操作 + 可信 UI 投影”的适配方式:Runtime 操作负责消息发送、
|
||||
标准交互提交/取消/过期、草稿和任务操作;UI 投影只负责 IM 视图、Agent 消息订阅和子会话展示。
|
||||
UI 投影不是另一套通信或存储协议,Interact 的可信 DOM 也不因此取得 Transport、Store、原始
|
||||
Agent Envelope、Host DOM 根节点或系统能力。当前 `chat` 兼容接口保留扁平方法,但新代码应按
|
||||
`sdk.runtime.*` 和 `sdk.ui.*` 使用。
|
||||
|
||||
## 3. 当前技术与 Host 基线
|
||||
|
||||
唯一客户端实现与验收基线是:
|
||||
|
||||
```text
|
||||
Tauri 2 Desktop Host + Web Reference Host
|
||||
```
|
||||
|
||||
它们运行同一份 TypeScript Runtime 与 MiniApp 代码,并不是两个客户端:
|
||||
|
||||
- **Web Reference Host**:浏览器开发、Tailscale 联调和自动化验证入口;只能提供可安全降级
|
||||
的浏览器能力。
|
||||
- **Tauri Desktop Host**:正式桌面交付;只在 Runtime 授权后提供文件、通知、媒体等 Host
|
||||
Provider 能力。
|
||||
|
||||
不规划独立 Android/Kotlin 客户端,也不规划 Wails Host。已退役的 Android/Wails Spike 只保留
|
||||
在 Git 历史中,不能作为架构、协议、目录或验收依据。
|
||||
|
||||
开发命令:
|
||||
|
||||
```bash
|
||||
cd lineup-app/tauri
|
||||
|
||||
npm run web:dev # Web / Tailscale Reference Host
|
||||
npm run desktop:dev # Tauri Desktop Host
|
||||
npm test -- --run # Runtime、MiniApp 与 Host 回归
|
||||
npm run build # TypeScript 检查与 Web production build
|
||||
```
|
||||
|
||||
Web Host 与 AppServer 的地址必须按访问位置匹配:同机浏览器使用 `127.0.0.1`,Tailscale 浏览器
|
||||
使用受信任的 Tailscale 地址。`0.0.0.0` 仅是服务器监听地址,不能作为浏览器 API 目标。
|
||||
|
||||
## 4. Runtime 的唯一所有权
|
||||
|
||||
Runtime 是以下对象的唯一所有者:
|
||||
|
||||
```text
|
||||
Transport
|
||||
Conversation Store
|
||||
sync loop
|
||||
outbox
|
||||
协议兼容与原始 Envelope 校验
|
||||
作用域筛选与 message_id 去重
|
||||
MiniApp Inbox 与 ACK
|
||||
MiniApp Registry、Instance、Focus、Lifecycle
|
||||
Tool Router、Inventory、Capability、Surface、审计与恢复
|
||||
```
|
||||
|
||||
当前消息路径必须保持单向:
|
||||
|
||||
```text
|
||||
AppServer / Remote Agent
|
||||
→ LineUp Runtime
|
||||
→ 协议、scope、权限、去重和持久化
|
||||
→ MiniApp SDK Inbox / Tool Call
|
||||
→ MiniApp UX
|
||||
→ SDK Result / Progress / Lifecycle Request
|
||||
→ Runtime 校验、审计、outbox
|
||||
→ AppServer / Remote Agent
|
||||
```
|
||||
|
||||
禁止项:
|
||||
|
||||
```text
|
||||
MiniApp 直接 fetch AppServer 或 Agent
|
||||
MiniApp 直接读写 Conversation Store、sync cursor 或 outbox
|
||||
MiniApp 构造或发送原始 Agent Envelope
|
||||
MiniApp 直接切换其他 MiniApp、改写 focus stack 或 Registry
|
||||
MiniApp 直接调用 Tauri invoke、Host DOM 根节点或系统能力
|
||||
Surface 从消息 payload 直接加载 URL、HTML、CSS、JS、Bundle 或 source
|
||||
```
|
||||
|
||||
## 5. MiniApp 信任模型与 SDK 权限视图
|
||||
|
||||
所有 MiniApp 共享 Runtime 的协议语义,但按来源获得不同的 SDK 视图。
|
||||
|
||||
| 能力 | Interact / System MiniApp | Installed MiniApp |
|
||||
|---|---:|---:|
|
||||
| 接收自身 Inbox、ACK | 可以 | 可以 |
|
||||
| 接收 Runtime Tool Call | 可以 | 可以 |
|
||||
| 上报 progress / result / error | 可以 | 可以 |
|
||||
| 请求前台、后台、关闭 | 可以,由 Runtime 决定 | 可以,由 Runtime 决定 |
|
||||
| 请求 Surface | 可以,经 Runtime Policy | 可以,经 Runtime Policy |
|
||||
| 请求 Capability | 可以,经 Runtime Policy | 可以,经 Runtime Policy |
|
||||
| 可信内建 DOM 组件 | 可以 | 不可以 |
|
||||
| 隔离 iframe / Surface Bridge | 可选 | 必须 |
|
||||
| 直连 Transport、Store、Agent | 不可以 | 不可以 |
|
||||
| 直接 Host / Tauri / OS 调用 | 不可以 | 不可以 |
|
||||
| 读取其他 MiniApp 数据 | 不可以 | 不可以 |
|
||||
|
||||
系统内建不等于绕过 Runtime:可信来源只决定代码的发布与 UI 执行方式,不改变 Runtime 的
|
||||
通信、焦点、Tool、权限和审计边界。
|
||||
|
||||
## 6. MiniApp 实例、焦点与恢复
|
||||
|
||||
MiniApp Registry 管理“有哪些小程序”,Instance Manager 管理“哪些实例正在运行”。二者不能
|
||||
混为一个状态。
|
||||
|
||||
```ts
|
||||
type MiniAppInstanceRecord = {
|
||||
instance_id: string;
|
||||
app_scope: string;
|
||||
conversation_id?: string;
|
||||
state:
|
||||
| "starting"
|
||||
| "foreground"
|
||||
| "background"
|
||||
| "suspended"
|
||||
| "stopping"
|
||||
| "stopped"
|
||||
| "failed";
|
||||
parent_instance_id?: string;
|
||||
started_at: string;
|
||||
stopped_at?: string;
|
||||
error?: string;
|
||||
};
|
||||
```
|
||||
|
||||
Runtime 保存焦点栈,不以单个 MiniApp 自己的布尔状态作为事实来源:
|
||||
|
||||
```text
|
||||
focus stack:
|
||||
chat:audio-001
|
||||
draw-and-guess:game-001
|
||||
|
||||
foreground:
|
||||
draw-and-guess:game-001
|
||||
|
||||
background:
|
||||
chat:audio-001
|
||||
```
|
||||
|
||||
当扩展 MiniApp 关闭或失败时,Runtime 必须恢复焦点栈中的前一有效实例。重启后,Runtime
|
||||
恢复有界的实例与焦点状态;`starting` 或 `stopping` 中断的实例必须安全降级为 `suspended`,
|
||||
不得伪装为已完成的 Host 操作。
|
||||
|
||||
## 7. MiniApp SDK v1
|
||||
|
||||
下一阶段的核心交付是 **LineUp MiniApp SDK v1**。SDK 不是万能 API,而是 Runtime 根据
|
||||
Manifest、实例状态、权限和 Policy 开放的最小请求集合。
|
||||
|
||||
```ts
|
||||
interface LineUpMiniAppSDK {
|
||||
readonly context: MiniAppContext;
|
||||
|
||||
inbox: MiniAppInboxAPI;
|
||||
tools: MiniAppToolAPI;
|
||||
lifecycle: MiniAppLifecycleAPI;
|
||||
surfaces: MiniAppSurfaceAPI;
|
||||
capabilities: MiniAppCapabilityRequestAPI;
|
||||
}
|
||||
|
||||
type MiniAppContext = {
|
||||
app_scope: string;
|
||||
instance_id: string;
|
||||
conversation_id: string;
|
||||
app_version: string;
|
||||
state: "starting" | "foreground" | "background" | "suspended";
|
||||
};
|
||||
```
|
||||
|
||||
### 7.1 Inbox
|
||||
|
||||
```ts
|
||||
interface MiniAppInboxAPI {
|
||||
list(after_message_id?: string): readonly MiniAppMessage[];
|
||||
subscribe(listener: (message: MiniAppMessage) => void): Unsubscribe;
|
||||
acknowledge(message_id: string): boolean;
|
||||
}
|
||||
```
|
||||
|
||||
Runtime 在投递前校验 Envelope、scope、目标 MiniApp、实例状态和去重;消息先持久化,再由
|
||||
MiniApp ACK。后台化、刷新、Surface 重载或 Runtime 重启不能丢失未处理消息。
|
||||
|
||||
### 7.2 Tool
|
||||
|
||||
```ts
|
||||
interface MiniAppToolAPI {
|
||||
subscribe(listener: (call: MiniAppToolCall) => void): Unsubscribe;
|
||||
get(call_id: string): MiniAppToolCall | undefined;
|
||||
reportProgress(call_id: string, progress: MiniAppToolProgress): Promise<CommandReceipt>;
|
||||
complete(call_id: string, result: JsonObject): Promise<CommandReceipt>;
|
||||
fail(call_id: string, failure: MiniAppToolFailure): Promise<CommandReceipt>;
|
||||
cancel(call_id: string, reason?: string): Promise<CommandReceipt>;
|
||||
}
|
||||
```
|
||||
|
||||
`complete`、`fail`、`cancel` 都是对 Runtime 的请求,不是直接网络发送。Runtime 必须验证
|
||||
`call_id` 的实例归属、合法状态迁移、输入/输出 schema、幂等性和权限,然后持久化、审计并
|
||||
写入 outbox。
|
||||
|
||||
一旦 Runtime 接受某个 App instance 的关闭,关闭表示释放该 instance,不是把它留在后台:Runtime 停止
|
||||
向它投递新普通 Tool,将仍为 `received / routing / waiting_for_app / running` 的绑定 Tool 原子收敛为
|
||||
`cancelled(app_closed)`,每条 Tool 只写一条 cancelled outbox;已 `submitted` 的结果不可改写,继续可靠发出。
|
||||
然后才关闭 Surface、停止 instance、结束 App 子会话并恢复焦点。普通 Tool 的完成或失败本身不关闭 App。
|
||||
标准交互是例外:App 关闭只通知 Agent,不自动取消仍 pending 的人与 Agent 交互。
|
||||
|
||||
### 7.3 人与 Agent 标准交互、Lifecycle、Surface 与 Capability
|
||||
|
||||
`notice`、`choice`、`confirm`、`input` 是 Interact 的人与 Agent 交互组件,与 IM 消息同属于
|
||||
当前会话;它们不是 MiniApp SDK,也不用于 MiniApp 自己的业务表单、确认或输入。Agent 通过
|
||||
标准 Tool 交互请求发起它们,Runtime 校验、持久化、恢复、审计并可靠回传结果,Interact 负责呈现。
|
||||
|
||||
这四种是普通的人与 Agent 会话交互,不在 SDK v1 中统一定义为密码或秘密输入。已提交内容遵循普通 IM
|
||||
的会话历史、保留和日志基线;尤其不能因为它们是普通输入,就突破全局“日志不记录会话正文、Tool 参数、
|
||||
token 等内容”的约束。未提交 `input` 草稿仅保留在当前运行期,重启清空。密码、卡密、私钥、临时 token
|
||||
等真正秘密输入留待未来作为独立的 `password-input` 原语设计;届时 Runtime 必须按类型强制其展示、记录、
|
||||
传递和清理规则,Agent 不能用普通 `input` 绕过这些保护。
|
||||
|
||||
每条人与 Agent 的标准交互都必须在 Interact / IM 中留下相应的卡片、气泡或结果记录。`notice` 是只读
|
||||
IM 卡片;当前界面可同时短暂 Toast 提醒,但 Toast 只是同一条记录的辅助呈现,不能作为唯一消息或用户
|
||||
已阅读的证明。Runtime 成功持久化 notice 卡片并交给 Interact 呈现后,即可向 Agent 返回 `accepted`。
|
||||
|
||||
MVP 中,一个 LineUp Runtime 只连接一个 Agent,当前登录会话的 `agent_uid` 作为每条交互和
|
||||
App 子会话的 `agent_id`。此处保存 Agent 身份是为了让答案按创建时的上下文回到正确对象;本阶段
|
||||
不实现多 Agent 连接、切换、会话列表、outbox 或跨 Agent 路由。
|
||||
|
||||
Agent 发 interactive Tool 时,未带经过 Runtime 验证的 `app_session_context.app_session_id`,一律归入主 IM;
|
||||
Runtime 绝不能按当前前台 App 猜测归属。若带有该上下文,Runtime 只接受同一 Agent、同一父会话的真实
|
||||
App 子会话;已经关闭的子会话可保留为历史上下文,但不复活旧 App。Runtime 创建子会话时会可靠告知 Agent
|
||||
其稳定 ID,非法或跨会话的引用直接拒绝且不创建交互。
|
||||
|
||||
当 Interact 位于前台时,标准交互显示在 IM 时间线/卡片中;当 bundled MiniApp 位于前台时,
|
||||
Interact / Shell 可以在当前 App 之上显示同一会话的交互层。视觉位置不改变归属:请求和结果始终
|
||||
属于当前 conversation、Agent call 与 Interact instance,结果经 Runtime 回传 Agent,而不交给
|
||||
前台 MiniApp。
|
||||
|
||||
用户作答时,Interact 只把“交互 ID + 用户动作/答案”交给 Runtime,不直接把答案发送给 Agent。Runtime
|
||||
从创建时保存的记录取得 Agent、主会话、App 子会话和 Tool 的归属,并核对当前 LineUp / Interact 会话、
|
||||
有效期、答案格式和是否已结束。只有第一次有效回答可以写入会话历史和可靠 outbox;后续重复或重放提交
|
||||
不得改变结果,也不得再次通知 Agent。展示位置不是交互的 owner 或提交权限:App 前后台切换、关闭或从
|
||||
覆盖层改在 IM 显示,都不改变交互归属。客户端不能提交或改写 Agent、会话、Tool 或 App 子会话的归属字段。
|
||||
|
||||
用户回答、Agent remote dismiss、到期和 Runtime 失败都只能竞争同一条交互的唯一终态。Runtime 以第一个
|
||||
成功完成的原子状态写入为准,并同时持久化唯一 Tool 结果/outbox;后来到达的动作不得覆盖结果或再次通知
|
||||
Agent。MVP 中 interactive Tool 的等待期限与交互的 `expires_at` 相同,Agent 主动停止等待必须走 Runtime
|
||||
私有的 `interaction.dismiss(control_id, call_id)`:Runtime 从受认证 Envelope 推导 Agent 和会话,以 `call_id`
|
||||
定位交互。重放或目标已终态只返回稳定幂等回执,绝不再写 outbox;该控制契约不属于 MiniApp SDK。
|
||||
|
||||
启动一个 App instance 会在主 IM 会话中创建或恢复与之关联的 App 子会话,并可靠向当前 Agent 发送
|
||||
`app_session.opened`,提供 `agent_id`、`conversation_id`、`app_session_id`、`app_scope` 与 `instance_id`。
|
||||
主 IM 将其显示为可展开的
|
||||
折叠组,记录 Agent 的提问、用户回答和 Agent 的简洁结果;它不记录 App 内部业务操作,例如用户在
|
||||
Task Dashboard 点击按钮或填写 App 自己的表单、在 Whiteboard 绘制和编辑内容。App 进入后台时,
|
||||
子会话仍继续;Agent Tool 完成也不自动结束子会话。只有 Runtime 的 `AppLifecycleManager.close` 使
|
||||
instance 进入 stopped 或 failed、并从 focus stack 移除时,子会话才结束但保留为主 IM 的历史。新的
|
||||
App instance 必须创建新的子会话,不接续旧实例记录。
|
||||
|
||||
已结束子会话可在主 IM 中只读展开,但不会重新启动 App、重新执行操作或复活未回答的问题。用户选择
|
||||
“继续处理”旧工作时,Runtime 启动新的 App instance 和新的子会话;新子会话可以引用旧会话、artifact
|
||||
或已保存的 App 状态作为上下文,但不能向旧子会话追加记录。
|
||||
|
||||
若 App 子会话关联等待回答的 Agent 问题,App 位于前台时 Interact 可在其上方显示提问层;用户切换到
|
||||
其他 App 或普通 IM 时,该层收起但问题继续等待,主 IM 的对应折叠组标记“等待你的回答”。用户既可
|
||||
展开该组直接回答,也可回到原 App 后回答。Shell 可避免用户看到重复的视觉卡片,但展示位置不改变交互
|
||||
归属或提交资格;Runtime 通过首次有效回答和终态检查防止重复结果。真正关闭 App 会按普通 Tool 的
|
||||
`app_closed` 规则先收敛未终态 Tool,随后移除该 App 上方的展示层并结束 App 子会话。Runtime 将关闭事件
|
||||
(含仍 pending 的 interaction call_id)可靠通知 Agent;问题本身仍属于 Interact / 主 IM,直到 Agent 远程
|
||||
dismiss、用户回答、到期或 Runtime 失败才结束。
|
||||
|
||||
因此 bundled MiniApp 不能发起、读取、提交或取消这类 Agent 交互,也不因其显示在自身上方而获得
|
||||
Host DOM 或会话数据。复杂、高频或私有业务 UI(例如 Task Dashboard 的“新建任务”表单、Whiteboard
|
||||
画布文字编辑、画笔、颜色选择、拖放和工具栏)必须留在 MiniApp 自己的 Surface 内。系统权限确认
|
||||
(文件、麦克风、通知等)则属于 Runtime/Host Capability Gateway,而不是普通 `confirm`。
|
||||
|
||||
```ts
|
||||
interface MiniAppLifecycleAPI {
|
||||
requestForeground(): Promise<CommandReceipt>;
|
||||
requestBackground(): Promise<CommandReceipt>;
|
||||
requestClose(reason?: string): Promise<CommandReceipt>;
|
||||
}
|
||||
```
|
||||
|
||||
MiniApp 只能请求生命周期变化;Runtime 决定是否允许。若接受关闭,则按上面的 `app_closed` 收敛普通 Tool、
|
||||
处理 Surface 并恢复焦点;若用户只想暂时离开,应请求后台化。
|
||||
|
||||
Surface 与 Capability 也只能请求:
|
||||
|
||||
```text
|
||||
MiniApp requestOpenSurface / requestCapability
|
||||
→ Runtime 校验 Manifest、Policy、实例状态和用户确认要求
|
||||
→ Host Provider 执行受限操作
|
||||
→ Runtime 记录审计并返回受控结果
|
||||
```
|
||||
|
||||
不应在 SDK v1 提供 `fetch`、任意网络、任意文件系统、剪贴板、麦克风、摄像头或 Tauri
|
||||
直接调用。高风险能力以后通过 Capability Request 按需开放。
|
||||
|
||||
## 8. Tool Contract 与 Inventory
|
||||
|
||||
MiniApp 只能在 Manifest 中声明 Tool;Agent 只能调用当前 Runtime 已公布 Inventory 中的 Tool。
|
||||
|
||||
```ts
|
||||
type ToolDescriptorBase = {
|
||||
id: string; // 例如 task-dashboard.open
|
||||
version: 1;
|
||||
input_schema: JsonSchema;
|
||||
output_schema: JsonSchema;
|
||||
permissions?: readonly string[];
|
||||
};
|
||||
|
||||
type ToolDescriptor = ToolDescriptorBase & (
|
||||
| { handling: "interactive"; target?: never }
|
||||
| {
|
||||
handling: "direct" | "launch" | "foreground" | "operation";
|
||||
target: {
|
||||
app_scope: string;
|
||||
requires_foreground: boolean;
|
||||
restore_previous_focus: boolean;
|
||||
};
|
||||
timeout_ms?: number;
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
统一调度路径:
|
||||
|
||||
```text
|
||||
Agent Tool Invoke
|
||||
→ Runtime 校验 Envelope、Inventory revision、Tool Descriptor、参数、scope、App 状态和权限
|
||||
→ 创建可持久化 Tool Call / 审计记录
|
||||
→ Tool Router 判定 direct / interactive / launch / foreground / operation
|
||||
├── interactive:不使用 target、不创建业务 instance,只由 Runtime 建立 Interact 会话交互
|
||||
└── 其他 handling:投递到目标 MiniApp instance
|
||||
→ MiniApp SDK 报告 progress / result / error
|
||||
→ Runtime 校验输出 schema、持久化、更新焦点、写 outbox
|
||||
→ Agent 收到可靠结果
|
||||
```
|
||||
|
||||
`notice`、`choice`、`confirm`、`input` 是 Agent 调起的 Runtime 统一 `interactive` Tool/交互记录
|
||||
能力。Interact 提供会话语义和呈现,Runtime 持有记录与可靠结果路径;其他 MiniApp 只能作为当前
|
||||
前台界面被 Interact 交互层覆盖,不能调用、实现或取得该交互的内容与结果。
|
||||
|
||||
可回答交互的请求以相对 `expires_in_ms` 指定期限;Runtime 计算和持久化 `expires_at`,缺省 15 分钟,
|
||||
仅接受 1 分钟至 24 小时。notice 不等待回答,也不接受期限。
|
||||
|
||||
建议稳定拒绝码至少包括:
|
||||
|
||||
```text
|
||||
inventory_revision_mismatch
|
||||
tool_not_advertised
|
||||
tool_input_invalid
|
||||
tool_output_invalid
|
||||
app_disabled
|
||||
app_instance_not_found
|
||||
app_scope_mismatch
|
||||
lifecycle_denied
|
||||
capability_denied
|
||||
call_already_final
|
||||
```
|
||||
|
||||
## 9. Surface、Bundle、Capability 与发布安全
|
||||
|
||||
复杂 UI 只能运行在受限 Surface 中。Installed MiniApp 的生产 Bundle 必须满足:
|
||||
|
||||
```text
|
||||
签名 Manifest
|
||||
→ 精确 byte size 校验
|
||||
→ SHA-256 校验
|
||||
→ 原子写入已验证 cache
|
||||
→ app_id + exact version 解析
|
||||
→ sandbox="allow-scripts" 的隔离 Surface
|
||||
```
|
||||
|
||||
### 9.1 Host 兼容矩阵
|
||||
|
||||
| 情况 | Host 必须行为 | 禁止行为 | 观测信号 |
|
||||
|---|---|---|---|
|
||||
| 旧 Client 收到未知 `lineup.v1.*` | 安全 fallback,不执行 | 将 payload 当 HTML/能力执行 | `unsupported_type` / `unsupported_version` |
|
||||
| 新 Client 收到旧 plain text | 仅作为 Markdown 显示 | 赋予交互或能力 | legacy markdown renderer |
|
||||
| 不支持目标 Surface version | 不挂载,显示安全提示 | 自动前移到相邻版本 | `surface_unavailable` |
|
||||
| 收到生产 Surface | 仅在签名、长度、SHA-256 成功后挂载已验证 bytes | 接收 URL、HTML、JS、Bundle/source | install disposition |
|
||||
| 候选 Bundle 验证失败 | 保留当前 active 已验证版本 | 覆盖 active bytes 或执行候选 | `signature_invalid` 等 |
|
||||
| active Bundle 被撤销 | 原子回滚到已验证 predecessor;没有则不可用 | 回退到未验证缓存/远端 URL | `rolled_back` / `unavailable` |
|
||||
| Bridge 收到非当前 iframe 消息 | 丢弃 | 仅相信 origin 字符串或调用 Host API | bridge rejection counter |
|
||||
| Capability Catalog 变化 | 发布最小新 Inventory | 用过期 Inventory 自动执行 | catalog revision |
|
||||
|
||||
### 9.2 生产发布序列
|
||||
|
||||
1. 发布方创建不可变 artifact 与生产 Manifest:`app_id`、精确版本、artifact ID、长度、
|
||||
SHA-256、最小 Host 版本、权限、可信 `key_id`、Ed25519 签名。
|
||||
2. Manifest 与 Bundle 部署在 Host 配置的 HTTPS artifact 服务;Manifest 不携带 URL、HTML、
|
||||
CSS、JS 或 source。
|
||||
3. Host 在临时内存中验证签名、精确长度与 SHA-256;失败不写 cache。
|
||||
4. 验证成功后原子安装,并只解析精确 `app_id + version`;不得自动前移版本。
|
||||
5. 仅向满足最小 Host 版本且已启用目标版本的 Client 灰度发布。
|
||||
6. 监控签名、摘要、下载、Bridge 与 Capability 状态;安全事件发生时只回滚到已验证的
|
||||
predecessor。
|
||||
7. 回滚后保留审计元数据,但不记录 artifact 内容、URL、token、文件路径或 Capability 参数;
|
||||
新 artifact 必须使用新版本号。
|
||||
|
||||
正式 HTTPS Host 才可被标记为 production-capable。HTTP LAN 开发 Host 在浏览器 WebCrypto
|
||||
受限时必须安全拒绝验签,不能为了联调放宽该规则。
|
||||
|
||||
## 10. 当前实现状态
|
||||
|
||||
### 10.1 已完成:历史安全基础
|
||||
|
||||
历史 M0~M4 已完成并沉淀在当前代码中;它们不是新的平行排期:
|
||||
|
||||
| 历史阶段 | 保留能力 |
|
||||
|---|---|
|
||||
| M0 | HTTP Transport、Conversation Store、协议解码、Markdown、安全 fallback、基础回归 |
|
||||
| M1 | Tool Call、Task、可靠交互结果、执行摘要、可信 choice/confirm/input |
|
||||
| M2 | Surface Registry、实例生命周期、隔离 iframe 与恢复 |
|
||||
| M3 | Capability Registry、用户确认、审计与受限 Host 执行 |
|
||||
| M4 | 签名 Manifest、Bundle 校验/缓存/回滚、golden fixture |
|
||||
|
||||
### 10.2 已完成:第二次升级
|
||||
|
||||
```text
|
||||
00.base
|
||||
→ Runtime 托管 Interact IM(当前兼容名 chat)的稳定基线
|
||||
|
||||
01.kernel
|
||||
→ MiniApp Instance / Focus / Lifecycle、Tool Router、App Orchestrator、
|
||||
Extension SDK 雏形、Workspace 持久化和恢复
|
||||
```
|
||||
|
||||
当前实现已验证登录、同步、本地回显、Markdown、安全渲染、Tool Call、Task、Surface、
|
||||
Capability、App Inbox、outbox、实例焦点恢复和生产构建。自动化回归当前为 23 个测试文件、
|
||||
99 个测试;`npm run build` 通过。
|
||||
|
||||
Runtime 的关键结构位于:
|
||||
|
||||
```text
|
||||
tauri/src/runtime/app-management/
|
||||
tauri/src/runtime/coordination/
|
||||
tauri/src/runtime/persistence/
|
||||
tauri/src/runtime/protocol/
|
||||
tauri/src/runtime/surfaces/
|
||||
tauri/src/runtime/capabilities/
|
||||
tauri/src/core-apps/chat/
|
||||
```
|
||||
|
||||
### 10.3 下一阶段:03.sdk_and_coreapp
|
||||
|
||||
下一阶段不是应用市场,也不是立即建设服务端下载目录。目标见
|
||||
[03.sdk_and_coreapp.md](迭代/03.sdk_and_coreapp/03.sdk_and_coreapp.md):定义并验证 MiniApp SDK v1:
|
||||
|
||||
```text
|
||||
冻结 AppManifest、ToolDescriptor、Tool Call、App Context、Result/Progress/Error 和错误码
|
||||
→ Runtime 实现通用 SDK 请求路由、schema 校验、审计、恢复和 revisioned Inventory
|
||||
→ Interact 适配为第一个 System MiniApp SDK 样本
|
||||
→ Task Dashboard 作为第一个参考 MiniApp 验证 Tool、progress、result、focus 与恢复
|
||||
→ Whiteboard 验证受限 Surface、状态 patch、Artifact 与 Capability 请求
|
||||
```
|
||||
|
||||
参考 MiniApp 在此阶段使用 `kind = bundled`:随开发 Host 内置、有 Manifest 和 Runtime 注册记录,
|
||||
但不依赖服务端 Catalog、远程下载或第三方发布。`bundled` 不是系统级信任;参考只描述这些
|
||||
MiniApp 在当前阶段用于验证 SDK 的工作目的,不是 Manifest 类型名称。
|
||||
|
||||
### 10.4 后续阶段
|
||||
|
||||
MiniApp SDK 与参考实现稳定后,再按顺序推进:
|
||||
|
||||
```text
|
||||
04.app-delivery-registry
|
||||
→ Catalog Entry、Manifest/Bundle 下载、验签、安装、启用、禁用、更新、回滚、移除
|
||||
|
||||
05.catalog-and-market(按产品需要)
|
||||
→ 搜索、分类、发布者、安装 UX、组织分发与可能的商业能力
|
||||
```
|
||||
|
||||
`03.sdk_and_coreapp` 已经包含 Task Dashboard 和 Whiteboard 的端到端参考实现;不再另设
|
||||
`03.reference-miniapps`,以免把同一批工作拆成两个编号。
|
||||
|
||||
“市场”属于最后的产品分发层,不能反向决定 Runtime、SDK、Tool 或安全模型。
|
||||
|
||||
## 11. 验收与发布门槛
|
||||
|
||||
每次 Runtime、MiniApp SDK 或 Host 改动至少满足:
|
||||
|
||||
```text
|
||||
npm test -- --run
|
||||
npm run build
|
||||
```
|
||||
|
||||
对于 SDK/参考 MiniApp,还必须有以下证据:
|
||||
|
||||
1. 合法与非法 Manifest、Inventory revision、Tool 输入/输出 schema 的自动化测试;
|
||||
2. Tool 启动 MiniApp、前后台切换、结果回传、失败回焦和重启恢复的场景测试;接受关闭后,未终态普通 Tool
|
||||
仅产生一次 `app_closed` 取消、已 submitted 结果继续 outbox,随后才关闭 Surface / instance 并恢复焦点;
|
||||
3. Interact 的 `notice / choice / confirm / input` 会话呈现不退化,覆盖 IM 内联与前台 bundled
|
||||
MiniApp 上交互层的显示、conversation/call 绑定、结果可靠回传 Agent、默认/越界交互期限、非法
|
||||
`app_session_context` 拒绝、`interaction.dismiss` 幂等重放,以及 bundled MiniApp 无法访问或提交交互的测试;
|
||||
4. MVP 的单 Agent 身份随交互和 App 子会话持久化;不新增多 Agent 连接、切换、会话列表或路由;
|
||||
5. App instance 的子会话创建、后台/暂停保留、`AppLifecycleManager.close` 后结束折叠、重启恢复和新 instance 隔离可验证;创建时会可靠向 Agent
|
||||
提供 `app_session_id`,关闭时会发出包含仍 pending interaction call_id 的可靠 App 已关闭事件,但不会自动
|
||||
取消 Interact 交互;子会话只包含人与 Agent 的交互,不包含 MiniApp 的内部业务操作;
|
||||
6. 已结束子会话可只读查看;“继续处理”旧工作会创建新 instance / 新子会话,可引用旧上下文但不复活旧问题;
|
||||
7. App 切换时未回答问题从前台 App 上方收起并在对应子会话标记;用户可在 IM 或回到原 App 后回答,且同一问题不可重复提交;
|
||||
8. Installed MiniApp 无法取得 Host DOM、Tauri invoke、token、任意网络或其他 MiniApp 数据;
|
||||
9. 有头浏览器验证登录、同步、本地回显和代表性 MiniApp 路径;
|
||||
10. 对生产 Bundle,验证签名 `open → patch → close → rollback`、隔离 Bridge 和 Capability 拒绝路径;
|
||||
11. `git diff --check` 无格式错误,日志不得包含身份、会话、消息正文、Tool 参数、token 或
|
||||
artifact 内容。
|
||||
|
||||
## 12. 相关文档与源码导航
|
||||
|
||||
- [迭代/00.base/00.base.md](迭代/00.base/00.base.md):Runtime 托管 Interact IM 的稳定基线。
|
||||
- [迭代/01.kernel/01.kernel.md](迭代/01.kernel/01.kernel.md):Runtime Kernel 与 MiniApp 编排目标和验收。
|
||||
- [tauri/src/README.md](tauri/src/README.md):当前源码目录和依赖方向。
|
||||
- [tauri/README.md](tauri/README.md):Tauri/Web Host 的运行、回归与历史实施细节。
|
||||
- [程序文件清单与功能说明.md](程序文件清单与功能说明.md):实现文件和功能说明。
|
||||
- [LineUp App 最终设计方案](../设计/02.正式方案/app_final_design.md):跨文档详细契约来源;
|
||||
若与本文的产品术语或当前迭代顺序冲突,以本文为准并同步更新上游方案。
|
||||
@@ -1,319 +0,0 @@
|
||||
# LineUp App — Android Spike 历史设计参考
|
||||
|
||||
> 状态:**仅保留为 Android 快速实验的历史/参考设计,不是当前 App Layer 总体架构。**
|
||||
> 原版本:0.1(提案)
|
||||
> 日期:2026-07-30
|
||||
> 基础:唐僧叨叨 Android 客户端(wkbase / wklogin / SDK 封装)
|
||||
|
||||
当前正式、跨端的 LineUp App 总体架构以 [LineUp App 层架构设计方案](../设计/02.正式方案/lineup-app-layer-architecture.md) 为准:Tauri 2 是当前 Reference Host,Android 为 Spike 基线,未来 Wails 按同一 Interaction Runtime 契约对照实现。本文件中的“Android 首端”“直接使用 WuKongIM SDK”等描述只反映当时的实验条件,不能覆盖正式架构、协议或实现优先级。
|
||||
|
||||
---
|
||||
|
||||
## 1. 定位
|
||||
|
||||
LineUp App 是一个**单通道 Agent 对话客户端**。用户打开 App 后只有一个对话——和他的 AI Agent。
|
||||
|
||||
不是社交 IM(没有好友列表、没有群聊、没有朋友圈)。
|
||||
当前版本只做一件事:**连上 WuKongIM,进入唯一频道,与 Agent 收发消息。**
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ LineUp App │
|
||||
│ │
|
||||
│ ┌───────────────────────────────┐ │
|
||||
│ │ Agent 对话频道 │ │
|
||||
│ │ │ │
|
||||
│ │ [用户] 帮我查一下今天天气 │ │
|
||||
│ │ [Agent] 北京今天晴, 22°C │ │
|
||||
│ │ [Agent] ┌─ choice ─┐ │ │
|
||||
│ │ │ 需要更多? │ │ │
|
||||
│ │ │ [是] [否] │ │ │
|
||||
│ │ └───────────┘ │ │
|
||||
│ └───────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────┐ │
|
||||
│ │ 输入框 │ │
|
||||
│ └───────────────────────────────┘ │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 架构分层
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ LineUp App (Android) │
|
||||
│ │
|
||||
│ UI 层: 聊天界面 + Agent 消息渲染 │
|
||||
│ ├─ ChatView (文本/图片/语音) │
|
||||
│ └─ AgentWidgets (choice/form/canvas) │
|
||||
│ │
|
||||
│ 业务层: 频道管理 + 消息路由 │
|
||||
│ ├─ ChannelManager (只有一个频道) │
|
||||
│ └─ MessageRouter (分发到UI) │
|
||||
│ │
|
||||
│ 通讯层: WuKongIM SDK 封装 │
|
||||
│ ├─ WKClient (长连接/心跳/重连) │
|
||||
│ ├─ WKSend / WKRecv │
|
||||
│ └─ TokenManager (认证) │
|
||||
│ │
|
||||
│ 本地存储: SQLite / SharedPreferences │
|
||||
│ ├─ Token 缓存 │
|
||||
│ └─ 消息历史 (最近N条) │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.1 复用唐僧叨叨的模块
|
||||
|
||||
| 唐僧叨叨模块 | 用途 | LineUp 是否复用 |
|
||||
|---|---|---|
|
||||
| `wklogin` | 登录 / 注册 / Token 获取 | ✅ 复用 |
|
||||
| `wkbase` | 基础 UI / WebSocket 连接 / 消息模型 | ✅ 复用核心 |
|
||||
| `wkpush` | 厂商推送 | ❌ 暂不需要 |
|
||||
| `wkscan` | 扫码 | ❌ 不需要 |
|
||||
| 群聊/通讯录/朋友圈 | — | ❌ 隐藏/移除 |
|
||||
|
||||
**核心思路:保留通信管道,砍掉社交外壳。**
|
||||
|
||||
---
|
||||
|
||||
## 3. 连接流程
|
||||
|
||||
### 3.1 首次启动
|
||||
|
||||
```
|
||||
Step 1: 用户打开 App
|
||||
→ 输入手机号 → 点"获取验证码" → 输入 123456
|
||||
→ App 调 LineUp App Server: POST /login
|
||||
→ Server 调 WuKongIM /user/token 生成 Token
|
||||
→ 返回 {uid, token, im_host, im_port}
|
||||
|
||||
Step 2: App 用 Token 连接 WuKongIM WebSocket
|
||||
→ ws://100.121.118.116:15200?token=xxx
|
||||
→ WuKongIM 返回 Connack → 连接建立
|
||||
|
||||
Step 3: App 进入唯一频道
|
||||
→ 频道 ID: "agent_default" (硬编码)
|
||||
→ App 调 WuKongIM Sub 帧订阅该频道
|
||||
→ 收到 Suback → 可以收发消息
|
||||
|
||||
Step 4: 显示对话界面
|
||||
→ 同步最近 N 条消息 (channel/messagesync)
|
||||
→ 等待用户输入
|
||||
```
|
||||
|
||||
### 3.2 后续启动(Token 未过期)
|
||||
|
||||
```
|
||||
Step 1: 读取本地缓存的 Token
|
||||
Step 2: 直接连 WuKongIM WebSocket
|
||||
Step 3: 跳过登录页,直接进入对话
|
||||
```
|
||||
|
||||
### 3.3 Agent 也在同一个频道
|
||||
|
||||
```
|
||||
用户的 WuKongIM 用户: uid="user_<phone>"
|
||||
Agent 的 WuKongIM 用户: uid="agent_default"
|
||||
|
||||
频道 ID: "agent_default_channel"
|
||||
频道类型: 个人频道 (channel_type=1)
|
||||
|
||||
用户 → Send(频道) → WuKongIM → Recv → Agent
|
||||
Agent → Send(频道) → WuKongIM → Recv → App
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 消息类型
|
||||
|
||||
### 4.1 普通文本消息
|
||||
|
||||
用户发什么就送什么,Agent 回文本也直接显示。
|
||||
|
||||
### 4.2 LineUp 结构化消息(信封格式)
|
||||
|
||||
沿用设计文档定义的信封协议:
|
||||
|
||||
```json
|
||||
{
|
||||
"v": 1,
|
||||
"id": "msg_xxx",
|
||||
"type": "lineup.v1.tool.call",
|
||||
"sender": {"kind": "agent", "id": "agent_default"},
|
||||
"target": {"kind": "device", "id": "device_phone_01"},
|
||||
"payload": {}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 第一批支持的 Agent 工具消息
|
||||
|
||||
| 类型 | 触发条件 | UI 渲染 |
|
||||
|---|---|---|
|
||||
| `lineup.v1.tool.choice` | Agent 需要用户做选择 | 渲染为可点击的选项按钮 |
|
||||
| `lineup.v1.tool.confirm` | Agent 需要用户确认操作 | 渲染确认/取消按钮 |
|
||||
| `lineup.v1.tool.input` | Agent 需要用户输入值 | 渲染输入框 |
|
||||
| `lineup.v1.tool.progress` | Agent 报告进度 | 渲染进度条 |
|
||||
| `lineup.v1.event.error` | Agent 报错 | 渲染错误提示 |
|
||||
|
||||
**这些 payload 通过 WuKongIM 的 SendPacket.Payload 字段传递,App 收到后解析 type 字段决定渲染方式。**
|
||||
|
||||
### 4.4 通用 UI Surface 与上层 App 能力
|
||||
|
||||
除内置文本、Markdown、choice、confirm、progress 等稳定组件外,LineUp Client 还支持受控的 UI Surface:Agent 可发送 `lineup.v1.ui.open`,以 HTML/CSS/JS 描述一个交互界面;后续以 `ui.patch` 推送状态,以 `ui.event` 接收用户动作,以 `ui.close` 关闭实例。
|
||||
|
||||
这不是把 Agent 脚本放进 App 主进程执行。Android 端必须在隔离 WebView 中承载 Surface,禁用任意网络访问、文件访问、同源权限和未注册的 JavaScript interface。Surface 只能通过受限 bridge 上报用户事件。
|
||||
|
||||
Agent 若要调用 App 的上层能力(例如打开链接、选文件、写剪贴板、相机、定位或业务模块),必须走独立的 `lineup.v1.app.list` → `app.call` → `app.result` 协议:Client 先声明 capability,再按风险等级弹出用户确认或系统授权,绝不允许 Surface 直接越权调用。本协议详见 [LineUp UI Surface 与 App Capability 协议](../设计/02.正式方案/lineup-ui-surface-protocol.md)。
|
||||
|
||||
---
|
||||
|
||||
## 5. UI 设计
|
||||
|
||||
### 5.1 消息渲染规则
|
||||
|
||||
```
|
||||
if payload 为空:
|
||||
→ 普通文本消息,照常显示
|
||||
|
||||
if payload.type == "lineup.v1.tool.choice":
|
||||
→ 渲染为选项卡片
|
||||
→ 用户点击某个选项后:
|
||||
→ App 组装 tool.result Payload
|
||||
→ Send 回 Agent
|
||||
|
||||
if payload.type == "lineup.v1.tool.progress":
|
||||
→ 渲染为进度条卡片
|
||||
→ 被动显示,无需用户操作
|
||||
```
|
||||
|
||||
### 5.2 登录页
|
||||
|
||||
```
|
||||
┌──────────────────────┐
|
||||
│ │
|
||||
│ LineUp │
|
||||
│ 你的 AI 搭档 │
|
||||
│ │
|
||||
│ ┌──────────────────┐│
|
||||
│ │ +86 手机号 ││
|
||||
│ └──────────────────┘│
|
||||
│ ┌────────┐ ┌──────┐ │
|
||||
│ │ 验证码 │ │ 获取 │ │
|
||||
│ └────────┘ └──────┘ │
|
||||
│ │
|
||||
│ [ 登 录 ] │
|
||||
│ │
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
### 5.3 主界面
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ Agent (在线) ··· │ ← 顶部栏:Agent名称+在线状态
|
||||
├──────────────────────────────┤
|
||||
│ │
|
||||
│ [Agent] 你好,我是你的 AI │
|
||||
│ 助手,有什么可以帮 │
|
||||
│ 助你的? │
|
||||
│ │
|
||||
│ [用户] 帮我查天气 │
|
||||
│ │
|
||||
│ [Agent] ┌─ 选择城市 ────┐ │
|
||||
│ │ ○ 北京 │ │
|
||||
│ │ ○ 上海 │ │
|
||||
│ │ ● 深圳 │ │
|
||||
│ │ [确认] │ │
|
||||
│ └────────────────┘ │
|
||||
│ │
|
||||
├──────────────────────────────┤
|
||||
│ ┌────────────────────┐ 📎 │ ← 输入栏
|
||||
│ │ 输入消息... │ 📷 │
|
||||
│ └────────────────────┘ 🎤 │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 服务端交互协议
|
||||
|
||||
### 6.1 App → LineUp App Server
|
||||
|
||||
| API | 方法 | 用途 |
|
||||
|---|---|---|
|
||||
| `/login` | POST | 手机号+验证码 → {uid, token, im_addr} |
|
||||
| `/ws` | WebSocket | App 维持与 Server 的长连接(接收 Server 推送) |
|
||||
|
||||
### 6.2 App → WuKongIM (直连)
|
||||
|
||||
| 操作 | 方向 | 协议 |
|
||||
|---|---|---|
|
||||
| 连接 | App → WK | WKProto Connect (带 Token) |
|
||||
| 发消息 | App → WK | WKProto Send (频道+Payload) |
|
||||
| 收消息 | WK → App | WKProto Recv |
|
||||
| 同步消息 | App → WK | channel/messagesync |
|
||||
| 心跳 | 双向 | WKProto Ping/Pong |
|
||||
|
||||
---
|
||||
|
||||
## 7. 与唐僧叨叨 App 的差异
|
||||
|
||||
| 功能 | 唐僧叨叨 | LineUp App |
|
||||
|---|---|---|
|
||||
| 登录 | 手机号+验证码 / 第三方 | 手机号+验证码(简化版) |
|
||||
| 主界面 | 会话列表(多聊天) | **直接进入唯一 Agent 对话** |
|
||||
| 通讯录 | 好友+群组+工作台 | 无 |
|
||||
| 发现页 | 朋友圈+附近 | 无 |
|
||||
| 消息类型 | 文本/图片/语音/文件/位置 | 文本 + Agent 工具卡片 |
|
||||
| 设置 | 隐私/通知/通用 | 极简(仅退出/重置) |
|
||||
| WuKongIM 连接 | `wkbase` SDK 封装 | 复用 `wkbase` 核心 |
|
||||
| 服务端 | 唐僧叨叨业务层 | LineUp App Server |
|
||||
|
||||
---
|
||||
|
||||
## 8. 实施计划
|
||||
|
||||
### Phase 1: 最小通信通路(1-2天)
|
||||
```
|
||||
目标: 能登录、连 WuKongIM、收发文本消息
|
||||
|
||||
□ 基于唐僧叨叨 wklogin + wkbase 创建简化版 App 壳
|
||||
□ 移除所有非必要模块(群聊/通讯录/朋友圈)
|
||||
□ 登录后直连 WuKongIM WebSocket
|
||||
□ 硬编码频道 ID,进入固定对话
|
||||
□ 能发送和接收文本消息
|
||||
```
|
||||
|
||||
### Phase 2: Agent 工具消息渲染(2-3天)
|
||||
```
|
||||
目标: 能解析 LineUp 信封,渲染 choice/confirm/progress
|
||||
|
||||
□ 实现 Payload 解析器
|
||||
□ choice 卡片 → 选项按钮列表
|
||||
□ confirm 卡片 → 确认/取消按钮
|
||||
□ progress 卡片 → 进度条
|
||||
□ 用户操作后组装 tool.result 回传给 Agent
|
||||
```
|
||||
|
||||
### Phase 3: UI 美化 + 体验优化(2-3天)
|
||||
```
|
||||
目标: 不再是粗制原型,而是可用产品
|
||||
|
||||
□ 对话气泡 UI 优化
|
||||
□ Agent 在线状态指示
|
||||
□ 消息发送状态(发送中/已送达)
|
||||
□ 启动动画 / 过渡效果
|
||||
□ 错误处理(断网/超时)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 参考资料
|
||||
|
||||
- 唐僧叨叨 Android 源码: `upstream/tangsengdaodao-android/`
|
||||
- LineUp App Server 设计: `lineup-app-server/DESIGN.md`
|
||||
- WuKongIM JS SDK: `@wukongim/sdk-js` (Agent 端用)
|
||||
- LineUp App 层架构: `设计/02.正式方案/lineup-app-layer-architecture.md`
|
||||
- 消息信封与 UI Surface 定义: `设计/02.正式方案/lineup-ui-surface-protocol.md`
|
||||
@@ -1,46 +1,114 @@
|
||||
# LineUp App
|
||||
|
||||
这里是 LineUp 多端客户端的工作区。
|
||||
这里是 LineUp App 的客户端工作区;LineUp Runtime 是其中向 MiniApp 提供通信、可靠性、
|
||||
调度与安全能力的核心运行环境。
|
||||
|
||||
当前 Kotlin Android 客户端是 IM、协议、设备接入与交互闭环的快速实验基线;长期目标是在系统 WebView / WebKit 之上构建 LineUp Mini Runtime,为内置 Renderer 与受限第三方小程序 Surface 提供统一的运行环境。
|
||||
## 当前实现基线
|
||||
|
||||
## Git 与开发验证约定
|
||||
LineUp 不只是一个聊天客户端,而是用户设备上的协作 Runtime:它连接
|
||||
AppServer 与 Remote Agent,维护会话、消息、Store、outbox、App Inbox、权限与恢复;
|
||||
当前 `chat` 兼容实现是其上的第一个系统级 MiniApp:Interact 的 IM Mode。
|
||||
|
||||
客户端工作区使用独立仓库,远端为 `ssh://git@100.121.118.116:2222/lineup/app.git`。Git 用于源码版本管理、备份和协作;提交应对应完整、可验证的改动,但**不再作为 Mac 浏览器验证前端改动的传输手段**。
|
||||
当前唯一的客户端实现与验收基线是:
|
||||
|
||||
日常 Web 联调由 Linux 工作机直接提供可信 Host:
|
||||
```text
|
||||
Tauri 2 Desktop Host + Web Reference Host
|
||||
```
|
||||
|
||||
这里的 **Host(运行外壳)** 指的是把同一套 LineUp 程序放进不同运行环境的方式,
|
||||
并不表示有两套客户端或两套聊天逻辑。它们共用同一份 TypeScript
|
||||
`LineUpRuntime` 和 Chat Core App:
|
||||
|
||||
- **Tauri Desktop Host** 是正式桌面应用的外壳。它负责创建桌面窗口,并在 Runtime
|
||||
授权后提供文件、通知等操作系统能力。
|
||||
- **Web Reference Host** 是浏览器中的同代码开发和验证入口。它用于 Vite 开发、
|
||||
Tailscale 联调与自动化回归,不能替代桌面版的系统能力。
|
||||
|
||||
无论运行在哪个 Host 中,连接、同步、消息存储和 Agent 协议都只能由 Runtime 管理,
|
||||
不能各自实现一套。
|
||||
|
||||
不再规划、实现或维护独立 Android/Kotlin 客户端,也不再维护 Wails Host。旧实验设计仅在
|
||||
Git 历史中保留,不是当前产品、协议或交付路线。
|
||||
|
||||
## MVP-R1:Runtime 托管 Chat
|
||||
|
||||
当前 MVP 已验证以下边界:
|
||||
|
||||
```text
|
||||
Chat Core App
|
||||
└── ChatRuntimeSDK
|
||||
└── LineUpRuntime
|
||||
├── Transport / sync loop / outbox
|
||||
├── Conversation Store / App Inbox
|
||||
├── app_scope + conversation_id 路由
|
||||
└── lineup.v1 Compatibility Adapter
|
||||
```
|
||||
|
||||
- `LineUpRuntime` 是网络连接、消息存储、同步循环和待发送队列的唯一管理者;
|
||||
- Runtime 从本地 Core App Registry(内置应用清单)加载默认 `chat`,而不是由
|
||||
`main.ts` 直接拼出聊天页面;
|
||||
- Chat 只能通过 Runtime SDK 接收已筛选的消息和状态、恢复并确认已处理的 Inbox 消息,
|
||||
再向 Runtime 提交用户动作;
|
||||
- Runtime 拒绝非法或不匹配的 `app_scope` / `conversation_id`,并在内部兼容既有
|
||||
`lineup.v1`;
|
||||
- 登录、同步、发送、本地回显、Markdown、Agent 状态和刷新恢复保持回归通过;
|
||||
- 当前 Tauri/Web 回归为 23 个测试文件、99 个测试,`npm run build` 通过。
|
||||
|
||||
完整边界、MiniApp SDK 路线和发布安全约束见 [APP架构设计.md](APP架构设计.md)。
|
||||
|
||||
## 开发与验证
|
||||
|
||||
客户端工作区使用独立仓库,远端为 `ssh://git@100.121.118.116:2222/lineup/app.git`。
|
||||
Linux 开发机通过 Vite 启动 Web Reference Host;Mac 或其他设备可经 Tailscale 用浏览器
|
||||
直接打开它,看到的就是 Linux 工作区中正在修改的同一份前端,无须每次改动都同步或重新
|
||||
clone 源码。
|
||||
|
||||
```bash
|
||||
cd lineup-app/tauri
|
||||
npm run web:dev
|
||||
```
|
||||
|
||||
Mac 浏览器通过 Tailscale 打开 `http://100.121.118.116:1420`,即可看到 Linux 工作区的 Vite 热更新。Mac 只在首次配置、切换开发机或需要本地桌面打包时再 clone / pull 源码:
|
||||
当前开发地址:
|
||||
|
||||
```bash
|
||||
git clone ssh://git@100.121.118.116:2222/lineup/app.git ~/Workspace/lineup-app
|
||||
cd ~/Workspace/lineup-app
|
||||
git pull --ff-only origin main
|
||||
```text
|
||||
http://100.121.118.116:1420/
|
||||
```
|
||||
|
||||
`tauri/node_modules/`、`tauri/dist/` 和 `tauri/src-tauri/target/` 均为本机可再生文件,已被忽略;每台机器按自身架构重新安装和构建。
|
||||
常用命令:
|
||||
|
||||
```bash
|
||||
# Runtime / SDK / Renderer 自动化回归
|
||||
npm test -- --run
|
||||
|
||||
# TypeScript 检查与 Web production build
|
||||
npm run build
|
||||
|
||||
# Tauri 桌面 Host 开发
|
||||
npm run desktop:dev
|
||||
```
|
||||
|
||||
`tauri/node_modules/`、`tauri/dist/` 和 `tauri/src-tauri/target/` 都是可再生本机构建产物,
|
||||
已被忽略。
|
||||
|
||||
## 文档入口
|
||||
|
||||
- [DESIGN.md](DESIGN.md):Android 实验阶段的产品、连接与交互设计;
|
||||
- [TECHNOLOGY_SELECTION.md](TECHNOLOGY_SELECTION.md):已确认的客户端与 Mini Runtime 技术路线;Tauri 2 是第一优先的 Reference Host Spike,Wails v3 在同一基线后续评估;
|
||||
- [tauri/README.md](tauri/README.md):Tauri 2 Reference Host 的实现、构建状态和下一阶段范围;
|
||||
- [LineUp App 层架构设计方案](../设计/02.正式方案/lineup-app-layer-architecture.md):跨端 App Shell、Interaction Kernel、Renderer、Surface 与 Capability 的正式架构;
|
||||
- [LineUp UI Surface 与 App Capability 协议](../设计/02.正式方案/lineup-ui-surface-protocol.md):小程序 Surface 和 Agent 调 App 能力的协议定义。
|
||||
- [APP架构设计.md](APP架构设计.md):LineUp App、Runtime、MiniApp SDK、Surface/Capability 安全与后续迭代的权威架构设计;
|
||||
- [程序文件清单与功能说明.md](程序文件清单与功能说明.md):客户端 Runtime、Chat Core App、Tauri/Web Host 与测试的文件清单;
|
||||
- [tauri/src/README.md](tauri/src/README.md):源码目录、依赖方向与模块放置规则;
|
||||
- [tauri/README.md](tauri/README.md):Tauri/Web Host 的运行、构建、行为回归和历史里程碑;
|
||||
- [迭代/](迭代/00.base/00.base.md):按迭代目录记录当前基线、设计评审和后续 Runtime Kernel / 应用编排目标;
|
||||
- [LineUp App 最终设计方案](../设计/02.正式方案/app_final_design.md):当前架构的唯一汇总入口;
|
||||
- [LineUp App 层架构方案](../设计/02.正式方案/lineup-app-layer-architecture.md):M0~M4 历史实施记录与迁移基础;
|
||||
- [LineUp Runtime 与 App SDK 架构方案](../设计/02.正式方案/lineup-runtime-sdk-architecture.md):Runtime / SDK 的详细契约来源;
|
||||
- [LineUp UI Surface 与 App Capability 协议](../设计/02.正式方案/lineup-ui-surface-protocol.md):Surface sandbox 和 Capability Gateway 协议;
|
||||
|
||||
## 当前目录边界
|
||||
## 工作区目录
|
||||
|
||||
```text
|
||||
lineup-app/
|
||||
├── DESIGN.md # Android 快速实验设计
|
||||
├── TECHNOLOGY_SELECTION.md # 已确认的客户端 / Mini Runtime 技术路线
|
||||
├── tauri/ # Tauri 2 Reference Host(第一优先 Spike)
|
||||
└── README.md # 本目录说明与文档入口
|
||||
├── README.md # LineUp App 工作区入口
|
||||
├── APP架构设计.md # 当前权威架构、SDK 与发布安全设计
|
||||
└── tauri/ # Tauri Desktop + Web Reference Host
|
||||
├── src/ # Runtime、Core App 与 Host 组合入口
|
||||
└── src-tauri/ # Tauri Rust Host
|
||||
```
|
||||
|
||||
实际的唐僧叨叨 Android 上游实验代码保留在 `../upstream/tangsengdaodao-android/`,不与未来的 LineUp 多端客户端工程混放。
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
# LineUp Client 与 Mini Runtime 技术选型:原生 Android、Tauri 2 与 Wails v3
|
||||
|
||||
**版本:** 0.1(评审稿)
|
||||
|
||||
**状态:** 已确认第一阶段技术路线
|
||||
|
||||
**日期:** 2026-08-02
|
||||
**关联设计:** [LineUp App 层架构设计方案](../设计/02.正式方案/lineup-app-layer-architecture.md)、[LineUp UI Surface 与 App Capability 协议](../设计/02.正式方案/lineup-ui-surface-protocol.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. 决策问题
|
||||
|
||||
LineUp 已有可运行的 Kotlin Android 客户端,并完成 App Server、WuKongIM、Hermes Adapter 的连通。它的定位是快速验证 IM、协议、设备接入和交互闭环,不是长期多端客户端的唯一实现。
|
||||
|
||||
长期目标是在 App 层建立 LineUp 自定义的“小程序运行环境”:利用系统 WebView / WebKit 执行 HTML、CSS、JavaScript,但由 LineUp 定义小程序包、生命周期、受限桥接、事件协议、权限与审计模型。这里不实现浏览器或 JavaScript 内核;实现的是运行在系统 Web 内核之上的 **LineUp Mini Runtime**。
|
||||
|
||||
本次只讨论客户端实现载体;LineUp Envelope、Interaction Kernel、Surface 安全模型与 Capability Registry 的协议边界不因载体而改变。
|
||||
|
||||
## 2. 已确认的技术路线
|
||||
|
||||
| 选项 | 决策 | 定位 |
|
||||
|---|---|---|
|
||||
| 现有 Kotlin Android | **保留** | 稳定基线、原生能力参考实现和持续交付通道 |
|
||||
| Tauri 2 | **第一优先开展独立 Spike** | LineUp Mini Runtime 的 Reference Host、安全与能力基线 |
|
||||
| Wails v3 | **第二阶段按同一基线评估** | 利用 Go 主技术栈的长期 Host 候选 |
|
||||
|
||||
不直接重写或删除当前 Kotlin App。先通过 Tauri Spike 验证跨端 Host、小程序隔离、系统能力桥接和移动端恢复;再以完全相同的 Mini Runtime 协议与验收标准验证 Wails v3。这样可将技术选择从框架偏好转为可实证的交付能力比较。
|
||||
|
||||
### 2.1 为什么先验证 Tauri
|
||||
|
||||
Tauri 的团队、社区、桌面生态和移动端路径相对成熟,适合作为高标准的参考宿主。它先回答的不是“能否把现有 Android 聊天页换成 Web”,而是“可信 LineUp 主应用与不可信小程序 Surface 能否在多端稳定且安全地共存”。
|
||||
|
||||
Wails v3 与 Go 主技术栈更匹配,仍是重要的长期候选;但它应接受已验证的 Runtime 基线,而不是由它当前的 Beta 状态来定义平台边界。
|
||||
|
||||
## 3. 当前资产与迁移匹配度
|
||||
|
||||
Web Reference Host 已存在下列可复用资产:
|
||||
|
||||
```text
|
||||
lineup-app-server/assets/chat/
|
||||
├── interaction-kernel.js # ConversationItemFactory + RendererRegistry
|
||||
└── index.html # GFM Markdown、安全净化、Surface Host、App Call UI
|
||||
```
|
||||
|
||||
可复用的核心能力包括:
|
||||
|
||||
- `marked + DOMPurify` 的 GFM Markdown;
|
||||
- `ConversationItem` 与 Renderer Registry;
|
||||
- `lineup.v1.text`、`agent.status`、`agent.progress`、`error` renderer;
|
||||
- `lineup.v1.ui.open / patch / close / event` 的 sandbox iframe Surface Host;
|
||||
- `lineup.v1.app.call / result` 的用户确认交互;
|
||||
- WebSocket 接入形态(服务端 WSS 尚待提供;当前 Android 是 HTTP 轮询)。
|
||||
|
||||
目前 Android 使用 OkHttp、RecyclerView、Clipboard、外链 Intent、Activity 生命周期和轮询,不依赖相机、蓝牙、音视频等重量级原生 SDK。故迁移“表现层与会话 UI”的阻力较低,但系统能力、推送和后台恢复仍必须有原生实现。
|
||||
|
||||
## 4. 方案对比
|
||||
|
||||
| 维度 | 当前 Kotlin Android | Tauri 2 | Wails v3 |
|
||||
|---|---|---|---|
|
||||
| Android UI | 原生 View / RecyclerView | Web 前端 + Android WebView | Web 前端 + 容器;移动端路径需进一步证实 |
|
||||
| Markdown | 临时 regex + HtmlCompat,不完整 | 直接复用 GFM + sanitizer | 可复用 Web 实现 |
|
||||
| LineUp Surface | 当前仅原生 Task Dashboard | 可复用 iframe sandbox Host | 需要专项验证 |
|
||||
| 内置 Renderer / 标准组件 | 需逐个原生实现 | 可复用现有 Web Renderer | 可复用现有 Web Renderer |
|
||||
| 系统能力 | Kotlin 直接调用 | Tauri Plugin / Kotlin bridge | Go bridge / 原生层,需专项验证 |
|
||||
| Push、通知、后台、Deep Link | 路径最直接 | 仍需要原生 Plugin | 仍需要原生 bridge |
|
||||
| Android 成熟度 | 已在项目真机运行 | 官方 Mobile 路径,作为首个真机 Spike | 官方 README 标为 v3 Beta,需以 Tauri 结果作对照 |
|
||||
| 现有 Web 实现复用 | 低 | 高 | 高 |
|
||||
| 推荐角色 | 稳定基线 | **Reference Host / 第一优先 Spike** | **Go 主栈长期候选 / 第二阶段对照实现** |
|
||||
|
||||
### 4.1 Tauri 2:Reference Host
|
||||
|
||||
Tauri 2 是当前最适合优先验证的跨端候选。官方 v2 文档覆盖 Android、iOS 与 Mobile;它能够复用已有 Web 表现层资产,同时仍允许通过原生插件承接 Android 系统能力。Tauri 在本项目中承担的是可信 LineUp 主应用与 Mini Runtime 的第一版参考实现,而不是第三方小程序本身。
|
||||
|
||||
它替换的是会话 UI、Markdown、Renderer、标准组件和 Surface Host,并非把 Android 原生层完全移除。Kotlin/Gradle 仍承担推送、通知、Deep Link、运行时权限、文件选择、分享、后台恢复,以及必要原生 Plugin。
|
||||
|
||||
### 4.2 Wails v3:Go Host 候选
|
||||
|
||||
Wails 的 Go 技术栈与 LineUp 的服务端、协议校验、同步、Outbox、Capability Policy 和审计逻辑高度匹配,长期上有明显的维护优势。官方 GitHub README 当前将 Wails v3 标记为 `Beta`;本次核验也没有取得足够的官方 Android 生产支持证据。因此不把它作为第一轮 Runtime 安全边界的验证载体,但在 Tauri 基线明确后,应以同一协议和验收范围做正式对照实现。
|
||||
|
||||
重点评估 Wails v3 是否能以 Go Host 达到 Tauri Reference Host 的小程序隔离、资源加载、桥接、生命周期、移动端系统能力与恢复标准;若能达到,Go 主栈优势将使其成为长期主方案的强候选。
|
||||
|
||||
## 5. LineUp Mini Runtime 与 Tauri Reference Host
|
||||
|
||||
```text
|
||||
LineUp Tauri Reference Host
|
||||
├── Web Frontend
|
||||
│ ├── Chat UI / 虚拟列表
|
||||
│ ├── CommonMark / GFM Markdown
|
||||
│ ├── ConversationItem + Renderer Registry
|
||||
│ ├── 标准组件库
|
||||
│ ├── Surface Host
|
||||
│ └── WebSocket + HTTP 补偿
|
||||
├── Rust / Tauri Core
|
||||
│ ├── 安全 capability policy
|
||||
│ ├── 会话 / token 安全存储
|
||||
│ └── 原生 bridge 管理
|
||||
└── Android Kotlin 壳
|
||||
├── Push / 通知 / Deep Link
|
||||
├── 文件、权限、系统分享
|
||||
└── 必要的原生 Plugin
|
||||
```
|
||||
|
||||
Tauri 的 Web Frontend 与第三方 Surface 必须是两个不同的安全域。LineUp 自有前端可以调用受控原生能力;Agent 或第三方 Surface 只能通过经过校验的事件协议请求能力,绝不能获得 Tauri `invoke`、登录 token 或宿主 DOM 的直接访问权。
|
||||
|
||||
### 5.1 Mini Runtime 的框架无关边界
|
||||
|
||||
Mini Runtime 是可被 Tauri、Wails 或原生 Shell 实现的协议层,最小由以下模块组成:
|
||||
|
||||
```text
|
||||
Package Manager app_id / version / manifest / integrity / allowlist
|
||||
Instance Manager instance_id / open / ready / patch / close / restore
|
||||
Secure Loader 不可变 bundle / 专用 origin / CSP / 网络与跳转限制
|
||||
Event Bridge postMessage schema、大小、频率、来源与实例校验
|
||||
Mini API ui.onPatch / ui.emit / app.call / app.result
|
||||
Capability Gateway policy / 用户确认 / 平台 handler / audit
|
||||
```
|
||||
|
||||
第三方小程序只能获得 Mini API,不能直接获得 Tauri 或 Wails 的原生调用对象。内置 Renderer 与标准组件可运行于可信主应用前端;小程序 Surface 则必须运行于隔离执行域。
|
||||
|
||||
## 6. 不可退让的安全约束
|
||||
|
||||
1. Agent bundle 与第三方 Surface 一律视为不可信输入。
|
||||
2. Surface iframe 不得访问 Tauri invoke API、宿主登录态或宿主 DOM。
|
||||
3. 仅允许完成实例、命名空间、事件名和消息大小校验的 `postMessage` bridge。
|
||||
4. CSP 默认禁止未授权网络、远程脚本和危险跳转。
|
||||
5. `ui.patch` 只能更新 JSON state,不能替换已验证 bundle。
|
||||
6. 所有系统能力只允许走 `app.call → Capability Registry → 用户确认 → app.result`。
|
||||
7. Android 的推送、通知、后台恢复与权限结果必须可审计、可恢复,不能依赖一个不受控网页生命周期。
|
||||
|
||||
## 7. Tauri Reference Host Spike 范围与验收
|
||||
|
||||
Spike 单独建立目录或模块,接入同一 App Server、WuKongIM、Hermes 与测试会话;不得改写当前 Kotlin Android 的可运行路径。先以桌面验证主应用与小程序双安全域,再进入 Android 真机验证。
|
||||
|
||||
- [ ] 真机登录现有 App Server;
|
||||
- [ ] 兼容 HTTP `/messages/sync`,为服务端 WSS 留出接入点;
|
||||
- [ ] 完整 GFM Markdown 与 DOMPurify 安全净化;
|
||||
- [ ] 兼容 `text / status / progress / tool.call / tool.result`;
|
||||
- [ ] 实现 `ui.open / patch / close / event` sandbox Surface;
|
||||
- [ ] 实现 `app.open_url` 与 `clipboard.write` 的 Capability bridge;
|
||||
- [ ] PJD110 真机安装、滚动性能、断线恢复与安全隔离通过;
|
||||
- [ ] 与既有 Kotlin 客户端对照确认消息顺序、去重、用户操作回传一致。
|
||||
|
||||
Wails v3 的第二阶段 Spike 使用以上相同清单。其目标不是重定义协议,而是证明 Go Host 能够以相同小程序包和安全边界完成等价交付。
|
||||
|
||||
## 8. 本机构建级验证前置条件
|
||||
|
||||
| 条件 | 当前状态 |
|
||||
|---|---:|
|
||||
| Android SDK / Gradle / 项目内 JDK 17 | 已有 |
|
||||
| Kotlin APK 构建与真机安装 | 已验证 |
|
||||
| Rust / Cargo | 已安装;本机 Rustup proxy 有清单异常,直接工具链 bin 可用 |
|
||||
| Tauri CLI | 已作为 `lineup-app/tauri/` 的 npm 开发依赖安装 |
|
||||
| Wails CLI | 未安装 |
|
||||
| Android NDK | 未安装 |
|
||||
| Tauri / Wails 现有工程 | Tauri Reference Host 已建立于 `lineup-app/tauri/`;Wails 尚未建立 |
|
||||
|
||||
Tauri Spike 已进入实现阶段:前端生产构建、Linux debug 二进制和 Debian 包均已生成;Android 真机 Spike 仍需 Android NDK 与 Rust Android targets。当前 Kotlin Android 实验工程保持不变。
|
||||
+39
-20
@@ -1,6 +1,15 @@
|
||||
# LineUp Tauri Reference Host
|
||||
|
||||
这是 LineUp Mini Runtime 的第一版 Tauri 2 Reference Host。它独立于当前 `upstream/tangsengdaodao-android/` 快速实验工程,不替换或修改现有 Android 验证路径。
|
||||
这是同一套 LineUp Runtime 的两种运行方式:Tauri 2 Desktop Host 和 Web Reference Host。
|
||||
它们不是两个客户端,也不是两套聊天功能;两者加载相同的 Runtime 和 Chat Core App。
|
||||
|
||||
- **Tauri Desktop Host** 是正式桌面应用的外壳。它提供窗口,并在 Runtime 作出授权决定后
|
||||
调用文件、通知等系统能力。
|
||||
- **Web Reference Host** 是浏览器中的开发和验证入口。它方便通过 Vite 热更新、
|
||||
Tailscale 联调和自动化测试,但受浏览器权限限制,不能替代桌面外壳。
|
||||
|
||||
无论使用哪种方式,AppServer 通信、消息同步、Store 和 Agent 协议都由 Runtime 管理。
|
||||
当前不存在并行的独立 Android/Kotlin 或 Wails 客户端路线。
|
||||
|
||||
## 已实现的最小闭环
|
||||
|
||||
@@ -16,7 +25,7 @@ AppServer 已配置只允许 `tauri://localhost`、`https://tauri.localhost`、`
|
||||
|
||||
macOS 的 `src-tauri/Info.plist` 为当前 Tailscale / 私网 HTTP AppServer 配置了 `NSAllowsArbitraryLoadsInWebContent`。该例外仅作用于 Host 的 WKWebView 内容请求,使其能访问类似 `http://100.x.y.z:8090` 的地址,不放宽 Rust 原生网络层;待所有 AppServer 都迁移到 HTTPS 后应移除。
|
||||
|
||||
`ui.open / patch / close` 和 `app.call` 已被协议层识别,但目前安全降级为可见提示;它们是下一阶段 Mini Runtime Surface Host 与 Capability Registry 的实现范围,不能在当前 Host 页面中直接执行 Agent JavaScript 或直接授予设备能力。
|
||||
Surface、Capability、签名 Bundle 与隔离 bridge 已有 M2~M4 的实现和测试基础,但它们不等于已开放的应用市场或通用 Installed App。当前 MVP-R1 的重点是 Runtime 托管 Chat:任何 Surface 或能力请求仍必须经过 Runtime 验证、受限 bridge、Capability Policy 和用户确认,不能在 Host 页面中直接执行 Agent JavaScript 或直接授予设备能力。
|
||||
|
||||
## Milestone 0 当前行为基线
|
||||
|
||||
@@ -24,14 +33,14 @@ macOS 的 `src-tauri/Info.plist` 为当前 Tailscale / 私网 HTTP AppServer 配
|
||||
|
||||
| 范围 | 当前行为 | 当前实现位置 | 重构后必须保持 |
|
||||
|---|---|---|---|
|
||||
| 登录 | 用户输入 AppServer 地址、手机号、验证码;登录请求 10 秒超时;地址仅保存到 localStorage | `src/main.ts`、`src/runtime/transport-adapter.ts` | 成功登录进入会话;失败原因对用户可见 |
|
||||
| 登录 | 用户输入 AppServer 地址、手机号、验证码;登录请求 10 秒超时;地址仅保存到 localStorage | `src/main.ts`、`src/runtime/communication/transport-adapter.ts` | 成功登录进入会话;失败原因对用户可见 |
|
||||
| 会话 | 当前只有一个 Agent channel;退出清空页面内消息并停止同步 | `src/main.ts` | 会话状态必须可由 Store 管理,不能依赖 DOM |
|
||||
| 发送 | 用户消息先本地显示,再通过 Transport 调用 `/messages/send`;显示已发送或失败 | `src/main.ts`、`src/runtime/transport-adapter.ts` | 即时反馈、送达状态与失败可见性不退化 |
|
||||
| 历史同步 | 首次从 cursor `0` 连续拉取到空页;之后以 `lastSeq + 1` 每 1.5 秒经 Transport 增量轮询 | `src/main.ts`、`src/runtime/transport-adapter.ts` | 保持包含式 cursor 语义,不能漏消息或无限热循环 |
|
||||
| Agent 文本 | 将 `lineup.v1.text` 解码为 Markdown,使用 `marked + DOMPurify` 渲染 | `src/protocol.ts`、`src/runtime/trusted-dom-renderers.ts` | GFM、安全净化和外链防护不退化 |
|
||||
| Agent 状态 | `thinking` 显示临时指示器;其他状态更新顶栏 presence | `src/protocol.ts`、`src/runtime/trusted-dom-renderers.ts` | 状态应更新已有状态项,而非无限追加气泡 |
|
||||
| 进度和错误 | progress/error 目前以系统提示显示 | `src/protocol.ts`、`src/runtime/trusted-dom-renderers.ts` | 迁移后至少保持可见,M1 再升级为正式卡片 |
|
||||
| 未实现扩展 | `ui.open / patch / close` 与 `app.call` 仅安全降级为提示,绝不执行不可信脚本或原生能力 | `src/protocol.ts`、`src/main.ts` | 在 M2/M3 完成前始终保持拒绝执行 |
|
||||
| 发送 | 用户消息先本地显示,再通过 Transport 调用 `/messages/send`;显示已发送或失败 | `src/main.ts`、`src/runtime/communication/transport-adapter.ts` | 即时反馈、送达状态与失败可见性不退化 |
|
||||
| 历史同步 | 首次从 cursor `0` 连续拉取到空页;之后以 `lastSeq + 1` 每 1.5 秒经 Transport 增量轮询 | `src/main.ts`、`src/runtime/communication/transport-adapter.ts` | 保持包含式 cursor 语义,不能漏消息或无限热循环 |
|
||||
| Agent 文本 | 将 `lineup.v1.text` 解码为 Markdown,使用 `marked + DOMPurify` 渲染 | `src/runtime/protocol/lineup-v1.ts`、`src/core-apps/chat/trusted-dom-renderers.ts` | GFM、安全净化和外链防护不退化 |
|
||||
| Agent 状态 | `thinking` 显示临时指示器;其他状态更新顶栏 presence | `src/runtime/protocol/lineup-v1.ts`、`src/core-apps/chat/trusted-dom-renderers.ts` | 状态应更新已有状态项,而非无限追加气泡 |
|
||||
| 进度和错误 | progress/error 目前以系统提示显示 | `src/runtime/protocol/lineup-v1.ts`、`src/core-apps/chat/trusted-dom-renderers.ts` | 迁移后至少保持可见,M1 再升级为正式卡片 |
|
||||
| 未实现扩展 | `ui.open / patch / close` 与 `app.call` 仅安全降级为提示,绝不执行不可信脚本或原生能力 | `src/runtime/protocol/lineup-v1.ts`、`src/main.ts` | 在 M2/M3 完成前始终保持拒绝执行 |
|
||||
|
||||
### M0 回归场景
|
||||
|
||||
@@ -52,7 +61,7 @@ M0-01~M0-08 已完成。Mac 经 Linux Tailscale Web Host `http://100.121.118.1
|
||||
|
||||
### M1-01 Tool Call 状态机(已完成)
|
||||
|
||||
`src/protocol.ts` 现可严格解码 `lineup.v1.tool.call`、`lineup.v1.tool.result`、`lineup.v1.tool.cancel`,只接受 `choice`、`confirm`、`input` 三种声明性请求。`src/runtime/tool-call-state.ts` 是独立于 UI、Storage、Transport、Tauri 与 Capability 的纯状态机;它以 `call_id` 为唯一关联键,只允许 `pending → submitted → completed / failed / cancelled / expired`,拒绝重复调用及非法状态跃迁。Interaction Kernel 仅投影这些状态,且只有已提交的 call 能接受 result。
|
||||
`src/runtime/protocol/lineup-v1.ts` 现可严格解码 `lineup.v1.tool.call`、`lineup.v1.tool.result`、`lineup.v1.tool.cancel`,只接受 `choice`、`confirm`、`input` 三种声明性请求。`src/runtime/coordination/tool-call-state.ts` 是独立于 UI、Storage、Transport、Tauri 与 Capability 的纯状态机;它以 `call_id` 为唯一关联键,只允许 `pending → submitted → completed / failed / cancelled / expired`,拒绝重复调用及非法状态跃迁。Interaction Kernel 仅投影这些状态,且只有已提交的 call 能接受 result。
|
||||
|
||||
这不是可用的交互组件:M1-01 不渲染选项、不提供按钮/表单、不发送 tool result,也不修改 Hermes Adapter。可信 ActionGroup 留给 M1-02,Confirm/Input/Form 留给 M1-03。`npm test` 当前为 3 个文件 / 14 个测试通过,`npm run build` 通过。
|
||||
|
||||
@@ -82,11 +91,11 @@ Conversation Store v5 持久化任务快照,刷新或重新登录后仍只显
|
||||
|
||||
### 当前结构债务(M0 的改造对象)
|
||||
|
||||
`src/main.ts` 目前包含 App Shell、页面 DOM、会话 cursor、同步循环与 Runtime 编排;M0-05 已将登录、发送与同步 HTTP `fetch` 迁入 `src/runtime/transport-adapter.ts`,M0-06 已将所有可信 DOM renderer 迁入 `src/runtime/trusted-dom-renderers.ts`。在 M0 准入前不新增 Surface 或 Capability 功能。
|
||||
该段记录的是 M0 历史结构债务。当前 MVP-R1 已将 Transport、Store、sync loop 与 outbox 收敛至 `src/runtime/coordination/lineup-runtime.ts`;HTTP 实现位于 `src/runtime/communication/transport-adapter.ts`,可信 Chat DOM Renderer 位于 `src/core-apps/chat/trusted-dom-renderers.ts`。现行目录与边界见 [src/README.md](src/README.md)。
|
||||
|
||||
### M0-02 协议模型(已完成)
|
||||
|
||||
`src/protocol.ts` 是目前唯一允许把 Transport 原始 payload 转换成可信表现模型的入口。它定义了 `JsonValue`、`Actor`、严格 `Envelope`、`ConversationItem`、`UserAction`、`DeliveryState` 与 `ProtocolFallback`,并提供:
|
||||
`src/runtime/protocol/lineup-v1.ts` 是目前唯一允许把 Transport 原始 payload 转换成可信表现模型的入口。它定义了 `JsonValue`、`Actor`、严格 `Envelope`、`ConversationItem`、`UserAction`、`DeliveryState` 与 `ProtocolFallback`,并提供:
|
||||
|
||||
- `parseEnvelope(value)`:校验版本、消息类型、`id`、`conversation_id`、sender/target、timestamp 和 JSON payload;
|
||||
- `parseEnvelopeJSON(raw)`:供不接受历史纯文本的 Transport / Kernel 路径把非法 JSON 明确归为 `invalid_json`;
|
||||
@@ -96,9 +105,9 @@ Conversation Store v5 持久化任务快照,刷新或重新登录后仍只显
|
||||
|
||||
### M0-03 Interaction Kernel 与 Renderer Registry(已完成)
|
||||
|
||||
`src/runtime/interaction-kernel.ts` 是 Transport 与表现层之间的唯一入口:它将原始同步/实时 payload 解码为 `ConversationItem`,对有有效 LineUp `id` 的消息实施有界去重,并将 Agent status 投影为可读取的 presence 快照。它不执行 `ui.*`、`app.*` 或用户动作。
|
||||
`src/runtime/coordination/interaction-kernel.ts` 是 Transport 与表现层之间的唯一入口:它将原始同步/实时 payload 解码为 `ConversationItem`,对有有效 LineUp `id` 的消息实施有界去重,并将 Agent status 投影为可读取的 presence 快照。它不执行 `ui.*`、`app.*` 或用户动作。
|
||||
|
||||
`src/runtime/renderer-registry.ts` 只根据已验证的 `ConversationItem.kind` 分派已注册的可信 renderer;renderer 的输入只有表现项与 View context,不会获得 Transport 或 Capability 对象。当前聊天页已通过该 Registry 渲染同步进入的标准项。历史上 M0-03 完成时具体 DOM renderer 仍在 `main.ts`;该 M0-06 迁移现已完成,当前可信 DOM renderer 位于 `src/runtime/trusted-dom-renderers.ts`。
|
||||
`src/core-apps/chat/renderer-registry.ts` 只根据已验证的 `ConversationItem.kind` 分派已注册的可信 renderer;renderer 的输入只有表现项与 View context,不会获得 Transport 或 Capability 对象。当前聊天页已通过该 Registry 渲染同步进入的标准项。历史上 M0-03 完成时具体 DOM renderer 仍在 `main.ts`;该 M0-06 迁移现已完成,当前可信 DOM renderer 位于 `src/core-apps/chat/trusted-dom-renderers.ts`。
|
||||
|
||||
## 本地命令
|
||||
|
||||
@@ -127,7 +136,13 @@ npm run tauri build -- --debug
|
||||
|
||||
`npm run desktop:dev` 不生成 `.app`、`.dmg` 或安装包。前端 TypeScript / CSS / HTML 保存后会由 Vite 自动热更新;改动 `src-tauri/` 中的 Rust Host 代码时,Tauri 会重新编译并重启开发窗口。只有需要交付可安装文件时才执行 `tauri build`。
|
||||
|
||||
`npm run web:dev` 只运行同一份可信 Host Web UI,适合在浏览器中快速调试登录、消息和 Renderer。它监听 `0.0.0.0:1420`,Mac 通过 Tailscale 打开 `http://100.121.118.116:1420`,直接验证 Linux 工作区的 Vite 热更新;不需要为每次验证将源码同步到 Mac。它不具备 Tauri 原生能力,也不应作为第三方 Mini Runtime Surface 的安全验收环境。AppServer 仅对固定开发 Origin `http://127.0.0.1:1420` 和 `http://100.121.118.116:1420` 配置 CORS;若改动 Vite 端口或 Host 地址,必须同步收紧更新 AppServer 的 `client.allowedOrigins`,不得改为 wildcard。
|
||||
`npm run web:dev` 是在浏览器里启动同一份可信 Host UI,适合快速检查登录、消息和
|
||||
Renderer。它监听 `0.0.0.0:1420`;Mac 可经 Tailscale 打开
|
||||
`http://100.121.118.116:1420`,直接看到 Linux 工作区的 Vite 热更新,无须把源码同步到
|
||||
Mac。浏览器模式没有 Tauri 的原生能力,也不能用作第三方 Mini Runtime Surface 的安全验收
|
||||
环境。AppServer 只对固定开发 Origin `http://127.0.0.1:1420` 和
|
||||
`http://100.121.118.116:1420` 配置 CORS;若改动 Vite 端口或 Host 地址,必须同步更新并
|
||||
保持收紧 AppServer 的 `client.allowedOrigins`,不得改成允许任意来源的 wildcard。
|
||||
|
||||
本项目的 `tauri` 与 `check:rust` npm 脚本会自动识别 Linux x86_64、macOS Apple Silicon、macOS Intel 的 Rust stable toolchain,并加入 `PATH`。因此不要直接执行裸 `tauri build`;统一使用 `npm run tauri build -- --debug`。如 Rust toolchain 安装在其他位置,可临时覆盖:
|
||||
|
||||
@@ -140,7 +155,7 @@ RUST_TOOLCHAIN_BIN=/path/to/rust/bin npm run tauri build -- --debug
|
||||
- `npm run build`:已通过;
|
||||
- Cargo/Tauri Rust crates:已下载并开始编译;
|
||||
- Tauri Linux desktop build:已通过;debug 可执行文件和 Debian 包已生成;
|
||||
- Android Spike:尚未开始。仍需要 Android NDK、Rust Android targets 与 Tauri Android 初始化。
|
||||
- 独立 Android/Kotlin 与 Wails Host:不在当前或后续规划范围内。
|
||||
|
||||
Linux 桌面构建依赖以下系统开发库;本机已具备。新环境缺少时可安装:
|
||||
|
||||
@@ -156,17 +171,21 @@ src-tauri/target/debug/lineup-tauri
|
||||
src-tauri/target/debug/bundle/deb/LineUp_0.1.0_amd64.deb
|
||||
```
|
||||
|
||||
Tauri 构建产物位于 `src-tauri/target/`,已被 Git 忽略。图标源文件是 `src-tauri/icons/icon.svg`,其余 PNG/ICO/ICNS 与 Android/iOS 图标由 `npm run tauri icon src-tauri/icons/icon.svg` 生成。
|
||||
Tauri 构建产物位于 `src-tauri/target/`,已被 Git 忽略。图标源文件是 `src-tauri/icons/icon.svg`;当前交付只面向 Tauri Desktop Host,其他图标派生产物不代表额外客户端路线。
|
||||
|
||||
## 目录边界
|
||||
|
||||
```text
|
||||
tauri/
|
||||
├── src/ # 可信 LineUp Host 前端
|
||||
│ ├── main.ts # 会话 UI、AppServer transport、Renderer 调度
|
||||
│ └── protocol.ts # 平台无关的 LineUp v1 → ConversationItem 解码
|
||||
├── src/
|
||||
│ ├── main.ts # 应用启动装配入口:连接 Host、Runtime 与默认 App
|
||||
│ ├── core-apps/chat/ # Chat Shell、可信 Renderer 与样式
|
||||
│ └── runtime/ # 按通信、协调、存储、应用、Surface、能力等分层
|
||||
├── src-tauri/ # Tauri Rust Host 与 capability 配置
|
||||
├── vite.config.ts # @/ → src/ 路径别名(Vite / Vitest)
|
||||
└── package.json # Vite、Tauri CLI 与前端依赖
|
||||
```
|
||||
|
||||
各 Runtime 子目录、依赖方向与新增模块约定见 [src/README.md](src/README.md)。
|
||||
|
||||
第三方 Agent Surface 未来必须运行于独立隔离执行域,只能经 LineUp Mini Runtime 事件桥与 Capability Gateway 通信;不得访问 Tauri `invoke`、Host DOM、登录态或本机特权。
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Tauri Rust Host 的应用构造入口;Runtime 协议和业务路由仍位于 TypeScript LineUpRuntime。
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Tauri 桌面二进制入口:仅委托给 lib.rs 中的可信 Host 构造逻辑。
|
||||
fn main() {
|
||||
lineup_tauri_lib::run();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# Tauri / Web Reference Host 源码导航
|
||||
|
||||
`src/` 是 LineUp 的前端实现目录。可以把它理解为三层协作:Runtime 负责连接、状态、
|
||||
存储和恢复;Chat 是用户首先看见的聊天功能;Tauri 或浏览器则是装载这套程序的运行外壳。
|
||||
同一份源码既可放进正式桌面窗口,也可在浏览器中用于开发和验证。
|
||||
|
||||
`main.ts` 是应用启动时的装配入口:它把 Host 侧实现、`LineUpRuntime` 和默认 Core App
|
||||
接起来。它不负责网络收发、消息存储、同步循环或原始 Agent 消息解析;这些都由 Runtime
|
||||
统一管理。
|
||||
|
||||
```text
|
||||
src/
|
||||
├── main.ts # 启动装配入口:连接 Tauri/Web Host、Runtime 与默认 App
|
||||
├── core-apps/
|
||||
│ └── chat/ # 第一个可信 Core App
|
||||
│ ├── chat-app-host.ts # Chat 的 Host 装配器:样式、Shell、Renderer 与 SDK
|
||||
│ ├── chat-shell.ts # Chat 的 DOM Shell
|
||||
│ ├── trusted-dom-renderers.ts # 已验证消息 → 可信 DOM
|
||||
│ ├── renderer-registry.ts # Chat Renderer 分派
|
||||
│ └── styles/ # Chat 专属样式
|
||||
└── runtime/
|
||||
├── app-management/ # Core App Registry、Chat SDK、App Host
|
||||
├── artifacts/ # Artifact 元数据与短生命周期内容缓存
|
||||
├── capabilities/ # Capability Registry、状态机、审计与执行器
|
||||
├── communication/ # AppServer Transport
|
||||
├── coordination/ # Runtime、Kernel、scope 路由、Tool/Task 状态
|
||||
├── inventory/ # Agent 可见 Client/Tool Inventory
|
||||
├── persistence/ # Conversation Store、Outbox、App Inbox
|
||||
├── protocol/ # LineUp v1 解码与 golden fixture
|
||||
└── surfaces/ # Surface Registry、实例、Bundle 与隔离 Host
|
||||
```
|
||||
|
||||
## 谁可以依赖谁
|
||||
|
||||
```text
|
||||
core-apps/chat ──ChatRuntimeSDK──► runtime/app-management
|
||||
│
|
||||
Host adapters ───────────────► runtime/coordination/LineUpRuntime
|
||||
│
|
||||
communication · persistence · protocol · capabilities
|
||||
│
|
||||
surfaces / inventory / artifacts
|
||||
```
|
||||
|
||||
- App 不直接导入 `communication`、`persistence` 或原始协议解码模块;Chat 通过
|
||||
`ChatRuntimeSDK` 接收已筛选的状态、消息和 Inbox,并以 Runtime Action 发起用户动作。
|
||||
简单说,Chat 可以向 Runtime 说“请发送这段文字”,但不能自己连服务器或改数据库。
|
||||
- `CoreAppRegistry` 决定默认启动哪个应用;`CoreAppHostRegistry` 决定如何挂载这个应用。
|
||||
当前注册的实现仍是 `chat`,但 `main.ts` 不再直接导入 Chat 的样式、Renderer 或 DOM
|
||||
Shell。
|
||||
- Runtime 通过 `openApp(app_scope)` 打开对应的 SDK 投影;`openChatApp()` 只作为旧调用方的
|
||||
兼容别名,新的 Core App Host 应使用注册表作用域打开 SDK。
|
||||
- `runtime/coordination/lineup-runtime.ts` 是 Transport、Store、sync loop 和 outbox
|
||||
的唯一所有者。
|
||||
- 每个模块的测试与实现同目录放置;`runtime/protocol/golden/` 保存兼容协议 fixture。
|
||||
- `@/` 指向 `src/`,由 `tsconfig.json` 与 `vite.config.ts` 共同配置。新增代码应使用该
|
||||
别名,避免跨层的相对路径依赖。
|
||||
|
||||
未来新增 `voice`、`whiteboard` 等 Core/Installed App 时,应给它们各自的目录和 SDK
|
||||
投影;不能把页面、网络连接或持久化逻辑重新堆回 `main.ts`。这样新应用只增加自己的体验,
|
||||
不会破坏已有聊天和连接逻辑。
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Chat Core App 的 Host 装配器。
|
||||
*
|
||||
* Chat 的样式、Shell、Renderer 和 DOM 上下文都属于 Chat 自己;Runtime Host
|
||||
* 只负责根据 app_scope 选择这个装配器,main.ts 不再直接依赖 Chat 的内部模块。
|
||||
*/
|
||||
import "@/core-apps/chat/styles/app.css";
|
||||
import "@/core-apps/chat/styles/capability-card.css";
|
||||
import "@/core-apps/chat/styles/execution-progress.css";
|
||||
import { LineUpRuntime } from "@/runtime/coordination/lineup-runtime";
|
||||
import type { ChatRuntimeSDK } from "@/runtime/app-management/app-sdk";
|
||||
import { mountChatShell } from "@/core-apps/chat/chat-shell";
|
||||
import { RendererRegistry } from "@/core-apps/chat/renderer-registry";
|
||||
import {
|
||||
TrustedDOMRendererContext,
|
||||
type CapabilityInteractionHandlers,
|
||||
type TaskInteractionHandlers,
|
||||
type ToolInteractionHandlers,
|
||||
} from "@/core-apps/chat/trusted-dom-renderers";
|
||||
|
||||
export type ChatAppHostOptions = {
|
||||
toolInteractions?: ToolInteractionHandlers;
|
||||
taskInteractions?: TaskInteractionHandlers;
|
||||
capabilityInteractions?: CapabilityInteractionHandlers;
|
||||
};
|
||||
|
||||
export type MountedChatApp = {
|
||||
app_scope: "chat";
|
||||
sdk: ChatRuntimeSDK;
|
||||
messages: HTMLElement;
|
||||
loginView: HTMLElement;
|
||||
chatView: HTMLElement;
|
||||
appSessions: HTMLElement;
|
||||
presence: HTMLElement;
|
||||
rendererContext: TrustedDOMRendererContext;
|
||||
rendererRegistry: RendererRegistry<TrustedDOMRendererContext>;
|
||||
};
|
||||
|
||||
function query<T extends Element>(root: ParentNode, selector: string): T {
|
||||
const found = root.querySelector<T>(selector);
|
||||
if (!found) throw new Error(`Missing Chat element: ${selector}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Mounts the trusted Chat implementation selected by the Runtime App Host. */
|
||||
export function mountChatApp(
|
||||
runtime: LineUpRuntime,
|
||||
root: HTMLElement,
|
||||
options: ChatAppHostOptions,
|
||||
): MountedChatApp {
|
||||
mountChatShell(root);
|
||||
const messages = query<HTMLElement>(root, "#messages");
|
||||
const loginView = query<HTMLElement>(root, "#login-view");
|
||||
const chatView = query<HTMLElement>(root, "#chat-view");
|
||||
const appSessions = query<HTMLElement>(root, "#app-sessions");
|
||||
const presence = query<HTMLElement>(root, "#presence");
|
||||
const rendererContext = new TrustedDOMRendererContext(
|
||||
messages,
|
||||
presence,
|
||||
options.toolInteractions,
|
||||
options.taskInteractions,
|
||||
options.capabilityInteractions,
|
||||
);
|
||||
const rendererRegistry = new RendererRegistry<TrustedDOMRendererContext>();
|
||||
registerChatRenderers(rendererRegistry);
|
||||
|
||||
return {
|
||||
app_scope: "chat",
|
||||
sdk: runtime.openApp("chat"),
|
||||
messages,
|
||||
loginView,
|
||||
chatView,
|
||||
appSessions,
|
||||
presence,
|
||||
rendererContext,
|
||||
rendererRegistry,
|
||||
};
|
||||
}
|
||||
|
||||
function registerChatRenderers(registry: RendererRegistry<TrustedDOMRendererContext>): void {
|
||||
registry.register("user-message", (item, context) => context.renderUserMessage(item));
|
||||
registry.register("markdown", (item, context) => context.renderMarkdown(item));
|
||||
registry.register("agent-status", (item, context) => context.renderAgentStatus(item));
|
||||
registry.register("execution-summary", (item, context) => context.renderExecutionTrace(item));
|
||||
registry.register("progress", (item, context) => context.renderProgress(item));
|
||||
registry.register("error", (item, context) => context.renderError(item));
|
||||
registry.register("tool-call", (item, context) => context.renderToolCall(item));
|
||||
registry.register("tool-result", (item, context) => context.renderToolResult(item));
|
||||
registry.register("tool-cancel", (item, context) => context.renderToolCancel(item));
|
||||
registry.register("surface", (item, context) => context.renderSurface(item));
|
||||
registry.register("app-call", (item, context) => context.renderAppCall(item));
|
||||
registry.register("fallback", (item, context) => context.renderFallback(item));
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Chat Core App 的可信 DOM 外壳。
|
||||
*
|
||||
* 这里只声明登录与聊天页面结构;Agent 消息、状态和用户动作都必须经过
|
||||
* ChatRuntimeSDK,而不能在页面中直接接触 Runtime 的通信或存储实现。
|
||||
*/
|
||||
export function mountChatShell(root: HTMLElement): void {
|
||||
root.innerHTML = `
|
||||
<main class="shell">
|
||||
<section id="login-view" class="login-view">
|
||||
<div class="brand"><span>✦</span><div><p>LINEUP</p><h1>你的 AI 协作空间</h1></div></div>
|
||||
<form id="login-form" class="login-card" novalidate>
|
||||
<label>AppServer 地址<input id="api" inputmode="url" placeholder="http://100.x.y.z:8090" required /></label>
|
||||
<label>手机号<input id="phone" inputmode="tel" autocomplete="tel" placeholder="请输入手机号" required /></label>
|
||||
<label>验证码<input id="code" inputmode="numeric" autocomplete="one-time-code" placeholder="请输入验证码" required /></label>
|
||||
<p id="login-error" class="error" role="alert"></p>
|
||||
<button id="login-submit" type="submit">进入 LineUp</button>
|
||||
</form>
|
||||
</section>
|
||||
<section id="chat-view" class="chat-view" hidden>
|
||||
<header class="chat-header"><div class="agent"><span class="avatar">✦</span><div><strong>AI Agent</strong><small id="presence">正在连接…</small></div></div><div><button id="surface-demo" class="quiet" type="button">启用任务面板</button><button id="logout" class="quiet">退出</button></div></header>
|
||||
<section id="app-sessions" class="app-sessions" aria-label="应用子会话"></section>
|
||||
<section id="messages" class="messages" aria-live="polite"></section>
|
||||
<form id="message-form" class="composer"><textarea id="message-input" rows="1" placeholder="问问你的 AI 搭档…"></textarea><button id="send" type="submit">发送</button></form>
|
||||
</section>
|
||||
</main>`;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Product-facing Interaction App mode boundary. `chat` remains the wire and
|
||||
* directory compatibility name, while IM is now explicitly one Interaction
|
||||
* mode alongside Audio and Video. Modes have no Transport or Host privilege.
|
||||
*/
|
||||
export type InteractionMode = "im" | "audio" | "video";
|
||||
|
||||
export type InteractionModeRecord = {
|
||||
mode: InteractionMode;
|
||||
enabled: boolean;
|
||||
requires_permission?: string;
|
||||
};
|
||||
|
||||
export class InteractionModeRegistry {
|
||||
private readonly modes = new Map<InteractionMode, InteractionModeRecord>([
|
||||
["im", { mode: "im", enabled: true }],
|
||||
["audio", { mode: "audio", enabled: false, requires_permission: "media.microphone" }],
|
||||
["video", { mode: "video", enabled: false, requires_permission: "media.camera" }],
|
||||
]);
|
||||
public list(): readonly InteractionModeRecord[] { return [...this.modes.values()].map(mode => ({ ...mode })); }
|
||||
public get(mode: InteractionMode): InteractionModeRecord { return { ...(this.modes.get(mode) ?? { mode, enabled: false }) }; }
|
||||
/** Only a trusted Runtime installation/configuration path can enable a mode. */
|
||||
public configure(mode: InteractionMode, enabled: boolean): void { this.modes.set(mode, { ...this.get(mode), enabled }); }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { InteractRuntimeSDK } from "@/runtime/app-management/app-sdk";
|
||||
import { InteractionModeRegistry, type InteractionMode } from "@/core-apps/chat/interaction-mode-registry";
|
||||
|
||||
/** Restricted Interaction App projection: IM today, Audio/Video when enabled. */
|
||||
export class InteractionRuntime {
|
||||
public readonly modes = new InteractionModeRegistry();
|
||||
private current: InteractionMode = "im";
|
||||
public constructor(public readonly sdk: InteractRuntimeSDK) {}
|
||||
public activeMode(): InteractionMode { return this.current; }
|
||||
public selectMode(mode: InteractionMode): boolean {
|
||||
if (!this.modes.get(mode).enabled) return false;
|
||||
this.current = mode;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+7
-1
@@ -1,4 +1,10 @@
|
||||
import type { ConversationItem } from "../protocol";
|
||||
/**
|
||||
* Chat 的可信 Renderer 注册表。
|
||||
*
|
||||
* 只允许已由 Runtime/协议层验证过的 ConversationItem 进入对应 Renderer,防止未校验的
|
||||
* 原始 Agent payload 直接驱动 DOM。
|
||||
*/
|
||||
import type { ConversationItem } from "@/runtime/protocol/lineup-v1";
|
||||
|
||||
export type ConversationItemKind = ConversationItem["kind"];
|
||||
export type ItemOfKind<K extends ConversationItemKind> = Extract<ConversationItem, { kind: K }>;
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,23 @@
|
||||
/* Chat 中可信 Capability 确认卡片的视觉状态;样式不授予或执行任何系统能力。 */
|
||||
.capability-card {
|
||||
display: grid;
|
||||
justify-self: start;
|
||||
gap: .7rem;
|
||||
width: min(100%, 32rem);
|
||||
padding: 1rem;
|
||||
border: 1px solid #537362;
|
||||
border-radius: .9rem;
|
||||
background: #1c2c24;
|
||||
}
|
||||
.capability-card > p { margin: 0; color: #bfd1c6; line-height: 1.45; }
|
||||
.capability-risk, .capability-status { color: #aac5b5; }
|
||||
.capability-actions { display: flex; flex-wrap: wrap; gap: .5rem; }
|
||||
.capability-actions button {
|
||||
border: 0; border-radius: .65rem; padding: .8rem 1rem;
|
||||
background: #a9dfc4; color: #102018; font-weight: 750; cursor: pointer;
|
||||
}
|
||||
.capability-actions .secondary-action { background: #395447; color: #e2f1e8; }
|
||||
.capability-card[data-status="completed"] { border-color: #89c7a6; }
|
||||
.capability-card[data-status="rejected"], .capability-card[data-status="expired"],
|
||||
.capability-card[data-status="failed"], .capability-card[data-status="unsupported"] { border-color: #587064; }
|
||||
.capability-card button:disabled { opacity: .5; cursor: not-allowed; }
|
||||
@@ -0,0 +1,45 @@
|
||||
/* Chat 内受限执行摘要卡片:只展示 Runtime/Adapter 已归约的公开步骤。 */
|
||||
.execution-progress-card {
|
||||
justify-self: start;
|
||||
width: min(100%, 32rem);
|
||||
border: 1px solid #577563;
|
||||
border-radius: .85rem;
|
||||
background: #182820;
|
||||
color: #cde3d5;
|
||||
}
|
||||
|
||||
.execution-progress-card summary {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: .8rem;
|
||||
padding: .72rem .85rem;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.execution-progress-card summary::-webkit-details-marker { display: none; }
|
||||
.execution-progress-card summary::before { content: "›"; font-size: 1.2rem; color: #a9dfc4; transition: transform .15s ease; }
|
||||
.execution-progress-card[open] summary::before { transform: rotate(90deg); }
|
||||
.execution-progress-card[data-status="running"] summary::before { content: "◌"; transform: none; }
|
||||
.execution-progress-heading strong { font-size: .9rem; }
|
||||
.execution-progress-duration { color: #9cbbaa; white-space: nowrap; }
|
||||
|
||||
.execution-progress-stages {
|
||||
display: grid;
|
||||
gap: .34rem;
|
||||
margin: 0;
|
||||
padding: 0 .9rem .85rem 2rem;
|
||||
color: #b9d1c2;
|
||||
font-size: .82rem;
|
||||
}
|
||||
|
||||
.execution-progress-stages li::marker { color: #85caa2; }
|
||||
.execution-progress-stages li[data-status="completed"] { color: #d5f0df; }
|
||||
.execution-progress-stages li[data-status="running"] { color: #d7e6a4; }
|
||||
.execution-progress-card[data-status="failed"] { border-color: #82625e; }
|
||||
.execution-progress-card[data-status="failed"] .execution-progress-stages li[data-status="failed"] { color: #ffb2aa; }
|
||||
|
||||
/* A trace belongs to the final Agent turn, not to the global message stream. */
|
||||
.message-agent-content { display: grid; gap: .45rem; min-width: 0; }
|
||||
.message-agent-content > .execution-progress-card { width: min(100%, 32rem); }
|
||||
+294
-28
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* Chat Core App 的可信 DOM 渲染实现。
|
||||
*
|
||||
* 本模块只把受限的表现模型转成页面节点;Markdown 必须经过 sanitizer,交互卡和能力卡
|
||||
* 只能调用 SDK 提供的受限回调,不能取得 Transport、Store 或 Tauri 特权对象。
|
||||
*/
|
||||
import DOMPurify from "dompurify";
|
||||
import { marked } from "marked";
|
||||
import type {
|
||||
@@ -6,11 +12,15 @@ import type {
|
||||
ConversationItem,
|
||||
DeliveryState,
|
||||
InputForm,
|
||||
NoticeDefinition,
|
||||
JsonObject,
|
||||
ToolCallRequest,
|
||||
} from "../protocol";
|
||||
import type { ToolCallRecord } from "./tool-call-state";
|
||||
import type { TaskRecord } from "./task-state";
|
||||
} from "@/runtime/protocol/lineup-v1";
|
||||
import type { ToolCallRecord } from "@/runtime/coordination/tool-call-state";
|
||||
import type { TaskRecord } from "@/runtime/coordination/task-state";
|
||||
import type { ExecutionProgressSummary } from "@/runtime/coordination/execution-progress-state";
|
||||
import type { ArtifactRecord } from "@/runtime/artifacts/artifact-state";
|
||||
import type { CapabilityCallRecord } from "@/runtime/capabilities/capability-call-state";
|
||||
|
||||
export type ToolInteractionHandlers = {
|
||||
submit: (callID: string, submission: JsonObject) => boolean;
|
||||
@@ -27,30 +37,49 @@ export type TaskInteractionHandlers = {
|
||||
read: (operationID: string) => TaskRecord | undefined;
|
||||
};
|
||||
|
||||
export type CapabilityInteractionHandlers = {
|
||||
approve: (callID: string) => Promise<CapabilityCallRecord | undefined>;
|
||||
reject: (callID: string) => CapabilityCallRecord | undefined;
|
||||
expire: (callID: string) => CapabilityCallRecord | undefined;
|
||||
read: (callID: string) => CapabilityCallRecord | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Trusted presentation context for the first-party chat shell. It owns only
|
||||
* DOM nodes and display-local state; it deliberately has no Store, Transport,
|
||||
* Kernel, Tauri bridge, or capability reference.
|
||||
*/
|
||||
export class TrustedDOMRendererContext {
|
||||
private thinking: HTMLElement | undefined;
|
||||
private readonly userRows = new Map<string, HTMLElement>();
|
||||
private readonly toolCards = new Map<string, { card: HTMLElement; status: HTMLElement; controls: readonly (HTMLButtonElement | HTMLInputElement | HTMLTextAreaElement)[] }>();
|
||||
private readonly taskCards = new Map<string, { card: HTMLElement; title: HTMLElement; progress: HTMLProgressElement; status: HTMLElement; cancel: HTMLButtonElement }>();
|
||||
private readonly capabilityCards = new Map<string, { card: HTMLElement; status: HTMLElement; approve: HTMLButtonElement; reject: HTMLButtonElement }>();
|
||||
private readonly executionCards = new Map<string, HTMLDetailsElement>();
|
||||
private readonly agentReplyGroups = new Map<string, HTMLElement>();
|
||||
private readonly executionSummaries = new Map<string, ExecutionProgressSummary>();
|
||||
private readonly expirationTimers = new Set<number>();
|
||||
private readonly executionTicker: number;
|
||||
|
||||
public constructor(
|
||||
private readonly messages: HTMLElement,
|
||||
private readonly presence: HTMLElement,
|
||||
private readonly toolInteractions?: ToolInteractionHandlers,
|
||||
private readonly taskInteractions?: TaskInteractionHandlers,
|
||||
) {}
|
||||
private readonly capabilityInteractions?: CapabilityInteractionHandlers,
|
||||
) {
|
||||
// Status envelopes are sparse. Keep elapsed time honest between them
|
||||
// without persisting a ticking value or treating it as Agent progress.
|
||||
this.executionTicker = window.setInterval(() => this.refreshExecutionDurations(), 1_000);
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.thinking = undefined;
|
||||
this.userRows.clear();
|
||||
this.toolCards.clear();
|
||||
this.taskCards.clear();
|
||||
this.capabilityCards.clear();
|
||||
this.executionCards.clear();
|
||||
this.agentReplyGroups.clear();
|
||||
this.executionSummaries.clear();
|
||||
for (const timer of this.expirationTimers) window.clearTimeout(timer);
|
||||
this.expirationTimers.clear();
|
||||
}
|
||||
@@ -61,16 +90,21 @@ export class TrustedDOMRendererContext {
|
||||
}
|
||||
|
||||
public renderMarkdown(item: Extract<ConversationItem, { kind: "markdown" }>): void {
|
||||
this.hideThinking();
|
||||
this.appendBubble("agent", item.markdown, true);
|
||||
const row = this.appendBubble("agent", item.markdown, true);
|
||||
if (item.execution_id) {
|
||||
row.dataset.executionId = item.execution_id;
|
||||
this.agentReplyGroups.set(item.execution_id, row);
|
||||
this.attachExecutionCard(item.execution_id);
|
||||
}
|
||||
}
|
||||
|
||||
public renderAgentStatus(item: Extract<ConversationItem, { kind: "agent-status" }>): void {
|
||||
if (item.status === "thinking") this.showThinking(item.detail || "正在思考…");
|
||||
else {
|
||||
this.hideThinking();
|
||||
this.setPresence(item.detail || item.status || "在线");
|
||||
}
|
||||
// Status detail is never displayed as thought content or an operation.
|
||||
this.setPresence(item.status === "thinking" ? "正在处理你的请求" : "已同步,等待消息");
|
||||
}
|
||||
|
||||
public renderExecutionTrace(_item: Extract<ConversationItem, { kind: "execution-summary" }>): void {
|
||||
// main.ts projects the validated trace into the existing summary card.
|
||||
}
|
||||
|
||||
public renderProgress(item: Extract<ConversationItem, { kind: "progress" }>): void {
|
||||
@@ -87,12 +121,58 @@ export class TrustedDOMRendererContext {
|
||||
}
|
||||
|
||||
public renderError(item: Extract<ConversationItem, { kind: "error" }>): void {
|
||||
this.hideThinking();
|
||||
this.renderSystemMessage(`Agent 错误:${item.message}`);
|
||||
}
|
||||
|
||||
/** Renders only protocol-validated public steps; raw tool data never reaches this card. */
|
||||
public renderExecutionSummary(summary: ExecutionProgressSummary): void {
|
||||
this.executionSummaries.set(summary.id, summary);
|
||||
// A completed reply which performed no publicly summarizable operation
|
||||
// must not leave a misleading empty ledger frame behind. A later genuine
|
||||
// trace still recreates and attaches the card under this same reply.
|
||||
if (summary.status !== "running" && summary.steps.length === 0) {
|
||||
const empty = this.executionCards.get(summary.id);
|
||||
empty?.remove();
|
||||
this.executionCards.delete(summary.id);
|
||||
return;
|
||||
}
|
||||
const current = this.executionCards.get(summary.id);
|
||||
if (current) {
|
||||
this.updateExecutionSummary(current, summary);
|
||||
return;
|
||||
}
|
||||
const card = document.createElement("details");
|
||||
card.className = "execution-progress-card";
|
||||
card.dataset.executionId = summary.id;
|
||||
const heading = document.createElement("summary");
|
||||
heading.className = "execution-progress-heading";
|
||||
const title = document.createElement("strong");
|
||||
const duration = document.createElement("small");
|
||||
duration.className = "execution-progress-duration";
|
||||
heading.append(title, duration);
|
||||
const steps = document.createElement("ul");
|
||||
steps.className = "execution-progress-stages";
|
||||
card.append(heading, steps);
|
||||
this.executionCards.set(summary.id, card);
|
||||
this.updateExecutionSummary(card, summary);
|
||||
this.attachExecutionCard(summary.id);
|
||||
if (!card.isConnected) this.messages.append(card);
|
||||
this.scrollLatest();
|
||||
}
|
||||
|
||||
/** Only terminal summaries survive a page restore; live animation never does. */
|
||||
public restoreExecutionSummaries(summaries: readonly ExecutionProgressSummary[]): void {
|
||||
for (const summary of summaries) {
|
||||
if (summary.status !== "running") this.renderExecutionSummary(summary);
|
||||
}
|
||||
}
|
||||
|
||||
public renderToolCall(item: Extract<ConversationItem, { kind: "tool-call" }>): void {
|
||||
const record = this.toolInteractions?.read(item.call.call_id);
|
||||
if (item.call.tool === "notice" && item.call.notice) {
|
||||
this.renderNoticeCard(item.call, item.call.notice);
|
||||
return;
|
||||
}
|
||||
if (item.call.tool === "choice" && item.call.action_group) {
|
||||
this.renderActionGroup(item.call, item.call.action_group, record);
|
||||
return;
|
||||
@@ -108,6 +188,30 @@ export class TrustedDOMRendererContext {
|
||||
this.renderSystemMessage("收到无法安全展示的标准交互请求。");
|
||||
}
|
||||
|
||||
private renderNoticeCard(call: ToolCallRequest, notice: NoticeDefinition): void {
|
||||
const card = document.createElement("article");
|
||||
card.className = "tool-card notice-card";
|
||||
card.dataset.callId = call.call_id;
|
||||
const heading = document.createElement("strong");
|
||||
heading.textContent = call.title || "提示";
|
||||
const detail = document.createElement("p");
|
||||
detail.textContent = notice.message;
|
||||
const status = document.createElement("small");
|
||||
status.className = "tool-card-status";
|
||||
status.textContent = notice.dismiss_label ?? "已收到";
|
||||
card.append(heading, detail, status);
|
||||
this.toolCards.set(call.call_id, { card, status, controls: [] });
|
||||
this.messages.append(card);
|
||||
this.scrollLatest();
|
||||
// Toast is only an additional short reminder; the durable IM card above remains.
|
||||
const toast = document.createElement("div");
|
||||
toast.className = "notice-toast";
|
||||
toast.textContent = notice.message;
|
||||
toast.setAttribute("role", "status");
|
||||
this.messages.parentElement?.append(toast);
|
||||
globalThis.setTimeout(() => toast.remove(), 4_000);
|
||||
}
|
||||
|
||||
public renderToolResult(item: Extract<ConversationItem, { kind: "tool-result" }>): void {
|
||||
const entry = this.toolCards.get(item.result.call_id);
|
||||
if (!entry) return;
|
||||
@@ -124,8 +228,85 @@ export class TrustedDOMRendererContext {
|
||||
this.renderSystemMessage(`收到扩展界面请求(${item.envelope.type});Mini Runtime Surface Host 将在下一步接入。`);
|
||||
}
|
||||
|
||||
public renderAppCall(_item: Extract<ConversationItem, { kind: "app-call" }>): void {
|
||||
this.renderSystemMessage("收到 App 能力调用请求;Capability 授权 UI 将在下一步接入。");
|
||||
/**
|
||||
* A capability card is native chrome, never an Agent-provided form. The
|
||||
* request arguments deliberately do not cross this presentation boundary:
|
||||
* URL, clipboard text, local path and Artifact content cannot be displayed
|
||||
* or copied from the confirmation UI.
|
||||
*/
|
||||
public renderAppCall(item: Extract<ConversationItem, { kind: "app-call" }>): void {
|
||||
const callID = typeof item.envelope.payload.call_id === "string" ? item.envelope.payload.call_id : "";
|
||||
const record = callID ? this.capabilityInteractions?.read(callID) : undefined;
|
||||
if (!record) {
|
||||
this.renderSystemMessage("收到无法安全确认的 App 能力请求。");
|
||||
return;
|
||||
}
|
||||
const existing = this.capabilityCards.get(record.call_id);
|
||||
if (existing) { this.updateCapabilityCard(existing, record); return; }
|
||||
|
||||
const card = document.createElement("article");
|
||||
card.className = "capability-card";
|
||||
card.dataset.callId = record.call_id;
|
||||
const heading = document.createElement("strong");
|
||||
const reason = document.createElement("p");
|
||||
const risk = document.createElement("small");
|
||||
risk.className = "capability-risk";
|
||||
const status = document.createElement("small");
|
||||
status.className = "capability-status";
|
||||
const controls = document.createElement("div");
|
||||
controls.className = "capability-actions";
|
||||
const reject = document.createElement("button");
|
||||
reject.type = "button"; reject.className = "secondary-action"; reject.textContent = "拒绝";
|
||||
const approve = document.createElement("button");
|
||||
approve.type = "button"; approve.textContent = "允许一次";
|
||||
const entry = { card, status, approve, reject };
|
||||
reject.addEventListener("click", () => {
|
||||
const next = this.capabilityInteractions?.reject(record.call_id);
|
||||
if (next) this.updateCapabilityCard(entry, next);
|
||||
});
|
||||
approve.addEventListener("click", () => {
|
||||
approve.disabled = true; reject.disabled = true;
|
||||
void this.capabilityInteractions?.approve(record.call_id).then(next => {
|
||||
if (next) this.updateCapabilityCard(entry, next);
|
||||
});
|
||||
});
|
||||
heading.textContent = capabilityLabel(record.request.capability);
|
||||
reason.textContent = record.request.reason;
|
||||
risk.textContent = capabilityRiskText(record.request.capability);
|
||||
controls.append(reject, approve);
|
||||
card.append(heading, reason, risk, status, controls);
|
||||
this.capabilityCards.set(record.call_id, entry);
|
||||
this.updateCapabilityCard(entry, record);
|
||||
this.messages.append(card);
|
||||
this.scrollLatest();
|
||||
|
||||
const delay = Date.parse(record.request.expires_at) - Date.now();
|
||||
if (delay > 0 && record.status === "pending") {
|
||||
const timer = window.setTimeout(() => {
|
||||
this.expirationTimers.delete(timer);
|
||||
const next = this.capabilityInteractions?.expire(record.call_id);
|
||||
if (next) this.updateCapabilityCard(entry, next);
|
||||
}, delay);
|
||||
this.expirationTimers.add(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/** Native Artifact display. It deliberately has no download URL or local reference. */
|
||||
public renderArtifact(record: ArtifactRecord): void {
|
||||
const card = document.createElement("article");
|
||||
card.className = "artifact-card";
|
||||
card.dataset.artifactId = record.artifact_id;
|
||||
const name = document.createElement("strong");
|
||||
name.textContent = record.name;
|
||||
const metadata = document.createElement("small");
|
||||
metadata.textContent = `${record.mime_type} · ${formatBytes(record.size_bytes)}`;
|
||||
const integrity = document.createElement("small");
|
||||
integrity.className = "artifact-integrity";
|
||||
integrity.dataset.integrity = record.integrity;
|
||||
integrity.textContent = record.integrity === "verified" ? "完整性已验证" : record.integrity === "failed" ? "完整性校验失败" : "完整性未验证";
|
||||
card.append(name, metadata, integrity);
|
||||
this.messages.append(card);
|
||||
this.scrollLatest();
|
||||
}
|
||||
|
||||
public renderFallback(item: Extract<ConversationItem, { kind: "fallback" }>): void {
|
||||
@@ -181,7 +362,14 @@ export class TrustedDOMRendererContext {
|
||||
meta.className = "meta";
|
||||
meta.textContent = `${new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}${delivery ? ` · ${delivery}` : ""}`;
|
||||
bubble.append(meta);
|
||||
row.append(bubble);
|
||||
if (role === "agent") {
|
||||
const content = document.createElement("div");
|
||||
content.className = "message-agent-content";
|
||||
content.append(bubble);
|
||||
row.append(content);
|
||||
} else {
|
||||
row.append(bubble);
|
||||
}
|
||||
this.messages.append(row);
|
||||
this.scrollLatest();
|
||||
return row;
|
||||
@@ -228,6 +416,16 @@ export class TrustedDOMRendererContext {
|
||||
entry.cancel.disabled = requested;
|
||||
}
|
||||
|
||||
private updateCapabilityCard(entry: { card: HTMLElement; status: HTMLElement; approve: HTMLButtonElement; reject: HTMLButtonElement }, record: CapabilityCallRecord): void {
|
||||
entry.card.dataset.status = record.status;
|
||||
const active = record.status === "pending";
|
||||
entry.approve.hidden = !active;
|
||||
entry.reject.hidden = !active;
|
||||
entry.approve.disabled = !active;
|
||||
entry.reject.disabled = !active;
|
||||
entry.status.textContent = capabilityStatusText(record.status);
|
||||
}
|
||||
|
||||
private renderActionGroup(call: ToolCallRequest, group: ActionGroup, record?: ToolCallRecord): void {
|
||||
const card = document.createElement("article");
|
||||
card.className = "action-group-card";
|
||||
@@ -244,7 +442,7 @@ export class TrustedDOMRendererContext {
|
||||
const buttons: HTMLButtonElement[] = [];
|
||||
const selected = new Set<string>();
|
||||
const submit = (actionIDs: readonly string[]) => {
|
||||
const accepted = this.toolInteractions?.submit(call.call_id, { action_ids: [...actionIDs] }) ?? false;
|
||||
const accepted = this.toolInteractions?.submit(call.call_id, { action_id: actionIDs[0] }) ?? false;
|
||||
if (!accepted) {
|
||||
status.textContent = "该交互已结束或已提交";
|
||||
return;
|
||||
@@ -321,7 +519,7 @@ export class TrustedDOMRendererContext {
|
||||
const locked = this.applyCallStatus(card, status, call, record);
|
||||
for (const button of buttons) button.disabled = locked;
|
||||
approve.addEventListener("click", () => {
|
||||
if (!this.toolInteractions?.submit(call.call_id, { confirmed: true })) return this.markInteractionUnavailable(status);
|
||||
if (!this.toolInteractions?.submit(call.call_id, { approved: true })) return this.markInteractionUnavailable(status);
|
||||
this.markSubmitted(card, status, buttons);
|
||||
});
|
||||
cancel.addEventListener("click", () => {
|
||||
@@ -384,7 +582,8 @@ export class TrustedDOMRendererContext {
|
||||
const values = this.readFormValues(fields);
|
||||
const problem = validateFormValues(form, values);
|
||||
if (problem) { errors.textContent = problem; return; }
|
||||
if (!this.toolInteractions?.submit(call.call_id, { values })) return this.markInteractionUnavailable(status);
|
||||
const text = values[form.fields[0]?.id ?? ""];
|
||||
if (!this.toolInteractions?.submit(call.call_id, { text })) return this.markInteractionUnavailable(status);
|
||||
errors.textContent = "";
|
||||
this.markSubmitted(card, status, controls);
|
||||
});
|
||||
@@ -453,16 +652,42 @@ export class TrustedDOMRendererContext {
|
||||
for (const control of entry.controls) control.disabled = true;
|
||||
}
|
||||
|
||||
private showThinking(detail: string): void {
|
||||
this.thinking?.remove();
|
||||
this.thinking = this.appendBubble("agent", detail);
|
||||
this.thinking.classList.add("thinking");
|
||||
this.setPresence("正在思考");
|
||||
private updateExecutionSummary(card: HTMLDetailsElement, summary: ExecutionProgressSummary): void {
|
||||
card.dataset.status = summary.status;
|
||||
card.open = summary.status === "running";
|
||||
const elapsed = executionElapsed(summary);
|
||||
const heading = card.querySelector<HTMLElement>(".execution-progress-heading strong");
|
||||
const duration = card.querySelector<HTMLElement>(".execution-progress-duration");
|
||||
const steps = card.querySelector<HTMLUListElement>(".execution-progress-stages");
|
||||
if (!heading || !duration || !steps) return;
|
||||
heading.textContent = summary.status === "running"
|
||||
? "正在执行操作"
|
||||
: summary.status === "completed"
|
||||
? "已完成处理 · 查看过程摘要"
|
||||
: "处理未完成 · 查看过程摘要";
|
||||
duration.textContent = `${elapsed} 秒`;
|
||||
steps.replaceChildren(...summary.steps.map(step => {
|
||||
const row = document.createElement("li");
|
||||
row.dataset.status = step.status;
|
||||
row.textContent = `${step.status === "running" ? "◌" : step.status === "completed" ? "✓" : "×"} ${step.title}`;
|
||||
return row;
|
||||
}));
|
||||
}
|
||||
|
||||
private hideThinking(): void {
|
||||
this.thinking?.remove();
|
||||
this.thinking = undefined;
|
||||
/** Moves a turn's safe ledger card under its matching final Agent reply. */
|
||||
private attachExecutionCard(executionID: string): void {
|
||||
const card = this.executionCards.get(executionID);
|
||||
const reply = this.agentReplyGroups.get(executionID);
|
||||
const container = reply?.querySelector<HTMLElement>(".message-agent-content");
|
||||
if (card && container && card.parentElement !== container) container.append(card);
|
||||
}
|
||||
|
||||
private refreshExecutionDurations(): void {
|
||||
for (const [id, summary] of this.executionSummaries) {
|
||||
if (summary.status !== "running") continue;
|
||||
const card = this.executionCards.get(id);
|
||||
if (card) this.updateExecutionSummary(card, summary);
|
||||
}
|
||||
}
|
||||
|
||||
private scrollLatest(): void {
|
||||
@@ -470,12 +695,53 @@ export class TrustedDOMRendererContext {
|
||||
}
|
||||
}
|
||||
|
||||
function executionElapsed(summary: ExecutionProgressSummary): number {
|
||||
const end = summary.status === "running" ? Date.now() : Date.parse(summary.finished_at ?? summary.updated_at);
|
||||
const start = Date.parse(summary.started_at);
|
||||
return Number.isFinite(start) && Number.isFinite(end) ? Math.max(0, Math.round((end - start) / 1_000)) : 0;
|
||||
}
|
||||
|
||||
function deliveryLabel(delivery: DeliveryState): string {
|
||||
if (delivery.status === "local_pending") return "发送中";
|
||||
if (delivery.status === "failed") return "发送失败";
|
||||
return "已发送";
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${Math.ceil(bytes / 1024)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function capabilityLabel(capability: CapabilityCallRecord["request"]["capability"]): string {
|
||||
switch (capability) {
|
||||
case "app.open_url": return "打开外部网站";
|
||||
case "clipboard.write": return "写入剪贴板";
|
||||
case "device.pick_file": return "选择一个文件";
|
||||
case "artifact.save": return "保存已验证的文件";
|
||||
}
|
||||
}
|
||||
|
||||
function capabilityRiskText(capability: CapabilityCallRecord["request"]["capability"]): string {
|
||||
return capability === "device.pick_file"
|
||||
? "这会打开系统文件选择器;只有你选择的文件元数据会返回给 Agent。"
|
||||
: "此操作仅在你本次明确允许后执行一次。";
|
||||
}
|
||||
|
||||
function capabilityStatusText(status: CapabilityCallRecord["status"]): string {
|
||||
switch (status) {
|
||||
case "pending": return "等待你的确认";
|
||||
case "approved":
|
||||
case "executing": return "正在执行已允许的操作…";
|
||||
case "completed": return "已完成";
|
||||
case "rejected": return "你已拒绝此操作";
|
||||
case "cancelled": return "操作已取消";
|
||||
case "expired": return "确认已过期";
|
||||
case "unsupported": return "此客户端当前不支持该操作";
|
||||
case "failed": return "操作未能完成";
|
||||
}
|
||||
}
|
||||
|
||||
function validateFormValues(form: InputForm, values: JsonObject): string | undefined {
|
||||
for (const field of form.fields) {
|
||||
const value = values[field.id];
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TaskDashboardMiniApp } from "@/core-apps/task-dashboard/task-dashboard-miniapp";
|
||||
|
||||
describe("TaskDashboardMiniApp", () => {
|
||||
it("keeps user-created task form data local and reports Tool lifecycle through SDK", async () => {
|
||||
const calls: string[] = [];
|
||||
const sdk = {
|
||||
context: { app_scope: "task-dashboard", instance_id: "task:1", conversation_id: "c", app_version: "1.0.0", state: "foreground" as const },
|
||||
inbox: { list: () => [], subscribe: () => () => undefined, acknowledge: () => true },
|
||||
tools: {
|
||||
subscribe: () => () => undefined,
|
||||
get: () => undefined,
|
||||
reportProgress: async (id: string) => { calls.push(`progress:${id}`); return { disposition: "accepted" as const }; },
|
||||
complete: async (id: string) => { calls.push(`complete:${id}`); return { disposition: "accepted" as const }; },
|
||||
fail: async () => ({ disposition: "accepted" as const }),
|
||||
cancel: async () => ({ disposition: "accepted" as const }),
|
||||
},
|
||||
lifecycle: { requestForeground: async () => ({ disposition: "accepted" as const }), requestBackground: async () => ({ disposition: "accepted" as const }), requestClose: async () => ({ disposition: "accepted" as const }) },
|
||||
surfaces: { request: async () => ({ disposition: "accepted" as const }) },
|
||||
capabilities: { request: async () => ({ disposition: "accepted" as const }) },
|
||||
workspace: () => ({ version: 1 as const, instances: { version: 1 as const, instances: [] }, focus: { version: 1 as const, stack: [] } }),
|
||||
};
|
||||
const app = new TaskDashboardMiniApp(sdk);
|
||||
app.start();
|
||||
app.createTask("task-1", { title: "本地任务" });
|
||||
await app.completeTask("call-1", "task-1");
|
||||
expect(calls).toEqual(["progress:call-1", "complete:call-1"]);
|
||||
app.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { LineUpMiniAppSDK } from "@/runtime/app-management/miniapp-sdk";
|
||||
import type { JsonObject } from "@/runtime/protocol/lineup-v1";
|
||||
|
||||
/** Minimal bundled reference App: exercises Inbox, Tool progress/result and lifecycle. */
|
||||
export class TaskDashboardMiniApp {
|
||||
private readonly tasks = new Map<string, JsonObject>();
|
||||
private unsubscribeInbox: (() => void) | undefined;
|
||||
private unsubscribeTools: (() => void) | undefined;
|
||||
|
||||
public constructor(private readonly sdk: LineUpMiniAppSDK) {}
|
||||
|
||||
public start(): void {
|
||||
void this.sdk.surfaces.request("open", { state: { title: "任务面板", status: "等待 Agent 任务", percent: 0, steps: [] } });
|
||||
this.unsubscribeInbox = this.sdk.inbox.subscribe(message => {
|
||||
if (message.item.kind === "app-call" || message.item.kind === "miniapp-tool-call") this.sdk.inbox.acknowledge(message.message_id);
|
||||
});
|
||||
this.unsubscribeTools = this.sdk.tools.subscribe(call => {
|
||||
if (call.instance_id !== this.sdk.context.instance_id) return;
|
||||
void this.handleTool(call);
|
||||
});
|
||||
}
|
||||
|
||||
private async handleTool(call: { call_id: string; tool_id: string; input: JsonObject }): Promise<void> {
|
||||
await this.sdk.tools.reportProgress(call.call_id, { status: "running", percent: 10, detail: call.tool_id });
|
||||
if (call.tool_id === "task-dashboard.open") {
|
||||
const title = typeof call.input.title === "string" ? call.input.title : "未命名任务";
|
||||
const taskID = typeof call.input.task_id === "string" ? call.input.task_id : `task-${call.call_id}`;
|
||||
this.createTask(taskID, { title, status: "open" });
|
||||
await this.sdk.surfaces.request("patch", { state: { title: "任务面板", status: `已创建:${title}`, percent: 100, steps: [title] } });
|
||||
await this.sdk.tools.complete(call.call_id, { task_id: taskID });
|
||||
return;
|
||||
}
|
||||
if (call.tool_id === "task-dashboard.update") {
|
||||
const taskID = typeof call.input.task_id === "string" ? call.input.task_id : `task-${call.call_id}`;
|
||||
const previous = this.tasks.get(taskID) ?? {};
|
||||
const next = { ...previous, ...(typeof call.input.status === "string" ? { status: call.input.status } : {}), ...(typeof call.input.progress === "number" ? { progress: call.input.progress } : {}) };
|
||||
this.tasks.set(taskID, next);
|
||||
await this.sdk.tools.complete(call.call_id, { task_id: taskID, task: next });
|
||||
return;
|
||||
}
|
||||
await this.sdk.tools.fail(call.call_id, { code: "tool_not_supported", message: `不支持的任务工具:${call.tool_id}` });
|
||||
}
|
||||
|
||||
/** App-local form submission; it is not an Agent standard interaction. */
|
||||
public createTask(taskID: string, values: JsonObject): void {
|
||||
this.tasks.set(taskID, values);
|
||||
}
|
||||
|
||||
public async completeTask(callID: string, taskID: string): Promise<void> {
|
||||
const task = this.tasks.get(taskID) ?? {};
|
||||
await this.sdk.tools.reportProgress(callID, { status: "completed", percent: 100 });
|
||||
await this.sdk.tools.complete(callID, { status: "completed", task_id: taskID, task });
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
this.unsubscribeInbox?.();
|
||||
this.unsubscribeTools?.();
|
||||
this.unsubscribeInbox = undefined;
|
||||
this.unsubscribeTools = undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { WhiteboardMiniApp } from "@/core-apps/whiteboard/whiteboard-miniapp";
|
||||
|
||||
describe("WhiteboardMiniApp", () => {
|
||||
it("uses only the Surface bridge for board state", async () => {
|
||||
const events: string[] = [];
|
||||
const sdk = {
|
||||
context: { app_scope: "whiteboard", instance_id: "whiteboard:1", conversation_id: "c", app_version: "1.0.0", state: "foreground" as const },
|
||||
inbox: { list: () => [], subscribe: () => () => undefined, acknowledge: () => true },
|
||||
tools: { subscribe: () => () => undefined, get: () => undefined, reportProgress: async () => ({ disposition: "accepted" as const }), complete: async () => ({ disposition: "accepted" as const }), fail: async () => ({ disposition: "accepted" as const }), cancel: async () => ({ disposition: "accepted" as const }) },
|
||||
lifecycle: { requestForeground: async () => ({ disposition: "accepted" as const }), requestBackground: async () => ({ disposition: "accepted" as const }), requestClose: async () => ({ disposition: "accepted" as const }) },
|
||||
surfaces: { request: async (event: "open" | "patch" | "close") => { events.push(event); return { disposition: "accepted" as const }; } },
|
||||
capabilities: { request: async () => ({ disposition: "accepted" as const }) },
|
||||
workspace: () => ({ version: 1 as const, instances: { version: 1 as const, instances: [] }, focus: { version: 1 as const, stack: [] } }),
|
||||
};
|
||||
const app = new WhiteboardMiniApp(sdk);
|
||||
await app.open({ nodes: [] });
|
||||
await app.patch({ nodes: [{ id: "n1" }] });
|
||||
await app.close();
|
||||
expect(events).toEqual(["open", "patch", "close"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { LineUpMiniAppSDK } from "@/runtime/app-management/miniapp-sdk";
|
||||
import type { JsonObject } from "@/runtime/protocol/lineup-v1";
|
||||
|
||||
/** Minimal bundled reference App: exercises isolated Surface requests and artifact metadata. */
|
||||
export class WhiteboardMiniApp {
|
||||
private state: JsonObject = {};
|
||||
private unsubscribeTools: (() => void) | undefined;
|
||||
|
||||
public constructor(private readonly sdk: LineUpMiniAppSDK) {}
|
||||
|
||||
public async open(initialState: JsonObject = {}): Promise<void> {
|
||||
this.state = { ...initialState };
|
||||
await this.sdk.surfaces.request("open", { state: this.state });
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
void this.open({ title: "Whiteboard", status: "可编辑", elements: [] });
|
||||
this.unsubscribeTools = this.sdk.tools.subscribe(call => { void this.handleTool(call); });
|
||||
}
|
||||
|
||||
private async handleTool(call: { call_id: string; tool_id: string; input: JsonObject }): Promise<void> {
|
||||
if (call.tool_id === "whiteboard.open") {
|
||||
await this.sdk.tools.reportProgress(call.call_id, { status: "opening", percent: 40 });
|
||||
await this.sdk.tools.complete(call.call_id, { status: "completed" });
|
||||
return;
|
||||
}
|
||||
if (call.tool_id === "whiteboard.submit") {
|
||||
const artifactID = typeof call.input.artifact_id === "string" ? call.input.artifact_id : `whiteboard-${call.call_id}`;
|
||||
await this.sdk.tools.reportProgress(call.call_id, { status: "exporting", percent: 80 });
|
||||
await this.sdk.surfaces.request("patch", { state: this.state, artifact: { artifact_id: artifactID } });
|
||||
await this.sdk.tools.complete(call.call_id, { artifact_id: artifactID });
|
||||
return;
|
||||
}
|
||||
await this.sdk.tools.fail(call.call_id, { code: "tool_not_supported", message: `不支持的画板工具:${call.tool_id}` });
|
||||
}
|
||||
|
||||
/** Drawing/editing is private Surface state and never enters the IM transcript. */
|
||||
public async patch(delta: JsonObject): Promise<void> {
|
||||
this.state = { ...this.state, ...delta };
|
||||
await this.sdk.surfaces.request("patch", { state: this.state });
|
||||
}
|
||||
|
||||
public async exportArtifact(artifact: JsonObject): Promise<void> {
|
||||
await this.sdk.surfaces.request("patch", { artifact });
|
||||
}
|
||||
|
||||
public async close(): Promise<void> {
|
||||
this.unsubscribeTools?.();
|
||||
this.unsubscribeTools = undefined;
|
||||
await this.sdk.surfaces.request("close", {});
|
||||
}
|
||||
}
|
||||
+734
-240
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
import type { AppInstanceRecord } from "@/runtime/app-management/app-instance-manager";
|
||||
|
||||
export type AppFocusSnapshot = { version: 1; stack: readonly string[] };
|
||||
|
||||
/** Focus is a stack, not an App-owned boolean: only Runtime may mutate it. */
|
||||
export class AppFocusManager {
|
||||
private stack: string[] = [];
|
||||
public foreground(): string | undefined { return this.stack.at(-1); }
|
||||
public history(): readonly string[] { return [...this.stack]; }
|
||||
public foregroundInstance(instances: { get(id: string): AppInstanceRecord | undefined }): AppInstanceRecord | undefined {
|
||||
const id = this.foreground(); return id ? instances.get(id) : undefined;
|
||||
}
|
||||
public push(instanceID: string): void { this.stack = [...this.stack.filter(id => id !== instanceID), instanceID]; }
|
||||
public remove(instanceID: string): void { this.stack = this.stack.filter(id => id !== instanceID); }
|
||||
public restore(snapshot: AppFocusSnapshot, hasInstance: (id: string) => boolean): void {
|
||||
this.stack = snapshot?.version === 1 && Array.isArray(snapshot.stack) ? snapshot.stack.filter((id): id is string => typeof id === "string" && hasInstance(id)) : [];
|
||||
}
|
||||
public snapshot(): AppFocusSnapshot { return { version: 1, stack: this.history() }; }
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { AppScope } from "@/runtime/app-management/app-registry";
|
||||
|
||||
export type AppInstanceState = "starting" | "foreground" | "background" | "suspended" | "stopping" | "stopped" | "failed";
|
||||
|
||||
export type AppInstanceRecord = {
|
||||
instance_id: string;
|
||||
app_session_id?: string;
|
||||
app_scope: AppScope;
|
||||
conversation_id?: string;
|
||||
state: AppInstanceState;
|
||||
parent_instance_id?: string;
|
||||
continued_from_app_session_id?: string;
|
||||
started_at: string;
|
||||
stopped_at?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type AppInstanceSnapshot = { version: 1; instances: readonly AppInstanceRecord[] };
|
||||
|
||||
const ACTIVE = new Set<AppInstanceState>(["starting", "foreground", "background", "suspended", "stopping"]);
|
||||
|
||||
/** Runtime's authoritative instance table; Apps receive only their own record. */
|
||||
export class AppInstanceManager {
|
||||
private readonly records = new Map<string, AppInstanceRecord>();
|
||||
|
||||
public create(record: Omit<AppInstanceRecord, "state" | "started_at"> & { state?: "starting"; started_at?: string }, now: string): AppInstanceRecord {
|
||||
if (!validID(record.instance_id) || this.records.has(record.instance_id)) throw new Error("App Instance id must be unique and valid.");
|
||||
if (record.parent_instance_id && !this.records.has(record.parent_instance_id)) throw new Error("App Instance parent is not running.");
|
||||
const created: AppInstanceRecord = { ...record, state: "starting", started_at: record.started_at ?? now };
|
||||
this.records.set(created.instance_id, created);
|
||||
return copy(created);
|
||||
}
|
||||
|
||||
public get(instanceID: string): AppInstanceRecord | undefined { const value = this.records.get(instanceID); return value && copy(value); }
|
||||
public list(conversationID?: string): readonly AppInstanceRecord[] {
|
||||
return [...this.records.values()].filter(record => !conversationID || record.conversation_id === conversationID).map(copy);
|
||||
}
|
||||
public activeFor(appScope: AppScope, conversationID?: string): AppInstanceRecord | undefined {
|
||||
return this.list(conversationID).find(record => record.app_scope === appScope && ACTIVE.has(record.state));
|
||||
}
|
||||
public transition(instanceID: string, state: AppInstanceState, now: string, error?: string): AppInstanceRecord {
|
||||
const current = this.records.get(instanceID);
|
||||
if (!current || !allowed(current.state, state)) throw new Error(`Invalid App Instance transition: ${current?.state ?? "missing"} -> ${state}`);
|
||||
const next: AppInstanceRecord = { ...current, state, ...(state === "stopped" || state === "failed" ? { stopped_at: now } : {}), ...(error ? { error } : {}) };
|
||||
this.records.set(instanceID, next);
|
||||
return copy(next);
|
||||
}
|
||||
public restore(snapshot: AppInstanceSnapshot): void {
|
||||
this.records.clear();
|
||||
if (snapshot?.version !== 1 || !Array.isArray(snapshot.instances)) return;
|
||||
for (const raw of snapshot.instances) {
|
||||
if (!raw || !validID(raw.instance_id) || (raw.app_session_id !== undefined && !validID(raw.app_session_id)) || (raw.continued_from_app_session_id !== undefined && !validID(raw.continued_from_app_session_id)) || typeof raw.app_scope !== "string" || !isState(raw.state) || !validTime(raw.started_at)) continue;
|
||||
// A host cannot safely resume an in-progress native operation. It can
|
||||
// however present suspended apps and let the orchestrator restore focus.
|
||||
const recovered = raw.state === "starting" || raw.state === "stopping" ? { ...raw, state: "suspended" as const } : raw;
|
||||
this.records.set(recovered.instance_id, copy(recovered));
|
||||
}
|
||||
}
|
||||
public snapshot(): AppInstanceSnapshot { return { version: 1, instances: this.list() }; }
|
||||
}
|
||||
|
||||
function allowed(from: AppInstanceState, to: AppInstanceState): boolean {
|
||||
if (from === to) return true;
|
||||
return ({ starting: ["foreground", "background", "suspended", "failed", "stopping"], foreground: ["background", "suspended", "stopping", "failed"], background: ["foreground", "suspended", "stopping", "failed"], suspended: ["foreground", "background", "stopping", "failed"], stopping: ["stopped", "failed"], stopped: [], failed: [] } as Record<AppInstanceState, AppInstanceState[]>)[from].includes(to);
|
||||
}
|
||||
function isState(value: unknown): value is AppInstanceState { return typeof value === "string" && ["starting", "foreground", "background", "suspended", "stopping", "stopped", "failed"].includes(value); }
|
||||
function validID(value: unknown): value is string { return typeof value === "string" && /^[A-Za-z0-9._:-]{1,128}$/.test(value); }
|
||||
function validTime(value: unknown): value is string { return typeof value === "string" && !Number.isNaN(Date.parse(value)); }
|
||||
function copy(value: AppInstanceRecord): AppInstanceRecord { return { ...value }; }
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AppLifecycleManager } from "@/runtime/app-management/app-lifecycle-manager";
|
||||
|
||||
const now = "2026-08-04T00:00:00.000Z";
|
||||
|
||||
describe("AppLifecycleManager", () => {
|
||||
it("preserves the prior foreground instance and restores a bounded workspace snapshot", () => {
|
||||
const lifecycle = new AppLifecycleManager();
|
||||
lifecycle.start({ instance_id: "interaction:audio-001", app_scope: "chat", conversation_id: "conversation" }, now);
|
||||
lifecycle.foreground("interaction:audio-001", now);
|
||||
lifecycle.start({ instance_id: "draw-and-guess:game-001", app_scope: "draw-and-guess", conversation_id: "conversation", parent_instance_id: "interaction:audio-001" }, now);
|
||||
lifecycle.foreground("draw-and-guess:game-001", now);
|
||||
|
||||
expect(lifecycle.instances.get("interaction:audio-001")).toMatchObject({ state: "background" });
|
||||
expect(lifecycle.instances.get("draw-and-guess:game-001")).toMatchObject({ state: "foreground" });
|
||||
expect(lifecycle.focus.history()).toEqual(["interaction:audio-001", "draw-and-guess:game-001"]);
|
||||
|
||||
lifecycle.close("draw-and-guess:game-001", now);
|
||||
lifecycle.foreground("interaction:audio-001", now);
|
||||
const restored = new AppLifecycleManager();
|
||||
restored.restore(lifecycle.snapshot());
|
||||
expect(restored.focus.foreground()).toBe("interaction:audio-001");
|
||||
expect(restored.instances.get("draw-and-guess:game-001")).toMatchObject({ state: "stopped" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { AppFocusManager, type AppFocusSnapshot } from "@/runtime/app-management/app-focus-manager";
|
||||
import { AppInstanceManager, type AppInstanceRecord, type AppInstanceSnapshot } from "@/runtime/app-management/app-instance-manager";
|
||||
import type { AppScope } from "@/runtime/app-management/app-registry";
|
||||
|
||||
export type AppWorkspaceSnapshot = { version: 1; instances: AppInstanceSnapshot; focus: AppFocusSnapshot };
|
||||
|
||||
/** Coordinates legal transitions and restoration; it never mounts a DOM Host. */
|
||||
export class AppLifecycleManager {
|
||||
public readonly instances = new AppInstanceManager();
|
||||
public readonly focus = new AppFocusManager();
|
||||
public start(input: { instance_id: string; app_scope: AppScope; conversation_id?: string; app_session_id?: string; parent_instance_id?: string; continued_from_app_session_id?: string }, now: string): AppInstanceRecord { return this.instances.create(input, now); }
|
||||
public foreground(instanceID: string, now: string): AppInstanceRecord {
|
||||
const current = this.instances.get(instanceID); if (!current) throw new Error("Unknown App Instance.");
|
||||
const prior = this.focus.foreground();
|
||||
if (prior && prior !== instanceID) { const record = this.instances.get(prior); if (record?.state === "foreground") this.instances.transition(prior, "background", now); }
|
||||
const next = this.instances.transition(instanceID, "foreground", now); this.focus.push(instanceID); return next;
|
||||
}
|
||||
public background(instanceID: string, now: string): AppInstanceRecord { const next = this.instances.transition(instanceID, "background", now); if (this.focus.foreground() === instanceID) this.focus.remove(instanceID); return next; }
|
||||
public suspend(instanceID: string, now: string): AppInstanceRecord { const next = this.instances.transition(instanceID, "suspended", now); if (this.focus.foreground() === instanceID) this.focus.remove(instanceID); return next; }
|
||||
public close(instanceID: string, now: string, error?: string): AppInstanceRecord | undefined {
|
||||
const current = this.instances.get(instanceID); if (!current || current.state === "stopped" || current.state === "failed") return current;
|
||||
this.instances.transition(instanceID, "stopping", now); const finished = this.instances.transition(instanceID, error ? "failed" : "stopped", now, error); this.focus.remove(instanceID); return finished;
|
||||
}
|
||||
public restore(snapshot: AppWorkspaceSnapshot): void { this.instances.restore(snapshot?.instances); this.focus.restore(snapshot?.focus, id => Boolean(this.instances.get(id))); }
|
||||
public snapshot(): AppWorkspaceSnapshot { return { version: 1, instances: this.instances.snapshot(), focus: this.focus.snapshot() }; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CoreAppRegistry } from "@/runtime/app-management/app-registry";
|
||||
|
||||
describe("CoreAppRegistry SDK v1 kind policy", () => {
|
||||
it("keeps Interact as the only system MiniApp", () => {
|
||||
const registry = new CoreAppRegistry();
|
||||
expect(registry.get("chat")?.kind).toBe("system");
|
||||
expect(() => registry.install({
|
||||
app_scope: "other-system", kind: "system", enabled: true, default_eligible: false, recovery: false,
|
||||
manifest: { app_scope: "other-system", version: "1.0.0", permissions: [], tools: [] },
|
||||
})).toThrow("Only the Interact system MiniApp");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Runtime 本地 Core App 注册表。
|
||||
*
|
||||
* 当前 MVP 只注册默认且可恢复的 `chat`;后续 App Registry/市场只能通过 Runtime
|
||||
* 管理记录,App 自身不得直接篡改注册表。
|
||||
*
|
||||
* Runtime-local application registry for the first App MVP.
|
||||
*
|
||||
* `app_scope` deliberately is a local routing key, not a publisher identity
|
||||
* or a marketplace namespace. The initial registry is static, but the same
|
||||
* record shape is the boundary that the later App Registry/Market will own.
|
||||
*/
|
||||
import { validateMiniAppManifest, type MiniAppManifestV1 } from "@/runtime/app-management/miniapp-manifest";
|
||||
import { BUNDLED_REFERENCE_MANIFESTS } from "@/runtime/app-management/reference-miniapps";
|
||||
import type { JsonObject } from "@/runtime/protocol/lineup-v1";
|
||||
|
||||
export type AppScope = "chat" | "app-registry" | "settings" | "runtime" | (string & {});
|
||||
export type MiniAppKind = "system" | "bundled";
|
||||
const SYSTEM_APP_SCOPE: AppScope = "chat";
|
||||
|
||||
export type AppToolDefinition = {
|
||||
name: string;
|
||||
handling: "direct" | "interactive" | "launch" | "foreground" | "operation";
|
||||
/** JSON-object property names accepted by this tool. */
|
||||
parameters: readonly string[];
|
||||
requires_permissions?: readonly string[];
|
||||
input_schema?: JsonObject;
|
||||
output_schema?: JsonObject;
|
||||
target?: {
|
||||
app_scope: AppScope;
|
||||
requires_foreground: boolean;
|
||||
restore_previous_focus: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* A Runtime-validated app manifest. This deliberately contains declarative
|
||||
* metadata only: no bundle URL, executable code, or Host capability handle.
|
||||
*/
|
||||
export type AppManifest = {
|
||||
app_scope: AppScope;
|
||||
version: string;
|
||||
tools: readonly AppToolDefinition[];
|
||||
permissions: readonly string[];
|
||||
};
|
||||
|
||||
export type CoreAppRecord = {
|
||||
app_scope: AppScope;
|
||||
kind: MiniAppKind;
|
||||
enabled: boolean;
|
||||
default_eligible: boolean;
|
||||
recovery: boolean;
|
||||
manifest: AppManifest;
|
||||
};
|
||||
|
||||
/**
|
||||
* The Runtime installation boundary uses the frozen SDK v1 vocabulary.
|
||||
* Legacy host labels (`core` / `extension`) are intentionally not accepted
|
||||
* here; callers must migrate before they can register an App.
|
||||
*/
|
||||
export type CoreAppRecordInput = Omit<CoreAppRecord, "kind"> & { kind: MiniAppKind };
|
||||
|
||||
const INITIAL_CORE_APPS: readonly CoreAppRecord[] = [
|
||||
{
|
||||
app_scope: "chat", kind: "system", enabled: true, default_eligible: true, recovery: true,
|
||||
manifest: {
|
||||
app_scope: "chat", version: "1.0.0", permissions: [],
|
||||
tools: [
|
||||
{ name: "choice", handling: "interactive", parameters: ["call_id", "tool", "title", "prompt", "data", "expires_at"] },
|
||||
{ name: "confirm", handling: "interactive", parameters: ["call_id", "tool", "title", "prompt", "data", "expires_at"] },
|
||||
{ name: "input", handling: "interactive", parameters: ["call_id", "tool", "title", "prompt", "data", "expires_at"] },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* The Runtime owns this registry. Apps may later request operations through
|
||||
* a Runtime SDK, but they never mutate this collection directly.
|
||||
*/
|
||||
export class CoreAppRegistry {
|
||||
private readonly apps = new Map<AppScope, CoreAppRecord>();
|
||||
|
||||
public constructor(records: readonly CoreAppRecordInput[] = INITIAL_CORE_APPS) {
|
||||
for (const record of records) this.register(record);
|
||||
if (records === INITIAL_CORE_APPS) {
|
||||
for (const manifest of BUNDLED_REFERENCE_MANIFESTS) this.installMiniAppManifest(manifest);
|
||||
}
|
||||
}
|
||||
|
||||
public list(): readonly CoreAppRecord[] {
|
||||
return [...this.apps.values()].map(copyRecord);
|
||||
}
|
||||
|
||||
public get(appScope: AppScope): CoreAppRecord | undefined {
|
||||
const record = this.apps.get(appScope);
|
||||
return record ? copyRecord(record) : undefined;
|
||||
}
|
||||
|
||||
public defaultApp(): CoreAppRecord {
|
||||
const selected = this.list().find(record => record.enabled && record.default_eligible);
|
||||
if (selected) return selected;
|
||||
const recovery = this.list().find(record => record.enabled && record.recovery);
|
||||
if (recovery) return recovery;
|
||||
throw new Error("Runtime has no enabled recovery application.");
|
||||
}
|
||||
|
||||
/** Runtime-only installation path; extension Apps never receive this object. */
|
||||
public install(record: CoreAppRecordInput): void { this.register(record); }
|
||||
|
||||
/** Admits a validated SDK v1 Manifest without exposing the Manifest object to Apps. */
|
||||
public installMiniAppManifest(manifest: MiniAppManifestV1, options: Pick<CoreAppRecord, "enabled" | "default_eligible" | "recovery"> = { enabled: true, default_eligible: false, recovery: false }): void {
|
||||
const validation = validateMiniAppManifest(manifest);
|
||||
if (!validation.accepted) throw new Error(`MiniApp Manifest denied: ${validation.detail}`);
|
||||
this.install({
|
||||
app_scope: manifest.app_scope,
|
||||
kind: manifest.kind,
|
||||
...options,
|
||||
manifest: {
|
||||
app_scope: manifest.app_scope,
|
||||
version: manifest.version,
|
||||
permissions: [...manifest.requested_capabilities],
|
||||
tools: manifest.tools.map(tool => ({
|
||||
name: tool.id,
|
||||
handling: tool.handling,
|
||||
parameters: [],
|
||||
input_schema: tool.input_schema,
|
||||
output_schema: tool.output_schema,
|
||||
target: tool.target,
|
||||
...(tool.permissions?.length ? { requires_permissions: [...tool.permissions] } : {}),
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private register(record: CoreAppRecordInput): void {
|
||||
if (!isAppScope(record.app_scope) || record.manifest.app_scope !== record.app_scope) throw new Error("Core App Registry received an invalid app_scope.");
|
||||
if (record.kind === "system" && record.app_scope !== SYSTEM_APP_SCOPE) throw new Error("Only the Interact system MiniApp may use kind=system in SDK v1.");
|
||||
if (this.apps.has(record.app_scope)) throw new Error(`Core App Registry has duplicate app_scope: ${record.app_scope}`);
|
||||
if (!isManifest(record.manifest)) throw new Error(`Core App Registry has an invalid manifest: ${record.app_scope}`);
|
||||
this.apps.set(record.app_scope, copyRecord(record));
|
||||
}
|
||||
}
|
||||
|
||||
function copyRecord(record: CoreAppRecord): CoreAppRecord {
|
||||
return {
|
||||
...record,
|
||||
manifest: {
|
||||
...record.manifest,
|
||||
permissions: [...record.manifest.permissions],
|
||||
tools: record.manifest.tools.map(tool => ({ ...tool, parameters: [...tool.parameters], ...(tool.input_schema ? { input_schema: clone(tool.input_schema) } : {}), ...(tool.output_schema ? { output_schema: clone(tool.output_schema) } : {}) })),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isManifest(manifest: AppManifest): boolean {
|
||||
const toolNames = new Set<string>();
|
||||
return typeof manifest.version === "string" && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(manifest.version)
|
||||
&& manifest.permissions.every(permission => /^[a-z][a-z0-9._-]{0,63}$/.test(permission))
|
||||
&& manifest.tools.every(tool => {
|
||||
const valid = /^[a-z][a-z0-9._-]{0,63}$/.test(tool.name)
|
||||
&& !toolNames.has(tool.name)
|
||||
&& ["direct", "interactive", "launch", "foreground", "operation"].includes(tool.handling)
|
||||
&& tool.parameters.every(parameter => /^[a-z][a-z0-9_]{0,63}$/.test(parameter))
|
||||
&& (!tool.input_schema || isJsonObject(tool.input_schema))
|
||||
&& (!tool.output_schema || isJsonObject(tool.output_schema))
|
||||
&& (!tool.requires_permissions || (tool.requires_permissions.length > 0
|
||||
&& new Set(tool.requires_permissions).size === tool.requires_permissions.length
|
||||
&& tool.requires_permissions.every(permission => /^[a-z][a-z0-9._-]{0,63}$/.test(permission)
|
||||
&& manifest.permissions.includes(permission))));
|
||||
toolNames.add(tool.name);
|
||||
return valid;
|
||||
});
|
||||
}
|
||||
|
||||
function clone<T>(value: T): T { return JSON.parse(JSON.stringify(value)) as T; }
|
||||
function isJsonObject(value: unknown): value is JsonObject { return Boolean(value && typeof value === "object" && !Array.isArray(value)); }
|
||||
|
||||
export function isAppScope(value: unknown): value is AppScope {
|
||||
return typeof value === "string" && /^[a-z][a-z0-9-]{0,63}$/.test(value);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Chat Core App 面向 Runtime 的受限 SDK 契约。
|
||||
*
|
||||
* SDK 暴露已筛选的状态、Agent 消息、可靠 Inbox 与 Runtime Action;它刻意不暴露
|
||||
* Transport、ConversationStore、认证信息或原始协议解析能力。
|
||||
*/
|
||||
import type { ConversationItem, JsonObject } from "@/runtime/protocol/lineup-v1";
|
||||
import type { AgentPresence, KernelSnapshot } from "@/runtime/coordination/interaction-kernel";
|
||||
import type { AppScope } from "@/runtime/app-management/app-registry";
|
||||
import type { RuntimeScope, ScopedConversationItem } from "@/runtime/coordination/runtime-envelope";
|
||||
import type { TaskRecord } from "@/runtime/coordination/task-state";
|
||||
import type { ToolCallRecord } from "@/runtime/coordination/tool-call-state";
|
||||
import type { AppWorkspaceSnapshot } from "@/runtime/app-management/app-lifecycle-manager";
|
||||
|
||||
export type Unsubscribe = () => void;
|
||||
|
||||
export type ChatViewModel = {
|
||||
app_scope: "chat";
|
||||
conversation_id?: string;
|
||||
active: boolean;
|
||||
agent_presence: readonly AgentPresence[];
|
||||
tool_calls: readonly ToolCallRecord[];
|
||||
tasks: readonly TaskRecord[];
|
||||
/** Collapsed App sub-session summaries; business UI actions are not included. */
|
||||
app_sessions: readonly Readonly<{
|
||||
app_session_id: string;
|
||||
app_scope: AppScope;
|
||||
instance_id: string;
|
||||
state: string;
|
||||
interaction_count: number;
|
||||
pending_interaction_count: number;
|
||||
history: readonly Readonly<{ id: string; role: "agent" | "user"; text: string }>[];
|
||||
continued_from_app_session_id?: string;
|
||||
}>[];
|
||||
};
|
||||
|
||||
export type AgentChatMessage = ScopedConversationItem & {
|
||||
scope: RuntimeScope & { app_scope: "chat" };
|
||||
};
|
||||
|
||||
export type ChatAction =
|
||||
| { type: "chat.send_text"; conversation_id: string; text: string; local_id?: string }
|
||||
| { type: "chat.submit_interaction"; conversation_id: string; call_id: string; result: JsonObject }
|
||||
| { type: "chat.cancel_interaction"; conversation_id: string; call_id: string; reason: string }
|
||||
/** A bounded result from a user-approved capability interaction. */
|
||||
| { type: "chat.report_app_result"; conversation_id: string; result: JsonObject }
|
||||
/** The current Chat Core App only exposes cancellation for a running Surface. */
|
||||
| { type: "chat.cancel_surface"; conversation_id: string; instance_id: string }
|
||||
| { type: "chat.continue_app_session"; conversation_id: string; app_session_id: string };
|
||||
|
||||
export type CommandReceipt =
|
||||
| { disposition: "accepted"; local_id?: string }
|
||||
| { disposition: "rejected"; code: "inactive" | "scope_mismatch" | "invalid_action" | "capability_not_declared" | "capability_unavailable" | "invalid_capability_request" | "capability_requires_foreground" };
|
||||
|
||||
export type InteractRuntimeOperations = Readonly<{
|
||||
dispatch(action: ChatAction): Promise<CommandReceipt>;
|
||||
interactions: {
|
||||
submit(callID: string, result: JsonObject): boolean;
|
||||
cancel(callID: string, reason: string): boolean;
|
||||
expire(callID: string): boolean;
|
||||
read(callID: string): ToolCallRecord | undefined;
|
||||
loadDraft(callID: string): JsonObject | undefined;
|
||||
saveDraft(callID: string, values: JsonObject): void;
|
||||
clearDraft(callID: string): void;
|
||||
};
|
||||
tasks: {
|
||||
requestCancel(operationID: string): boolean;
|
||||
read(operationID: string): TaskRecord | undefined;
|
||||
};
|
||||
}>;
|
||||
|
||||
export type InteractUIProjection = Readonly<{
|
||||
snapshot(): ChatViewModel;
|
||||
subscribe(listener: (view: ChatViewModel) => void): Unsubscribe;
|
||||
subscribeEvents(listener: (event: Exclude<RuntimeAppEvent, { type: "agent-message" }>) => void): Unsubscribe;
|
||||
subscribeAgentMessages(listener: (message: AgentChatMessage) => void): Unsubscribe;
|
||||
listAgentMessages(afterMessageID?: string): readonly AgentChatMessage[];
|
||||
acknowledgeAgentMessage(messageID: string): boolean;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Interact's SDK is the common Runtime contract plus a trusted-DOM UI projection.
|
||||
* The UI projection is an adapter for IM rendering; it is not a second transport,
|
||||
* persistence, Tool or capability protocol.
|
||||
*/
|
||||
export interface InteractRuntimeSDK {
|
||||
readonly app_scope: "chat";
|
||||
readonly runtime: InteractRuntimeOperations;
|
||||
readonly ui: InteractUIProjection;
|
||||
snapshot(): ChatViewModel;
|
||||
subscribe(listener: (view: ChatViewModel) => void): Unsubscribe;
|
||||
/** Chat-safe Runtime events, excluding raw Agent delivery (use subscribeAgentMessages). */
|
||||
subscribeEvents(listener: (event: Exclude<RuntimeAppEvent, { type: "agent-message" }>) => void): Unsubscribe;
|
||||
subscribeAgentMessages(
|
||||
listener: (message: AgentChatMessage) => void,
|
||||
): Unsubscribe;
|
||||
listAgentMessages(afterMessageID?: string): readonly AgentChatMessage[];
|
||||
acknowledgeAgentMessage(messageID: string): boolean;
|
||||
dispatch(action: ChatAction): Promise<CommandReceipt>;
|
||||
readonly interactions: {
|
||||
submit(callID: string, result: JsonObject): boolean;
|
||||
cancel(callID: string, reason: string): boolean;
|
||||
expire(callID: string): boolean;
|
||||
read(callID: string): ToolCallRecord | undefined;
|
||||
loadDraft(callID: string): JsonObject | undefined;
|
||||
saveDraft(callID: string, values: JsonObject): void;
|
||||
clearDraft(callID: string): void;
|
||||
};
|
||||
readonly tasks: {
|
||||
requestCancel(operationID: string): boolean;
|
||||
read(operationID: string): TaskRecord | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
/** @deprecated Use InteractRuntimeSDK; kept for the compatibility app_scope=chat name. */
|
||||
export type ChatRuntimeSDK = InteractRuntimeSDK;
|
||||
|
||||
export type RuntimeAppEvent =
|
||||
| { type: "agent-message"; message: ScopedConversationItem }
|
||||
| { type: "local-item"; item: ConversationItem; local_id: string }
|
||||
| { type: "delivery"; local_id: string; item?: ConversationItem }
|
||||
| { type: "sync-status"; status: "syncing" | "idle" | "retrying" }
|
||||
| { type: "scope-rejected"; reason: "invalid_scope" | "scope_mismatch"; raw: string }
|
||||
| { type: "state"; snapshot: KernelSnapshot }
|
||||
| { type: "app-lifecycle"; workspace: AppWorkspaceSnapshot; reason: "launched" | "closed" | "failed" | "restored" }
|
||||
/** A Runtime-approved local Surface request from a bundled MiniApp. */
|
||||
| {
|
||||
type: "surface-request";
|
||||
app_scope: AppScope;
|
||||
instance_id: string;
|
||||
event: "open" | "patch" | "close";
|
||||
data: JsonObject;
|
||||
};
|
||||
|
||||
export type AppMessageSubscription = {
|
||||
app_scope: AppScope;
|
||||
listener: (message: ScopedConversationItem) => void;
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"contract": "lineup-miniapp-sdk-v1-golden-1",
|
||||
"description": "03.sdk_and_coreapp frozen runtime contract cases. These cases express the required observable result; they do not grant MiniApps access to Runtime internals.",
|
||||
"cases": [
|
||||
{
|
||||
"id": "bundled-manifest-requires-surface",
|
||||
"input": { "manifest": { "kind": "bundled", "host": { "surface_required": true } } },
|
||||
"expected": { "accepted": true }
|
||||
},
|
||||
{
|
||||
"id": "bundled-manifest-without-surface-rejected",
|
||||
"input": { "manifest": { "kind": "bundled", "host": { "surface_required": false } } },
|
||||
"expected": { "accepted": false, "code": "manifest_denied" }
|
||||
},
|
||||
{
|
||||
"id": "interactive-without-app-context-goes-to-main-im",
|
||||
"input": { "tool": { "handling": "interactive" } },
|
||||
"expected": { "app_session_id": null, "creates_business_instance": false, "enters_miniapp_inbox": false }
|
||||
},
|
||||
{
|
||||
"id": "interactive-with-verified-closed-app-session-keeps-history-context",
|
||||
"input": { "tool": { "handling": "interactive" }, "app_session_context": { "app_session_id": "session-whiteboard-1", "state": "ended", "same_agent": true, "same_conversation": true } },
|
||||
"expected": { "app_session_id": "session-whiteboard-1", "creates_business_instance": false, "revives_instance": false }
|
||||
},
|
||||
{
|
||||
"id": "interactive-with-cross-conversation-app-session-rejected",
|
||||
"input": { "tool": { "handling": "interactive" }, "app_session_context": { "app_session_id": "session-other", "state": "active", "same_agent": true, "same_conversation": false } },
|
||||
"expected": { "accepted": false, "code": "app_session_context_invalid", "creates_interaction": false }
|
||||
},
|
||||
{
|
||||
"id": "answerable-interaction-uses-default-expiry",
|
||||
"input": { "request": { "kind": "confirm" } },
|
||||
"expected": { "expires_in_ms": 900000 }
|
||||
},
|
||||
{
|
||||
"id": "answerable-interaction-expiry-boundary",
|
||||
"input": { "request": { "kind": "input", "expires_in_ms": 60000 } },
|
||||
"expected": { "accepted": true, "expires_in_ms": 60000 }
|
||||
},
|
||||
{
|
||||
"id": "notice-does-not-accept-expiry",
|
||||
"input": { "request": { "kind": "notice", "expires_in_ms": 60000 } },
|
||||
"expected": { "accepted": false, "code": "interaction_request_invalid" }
|
||||
},
|
||||
{
|
||||
"id": "dismiss-is-idempotent-and-addressed-by-call-id",
|
||||
"input": { "dismiss": { "control_id": "dismiss-1", "call_id": "interactive-call-1" }, "interaction": { "status": "pending", "same_agent": true, "same_conversation": true } },
|
||||
"expected": { "status": "cancelled", "tool_outbox_count": 1, "replay_outbox_count": 0 }
|
||||
},
|
||||
{
|
||||
"id": "accepted-app-close-cancels-running-tool",
|
||||
"input": { "close": { "accepted": true }, "tool": { "status": "running", "instance_id": "task-dashboard-1" } },
|
||||
"expected": { "tool_status": "cancelled", "cancel_reason": "app_closed", "tool_outbox_count": 1, "close_surface": true, "stop_instance": true }
|
||||
},
|
||||
{
|
||||
"id": "accepted-app-close-preserves-submitted-result",
|
||||
"input": { "close": { "accepted": true }, "tool": { "status": "submitted", "instance_id": "whiteboard-1" } },
|
||||
"expected": { "tool_status": "submitted", "continue_existing_outbox": true, "close_surface": true, "stop_instance": true }
|
||||
},
|
||||
{
|
||||
"id": "tool-complete-does-not-close-bundled-app",
|
||||
"input": { "tool": { "status": "completed", "app_kind": "bundled" } },
|
||||
"expected": { "close_surface": false, "stop_instance": false, "end_app_session": false }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* MiniApp SDK v1 的声明性 Manifest 契约。
|
||||
*
|
||||
* 该模块只验证 Runtime 接受的声明,不加载 Bundle、不创建 DOM,也不授予任何能力。
|
||||
* Runtime、Inventory 和 Tool Router 共享它,避免各自解释 Manifest。
|
||||
*/
|
||||
import type { JsonObject } from "@/runtime/protocol/lineup-v1";
|
||||
|
||||
export type MiniAppKind = "system" | "bundled";
|
||||
export type MiniAppToolHandling = "direct" | "interactive" | "launch" | "foreground" | "operation";
|
||||
|
||||
export type MiniAppToolTarget = Readonly<{
|
||||
app_scope: string;
|
||||
requires_foreground: boolean;
|
||||
restore_previous_focus: boolean;
|
||||
}>;
|
||||
|
||||
export type MiniAppToolDescriptor = Readonly<{
|
||||
id: string;
|
||||
version: 1;
|
||||
handling: MiniAppToolHandling;
|
||||
target?: MiniAppToolTarget;
|
||||
input_schema: JsonObject;
|
||||
output_schema: JsonObject;
|
||||
permissions?: readonly string[];
|
||||
timeout_ms?: number;
|
||||
}>;
|
||||
|
||||
export type MiniAppManifestV1 = Readonly<{
|
||||
app_scope: string;
|
||||
version: string;
|
||||
kind: MiniAppKind;
|
||||
tools: readonly MiniAppToolDescriptor[];
|
||||
subscriptions: readonly string[];
|
||||
requested_capabilities: readonly string[];
|
||||
host: Readonly<{
|
||||
min_version: string;
|
||||
surface_required: boolean;
|
||||
}>;
|
||||
}>;
|
||||
|
||||
export type ManifestValidation =
|
||||
| Readonly<{ accepted: true }>
|
||||
| Readonly<{ accepted: false; code: "manifest_denied"; detail: string }>;
|
||||
|
||||
const APP_SCOPE = /^[a-z][a-z0-9-]{0,63}$/;
|
||||
const TOOL_ID = /^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$/;
|
||||
const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
||||
const CAPABILITY = /^[a-z][a-z0-9._-]{0,63}$/;
|
||||
const HANDLINGS = new Set<MiniAppToolHandling>(["direct", "interactive", "launch", "foreground", "operation"]);
|
||||
|
||||
/**
|
||||
* Checks only static, local rules. Policy and per-user capability approval are
|
||||
* intentionally deferred to Runtime, because a Manifest never grants access.
|
||||
*/
|
||||
export function validateMiniAppManifest(manifest: MiniAppManifestV1): ManifestValidation {
|
||||
if (!APP_SCOPE.test(manifest.app_scope)) return reject("app_scope must be a lowercase Runtime scope.");
|
||||
if (!SEMVER.test(manifest.version) || !SEMVER.test(manifest.host.min_version)) return reject("version and host.min_version must be semantic versions.");
|
||||
if (manifest.kind !== "system" && manifest.kind !== "bundled") return reject("kind must be system or bundled.");
|
||||
if (manifest.kind === "bundled" && manifest.host.surface_required !== true) return reject("bundled MiniApps require a restricted Surface.");
|
||||
if (!Array.isArray(manifest.tools) || !Array.isArray(manifest.subscriptions) || !Array.isArray(manifest.requested_capabilities)) return reject("manifest collections must be arrays.");
|
||||
if (!manifest.subscriptions.every(entry => typeof entry === "string" && entry.length > 0)) return reject("subscriptions must be non-empty strings.");
|
||||
if (!manifest.requested_capabilities.every(entry => CAPABILITY.test(entry))) return reject("requested capabilities contain an invalid identifier.");
|
||||
|
||||
const toolIDs = new Set<string>();
|
||||
for (const tool of manifest.tools) {
|
||||
if (!TOOL_ID.test(tool.id) || toolIDs.has(tool.id)) return reject("tool IDs must be unique dotted identifiers.");
|
||||
toolIDs.add(tool.id);
|
||||
if (tool.version !== 1 || !HANDLINGS.has(tool.handling)) return reject("tool version or handling is invalid.");
|
||||
if (!isJsonObject(tool.input_schema) || !isJsonObject(tool.output_schema)) return reject("tool schemas must be JSON objects.");
|
||||
if (tool.permissions && !tool.permissions.every((permission: string) => manifest.requested_capabilities.includes(permission))) return reject("tool permissions must be declared by the manifest.");
|
||||
if (tool.timeout_ms !== undefined && (!Number.isInteger(tool.timeout_ms) || tool.timeout_ms <= 0)) return reject("tool timeout_ms must be a positive integer.");
|
||||
|
||||
if (tool.handling === "interactive") {
|
||||
if (tool.target !== undefined) return reject("interactive Tools must not target a MiniApp.");
|
||||
if (tool.timeout_ms !== undefined) return reject("interactive Tools use interaction expiry, not tool timeout_ms.");
|
||||
} else if (!validTarget(tool.target)) {
|
||||
return reject("non-interactive Tools require a valid target.");
|
||||
}
|
||||
}
|
||||
|
||||
return { accepted: true };
|
||||
}
|
||||
|
||||
function validTarget(target: MiniAppToolTarget | undefined): target is MiniAppToolTarget {
|
||||
return Boolean(target
|
||||
&& APP_SCOPE.test(target.app_scope)
|
||||
&& typeof target.requires_foreground === "boolean"
|
||||
&& typeof target.restore_previous_focus === "boolean");
|
||||
}
|
||||
|
||||
function isJsonObject(value: unknown): value is JsonObject {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function reject(detail: string): ManifestValidation {
|
||||
return { accepted: false, code: "manifest_denied", detail };
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import fixture from "@/runtime/app-management/golden/miniapp-sdk-v1.json";
|
||||
import { validateMiniAppManifest, type MiniAppManifestV1 } from "@/runtime/app-management/miniapp-manifest";
|
||||
import { validateStandardInteractionAnswer, validateStandardInteractionRequest } from "@/runtime/coordination/standard-interaction-contract";
|
||||
|
||||
type GoldenCase = Readonly<{
|
||||
id: string;
|
||||
input: Readonly<Record<string, unknown>>;
|
||||
expected: Readonly<Record<string, unknown>>;
|
||||
}>;
|
||||
|
||||
const cases = fixture.cases as readonly GoldenCase[];
|
||||
|
||||
describe("MiniApp SDK v1 frozen golden contract", () => {
|
||||
it("uses the versioned 03.sdk_and_coreapp fixture and stable case ordering", () => {
|
||||
expect(fixture.contract).toBe("lineup-miniapp-sdk-v1-golden-1");
|
||||
expect(cases.map(entry => entry.id)).toEqual([
|
||||
"bundled-manifest-requires-surface",
|
||||
"bundled-manifest-without-surface-rejected",
|
||||
"interactive-without-app-context-goes-to-main-im",
|
||||
"interactive-with-verified-closed-app-session-keeps-history-context",
|
||||
"interactive-with-cross-conversation-app-session-rejected",
|
||||
"answerable-interaction-uses-default-expiry",
|
||||
"answerable-interaction-expiry-boundary",
|
||||
"notice-does-not-accept-expiry",
|
||||
"dismiss-is-idempotent-and-addressed-by-call-id",
|
||||
"accepted-app-close-cancels-running-tool",
|
||||
"accepted-app-close-preserves-submitted-result",
|
||||
"tool-complete-does-not-close-bundled-app",
|
||||
]);
|
||||
});
|
||||
|
||||
it("contains only cases with an input and observable expected result", () => {
|
||||
for (const entry of cases) {
|
||||
expect(entry.id).toMatch(/^[a-z][a-z0-9-]+$/);
|
||||
expect(Object.keys(entry.input).length).toBeGreaterThan(0);
|
||||
expect(Object.keys(entry.expected).length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("enforces the frozen bundled Surface boundary", () => {
|
||||
const manifest = (surfaceRequired: boolean): MiniAppManifestV1 => ({
|
||||
app_scope: "task-dashboard",
|
||||
version: "1.0.0",
|
||||
kind: "bundled",
|
||||
tools: [],
|
||||
subscriptions: [],
|
||||
requested_capabilities: [],
|
||||
host: { min_version: "1.0.0", surface_required: surfaceRequired },
|
||||
});
|
||||
expect(validateMiniAppManifest(manifest(true))).toEqual({ accepted: true });
|
||||
expect(validateMiniAppManifest(manifest(false))).toMatchObject({ accepted: false, code: "manifest_denied" });
|
||||
});
|
||||
|
||||
it("enforces frozen standard-interaction expiry rules", () => {
|
||||
const now = new Date("2026-08-05T00:00:00.000Z");
|
||||
expect(validateStandardInteractionRequest({ kind: "confirm", title: "继续", prompt: "继续吗?" }, now)).toEqual({
|
||||
accepted: true,
|
||||
expires_at: "2026-08-05T00:15:00.000Z",
|
||||
});
|
||||
expect(validateStandardInteractionRequest({ kind: "input", title: "描述", prompt: "请输入", expires_in_ms: 60_000, field: { id: "text", label: "描述", type: "textarea" } }, now)).toMatchObject({ accepted: true });
|
||||
expect(validateStandardInteractionRequest({ kind: "notice", title: "提示", message: "已保存", expires_in_ms: 60_000 } as never, now)).toEqual({ accepted: false, code: "interaction_request_invalid" });
|
||||
});
|
||||
|
||||
it("accepts only the frozen answer shape for each standard interaction", () => {
|
||||
const choice = { kind: "choice", title: "选择", prompt: "选一个", mode: "single-choice", actions: [{ id: "a", label: "A" }, { id: "b", label: "B" }] } as const;
|
||||
const confirm = { kind: "confirm", title: "确认", prompt: "继续吗?" } as const;
|
||||
const input = { kind: "input", title: "描述", prompt: "请输入", field: { id: "text", label: "描述", type: "textarea" } } as const;
|
||||
expect(validateStandardInteractionAnswer(choice, { action_id: "a" })).toBe(true);
|
||||
expect(validateStandardInteractionAnswer(choice, { action_ids: ["a"] })).toBe(false);
|
||||
expect(validateStandardInteractionAnswer(confirm, { approved: true })).toBe(true);
|
||||
expect(validateStandardInteractionAnswer(confirm, { confirmed: true })).toBe(false);
|
||||
expect(validateStandardInteractionAnswer(input, { text: "hello" })).toBe(true);
|
||||
expect(validateStandardInteractionAnswer(input, { values: { text: "hello" } })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { AppScope } from "@/runtime/app-management/app-registry";
|
||||
import type { ScopedConversationItem } from "@/runtime/coordination/runtime-envelope";
|
||||
import type { MiniAppToolCallRecord } from "@/runtime/coordination/miniapp-tool-state";
|
||||
import type { JsonObject } from "@/runtime/protocol/lineup-v1";
|
||||
import type { CommandReceipt, Unsubscribe } from "@/runtime/app-management/app-sdk";
|
||||
|
||||
export type MiniAppSDKContext = Readonly<{
|
||||
app_scope: AppScope;
|
||||
instance_id: string;
|
||||
conversation_id: string;
|
||||
app_version: string;
|
||||
state: "starting" | "foreground" | "background" | "suspended";
|
||||
}>;
|
||||
|
||||
export type MiniAppToolProgress = Readonly<{ percent?: number; status?: string; detail?: string }>;
|
||||
export type MiniAppToolFailure = Readonly<{ code?: string; message: string }>;
|
||||
|
||||
export interface LineUpMiniAppSDK {
|
||||
readonly context: MiniAppSDKContext;
|
||||
readonly inbox: {
|
||||
list(after_message_id?: string): readonly ScopedConversationItem[];
|
||||
subscribe(listener: (message: ScopedConversationItem) => void): Unsubscribe;
|
||||
acknowledge(message_id: string): boolean;
|
||||
};
|
||||
readonly tools: {
|
||||
subscribe(listener: (call: MiniAppToolCallRecord) => void): Unsubscribe;
|
||||
get(call_id: string): MiniAppToolCallRecord | undefined;
|
||||
reportProgress(call_id: string, progress: MiniAppToolProgress): Promise<CommandReceipt>;
|
||||
complete(call_id: string, result: JsonObject): Promise<CommandReceipt>;
|
||||
fail(call_id: string, failure: MiniAppToolFailure): Promise<CommandReceipt>;
|
||||
cancel(call_id: string, reason?: string): Promise<CommandReceipt>;
|
||||
};
|
||||
readonly lifecycle: {
|
||||
requestForeground(): Promise<CommandReceipt>;
|
||||
requestBackground(): Promise<CommandReceipt>;
|
||||
requestClose(reason?: string): Promise<CommandReceipt>;
|
||||
};
|
||||
readonly surfaces: {
|
||||
request(event: "open" | "patch" | "close", data: JsonObject): Promise<CommandReceipt>;
|
||||
};
|
||||
readonly capabilities: {
|
||||
request(name: string, reason: string, input?: JsonObject): Promise<CommandReceipt>;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateMiniAppManifest } from "@/runtime/app-management/miniapp-manifest";
|
||||
import { BUNDLED_REFERENCE_MANIFESTS } from "@/runtime/app-management/reference-miniapps";
|
||||
|
||||
describe("bundled MiniApp reference manifests", () => {
|
||||
it("ships Task Dashboard and Whiteboard as restricted bundled apps", () => {
|
||||
expect(BUNDLED_REFERENCE_MANIFESTS.map(manifest => manifest.app_scope)).toEqual(["task-dashboard", "whiteboard"]);
|
||||
for (const manifest of BUNDLED_REFERENCE_MANIFESTS) {
|
||||
expect(manifest.kind).toBe("bundled");
|
||||
expect(validateMiniAppManifest(manifest)).toEqual({ accepted: true });
|
||||
expect(manifest.host.surface_required).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { MiniAppManifestV1 } from "@/runtime/app-management/miniapp-manifest";
|
||||
|
||||
/** The two bundled SDK reference manifests shipped by the Host in SDK v1. */
|
||||
export const TASK_DASHBOARD_MANIFEST: MiniAppManifestV1 = {
|
||||
app_scope: "task-dashboard",
|
||||
version: "1.0.0",
|
||||
kind: "bundled",
|
||||
subscriptions: ["lineup.v1.app.call", "lineup.v1.tool.call", "lineup.v1.text"],
|
||||
requested_capabilities: [],
|
||||
host: { min_version: "1.0.0", surface_required: true },
|
||||
tools: [
|
||||
{
|
||||
id: "task-dashboard.open",
|
||||
version: 1,
|
||||
handling: "launch",
|
||||
target: { app_scope: "task-dashboard", requires_foreground: true, restore_previous_focus: true },
|
||||
input_schema: { type: "object", required: ["title"], properties: { task_id: { type: "string", minLength: 1 }, title: { type: "string", minLength: 1, maxLength: 200 }, initial_state: { type: "object" } }, additionalProperties: false },
|
||||
output_schema: { type: "object", required: ["status", "task_id"], properties: { status: { type: "string", enum: ["completed", "cancelled", "failed"] }, task_id: { type: "string", minLength: 1 }, task: { type: "object" } }, additionalProperties: false },
|
||||
},
|
||||
{
|
||||
id: "task-dashboard.update",
|
||||
version: 1,
|
||||
handling: "direct",
|
||||
target: { app_scope: "task-dashboard", requires_foreground: false, restore_previous_focus: false },
|
||||
input_schema: { type: "object", required: ["task_id"], properties: { task_id: { type: "string", minLength: 1 }, progress: { type: "number", minimum: 0, maximum: 100 }, status: { type: "string" } }, additionalProperties: false },
|
||||
output_schema: { type: "object", required: ["task_id"], properties: { task_id: { type: "string", minLength: 1 }, task: { type: "object" } }, additionalProperties: false },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const WHITEBOARD_MANIFEST: MiniAppManifestV1 = {
|
||||
app_scope: "whiteboard",
|
||||
version: "1.0.0",
|
||||
kind: "bundled",
|
||||
subscriptions: ["lineup.v1.ui.open", "lineup.v1.ui.patch", "lineup.v1.ui.close", "lineup.v1.artifact.offer"],
|
||||
requested_capabilities: [],
|
||||
host: { min_version: "1.0.0", surface_required: true },
|
||||
tools: [
|
||||
{
|
||||
id: "whiteboard.open",
|
||||
version: 1,
|
||||
handling: "launch",
|
||||
target: { app_scope: "whiteboard", requires_foreground: true, restore_previous_focus: true },
|
||||
input_schema: { type: "object", required: ["board_id", "title"], properties: { board_id: { type: "string", minLength: 1 }, title: { type: "string", minLength: 1 }, initial_state: { type: "object" } }, additionalProperties: false },
|
||||
output_schema: { type: "object" },
|
||||
},
|
||||
{
|
||||
id: "whiteboard.submit",
|
||||
version: 1,
|
||||
handling: "operation",
|
||||
target: { app_scope: "whiteboard", requires_foreground: true, restore_previous_focus: true },
|
||||
input_schema: { type: "object", required: ["board_id"], properties: { board_id: { type: "string", minLength: 1 }, submit_request: { type: "object" } }, additionalProperties: false },
|
||||
output_schema: { type: "object", properties: { artifact_id: { type: "string", minLength: 1 }, summary: { type: "object" } }, additionalProperties: false },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const BUNDLED_REFERENCE_MANIFESTS: readonly MiniAppManifestV1[] = [TASK_DASHBOARD_MANIFEST, WHITEBOARD_MANIFEST];
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { LineUpRuntime } from "@/runtime/coordination/lineup-runtime";
|
||||
import {
|
||||
CoreAppHostRegistry,
|
||||
RuntimeAppHost,
|
||||
} from "@/runtime/app-management/runtime-app-host";
|
||||
|
||||
class MemoryStorage implements Storage {
|
||||
private readonly values = new Map<string, string>();
|
||||
public get length(): number { return this.values.size; }
|
||||
public clear(): void { this.values.clear(); }
|
||||
public getItem(key: string): string | null { return this.values.get(key) ?? null; }
|
||||
public key(index: number): string | null { return [...this.values.keys()][index] ?? null; }
|
||||
public removeItem(key: string): void { this.values.delete(key); }
|
||||
public setItem(key: string, value: string): void { this.values.set(key, value); }
|
||||
}
|
||||
|
||||
describe("RuntimeAppHost", () => {
|
||||
it("uses the host registered for Runtime's selected default Core App", () => {
|
||||
const runtime = new LineUpRuntime({ storage: new MemoryStorage() });
|
||||
const root = { innerHTML: "" } as HTMLElement;
|
||||
const hostRegistry = new CoreAppHostRegistry();
|
||||
hostRegistry.register("chat", (selectedRuntime, selectedRoot) => {
|
||||
selectedRoot.innerHTML = '<main id="chat-view"><form id="login-form"></form></main>';
|
||||
return {
|
||||
app_scope: "chat",
|
||||
sdk: selectedRuntime.openApp("chat"),
|
||||
} as never;
|
||||
});
|
||||
|
||||
const mounted = new RuntimeAppHost(runtime, root, {}, hostRegistry).mountDefaultApp();
|
||||
|
||||
expect(mounted.app_scope).toBe("chat");
|
||||
expect(mounted.sdk.app_scope).toBe("chat");
|
||||
expect(root.innerHTML).toContain('id="chat-view"');
|
||||
expect(root.innerHTML).toContain('id="login-form"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Runtime 到可信 Core App 的通用 Host 加载器。
|
||||
*
|
||||
* Runtime App Registry 决定“启动哪个应用”;CoreAppHostRegistry 决定“如何挂载这个
|
||||
* 应用”。两件事分开后,main.ts 不需要知道默认应用是 Chat、IM、语音还是其他模式。
|
||||
*/
|
||||
import { mountChatApp, type ChatAppHostOptions, type MountedChatApp } from "@/core-apps/chat/chat-app-host";
|
||||
import type { AppScope } from "@/runtime/app-management/app-registry";
|
||||
import { LineUpRuntime } from "@/runtime/coordination/lineup-runtime";
|
||||
|
||||
export type CoreAppHost = MountedChatApp;
|
||||
export type CoreAppHostFactory = (
|
||||
runtime: LineUpRuntime,
|
||||
root: HTMLElement,
|
||||
) => CoreAppHost;
|
||||
|
||||
export class CoreAppHostRegistry {
|
||||
private readonly factories = new Map<AppScope, CoreAppHostFactory>();
|
||||
|
||||
public register(appScope: AppScope, factory: CoreAppHostFactory): void {
|
||||
if (this.factories.has(appScope)) throw new Error(`Core App Host already registered: ${appScope}`);
|
||||
this.factories.set(appScope, factory);
|
||||
}
|
||||
|
||||
public get(appScope: AppScope): CoreAppHostFactory | undefined {
|
||||
return this.factories.get(appScope);
|
||||
}
|
||||
}
|
||||
|
||||
export function createDefaultCoreAppHostRegistry(options: ChatAppHostOptions = {}): CoreAppHostRegistry {
|
||||
const registry = new CoreAppHostRegistry();
|
||||
registry.register("chat", (runtime, root) => mountChatApp(runtime, root, options));
|
||||
return registry;
|
||||
}
|
||||
|
||||
export class RuntimeAppHost {
|
||||
private readonly hostRegistry: CoreAppHostRegistry;
|
||||
|
||||
public constructor(
|
||||
private readonly runtime: LineUpRuntime,
|
||||
private readonly root: HTMLElement,
|
||||
private readonly options: ChatAppHostOptions = {},
|
||||
hostRegistry?: CoreAppHostRegistry,
|
||||
) {
|
||||
this.hostRegistry = hostRegistry ?? createDefaultCoreAppHostRegistry(this.options);
|
||||
}
|
||||
|
||||
public mountDefaultApp(): CoreAppHost {
|
||||
const app = this.runtime.apps.defaultApp();
|
||||
const factory = this.hostRegistry.get(app.app_scope);
|
||||
if (!factory) throw new Error(`No Core App host is registered for ${app.app_scope}.`);
|
||||
if (!app.enabled) throw new Error(`Core App is disabled: ${app.app_scope}.`);
|
||||
return factory(this.runtime, this.root);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ArtifactContentCache } from "@/runtime/artifacts/artifact-content-cache";
|
||||
|
||||
describe("ArtifactContentCache", () => {
|
||||
it("accepts only host-owned opaque references and never exposes paths", () => {
|
||||
const cache = new ArtifactContentCache();
|
||||
expect(cache.put("artifact-cache-001", new Blob(["verified bytes"]))).toBe(true);
|
||||
expect(cache.has("artifact-cache-001")).toBe(true);
|
||||
expect(cache.get("artifact-cache-001")?.size).toBe(14);
|
||||
expect(cache.put("/tmp/report.pdf", new Blob(["x"]))).toBe(false);
|
||||
expect(cache.has("/tmp/report.pdf")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Host 所有的易失 Artifact 内容缓存。
|
||||
*
|
||||
* 它只保存已经通过 ArtifactState 接纳的宿主字节;内容不写入 Conversation Store,
|
||||
* Agent 只能引用不透明 local_ref,不能直接填充或读取缓存。
|
||||
*
|
||||
* Host-owned volatile bytes for Artifact metadata already accepted by
|
||||
* ArtifactState. This cache has no network, persistence, DOM or protocol
|
||||
* dependency: an Agent can name an opaque ref but cannot populate or read it.
|
||||
*/
|
||||
export class ArtifactContentCache {
|
||||
private readonly blobs = new Map<string, Blob>();
|
||||
|
||||
public put(localRef: string, content: Blob): boolean {
|
||||
if (!opaqueRef(localRef) || content.size > 512 * 1024 * 1024) return false;
|
||||
this.blobs.set(localRef, content);
|
||||
return true;
|
||||
}
|
||||
|
||||
public get(localRef: string): Blob | undefined { return this.blobs.get(localRef); }
|
||||
public has(localRef: string | undefined): boolean { return Boolean(localRef && this.blobs.has(localRef)); }
|
||||
public clear(): void { this.blobs.clear(); }
|
||||
}
|
||||
|
||||
function opaqueRef(value: string): boolean {
|
||||
return /^[A-Za-z0-9_.:-]{1,128}$/.test(value) && !/[\\/]/.test(value);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ArtifactState } from "@/runtime/artifacts/artifact-state";
|
||||
|
||||
describe("ArtifactState", () => {
|
||||
it("keeps only bounded metadata and rejects paths or duplicate offers", () => {
|
||||
const artifacts = new ArtifactState();
|
||||
const offered = { artifact_id: "report_001", name: "report.pdf", mime_type: "application/pdf", size_bytes: 42, integrity: "verified", created_at: "2026-08-03T00:00:00.000Z", local_ref: "artifact-cache-001" };
|
||||
expect(artifacts.offer(offered)).toEqual(expect.objectContaining({ artifact_id: "report_001", integrity: "verified" }));
|
||||
expect(artifacts.getByLocalRef("artifact-cache-001")).toEqual(expect.objectContaining({ artifact_id: "report_001" }));
|
||||
expect(artifacts.offer(offered)).toBeUndefined();
|
||||
expect(artifacts.offer({ ...offered, artifact_id: "report_002", local_ref: "/tmp/secret.pdf" })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Artifact 的元数据状态机。
|
||||
*
|
||||
* 这里仅维护名称、MIME、大小与完整性结果;不保存下载内容、文件路径,也不触发任何 DOM
|
||||
* 或系统操作。
|
||||
*/
|
||||
export type ArtifactIntegrity = "verified" | "unverified" | "failed";
|
||||
export type ArtifactRecord = Readonly<{
|
||||
artifact_id: string;
|
||||
name: string;
|
||||
mime_type: string;
|
||||
size_bytes: number;
|
||||
integrity: ArtifactIntegrity;
|
||||
created_at: string;
|
||||
local_ref?: string;
|
||||
}>;
|
||||
|
||||
/** Metadata-only local Artifact registry. No download, file path, or DOM action. */
|
||||
export class ArtifactState {
|
||||
private readonly records = new Map<string, ArtifactRecord>();
|
||||
|
||||
public offer(value: unknown): ArtifactRecord | undefined {
|
||||
const record = parse(value);
|
||||
if (!record || this.records.has(record.artifact_id)) return undefined;
|
||||
this.records.set(record.artifact_id, record);
|
||||
return { ...record };
|
||||
}
|
||||
|
||||
public get(id: string): ArtifactRecord | undefined { const record = this.records.get(id); return record ? { ...record } : undefined; }
|
||||
public getByLocalRef(localRef: string): ArtifactRecord | undefined {
|
||||
const record = [...this.records.values()].find(candidate => candidate.local_ref === localRef);
|
||||
return record ? { ...record } : undefined;
|
||||
}
|
||||
/** Removes a just-offered record when its separately validated host bytes fail to cache. */
|
||||
public discard(id: string): void { this.records.delete(id); }
|
||||
public snapshot(): readonly ArtifactRecord[] { return [...this.records.values()].sort((left, right) => left.artifact_id.localeCompare(right.artifact_id)).map(record => ({ ...record })); }
|
||||
}
|
||||
|
||||
function parse(value: unknown): ArtifactRecord | undefined {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
||||
const item = value as Record<string, unknown>;
|
||||
const id = text(item.artifact_id); const name = text(item.name); const mime = text(item.mime_type); const integrity = item.integrity;
|
||||
if (!/^[A-Za-z][A-Za-z0-9_.:-]{0,127}$/.test(id) || !name || name.length > 255 || !/^[a-z]+\/[a-z0-9.+-]+$/i.test(mime)
|
||||
|| typeof item.size_bytes !== "number" || !Number.isSafeInteger(item.size_bytes) || item.size_bytes < 0 || item.size_bytes > 512 * 1024 * 1024
|
||||
|| (integrity !== "verified" && integrity !== "unverified" && integrity !== "failed") || typeof item.created_at !== "string" || Number.isNaN(Date.parse(item.created_at))) return undefined;
|
||||
const localRef = item.local_ref === undefined ? undefined : text(item.local_ref);
|
||||
// A reference is opaque, bounded, and must never be a filesystem path or URL.
|
||||
if (localRef !== undefined && (!/^[A-Za-z0-9_.:-]{1,128}$/.test(localRef) || /[\\/]/.test(localRef))) return undefined;
|
||||
return { artifact_id: id, name, mime_type: mime, size_bytes: item.size_bytes, integrity, created_at: item.created_at, ...(localRef ? { local_ref: localRef } : {}) };
|
||||
}
|
||||
function text(value: unknown): string { return typeof value === "string" ? value.trim() : ""; }
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CapabilityAuditLog } from "@/runtime/capabilities/capability-audit";
|
||||
|
||||
describe("CapabilityAuditLog", () => {
|
||||
it("records authority metadata but never arguments or result values", () => {
|
||||
const audit = new CapabilityAuditLog();
|
||||
audit.record({
|
||||
call_id: "cap_001", conversation_id: "conversation",
|
||||
request: { call_id: "cap_001", capability: "clipboard.write", reason: "复制内容", expires_at: "2026-08-03T01:00:00.000Z", arguments: { text: "super-secret" } },
|
||||
status: "rejected", created_at: "2026-08-03T00:00:00.000Z", updated_at: "2026-08-03T00:00:01.000Z", result: { echoed: "super-secret" },
|
||||
}, "user_confirmation", "2026-08-03T00:00:01.000Z");
|
||||
expect(audit.snapshot()).toEqual([expect.objectContaining({ call_id: "cap_001", capability: "clipboard.write", status: "rejected" })]);
|
||||
expect(JSON.stringify(audit.snapshot())).not.toContain("super-secret");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Capability 调用的最小审计日志。
|
||||
*
|
||||
* 只记录可安全公开的调用标识、风险等级、处置和时间,绝不写入文件路径、表单内容、Token
|
||||
* 或宿主异常细节。
|
||||
*/
|
||||
import type { CapabilityCallRecord } from "@/runtime/capabilities/capability-call-state";
|
||||
import type { CapabilityRisk } from "@/runtime/capabilities/capability-registry";
|
||||
|
||||
export type CapabilityAuditEntry = Readonly<{
|
||||
call_id: string;
|
||||
capability: string;
|
||||
risk: CapabilityRisk;
|
||||
status: CapabilityCallRecord["status"];
|
||||
occurred_at: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Bounded audit projection. Parameters and results are intentionally absent:
|
||||
* a URL, clipboard value, selected path, artifact bytes or native error must
|
||||
* never become a durable UI audit field merely because a capability ran.
|
||||
*/
|
||||
export class CapabilityAuditLog {
|
||||
private readonly entries: CapabilityAuditEntry[] = [];
|
||||
|
||||
public record(record: CapabilityCallRecord, risk: CapabilityRisk, occurredAt: string): CapabilityAuditEntry {
|
||||
const entry: CapabilityAuditEntry = { call_id: record.call_id, capability: record.request.capability, risk, status: record.status, occurred_at: occurredAt };
|
||||
this.entries.push(entry);
|
||||
if (this.entries.length > 200) this.entries.splice(0, this.entries.length - 200);
|
||||
return { ...entry };
|
||||
}
|
||||
|
||||
public recordDecision(callID: string, capability: string, risk: CapabilityRisk, status: CapabilityCallRecord["status"], occurredAt: string): CapabilityAuditEntry {
|
||||
const entry: CapabilityAuditEntry = { call_id: callID, capability, risk, status, occurred_at: occurredAt };
|
||||
this.entries.push(entry);
|
||||
if (this.entries.length > 200) this.entries.splice(0, this.entries.length - 200);
|
||||
return { ...entry };
|
||||
}
|
||||
|
||||
public snapshot(): readonly CapabilityAuditEntry[] { return this.entries.map(entry => ({ ...entry })); }
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CapabilityCallStateMachine, type CapabilityCallRequest } from "@/runtime/capabilities/capability-call-state";
|
||||
import { parseCapabilityCall } from "@/runtime/capabilities/capability-call-state";
|
||||
import { CapabilityRegistry } from "@/runtime/capabilities/capability-registry";
|
||||
|
||||
const now = "2026-08-03T00:00:00.000Z";
|
||||
const request: CapabilityCallRequest = { call_id: "cap_001", capability: "app.open_url", reason: "打开文档", expires_at: "2026-08-03T00:05:00.000Z", arguments: { url: "https://example.com" } };
|
||||
|
||||
describe("CapabilityCallStateMachine", () => {
|
||||
it("requires explicit approval before a handler can start", () => {
|
||||
const calls = new CapabilityCallStateMachine();
|
||||
calls.receive(request, "conversation", now);
|
||||
expect(calls.begin("cap_001", now).disposition).toBe("invalid_transition");
|
||||
expect(calls.approve("cap_001", now).disposition).toBe("accepted");
|
||||
expect(calls.begin("cap_001", now).disposition).toBe("accepted");
|
||||
expect(calls.finish("cap_001", "completed", { opened: true }, now)).toMatchObject({ disposition: "accepted", record: { status: "completed", result: { opened: true } } });
|
||||
});
|
||||
|
||||
it("is idempotent and provides terminal results for denial and expiry", () => {
|
||||
const calls = new CapabilityCallStateMachine();
|
||||
calls.receive(request, "conversation", now);
|
||||
expect(calls.receive({ ...request, reason: "changed" }, "other", now).disposition).toBe("duplicate");
|
||||
expect(calls.reject("cap_001", now)).toMatchObject({ disposition: "accepted", record: { status: "rejected" } });
|
||||
expect(calls.reject("cap_001", now).disposition).toBe("invalid_transition");
|
||||
calls.receive({ ...request, call_id: "cap_002", expires_at: "2026-08-03T00:01:00.000Z" }, "conversation", now);
|
||||
expect(calls.approve("cap_002", "2026-08-03T00:02:00.000Z")).toMatchObject({ record: { status: "expired" } });
|
||||
});
|
||||
|
||||
it("admits only current registry capabilities with bounded, unexpired arguments", () => {
|
||||
const registry = new CapabilityRegistry({ visible: ["app.open_url"] });
|
||||
expect(parseCapabilityCall(request, registry, now)).toMatchObject({ disposition: "accepted", request: { capability: "app.open_url" } });
|
||||
expect(parseCapabilityCall({ ...request, capability: "clipboard.write", arguments: { text: "x" } }, registry, now)).toEqual({ disposition: "unsupported" });
|
||||
expect(parseCapabilityCall({ ...request, arguments: { url: "https://example.com", bypass: "yes" } }, registry, now)).toEqual({ disposition: "invalid" });
|
||||
expect(parseCapabilityCall({ ...request, expires_at: "2026-08-02T00:00:00.000Z" }, registry, now)).toEqual({ disposition: "invalid" });
|
||||
expect(parseCapabilityCall({ ...request, reason: "打开 https://private.example/path?q=secret" }, registry, now)).toMatchObject({ disposition: "accepted", request: { reason: "打开 链接" } });
|
||||
});
|
||||
|
||||
it("restores a durable call ledger without reopening terminal authority", () => {
|
||||
const original = new CapabilityCallStateMachine();
|
||||
original.receive(request, "conversation", now);
|
||||
original.approve("cap_001", now);
|
||||
const restored = new CapabilityCallStateMachine();
|
||||
restored.restore(original.snapshot());
|
||||
expect(restored.get("cap_001")).toMatchObject({ status: "approved" });
|
||||
expect(restored.unsupported("cap_001", now)).toMatchObject({ disposition: "accepted", record: { status: "unsupported" } });
|
||||
expect(restored.begin("cap_001", now).disposition).toBe("invalid_transition");
|
||||
expect(restored.receive(request, "conversation", now).disposition).toBe("duplicate");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Capability 调用的纯状态机。
|
||||
*
|
||||
* 它只裁决 pending、确认、执行和终态之间的合法跃迁;不持久化、不调用 DOM、网络、权限
|
||||
* 或实际 Host handler,副作用必须在 Runtime/Host 边界之外执行。
|
||||
*/
|
||||
import type { JsonObject } from "@/runtime/protocol/lineup-v1";
|
||||
import type { CapabilityDefinition } from "@/runtime/capabilities/capability-registry";
|
||||
import { CapabilityRegistry } from "@/runtime/capabilities/capability-registry";
|
||||
|
||||
export type CapabilityCallStatus = "pending" | "approved" | "executing" | "completed" | "rejected" | "cancelled" | "expired" | "unsupported" | "failed";
|
||||
|
||||
export type CapabilityCallRequest = Readonly<{
|
||||
call_id: string;
|
||||
capability: CapabilityDefinition["name"];
|
||||
reason: string;
|
||||
expires_at: string;
|
||||
arguments: JsonObject;
|
||||
}>;
|
||||
|
||||
export type CapabilityCallRecord = Readonly<{
|
||||
call_id: string;
|
||||
conversation_id: string;
|
||||
request: CapabilityCallRequest;
|
||||
status: CapabilityCallStatus;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
result?: JsonObject;
|
||||
}>;
|
||||
|
||||
export type CapabilityCallTransition = Readonly<{ disposition: "accepted" | "duplicate" | "missing" | "invalid_transition"; record?: CapabilityCallRecord }>;
|
||||
|
||||
export type CapabilityCallParseResult =
|
||||
| Readonly<{ disposition: "accepted"; request: CapabilityCallRequest }>
|
||||
| Readonly<{ disposition: "unsupported" | "invalid" }>;
|
||||
|
||||
/**
|
||||
* Narrows an untrusted wire payload before a trusted confirmation UI sees it.
|
||||
* Registry availability is intentionally checked here as well as at execution
|
||||
* time, so an old inventory revision cannot create a usable approval card.
|
||||
*/
|
||||
export function parseCapabilityCall(payload: unknown, registry: CapabilityRegistry, now: string): CapabilityCallParseResult {
|
||||
if (!isObject(payload)) return { disposition: "invalid" };
|
||||
const callID = text(payload.call_id);
|
||||
const capability = text(payload.capability) as CapabilityDefinition["name"];
|
||||
const reason = sanitizeCapabilityReason(text(payload.reason));
|
||||
const expiresAt = text(payload.expires_at);
|
||||
if (!/^[A-Za-z][A-Za-z0-9_.:-]{0,127}$/.test(callID) || !reason || reason.length > 500 || !expiresAt || Number.isNaN(Date.parse(expiresAt))) return { disposition: "invalid" };
|
||||
const resolution = registry.resolve(capability);
|
||||
if (resolution.disposition !== "available") return { disposition: "unsupported" };
|
||||
if (!registry.validateArguments(capability, payload.arguments) || Date.parse(expiresAt) <= Date.parse(now)) return { disposition: "invalid" };
|
||||
return { disposition: "accepted", request: { call_id: callID, capability, reason, expires_at: expiresAt, arguments: clone(payload.arguments as JsonObject) } };
|
||||
}
|
||||
|
||||
/** Lifecycle gate only. No DOM, persistence, network, permission or handler exists here. */
|
||||
export class CapabilityCallStateMachine {
|
||||
private readonly calls = new Map<string, CapabilityCallRecord>();
|
||||
|
||||
public receive(request: CapabilityCallRequest, conversationID: string, now: string): CapabilityCallTransition {
|
||||
const existing = this.calls.get(request.call_id);
|
||||
if (existing) return { disposition: "duplicate", record: copy(existing) };
|
||||
const expired = Date.parse(request.expires_at) <= Date.parse(now);
|
||||
const record: CapabilityCallRecord = { call_id: request.call_id, conversation_id: conversationID, request: copyRequest(request), status: expired ? "expired" : "pending", created_at: now, updated_at: now };
|
||||
this.calls.set(record.call_id, record);
|
||||
return { disposition: "accepted", record: copy(record) };
|
||||
}
|
||||
|
||||
public approve(callID: string, now: string): CapabilityCallTransition { return this.transition(callID, now, ["pending"], "approved"); }
|
||||
public reject(callID: string, now: string): CapabilityCallTransition { return this.transition(callID, now, ["pending"], "rejected"); }
|
||||
public begin(callID: string, now: string): CapabilityCallTransition { return this.transition(callID, now, ["approved"], "executing"); }
|
||||
public cancel(callID: string, now: string): CapabilityCallTransition { return this.transition(callID, now, ["pending", "approved", "executing"], "cancelled"); }
|
||||
public unsupported(callID: string, now: string): CapabilityCallTransition { return this.transition(callID, now, ["pending", "approved"], "unsupported"); }
|
||||
public finish(callID: string, status: "completed" | "failed", result: JsonObject, now: string): CapabilityCallTransition {
|
||||
const transition = this.transition(callID, now, ["executing"], status);
|
||||
if (transition.disposition !== "accepted" || !transition.record) return transition;
|
||||
const record = this.calls.get(callID)! as CapabilityCallRecord & { result?: JsonObject };
|
||||
record.result = clone(result);
|
||||
return { disposition: "accepted", record: copy(record) };
|
||||
}
|
||||
public expire(callID: string, now: string): CapabilityCallTransition { return this.transition(callID, now, ["pending", "approved"], "expired"); }
|
||||
public get(callID: string): CapabilityCallRecord | undefined { const record = this.calls.get(callID); return record ? copy(record) : undefined; }
|
||||
public snapshot(): readonly CapabilityCallRecord[] { return [...this.calls.values()].map(copy); }
|
||||
|
||||
/** Restores only records already normalized by the persistence boundary. */
|
||||
public restore(records: readonly CapabilityCallRecord[]): void {
|
||||
this.calls.clear();
|
||||
for (const record of records) {
|
||||
if (this.calls.has(record.call_id)) continue;
|
||||
this.calls.set(record.call_id, copy(record));
|
||||
}
|
||||
}
|
||||
|
||||
private transition(callID: string, now: string, allowed: readonly CapabilityCallStatus[], next: CapabilityCallStatus): CapabilityCallTransition {
|
||||
const record = this.calls.get(callID);
|
||||
if (!record) return { disposition: "missing" };
|
||||
if (Date.parse(record.request.expires_at) <= Date.parse(now) && (record.status === "pending" || record.status === "approved")) {
|
||||
(record as { status: CapabilityCallStatus; updated_at: string }).status = "expired";
|
||||
(record as { updated_at: string }).updated_at = now;
|
||||
return { disposition: "invalid_transition", record: copy(record) };
|
||||
}
|
||||
if (!allowed.includes(record.status)) return { disposition: "invalid_transition", record: copy(record) };
|
||||
(record as { status: CapabilityCallStatus; updated_at: string }).status = next;
|
||||
(record as { updated_at: string }).updated_at = now;
|
||||
return { disposition: "accepted", record: copy(record) };
|
||||
}
|
||||
}
|
||||
|
||||
function copyRequest(request: CapabilityCallRequest): CapabilityCallRequest { return { ...request, arguments: clone(request.arguments) }; }
|
||||
function copy(record: CapabilityCallRecord): CapabilityCallRecord { return { ...record, request: copyRequest(record.request), ...(record.result ? { result: clone(record.result) } : {}) }; }
|
||||
function clone<T>(value: T): T { return JSON.parse(JSON.stringify(value)) as T; }
|
||||
function text(value: unknown): string { return typeof value === "string" ? value : ""; }
|
||||
/** A confirmation reason is an intent label, never a covert argument preview. */
|
||||
export function sanitizeCapabilityReason(value: string): string {
|
||||
return value.trim()
|
||||
.replace(/https?:\/\/\S+/giu, "链接")
|
||||
.replace(/(?:[A-Za-z]:)?[\\/]\S+/gu, "本地资源")
|
||||
.replace(/\s+/gu, " ")
|
||||
.slice(0, 500)
|
||||
.trim();
|
||||
}
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ArtifactState } from "@/runtime/artifacts/artifact-state";
|
||||
import { CapabilityExecutor } from "@/runtime/capabilities/capability-executor";
|
||||
import type { CapabilityCallRecord } from "@/runtime/capabilities/capability-call-state";
|
||||
|
||||
const now = "2026-08-03T00:00:00.000Z";
|
||||
function call(capability: CapabilityCallRecord["request"]["capability"], argumentsValue: Record<string, string>): CapabilityCallRecord {
|
||||
return { call_id: "cap_001", conversation_id: "conversation", request: { call_id: "cap_001", capability, reason: "测试", expires_at: "2026-08-03T01:00:00.000Z", arguments: argumentsValue }, status: "executing", created_at: now, updated_at: now };
|
||||
}
|
||||
|
||||
describe("CapabilityExecutor", () => {
|
||||
it("uses injected host handlers only after execution has begun", async () => {
|
||||
const handlers = { openURL: vi.fn(async () => {}), writeClipboard: vi.fn(async () => {}), pickFile: vi.fn(async () => undefined), saveArtifact: vi.fn(async () => {}) };
|
||||
const executor = new CapabilityExecutor(handlers, new ArtifactState());
|
||||
await expect(executor.execute({ ...call("app.open_url", { url: "https://example.com" }), status: "pending" })).resolves.toEqual({ status: "failed", result: { code: "not_executing" } });
|
||||
await expect(executor.execute(call("app.open_url", { url: "https://example.com" }))).resolves.toEqual({ status: "completed", result: { opened: true } });
|
||||
expect(handlers.openURL).toHaveBeenCalledWith("https://example.com");
|
||||
await expect(executor.execute(call("app.open_url", { url: "javascript:alert(1)" }))).resolves.toEqual({ status: "failed", result: { code: "invalid_url" } });
|
||||
handlers.openURL.mockRejectedValueOnce(new Error("popup blocked"));
|
||||
await expect(executor.execute(call("app.open_url", { url: "https://example.com" }))).resolves.toEqual({ status: "failed", result: { code: "handler_failed" } });
|
||||
});
|
||||
|
||||
it("never saves an unknown or unverified artifact", async () => {
|
||||
const handlers = { openURL: vi.fn(async () => {}), writeClipboard: vi.fn(async () => {}), pickFile: vi.fn(async () => undefined), saveArtifact: vi.fn(async () => {}) };
|
||||
const artifacts = new ArtifactState();
|
||||
const executor = new CapabilityExecutor(handlers, artifacts);
|
||||
await expect(executor.execute(call("artifact.save", { artifact_id: "missing" }))).resolves.toEqual({ status: "failed", result: { code: "artifact_unavailable" } });
|
||||
artifacts.offer({ artifact_id: "unverified", name: "x.txt", mime_type: "text/plain", size_bytes: 1, integrity: "unverified", created_at: now });
|
||||
await expect(executor.execute(call("artifact.save", { artifact_id: "unverified" }))).resolves.toEqual({ status: "failed", result: { code: "artifact_unavailable" } });
|
||||
expect(handlers.saveArtifact).not.toHaveBeenCalled();
|
||||
artifacts.offer({ artifact_id: "verified", name: "x.txt", mime_type: "text/plain", size_bytes: 1, integrity: "verified", created_at: now });
|
||||
await executor.execute(call("artifact.save", { artifact_id: "verified" }));
|
||||
expect(handlers.saveArtifact).toHaveBeenCalledWith("verified");
|
||||
});
|
||||
|
||||
it("turns a cancelled file selection into an argument-free terminal outcome", async () => {
|
||||
const handlers = { openURL: vi.fn(async () => {}), writeClipboard: vi.fn(async () => {}), pickFile: vi.fn(async () => undefined), saveArtifact: vi.fn(async () => {}) };
|
||||
const executor = new CapabilityExecutor(handlers, new ArtifactState());
|
||||
await expect(executor.execute(call("device.pick_file", {}))).resolves.toEqual({ status: "cancelled", result: {} });
|
||||
expect(handlers.pickFile).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* 可信 Host 能力执行适配层。
|
||||
*
|
||||
* 该层只接收已经过 Runtime 状态机与策略确认的调用,执行 Host handler 后把结果收敛为
|
||||
* 可安全回传的有限结果,不能把原始异常、文件路径或宿主对象泄漏给 Agent。
|
||||
*/
|
||||
import type { JsonObject } from "@/runtime/protocol/lineup-v1";
|
||||
import type { ArtifactState } from "@/runtime/artifacts/artifact-state";
|
||||
import type { CapabilityCallRecord } from "@/runtime/capabilities/capability-call-state";
|
||||
|
||||
export type PickedFile = Readonly<{ name: string; mime_type: string; size_bytes: number }>;
|
||||
export type CapabilityHostHandlers = Readonly<{
|
||||
openURL: (url: string) => Promise<void>;
|
||||
writeClipboard: (text: string) => Promise<void>;
|
||||
pickFile: () => Promise<PickedFile | undefined>;
|
||||
saveArtifact: (artifactID: string) => Promise<void>;
|
||||
}>;
|
||||
|
||||
export type CapabilityExecution = Readonly<{ status: "completed" | "failed" | "cancelled"; result: JsonObject }>;
|
||||
|
||||
/**
|
||||
* Platform handler dispatcher. It contains no confirmation UI and must be
|
||||
* invoked only after CapabilityCallStateMachine has moved the call to
|
||||
* `executing`; callers own that state transition and audit recording.
|
||||
*/
|
||||
export class CapabilityExecutor {
|
||||
public constructor(private readonly handlers: CapabilityHostHandlers, private readonly artifacts: ArtifactState) {}
|
||||
|
||||
public async execute(record: CapabilityCallRecord): Promise<CapabilityExecution> {
|
||||
if (record.status !== "executing") return { status: "failed", result: { code: "not_executing" } };
|
||||
try {
|
||||
switch (record.request.capability) {
|
||||
case "app.open_url": {
|
||||
const url = requiredText(record.request.arguments, "url");
|
||||
if (!url || !isSafeExternalURL(url)) return { status: "failed", result: { code: "invalid_url" } };
|
||||
await this.handlers.openURL(url);
|
||||
return { status: "completed", result: { opened: true } };
|
||||
}
|
||||
case "clipboard.write": {
|
||||
const text = requiredText(record.request.arguments, "text");
|
||||
if (!text) return { status: "failed", result: { code: "invalid_text" } };
|
||||
await this.handlers.writeClipboard(text);
|
||||
return { status: "completed", result: { written: true } };
|
||||
}
|
||||
case "device.pick_file": {
|
||||
const file = await this.handlers.pickFile();
|
||||
return file ? { status: "completed", result: { selected: true, name: file.name, mime_type: file.mime_type, size_bytes: file.size_bytes } } : { status: "cancelled", result: {} };
|
||||
}
|
||||
case "artifact.save": {
|
||||
const artifactID = requiredText(record.request.arguments, "artifact_id");
|
||||
const artifact = artifactID ? this.artifacts.get(artifactID) : undefined;
|
||||
if (!artifact || artifact.integrity !== "verified") return { status: "failed", result: { code: "artifact_unavailable" } };
|
||||
await this.handlers.saveArtifact(artifact.artifact_id);
|
||||
return { status: "completed", result: { saved: true, artifact_id: artifact.artifact_id } };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return { status: "failed", result: { code: "handler_failed" } };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function requiredText(value: JsonObject, key: string): string | undefined {
|
||||
const text = value[key];
|
||||
return typeof text === "string" && text.trim() ? text : undefined;
|
||||
}
|
||||
function isSafeExternalURL(value: string): boolean {
|
||||
try { const parsed = new URL(value); return parsed.protocol === "https:" && Boolean(parsed.hostname); } catch { return false; }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CapabilityRegistry } from "@/runtime/capabilities/capability-registry";
|
||||
|
||||
describe("CapabilityRegistry", () => {
|
||||
it("declares requestability without making a capability executable", () => {
|
||||
const registry = new CapabilityRegistry();
|
||||
expect(registry.resolve("app.open_url")).toEqual(expect.objectContaining({ disposition: "available", definition: expect.objectContaining({ risk: "user_confirmation" }) }));
|
||||
expect(registry.validateArguments("app.open_url", { url: "https://example.com" })).toBe(true);
|
||||
expect(registry.validateArguments("app.open_url", { url: "https://example.com", bypass: "yes" })).toBe(false);
|
||||
expect(registry.resolve("artifact.save")).toEqual({ disposition: "revoked" });
|
||||
});
|
||||
|
||||
it("treats unknown and revoked capabilities as unavailable", () => {
|
||||
const registry = new CapabilityRegistry({ visible: ["artifact.save"] });
|
||||
expect(registry.resolve("clipboard.write")).toEqual({ disposition: "revoked" });
|
||||
expect(registry.resolve("camera.capture")).toEqual({ disposition: "unknown" });
|
||||
expect(registry.validateArguments("clipboard.write", { text: "secret" })).toBe(false);
|
||||
});
|
||||
|
||||
it("restores only known capability names", () => {
|
||||
const registry = new CapabilityRegistry({ visible: [] });
|
||||
registry.restore({ version: 1, policy: { visible: ["app.open_url", "camera.capture"] } });
|
||||
expect(registry.inventory().map(definition => definition.name)).toEqual(["app.open_url"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Host 可公开能力的声明与策略注册表。
|
||||
*
|
||||
* Runtime 依据这里的风险等级、可见性和参数 schema 生成 Agent Inventory;Surface 或 Agent
|
||||
* 不可绕过本表直接请求系统能力。
|
||||
*/
|
||||
import type { JsonObject } from "@/runtime/protocol/lineup-v1";
|
||||
|
||||
export type CapabilityRisk = "display_only" | "user_confirmation" | "system_permission" | "restricted";
|
||||
|
||||
export type CapabilityInputSchema = Readonly<{
|
||||
type: "object";
|
||||
required: readonly string[];
|
||||
properties: Readonly<Record<string, "string">>;
|
||||
}>;
|
||||
|
||||
export type CapabilityDefinition = Readonly<{
|
||||
name: "app.open_url" | "clipboard.write" | "device.pick_file" | "artifact.save";
|
||||
version: "1.0";
|
||||
risk: CapabilityRisk;
|
||||
input_schema: CapabilityInputSchema;
|
||||
foreground_required: boolean;
|
||||
}>;
|
||||
|
||||
export type CapabilityPolicy = Readonly<{
|
||||
visible: readonly CapabilityDefinition["name"][];
|
||||
}>;
|
||||
|
||||
export type CapabilityRegistrySnapshot = Readonly<{
|
||||
version: 1;
|
||||
policy: CapabilityPolicy;
|
||||
}>;
|
||||
|
||||
export type CapabilityResolution =
|
||||
| Readonly<{ disposition: "available"; definition: CapabilityDefinition }>
|
||||
| Readonly<{ disposition: "unknown" | "revoked" }>;
|
||||
|
||||
const DEFINITIONS: readonly CapabilityDefinition[] = [
|
||||
{ name: "app.open_url", version: "1.0", risk: "user_confirmation", input_schema: { type: "object", required: ["url"], properties: { url: "string" } }, foreground_required: true },
|
||||
{ name: "clipboard.write", version: "1.0", risk: "user_confirmation", input_schema: { type: "object", required: ["text"], properties: { text: "string" } }, foreground_required: true },
|
||||
{ name: "device.pick_file", version: "1.0", risk: "system_permission", input_schema: { type: "object", required: [], properties: {} }, foreground_required: true },
|
||||
{ name: "artifact.save", version: "1.0", risk: "user_confirmation", input_schema: { type: "object", required: ["artifact_id"], properties: { artifact_id: "string" } }, foreground_required: true },
|
||||
];
|
||||
|
||||
const NAMES = new Set(DEFINITIONS.map(definition => definition.name));
|
||||
// An Artifact offer in M3 is metadata-only; no host-owned bytes/cache exist
|
||||
// yet. Do not advertise a `save` operation until M4 provides a verified local
|
||||
// Artifact cache. Callers with such a cache may explicitly enable it.
|
||||
const DEFAULT_VISIBLE = DEFINITIONS
|
||||
.filter(definition => definition.name !== "artifact.save")
|
||||
.map(definition => definition.name);
|
||||
|
||||
/**
|
||||
* M3's local authority boundary. This has no DOM, storage, transport,
|
||||
* Surface bridge, browser permission, or handler side effect. It can say a
|
||||
* capability is requestable, never that an Agent is authorized to execute it.
|
||||
*/
|
||||
export class CapabilityRegistry {
|
||||
private readonly visible = new Set<CapabilityDefinition["name"]>();
|
||||
|
||||
public constructor(policy: CapabilityPolicy = { visible: DEFAULT_VISIBLE }) {
|
||||
this.restore({ version: 1, policy });
|
||||
}
|
||||
|
||||
public resolve(name: unknown): CapabilityResolution {
|
||||
if (typeof name !== "string" || !NAMES.has(name as CapabilityDefinition["name"])) return { disposition: "unknown" };
|
||||
const definition = DEFINITIONS.find(candidate => candidate.name === name)!;
|
||||
return this.visible.has(definition.name) ? { disposition: "available", definition } : { disposition: "revoked" };
|
||||
}
|
||||
|
||||
public setVisible(name: CapabilityDefinition["name"], visible: boolean): CapabilityResolution {
|
||||
const result = this.resolveKnown(name);
|
||||
if (!result) return { disposition: "unknown" };
|
||||
if (visible) this.visible.add(name); else this.visible.delete(name);
|
||||
return this.resolve(name);
|
||||
}
|
||||
|
||||
public validateArguments(name: unknown, argumentsValue: unknown): boolean {
|
||||
const resolution = this.resolve(name);
|
||||
if (resolution.disposition !== "available" || !isPlainObject(argumentsValue)) return false;
|
||||
const schema = resolution.definition.input_schema;
|
||||
const keys = Object.keys(argumentsValue);
|
||||
if (keys.some(key => !(key in schema.properties))) return false;
|
||||
if (schema.required.some(key => typeof argumentsValue[key] !== "string" || !argumentsValue[key].trim())) return false;
|
||||
return keys.every(key => typeof argumentsValue[key] === "string" && argumentsValue[key].length <= 8_192);
|
||||
}
|
||||
|
||||
public snapshot(): CapabilityRegistrySnapshot {
|
||||
return { version: 1, policy: { visible: DEFINITIONS.map(definition => definition.name).filter(name => this.visible.has(name)) } };
|
||||
}
|
||||
|
||||
public inventory(): readonly CapabilityDefinition[] {
|
||||
return DEFINITIONS.filter(definition => this.visible.has(definition.name));
|
||||
}
|
||||
|
||||
public restore(snapshot: unknown): void {
|
||||
this.visible.clear();
|
||||
if (!isPlainObject(snapshot) || snapshot.version !== 1 || !isPlainObject(snapshot.policy) || !Array.isArray(snapshot.policy.visible)) return;
|
||||
for (const name of snapshot.policy.visible) if (typeof name === "string" && NAMES.has(name as CapabilityDefinition["name"])) this.visible.add(name as CapabilityDefinition["name"]);
|
||||
}
|
||||
|
||||
private resolveKnown(name: CapabilityDefinition["name"]): CapabilityDefinition | undefined {
|
||||
return DEFINITIONS.find(definition => definition.name === name);
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is JsonObject {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
+5
@@ -1,4 +1,9 @@
|
||||
/**
|
||||
* AppServer 通信适配边界。
|
||||
*
|
||||
* 本模块是当前 HTTP 登录、发送、同步、超时和响应校验的唯一实现位置;调用方只处理会话
|
||||
* 数据,不直接使用 `fetch` 或 `Response`,以便未来替换传输方式而不改变 Runtime/App。
|
||||
*
|
||||
* The Transport Adapter is the only M0 boundary that knows the current
|
||||
* AppServer HTTP endpoints. Its callers work with conversation data and do
|
||||
* not receive Response objects or call fetch directly, so this contract can
|
||||
@@ -1,113 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ToolCallRecord } from "./tool-call-state";
|
||||
import type { TaskRecord } from "./task-state";
|
||||
import { ConversationStore } from "./conversation-store";
|
||||
|
||||
class MemoryStorage implements Storage {
|
||||
private readonly values = new Map<string, string>();
|
||||
|
||||
public get length(): number { return this.values.size; }
|
||||
public clear(): void { this.values.clear(); }
|
||||
public getItem(key: string): string | null { return this.values.get(key) ?? null; }
|
||||
public key(index: number): string | null { return [...this.values.keys()][index] ?? null; }
|
||||
public removeItem(key: string): void { this.values.delete(key); }
|
||||
public setItem(key: string, value: string): void { this.values.set(key, value); }
|
||||
}
|
||||
|
||||
const conversationID = "user_test:2:agent_channel";
|
||||
const createdAt = "2026-08-03T00:00:00.000Z";
|
||||
|
||||
describe("ConversationStore", () => {
|
||||
it("keeps the inclusive sync cursor at zero after a send acknowledgement", () => {
|
||||
const store = new ConversationStore(new MemoryStorage());
|
||||
store.open(conversationID);
|
||||
store.appendLocalText("local_001", "hello", createdAt);
|
||||
store.markSubmitted("local_001", 41, createdAt);
|
||||
|
||||
expect(store.snapshot()).toMatchObject({ cursor: 0, outbox: [] });
|
||||
expect(store.snapshot().items).toEqual([expect.objectContaining({
|
||||
local_id: "local_001",
|
||||
message_seq: 41,
|
||||
item: expect.objectContaining({ kind: "user-message", delivery: expect.objectContaining({ status: "submitted" }) }),
|
||||
})]);
|
||||
});
|
||||
|
||||
it("deduplicates the inclusive sync echo of a locally submitted user message", () => {
|
||||
const store = new ConversationStore(new MemoryStorage());
|
||||
store.open(conversationID);
|
||||
store.appendLocalText("local_001", "hello", createdAt);
|
||||
store.markSubmitted("local_001", 41, createdAt);
|
||||
|
||||
const restored = store.recordSyncedUserText(41, "hello", createdAt);
|
||||
|
||||
expect(restored).toBeUndefined();
|
||||
expect(store.snapshot()).toMatchObject({ cursor: 41 });
|
||||
expect(store.snapshot().items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("persists a local echo and restores it for the same conversation", () => {
|
||||
const storage = new MemoryStorage();
|
||||
const first = new ConversationStore(storage);
|
||||
first.open(conversationID);
|
||||
first.appendLocalText("local_002", "persist me", createdAt);
|
||||
|
||||
const reopened = new ConversationStore(storage);
|
||||
const snapshot = reopened.open(conversationID);
|
||||
|
||||
expect(snapshot.cursor).toBe(0);
|
||||
expect(snapshot.outbox).toEqual([expect.objectContaining({ local_id: "local_002", payload: "persist me" })]);
|
||||
expect(snapshot.items).toEqual([expect.objectContaining({
|
||||
local_id: "local_002",
|
||||
item: expect.objectContaining({ kind: "user-message", text: "persist me" }),
|
||||
})]);
|
||||
});
|
||||
|
||||
it("accepts one remote message per IM sequence and advances the cursor only from sync data", () => {
|
||||
const store = new ConversationStore(new MemoryStorage());
|
||||
store.open(conversationID);
|
||||
const item = { kind: "markdown" as const, id: "evt_remote_001", markdown: "remote" };
|
||||
|
||||
expect(store.recordIncoming(item, 42, createdAt)).toBe(true);
|
||||
expect(store.recordIncoming(item, 42, createdAt)).toBe(false);
|
||||
expect(store.snapshot()).toMatchObject({ cursor: 42 });
|
||||
expect(store.snapshot().items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("persists Tool Call state and an unsubmitted form draft per conversation", () => {
|
||||
const storage = new MemoryStorage();
|
||||
const state: ToolCallRecord = {
|
||||
call_id: "call_form_001",
|
||||
conversation_id: conversationID,
|
||||
request: {
|
||||
call_id: "call_form_001",
|
||||
tool: "input",
|
||||
title: "Deploy",
|
||||
prompt: "Provide a version.",
|
||||
data: { form: {} },
|
||||
},
|
||||
status: "pending",
|
||||
created_at: createdAt,
|
||||
updated_at: createdAt,
|
||||
};
|
||||
const first = new ConversationStore(storage);
|
||||
first.open(conversationID);
|
||||
first.replaceToolCalls([state]);
|
||||
first.saveToolDraft("call_form_001", { version: "1.2.3" });
|
||||
|
||||
const reopened = new ConversationStore(storage).open(conversationID);
|
||||
expect(reopened.tool_calls).toEqual([expect.objectContaining({ call_id: "call_form_001", status: "pending" })]);
|
||||
expect(reopened.tool_drafts).toEqual({ call_form_001: { version: "1.2.3" } });
|
||||
});
|
||||
|
||||
it("persists the latest aggregated task record per conversation", () => {
|
||||
const storage = new MemoryStorage();
|
||||
const task: TaskRecord = {
|
||||
operation_id: "task_store_001", conversation_id: conversationID, title: "Render preview", percent: 70, status: "running", cancellable: true, updated_at: createdAt,
|
||||
};
|
||||
const first = new ConversationStore(storage);
|
||||
first.open(conversationID);
|
||||
first.replaceTasks([task]);
|
||||
|
||||
expect(new ConversationStore(storage).open(conversationID).tasks).toEqual([expect.objectContaining({ operation_id: "task_store_001", percent: 70 })]);
|
||||
});
|
||||
});
|
||||
@@ -1,260 +0,0 @@
|
||||
import type { ConversationItem, DeliveryState, JsonObject } from "../protocol";
|
||||
import type { ToolCallRecord } from "./tool-call-state";
|
||||
import type { TaskRecord } from "./task-state";
|
||||
|
||||
export type StoredConversationItem = {
|
||||
local_id: string;
|
||||
item: ConversationItem;
|
||||
created_at: string;
|
||||
message_seq?: number;
|
||||
};
|
||||
|
||||
export type OutboxEntry = {
|
||||
local_id: string;
|
||||
payload: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type ConversationSnapshot = {
|
||||
conversation_id: string;
|
||||
cursor: number;
|
||||
items: readonly StoredConversationItem[];
|
||||
outbox: readonly OutboxEntry[];
|
||||
tool_calls: readonly ToolCallRecord[];
|
||||
tool_drafts: Readonly<Record<string, JsonObject>>;
|
||||
tasks: readonly TaskRecord[];
|
||||
};
|
||||
|
||||
type PersistedConversation = {
|
||||
version: 5;
|
||||
cursor: number;
|
||||
items: StoredConversationItem[];
|
||||
outbox: OutboxEntry[];
|
||||
tool_calls: ToolCallRecord[];
|
||||
tool_drafts: Record<string, JsonObject>;
|
||||
tasks: TaskRecord[];
|
||||
};
|
||||
|
||||
const STORE_PREFIX = "lineup.conversation.v1";
|
||||
|
||||
/**
|
||||
* Conversation-local state and its browser persistence adapter.
|
||||
*
|
||||
* It stores only validated ConversationItems, Tool Call cache/drafts, and
|
||||
* outgoing plaintext awaiting transport acknowledgement. It has no DOM,
|
||||
* renderer, HTTP, or IM dependency.
|
||||
* A native Store can implement the same snapshot semantics in M4 hosts.
|
||||
*/
|
||||
export class ConversationStore {
|
||||
private activeConversationID: string | undefined;
|
||||
private state: PersistedConversation = emptyConversation();
|
||||
|
||||
public constructor(private readonly storage: Storage) {}
|
||||
|
||||
public open(conversationID: string): ConversationSnapshot {
|
||||
this.activeConversationID = conversationID;
|
||||
this.state = this.load(conversationID);
|
||||
return this.snapshot();
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
this.activeConversationID = undefined;
|
||||
this.state = emptyConversation();
|
||||
}
|
||||
|
||||
public snapshot(): ConversationSnapshot {
|
||||
return {
|
||||
conversation_id: this.requireActiveConversationID(),
|
||||
cursor: this.state.cursor,
|
||||
items: this.state.items.map(item => ({ ...item })),
|
||||
outbox: this.state.outbox.map(entry => ({ ...entry })),
|
||||
tool_calls: clone(this.state.tool_calls),
|
||||
tool_drafts: clone(this.state.tool_drafts),
|
||||
tasks: clone(this.state.tasks),
|
||||
};
|
||||
}
|
||||
|
||||
/** Replaces only the current conversation's validated Kernel Tool Call projection. */
|
||||
public replaceToolCalls(records: readonly ToolCallRecord[]): void {
|
||||
this.state.tool_calls = records.map(record => clone(record));
|
||||
this.persist();
|
||||
}
|
||||
|
||||
public getToolDraft(callID: string): JsonObject | undefined {
|
||||
const draft = this.state.tool_drafts[callID];
|
||||
return draft ? clone(draft) : undefined;
|
||||
}
|
||||
|
||||
public saveToolDraft(callID: string, values: JsonObject): void {
|
||||
this.state.tool_drafts[callID] = clone(values);
|
||||
this.persist();
|
||||
}
|
||||
|
||||
public clearToolDraft(callID: string): void {
|
||||
if (!(callID in this.state.tool_drafts)) return;
|
||||
delete this.state.tool_drafts[callID];
|
||||
this.persist();
|
||||
}
|
||||
|
||||
public replaceTasks(records: readonly TaskRecord[]): void {
|
||||
this.state.tasks = records.map(record => clone(record));
|
||||
this.persist();
|
||||
}
|
||||
|
||||
public appendLocalText(localID: string, text: string, createdAt: string): StoredConversationItem {
|
||||
const delivery: DeliveryState = { status: "local_pending", updated_at: createdAt };
|
||||
const stored: StoredConversationItem = {
|
||||
local_id: localID,
|
||||
created_at: createdAt,
|
||||
item: { kind: "user-message", id: localID, text, delivery },
|
||||
};
|
||||
this.state.items.push(stored);
|
||||
this.state.outbox.push({ local_id: localID, payload: text, created_at: createdAt });
|
||||
this.persist();
|
||||
return { ...stored };
|
||||
}
|
||||
|
||||
/** Transport acknowledgement gives the local message its durable IM sequence. */
|
||||
public markSubmitted(localID: string, messageSeq: number, updatedAt: string): void {
|
||||
const stored = this.state.items.find(item => item.local_id === localID);
|
||||
if (!stored || stored.item.kind !== "user-message") return;
|
||||
stored.message_seq = messageSeq;
|
||||
stored.item = {
|
||||
...stored.item,
|
||||
delivery: { status: "submitted", updated_at: updatedAt, transport_id: String(messageSeq) },
|
||||
};
|
||||
this.state.outbox = this.state.outbox.filter(entry => entry.local_id !== localID);
|
||||
// A send acknowledgement is not a sync acknowledgement. The legacy
|
||||
// `/messages/sync` cursor is inclusive, so advancing it here would make a
|
||||
// refresh start at `messageSeq + 1` and permanently skip this user's echo.
|
||||
// Only a message that has actually passed through the sync path advances
|
||||
// the cursor (recordSyncedUserText / recordIncoming / advanceCursor).
|
||||
this.persist();
|
||||
}
|
||||
|
||||
public markFailed(localID: string, error: string, updatedAt: string): void {
|
||||
const stored = this.state.items.find(item => item.local_id === localID);
|
||||
if (!stored || stored.item.kind !== "user-message") return;
|
||||
stored.item = {
|
||||
...stored.item,
|
||||
delivery: { status: "failed", updated_at: updatedAt, error, retryable: true },
|
||||
};
|
||||
this.persist();
|
||||
}
|
||||
|
||||
public advanceCursor(messageSeq: number): void {
|
||||
if (messageSeq <= this.state.cursor) return;
|
||||
this.state.cursor = messageSeq;
|
||||
this.persist();
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts a remote item exactly once. Sequence is the primary IM identity;
|
||||
* an envelope id catches duplicate messages arriving through another source.
|
||||
*/
|
||||
public recordIncoming(item: ConversationItem, messageSeq: number, createdAt: string): boolean {
|
||||
if (this.state.items.some(existing => existing.message_seq === messageSeq)) {
|
||||
this.state.cursor = Math.max(this.state.cursor, messageSeq);
|
||||
this.persist();
|
||||
return false;
|
||||
}
|
||||
if (item.id && this.state.items.some(existing => existing.item.id === item.id)) {
|
||||
this.state.cursor = Math.max(this.state.cursor, messageSeq);
|
||||
this.persist();
|
||||
return false;
|
||||
}
|
||||
this.state.items.push({
|
||||
local_id: `remote_${messageSeq}`,
|
||||
item,
|
||||
created_at: createdAt,
|
||||
message_seq: messageSeq,
|
||||
});
|
||||
this.state.cursor = Math.max(this.state.cursor, messageSeq);
|
||||
this.persist();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Restores a user message sent by this client when no local record exists. */
|
||||
public recordSyncedUserText(messageSeq: number, text: string, createdAt: string): StoredConversationItem | undefined {
|
||||
if (this.state.items.some(existing => existing.message_seq === messageSeq)) {
|
||||
this.state.cursor = Math.max(this.state.cursor, messageSeq);
|
||||
this.persist();
|
||||
return undefined;
|
||||
}
|
||||
const item: ConversationItem = {
|
||||
kind: "user-message",
|
||||
id: `remote_${messageSeq}`,
|
||||
text,
|
||||
delivery: { status: "delivered", updated_at: createdAt, transport_id: String(messageSeq) },
|
||||
};
|
||||
this.state.items.push({ local_id: `remote_${messageSeq}`, item, created_at: createdAt, message_seq: messageSeq });
|
||||
this.state.cursor = Math.max(this.state.cursor, messageSeq);
|
||||
this.persist();
|
||||
return { local_id: `remote_${messageSeq}`, item, created_at: createdAt, message_seq: messageSeq };
|
||||
}
|
||||
|
||||
private load(conversationID: string): PersistedConversation {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(this.storage.getItem(this.key(conversationID)) ?? "null");
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return emptyConversation();
|
||||
const candidate = parsed as { version?: unknown; cursor?: unknown; items?: unknown; outbox?: unknown; tool_calls?: unknown; tool_drafts?: unknown; tasks?: unknown };
|
||||
if ((candidate.version !== 1 && candidate.version !== 2 && candidate.version !== 3 && candidate.version !== 4 && candidate.version !== 5) || !Array.isArray(candidate.items) || !Array.isArray(candidate.outbox) || typeof candidate.cursor !== "number") {
|
||||
return emptyConversation();
|
||||
}
|
||||
|
||||
if (candidate.version !== 5) {
|
||||
// v1 advanced its cursor from a send acknowledgement. Some browsers
|
||||
// had already been upgraded to v2 before that correction, retaining a
|
||||
// high cursor with holes for their own echoes. Preserve every valid
|
||||
// local item but replay the server history once to repair those holes;
|
||||
// the next persist upgrades this record to v5.
|
||||
return {
|
||||
version: 5,
|
||||
cursor: candidate.version === 4 ? Math.max(0, candidate.cursor) : 0,
|
||||
items: candidate.items as StoredConversationItem[],
|
||||
outbox: candidate.outbox as OutboxEntry[],
|
||||
tool_calls: candidate.version === 4 && Array.isArray(candidate.tool_calls) ? candidate.tool_calls as ToolCallRecord[] : [],
|
||||
tool_drafts: candidate.version === 4 && isDraftMap(candidate.tool_drafts) ? candidate.tool_drafts : {},
|
||||
tasks: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
version: 5,
|
||||
cursor: Math.max(0, candidate.cursor),
|
||||
items: candidate.items as StoredConversationItem[],
|
||||
outbox: candidate.outbox as OutboxEntry[],
|
||||
tool_calls: Array.isArray(candidate.tool_calls) ? candidate.tool_calls as ToolCallRecord[] : [],
|
||||
tool_drafts: isDraftMap(candidate.tool_drafts) ? candidate.tool_drafts : {},
|
||||
tasks: Array.isArray(candidate.tasks) ? candidate.tasks as TaskRecord[] : [],
|
||||
};
|
||||
} catch {
|
||||
return emptyConversation();
|
||||
}
|
||||
}
|
||||
|
||||
private persist(): void {
|
||||
this.storage.setItem(this.key(this.requireActiveConversationID()), JSON.stringify(this.state));
|
||||
}
|
||||
|
||||
private key(conversationID: string): string {
|
||||
return `${STORE_PREFIX}:${conversationID}`;
|
||||
}
|
||||
|
||||
private requireActiveConversationID(): string {
|
||||
if (!this.activeConversationID) throw new Error("ConversationStore has no active conversation.");
|
||||
return this.activeConversationID;
|
||||
}
|
||||
}
|
||||
|
||||
function emptyConversation(): PersistedConversation {
|
||||
return { version: 5, cursor: 0, items: [], outbox: [], tool_calls: [], tool_drafts: {}, tasks: [] };
|
||||
}
|
||||
|
||||
function isDraftMap(value: unknown): value is Record<string, JsonObject> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
&& Object.values(value).every(draft => draft !== null && typeof draft === "object" && !Array.isArray(draft));
|
||||
}
|
||||
|
||||
function clone<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AgentInteractionService } from "@/runtime/coordination/agent-interaction-service";
|
||||
|
||||
const base = {
|
||||
interaction_id: "interaction-1",
|
||||
call_id: "call-1",
|
||||
agent_id: "agent-1",
|
||||
conversation_id: "conversation-1",
|
||||
interact_instance_id: "chat-1",
|
||||
created_at: "2026-08-05T00:00:00.000Z",
|
||||
};
|
||||
|
||||
describe("AgentInteractionService", () => {
|
||||
it("creates, presents and accepts notice exactly once", () => {
|
||||
const service = new AgentInteractionService();
|
||||
expect(service.create({ ...base, request: { kind: "notice", title: "已保存", message: "任务已保存" } })).toMatchObject({ disposition: "accepted" });
|
||||
expect(service.present("interaction-1", "2026-08-05T00:00:01.000Z")).toMatchObject({ disposition: "accepted", record: { status: "completed", result: { outcome: "accepted" } } });
|
||||
expect(service.present("interaction-1", "2026-08-05T00:00:02.000Z")).toMatchObject({ disposition: "invalid_transition", record: { status: "completed" } });
|
||||
});
|
||||
|
||||
it("lets only the first answer/dismiss/expiry transition win", () => {
|
||||
const service = new AgentInteractionService();
|
||||
service.create({ ...base, request: { kind: "confirm", title: "确认", prompt: "继续吗?", expires_in_ms: 60_000 } });
|
||||
service.present("interaction-1", "2026-08-05T00:00:01.000Z");
|
||||
expect(service.submit("interaction-1", { approved: true }, "2026-08-05T00:00:02.000Z")).toMatchObject({ record: { status: "completed", result: { outcome: "answered" } } });
|
||||
expect(service.dismiss({ call_id: "call-1", agent_id: "agent-1", conversation_id: "conversation-1" }, "2026-08-05T00:00:03.000Z")).toMatchObject({ disposition: "invalid_transition", record: { status: "completed" } });
|
||||
});
|
||||
|
||||
it("rejects cross-agent dismiss and restores expired records", () => {
|
||||
const service = new AgentInteractionService();
|
||||
service.create({ ...base, request: { kind: "input", title: "描述", prompt: "输入", expires_in_ms: 60_000, field: { id: "text", label: "描述", type: "textarea" } } });
|
||||
expect(service.dismiss({ call_id: "call-1", agent_id: "other-agent", conversation_id: "conversation-1" }, "2026-08-05T00:00:01.000Z")).toEqual({ disposition: "invalid_transition" });
|
||||
service.restore(service.list(), "2026-08-05T00:01:00.000Z");
|
||||
expect(service.get("interaction-1")).toMatchObject({ status: "expired", result: { outcome: "expired" } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import {
|
||||
validateStandardInteractionRequest,
|
||||
validateStandardInteractionAnswer,
|
||||
type StandardInteractionRequest,
|
||||
} from "@/runtime/coordination/standard-interaction-contract";
|
||||
|
||||
export type StandardInteractionKind = StandardInteractionRequest["kind"];
|
||||
|
||||
export type StandardInteractionResult =
|
||||
| Readonly<{ outcome: "accepted" }>
|
||||
| Readonly<{ outcome: "answered"; answer: Readonly<Record<string, unknown>> }>
|
||||
| Readonly<{ outcome: "cancelled" }>
|
||||
| Readonly<{ outcome: "expired" }>
|
||||
| Readonly<{ outcome: "failed"; error_code: string }>;
|
||||
|
||||
export type StandardInteractionStatus = "pending" | "presented" | "submitted" | "completed" | "cancelled" | "expired" | "failed";
|
||||
|
||||
export type StandardInteractionRecord = Readonly<{
|
||||
interaction_id: string;
|
||||
call_id: string;
|
||||
agent_id: string;
|
||||
conversation_id: string;
|
||||
interact_instance_id: string;
|
||||
app_session_id?: string;
|
||||
kind: StandardInteractionKind;
|
||||
request: StandardInteractionRequest;
|
||||
status: StandardInteractionStatus;
|
||||
result?: StandardInteractionResult;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
expires_at?: string;
|
||||
}>;
|
||||
|
||||
export type InteractionTransition =
|
||||
| Readonly<{ disposition: "accepted"; record: StandardInteractionRecord }>
|
||||
| Readonly<{ disposition: "duplicate" | "invalid_transition" | "missing" | "invalid_request"; record?: StandardInteractionRecord }>;
|
||||
|
||||
export type CreateInteractionInput = Readonly<{
|
||||
interaction_id: string;
|
||||
call_id: string;
|
||||
agent_id: string;
|
||||
conversation_id: string;
|
||||
interact_instance_id: string;
|
||||
app_session_id?: string;
|
||||
request: StandardInteractionRequest;
|
||||
created_at: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Runtime-owned state machine for Agent-facing Interact records.
|
||||
* It deliberately knows nothing about renderers or transport. Outbox writes
|
||||
* are made by the caller only for an accepted terminal transition.
|
||||
*/
|
||||
export class AgentInteractionService {
|
||||
private readonly records = new Map<string, StandardInteractionRecord>();
|
||||
|
||||
public create(input: CreateInteractionInput): InteractionTransition {
|
||||
const existing = this.records.get(input.interaction_id) ?? this.byCall(input.call_id);
|
||||
if (existing) return { disposition: "duplicate", record: copy(existing) };
|
||||
const validation = validateStandardInteractionRequest(input.request, new Date(input.created_at));
|
||||
if (!validation.accepted) return { disposition: "invalid_request" };
|
||||
const record: StandardInteractionRecord = {
|
||||
interaction_id: input.interaction_id,
|
||||
call_id: input.call_id,
|
||||
agent_id: input.agent_id,
|
||||
conversation_id: input.conversation_id,
|
||||
interact_instance_id: input.interact_instance_id,
|
||||
...(input.app_session_id ? { app_session_id: input.app_session_id } : {}),
|
||||
kind: input.request.kind,
|
||||
request: cloneRequest(input.request),
|
||||
status: "pending",
|
||||
created_at: input.created_at,
|
||||
updated_at: input.created_at,
|
||||
...(validation.expires_at ? { expires_at: validation.expires_at } : {}),
|
||||
};
|
||||
this.records.set(record.interaction_id, record);
|
||||
return { disposition: "accepted", record: copy(record) };
|
||||
}
|
||||
|
||||
public present(interactionID: string, now: string): InteractionTransition {
|
||||
const record = this.records.get(interactionID);
|
||||
if (!record) return { disposition: "missing" };
|
||||
if (record.status !== "pending") return { disposition: "invalid_transition", record: copy(record) };
|
||||
const presented: StandardInteractionRecord = { ...record, status: "presented", updated_at: now };
|
||||
this.records.set(interactionID, presented);
|
||||
if (presented.kind === "notice") {
|
||||
const completed: StandardInteractionRecord = { ...presented, status: "completed", result: { outcome: "accepted" }, updated_at: now };
|
||||
this.records.set(interactionID, completed);
|
||||
return { disposition: "accepted", record: copy(completed) };
|
||||
}
|
||||
return { disposition: "accepted", record: copy(presented) };
|
||||
}
|
||||
|
||||
public submit(interactionID: string, answer: Readonly<Record<string, unknown>>, now: string): InteractionTransition {
|
||||
const record = this.records.get(interactionID);
|
||||
if (!record) return { disposition: "missing" };
|
||||
if (record.status !== "pending" && record.status !== "presented") return { disposition: "invalid_transition", record: copy(record) };
|
||||
if (expired(record, now)) return this.expire(interactionID, now);
|
||||
if (!validateStandardInteractionAnswer(record.request, answer)) return { disposition: "invalid_request", record: copy(record) };
|
||||
const submitted: StandardInteractionRecord = { ...record, status: "submitted", result: { outcome: "answered", answer: { ...answer } }, updated_at: now };
|
||||
const completed: StandardInteractionRecord = { ...submitted, status: "completed", updated_at: now };
|
||||
this.records.set(interactionID, completed);
|
||||
return { disposition: "accepted", record: copy(completed) };
|
||||
}
|
||||
|
||||
public dismiss(input: Readonly<{ call_id: string; agent_id: string; conversation_id: string }>, now: string): InteractionTransition {
|
||||
const record = this.byCall(input.call_id);
|
||||
if (!record) return { disposition: "missing" };
|
||||
if (record.agent_id !== input.agent_id || record.conversation_id !== input.conversation_id) return { disposition: "invalid_transition" };
|
||||
if (record.status !== "pending" && record.status !== "presented") return { disposition: "invalid_transition", record: copy(record) };
|
||||
const cancelled: StandardInteractionRecord = { ...record, status: "cancelled", result: { outcome: "cancelled" }, updated_at: now };
|
||||
this.records.set(record.interaction_id, cancelled);
|
||||
return { disposition: "accepted", record: copy(cancelled) };
|
||||
}
|
||||
|
||||
public expire(interactionID: string, now: string): InteractionTransition {
|
||||
const record = this.records.get(interactionID);
|
||||
if (!record) return { disposition: "missing" };
|
||||
if (record.status !== "pending" && record.status !== "presented") return { disposition: "invalid_transition", record: copy(record) };
|
||||
if (!expired(record, now)) return { disposition: "invalid_transition", record: copy(record) };
|
||||
const final: StandardInteractionRecord = { ...record, status: "expired", result: { outcome: "expired" }, updated_at: now };
|
||||
this.records.set(interactionID, final);
|
||||
return { disposition: "accepted", record: copy(final) };
|
||||
}
|
||||
|
||||
public get(interactionID: string): StandardInteractionRecord | undefined {
|
||||
const record = this.records.get(interactionID);
|
||||
return record ? copy(record) : undefined;
|
||||
}
|
||||
|
||||
public list(): readonly StandardInteractionRecord[] { return [...this.records.values()].map(copy); }
|
||||
|
||||
public restore(records: readonly StandardInteractionRecord[], now: string): void {
|
||||
this.records.clear();
|
||||
for (const record of records) {
|
||||
if (!isRecord(record) || this.records.has(record.interaction_id)) continue;
|
||||
const restored = copy(record);
|
||||
if ((restored.status === "pending" || restored.status === "presented") && expired(restored, now)) {
|
||||
this.records.set(restored.interaction_id, { ...restored, status: "expired", result: { outcome: "expired" }, updated_at: now });
|
||||
} else {
|
||||
this.records.set(restored.interaction_id, restored);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public reset(): void { this.records.clear(); }
|
||||
|
||||
private byCall(callID: string): StandardInteractionRecord | undefined {
|
||||
return [...this.records.values()].find(record => record.call_id === callID);
|
||||
}
|
||||
}
|
||||
|
||||
function expired(record: StandardInteractionRecord, now: string): boolean {
|
||||
return Boolean(record.expires_at && Date.parse(record.expires_at) <= Date.parse(now));
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is StandardInteractionRecord {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const candidate = value as Partial<StandardInteractionRecord>;
|
||||
return typeof candidate.interaction_id === "string"
|
||||
&& typeof candidate.call_id === "string"
|
||||
&& typeof candidate.agent_id === "string"
|
||||
&& typeof candidate.conversation_id === "string"
|
||||
&& typeof candidate.interact_instance_id === "string"
|
||||
&& typeof candidate.created_at === "string"
|
||||
&& !Number.isNaN(Date.parse(candidate.created_at))
|
||||
&& typeof candidate.updated_at === "string"
|
||||
&& !Number.isNaN(Date.parse(candidate.updated_at))
|
||||
&& Boolean(candidate.request)
|
||||
&& ["pending", "presented", "submitted", "completed", "cancelled", "expired", "failed"].includes(candidate.status ?? "");
|
||||
}
|
||||
|
||||
function cloneRequest(request: StandardInteractionRequest): StandardInteractionRequest {
|
||||
return JSON.parse(JSON.stringify(request)) as StandardInteractionRequest;
|
||||
}
|
||||
|
||||
function copy(record: StandardInteractionRecord): StandardInteractionRecord {
|
||||
return { ...record, request: cloneRequest(record.request), ...(record.result ? { result: JSON.parse(JSON.stringify(record.result)) as StandardInteractionResult } : {}) };
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { AppInstanceRecord } from "@/runtime/app-management/app-instance-manager";
|
||||
import { AppLifecycleManager } from "@/runtime/app-management/app-lifecycle-manager";
|
||||
import type { AppScope, CoreAppRegistry } from "@/runtime/app-management/app-registry";
|
||||
|
||||
export type AppOrchestrationResult =
|
||||
| { disposition: "foreground"; instance: AppInstanceRecord; previous?: AppInstanceRecord }
|
||||
| { disposition: "rejected"; code: "not_installed" | "disabled" };
|
||||
|
||||
/** Applies Tool Router decisions atomically to instance and focus state. */
|
||||
export class AppOrchestrator {
|
||||
public constructor(public readonly lifecycle: AppLifecycleManager, private readonly apps: CoreAppRegistry, private readonly newID: (prefix: string) => string) {}
|
||||
public launch(appScope: AppScope, conversationID: string, now: string, options: { instance_id?: string; parent_instance_id?: string; continued_from_app_session_id?: string } = {}): AppOrchestrationResult {
|
||||
const app = this.apps.get(appScope);
|
||||
if (!app) return { disposition: "rejected", code: "not_installed" };
|
||||
if (!app.enabled) return { disposition: "rejected", code: "disabled" };
|
||||
const previous = this.lifecycle.focus.foregroundInstance(this.lifecycle.instances);
|
||||
const existing = this.lifecycle.instances.activeFor(appScope, conversationID);
|
||||
const instance = existing ?? this.lifecycle.start({ instance_id: options.instance_id ?? this.newID(`${appScope}:instance`), app_session_id: this.newID(`${appScope}:session`), app_scope: appScope, conversation_id: conversationID, ...(options.parent_instance_id ? { parent_instance_id: options.parent_instance_id } : {}), ...(options.continued_from_app_session_id ? { continued_from_app_session_id: options.continued_from_app_session_id } : {}) }, now);
|
||||
return { disposition: "foreground", instance: this.lifecycle.foreground(instance.instance_id, now), ...(previous ? { previous } : {}) };
|
||||
}
|
||||
public close(instanceID: string, now: string, error?: string): { closed?: AppInstanceRecord; restored?: AppInstanceRecord } {
|
||||
const closed = this.lifecycle.close(instanceID, now, error);
|
||||
const restoreID = this.lifecycle.focus.foreground();
|
||||
const restored = restoreID ? this.lifecycle.foreground(restoreID, now) : undefined;
|
||||
return { ...(closed ? { closed } : {}), ...(restored ? { restored } : {}) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ExecutionProgressState } from "@/runtime/coordination/execution-progress-state";
|
||||
|
||||
const T0 = "2026-08-03T00:00:00.000Z";
|
||||
const T1 = "2026-08-03T00:00:18.000Z";
|
||||
|
||||
describe("ExecutionProgressState", () => {
|
||||
it("starts a turn with no invented operation steps", () => {
|
||||
const state = new ExecutionProgressState();
|
||||
expect(state.begin("exec_001", T0)).toEqual(expect.objectContaining({ id: "exec_001", status: "running", steps: [] }));
|
||||
});
|
||||
|
||||
it("only accepts bounded, canonical execution steps for the matching turn", () => {
|
||||
const state = new ExecutionProgressState();
|
||||
state.begin("exec_001", T0);
|
||||
expect(state.applyTrace("exec_other", "completed", [], T1)).toBeUndefined();
|
||||
const summary = state.applyTrace("exec_001", "completed", [{ id: "step_01", title: "检查 qmtbridge health", status: "completed" }], T1);
|
||||
expect(summary).toEqual(expect.objectContaining({ status: "completed", steps: [{ id: "step_01", title: "检查 qmtbridge health", status: "completed" }] }));
|
||||
});
|
||||
|
||||
it("updates a real running step in place before the turn ends", () => {
|
||||
const state = new ExecutionProgressState();
|
||||
state.begin("exec_001", T0);
|
||||
expect(state.applyTrace("exec_001", "running", [{ id: "step_01", title: "查询公开资料", status: "running" }], T1))
|
||||
.toEqual(expect.objectContaining({ status: "running", finished_at: undefined, steps: [{ id: "step_01", title: "查询公开资料", status: "running" }] }));
|
||||
expect(state.applyTrace("exec_001", "completed", [{ id: "step_01", title: "查询公开资料", status: "completed" }], T1))
|
||||
.toEqual(expect.objectContaining({ status: "completed", finished_at: T1 }));
|
||||
});
|
||||
|
||||
it("finishes without a trace without fabricating a fixed lifecycle", () => {
|
||||
const state = new ExecutionProgressState();
|
||||
state.begin("exec_001", T0);
|
||||
expect(state.finishActive("completed", T1)).toEqual(expect.objectContaining({ steps: [] }));
|
||||
});
|
||||
|
||||
it("keeps overlapping execution ids independent and finishes only the matching turn", () => {
|
||||
const state = new ExecutionProgressState();
|
||||
state.begin("exec_001", T0);
|
||||
state.begin("exec_002", T1);
|
||||
state.finish("exec_002", "completed", T1);
|
||||
expect(state.snapshot()).toEqual([
|
||||
expect.objectContaining({ id: "exec_001", status: "running" }),
|
||||
expect.objectContaining({ id: "exec_002", status: "completed" }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* 单个 Agent 回合的可展示执行摘要投影。
|
||||
*
|
||||
* 只接受 Adapter 提供的受控步骤;不能从状态文本、模型推理、命令、参数、URL、路径或工具
|
||||
* 输出推断步骤,从而避免把隐私或 CoT 显示/持久化到客户端。
|
||||
*
|
||||
* Display-safe projection of one Agent turn.
|
||||
*
|
||||
* The client never turns status text, model reasoning, commands, arguments,
|
||||
* URLs, paths, or tool output into a step. Steps arrive only through the
|
||||
* separately validated, adapter-authoritative `agent.execution` envelope.
|
||||
*/
|
||||
export type ExecutionProgressStatus = "running" | "completed" | "failed";
|
||||
export type ExecutionStepStatus = "running" | "completed" | "failed";
|
||||
|
||||
export type ExecutionProgressStep = {
|
||||
id: string;
|
||||
title: string;
|
||||
status: ExecutionStepStatus;
|
||||
};
|
||||
|
||||
export type ExecutionProgressSummary = {
|
||||
id: string;
|
||||
status: ExecutionProgressStatus;
|
||||
started_at: string;
|
||||
updated_at: string;
|
||||
finished_at?: string;
|
||||
steps: readonly ExecutionProgressStep[];
|
||||
};
|
||||
|
||||
const MAX_SUMMARIES = 20;
|
||||
const MAX_STEPS = 12;
|
||||
const ID = /^[A-Za-z][A-Za-z0-9_.:-]{0,127}$/;
|
||||
const VALID_STATUSES = new Set<ExecutionProgressStatus>(["running", "completed", "failed"]);
|
||||
const VALID_STEP_STATUSES = new Set<ExecutionStepStatus>(["running", "completed", "failed"]);
|
||||
|
||||
export class ExecutionProgressState {
|
||||
private summaries: ExecutionProgressSummary[] = [];
|
||||
|
||||
/** A status boundary starts its exact turn, but deliberately creates no fake step. */
|
||||
public begin(id: string, now: string): ExecutionProgressSummary {
|
||||
const safeID = validID(id) ? id : `execution_${Date.parse(now) || Date.now()}`;
|
||||
const existing = this.summaries.find(summary => summary.id === safeID);
|
||||
if (existing) {
|
||||
existing.updated_at = now;
|
||||
return copy(existing);
|
||||
}
|
||||
const summary: ExecutionProgressSummary = { id: safeID, status: "running", started_at: now, updated_at: now, steps: [] };
|
||||
this.summaries.push(summary);
|
||||
this.trim();
|
||||
return copy(summary);
|
||||
}
|
||||
|
||||
/** Applies a protocol-validated trace for its exact execution id. */
|
||||
public applyTrace(id: string, status: ExecutionProgressStatus, steps: readonly ExecutionProgressStep[], now: string): ExecutionProgressSummary | undefined {
|
||||
const summary = this.summaries.find(entry => entry.id === id);
|
||||
if (!summary) return undefined;
|
||||
summary.status = status;
|
||||
summary.updated_at = now;
|
||||
summary.finished_at = status === "running" ? undefined : now;
|
||||
summary.steps = sanitizeSteps(steps);
|
||||
return copy(summary);
|
||||
}
|
||||
|
||||
/** Ends the one active turn without inventing an operation description. */
|
||||
public finishActive(status: "completed" | "failed", now: string): ExecutionProgressSummary | undefined {
|
||||
const active = this.summaries.find(summary => summary.status === "running");
|
||||
if (!active) return undefined;
|
||||
active.status = status;
|
||||
active.updated_at = now;
|
||||
active.finished_at = now;
|
||||
return copy(active);
|
||||
}
|
||||
|
||||
/** Ends only the matching running execution; never steals another turn's card. */
|
||||
public finish(id: string | undefined, status: "completed" | "failed", now: string): ExecutionProgressSummary | undefined {
|
||||
if (!id) return this.finishActive(status, now);
|
||||
const summary = this.summaries.find(entry => entry.id === id && entry.status === "running");
|
||||
if (!summary) return undefined;
|
||||
summary.status = status;
|
||||
summary.updated_at = now;
|
||||
summary.finished_at = now;
|
||||
return copy(summary);
|
||||
}
|
||||
|
||||
public restore(summaries: readonly ExecutionProgressSummary[]): void {
|
||||
this.summaries = summaries.flatMap(summary => valid(summary) ? [copy(summary)] : []).slice(-MAX_SUMMARIES);
|
||||
}
|
||||
|
||||
public snapshot(): readonly ExecutionProgressSummary[] { return this.summaries.map(copy); }
|
||||
|
||||
private trim(): void { if (this.summaries.length > MAX_SUMMARIES) this.summaries.splice(0, this.summaries.length - MAX_SUMMARIES); }
|
||||
}
|
||||
|
||||
function valid(value: ExecutionProgressSummary): boolean {
|
||||
return validID(value.id) && VALID_STATUSES.has(value.status)
|
||||
&& validTime(value.started_at) && validTime(value.updated_at)
|
||||
&& (value.finished_at === undefined || validTime(value.finished_at))
|
||||
&& Array.isArray(value.steps) && value.steps.length <= MAX_STEPS
|
||||
&& value.steps.every(step => validStep(step));
|
||||
}
|
||||
|
||||
function validID(value: unknown): value is string { return typeof value === "string" && ID.test(value); }
|
||||
function validTime(value: unknown): value is string { return typeof value === "string" && !Number.isNaN(Date.parse(value)); }
|
||||
function validStep(value: unknown): value is ExecutionProgressStep {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value)
|
||||
&& validID((value as ExecutionProgressStep).id)
|
||||
&& typeof (value as ExecutionProgressStep).title === "string"
|
||||
&& (value as ExecutionProgressStep).title.trim().length > 0
|
||||
&& (value as ExecutionProgressStep).title.length <= 160
|
||||
&& VALID_STEP_STATUSES.has((value as ExecutionProgressStep).status));
|
||||
}
|
||||
function sanitizeSteps(steps: readonly ExecutionProgressStep[]): ExecutionProgressStep[] {
|
||||
const seen = new Set<string>();
|
||||
return steps.flatMap(step => validStep(step) && !seen.has(step.id) ? (seen.add(step.id), [{ id: step.id, title: step.title.trim(), status: step.status }]) : []).slice(0, MAX_STEPS);
|
||||
}
|
||||
function copy(summary: ExecutionProgressSummary): ExecutionProgressSummary {
|
||||
return { ...summary, steps: summary.steps.map(step => ({ ...step })) };
|
||||
}
|
||||
+55
-2
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseEnvelopeJSON } from "../protocol";
|
||||
import { InteractionKernel } from "./interaction-kernel";
|
||||
import { decodeConversationItem, parseEnvelopeJSON } from "@/runtime/protocol/lineup-v1";
|
||||
import { InteractionKernel } from "@/runtime/coordination/interaction-kernel";
|
||||
|
||||
function envelope(overrides: Record<string, unknown> = {}): string {
|
||||
return JSON.stringify({
|
||||
@@ -195,4 +195,57 @@ describe("InteractionKernel", () => {
|
||||
expect(kernel.requestTaskCancel("task_kernel_001", "2026-08-03T09:05:00.000Z")).toMatchObject({ disposition: "accepted" });
|
||||
expect(kernel.snapshot().tasks).toEqual([expect.objectContaining({ cancel_requested_at: "2026-08-03T09:05:00.000Z" })]);
|
||||
});
|
||||
|
||||
it("admits only app identity and state for a Surface open, never an Agent-provided bundle", () => {
|
||||
const safe = decodeConversationItem(envelope({
|
||||
id: "evt_surface_safe",
|
||||
type: "lineup.v1.ui.open",
|
||||
payload: { instance_id: "task:001", app: { app_id: "lineup.task-dashboard", version: "0.1.0" }, state: { title: "Build" } },
|
||||
}));
|
||||
const injectedBundle = decodeConversationItem(envelope({
|
||||
id: "evt_surface_bundle",
|
||||
type: "lineup.v1.ui.open",
|
||||
payload: { instance_id: "task:002", app: { app_id: "lineup.task-dashboard", version: "0.1.0" }, bundle_url: "https://example.invalid/app.js" },
|
||||
}));
|
||||
const extraAppField = decodeConversationItem(envelope({
|
||||
id: "evt_surface_extra_app",
|
||||
type: "lineup.v1.ui.open",
|
||||
payload: { instance_id: "task:003", app: { app_id: "lineup.task-dashboard", version: "0.1.0", source: "injected" } },
|
||||
}));
|
||||
|
||||
expect(safe).toEqual(expect.objectContaining({ kind: "surface", id: "evt_surface_safe" }));
|
||||
expect(injectedBundle).toEqual(expect.objectContaining({ kind: "fallback", reason: "invalid_payload" }));
|
||||
expect(extraAppField).toEqual(expect.objectContaining({ kind: "fallback", reason: "invalid_payload" }));
|
||||
});
|
||||
|
||||
it("requires a bounded reason, expiry and exact payload shape for app calls", () => {
|
||||
const valid = decodeConversationItem(envelope({
|
||||
id: "evt_capability_valid", type: "lineup.v1.app.call",
|
||||
payload: { call_id: "cap_001", capability: "app.open_url", reason: "打开文档", expires_at: "2026-08-04T00:00:00.000Z", arguments: { url: "https://example.com" } },
|
||||
}));
|
||||
const missingExpiry = decodeConversationItem(envelope({
|
||||
id: "evt_capability_invalid", type: "lineup.v1.app.call",
|
||||
payload: { call_id: "cap_002", capability: "app.open_url", reason: "打开文档", arguments: { url: "https://example.com" } },
|
||||
}));
|
||||
expect(valid).toEqual(expect.objectContaining({ kind: "app-call" }));
|
||||
expect(missingExpiry).toEqual(expect.objectContaining({ kind: "fallback", reason: "invalid_payload" }));
|
||||
});
|
||||
|
||||
it("admits artifact offers as data but rejects undeclared control fields", () => {
|
||||
const safe = decodeConversationItem(envelope({
|
||||
id: "evt_artifact_safe", type: "lineup.v1.artifact.offer",
|
||||
payload: { artifact_id: "report_001", name: "report.pdf", mime_type: "application/pdf", size_bytes: 42, integrity: "verified", created_at: "2026-08-03T00:00:00.000Z" },
|
||||
}));
|
||||
const injected = decodeConversationItem(envelope({
|
||||
id: "evt_artifact_injected", type: "lineup.v1.artifact.offer",
|
||||
payload: { artifact_id: "report_002", name: "report.pdf", mime_type: "application/pdf", size_bytes: 42, integrity: "verified", created_at: "2026-08-03T00:00:00.000Z", url: "https://bad.example" },
|
||||
}));
|
||||
expect(safe).toEqual(expect.objectContaining({ kind: "artifact-offer" }));
|
||||
const content = decodeConversationItem(envelope({
|
||||
id: "evt_artifact_content", type: "lineup.v1.artifact.offer",
|
||||
payload: { artifact_id: "report_003", name: "report.txt", mime_type: "text/plain", size_bytes: 5, integrity: "verified", created_at: "2026-08-03T00:00:00.000Z", local_ref: "artifact-cache-003", content_base64: "aGVsbG8=" },
|
||||
}));
|
||||
expect(content).toEqual(expect.objectContaining({ kind: "artifact-offer" }));
|
||||
expect(injected).toEqual(expect.objectContaining({ kind: "fallback", reason: "invalid_payload" }));
|
||||
});
|
||||
});
|
||||
+13
-3
@@ -1,12 +1,18 @@
|
||||
/**
|
||||
* Runtime 的协议接纳与交互状态协调内核。
|
||||
*
|
||||
* 它把原始传输文本解码为受信任的 ConversationItem、抑制重复 envelope、维护 Agent 状态和
|
||||
* Tool/Task 投影;不操作 DOM、网络、持久化或系统能力。
|
||||
*/
|
||||
import {
|
||||
decodeConversationItem,
|
||||
type Actor,
|
||||
type ConversationItem,
|
||||
type JsonObject,
|
||||
type ToolCallFinalStatus,
|
||||
} from "../protocol";
|
||||
import { ToolCallStateMachine, type ToolCallRecord } from "./tool-call-state";
|
||||
import { TaskStateMachine, type TaskRecord } from "./task-state";
|
||||
} from "@/runtime/protocol/lineup-v1";
|
||||
import { ToolCallStateMachine, type ToolCallRecord } from "@/runtime/coordination/tool-call-state";
|
||||
import { TaskStateMachine, type TaskRecord } from "@/runtime/coordination/task-state";
|
||||
|
||||
/** Transport metadata is intentionally opaque to the protocol and renderer. */
|
||||
export type IncomingMessage = {
|
||||
@@ -90,6 +96,10 @@ export class InteractionKernel {
|
||||
return this.toolCalls.expireCall(callID, updatedAt);
|
||||
}
|
||||
|
||||
public setToolCallExpiry(callID: string, expiresAt: string, updatedAt: string) {
|
||||
return this.toolCalls.setExpiry(callID, expiresAt, updatedAt);
|
||||
}
|
||||
|
||||
public restoreToolCalls(records: readonly ToolCallRecord[], now: string): void {
|
||||
this.toolCalls.restore(records, now);
|
||||
}
|
||||
@@ -0,0 +1,598 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { LoginRequest, LoginResult, SendTextRequest, SyncRequest, TransportAdapter, TransportMessage } from "@/runtime/communication/transport-adapter";
|
||||
import { LineUpRuntime } from "@/runtime/coordination/lineup-runtime";
|
||||
|
||||
class MemoryStorage implements Storage {
|
||||
private readonly values = new Map<string, string>();
|
||||
public get length(): number { return this.values.size; }
|
||||
public clear(): void { this.values.clear(); }
|
||||
public getItem(key: string): string | null { return this.values.get(key) ?? null; }
|
||||
public key(index: number): string | null { return [...this.values.keys()][index] ?? null; }
|
||||
public removeItem(key: string): void { this.values.delete(key); }
|
||||
public setItem(key: string, value: string): void { this.values.set(key, value); }
|
||||
}
|
||||
|
||||
class FakeTransport implements TransportAdapter {
|
||||
public readonly sent: SendTextRequest[] = [];
|
||||
private readonly pages: TransportMessage[][];
|
||||
public failSends = false;
|
||||
|
||||
public constructor(pages: TransportMessage[][] = []) {
|
||||
this.pages = [...pages];
|
||||
}
|
||||
|
||||
public async login(_request: LoginRequest): Promise<LoginResult> {
|
||||
return { uid: "user_1", agent_uid: "agent_1", channel_id: "channel_1", channel_type: 2 };
|
||||
}
|
||||
|
||||
public async sendText(request: SendTextRequest): Promise<number | undefined> {
|
||||
if (this.failSends) throw new Error("offline");
|
||||
this.sent.push(request);
|
||||
return 91 + this.sent.length;
|
||||
}
|
||||
|
||||
public async sync(_request: SyncRequest): Promise<readonly TransportMessage[]> {
|
||||
return this.pages.shift() ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
const login = {
|
||||
api: "http://lineup.test",
|
||||
phone: "13800000000",
|
||||
code: "123456",
|
||||
fallback_agent_uid: "agent_fallback",
|
||||
fallback_channel_id: "channel_fallback",
|
||||
fallback_channel_type: 2,
|
||||
};
|
||||
|
||||
function agentEnvelope(id: string, conversationID: string, extra: Record<string, unknown> = {}): string {
|
||||
return JSON.stringify({
|
||||
v: 1,
|
||||
id,
|
||||
type: "lineup.v1.text",
|
||||
conversation_id: conversationID,
|
||||
sender: { kind: "agent", id: "agent_1" },
|
||||
payload: { markdown: "**hello**" },
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
describe("LineUpRuntime", () => {
|
||||
it("launches an installed extension, backgrounds Interaction IM, restores focus after completion, and recovers workspace", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const conversationID = "user_1:2:channel_1";
|
||||
const launch = JSON.stringify({
|
||||
v: 1, id: "launch-game", type: "lineup.v1.app.call", conversation_id: conversationID,
|
||||
sender: { kind: "agent", id: "agent_1" },
|
||||
payload: { call_id: "game-call", capability: "runtime.launch_app", reason: "Start game", expires_at: "2026-08-05T00:00:00.000Z", arguments: { app_scope: "draw-and-guess", instance_id: "draw-and-guess:game-001" } },
|
||||
});
|
||||
const transport = new FakeTransport([[{ message_seq: 30, from_uid: "agent_1", payload: launch }], []]);
|
||||
const runtime = new LineUpRuntime({ storage, createTransport: () => transport, now: () => "2026-08-04T00:00:00.000Z" });
|
||||
runtime.apps.install({
|
||||
app_scope: "draw-and-guess", kind: "bundled", enabled: true, default_eligible: false, recovery: false,
|
||||
manifest: { app_scope: "draw-and-guess", version: "1.0.0", permissions: [], tools: [{ name: "app.launch", handling: "launch", parameters: ["app_scope", "instance_id"] }] },
|
||||
});
|
||||
await runtime.login(login);
|
||||
await runtime.syncOnce();
|
||||
|
||||
await runtime.flushOutbox();
|
||||
expect(transport.sent.map(entry => JSON.parse(entry.payload)).find(entry => entry.type === "lineup.v1.app.session.opened")).toMatchObject({
|
||||
payload: { agent_id: "agent_1", conversation_id: conversationID, app_session_id: expect.any(String) },
|
||||
});
|
||||
|
||||
expect(runtime.lifecycle.instances.get("draw-and-guess:game-001")).toMatchObject({ state: "foreground", conversation_id: conversationID });
|
||||
const interaction = runtime.lifecycle.instances.activeFor("chat", conversationID);
|
||||
expect(interaction).toMatchObject({ state: "background" });
|
||||
expect(runtime.snapshot()?.app_inbox).toEqual([expect.objectContaining({ app_scope: "draw-and-guess", message_id: "launch-game" })]);
|
||||
runtime.closeAppInstance("draw-and-guess:game-001");
|
||||
expect(runtime.lifecycle.focus.foreground()).toBe(interaction?.instance_id);
|
||||
expect(runtime.lifecycle.instances.get(interaction!.instance_id)).toMatchObject({ state: "foreground" });
|
||||
|
||||
const recovered = new LineUpRuntime({ storage, createTransport: () => new FakeTransport(), now: () => "2026-08-04T00:00:00.000Z" });
|
||||
await recovered.login(login);
|
||||
expect(recovered.workspaceSnapshot().focus.stack).toEqual([interaction!.instance_id]);
|
||||
expect(recovered.lifecycle.instances.get("draw-and-guess:game-001")).toMatchObject({ state: "stopped" });
|
||||
});
|
||||
|
||||
it("loads chat from the Core App Registry and exposes only a scoped Chat SDK", async () => {
|
||||
const transport = new FakeTransport();
|
||||
const runtime = new LineUpRuntime({ storage: new MemoryStorage(), createTransport: () => transport });
|
||||
|
||||
expect(runtime.apps.list()).toContainEqual(expect.objectContaining({ app_scope: "chat", kind: "system", enabled: true, recovery: true }));
|
||||
expect(runtime.apps.list()).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ app_scope: "task-dashboard", kind: "bundled" }),
|
||||
expect.objectContaining({ app_scope: "whiteboard", kind: "bundled" }),
|
||||
]));
|
||||
const chat = runtime.openApp("chat");
|
||||
expect(chat.app_scope).toBe("chat");
|
||||
expect(chat.runtime.dispatch).toBe(chat.dispatch);
|
||||
expect(chat.runtime.interactions).toBe(chat.interactions);
|
||||
expect(chat.runtime.tasks).toBe(chat.tasks);
|
||||
expect(chat.ui.snapshot).toBe(chat.snapshot);
|
||||
expect(chat.snapshot()).toMatchObject({ app_scope: "chat", active: false });
|
||||
|
||||
await runtime.login(login);
|
||||
expect(chat.snapshot()).toMatchObject({ app_scope: "chat", active: true, conversation_id: "user_1:2:channel_1" });
|
||||
});
|
||||
|
||||
it("includes agent and conversation context in App session closed events", async () => {
|
||||
const conversationID = "user_1:2:channel_1";
|
||||
const transport = new FakeTransport();
|
||||
const runtime = new LineUpRuntime({ storage: new MemoryStorage(), createTransport: () => transport });
|
||||
await runtime.login(login);
|
||||
const instance = runtime.lifecycle.start({ app_scope: "whiteboard", instance_id: "whiteboard:context-1", app_session_id: "whiteboard:session-1", conversation_id: conversationID }, "2026-08-04T00:00:00.000Z");
|
||||
runtime.lifecycle.foreground(instance.instance_id, "2026-08-04T00:00:00.000Z");
|
||||
await runtime.openBundledMiniApp("whiteboard", instance.instance_id).lifecycle.requestBackground();
|
||||
runtime.closeAppInstance(instance.instance_id);
|
||||
await runtime.flushOutbox();
|
||||
const event = transport.sent.map(entry => JSON.parse(entry.payload) as { type: string; payload: Record<string, unknown> }).find(entry => entry.type === "lineup.v1.app.session.closed");
|
||||
expect(event).toMatchObject({ type: "lineup.v1.app.session.closed", payload: { agent_id: "agent_1", conversation_id: conversationID, app_session_id: "whiteboard:session-1" } });
|
||||
});
|
||||
|
||||
it("adapts legacy LineUp v1 messages into chat scope before SDK delivery", async () => {
|
||||
const conversationID = "user_1:2:channel_1";
|
||||
const transport = new FakeTransport([[{ message_seq: 7, from_uid: "agent_1", payload: agentEnvelope("agent_msg_7", conversationID) }], []]);
|
||||
const runtime = new LineUpRuntime({ storage: new MemoryStorage(), createTransport: () => transport, now: () => "2026-08-04T00:00:00.000Z" });
|
||||
const chat = runtime.openChatApp();
|
||||
const received: unknown[] = [];
|
||||
chat.subscribeAgentMessages(message => received.push(message));
|
||||
|
||||
await runtime.login(login);
|
||||
await runtime.syncOnce();
|
||||
|
||||
expect(received).toEqual([expect.objectContaining({
|
||||
message_id: "agent_msg_7",
|
||||
scope: { app_scope: "chat", conversation_id: conversationID },
|
||||
item: expect.objectContaining({ kind: "markdown", markdown: "**hello**" }),
|
||||
})]);
|
||||
expect(chat.listAgentMessages()).toEqual([expect.objectContaining({ message_id: "agent_msg_7" })]);
|
||||
});
|
||||
|
||||
it("keeps Agent standard interactions in Interact/IM and out of every MiniApp Inbox", async () => {
|
||||
const conversationID = "user_1:2:channel_1";
|
||||
const interaction = JSON.stringify({
|
||||
v: 1, id: "interactive-call-1", type: "lineup.v1.tool.call", conversation_id: conversationID,
|
||||
sender: { kind: "agent", id: "agent_1" },
|
||||
payload: { call_id: "interactive-call-1", tool: "confirm", title: "确认", prompt: "继续吗?", data: { confirm: {} } },
|
||||
});
|
||||
const runtime = new LineUpRuntime({
|
||||
storage: new MemoryStorage(),
|
||||
createTransport: () => new FakeTransport([[{ message_seq: 12, from_uid: "agent_1", payload: interaction }], []]),
|
||||
});
|
||||
const chat = runtime.openChatApp();
|
||||
await runtime.login(login);
|
||||
await runtime.syncOnce();
|
||||
|
||||
expect(chat.listAgentMessages()).toEqual([expect.objectContaining({ item: expect.objectContaining({ kind: "tool-call" }) })]);
|
||||
expect(runtime.snapshot()?.app_inbox).toEqual([]);
|
||||
});
|
||||
|
||||
it("persists a pending Interact question, restores it after restart, and sends one answer", async () => {
|
||||
const conversationID = "user_1:2:channel_1";
|
||||
const interaction = JSON.stringify({
|
||||
v: 1, id: "interactive-restart-1", type: "lineup.v1.tool.call", conversation_id: conversationID,
|
||||
sender: { kind: "agent", id: "agent_1" },
|
||||
payload: { call_id: "interactive-restart-1", tool: "confirm", title: "确认", prompt: "继续吗?", data: { confirm: {} } },
|
||||
});
|
||||
const storage = new MemoryStorage();
|
||||
const firstTransport = new FakeTransport([[{ message_seq: 13, from_uid: "agent_1", payload: interaction }], []]);
|
||||
const first = new LineUpRuntime({ storage, createTransport: () => firstTransport, now: () => "2026-08-04T00:00:00.000Z" });
|
||||
await first.login(login);
|
||||
await first.syncOnce();
|
||||
expect(first.standardInteractions()).toEqual([expect.objectContaining({ call_id: "interactive-restart-1", status: "presented" })]);
|
||||
|
||||
const secondTransport = new FakeTransport();
|
||||
const second = new LineUpRuntime({ storage, createTransport: () => secondTransport, now: () => "2026-08-04T00:01:00.000Z" });
|
||||
const chat = second.openChatApp();
|
||||
await second.login(login);
|
||||
expect(second.standardInteractions()).toEqual([expect.objectContaining({ call_id: "interactive-restart-1", status: "presented" })]);
|
||||
expect(chat.interactions.submit("interactive-restart-1", { approved: true })).toBe(true);
|
||||
await second.flushOutbox();
|
||||
expect(second.standardInteractions()).toEqual([expect.objectContaining({ call_id: "interactive-restart-1", status: "completed", result: { outcome: "answered", answer: { approved: true } } })]);
|
||||
expect(secondTransport.sent.filter(entry => entry.payload.includes("lineup.v1.tool.result"))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("associates an Agent question with a real App sub-session and rejects a fabricated one", async () => {
|
||||
const conversationID = "user_1:2:channel_1";
|
||||
const launch = JSON.stringify({
|
||||
v: 1, id: "launch-whiteboard", type: "lineup.v1.app.call", conversation_id: conversationID,
|
||||
sender: { kind: "agent", id: "agent_1" },
|
||||
payload: { call_id: "launch-whiteboard", capability: "runtime.launch_app", reason: "打开画板", expires_at: "2026-08-05T00:00:00.000Z", arguments: { app_scope: "whiteboard", instance_id: "whiteboard:1" } },
|
||||
});
|
||||
const validQuestion = JSON.stringify({
|
||||
v: 1, id: "whiteboard-question", type: "lineup.v1.tool.call", conversation_id: conversationID,
|
||||
sender: { kind: "agent", id: "agent_1" },
|
||||
payload: { call_id: "whiteboard-question", tool: "input", title: "画板名称", prompt: "请输入名称", app_session_context: { app_session_id: "whiteboard:session-id" }, data: { form: { fields: [{ id: "name", label: "名称", type: "text" }] } } },
|
||||
});
|
||||
const invalidQuestion = validQuestion.replace("whiteboard-question", "whiteboard-question-invalid").replace("whiteboard:session-id", "not-a-real-session");
|
||||
const transport = new FakeTransport([[{ message_seq: 40, from_uid: "agent_1", payload: launch }], [{ message_seq: 41, from_uid: "agent_1", payload: validQuestion }, { message_seq: 42, from_uid: "agent_1", payload: invalidQuestion }], []]);
|
||||
const runtime = new LineUpRuntime({ storage: new MemoryStorage(), createTransport: () => transport, now: () => "2026-08-04T00:00:00.000Z", newID: prefix => prefix === "whiteboard:session" ? "whiteboard:session-id" : `${prefix}-id` });
|
||||
await runtime.login(login);
|
||||
await runtime.syncOnce();
|
||||
expect(runtime.lifecycle.instances.get("whiteboard:1")?.app_session_id).toBe("whiteboard:session-id");
|
||||
await runtime.syncOnce();
|
||||
expect(runtime.standardInteractions()).toEqual([expect.objectContaining({ call_id: "whiteboard-question", app_session_id: "whiteboard:session-id" })]);
|
||||
expect(runtime.standardInteractions().some(record => record.call_id === "whiteboard-question-invalid")).toBe(false);
|
||||
});
|
||||
|
||||
it("handles Agent interaction.dismiss idempotently and emits one cancellation", async () => {
|
||||
const conversationID = "user_1:2:channel_1";
|
||||
const question = JSON.stringify({ v: 1, id: "dismiss-question", type: "lineup.v1.tool.call", conversation_id: conversationID, sender: { kind: "agent", id: "agent_1" }, payload: { call_id: "dismiss-question", tool: "confirm", title: "确认", prompt: "继续吗?", data: { confirm: {} } } });
|
||||
const dismiss = (id: string) => JSON.stringify({ v: 1, id, type: "lineup.v1.interaction.dismiss", conversation_id: conversationID, sender: { kind: "agent", id: "agent_1" }, payload: { control_id: "dismiss-control-1", call_id: "dismiss-question", reason: "已不再需要" } });
|
||||
const transport = new FakeTransport([[{ message_seq: 50, from_uid: "agent_1", payload: question }], [{ message_seq: 51, from_uid: "agent_1", payload: dismiss("dismiss-1") }, { message_seq: 52, from_uid: "agent_1", payload: dismiss("dismiss-2") }], []]);
|
||||
const runtime = new LineUpRuntime({ storage: new MemoryStorage(), createTransport: () => transport, now: () => "2026-08-04T00:00:00.000Z" });
|
||||
await runtime.login(login);
|
||||
await runtime.syncOnce();
|
||||
await runtime.syncOnce();
|
||||
await runtime.flushOutbox();
|
||||
expect(runtime.standardInteractions()).toEqual([expect.objectContaining({ call_id: "dismiss-question", status: "cancelled" })]);
|
||||
expect(runtime.toolCalls()).toEqual([expect.objectContaining({ call_id: "dismiss-question", status: "cancelled" })]);
|
||||
expect(transport.sent.filter(entry => entry.payload.includes("lineup.v1.tool.cancel"))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rejects an invalid standard answer before either interaction state or outbox changes", async () => {
|
||||
const conversationID = "user_1:2:channel_1";
|
||||
const question = JSON.stringify({ v: 1, id: "answer-shape-question", type: "lineup.v1.tool.call", conversation_id: conversationID, sender: { kind: "agent", id: "agent_1" }, payload: { call_id: "answer-shape-question", tool: "choice", title: "选择", prompt: "选一个", data: { action_group: { mode: "single-choice", actions: [{ id: "a", label: "A" }, { id: "b", label: "B" }] } } } });
|
||||
const transport = new FakeTransport([[{ message_seq: 53, from_uid: "agent_1", payload: question }], []]);
|
||||
const runtime = new LineUpRuntime({ storage: new MemoryStorage(), createTransport: () => transport });
|
||||
const chat = runtime.openChatApp();
|
||||
await runtime.login(login);
|
||||
await runtime.syncOnce();
|
||||
expect(chat.interactions.submit("answer-shape-question", { action_ids: ["a"] })).toBe(false);
|
||||
expect(runtime.standardInteractions()).toEqual([expect.objectContaining({ call_id: "answer-shape-question", status: "presented" })]);
|
||||
expect(runtime.toolCalls()).toEqual([expect.objectContaining({ call_id: "answer-shape-question", status: "pending" })]);
|
||||
expect(chat.interactions.submit("answer-shape-question", { action_id: "a" })).toBe(true);
|
||||
await runtime.flushOutbox();
|
||||
expect(transport.sent.filter(entry => entry.payload.includes("lineup.v1.tool.result"))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("uses one default expiry for Interaction and Kernel and emits one expired result", async () => {
|
||||
let now = "2026-08-04T00:00:00.000Z";
|
||||
const conversationID = "user_1:2:channel_1";
|
||||
const question = JSON.stringify({ v: 1, id: "default-expiry-question", type: "lineup.v1.tool.call", conversation_id: conversationID, sender: { kind: "agent", id: "agent_1" }, payload: { call_id: "default-expiry-question", tool: "confirm", title: "确认", prompt: "继续吗?", data: { confirm: {} } } });
|
||||
const transport = new FakeTransport([[{ message_seq: 54, from_uid: "agent_1", payload: question }], []]);
|
||||
const runtime = new LineUpRuntime({ storage: new MemoryStorage(), createTransport: () => transport, now: () => now });
|
||||
await runtime.login(login);
|
||||
await runtime.syncOnce();
|
||||
expect(runtime.standardInteractions()[0]).toMatchObject({ expires_at: "2026-08-04T00:15:00.000Z", status: "presented" });
|
||||
expect(runtime.toolCalls()[0]).toMatchObject({ request: { expires_at: "2026-08-04T00:15:00.000Z" }, status: "pending" });
|
||||
now = "2026-08-04T00:16:00.000Z";
|
||||
expect(runtime.expireToolCall("default-expiry-question")).toBe(true);
|
||||
await runtime.flushOutbox();
|
||||
expect(runtime.standardInteractions()[0]).toMatchObject({ status: "expired" });
|
||||
expect(runtime.toolCalls()[0]).toMatchObject({ status: "expired" });
|
||||
expect(transport.sent.filter(entry => entry.payload.includes("default-expiry-question") && entry.payload.includes("\"status\":\"expired\"")).length).toBe(1);
|
||||
});
|
||||
|
||||
it("replays one expired result when Runtime restarts after an unanswered interaction expires", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const conversationID = "user_1:2:channel_1";
|
||||
const question = JSON.stringify({ v: 1, id: "restart-expiry-question", type: "lineup.v1.tool.call", conversation_id: conversationID, sender: { kind: "agent", id: "agent_1" }, payload: { call_id: "restart-expiry-question", tool: "confirm", title: "确认", prompt: "继续吗?", data: { confirm: {} } } });
|
||||
const first = new LineUpRuntime({ storage, createTransport: () => new FakeTransport([[{ message_seq: 55, from_uid: "agent_1", payload: question }], []]), now: () => "2026-08-04T00:00:00.000Z" });
|
||||
await first.login(login);
|
||||
await first.syncOnce();
|
||||
const transport = new FakeTransport();
|
||||
const second = new LineUpRuntime({ storage, createTransport: () => transport, now: () => "2026-08-04T00:16:00.000Z" });
|
||||
await second.login(login);
|
||||
expect(second.standardInteractions()).toEqual([expect.objectContaining({ call_id: "restart-expiry-question", status: "expired" })]);
|
||||
expect(second.toolCalls()).toEqual([expect.objectContaining({ call_id: "restart-expiry-question", status: "expired" })]);
|
||||
await second.flushOutbox();
|
||||
expect(transport.sent.filter(entry => entry.payload.includes("restart-expiry-question") && entry.payload.includes("\"status\":\"expired\"")).length).toBe(1);
|
||||
});
|
||||
|
||||
it("delivers a revisioned ordinary Tool to bundled MiniApp SDK and keeps the App open after completion", async () => {
|
||||
const conversationID = "user_1:2:channel_1";
|
||||
const call = JSON.stringify({
|
||||
v: 1, id: "task-tool-call", type: "lineup.v1.miniapp.tool.call", conversation_id: conversationID,
|
||||
sender: { kind: "agent", id: "agent_1" },
|
||||
payload: { call_id: "task-tool-call", tool_id: "task-dashboard.open", app_scope: "task-dashboard", inventory_revision: "catalog-1", input: { title: "写验收记录" } },
|
||||
});
|
||||
const transport = new FakeTransport([[{ message_seq: 60, from_uid: "agent_1", payload: call }], []]);
|
||||
const runtime = new LineUpRuntime({ storage: new MemoryStorage(), createTransport: () => transport, now: () => "2026-08-04T00:00:00.000Z", newID: prefix => `${prefix}-id` });
|
||||
await runtime.login(login);
|
||||
await runtime.syncOnce();
|
||||
const instance = runtime.lifecycle.instances.activeFor("task-dashboard", conversationID);
|
||||
expect(instance?.app_session_id).toBe("task-dashboard:session-id");
|
||||
expect(runtime.snapshot()?.app_inbox).toEqual([expect.objectContaining({ app_scope: "task-dashboard", item: expect.objectContaining({ kind: "miniapp-tool-call" }) })]);
|
||||
const sdk = runtime.openBundledMiniApp("task-dashboard", instance!.instance_id);
|
||||
const received: string[] = [];
|
||||
sdk.tools.subscribe(tool => received.push(tool.call_id));
|
||||
expect(received).toEqual(["task-tool-call"]);
|
||||
expect(runtime.miniAppToolCalls()).toEqual([expect.objectContaining({ call_id: "task-tool-call", status: "running", instance_id: instance!.instance_id })]);
|
||||
await sdk.tools.complete("task-tool-call", { status: "completed", task_id: "task-1" });
|
||||
await runtime.flushOutbox();
|
||||
expect(runtime.miniAppToolCalls()).toEqual([expect.objectContaining({ call_id: "task-tool-call", status: "completed" })]);
|
||||
expect(runtime.lifecycle.instances.get(instance!.instance_id)).toMatchObject({ state: "foreground" });
|
||||
expect(transport.sent.filter(entry => entry.payload.includes("lineup.v1.miniapp.tool.result"))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("isolates bundled MiniApp Inbox subscriptions by instance and does not expose global workspace", async () => {
|
||||
const conversationID = "user_1:2:channel_1";
|
||||
const runtime = new LineUpRuntime({ storage: new MemoryStorage(), createTransport: () => new FakeTransport() });
|
||||
await runtime.login(login);
|
||||
const first = runtime.lifecycle.start({ app_scope: "task-dashboard", instance_id: "task-dashboard:first", app_session_id: "task-dashboard:first-session", conversation_id: conversationID }, "2026-08-04T00:00:00.000Z");
|
||||
const second = runtime.lifecycle.start({ app_scope: "task-dashboard", instance_id: "task-dashboard:second", app_session_id: "task-dashboard:second-session", conversation_id: conversationID }, "2026-08-04T00:00:00.000Z");
|
||||
runtime.lifecycle.foreground(first.instance_id, "2026-08-04T00:00:00.000Z");
|
||||
runtime.lifecycle.foreground(second.instance_id, "2026-08-04T00:00:00.000Z");
|
||||
const sdk = runtime.openBundledMiniApp("task-dashboard", first.instance_id);
|
||||
const received: string[] = [];
|
||||
sdk.inbox.subscribe(message => received.push(message.message_id));
|
||||
const store = (runtime as unknown as { store: { enqueueAppInbox: (entry: unknown) => boolean } }).store;
|
||||
store.enqueueAppInbox({ message_id: "first-message", app_scope: "task-dashboard", instance_id: first.instance_id, conversation_id: conversationID, item: { kind: "markdown", id: "first-message", markdown: "first" }, received_at: "2026-08-04T00:00:00.000Z" });
|
||||
store.enqueueAppInbox({ message_id: "second-message", app_scope: "task-dashboard", instance_id: second.instance_id, conversation_id: conversationID, item: { kind: "markdown", id: "second-message", markdown: "second" }, received_at: "2026-08-04T00:00:00.000Z" });
|
||||
const emit = (runtime as unknown as { emit: (event: unknown) => void }).emit.bind(runtime);
|
||||
emit({ type: "agent-message", message: { message_id: "first-message", scope: { app_scope: "task-dashboard", conversation_id: conversationID }, item: { kind: "markdown", id: "first-message", markdown: "first" }, received_at: "2026-08-04T00:00:00.000Z" } });
|
||||
emit({ type: "agent-message", message: { message_id: "second-message", scope: { app_scope: "task-dashboard", conversation_id: conversationID }, item: { kind: "markdown", id: "second-message", markdown: "second" }, received_at: "2026-08-04T00:00:00.000Z" } });
|
||||
expect(received).toEqual(["first-message"]);
|
||||
expect("workspace" in sdk).toBe(false);
|
||||
});
|
||||
|
||||
it("requires bundled MiniApp capabilities to be declared and policy-valid before queuing an Agent request", async () => {
|
||||
const conversationID = "user_1:2:channel_1";
|
||||
const transport = new FakeTransport();
|
||||
const runtime = new LineUpRuntime({ storage: new MemoryStorage(), createTransport: () => transport, now: () => "2026-08-04T00:00:00.000Z" });
|
||||
runtime.apps.install({
|
||||
app_scope: "capability-demo", kind: "bundled", enabled: true, default_eligible: false, recovery: false,
|
||||
manifest: { app_scope: "capability-demo", version: "1.0.0", permissions: ["app.open_url"], tools: [] },
|
||||
});
|
||||
await runtime.login(login);
|
||||
const instance = runtime.lifecycle.start({ app_scope: "capability-demo", instance_id: "capability-demo:1", app_session_id: "capability-demo:session", conversation_id: conversationID }, "2026-08-04T00:00:00.000Z");
|
||||
runtime.lifecycle.foreground(instance.instance_id, "2026-08-04T00:00:00.000Z");
|
||||
const sdk = runtime.openBundledMiniApp("capability-demo", instance.instance_id);
|
||||
expect(await sdk.capabilities.request("clipboard.write", "复制文本", { text: "x" })).toEqual({ disposition: "rejected", code: "capability_not_declared" });
|
||||
expect(await sdk.capabilities.request("app.open_url", "打开文档", { url: "https://example.com" })).toEqual(expect.objectContaining({ disposition: "accepted" }));
|
||||
await runtime.flushOutbox();
|
||||
expect(transport.sent.filter(entry => entry.payload.includes("lineup.v1.app.call"))).toHaveLength(1);
|
||||
expect(transport.sent.at(-1)?.payload).toContain("app.open_url");
|
||||
});
|
||||
|
||||
it("keeps bundled Surface requests local and scopes them to the current instance", async () => {
|
||||
const conversationID = "user_1:2:channel_1";
|
||||
const launch = JSON.stringify({
|
||||
v: 1, id: "whiteboard-launch", type: "lineup.v1.miniapp.tool.call", conversation_id: conversationID,
|
||||
sender: { kind: "agent", id: "agent_1" },
|
||||
payload: { call_id: "whiteboard-launch", tool_id: "whiteboard.open", app_scope: "whiteboard", inventory_revision: "catalog-1", input: { board_id: "board-1", title: "验收画板" } },
|
||||
});
|
||||
const transport = new FakeTransport([[{ message_seq: 62, from_uid: "agent_1", payload: launch }], []]);
|
||||
const runtime = new LineUpRuntime({ storage: new MemoryStorage(), createTransport: () => transport });
|
||||
await runtime.login(login);
|
||||
await runtime.syncOnce();
|
||||
const instance = runtime.lifecycle.instances.activeFor("whiteboard", conversationID);
|
||||
expect(instance).toBeDefined();
|
||||
const requests: unknown[] = [];
|
||||
runtime.onEvent(event => { if (event.type === "surface-request") requests.push(event); });
|
||||
const sdk = runtime.openBundledMiniApp("whiteboard", instance!.instance_id);
|
||||
expect(await sdk.surfaces.request("open", { state: { title: "验收画板" } })).toEqual({ disposition: "accepted" });
|
||||
expect(await sdk.surfaces.request("patch", { state: { title: "已更新" } })).toEqual({ disposition: "accepted" });
|
||||
expect(requests).toEqual([
|
||||
expect.objectContaining({ type: "surface-request", app_scope: "whiteboard", instance_id: instance!.instance_id, event: "open", data: { state: { title: "验收画板" } } }),
|
||||
expect.objectContaining({ type: "surface-request", event: "patch", data: { state: { title: "已更新" } } }),
|
||||
]);
|
||||
expect(transport.sent.some(entry => entry.payload.includes("lineup.v1.ui.open"))).toBe(false);
|
||||
});
|
||||
|
||||
it("converges an unclaimed bundled Tool once on App close and keeps no new delivery path", async () => {
|
||||
const conversationID = "user_1:2:channel_1";
|
||||
const call = JSON.stringify({ v: 1, id: "task-tool-close", type: "lineup.v1.miniapp.tool.call", conversation_id: conversationID, sender: { kind: "agent", id: "agent_1" }, payload: { call_id: "task-tool-close", tool_id: "task-dashboard.open", app_scope: "task-dashboard", inventory_revision: "catalog-1", input: { title: "关闭测试" } } });
|
||||
const transport = new FakeTransport([[{ message_seq: 61, from_uid: "agent_1", payload: call }], []]);
|
||||
const runtime = new LineUpRuntime({ storage: new MemoryStorage(), createTransport: () => transport, now: () => "2026-08-04T00:00:00.000Z" });
|
||||
await runtime.login(login);
|
||||
await runtime.syncOnce();
|
||||
const instance = runtime.lifecycle.instances.activeFor("task-dashboard", conversationID);
|
||||
runtime.closeAppInstance(instance!.instance_id);
|
||||
await runtime.flushOutbox();
|
||||
expect(runtime.miniAppToolCalls()).toEqual([expect.objectContaining({ call_id: "task-tool-close", status: "cancelled", cancel_reason: "app_closed" })]);
|
||||
expect(runtime.lifecycle.instances.get(instance!.instance_id)).toMatchObject({ state: "stopped" });
|
||||
expect(transport.sent.filter(entry => entry.payload.includes("lineup.v1.miniapp.tool.cancel"))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("continues a closed bundled App in a new instance and new App sub-session", async () => {
|
||||
const conversationID = "user_1:2:channel_1";
|
||||
const transport = new FakeTransport();
|
||||
const runtime = new LineUpRuntime({ storage: new MemoryStorage(), createTransport: () => transport, newID: prefix => `${prefix}-${Math.random().toString(36).slice(2, 7)}` });
|
||||
await runtime.login(login);
|
||||
const first = runtime.lifecycle.instances.activeFor("whiteboard", conversationID) ?? runtime.lifecycle.start({ app_scope: "whiteboard", instance_id: "whiteboard:first", app_session_id: "whiteboard:first-session", conversation_id: conversationID }, "2026-08-04T00:00:00.000Z");
|
||||
if (first.state === "starting") runtime.lifecycle.foreground(first.instance_id, "2026-08-04T00:00:00.000Z");
|
||||
runtime.closeAppInstance(first.instance_id);
|
||||
const receipt = await runtime.openApp("chat").dispatch({ type: "chat.continue_app_session", conversation_id: conversationID, app_session_id: first.app_session_id! });
|
||||
expect(receipt).toEqual({ disposition: "accepted" });
|
||||
const continued = runtime.lifecycle.instances.activeFor("whiteboard", conversationID);
|
||||
expect(continued?.instance_id).not.toBe(first.instance_id);
|
||||
expect(continued?.app_session_id).not.toBe(first.app_session_id);
|
||||
expect(continued?.continued_from_app_session_id).toBe(first.app_session_id);
|
||||
});
|
||||
|
||||
it("replays a submitted MiniApp result from the outbox after Runtime restart", async () => {
|
||||
const conversationID = "user_1:2:channel_1";
|
||||
const call = JSON.stringify({
|
||||
v: 1, id: "restart-tool-call", type: "lineup.v1.miniapp.tool.call", conversation_id: conversationID,
|
||||
sender: { kind: "agent", id: "agent_1" },
|
||||
payload: { call_id: "restart-tool-call", tool_id: "task-dashboard.open", app_scope: "task-dashboard", inventory_revision: "catalog-1", input: { title: "重启恢复" } },
|
||||
});
|
||||
const storage = new MemoryStorage();
|
||||
const firstTransport = new FakeTransport([[{ message_seq: 70, from_uid: "agent_1", payload: call }], []]);
|
||||
firstTransport.failSends = true;
|
||||
const first = new LineUpRuntime({ storage, createTransport: () => firstTransport });
|
||||
await first.login(login);
|
||||
await first.syncOnce();
|
||||
const instance = first.lifecycle.instances.activeFor("task-dashboard", conversationID)!;
|
||||
const sdk = first.openBundledMiniApp("task-dashboard", instance.instance_id);
|
||||
sdk.tools.subscribe(() => undefined);
|
||||
await sdk.tools.complete("restart-tool-call", { status: "completed", task_id: "restart-task" });
|
||||
expect(first.miniAppToolCalls()).toEqual([expect.objectContaining({ call_id: "restart-tool-call", status: "submitted" })]);
|
||||
|
||||
const secondTransport = new FakeTransport();
|
||||
const second = new LineUpRuntime({ storage, createTransport: () => secondTransport });
|
||||
await second.login(login);
|
||||
expect(second.miniAppToolCalls()).toEqual([expect.objectContaining({ call_id: "restart-tool-call", status: "submitted" })]);
|
||||
await second.flushOutbox();
|
||||
expect(second.miniAppToolCalls()).toEqual([expect.objectContaining({ call_id: "restart-tool-call", status: "completed" })]);
|
||||
expect(secondTransport.sent.filter(entry => entry.payload.includes("restart-tool-call"))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("restores the latest MiniApp progress after Runtime restart", async () => {
|
||||
const conversationID = "user_1:2:channel_1";
|
||||
const call = JSON.stringify({
|
||||
v: 1, id: "progress-restart-call", type: "lineup.v1.miniapp.tool.call", conversation_id: conversationID,
|
||||
sender: { kind: "agent", id: "agent_1" },
|
||||
payload: { call_id: "progress-restart-call", tool_id: "task-dashboard.open", app_scope: "task-dashboard", inventory_revision: "catalog-1", input: { title: "进度恢复" } },
|
||||
});
|
||||
const storage = new MemoryStorage();
|
||||
const firstTransport = new FakeTransport([[{ message_seq: 71, from_uid: "agent_1", payload: call }], []]);
|
||||
const first = new LineUpRuntime({ storage, createTransport: () => firstTransport });
|
||||
await first.login(login);
|
||||
await first.syncOnce();
|
||||
const instance = first.lifecycle.instances.activeFor("task-dashboard", conversationID)!;
|
||||
const sdk = first.openBundledMiniApp("task-dashboard", instance.instance_id);
|
||||
await sdk.tools.reportProgress("progress-restart-call", { percent: 42, status: "working", detail: "正在处理" });
|
||||
expect(first.miniAppToolCalls()).toEqual([expect.objectContaining({ call_id: "progress-restart-call", status: "running", progress: { percent: 42, status: "working", detail: "正在处理" } })]);
|
||||
|
||||
const second = new LineUpRuntime({ storage, createTransport: () => new FakeTransport() });
|
||||
await second.login(login);
|
||||
expect(second.miniAppToolCalls()).toEqual([expect.objectContaining({ call_id: "progress-restart-call", status: "waiting_for_app", progress: { percent: 42, status: "working", detail: "正在处理" } })]);
|
||||
});
|
||||
|
||||
it("projects Agent state and Chat-safe Runtime status through the SDK", async () => {
|
||||
const conversationID = "user_1:2:channel_1";
|
||||
const transport = new FakeTransport([[
|
||||
{
|
||||
message_seq: 8,
|
||||
from_uid: "agent_1",
|
||||
payload: agentEnvelope("agent_thinking_8", conversationID, {
|
||||
type: "lineup.v1.agent.status",
|
||||
payload: { status: "thinking" },
|
||||
}),
|
||||
},
|
||||
], []]);
|
||||
const runtime = new LineUpRuntime({ storage: new MemoryStorage(), createTransport: () => transport });
|
||||
const chat = runtime.openChatApp();
|
||||
const views: Array<{ active: boolean; agent_presence: readonly { status: string }[] }> = [];
|
||||
const statuses: string[] = [];
|
||||
chat.subscribe(view => views.push(view));
|
||||
chat.subscribeEvents(event => { if (event.type === "sync-status") statuses.push(event.status); });
|
||||
|
||||
await runtime.login(login);
|
||||
await runtime.syncOnce();
|
||||
|
||||
expect(views[0]).toMatchObject({ active: false, agent_presence: [] });
|
||||
expect(views).toContainEqual(expect.objectContaining({
|
||||
active: true,
|
||||
agent_presence: [expect.objectContaining({ status: "thinking" })],
|
||||
}));
|
||||
expect(statuses).toEqual(["syncing", "idle"]);
|
||||
});
|
||||
|
||||
it("rejects an explicit App or conversation scope mismatch before Chat can observe it", async () => {
|
||||
const conversationID = "user_1:2:channel_1";
|
||||
const transport = new FakeTransport([[
|
||||
{ message_seq: 8, from_uid: "agent_1", payload: agentEnvelope("wrong_app_scope", conversationID, {
|
||||
scope: { app_scope: "not-installed-app", conversation_id: conversationID },
|
||||
}) },
|
||||
{ message_seq: 9, from_uid: "agent_1", payload: agentEnvelope("wrong_conversation_scope", conversationID, {
|
||||
scope: { app_scope: "chat", conversation_id: "other:2:conversation" },
|
||||
}) },
|
||||
], []]);
|
||||
const runtime = new LineUpRuntime({ storage: new MemoryStorage(), createTransport: () => transport });
|
||||
const chat = runtime.openChatApp();
|
||||
const received: unknown[] = [];
|
||||
const rejected: unknown[] = [];
|
||||
chat.subscribeAgentMessages(message => received.push(message));
|
||||
runtime.onEvent(event => { if (event.type === "scope-rejected") rejected.push(event); });
|
||||
|
||||
await runtime.login(login);
|
||||
await runtime.syncOnce();
|
||||
|
||||
expect(received).toEqual([]);
|
||||
expect(chat.listAgentMessages()).toEqual([]);
|
||||
expect(rejected).toEqual([
|
||||
expect.objectContaining({ reason: "scope_mismatch" }),
|
||||
expect.objectContaining({ reason: "scope_mismatch" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores channel messages addressed to another user before scope routing", async () => {
|
||||
const foreign = agentEnvelope("foreign-user-message", "lineup:2:channel_1:user_2", {
|
||||
target: { kind: "human", id: "user_2" },
|
||||
});
|
||||
const own = agentEnvelope("current-user-message", "lineup:2:channel_1:user_1", {
|
||||
target: { kind: "human", id: "user_1" },
|
||||
});
|
||||
const transport = new FakeTransport([[
|
||||
{ message_seq: 40, from_uid: "agent_1", payload: foreign },
|
||||
{ message_seq: 41, from_uid: "agent_1", payload: own },
|
||||
], []]);
|
||||
const runtime = new LineUpRuntime({ storage: new MemoryStorage(), createTransport: () => transport });
|
||||
const chat = runtime.openChatApp();
|
||||
await runtime.login(login);
|
||||
await runtime.syncOnce();
|
||||
|
||||
expect(chat.listAgentMessages()).toHaveLength(1);
|
||||
expect(chat.listAgentMessages()[0]?.message_id).toBe("current-user-message");
|
||||
expect(runtime.snapshot()?.cursor).toBe(41);
|
||||
});
|
||||
|
||||
it("rejects malformed scope and never delivers the same message id twice", async () => {
|
||||
const conversationID = "user_1:2:channel_1";
|
||||
const duplicate = agentEnvelope("dedupe_me", conversationID);
|
||||
const malformed = agentEnvelope("bad_scope", conversationID, {
|
||||
scope: { app_scope: 42, conversation_id: conversationID },
|
||||
});
|
||||
const transport = new FakeTransport([[
|
||||
{ message_seq: 20, from_uid: "agent_1", payload: duplicate },
|
||||
{ message_seq: 21, from_uid: "agent_1", payload: duplicate },
|
||||
{ message_seq: 22, from_uid: "agent_1", payload: malformed },
|
||||
], []]);
|
||||
const runtime = new LineUpRuntime({ storage: new MemoryStorage(), createTransport: () => transport });
|
||||
const chat = runtime.openChatApp();
|
||||
const delivered: string[] = [];
|
||||
const rejected: string[] = [];
|
||||
chat.subscribeAgentMessages(message => delivered.push(message.message_id));
|
||||
runtime.onEvent(event => { if (event.type === "scope-rejected") rejected.push(event.reason); });
|
||||
|
||||
await runtime.login(login);
|
||||
await runtime.syncOnce();
|
||||
|
||||
expect(delivered).toEqual(["dedupe_me"]);
|
||||
expect(chat.listAgentMessages()).toHaveLength(1);
|
||||
expect(rejected).toEqual(["invalid_scope"]);
|
||||
});
|
||||
|
||||
it("persists an unacknowledged App Inbox message and restores it after Runtime recreation", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const conversationID = "user_1:2:channel_1";
|
||||
const firstTransport = new FakeTransport([[{ message_seq: 11, from_uid: "agent_1", payload: agentEnvelope("agent_msg_11", conversationID) }], []]);
|
||||
const first = new LineUpRuntime({ storage, createTransport: () => firstTransport });
|
||||
first.openChatApp();
|
||||
await first.login(login);
|
||||
await first.syncOnce();
|
||||
|
||||
const restored = new LineUpRuntime({ storage, createTransport: () => new FakeTransport() });
|
||||
const chat = restored.openChatApp();
|
||||
await restored.login(login);
|
||||
|
||||
expect(chat.listAgentMessages()).toEqual([expect.objectContaining({
|
||||
message_id: "agent_msg_11",
|
||||
scope: { app_scope: "chat", conversation_id: conversationID },
|
||||
})]);
|
||||
expect(chat.acknowledgeAgentMessage("agent_msg_11")).toBe(true);
|
||||
expect(chat.listAgentMessages()).toEqual([]);
|
||||
});
|
||||
|
||||
it("sends user text through Runtime Action, local echo and Runtime-owned outbox", async () => {
|
||||
const transport = new FakeTransport();
|
||||
const runtime = new LineUpRuntime({ storage: new MemoryStorage(), createTransport: () => transport });
|
||||
const chat = runtime.openChatApp();
|
||||
const local: unknown[] = [];
|
||||
chat.subscribeEvents(event => { if (event.type === "local-item") local.push(event); });
|
||||
await runtime.login(login);
|
||||
|
||||
const receipt = await chat.dispatch({ type: "chat.send_text", conversation_id: "user_1:2:channel_1", text: "hello runtime", local_id: "local_test" });
|
||||
await runtime.flushOutbox();
|
||||
|
||||
expect(receipt).toEqual({ disposition: "accepted", local_id: "local_test" });
|
||||
expect(local).toEqual([expect.objectContaining({ local_id: "local_test", item: expect.objectContaining({ kind: "user-message", text: "hello runtime" }) })]);
|
||||
expect(transport.sent.filter(entry => entry.payload === "hello runtime")).toEqual([expect.objectContaining({ from_uid: "user_1", channel_id: "channel_1" })]);
|
||||
expect(runtime.snapshot()?.outbox).toEqual([]);
|
||||
expect(runtime.snapshot()?.items).toEqual([expect.objectContaining({ local_id: "local_test", item: expect.objectContaining({ delivery: expect.objectContaining({ status: "submitted" }) }) })]);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
import type { JsonObject, JsonValue } from "@/runtime/protocol/lineup-v1";
|
||||
|
||||
/** Small, deterministic JSON Schema subset frozen for MiniApp Tool v1. */
|
||||
export function validateMiniAppToolSchema(value: JsonValue, schema: JsonObject | undefined): boolean {
|
||||
if (!schema) return value !== undefined;
|
||||
if (schema.enum !== undefined && (!Array.isArray(schema.enum) || !schema.enum.some(candidate => JSON.stringify(candidate) === JSON.stringify(value)))) return false;
|
||||
const type = typeof schema.type === "string" ? schema.type : undefined;
|
||||
if (type && !matchesType(value, type)) return false;
|
||||
if (typeof value === "string") {
|
||||
if (typeof schema.minLength === "number" && value.length < schema.minLength) return false;
|
||||
if (typeof schema.maxLength === "number" && value.length > schema.maxLength) return false;
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
if (typeof schema.minimum === "number" && value < schema.minimum) return false;
|
||||
if (typeof schema.maximum === "number" && value > schema.maximum) return false;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (typeof schema.maxItems === "number" && value.length > schema.maxItems) return false;
|
||||
if (schema.items && typeof schema.items === "object" && !Array.isArray(schema.items) && !value.every(item => validateMiniAppToolSchema(item, schema.items as JsonObject))) return false;
|
||||
}
|
||||
if (isObject(value)) {
|
||||
const required = Array.isArray(schema.required) ? schema.required.filter(entry => typeof entry === "string") : [];
|
||||
if (!required.every(key => Object.hasOwn(value, key))) return false;
|
||||
const properties = isObject(schema.properties) ? schema.properties : {};
|
||||
if (schema.additionalProperties === false && Object.keys(value).some(key => !Object.hasOwn(properties, key))) return false;
|
||||
for (const [key, child] of Object.entries(properties)) if (Object.hasOwn(value, key) && isObject(child) && !validateMiniAppToolSchema(value[key], child)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function matchesType(value: JsonValue, type: string): boolean {
|
||||
return type === "object" ? isObject(value)
|
||||
: type === "array" ? Array.isArray(value)
|
||||
: type === "string" ? typeof value === "string"
|
||||
: type === "number" ? typeof value === "number" && Number.isFinite(value)
|
||||
: type === "boolean" ? typeof value === "boolean"
|
||||
: type === "null" ? value === null
|
||||
: false;
|
||||
}
|
||||
|
||||
function isObject(value: JsonValue | undefined): value is JsonObject {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MiniAppToolStateMachine } from "@/runtime/coordination/miniapp-tool-state";
|
||||
|
||||
describe("MiniAppToolStateMachine", () => {
|
||||
it("moves a call through claim and submitted result, while rejecting duplicate terminal writes", () => {
|
||||
const machine = new MiniAppToolStateMachine();
|
||||
machine.receive({ call_id: "call-1", tool_id: "task-dashboard.open", inventory_revision: "catalog-1", app_scope: "task-dashboard", instance_id: "task:1", conversation_id: "c", input: {}, created_at: "2026-08-04T00:00:00.000Z" });
|
||||
expect(machine.route("call-1", "task:1", "2026-08-04T00:00:01.000Z")).toMatchObject({ disposition: "accepted", record: { status: "waiting_for_app" } });
|
||||
expect(machine.start("call-1", "task:1", "2026-08-04T00:00:02.000Z")).toMatchObject({ disposition: "accepted", record: { status: "running" } });
|
||||
expect(machine.submit("call-1", "task:1", { task_id: "t1" }, "2026-08-04T00:00:03.000Z")).toMatchObject({ disposition: "accepted", record: { status: "submitted" } });
|
||||
expect(machine.submit("call-1", "task:1", { task_id: "t2" }, "2026-08-04T00:00:04.000Z")).toMatchObject({ disposition: "invalid_transition", record: { status: "submitted" } });
|
||||
expect(machine.complete("call-1", "2026-08-04T00:00:05.000Z")).toMatchObject({ disposition: "accepted", record: { status: "completed" } });
|
||||
});
|
||||
|
||||
it("cancels only the instance that owns a pending call", () => {
|
||||
const machine = new MiniAppToolStateMachine();
|
||||
machine.receive({ call_id: "call-2", tool_id: "task-dashboard.open", inventory_revision: "catalog-1", app_scope: "task-dashboard", instance_id: "task:1", conversation_id: "c", input: {}, created_at: "2026-08-04T00:00:00.000Z" });
|
||||
machine.route("call-2", "task:1", "2026-08-04T00:00:01.000Z");
|
||||
expect(machine.cancel("call-2", "task:2", "app_closed", "2026-08-04T00:00:02.000Z")).toEqual(expect.objectContaining({ disposition: "invalid_scope" }));
|
||||
expect(machine.cancel("call-2", "task:1", "app_closed", "2026-08-04T00:00:02.000Z")).toMatchObject({ disposition: "accepted", record: { status: "cancelled", cancel_reason: "app_closed" } });
|
||||
});
|
||||
|
||||
it("keeps the latest progress in the durable call record", () => {
|
||||
const machine = new MiniAppToolStateMachine();
|
||||
machine.receive({ call_id: "call-progress", tool_id: "task-dashboard.open", inventory_revision: "catalog-1", app_scope: "task-dashboard", instance_id: "task:1", conversation_id: "c", input: {}, created_at: "2026-08-04T00:00:00.000Z" });
|
||||
machine.route("call-progress", "task:1", "2026-08-04T00:00:01.000Z");
|
||||
expect(machine.progress("call-progress", "task:1", { percent: 42, status: "working", detail: "正在处理" }, "2026-08-04T00:00:02.000Z")).toMatchObject({ disposition: "accepted", record: { progress: { percent: 42, status: "working", detail: "正在处理" } } });
|
||||
const restored = new MiniAppToolStateMachine();
|
||||
restored.restore(machine.list(), "2026-08-04T00:00:03.000Z");
|
||||
expect(restored.get("call-progress")).toMatchObject({ status: "waiting_for_app", progress: { percent: 42, status: "working", detail: "正在处理" } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { JsonObject } from "@/runtime/protocol/lineup-v1";
|
||||
|
||||
export type MiniAppToolStatus =
|
||||
| "received"
|
||||
| "routing"
|
||||
| "waiting_for_app"
|
||||
| "running"
|
||||
| "submitted"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "cancelled"
|
||||
| "expired"
|
||||
| "rejected";
|
||||
|
||||
export type MiniAppToolCancelReason = "app_closed" | "agent_cancelled" | "runtime_cancelled";
|
||||
|
||||
export type MiniAppToolProgress = {
|
||||
percent?: number;
|
||||
status?: string;
|
||||
detail?: string;
|
||||
};
|
||||
|
||||
export type MiniAppToolCallRecord = {
|
||||
call_id: string;
|
||||
tool_id: string;
|
||||
inventory_revision: string;
|
||||
app_scope: string;
|
||||
instance_id?: string;
|
||||
conversation_id: string;
|
||||
status: MiniAppToolStatus;
|
||||
input: JsonObject;
|
||||
progress?: MiniAppToolProgress;
|
||||
result?: JsonObject;
|
||||
error_code?: string;
|
||||
cancel_reason?: MiniAppToolCancelReason;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
submitted_at?: string;
|
||||
};
|
||||
|
||||
export type MiniAppToolTransition =
|
||||
| { disposition: "accepted"; record: MiniAppToolCallRecord }
|
||||
| { disposition: "duplicate" | "missing" | "invalid_transition" | "invalid_scope"; record?: MiniAppToolCallRecord };
|
||||
|
||||
export class MiniAppToolStateMachine {
|
||||
private readonly records = new Map<string, MiniAppToolCallRecord>();
|
||||
|
||||
public receive(input: Omit<MiniAppToolCallRecord, "status" | "created_at" | "updated_at"> & { created_at: string }): MiniAppToolTransition {
|
||||
const existing = this.records.get(input.call_id);
|
||||
if (existing) return { disposition: "duplicate", record: copy(existing) };
|
||||
const record: MiniAppToolCallRecord = {
|
||||
...input,
|
||||
status: "received",
|
||||
created_at: input.created_at,
|
||||
updated_at: input.created_at,
|
||||
input: clone(input.input),
|
||||
};
|
||||
this.records.set(record.call_id, record);
|
||||
return { disposition: "accepted", record: copy(record) };
|
||||
}
|
||||
|
||||
public route(callID: string, instanceID: string, now: string): MiniAppToolTransition {
|
||||
return this.transition(callID, now, ["received", "routing"], record => {
|
||||
record.status = "waiting_for_app";
|
||||
record.instance_id = instanceID;
|
||||
});
|
||||
}
|
||||
|
||||
public start(callID: string, instanceID: string, now: string): MiniAppToolTransition {
|
||||
return this.transition(callID, now, ["waiting_for_app", "running"], record => {
|
||||
if (record.instance_id !== instanceID) throw new Error("instance mismatch");
|
||||
record.status = "running";
|
||||
});
|
||||
}
|
||||
|
||||
public progress(callID: string, instanceID: string, progress: MiniAppToolProgress, now: string): MiniAppToolTransition {
|
||||
return this.transition(callID, now, ["waiting_for_app", "running"], record => {
|
||||
if (record.instance_id !== instanceID) throw new Error("instance mismatch");
|
||||
record.status = "running";
|
||||
record.progress = clone(progress);
|
||||
});
|
||||
}
|
||||
|
||||
public submit(callID: string, instanceID: string, result: JsonObject | undefined, now: string): MiniAppToolTransition {
|
||||
return this.transition(callID, now, ["waiting_for_app", "running"], record => {
|
||||
if (record.instance_id !== instanceID) throw new Error("instance mismatch");
|
||||
record.status = "submitted";
|
||||
record.submitted_at = now;
|
||||
if (result) record.result = clone(result);
|
||||
});
|
||||
}
|
||||
|
||||
public complete(callID: string, now: string): MiniAppToolTransition {
|
||||
return this.transition(callID, now, ["submitted"], record => { record.status = "completed"; });
|
||||
}
|
||||
|
||||
public fail(callID: string, instanceID: string, code: string, now: string): MiniAppToolTransition {
|
||||
return this.transition(callID, now, ["waiting_for_app", "running"], record => {
|
||||
if (record.instance_id !== instanceID) throw new Error("instance mismatch");
|
||||
record.status = "failed";
|
||||
record.error_code = code;
|
||||
});
|
||||
}
|
||||
|
||||
public cancel(callID: string, instanceID: string | undefined, reason: MiniAppToolCancelReason, now: string): MiniAppToolTransition {
|
||||
return this.transition(callID, now, ["received", "routing", "waiting_for_app", "running"], record => {
|
||||
if (instanceID !== undefined && record.instance_id !== instanceID) throw new Error("instance mismatch");
|
||||
record.status = "cancelled";
|
||||
record.cancel_reason = reason;
|
||||
});
|
||||
}
|
||||
|
||||
public get(callID: string): MiniAppToolCallRecord | undefined {
|
||||
const record = this.records.get(callID);
|
||||
return record ? copy(record) : undefined;
|
||||
}
|
||||
|
||||
public list(): readonly MiniAppToolCallRecord[] { return [...this.records.values()].map(copy); }
|
||||
|
||||
public restore(records: readonly MiniAppToolCallRecord[], now: string): void {
|
||||
this.records.clear();
|
||||
for (const raw of records) {
|
||||
if (!validRecord(raw) || this.records.has(raw.call_id)) continue;
|
||||
const record = copy(raw);
|
||||
// A native MiniApp cannot safely resume an in-flight callback. Keep the
|
||||
// durable call waiting for the newly mounted instance to claim it.
|
||||
if (["routing", "running"].includes(record.status)) record.status = "waiting_for_app";
|
||||
record.updated_at = record.status === raw.status ? record.updated_at : now;
|
||||
this.records.set(record.call_id, record);
|
||||
}
|
||||
}
|
||||
|
||||
public replace(records: readonly MiniAppToolCallRecord[]): void {
|
||||
this.records.clear();
|
||||
for (const record of records) if (validRecord(record)) this.records.set(record.call_id, copy(record));
|
||||
}
|
||||
|
||||
private transition(callID: string, now: string, allowed: readonly MiniAppToolStatus[], apply: (record: MiniAppToolCallRecord) => void): MiniAppToolTransition {
|
||||
const record = this.records.get(callID);
|
||||
if (!record) return { disposition: "missing" };
|
||||
if (!allowed.includes(record.status)) return { disposition: "invalid_transition", record: copy(record) };
|
||||
try { apply(record); } catch { return { disposition: "invalid_scope", record: copy(record) }; }
|
||||
record.updated_at = now;
|
||||
return { disposition: "accepted", record: copy(record) };
|
||||
}
|
||||
}
|
||||
|
||||
function validRecord(value: unknown): value is MiniAppToolCallRecord {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const record = value as Partial<MiniAppToolCallRecord>;
|
||||
return typeof record.call_id === "string" && record.call_id.length > 0
|
||||
&& typeof record.tool_id === "string" && record.tool_id.length > 0
|
||||
&& typeof record.inventory_revision === "string" && record.inventory_revision.length > 0
|
||||
&& typeof record.app_scope === "string" && record.app_scope.length > 0
|
||||
&& typeof record.conversation_id === "string" && record.conversation_id.length > 0
|
||||
&& record.input !== undefined && typeof record.input === "object" && !Array.isArray(record.input)
|
||||
&& typeof record.status === "string" && ["received", "routing", "waiting_for_app", "running", "submitted", "completed", "failed", "cancelled", "expired", "rejected"].includes(record.status)
|
||||
&& typeof record.created_at === "string" && !Number.isNaN(Date.parse(record.created_at))
|
||||
&& typeof record.updated_at === "string" && !Number.isNaN(Date.parse(record.updated_at));
|
||||
}
|
||||
|
||||
function clone<T>(value: T): T { return JSON.parse(JSON.stringify(value)) as T; }
|
||||
function copy(record: MiniAppToolCallRecord): MiniAppToolCallRecord { return clone(record); }
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Runtime 内部可路由消息的作用域模型与兼容适配器。
|
||||
*
|
||||
* 所有投递到 App 的消息必须有合法 `app_scope + conversation_id`。旧 `lineup.v1` 未携带
|
||||
* scope 时只在 Runtime 内映射到当前 Chat 会话;显式非法或不匹配 scope 一律拒绝。
|
||||
*/
|
||||
import type { ConversationItem } from "@/runtime/protocol/lineup-v1";
|
||||
import { isAppScope, type AppScope } from "@/runtime/app-management/app-registry";
|
||||
|
||||
export type RuntimeScope = {
|
||||
app_scope: AppScope;
|
||||
conversation_id: string;
|
||||
instance_id?: string;
|
||||
operation_id?: string;
|
||||
};
|
||||
|
||||
export type RuntimeEnvelope = {
|
||||
v: 1;
|
||||
id: string;
|
||||
type: string;
|
||||
timestamp: string;
|
||||
scope: RuntimeScope;
|
||||
payload: unknown;
|
||||
};
|
||||
|
||||
export type ScopedConversationItem = {
|
||||
message_id: string;
|
||||
scope: RuntimeScope;
|
||||
item: ConversationItem;
|
||||
received_at: string;
|
||||
message_seq?: number;
|
||||
};
|
||||
|
||||
export type ScopeResolution =
|
||||
| { disposition: "accepted"; scope: RuntimeScope }
|
||||
| { disposition: "rejected"; reason: "invalid_scope" | "scope_mismatch" };
|
||||
|
||||
/**
|
||||
* Compatibility adapter for the existing LineUp v1 wire protocol.
|
||||
*
|
||||
* Old envelopes have only `conversation_id`; until all producers have been
|
||||
* upgraded, Runtime attaches the receiving Core App's local scope. New
|
||||
* producers may include `scope`, but it is never trusted without exact
|
||||
* validation against the active conversation and target app.
|
||||
*/
|
||||
export function resolveIncomingScope(
|
||||
raw: string,
|
||||
expected: { app_scope: AppScope; conversation_id: string; conversation_ids?: readonly string[]; acceptsAppScope?: (scope: AppScope) => boolean },
|
||||
): ScopeResolution {
|
||||
const matchesConversation = (value: unknown): boolean => typeof value === "string"
|
||||
&& (value === expected.conversation_id || expected.conversation_ids?.includes(value) === true);
|
||||
const legacyScope: RuntimeScope = { app_scope: expected.app_scope, conversation_id: expected.conversation_id };
|
||||
let parsed: unknown;
|
||||
try { parsed = JSON.parse(raw); } catch {
|
||||
// Legacy plaintext is a Chat message in the active conversation.
|
||||
return { disposition: "accepted", scope: legacyScope };
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return { disposition: "accepted", scope: legacyScope };
|
||||
}
|
||||
const candidate = parsed as { scope?: unknown; conversation_id?: unknown };
|
||||
if (candidate.scope === undefined) {
|
||||
// Existing v1 envelope: conversation_id remains authoritative.
|
||||
if (typeof candidate.conversation_id === "string" && !matchesConversation(candidate.conversation_id)) {
|
||||
return { disposition: "rejected", reason: "scope_mismatch" };
|
||||
}
|
||||
return { disposition: "accepted", scope: legacyScope };
|
||||
}
|
||||
if (!candidate.scope || typeof candidate.scope !== "object" || Array.isArray(candidate.scope)) {
|
||||
return { disposition: "rejected", reason: "invalid_scope" };
|
||||
}
|
||||
const scope = candidate.scope as { app_scope?: unknown; conversation_id?: unknown; instance_id?: unknown; operation_id?: unknown };
|
||||
if (!isAppScope(scope.app_scope) || typeof scope.conversation_id !== "string" || !scope.conversation_id) {
|
||||
return { disposition: "rejected", reason: "invalid_scope" };
|
||||
}
|
||||
if (!matchesConversation(scope.conversation_id) || (expected.acceptsAppScope ? !expected.acceptsAppScope(scope.app_scope) : scope.app_scope !== expected.app_scope)) {
|
||||
return { disposition: "rejected", reason: "scope_mismatch" };
|
||||
}
|
||||
if ((scope.instance_id !== undefined && typeof scope.instance_id !== "string") || (scope.operation_id !== undefined && typeof scope.operation_id !== "string")) {
|
||||
return { disposition: "rejected", reason: "invalid_scope" };
|
||||
}
|
||||
return {
|
||||
disposition: "accepted",
|
||||
scope: {
|
||||
app_scope: scope.app_scope,
|
||||
conversation_id: scope.conversation_id,
|
||||
...(typeof scope.instance_id === "string" ? { instance_id: scope.instance_id } : {}),
|
||||
...(typeof scope.operation_id === "string" ? { operation_id: scope.operation_id } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/** Runtime-owned validation for Agent-facing Interact standard interactions. */
|
||||
|
||||
export const DEFAULT_INTERACTION_EXPIRY_MS = 15 * 60 * 1000;
|
||||
export const MIN_INTERACTION_EXPIRY_MS = 60 * 1000;
|
||||
export const MAX_INTERACTION_EXPIRY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
type InteractionBase = Readonly<{ title: string; prompt: string; expires_in_ms?: number }>;
|
||||
|
||||
export type NoticeInteractionRequest = Readonly<{
|
||||
kind: "notice";
|
||||
title: string;
|
||||
message: string;
|
||||
dismiss_label?: string;
|
||||
}>;
|
||||
|
||||
export type ChoiceInteractionRequest = InteractionBase & Readonly<{
|
||||
kind: "choice";
|
||||
mode: "single-choice";
|
||||
actions: readonly Readonly<{ id: string; label: string; description?: string }>[];
|
||||
}>;
|
||||
|
||||
export type ConfirmInteractionRequest = InteractionBase & Readonly<{
|
||||
kind: "confirm";
|
||||
approve_label?: string;
|
||||
cancel_label?: string;
|
||||
}>;
|
||||
|
||||
export type InputInteractionRequest = InteractionBase & Readonly<{
|
||||
kind: "input";
|
||||
field: Readonly<{
|
||||
id: string;
|
||||
label: string;
|
||||
type: "text" | "textarea";
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
max_length?: number;
|
||||
}>;
|
||||
submit_label?: string;
|
||||
cancel_label?: string;
|
||||
}>;
|
||||
|
||||
export type StandardInteractionRequest =
|
||||
| NoticeInteractionRequest
|
||||
| ChoiceInteractionRequest
|
||||
| ConfirmInteractionRequest
|
||||
| InputInteractionRequest;
|
||||
|
||||
export type InteractionRequestValidation =
|
||||
| Readonly<{ accepted: true; expires_at?: string }>
|
||||
| Readonly<{ accepted: false; code: "interaction_request_invalid" }>;
|
||||
|
||||
/** Validates the only answer shape that Runtime accepts for a standard interaction. */
|
||||
export function validateStandardInteractionAnswer(request: StandardInteractionRequest, answer: unknown): answer is Readonly<Record<string, unknown>> {
|
||||
if (!isObject(answer)) return false;
|
||||
const keys = Object.keys(answer);
|
||||
if (request.kind === "choice") {
|
||||
if (keys.length !== 1 || keys[0] !== "action_id" || typeof answer.action_id !== "string") return false;
|
||||
return request.actions.some(action => action.id === answer.action_id);
|
||||
}
|
||||
if (request.kind === "confirm") return keys.length === 1 && keys[0] === "approved" && typeof answer.approved === "boolean";
|
||||
if (request.kind === "input") {
|
||||
if (keys.length !== 1 || keys[0] !== "text" || typeof answer.text !== "string") return false;
|
||||
const text = answer.text;
|
||||
if (request.field.required && text.trim().length === 0) return false;
|
||||
return text.length <= (request.field.max_length ?? 1000);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Validates a request before it becomes a durable StandardInteractionRecord. */
|
||||
export function validateStandardInteractionRequest(request: StandardInteractionRequest, now: Date): InteractionRequestValidation {
|
||||
if (!validTitle(request.title)) return invalid();
|
||||
if (request.kind === "notice") {
|
||||
if (!validText(request.message) || Object.hasOwn(request, "expires_in_ms")) return invalid();
|
||||
return { accepted: true };
|
||||
}
|
||||
if (!validText(request.prompt)) return invalid();
|
||||
if (request.kind === "choice" && !validChoice(request)) return invalid();
|
||||
if (request.kind === "input" && !validInput(request)) return invalid();
|
||||
|
||||
const duration = request.expires_in_ms ?? DEFAULT_INTERACTION_EXPIRY_MS;
|
||||
if (!Number.isInteger(duration) || duration < MIN_INTERACTION_EXPIRY_MS || duration > MAX_INTERACTION_EXPIRY_MS) return invalid();
|
||||
return { accepted: true, expires_at: new Date(now.getTime() + duration).toISOString() };
|
||||
}
|
||||
|
||||
function validChoice(request: ChoiceInteractionRequest): boolean {
|
||||
if (request.mode !== "single-choice" || request.actions.length < 2 || request.actions.length > 6) return false;
|
||||
const ids = new Set<string>();
|
||||
return request.actions.every(action => validIdentifier(action.id) && validText(action.label) && !ids.has(action.id) && (ids.add(action.id), true));
|
||||
}
|
||||
|
||||
function validInput(request: InputInteractionRequest): boolean {
|
||||
const { field } = request;
|
||||
return validIdentifier(field.id)
|
||||
&& validText(field.label)
|
||||
&& (field.type === "text" || field.type === "textarea")
|
||||
&& (field.max_length === undefined || (Number.isInteger(field.max_length) && field.max_length > 0 && field.max_length <= 1000));
|
||||
}
|
||||
|
||||
function validTitle(value: string): boolean { return validText(value) && value.length <= 200; }
|
||||
function validText(value: string): boolean { return typeof value === "string" && value.trim().length > 0 && value.length <= 4000; }
|
||||
function validIdentifier(value: string): boolean { return typeof value === "string" && /^[A-Za-z0-9._:-]{1,128}$/.test(value); }
|
||||
function invalid(): InteractionRequestValidation { return { accepted: false, code: "interaction_request_invalid" }; }
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TaskStateMachine } from "./task-state";
|
||||
import { TaskStateMachine } from "@/runtime/coordination/task-state";
|
||||
|
||||
const TIME = "2026-08-03T09:00:00.000Z";
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import type { TaskProgress, TaskStatus } from "../protocol";
|
||||
/**
|
||||
* 按 `operation_id` 聚合 Agent 任务进度的纯状态机。
|
||||
*
|
||||
* 同一任务的更新只替换最新投影,避免聊天时间线因进度变化无限新增卡片;取消请求也必须
|
||||
* 经过受限状态跃迁。
|
||||
*/
|
||||
import type { TaskProgress, TaskStatus } from "@/runtime/protocol/lineup-v1";
|
||||
|
||||
export type TaskRecord = TaskProgress & {
|
||||
conversation_id: string;
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ToolCallRequest } from "../protocol";
|
||||
import { ToolCallStateMachine } from "./tool-call-state";
|
||||
import type { ToolCallRequest } from "@/runtime/protocol/lineup-v1";
|
||||
import { ToolCallStateMachine } from "@/runtime/coordination/tool-call-state";
|
||||
|
||||
const RECEIVED_AT = "2026-08-03T08:00:00.000Z";
|
||||
|
||||
+19
-1
@@ -1,16 +1,25 @@
|
||||
/**
|
||||
* 标准 Tool Call 的纯状态机。
|
||||
*
|
||||
* `call_id` 是唯一关联键,只允许声明性 choice、confirm、input 在合法生命周期内迁移;
|
||||
* 本模块不渲染、不写 Store、不联网,实际结果发送由 Runtime Action/outbox 完成。
|
||||
*/
|
||||
import type {
|
||||
JsonObject,
|
||||
ToolCallCancel,
|
||||
ToolCallFinalStatus,
|
||||
ToolCallRequest,
|
||||
ToolCallResult,
|
||||
} from "../protocol";
|
||||
} from "@/runtime/protocol/lineup-v1";
|
||||
|
||||
export type ToolCallStatus = "pending" | "submitted" | ToolCallFinalStatus;
|
||||
|
||||
export type ToolCallRecord = {
|
||||
call_id: string;
|
||||
conversation_id: string;
|
||||
/** Runtime binding for ordinary MiniApp Tools; absent for Interact calls. */
|
||||
app_scope?: string;
|
||||
instance_id?: string;
|
||||
request: ToolCallRequest;
|
||||
status: ToolCallStatus;
|
||||
created_at: string;
|
||||
@@ -87,6 +96,15 @@ export class ToolCallStateMachine {
|
||||
return { disposition: "accepted", record: copy(record) };
|
||||
}
|
||||
|
||||
public setExpiry(callID: string, expiresAt: string, updatedAt: string): ToolCallTransition {
|
||||
const record = this.calls.get(callID);
|
||||
if (!record || record.status !== "pending" || !Number.isFinite(Date.parse(expiresAt))) return { disposition: record ? "invalid_transition" : "missing", ...(record ? { record: copy(record) } : {}) };
|
||||
if (record.request.expires_at && record.request.expires_at !== expiresAt) return { disposition: "invalid_transition", record: copy(record) };
|
||||
record.request = { ...record.request, expires_at: expiresAt };
|
||||
record.updated_at = updatedAt;
|
||||
return { disposition: "accepted", record: copy(record) };
|
||||
}
|
||||
|
||||
public expire(now: string): readonly ToolCallRecord[] {
|
||||
const changed: ToolCallRecord[] = [];
|
||||
const nowTime = Date.parse(now);
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CoreAppRegistry } from "@/runtime/app-management/app-registry";
|
||||
import { ToolRouter } from "@/runtime/coordination/tool-router";
|
||||
|
||||
function extension(enabled = true) {
|
||||
return {
|
||||
app_scope: "draw-and-guess", kind: "bundled" as const, enabled, default_eligible: false, recovery: false,
|
||||
manifest: { app_scope: "draw-and-guess", version: "1.0.0", permissions: [], tools: [{ name: "app.launch", handling: "launch" as const, parameters: ["app_scope"] }] },
|
||||
};
|
||||
}
|
||||
function launch(scope = "draw-and-guess", extra: Record<string, unknown> = {}) {
|
||||
return JSON.stringify({ v: 1, id: "launch-1", type: "lineup.v1.app.call", conversation_id: "c1", sender: { kind: "agent", id: "agent" }, payload: { call_id: "call-1", capability: "runtime.launch_app", reason: "Start a game", expires_at: "2026-08-05T00:00:00.000Z", arguments: { app_scope: scope, ...extra } } });
|
||||
}
|
||||
|
||||
describe("ToolRouter", () => {
|
||||
it("routes standard Agent interactions to Runtime without selecting a MiniApp target", () => {
|
||||
const apps = new CoreAppRegistry();
|
||||
const router = new ToolRouter(apps);
|
||||
const raw = JSON.stringify({ v: 1, id: "interaction-1", type: "lineup.v1.tool.call", conversation_id: "c1", sender: { kind: "agent", id: "agent" }, payload: {
|
||||
call_id: "interaction-call-1", tool: "confirm", title: "确认", prompt: "继续吗?", data: { confirm: {} },
|
||||
} });
|
||||
expect(router.route(raw, { app_scope: "chat", conversation_id: "c1" })).toEqual({ disposition: "interactive", call_id: "interaction-call-1" });
|
||||
});
|
||||
|
||||
it("routes a revisioned ordinary MiniApp Tool to its declared bundled target", () => {
|
||||
const apps = new CoreAppRegistry();
|
||||
apps.install({
|
||||
app_scope: "custom-dashboard", kind: "bundled", enabled: true, default_eligible: false, recovery: false,
|
||||
manifest: { app_scope: "custom-dashboard", version: "1.0.0", permissions: [], tools: [{ name: "task-dashboard.open", handling: "operation", parameters: [], input_schema: { type: "object", required: ["title"], properties: { title: { type: "string" } }, additionalProperties: false } }] },
|
||||
});
|
||||
const router = new ToolRouter(apps);
|
||||
const raw = JSON.stringify({ v: 1, id: "miniapp-call-1", type: "lineup.v1.miniapp.tool.call", conversation_id: "c1", sender: { kind: "agent", id: "agent" }, payload: { call_id: "miniapp-call-1", tool_id: "task-dashboard.open", app_scope: "custom-dashboard", inventory_revision: "catalog-1", input: { title: "测试任务" } } });
|
||||
expect(router.route(raw, { app_scope: "chat", conversation_id: "c1", inventory_revision: "catalog-1" })).toEqual(expect.objectContaining({ disposition: "miniapp", handling: "operation", app_scope: "custom-dashboard" }));
|
||||
expect(router.route(raw.replace("catalog-1", "catalog-2"), { app_scope: "chat", conversation_id: "c1", inventory_revision: "catalog-1" })).toEqual({ disposition: "rejected", code: "inventory_revision_mismatch" });
|
||||
expect(router.route(raw.replace('"title":"测试任务"', '"unknown":true'), { app_scope: "chat", conversation_id: "c1", inventory_revision: "catalog-1" })).toEqual({ disposition: "rejected", code: "invalid_request" });
|
||||
});
|
||||
|
||||
it("retains verified App session context as metadata only", () => {
|
||||
const router = new ToolRouter(new CoreAppRegistry());
|
||||
const raw = JSON.stringify({ v: 1, id: "interaction-2", type: "lineup.v1.tool.call", conversation_id: "c1", sender: { kind: "agent", id: "agent" }, payload: {
|
||||
call_id: "interaction-call-2", tool: "input", title: "描述", prompt: "输入", data: { form: { fields: [{ id: "text", label: "描述", type: "textarea" }] } }, app_session_context: { app_session_id: "whiteboard:1" },
|
||||
} });
|
||||
expect(router.route(raw, { app_scope: "chat", conversation_id: "c1" })).toEqual({ disposition: "interactive", call_id: "interaction-call-2", app_session_id: "whiteboard:1" });
|
||||
});
|
||||
|
||||
it("accepts only a manifest-authorized installed extension launch", () => {
|
||||
const apps = new CoreAppRegistry(); apps.install(extension());
|
||||
expect(new ToolRouter(apps).route(launch(), { app_scope: "chat", conversation_id: "c1" })).toMatchObject({ disposition: "launch", app_scope: "draw-and-guess", call_id: "call-1" });
|
||||
});
|
||||
it("rejects unknown and malformed launch requests before an App can see them", () => {
|
||||
const apps = new CoreAppRegistry(); apps.install(extension()); const router = new ToolRouter(apps);
|
||||
expect(router.route(launch("not-installed"), { app_scope: "chat", conversation_id: "c1" })).toEqual({ disposition: "rejected", code: "not_installed" });
|
||||
expect(router.route(launch("draw-and-guess", { bundle_url: "https://evil.invalid" }), { app_scope: "chat", conversation_id: "c1" })).toEqual({ disposition: "rejected", code: "invalid_request" });
|
||||
});
|
||||
|
||||
it("checks every permission declared by a MiniApp Tool", () => {
|
||||
const apps = new CoreAppRegistry();
|
||||
apps.install({
|
||||
app_scope: "permissioned-app", kind: "bundled", enabled: true, default_eligible: false, recovery: false,
|
||||
manifest: {
|
||||
app_scope: "permissioned-app", version: "1.0.0", permissions: ["one", "two"],
|
||||
tools: [{ name: "permissioned.run", handling: "operation", parameters: [], requires_permissions: ["one", "two"], input_schema: { type: "object" } }],
|
||||
},
|
||||
});
|
||||
const router = new ToolRouter(apps);
|
||||
const raw = JSON.stringify({ v: 1, id: "permissioned-call", type: "lineup.v1.miniapp.tool.call", conversation_id: "c1", sender: { kind: "agent", id: "agent" }, payload: { call_id: "permissioned-call", tool_id: "permissioned.run", app_scope: "permissioned-app", inventory_revision: "catalog-1", input: {} } });
|
||||
expect(router.route(raw, { app_scope: "chat", conversation_id: "c1", inventory_revision: "catalog-1" })).toMatchObject({ disposition: "miniapp" });
|
||||
expect(() => apps.install({
|
||||
app_scope: "incomplete-permission-app", kind: "bundled", enabled: true, default_eligible: false, recovery: false,
|
||||
manifest: {
|
||||
app_scope: "incomplete-permission-app", version: "1.0.0", permissions: ["one"],
|
||||
tools: [{ name: "permissioned.run", handling: "operation", parameters: [], requires_permissions: ["one", "two"], input_schema: { type: "object" } }],
|
||||
},
|
||||
})).toThrow("invalid manifest");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Runtime Tool Router. It is the only interpreter for Agent requests that
|
||||
* alter the application workspace. A renderer merely receives the already
|
||||
* routed result and can never turn an arbitrary app.call into a launch.
|
||||
*/
|
||||
import type { AppManifest, AppScope, CoreAppRegistry } from "@/runtime/app-management/app-registry";
|
||||
import { parseEnvelopeJSON, type Envelope, type JsonObject, type MiniAppToolInvoke } from "@/runtime/protocol/lineup-v1";
|
||||
import { validateMiniAppToolSchema } from "@/runtime/coordination/miniapp-tool-schema";
|
||||
|
||||
export type ToolRoute =
|
||||
| { disposition: "pass" }
|
||||
| { disposition: "interactive"; call_id: string; app_session_id?: string }
|
||||
| { disposition: "dismiss"; call_id: string; control_id: string; reason?: string }
|
||||
| { disposition: "miniapp"; call: MiniAppToolInvoke; handling: "direct" | "launch" | "foreground" | "operation"; app_scope: AppScope; instance_id?: string; requires_foreground: boolean; restore_previous_focus: boolean }
|
||||
| { disposition: "launch"; call_id: string; app_scope: AppScope; instance_id?: string; arguments: JsonObject }
|
||||
| { disposition: "rejected"; code: "invalid_request" | "scope_mismatch" | "app_session_context_invalid" | "inventory_revision_mismatch" | "not_installed" | "disabled" | "manifest_denied" | "permission_denied" };
|
||||
|
||||
export class ToolRouter {
|
||||
public constructor(private readonly apps: CoreAppRegistry) {}
|
||||
|
||||
/** Returns pass for ordinary protocol messages, preserving 00.base handling. */
|
||||
public route(raw: string, expected: { conversation_id: string; conversation_ids?: readonly string[]; app_scope: AppScope; inventory_revision?: string }): ToolRoute {
|
||||
const parsed = parseEnvelopeJSON(raw);
|
||||
if (!parsed.ok) return { disposition: "pass" };
|
||||
const envelope = parsed.envelope;
|
||||
if (envelope.type === "lineup.v1.interaction.dismiss") return this.routeDismiss(envelope, expected);
|
||||
if (envelope.type === "lineup.v1.miniapp.tool.call") return this.routeMiniApp(envelope, expected);
|
||||
if (envelope.type === "lineup.v1.tool.call") return this.routeInteractive(envelope, expected);
|
||||
if (envelope.type !== "lineup.v1.app.call" || envelope.payload.capability !== "runtime.launch_app") return { disposition: "pass" };
|
||||
if (!matchesConversation(envelope.conversation_id, expected)) return { disposition: "rejected", code: "scope_mismatch" };
|
||||
return this.routeLaunch(envelope);
|
||||
}
|
||||
|
||||
private routeMiniApp(envelope: Envelope, expected: { conversation_id: string; inventory_revision?: string }): ToolRoute {
|
||||
if (!matchesConversation(envelope.conversation_id, expected)) return { disposition: "rejected", code: "scope_mismatch" };
|
||||
const callID = text(envelope.payload.call_id);
|
||||
const toolID = text(envelope.payload.tool_id);
|
||||
const appScope = text(envelope.payload.app_scope);
|
||||
const revision = text(envelope.payload.inventory_revision);
|
||||
const input = object(envelope.payload.input);
|
||||
const instanceID = envelope.payload.instance_id === undefined ? undefined : text(envelope.payload.instance_id);
|
||||
if (!callID || !toolID || !appScope || !revision || !input || (envelope.payload.instance_id !== undefined && !instanceID)) return { disposition: "rejected", code: "invalid_request" };
|
||||
if (expected.inventory_revision && revision !== expected.inventory_revision) return { disposition: "rejected", code: "inventory_revision_mismatch" };
|
||||
const app = this.apps.get(appScope);
|
||||
if (!app) return { disposition: "rejected", code: "not_installed" };
|
||||
if (!app.enabled) return { disposition: "rejected", code: "disabled" };
|
||||
const definition = app.manifest.tools.find(tool => tool.name === toolID);
|
||||
if (!definition || definition.handling === "interactive") return { disposition: "rejected", code: "manifest_denied" };
|
||||
if (!validateMiniAppToolSchema(input, definition.input_schema)) return { disposition: "rejected", code: "invalid_request" };
|
||||
if (definition.requires_permissions && !definition.requires_permissions.every(permission => app.manifest.permissions.includes(permission))) return { disposition: "rejected", code: "permission_denied" };
|
||||
return { disposition: "miniapp", call: { call_id: callID, tool_id: toolID, app_scope: appScope, inventory_revision: revision, input, ...(instanceID ? { instance_id: instanceID } : {}) }, handling: definition.handling, app_scope: appScope, ...(instanceID ? { instance_id: instanceID } : {}), requires_foreground: definition.target?.requires_foreground ?? true, restore_previous_focus: definition.target?.restore_previous_focus ?? false };
|
||||
}
|
||||
|
||||
private routeDismiss(envelope: Envelope, expected: { conversation_id: string; conversation_ids?: readonly string[] }): ToolRoute {
|
||||
if (!matchesConversation(envelope.conversation_id, expected)) return { disposition: "rejected", code: "scope_mismatch" };
|
||||
const controlID = text(envelope.payload.control_id);
|
||||
const callID = text(envelope.payload.call_id);
|
||||
if (!controlID || !callID || Object.keys(envelope.payload).some(key => key !== "control_id" && key !== "call_id" && key !== "reason")) return { disposition: "rejected", code: "invalid_request" };
|
||||
const reason = envelope.payload.reason === undefined ? undefined : text(envelope.payload.reason);
|
||||
if (envelope.payload.reason !== undefined && !reason) return { disposition: "rejected", code: "invalid_request" };
|
||||
return { disposition: "dismiss", call_id: callID, control_id: controlID, ...(reason ? { reason } : {}) };
|
||||
}
|
||||
|
||||
private routeLaunch(envelope: Envelope): ToolRoute {
|
||||
const callID = text(envelope.payload.call_id);
|
||||
const args = object(envelope.payload.arguments);
|
||||
const requestedScope = args && text(args.app_scope);
|
||||
if (!callID || !args || !requestedScope || Object.keys(args).some(key => key !== "app_scope" && key !== "instance_id" && key !== "parameters")) return { disposition: "rejected", code: "invalid_request" };
|
||||
const record = this.apps.get(requestedScope);
|
||||
if (!record) return { disposition: "rejected", code: "not_installed" };
|
||||
if (!record.enabled) return { disposition: "rejected", code: "disabled" };
|
||||
const definition = record.manifest.tools.find(tool => tool.handling === "launch");
|
||||
if (!definition) return { disposition: "rejected", code: "manifest_denied" };
|
||||
if (definition.requires_permissions && !definition.requires_permissions.every(permission => record.manifest.permissions.includes(permission))) return { disposition: "rejected", code: "permission_denied" };
|
||||
const instanceID = args.instance_id === undefined ? undefined : text(args.instance_id);
|
||||
if (args.instance_id !== undefined && (!instanceID || !/^[A-Za-z0-9._:-]{1,128}$/.test(instanceID))) return { disposition: "rejected", code: "invalid_request" };
|
||||
const parameters = args.parameters === undefined ? {} : object(args.parameters);
|
||||
if (!parameters) return { disposition: "rejected", code: "invalid_request" };
|
||||
return { disposition: "launch", call_id: callID, app_scope: record.app_scope, ...(instanceID ? { instance_id: instanceID } : {}), arguments: parameters };
|
||||
}
|
||||
|
||||
private routeInteractive(envelope: Envelope, expected: { conversation_id: string; conversation_ids?: readonly string[] }): ToolRoute {
|
||||
if (!matchesConversation(envelope.conversation_id, expected)) return { disposition: "rejected", code: "scope_mismatch" };
|
||||
const tool = text(envelope.payload.tool);
|
||||
if (tool !== "notice" && tool !== "choice" && tool !== "confirm" && tool !== "input") return { disposition: "pass" };
|
||||
const callID = text(envelope.payload.call_id);
|
||||
if (!callID) return { disposition: "rejected", code: "invalid_request" };
|
||||
const context = envelope.payload.app_session_context;
|
||||
if (context !== undefined && (!object(context) || !text(object(context)?.app_session_id))) return { disposition: "rejected", code: "invalid_request" };
|
||||
const appSessionID = object(context) ? text(object(context)?.app_session_id) : undefined;
|
||||
return { disposition: "interactive", call_id: callID, ...(appSessionID ? { app_session_id: appSessionID } : {}) };
|
||||
}
|
||||
}
|
||||
|
||||
function object(value: unknown): JsonObject | undefined { return value && typeof value === "object" && !Array.isArray(value) ? value as JsonObject : undefined; }
|
||||
function text(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value : undefined; }
|
||||
function matchesConversation(value: unknown, expected: { conversation_id: string; conversation_ids?: readonly string[] }): boolean {
|
||||
return typeof value === "string" && (value === expected.conversation_id || expected.conversation_ids?.includes(value) === true);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CapabilityRegistry } from "@/runtime/capabilities/capability-registry";
|
||||
import { ClientInventoryPublisher } from "@/runtime/inventory/client-inventory";
|
||||
import { SurfaceRegistry } from "@/runtime/surfaces/surface-registry";
|
||||
|
||||
describe("ClientInventoryPublisher", () => {
|
||||
it("publishes only enabled apps and policy-visible capabilities", () => {
|
||||
const surfaces = new SurfaceRegistry();
|
||||
const capabilities = new CapabilityRegistry({ visible: ["app.open_url"] });
|
||||
const publisher = new ClientInventoryPublisher();
|
||||
const first = publisher.sync(surfaces.snapshot(), capabilities.inventory());
|
||||
expect(first).toMatchObject({ changed: true, inventory: { revision: "catalog-1", applications: [], capabilities: [expect.objectContaining({ name: "app.open_url" })] } });
|
||||
expect(JSON.stringify(first.inventory)).not.toContain("bundle_id");
|
||||
expect(JSON.stringify(first.inventory)).not.toContain("enabled_at");
|
||||
|
||||
surfaces.enable("lineup.task-dashboard", "0.1.0", "2026-08-03T00:00:00.000Z");
|
||||
const second = publisher.sync(surfaces.snapshot(), capabilities.inventory());
|
||||
expect(second).toMatchObject({ changed: true, inventory: { revision: "catalog-2", applications: [{ id: "lineup.task-dashboard", surfaces: [{ events: ["cancel"] }] }] } });
|
||||
});
|
||||
|
||||
it("does not reuse a revision and does not republish unchanged state", () => {
|
||||
const surfaces = new SurfaceRegistry();
|
||||
const capabilities = new CapabilityRegistry();
|
||||
const publisher = new ClientInventoryPublisher();
|
||||
const first = publisher.sync(surfaces.snapshot(), capabilities.inventory());
|
||||
const repeat = publisher.sync(surfaces.snapshot(), capabilities.inventory());
|
||||
expect(repeat).toEqual({ changed: false, inventory: first.inventory });
|
||||
capabilities.setVisible("clipboard.write", false);
|
||||
expect(publisher.sync(surfaces.snapshot(), capabilities.inventory())).toMatchObject({ changed: true, inventory: { revision: "catalog-2" } });
|
||||
});
|
||||
|
||||
it("does not advertise artifact.save without a host verified-cache implementation", () => {
|
||||
const inventory = new ClientInventoryPublisher().sync(new SurfaceRegistry().snapshot(), new CapabilityRegistry().inventory()).inventory;
|
||||
expect(inventory.capabilities.map(capability => capability.name)).not.toContain("artifact.save");
|
||||
});
|
||||
|
||||
it("advertises only enabled Runtime manifests and their declarative tools", () => {
|
||||
const inventory = new ClientInventoryPublisher().sync(new SurfaceRegistry().snapshot(), new CapabilityRegistry().inventory(), [{
|
||||
app_scope: "draw-and-guess", kind: "bundled", enabled: true, default_eligible: false, recovery: false,
|
||||
manifest: { app_scope: "draw-and-guess", version: "1.0.0", permissions: [], tools: [{ name: "app.launch", handling: "launch", parameters: ["app_scope"] }] },
|
||||
}]).inventory;
|
||||
expect(inventory.applications).toContainEqual(expect.objectContaining({ id: "draw-and-guess", tools: [{ name: "app.launch", handling: "launch" }] }));
|
||||
expect(JSON.stringify(inventory)).not.toContain("permissions");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* 面向 Agent 的客户端能力清单生成器。
|
||||
*
|
||||
* 根据当前公开的 Surface 和 Capability 计算带 revision 的 Inventory;只有可见能力变化时
|
||||
* 才更新,避免重试或内部状态抖动错误扩大 Agent 的可调用范围。
|
||||
*/
|
||||
import type { CapabilityDefinition } from "@/runtime/capabilities/capability-registry";
|
||||
import type { SurfaceRegistrySnapshot } from "@/runtime/surfaces/surface-registry";
|
||||
import type { CoreAppRecordInput } from "@/runtime/app-management/app-registry";
|
||||
|
||||
export type ClientInventory = Readonly<{
|
||||
revision: string;
|
||||
standard_components: readonly Readonly<{ id: "notice" | "choice" | "confirm" | "input"; version: "1" }>[];
|
||||
applications: readonly Readonly<{
|
||||
id: string;
|
||||
version: string;
|
||||
surfaces: readonly Readonly<{ id: string; events: readonly string[] }>[];
|
||||
tools?: readonly Readonly<{ name: string; handling: string }>[];
|
||||
}>[];
|
||||
capabilities: readonly CapabilityDefinition[];
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Builds the only inventory the Agent may receive. It is an observation of
|
||||
* host-local enablement and policy, not an install operation nor a grant of
|
||||
* authority. The model deliberately has no access to bundle IDs, code, local
|
||||
* paths, enabled timestamps, user content, cookies, or device identifiers.
|
||||
*/
|
||||
export class ClientInventoryPublisher {
|
||||
private revision = 0;
|
||||
private previousFingerprint = "";
|
||||
private current: ClientInventory | undefined;
|
||||
|
||||
public sync(surfaces: SurfaceRegistrySnapshot, capabilities: readonly CapabilityDefinition[], apps: readonly CoreAppRecordInput[] = []): { changed: boolean; inventory: ClientInventory } {
|
||||
const surfaceApplications = surfaces.enabled
|
||||
.map(enabled => ({ id: enabled.app_id, version: enabled.version, surfaces: [{ id: "task-dashboard" as const, events: ["cancel"] as ["cancel"] }] }))
|
||||
const manifestApplications = apps.filter(app => app.enabled).map(app => ({
|
||||
id: app.app_scope, version: app.manifest.version, surfaces: [],
|
||||
tools: app.manifest.tools.map(tool => ({ name: tool.name, handling: tool.handling })),
|
||||
}));
|
||||
const applications = [...surfaceApplications, ...manifestApplications]
|
||||
.sort((left, right) => `${left.id}@${left.version}`.localeCompare(`${right.id}@${right.version}`));
|
||||
const allowedCapabilities = [...capabilities]
|
||||
.sort((left, right) => left.name.localeCompare(right.name))
|
||||
.map(definition => ({ ...definition, input_schema: { ...definition.input_schema, required: [...definition.input_schema.required], properties: { ...definition.input_schema.properties } } }));
|
||||
const fingerprint = JSON.stringify({ applications, capabilities: allowedCapabilities });
|
||||
if (this.current && fingerprint === this.previousFingerprint) return { changed: false, inventory: this.current };
|
||||
this.revision += 1;
|
||||
this.previousFingerprint = fingerprint;
|
||||
this.current = {
|
||||
revision: `catalog-${this.revision}`,
|
||||
standard_components: [
|
||||
{ id: "notice", version: "1" },
|
||||
{ id: "choice", version: "1" },
|
||||
{ id: "confirm", version: "1" },
|
||||
{ id: "input", version: "1" },
|
||||
],
|
||||
applications,
|
||||
capabilities: allowedCapabilities,
|
||||
};
|
||||
return { changed: true, inventory: this.current };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ToolCallRecord } from "@/runtime/coordination/tool-call-state";
|
||||
import type { MiniAppToolCallRecord } from "@/runtime/coordination/miniapp-tool-state";
|
||||
import type { TaskRecord } from "@/runtime/coordination/task-state";
|
||||
import { ConversationStore } from "@/runtime/persistence/conversation-store";
|
||||
|
||||
class MemoryStorage implements Storage {
|
||||
private readonly values = new Map<string, string>();
|
||||
|
||||
public get length(): number { return this.values.size; }
|
||||
public clear(): void { this.values.clear(); }
|
||||
public getItem(key: string): string | null { return this.values.get(key) ?? null; }
|
||||
public key(index: number): string | null { return [...this.values.keys()][index] ?? null; }
|
||||
public removeItem(key: string): void { this.values.delete(key); }
|
||||
public setItem(key: string, value: string): void { this.values.set(key, value); }
|
||||
}
|
||||
|
||||
const conversationID = "user_test:2:agent_channel";
|
||||
const createdAt = "2026-08-03T00:00:00.000Z";
|
||||
|
||||
describe("ConversationStore", () => {
|
||||
it("keeps the inclusive sync cursor at zero after a send acknowledgement", () => {
|
||||
const store = new ConversationStore(new MemoryStorage());
|
||||
store.open(conversationID);
|
||||
store.appendLocalText("local_001", "hello", createdAt);
|
||||
store.markSubmitted("local_001", 41, createdAt);
|
||||
|
||||
expect(store.snapshot()).toMatchObject({ cursor: 0, outbox: [] });
|
||||
expect(store.snapshot().items).toEqual([expect.objectContaining({
|
||||
local_id: "local_001",
|
||||
message_seq: 41,
|
||||
item: expect.objectContaining({ kind: "user-message", delivery: expect.objectContaining({ status: "submitted" }) }),
|
||||
})]);
|
||||
});
|
||||
|
||||
it("deduplicates the inclusive sync echo of a locally submitted user message", () => {
|
||||
const store = new ConversationStore(new MemoryStorage());
|
||||
store.open(conversationID);
|
||||
store.appendLocalText("local_001", "hello", createdAt);
|
||||
store.markSubmitted("local_001", 41, createdAt);
|
||||
|
||||
const restored = store.recordSyncedUserText(41, "hello", createdAt);
|
||||
|
||||
expect(restored).toBeUndefined();
|
||||
expect(store.snapshot()).toMatchObject({ cursor: 41 });
|
||||
expect(store.snapshot().items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("persists a local echo and restores it for the same conversation", () => {
|
||||
const storage = new MemoryStorage();
|
||||
const first = new ConversationStore(storage);
|
||||
first.open(conversationID);
|
||||
first.appendLocalText("local_002", "persist me", createdAt);
|
||||
|
||||
const reopened = new ConversationStore(storage);
|
||||
const snapshot = reopened.open(conversationID);
|
||||
|
||||
expect(snapshot.cursor).toBe(0);
|
||||
expect(snapshot.outbox).toEqual([expect.objectContaining({ local_id: "local_002", kind: "text", payload: "persist me" })]);
|
||||
expect(snapshot.items).toEqual([expect.objectContaining({
|
||||
local_id: "local_002",
|
||||
item: expect.objectContaining({ kind: "user-message", text: "persist me" }),
|
||||
})]);
|
||||
});
|
||||
|
||||
it("persists a scoped App Inbox independently of the rendered conversation transcript", () => {
|
||||
const storage = new MemoryStorage();
|
||||
const first = new ConversationStore(storage);
|
||||
first.open(conversationID);
|
||||
first.enqueueAppInbox({
|
||||
message_id: "agent_inbox_001",
|
||||
app_scope: "chat",
|
||||
conversation_id: conversationID,
|
||||
item: { kind: "markdown", id: "agent_inbox_001", markdown: "recover me" },
|
||||
received_at: createdAt,
|
||||
message_seq: 77,
|
||||
});
|
||||
|
||||
const reopened = new ConversationStore(storage);
|
||||
reopened.open(conversationID);
|
||||
expect(reopened.listAppInbox("chat")).toEqual([expect.objectContaining({
|
||||
message_id: "agent_inbox_001", app_scope: "chat", message_seq: 77,
|
||||
})]);
|
||||
expect(reopened.acknowledgeAppInbox("chat", "agent_inbox_001")).toBe(true);
|
||||
expect(reopened.listAppInbox("chat")).toEqual([]);
|
||||
});
|
||||
|
||||
it("upgrades v10 durable conversations without dropping existing state while adding App Inbox", () => {
|
||||
const storage = new MemoryStorage();
|
||||
const key = "lineup.conversation.v1:user_test:2:agent_channel";
|
||||
storage.setItem(key, JSON.stringify({
|
||||
version: 10,
|
||||
cursor: 9,
|
||||
items: [],
|
||||
outbox: [],
|
||||
tool_calls: [{ call_id: "preserved", conversation_id: conversationID, status: "completed" }],
|
||||
tool_drafts: { preserved: { value: "yes" } },
|
||||
tasks: [{ operation_id: "task", conversation_id: conversationID, title: "Task", percent: 50, status: "running", cancellable: false, updated_at: createdAt }],
|
||||
execution_summaries: [],
|
||||
surfaces: { version: 1, instances: [] },
|
||||
capability_calls: [],
|
||||
}));
|
||||
|
||||
const snapshot = new ConversationStore(storage).open(conversationID);
|
||||
expect(snapshot.cursor).toBe(9);
|
||||
expect(snapshot.tool_calls).toEqual([expect.objectContaining({ call_id: "preserved" })]);
|
||||
expect(snapshot.tool_drafts).toEqual({ preserved: { value: "yes" } });
|
||||
expect(snapshot.tasks).toEqual([expect.objectContaining({ operation_id: "task" })]);
|
||||
expect(snapshot.app_inbox).toEqual([]);
|
||||
expect(storage.getItem(key)).toContain('"version":14');
|
||||
});
|
||||
|
||||
it("removes legacy Artifact content bytes from memory and durable storage on open", () => {
|
||||
const storage = new MemoryStorage();
|
||||
const key = "lineup.conversation.v1:user_test:2:agent_channel";
|
||||
storage.setItem(key, JSON.stringify({
|
||||
version: 10, cursor: 6, outbox: [], tool_calls: [], tool_drafts: {}, tasks: [], execution_summaries: [], surfaces: { version: 1, instances: [] }, capability_calls: [],
|
||||
items: [{
|
||||
local_id: "remote_006", created_at: createdAt, message_seq: 6,
|
||||
item: {
|
||||
kind: "artifact-offer", id: "artifact_006",
|
||||
envelope: {
|
||||
v: 1, id: "artifact_006", type: "lineup.v1.artifact.offer", conversation_id: conversationID,
|
||||
sender: { kind: "agent", id: "agent" },
|
||||
payload: { artifact_id: "report", name: "report.txt", mime_type: "text/plain", size_bytes: 5, integrity: "verified", created_at: createdAt, local_ref: "opaque-ref", content_base64: "aGVsbG8=" },
|
||||
},
|
||||
},
|
||||
}],
|
||||
}));
|
||||
|
||||
const snapshot = new ConversationStore(storage).open(conversationID);
|
||||
const artifact = snapshot.items[0].item;
|
||||
expect(artifact.kind).toBe("artifact-offer");
|
||||
expect(JSON.stringify(artifact)).not.toContain("content_base64");
|
||||
expect(storage.getItem(key)).not.toContain("content_base64");
|
||||
expect(storage.getItem(key)).not.toContain("aGVsbG8=");
|
||||
});
|
||||
|
||||
it("accepts one remote message per IM sequence and advances the cursor only from sync data", () => {
|
||||
const store = new ConversationStore(new MemoryStorage());
|
||||
store.open(conversationID);
|
||||
const item = { kind: "markdown" as const, id: "evt_remote_001", markdown: "remote" };
|
||||
|
||||
expect(store.recordIncoming(item, 42, createdAt)).toBe(true);
|
||||
expect(store.recordIncoming(item, 42, createdAt)).toBe(false);
|
||||
expect(store.snapshot()).toMatchObject({ cursor: 42 });
|
||||
expect(store.snapshot().items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("persists Tool Call state and an unsubmitted form draft per conversation", () => {
|
||||
const storage = new MemoryStorage();
|
||||
const state: ToolCallRecord = {
|
||||
call_id: "call_form_001",
|
||||
conversation_id: conversationID,
|
||||
request: {
|
||||
call_id: "call_form_001",
|
||||
tool: "input",
|
||||
title: "Deploy",
|
||||
prompt: "Provide a version.",
|
||||
data: { form: {} },
|
||||
},
|
||||
status: "pending",
|
||||
created_at: createdAt,
|
||||
updated_at: createdAt,
|
||||
};
|
||||
const first = new ConversationStore(storage);
|
||||
first.open(conversationID);
|
||||
first.replaceToolCalls([state]);
|
||||
first.saveToolDraft("call_form_001", { version: "1.2.3" });
|
||||
|
||||
const reopened = new ConversationStore(storage).open(conversationID);
|
||||
expect(reopened.tool_calls).toEqual([expect.objectContaining({ call_id: "call_form_001", status: "pending" })]);
|
||||
expect(reopened.tool_drafts).toEqual({ call_form_001: { version: "1.2.3" } });
|
||||
});
|
||||
|
||||
it("persists the latest aggregated task record per conversation", () => {
|
||||
const storage = new MemoryStorage();
|
||||
const task: TaskRecord = {
|
||||
operation_id: "task_store_001", conversation_id: conversationID, title: "Render preview", percent: 70, status: "running", cancellable: true, updated_at: createdAt,
|
||||
};
|
||||
const first = new ConversationStore(storage);
|
||||
first.open(conversationID);
|
||||
first.replaceTasks([task]);
|
||||
|
||||
expect(new ConversationStore(storage).open(conversationID).tasks).toEqual([expect.objectContaining({ operation_id: "task_store_001", percent: 70 })]);
|
||||
});
|
||||
|
||||
it("durably queues a tool result without creating a user-message history item", () => {
|
||||
const storage = new MemoryStorage();
|
||||
const first = new ConversationStore(storage);
|
||||
first.open(conversationID);
|
||||
first.enqueueToolAction("tool_001", "tool-result", '{"type":"lineup.v1.tool.result"}', createdAt);
|
||||
|
||||
const reopened = new ConversationStore(storage).open(conversationID);
|
||||
expect(reopened.items).toEqual([]);
|
||||
expect(reopened.outbox).toEqual([{
|
||||
local_id: "tool_001", kind: "tool-result", payload: '{"type":"lineup.v1.tool.result"}', created_at: createdAt,
|
||||
}]);
|
||||
|
||||
const current = new ConversationStore(storage);
|
||||
current.open(conversationID);
|
||||
current.acknowledgeOutbox("tool_001");
|
||||
expect(current.snapshot().outbox).toEqual([]);
|
||||
});
|
||||
|
||||
it("persists capability call state and a durable app result without a chat bubble", () => {
|
||||
const storage = new MemoryStorage();
|
||||
const first = new ConversationStore(storage);
|
||||
first.open(conversationID);
|
||||
first.replaceCapabilityCalls([{
|
||||
call_id: "cap_001", conversation_id: conversationID,
|
||||
request: { call_id: "cap_001", capability: "app.open_url", reason: "打开 https://private.example/doc", expires_at: "2026-08-03T01:00:00.000Z", arguments: { url: "https://example.com" } },
|
||||
status: "rejected", created_at: createdAt, updated_at: createdAt,
|
||||
}]);
|
||||
first.enqueueToolAction("app_001", "app-result", '{"type":"lineup.v1.app.result"}', createdAt);
|
||||
const restored = new ConversationStore(storage).open(conversationID);
|
||||
expect(restored.capability_calls).toEqual([expect.objectContaining({ call_id: "cap_001", status: "rejected", request: expect.objectContaining({ reason: "打开 链接" }) })]);
|
||||
expect(restored.outbox).toEqual([expect.objectContaining({ local_id: "app_001", kind: "app-result" })]);
|
||||
expect(restored.items).toEqual([]);
|
||||
});
|
||||
|
||||
it("persists only the already-sanitized execution progress summary projection", () => {
|
||||
const storage = new MemoryStorage();
|
||||
const first = new ConversationStore(storage);
|
||||
first.open(conversationID);
|
||||
first.replaceExecutionSummaries([{
|
||||
id: "execution_001",
|
||||
status: "completed",
|
||||
started_at: createdAt,
|
||||
updated_at: "2026-08-03T00:00:08.000Z",
|
||||
finished_at: "2026-08-03T00:00:08.000Z",
|
||||
steps: [{ id: "step_01", title: "检查 qmtbridge health", status: "completed" }],
|
||||
}]);
|
||||
|
||||
const snapshot = new ConversationStore(storage).open(conversationID);
|
||||
expect(snapshot.execution_summaries).toEqual([expect.objectContaining({
|
||||
id: "execution_001", steps: [{ id: "step_01", title: "检查 qmtbridge health", status: "completed" }],
|
||||
})]);
|
||||
expect(JSON.stringify(snapshot.execution_summaries)).not.toContain("detail");
|
||||
});
|
||||
|
||||
it("persists ordinary MiniApp Tool state separately from Interact Tool calls", () => {
|
||||
const storage = new MemoryStorage();
|
||||
const conversationID = "user_test:2:agent_channel";
|
||||
const record: MiniAppToolCallRecord = {
|
||||
call_id: "miniapp_001", tool_id: "task-dashboard.open", inventory_revision: "catalog-1", app_scope: "task-dashboard", instance_id: "task:instance", conversation_id: conversationID,
|
||||
status: "submitted", input: { title: "任务" }, result: { task_id: "task-1" }, created_at: "2026-08-04T00:00:00.000Z", updated_at: "2026-08-04T00:00:01.000Z", submitted_at: "2026-08-04T00:00:01.000Z",
|
||||
};
|
||||
const first = new ConversationStore(storage);
|
||||
first.open(conversationID);
|
||||
first.replaceMiniAppToolCalls([record]);
|
||||
expect(new ConversationStore(storage).open(conversationID).miniapp_tool_calls).toEqual([expect.objectContaining({ call_id: "miniapp_001", status: "submitted", app_scope: "task-dashboard" })]);
|
||||
});
|
||||
|
||||
it("drops untrusted extra fields from persisted execution summaries", () => {
|
||||
const storage = new MemoryStorage();
|
||||
const store = new ConversationStore(storage);
|
||||
store.open(conversationID);
|
||||
store.replaceExecutionSummaries([{
|
||||
id: "execution_safe",
|
||||
status: "running",
|
||||
started_at: createdAt,
|
||||
updated_at: createdAt,
|
||||
steps: [],
|
||||
detail: "do not persist this",
|
||||
} as unknown as import("@/runtime/coordination/execution-progress-state").ExecutionProgressSummary]);
|
||||
|
||||
expect(JSON.stringify(store.snapshot().execution_summaries)).not.toContain("do not persist this");
|
||||
});
|
||||
|
||||
it("upgrades v5 plaintext outbox entries to typed text entries", () => {
|
||||
const storage = new MemoryStorage();
|
||||
storage.setItem("lineup.conversation.v1:user_test:2:agent_channel", JSON.stringify({
|
||||
version: 5, cursor: 8, items: [], outbox: [{ local_id: "legacy_001", payload: "hello", created_at: createdAt }], tool_calls: [], tool_drafts: {}, tasks: [],
|
||||
}));
|
||||
expect(new ConversationStore(storage).open(conversationID).outbox).toEqual([{
|
||||
local_id: "legacy_001", kind: "text", payload: "hello", created_at: createdAt,
|
||||
}]);
|
||||
});
|
||||
|
||||
it("preserves v6 tool state while adding the execution summary field", () => {
|
||||
const storage = new MemoryStorage();
|
||||
const state = {
|
||||
call_id: "call_migrate_006", tool: "confirm", status: "completed", conversation_id: conversationID,
|
||||
created_at: createdAt, updated_at: createdAt,
|
||||
} as unknown as ToolCallRecord;
|
||||
storage.setItem("lineup.conversation.v1:user_test:2:agent_channel", JSON.stringify({
|
||||
version: 6, cursor: 8, items: [], outbox: [], tool_calls: [state], tool_drafts: { call_migrate_006: { value: "not-lost" } }, tasks: [],
|
||||
}));
|
||||
|
||||
const snapshot = new ConversationStore(storage).open(conversationID);
|
||||
expect(snapshot.tool_calls).toEqual([expect.objectContaining({ call_id: "call_migrate_006", status: "completed" })]);
|
||||
expect(snapshot.tool_drafts).toEqual({ call_migrate_006: { value: "not-lost" } });
|
||||
expect(snapshot.execution_summaries).toEqual([]);
|
||||
});
|
||||
|
||||
it("upgrades v7 while retaining tool state but dropping fixed lifecycle placeholders", () => {
|
||||
const storage = new MemoryStorage();
|
||||
const state = { call_id: "call_migrate_007", tool: "confirm", status: "completed", conversation_id: conversationID, created_at: createdAt, updated_at: createdAt } as unknown as ToolCallRecord;
|
||||
storage.setItem("lineup.conversation.v1:user_test:2:agent_channel", JSON.stringify({
|
||||
version: 7, cursor: 9, items: [], outbox: [], tool_calls: [state], tool_drafts: { call_migrate_007: { ignored: "draft" } }, tasks: [],
|
||||
execution_summaries: [{ id: "execution_legacy", status: "completed", started_at: createdAt, updated_at: createdAt, finished_at: createdAt, stages: ["received", "completed"] }],
|
||||
}));
|
||||
const snapshot = new ConversationStore(storage).open(conversationID);
|
||||
expect(snapshot.tool_calls).toEqual([expect.objectContaining({ call_id: "call_migrate_007" })]);
|
||||
expect(snapshot.tool_drafts).toEqual({ call_migrate_007: { ignored: "draft" } });
|
||||
expect(snapshot.execution_summaries).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,590 @@
|
||||
/**
|
||||
* 当前会话的 Runtime 持久化存储。
|
||||
*
|
||||
* 保存已验证消息、同步 cursor、typed outbox、Tool/Task 投影及每个 App 的可靠 Inbox;不含
|
||||
* DOM、网络或协议解码。App 刷新、崩溃或暂停后由 Runtime 从这里恢复未 ACK 的投递。
|
||||
*/
|
||||
import type { ConversationItem, DeliveryState, JsonObject } from "@/runtime/protocol/lineup-v1";
|
||||
import type { ToolCallRecord } from "@/runtime/coordination/tool-call-state";
|
||||
import type { TaskRecord } from "@/runtime/coordination/task-state";
|
||||
import type { ExecutionProgressSummary } from "@/runtime/coordination/execution-progress-state";
|
||||
import type { SurfaceInstanceSnapshot } from "@/runtime/surfaces/surface-instance-manager";
|
||||
import { sanitizeCapabilityReason, type CapabilityCallRecord } from "@/runtime/capabilities/capability-call-state";
|
||||
import type { AppScope } from "@/runtime/app-management/app-registry";
|
||||
import type { AppWorkspaceSnapshot } from "@/runtime/app-management/app-lifecycle-manager";
|
||||
import type { StandardInteractionRecord } from "@/runtime/coordination/agent-interaction-service";
|
||||
import type { MiniAppToolCallRecord } from "@/runtime/coordination/miniapp-tool-state";
|
||||
|
||||
export type StoredConversationItem = {
|
||||
local_id: string;
|
||||
item: ConversationItem;
|
||||
created_at: string;
|
||||
message_seq?: number;
|
||||
};
|
||||
|
||||
export type OutboxEntry = {
|
||||
local_id: string;
|
||||
kind: "text" | "tool-result" | "tool-cancel" | "surface-event" | "app-result";
|
||||
payload: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A durable, per-App delivery record. Runtime owns this inbox so an App can
|
||||
* recover messages that arrived while its UI was suspended or reloading.
|
||||
*/
|
||||
export type AppInboxEntry = {
|
||||
message_id: string;
|
||||
app_scope: AppScope;
|
||||
conversation_id: string;
|
||||
item: ConversationItem;
|
||||
received_at: string;
|
||||
message_seq?: number;
|
||||
instance_id?: string;
|
||||
};
|
||||
|
||||
export type ConversationSnapshot = {
|
||||
conversation_id: string;
|
||||
cursor: number;
|
||||
items: readonly StoredConversationItem[];
|
||||
outbox: readonly OutboxEntry[];
|
||||
tool_calls: readonly ToolCallRecord[];
|
||||
tool_drafts: Readonly<Record<string, JsonObject>>;
|
||||
tasks: readonly TaskRecord[];
|
||||
/** Allowlisted public operation summaries; never reasoning or raw tool data. */
|
||||
execution_summaries: readonly ExecutionProgressSummary[];
|
||||
surfaces: SurfaceInstanceSnapshot;
|
||||
capability_calls: readonly CapabilityCallRecord[];
|
||||
app_inbox: readonly AppInboxEntry[];
|
||||
standard_interactions: readonly StandardInteractionRecord[];
|
||||
miniapp_tool_calls: readonly MiniAppToolCallRecord[];
|
||||
/** Runtime-owned instance/focus state. Never supplied by an App. */
|
||||
app_workspace: AppWorkspaceSnapshot;
|
||||
};
|
||||
|
||||
type PersistedConversation = {
|
||||
version: 14;
|
||||
cursor: number;
|
||||
items: StoredConversationItem[];
|
||||
outbox: OutboxEntry[];
|
||||
tool_calls: ToolCallRecord[];
|
||||
tool_drafts: Record<string, JsonObject>;
|
||||
tasks: TaskRecord[];
|
||||
execution_summaries: ExecutionProgressSummary[];
|
||||
surfaces: SurfaceInstanceSnapshot;
|
||||
capability_calls: CapabilityCallRecord[];
|
||||
app_inbox: AppInboxEntry[];
|
||||
standard_interactions: StandardInteractionRecord[];
|
||||
miniapp_tool_calls: MiniAppToolCallRecord[];
|
||||
app_workspace: AppWorkspaceSnapshot;
|
||||
};
|
||||
|
||||
const STORE_PREFIX = "lineup.conversation.v1";
|
||||
|
||||
/**
|
||||
* Conversation-local state and its browser persistence adapter.
|
||||
*
|
||||
* It stores only validated ConversationItems, Tool Call cache/drafts, and
|
||||
* outgoing plaintext awaiting transport acknowledgement. It has no DOM,
|
||||
* renderer, HTTP, or IM dependency.
|
||||
* A native Store can implement the same snapshot semantics in M4 hosts.
|
||||
*/
|
||||
export class ConversationStore {
|
||||
private activeConversationID: string | undefined;
|
||||
private state: PersistedConversation = emptyConversation();
|
||||
|
||||
public constructor(private readonly storage: Storage) {}
|
||||
|
||||
public open(conversationID: string): ConversationSnapshot {
|
||||
this.activeConversationID = conversationID;
|
||||
this.state = this.load(conversationID);
|
||||
// `load` also removes content bodies from legacy Artifact offers. Persist
|
||||
// immediately so a reload cannot leave those bytes in local storage until
|
||||
// the next unrelated conversation mutation.
|
||||
this.persist();
|
||||
return this.snapshot();
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
this.activeConversationID = undefined;
|
||||
this.state = emptyConversation();
|
||||
}
|
||||
|
||||
public snapshot(): ConversationSnapshot {
|
||||
return {
|
||||
conversation_id: this.requireActiveConversationID(),
|
||||
cursor: this.state.cursor,
|
||||
items: this.state.items.map(item => ({ ...item })),
|
||||
outbox: this.state.outbox.map(entry => ({ ...entry })),
|
||||
tool_calls: clone(this.state.tool_calls),
|
||||
tool_drafts: clone(this.state.tool_drafts),
|
||||
tasks: clone(this.state.tasks),
|
||||
execution_summaries: clone(this.state.execution_summaries),
|
||||
surfaces: clone(this.state.surfaces),
|
||||
capability_calls: clone(this.state.capability_calls),
|
||||
app_inbox: this.state.app_inbox.map(entry => ({ ...entry })),
|
||||
standard_interactions: clone(this.state.standard_interactions),
|
||||
miniapp_tool_calls: clone(this.state.miniapp_tool_calls),
|
||||
app_workspace: clone(this.state.app_workspace),
|
||||
};
|
||||
}
|
||||
|
||||
/** Replaces only the current conversation's validated Kernel Tool Call projection. */
|
||||
public replaceToolCalls(records: readonly ToolCallRecord[]): void {
|
||||
this.state.tool_calls = records.map(record => clone(record));
|
||||
this.persist();
|
||||
}
|
||||
|
||||
public getToolDraft(callID: string): JsonObject | undefined {
|
||||
const draft = this.state.tool_drafts[callID];
|
||||
return draft ? clone(draft) : undefined;
|
||||
}
|
||||
|
||||
public saveToolDraft(callID: string, values: JsonObject): void {
|
||||
this.state.tool_drafts[callID] = clone(values);
|
||||
this.persist();
|
||||
}
|
||||
|
||||
public clearToolDraft(callID: string): void {
|
||||
if (!(callID in this.state.tool_drafts)) return;
|
||||
delete this.state.tool_drafts[callID];
|
||||
this.persist();
|
||||
}
|
||||
|
||||
public replaceTasks(records: readonly TaskRecord[]): void {
|
||||
this.state.tasks = records.map(record => clone(record));
|
||||
this.persist();
|
||||
}
|
||||
|
||||
/** Stores only bounded public operation labels and timestamps. */
|
||||
public replaceExecutionSummaries(records: readonly ExecutionProgressSummary[]): void {
|
||||
this.state.execution_summaries = normalizeExecutionSummaries(records);
|
||||
this.persist();
|
||||
}
|
||||
|
||||
public replaceSurfaces(snapshot: SurfaceInstanceSnapshot): void {
|
||||
this.state.surfaces = clone(snapshot);
|
||||
this.persist();
|
||||
}
|
||||
|
||||
public replaceCapabilityCalls(records: readonly CapabilityCallRecord[]): void {
|
||||
this.state.capability_calls = normalizeCapabilityCalls(records);
|
||||
this.persist();
|
||||
}
|
||||
|
||||
public replaceAppWorkspace(snapshot: AppWorkspaceSnapshot): void {
|
||||
this.state.app_workspace = normalizeAppWorkspace(snapshot);
|
||||
this.persist();
|
||||
}
|
||||
|
||||
/** Adds a Runtime-validated Agent message to its target App's durable inbox. */
|
||||
public enqueueAppInbox(entry: AppInboxEntry): boolean {
|
||||
if (entry.conversation_id !== this.requireActiveConversationID()) throw new Error("App Inbox conversation scope mismatch.");
|
||||
if (this.state.app_inbox.some(existing => existing.app_scope === entry.app_scope && existing.message_id === entry.message_id)) return false;
|
||||
this.state.app_inbox.push({ ...entry });
|
||||
this.persist();
|
||||
return true;
|
||||
}
|
||||
|
||||
public listAppInbox(appScope: AppScope, instanceID?: string): readonly AppInboxEntry[] {
|
||||
return this.state.app_inbox
|
||||
.filter(entry => entry.app_scope === appScope && (instanceID === undefined ? true : entry.instance_id === instanceID))
|
||||
.map(entry => ({ ...entry }));
|
||||
}
|
||||
|
||||
public acknowledgeAppInbox(appScope: AppScope, messageID: string, instanceID?: string): boolean {
|
||||
const before = this.state.app_inbox.length;
|
||||
this.state.app_inbox = this.state.app_inbox.filter(entry => !(entry.app_scope === appScope && entry.message_id === messageID
|
||||
&& (instanceID === undefined ? true : entry.instance_id === instanceID)));
|
||||
if (this.state.app_inbox.length === before) return false;
|
||||
this.persist();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Replaces the Runtime-owned Agent/Interact interaction projection. */
|
||||
public replaceStandardInteractions(records: readonly StandardInteractionRecord[]): void {
|
||||
this.state.standard_interactions = records.map(record => clone(record));
|
||||
this.persist();
|
||||
}
|
||||
|
||||
/** Replaces the Runtime-owned ordinary MiniApp Tool projection. */
|
||||
public replaceMiniAppToolCalls(records: readonly MiniAppToolCallRecord[]): void {
|
||||
this.state.miniapp_tool_calls = records.map(record => clone(record));
|
||||
this.persist();
|
||||
}
|
||||
|
||||
public appendLocalText(localID: string, text: string, createdAt: string): StoredConversationItem {
|
||||
const delivery: DeliveryState = { status: "local_pending", updated_at: createdAt };
|
||||
const stored: StoredConversationItem = {
|
||||
local_id: localID,
|
||||
created_at: createdAt,
|
||||
item: { kind: "user-message", id: localID, text, delivery },
|
||||
};
|
||||
this.state.items.push(stored);
|
||||
this.state.outbox.push({ local_id: localID, kind: "text", payload: text, created_at: createdAt });
|
||||
this.persist();
|
||||
return { ...stored };
|
||||
}
|
||||
|
||||
/** Queues a standard interaction response before any network attempt. */
|
||||
public enqueueToolAction(localID: string, kind: "tool-result" | "tool-cancel" | "surface-event" | "app-result", payload: string, createdAt: string): void {
|
||||
if (this.state.outbox.some(entry => entry.local_id === localID)) return;
|
||||
this.state.outbox.push({ local_id: localID, kind, payload, created_at: createdAt });
|
||||
this.persist();
|
||||
}
|
||||
|
||||
/** Transport acknowledgement gives the local message its durable IM sequence. */
|
||||
public markSubmitted(localID: string, messageSeq: number, updatedAt: string): void {
|
||||
const stored = this.state.items.find(item => item.local_id === localID);
|
||||
if (stored?.item.kind === "user-message") {
|
||||
stored.message_seq = messageSeq;
|
||||
stored.item = {
|
||||
...stored.item,
|
||||
delivery: { status: "submitted", updated_at: updatedAt, transport_id: String(messageSeq) },
|
||||
};
|
||||
}
|
||||
this.acknowledgeOutbox(localID);
|
||||
// A send acknowledgement is not a sync acknowledgement. The legacy
|
||||
// `/messages/sync` cursor is inclusive, so advancing it here would make a
|
||||
// refresh start at `messageSeq + 1` and permanently skip this user's echo.
|
||||
// Only a message that has actually passed through the sync path advances
|
||||
// the cursor (recordSyncedUserText / recordIncoming / advanceCursor).
|
||||
this.persist();
|
||||
}
|
||||
|
||||
/** Removes a delivered non-text action without creating a chat bubble. */
|
||||
public acknowledgeOutbox(localID: string): void {
|
||||
const before = this.state.outbox.length;
|
||||
this.state.outbox = this.state.outbox.filter(entry => entry.local_id !== localID);
|
||||
if (this.state.outbox.length !== before) this.persist();
|
||||
}
|
||||
|
||||
public markFailed(localID: string, error: string, updatedAt: string): void {
|
||||
const stored = this.state.items.find(item => item.local_id === localID);
|
||||
if (!stored || stored.item.kind !== "user-message") return;
|
||||
stored.item = {
|
||||
...stored.item,
|
||||
delivery: { status: "failed", updated_at: updatedAt, error, retryable: true },
|
||||
};
|
||||
this.persist();
|
||||
}
|
||||
|
||||
public advanceCursor(messageSeq: number): void {
|
||||
if (messageSeq <= this.state.cursor) return;
|
||||
this.state.cursor = messageSeq;
|
||||
this.persist();
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts a remote item exactly once. Sequence is the primary IM identity;
|
||||
* an envelope id catches duplicate messages arriving through another source.
|
||||
*/
|
||||
public recordIncoming(item: ConversationItem, messageSeq: number, createdAt: string): boolean {
|
||||
if (this.state.items.some(existing => existing.message_seq === messageSeq)) {
|
||||
this.state.cursor = Math.max(this.state.cursor, messageSeq);
|
||||
this.persist();
|
||||
return false;
|
||||
}
|
||||
if (item.id && this.state.items.some(existing => existing.item.id === item.id)) {
|
||||
this.state.cursor = Math.max(this.state.cursor, messageSeq);
|
||||
this.persist();
|
||||
return false;
|
||||
}
|
||||
this.state.items.push({
|
||||
local_id: `remote_${messageSeq}`,
|
||||
item,
|
||||
created_at: createdAt,
|
||||
message_seq: messageSeq,
|
||||
});
|
||||
this.state.cursor = Math.max(this.state.cursor, messageSeq);
|
||||
this.persist();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Restores a user message sent by this client when no local record exists. */
|
||||
public recordSyncedUserText(messageSeq: number, text: string, createdAt: string): StoredConversationItem | undefined {
|
||||
if (this.state.items.some(existing => existing.message_seq === messageSeq)) {
|
||||
this.state.cursor = Math.max(this.state.cursor, messageSeq);
|
||||
this.persist();
|
||||
return undefined;
|
||||
}
|
||||
const item: ConversationItem = {
|
||||
kind: "user-message",
|
||||
id: `remote_${messageSeq}`,
|
||||
text,
|
||||
delivery: { status: "delivered", updated_at: createdAt, transport_id: String(messageSeq) },
|
||||
};
|
||||
this.state.items.push({ local_id: `remote_${messageSeq}`, item, created_at: createdAt, message_seq: messageSeq });
|
||||
this.state.cursor = Math.max(this.state.cursor, messageSeq);
|
||||
this.persist();
|
||||
return { local_id: `remote_${messageSeq}`, item, created_at: createdAt, message_seq: messageSeq };
|
||||
}
|
||||
|
||||
private load(conversationID: string): PersistedConversation {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(this.storage.getItem(this.key(conversationID)) ?? "null");
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return emptyConversation();
|
||||
const candidate = parsed as { version?: unknown; cursor?: unknown; items?: unknown; outbox?: unknown; tool_calls?: unknown; tool_drafts?: unknown; tasks?: unknown; execution_summaries?: unknown; surfaces?: unknown; capability_calls?: unknown; app_inbox?: unknown; standard_interactions?: unknown; miniapp_tool_calls?: unknown; app_workspace?: unknown };
|
||||
if ((candidate.version !== 1 && candidate.version !== 2 && candidate.version !== 3 && candidate.version !== 4 && candidate.version !== 5 && candidate.version !== 6 && candidate.version !== 7 && candidate.version !== 8 && candidate.version !== 9 && candidate.version !== 10 && candidate.version !== 11 && candidate.version !== 12 && candidate.version !== 13 && candidate.version !== 14) || !Array.isArray(candidate.items) || !Array.isArray(candidate.outbox) || typeof candidate.cursor !== "number") {
|
||||
return emptyConversation();
|
||||
}
|
||||
|
||||
if (candidate.version === 10 || candidate.version === 11) {
|
||||
// v10 is the immediately preceding complete conversation format. Add
|
||||
// the Runtime-owned App Inbox without discarding any already durable
|
||||
// tool, task, Surface, capability, or execution projection.
|
||||
return {
|
||||
version: 14,
|
||||
cursor: Math.max(0, candidate.cursor),
|
||||
items: normalizeStoredItems(candidate.items),
|
||||
outbox: normalizeOutbox(candidate.outbox),
|
||||
tool_calls: Array.isArray(candidate.tool_calls) ? candidate.tool_calls as ToolCallRecord[] : [],
|
||||
tool_drafts: isDraftMap(candidate.tool_drafts) ? candidate.tool_drafts : {},
|
||||
tasks: Array.isArray(candidate.tasks) ? candidate.tasks as TaskRecord[] : [],
|
||||
execution_summaries: normalizeExecutionSummaries(candidate.execution_summaries),
|
||||
surfaces: normalizeSurfaces(candidate.surfaces),
|
||||
capability_calls: normalizeCapabilityCalls(candidate.capability_calls),
|
||||
app_inbox: normalizeAppInbox(candidate.app_inbox, conversationID),
|
||||
standard_interactions: [],
|
||||
miniapp_tool_calls: [],
|
||||
app_workspace: emptyAppWorkspace(),
|
||||
};
|
||||
}
|
||||
|
||||
if (candidate.version !== 12 && candidate.version !== 13 && candidate.version !== 14) {
|
||||
// v1 advanced its cursor from a send acknowledgement. Some browsers
|
||||
// had already been upgraded to v2 before that correction, retaining a
|
||||
// high cursor with holes for their own echoes. Preserve every valid
|
||||
// local item but replay the server history once to repair those holes;
|
||||
// the next persist upgrades this record to v5.
|
||||
return {
|
||||
version: 14,
|
||||
cursor: candidate.version === 4 || candidate.version === 5 || candidate.version === 6 || candidate.version === 7 ? Math.max(0, candidate.cursor) : 0,
|
||||
items: normalizeStoredItems(candidate.items),
|
||||
outbox: normalizeOutbox(candidate.outbox),
|
||||
tool_calls: (candidate.version === 4 || candidate.version === 5 || candidate.version === 6 || candidate.version === 7) && Array.isArray(candidate.tool_calls) ? candidate.tool_calls as ToolCallRecord[] : [],
|
||||
tool_drafts: (candidate.version === 4 || candidate.version === 5 || candidate.version === 6 || candidate.version === 7) && isDraftMap(candidate.tool_drafts) ? candidate.tool_drafts : {},
|
||||
tasks: (candidate.version === 5 || candidate.version === 6 || candidate.version === 7) && Array.isArray(candidate.tasks) ? candidate.tasks as TaskRecord[] : [],
|
||||
// v7's fixed lifecycle placeholders are intentionally discarded.
|
||||
execution_summaries: [],
|
||||
surfaces: emptySurfaces(),
|
||||
capability_calls: [],
|
||||
app_inbox: [],
|
||||
standard_interactions: [],
|
||||
miniapp_tool_calls: [],
|
||||
app_workspace: emptyAppWorkspace(),
|
||||
};
|
||||
}
|
||||
return {
|
||||
version: 14,
|
||||
cursor: Math.max(0, candidate.cursor),
|
||||
items: normalizeStoredItems(candidate.items),
|
||||
outbox: normalizeOutbox(candidate.outbox),
|
||||
tool_calls: Array.isArray(candidate.tool_calls) ? candidate.tool_calls as ToolCallRecord[] : [],
|
||||
tool_drafts: isDraftMap(candidate.tool_drafts) ? candidate.tool_drafts : {},
|
||||
tasks: Array.isArray(candidate.tasks) ? candidate.tasks as TaskRecord[] : [],
|
||||
execution_summaries: normalizeExecutionSummaries(candidate.execution_summaries),
|
||||
surfaces: normalizeSurfaces(candidate.surfaces),
|
||||
capability_calls: normalizeCapabilityCalls(candidate.capability_calls),
|
||||
app_inbox: normalizeAppInbox(candidate.app_inbox, conversationID),
|
||||
standard_interactions: normalizeStandardInteractions(candidate.standard_interactions, conversationID),
|
||||
miniapp_tool_calls: normalizeMiniAppToolCalls(candidate.miniapp_tool_calls, conversationID),
|
||||
app_workspace: normalizeAppWorkspace(candidate.app_workspace),
|
||||
};
|
||||
} catch {
|
||||
return emptyConversation();
|
||||
}
|
||||
}
|
||||
|
||||
private persist(): void {
|
||||
this.storage.setItem(this.key(this.requireActiveConversationID()), JSON.stringify(this.state));
|
||||
}
|
||||
|
||||
private key(conversationID: string): string {
|
||||
return `${STORE_PREFIX}:${conversationID}`;
|
||||
}
|
||||
|
||||
private requireActiveConversationID(): string {
|
||||
if (!this.activeConversationID) throw new Error("ConversationStore has no active conversation.");
|
||||
return this.activeConversationID;
|
||||
}
|
||||
}
|
||||
|
||||
function emptyConversation(): PersistedConversation {
|
||||
return { version: 14, cursor: 0, items: [], outbox: [], tool_calls: [], tool_drafts: {}, tasks: [], execution_summaries: [], surfaces: emptySurfaces(), capability_calls: [], app_inbox: [], standard_interactions: [], miniapp_tool_calls: [], app_workspace: emptyAppWorkspace() };
|
||||
}
|
||||
|
||||
function emptySurfaces(): SurfaceInstanceSnapshot { return { version: 1, instances: [] }; }
|
||||
function normalizeSurfaces(value: unknown): SurfaceInstanceSnapshot { return value && typeof value === "object" && !Array.isArray(value) ? value as SurfaceInstanceSnapshot : emptySurfaces(); }
|
||||
function emptyAppWorkspace(): AppWorkspaceSnapshot { return { version: 1, instances: { version: 1, instances: [] }, focus: { version: 1, stack: [] } }; }
|
||||
function normalizeAppWorkspace(value: unknown): AppWorkspaceSnapshot {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return emptyAppWorkspace();
|
||||
const candidate = value as Partial<AppWorkspaceSnapshot>;
|
||||
if (candidate.version !== 1 || !candidate.instances || !candidate.focus) return emptyAppWorkspace();
|
||||
return candidate as AppWorkspaceSnapshot;
|
||||
}
|
||||
|
||||
function normalizeOutbox(value: unknown): OutboxEntry[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.flatMap(entry => {
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return [];
|
||||
const candidate = entry as Partial<OutboxEntry>;
|
||||
if (typeof candidate.local_id !== "string" || typeof candidate.payload !== "string" || typeof candidate.created_at !== "string") return [];
|
||||
const kind = candidate.kind === "tool-result" || candidate.kind === "tool-cancel" || candidate.kind === "surface-event" || candidate.kind === "app-result" ? candidate.kind : "text";
|
||||
return [{ local_id: candidate.local_id, kind, payload: candidate.payload, created_at: candidate.created_at }];
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeAppInbox(value: unknown, conversationID: string): AppInboxEntry[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const seen = new Set<string>();
|
||||
return value.flatMap(raw => {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return [];
|
||||
const entry = raw as Partial<AppInboxEntry>;
|
||||
if (typeof entry.message_id !== "string" || !entry.message_id || typeof entry.app_scope !== "string" || !/^[a-z][a-z0-9-]{0,63}$/.test(entry.app_scope) || entry.conversation_id !== conversationID || typeof entry.received_at !== "string" || !entry.item || typeof entry.item !== "object") return [];
|
||||
const key = `${entry.app_scope}:${entry.message_id}`;
|
||||
if (seen.has(key)) return [];
|
||||
seen.add(key);
|
||||
return [{
|
||||
message_id: entry.message_id,
|
||||
app_scope: entry.app_scope,
|
||||
conversation_id: conversationID,
|
||||
item: entry.item as ConversationItem,
|
||||
received_at: entry.received_at,
|
||||
...(typeof entry.message_seq === "number" ? { message_seq: entry.message_seq } : {}),
|
||||
...(typeof entry.instance_id === "string" ? { instance_id: entry.instance_id } : {}),
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeStandardInteractions(value: unknown, conversationID: string): StandardInteractionRecord[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const seen = new Set<string>();
|
||||
return value.flatMap(raw => {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return [];
|
||||
const record = raw as Partial<StandardInteractionRecord>;
|
||||
if (typeof record.interaction_id !== "string" || !record.interaction_id || seen.has(record.interaction_id)
|
||||
|| typeof record.call_id !== "string" || typeof record.agent_id !== "string"
|
||||
|| record.conversation_id !== conversationID || typeof record.interact_instance_id !== "string"
|
||||
|| typeof record.kind !== "string" || !record.request || typeof record.request !== "object"
|
||||
|| typeof record.status !== "string" || typeof record.created_at !== "string" || Number.isNaN(Date.parse(record.created_at))
|
||||
|| typeof record.updated_at !== "string" || Number.isNaN(Date.parse(record.updated_at))) return [];
|
||||
seen.add(record.interaction_id);
|
||||
return [clone(record as StandardInteractionRecord)];
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeMiniAppToolCalls(value: unknown, conversationID: string): MiniAppToolCallRecord[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const seen = new Set<string>();
|
||||
return value.flatMap(raw => {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return [];
|
||||
const record = raw as Partial<MiniAppToolCallRecord>;
|
||||
if (typeof record.call_id !== "string" || !record.call_id || seen.has(record.call_id)
|
||||
|| typeof record.tool_id !== "string" || typeof record.inventory_revision !== "string"
|
||||
|| typeof record.app_scope !== "string" || record.conversation_id !== conversationID
|
||||
|| !record.input || typeof record.input !== "object" || Array.isArray(record.input)
|
||||
|| typeof record.status !== "string" || typeof record.created_at !== "string" || Number.isNaN(Date.parse(record.created_at))
|
||||
|| typeof record.updated_at !== "string" || Number.isNaN(Date.parse(record.updated_at))) return [];
|
||||
seen.add(record.call_id);
|
||||
return [clone(record as MiniAppToolCallRecord)];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Artifact content is delivery-only data for the host-owned volatile cache.
|
||||
* It must never survive in the durable conversation transcript, including
|
||||
* transcripts written by a pre-cache version of the client.
|
||||
*/
|
||||
function normalizeStoredItems(value: unknown): StoredConversationItem[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.flatMap(raw => {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return [];
|
||||
const stored = raw as Partial<StoredConversationItem>;
|
||||
if (typeof stored.local_id !== "string" || !stored.item || typeof stored.item !== "object" || Array.isArray(stored.item)
|
||||
|| typeof stored.created_at !== "string") return [];
|
||||
const item = stored.item as ConversationItem;
|
||||
if (item.kind !== "artifact-offer" || !("envelope" in item) || !item.envelope?.payload || !("content_base64" in item.envelope.payload)) {
|
||||
return [{ ...stored, item: clone(item) } as StoredConversationItem];
|
||||
}
|
||||
const { content_base64: _content, ...metadata } = item.envelope.payload;
|
||||
return [{
|
||||
...stored,
|
||||
item: { ...item, envelope: { ...item.envelope, payload: metadata } },
|
||||
} as StoredConversationItem];
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeCapabilityCalls(value: unknown): CapabilityCallRecord[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const statuses = new Set(["pending", "approved", "executing", "completed", "rejected", "cancelled", "expired", "unsupported", "failed"]);
|
||||
const names = new Set(["app.open_url", "clipboard.write", "device.pick_file", "artifact.save"]);
|
||||
return value.flatMap(raw => {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return [];
|
||||
const record = raw as Partial<CapabilityCallRecord>;
|
||||
const request = record.request;
|
||||
if (typeof record.call_id !== "string" || !record.call_id || typeof record.conversation_id !== "string" || !record.conversation_id
|
||||
|| !request || typeof request !== "object" || request.call_id !== record.call_id || !names.has(request.capability)
|
||||
|| typeof request.reason !== "string" || !request.reason.trim() || request.reason.length > 500
|
||||
|| typeof request.expires_at !== "string" || Number.isNaN(Date.parse(request.expires_at))
|
||||
|| !request.arguments || typeof request.arguments !== "object" || Array.isArray(request.arguments)
|
||||
|| typeof record.status !== "string" || !statuses.has(record.status)
|
||||
|| typeof record.created_at !== "string" || Number.isNaN(Date.parse(record.created_at))
|
||||
|| typeof record.updated_at !== "string" || Number.isNaN(Date.parse(record.updated_at))) return [];
|
||||
return [{
|
||||
call_id: record.call_id, conversation_id: record.conversation_id,
|
||||
request: { call_id: request.call_id, capability: request.capability, reason: sanitizeCapabilityReason(request.reason), expires_at: request.expires_at, arguments: clone(request.arguments) },
|
||||
status: record.status as CapabilityCallRecord["status"], created_at: record.created_at, updated_at: record.updated_at,
|
||||
...(record.result && typeof record.result === "object" && !Array.isArray(record.result) ? { result: clone(record.result) } : {}),
|
||||
}];
|
||||
}).slice(-100);
|
||||
}
|
||||
|
||||
function isDraftMap(value: unknown): value is Record<string, JsonObject> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
&& Object.values(value).every(draft => draft !== null && typeof draft === "object" && !Array.isArray(draft));
|
||||
}
|
||||
|
||||
/**
|
||||
* This is intentionally stricter than a generic JSON clone. Execution
|
||||
* summaries are a privacy boundary: unknown fields (including a model detail,
|
||||
* command, argument, URL, path, output, or tool input) are discarded first.
|
||||
*/
|
||||
function normalizeExecutionSummaries(value: unknown): ExecutionProgressSummary[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const statuses = new Set(["running", "completed", "failed"]);
|
||||
const stepStatuses = new Set(["completed", "failed"]);
|
||||
return value.flatMap(raw => {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return [];
|
||||
const candidate = raw as Partial<ExecutionProgressSummary>;
|
||||
if (typeof candidate.id !== "string" || candidate.id.length < 1 || candidate.id.length > 128
|
||||
|| typeof candidate.status !== "string" || !statuses.has(candidate.status)
|
||||
|| typeof candidate.started_at !== "string" || Number.isNaN(Date.parse(candidate.started_at))
|
||||
|| typeof candidate.updated_at !== "string" || Number.isNaN(Date.parse(candidate.updated_at))
|
||||
|| (candidate.finished_at !== undefined && (typeof candidate.finished_at !== "string" || Number.isNaN(Date.parse(candidate.finished_at))))
|
||||
|| !Array.isArray(candidate.steps) || candidate.steps.length > 12) return [];
|
||||
const seen = new Set<string>();
|
||||
const steps = candidate.steps.flatMap(raw => {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return [];
|
||||
const step = raw as { id?: unknown; title?: unknown; status?: unknown };
|
||||
if (typeof step.id !== "string" || !/^[A-Za-z][A-Za-z0-9_.:-]{0,127}$/.test(step.id)
|
||||
|| typeof step.title !== "string" || !step.title.trim() || step.title.length > 160
|
||||
|| typeof step.status !== "string" || !stepStatuses.has(step.status) || seen.has(step.id)) return [];
|
||||
seen.add(step.id);
|
||||
return [{ id: step.id, title: step.title.trim(), status: step.status as "completed" | "failed" }];
|
||||
});
|
||||
if (steps.length !== candidate.steps.length) return [];
|
||||
return [{
|
||||
id: candidate.id,
|
||||
status: candidate.status as ExecutionProgressSummary["status"],
|
||||
started_at: candidate.started_at,
|
||||
updated_at: candidate.updated_at,
|
||||
...(candidate.finished_at ? { finished_at: candidate.finished_at } : {}),
|
||||
steps,
|
||||
}];
|
||||
}).slice(-20);
|
||||
}
|
||||
|
||||
function clone<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { decodeConversationItem, type ConversationItem, type JsonObject } from "@/runtime/protocol/lineup-v1";
|
||||
import { InteractionKernel } from "@/runtime/coordination/interaction-kernel";
|
||||
import fixture from "@/runtime/protocol/golden/lineup-v1.json";
|
||||
|
||||
type GoldenExpected = Readonly<{
|
||||
item_kind: ConversationItem["kind"];
|
||||
fallback?: "invalid_payload" | "unsupported_version";
|
||||
kernel_tool_status?: "pending" | "completed";
|
||||
}>;
|
||||
type GoldenTransition = Readonly<{ after_envelope: number; kind: "submit_tool_call"; call_id: string; submission: JsonObject }>;
|
||||
type GoldenCase = Readonly<{ id: string; expected: GoldenExpected; envelopes: readonly unknown[]; transitions?: readonly GoldenTransition[] }>;
|
||||
|
||||
const cases = fixture.cases as readonly GoldenCase[];
|
||||
|
||||
describe("LineUp v1 host-neutral golden contract", () => {
|
||||
it("uses a versioned portable fixture", () => {
|
||||
expect(fixture.contract).toBe("lineup-v1-golden-1");
|
||||
expect(cases.map(test => test.id)).toEqual([
|
||||
"tool-call-confirm", "tool-result-completes-call", "surface-open-is-data-only",
|
||||
"surface-source-injection-rejected", "app-call-is-a-consent-request", "unknown-protocol-version-rejected",
|
||||
]);
|
||||
});
|
||||
|
||||
for (const test of cases) {
|
||||
it(`${test.id}: accepts only the documented projection`, () => {
|
||||
const kernel = new InteractionKernel();
|
||||
let item: ConversationItem | undefined;
|
||||
for (const [index, envelope] of test.envelopes.entries()) {
|
||||
const raw = JSON.stringify(envelope);
|
||||
item = decodeConversationItem(raw);
|
||||
kernel.ingest({ raw, source: "live", received_at: "2026-08-04T00:00:00.000Z" });
|
||||
for (const transition of test.transitions?.filter(entry => entry.after_envelope === index) ?? []) {
|
||||
kernel.submitToolCall(transition.call_id, "2026-08-04T00:00:01.000Z", transition.submission);
|
||||
}
|
||||
}
|
||||
expect(item?.kind).toBe(test.expected.item_kind);
|
||||
if (test.expected.fallback) {
|
||||
expect(item).toMatchObject({ kind: "fallback", reason: test.expected.fallback });
|
||||
expect(kernel.snapshot().tool_calls).toEqual([]);
|
||||
}
|
||||
if (test.expected.kernel_tool_status) {
|
||||
expect(kernel.snapshot().tool_calls).toMatchObject([{ call_id: test.id === "tool-call-confirm" ? "golden-confirm" : "golden-result", status: test.expected.kernel_tool_status }]);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"contract": "lineup-v1-golden-1",
|
||||
"description": "Host-neutral acceptance and safe-fallback cases. Every Host must produce the stated item kind and must never execute a rejected input.",
|
||||
"cases": [
|
||||
{
|
||||
"id": "tool-call-confirm",
|
||||
"expected": { "item_kind": "tool-call", "kernel_tool_status": "pending" },
|
||||
"envelopes": [
|
||||
{ "v": 1, "id": "golden-tool-call", "type": "lineup.v1.tool.call", "conversation_id": "golden-conversation", "sender": { "kind": "agent", "id": "golden-agent" }, "payload": { "call_id": "golden-confirm", "tool": "confirm", "title": "确认操作", "prompt": "继续吗?", "data": { "confirm": { "approve_label": "继续", "cancel_label": "取消" } } } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "tool-result-completes-call",
|
||||
"expected": { "item_kind": "tool-result", "kernel_tool_status": "completed" },
|
||||
"transitions": [
|
||||
{ "after_envelope": 0, "kind": "submit_tool_call", "call_id": "golden-result", "submission": { "approved": true } }
|
||||
],
|
||||
"envelopes": [
|
||||
{ "v": 1, "id": "golden-tool-open", "type": "lineup.v1.tool.call", "conversation_id": "golden-conversation", "sender": { "kind": "agent", "id": "golden-agent" }, "payload": { "call_id": "golden-result", "tool": "confirm", "title": "确认操作", "prompt": "继续吗?", "data": { "confirm": {} } } },
|
||||
{ "v": 1, "id": "golden-tool-result", "type": "lineup.v1.tool.result", "conversation_id": "golden-conversation", "sender": { "kind": "human", "id": "golden-human" }, "payload": { "call_id": "golden-result", "status": "completed", "result": { "approved": true } } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "surface-open-is-data-only",
|
||||
"expected": { "item_kind": "surface" },
|
||||
"envelopes": [
|
||||
{ "v": 1, "id": "golden-surface-open", "type": "lineup.v1.ui.open", "conversation_id": "golden-conversation", "sender": { "kind": "agent", "id": "golden-agent" }, "payload": { "instance_id": "golden:surface", "app": { "app_id": "lineup.task-dashboard", "version": "0.1.0" }, "state": { "title": "同步中", "percent": 42 } } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "surface-source-injection-rejected",
|
||||
"expected": { "item_kind": "fallback", "fallback": "invalid_payload" },
|
||||
"envelopes": [
|
||||
{ "v": 1, "id": "golden-surface-injection", "type": "lineup.v1.ui.open", "conversation_id": "golden-conversation", "sender": { "kind": "agent", "id": "golden-agent" }, "payload": { "instance_id": "golden:injection", "app": { "app_id": "lineup.task-dashboard", "version": "0.1.0" }, "html": "<script>evil()</script>" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "app-call-is-a-consent-request",
|
||||
"expected": { "item_kind": "app-call" },
|
||||
"envelopes": [
|
||||
{ "v": 1, "id": "golden-app-call", "type": "lineup.v1.app.call", "conversation_id": "golden-conversation", "sender": { "kind": "agent", "id": "golden-agent" }, "payload": { "call_id": "golden-capability", "capability": "app.open_url", "reason": "打开帮助页面", "expires_at": "2026-08-04T01:00:00.000Z", "arguments": { "url": "https://example.com/help" } } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "unknown-protocol-version-rejected",
|
||||
"expected": { "item_kind": "fallback", "fallback": "unsupported_version" },
|
||||
"envelopes": [
|
||||
{ "v": 2, "id": "golden-v2", "type": "lineup.v1.text", "conversation_id": "golden-conversation", "sender": { "kind": "agent", "id": "golden-agent" }, "payload": { "text": "must not render as trusted v1" } }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
/**
|
||||
* 平台无关的 LineUp v1 协议模型与安全解码边界。
|
||||
*
|
||||
* 本模块把不可信传输 payload 转为受信任的 ConversationItem;它不依赖 DOM、网络、Tauri 或
|
||||
* 本地存储。Runtime 在此基础上再施加 app_scope、conversation_id 与 App 路由策略。
|
||||
*
|
||||
* Platform-neutral LineUp v1 protocol model.
|
||||
*
|
||||
* This module deliberately contains no DOM, network, Tauri, or storage code.
|
||||
@@ -38,7 +43,7 @@ export type DeliveryState =
|
||||
| { status: "failed"; updated_at: string; error: string; retryable: boolean };
|
||||
|
||||
/** The only standard interaction requests admitted during Milestone 1. */
|
||||
export type ToolCallKind = "choice" | "confirm" | "input";
|
||||
export type ToolCallKind = "notice" | "choice" | "confirm" | "input";
|
||||
|
||||
export type ActionGroupMode = "single-choice" | "multi-choice" | "button";
|
||||
|
||||
@@ -58,6 +63,11 @@ export type ConfirmDefinition = {
|
||||
cancel_label: string;
|
||||
};
|
||||
|
||||
export type NoticeDefinition = {
|
||||
message: string;
|
||||
dismiss_label?: string;
|
||||
};
|
||||
|
||||
export type InputFieldType = "text" | "textarea" | "number";
|
||||
|
||||
export type InputField = {
|
||||
@@ -86,12 +96,24 @@ export type ToolCallRequest = {
|
||||
data: JsonObject;
|
||||
/** Present only for the trusted `choice` renderer introduced in M1-02. */
|
||||
action_group?: ActionGroup;
|
||||
/** Present only for the trusted `notice` renderer introduced in SDK v1. */
|
||||
notice?: NoticeDefinition;
|
||||
/** Present only for the trusted `confirm` renderer introduced in M1-03. */
|
||||
confirm?: ConfirmDefinition;
|
||||
/** Present only for the trusted `input` renderer introduced in M1-03. */
|
||||
form?: InputForm;
|
||||
};
|
||||
|
||||
/** A non-interactive Tool invocation delivered to a bundled MiniApp. */
|
||||
export type MiniAppToolInvoke = {
|
||||
call_id: string;
|
||||
tool_id: string;
|
||||
app_scope: string;
|
||||
inventory_revision: string;
|
||||
input: JsonObject;
|
||||
instance_id?: string;
|
||||
};
|
||||
|
||||
export type ToolCallFinalStatus = "completed" | "failed" | "cancelled" | "expired";
|
||||
|
||||
export type ToolCallResult = {
|
||||
@@ -116,6 +138,10 @@ export type TaskProgress = {
|
||||
cancellable: boolean;
|
||||
};
|
||||
|
||||
/** Display-only, adapter-authoritative execution trace. It grants no ability. */
|
||||
export type AgentExecutionStep = { id: string; title: string; status: "running" | "completed" | "failed" };
|
||||
export type AgentExecutionTrace = { execution_id: string; status: "running" | "completed" | "failed"; steps: readonly AgentExecutionStep[] };
|
||||
|
||||
/**
|
||||
* A user-originated action awaiting delivery through the Transport Adapter.
|
||||
* It is data only: the Store owns retries and the Kernel owns state changes.
|
||||
@@ -180,14 +206,17 @@ type ConversationItemBase = {
|
||||
|
||||
export type ConversationItem =
|
||||
| (ConversationItemBase & { kind: "user-message"; text: string; delivery: DeliveryState })
|
||||
| (ConversationItemBase & { kind: "markdown"; markdown: string })
|
||||
| (ConversationItemBase & { kind: "agent-status"; status: string; detail: string })
|
||||
| (ConversationItemBase & { kind: "markdown"; markdown: string; execution_id?: string })
|
||||
| (ConversationItemBase & { kind: "agent-status"; status: string; detail: string; execution_id?: string })
|
||||
| (ConversationItemBase & { kind: "execution-summary"; trace: AgentExecutionTrace })
|
||||
| (ConversationItemBase & { kind: "progress"; title: string; percent: number; status: string; task?: TaskProgress })
|
||||
| (ConversationItemBase & { kind: "error"; message: string; code?: string })
|
||||
| (ConversationItemBase & { kind: "tool-call"; call: ToolCallRequest })
|
||||
| (ConversationItemBase & { kind: "miniapp-tool-call"; call: MiniAppToolInvoke })
|
||||
| (ConversationItemBase & { kind: "tool-result"; result: ToolCallResult })
|
||||
| (ConversationItemBase & { kind: "tool-cancel"; cancel: ToolCallCancel })
|
||||
| (ConversationItemBase & { kind: "surface"; envelope: Envelope })
|
||||
| (ConversationItemBase & { kind: "artifact-offer"; envelope: Envelope })
|
||||
| (ConversationItemBase & { kind: "app-call"; envelope: Envelope })
|
||||
| (ConversationItemBase & { kind: "fallback"; reason: ProtocolFallbackCode; detail: string; envelope?: Envelope });
|
||||
|
||||
@@ -199,18 +228,23 @@ const ACTOR_KINDS = new Set<ActorKind>(["human", "agent", "app", "system"]);
|
||||
const SUPPORTED_TYPES = new Set([
|
||||
"lineup.v1.text",
|
||||
"lineup.v1.agent.status",
|
||||
"lineup.v1.agent.execution",
|
||||
"lineup.v1.agent.progress",
|
||||
"lineup.v1.error",
|
||||
"lineup.v1.tool.call",
|
||||
"lineup.v1.miniapp.tool.call",
|
||||
"lineup.v1.tool.result",
|
||||
"lineup.v1.tool.cancel",
|
||||
"lineup.v1.ui.open",
|
||||
"lineup.v1.ui.patch",
|
||||
"lineup.v1.ui.close",
|
||||
"lineup.v1.artifact.offer",
|
||||
"lineup.v1.app.call",
|
||||
]);
|
||||
const ID_MAX_LENGTH = 256;
|
||||
const INSTANCE_ID = /^[A-Za-z0-9._:-]{1,128}$/;
|
||||
const SURFACE_APP_ID = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/;
|
||||
const SURFACE_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
||||
const MESSAGE_TYPE = /^lineup\.v1\.[a-z][a-z0-9.]*[a-z0-9]$/;
|
||||
|
||||
function object(value: unknown): JsonObject | null {
|
||||
@@ -307,22 +341,38 @@ function validSurfacePayload(type: string, payload: JsonObject): boolean {
|
||||
const instanceID = text(payload.instance_id);
|
||||
if (!INSTANCE_ID.test(instanceID)) return false;
|
||||
if (type === "lineup.v1.ui.open") {
|
||||
if (!object(payload.app)) return false;
|
||||
// Surface code is strictly host-local in M2. A wire message may identify
|
||||
// an app/version and state, but can never inject HTML/CSS/JS or a bundle
|
||||
// location. Exact enablement is checked by the M2 Instance Manager.
|
||||
if (hasSurfaceBundleOverride(payload)) return false;
|
||||
const app = object(payload.app);
|
||||
if (!app || Object.keys(app).some(field => field !== "app_id" && field !== "version")) return false;
|
||||
const appID = text(app.app_id);
|
||||
const version = text(app.version);
|
||||
if (!SURFACE_APP_ID.test(appID) || appID.length > 96 || !SURFACE_VERSION.test(version) || version.length > 64) return false;
|
||||
return payload.state === undefined || object(payload.state) !== null;
|
||||
}
|
||||
if (type === "lineup.v1.ui.patch") return object(payload.state) !== null;
|
||||
return type === "lineup.v1.ui.close";
|
||||
}
|
||||
|
||||
function validAppCallPayload(payload: JsonObject): boolean {
|
||||
return Boolean(identifier(payload.call_id) && identifier(payload.capability) && object(payload.arguments));
|
||||
function hasSurfaceBundleOverride(payload: JsonObject): boolean {
|
||||
return ["bundle", "bundle_id", "bundle_url", "url", "html", "css", "javascript", "js", "script", "source"].some(field => field in payload);
|
||||
}
|
||||
|
||||
const TOOL_KINDS = new Set<ToolCallKind>(["choice", "confirm", "input"]);
|
||||
function validAppCallPayload(payload: JsonObject): boolean {
|
||||
if (Object.keys(payload).some(key => key !== "call_id" && key !== "capability" && key !== "reason" && key !== "expires_at" && key !== "arguments")) return false;
|
||||
const reason = text(payload.reason).trim();
|
||||
const expiry = optionalTimestamp(payload.expires_at);
|
||||
return Boolean(identifier(payload.call_id) && identifier(payload.capability) && reason && reason.length <= 500 && expiry && object(payload.arguments));
|
||||
}
|
||||
|
||||
const TOOL_KINDS = new Set<ToolCallKind>(["notice", "choice", "confirm", "input"]);
|
||||
const TOOL_FINAL_STATUSES = new Set<ToolCallFinalStatus>(["completed", "failed", "cancelled", "expired"]);
|
||||
const ACTION_GROUP_MODES = new Set<ActionGroupMode>(["single-choice", "multi-choice", "button"]);
|
||||
const INPUT_FIELD_TYPES = new Set<InputFieldType>(["text", "textarea", "number"]);
|
||||
const TASK_STATUSES = new Set<TaskStatus>(["running", "waiting", "completed", "failed", "cancelled"]);
|
||||
const EXECUTION_STATUSES = new Set(["running", "completed", "failed"]);
|
||||
|
||||
function optionalTimestamp(value: unknown): string | undefined {
|
||||
return typeof value === "string" && !Number.isNaN(Date.parse(value)) ? value : undefined;
|
||||
@@ -360,6 +410,14 @@ function parseConfirm(value: unknown): ConfirmDefinition | undefined {
|
||||
return approve && cancel ? { approve_label: approve, cancel_label: cancel } : undefined;
|
||||
}
|
||||
|
||||
function parseNotice(value: unknown): NoticeDefinition | undefined {
|
||||
const candidate = object(value);
|
||||
if (!candidate) return undefined;
|
||||
const message = text(candidate.message).trim();
|
||||
const dismiss = candidate.dismiss_label === undefined ? undefined : boundedLabel(candidate.dismiss_label, "关闭");
|
||||
return message && (candidate.dismiss_label === undefined || dismiss) ? { message, ...(dismiss ? { dismiss_label: dismiss } : {}) } : undefined;
|
||||
}
|
||||
|
||||
function wholeNumber(value: unknown, maximum: number): number | undefined {
|
||||
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= maximum ? value : undefined;
|
||||
}
|
||||
@@ -408,9 +466,11 @@ function parseToolCall(payload: JsonObject): ToolCallRequest | undefined {
|
||||
if (payload.expires_at !== undefined && !expiresAt) return undefined;
|
||||
const data = payload.data === undefined ? {} : object(payload.data);
|
||||
if (!data) return undefined;
|
||||
const notice = tool === "notice" ? parseNotice(data.notice ?? data) : undefined;
|
||||
const actionGroup = tool === "choice" ? parseActionGroup(data.action_group) : undefined;
|
||||
const confirm = tool === "confirm" ? parseConfirm(data.confirm) : undefined;
|
||||
const form = tool === "input" ? parseInputForm(data.form) : undefined;
|
||||
if (tool === "notice" && !notice) return undefined;
|
||||
if (tool === "choice" && !actionGroup) return undefined;
|
||||
if (tool === "confirm" && !confirm) return undefined;
|
||||
if (tool === "input" && !form) return undefined;
|
||||
@@ -421,12 +481,25 @@ function parseToolCall(payload: JsonObject): ToolCallRequest | undefined {
|
||||
prompt: text(payload.prompt),
|
||||
...(expiresAt ? { expires_at: expiresAt } : {}),
|
||||
data,
|
||||
...(notice ? { notice } : {}),
|
||||
...(actionGroup ? { action_group: actionGroup } : {}),
|
||||
...(confirm ? { confirm } : {}),
|
||||
...(form ? { form } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function parseMiniAppToolInvoke(payload: JsonObject): MiniAppToolInvoke | undefined {
|
||||
const callID = identifier(payload.call_id);
|
||||
const toolID = identifier(payload.tool_id);
|
||||
const appScope = text(payload.app_scope);
|
||||
const inventoryRevision = identifier(payload.inventory_revision);
|
||||
const input = object(payload.input);
|
||||
const instanceID = payload.instance_id === undefined ? undefined : identifier(payload.instance_id);
|
||||
if (!callID || !toolID || !appScope || !inventoryRevision || !input || (payload.instance_id !== undefined && !instanceID)) return undefined;
|
||||
if (Object.keys(payload).some(key => !["call_id", "tool_id", "app_scope", "inventory_revision", "input", "instance_id"].includes(key))) return undefined;
|
||||
return { call_id: callID, tool_id: toolID, app_scope: appScope, inventory_revision: inventoryRevision, input, ...(instanceID ? { instance_id: instanceID } : {}) };
|
||||
}
|
||||
|
||||
function parseTaskProgress(payload: JsonObject, percent: number): TaskProgress | undefined {
|
||||
const operationID = identifier(payload.operation_id);
|
||||
const status = text(payload.status) as TaskStatus;
|
||||
@@ -436,6 +509,24 @@ function parseTaskProgress(payload: JsonObject, percent: number): TaskProgress |
|
||||
return { operation_id: operationID, title, percent, status, cancellable: payload.cancellable === true };
|
||||
}
|
||||
|
||||
function parseExecutionTrace(payload: JsonObject): AgentExecutionTrace | undefined {
|
||||
const executionID = identifier(payload.execution_id);
|
||||
const status = text(payload.status);
|
||||
if (!executionID || !EXECUTION_STATUSES.has(status) || !Array.isArray(payload.steps) || payload.steps.length > 12) return undefined;
|
||||
const steps: AgentExecutionStep[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const raw of payload.steps) {
|
||||
const step = object(raw);
|
||||
const id = step && identifier(step.id);
|
||||
const title = step && text(step.title).trim();
|
||||
const stepStatus = step && text(step.status);
|
||||
if (!id || !title || title.length > 160 || !stepStatus || !EXECUTION_STATUSES.has(stepStatus) || seen.has(id)) return undefined;
|
||||
seen.add(id);
|
||||
steps.push({ id, title, status: stepStatus as AgentExecutionStep["status"] });
|
||||
}
|
||||
return { execution_id: executionID, status: status as AgentExecutionTrace["status"], steps };
|
||||
}
|
||||
|
||||
function parseToolResult(payload: JsonObject): ToolCallResult | undefined {
|
||||
const callID = identifier(payload.call_id);
|
||||
const status = text(payload.status) as ToolCallFinalStatus;
|
||||
@@ -484,14 +575,20 @@ export function decodeConversationItem(raw: string): ConversationItem {
|
||||
switch (envelope.type) {
|
||||
case "lineup.v1.text": {
|
||||
const markdown = text(payload.markdown) || text(payload.text);
|
||||
return markdown ? { kind: "markdown", markdown, ...base } : fallbackItem({ code: "invalid_payload", detail: "Text payload requires text or markdown.", envelope });
|
||||
return markdown
|
||||
? { kind: "markdown", markdown, ...(identifier(payload.execution_id) ? { execution_id: identifier(payload.execution_id)! } : {}), ...base }
|
||||
: fallbackItem({ code: "invalid_payload", detail: "Text payload requires text or markdown.", envelope });
|
||||
}
|
||||
case "lineup.v1.agent.status": {
|
||||
const status = text(payload.status);
|
||||
return status
|
||||
? { kind: "agent-status", status, detail: text(payload.detail), ...base }
|
||||
? { kind: "agent-status", status, detail: text(payload.detail), ...(identifier(payload.execution_id) ? { execution_id: identifier(payload.execution_id)! } : {}), ...base }
|
||||
: fallbackItem({ code: "invalid_payload", detail: "Agent status payload requires status.", envelope });
|
||||
}
|
||||
case "lineup.v1.agent.execution": {
|
||||
const trace = parseExecutionTrace(payload);
|
||||
return trace ? { kind: "execution-summary", trace, ...base } : fallbackItem({ code: "invalid_payload", detail: "Agent execution requires bounded public steps.", envelope });
|
||||
}
|
||||
case "lineup.v1.agent.progress": {
|
||||
const percent = payload.percent;
|
||||
if (typeof percent !== "number" || !Number.isFinite(percent) || percent < 0 || percent > 100) {
|
||||
@@ -511,6 +608,10 @@ export function decodeConversationItem(raw: string): ConversationItem {
|
||||
const call = parseToolCall(payload);
|
||||
return call ? { kind: "tool-call", call, ...base } : fallbackItem({ code: "invalid_payload", detail: "Tool call requires call_id, supported tool, and JSON data.", envelope });
|
||||
}
|
||||
case "lineup.v1.miniapp.tool.call": {
|
||||
const call = parseMiniAppToolInvoke(payload);
|
||||
return call ? { kind: "miniapp-tool-call", call, ...base } : fallbackItem({ code: "invalid_payload", detail: "MiniApp Tool call requires call_id, tool_id, app_scope, inventory_revision, and JSON input.", envelope });
|
||||
}
|
||||
case "lineup.v1.tool.result": {
|
||||
const result = parseToolResult(payload);
|
||||
return result ? { kind: "tool-result", result, ...base } : fallbackItem({ code: "invalid_payload", detail: "Tool result requires call_id, final status, and JSON result.", envelope });
|
||||
@@ -525,6 +626,10 @@ export function decodeConversationItem(raw: string): ConversationItem {
|
||||
return validSurfacePayload(envelope.type, payload)
|
||||
? { kind: "surface", ...base }
|
||||
: fallbackItem({ code: "invalid_payload", detail: "Surface payload does not match its lifecycle contract.", envelope });
|
||||
case "lineup.v1.artifact.offer":
|
||||
return Object.keys(payload).every(key => ["artifact_id", "name", "mime_type", "size_bytes", "integrity", "created_at", "local_ref", "content_base64"].includes(key))
|
||||
? { kind: "artifact-offer", ...base }
|
||||
: fallbackItem({ code: "invalid_payload", detail: "Artifact offer contains unsupported fields.", envelope });
|
||||
case "lineup.v1.app.call":
|
||||
return validAppCallPayload(payload)
|
||||
? { kind: "app-call", ...base }
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CachedVerifiedSurfaceDocumentResolver, parseBridgeMessage, parseReadyMessage, productionSurfaceDocument, surfaceDocument } from "@/runtime/surfaces/isolated-surface-host";
|
||||
import { VerifiedSurfaceBundleCache, type SurfaceBundleDigest, type SurfaceManifestSignatureVerifier } from "@/runtime/surfaces/surface-bundle-cache";
|
||||
|
||||
describe("isolated Surface bridge contract", () => {
|
||||
it("accepts only the exact minimal ready message", () => {
|
||||
expect(parseReadyMessage({ v: 1, type: "lineup.surface.v1.ready", instance_id: "task:001" })).toEqual({ v: 1, type: "lineup.surface.v1.ready", instance_id: "task:001" });
|
||||
expect(parseReadyMessage({ v: 1, type: "lineup.surface.v1.ready", instance_id: "task:001", state: {} })).toBeUndefined();
|
||||
expect(parseReadyMessage({ v: 1, type: "lineup.surface.v1.event", instance_id: "task:001" })).toBeUndefined();
|
||||
expect(parseReadyMessage({ v: 1, type: "lineup.surface.v1.ready", instance_id: "<script>" })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("builds a host-local opaque-sandbox document with a default-deny CSP", () => {
|
||||
const document = surfaceDocument("task:001");
|
||||
expect(document).toContain("default-src 'none'");
|
||||
expect(document).toContain("connect-src 'none'");
|
||||
expect(document).toContain("base-uri 'none'");
|
||||
expect(document).toContain("lineup.surface.v1.ready");
|
||||
expect(document).not.toContain("https://");
|
||||
});
|
||||
|
||||
it("accepts the SDK camelCase reportProgress bridge method", () => {
|
||||
expect(parseBridgeMessage({ v: 1, type: "lineup.miniapp.v1.request", instance_id: "task:001", request_id: "req_1", method: "tools.reportProgress", payload: { call_id: "call-1", progress: { percent: 10 } } })).toMatchObject({ method: "tools.reportProgress" });
|
||||
});
|
||||
|
||||
it("wraps only an exact verified production bundle in the host default-deny CSP", async () => {
|
||||
// Digest mismatch behavior belongs to the cache suite. This test isolates
|
||||
// the resolver's exact-version lookup and Host-owned CSP wrapping.
|
||||
const digest: SurfaceBundleDigest = { async sha256() { return "b".repeat(64); } };
|
||||
const signatures: SurfaceManifestSignatureVerifier = { async verify() { return true; } };
|
||||
const cache = new VerifiedSurfaceBundleCache(signatures, digest);
|
||||
const bundle = new TextEncoder().encode("<main><script>window.ok=1<\/script></main>");
|
||||
await cache.install({
|
||||
v: 1, app_id: "lineup.task-dashboard", version: "1.0.0",
|
||||
artifact: { artifact_id: "dashboard-prod", sha256: "b".repeat(64), size_bytes: bundle.length },
|
||||
min_host_version: "0.1.0", permissions: ["surface.event.cancel"], key_id: "release-key", signature: "A".repeat(86),
|
||||
}, bundle);
|
||||
const resolver = new CachedVerifiedSurfaceDocumentResolver(cache);
|
||||
const document = resolver.documentFor({ instance_id: "surface:001", conversation_id: "conversation", app_id: "lineup.task-dashboard", version: "1.0.0", state: {}, phase: "opening", opened_at: "2026-08-04T00:00:00.000Z", updated_at: "2026-08-04T00:00:00.000Z" });
|
||||
expect(document).toContain("default-src 'none'");
|
||||
expect(document).toContain("window.__LINEUP_SURFACE_INSTANCE__=\"surface:001\"");
|
||||
expect(document).toContain("window.ok=1");
|
||||
expect(resolver.documentFor({ instance_id: "surface:002", conversation_id: "conversation", app_id: "lineup.task-dashboard", version: "9.9.9", state: {}, phase: "opening", opened_at: "2026-08-04T00:00:00.000Z", updated_at: "2026-08-04T00:00:00.000Z" })).toBeUndefined();
|
||||
expect(productionSurfaceDocument("surface:003", "<script src=\"https://attacker.invalid/x.js\"><\/script>")).toContain("script-src 'unsafe-inline'");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* 受限 Surface 的隔离 iframe Host。
|
||||
*
|
||||
* 只把 Host 已验证的 Bundle 文档装入 opaque-origin iframe,并通过严格 bridge 接收 ready
|
||||
* 或声明性事件;Surface 不可获得父 DOM、登录态、Tauri API 或任意网络能力。
|
||||
*/
|
||||
import type { JsonObject } from "@/runtime/protocol/lineup-v1";
|
||||
import type { SurfaceInstance } from "@/runtime/surfaces/surface-instance-manager";
|
||||
import type { VerifiedSurfaceBundleCache } from "@/runtime/surfaces/surface-bundle-cache";
|
||||
|
||||
export type SurfaceHostCallbacks = Readonly<{
|
||||
ready: (instanceID: string) => void;
|
||||
event: (instanceID: string, event: "cancel") => void;
|
||||
/** Runtime-owned MiniApp Bridge request; the iframe never receives Runtime internals. */
|
||||
request?: (instanceID: string, method: string, payload: JsonObject) => Promise<JsonObject | undefined>;
|
||||
}>;
|
||||
|
||||
/** Resolves only host-verified bytes to an iframe document; it has no bridge authority. */
|
||||
export interface VerifiedSurfaceDocumentResolver {
|
||||
documentFor(instance: SurfaceInstance): string | undefined;
|
||||
}
|
||||
|
||||
type HostedSurface = {
|
||||
readonly instance: SurfaceInstance;
|
||||
readonly frame: HTMLIFrameElement;
|
||||
ready: boolean;
|
||||
cancelSent: boolean;
|
||||
};
|
||||
|
||||
const BRIDGE_VERSION = 1;
|
||||
const READY_TYPE = "lineup.surface.v1.ready";
|
||||
const STATE_TYPE = "lineup.surface.v1.state";
|
||||
const BRIDGE_REQUEST_TYPE = "lineup.miniapp.v1.request";
|
||||
const BRIDGE_RESPONSE_TYPE = "lineup.miniapp.v1.response";
|
||||
|
||||
/**
|
||||
* M2-03 Web Reference Host. The frame is deliberately an opaque sandbox
|
||||
* origin: it receives no same-origin access to the chat page, its storage,
|
||||
* the Tauri bridge, or browser network credentials. Its only connection to
|
||||
* the parent is the narrow postMessage bridge below.
|
||||
*/
|
||||
export class IsolatedSurfaceHost {
|
||||
private readonly surfaces = new Map<string, HostedSurface>();
|
||||
|
||||
public constructor(private readonly mountPoint: HTMLElement, private readonly callbacks: SurfaceHostCallbacks, private readonly documents?: VerifiedSurfaceDocumentResolver) {
|
||||
window.addEventListener("message", this.receiveMessage);
|
||||
}
|
||||
|
||||
public mount(instance: SurfaceInstance): void {
|
||||
const current = this.surfaces.get(instance.instance_id);
|
||||
if (current) {
|
||||
current.frame.dataset.surfaceState = JSON.stringify(instance.state);
|
||||
if (current.ready) this.sendState(current, instance.state);
|
||||
return;
|
||||
}
|
||||
// Development uses the static dashboard. A production resolver can return
|
||||
// only bytes that were already signature/hash verified by M4-02; a missing
|
||||
// exact version is a safe no-op rather than a fallback to unverified code.
|
||||
const resolvedDocument = this.documents ? this.documents.documentFor(instance) : surfaceDocument(instance.instance_id, instance.app_id);
|
||||
if (!resolvedDocument) return;
|
||||
const frame = document.createElement("iframe");
|
||||
frame.className = "isolated-surface-frame";
|
||||
frame.title = `LineUp Surface ${instance.app_id}`;
|
||||
frame.setAttribute("sandbox", "allow-scripts");
|
||||
frame.setAttribute("referrerpolicy", "no-referrer");
|
||||
frame.setAttribute("aria-label", "受限扩展界面");
|
||||
frame.srcdoc = resolvedDocument;
|
||||
const hosted: HostedSurface = { instance, frame, ready: false, cancelSent: false };
|
||||
this.surfaces.set(instance.instance_id, hosted);
|
||||
this.mountPoint.append(frame);
|
||||
}
|
||||
|
||||
public update(instance: SurfaceInstance): void {
|
||||
const hosted = this.surfaces.get(instance.instance_id);
|
||||
if (!hosted || hosted.instance.app_id !== instance.app_id || hosted.instance.version !== instance.version) return;
|
||||
if (hosted.ready) this.sendState(hosted, instance.state);
|
||||
}
|
||||
|
||||
public unmount(instanceID: string): void {
|
||||
const hosted = this.surfaces.get(instanceID);
|
||||
if (!hosted) return;
|
||||
this.surfaces.delete(instanceID);
|
||||
hosted.frame.remove();
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
window.removeEventListener("message", this.receiveMessage);
|
||||
for (const instanceID of [...this.surfaces.keys()]) this.unmount(instanceID);
|
||||
}
|
||||
|
||||
private readonly receiveMessage = (event: MessageEvent<unknown>): void => {
|
||||
if (event.data && typeof event.data === "object") {
|
||||
const candidate = event.data as { type?: unknown; instance_id?: unknown; method?: unknown };
|
||||
if (candidate.type === BRIDGE_REQUEST_TYPE || candidate.type === READY_TYPE) {
|
||||
console.info("[LineUp Surface] bridge.raw", { type: String(candidate.type), instance_id: String(candidate.instance_id ?? ""), method: typeof candidate.method === "string" ? candidate.method : "" });
|
||||
}
|
||||
}
|
||||
const message = parseBridgeMessage(event.data);
|
||||
if (!message) return;
|
||||
const hosted = this.surfaces.get(message.instance_id);
|
||||
// An opaque sandbox needs targetOrigin="*" to receive a postMessage, so
|
||||
// the source Window identity and instance id are mandatory checks here.
|
||||
if (!hosted || event.source !== hosted.frame.contentWindow) return;
|
||||
if (message.type === READY_TYPE) {
|
||||
if (hosted.ready) return;
|
||||
hosted.ready = true;
|
||||
this.sendState(hosted, hosted.instance.state);
|
||||
this.callbacks.ready(hosted.instance.instance_id);
|
||||
return;
|
||||
}
|
||||
if (message.type === BRIDGE_REQUEST_TYPE) {
|
||||
if (!hosted.ready || !this.callbacks.request) return;
|
||||
console.info("[LineUp Surface] bridge.request", { instance_id: message.instance_id, method: message.method });
|
||||
void this.callbacks.request(hosted.instance.instance_id, message.method, message.payload)
|
||||
.then(result => {
|
||||
console.info("[LineUp Surface] bridge.response", { instance_id: message.instance_id, method: message.method, disposition: typeof result?.disposition === "string" ? result.disposition : "data" });
|
||||
this.sendResponse(hosted, message.request_id, result);
|
||||
})
|
||||
.catch(error => {
|
||||
const reason = error instanceof Error ? error.message : "bridge_request_failed";
|
||||
console.info("[LineUp Surface] bridge.error", { instance_id: message.instance_id, method: message.method, reason });
|
||||
this.sendError(hosted, message.request_id, reason);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!hosted.ready || hosted.cancelSent) return;
|
||||
hosted.cancelSent = true;
|
||||
this.callbacks.event(hosted.instance.instance_id, "cancel");
|
||||
};
|
||||
|
||||
private sendState(hosted: HostedSurface, state: JsonObject): void {
|
||||
// The sandbox has an opaque origin; Window identity was captured at mount
|
||||
// and all incoming traffic is checked above. No privileged data is sent.
|
||||
hosted.frame.contentWindow?.postMessage({ v: BRIDGE_VERSION, type: STATE_TYPE, instance_id: hosted.instance.instance_id, state }, "*");
|
||||
}
|
||||
|
||||
private sendResponse(hosted: HostedSurface, requestID: string, result: JsonObject | undefined): void {
|
||||
hosted.frame.contentWindow?.postMessage({ v: BRIDGE_VERSION, type: BRIDGE_RESPONSE_TYPE, instance_id: hosted.instance.instance_id, request_id: requestID, ok: true, result: result ?? {} }, "*");
|
||||
}
|
||||
|
||||
private sendError(hosted: HostedSurface, requestID: string, error: string): void {
|
||||
hosted.frame.contentWindow?.postMessage({ v: BRIDGE_VERSION, type: BRIDGE_RESPONSE_TYPE, instance_id: hosted.instance.instance_id, request_id: requestID, ok: false, error: error.slice(0, 200) }, "*");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an exact, verified bundle into a host-CSP-wrapped opaque document.
|
||||
* The bundle itself is untrusted code: it gets no origin, network, navigation,
|
||||
* forms or privileged bridge. A bundle-provided CSP can only further restrict
|
||||
* this host policy, never relax it.
|
||||
*/
|
||||
export class CachedVerifiedSurfaceDocumentResolver implements VerifiedSurfaceDocumentResolver {
|
||||
public constructor(private readonly cache: VerifiedSurfaceBundleCache) {}
|
||||
|
||||
public documentFor(instance: SurfaceInstance): string | undefined {
|
||||
const entry = this.cache.bundle(instance.app_id, instance.version);
|
||||
if (!entry || !entry.bytes.byteLength) return undefined;
|
||||
try {
|
||||
const source = new TextDecoder("utf-8", { fatal: true }).decode(entry.bytes);
|
||||
return productionSurfaceDocument(instance.instance_id, source);
|
||||
} catch { return undefined; }
|
||||
}
|
||||
}
|
||||
|
||||
type SurfaceReadyMessage = Readonly<{ v: 1; type: typeof READY_TYPE; instance_id: string }>;
|
||||
type SurfaceEventMessage = Readonly<{ v: 1; type: "lineup.surface.v1.event"; instance_id: string; event: "cancel" }>;
|
||||
type BridgeRequestMessage = Readonly<{ v: 1; type: typeof BRIDGE_REQUEST_TYPE; instance_id: string; request_id: string; method: string; payload: JsonObject }>;
|
||||
|
||||
export function parseReadyMessage(value: unknown): SurfaceReadyMessage | undefined {
|
||||
const parsed = parseBridgeMessage(value);
|
||||
return parsed?.type === READY_TYPE ? parsed : undefined;
|
||||
}
|
||||
|
||||
export function parseBridgeMessage(value: unknown): SurfaceReadyMessage | SurfaceEventMessage | BridgeRequestMessage | undefined {
|
||||
if (!isPlainObject(value) || value.v !== BRIDGE_VERSION || (value.type !== READY_TYPE && value.type !== "lineup.surface.v1.event" && value.type !== BRIDGE_REQUEST_TYPE) || typeof value.instance_id !== "string"
|
||||
|| !/^[A-Za-z0-9._:-]{1,128}$/.test(value.instance_id)) return undefined;
|
||||
if (value.type === READY_TYPE && Object.keys(value).every(key => key === "v" || key === "type" || key === "instance_id")) return { v: 1, type: READY_TYPE, instance_id: value.instance_id };
|
||||
if (value.type === "lineup.surface.v1.event" && value.event === "cancel" && Object.keys(value).every(key => key === "v" || key === "type" || key === "instance_id" || key === "event")) return { v: 1, type: "lineup.surface.v1.event", instance_id: value.instance_id, event: "cancel" };
|
||||
if (value.type === BRIDGE_REQUEST_TYPE && typeof value.request_id === "string" && /^[A-Za-z0-9._:-]{1,128}$/.test(value.request_id)
|
||||
&& typeof value.method === "string" && /^[a-z][A-Za-z0-9._-]{0,63}$/.test(value.method) && isPlainObject(value.payload)
|
||||
&& Object.keys(value).every(key => key === "v" || key === "type" || key === "instance_id" || key === "request_id" || key === "method" || key === "payload")) {
|
||||
return { v: 1, type: BRIDGE_REQUEST_TYPE, instance_id: value.instance_id, request_id: value.request_id, method: value.method, payload: cloneObject(value.payload) };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Development bundles for the two shipped bundled MiniApps. The business
|
||||
* state and DOM live in this opaque iframe; the parent only exposes the
|
||||
* Runtime-owned request bridge above.
|
||||
*/
|
||||
export function surfaceDocument(instanceID: string, appID = "lineup.task-dashboard"): string {
|
||||
const safeID = JSON.stringify(instanceID);
|
||||
const isWhiteboard = appID === "lineup.whiteboard";
|
||||
const title = isWhiteboard ? "Whiteboard" : "任务面板";
|
||||
const appScript = isWhiteboard ? whiteboardSurfaceScript() : taskDashboardSurfaceScript();
|
||||
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="referrer" content="no-referrer"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; connect-src 'none'; img-src data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-src 'none'; object-src 'none'; navigate-to 'none'"><style>html,body{margin:0;background:#15231c;color:#e7f0eb;font:14px system-ui,sans-serif}main{padding:12px;display:grid;gap:10px}h2,p{margin:0}progress{width:100%}ul{margin:0;padding-left:20px}#logs{max-height:64px;overflow:auto;color:#b8cfc0;font-size:12px}button{justify-self:start;border:0;border-radius:7px;padding:8px 10px;background:#a9dfc4;color:#102018;font-weight:700}textarea{min-height:4rem;background:#0e1713;color:#eff7f2;border:1px solid #456253;border-radius:7px;padding:7px}</style></head><body><main><h2 id="title">${title}</h2><progress id="progress" max="100"></progress><p id="status">等待状态</p><textarea id="editor" aria-label="小程序业务输入"></textarea><ul id="steps"></ul><div id="logs" aria-label="日志摘要"></div><button id="cancel" type="button">请求取消</button></main><script>"use strict";const instanceId=${safeID};${bridgeScript()}${appScript}</script></body></html>`;
|
||||
}
|
||||
|
||||
function bridgeScript(): string {
|
||||
return `const pending=new Map;let sequence=0;function bridge(method,payload={}){const request_id="req_"+(++sequence);return new Promise((resolve,reject)=>{pending.set(request_id,{resolve,reject});parent.postMessage({v:1,type:"lineup.miniapp.v1.request",instance_id:instanceId,request_id,method,payload},"*");setTimeout(()=>{const item=pending.get(request_id);if(item){pending.delete(request_id);item.reject(new Error("bridge_timeout"));}},10000)})}window.addEventListener("message",event=>{if(event.source!==parent)return;const d=event.data;if(!d||d.v!==1||d.instance_id!==instanceId)return;if(d.type==="lineup.miniapp.v1.response"&&typeof d.request_id==="string"){const item=pending.get(d.request_id);if(!item)return;pending.delete(d.request_id);d.ok?item.resolve(d.result||{}):item.reject(new Error(typeof d.error==="string"?d.error:"bridge_rejected"));return}if(d.type!=="lineup.surface.v1.state"||!d.state||typeof d.state!=="object"||Array.isArray(d.state))return;renderState(d.state)});function renderState(s){const title=document.getElementById("title"),progress=document.getElementById("progress"),status=document.getElementById("status"),steps=document.getElementById("steps");if(typeof s.title==="string")title.textContent=s.title;if(typeof s.percent==="number")progress.value=Math.max(0,Math.min(100,s.percent));if(typeof s.status==="string")status.textContent=s.status;steps.replaceChildren(...(Array.isArray(s.steps)?s.steps.slice(0,12).map(x=>{const n=document.createElement("li");n.textContent=typeof x==="string"?x:"";return n}):[]));}parent.postMessage({v:1,type:"lineup.surface.v1.ready",instance_id:instanceId},"*");`;
|
||||
}
|
||||
|
||||
function taskDashboardSurfaceScript(): string {
|
||||
return `const handled=new Set;const tasks=new Map;const editor=document.getElementById("editor");document.getElementById("cancel").onclick=()=>bridge("lifecycle.close",{reason:"用户关闭任务面板"});async function poll(){try{const result=await bridge("tools.list");for(const call of Array.isArray(result.calls)?result.calls:[]){if(handled.has(call.call_id)||!(call.status==="waiting_for_app"||call.status==="running"))continue;handled.add(call.call_id);await handleTask(call)}}catch(_){}}async function handleTask(call){try{await bridge("tools.reportProgress",{call_id:call.call_id,progress:{status:"running",percent:10}});if(call.tool_id==="task-dashboard.open"){const title=typeof call.input?.title==="string"?call.input.title:"未命名任务";const task_id=typeof call.input?.task_id==="string"?call.input.task_id:"task-"+call.call_id;tasks.set(task_id,{title,status:"open"});await bridge("surface.patch",{data:{state:{title:"任务面板",status:"已创建:"+title,percent:100,steps:[...tasks.values()].map(x=>x.title)}}});await bridge("tools.complete",{call_id:call.call_id,result:{status:"completed",task_id}})}else if(call.tool_id==="task-dashboard.update"){const task_id=String(call.input?.task_id||("task-"+call.call_id));const task={...(tasks.get(task_id)||{}),...(typeof call.input?.status==="string"?{status:call.input.status}:{}),...(typeof call.input?.progress==="number"?{progress:call.input.progress}: {})};tasks.set(task_id,task);await bridge("tools.complete",{call_id:call.call_id,result:{task_id,task}})}else await bridge("tools.fail",{call_id:call.call_id,code:"tool_not_supported",message:"不支持的任务工具"})}catch(_){await bridge("tools.fail",{call_id:call.call_id,code:"app_bridge_failed",message:"任务面板处理失败"}).catch(()=>{})}}setInterval(poll,500);poll();`;
|
||||
}
|
||||
|
||||
function whiteboardSurfaceScript(): string {
|
||||
return `const handled=new Set;let board={title:"Whiteboard",status:"可编辑",elements:[]};const editor=document.getElementById("editor");document.getElementById("cancel").onclick=()=>bridge("lifecycle.close",{reason:"用户关闭画板"});editor.addEventListener("input",()=>{board={...board,text:editor.value};bridge("surface.patch",{data:{state:board}}).catch(()=>{})});async function poll(){try{const result=await bridge("tools.list");for(const call of Array.isArray(result.calls)?result.calls:[]){if(handled.has(call.call_id)||!(call.status==="waiting_for_app"||call.status==="running"))continue;handled.add(call.call_id);await handleBoard(call)}}catch(_){}}async function handleBoard(call){try{if(call.tool_id==="whiteboard.open"){await bridge("tools.reportProgress",{call_id:call.call_id,progress:{status:"opening",percent:40}});await bridge("tools.complete",{call_id:call.call_id,result:{status:"completed"}})}else if(call.tool_id==="whiteboard.submit"){const artifact_id=typeof call.input?.artifact_id==="string"?call.input.artifact_id:"whiteboard-"+call.call_id;await bridge("tools.reportProgress",{call_id:call.call_id,progress:{status:"submitting",percent:80}});await bridge("surface.patch",{data:{state:{...board,artifact:{artifact_id}}}});await bridge("tools.complete",{call_id:call.call_id,result:{artifact_id}})}else await bridge("tools.fail",{call_id:call.call_id,code:"tool_not_supported",message:"不支持的画板工具"})}catch(_){await bridge("tools.fail",{call_id:call.call_id,code:"app_bridge_failed",message:"画板处理失败"}).catch(()=>{})}}setInterval(poll,500);poll();`;
|
||||
}
|
||||
|
||||
export function productionSurfaceDocument(instanceID: string, verifiedBundle: string): string {
|
||||
const safeID = JSON.stringify(instanceID);
|
||||
const csp = "default-src 'none'; connect-src 'none'; img-src data: blob:; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-src 'none'; object-src 'none'; navigate-to 'none'";
|
||||
// The content is an already signature/hash-verified artifact, never an
|
||||
// Envelope/source patch. Its own CSP is additive; this one remains in force.
|
||||
return `<!doctype html><meta charset="utf-8"><meta name="referrer" content="no-referrer"><meta http-equiv="Content-Security-Policy" content="${csp}"><script>"use strict";window.__LINEUP_SURFACE_INSTANCE__=${safeID};</script>${verifiedBundle}`;
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
|
||||
function cloneObject(value: Record<string, unknown>): JsonObject {
|
||||
return JSON.parse(JSON.stringify(value)) as JsonObject;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { canonicalManifestPayload, compareSemver, parseProductionSurfaceManifest, ProductionSurfaceManifestRegistry } from "@/runtime/surfaces/production-surface-manifest";
|
||||
|
||||
const manifest = {
|
||||
v: 1, app_id: "lineup.task-dashboard", version: "1.2.0",
|
||||
artifact: { artifact_id: "task-dashboard-1.2.0", sha256: "a".repeat(64), size_bytes: 4096 },
|
||||
min_host_version: "0.2.0", permissions: ["surface.event.cancel"], key_id: "lineup-release-2026", signature: "A".repeat(86),
|
||||
};
|
||||
|
||||
describe("ProductionSurfaceManifest", () => {
|
||||
it("admits only an immutable, signed-shape allowlisted manifest", () => {
|
||||
const registry = new ProductionSurfaceManifestRegistry({ host_version: "0.3.0", trusted_key_ids: ["lineup-release-2026"] });
|
||||
expect(registry.admit(manifest)).toMatchObject({ disposition: "accepted", manifest: { app_id: "lineup.task-dashboard" } });
|
||||
expect(registry.admit(manifest)).toMatchObject({ disposition: "duplicate" });
|
||||
expect(registry.admit({ ...manifest, artifact: { ...manifest.artifact, sha256: "b".repeat(64) } })).toEqual({ disposition: "invalid_manifest" });
|
||||
const copy = registry.get("lineup.task-dashboard", "1.2.0")!;
|
||||
(copy.artifact as { sha256: string }).sha256 = "c".repeat(64);
|
||||
expect(registry.get("lineup.task-dashboard", "1.2.0")?.artifact.sha256).toBe("a".repeat(64));
|
||||
});
|
||||
|
||||
it("rejects unknown fields, untrusted keys, unsupported hosts and undeclared permissions", () => {
|
||||
expect(parseProductionSurfaceManifest({ ...manifest, url: "https://attacker.invalid/bundle" })).toBeUndefined();
|
||||
expect(parseProductionSurfaceManifest({ ...manifest, permissions: ["device.pick_file"] })).toBeUndefined();
|
||||
expect(new ProductionSurfaceManifestRegistry({ host_version: "0.3.0", trusted_key_ids: [] }).admit(manifest)).toEqual({ disposition: "untrusted_key" });
|
||||
expect(new ProductionSurfaceManifestRegistry({ host_version: "0.1.0", trusted_key_ids: [manifest.key_id] }).admit(manifest)).toEqual({ disposition: "unsupported_host" });
|
||||
});
|
||||
|
||||
it("uses one stable signed payload and semver ordering across hosts", () => {
|
||||
expect(canonicalManifestPayload(parseProductionSurfaceManifest(manifest)!)).toBe('{"app_id":"lineup.task-dashboard","artifact":{"artifact_id":"task-dashboard-1.2.0","sha256":"' + "a".repeat(64) + '","size_bytes":4096},"key_id":"lineup-release-2026","min_host_version":"0.2.0","permissions":["surface.event.cancel"],"v":1,"version":"1.2.0"}');
|
||||
expect(compareSemver("1.0.0", "1.0.0")).toBe(0);
|
||||
expect(compareSemver("1.0.0", "1.0.0-beta.1")).toBe(1);
|
||||
expect(compareSemver("1.0.0-beta.1", "1.0.0")).toBe(-1);
|
||||
expect(compareSemver("1.2.0", "1.10.0")).toBe(-1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* 生产 Surface Manifest 的严格声明与解析。
|
||||
*
|
||||
* Manifest 仅描述不可变元数据,不能携带 Bundle URL 或可执行源码;下载、签名校验和缓存
|
||||
* 安装必须在后续独立步骤完成,避免 Agent/消息直接注入界面代码。
|
||||
*
|
||||
* M4 production Surface declaration. A manifest is only immutable metadata;
|
||||
* it contains neither a bundle URL nor executable source. Download, signature
|
||||
* verification and cache installation are deliberately separate M4-02 steps.
|
||||
*/
|
||||
export type SurfacePermission = "surface.event.cancel";
|
||||
|
||||
export type ProductionSurfaceManifest = Readonly<{
|
||||
v: 1;
|
||||
app_id: string;
|
||||
version: string;
|
||||
artifact: Readonly<{
|
||||
artifact_id: string;
|
||||
sha256: string;
|
||||
size_bytes: number;
|
||||
}>;
|
||||
min_host_version: string;
|
||||
permissions: readonly SurfacePermission[];
|
||||
key_id: string;
|
||||
/** Base64url Ed25519 signature of canonicalManifestPayload(manifest). */
|
||||
signature: string;
|
||||
}>;
|
||||
|
||||
export type ProductionManifestDisposition =
|
||||
| "accepted"
|
||||
| "invalid_manifest"
|
||||
| "unsupported_host"
|
||||
| "untrusted_key"
|
||||
| "duplicate";
|
||||
|
||||
export type ProductionManifestResult = Readonly<{
|
||||
disposition: ProductionManifestDisposition;
|
||||
manifest?: ProductionSurfaceManifest;
|
||||
}>;
|
||||
|
||||
const APP_ID = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/;
|
||||
const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
||||
const ARTIFACT_ID = /^[A-Za-z][A-Za-z0-9_.:-]{0,127}$/;
|
||||
const KEY_ID = /^[A-Za-z][A-Za-z0-9_.:-]{0,127}$/;
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
const BASE64URL_SIGNATURE = /^[A-Za-z0-9_-]{86}$/;
|
||||
const PERMISSIONS = new Set<SurfacePermission>(["surface.event.cancel"]);
|
||||
const MAX_BUNDLE_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Host-owned allowlist for production manifests. Remote messages cannot add a
|
||||
* key, weaken the minimum host version, mutate a previously accepted artifact
|
||||
* or cause a bundle to run; M4-02 must verify the signature and digest before
|
||||
* a cached bundle is made active.
|
||||
*/
|
||||
export class ProductionSurfaceManifestRegistry {
|
||||
private readonly manifests = new Map<string, ProductionSurfaceManifest>();
|
||||
private readonly trustedKeys: ReadonlySet<string>;
|
||||
|
||||
public constructor(options: Readonly<{ host_version: string; trusted_key_ids: readonly string[] }>) {
|
||||
if (!validVersion(options.host_version)) throw new Error("host_version must be semver.");
|
||||
this.hostVersion = options.host_version;
|
||||
this.trustedKeys = new Set(options.trusted_key_ids.filter(validKeyID));
|
||||
}
|
||||
|
||||
private readonly hostVersion: string;
|
||||
|
||||
public admit(value: unknown): ProductionManifestResult {
|
||||
const parsed = parseProductionSurfaceManifest(value);
|
||||
if (!parsed) return { disposition: "invalid_manifest" };
|
||||
if (!this.trustedKeys.has(parsed.key_id)) return { disposition: "untrusted_key" };
|
||||
if (compareSemver(this.hostVersion, parsed.min_host_version) < 0) return { disposition: "unsupported_host" };
|
||||
const key = manifestKey(parsed.app_id, parsed.version);
|
||||
const existing = this.manifests.get(key);
|
||||
if (existing) return sameManifest(existing, parsed)
|
||||
? { disposition: "duplicate", manifest: copyManifest(existing) }
|
||||
: { disposition: "invalid_manifest" };
|
||||
const immutable = freezeManifest(parsed);
|
||||
this.manifests.set(key, immutable);
|
||||
return { disposition: "accepted", manifest: copyManifest(immutable) };
|
||||
}
|
||||
|
||||
public get(appID: string, version: string): ProductionSurfaceManifest | undefined {
|
||||
const manifest = this.manifests.get(manifestKey(appID, version));
|
||||
return manifest && copyManifest(manifest);
|
||||
}
|
||||
|
||||
public snapshot(): readonly ProductionSurfaceManifest[] {
|
||||
return [...this.manifests.values()].sort((left, right) => manifestKey(left.app_id, left.version).localeCompare(manifestKey(right.app_id, right.version))).map(copyManifest);
|
||||
}
|
||||
}
|
||||
|
||||
/** Strict parse used before signature verification and any cache/network work. */
|
||||
export function parseProductionSurfaceManifest(value: unknown): ProductionSurfaceManifest | undefined {
|
||||
if (!plainObject(value)) return undefined;
|
||||
const keys = ["v", "app_id", "version", "artifact", "min_host_version", "permissions", "key_id", "signature"];
|
||||
if (Object.keys(value).length !== keys.length || Object.keys(value).some(key => !keys.includes(key))) return undefined;
|
||||
if (value.v !== 1 || !validAppID(value.app_id) || !validVersion(value.version) || !validVersion(value.min_host_version)
|
||||
|| !validKeyID(value.key_id) || typeof value.signature !== "string" || !BASE64URL_SIGNATURE.test(value.signature)
|
||||
|| !Array.isArray(value.permissions) || value.permissions.length < 1 || value.permissions.length > PERMISSIONS.size
|
||||
|| new Set(value.permissions).size !== value.permissions.length || value.permissions.some(permission => typeof permission !== "string" || !PERMISSIONS.has(permission as SurfacePermission))
|
||||
|| !plainObject(value.artifact) || Object.keys(value.artifact).length !== 3 || Object.keys(value.artifact).some(key => !["artifact_id", "sha256", "size_bytes"].includes(key))) return undefined;
|
||||
const artifact = value.artifact as Record<string, unknown>;
|
||||
if (typeof artifact.artifact_id !== "string" || !ARTIFACT_ID.test(artifact.artifact_id)
|
||||
|| typeof artifact.sha256 !== "string" || !SHA256.test(artifact.sha256)
|
||||
|| typeof artifact.size_bytes !== "number" || !Number.isSafeInteger(artifact.size_bytes) || artifact.size_bytes < 1 || artifact.size_bytes > MAX_BUNDLE_BYTES) return undefined;
|
||||
return {
|
||||
v: 1, app_id: value.app_id, version: value.version,
|
||||
artifact: { artifact_id: artifact.artifact_id, sha256: artifact.sha256, size_bytes: artifact.size_bytes },
|
||||
min_host_version: value.min_host_version,
|
||||
permissions: [...value.permissions] as SurfacePermission[],
|
||||
key_id: value.key_id, signature: value.signature,
|
||||
};
|
||||
}
|
||||
|
||||
/** Stable UTF-8 payload used by every Host before Ed25519 verification. */
|
||||
export function canonicalManifestPayload(manifest: ProductionSurfaceManifest): string {
|
||||
return JSON.stringify({
|
||||
app_id: manifest.app_id,
|
||||
artifact: { artifact_id: manifest.artifact.artifact_id, sha256: manifest.artifact.sha256, size_bytes: manifest.artifact.size_bytes },
|
||||
key_id: manifest.key_id,
|
||||
min_host_version: manifest.min_host_version,
|
||||
permissions: [...manifest.permissions],
|
||||
v: manifest.v,
|
||||
version: manifest.version,
|
||||
});
|
||||
}
|
||||
|
||||
export function compareSemver(left: string, right: string): number {
|
||||
const leftCore = left.split(/[+-]/, 1)[0].split(".").map(Number);
|
||||
const rightCore = right.split(/[+-]/, 1)[0].split(".").map(Number);
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
if (leftCore[index] !== rightCore[index]) return leftCore[index] > rightCore[index] ? 1 : -1;
|
||||
}
|
||||
const leftPre = left.includes("-") ? left.slice(left.indexOf("-") + 1).split("+", 1)[0] : undefined;
|
||||
const rightPre = right.includes("-") ? right.slice(right.indexOf("-") + 1).split("+", 1)[0] : undefined;
|
||||
if (!leftPre && !rightPre) return 0;
|
||||
if (!leftPre) return 1;
|
||||
if (!rightPre) return -1;
|
||||
return leftPre === rightPre ? 0 : leftPre.localeCompare(rightPre);
|
||||
}
|
||||
|
||||
function validAppID(value: unknown): value is string { return typeof value === "string" && value.length <= 96 && APP_ID.test(value); }
|
||||
function validVersion(value: unknown): value is string { return typeof value === "string" && value.length <= 64 && VERSION.test(value); }
|
||||
function validKeyID(value: unknown): value is string { return typeof value === "string" && KEY_ID.test(value); }
|
||||
function manifestKey(appID: string, version: string): string { return `${appID}@${version}`; }
|
||||
function plainObject(value: unknown): value is Record<string, unknown> { return value !== null && typeof value === "object" && !Array.isArray(value) && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null); }
|
||||
function copyManifest(manifest: ProductionSurfaceManifest): ProductionSurfaceManifest { return { ...manifest, artifact: { ...manifest.artifact }, permissions: [...manifest.permissions] }; }
|
||||
function freezeManifest(manifest: ProductionSurfaceManifest): ProductionSurfaceManifest { return Object.freeze({ ...manifest, artifact: Object.freeze({ ...manifest.artifact }), permissions: Object.freeze([...manifest.permissions]) }); }
|
||||
function sameManifest(left: ProductionSurfaceManifest, right: ProductionSurfaceManifest): boolean { return canonicalManifestPayload(left) === canonicalManifestPayload(right) && left.signature === right.signature; }
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ProductionSurfaceManifestRegistry } from "@/runtime/surfaces/production-surface-manifest";
|
||||
import { ProductionSurfacePolicy } from "@/runtime/surfaces/production-surface-policy";
|
||||
import { SurfaceInstanceManager } from "@/runtime/surfaces/surface-instance-manager";
|
||||
import { TrustedSurfaceBundleDownloader, VerifiedSurfaceBundleCache, type SurfaceBundleDigest, type SurfaceManifestSignatureVerifier } from "@/runtime/surfaces/surface-bundle-cache";
|
||||
|
||||
const bytes = new TextEncoder().encode("<main>verified</main>");
|
||||
const digest: SurfaceBundleDigest = { async sha256() { return "a".repeat(64); } };
|
||||
const signatures: SurfaceManifestSignatureVerifier = { async verify() { return true; } };
|
||||
const candidate = {
|
||||
v: 1, app_id: "lineup.production-dashboard", version: "1.0.0",
|
||||
artifact: { artifact_id: "dashboard-1", sha256: "a".repeat(64), size_bytes: bytes.byteLength },
|
||||
min_host_version: "0.1.0", permissions: ["surface.event.cancel"], key_id: "release-key", signature: "A".repeat(86),
|
||||
};
|
||||
|
||||
describe("ProductionSurfacePolicy", () => {
|
||||
it("admits an instance only after an exact signed/verified bundle exists", async () => {
|
||||
const cache = new VerifiedSurfaceBundleCache(signatures, digest);
|
||||
const policy = new ProductionSurfacePolicy(new ProductionSurfaceManifestRegistry({ host_version: "0.1.0", trusted_key_ids: ["release-key"] }), cache);
|
||||
const manager = new SurfaceInstanceManager(policy);
|
||||
const request = { instance_id: "prod:001", app: { app_id: candidate.app_id, version: candidate.version }, state: { title: "安全运行" } };
|
||||
expect(manager.open(request, "conversation", "2026-08-04T00:00:00.000Z")).toMatchObject({ disposition: "unknown_app" });
|
||||
const downloader = new TrustedSurfaceBundleDownloader("https://bundles.lineup.example/releases", async () => ({ ok: true, url: "https://bundles.lineup.example/releases/artifacts/dashboard-1.bundle", async bytes() { return bytes; } }));
|
||||
await expect(policy.downloadAndEnable(candidate, downloader)).resolves.toEqual({ disposition: "installed" });
|
||||
expect(manager.open(request, "conversation", "2026-08-04T00:00:00.000Z")).toMatchObject({ disposition: "accepted", instance: { app_id: candidate.app_id, version: candidate.version } });
|
||||
expect(manager.open({ ...request, instance_id: "prod:injected", html: "<script>evil()</script>" }, "conversation", "2026-08-04T00:00:00.000Z")).toMatchObject({ disposition: "bundle_override_forbidden" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* 生产 Surface 的接纳策略。
|
||||
*
|
||||
* 只有签名、版本、完整性和本地缓存均已验证的精确 app/version 才能创建实例;策略层不执行
|
||||
* DOM 或下载副作用,只返回可审计的接纳/拒绝结果。
|
||||
*/
|
||||
import type { JsonObject } from "@/runtime/protocol/lineup-v1";
|
||||
import { parseProductionSurfaceManifest, ProductionSurfaceManifestRegistry, type ProductionManifestDisposition } from "@/runtime/surfaces/production-surface-manifest";
|
||||
import { TrustedSurfaceBundleDownloader, VerifiedSurfaceBundleCache } from "@/runtime/surfaces/surface-bundle-cache";
|
||||
import type { SurfaceAdmissionPolicy, SurfaceRegistryDisposition, SurfaceRegistryResult } from "@/runtime/surfaces/surface-registry";
|
||||
|
||||
export type ProductionSurfacePreparation = Readonly<{
|
||||
disposition: ProductionManifestDisposition | "installed" | "download_failed" | "signature_invalid" | "size_mismatch" | "digest_mismatch" | "invalid_manifest";
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Host-owned production admission bridge. An app/version becomes openable
|
||||
* only after its manifest is locally allowed *and* exact verified bytes exist
|
||||
* in cache. It intentionally exposes no bundle bytes or remote URLs.
|
||||
*/
|
||||
export class ProductionSurfacePolicy implements SurfaceAdmissionPolicy {
|
||||
private readonly enabled = new Set<string>();
|
||||
|
||||
public constructor(
|
||||
private readonly manifests: ProductionSurfaceManifestRegistry,
|
||||
private readonly cache: VerifiedSurfaceBundleCache,
|
||||
private readonly maxStateBytes = 64 * 1024,
|
||||
) {}
|
||||
|
||||
public async downloadAndEnable(candidate: unknown, downloader: TrustedSurfaceBundleDownloader): Promise<ProductionSurfacePreparation> {
|
||||
const admitted = this.manifests.admit(candidate);
|
||||
if (admitted.disposition !== "accepted" && admitted.disposition !== "duplicate") return { disposition: admitted.disposition };
|
||||
const parsed = parseProductionSurfaceManifest(candidate);
|
||||
if (!parsed) return { disposition: "invalid_manifest" };
|
||||
const installed = await this.cache.downloadAndInstall(parsed, downloader);
|
||||
if ((installed.disposition === "installed" || installed.disposition === "duplicate") && this.cache.bundle(parsed.app_id, parsed.version)) {
|
||||
this.enabled.add(key(parsed.app_id, parsed.version));
|
||||
return { disposition: "installed" };
|
||||
}
|
||||
return { disposition: installed.disposition };
|
||||
}
|
||||
|
||||
public validateOpenRequest(payload: unknown): SurfaceRegistryResult {
|
||||
if (!plainObject(payload) || hasSource(payload) || !plainObject(payload.app) || typeof payload.instance_id !== "string" || !/^[A-Za-z0-9._:-]{1,128}$/.test(payload.instance_id)
|
||||
|| typeof payload.app.app_id !== "string" || typeof payload.app.version !== "string" || (payload.state !== undefined && !plainObject(payload.state))) return { disposition: "invalid_state" };
|
||||
return this.validateState(payload.app.app_id, payload.app.version, payload.state ?? {});
|
||||
}
|
||||
|
||||
public validateState(appID: string, version: string, state: unknown): SurfaceRegistryResult {
|
||||
const manifest = this.manifests.get(appID, version);
|
||||
if (!manifest) return { disposition: "unknown_app" };
|
||||
if (!this.enabled.has(key(appID, version)) || !this.cache.bundle(appID, version)) return { disposition: "disabled" };
|
||||
if (!plainObject(state)) return { disposition: "invalid_state" };
|
||||
try {
|
||||
return new TextEncoder().encode(JSON.stringify(state as JsonObject)).byteLength <= this.maxStateBytes
|
||||
? { disposition: "accepted" }
|
||||
: { disposition: "state_too_large" };
|
||||
} catch { return { disposition: "invalid_state" }; }
|
||||
}
|
||||
}
|
||||
|
||||
function key(appID: string, version: string): string { return `${appID}@${version}`; }
|
||||
function plainObject(value: unknown): value is Record<string, unknown> { return value !== null && typeof value === "object" && !Array.isArray(value) && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null); }
|
||||
function hasSource(value: Record<string, unknown>): boolean { return ["bundle", "bundle_id", "bundle_url", "url", "html", "css", "javascript", "js", "script", "source"].some(field => field in value); }
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TrustedSurfaceBundleDownloader, VerifiedSurfaceBundleCache, WebCryptoEd25519SurfaceManifestSignatureVerifier, type SurfaceBundleDigest, type SurfaceManifestSignatureVerifier } from "@/runtime/surfaces/surface-bundle-cache";
|
||||
|
||||
const bytes = new Uint8Array([1, 2, 3, 4]);
|
||||
const digest: SurfaceBundleDigest = { async sha256(value) { return value.join("").padEnd(64, "0"); } };
|
||||
const signatures: SurfaceManifestSignatureVerifier = { async verify(_payload, signature, keyID) { return signature === "A".repeat(86) && keyID === "release-key"; } };
|
||||
function manifest(version: string, artifactID = `task-${version}`) {
|
||||
return { v: 1, app_id: "lineup.task-dashboard", version, artifact: { artifact_id: artifactID, sha256: "1234".padEnd(64, "0"), size_bytes: 4 }, min_host_version: "0.1.0", permissions: ["surface.event.cancel"], key_id: "release-key", signature: "A".repeat(86) };
|
||||
}
|
||||
|
||||
describe("VerifiedSurfaceBundleCache", () => {
|
||||
it("performs real Ed25519 public-key verification without a client signing key", async () => {
|
||||
const pair = await crypto.subtle.generateKey({ name: "Ed25519" }, true, ["sign", "verify"]);
|
||||
const payload = new TextEncoder().encode("canonical-manifest-payload");
|
||||
const signature = new Uint8Array(await crypto.subtle.sign({ name: "Ed25519" }, pair.privateKey, payload));
|
||||
const publicKey = new Uint8Array(await crypto.subtle.exportKey("raw", pair.publicKey));
|
||||
const encode = (value: Uint8Array) => btoa(String.fromCharCode(...value)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
||||
const verifier = new WebCryptoEd25519SurfaceManifestSignatureVerifier({ "release-key": encode(publicKey) });
|
||||
await expect(verifier.verify(payload, encode(signature), "release-key")).resolves.toBe(true);
|
||||
await expect(verifier.verify(new TextEncoder().encode("tampered"), encode(signature), "release-key")).resolves.toBe(false);
|
||||
await expect(verifier.verify(payload, encode(signature), "unknown-key")).resolves.toBe(false);
|
||||
await expect(verifier.verify(payload, "not-a-signature", "release-key")).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("verifies signature, exact size and digest before atomically activating bytes", async () => {
|
||||
const cache = new VerifiedSurfaceBundleCache(signatures, digest);
|
||||
await expect(cache.install({ ...manifest("1.0.0"), signature: "B".repeat(86) }, bytes)).resolves.toEqual({ disposition: "signature_invalid" });
|
||||
await expect(cache.install(manifest("1.0.0"), new Uint8Array([1]))).resolves.toEqual({ disposition: "size_mismatch" });
|
||||
await expect(cache.install({ ...manifest("1.0.0"), artifact: { ...manifest("1.0.0").artifact, sha256: "f".repeat(64) } }, bytes)).resolves.toEqual({ disposition: "digest_mismatch" });
|
||||
expect(cache.activeBundle("lineup.task-dashboard")).toBeUndefined();
|
||||
await expect(cache.install(manifest("1.0.0"), bytes)).resolves.toMatchObject({ disposition: "installed", entry: { manifest: { version: "1.0.0" } } });
|
||||
expect(cache.activeBundle("lineup.task-dashboard")?.bytes).toEqual(bytes);
|
||||
});
|
||||
|
||||
it("keeps a verified predecessor and rolls back without re-downloading bytes", async () => {
|
||||
const cache = new VerifiedSurfaceBundleCache(signatures, digest);
|
||||
await cache.install(manifest("1.0.0"), bytes);
|
||||
await cache.install(manifest("1.1.0"), bytes);
|
||||
expect(cache.activeBundle("lineup.task-dashboard")?.manifest.version).toBe("1.1.0");
|
||||
expect(cache.rollback("lineup.task-dashboard")).toMatchObject({ disposition: "rolled_back", entry: { manifest: { version: "1.0.0" } } });
|
||||
expect(cache.activeBundle("lineup.task-dashboard")?.manifest.version).toBe("1.0.0");
|
||||
expect(cache.invalidate("lineup.task-dashboard", "task-1.0.0")).toMatchObject({ disposition: "rolled_back", entry: { manifest: { version: "1.1.0" } } });
|
||||
});
|
||||
|
||||
it("does not expose bytes through its public cache snapshot", async () => {
|
||||
const cache = new VerifiedSurfaceBundleCache(signatures, digest);
|
||||
await cache.install(manifest("1.0.0"), bytes);
|
||||
expect(cache.snapshot()).toEqual([{ app_id: "lineup.task-dashboard", version: "1.0.0", artifact_id: "task-1.0.0", active: true }]);
|
||||
expect(JSON.stringify(cache.snapshot())).not.toContain("1234");
|
||||
});
|
||||
|
||||
it("downloads only from a configured HTTPS source before installing", async () => {
|
||||
const fetched: string[] = [];
|
||||
const downloader = new TrustedSurfaceBundleDownloader("https://bundles.lineup.example/release", async url => {
|
||||
fetched.push(url.toString());
|
||||
return { ok: true, url: "https://bundles.lineup.example/release/artifacts/task-1.0.0.bundle", async bytes() { return bytes; } };
|
||||
});
|
||||
const cache = new VerifiedSurfaceBundleCache(signatures, digest);
|
||||
await expect(cache.downloadAndInstall(manifest("1.0.0"), downloader)).resolves.toMatchObject({ disposition: "installed" });
|
||||
expect(fetched).toEqual(["https://bundles.lineup.example/release/artifacts/task-1.0.0.bundle"]);
|
||||
const redirected = new TrustedSurfaceBundleDownloader("https://bundles.lineup.example/release", async () => ({ ok: true, url: "https://attacker.invalid/bundle", async bytes() { return bytes; } }));
|
||||
await expect(new VerifiedSurfaceBundleCache(signatures, digest).downloadAndInstall(manifest("1.0.0"), redirected)).resolves.toEqual({ disposition: "download_failed" });
|
||||
expect(() => new TrustedSurfaceBundleDownloader("http://bundles.lineup.example", async () => ({ ok: false, url: "", async bytes() { return bytes; } }))).toThrow("HTTPS");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Surface Bundle 的下载、验签、完整性校验与可回滚缓存。
|
||||
*
|
||||
* Bundle 只有经 Manifest、Ed25519、精确大小和 SHA-256 全部验证后才可成为 active cache;
|
||||
* 失败不会污染已有版本,并可安全回滚到此前已验证的版本。
|
||||
*/
|
||||
import { canonicalManifestPayload, parseProductionSurfaceManifest, type ProductionSurfaceManifest } from "@/runtime/surfaces/production-surface-manifest";
|
||||
|
||||
export type BundleVerificationDisposition = "installed" | "duplicate" | "invalid_manifest" | "download_failed" | "signature_invalid" | "size_mismatch" | "digest_mismatch";
|
||||
export type BundleInstallResult = Readonly<{ disposition: BundleVerificationDisposition; entry?: InstalledSurfaceBundle }>;
|
||||
export type BundleRollbackResult = Readonly<{ disposition: "rolled_back" | "unavailable"; entry?: InstalledSurfaceBundle }>;
|
||||
|
||||
export type InstalledSurfaceBundle = Readonly<{
|
||||
manifest: ProductionSurfaceManifest;
|
||||
bytes: Uint8Array;
|
||||
}>;
|
||||
|
||||
/** Implemented by every Host with its platform trust store. */
|
||||
export interface SurfaceManifestSignatureVerifier {
|
||||
verify(payload: Uint8Array, signature: string, keyID: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Production Ed25519 verifier. A Host receives only public keys, keyed by
|
||||
* the immutable manifest `key_id`; a missing/invalid key is indistinguishable
|
||||
* from a bad signature. No signing key and no remote key enrollment exists
|
||||
* in the client.
|
||||
*/
|
||||
export class WebCryptoEd25519SurfaceManifestSignatureVerifier implements SurfaceManifestSignatureVerifier {
|
||||
private readonly publicKeys: ReadonlyMap<string, Uint8Array>;
|
||||
|
||||
public constructor(trustedPublicKeys: Readonly<Record<string, string>>) {
|
||||
const keys = new Map<string, Uint8Array>();
|
||||
for (const [keyID, encoded] of Object.entries(trustedPublicKeys)) {
|
||||
const bytes = base64urlBytes(encoded);
|
||||
// Raw Ed25519 public keys are exactly 32 bytes.
|
||||
if (bytes?.byteLength === 32) keys.set(keyID, bytes);
|
||||
}
|
||||
this.publicKeys = keys;
|
||||
}
|
||||
|
||||
public async verify(payload: Uint8Array, signature: string, keyID: string): Promise<boolean> {
|
||||
const publicKey = this.publicKeys.get(keyID);
|
||||
const signatureBytes = base64urlBytes(signature);
|
||||
if (!publicKey || !signatureBytes || signatureBytes.byteLength !== 64) return false;
|
||||
try {
|
||||
const key = await crypto.subtle.importKey("raw", Uint8Array.from(publicKey).buffer, { name: "Ed25519" }, false, ["verify"]);
|
||||
return await crypto.subtle.verify({ name: "Ed25519" }, key, Uint8Array.from(signatureBytes).buffer, Uint8Array.from(payload).buffer);
|
||||
} catch { return false; }
|
||||
}
|
||||
}
|
||||
|
||||
/** SHA-256 implementation boundary; no caller may supply a precomputed digest. */
|
||||
export interface SurfaceBundleDigest {
|
||||
sha256(bytes: Uint8Array): Promise<string>;
|
||||
}
|
||||
|
||||
export type TrustedBundleDownloadResponse = Readonly<{
|
||||
ok: boolean;
|
||||
/** Final URL after redirects, used to reject an origin switch. */
|
||||
url: string;
|
||||
bytes(): Promise<Uint8Array>;
|
||||
}>;
|
||||
|
||||
export type TrustedBundleFetcher = (url: URL) => Promise<TrustedBundleDownloadResponse>;
|
||||
|
||||
/**
|
||||
* Download URL construction is Host configuration, never a field in a remote
|
||||
* manifest or ui.open envelope. HTTPS and same-origin final responses prevent
|
||||
* an artifact id from becoming a general-purpose network primitive.
|
||||
*/
|
||||
export class TrustedSurfaceBundleDownloader {
|
||||
private readonly baseURL: URL;
|
||||
|
||||
public constructor(baseURL: string, private readonly fetcher: TrustedBundleFetcher) {
|
||||
const parsed = new URL(baseURL);
|
||||
if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.search || parsed.hash) throw new Error("Bundle source must be a clean HTTPS origin/base path.");
|
||||
this.baseURL = parsed.toString().endsWith("/") ? parsed : new URL(`${parsed.toString()}/`);
|
||||
}
|
||||
|
||||
public async download(manifest: ProductionSurfaceManifest): Promise<Uint8Array | undefined> {
|
||||
const destination = new URL(`artifacts/${encodeURIComponent(manifest.artifact.artifact_id)}.bundle`, this.baseURL);
|
||||
let response: TrustedBundleDownloadResponse;
|
||||
try { response = await this.fetcher(destination); } catch { return undefined; }
|
||||
if (!response.ok) return undefined;
|
||||
try {
|
||||
const finalURL = new URL(response.url);
|
||||
if (finalURL.protocol !== "https:" || finalURL.origin !== this.baseURL.origin) return undefined;
|
||||
return new Uint8Array(await response.bytes());
|
||||
} catch { return undefined; }
|
||||
}
|
||||
}
|
||||
|
||||
/** Browser/Tauri WebCrypto implementation; Android/Wails use the same hex format. */
|
||||
export class WebCryptoSurfaceBundleDigest implements SurfaceBundleDigest {
|
||||
public async sha256(bytes: Uint8Array): Promise<string> {
|
||||
// Copy to an owned ArrayBuffer: TypeScript permits a Uint8Array backed by
|
||||
// SharedArrayBuffer, while WebCrypto's browser contract is ArrayBuffer.
|
||||
const owned = Uint8Array.from(bytes);
|
||||
const digest = await crypto.subtle.digest("SHA-256", owned.buffer);
|
||||
return [...new Uint8Array(digest)].map(value => value.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Host-owned verified cache. It accepts bytes only after manifest signature,
|
||||
* exact length and SHA-256 validation succeed. A failed candidate never
|
||||
* mutates the current active bundle, making rollback an atomic pointer swap.
|
||||
* Bundle bytes are intentionally inaccessible to conversation/protocol code.
|
||||
*/
|
||||
export class VerifiedSurfaceBundleCache {
|
||||
private readonly entries = new Map<string, InstalledSurfaceBundle>();
|
||||
private readonly active = new Map<string, string>();
|
||||
private readonly previous = new Map<string, string>();
|
||||
|
||||
public constructor(
|
||||
private readonly signatureVerifier: SurfaceManifestSignatureVerifier,
|
||||
private readonly digest: SurfaceBundleDigest = new WebCryptoSurfaceBundleDigest(),
|
||||
) {}
|
||||
|
||||
public async install(candidate: unknown, bytes: Uint8Array): Promise<BundleInstallResult> {
|
||||
const manifest = parseProductionSurfaceManifest(candidate);
|
||||
if (!manifest) return { disposition: "invalid_manifest" };
|
||||
const payload = new TextEncoder().encode(canonicalManifestPayload(manifest));
|
||||
if (!await this.signatureVerifier.verify(payload, manifest.signature, manifest.key_id)) return { disposition: "signature_invalid" };
|
||||
if (bytes.byteLength !== manifest.artifact.size_bytes) return { disposition: "size_mismatch" };
|
||||
if (await this.digest.sha256(bytes) !== manifest.artifact.sha256) return { disposition: "digest_mismatch" };
|
||||
|
||||
const app = appKey(manifest);
|
||||
const key = bundleKey(manifest);
|
||||
const existing = this.entries.get(key);
|
||||
if (existing) return { disposition: "duplicate", entry: copy(existing) };
|
||||
|
||||
// Every validation above completes before the first cache mutation.
|
||||
const entry = freezeEntry({ manifest, bytes: new Uint8Array(bytes) });
|
||||
const oldActive = this.active.get(app);
|
||||
this.entries.set(key, entry);
|
||||
this.active.set(app, key);
|
||||
if (oldActive && oldActive !== key) this.previous.set(app, oldActive);
|
||||
return { disposition: "installed", entry: copy(entry) };
|
||||
}
|
||||
|
||||
public async downloadAndInstall(candidate: unknown, downloader: TrustedSurfaceBundleDownloader): Promise<BundleInstallResult> {
|
||||
const manifest = parseProductionSurfaceManifest(candidate);
|
||||
if (!manifest) return { disposition: "invalid_manifest" };
|
||||
const bytes = await downloader.download(manifest);
|
||||
return bytes ? this.install(manifest, bytes) : { disposition: "download_failed" };
|
||||
}
|
||||
|
||||
public activeBundle(appID: string): InstalledSurfaceBundle | undefined {
|
||||
const key = this.active.get(appID);
|
||||
const entry = key ? this.entries.get(key) : undefined;
|
||||
return entry && copy(entry);
|
||||
}
|
||||
|
||||
/** Exact version lookup for a persisted Surface instance; never falls forward. */
|
||||
public bundle(appID: string, version: string): InstalledSurfaceBundle | undefined {
|
||||
const entry = [...this.entries.values()].find(candidate => candidate.manifest.app_id === appID && candidate.manifest.version === version);
|
||||
return entry && copy(entry);
|
||||
}
|
||||
|
||||
/** Reverts only to a previously verified bundle for the same app. */
|
||||
public rollback(appID: string): BundleRollbackResult {
|
||||
const prior = this.previous.get(appID);
|
||||
const current = this.active.get(appID);
|
||||
const entry = prior ? this.entries.get(prior) : undefined;
|
||||
if (!current || !prior || !entry) return { disposition: "unavailable" };
|
||||
this.active.set(appID, prior);
|
||||
this.previous.set(appID, current);
|
||||
return { disposition: "rolled_back", entry: copy(entry) };
|
||||
}
|
||||
|
||||
/** An invalidated active artifact falls back only to a verified predecessor. */
|
||||
public invalidate(appID: string, artifactID: string): BundleRollbackResult {
|
||||
const current = this.active.get(appID);
|
||||
const currentEntry = current ? this.entries.get(current) : undefined;
|
||||
if (!current || !currentEntry || currentEntry.manifest.artifact.artifact_id !== artifactID) return { disposition: "unavailable" };
|
||||
return this.rollback(appID);
|
||||
}
|
||||
|
||||
public snapshot(): readonly Readonly<{ app_id: string; version: string; artifact_id: string; active: boolean }>[] {
|
||||
return [...this.entries.entries()].map(([key, entry]) => ({
|
||||
app_id: entry.manifest.app_id, version: entry.manifest.version, artifact_id: entry.manifest.artifact.artifact_id,
|
||||
active: this.active.get(appKey(entry.manifest)) === key,
|
||||
})).sort((left, right) => `${left.app_id}@${left.version}`.localeCompare(`${right.app_id}@${right.version}`));
|
||||
}
|
||||
}
|
||||
|
||||
function appKey(manifest: ProductionSurfaceManifest): string { return manifest.app_id; }
|
||||
function bundleKey(manifest: ProductionSurfaceManifest): string { return `${manifest.app_id}@${manifest.version}#${manifest.artifact.sha256}`; }
|
||||
function copy(entry: InstalledSurfaceBundle): InstalledSurfaceBundle { return { manifest: { ...entry.manifest, artifact: { ...entry.manifest.artifact }, permissions: [...entry.manifest.permissions] }, bytes: new Uint8Array(entry.bytes) }; }
|
||||
function freezeEntry(entry: InstalledSurfaceBundle): InstalledSurfaceBundle { return Object.freeze({ manifest: Object.freeze({ ...entry.manifest, artifact: Object.freeze({ ...entry.manifest.artifact }), permissions: Object.freeze([...entry.manifest.permissions]) }), bytes: new Uint8Array(entry.bytes) }); }
|
||||
|
||||
function base64urlBytes(value: string): Uint8Array | undefined {
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(value) || value.length % 4 === 1) return undefined;
|
||||
try {
|
||||
const binary = atob(value.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - value.length % 4) % 4));
|
||||
return Uint8Array.from(binary, character => character.charCodeAt(0));
|
||||
} catch { return undefined; }
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SurfaceInstanceManager } from "@/runtime/surfaces/surface-instance-manager";
|
||||
import { SurfaceRegistry } from "@/runtime/surfaces/surface-registry";
|
||||
|
||||
const APP = "lineup.task-dashboard";
|
||||
const VERSION = "0.1.0";
|
||||
const CONVERSATION = "conversation_surface_001";
|
||||
const OPENED = "2026-08-03T10:30:00.000Z";
|
||||
const UPDATED = "2026-08-03T10:31:00.000Z";
|
||||
|
||||
function createManager(): { registry: SurfaceRegistry; manager: SurfaceInstanceManager } {
|
||||
const registry = new SurfaceRegistry();
|
||||
registry.enable(APP, VERSION, OPENED);
|
||||
return { registry, manager: new SurfaceInstanceManager(registry) };
|
||||
}
|
||||
|
||||
function openPayload(state: object = { title: "Build", percent: 10 }): object {
|
||||
return { instance_id: "task:001", app: { app_id: APP, version: VERSION }, state };
|
||||
}
|
||||
|
||||
describe("SurfaceInstanceManager", () => {
|
||||
it("opens only an explicitly enabled local app and makes same-instance open idempotent", () => {
|
||||
const { manager } = createManager();
|
||||
expect(manager.open(openPayload(), CONVERSATION, OPENED)).toMatchObject({ disposition: "accepted", instance: { phase: "opening", state: { title: "Build", percent: 10 } } });
|
||||
expect(manager.open(openPayload({ title: "Ignored patch" }), CONVERSATION, UPDATED)).toMatchObject({ disposition: "duplicate", instance: { state: { title: "Build", percent: 10 } } });
|
||||
});
|
||||
|
||||
it("rejects an unenabled app, an app-version mismatch, and injected bundle fields", () => {
|
||||
const registry = new SurfaceRegistry();
|
||||
const manager = new SurfaceInstanceManager(registry);
|
||||
expect(manager.open(openPayload(), CONVERSATION, OPENED).disposition).toBe("disabled");
|
||||
registry.enable(APP, VERSION, OPENED);
|
||||
expect(manager.open({ ...openPayload(), app: { app_id: APP, version: "1.0.0" } }, CONVERSATION, OPENED).disposition).toBe("version_mismatch");
|
||||
expect(manager.open({ ...openPayload(), bundle: "<script>" }, CONVERSATION, OPENED).disposition).toBe("bundle_override_forbidden");
|
||||
});
|
||||
|
||||
it("moves to ready once and permits only state replacement through patch", () => {
|
||||
const { manager } = createManager();
|
||||
manager.open(openPayload(), CONVERSATION, OPENED);
|
||||
expect(manager.ready("task:001", UPDATED)).toMatchObject({ disposition: "accepted", instance: { phase: "ready" } });
|
||||
expect(manager.ready("task:001", UPDATED).disposition).toBe("duplicate");
|
||||
expect(manager.patch("task:001", { title: "Build", percent: 60 }, UPDATED)).toMatchObject({ disposition: "accepted", instance: { state: { title: "Build", percent: 60 }, app_id: APP, version: VERSION } });
|
||||
expect(manager.patch("task:001", { date: new Date() }, UPDATED).disposition).toBe("invalid_state");
|
||||
});
|
||||
|
||||
it("closes instances and never restores disabled, malformed, or unknown snapshots", () => {
|
||||
const { registry, manager } = createManager();
|
||||
manager.open(openPayload(), CONVERSATION, OPENED);
|
||||
const snapshot = manager.snapshot();
|
||||
expect(manager.close("task:001").disposition).toBe("accepted");
|
||||
expect(manager.get("task:001")).toBeUndefined();
|
||||
manager.restore(snapshot);
|
||||
expect(manager.get("task:001")).toMatchObject({ phase: "opening", state: { title: "Build", percent: 10 } });
|
||||
registry.disable(APP, VERSION);
|
||||
manager.restore(snapshot);
|
||||
expect(manager.snapshot().instances).toEqual([]);
|
||||
manager.restore({ version: 1, instances: [{ instance_id: "bad", conversation_id: CONVERSATION, app_id: "unknown.surface", version: VERSION, state: {}, phase: "ready", opened_at: OPENED, updated_at: UPDATED }] });
|
||||
expect(manager.snapshot().instances).toEqual([]);
|
||||
});
|
||||
|
||||
it("treats a conflicting reuse of an instance id as a safe no-op", () => {
|
||||
const { manager } = createManager();
|
||||
manager.open(openPayload(), CONVERSATION, OPENED);
|
||||
expect(manager.open(openPayload(), "another_conversation", UPDATED)).toMatchObject({ disposition: "conflict", instance: { conversation_id: CONVERSATION } });
|
||||
expect(manager.patch("missing", {}, UPDATED).disposition).toBe("missing");
|
||||
expect(manager.close("missing").disposition).toBe("missing");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Surface 实例的纯生命周期管理器。
|
||||
*
|
||||
* 以 `instance_id` 管理 open、ready、patch、close 和 restore,并验证所属 App/版本/会话;
|
||||
* 重试必须幂等,冲突复用与非法状态替换必须被拒绝。
|
||||
*/
|
||||
import type { JsonObject } from "@/runtime/protocol/lineup-v1";
|
||||
import {
|
||||
parseSurfaceOpenRequest,
|
||||
type SurfaceAdmissionPolicy,
|
||||
type SurfaceRegistryDisposition,
|
||||
} from "@/runtime/surfaces/surface-registry";
|
||||
|
||||
export type SurfaceInstancePhase = "opening" | "ready";
|
||||
|
||||
export type SurfaceInstance = Readonly<{
|
||||
instance_id: string;
|
||||
conversation_id: string;
|
||||
app_id: string;
|
||||
version: string;
|
||||
state: JsonObject;
|
||||
phase: SurfaceInstancePhase;
|
||||
opened_at: string;
|
||||
updated_at: string;
|
||||
}>;
|
||||
|
||||
type MutableSurfaceInstance = { -readonly [Field in keyof SurfaceInstance]: SurfaceInstance[Field] };
|
||||
|
||||
export type SurfaceInstanceTransition = Readonly<{
|
||||
disposition:
|
||||
| "accepted"
|
||||
| "duplicate"
|
||||
| "missing"
|
||||
| "conflict"
|
||||
| "invalid_request"
|
||||
| SurfaceRegistryDisposition;
|
||||
instance?: SurfaceInstance;
|
||||
}>;
|
||||
|
||||
export type SurfaceInstanceSnapshot = Readonly<{
|
||||
version: 1;
|
||||
instances: readonly SurfaceInstance[];
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Lifecycle-only M2-02 manager. It never creates a frame, executes Surface
|
||||
* code, forwards events, persists state, or grants capabilities. A host can
|
||||
* persist its snapshot and hand the restored records back after App Center is
|
||||
* initialized.
|
||||
*/
|
||||
export class SurfaceInstanceManager {
|
||||
private readonly instances = new Map<string, MutableSurfaceInstance>();
|
||||
|
||||
public constructor(private readonly registry: SurfaceAdmissionPolicy) {}
|
||||
|
||||
/**
|
||||
* A repeated open is deliberately a no-op. The first accepted state wins;
|
||||
* later changes must use patch, so an Agent cannot smuggle a state update in
|
||||
* a retry of an existing instance id.
|
||||
*/
|
||||
public open(payload: unknown, conversationID: string, now: string): SurfaceInstanceTransition {
|
||||
if (!validConversationID(conversationID) || !validTimestamp(now)) return { disposition: "invalid_request" };
|
||||
const request = parseSurfaceOpenRequest(payload);
|
||||
if (!request) return { disposition: hasBundleField(payload) ? "bundle_override_forbidden" : "invalid_request" };
|
||||
const current = this.instances.get(request.instance_id);
|
||||
if (current) {
|
||||
return current.app_id === request.app.app_id && current.version === request.app.version && current.conversation_id === conversationID
|
||||
? { disposition: "duplicate", instance: copy(current) }
|
||||
: { disposition: "conflict", instance: copy(current) };
|
||||
}
|
||||
const validation = this.registry.validateOpenRequest(payload);
|
||||
if (validation.disposition !== "accepted") return validation;
|
||||
const instance: MutableSurfaceInstance = {
|
||||
instance_id: request.instance_id,
|
||||
conversation_id: conversationID,
|
||||
app_id: request.app.app_id,
|
||||
version: request.app.version,
|
||||
state: clone(request.state ?? {}),
|
||||
phase: "opening",
|
||||
opened_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
this.instances.set(instance.instance_id, instance);
|
||||
return { disposition: "accepted", instance: copy(instance) };
|
||||
}
|
||||
|
||||
/** A frame may acknowledge readiness once; duplicate acknowledgements are harmless. */
|
||||
public ready(instanceID: string, now: string): SurfaceInstanceTransition {
|
||||
const instance = this.instances.get(instanceID);
|
||||
if (!instance) return { disposition: "missing" };
|
||||
if (!validTimestamp(now)) return { disposition: "invalid_request", instance: copy(instance) };
|
||||
if (instance.phase === "ready") return { disposition: "duplicate", instance: copy(instance) };
|
||||
instance.phase = "ready";
|
||||
instance.updated_at = now;
|
||||
return { disposition: "accepted", instance: copy(instance) };
|
||||
}
|
||||
|
||||
/** Patch may replace state only; app identity and bundle are immutable after open. */
|
||||
public patch(instanceID: string, state: unknown, now: string): SurfaceInstanceTransition {
|
||||
const instance = this.instances.get(instanceID);
|
||||
if (!instance) return { disposition: "missing" };
|
||||
if (!validTimestamp(now)) return { disposition: "invalid_request", instance: copy(instance) };
|
||||
const validation = this.registry.validateState(instance.app_id, instance.version, state);
|
||||
if (validation.disposition !== "accepted") return validation;
|
||||
instance.state = clone(state as JsonObject);
|
||||
instance.updated_at = now;
|
||||
return { disposition: "accepted", instance: copy(instance) };
|
||||
}
|
||||
|
||||
/** Closing is terminal for this manager snapshot; a later open starts fresh. */
|
||||
public close(instanceID: string): SurfaceInstanceTransition {
|
||||
const instance = this.instances.get(instanceID);
|
||||
if (!instance) return { disposition: "missing" };
|
||||
this.instances.delete(instanceID);
|
||||
return { disposition: "accepted", instance: copy(instance) };
|
||||
}
|
||||
|
||||
public get(instanceID: string): SurfaceInstance | undefined {
|
||||
const instance = this.instances.get(instanceID);
|
||||
return instance ? copy(instance) : undefined;
|
||||
}
|
||||
|
||||
public snapshot(): SurfaceInstanceSnapshot {
|
||||
return {
|
||||
version: 1,
|
||||
instances: [...this.instances.values()]
|
||||
.sort((left, right) => left.instance_id.localeCompare(right.instance_id))
|
||||
.map(copy),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore is intentionally stricter than ordinary open: it accepts only
|
||||
* valid, currently enabled local manifests. A disabled or upgraded app
|
||||
* cannot reappear after a reload through an old persisted snapshot.
|
||||
*/
|
||||
public restore(snapshot: unknown): void {
|
||||
this.instances.clear();
|
||||
if (!isPlainObject(snapshot) || snapshot.version !== 1 || !Array.isArray(snapshot.instances)) return;
|
||||
for (const raw of snapshot.instances) {
|
||||
if (!isPlainObject(raw) || !validConversationID(raw.conversation_id) || !validTimestamp(raw.opened_at)
|
||||
|| !validTimestamp(raw.updated_at) || (raw.phase !== "opening" && raw.phase !== "ready")) continue;
|
||||
const opened = this.open({
|
||||
instance_id: raw.instance_id,
|
||||
app: { app_id: raw.app_id, version: raw.version },
|
||||
state: raw.state,
|
||||
}, raw.conversation_id, raw.opened_at);
|
||||
if (opened.disposition !== "accepted") continue;
|
||||
const instance = this.instances.get(raw.instance_id as string);
|
||||
if (!instance) continue;
|
||||
instance.phase = raw.phase;
|
||||
instance.updated_at = raw.updated_at;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validConversationID(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0 && value.length <= 256;
|
||||
}
|
||||
|
||||
function validTimestamp(value: unknown): value is string {
|
||||
return typeof value === "string" && !Number.isNaN(Date.parse(value));
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
|
||||
function hasBundleField(value: unknown): boolean {
|
||||
if (!isPlainObject(value)) return false;
|
||||
return ["bundle", "bundle_id", "bundle_url", "url", "html", "css", "javascript", "js", "script", "source"].some(field => field in value);
|
||||
}
|
||||
|
||||
function clone<T>(value: T): T { return JSON.parse(JSON.stringify(value)) as T; }
|
||||
function copy(instance: SurfaceInstance): SurfaceInstance { return { ...instance, state: clone(instance.state) }; }
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEVELOPMENT_SURFACE_MANIFESTS,
|
||||
SurfaceRegistry,
|
||||
parseSurfaceOpenRequest,
|
||||
} from "@/runtime/surfaces/surface-registry";
|
||||
|
||||
const APP = "lineup.task-dashboard";
|
||||
const VERSION = "0.1.0";
|
||||
const TIME = "2026-08-03T10:00:00.000Z";
|
||||
|
||||
describe("SurfaceRegistry", () => {
|
||||
it("requires explicit local enablement and an exact locally declared version", () => {
|
||||
const registry = new SurfaceRegistry();
|
||||
|
||||
expect(registry.resolveEnabled(APP, VERSION).disposition).toBe("disabled");
|
||||
expect(registry.enable(APP, VERSION, TIME)).toMatchObject({ disposition: "accepted", manifest: DEVELOPMENT_SURFACE_MANIFESTS[0] });
|
||||
expect(registry.resolveEnabled(APP, VERSION)).toMatchObject({ disposition: "accepted", manifest: DEVELOPMENT_SURFACE_MANIFESTS[0] });
|
||||
expect(registry.resolveEnabled(APP, "0.1.1").disposition).toBe("version_mismatch");
|
||||
expect(registry.resolveEnabled("unknown.surface", VERSION).disposition).toBe("unknown_app");
|
||||
});
|
||||
|
||||
it("immediately removes an app from the openable local set when disabled", () => {
|
||||
const registry = new SurfaceRegistry();
|
||||
registry.enable(APP, VERSION, TIME);
|
||||
|
||||
expect(registry.disable(APP, VERSION).disposition).toBe("accepted");
|
||||
expect(registry.resolveEnabled(APP, VERSION).disposition).toBe("disabled");
|
||||
});
|
||||
|
||||
it("only restores valid entries for locally known versions", () => {
|
||||
const registry = new SurfaceRegistry();
|
||||
registry.restore({
|
||||
version: 1,
|
||||
enabled: [
|
||||
{ app_id: APP, version: VERSION, enabled_at: TIME },
|
||||
{ app_id: APP, version: "9.9.9", enabled_at: TIME },
|
||||
{ app_id: "unknown.surface", version: VERSION, enabled_at: TIME },
|
||||
{ app_id: APP, version: VERSION, enabled_at: "not-a-time" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(registry.snapshot()).toEqual({ version: 1, enabled: [{ app_id: APP, version: VERSION, enabled_at: TIME }] });
|
||||
});
|
||||
|
||||
it("rejects malformed development declarations instead of silently enabling them", () => {
|
||||
const registry = new SurfaceRegistry([{
|
||||
...DEVELOPMENT_SURFACE_MANIFESTS[0],
|
||||
app_id: "bad app id",
|
||||
}]);
|
||||
|
||||
expect(registry.enable(APP, VERSION, TIME).disposition).toBe("unknown_app");
|
||||
});
|
||||
|
||||
it("bounds state shape, nesting, keys, and encoded bytes before it is retained", () => {
|
||||
const registry = new SurfaceRegistry();
|
||||
registry.enable(APP, VERSION, TIME);
|
||||
|
||||
expect(registry.validateState(APP, VERSION, { title: "A", steps: [{ id: "one" }] }).disposition).toBe("accepted");
|
||||
expect(registry.validateState(APP, VERSION, { deep: { a: { b: { c: { d: { e: { f: { g: { h: { i: { j: { k: { l: { m: { n: { o: { p: { q: "too deep" } } } } } } } } } } } } } } } } } }).disposition).toBe("invalid_state");
|
||||
expect(registry.validateState(APP, VERSION, { payload: "x".repeat(40_000) }).disposition).toBe("state_too_large");
|
||||
expect(registry.validateState(APP, VERSION, { date: new Date() }).disposition).toBe("invalid_state");
|
||||
});
|
||||
|
||||
it("refuses Agent supplied replacement bundles and accepts only a bounded open request", () => {
|
||||
const registry = new SurfaceRegistry();
|
||||
registry.enable(APP, VERSION, TIME);
|
||||
|
||||
expect(registry.validateOpenRequest({
|
||||
instance_id: "task:001",
|
||||
app: { app_id: APP, version: VERSION },
|
||||
state: { title: "Build" },
|
||||
bundle_url: "https://example.invalid/surface.js",
|
||||
}).disposition).toBe("bundle_override_forbidden");
|
||||
expect(registry.validateOpenRequest({
|
||||
instance_id: "task:001",
|
||||
app: { app_id: APP, version: VERSION },
|
||||
state: { title: "Build" },
|
||||
}).disposition).toBe("accepted");
|
||||
expect(parseSurfaceOpenRequest({
|
||||
instance_id: "task:001",
|
||||
app: { app_id: APP, version: VERSION },
|
||||
state: { title: "Build" },
|
||||
})).toEqual({ instance_id: "task:001", app: { app_id: APP, version: VERSION }, state: { title: "Build" } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* 本地可信 Surface 注册表与开发期接纳契约。
|
||||
*
|
||||
* 记录由 Host 打包的精确 App/版本,不接受会话 payload 传来的 HTML、JavaScript、URL 或
|
||||
* Bundle 替换字段;后续生产策略沿用相同的受限接纳边界。
|
||||
*/
|
||||
import type { JsonObject } from "@/runtime/protocol/lineup-v1";
|
||||
|
||||
/**
|
||||
* M2-01 development-time Surface declaration. These records are authored by
|
||||
* the host, bundled with it, and never accepted from a conversation payload.
|
||||
* Remote bundle URLs, hashes and downloads deliberately belong to M4.
|
||||
*/
|
||||
export type SurfaceManifest = Readonly<{
|
||||
app_id: string;
|
||||
version: string;
|
||||
bundle_id: string;
|
||||
state_schema_version: number;
|
||||
max_state_bytes: number;
|
||||
max_bundle_bytes: number;
|
||||
local_bundle_bytes: number;
|
||||
development_only: true;
|
||||
}>;
|
||||
|
||||
export type EnabledSurface = Readonly<{
|
||||
app_id: string;
|
||||
version: string;
|
||||
enabled_at: string;
|
||||
}>;
|
||||
|
||||
export type SurfaceRegistryDisposition =
|
||||
| "accepted"
|
||||
| "unknown_app"
|
||||
| "version_mismatch"
|
||||
| "disabled"
|
||||
| "invalid_manifest"
|
||||
| "invalid_state"
|
||||
| "state_too_large"
|
||||
| "bundle_override_forbidden";
|
||||
|
||||
export type SurfaceRegistryResult = Readonly<{
|
||||
disposition: SurfaceRegistryDisposition;
|
||||
manifest?: SurfaceManifest;
|
||||
}>;
|
||||
|
||||
export type SurfaceRegistrySnapshot = Readonly<{
|
||||
version: 1;
|
||||
enabled: readonly EnabledSurface[];
|
||||
}>;
|
||||
|
||||
export type SurfaceOpenRequest = Readonly<{
|
||||
instance_id: string;
|
||||
app: Readonly<{ app_id: string; version: string }>;
|
||||
state?: JsonObject;
|
||||
}>;
|
||||
|
||||
/** Shared contract for development and production Surface admission policies. */
|
||||
export interface SurfaceAdmissionPolicy {
|
||||
validateOpenRequest(payload: unknown): SurfaceRegistryResult;
|
||||
validateState(appID: string, version: string, state: unknown): SurfaceRegistryResult;
|
||||
}
|
||||
|
||||
const APP_ID = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/;
|
||||
const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
||||
const BUNDLE_ID = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/;
|
||||
const INSTANCE_ID = /^[A-Za-z0-9._:-]{1,128}$/;
|
||||
const MAX_APP_ID_LENGTH = 96;
|
||||
const MAX_VERSION_LENGTH = 64;
|
||||
const MAX_BUNDLE_ID_LENGTH = 128;
|
||||
const MAX_STATE_DEPTH = 16;
|
||||
const MAX_STATE_KEYS = 512;
|
||||
const MAX_DECLARED_STATE_BYTES = 64 * 1024;
|
||||
const MAX_DECLARED_BUNDLE_BYTES = 512 * 1024;
|
||||
|
||||
/**
|
||||
* The sole surface known in the development build. Declaring it here does
|
||||
* not make it visible to an Agent or openable: App Center must explicitly
|
||||
* enable this exact app/version first.
|
||||
*/
|
||||
export const DEVELOPMENT_SURFACE_MANIFESTS: readonly SurfaceManifest[] = Object.freeze([
|
||||
Object.freeze({
|
||||
app_id: "lineup.task-dashboard",
|
||||
version: "0.1.0",
|
||||
bundle_id: "lineup.task-dashboard.dev.v1",
|
||||
state_schema_version: 1,
|
||||
max_state_bytes: 32 * 1024,
|
||||
max_bundle_bytes: 256 * 1024,
|
||||
local_bundle_bytes: 0,
|
||||
development_only: true,
|
||||
}),
|
||||
Object.freeze({
|
||||
app_id: "lineup.whiteboard",
|
||||
version: "0.1.0",
|
||||
bundle_id: "lineup.whiteboard.dev.v1",
|
||||
state_schema_version: 1,
|
||||
max_state_bytes: 32 * 1024,
|
||||
max_bundle_bytes: 256 * 1024,
|
||||
local_bundle_bytes: 0,
|
||||
development_only: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Local App Center enablement boundary. It is intentionally free of DOM,
|
||||
* storage, transport, iframe and capability concerns so it can be persisted
|
||||
* by a future host adapter without changing its security rules.
|
||||
*/
|
||||
export class SurfaceRegistry {
|
||||
private readonly manifests = new Map<string, SurfaceManifest>();
|
||||
private readonly enabled = new Map<string, EnabledSurface>();
|
||||
|
||||
public constructor(manifests: readonly SurfaceManifest[] = DEVELOPMENT_SURFACE_MANIFESTS) {
|
||||
for (const candidate of manifests) {
|
||||
const manifest = normalizeManifest(candidate);
|
||||
if (!manifest || this.manifests.has(key(manifest.app_id, manifest.version))) continue;
|
||||
this.manifests.set(key(manifest.app_id, manifest.version), manifest);
|
||||
}
|
||||
}
|
||||
|
||||
public enable(appID: string, version: string, enabledAt: string): SurfaceRegistryResult {
|
||||
const result = this.find(appID, version);
|
||||
if (result.disposition !== "accepted" || !result.manifest || !validTimestamp(enabledAt)) return result.disposition === "accepted"
|
||||
? { disposition: "invalid_manifest" }
|
||||
: result;
|
||||
this.enabled.set(key(appID, version), { app_id: appID, version, enabled_at: enabledAt });
|
||||
return result;
|
||||
}
|
||||
|
||||
public disable(appID: string, version: string): SurfaceRegistryResult {
|
||||
const result = this.find(appID, version);
|
||||
if (result.disposition !== "accepted") return result;
|
||||
this.enabled.delete(key(appID, version));
|
||||
return result;
|
||||
}
|
||||
|
||||
public isEnabled(appID: string, version: string): boolean {
|
||||
return this.enabled.has(key(appID, version));
|
||||
}
|
||||
|
||||
/** Resolves only an exact, locally enabled declaration; no inventory leaks. */
|
||||
public resolveEnabled(appID: string, version: string): SurfaceRegistryResult {
|
||||
const result = this.find(appID, version);
|
||||
if (result.disposition !== "accepted") return result;
|
||||
return this.enabled.has(key(appID, version)) ? result : { disposition: "disabled" };
|
||||
}
|
||||
|
||||
/** Validates a trusted host state before Instance Manager can retain it. */
|
||||
public validateState(appID: string, version: string, state: unknown): SurfaceRegistryResult {
|
||||
const result = this.resolveEnabled(appID, version);
|
||||
if (result.disposition !== "accepted" || !result.manifest) return result;
|
||||
const measured = measureState(state);
|
||||
if (!measured.valid) return { disposition: "invalid_state" };
|
||||
return measured.bytes <= result.manifest.max_state_bytes
|
||||
? result
|
||||
: { disposition: "state_too_large" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects payload attempts to replace the locally packaged bundle before
|
||||
* they can reach Instance Manager. This validates shape only; M2-02 owns
|
||||
* lifecycle semantics and M2-04 owns the dashboard state schema.
|
||||
*/
|
||||
public validateOpenRequest(payload: unknown): SurfaceRegistryResult {
|
||||
if (!isPlainObject(payload)) return { disposition: "invalid_state" };
|
||||
if (hasBundleOverride(payload)) return { disposition: "bundle_override_forbidden" };
|
||||
const instanceID = payload.instance_id;
|
||||
const app = payload.app;
|
||||
if (typeof instanceID !== "string" || !INSTANCE_ID.test(instanceID) || !isPlainObject(app)
|
||||
|| !validAppID(app.app_id) || !validVersion(app.version)) return { disposition: "invalid_state" };
|
||||
if (payload.state !== undefined && !isPlainObject(payload.state)) return { disposition: "invalid_state" };
|
||||
return this.validateState(app.app_id, app.version, payload.state ?? {});
|
||||
}
|
||||
|
||||
public snapshot(): SurfaceRegistrySnapshot {
|
||||
return {
|
||||
version: 1,
|
||||
enabled: [...this.enabled.values()]
|
||||
.sort((left, right) => key(left.app_id, left.version).localeCompare(key(right.app_id, right.version)))
|
||||
.map(entry => ({ ...entry })),
|
||||
};
|
||||
}
|
||||
|
||||
/** Restoring never adds an unknown, mismatched, malformed or duplicate app. */
|
||||
public restore(snapshot: unknown): void {
|
||||
this.enabled.clear();
|
||||
if (!isPlainObject(snapshot) || snapshot.version !== 1 || !Array.isArray(snapshot.enabled)) return;
|
||||
for (const raw of snapshot.enabled) {
|
||||
if (!isPlainObject(raw) || !validAppID(raw.app_id) || !validVersion(raw.version) || !validTimestamp(raw.enabled_at)) continue;
|
||||
const result = this.find(raw.app_id, raw.version);
|
||||
if (result.disposition !== "accepted") continue;
|
||||
this.enabled.set(key(raw.app_id, raw.version), { app_id: raw.app_id, version: raw.version, enabled_at: raw.enabled_at });
|
||||
}
|
||||
}
|
||||
|
||||
private find(appID: string, version: string): SurfaceRegistryResult {
|
||||
if (!validAppID(appID)) return { disposition: "unknown_app" };
|
||||
const matchingApp = [...this.manifests.values()].some(manifest => manifest.app_id === appID);
|
||||
if (!matchingApp) return { disposition: "unknown_app" };
|
||||
if (!validVersion(version) || !this.manifests.has(key(appID, version))) return { disposition: "version_mismatch" };
|
||||
return { disposition: "accepted", manifest: this.manifests.get(key(appID, version))! };
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeManifest(value: SurfaceManifest): SurfaceManifest | undefined {
|
||||
if (!value || typeof value !== "object" || !validAppID(value.app_id) || !validVersion(value.version)
|
||||
|| !validBundleID(value.bundle_id) || !Number.isSafeInteger(value.state_schema_version) || value.state_schema_version < 1
|
||||
|| !validBound(value.max_state_bytes, MAX_DECLARED_STATE_BYTES) || !validBound(value.max_bundle_bytes, MAX_DECLARED_BUNDLE_BYTES)
|
||||
|| !Number.isSafeInteger(value.local_bundle_bytes) || value.local_bundle_bytes < 0 || value.local_bundle_bytes > value.max_bundle_bytes
|
||||
|| value.development_only !== true) return undefined;
|
||||
return Object.freeze({ ...value });
|
||||
}
|
||||
|
||||
function validAppID(value: unknown): value is string { return typeof value === "string" && value.length <= MAX_APP_ID_LENGTH && APP_ID.test(value); }
|
||||
function validVersion(value: unknown): value is string { return typeof value === "string" && value.length <= MAX_VERSION_LENGTH && VERSION.test(value); }
|
||||
function validBundleID(value: unknown): value is string { return typeof value === "string" && value.length <= MAX_BUNDLE_ID_LENGTH && BUNDLE_ID.test(value); }
|
||||
function validBound(value: unknown, maximum: number): value is number { return typeof value === "number" && Number.isSafeInteger(value) && value > 0 && value <= maximum; }
|
||||
function validTimestamp(value: unknown): value is string { return typeof value === "string" && !Number.isNaN(Date.parse(value)); }
|
||||
function key(appID: string, version: string): string { return `${appID}@${version}`; }
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
|
||||
function hasBundleOverride(payload: Record<string, unknown>): boolean {
|
||||
return ["bundle", "bundle_id", "bundle_url", "url", "html", "css", "javascript", "js", "script", "source"].some(field => field in payload);
|
||||
}
|
||||
|
||||
function measureState(value: unknown): { valid: boolean; bytes: number } {
|
||||
let keys = 0;
|
||||
const visit = (candidate: unknown, depth: number): boolean => {
|
||||
if (depth > MAX_STATE_DEPTH || candidate === null || typeof candidate === "string" || typeof candidate === "boolean") return depth <= MAX_STATE_DEPTH;
|
||||
if (typeof candidate === "number") return Number.isFinite(candidate);
|
||||
if (Array.isArray(candidate)) return candidate.every(entry => visit(entry, depth + 1));
|
||||
if (!isPlainObject(candidate)) return false;
|
||||
const entries = Object.entries(candidate);
|
||||
keys += entries.length;
|
||||
return keys <= MAX_STATE_KEYS && entries.every(([entryKey, entryValue]) => entryKey.length <= 128 && visit(entryValue, depth + 1));
|
||||
};
|
||||
if (!isPlainObject(value) || !visit(value, 0)) return { valid: false, bytes: 0 };
|
||||
try {
|
||||
return { valid: true, bytes: new TextEncoder().encode(JSON.stringify(value as JsonObject)).byteLength };
|
||||
} catch {
|
||||
return { valid: false, bytes: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/** A narrow helper for the M2-02 manager; it does not mutate registry state. */
|
||||
export function parseSurfaceOpenRequest(payload: unknown): SurfaceOpenRequest | undefined {
|
||||
if (!isPlainObject(payload) || hasBundleOverride(payload) || typeof payload.instance_id !== "string" || !INSTANCE_ID.test(payload.instance_id)
|
||||
|| !isPlainObject(payload.app) || !validAppID(payload.app.app_id) || !validVersion(payload.app.version)
|
||||
|| (payload.state !== undefined && !isPlainObject(payload.state))) return undefined;
|
||||
return {
|
||||
instance_id: payload.instance_id,
|
||||
app: { app_id: payload.app.app_id, version: payload.app.version },
|
||||
...(payload.state === undefined ? {} : { state: payload.state as JsonObject }),
|
||||
};
|
||||
}
|
||||
|
||||
/** Exposed only for focused state-limit tests; no renderer should need it. */
|
||||
export function isSurfaceStateJson(value: unknown): value is JsonObject {
|
||||
return measureState(value).valid && isPlainObject(value);
|
||||
}
|
||||
@@ -6,6 +6,10 @@
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"allowImportingTsExtensions": false,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
/**
|
||||
* Keep Vite, Vitest and TypeScript on the same source-root import contract.
|
||||
* Module boundaries may evolve without introducing fragile ../../ imports.
|
||||
*/
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": "/src",
|
||||
},
|
||||
},
|
||||
});
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
# LineUp App 程序文件清单与功能说明
|
||||
|
||||
> 最后核验:2026-08-04(Asia/Shanghai)
|
||||
>
|
||||
> 本文是 `lineup-app/` 的客户端源码导航,覆盖 Tauri/Desktop Host、Web Reference Host、
|
||||
> `LineUpRuntime`、Interact MiniApp(当前兼容名 Chat)与客户端测试。跨项目的 AppServer、Hermes Adapter、
|
||||
> WuKongIM 和运行配置见[根程序文件清单](../程序文件清单与功能说明.md)。
|
||||
|
||||
## 1. 当前客户端怎样运行
|
||||
|
||||
LineUp 不是“聊天页面直接连 Agent”。用户首先打开的是一个运行外壳:正式交付时是 Tauri
|
||||
桌面窗口,开发和联调时可在浏览器中打开 Web Reference Host。两种外壳都会启动同一个
|
||||
Runtime;Runtime 再加载默认 Chat,并负责 Chat 与 AppServer / Remote Agent 之间的全部
|
||||
通信。
|
||||
|
||||
```text
|
||||
Tauri Desktop Host / Web Reference Host
|
||||
→ main.ts(启动时把 Host、Runtime 与默认 App 接起来)
|
||||
→ LineUpRuntime
|
||||
→ CoreAppRegistry → chat Core App
|
||||
→ ChatRuntimeSDK
|
||||
→ AppServer / Remote Agent
|
||||
```
|
||||
|
||||
当前唯一的客户端实现与验收基线是 Tauri 2 Desktop Host + Web Reference Host。两种 Host
|
||||
复用同一份 TypeScript Runtime 与 Chat Core App,不是两套客户端。`main.ts` 只在启动时把
|
||||
Host、`LineUpRuntime` 和默认 App 接起来;网络传输、消息存储、同步循环、待发送队列、
|
||||
scope 路由和旧 `lineup.v1` 兼容都由 Runtime 统一管理。
|
||||
|
||||
## 2. 工作区顶层文件
|
||||
|
||||
| 文件 / 目录 | 功能 |
|
||||
|---|---|
|
||||
| `README.md` | LineUp App 工作区入口、当前实现摘要、开发与文档导航。 |
|
||||
| `程序文件清单与功能说明.md` | 本文:客户端源码与测试导航。 |
|
||||
| `APP架构设计.md` | LineUp App、Runtime、MiniApp SDK、Surface/Capability 安全、发布约束和后续路线。 |
|
||||
| `tauri/` | Tauri Desktop Host 与 Web Reference Host 的工程目录。 |
|
||||
|
||||
## 3. Tauri/Web 工程入口
|
||||
|
||||
目录:`tauri/`
|
||||
|
||||
| 文件 / 目录 | 功能 |
|
||||
|---|---|
|
||||
| `package.json` | Vite、Vitest、Tauri CLI 与开发/构建脚本。 |
|
||||
| `vite.config.ts` | Vite/Vitest 的 `@/ → src/` 源码别名。 |
|
||||
| `tsconfig.json` | TypeScript 严格检查与同一 `@/` 路径别名。 |
|
||||
| `index.html` | Vite 页面入口。 |
|
||||
| `scripts/tauri.sh` | Tauri/Rust 工具链包装脚本。 |
|
||||
| `src/` | TypeScript Host、Runtime、Core App 与测试。 |
|
||||
| `src-tauri/` | Tauri Rust Host、窗口配置、能力声明和图标。 |
|
||||
| `README.md` | Tauri/Web Host 的运行、构建、回归和历史里程碑。 |
|
||||
|
||||
## 4. TypeScript Runtime 与 Chat Core App
|
||||
|
||||
目录:`tauri/src/`。完整依赖方向见 [src/README.md](tauri/src/README.md)。
|
||||
|
||||
### 4.1 Host 组合与 Chat Core App
|
||||
|
||||
| 文件 / 目录 | 功能 |
|
||||
|---|---|
|
||||
| `main.ts` | 应用启动装配入口:创建 Runtime,并请求通用 `RuntimeAppHost` 根据 App Registry 挂载默认 Core App。它不直接导入 Chat Renderer,也不拥有网络传输、消息存储、同步循环或待发送队列。 |
|
||||
| `core-apps/chat/chat-shell.ts` | Chat Core App 的可信 DOM Shell。 |
|
||||
| `core-apps/chat/chat-app-host.ts` | Chat Core App 的 Host 装配器:集中加载 Chat 样式、Shell、Renderer、DOM 上下文和 Chat SDK,避免这些细节进入 `main.ts`。 |
|
||||
| `core-apps/chat/trusted-dom-renderers.ts` | 已验证消息到可信 DOM:Markdown、消息、交互卡、任务、执行摘要、Surface、Capability、Artifact 与 fallback。 |
|
||||
| `core-apps/chat/renderer-registry.ts` | 按受信任 `ConversationItem.kind` 分派 Chat Renderer。 |
|
||||
| `core-apps/chat/styles/` | Chat Shell、能力卡片、执行摘要样式。 |
|
||||
|
||||
### 4.2 App 管理、SDK 与 Runtime 协调
|
||||
|
||||
| 文件 | 功能 |
|
||||
|---|---|
|
||||
| `runtime/coordination/lineup-runtime.ts` | Runtime 唯一协调器:持有 Transport、Conversation Store、sync loop、outbox、scope 筛选、`lineup.v1` 兼容、可靠 App Inbox 与 Chat SDK 投影。 |
|
||||
| `runtime/app-management/app-registry.ts` | 本地 Core App Registry;当前静态注册默认/恢复 `chat`。 |
|
||||
| `runtime/app-management/app-sdk.ts` | 当前 Chat/IM Runtime SDK:Core App 与 Runtime 之间的受限接口;提供状态/消息订阅、Inbox ACK、Runtime Action、Tool/Task 投影。Runtime 通过 `openApp(app_scope)` 选择对应 SDK。 |
|
||||
| `runtime/app-management/runtime-app-host.ts` | 从 Runtime App Registry 解析默认 App,再通过 Core App Host Registry 找到对应装配器并挂载可信 Core App。 |
|
||||
| `runtime/coordination/interaction-kernel.ts` | 无副作用协议入口:decode、重复抑制、Agent 状态、Tool/Task 投影。 |
|
||||
| `runtime/coordination/runtime-envelope.ts` | 内部 `RuntimeEnvelope`、`app_scope + conversation_id` 校验及旧协议作用域映射。 |
|
||||
| `runtime/coordination/tool-call-state.ts` | `choice / confirm / input` 交互状态机。 |
|
||||
| `runtime/coordination/task-state.ts` | `agent.progress` 按 `operation_id` 聚合。 |
|
||||
| `runtime/coordination/execution-progress-state.ts` | 按 `execution_id` 关联 ACP 实时步骤。 |
|
||||
|
||||
### 4.3 通信、持久化与协议
|
||||
|
||||
| 文件 | 功能 |
|
||||
|---|---|
|
||||
| `runtime/communication/transport-adapter.ts` | HTTP Transport:登录、发送、同步、超时和响应校验。 |
|
||||
| `runtime/persistence/conversation-store.ts` | 会话持久化:cursor、消息、typed outbox、App Inbox、Tool/Task、摘要、Surface/Capability 快照。 |
|
||||
| `runtime/protocol/lineup-v1.ts` | LineUp v1 受限协议模型与解析边界。 |
|
||||
| `runtime/protocol/golden/lineup-v1.json` | `lineup-v1-golden-1` Runtime compatibility fixture。 |
|
||||
|
||||
### 4.4 Surface、能力、Artifact 与 Inventory
|
||||
|
||||
| 文件 | 功能 |
|
||||
|---|---|
|
||||
| `runtime/surfaces/surface-registry.ts` | 开发期 Surface Manifest 与本地启用注册表;仅精确 app id/version 可用。 |
|
||||
| `runtime/surfaces/surface-instance-manager.ts` | `open / ready / patch / close / restore` 的纯状态生命周期、幂等与冲突拒绝。 |
|
||||
| `runtime/surfaces/isolated-surface-host.ts` | opaque-origin iframe 宿主;`sandbox="allow-scripts"`、严格 CSP、仅受限 bridge。 |
|
||||
| `runtime/surfaces/production-surface-manifest.ts` | 生产 Manifest:immutable artifact、版本、大小、SHA-256、Ed25519、key id、最小 Host、权限 allowlist。 |
|
||||
| `runtime/surfaces/production-surface-policy.ts` | production admission policy:只允许已验证的精确 app/version 创建实例。 |
|
||||
| `runtime/surfaces/surface-bundle-cache.ts` | HTTPS 下载、Ed25519 验签、大小/SHA-256 校验、verified cache、失效与回滚。 |
|
||||
| `runtime/inventory/client-inventory.ts` | 生成 revisioned `lineup.v1.client.inventory`。 |
|
||||
| `runtime/capabilities/capability-registry.ts` | 能力声明、风险等级、可见性与参数 schema 校验。 |
|
||||
| `runtime/capabilities/capability-call-state.ts` | 能力调用状态机:`pending → approved → executing → terminal`。 |
|
||||
| `runtime/capabilities/capability-executor.ts` | 调用 Host handler,并将结果归约为安全确定的 `app.result`。 |
|
||||
| `runtime/capabilities/capability-audit.ts` | 最小审计:call id、能力、风险、处置、时间;不保存敏感参数。 |
|
||||
| `runtime/artifacts/artifact-state.ts` | Artifact 元数据状态:名称、MIME、大小、完整性。 |
|
||||
| `runtime/artifacts/artifact-content-cache.ts` | Host-owned 易失内容缓存;内容不进入 Conversation Store。 |
|
||||
|
||||
## 5. Tauri Rust Host
|
||||
|
||||
目录:`tauri/src-tauri/`
|
||||
|
||||
| 文件 | 功能 |
|
||||
|---|---|
|
||||
| `src/main.rs` | Tauri 桌面二进制入口。 |
|
||||
| `src/lib.rs` | Tauri 应用构造与运行库入口。 |
|
||||
| `tauri.conf.json` | 窗口、应用元数据与构建配置。 |
|
||||
| `capabilities/default.json` | Tauri 权限声明;当前只保留核心默认能力。 |
|
||||
| `Info.plist` | macOS WebView / ATS 兼容配置。 |
|
||||
| `build.rs` | Tauri/Rust 构建脚本入口。 |
|
||||
|
||||
## 6. 客户端测试与构建
|
||||
|
||||
各 `src/runtime/**` 模块的 `.test.ts` 与实现同目录放置,主要覆盖 Runtime/SDK、协议与
|
||||
Kernel、Store/App Inbox、Tool Call、Task、执行摘要、Surface、Capability、Artifact、
|
||||
生产 Bundle 与 golden fixture。
|
||||
|
||||
`runtime/coordination/lineup-runtime.test.ts` 覆盖 MVP-R1 的 Core App Registry、scope
|
||||
筛选、`lineup.v1` 兼容、App Inbox 恢复、去重和 Runtime Action 边界;
|
||||
`runtime/app-management/runtime-app-host.test.ts` 验证默认 Chat 由 Runtime Registry 加载。
|
||||
|
||||
```bash
|
||||
cd lineup-app/tauri
|
||||
|
||||
# Runtime / SDK / Renderer 自动化回归
|
||||
npm test -- --run
|
||||
|
||||
# TypeScript 检查与 Web production build
|
||||
npm run build
|
||||
|
||||
# Web Reference Host(Tailscale 开发)
|
||||
npm run web:dev
|
||||
|
||||
# Tauri Desktop Host
|
||||
npm run desktop:dev
|
||||
```
|
||||
|
||||
当前验证基线:23 个测试文件、99 个测试通过,`npm run build` 通过。
|
||||
|
||||
## 7. 相关文档
|
||||
|
||||
- [工作区 README](README.md):产品基线、MVP-R1、开发入口与文档导航。
|
||||
- [迭代基线与目标](迭代/00.base/00.base.md):`00.base` 已实现内容和 `01.kernel` Runtime Kernel 目标。
|
||||
- [APP 架构设计](APP架构设计.md):LineUp App、Runtime、MiniApp SDK、发布安全与后续阶段。
|
||||
- [Tauri/Web 源码导航](tauri/src/README.md):目录边界与依赖方向。
|
||||
- [Tauri/Web Host README](tauri/README.md):Host 运行、构建、行为回归与历史记录。
|
||||
- [根程序文件清单](../程序文件清单与功能说明.md):AppServer、Hermes、WuKongIM 和运行配置。
|
||||
- [App 最终设计方案](../设计/02.正式方案/app_final_design.md):架构决策的唯一汇总入口。
|
||||
@@ -0,0 +1,124 @@
|
||||
# LineUp App 迭代基线:Runtime 托管 Chat
|
||||
|
||||
**迭代编号:** 00.base
|
||||
**状态:** 已实现、已验证
|
||||
**日期:** 2026-08-04
|
||||
**定位:** 后续迭代必须保持的客户端基础能力
|
||||
|
||||
## 1. 基线说明
|
||||
|
||||
本迭代建立了 LineUp Runtime 承载第一个 Core App 的最小闭环。当前实现中的 `chat` 是
|
||||
图文 IM 形态的应用;在后续设计中,它将演进为 `Interaction App` 的 IM 模式,但现阶段
|
||||
仍保留 `app_scope = "chat"` 作为代码和协议兼容名称。
|
||||
|
||||
当前客户端基线是:
|
||||
|
||||
```text
|
||||
Tauri 2 Desktop Host + Web Reference Host
|
||||
```
|
||||
|
||||
两种 Host 运行同一份 TypeScript Runtime 和 Core App:Tauri 用于正式桌面交付,Web 用于
|
||||
浏览器开发、Tailscale 联调和自动化验证。它们不是两套客户端,也不各自实现通信、同步或
|
||||
消息存储。
|
||||
|
||||
## 2. 已实现的 Runtime 边界
|
||||
|
||||
`LineUpRuntime` 是以下对象的唯一所有者:
|
||||
|
||||
```text
|
||||
Transport
|
||||
Conversation Store
|
||||
sync loop
|
||||
outbox
|
||||
App Inbox
|
||||
作用域筛选
|
||||
旧 lineup.v1 兼容
|
||||
去重与恢复
|
||||
```
|
||||
|
||||
Chat 不能直接访问 AppServer、Transport、Store、登录态或 Host 特权能力,只能使用
|
||||
`ChatRuntimeSDK`:
|
||||
|
||||
```text
|
||||
Runtime
|
||||
→ 校验、持久化和筛选 Agent 消息
|
||||
→ 投递 app_scope + conversation_id 匹配的消息
|
||||
→ 通过 SDK 提供订阅、Inbox、ACK 和 Runtime Action
|
||||
→ 接收 Chat 的用户动作并进入 outbox
|
||||
```
|
||||
|
||||
## 3. 已实现的应用加载关系
|
||||
|
||||
当前已经将“默认应用选择”和“应用具体挂载”分开:
|
||||
|
||||
```text
|
||||
CoreAppRegistry
|
||||
→ 选择默认 app_scope = chat
|
||||
|
||||
CoreAppHostRegistry
|
||||
→ 找到 chat 对应的 Host 装配器
|
||||
|
||||
chat-app-host.ts
|
||||
→ 加载 Chat Shell、样式、Renderer、DOM Context 和 SDK
|
||||
```
|
||||
|
||||
`main.ts` 现在只负责启动 Runtime、创建 Host 绑定和请求挂载默认 App,不再直接导入
|
||||
Chat 的样式、Renderer 或 Shell。
|
||||
|
||||
相关实现:
|
||||
|
||||
- `tauri/src/runtime/coordination/lineup-runtime.ts`
|
||||
- `tauri/src/runtime/app-management/app-registry.ts`
|
||||
- `tauri/src/runtime/app-management/runtime-app-host.ts`
|
||||
- `tauri/src/core-apps/chat/chat-app-host.ts`
|
||||
- `tauri/src/main.ts`
|
||||
|
||||
## 4. 已实现的消息和交互能力
|
||||
|
||||
当前基线已经覆盖:
|
||||
|
||||
- 登录、退出和会话恢复;
|
||||
- HTTP 增量同步与包含式 cursor;
|
||||
- 用户消息本地回显、发送状态和 outbox;
|
||||
- Agent Markdown 消息和安全 DOM 渲染;
|
||||
- Agent status、progress、error 和执行摘要;
|
||||
- `choice`、`confirm`、`input` Tool Call 的状态机和可信交互卡片;
|
||||
- Tool Result / Tool Cancel 回声;
|
||||
- Surface 生命周期的基础管理;
|
||||
- Capability Registry、确认、受限 Host 执行和审计;
|
||||
- Artifact 元数据校验与 Host 临时内容缓存;
|
||||
- App Inbox 的持久化、恢复、ACK 和 `message_id` 去重。
|
||||
|
||||
所有入站内容必须先经过 Runtime 协议和作用域校验。非法或不匹配的消息不得进入 Chat,
|
||||
未知内容只能安全降级,不能执行远端脚本或系统能力。
|
||||
|
||||
## 5. 基线验证
|
||||
|
||||
```text
|
||||
npm test -- --run
|
||||
21 个测试文件通过
|
||||
94 个测试通过
|
||||
|
||||
npm run build
|
||||
TypeScript 检查通过
|
||||
Vite production build 通过
|
||||
```
|
||||
|
||||
## 6. 本基线的边界
|
||||
|
||||
以下能力尚未作为本迭代的完整实现:
|
||||
|
||||
```text
|
||||
动态 App Registry
|
||||
App 安装、更新、回滚和移除
|
||||
App Instance Manager
|
||||
App Focus Manager
|
||||
Runtime Tool Router
|
||||
Interaction App 的 IM / Audio / Video 模式统一运行时
|
||||
扩展 App 的前后台切换和恢复
|
||||
完整应用市场
|
||||
```
|
||||
|
||||
这些内容属于 [01.kernel.md](../01.kernel/01.kernel.md) 定义的下一阶段目标。`00.base` 的意义是保护
|
||||
已经完成的通信、可靠投递、作用域和安全边界,后续重构不得让这些能力回到 App 或
|
||||
`main.ts` 中。
|
||||
@@ -0,0 +1,293 @@
|
||||
# LineUp App 迭代目标:Runtime Kernel 与应用编排
|
||||
|
||||
**迭代编号:** 01.kernel
|
||||
**状态:** 目标设计,待实现
|
||||
**日期:** 2026-08-04
|
||||
**前置基线:** [00.base.md](../00.base/00.base.md)
|
||||
|
||||
## 1. 迭代目标
|
||||
|
||||
本迭代要把 LineUp 从“Runtime 加载一个 Chat 页面”推进为“Runtime 管理一个应用工作区”。
|
||||
Runtime 不仅负责网络、消息和存储,还要负责应用实例、工具路由、前后台焦点和恢复;
|
||||
`Interaction App` 则负责用户与 Agent 的核心交互体验。
|
||||
|
||||
目标结构:
|
||||
|
||||
```text
|
||||
LineUp Runtime
|
||||
│
|
||||
├── Runtime Shell / Desktop
|
||||
│ ├── 应用启动
|
||||
│ ├── 应用切换
|
||||
│ ├── 前后台与焦点
|
||||
│ ├── 通知和安全恢复
|
||||
│ └── Host 挂载协调
|
||||
│
|
||||
├── Interaction App(Core App)
|
||||
│ ├── IM Mode:文字、图片、短消息
|
||||
│ ├── Audio Mode:实时语音
|
||||
│ ├── Video Mode:实时视频
|
||||
│ └── 标准交互原语:choice / confirm / input
|
||||
│
|
||||
└── Installed / Extension Apps
|
||||
├── Whiteboard
|
||||
├── Draw-and-Guess
|
||||
├── Task Dashboard
|
||||
└── 其他可安装应用
|
||||
```
|
||||
|
||||
当前代码中的 `chat` 继续作为兼容实现名称,产品概念上将其定位为:
|
||||
|
||||
```text
|
||||
Interaction App 的 IM 实现
|
||||
```
|
||||
|
||||
后续是否将目录和作用域从 `chat` 正式迁移到 `interaction`,另行作为命名迁移任务处理,
|
||||
不与本迭代的运行时编排重构混在一起。
|
||||
|
||||
## 2. 核心职责分工
|
||||
|
||||
### 2.1 LineUp Runtime
|
||||
|
||||
Runtime 是整个应用工作区的底层协调中心,拥有最终调度和安全决策权:
|
||||
|
||||
- 管理 App Registry、App Instance 和焦点栈;
|
||||
- 校验 Agent Tool Call、Manifest、Inventory、参数和作用域;
|
||||
- 决定调用应直接执行、启动 App、切换前台、等待用户交互还是创建异步任务;
|
||||
- 维护 App 的启动、运行、后台、挂起、恢复、关闭和失败状态;
|
||||
- 管理 Conversation、Store、App Inbox、outbox、权限和审计;
|
||||
- 在扩展 App 结束或启动失败时恢复原来的前台实例。
|
||||
|
||||
### 2.2 Runtime Shell / Desktop
|
||||
|
||||
Runtime Shell 是一个拥有特殊权限的内置工作区。它在产品体验上类似桌面或 Launcher,
|
||||
但最终的生命周期和安全决策仍由 Runtime Core 管理。
|
||||
|
||||
它负责:
|
||||
|
||||
- 把 App 实例挂载到 Tauri 或 Web Host;
|
||||
- 显示当前前台 App;
|
||||
- 管理应用切换、恢复和关闭;
|
||||
- 提供全局连接状态、通知和安全恢复入口;
|
||||
- 向 App 传递受限的 Host 能力。
|
||||
|
||||
普通 App 不能伪造 Runtime Shell,也不能直接修改焦点栈。
|
||||
|
||||
### 2.3 Interaction App
|
||||
|
||||
Interaction App 是默认的人与 Agent 交互应用,但不是整个应用生态的调度器。
|
||||
|
||||
它负责:
|
||||
|
||||
- 当前 Conversation 的主要交互体验;
|
||||
- IM、Audio、Video 等交互模式;
|
||||
- 选择框、确认框、输入框和进度卡片等标准交互原语;
|
||||
- 显示普通 Agent 消息、任务、Tool 结果和扩展 App 的结果;
|
||||
- 通过 SDK 提交用户动作。
|
||||
|
||||
它不负责:
|
||||
|
||||
- 直接连接 AppServer 或 Agent;
|
||||
- 决定其他 App 是否启动;
|
||||
- 管理其他 App 的前后台状态;
|
||||
- 直接执行未经 Runtime 授权的 Tool 或 Capability。
|
||||
|
||||
### 2.4 Extension App
|
||||
|
||||
扩展 App 与 Interaction App 是 Runtime 上的平级应用。画板、你画我猜和任务面板拥有
|
||||
自己的页面、状态、Tool、Surface 和实例生命周期,但必须使用 Runtime SDK。
|
||||
|
||||
扩展 App 可以请求:
|
||||
|
||||
```text
|
||||
启动自己
|
||||
创建 Surface
|
||||
进入前台
|
||||
进入后台
|
||||
提交结果
|
||||
请求关闭
|
||||
```
|
||||
|
||||
最终是否允许、如何持久化、是否需要用户确认,由 Runtime 决定。
|
||||
|
||||
## 3. 应用实例与焦点模型
|
||||
|
||||
App Registry 管理“有哪些应用”,App Instance Manager 管理“哪些应用正在运行”。两者
|
||||
不能混为一个状态。
|
||||
|
||||
```ts
|
||||
type AppInstanceRecord = {
|
||||
instance_id: string;
|
||||
app_scope: string;
|
||||
conversation_id?: string;
|
||||
state:
|
||||
| "starting"
|
||||
| "foreground"
|
||||
| "background"
|
||||
| "suspended"
|
||||
| "stopping"
|
||||
| "stopped"
|
||||
| "failed";
|
||||
parent_instance_id?: string;
|
||||
started_at: string;
|
||||
stopped_at?: string;
|
||||
error?: string;
|
||||
};
|
||||
```
|
||||
|
||||
Runtime 需要保存当前会话的焦点栈:
|
||||
|
||||
```text
|
||||
focus_stack:
|
||||
interaction:audio-001
|
||||
draw-and-guess:game-001
|
||||
|
||||
foreground:
|
||||
draw-and-guess:game-001
|
||||
|
||||
background:
|
||||
interaction:audio-001
|
||||
```
|
||||
|
||||
应用切换不会自动删除原实例。原实例可能进入 `background` 或 `suspended`,在新应用关闭、
|
||||
失败或用户返回时恢复。
|
||||
|
||||
## 4. Tool 调度模型
|
||||
|
||||
Tool 调度权在 Runtime,不在 Interaction App。
|
||||
|
||||
```text
|
||||
Agent Tool Call
|
||||
→ Runtime 校验 Envelope / Inventory / Manifest / 参数 / Scope
|
||||
→ Tool Router 判断处理方式
|
||||
→ App Orchestrator 创建或切换 App Instance
|
||||
→ Focus Manager 调整前后台
|
||||
→ Interaction App / Mode / Extension App 承接
|
||||
→ App 通过 SDK 返回 progress / result / error
|
||||
→ Runtime 校验、持久化、审计并回传 Agent
|
||||
```
|
||||
|
||||
Tool 的处理方式至少包括:
|
||||
|
||||
```text
|
||||
direct 直接由 Runtime 或受信 App 处理
|
||||
interactive 交给 Interaction App 等待用户选择/确认/输入
|
||||
launch 启动或唤醒一个扩展 App
|
||||
foreground 要求目标 App 进入前台
|
||||
operation 创建长时间运行的异步任务
|
||||
```
|
||||
|
||||
Interact 可以请求 Runtime 启动扩展 App,但不能直接加载 Bundle、切换其他 App 或执行
|
||||
系统能力。
|
||||
|
||||
## 5. 典型场景:语音切换到你画我猜
|
||||
|
||||
开始时:
|
||||
|
||||
```text
|
||||
Runtime Shell
|
||||
└── Interaction App
|
||||
└── Audio Mode(foreground)
|
||||
```
|
||||
|
||||
用户说:“我们来玩一局你画我猜吧。” Agent 发来启动请求后:
|
||||
|
||||
```text
|
||||
Agent launch(draw-and-guess)
|
||||
→ Runtime 检查 App 是否已安装、启用和兼容
|
||||
→ 校验 Tool、Manifest、Inventory、参数和权限
|
||||
→ 创建 draw-and-guess:game-001
|
||||
→ 保存 interaction:audio-001 的焦点位置
|
||||
→ Audio Mode 进入 background / suspended
|
||||
→ Draw-and-Guess 进入 starting → foreground
|
||||
```
|
||||
|
||||
游戏完成后:
|
||||
|
||||
```text
|
||||
Draw-and-Guess App
|
||||
→ 通过 SDK 提交 game.result
|
||||
→ Runtime 持久化并回传 Agent
|
||||
→ 游戏实例关闭或挂起
|
||||
→ Runtime 恢复焦点栈中的 Audio Mode
|
||||
→ Interaction App 显示游戏结果
|
||||
```
|
||||
|
||||
游戏和原来的语音交互使用同一个 `conversation_id`,但拥有不同的 `instance_id`。这样既
|
||||
能把结果归还给同一个 Agent 会话,也能独立管理每个应用实例。
|
||||
|
||||
## 6. 标准交互原语与扩展 App 的边界
|
||||
|
||||
以下内容属于 Interaction App 的内建能力,不需要安装独立 App:
|
||||
|
||||
```text
|
||||
choice
|
||||
confirm
|
||||
input
|
||||
progress
|
||||
error
|
||||
```
|
||||
|
||||
例如 Agent 请求选择框:
|
||||
|
||||
```text
|
||||
Agent tool.call(choice)
|
||||
→ Runtime 校验并持久化 pending Tool Call
|
||||
→ Interaction App 在 IM 中显示 Choice Card
|
||||
→ 用户选择
|
||||
→ Runtime 校验 call_id 并写入 outbox
|
||||
→ Choice Card 变为 submitted / completed,不再可操作
|
||||
→ IM 时间线显示“你选择了……”
|
||||
```
|
||||
|
||||
选择框可以从界面上退出可操作状态,但交互记录不能从 Store 中删除。这样才能支持刷新
|
||||
恢复、Agent 回声、幂等去重和审计。
|
||||
|
||||
画板、游戏和复杂任务面板则属于可安装扩展 App,拥有自己的 Tool 和 Surface。
|
||||
|
||||
## 7. 目标模块
|
||||
|
||||
```text
|
||||
runtime/app-management/
|
||||
├── app-registry.ts
|
||||
├── app-instance-manager.ts
|
||||
├── app-focus-manager.ts
|
||||
├── app-lifecycle-manager.ts
|
||||
└── runtime-app-host.ts
|
||||
|
||||
runtime/coordination/
|
||||
├── tool-router.ts
|
||||
├── app-orchestrator.ts
|
||||
└── interaction-orchestrator.ts
|
||||
|
||||
core-apps/interaction/(当前由 core-apps/chat 兼容实现)
|
||||
├── interaction-runtime.ts
|
||||
├── interaction-shell.ts
|
||||
├── interaction-mode-registry.ts
|
||||
├── modes/im/
|
||||
├── modes/audio/
|
||||
├── modes/video/
|
||||
└── primitives/
|
||||
|
||||
installed-apps/
|
||||
├── whiteboard/
|
||||
├── draw-and-guess/
|
||||
└── task-dashboard/
|
||||
```
|
||||
|
||||
`RuntimeAppHost` 负责实际挂载和卸载;App Instance、Focus、Lifecycle 和 Tool Router 负责
|
||||
状态和决策。这样不会把所有业务规则重新堆回 `main.ts`。
|
||||
|
||||
## 8. 本迭代验收目标
|
||||
|
||||
```text
|
||||
1. Runtime 能从 App Registry 选择默认 Interaction App。
|
||||
2. Runtime 能创建、前台化、后台化、挂起、恢复和关闭 App Instance。
|
||||
3. Agent 启动扩展 App 时,当前前台 App 能安全进入后台。
|
||||
4. 扩展 App 结束或失败后,Runtime 能恢复原来的焦点和交互模式。
|
||||
5. Tool 调用必须经过 Runtime 的 Inventory、Manifest、参数、作用域和权限校验。
|
||||
6. Interaction App 能处理 choice / confirm / input 等标准交互原语。
|
||||
7. 扩展 App 只能通过 SDK 返回结果,不能直接访问 Agent、Host DOM 或系统特权。
|
||||
8. 断线或重启后,App Instance、Tool Call、App Inbox、outbox 和焦点栈可以有界恢复。
|
||||
9. 00.base 中已经通过的 21 个测试文件、94 个测试和生产构建不能退化。
|
||||
```
|
||||
@@ -0,0 +1,353 @@
|
||||
# `03.sdk_and_coreapp` 第 1 次设计评审记录
|
||||
|
||||
> 评审日期:2026-08-05
|
||||
> 评审编号:01
|
||||
> 评审基线:[APP架构设计.md](../../APP架构设计.md)
|
||||
> 评审对象:[03.sdk_and_coreapp.md](03.sdk_and_coreapp.md)
|
||||
> 评审方式:独立子 agent 只读评审;本文件记录评审意见,不代表已采纳或已实现。
|
||||
|
||||
> **后续决议(2026-08-05):** `MiniAppManifest.kind` 收敛为 `system | bundled`。Task Dashboard
|
||||
> 与 Whiteboard 均为非系统级 `bundled` MiniApp,必须采用与一般 MiniApp 相同的受限 Surface /
|
||||
> Bridge 模型;“参考”仅描述它们在本迭代验证 SDK 的目的。下文的 `bundled-reference` 为评审时的
|
||||
> 历史术语,已由此决议取代。
|
||||
|
||||
> **后续决议(2026-08-05):** `notice / choice / confirm / input` 始终是 Interact 的人与 Agent
|
||||
> 会话交互,不属于 MiniApp SDK,也不用于 MiniApp 内部业务逻辑。当前 bundled MiniApp 位于前台时,
|
||||
> Interact / Shell 可在其上方显示同一会话的交互层;请求与答案仍只属于 Interact、当前会话和 Agent。
|
||||
|
||||
> **后续决议(2026-08-05):** 每个启动的 App instance 在主 IM 中创建或恢复一个 App 子会话;其中
|
||||
> 只保留人与 Agent 围绕该 App 的提问、回答和简洁结果。App 的内部按钮、表单、画布编辑等业务操作
|
||||
> 不进入子会话。后台化不结束子会话;真正结束时保留折叠历史;新的 instance 创建新的子会话。
|
||||
|
||||
> **后续决议(2026-08-05):** 已结束的 App 子会话可以在主 IM 中只读展开。用户选择“继续处理”时,
|
||||
> Runtime 创建新的 App instance 和新的子会话;新会话可引用旧会话或 App 数据,但不能续写旧记录或
|
||||
> 复活旧问题。
|
||||
|
||||
> **后续决议(2026-08-05,已更新):** App 子会话只在 `AppLifecycleManager.close` 使对应 instance
|
||||
> 停止或失败时结束;前后台切换、暂停和 Agent Tool 完成都不结束子会话。关闭时,Runtime 向 Agent
|
||||
> 可靠发送 App 已关闭事件;尚未回答的 Interact 交互不自动取消,Agent 可 remote dismiss,或让其继续
|
||||
> 在主 IM 中等待用户回答/超时。子会话保留为主 IM 历史。
|
||||
|
||||
> **后续决议(2026-08-05):** MVP 中一个 LineUp Runtime 只连接一个 Agent。每条标准交互和
|
||||
> App 子会话仍保存当前 `agent_uid` 作为 `agent_id`,但本迭代不实现多个 Agent 的连接、切换、
|
||||
> 会话列表、outbox 或路由。
|
||||
|
||||
## 问题清单(Outline)
|
||||
|
||||
> **状态标记:** ✅ 已解决并回填设计; 🟡 待产品/架构决策; 🔵 待在契约中细化; ⚪ 待实施编排或文档整理; — 不再适用。
|
||||
>
|
||||
> 本清单是当前有效视图;后文保留评审时的原始问题、分析和建议作为依据。每次确认一个决策或完成
|
||||
> 回填时,应先更新此处状态,再更新对应正文和验收项。
|
||||
|
||||
| 状态 | 编号 | 问题 | 当前结论 / 下一步 |
|
||||
|---|---|---|---|
|
||||
| ✅ | B1 | Task Dashboard 的信任级别与 UI 容器 | 已确认:Task Dashboard、Whiteboard 均为非系统级 `kind = bundled` MiniApp,必须运行于受限 Surface / Bridge,不能使用可信 Host DOM。 |
|
||||
| ✅ | I1 | `bundled-reference` / `bundled-development` 命名歧义 | 已确认:Manifest 枚举为 `system \| bundled`;“参考”仅描述当前迭代的工作目的。 |
|
||||
| ✅ | I2 | 标准交互由谁显示、答案如何回传 | 已确认:Interact / Shell 显示人与 Agent 的交互;bundled MiniApp 前台时可被该交互层覆盖。结果由 Runtime 可靠回传 Agent,不交给 MiniApp。 |
|
||||
| ✅ | I3 | 标准交互与 Agent Tool Call 的关系 | 已确认:标准交互仅由 Agent Tool 调起并创建交互记录;MiniApp 不存在 `sdk.ui.request`,不会自行创建 Agent Tool Call。 |
|
||||
| ✅ | I4 | 标准交互结果的返回通道 | 已确认:用户答案先回 Runtime,再可靠回传 Agent;不经 MiniApp Inbox、Tool 订阅或 MiniApp SDK 返回。 |
|
||||
| ✅ | I5 | Request/Result schema、状态机、幂等与错误码 | 已确认第一版:`notice` 非阻塞;`confirm` 二选一且默认取消;`choice` 只支持 2~6 项单选;`input` 只支持一个文本输入;首次有效回答终结交互;默认 15 分钟超时,可在 1 分钟~24 小时内调整。 |
|
||||
| ✅ | I6 | 敏感输入的持久化、恢复、审计和日志边界 | 已确认:未提交草稿仅存在当前运行期间,重启即清空;已提交的人与 Agent 答案保留在所属主 IM 或 App 子会话中,随父 IM 会话处理;二者均不写日志、Telemetry 或普通审计明文。 |
|
||||
| ✅ | I7 | App 子会话与主 IM 的关系 | 已确认:每个 App instance 创建/恢复一个子会话;后台保留,真正结束后在主 IM 中折叠存档,新 instance 独立。仅记录人与 Agent 围绕 App 的交互,不记录 App 内部业务操作。 |
|
||||
| ✅ | I8 | 已结束 App 子会话的查看与继续处理 | 已确认:旧子会话可只读展开;继续旧工作创建新 instance / 新子会话,可引用旧上下文,但不追加旧记录或复活旧问题。 |
|
||||
| ✅ | I9 | App 子会话何时结束 | 已确认:仅 `AppLifecycleManager.close` 导致 instance stopped/failed 时结束;前后台切换、暂停、Agent Tool 完成不结束。关闭时 Runtime 向 Agent 发送 App 已关闭事件,但不自动取消未回答的 Interact 交互。 |
|
||||
| ✅ | I10 | MVP 的 Agent 身份范围 | 已确认:Runtime 仅连接一个 Agent;交互和 App 子会话保存该 `agent_id`,但不实现多 Agent 连接、切换或路由。 |
|
||||
| — | S1 | `parent_call_id` 的关联校验与父 Tool 终止后的处置 | 不再适用:MiniApp 不再发起标准交互;标准交互直接绑定 Agent call 与 conversation。 |
|
||||
| ✅ | S2 | 交互层显示与恢复规则 | 已确认:原 App 前台时显示交互层;切换到其他 App/IM 时收起并在子会话标记“等待你的回答”;用户可在 IM 或回到原 App 后回答;关闭 App 只移除覆盖层并通知 Agent,不自动取消交互。 |
|
||||
| ✅ | S3 | Interact 呈现与 Agent Tool 回传的分层 | 已确认:Interact / Shell 只显示并把用户动作/答案交给 Runtime;Runtime 根据已保存的交互上下文校验、持久化和可靠回传 Agent;不经过 MiniApp。 |
|
||||
| ✅ | S4 | 阶段编号重复 | 已确认:Task Dashboard / Whiteboard 的端到端参考实现属于 `03.sdk_and_coreapp`;移除重复的 `03.reference-miniapps`,后续应用分发阶段保持为 `04.app-delivery-registry`。 |
|
||||
| ✅ | S5 | 实施步骤顺序 | 已确认:先完成通用 Tool 闭环,再完成 Runtime 独占的 Agent interaction service 和 Interact 回传契约,之后才适配 Interact、实现两个 bundled 参考 MiniApp,最后端到端验收。 |
|
||||
|
||||
## 1. 总体结论
|
||||
|
||||
这份文件保留了首次评审时提出的问题和建议,方便追溯讨论过程;其中涉及 `sdk.ui`、MiniApp 发起
|
||||
标准交互、owner 注入等早期设想,均已被本文件顶部的“后续决议”和问题清单中的最终结论取代,不能作为
|
||||
实施依据。
|
||||
|
||||
当前已经收敛的核心结论是:
|
||||
|
||||
- 产品层级是 **LineUp App → LineUp Runtime → MiniApps**,Runtime 不是与 MiniApp 平级的产品 App。
|
||||
- `notice / choice / confirm / input` 是 Interact 中人与 Agent 对话的一部分,不是 MiniApp SDK,也不能由
|
||||
bundled MiniApp 发起、读取、提交或取消。
|
||||
- Interact / Shell 只呈现和收集用户答案;Runtime 保存交互归属、校验首次有效回答、写入可靠 outbox,并
|
||||
向当前唯一 Agent 回传结果。
|
||||
- Task Dashboard 与 Whiteboard 都是 `kind = bundled`;它们使用一般 MiniApp 的受限 Surface、Bridge、
|
||||
生命周期和 SDK,只保留自身的业务 UI。
|
||||
- 保留 `lineup.v1.tool.call(choice | confirm | input)` 的 Agent 交互兼容入口;不在本迭代迁移
|
||||
`chat → interact` 命名。
|
||||
- 本迭代不建设 AppServer Catalog、下载、安装、更新、市场、远程 Bundle、真实媒体/文件能力或多 Agent
|
||||
Runtime 连接。
|
||||
|
||||
本轮评审问题均已得到设计结论,后续进入实现时应以 [03.sdk_and_coreapp.md](03.sdk_and_coreapp.md)
|
||||
和 [APP架构设计.md](../../APP架构设计.md) 为准。
|
||||
|
||||
## 2. 阻塞问题
|
||||
|
||||
### B1. Task Dashboard 的可信 DOM / Host adapter 权限突破了当前信任模型
|
||||
|
||||
**架构基线**在 [APP架构设计.md](../../APP架构设计.md) 的 MiniApp 信任模型中明确:
|
||||
|
||||
- `Interact / System MiniApp` 可使用可信内建 DOM 组件;
|
||||
- Installed MiniApp 必须经隔离 iframe / Surface Bridge 运行。
|
||||
|
||||
而 [03.sdk_and_coreapp.md](03.sdk_and_coreapp.md) 同时写了:
|
||||
|
||||
```text
|
||||
Task Dashboard 可由受信 Host adapter 挂载;Whiteboard 必须走受限 Surface
|
||||
```
|
||||
|
||||
并将 Task Dashboard 定义为:
|
||||
|
||||
```text
|
||||
kind = bundled-reference
|
||||
```
|
||||
|
||||
且说明它可使用“随 LineUp 发布的受信 Host adapter”。
|
||||
|
||||
**问题:** 当前权威架构没有明确授权 `bundled-reference` 获得与 System MiniApp 相同的可信 DOM 容器。若 Host adapter 可执行或挂载 Task Dashboard 的 UI,而未经过受限 Surface,隔离边界、SDK 受限视图和“禁止访问 Host DOM”就无法以同一安全模型证明。
|
||||
|
||||
**需要冻结的决策(二选一):**
|
||||
|
||||
1. **保守方案,且最符合当前权威基线:** Task Dashboard 和 Whiteboard 都是非 System MiniApp,必须经隔离 Surface / Bridge 运行。Host adapter 仅作为 Runtime/Shell 的不可见装配层,不向 MiniApp 提供可直接操作的可信 Host DOM。
|
||||
2. **若产品确实需要 Task Dashboard 使用可信原生 DOM:** 将其升级为 `kind = system`,并在架构主文档新增或明确“bundled trusted system MiniApp”信任层、发布来源、可用 DOM 权限和审计边界;不能继续称其为普通 `bundled-reference`。
|
||||
|
||||
**建议验收:**
|
||||
|
||||
- 若维持 `bundled-reference`,Task Dashboard 无法获得 Host 根 DOM、Tauri invoke 或未授权 Host adapter。
|
||||
- 若改为 `system`,仍要验证其特权只限 Runtime SDK,不能取得 Transport、Store 或 Agent 访问权。
|
||||
|
||||
## 3. 重要问题
|
||||
|
||||
### I1. `bundled-development` 与 `bundled-reference` 的术语、Manifest 枚举未建立映射
|
||||
|
||||
迭代文档把“内置”解释为 `bundled-development`,但冻结的 `MiniAppManifest.kind` 只有:
|
||||
|
||||
```ts
|
||||
kind: "system" | "bundled-reference";
|
||||
```
|
||||
|
||||
架构主文档也使用了“参考 MiniApp 在此阶段可以是 `bundled-development`”的表述。
|
||||
|
||||
这两个词可以共存,但必须明确它们不是互相竞争的 `kind` 枚举值。
|
||||
|
||||
**建议补充最小映射表:**
|
||||
|
||||
| 概念 | 建议语义 |
|
||||
|---|---|
|
||||
| `bundled-development` | 本迭代的交付/加载方式:随开发 Host 预置,不下载、不安装、不经服务端 Catalog。 |
|
||||
| `kind = bundled-reference` | Manifest 身份:参考 MiniApp,决定 SDK / Surface 信任策略。 |
|
||||
| `kind = system` | 随产品发布、代码可信的系统 MiniApp,例如 Interact。 |
|
||||
|
||||
并明确:`bundled-development` **不是** `MiniAppManifest.kind` 的第三个取值。
|
||||
|
||||
### I2. `sdk.ui` 缺少 Renderer 如何安全提交用户结果给 Runtime 的闭环契约
|
||||
|
||||
当前公开 API 只有:
|
||||
|
||||
```ts
|
||||
request(...)
|
||||
get(...)
|
||||
subscribe(...)
|
||||
cancel(...)
|
||||
```
|
||||
|
||||
但未定义:
|
||||
|
||||
- Runtime 如何把被 Policy 选中、可呈现的 interaction record 交给 renderer;
|
||||
- renderer 如何提交 `submitted`、`result` 或 `cancelled`;
|
||||
- Runtime 如何校验 `interaction_id`、owner、renderer binding、状态迁移、一次性提交和结果 schema;
|
||||
- 在 opaque-origin iframe / Surface Bridge 内,呈现和回传采用何种受限消息协议;
|
||||
- renderer 与 owner 是同一 MiniApp 时,是否通过 `sdk.ui` 提交,还是只能通过 Runtime/Shell 私有 Renderer API 提交。
|
||||
|
||||
**建议冻结 Runtime 私有 Renderer Contract / 最小 Bridge Contract:**
|
||||
|
||||
```text
|
||||
Runtime 分配 interaction_id + renderer_binding + presentation container/surface
|
||||
→ renderer 仅接收 presentation-safe request projection
|
||||
→ renderer 向 Runtime-owned endpoint 提交 action/result
|
||||
→ Runtime 校验:
|
||||
- 被分配的 renderer / container
|
||||
- interaction_id
|
||||
- owner binding
|
||||
- 当前状态与一次性提交
|
||||
- result schema
|
||||
- expiry
|
||||
→ Runtime 持久化状态迁移,并向 owner-scoped ui 订阅发出更新
|
||||
```
|
||||
|
||||
隔离 Surface 至少还要限定消息白名单、`surface_id` / `instance_id` 绑定和 nonce 或等效关联方式;不得只凭 origin 信任。
|
||||
|
||||
### I3. 标准交互与 `RuntimeToolCallRecord` 的关系不清晰
|
||||
|
||||
当前文档同时规定:
|
||||
|
||||
- MiniApp 可调用 `sdk.ui.request(...)`;
|
||||
- owner 的 source 可为 `agent_tool | miniapp_tool | runtime_policy`;
|
||||
- 所有标准交互“必须映射为 Runtime 统一的 `interactive` Tool Call / `StandardInteractionRecord`”。
|
||||
|
||||
因此会产生问题:Task Dashboard 在没有 Agent Tool Call、例如用户点击“关闭未完成任务”时调用 `sdk.ui.request(confirm)`,Runtime 是否必须凭空创建 Agent-facing `RuntimeToolCallRecord`?
|
||||
|
||||
**建议明确拆成三条路径:**
|
||||
|
||||
| 来源 | Runtime 记录 | 与 Agent Tool 的关系 |
|
||||
|---|---|---|
|
||||
| Agent / legacy `lineup.v1.tool.call(choice/confirm/input)` | `RuntimeToolCallRecord` + `StandardInteractionRecord` | 交互终态受控驱动该 Tool 的后续处理。 |
|
||||
| MiniApp `sdk.ui.request(...)` | owner-bound `StandardInteractionRecord` | 不自动创建新的 Agent Tool;若提供合法 `parent_call_id`,只建立关联。父 Tool 的完成由 owner 经 `sdk.tools.*` 决定。 |
|
||||
| Runtime Policy 自发提示 | `runtime_policy` owner 的 interaction record | 需明确其是否绑定某一 Capability / lifecycle request。 |
|
||||
|
||||
没有 parent Tool 的交互只改变 MiniApp 本地工作流,不应制造 Inventory、Agent 回传或 outbox 语义。
|
||||
|
||||
### I4. 结果投递位置与公开 API 不一致
|
||||
|
||||
流程写“结果仅投递回 owner MiniApp 的 Tool/Inbox”,但 SDK 已公开 `sdk.ui.get/subscribe`。
|
||||
|
||||
否则实现可能分叉成:
|
||||
|
||||
1. 结果通过 `sdk.ui.subscribe` 的 record update 返回;
|
||||
2. 结果作为 Inbox 消息;
|
||||
3. 结果通过 parent Tool 的 SDK Tool event 返回。
|
||||
|
||||
**建议冻结为:**
|
||||
|
||||
```text
|
||||
sdk.ui.get / sdk.ui.subscribe
|
||||
= owner 获取标准交互状态与结果的唯一 UI-domain 通道
|
||||
|
||||
sdk.inbox
|
||||
= Agent / Runtime 入站消息;不复用为 interaction result transport
|
||||
|
||||
sdk.tools.complete / fail / cancel
|
||||
= owner 根据 UI result 自行决定是否推进关联 parent Tool
|
||||
```
|
||||
|
||||
还需定义:订阅是否立即回放 owner 当前 pending records、终态保留/清理策略、断线重连后的重放及去重语义。
|
||||
|
||||
### I5. Schema、状态机、幂等规则不足以支持安全验收
|
||||
|
||||
已有四类 request 及基础状态,但还需要明确:
|
||||
|
||||
- `StandardInteractionResult` 与 `InteractionReceipt` 的类型;
|
||||
- `choice.actions[].id` 的唯一性、最大数量、空数组是否允许,以及 single/multi/button 的选择基数;
|
||||
- `input.fields[].id` 的唯一性、字段数上限、必填、数值解析、空字符串、长度和范围的适用规则;
|
||||
- `notice` 的“只读”与 `acknowledged` 的精确定义:是否需要用户确认、是否自动完成、是否可超时;
|
||||
- `request`、`submit`、`cancel`、超时、恢复之间的合法状态迁移;
|
||||
- 并发提交、重放、重复取消、跨 Surface 重放的确定性返回;
|
||||
- 交互专用稳定拒绝码。
|
||||
|
||||
建议至少增加:
|
||||
|
||||
```text
|
||||
interaction_not_found
|
||||
interaction_owner_mismatch
|
||||
interaction_state_invalid
|
||||
interaction_expired
|
||||
interaction_result_invalid
|
||||
interaction_renderer_mismatch
|
||||
parent_call_invalid
|
||||
```
|
||||
|
||||
### I6. 持久化、审计与敏感输入的日志最小化约束尚未衔接
|
||||
|
||||
`input` 可以含用户自由文本;标准交互要求持久化、恢复和审计,但架构文档又要求日志不得包含身份、会话、消息正文、Tool 参数、token 或 artifact 内容。
|
||||
|
||||
**建议明确:**
|
||||
|
||||
- 恢复所需的受保护 operational state、审计最小元数据、日志/Telemetry 三者的分层;
|
||||
- `input` 草稿和结果的保留期、终态清理策略,以及是否加密;
|
||||
- 审计仅记录 interaction ID、kind、受保护 owner 标识、状态迁移和错误码;
|
||||
- title、prompt、字段值和 action label 不进入日志、指标、错误详情或普通审计明文;
|
||||
- 验收增加日志负向断言。
|
||||
|
||||
## 4. 建议完善项
|
||||
|
||||
### S1. 扩大 `parent_call_id` 校验条件
|
||||
|
||||
除“属于当前实例且状态允许关联”外,Runtime 至少应校验:
|
||||
|
||||
```text
|
||||
同 app_scope
|
||||
同 instance_id
|
||||
同 conversation_id
|
||||
父 Tool 非终态
|
||||
不存在跨 owner 关联
|
||||
```
|
||||
|
||||
还要定义父 Tool 被取消或超时时关联 interaction 的处理:自动取消、保留为独立操作,或交由 Policy 决定。否则恢复后容易遗留孤儿卡片。
|
||||
|
||||
### S2. 将 presentation policy 写成可判定矩阵
|
||||
|
||||
至少应覆盖:
|
||||
|
||||
| owner 状态 / 容器 | 建议行为 |
|
||||
|---|---|
|
||||
| Agent IM / Interact 有效 | Interact IM timeline/card。 |
|
||||
| owner 前台且存在合法 renderer | owner 容器或已绑定 Surface 的标准 modal/sheet/card。 |
|
||||
| owner 后台或 suspended | 保持 pending;通知/唤醒仅经 Policy,不得抢占焦点。 |
|
||||
| Surface 已关闭或失效 | 不向旧 Surface 投递;恢复、降级或安全失败。 |
|
||||
| renderer 不可用 | 保持 pending 或返回稳定拒绝码;不能把 payload 注入 Host DOM。 |
|
||||
| 系统能力确认 | 走 Capability Gateway,禁止降级为普通 `confirm`。 |
|
||||
|
||||
### S3. Interact 呈现与 Agent Tool 回传的分层
|
||||
|
||||
**已确认(2026-08-05):** 用户在 Interact 中回答时,Interact 只负责显示问题、收集用户动作并把
|
||||
答案交给 Runtime。它不是 Agent 的消息发送端,也不负责判断答案属于哪个 Agent、哪个会话或哪个 App
|
||||
子会话。
|
||||
|
||||
```text
|
||||
Interact / Shell 显示问题
|
||||
→ 用户作答
|
||||
→ Interact 向 Runtime 提交「interaction_id + 用户动作/答案」
|
||||
→ Runtime 从已保存的交互记录取得 agent_id、conversation、App 子会话和 Agent Tool 归属
|
||||
→ Runtime 核对 Interact 实例、展示位置、状态、期限、答案格式和首次提交
|
||||
→ Runtime 持久化会话记录与终态,并写入可靠 outbox
|
||||
→ Runtime 向当前唯一 Agent 回传一次结果
|
||||
```
|
||||
|
||||
提交端不能传递或覆盖 `agent_id`、`conversation_id`、`call_id`、`app_session_id` 等归属信息;这些
|
||||
只能由 Runtime 创建交互时保存。重复点击、刷新后的旧页面、无效展示位置、过期或已结束问题的提交都
|
||||
必须被拒绝,且不能改变已经保存的结果或再次通知 Agent。Task Dashboard、Whiteboard 等 bundled
|
||||
MiniApp 始终不参与这条链路,也读不到问题或答案。
|
||||
|
||||
### S4. 统一阶段编号
|
||||
|
||||
**已确认(2026-08-05):** Task Dashboard 与 Whiteboard 是 `03.sdk_and_coreapp` 中用来验证 SDK 的
|
||||
bundled 参考 MiniApp,本阶段就完成其端到端路径。因此删除重复的 `03.reference-miniapps`;后续阶段
|
||||
保持为 `04.app-delivery-registry`,不需要整体重编号。
|
||||
|
||||
### S5. 在实施步骤中拆出标准交互服务和 renderer/Bridge 契约
|
||||
|
||||
**已确认(2026-08-05):** 实施顺序已调整为:
|
||||
|
||||
1. 冻结契约与测试样本;
|
||||
2. 实现 Runtime 通用 Tool 闭环;
|
||||
3. 实现 Runtime 独占的 Agent interaction service,以及 Runtime ↔ Interact 的最小呈现/提交契约;
|
||||
4. 再适配 Interact IM 和 App 子会话;
|
||||
5. 最后通过 Task Dashboard、Whiteboard 验证一般 bundled MiniApp 路径,并进行端到端验收。
|
||||
|
||||
这样标准交互不会先被做成 Chat 专用或 MiniApp SDK 能力;Interact 只是第一个使用 Runtime 私有交互
|
||||
契约的系统级呈现者,两个 bundled MiniApp 则只验证各自的 Tool、Surface 和业务 UI 边界。
|
||||
|
||||
## 5. 建议新增的最小验收场景
|
||||
|
||||
1. A 实例提交属于 B 实例、不同 conversation 或已终态 Tool 的 `parent_call_id` 时,Runtime 拒绝且不创建 record。
|
||||
2. A 创建 interaction 后,B 无法 `get`、订阅、取消、提交,或通过伪造 `interaction_id` 获得任何 payload/result。
|
||||
3. 同一 interaction 的两次 renderer submit 只有一次成功;第二次得到稳定终态错误且不改变已持久化结果。
|
||||
4. 旧 `lineup.v1.tool.call(choice|confirm|input)` 创建关联 Tool Call 和 owner 为 Interact IM context 的 interaction;用户结果先由 Runtime 落库,再由 Interact 以 owner 身份完成 Tool。
|
||||
5. Task Dashboard 无 parent Tool 的 `sdk.ui.request(confirm)` 不创建 Agent-facing Tool Call、不写 Agent outbox;带合法 parent Tool 时仅关联,不自动完成该 Tool。
|
||||
6. Task Dashboard 前台时,标准 modal/sheet/card 在其合法容器显示,焦点栈与 Interact background 状态不被改写。
|
||||
7. Whiteboard 在 Surface reload、关闭、旧 iframe 重放提交时,`surface_id` 与 renderer binding 都被验证,旧 iframe 消息被拒绝。
|
||||
8. 非法 input、重复/未知 choice action、过期 interaction、跨 owner cancel 都被拒绝且不写错误结果。
|
||||
9. 标准交互输入值、提示正文、选择标签不出现在日志、Telemetry 或 Audit 明文中;重启恢复仅保留设计允许的最小数据。
|
||||
10. 若维持 `bundled-reference`,Task Dashboard 不能直接挂载可信 Host DOM;若改为 `system`,必须有对应的 manifest/source-trust 回归用例。
|
||||
|
||||
## 6. 建议的阅读和决策顺序
|
||||
|
||||
明天继续时,建议按以下顺序决策和回填:
|
||||
|
||||
1. 先决定 **B1:Task Dashboard 的信任级别与 UI 容器**;
|
||||
2. 冻结 **I2:Renderer → Runtime 的提交/Bridge 契约**;
|
||||
3. 冻结 **I3/I4:StandardInteractionRecord 与 Tool Call 的关系,以及 UI result 的唯一返回通道**;
|
||||
4. 细化 **I5/I6:schema、状态机、幂等、错误码、持久化与日志最小化**;
|
||||
5. 把 S1~S5 与第 5 节的验收场景回填入 [03.sdk_and_coreapp.md](03.sdk_and_coreapp.md) 和必要的 [APP架构设计.md](../../APP架构设计.md)。
|
||||
|
||||
在上述收敛前,不建议开始 `standard-interaction-service` 或参考 MiniApp 的实现,以免重新形成 Chat / Interact 专用旁路。
|
||||
@@ -0,0 +1,164 @@
|
||||
# `03.sdk_and_coreapp` 第 2 次设计评审记录
|
||||
|
||||
> 评审编号:02
|
||||
> 评审日期:2026-08-05
|
||||
> 评审对象:[03.sdk_and_coreapp.md](03.sdk_and_coreapp.md)、[01.design_review.md](01.design_review.md)
|
||||
> 评审方式:独立子 agent 只读复核
|
||||
> 结论:核心方向已收敛;没有 P0 阻塞问题。以下 P1 项需在开始编码前逐项确认并回填主定义文档。
|
||||
|
||||
## 问题清单(Outline)
|
||||
|
||||
> **状态标记:** ✅ 已确认并回填设计; 🟡 待产品/架构决策; 🔵 待在契约中细化; ⚪ 文档或实施编排建议; — 不适用或无此问题。
|
||||
>
|
||||
> 本清单是本次评审的当前有效视图。后文保留每个问题的背景和建议;在问题被确认前,建议不是实施依据。
|
||||
> 确认后应先更新本清单,再回填 [03.sdk_and_coreapp.md](03.sdk_and_coreapp.md) 的契约、实施步骤和验收项。
|
||||
|
||||
| 状态 | 编号 | 问题 | 当前结论 / 下一步 |
|
||||
|---|---|---|---|
|
||||
| — | P0 | 阻塞级架构冲突 | 本次未发现需要推倒既有模型的 P0 问题。 |
|
||||
| ✅ | P1-1 | Task Dashboard 示例中 Tool 完成后关闭 App | 已回填:Tool 完成只回传 Agent;App 保留、后台或关闭由用户动作、`requestClose` 或 Lifecycle Policy 决定,只有真正 `AppLifecycleManager.close` 才结束 App 与子会话。 |
|
||||
| ✅ | P1-2 | `interactive` Tool 的专用路由 | 已回填:`interactive` 是 Runtime 自己处理的 Agent 交互分支,不创建/复用业务 MiniApp instance,不调整业务 App 焦点,也不进入任何 MiniApp SDK Tool Inbox;Runtime 私有契约交给 Interact / Shell 呈现。 |
|
||||
| — | P1-3 | 展示位置切换后的提交资格 | 不再适用:展示位置不是交互 owner 或提交权限。切换 App 前后台、关闭 App 或改在 IM 显示不改变同一 `interaction_id` 的归属;Runtime 只接受首次有效回答,之后才拒绝重复/重放。 |
|
||||
| ✅ | P1-4 | Tool 与交互的取消、超时和并发提交 | 已回填:用户回答、Agent dismiss、到期和 Runtime 失败竞争同一唯一终态;Runtime 第一个原子状态写入获胜,Tool 跟随同一结果,且只写一条 outbox。interactive Tool 仅使用交互 `expires_at`,不维护独立超时。 |
|
||||
| ✅ | P1-5 | `notice` 的 Tool 完成时点与结果 | 已回填:notice 必须保留为 Interact / IM 的只读卡片,可选同时 Toast 数秒;Runtime 持久化卡片并安排呈现后,立即以 `{ outcome: "accepted" }` 完成并回传,不表示用户已阅读。 |
|
||||
| — | P2-1 | 将普通标准交互统一当作敏感秘密输入 | 不再适用:`notice / choice / confirm / input` 按普通 IM 会话内容与日志基线处理;本迭代不为它们另设秘密输入契约或专门负向扫描。未提交草稿仍只在运行期存在,重启清空。 |
|
||||
| ⚪ | P4-1 | 首次评审中的历史提案可读性 | **遗留问题:** 不阻塞本迭代;建议在下一次文档整理或新评审时,进一步突出其中 `sdk.ui`、owner、`parent_call_id` 等旧提案仅供追溯、不可实施。 |
|
||||
| ⚪ | P4-2 | `password-input` 秘密输入原语 | **遗留问题:** 当前不阻塞 `03`;真正出现密码、卡密、私钥或临时 token 的 Agent 交互需求时,单独定义该原语及其不显示明文、不进入普通 IM 正文/日志/Telemetry/审计、可靠传递与清理等安全契约。 |
|
||||
|
||||
## 通过项
|
||||
|
||||
- 标准交互已经清楚限定为 Interact 中的人与 Agent 会话交互,而非 MiniApp SDK;公开 SDK 不包含 `sdk.ui`。
|
||||
- Task Dashboard、Whiteboard 已收敛为 `kind = bundled`,必须走受限 Surface / Bridge,不能取得可信 Host DOM、Tauri、Transport、Store、Agent 或其他 MiniApp 数据。
|
||||
- 用户答案经 Interact 交给 Runtime;Runtime 保存交互归属、校验、持久化并可靠回传当前唯一 Agent,bundled MiniApp 不参与。
|
||||
- App 子会话正确区分了人与 Agent 的记录和 MiniApp 内部业务操作;前后台、关闭、历史只读和“继续处理”的总体规则一致。
|
||||
- MVP 单 Agent 边界明确,没有提前引入多 Agent 连接、切换或路由。
|
||||
|
||||
## P0:阻塞问题
|
||||
|
||||
无。
|
||||
|
||||
## P1:开始编码前需要确认的事项
|
||||
|
||||
### P1-1:Tool 完成不应自动关闭 Task Dashboard
|
||||
|
||||
**已解决(2026-08-05):** 主定义文档已经确认“Agent Tool 完成不自动关闭 App,也不结束 App
|
||||
子会话”。Task Dashboard 示例与验收现已统一为该规则。
|
||||
|
||||
当前规则:
|
||||
|
||||
```text
|
||||
Tool 完成
|
||||
→ Runtime 持久化并回传 Agent
|
||||
→ App 是否保持前台、进入后台或关闭,取决于用户动作、MiniApp requestClose 或 Lifecycle Policy
|
||||
→ 只有真正执行 AppLifecycleManager.close,才结束 App 与 App 子会话
|
||||
```
|
||||
|
||||
验收已要求:Tool 成功回传后 Dashboard 可继续使用;只有真正关闭才结束子会话。
|
||||
|
||||
### P1-2:interactive Tool 必须不进入 MiniApp SDK Tool Inbox
|
||||
|
||||
**已解决(2026-08-05):** 通用 Tool 路由、实施步骤和验收已明确拆开 `interactive` 与一般 Tool。
|
||||
`notice / choice / confirm / input` 不会投递到 App Orchestrator、业务 MiniApp instance、SDK Tool Inbox
|
||||
或 `sdk.tools.subscribe`。
|
||||
|
||||
当前规则:
|
||||
|
||||
```text
|
||||
handling = interactive
|
||||
→ 不投递任何 MiniApp SDK Tool Inbox
|
||||
→ Runtime Agent interaction service 创建 StandardInteractionRecord
|
||||
→ Runtime 私有契约交给 Interact / Shell 呈现
|
||||
→ Interact 提交 interaction_id + 用户动作/答案
|
||||
→ Runtime 校验、持久化、outbox 回传 Agent
|
||||
|
||||
handling = direct / launch / foreground / operation
|
||||
→ 才进入 App Orchestrator、SDK Tool Inbox、MiniApp tools.* 路径
|
||||
```
|
||||
|
||||
验收已要求:Agent 发起的四类标准交互不得出现在任何 MiniApp Inbox 或 `sdk.tools.subscribe`。
|
||||
|
||||
### P1-3:切换展示位置后,旧页面必须失去提交资格
|
||||
|
||||
**不再适用(2026-08-05):** 该问题错误地把 App 上方覆盖层或 IM 卡片当成了交互的 owner。标准交互
|
||||
始终属于 Interact / 主 IM;`app_session_id` 只保留与哪段 App 工作相关的上下文,不形成 UI 父子关系。
|
||||
|
||||
切换 App 前后台、关闭 App 或改变展示位置不改变同一个 `interaction_id` 的归属,也不需要用
|
||||
`presentation_id` / `presentation_revision` 废止旧页面的回答资格。只要用户仍处于有效 LineUp /
|
||||
Interact 会话且交互尚未终态,Runtime 可接受该交互的首次有效回答;以后到达的重复、重放或迟到提交
|
||||
因交互已经终态而被拒绝。Shell 可以避免用户看到重复视觉卡片,但这是体验策略,不是结果正确性的依据。
|
||||
|
||||
验收应覆盖:交互从 Whiteboard 覆盖层改在 IM 中显示、Whiteboard 关闭后交互仍 pending 时,任一有效
|
||||
Interact 页面提交的第一份答案只产生一次持久化结果和一次 Agent 回传;后续提交不改变结果。
|
||||
|
||||
### P1-4:Tool 与交互的取消、超时和并发提交需要收敛规则
|
||||
|
||||
**已解决(2026-08-05):** Runtime 是标准交互的唯一终态裁决者。用户回答、Agent dismiss、到期和
|
||||
Runtime 失败都竞争同一条交互的唯一结果。
|
||||
|
||||
当前规则:
|
||||
|
||||
```text
|
||||
Runtime 对仍为 pending / presented 的 interaction 执行原子状态写入
|
||||
→ 第一个成功写入的动作获胜
|
||||
→ 用户回答:持久化答案,Tool 得到 answered
|
||||
→ Agent dismiss:交互与 Tool 得到 cancelled
|
||||
→ expires_at 到期:交互与 Tool 得到 expired
|
||||
→ Runtime 失败:交互与 Tool 得到 failed
|
||||
→ 同一事务或等价原子动作中只创建一条 Tool 结果 outbox
|
||||
|
||||
任何后到的回答、dismiss、取消或超时处理
|
||||
→ interaction_already_final
|
||||
→ 不覆盖结果,不创建第二条 outbox
|
||||
```
|
||||
|
||||
MVP 中 interactive Tool 没有另一套独立 timeout;其期限就是交互 `expires_at`。Agent 主动停止等待必须走
|
||||
`dismiss`,不走独立的通用 Tool 取消路径。
|
||||
|
||||
### P1-5:`notice` 的完成时点和 Tool 结果需要固定
|
||||
|
||||
**已解决(2026-08-05):** `notice` 是 Interact / IM 中持久保留的只读卡片;Toast 只是同一条 notice
|
||||
记录的可选短暂提醒,不是没有历史的独立消息。
|
||||
|
||||
```text
|
||||
Runtime 校验并持久化 notice 卡片
|
||||
→ 交给 Interact 呈现
|
||||
→ 当前前台界面可同时 Toast 数秒
|
||||
→ Runtime 立即以 { outcome: "accepted" } 完成并回传一次 Tool 结果
|
||||
```
|
||||
|
||||
`accepted` 只表示 Runtime 已接受、保存并安排呈现,不表示用户已看见或阅读。Toast 自动消失、用户忽略
|
||||
Toast 或收起 notice 卡片,均不产生新的 Agent 结果;持久化或安排呈现前 Runtime 失败时,结果为 `failed`。
|
||||
|
||||
## P2:本迭代验收前必须处理(本次无未决项)
|
||||
|
||||
### P2-1:将普通标准交互统一当作敏感秘密输入
|
||||
|
||||
**不再适用(2026-08-05):** 此问题把所有标准交互一概当成秘密输入,边界过重且不符合产品语义。
|
||||
`notice`、`choice`、`confirm` 与普通 `input` 是人与 Agent 的正常 IM 会话内容:已提交的内容按普通 IM
|
||||
会话历史、保留与日志基线处理,不在 SDK v1 中额外定义为密码或秘密。全局日志基线仍然有效,不能因此
|
||||
随意打印会话正文、Tool 参数或 token。
|
||||
|
||||
未提交的 `input` 草稿仍只能存在于当前运行期;重启后清空,不自动提交、发送或进入 outbox。这是恢复
|
||||
和正确性要求,不代表普通 `input` 已升级为秘密输入能力。
|
||||
|
||||
## P4:遗留问题(不阻塞当前迭代)
|
||||
|
||||
### P4-1:首次评审中的历史提案可读性
|
||||
|
||||
**延期原因:** 顶部 Outline 和总体结论已经说明早期 `sdk.ui`、owner、`parent_call_id` 等提案不再是
|
||||
实施依据;这不影响当前 SDK、Runtime 或验收实现。
|
||||
|
||||
建议在下一次文档整理或新的设计评审时,将原始评审正文加上“仅供追溯,禁止作为实现、测试或验收依据”
|
||||
的更醒目标识,或移至附录。当前有效结论始终以主定义文档和评审记录顶部的 Outline 为准。
|
||||
|
||||
### P4-2:`password-input` 秘密输入原语
|
||||
|
||||
**延期原因:** 当前 `03` 要完成的是 `notice / choice / confirm / input` 的 SDK v1 与会话交互闭环;
|
||||
目前没有真实的密码、卡密、私钥或临时 token 输入场景。把秘密输入仅做成普通 `input` 的掩码样式,不能
|
||||
解决内容怎样保留、传递、重试和清理的问题,因此不在本迭代仓促实现。
|
||||
|
||||
**重新评估条件:** Agent 确实需要向用户收集密码、卡密、私钥、临时 token 或同类秘密时,启动单独设计。
|
||||
届时 `password-input` 应作为与 `input` 并列的 Agent 交互原语,由 Runtime 按 `kind` 强制执行安全契约,
|
||||
而不是允许 Agent 用普通 `input` 自行约定保护方式。至少要明确:界面不显示明文;主 IM 只保留“已提交
|
||||
敏感信息”等替代记录;内容不进入普通日志、Telemetry 或审计;可靠 outbox 的暂存、加密(如需要)、
|
||||
Agent 接收后的清理、重启、失败、重试与一次性传递规则。
|
||||
@@ -0,0 +1,174 @@
|
||||
# `03.sdk_and_coreapp` 第 3 次设计评审记录
|
||||
|
||||
> 评审编号:03
|
||||
> 评审日期:2026-08-05
|
||||
> 评审对象:[03.sdk_and_coreapp.md](03.sdk_and_coreapp.md)
|
||||
> 参考资料:[APP架构设计.md](../../APP架构设计.md)、[01.design_review.md](01.design_review.md)、[02.design_review.md](02.design_review.md)
|
||||
> 评审方式:基于当前主定义的独立只读复核;重点检查已确认的边界在契约、实施步骤与验收目标之间是否能够由同一套实现兑现。
|
||||
> 结论:产品层级、标准交互归属和受限 MiniApp 信任模型已稳定;未发现 P0 架构冲突。经本轮统一收敛,3 项 P1 与 2 项 P3 均已确认并回填主定义文档;后续不再进行纯设计评审,直接进入实现,并在实现完成后做一次关闭式复核。
|
||||
|
||||
## 问题清单(Outline)
|
||||
|
||||
> **状态标记:** ✅ 已确认并回填设计; 🟡 待产品/架构决策; 🔵 待在契约中细化; ⚪ 文档或实施编排建议; — 不适用或无此问题。
|
||||
>
|
||||
> 本清单是本次评审的当前有效视图。后文说明问题为何会造成实现分歧,并给出需要冻结的最小决策;在问题确认前,建议不是实施依据。确认后应先更新本清单,再回填 [03.sdk_and_coreapp.md](03.sdk_and_coreapp.md) 的契约、实施步骤和验收项。
|
||||
|
||||
| 状态 | 编号 | 问题 | 当前结论 / 下一步 |
|
||||
|---|---|---|---|
|
||||
| — | P0 | 阻塞级架构冲突 | 未发现。Runtime 为唯一通信与裁决方、标准交互属于 Interact、bundled MiniApp 受限运行的主线一致。 |
|
||||
| ✅ | P1-1 | 用户关闭 App 时,尚未结束的普通 MiniApp Tool 怎样收敛 | 已确认并回填:一旦 Runtime 接受关闭,未终态的普通 Tool 原子转为 `cancelled(app_closed)`;已提交结果只继续 outbox;随后关闭 Surface、停止 instance、结束子会话并恢复焦点。标准交互不自动取消。 |
|
||||
| ✅ | P1-2 | Agent `dismiss` 等待中的标准交互,缺少可执行的控制契约 | 已确认并回填:Runtime 私有 `interaction.dismiss` 以 `control_id + call_id` 请求;Runtime 从受认证 Envelope 推导 Agent/会话,幂等处理并只写唯一 cancelled outbox。App 已关闭事件带出仍 pending 的交互 call_id。 |
|
||||
| ✅ | P1-3 | `interactive` Tool 如何确定 App 子会话关联,和通用 `ToolDescriptor.target` 怎样一致 | 已确认并回填:无 `app_session_context` 一律归主 IM;有上下文时 Runtime 验证同 Agent、同父会话的 App 子会话,已关闭会话可仅作历史关联。interactive 不使用普通 Tool target。 |
|
||||
| ✅ | P3-1 | 标准交互请求类型不能直接作为 fixture 或代码依据 | 已确认并回填:去除重复字段,定义请求联合类型;可回答交互使用 Runtime 计算的 `expires_in_ms`,默认 15 分钟、范围 1 分钟至 24 小时;notice 不接受期限。 |
|
||||
| ✅ | P3-2 | Whiteboard 的完成后关闭表述与统一生命周期规则冲突 | 已确认并回填:所有 bundled App 的普通 Tool 完成只结束 Tool;关闭 Surface/instance/子会话只能由显式 Lifecycle 关闭处理。重复目录条目已删除。 |
|
||||
|
||||
## 通过项
|
||||
|
||||
- `Interact` 是唯一 `kind = system` MiniApp;Task Dashboard 与 Whiteboard 均是 `kind = bundled`,没有可信 Host DOM、Tauri、Transport、Store、Agent 或任意网络特权。
|
||||
- `notice / choice / confirm / input` 始终是人与 Agent 的会话交互,不进入任何 MiniApp SDK Inbox 或 `sdk.tools.subscribe`;前台 bundled App 上的视觉覆盖不成为 owner 或提交权限。
|
||||
- Runtime 保存交互归属并以原子状态写入决定唯一结果;用户首次有效回答、Agent dismiss、到期和 Runtime 失败不会产生多条 Agent outbox。
|
||||
- App 子会话只保存人与 Agent 围绕 App 的交互,不记录 Dashboard 表单、画板编辑等 App 内部业务动作;关闭 App 不自动取消仍 pending 的 Interact 交互。
|
||||
- 普通 `input` 按现有 IM 内容与日志基线处理;秘密输入已正确作为未来独立的 `password-input` P4 遗留事项保留在 [02.design_review.md](02.design_review.md)。
|
||||
|
||||
## P0:阻塞问题
|
||||
|
||||
无。
|
||||
|
||||
## P1:开始相关编码前必须确认(均已解决)
|
||||
|
||||
### P1-1:用户关闭 App 时,尚未结束的普通 MiniApp Tool 怎样收敛
|
||||
|
||||
**用人话说:** 用户把 Task Dashboard 或 Whiteboard 关掉时,Runtime 不能让刚才交给那个 App 的工作
|
||||
悬在半空。Agent 要么收到“这项工作已取消/失败”的唯一结果,要么 Runtime 明确保留一个可恢复、仍有执行者
|
||||
的工作;不能只关闭画面而不说明 Tool 的命运。
|
||||
|
||||
当前文档同时出现了三种没有被统一的说法:
|
||||
|
||||
```text
|
||||
Task Dashboard Tool 完成
|
||||
→ 不自动关闭 Dashboard 或 App 子会话
|
||||
|
||||
Whiteboard App 完成或失败
|
||||
→ Runtime 关闭 Surface、恢复 Interact
|
||||
|
||||
MiniApp requestClose()
|
||||
→ Runtime 检查 pending Tool、Surface、operation 和 Policy
|
||||
→ 允许关闭或返回拒绝码
|
||||
```
|
||||
|
||||
最后一条没有说明“检查之后”的规则。若用户关闭正在执行 Tool 的 App,Tool 是被拒绝关闭、由 Runtime 先取消
|
||||
Tool、由 App 收到取消后再关闭,还是允许 Surface 消失但实例在后台恢复执行?这些选择对 outbox、恢复和用户
|
||||
看到的状态都不同。Whiteboard 的“完成后关闭”也与 Task Dashboard 的“完成不关闭”相互冲突。
|
||||
|
||||
**已确认(2026-08-05):** 一旦 Runtime 接受用户、MiniApp 或 Policy 的关闭请求,关闭的意思就是释放该
|
||||
App instance,不是把它偷偷留在后台。Runtime 不再向该 instance 投递新 Tool;对绑定该 instance 且仍为
|
||||
`received / routing / waiting_for_app / running` 的普通 Tool,以 `app_closed` 原因原子转为 `cancelled`,并且
|
||||
每条 Tool 只写一条 cancelled outbox。已处于 `submitted` 的 Tool 结果不可改写,Runtime 继续将已固定的结果
|
||||
可靠发出。之后 Runtime 关闭 Surface、停止 instance、结束 App 子会话并恢复前一有效前台 App。
|
||||
|
||||
同一时刻 Tool 完成与关闭竞争时,第一个原子终态写入获胜;重复关闭、重启恢复和迟到上报不得产生第二条
|
||||
outbox。用户若只想暂时离开 App,应进入后台而不是关闭。此规则只处理普通 MiniApp Tool;仍 pending 的
|
||||
人与 Agent 标准交互不自动取消,Runtime 仅通知 Agent,由 Agent 选择是否 dismiss。
|
||||
|
||||
以下原“需要冻结”的项目均由上述决议覆盖:
|
||||
|
||||
1. 对每种 Tool 状态(`waiting_for_app`、`running`、`submitted` 等),规定用户/Policy 请求关闭时的行为;
|
||||
2. 规定普通 Tool 的最终结果由谁写入、是否必须先让 Tool 进入 `completed / failed / cancelled / expired` 才能完成关闭;
|
||||
3. 明确“关闭 App instance”“关闭该 App 的 Surface”“Tool 成功/失败”三者不是同一个事件,并定义允许的先后顺序;
|
||||
4. 将 Task Dashboard 与 Whiteboard 的完成、关闭和焦点恢复规则统一到同一条生命周期原则;
|
||||
5. 增加关闭进行中 Tool、重启恢复和重复关闭只产生一个最终 Tool outbox 的验收 fixture。
|
||||
|
||||
### P1-2:Agent `dismiss` 等待中的标准交互,缺少可执行的控制契约
|
||||
|
||||
**用人话说:** 文档已经允许 Agent 看到“画板已关闭”后,决定把之前的问题收起来。这很好;但还没有写清
|
||||
Agent 发来的“收起这题”消息长什么样,Runtime 怎么确认它收的是正确那一道题,以及网络重发时如何不重复
|
||||
通知 Agent。实现者因此可能各自发明一个临时控制消息。
|
||||
|
||||
当前仅有行为描述:
|
||||
|
||||
```text
|
||||
Agent remote dismiss
|
||||
→ Runtime 将 pending / presented interaction 终结为 cancelled
|
||||
→ 写入唯一 cancelled outbox
|
||||
```
|
||||
|
||||
但缺少下面的契约:
|
||||
|
||||
- Agent 发起 `dismiss` 使用 `interaction_id`、原始 `call_id`,还是二者都使用;
|
||||
- Runtime 如何从已保存记录校验 `agent_id`、`conversation_id` 与当前状态,而不是信任客户端字段;
|
||||
- 目标已回答、已到期或同一条 dismiss 重放时,应得到何种稳定回执,是否绝不新增 outbox;
|
||||
- Runtime 发出的 App 已关闭事件如何关联原 Tool/interaction,使 Agent 能准确选择要 dismiss 的问题;
|
||||
- dismiss 控制请求本身如何去重、审计并在 Runtime 重启后恢复处理。
|
||||
|
||||
**已确认(2026-08-05):** 定义 Runtime 私有的 Agent → Runtime `interaction.dismiss` 控制契约。Agent 使用
|
||||
自己发起原 interactive Tool 时持有的 `call_id` 定位目标,并为每次控制请求提供唯一 `control_id`。Runtime 从
|
||||
受认证 Envelope 取得 `agent_id` 与 `conversation_id`,不信任请求额外携带的归属字段;它以这两个值和
|
||||
`call_id` 查找交互,原子地将仍 pending / presented 的记录转为 cancelled 并写入唯一 outbox。已终态或重放
|
||||
请求只返回稳定幂等回执,不覆盖结果、不再写 outbox。
|
||||
|
||||
App 已关闭事件会携带仍 pending 的 `pending_interaction_call_ids`,让 Agent 能按业务需要精确 dismiss;该控制
|
||||
契约不属于 MiniApp SDK,也不能投递给 Interact 或 bundled MiniApp。
|
||||
|
||||
### P1-3:`interactive` Tool 如何确定 App 子会话关联,和通用 `ToolDescriptor.target` 怎样一致
|
||||
|
||||
**用人话说:** 视觉上哪个 App 在前台,不等于 Agent 的问题就属于哪个 App。例如用户正看 Whiteboard,
|
||||
Agent 仍可能问一条普通 IM 问题;反过来,画板已经关了,Agent 仍可能针对刚才的画板提问。因此 Runtime
|
||||
不能用“当前前台 App”猜测问题应该放进哪个子会话。
|
||||
|
||||
当前记录有可选 `app_session_id`,且要求与 App 有关的问题写进子会话;但没有说明 Agent Tool 请求如何
|
||||
表达这层上下文、Runtime 如何验证它。与此同时,所有 `ToolDescriptor` 都要求 `target.app_scope`,而
|
||||
`interactive` 明确不创建/复用任何业务 MiniApp,也不投递给 Interact 的 SDK Tool Inbox。这会造成至少两种
|
||||
不兼容实现:有人将交互强行 target 到 `chat`,有人直接跳过 `target`,也有人按当前前台 App 自动归档。
|
||||
|
||||
**已确认(2026-08-05):** 不带 `app_session_context` 的 interactive Tool 一律记录在主 IM,不创建或猜测
|
||||
App 子会话。Agent 若要关联某段 App 工作,可在 interactive Tool 的运行时上下文中提供
|
||||
`app_session_context.app_session_id`;Runtime 必须验证其属于同一 `agent_id`、父 conversation 和真实 App
|
||||
子会话。已经关闭的子会话仍可作为历史上下文关联,但不会复活 instance 或旧问题。验证失败则拒绝该 Tool,
|
||||
不创建交互。
|
||||
|
||||
`handling = interactive` 不使用普通 MiniApp Tool 的 `target`,也不因此变为 Interact MiniApp Tool。Runtime
|
||||
在创建 App 子会话时向当前 Agent 可靠发送其稳定 ID,供后续 Agent 交互引用。
|
||||
|
||||
以下原“需要冻结”的项目均由上述决议覆盖:
|
||||
|
||||
1. 不带经验证 App 上下文的 `interactive` Tool 一律写入主 IM,不创建 App 子会话;
|
||||
2. 若 Agent 需要关联某段 App 工作,定义它可引用的稳定 App 子会话标识,以及 Runtime 必须验证的
|
||||
`agent_id`、父 conversation、App session 状态和权限边界;已关闭 App 关联的问题是否仍允许保留,需要明确;
|
||||
3. 为 `handling = interactive` 定义与普通 `ToolDescriptor.target` 不同的明确规则:例如 target 对它不适用,
|
||||
或只允许一个受限的“会话上下文”字段;不能让它暗中变成 Interact MiniApp Tool;
|
||||
4. 在 fixture 中覆盖“Whiteboard 前台但问题属于主 IM”“App 已关闭但问题仍关联旧子会话”“非法/跨会话
|
||||
`app_session_id` 被拒绝并不产生交互”三种情况。
|
||||
|
||||
## P2:本迭代验收前必须处理(本次无未决项)
|
||||
|
||||
本次未发现独立 P2 问题。P1 项处理后,应把其中的关闭、dismiss、上下文绑定 fixture 纳入本迭代验收。
|
||||
|
||||
## P3:本迭代结束前必须处理(均已解决)
|
||||
|
||||
### P3-1:标准交互请求类型不能直接作为 fixture 或代码依据
|
||||
|
||||
`ChoiceRequest` 代码块中 `prompt` 出现两次。它不是不同字段,直接照抄会造成 TypeScript 重复属性定义。
|
||||
此外,记录使用了尚未声明的 `StandardInteractionRequest`,文字规定 Agent 可给出 1 分钟至 24 小时的期限,
|
||||
但四种最低请求类型都没有统一的期限字段。
|
||||
|
||||
**已确认(2026-08-05):** 删除重复 `prompt`;`StandardInteractionRequest` 由四种请求组成。可回答交互
|
||||
在请求中使用 `expires_in_ms`,Runtime 根据自身当前时间计算并持久化 `expires_at`;未提供时默认 15 分钟,
|
||||
仅接受 1 分钟至 24 小时。notice 不等待回答,也不接受期限。fixture 覆盖默认、越界和 notice 错带期限。
|
||||
|
||||
### P3-2:Whiteboard 的完成后关闭表述与统一生命周期规则冲突
|
||||
|
||||
Task Dashboard 已清楚规定 Tool 完成不会关闭 App;Whiteboard 的最小体验却写成“App 完成或失败,Runtime
|
||||
关闭 Surface、恢复 Interact”。这会让两个同为 `kind = bundled` 的参考 App 获得不同且没有声明依据的
|
||||
生命周期语义,也和“只有 `AppLifecycleManager.close(instance_id)` 才停止实例/结束子会话”不一致。
|
||||
|
||||
**已确认(2026-08-05):** Whiteboard 与 Task Dashboard 使用相同规则:Tool `complete / fail` 只结束 Tool;
|
||||
Surface、instance、焦点和 App 子会话仅由前后台或显式 `AppLifecycleManager.close` 处理。SDK v1 不为
|
||||
Whiteboard 预设“提交后自动关闭”的例外;将来若需要,必须作为显式 Lifecycle Policy 并遵循 P1-1 的关闭收敛。
|
||||
|
||||
实现模块目录树中的 `conversation-store.ts` 也重复出现一次,应一并删除重复行,避免错误引导目录改动。
|
||||
|
||||
## P4~P5:遗留问题
|
||||
|
||||
本次没有新增 P4/P5。第 2 次评审已登记的 `P4-1`(历史提案可读性)和 `P4-2`(未来 `password-input`
|
||||
秘密输入原语)继续作为不阻塞当前迭代的遗留问题,其延期原因与重新评估条件以
|
||||
[02.design_review.md](02.design_review.md) 为准。
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user