feat(runtime): establish miniapp kernel and sdk design

This commit is contained in:
2026-08-05 00:46:16 +08:00
parent d8aa087bc8
commit 9fb8cd1598
86 changed files with 4615 additions and 1364 deletions
+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`。这样新应用只增加自己的体验,
不会破坏已有聊天和连接逻辑。
+90
View File
@@ -0,0 +1,90 @@
/**
* 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;
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 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,
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));
}
+26
View File
@@ -0,0 +1,26 @@
/**
* 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="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 { ChatRuntimeSDK } 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: ChatRuntimeSDK) {}
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
@@ -1,3 +1,4 @@
/* Chat 中可信 Capability 确认卡片的视觉状态;样式不授予或执行任何系统能力。 */
.capability-card {
display: grid;
justify-self: start;
@@ -1,3 +1,4 @@
/* Chat 内受限执行摘要卡片:只展示 Runtime/Adapter 已归约的公开步骤。 */
.execution-progress-card {
justify-self: start;
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 {
@@ -8,12 +14,12 @@ import type {
InputForm,
JsonObject,
ToolCallRequest,
} from "../protocol";
import type { ToolCallRecord } from "./tool-call-state";
import type { TaskRecord } from "./task-state";
import type { ExecutionProgressSummary } from "./execution-progress-state";
import type { ArtifactRecord } from "./artifact-state";
import type { CapabilityCallRecord } from "./capability-call-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;
+218 -370
View File
@@ -1,43 +1,34 @@
import "./style.css";
import "./capability-card.css";
import "./execution-progress.css";
/**
* Tauri/Desktop 与 Web Reference Host 的组合入口。
*
* 本文件负责创建 Host 侧适配、LineUpRuntime 和默认 Chat Core App,并连接可信的
* Renderer、Surface、Capability 集成。Transport、Store、同步循环、outbox 与原始
* Agent 消息解析均由 Runtime 独占,不能重新回流到此入口。
*/
// 协议层只提供经过 Runtime 校验后的表现模型类型;本入口不直接解析原始网络数据。
import {
type Actor,
type ConversationItem,
type Envelope,
type JsonObject,
parseEnvelopeJSON,
} from "./protocol";
import { ConversationStore } from "./runtime/conversation-store";
import { ExecutionProgressState } from "./runtime/execution-progress-state";
import { InteractionKernel } from "./runtime/interaction-kernel";
import { RendererRegistry } from "./runtime/renderer-registry";
import { HttpTransportAdapter, type TransportAdapter } from "./runtime/transport-adapter";
import { TrustedDOMRendererContext } from "./runtime/trusted-dom-renderers";
import { SurfaceRegistry } from "./runtime/surface-registry";
import { SurfaceInstanceManager } from "./runtime/surface-instance-manager";
import { CachedVerifiedSurfaceDocumentResolver, IsolatedSurfaceHost } from "./runtime/isolated-surface-host";
import { ProductionSurfaceManifestRegistry } from "./runtime/production-surface-manifest";
import { ProductionSurfacePolicy } from "./runtime/production-surface-policy";
import { TrustedSurfaceBundleDownloader, VerifiedSurfaceBundleCache, WebCryptoEd25519SurfaceManifestSignatureVerifier } from "./runtime/surface-bundle-cache";
import { CapabilityRegistry } from "./runtime/capability-registry";
import { ClientInventoryPublisher } from "./runtime/client-inventory";
import { ArtifactState } from "./runtime/artifact-state";
import { ArtifactContentCache } from "./runtime/artifact-content-cache";
import { CapabilityCallStateMachine, type CapabilityCallRecord, parseCapabilityCall } from "./runtime/capability-call-state";
import { CapabilityAuditLog } from "./runtime/capability-audit";
import { CapabilityExecutor, type PickedFile } from "./runtime/capability-executor";
type Session = {
api: string;
uid: string;
agentUID: string;
channelID: string;
channelType: number;
lastSeq: number;
active: boolean;
};
} from "@/runtime/protocol/lineup-v1";
// 下面这些模块分别对应 Host 侧的扩展界面、系统能力、Artifact 和执行状态适配。
import { ExecutionProgressState } from "@/runtime/coordination/execution-progress-state";
import { SurfaceRegistry } from "@/runtime/surfaces/surface-registry";
import { SurfaceInstanceManager } from "@/runtime/surfaces/surface-instance-manager";
import { CachedVerifiedSurfaceDocumentResolver, IsolatedSurfaceHost } from "@/runtime/surfaces/isolated-surface-host";
import { ProductionSurfaceManifestRegistry } from "@/runtime/surfaces/production-surface-manifest";
import { ProductionSurfacePolicy } from "@/runtime/surfaces/production-surface-policy";
import { TrustedSurfaceBundleDownloader, VerifiedSurfaceBundleCache, WebCryptoEd25519SurfaceManifestSignatureVerifier } from "@/runtime/surfaces/surface-bundle-cache";
import { CapabilityRegistry } from "@/runtime/capabilities/capability-registry";
import { ClientInventoryPublisher } from "@/runtime/inventory/client-inventory";
import { ArtifactState } from "@/runtime/artifacts/artifact-state";
import { ArtifactContentCache } from "@/runtime/artifacts/artifact-content-cache";
import { CapabilityCallStateMachine, type CapabilityCallRecord, parseCapabilityCall } from "@/runtime/capabilities/capability-call-state";
import { CapabilityAuditLog } from "@/runtime/capabilities/capability-audit";
import { CapabilityExecutor, type PickedFile } from "@/runtime/capabilities/capability-executor";
import { LineUpRuntime } from "@/runtime/coordination/lineup-runtime";
import { RuntimeAppHost } from "@/runtime/app-management/runtime-app-host";
// Vite 的开发标记和 M4 测试控制器需要扩展浏览器的全局类型。
declare global {
interface ImportMeta {
readonly env: Readonly<{ DEV: boolean }>;
@@ -49,13 +40,16 @@ declare global {
}
}
const DEFAULT_API = "http://127.0.0.1:8090";
let session: Session = { api: localStorage.getItem("lineup.api") || DEFAULT_API, uid: "", agentUID: "agent_hermes_main", channelID: "agent_default_channel", channelType: 2, lastSeq: 0, active: false };
let transport: TransportAdapter | undefined;
let polling = false;
let flushingOutbox = false;
const interactionKernel = new InteractionKernel();
const conversationStore = new ConversationStore(localStorage);
// 这是登录表单首次显示时使用的本地默认地址;用户提交后的地址会保存到 localStorage。
// `0.0.0.0` is a server bind address, never a browser destination. When the
// Web Host is opened locally use loopback; when it is opened through the
// trusted Tailscale Host use the matching reachable AppServer address.
const DEFAULT_API = location.hostname === "127.0.0.1" || location.hostname === "localhost"
? "http://127.0.0.1:8090"
: "http://100.121.118.116:8090";
// 这些状态对象属于当前可信 Host 的装配层:Runtime 负责会话、消息和可靠性,
// 它们负责将 Surface、Capability、Artifact 和执行进度投影到当前页面。
const executionProgress = new ExecutionProgressState();
const surfaceRegistry = new SurfaceRegistry();
const surfaceInstances = new SurfaceInstanceManager(surfaceRegistry);
@@ -65,6 +59,8 @@ const artifactState = new ArtifactState();
const artifactContentCache = new ArtifactContentCache();
const capabilityCalls = new CapabilityCallStateMachine();
const capabilityAudit = new CapabilityAuditLog();
// CapabilityExecutor 只在 Capability 状态机已经批准调用后才会触发这些 Host handler。
// 它不会因为 Agent 发来一个 payload 就直接调用浏览器或桌面能力。
const capabilityExecutor = new CapabilityExecutor({
async openURL(url) {
// With `noopener`, Chromium intentionally returns null even when it did
@@ -77,46 +73,64 @@ const capabilityExecutor = new CapabilityExecutor({
pickFile: pickFileFromTrustedHost,
async saveArtifact(artifactID) { saveCachedArtifact(artifactID); },
}, artifactState);
// LineUpRuntime 是本文件中最重要的底座对象。它独占 Transport、Store、同步循环、
// outbox、App Inbox 和作用域校验;main.ts 只通过 Runtime/SDK 与它协作。
const runtime = new LineUpRuntime({
storage: localStorage,
prepareIncoming(item) { return item.kind === "artifact-offer" ? prepareArtifactOffer(item) : item; },
});
let lastInventoryRevision = "";
const SURFACE_REGISTRY_STORAGE_KEY = "lineup.surface-registry.v1";
try { surfaceRegistry.restore(JSON.parse(localStorage.getItem(SURFACE_REGISTRY_STORAGE_KEY) ?? "null")); } catch { surfaceRegistry.restore(null); }
// RuntimeAppHost 会读取 Runtime 的 Core App Registry,选择并挂载默认 Core App。
// main.ts 不在这里直接创建 Chat/IM 页面。
const app = document.querySelector<HTMLDivElement>("#app");
if (!app) throw new Error("LineUp host root is missing");
app.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="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>`;
// Host 页面是可信的静态 Shell。缺少必要节点属于装配错误,应尽早失败,而不是运行到
// 登录或消息处理时才出现难以定位的空引用。
const $ = <T extends Element>(selector: string): T => {
const found = document.querySelector<T>(selector);
if (!found) throw new Error(`Missing element: ${selector}`);
return found;
};
const messages = $<HTMLElement>("#messages");
const loginView = $<HTMLElement>("#login-view");
const chatView = $<HTMLElement>("#chat-view");
const presence = $<HTMLElement>("#presence");
// 当前默认 Core App 的 SDK。变量在挂载应用后赋值;前面传入的回调只会在用户操作后执行,
// 因此它们可以安全地引用这个稍后完成初始化的 SDK。
let chatSDK: ReturnType<LineUpRuntime["openApp"]>;
// 将 Chat 所需的交互回调注入 App Host。回调本身仍然只能通过 SDK 访问 Runtime
// Chat Renderer 不会获得 Transport、Store 或 Tauri 特权对象。
const mountedApp = new RuntimeAppHost(runtime, app, {
toolInteractions: {
submit(callID, submission) { return chatSDK.interactions.submit(callID, submission); },
cancel(callID, reason) { return chatSDK.interactions.cancel(callID, reason); },
expire(callID) { return chatSDK.interactions.expire(callID); },
read(callID) { return chatSDK.interactions.read(callID); },
loadDraft(callID) { return chatSDK.interactions.loadDraft(callID); },
saveDraft(callID, values) { chatSDK.interactions.saveDraft(callID, values); },
clearDraft(callID) { chatSDK.interactions.clearDraft(callID); },
},
taskInteractions: {
requestCancel(operationID) { return chatSDK.tasks.requestCancel(operationID); },
read(operationID) { return chatSDK.tasks.read(operationID); },
},
capabilityInteractions: {
async approve(callID) { return approveCapabilityCall(callID); },
reject(callID) { return rejectCapabilityCall(callID); },
expire(callID) { return expireCapabilityCall(callID); },
read(callID) { return capabilityCalls.get(callID); },
},
}).mountDefaultApp();
// 从通用挂载结果中取得当前应用的 SDK 和页面投影对象。
chatSDK = mountedApp.sdk;
const { messages, loginView, chatView, presence, rendererContext, rendererRegistry } = mountedApp;
// Surface Host 只负责承载已经通过 Runtime/Surface 策略检查的隔离界面。
// Surface 的 ready/event 回调仍回到状态管理和 SDK Action,不直接执行 Agent 指令。
const surfaceHost = new IsolatedSurfaceHost(messages, {
ready(instanceID) { surfaceInstances.ready(instanceID, new Date().toISOString()); },
event(instanceID) { queueSurfaceCancel(instanceID); },
});
$<HTMLInputElement>("#api").value = session.api;
$<HTMLInputElement>("#api").value = localStorage.getItem("lineup.api") || DEFAULT_API;
/**
* A pre-signed public fixture used only by the headed M4 acceptance route.
@@ -124,7 +138,10 @@ $<HTMLInputElement>("#api").value = session.api;
* the actual Ed25519 → size → SHA-256 → cache → exact resolver path.
*/
async function mountM4SignedFixture(): Promise<void> {
// 这是开发环境专用的签名 Surface 验收入口。正常启动和生产构建不会执行这里。
if (!import.meta.env.DEV || !new URLSearchParams(location.search).has("m4-signed-surface-e2e")) return;
// Bundle 使用公开测试数据,下面的流程会真实执行“验签 → 大小校验 → SHA-256 校验
// → 缓存 → 按精确版本解析”,用于验证生产 Surface 的准入链路。
const encodedBundle = "PG1haW4-PGgyIGlkPSJtNC1zaWduZWQiPk00IOW3suetvuWQjSBTdXJmYWNlPC9oMj48cCBpZD0ic3RhdGUiPuetieW-heeKtuaAgTwvcD48c2NyaXB0PndpbmRvdy5hZGRFdmVudExpc3RlbmVyKCJtZXNzYWdlIixlPT57Y29uc3QgZD1lLmRhdGE7aWYoZCYmZC50eXBlPT09ImxpbmV1cC5zdXJmYWNlLnYxLnN0YXRlIilkb2N1bWVudC5xdWVyeVNlbGVjdG9yKCIjc3RhdGUiKS50ZXh0Q29udGVudD1TdHJpbmcoZC5zdGF0ZT8uc3RhdHVzfHwi6L-Q6KGM5LitIil9KTt3aW5kb3cucGFyZW50LnBvc3RNZXNzYWdlKHt2OjEsdHlwZToibGluZXVwLnN1cmZhY2UudjEucmVhZHkiLGluc3RhbmNlX2lkOndpbmRvdy5fX0xJTkVVUF9TVVJGQUNFX0lOU1RBTkNFX199LCIqIik8L3NjcmlwdD48L21haW4-";
const standardBase64 = encodedBundle.replace(/-/g, "+").replace(/_/g, "/");
const bundle = Uint8Array.from(atob(standardBase64 + "=".repeat((4 - standardBase64.length % 4) % 4)), value => value.charCodeAt(0));
@@ -155,6 +172,7 @@ async function mountM4SignedFixture(): Promise<void> {
};
}
/** 生成只在本地 Store 中使用的临时 ID,不把它当作服务器身份或消息协议 ID。 */
function newLocalID(prefix: string): string {
// `crypto.randomUUID` is absent from older Safari / WKWebView versions.
// A local item id needs uniqueness within this browser store, not a
@@ -164,11 +182,13 @@ function newLocalID(prefix: string): string {
return `${prefix}_${uuid ?? `${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`}`;
}
/** 将 Store/SDK 异常转换成用户可以理解的提示,同时保留发送尝试的语义。 */
function storageFailureMessage(reason: unknown): string {
const detail = reason instanceof Error && reason.message ? `${reason.message}` : "";
return `本地对话历史暂时无法保存${detail};消息仍会尝试发送,刷新后将从服务端重新同步。`;
}
/** 校验并规范化 AppServer 地址,只允许 HTTP/HTTPS,避免把任意协议交给 Transport。 */
function normalizeAPIAddress(value: string): string {
const candidate = value.trim();
if (!candidate) throw new Error("请输入 AppServer 地址。");
@@ -180,6 +200,7 @@ function normalizeAPIAddress(value: string): string {
return parsed.toString().replace(/\/$/, "");
}
/** 把网络、超时和输入错误转换成登录页可显示的中文原因。 */
function loginFailureMessage(reason: unknown, endpoint?: string): string {
const destination = endpoint ? ` ${endpoint}` : "上方填写的服务地址";
if (reason instanceof Error && reason.message === "LINEUP_LOGIN_TIMEOUT") return `连接 AppServer 超时(10 秒)。请确认当前网络可以访问${destination}`;
@@ -189,63 +210,41 @@ function loginFailureMessage(reason: unknown, endpoint?: string): string {
return message || "登录失败,请稍后重试。";
}
const rendererContext = new TrustedDOMRendererContext(messages, presence, {
submit(callID, submission) {
const transition = interactionKernel.submitToolCall(callID, new Date().toISOString(), submission);
conversationStore.replaceToolCalls(interactionKernel.snapshot().tool_calls);
if (transition.disposition === "accepted") {
conversationStore.clearToolDraft(callID);
queueToolAction("tool-result", callID, { call_id: callID, status: "completed", result: submission });
}
return transition.disposition === "accepted";
},
cancel(callID, reason) {
const transition = interactionKernel.cancelToolCall(callID, reason, new Date().toISOString());
conversationStore.replaceToolCalls(interactionKernel.snapshot().tool_calls);
if (transition.disposition === "accepted") {
conversationStore.clearToolDraft(callID);
queueToolAction("tool-cancel", callID, { call_id: callID, reason });
}
return transition.disposition === "accepted";
},
expire(callID) {
const transition = interactionKernel.expireToolCall(callID, new Date().toISOString());
conversationStore.replaceToolCalls(interactionKernel.snapshot().tool_calls);
return transition.disposition === "accepted";
},
read(callID) { return interactionKernel.snapshot().tool_calls.find(record => record.call_id === callID); },
loadDraft(callID) { return conversationStore.getToolDraft(callID); },
saveDraft(callID, values) { conversationStore.saveToolDraft(callID, values); },
clearDraft(callID) { conversationStore.clearToolDraft(callID); },
}, {
requestCancel(operationID) {
const transition = interactionKernel.requestTaskCancel(operationID, new Date().toISOString());
conversationStore.replaceTasks(interactionKernel.snapshot().tasks);
return transition.disposition === "accepted";
},
read(operationID) { return interactionKernel.snapshot().tasks.find(task => task.operation_id === operationID); },
}, {
async approve(callID) { return approveCapabilityCall(callID); },
reject(callID) { return rejectCapabilityCall(callID); },
expire(callID) { return expireCapabilityCall(callID); },
read(callID) { return capabilityCalls.get(callID); },
// Chat 是 Runtime 上的 Core App。它不接收 Transport 原始 payloadRuntime 会先完成
// 协议解析、作用域筛选、持久化和 App Inbox 投递,再通过 SDK 交给这里。
chatSDK.subscribeAgentMessages(message => {
// 收到 Agent 消息后,先把执行状态投影出来,再进行具体内容渲染,最后 ACK。
// ACK 表示 Chat 已经接管这条消息,Runtime 才可以从可靠 Inbox 中移除它。
projectExecutionProgress(message.item, message.received_at);
renderIncoming(message.item);
chatSDK.acknowledgeAgentMessage(message.message_id);
});
chatSDK.subscribeEvents(event => {
// 这些是 Runtime 给 Chat 的安全事件投影,不包含原始 Agent delivery。
if (event.type === "local-item") {
renderIncoming(event.item);
return;
}
if (event.type === "delivery" && event.item?.kind === "user-message") {
rendererContext.updateUserDelivery(event.local_id, event.item.delivery);
return;
}
if (event.type === "sync-status") {
rendererContext.setPresence(event.status === "syncing" ? "正在同步消息…" : event.status === "idle" ? "已同步,等待消息" : "同步中断,正在重试…");
return;
}
if (event.type === "scope-rejected") {
rendererContext.renderSystemMessage("收到作用域不匹配的 Agent 消息,已安全忽略。");
}
});
const rendererRegistry = new RendererRegistry<TrustedDOMRendererContext>();
rendererRegistry.register("user-message", (item, context) => context.renderUserMessage(item));
rendererRegistry.register("markdown", (item, context) => context.renderMarkdown(item));
rendererRegistry.register("agent-status", (item, context) => context.renderAgentStatus(item));
rendererRegistry.register("execution-summary", (item, context) => context.renderExecutionTrace(item));
rendererRegistry.register("progress", (item, context) => context.renderProgress(item));
rendererRegistry.register("error", (item, context) => context.renderError(item));
rendererRegistry.register("tool-call", (item, context) => context.renderToolCall(item));
rendererRegistry.register("tool-result", (item, context) => context.renderToolResult(item));
rendererRegistry.register("tool-cancel", (item, context) => context.renderToolCancel(item));
rendererRegistry.register("surface", (item, context) => context.renderSurface(item));
rendererRegistry.register("app-call", (item, context) => context.renderAppCall(item));
rendererRegistry.register("fallback", (item, context) => context.renderFallback(item));
function renderIncoming(item: ConversationItem): void {
// 这是 Chat 的统一表现入口。只有 Runtime 已经验证并转换成 ConversationItem 的内容
// 才能到达这里;高风险类型仍需经过各自状态机或策略再次检查。
if (item.kind === "app-call") {
// App/Capability 请求不能直接变成按钮点击。先解析能力声明,再交给状态机记录
// pending 状态;Renderer 只显示确认卡片,真正执行发生在用户批准之后。
const now = new Date().toISOString();
const parsed = parseCapabilityCall(item.envelope.payload, capabilityRegistry, now);
if (parsed.disposition === "unsupported") {
@@ -264,6 +263,8 @@ function renderIncoming(item: ConversationItem): void {
return;
}
if (item.kind === "artifact-offer") {
// Artifact 先验证元数据和可选内容,再由 Host 负责显示。持久化只保存元数据,
// 不把二进制内容写入对话 Store。
const artifact = artifactState.offer(item.envelope.payload);
if (!artifact) {
rendererContext.renderSystemMessage("收到无法安全验证的 Artifact。 ");
@@ -274,28 +275,32 @@ function renderIncoming(item: ConversationItem): void {
return;
}
if (item.kind === "surface") {
// Surface 生命周期消息分别对应打开、更新和关闭。每一步都由
// SurfaceInstanceManager 做幂等、作用域和状态跃迁检查。
const now = new Date().toISOString();
if (item.envelope.type === "lineup.v1.ui.open") {
const result = surfaceInstances.open(item.envelope.payload, item.envelope.conversation_id, now);
if (result.instance && (result.disposition === "accepted" || result.disposition === "duplicate")) surfaceHost.mount(result.instance);
if (result.disposition === "accepted") conversationStore.replaceSurfaces(surfaceInstances.snapshot());
if (result.disposition === "accepted") runtime.replaceSurfaces(surfaceInstances.snapshot());
return;
}
if (item.envelope.type === "lineup.v1.ui.patch") {
const instanceID = item.envelope.payload.instance_id;
const result = typeof instanceID === "string" ? surfaceInstances.patch(instanceID, item.envelope.payload.state, now) : { disposition: "invalid_request" as const };
if (result.instance && result.disposition === "accepted") surfaceHost.update(result.instance);
if (result.disposition === "accepted") conversationStore.replaceSurfaces(surfaceInstances.snapshot());
if (result.disposition === "accepted") runtime.replaceSurfaces(surfaceInstances.snapshot());
return;
}
if (item.envelope.type === "lineup.v1.ui.close") {
const instanceID = item.envelope.payload.instance_id;
if (typeof instanceID === "string") surfaceInstances.close(instanceID);
if (typeof instanceID === "string") surfaceHost.unmount(instanceID);
conversationStore.replaceSurfaces(surfaceInstances.snapshot());
runtime.replaceSurfaces(surfaceInstances.snapshot());
return;
}
}
// 普通文本、状态、Tool、错误和 fallback 等内容交给受信 Renderer Registry
// 未注册的类型只能安全降级为提示,不能执行未知脚本。
if (!rendererRegistry.render(item, rendererContext)) rendererContext.renderSystemMessage("收到当前客户端没有可信 Renderer 的内容。");
}
@@ -310,7 +315,10 @@ function renderIncoming(item: ConversationItem): void {
* through SurfaceInstanceManager and SurfaceRegistry.
*/
function replayStoredSurfaceLifecycle(): void {
const snapshot = conversationStore.open(currentConversationKey());
// 某个 Surface 可能在对应本地应用启用前就已到达。Runtime 保留了对话记录,
// 用户启用应用后这里只重放持久化的 Surface 过渡,不会自动下载、启用或放宽校验。
const snapshot = runtime.snapshot();
if (!snapshot) return;
for (const stored of snapshot.items) {
if (stored.item.kind === "surface") renderIncoming(stored.item);
}
@@ -322,26 +330,23 @@ function replayStoredSurfaceLifecycle(): void {
* availability changes, never because of a retry.
*/
async function publishInventory(): Promise<void> {
if (!session.active || !session.uid || !transport) return;
const update = inventoryPublisher.sync(surfaceRegistry.snapshot(), capabilityRegistry.inventory());
// Inventory 是“当前客户端有哪些公开能力”的声明,不是 Agent 发来的执行命令。
// revision 没变化时不重复发送;发送失败交由 Runtime outbox/sync loop 重试。
const session = runtime.getSession();
if (!session.active || !session.uid) return;
const update = inventoryPublisher.sync(surfaceRegistry.snapshot(), capabilityRegistry.inventory(), runtime.apps.list());
if (update.inventory.revision === lastInventoryRevision) return;
const envelope = makeProtocolEvent(
"lineup.v1.client.inventory",
update.inventory as unknown as JsonObject,
currentConversationKey(),
{ kind: "human", id: session.uid },
{ kind: "agent", id: session.agentUID },
);
await transport.sendText({ from_uid: session.uid, channel_id: session.channelID, channel_type: session.channelType, payload: JSON.stringify(envelope) });
await runtime.sendProtocolEnvelope("lineup.v1.client.inventory", update.inventory as unknown as JsonObject);
lastInventoryRevision = update.inventory.revision;
}
$<HTMLButtonElement>("#surface-demo").addEventListener("click", () => {
// 这是当前 MVP 的本地演示开关,用于启用一个可信 Surface 并刷新 Agent 可见 Inventory。
const now = new Date().toISOString();
surfaceRegistry.enable("lineup.task-dashboard", "0.1.0", now);
localStorage.setItem(SURFACE_REGISTRY_STORAGE_KEY, JSON.stringify(surfaceRegistry.snapshot()));
replayStoredSurfaceLifecycle();
conversationStore.replaceSurfaces(surfaceInstances.snapshot());
runtime.replaceSurfaces(surfaceInstances.snapshot());
void publishInventory();
});
@@ -350,16 +355,18 @@ $<HTMLButtonElement>("#surface-demo").addEventListener("click", () => {
* the separately validated adapter trace, never from Agent status/detail.
*/
function projectExecutionProgress(item: ConversationItem, now: string): void {
// 将 Agent 的 thinking、validated execution trace 和终态错误/idle 状态聚合成一张
// 可恢复的执行摘要卡片。原始 status/detail 不被当作 Agent 的“思维内容”展示。
if (item.kind === "agent-status" && item.status === "thinking" && item.envelope?.sender.kind === "agent") {
const summary = executionProgress.begin(item.execution_id ?? newLocalID("execution"), now);
conversationStore.replaceExecutionSummaries(executionProgress.snapshot());
runtime.replaceExecutionSummaries(executionProgress.snapshot());
rendererContext.renderExecutionSummary(summary);
return;
}
if (item.kind === "execution-summary") {
const summary = executionProgress.applyTrace(item.trace.execution_id, item.trace.status, item.trace.steps, now);
if (summary) {
conversationStore.replaceExecutionSummaries(executionProgress.snapshot());
runtime.replaceExecutionSummaries(executionProgress.snapshot());
rendererContext.renderExecutionSummary(summary);
}
return;
@@ -369,12 +376,13 @@ function projectExecutionProgress(item: ConversationItem, now: string): void {
const executionID = item.kind === "agent-status" ? item.execution_id : undefined;
const summary = executionProgress.finish(executionID, terminal, now);
if (!summary) return;
conversationStore.replaceExecutionSummaries(executionProgress.snapshot());
runtime.replaceExecutionSummaries(executionProgress.snapshot());
rendererContext.renderExecutionSummary(summary);
}
function currentConversationKey(): string {
return `${session.uid}:${session.channelType}:${session.channelID}`;
// Surface 和全局 Runtime 事件都需要一个会话键;未登录时使用保留的全局作用域。
return runtime.currentConversationID() ?? "runtime:global";
}
/**
@@ -383,6 +391,8 @@ function currentConversationKey(): string {
* cache entry the capability is absent from the next client.inventory.
*/
function reconcileArtifactSaveAvailability(): void {
// 只有“已验证 Artifact + Host 仍持有对应临时内容”同时成立时,才把 artifact.save
// 暴露给 Agent。内容失效后要立即从下一版 Inventory 中撤回该能力。
const available = artifactState.snapshot().some(artifact => artifact.integrity === "verified" && artifactContentCache.has(artifact.local_ref));
const before = capabilityRegistry.resolve("artifact.save").disposition === "available";
if (before === available) return;
@@ -391,6 +401,8 @@ function reconcileArtifactSaveAvailability(): void {
}
function saveCachedArtifact(artifactID: string): void {
// 这里只能保存 Host 自己缓存的 Blob。协议里的 local_ref 是不透明引用,绝不当作
// 文件路径或 URL 访问,下载完成后立即释放临时 Object URL。
const artifact = artifactState.get(artifactID);
const blob = artifact?.integrity === "verified" && artifact.local_ref ? artifactContentCache.get(artifact.local_ref) : undefined;
if (!artifact || !blob) throw new Error("artifact_cache_unavailable");
@@ -415,6 +427,8 @@ function saveCachedArtifact(artifactID: string): void {
* reflected into a protocol result.
*/
async function writeClipboardFromTrustedHost(text: string): Promise<void> {
// 复制操作只能在 Capability 状态机批准后执行。HTTPS/Tauri 优先使用 Clipboard API
// HTTP 开发 Host 退回到用户激活约束下的 execCommand,不持久化剪贴板内容。
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
@@ -445,6 +459,8 @@ async function writeClipboardFromTrustedHost(text: string): Promise<void> {
* local ref before it enters the host-owned volatile cache.
*/
function cacheArtifactContent(payload: JsonObject, artifact: { size_bytes: number; local_ref?: string }): boolean {
// 可选二进制内容必须是有大小上限的 base64,并且实际字节数要和 Artifact 元数据一致。
// 通过后只进入 Host 的易失缓存,不进入 Runtime 的持久化对话记录。
const encoded = payload.content_base64;
if (encoded === undefined) return true;
if (typeof encoded !== "string" || !artifact.local_ref || encoded.length > 8 * 1024 * 1024) return false;
@@ -462,6 +478,8 @@ function cacheArtifactContent(payload: JsonObject, artifact: { size_bytes: numbe
* or silently restore a save operation without a new host-owned offer.
*/
function prepareArtifactOffer(item: Extract<ConversationItem, { kind: "artifact-offer" }>): Extract<ConversationItem, { kind: "artifact-offer" }> | undefined {
// Runtime 写入 Store 前先调用这个 Host hook:缓存内容后从持久化 payload 移除 base64
// 让刷新后仍能恢复 Artifact 元数据,但不能无提示地恢复本地文件保存动作。
const payload = item.envelope.payload;
const localRef = typeof payload.local_ref === "string" ? payload.local_ref : undefined;
const size = typeof payload.size_bytes === "number" ? payload.size_bytes : -1;
@@ -472,12 +490,14 @@ function prepareArtifactOffer(item: Extract<ConversationItem, { kind: "artifact-
}
function persistCapabilityCall(record: CapabilityCallRecord, occurredAt: string): void {
conversationStore.replaceCapabilityCalls(capabilityCalls.snapshot());
// Capability 状态变化同时更新 Runtime 快照,并写入不含敏感参数的最小审计记录。
runtime.replaceCapabilityCalls(capabilityCalls.snapshot());
const resolution = capabilityRegistry.resolve(record.request.capability);
if (resolution.disposition === "available") capabilityAudit.record(record, resolution.definition.risk, occurredAt);
}
function rejectCapabilityCall(callID: string): CapabilityCallRecord | undefined {
// 用户拒绝能力请求:只推进本地状态并向 Agent 回传受限结果,不执行 Host handler。
const now = new Date().toISOString();
const transition = capabilityCalls.reject(callID, now);
if (transition.disposition !== "accepted" || !transition.record) return transition.record;
@@ -487,6 +507,7 @@ function rejectCapabilityCall(callID: string): CapabilityCallRecord | undefined
}
function expireCapabilityCall(callID: string): CapabilityCallRecord | undefined {
// 请求过期与拒绝类似,属于终态;过期请求不能再次被批准或执行。
const now = new Date().toISOString();
const transition = capabilityCalls.expire(callID, now);
if (transition.disposition !== "accepted" || !transition.record) return transition.record;
@@ -496,6 +517,8 @@ function expireCapabilityCall(callID: string): CapabilityCallRecord | undefined
}
async function approveCapabilityCall(callID: string): Promise<CapabilityCallRecord | undefined> {
// 用户批准后仍要重新检查当前 Capability Policy,防止“显示确认卡片后策略已变化”
// 的旧请求绕过最新权限。只有状态机进入 executing 才会调用 Host handler。
let now = new Date().toISOString();
const approval = capabilityCalls.approve(callID, now);
if (approval.disposition !== "accepted" || !approval.record) return approval.record;
@@ -536,17 +559,17 @@ async function approveCapabilityCall(callID: string): Promise<CapabilityCallReco
* idempotency boundary: only a newly accepted terminal transition queues it.
*/
function queueAppResult(record: CapabilityCallRecord): void {
if (!session.uid) return;
// 回传给 Agent 的只是有限结果(call_id、能力名、状态和有限错误码),不包含 URL、
// 剪贴板正文、本地路径、异常堆栈或 Artifact 引用等敏感输入。
const conversationID = chatSDK.snapshot().conversation_id;
if (!conversationID) return;
const payload = safeCapabilityResult(record);
const createdAt = new Date().toISOString();
const envelope = makeProtocolEvent("lineup.v1.app.result", payload, currentConversationKey(), { kind: "human", id: session.uid }, { kind: "agent", id: session.agentUID });
try {
conversationStore.enqueueToolAction(newLocalID("app-result"), "app-result", JSON.stringify(envelope), createdAt);
void flushOutbox();
} catch (reason) { rendererContext.renderSystemMessage(storageFailureMessage(reason)); }
void chatSDK.dispatch({ type: "chat.report_app_result", conversation_id: conversationID, result: payload })
.catch(reason => rendererContext.renderSystemMessage(storageFailureMessage(reason)));
}
function safeCapabilityResult(record: CapabilityCallRecord): JsonObject {
// 构造稳定、可幂等消费的能力结果投影;成功只报告 completed,失败只报告受控 code。
const outcome: JsonObject = {
call_id: record.call_id,
capability: record.request.capability,
@@ -558,6 +581,8 @@ function safeCapabilityResult(record: CapabilityCallRecord): JsonObject {
}
function pickFileFromTrustedHost(): Promise<PickedFile | undefined> {
// 通过浏览器原生文件选择器获得最小文件元数据。这里不读取或保存本地真实路径,
// 用户取消选择时也必须显式结束 pending Capability,避免状态机永久执行中。
return new Promise(resolve => {
const input = document.createElement("input");
input.type = "file";
@@ -592,160 +617,19 @@ function pickFileFromTrustedHost(): Promise<PickedFile | undefined> {
});
}
function queueToolAction(kind: "tool-result" | "tool-cancel", callID: string, payload: JsonObject): void {
if (!session.uid) {
rendererContext.renderSystemMessage("交互结果已保留,将在下次登录后发送。");
return;
}
const createdAt = new Date().toISOString();
const localID = newLocalID("tool");
const type = kind === "tool-result" ? "lineup.v1.tool.result" : "lineup.v1.tool.cancel";
const envelope = makeProtocolEvent(
type, payload, currentConversationKey(),
{ kind: "human", id: session.uid }, { kind: "agent", id: session.agentUID },
);
try {
// Durable enqueue precedes the HTTP request: a reload or transient
// network failure cannot turn one accepted UI action into an untracked
// second submission.
conversationStore.enqueueToolAction(localID, kind, JSON.stringify(envelope), createdAt);
} catch (reason) {
rendererContext.renderSystemMessage(storageFailureMessage(reason));
return;
}
void flushOutbox();
}
function queueSurfaceCancel(instanceID: string): void {
if (!session.uid) return;
const createdAt = new Date().toISOString();
const envelope = makeProtocolEvent("lineup.v1.ui.event", { instance_id: instanceID, event: "cancel", data: {} }, currentConversationKey(), { kind: "human", id: session.uid }, { kind: "agent", id: session.agentUID });
try {
conversationStore.enqueueToolAction(newLocalID("surface"), "surface-event", JSON.stringify(envelope), createdAt);
rendererContext.renderSystemMessage("已请求取消任务。");
void flushOutbox();
} catch (reason) { rendererContext.renderSystemMessage(storageFailureMessage(reason)); }
}
async function flushOutbox(): Promise<void> {
if (flushingOutbox || !session.active || !transport || !session.uid) return;
flushingOutbox = true;
try {
for (const entry of conversationStore.snapshot().outbox) {
try {
const sequence = await transport.sendText({
from_uid: session.uid,
channel_id: session.channelID,
channel_type: session.channelType,
payload: entry.payload,
});
if (entry.kind === "text") {
if (sequence) conversationStore.markSubmitted(entry.local_id, sequence, new Date().toISOString());
rendererContext.updateUserDelivery(entry.local_id, { status: "submitted", updated_at: new Date().toISOString(), ...(sequence ? { transport_id: String(sequence) } : {}) });
} else {
conversationStore.acknowledgeOutbox(entry.local_id);
}
} catch (reason) {
if (entry.kind === "text") {
const message = reason instanceof Error ? reason.message : "发送失败";
conversationStore.markFailed(entry.local_id, message, new Date().toISOString());
rendererContext.updateUserDelivery(entry.local_id, { status: "failed", updated_at: new Date().toISOString(), error: message, retryable: true });
}
// Keep this and later entries durable; the next sync/login cycle
// retries in original order.
return;
}
}
} finally {
flushingOutbox = false;
}
}
function decodeBase64(value: string): string {
try { return decodeURIComponent([...atob(value)].map(c => `%${c.charCodeAt(0).toString(16).padStart(2, "0")}`).join("")); } catch { return value; }
}
async function syncLoop(): Promise<void> {
if (polling) return;
polling = true;
while (session.active) {
try {
try { await publishInventory(); } catch { /* retried by the next loop */ }
// WuKongIM's legacy sync endpoint can return a single retained message
// per request even when `more` is 0. Drain until it is empty before
// switching to the paced live-polling loop; otherwise a reopened chat
// with history needs minutes to reach the latest Agent response.
while (session.active) {
const startMessageSeq = session.lastSeq === 0 ? 0 : session.lastSeq + 1;
const activeTransport = transport;
if (!activeTransport) throw new Error("当前会话传输尚未初始化。");
const synced = await activeTransport.sync({
channel_id: session.channelID,
channel_type: session.channelType,
login_uid: session.uid,
start_message_seq: startMessageSeq,
limit: 50,
});
if (synced.length === 0) {
rendererContext.setPresence("已同步,等待消息");
break;
}
let advanced = false;
for (const message of synced) {
const previousSeq = session.lastSeq;
session.lastSeq = Math.max(session.lastSeq, message.message_seq);
advanced ||= session.lastSeq > previousSeq;
conversationStore.advanceCursor(message.message_seq);
const payload = decodeBase64(message.payload);
if (message.from_uid === session.uid) {
const ownEnvelope = parseEnvelopeJSON(payload);
if (ownEnvelope.ok && (ownEnvelope.envelope.type === "lineup.v1.tool.result" || ownEnvelope.envelope.type === "lineup.v1.tool.cancel" || ownEnvelope.envelope.type === "lineup.v1.ui.event" || ownEnvelope.envelope.type === "lineup.v1.client.inventory" || ownEnvelope.envelope.type === "lineup.v1.app.result")) {
// Tool action echoes advance the inclusive cursor but are never
// rendered as raw JSON user messages or replayed as a new action.
continue;
}
const restored = conversationStore.recordSyncedUserText(message.message_seq, payload, new Date().toISOString());
if (restored) renderIncoming(restored.item);
} else {
const result = interactionKernel.ingest({
raw: payload,
source: "sync",
transport_id: String(message.message_seq),
});
if (result.items.some(item => item.kind === "tool-call" || item.kind === "tool-result" || item.kind === "tool-cancel")) {
conversationStore.replaceToolCalls(result.snapshot.tool_calls);
}
if (result.items.some(item => item.kind === "progress" && item.task)) {
conversationStore.replaceTasks(result.snapshot.tasks);
}
for (const item of result.items) {
const receivedAt = new Date().toISOString();
const safeItem = item.kind === "artifact-offer" ? prepareArtifactOffer(item) : item;
if (!safeItem) continue;
if (conversationStore.recordIncoming(safeItem, message.message_seq, receivedAt)) {
projectExecutionProgress(safeItem, receivedAt);
renderIncoming(safeItem);
}
}
}
}
// Avoid a hot loop if an upstream response repeats a sequence that
// cannot advance the local cursor.
if (!advanced) {
rendererContext.setPresence("已同步,等待消息");
break;
}
}
} catch {
if (session.active) rendererContext.setPresence("同步中断,正在重试…");
}
await new Promise(resolve => window.setTimeout(resolve, 1500));
}
polling = false;
// Surface 内部事件只转换为 Runtime Action;它不能直接发送网络消息或调用系统能力。
const conversationID = chatSDK.snapshot().conversation_id;
if (!conversationID) return;
void chatSDK.dispatch({ type: "chat.cancel_surface", conversation_id: conversationID, instance_id: instanceID })
.then(receipt => {
if (receipt.disposition === "accepted") rendererContext.renderSystemMessage("已请求取消任务。");
})
.catch(reason => rendererContext.renderSystemMessage(storageFailureMessage(reason)));
}
// 登录是唯一会创建 Runtime 远端会话的页面操作。登录成功后由 Runtime 返回恢复快照,
// main.ts 负责把快照投影到 Chat、挂载现有 Surface,并启动同步循环。
$<HTMLFormElement>("#login-form").addEventListener("submit", async event => {
event.preventDefault();
const apiInput = $<HTMLInputElement>("#api");
@@ -759,110 +643,74 @@ $<HTMLFormElement>("#login-form").addEventListener("submit", async event => {
if (!phone) { error.textContent = "请输入手机号。"; $<HTMLInputElement>("#phone").focus(); return; }
if (!code) { error.textContent = "请输入验证码。"; $<HTMLInputElement>("#code").focus(); return; }
session.api = endpoint;
localStorage.setItem("lineup.api", session.api);
error.textContent = `正在连接 ${session.api}`;
localStorage.setItem("lineup.api", endpoint);
error.textContent = `正在连接 ${endpoint}`;
submit.disabled = true;
submit.textContent = "正在进入…";
try {
const candidateTransport = new HttpTransportAdapter(session.api);
const body = await candidateTransport.login({ phone, code });
interactionKernel.reset();
transport = candidateTransport;
session = { ...session, uid: body.uid, agentUID: body.agent_uid || session.agentUID, channelID: body.channel_id || session.channelID, channelType: body.channel_type || session.channelType, lastSeq: 0, active: true };
const snapshot = conversationStore.open(currentConversationKey());
// Runtime 内部负责 Transport 登录、打开 Conversation Store 和恢复 Tool/Task 状态。
const snapshot = await runtime.login({
api: endpoint,
phone,
code,
fallback_agent_uid: "agent_hermes_main",
fallback_channel_id: "agent_default_channel",
fallback_channel_type: 2,
});
// 先恢复可持久化的交互状态,再清空并重建页面投影。
surfaceInstances.restore(snapshot.surfaces);
interactionKernel.restoreToolCalls(snapshot.tool_calls, new Date().toISOString());
interactionKernel.restoreTasks(snapshot.tasks);
capabilityCalls.restore(snapshot.capability_calls);
executionProgress.restore(snapshot.execution_summaries);
conversationStore.replaceToolCalls(interactionKernel.snapshot().tool_calls);
conversationStore.replaceTasks(interactionKernel.snapshot().tasks);
session.lastSeq = snapshot.cursor;
messages.replaceChildren(); rendererContext.reset();
loginView.hidden = true; chatView.hidden = false;
// Status envelopes describe ephemeral activity. The durable M1-08 summary
// below is the only restored execution view, so historical `thinking`
// messages cannot create a fresh or expanded pseudo-live card on reload.
// thinking 等状态消息只描述当时的临时活动,不在刷新后重新创建一张“正在执行”卡;
// 可恢复的执行摘要由下面的 restoreExecutionSummaries 统一恢复。
for (const stored of snapshot.items) {
if (stored.item.kind !== "agent-status") renderIncoming(stored.item);
}
// Runtime retained Agent deliveries while this Core App was not mounted.
// The restored transcript above is now their successful Chat projection,
// so acknowledge the durable Inbox records without replaying duplicate
// DOM nodes or re-executing a user action.
// 这些 Inbox 消息已经通过持久化 transcript 成功渲染,因此这里只 ACK,不再次渲染,
// 避免刷新后出现重复消息或重新执行用户动作。
for (const pending of chatSDK.listAgentMessages()) chatSDK.acknowledgeAgentMessage(pending.message_id);
for (const instance of surfaceInstances.snapshot().instances) surfaceHost.mount(instance);
rendererContext.restoreExecutionSummaries(snapshot.execution_summaries);
await mountM4SignedFixture();
rendererContext.renderSystemMessage("已进入与 AI Agent 的对话"); rendererContext.setPresence("正在同步消息…"); void publishInventory(); void flushOutbox(); void syncLoop();
rendererContext.renderSystemMessage("已进入与 AI Agent 的对话"); rendererContext.setPresence("正在同步消息…");
void publishInventory();
// 页面恢复完成后才开始增量同步,避免历史恢复与实时投递交错造成重复显示。
runtime.startSync();
} catch (reason) { error.textContent = loginFailureMessage(reason, endpoint); }
finally { submit.disabled = false; submit.textContent = "进入 LineUp"; }
});
// 发送消息必须通过 Chat SDK 提交 Runtime Action。Runtime 会负责本地回显、outbox、
// 作用域校验和 Transport 发送,页面只负责采集输入和显示结果。
$<HTMLFormElement>("#message-form").addEventListener("submit", async event => {
event.preventDefault();
const input = $<HTMLTextAreaElement>("#message-input"); const send = $<HTMLButtonElement>("#send"); const text = input.value.trim();
if (!text) return;
if (!session.uid) {
const conversationID = chatSDK.snapshot().conversation_id;
if (!conversationID) {
rendererContext.renderSystemMessage("当前会话尚未初始化,无法发送消息;请退出后重新登录。");
input.focus();
return;
}
input.value = ""; send.disabled = true;
const localID = newLocalID("local");
const createdAt = new Date().toISOString();
// A browser storage failure must never prevent the user from seeing or
// submitting their message. The Store remains the durable source when it
// is available; the server sync is the recovery path when it is not.
const localItem: ConversationItem = {
kind: "user-message",
id: localID,
text,
delivery: { status: "local_pending", updated_at: createdAt },
};
renderIncoming(localItem);
let storedLocally = true;
try { conversationStore.appendLocalText(localID, text, createdAt); }
catch (reason) { storedLocally = false; rendererContext.renderSystemMessage(storageFailureMessage(reason)); }
try {
if (!transport) throw new Error("当前会话传输尚未初始化,请退出后重新登录。");
const sequence = await transport.sendText({
from_uid: session.uid,
channel_id: session.channelID,
channel_type: session.channelType,
payload: text,
});
if (sequence && storedLocally) {
try { conversationStore.markSubmitted(localID, sequence, new Date().toISOString()); }
catch (reason) { storedLocally = false; rendererContext.renderSystemMessage(storageFailureMessage(reason)); }
}
rendererContext.updateUserDelivery(localID, { status: "submitted", updated_at: new Date().toISOString(), ...(sequence ? { transport_id: String(sequence) } : {}) });
}
catch (reason) {
const message = reason instanceof Error ? reason.message : "发送失败";
if (storedLocally) {
try { conversationStore.markFailed(localID, message, new Date().toISOString()); }
catch (storageReason) { rendererContext.renderSystemMessage(storageFailureMessage(storageReason)); }
}
rendererContext.updateUserDelivery(localID, { status: "failed", updated_at: new Date().toISOString(), error: message, retryable: true });
rendererContext.renderSystemMessage(message);
const receipt = await chatSDK.dispatch({ type: "chat.send_text", conversation_id: conversationID, text });
if (receipt.disposition === "rejected") rendererContext.renderSystemMessage("当前会话无法发送消息,请退出后重新登录。");
} catch (reason) {
rendererContext.renderSystemMessage(storageFailureMessage(reason));
}
finally { send.disabled = false; input.focus(); }
});
$<HTMLButtonElement>("#logout").addEventListener("click", () => { session.active = false; transport = undefined; interactionKernel.reset(); conversationStore.close(); messages.replaceChildren(); rendererContext.reset(); chatView.hidden = true; loginView.hidden = false; });
export function makeProtocolEvent(
type: string,
payload: JsonObject,
conversationID: string,
sender: Actor,
target?: Actor,
): Envelope {
return {
v: 1,
id: newLocalID("msg"),
type,
conversation_id: conversationID,
sender,
...(target ? { target } : {}),
timestamp: new Date().toISOString(),
payload,
};
}
// 退出时停止同步、清理 Runtime 会话和当前页面投影,但保留 localStorage 中的配置/历史,
// 以便下一次登录时由 Runtime 重新恢复。
$<HTMLButtonElement>("#logout").addEventListener("click", () => { runtime.logout(); messages.replaceChildren(); rendererContext.reset(); chatView.hidden = true; loginView.hidden = false; });
@@ -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,67 @@
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_scope: AppScope;
conversation_id?: string;
state: AppInstanceState;
parent_instance_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) || 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; parent_instance_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,124 @@
/**
* 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.
*/
export type AppScope = "chat" | "app-registry" | "settings" | "runtime" | (string & {});
export type AppToolDefinition = {
name: string;
handling: "direct" | "interactive" | "launch" | "foreground" | "operation";
/** JSON-object property names accepted by this tool. */
parameters: readonly string[];
requires_permission?: string;
};
/**
* 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: "core" | "extension";
enabled: boolean;
default_eligible: boolean;
recovery: boolean;
manifest: AppManifest;
};
const INITIAL_CORE_APPS: readonly CoreAppRecord[] = [
{
app_scope: "chat", kind: "core", 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 CoreAppRecord[] = INITIAL_CORE_APPS) {
for (const record of records) this.register(record);
}
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: CoreAppRecord): void { this.register(record); }
private register(record: CoreAppRecord): 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 (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] })),
},
};
}
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.requires_permission || manifest.permissions.includes(tool.requires_permission));
toolNames.add(tool.name);
return valid;
});
}
export function isAppScope(value: unknown): value is AppScope {
return typeof value === "string" && /^[a-z][a-z0-9-]{0,63}$/.test(value);
}
@@ -0,0 +1,97 @@
/**
* 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[];
};
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 };
export type CommandReceipt =
| { disposition: "accepted"; local_id?: string }
| { disposition: "rejected"; code: "inactive" | "scope_mismatch" | "invalid_action" };
export interface ChatRuntimeSDK {
readonly app_scope: "chat";
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;
};
}
/**
* The intentionally smaller SDK granted to an installed Extension App. It
* does not reveal another App's inbox, the focus stack, Transport, DOM root,
* storage, credentials, or Host capabilities.
*/
export interface ExtensionRuntimeSDK {
readonly app_scope: AppScope;
readonly instance_id: string;
readonly conversation_id: string;
listMessages(afterMessageID?: string): readonly ScopedConversationItem[];
acknowledgeMessage(messageID: string): boolean;
reportResult(result: JsonObject): Promise<CommandReceipt>;
reportFailure(message: string): void;
}
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" };
export type AppMessageSubscription = {
app_scope: AppScope;
listener: (message: ScopedConversationItem) => void;
};
@@ -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);
}
}
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { ArtifactContentCache } from "./artifact-content-cache";
import { ArtifactContentCache } from "@/runtime/artifacts/artifact-content-cache";
describe("ArtifactContentCache", () => {
it("accepts only host-owned opaque references and never exposes paths", () => {
@@ -1,4 +1,9 @@
/**
* 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.
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { ArtifactState } from "./artifact-state";
import { ArtifactState } from "@/runtime/artifacts/artifact-state";
describe("ArtifactState", () => {
it("keeps only bounded metadata and rejects paths or duplicate offers", () => {
@@ -1,3 +1,9 @@
/**
* Artifact
*
* MIME DOM
*
*/
export type ArtifactIntegrity = "verified" | "unverified" | "failed";
export type ArtifactRecord = Readonly<{
artifact_id: string;
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { CapabilityAuditLog } from "./capability-audit";
import { CapabilityAuditLog } from "@/runtime/capabilities/capability-audit";
describe("CapabilityAuditLog", () => {
it("records authority metadata but never arguments or result values", () => {
@@ -1,5 +1,11 @@
import type { CapabilityCallRecord } from "./capability-call-state";
import type { CapabilityRisk } from "./capability-registry";
/**
* 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;
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { CapabilityCallStateMachine, type CapabilityCallRequest } from "./capability-call-state";
import { parseCapabilityCall } from "./capability-call-state";
import { CapabilityRegistry } from "./capability-registry";
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" } };
@@ -1,6 +1,12 @@
import type { JsonObject } from "../protocol";
import type { CapabilityDefinition } from "./capability-registry";
import { CapabilityRegistry } from "./capability-registry";
/**
* 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";
@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { ArtifactState } from "./artifact-state";
import { CapabilityExecutor } from "./capability-executor";
import type { CapabilityCallRecord } from "./capability-call-state";
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 {
@@ -1,6 +1,12 @@
import type { JsonObject } from "../protocol";
import type { ArtifactState } from "./artifact-state";
import type { CapabilityCallRecord } from "./capability-call-state";
/**
* 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<{
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { CapabilityRegistry } from "./capability-registry";
import { CapabilityRegistry } from "@/runtime/capabilities/capability-registry";
describe("CapabilityRegistry", () => {
it("declares requestability without making a capability executable", () => {
@@ -1,4 +1,10 @@
import type { JsonObject } from "../protocol";
/**
* Host
*
* Runtime schema Agent InventorySurface Agent
*
*/
import type { JsonObject } from "@/runtime/protocol/lineup-v1";
export type CapabilityRisk = "display_only" | "user_confirmation" | "system_permission" | "restricted";
@@ -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
@@ -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 } = {}): 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_scope: appScope, conversation_id: conversationID, ...(options.parent_instance_id ? { parent_instance_id: options.parent_instance_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 } : {}) };
}
}
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { ExecutionProgressState } from "./execution-progress-state";
import { ExecutionProgressState } from "@/runtime/coordination/execution-progress-state";
const T0 = "2026-08-03T00:00:00.000Z";
const T1 = "2026-08-03T00:00:18.000Z";
@@ -1,4 +1,9 @@
/**
* Agent
*
* Adapter URL
* CoT /
*
* Display-safe projection of one Agent turn.
*
* The client never turns status text, model reasoning, commands, arguments,
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { decodeConversationItem, 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({
@@ -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 = {
@@ -0,0 +1,249 @@
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 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> {
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: "extension", 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();
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" })]);
const game = runtime.openExtensionApp("draw-and-guess", "draw-and-guess:game-001");
expect(game.listMessages()).toEqual([expect.objectContaining({ scope: { app_scope: "draw-and-guess", conversation_id: conversationID } })]);
expect(await game.reportResult({ winner: "user_1" })).toMatchObject({ disposition: "accepted" });
expect(transport.sent.at(-1)?.payload).toContain("lineup.v1.app.result");
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()).toEqual([expect.objectContaining({ app_scope: "chat", enabled: true, recovery: true })]);
const chat = runtime.openApp("chat");
expect(chat.app_scope).toBe("chat");
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("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("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: "whiteboard", 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("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).toEqual([expect.objectContaining({ from_uid: "user_1", channel_id: "channel_1", payload: "hello runtime" })]);
expect(runtime.snapshot()?.outbox).toEqual([]);
expect(runtime.snapshot()?.items).toEqual([expect.objectContaining({ local_id: "local_test", item: expect.objectContaining({ delivery: expect.objectContaining({ status: "submitted" }) }) })]);
});
});
@@ -0,0 +1,638 @@
/**
* LineUp Runtime 的唯一协调器。
*
* Runtime 独占 Transport、Conversation Store、sync loop 和 outbox;它在入站时完成旧协议
* 兼容、scope 校验、去重、持久化和 App Inbox 投递,再经 SDK 向 Chat 提供受限投影。
*/
import {
parseEnvelopeJSON,
type Actor,
type ConversationItem,
type Envelope,
type JsonObject,
} from "@/runtime/protocol/lineup-v1";
import { CoreAppRegistry, type AppScope } from "@/runtime/app-management/app-registry";
import {
type AgentChatMessage,
type ChatAction,
type ChatRuntimeSDK,
type ChatViewModel,
type CommandReceipt,
type ExtensionRuntimeSDK,
type RuntimeAppEvent,
type Unsubscribe,
} from "@/runtime/app-management/app-sdk";
import {
ConversationStore,
type ConversationSnapshot,
type OutboxEntry,
} from "@/runtime/persistence/conversation-store";
import { InteractionKernel, type KernelSnapshot } from "@/runtime/coordination/interaction-kernel";
import { resolveIncomingScope, type ScopedConversationItem } from "@/runtime/coordination/runtime-envelope";
import {
HttpTransportAdapter,
type LoginRequest,
type LoginResult,
type TransportAdapter,
type TransportMessage,
} from "@/runtime/communication/transport-adapter";
import type { SurfaceInstanceSnapshot } from "@/runtime/surfaces/surface-instance-manager";
import type { CapabilityCallRecord } from "@/runtime/capabilities/capability-call-state";
import type { ExecutionProgressSummary } from "@/runtime/coordination/execution-progress-state";
import type { TaskRecord } from "@/runtime/coordination/task-state";
import type { ToolCallRecord } from "@/runtime/coordination/tool-call-state";
import { AppLifecycleManager, type AppWorkspaceSnapshot } from "@/runtime/app-management/app-lifecycle-manager";
import { AppOrchestrator } from "@/runtime/coordination/app-orchestrator";
import { ToolRouter } from "@/runtime/coordination/tool-router";
export type RuntimeSession = {
api: string;
uid: string;
agent_uid: string;
channel_id: string;
channel_type: number;
last_seq: number;
active: boolean;
};
export type RuntimeLoginRequest = LoginRequest & {
api: string;
fallback_agent_uid: string;
fallback_channel_id: string;
fallback_channel_type: number;
};
export type RuntimeDependencies = {
storage: Storage;
createTransport?: (api: string) => TransportAdapter;
now?: () => string;
newID?: (prefix: string) => string;
sleep?: (milliseconds: number) => Promise<void>;
/** Host-only sanitizer/cache hook; Runtime still owns persistence/routing. */
prepareIncoming?: (item: ConversationItem) => ConversationItem | undefined;
};
const CONTROL_ECHO_TYPES = new Set([
"lineup.v1.tool.result",
"lineup.v1.tool.cancel",
"lineup.v1.ui.event",
"lineup.v1.client.inventory",
"lineup.v1.app.result",
]);
/**
* The only owner of Transport, ConversationStore, sync loop and outbox.
*
* It deliberately has no DOM dependency. A Core App gets a narrow SDK view;
* host rendering and platform-specific artifact cache hooks are injected at
* the edge instead of letting the Chat UI depend on transport/storage.
*/
export class LineUpRuntime {
public readonly apps = new CoreAppRegistry();
public readonly lifecycle = new AppLifecycleManager();
private readonly store: ConversationStore;
private readonly kernel = new InteractionKernel();
private readonly listeners = new Set<(event: RuntimeAppEvent) => void>();
private readonly chatStateListeners = new Set<(view: ChatViewModel) => void>();
private readonly chatMessageListeners = new Set<(message: AgentChatMessage) => void>();
private readonly createTransport: (api: string) => TransportAdapter;
private readonly now: () => string;
private readonly newID: (prefix: string) => string;
private readonly sleep: (milliseconds: number) => Promise<void>;
private readonly prepareIncoming: (item: ConversationItem) => ConversationItem | undefined;
private readonly orchestrator: AppOrchestrator;
private readonly toolRouter: ToolRouter;
private transport: TransportAdapter | undefined;
private session: RuntimeSession = {
api: "",
uid: "",
agent_uid: "",
channel_id: "",
channel_type: 2,
last_seq: 0,
active: false,
};
private syncing = false;
private syncRequested = false;
public constructor(dependencies: RuntimeDependencies) {
this.store = new ConversationStore(dependencies.storage);
this.createTransport = dependencies.createTransport ?? (api => new HttpTransportAdapter(api));
this.now = dependencies.now ?? (() => new Date().toISOString());
this.newID = dependencies.newID ?? defaultID;
this.sleep = dependencies.sleep ?? (milliseconds => new Promise(resolve => globalThis.setTimeout(resolve, milliseconds)));
this.prepareIncoming = dependencies.prepareIncoming ?? (item => item);
this.orchestrator = new AppOrchestrator(this.lifecycle, this.apps, this.newID);
this.toolRouter = new ToolRouter(this.apps);
}
public getSession(): RuntimeSession {
return { ...this.session };
}
public currentConversationID(): string | undefined {
return this.session.active ? `${this.session.uid}:${this.session.channel_type}:${this.session.channel_id}` : undefined;
}
public snapshot(): ConversationSnapshot | undefined {
return this.session.active ? this.store.snapshot() : undefined;
}
public kernelSnapshot(): KernelSnapshot {
return this.kernel.snapshot();
}
public workspaceSnapshot(): AppWorkspaceSnapshot { return this.lifecycle.snapshot(); }
/** Runtime-only lifecycle entry point used when a mounted App reports completion. */
public closeAppInstance(instanceID: string, error?: string): void {
if (!this.session.active) return;
this.orchestrator.close(instanceID, this.now(), error);
this.persistWorkspace();
runtimeLog("app.lifecycle", { action: error ? "failed" : "closed", instance_id: instanceID });
this.emit({ type: "app-lifecycle", workspace: this.workspaceSnapshot(), reason: error ? "failed" : "closed" });
}
public onEvent(listener: (event: RuntimeAppEvent) => void): Unsubscribe {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
/**
* Opens the SDK projection for a Runtime-registered Core App.
*
* The first SDK implementation is still Chat-shaped, but the selection key
* now comes from the App Registry rather than from a Chat-specific Runtime
* entry point. New App SDK projections can be added behind this boundary.
*/
public openApp(appScope: AppScope): ChatRuntimeSDK {
const app = this.apps.get(appScope);
if (!app?.enabled) throw new Error(`The ${appScope} Core App is unavailable.`);
if (appScope !== "chat") throw new Error(`The ${appScope} Core App SDK is not registered.`);
return {
app_scope: "chat",
snapshot: () => this.chatView(),
subscribe: listener => {
this.chatStateListeners.add(listener);
listener(this.chatView());
return () => this.chatStateListeners.delete(listener);
},
subscribeEvents: listener => this.onEvent(event => {
// Agent content has a separate, scope-checked SDK channel. Keeping it
// out of the generic event stream prevents a Chat renderer from
// accidentally consuming an unprojected delivery path.
if (event.type !== "agent-message") listener(event);
}),
subscribeAgentMessages: listener => {
this.chatMessageListeners.add(listener);
return () => this.chatMessageListeners.delete(listener);
},
listAgentMessages: afterMessageID => this.listChatMessages(afterMessageID),
acknowledgeAgentMessage: messageID => this.acknowledgeChatMessage(messageID),
dispatch: action => this.dispatchChatAction(action),
interactions: {
submit: (callID, result) => this.submitToolCall(callID, result),
cancel: (callID, reason) => this.cancelToolCall(callID, reason),
expire: callID => this.expireToolCall(callID),
read: callID => this.toolCalls().find(record => record.call_id === callID),
loadDraft: callID => this.getToolDraft(callID),
saveDraft: (callID, values) => this.saveToolDraft(callID, values),
clearDraft: callID => this.clearToolDraft(callID),
},
tasks: {
requestCancel: operationID => this.requestTaskCancel(operationID),
read: operationID => this.tasks().find(task => task.operation_id === operationID),
},
};
}
/** Opens a per-instance Extension projection after Runtime has started it. */
public openExtensionApp(appScope: AppScope, instanceID: string): ExtensionRuntimeSDK {
const app = this.apps.get(appScope);
const instance = this.lifecycle.instances.get(instanceID);
const conversationID = this.requireConversationID();
if (!app || app.kind !== "extension" || !app.enabled || !instance || instance.app_scope !== appScope || instance.conversation_id !== conversationID) {
throw new Error("Extension App SDK is unavailable for this instance.");
}
return {
app_scope: appScope, instance_id: instanceID, conversation_id: conversationID,
listMessages: afterMessageID => this.listAppMessages(appScope, afterMessageID),
acknowledgeMessage: messageID => this.store.acknowledgeAppInbox(appScope, messageID),
reportResult: result => this.queueProtocolAction("app-result", "lineup.v1.app.result", { instance_id: instanceID, app_scope: appScope, result }),
reportFailure: message => this.closeAppInstance(instanceID, boundedFailure(message)),
};
}
/** @deprecated Use openApp("chat") so App selection remains registry-driven. */
public openChatApp(): ChatRuntimeSDK {
return this.openApp("chat");
}
public async login(request: RuntimeLoginRequest): Promise<ConversationSnapshot> {
const transport = this.createTransport(request.api);
const result = await transport.login({ phone: request.phone, code: request.code });
this.stopSync();
this.transport = transport;
this.kernel.reset();
this.session = {
api: request.api,
uid: result.uid,
agent_uid: result.agent_uid || request.fallback_agent_uid,
channel_id: result.channel_id || request.fallback_channel_id,
channel_type: result.channel_type || request.fallback_channel_type,
last_seq: 0,
active: true,
};
const snapshot = this.store.open(this.requireConversationID());
this.lifecycle.restore(snapshot.app_workspace);
this.kernel.restoreToolCalls(snapshot.tool_calls, this.now());
this.kernel.restoreTasks(snapshot.tasks);
this.store.replaceToolCalls(this.kernel.snapshot().tool_calls);
this.store.replaceTasks(this.kernel.snapshot().tasks);
this.session.last_seq = snapshot.cursor;
if (!this.lifecycle.instances.activeFor("chat", this.requireConversationID())) {
this.orchestrator.launch("chat", this.requireConversationID(), this.now());
this.persistWorkspace();
runtimeLog("app.lifecycle", { action: "started", app_scope: "chat" });
}
runtimeLog("session.restored", { restored_instances: this.lifecycle.instances.list(this.requireConversationID()).length, foreground: this.lifecycle.focus.foreground() ?? "none" });
this.emitState();
return this.store.snapshot();
}
public logout(): void {
this.stopSync();
this.transport = undefined;
this.kernel.reset();
this.store.close();
this.session = { ...this.session, uid: "", agent_uid: "", channel_id: "", last_seq: 0, active: false };
this.emitState();
}
public startSync(): void {
if (!this.session.active || this.syncRequested) return;
this.syncRequested = true;
void this.syncLoop();
}
public stopSync(): void {
this.syncRequested = false;
}
/** A deterministic single drain for tests and for the production sync loop. */
public async syncOnce(): Promise<void> {
if (!this.session.active || !this.transport) return;
this.emit({ type: "sync-status", status: "syncing" });
while (this.session.active && this.transport) {
const startMessageSeq = this.session.last_seq === 0 ? 0 : this.session.last_seq + 1;
const messages = await this.transport.sync({
channel_id: this.session.channel_id,
channel_type: this.session.channel_type,
login_uid: this.session.uid,
start_message_seq: startMessageSeq,
limit: 50,
});
if (messages.length === 0) {
this.emit({ type: "sync-status", status: "idle" });
return;
}
let advanced = false;
for (const message of messages) {
const previousSeq = this.session.last_seq;
this.session.last_seq = Math.max(this.session.last_seq, message.message_seq);
advanced ||= this.session.last_seq > previousSeq;
this.store.advanceCursor(message.message_seq);
this.processTransportMessage(message);
}
if (!advanced) {
this.emit({ type: "sync-status", status: "idle" });
return;
}
}
}
public async flushOutbox(): Promise<void> {
if (this.syncing || !this.session.active || !this.transport) return;
this.syncing = true;
try {
for (const entry of this.store.snapshot().outbox) {
try {
const sequence = await this.transport.sendText({
from_uid: this.session.uid,
channel_id: this.session.channel_id,
channel_type: this.session.channel_type,
payload: entry.payload,
});
this.handleOutboxSuccess(entry, sequence);
} catch (reason) {
this.handleOutboxFailure(entry, reason);
return;
}
}
} finally {
this.syncing = false;
this.emitState();
}
}
public submitToolCall(callID: string, result: JsonObject): boolean {
const transition = this.kernel.submitToolCall(callID, this.now(), result);
this.store.replaceToolCalls(this.kernel.snapshot().tool_calls);
if (transition.disposition !== "accepted") return false;
this.store.clearToolDraft(callID);
void this.queueProtocolAction("tool-result", "lineup.v1.tool.result", { call_id: callID, status: "completed", result });
this.emitState();
return true;
}
public cancelToolCall(callID: string, reason: string): boolean {
const transition = this.kernel.cancelToolCall(callID, reason, this.now());
this.store.replaceToolCalls(this.kernel.snapshot().tool_calls);
if (transition.disposition !== "accepted") return false;
this.store.clearToolDraft(callID);
void this.queueProtocolAction("tool-cancel", "lineup.v1.tool.cancel", { call_id: callID, reason });
this.emitState();
return true;
}
public expireToolCall(callID: string): boolean {
const transition = this.kernel.expireToolCall(callID, this.now());
this.store.replaceToolCalls(this.kernel.snapshot().tool_calls);
this.emitState();
return transition.disposition === "accepted";
}
public requestTaskCancel(operationID: string): boolean {
const transition = this.kernel.requestTaskCancel(operationID, this.now());
this.store.replaceTasks(this.kernel.snapshot().tasks);
this.emitState();
return transition.disposition === "accepted";
}
public toolCalls(): readonly ToolCallRecord[] { return this.kernel.snapshot().tool_calls; }
public tasks(): readonly TaskRecord[] { return this.kernel.snapshot().tasks; }
public getToolDraft(callID: string): JsonObject | undefined { return this.store.getToolDraft(callID); }
public saveToolDraft(callID: string, values: JsonObject): void { this.store.saveToolDraft(callID, values); }
public clearToolDraft(callID: string): void { this.store.clearToolDraft(callID); }
public replaceTasks(records: readonly TaskRecord[]): void { this.store.replaceTasks(records); this.emitState(); }
public replaceExecutionSummaries(records: readonly ExecutionProgressSummary[]): void { this.store.replaceExecutionSummaries(records); }
public replaceSurfaces(snapshot: SurfaceInstanceSnapshot): void { this.store.replaceSurfaces(snapshot); }
public replaceCapabilityCalls(records: readonly CapabilityCallRecord[]): void { this.store.replaceCapabilityCalls(records); }
public acknowledgeChatMessage(messageID: string): boolean {
return this.store.acknowledgeAppInbox("chat", messageID);
}
/** Runtime-owned durable protocol result path for non-Chat host modules. */
public async queueProtocolAction(
kind: Exclude<OutboxEntry["kind"], "text">,
type: string,
payload: JsonObject,
): Promise<CommandReceipt> {
if (!this.session.active) return { disposition: "rejected", code: "inactive" };
const localID = this.newID(kind);
const envelope: Envelope = {
v: 1,
id: this.newID("msg"),
type,
conversation_id: this.requireConversationID(),
sender: { kind: "human", id: this.session.uid },
target: { kind: "agent", id: this.session.agent_uid },
timestamp: this.now(),
payload,
};
this.store.enqueueToolAction(localID, kind, JSON.stringify(envelope), this.now());
void this.flushOutbox();
return { disposition: "accepted", local_id: localID };
}
/** Runtime-owned ephemeral route used only for declarative inventory updates. */
public async sendProtocolEnvelope(type: string, payload: JsonObject): Promise<void> {
if (!this.session.active || !this.transport) return;
const envelope: Envelope = {
v: 1,
id: this.newID("msg"),
type,
conversation_id: this.requireConversationID(),
sender: { kind: "human", id: this.session.uid },
target: { kind: "agent", id: this.session.agent_uid },
timestamp: this.now(),
payload,
};
await this.transport.sendText({
from_uid: this.session.uid,
channel_id: this.session.channel_id,
channel_type: this.session.channel_type,
payload: JSON.stringify(envelope),
});
}
private async dispatchChatAction(action: ChatAction): Promise<CommandReceipt> {
if (!this.session.active) return { disposition: "rejected", code: "inactive" };
if (action.conversation_id !== this.requireConversationID()) return { disposition: "rejected", code: "scope_mismatch" };
if (action.type === "chat.send_text") {
const text = action.text.trim();
if (!text) return { disposition: "rejected", code: "invalid_action" };
const localID = action.local_id ?? this.newID("local");
const stored = this.store.appendLocalText(localID, text, this.now());
this.emit({ type: "local-item", item: stored.item, local_id: localID });
this.emitState();
void this.flushOutbox();
return { disposition: "accepted", local_id: localID };
}
if (action.type === "chat.submit_interaction") {
return this.submitToolCall(action.call_id, action.result)
? { disposition: "accepted" }
: { disposition: "rejected", code: "invalid_action" };
}
if (action.type === "chat.cancel_interaction") {
return this.cancelToolCall(action.call_id, action.reason)
? { disposition: "accepted" }
: { disposition: "rejected", code: "invalid_action" };
}
if (action.type === "chat.report_app_result") {
return this.queueProtocolAction("app-result", "lineup.v1.app.result", action.result);
}
if (action.type === "chat.cancel_surface") {
if (!action.instance_id) return { disposition: "rejected", code: "invalid_action" };
return this.queueProtocolAction("surface-event", "lineup.v1.ui.event", {
instance_id: action.instance_id,
event: "cancel",
data: {},
});
}
return { disposition: "rejected", code: "invalid_action" };
}
private processTransportMessage(message: TransportMessage): void {
const payload = decodeTransportPayload(message.payload);
if (message.from_uid === this.session.uid) {
const own = parseEnvelopeJSON(payload);
if (own.ok && CONTROL_ECHO_TYPES.has(own.envelope.type)) return;
const restored = this.store.recordSyncedUserText(message.message_seq, payload, this.now());
if (restored) this.emit({ type: "local-item", item: restored.item, local_id: restored.local_id });
return;
}
const scopeResolution = resolveIncomingScope(payload, {
app_scope: "chat", conversation_id: this.requireConversationID(),
acceptsAppScope: scope => Boolean(this.apps.get(scope)?.enabled),
});
if (scopeResolution.disposition === "rejected") {
runtimeLog("message.rejected", { stage: "scope", reason: scopeResolution.reason });
this.emit({ type: "scope-rejected", reason: scopeResolution.reason, raw: payload });
return;
}
const route = this.toolRouter.route(payload, { app_scope: scopeResolution.scope.app_scope, conversation_id: this.requireConversationID() });
if (route.disposition === "rejected") {
runtimeLog("tool.rejected", { reason: route.code });
this.emit({ type: "scope-rejected", reason: route.code === "scope_mismatch" ? "scope_mismatch" : "invalid_scope", raw: payload });
return;
}
const result = this.kernel.ingest({ raw: payload, source: "sync", transport_id: String(message.message_seq), received_at: this.now() });
this.store.replaceToolCalls(result.snapshot.tool_calls);
this.store.replaceTasks(result.snapshot.tasks);
for (const rawItem of result.items) {
const item = this.prepareIncoming(rawItem);
if (!item) continue;
const receivedAt = this.now();
if (!this.store.recordIncoming(item, message.message_seq, receivedAt)) continue;
const messageID = item.id ?? `transport:${message.message_seq}`;
const scoped: ScopedConversationItem = {
message_id: messageID,
scope: scopeResolution.scope,
item,
received_at: receivedAt,
message_seq: message.message_seq,
};
const targetScope = route.disposition === "launch" ? route.app_scope : scopeResolution.scope.app_scope;
this.store.enqueueAppInbox({
message_id: messageID,
app_scope: targetScope,
conversation_id: scopeResolution.scope.conversation_id,
item,
received_at: receivedAt,
message_seq: message.message_seq,
});
this.emit({ type: "agent-message", message: scoped });
if (targetScope === "chat") this.emitChatMessage(scoped);
}
if (route.disposition === "launch") {
const launched = this.orchestrator.launch(route.app_scope, this.requireConversationID(), this.now(), { instance_id: route.instance_id });
if (launched.disposition === "foreground") {
this.persistWorkspace();
runtimeLog("app.lifecycle", { action: "launched", app_scope: route.app_scope, instance_id: launched.instance.instance_id, prior_instance_id: launched.previous?.instance_id ?? "none" });
this.emit({ type: "app-lifecycle", workspace: this.workspaceSnapshot(), reason: "launched" });
}
}
this.emit({ type: "state", snapshot: result.snapshot });
this.emitState();
}
private handleOutboxSuccess(entry: OutboxEntry, sequence: number | undefined): void {
if (entry.kind === "text") {
if (sequence) this.store.markSubmitted(entry.local_id, sequence, this.now());
const item = this.store.snapshot().items.find(candidate => candidate.local_id === entry.local_id)?.item;
this.emit({ type: "delivery", local_id: entry.local_id, ...(item ? { item } : {}) });
return;
}
this.store.acknowledgeOutbox(entry.local_id);
}
private handleOutboxFailure(entry: OutboxEntry, reason: unknown): void {
runtimeLog("outbox.delivery_failed", { kind: entry.kind });
if (entry.kind !== "text") return;
const message = reason instanceof Error ? reason.message : "发送失败";
this.store.markFailed(entry.local_id, message, this.now());
const item = this.store.snapshot().items.find(candidate => candidate.local_id === entry.local_id)?.item;
this.emit({ type: "delivery", local_id: entry.local_id, ...(item ? { item } : {}) });
}
private async syncLoop(): Promise<void> {
while (this.syncRequested && this.session.active) {
try {
await this.flushOutbox();
await this.syncOnce();
} catch {
if (this.session.active) this.emit({ type: "sync-status", status: "retrying" });
}
if (this.syncRequested && this.session.active) await this.sleep(1_500);
}
}
private chatView(): ChatViewModel {
const snapshot = this.kernel.snapshot();
return {
app_scope: "chat",
conversation_id: this.currentConversationID(),
active: this.session.active,
agent_presence: snapshot.agent_presence,
tool_calls: snapshot.tool_calls,
tasks: snapshot.tasks,
};
}
private listChatMessages(afterMessageID?: string): readonly AgentChatMessage[] {
return this.listAppMessages("chat", afterMessageID) as readonly AgentChatMessage[];
}
private listAppMessages(appScope: AppScope, afterMessageID?: string): readonly ScopedConversationItem[] {
const entries = this.store.listAppInbox(appScope);
const start = afterMessageID ? entries.findIndex(entry => entry.message_id === afterMessageID) + 1 : 0;
return entries.slice(Math.max(0, start)).map(entry => ({
message_id: entry.message_id,
scope: { app_scope: appScope, conversation_id: entry.conversation_id },
item: entry.item,
received_at: entry.received_at,
...(entry.message_seq !== undefined ? { message_seq: entry.message_seq } : {}),
}));
}
private emitChatMessage(message: ScopedConversationItem): void {
if (message.scope.app_scope !== "chat") return;
const scoped = message as AgentChatMessage;
for (const listener of this.chatMessageListeners) listener(scoped);
}
private emitState(): void {
const view = this.chatView();
for (const listener of this.chatStateListeners) listener(view);
}
private persistWorkspace(): void { this.store.replaceAppWorkspace(this.lifecycle.snapshot()); }
private emit(event: RuntimeAppEvent): void {
for (const listener of this.listeners) listener(event);
}
private requireConversationID(): string {
const conversationID = this.currentConversationID();
if (!conversationID) throw new Error("LineUpRuntime has no active conversation.");
return conversationID;
}
}
function boundedFailure(value: string): string {
const message = value.trim();
return message ? message.slice(0, 500) : "Extension App failed.";
}
/** Deliberately excludes identities, conversation IDs, message bodies and Tool arguments. */
function runtimeLog(event: string, fields: Record<string, string | number>): void {
console.info("[LineUp Runtime]", event, fields);
}
function defaultID(prefix: string): string {
const uuid = globalThis.crypto?.randomUUID?.();
return `${prefix}_${uuid ?? `${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`}`;
}
function decodeTransportPayload(value: string): string {
try {
return decodeURIComponent([...atob(value)].map(character => `%${character.charCodeAt(0).toString(16).padStart(2, "0")}`).join(""));
} catch {
return value;
}
}
@@ -0,0 +1,89 @@
/**
* 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; acceptsAppScope?: (scope: AppScope) => boolean },
): ScopeResolution {
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" && candidate.conversation_id !== expected.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 (scope.conversation_id !== expected.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 } : {}),
},
};
}
@@ -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,10 +1,16 @@
/**
* 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;
@@ -0,0 +1,25 @@
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: "extension" 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("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" });
});
});
@@ -0,0 +1,47 @@
/**
* 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 } from "@/runtime/protocol/lineup-v1";
export type ToolRoute =
| { disposition: "pass" }
| { disposition: "launch"; call_id: string; app_scope: AppScope; instance_id?: string; arguments: JsonObject }
| { disposition: "rejected"; code: "invalid_request" | "scope_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; app_scope: AppScope }): ToolRoute {
const parsed = parseEnvelopeJSON(raw);
if (!parsed.ok) return { disposition: "pass" };
const envelope = parsed.envelope;
if (envelope.type !== "lineup.v1.app.call" || envelope.payload.capability !== "runtime.launch_app") return { disposition: "pass" };
if (envelope.conversation_id !== expected.conversation_id) return { disposition: "rejected", code: "scope_mismatch" };
return this.routeLaunch(envelope);
}
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.name === "app.launch" && tool.handling === "launch");
if (!definition) return { disposition: "rejected", code: "manifest_denied" };
if (definition.requires_permission && !record.manifest.permissions.includes(definition.requires_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 };
}
}
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; }
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { CapabilityRegistry } from "./capability-registry";
import { ClientInventoryPublisher } from "./client-inventory";
import { SurfaceRegistry } from "./surface-registry";
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", () => {
@@ -33,4 +33,13 @@ describe("ClientInventoryPublisher", () => {
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: "extension", 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");
});
});
@@ -1,5 +1,12 @@
import type { CapabilityDefinition } from "./capability-registry";
import type { SurfaceRegistrySnapshot } from "./surface-registry";
/**
* Agent
*
* Surface Capability revision Inventory
* Agent
*/
import type { CapabilityDefinition } from "@/runtime/capabilities/capability-registry";
import type { SurfaceRegistrySnapshot } from "@/runtime/surfaces/surface-registry";
import type { CoreAppRecord } from "@/runtime/app-management/app-registry";
export type ClientInventory = Readonly<{
revision: string;
@@ -7,7 +14,8 @@ export type ClientInventory = Readonly<{
applications: readonly Readonly<{
id: string;
version: string;
surfaces: readonly Readonly<{ id: "task-dashboard"; events: readonly ["cancel"] }>[];
surfaces: readonly Readonly<{ id: string; events: readonly string[] }>[];
tools?: readonly Readonly<{ name: string; handling: string }>[];
}>[];
capabilities: readonly CapabilityDefinition[];
}>;
@@ -23,9 +31,14 @@ export class ClientInventoryPublisher {
private previousFingerprint = "";
private current: ClientInventory | undefined;
public sync(surfaces: SurfaceRegistrySnapshot, capabilities: readonly CapabilityDefinition[]): { changed: boolean; inventory: ClientInventory } {
const applications = surfaces.enabled
public sync(surfaces: SurfaceRegistrySnapshot, capabilities: readonly CapabilityDefinition[], apps: readonly CoreAppRecord[] = []): { 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))
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import type { ToolCallRecord } from "./tool-call-state";
import type { TaskRecord } from "./task-state";
import { ConversationStore } from "./conversation-store";
import type { ToolCallRecord } from "@/runtime/coordination/tool-call-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>();
@@ -62,6 +62,53 @@ describe("ConversationStore", () => {
})]);
});
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":12');
});
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";
@@ -202,7 +249,7 @@ describe("ConversationStore", () => {
updated_at: createdAt,
steps: [],
detail: "do not persist this",
} as unknown as import("./execution-progress-state").ExecutionProgressSummary]);
} as unknown as import("@/runtime/coordination/execution-progress-state").ExecutionProgressSummary]);
expect(JSON.stringify(store.snapshot().execution_summaries)).not.toContain("do not persist this");
});
@@ -1,9 +1,17 @@
import type { ConversationItem, DeliveryState, JsonObject } from "../protocol";
import type { ToolCallRecord } from "./tool-call-state";
import type { TaskRecord } from "./task-state";
import type { ExecutionProgressSummary } from "./execution-progress-state";
import type { SurfaceInstanceSnapshot } from "./surface-instance-manager";
import { sanitizeCapabilityReason, type CapabilityCallRecord } from "./capability-call-state";
/**
* Runtime
*
* cursortyped outboxTool/Task App Inbox
* DOMApp 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";
export type StoredConversationItem = {
local_id: string;
@@ -19,6 +27,19 @@ export type OutboxEntry = {
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;
};
export type ConversationSnapshot = {
conversation_id: string;
cursor: number;
@@ -31,10 +52,13 @@ export type ConversationSnapshot = {
execution_summaries: readonly ExecutionProgressSummary[];
surfaces: SurfaceInstanceSnapshot;
capability_calls: readonly CapabilityCallRecord[];
app_inbox: readonly AppInboxEntry[];
/** Runtime-owned instance/focus state. Never supplied by an App. */
app_workspace: AppWorkspaceSnapshot;
};
type PersistedConversation = {
version: 10;
version: 12;
cursor: number;
items: StoredConversationItem[];
outbox: OutboxEntry[];
@@ -44,6 +68,8 @@ type PersistedConversation = {
execution_summaries: ExecutionProgressSummary[];
surfaces: SurfaceInstanceSnapshot;
capability_calls: CapabilityCallRecord[];
app_inbox: AppInboxEntry[];
app_workspace: AppWorkspaceSnapshot;
};
const STORE_PREFIX = "lineup.conversation.v1";
@@ -89,6 +115,8 @@ export class ConversationStore {
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 })),
app_workspace: clone(this.state.app_workspace),
};
}
@@ -135,6 +163,34 @@ export class ConversationStore {
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): readonly AppInboxEntry[] {
return this.state.app_inbox
.filter(entry => entry.app_scope === appScope)
.map(entry => ({ ...entry }));
}
public acknowledgeAppInbox(appScope: AppScope, messageID: 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));
if (this.state.app_inbox.length === before) return false;
this.persist();
return true;
}
public appendLocalText(localID: string, text: string, createdAt: string): StoredConversationItem {
const delivery: DeliveryState = { status: "local_pending", updated_at: createdAt };
const stored: StoredConversationItem = {
@@ -246,19 +302,39 @@ export class ConversationStore {
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 };
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) || !Array.isArray(candidate.items) || !Array.isArray(candidate.outbox) || typeof candidate.cursor !== "number") {
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; 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) || !Array.isArray(candidate.items) || !Array.isArray(candidate.outbox) || typeof candidate.cursor !== "number") {
return emptyConversation();
}
if (candidate.version !== 10) {
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: 12,
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),
app_workspace: emptyAppWorkspace(),
};
}
if (candidate.version !== 12) {
// 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: 10,
version: 12,
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),
@@ -269,10 +345,12 @@ export class ConversationStore {
execution_summaries: [],
surfaces: emptySurfaces(),
capability_calls: [],
app_inbox: [],
app_workspace: emptyAppWorkspace(),
};
}
return {
version: 10,
version: 12,
cursor: Math.max(0, candidate.cursor),
items: normalizeStoredItems(candidate.items),
outbox: normalizeOutbox(candidate.outbox),
@@ -282,6 +360,8 @@ export class ConversationStore {
execution_summaries: normalizeExecutionSummaries(candidate.execution_summaries),
surfaces: normalizeSurfaces(candidate.surfaces),
capability_calls: normalizeCapabilityCalls(candidate.capability_calls),
app_inbox: normalizeAppInbox(candidate.app_inbox, conversationID),
app_workspace: normalizeAppWorkspace(candidate.app_workspace),
};
} catch {
return emptyConversation();
@@ -303,11 +383,18 @@ export class ConversationStore {
}
function emptyConversation(): PersistedConversation {
return { version: 10, cursor: 0, items: [], outbox: [], tool_calls: [], tool_drafts: {}, tasks: [], execution_summaries: [], surfaces: emptySurfaces(), capability_calls: [] };
return { version: 12, cursor: 0, items: [], outbox: [], tool_calls: [], tool_drafts: {}, tasks: [], execution_summaries: [], surfaces: emptySurfaces(), capability_calls: [], app_inbox: [], 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 [];
@@ -320,6 +407,27 @@ function normalizeOutbox(value: unknown): OutboxEntry[] {
});
}
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 } : {}),
}];
});
}
/**
* Artifact content is delivery-only data for the host-owned volatile cache.
* It must never survive in the durable conversation transcript, including
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { decodeConversationItem, type ConversationItem, type JsonObject } from "../protocol";
import { InteractionKernel } from "./interaction-kernel";
import fixture from "./golden/lineup-v1.json";
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"];
@@ -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.
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { CachedVerifiedSurfaceDocumentResolver, parseReadyMessage, productionSurfaceDocument, surfaceDocument } from "./isolated-surface-host";
import { VerifiedSurfaceBundleCache, type SurfaceBundleDigest, type SurfaceManifestSignatureVerifier } from "./surface-bundle-cache";
import { CachedVerifiedSurfaceDocumentResolver, 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", () => {
@@ -1,6 +1,12 @@
import type { JsonObject } from "../protocol";
import type { SurfaceInstance } from "./surface-instance-manager";
import type { VerifiedSurfaceBundleCache } from "./surface-bundle-cache";
/**
* Surface iframe Host
*
* Host Bundle opaque-origin iframe bridge ready
* Surface DOMTauri 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;
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { canonicalManifestPayload, compareSemver, parseProductionSurfaceManifest, ProductionSurfaceManifestRegistry } from "./production-surface-manifest";
import { canonicalManifestPayload, compareSemver, parseProductionSurfaceManifest, ProductionSurfaceManifestRegistry } from "@/runtime/surfaces/production-surface-manifest";
const manifest = {
v: 1, app_id: "lineup.task-dashboard", version: "1.2.0",
@@ -1,4 +1,9 @@
/**
* 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.
@@ -1,8 +1,8 @@
import { describe, expect, it } from "vitest";
import { ProductionSurfaceManifestRegistry } from "./production-surface-manifest";
import { ProductionSurfacePolicy } from "./production-surface-policy";
import { SurfaceInstanceManager } from "./surface-instance-manager";
import { TrustedSurfaceBundleDownloader, VerifiedSurfaceBundleCache, type SurfaceBundleDigest, type SurfaceManifestSignatureVerifier } from "./surface-bundle-cache";
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); } };
@@ -1,7 +1,13 @@
import type { JsonObject } from "../protocol";
import { parseProductionSurfaceManifest, ProductionSurfaceManifestRegistry, type ProductionManifestDisposition } from "./production-surface-manifest";
import { TrustedSurfaceBundleDownloader, VerifiedSurfaceBundleCache } from "./surface-bundle-cache";
import type { SurfaceAdmissionPolicy, SurfaceRegistryDisposition, SurfaceRegistryResult } from "./surface-registry";
/**
* 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";
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { TrustedSurfaceBundleDownloader, VerifiedSurfaceBundleCache, WebCryptoEd25519SurfaceManifestSignatureVerifier, type SurfaceBundleDigest, type SurfaceManifestSignatureVerifier } from "./surface-bundle-cache";
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"); } };
@@ -1,4 +1,10 @@
import { canonicalManifestPayload, parseProductionSurfaceManifest, type ProductionSurfaceManifest } from "./production-surface-manifest";
/**
* Surface Bundle
*
* Bundle ManifestEd25519 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 }>;
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { SurfaceInstanceManager } from "./surface-instance-manager";
import { SurfaceRegistry } from "./surface-registry";
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";
@@ -1,9 +1,15 @@
import type { JsonObject } from "../protocol";
/**
* Surface
*
* `instance_id` openreadypatchclose restore App//
*
*/
import type { JsonObject } from "@/runtime/protocol/lineup-v1";
import {
parseSurfaceOpenRequest,
type SurfaceAdmissionPolicy,
type SurfaceRegistryDisposition,
} from "./surface-registry";
} from "@/runtime/surfaces/surface-registry";
export type SurfaceInstancePhase = "opening" | "ready";
@@ -3,7 +3,7 @@ import {
DEVELOPMENT_SURFACE_MANIFESTS,
SurfaceRegistry,
parseSurfaceOpenRequest,
} from "./surface-registry";
} from "@/runtime/surfaces/surface-registry";
const APP = "lineup.task-dashboard";
const VERSION = "0.1.0";
@@ -1,4 +1,10 @@
import type { JsonObject } from "../protocol";
/**
* Surface
*
* Host App/ payload HTMLJavaScriptURL
* Bundle 沿
*/
import type { JsonObject } from "@/runtime/protocol/lineup-v1";
/**
* M2-01 development-time Surface declaration. These records are authored by