Compare commits

...

31 Commits

Author SHA1 Message Date
kyugao 9f5ea6eb4a docs(iteration): record fifth sdk acceptance 2026-08-06 01:38:22 +08:00
kyugao ca9e8b9791 docs(iteration): record final sdk test count 2026-08-06 01:37:39 +08:00
kyugao fec04a90d7 fix(runtime): restrict system miniapp registration 2026-08-06 01:37:07 +08:00
kyugao e91d07b389 fix(runtime): enforce all miniapp tool permissions 2026-08-06 01:36:30 +08:00
kyugao 94817f798c docs(iteration): refresh sdk acceptance evidence 2026-08-06 01:34:17 +08:00
kyugao b80af9679d docs(iteration): correct bundled acceptance wording 2026-08-06 01:32:03 +08:00
kyugao 44b6a83a30 fix(runtime): freeze miniapp kind vocabulary 2026-08-06 01:31:38 +08:00
kyugao b81a0edcca docs(iteration): mark sdk v1 iteration complete 2026-08-06 01:25:37 +08:00
kyugao aabf58a289 docs(iteration): record completed miniapp bridge e2e 2026-08-06 01:21:44 +08:00
kyugao c7c18168b6 fix(runtime): accept camelcase miniapp bridge methods 2026-08-06 01:20:41 +08:00
kyugao c4613ce14e docs(iteration): retain appserver blocker for surface acceptance 2026-08-06 01:05:28 +08:00
kyugao 180d03896f docs(iteration): record browser surface acceptance 2026-08-06 01:04:18 +08:00
kyugao 20e63dc0dd refactor(runtime): remove legacy extension sdk bypass 2026-08-06 00:55:17 +08:00
kyugao 75529cdd49 fix(runtime): include app session context in lifecycle events 2026-08-06 00:53:31 +08:00
kyugao 0b7257f3d3 fix(runtime): persist miniapp tool progress 2026-08-06 00:51:52 +08:00
kyugao 6fa350267a fix(iteration): align reference miniapp tool names 2026-08-06 00:50:21 +08:00
kyugao 309dd6161d fix(runtime): unify standard interaction terminal states 2026-08-06 00:44:01 +08:00
kyugao 823e71a162 refactor(interact): separate runtime actions from ui projection 2026-08-06 00:32:59 +08:00
kyugao c77a0cf69b feat(runtime): enforce miniapp capability policy 2026-08-06 00:23:11 +08:00
kyugao 58e77462ad feat(runtime): isolate bundled miniapps behind bridge 2026-08-06 00:19:36 +08:00
kyugao a68b7cf3bf fix(runtime): isolate channel messages by target 2026-08-05 19:03:34 +08:00
kyugao 7cac422a31 docs(iteration): clarify final acceptance status 2026-08-05 18:47:18 +08:00
kyugao 121ee6ac73 fix(runtime): align AppServer conversation scope 2026-08-05 18:47:00 +08:00
kyugao d2a2fd0aff feat(iteration): implement sdk v1 miniapp runtime loop 2026-08-05 18:30:29 +08:00
kyugao 486280bec0 docs(iteration): freeze sdk v1 contract fixtures 2026-08-05 16:36:01 +08:00
kyugao 9fb8cd1598 feat(runtime): establish miniapp kernel and sdk design 2026-08-05 00:46:16 +08:00
kyugao d8aa087bc8 feat(m4): verify signed surface bundles across hosts 2026-08-04 01:51:23 +08:00
kyugao 4fc83e5b13 feat(m3): add consent-gated capability runtime 2026-08-04 01:09:20 +08:00
kyugao ccff57b2a1 feat(m2): ship isolated task dashboard surface 2026-08-03 23:11:29 +08:00
kyugao 9fd321c41c feat(m1-08): stream safe execution summaries 2026-08-03 23:10:49 +08:00
kyugao ffda402861 feat: reliably deliver tool interaction responses 2026-08-03 16:33:38 +08:00
103 changed files with 12075 additions and 1181 deletions
+593
View File
@@ -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 Providernotice / choice / confirm / input
```
当前实现仍保留兼容名称:
```text
产品概念:Interact MiniApp
当前 app_scopechat
当前目录: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 中声明 ToolAgent 只能调用当前 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):跨文档详细契约来源;
若与本文的产品术语或当前迭代顺序冲突,以本文为准并同步更新上游方案。
-319
View File
@@ -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 HostAndroid 为 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 SurfaceAgent 可发送 `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`
+91 -23
View File
@@ -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` 兼容实现是其上的第一个系统级 MiniAppInteract 的 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-R1Runtime 托管 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 HostMac 或其他设备可经 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 SpikeWails 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 多端客户端工程混放。
-158
View File
@@ -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 2Reference 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 v3Go 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
View File
@@ -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-01M0-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-02Confirm/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
View File
@@ -1,3 +1,4 @@
// Tauri Rust Host 的应用构造入口;Runtime 协议和业务路由仍位于 TypeScript LineUpRuntime。
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
+1
View File
@@ -1,3 +1,4 @@
// Tauri 桌面二进制入口:仅委托给 lib.rs 中的可信 Host 构造逻辑。
fn main() {
lineup_tauri_lib::run();
}
+61
View File
@@ -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`。这样新应用只增加自己的体验,
不会破坏已有聊天和连接逻辑。
+93
View File
@@ -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));
}
+27
View File
@@ -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;
}
}
@@ -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); }
@@ -1,3 +1,9 @@
/**
* Chat Core App DOM
*
* Markdown sanitizer
* SDK TransportStore 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
View File
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);
}
+138
View File
@@ -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 InventorySurface 或 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;
}
@@ -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 })]);
});
});
-260
View File
@@ -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 })) };
}
@@ -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" }));
});
});
@@ -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,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;
@@ -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";
@@ -1,16 +1,25 @@
/**
* Tool Call
*
* `call_id` choiceconfirminput
* 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 DOMTauri
* Runtime app_scopeconversation_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);
}
+4
View File
@@ -6,6 +6,10 @@
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"allowImportingTsExtensions": false,
"resolveJsonModule": true,
"isolatedModules": true,
+13
View File
@@ -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
View File
@@ -0,0 +1,159 @@
# LineUp App 程序文件清单与功能说明
> 最后核验:2026-08-04Asia/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。两种外壳都会启动同一个
RuntimeRuntime 再加载默认 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 SDKCore 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` | 生产 Manifestimmutable 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 HostTailscale 开发)
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):架构决策的唯一汇总入口。
+124
View File
@@ -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 AppTauri 用于正式桌面交付,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` 中。
+293
View File
@@ -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 AppCore 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 Modeforeground
```
用户说:“我们来玩一局你画我猜吧。” 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` 只支持 26 项单选;`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. 先决定 **B1Task Dashboard 的信任级别与 UI 容器**
2. 冻结 **I2Renderer → Runtime 的提交/Bridge 契约**
3. 冻结 **I3/I4StandardInteractionRecord 与 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 InboxRuntime 私有契约交给 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 交给 RuntimeRuntime 保存交互归属、校验、持久化并可靠回传当前唯一 Agentbundled MiniApp 不参与。
- App 子会话正确区分了人与 Agent 的记录和 MiniApp 内部业务操作;前后台、关闭、历史只读和“继续处理”的总体规则一致。
- MVP 单 Agent 边界明确,没有提前引入多 Agent 连接、切换或路由。
## P0:阻塞问题
无。
## P1:开始编码前需要确认的事项
### P1-1Tool 完成不应自动关闭 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-2interactive 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` MiniAppTask 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-2Agent `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-2Whiteboard 的完成后关闭表述与统一生命周期规则冲突
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` 也重复出现一次,应一并删除重复行,避免错误引导目录改动。
## P4P5:遗留问题
本次没有新增 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