feat(iteration): implement sdk v1 miniapp runtime loop
This commit is contained in:
@@ -30,6 +30,7 @@ export type MountedChatApp = {
|
|||||||
messages: HTMLElement;
|
messages: HTMLElement;
|
||||||
loginView: HTMLElement;
|
loginView: HTMLElement;
|
||||||
chatView: HTMLElement;
|
chatView: HTMLElement;
|
||||||
|
appSessions: HTMLElement;
|
||||||
presence: HTMLElement;
|
presence: HTMLElement;
|
||||||
rendererContext: TrustedDOMRendererContext;
|
rendererContext: TrustedDOMRendererContext;
|
||||||
rendererRegistry: RendererRegistry<TrustedDOMRendererContext>;
|
rendererRegistry: RendererRegistry<TrustedDOMRendererContext>;
|
||||||
@@ -51,6 +52,7 @@ export function mountChatApp(
|
|||||||
const messages = query<HTMLElement>(root, "#messages");
|
const messages = query<HTMLElement>(root, "#messages");
|
||||||
const loginView = query<HTMLElement>(root, "#login-view");
|
const loginView = query<HTMLElement>(root, "#login-view");
|
||||||
const chatView = query<HTMLElement>(root, "#chat-view");
|
const chatView = query<HTMLElement>(root, "#chat-view");
|
||||||
|
const appSessions = query<HTMLElement>(root, "#app-sessions");
|
||||||
const presence = query<HTMLElement>(root, "#presence");
|
const presence = query<HTMLElement>(root, "#presence");
|
||||||
const rendererContext = new TrustedDOMRendererContext(
|
const rendererContext = new TrustedDOMRendererContext(
|
||||||
messages,
|
messages,
|
||||||
@@ -68,6 +70,7 @@ export function mountChatApp(
|
|||||||
messages,
|
messages,
|
||||||
loginView,
|
loginView,
|
||||||
chatView,
|
chatView,
|
||||||
|
appSessions,
|
||||||
presence,
|
presence,
|
||||||
rendererContext,
|
rendererContext,
|
||||||
rendererRegistry,
|
rendererRegistry,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export function mountChatShell(root: HTMLElement): void {
|
|||||||
</section>
|
</section>
|
||||||
<section id="chat-view" class="chat-view" hidden>
|
<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>
|
<header class="chat-header"><div class="agent"><span class="avatar">✦</span><div><strong>AI Agent</strong><small id="presence">正在连接…</small></div></div><div><button id="surface-demo" class="quiet" type="button">启用任务面板</button><button id="logout" class="quiet">退出</button></div></header>
|
||||||
|
<section id="app-sessions" class="app-sessions" aria-label="应用子会话"></section>
|
||||||
<section id="messages" class="messages" aria-live="polite"></section>
|
<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>
|
<form id="message-form" class="composer"><textarea id="message-input" rows="1" placeholder="问问你的 AI 搭档…"></textarea><button id="send" type="submit">发送</button></form>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -12,6 +12,7 @@ import type {
|
|||||||
ConversationItem,
|
ConversationItem,
|
||||||
DeliveryState,
|
DeliveryState,
|
||||||
InputForm,
|
InputForm,
|
||||||
|
NoticeDefinition,
|
||||||
JsonObject,
|
JsonObject,
|
||||||
ToolCallRequest,
|
ToolCallRequest,
|
||||||
} from "@/runtime/protocol/lineup-v1";
|
} from "@/runtime/protocol/lineup-v1";
|
||||||
@@ -168,6 +169,10 @@ export class TrustedDOMRendererContext {
|
|||||||
|
|
||||||
public renderToolCall(item: Extract<ConversationItem, { kind: "tool-call" }>): void {
|
public renderToolCall(item: Extract<ConversationItem, { kind: "tool-call" }>): void {
|
||||||
const record = this.toolInteractions?.read(item.call.call_id);
|
const record = this.toolInteractions?.read(item.call.call_id);
|
||||||
|
if (item.call.tool === "notice" && item.call.notice) {
|
||||||
|
this.renderNoticeCard(item.call, item.call.notice);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (item.call.tool === "choice" && item.call.action_group) {
|
if (item.call.tool === "choice" && item.call.action_group) {
|
||||||
this.renderActionGroup(item.call, item.call.action_group, record);
|
this.renderActionGroup(item.call, item.call.action_group, record);
|
||||||
return;
|
return;
|
||||||
@@ -183,6 +188,30 @@ export class TrustedDOMRendererContext {
|
|||||||
this.renderSystemMessage("收到无法安全展示的标准交互请求。");
|
this.renderSystemMessage("收到无法安全展示的标准交互请求。");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private renderNoticeCard(call: ToolCallRequest, notice: NoticeDefinition): void {
|
||||||
|
const card = document.createElement("article");
|
||||||
|
card.className = "tool-card notice-card";
|
||||||
|
card.dataset.callId = call.call_id;
|
||||||
|
const heading = document.createElement("strong");
|
||||||
|
heading.textContent = call.title || "提示";
|
||||||
|
const detail = document.createElement("p");
|
||||||
|
detail.textContent = notice.message;
|
||||||
|
const status = document.createElement("small");
|
||||||
|
status.className = "tool-card-status";
|
||||||
|
status.textContent = notice.dismiss_label ?? "已收到";
|
||||||
|
card.append(heading, detail, status);
|
||||||
|
this.toolCards.set(call.call_id, { card, status, controls: [] });
|
||||||
|
this.messages.append(card);
|
||||||
|
this.scrollLatest();
|
||||||
|
// Toast is only an additional short reminder; the durable IM card above remains.
|
||||||
|
const toast = document.createElement("div");
|
||||||
|
toast.className = "notice-toast";
|
||||||
|
toast.textContent = notice.message;
|
||||||
|
toast.setAttribute("role", "status");
|
||||||
|
this.messages.parentElement?.append(toast);
|
||||||
|
globalThis.setTimeout(() => toast.remove(), 4_000);
|
||||||
|
}
|
||||||
|
|
||||||
public renderToolResult(item: Extract<ConversationItem, { kind: "tool-result" }>): void {
|
public renderToolResult(item: Extract<ConversationItem, { kind: "tool-result" }>): void {
|
||||||
const entry = this.toolCards.get(item.result.call_id);
|
const entry = this.toolCards.get(item.result.call_id);
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { TaskDashboardMiniApp } from "@/core-apps/task-dashboard/task-dashboard-miniapp";
|
||||||
|
|
||||||
|
describe("TaskDashboardMiniApp", () => {
|
||||||
|
it("keeps user-created task form data local and reports Tool lifecycle through SDK", async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const sdk = {
|
||||||
|
context: { app_scope: "task-dashboard", instance_id: "task:1", conversation_id: "c", app_version: "1.0.0", state: "foreground" as const },
|
||||||
|
inbox: { list: () => [], subscribe: () => () => undefined, acknowledge: () => true },
|
||||||
|
tools: {
|
||||||
|
subscribe: () => () => undefined,
|
||||||
|
get: () => undefined,
|
||||||
|
reportProgress: async (id: string) => { calls.push(`progress:${id}`); return { disposition: "accepted" as const }; },
|
||||||
|
complete: async (id: string) => { calls.push(`complete:${id}`); return { disposition: "accepted" as const }; },
|
||||||
|
fail: async () => ({ disposition: "accepted" as const }),
|
||||||
|
cancel: async () => ({ disposition: "accepted" as const }),
|
||||||
|
},
|
||||||
|
lifecycle: { requestForeground: async () => ({ disposition: "accepted" as const }), requestBackground: async () => ({ disposition: "accepted" as const }), requestClose: async () => ({ disposition: "accepted" as const }) },
|
||||||
|
surfaces: { request: async () => ({ disposition: "accepted" as const }) },
|
||||||
|
capabilities: { request: async () => ({ disposition: "accepted" as const }) },
|
||||||
|
workspace: () => ({ version: 1 as const, instances: { version: 1 as const, instances: [] }, focus: { version: 1 as const, stack: [] } }),
|
||||||
|
};
|
||||||
|
const app = new TaskDashboardMiniApp(sdk);
|
||||||
|
app.start();
|
||||||
|
app.createTask("task-1", { title: "本地任务" });
|
||||||
|
await app.completeTask("call-1", "task-1");
|
||||||
|
expect(calls).toEqual(["progress:call-1", "complete:call-1"]);
|
||||||
|
app.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { LineUpMiniAppSDK } from "@/runtime/app-management/miniapp-sdk";
|
||||||
|
import type { JsonObject } from "@/runtime/protocol/lineup-v1";
|
||||||
|
|
||||||
|
/** Minimal bundled reference App: exercises Inbox, Tool progress/result and lifecycle. */
|
||||||
|
export class TaskDashboardMiniApp {
|
||||||
|
private readonly tasks = new Map<string, JsonObject>();
|
||||||
|
private unsubscribeInbox: (() => void) | undefined;
|
||||||
|
private unsubscribeTools: (() => void) | undefined;
|
||||||
|
|
||||||
|
public constructor(private readonly sdk: LineUpMiniAppSDK) {}
|
||||||
|
|
||||||
|
public start(): void {
|
||||||
|
void this.sdk.surfaces.request("open", { state: { title: "任务面板", status: "等待 Agent 任务", percent: 0, steps: [] } });
|
||||||
|
this.unsubscribeInbox = this.sdk.inbox.subscribe(message => {
|
||||||
|
if (message.item.kind === "app-call" || message.item.kind === "miniapp-tool-call") this.sdk.inbox.acknowledge(message.message_id);
|
||||||
|
});
|
||||||
|
this.unsubscribeTools = this.sdk.tools.subscribe(call => {
|
||||||
|
if (call.instance_id !== this.sdk.context.instance_id) return;
|
||||||
|
void this.handleTool(call);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleTool(call: { call_id: string; tool_id: string; input: JsonObject }): Promise<void> {
|
||||||
|
await this.sdk.tools.reportProgress(call.call_id, { status: "running", percent: 10, detail: call.tool_id });
|
||||||
|
if (call.tool_id === "task.create") {
|
||||||
|
const title = typeof call.input.title === "string" ? call.input.title : "未命名任务";
|
||||||
|
const taskID = typeof call.input.task_id === "string" ? call.input.task_id : `task-${call.call_id}`;
|
||||||
|
this.createTask(taskID, { title, status: "open" });
|
||||||
|
await this.sdk.surfaces.request("patch", { state: { title: "任务面板", status: `已创建:${title}`, percent: 100, steps: [title] } });
|
||||||
|
await this.sdk.tools.complete(call.call_id, { task_id: taskID });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (call.tool_id === "task.list") {
|
||||||
|
await this.sdk.tools.complete(call.call_id, { tasks: [...this.tasks.entries()].map(([task_id, task]) => ({ task_id, ...task })) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.sdk.tools.fail(call.call_id, { code: "tool_not_supported", message: `不支持的任务工具:${call.tool_id}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** App-local form submission; it is not an Agent standard interaction. */
|
||||||
|
public createTask(taskID: string, values: JsonObject): void {
|
||||||
|
this.tasks.set(taskID, values);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async completeTask(callID: string, taskID: string): Promise<void> {
|
||||||
|
const task = this.tasks.get(taskID) ?? {};
|
||||||
|
await this.sdk.tools.reportProgress(callID, { status: "completed", percent: 100 });
|
||||||
|
await this.sdk.tools.complete(callID, { task_id: taskID, task });
|
||||||
|
}
|
||||||
|
|
||||||
|
public stop(): void {
|
||||||
|
this.unsubscribeInbox?.();
|
||||||
|
this.unsubscribeTools?.();
|
||||||
|
this.unsubscribeInbox = undefined;
|
||||||
|
this.unsubscribeTools = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { WhiteboardMiniApp } from "@/core-apps/whiteboard/whiteboard-miniapp";
|
||||||
|
|
||||||
|
describe("WhiteboardMiniApp", () => {
|
||||||
|
it("uses only the Surface bridge for board state", async () => {
|
||||||
|
const events: string[] = [];
|
||||||
|
const sdk = {
|
||||||
|
context: { app_scope: "whiteboard", instance_id: "whiteboard:1", conversation_id: "c", app_version: "1.0.0", state: "foreground" as const },
|
||||||
|
inbox: { list: () => [], subscribe: () => () => undefined, acknowledge: () => true },
|
||||||
|
tools: { subscribe: () => () => undefined, get: () => undefined, reportProgress: async () => ({ disposition: "accepted" as const }), complete: async () => ({ disposition: "accepted" as const }), fail: async () => ({ disposition: "accepted" as const }), cancel: async () => ({ disposition: "accepted" as const }) },
|
||||||
|
lifecycle: { requestForeground: async () => ({ disposition: "accepted" as const }), requestBackground: async () => ({ disposition: "accepted" as const }), requestClose: async () => ({ disposition: "accepted" as const }) },
|
||||||
|
surfaces: { request: async (event: "open" | "patch" | "close") => { events.push(event); return { disposition: "accepted" as const }; } },
|
||||||
|
capabilities: { request: async () => ({ disposition: "accepted" as const }) },
|
||||||
|
workspace: () => ({ version: 1 as const, instances: { version: 1 as const, instances: [] }, focus: { version: 1 as const, stack: [] } }),
|
||||||
|
};
|
||||||
|
const app = new WhiteboardMiniApp(sdk);
|
||||||
|
await app.open({ nodes: [] });
|
||||||
|
await app.patch({ nodes: [{ id: "n1" }] });
|
||||||
|
await app.close();
|
||||||
|
expect(events).toEqual(["open", "patch", "close"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import type { LineUpMiniAppSDK } from "@/runtime/app-management/miniapp-sdk";
|
||||||
|
import type { JsonObject } from "@/runtime/protocol/lineup-v1";
|
||||||
|
|
||||||
|
/** Minimal bundled reference App: exercises isolated Surface requests and artifact metadata. */
|
||||||
|
export class WhiteboardMiniApp {
|
||||||
|
private state: JsonObject = {};
|
||||||
|
private unsubscribeTools: (() => void) | undefined;
|
||||||
|
|
||||||
|
public constructor(private readonly sdk: LineUpMiniAppSDK) {}
|
||||||
|
|
||||||
|
public async open(initialState: JsonObject = {}): Promise<void> {
|
||||||
|
this.state = { ...initialState };
|
||||||
|
await this.sdk.surfaces.request("open", { state: this.state });
|
||||||
|
}
|
||||||
|
|
||||||
|
public start(): void {
|
||||||
|
void this.open({ title: "Whiteboard", status: "可编辑", elements: [] });
|
||||||
|
this.unsubscribeTools = this.sdk.tools.subscribe(call => { void this.handleTool(call); });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleTool(call: { call_id: string; tool_id: string; input: JsonObject }): Promise<void> {
|
||||||
|
if (call.tool_id === "whiteboard.open") {
|
||||||
|
await this.sdk.tools.reportProgress(call.call_id, { status: "opening", percent: 40 });
|
||||||
|
await this.sdk.tools.complete(call.call_id, { status: "completed" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (call.tool_id === "whiteboard.export") {
|
||||||
|
const artifactID = typeof call.input.artifact_id === "string" ? call.input.artifact_id : `whiteboard-${call.call_id}`;
|
||||||
|
await this.sdk.tools.reportProgress(call.call_id, { status: "exporting", percent: 80 });
|
||||||
|
await this.sdk.surfaces.request("patch", { state: this.state, artifact: { artifact_id: artifactID } });
|
||||||
|
await this.sdk.tools.complete(call.call_id, { artifact_id: artifactID });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.sdk.tools.fail(call.call_id, { code: "tool_not_supported", message: `不支持的画板工具:${call.tool_id}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drawing/editing is private Surface state and never enters the IM transcript. */
|
||||||
|
public async patch(delta: JsonObject): Promise<void> {
|
||||||
|
this.state = { ...this.state, ...delta };
|
||||||
|
await this.sdk.surfaces.request("patch", { state: this.state });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async exportArtifact(artifact: JsonObject): Promise<void> {
|
||||||
|
await this.sdk.surfaces.request("patch", { artifact });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async close(): Promise<void> {
|
||||||
|
this.unsubscribeTools?.();
|
||||||
|
this.unsubscribeTools = undefined;
|
||||||
|
await this.sdk.surfaces.request("close", {});
|
||||||
|
}
|
||||||
|
}
|
||||||
+119
-1
@@ -27,6 +27,8 @@ import { CapabilityAuditLog } from "@/runtime/capabilities/capability-audit";
|
|||||||
import { CapabilityExecutor, type PickedFile } from "@/runtime/capabilities/capability-executor";
|
import { CapabilityExecutor, type PickedFile } from "@/runtime/capabilities/capability-executor";
|
||||||
import { LineUpRuntime } from "@/runtime/coordination/lineup-runtime";
|
import { LineUpRuntime } from "@/runtime/coordination/lineup-runtime";
|
||||||
import { RuntimeAppHost } from "@/runtime/app-management/runtime-app-host";
|
import { RuntimeAppHost } from "@/runtime/app-management/runtime-app-host";
|
||||||
|
import { TaskDashboardMiniApp } from "@/core-apps/task-dashboard/task-dashboard-miniapp";
|
||||||
|
import { WhiteboardMiniApp } from "@/core-apps/whiteboard/whiteboard-miniapp";
|
||||||
|
|
||||||
// Vite 的开发标记和 M4 测试控制器需要扩展浏览器的全局类型。
|
// Vite 的开发标记和 M4 测试控制器需要扩展浏览器的全局类型。
|
||||||
declare global {
|
declare global {
|
||||||
@@ -123,13 +125,84 @@ const mountedApp = new RuntimeAppHost(runtime, app, {
|
|||||||
}).mountDefaultApp();
|
}).mountDefaultApp();
|
||||||
// 从通用挂载结果中取得当前应用的 SDK 和页面投影对象。
|
// 从通用挂载结果中取得当前应用的 SDK 和页面投影对象。
|
||||||
chatSDK = mountedApp.sdk;
|
chatSDK = mountedApp.sdk;
|
||||||
const { messages, loginView, chatView, presence, rendererContext, rendererRegistry } = mountedApp;
|
const { messages, appSessions, loginView, chatView, presence, rendererContext, rendererRegistry } = mountedApp;
|
||||||
// Surface Host 只负责承载已经通过 Runtime/Surface 策略检查的隔离界面。
|
// Surface Host 只负责承载已经通过 Runtime/Surface 策略检查的隔离界面。
|
||||||
// Surface 的 ready/event 回调仍回到状态管理和 SDK Action,不直接执行 Agent 指令。
|
// Surface 的 ready/event 回调仍回到状态管理和 SDK Action,不直接执行 Agent 指令。
|
||||||
const surfaceHost = new IsolatedSurfaceHost(messages, {
|
const surfaceHost = new IsolatedSurfaceHost(messages, {
|
||||||
ready(instanceID) { surfaceInstances.ready(instanceID, new Date().toISOString()); },
|
ready(instanceID) { surfaceInstances.ready(instanceID, new Date().toISOString()); },
|
||||||
event(instanceID) { queueSurfaceCancel(instanceID); },
|
event(instanceID) { queueSurfaceCancel(instanceID); },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// These are the two shipped reference MiniApps. They are mounted through the
|
||||||
|
// same restricted SDK as any future bundled MiniApp; this adapter only wires
|
||||||
|
// lifecycle to the SDK object and never hands them Runtime internals.
|
||||||
|
const bundledMiniApps = new Map<string, { stop: () => void }>();
|
||||||
|
function reconcileBundledMiniApps(): void {
|
||||||
|
const active = new Set<string>();
|
||||||
|
for (const instance of runtime.lifecycle.instances.list(runtime.currentConversationID())) {
|
||||||
|
if (instance.app_scope !== "task-dashboard" && instance.app_scope !== "whiteboard") continue;
|
||||||
|
if (!["starting", "foreground", "background", "suspended"].includes(instance.state)) continue;
|
||||||
|
active.add(instance.instance_id);
|
||||||
|
if (bundledMiniApps.has(instance.instance_id)) continue;
|
||||||
|
try {
|
||||||
|
const sdk = runtime.openBundledMiniApp(instance.app_scope, instance.instance_id);
|
||||||
|
if (instance.app_scope === "task-dashboard") {
|
||||||
|
const miniapp = new TaskDashboardMiniApp(sdk);
|
||||||
|
miniapp.start();
|
||||||
|
bundledMiniApps.set(instance.instance_id, { stop: () => miniapp.stop() });
|
||||||
|
} else {
|
||||||
|
const miniapp = new WhiteboardMiniApp(sdk);
|
||||||
|
miniapp.start();
|
||||||
|
bundledMiniApps.set(instance.instance_id, { stop: () => { void miniapp.close(); } });
|
||||||
|
}
|
||||||
|
} catch (reason) {
|
||||||
|
console.warn("[LineUp Host] bundled MiniApp mount rejected", instance.instance_id, reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [instanceID, mounted] of bundledMiniApps) {
|
||||||
|
if (active.has(instanceID)) continue;
|
||||||
|
mounted.stop();
|
||||||
|
bundledMiniApps.delete(instanceID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runtime.onEvent(event => {
|
||||||
|
if (event.type === "app-lifecycle") reconcileBundledMiniApps();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bundled MiniApps request local Surfaces through Runtime. The Host adapter
|
||||||
|
* is the only place that turns the already-scoped request into a DOM iframe;
|
||||||
|
* no request is sent back through the Agent protocol.
|
||||||
|
*/
|
||||||
|
runtime.onEvent(event => {
|
||||||
|
if (event.type !== "surface-request") return;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const appID = event.app_scope === "task-dashboard" ? "lineup.task-dashboard" : event.app_scope === "whiteboard" ? "lineup.whiteboard" : undefined;
|
||||||
|
if (!appID) return;
|
||||||
|
const version = "0.1.0";
|
||||||
|
if (event.event === "open") {
|
||||||
|
const result = surfaceInstances.open({ instance_id: event.instance_id, app: { app_id: appID, version }, state: event.data.state ?? event.data }, currentConversationKey(), now);
|
||||||
|
if (result.instance && (result.disposition === "accepted" || result.disposition === "duplicate")) surfaceHost.mount(result.instance);
|
||||||
|
if (result.disposition === "accepted") runtime.replaceSurfaces(surfaceInstances.snapshot());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.event === "patch") {
|
||||||
|
const result = surfaceInstances.patch(event.instance_id, event.data.state ?? event.data, now);
|
||||||
|
if (result.instance && result.disposition === "accepted") surfaceHost.update(result.instance);
|
||||||
|
if (result.disposition === "accepted") runtime.replaceSurfaces(surfaceInstances.snapshot());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
surfaceInstances.close(event.instance_id);
|
||||||
|
surfaceHost.unmount(event.instance_id);
|
||||||
|
runtime.replaceSurfaces(surfaceInstances.snapshot());
|
||||||
|
});
|
||||||
|
|
||||||
|
// These two Surfaces are shipped with the Host for SDK v1. Enabling them here
|
||||||
|
// does not create a market or download path; it only admits the exact local
|
||||||
|
// manifests used by the bundled reference MiniApps.
|
||||||
|
for (const [appID, version] of [["lineup.task-dashboard", "0.1.0"], ["lineup.whiteboard", "0.1.0"]] as const) {
|
||||||
|
surfaceRegistry.enable(appID, version, new Date().toISOString());
|
||||||
|
}
|
||||||
$<HTMLInputElement>("#api").value = localStorage.getItem("lineup.api") || DEFAULT_API;
|
$<HTMLInputElement>("#api").value = localStorage.getItem("lineup.api") || DEFAULT_API;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -220,6 +293,51 @@ chatSDK.subscribeAgentMessages(message => {
|
|||||||
chatSDK.acknowledgeAgentMessage(message.message_id);
|
chatSDK.acknowledgeAgentMessage(message.message_id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
chatSDK.subscribe(view => renderAppSessions(view.app_sessions));
|
||||||
|
|
||||||
|
function renderAppSessions(sessions: readonly {
|
||||||
|
app_session_id: string;
|
||||||
|
app_scope: string;
|
||||||
|
instance_id: string;
|
||||||
|
state: string;
|
||||||
|
interaction_count: number;
|
||||||
|
pending_interaction_count: number;
|
||||||
|
history: readonly { id: string; role: "agent" | "user"; text: string }[];
|
||||||
|
continued_from_app_session_id?: string;
|
||||||
|
}[]): void {
|
||||||
|
appSessions.replaceChildren();
|
||||||
|
for (const session of sessions.filter(candidate => candidate.app_scope !== "chat")) {
|
||||||
|
const group = document.createElement("details");
|
||||||
|
group.className = "app-session-group";
|
||||||
|
const summary = document.createElement("summary");
|
||||||
|
const name = session.app_scope === "task-dashboard" ? "Task Dashboard" : session.app_scope === "whiteboard" ? "Whiteboard" : session.app_scope;
|
||||||
|
const pending = session.pending_interaction_count > 0 ? ` · 等待回答 ${session.pending_interaction_count}` : "";
|
||||||
|
summary.textContent = `${name} · ${session.interaction_count} 条交互${pending}`;
|
||||||
|
const detail = document.createElement("small");
|
||||||
|
detail.textContent = session.state === "stopped" || session.state === "failed" ? "已结束,只读历史" : session.state === "background" ? "后台运行" : "当前应用会话";
|
||||||
|
group.append(summary, detail);
|
||||||
|
const history = document.createElement("div");
|
||||||
|
history.className = "app-session-history";
|
||||||
|
for (const entry of session.history) {
|
||||||
|
const line = document.createElement("p");
|
||||||
|
line.className = `app-session-history-${entry.role}`;
|
||||||
|
line.textContent = `${entry.role === "agent" ? "Agent" : "你"}:${entry.text}`;
|
||||||
|
history.append(line);
|
||||||
|
}
|
||||||
|
group.append(history);
|
||||||
|
if (session.state === "stopped" || session.state === "failed") {
|
||||||
|
const continueButton = document.createElement("button");
|
||||||
|
continueButton.type = "button";
|
||||||
|
continueButton.textContent = "继续处理";
|
||||||
|
continueButton.addEventListener("click", () => {
|
||||||
|
void chatSDK.dispatch({ type: "chat.continue_app_session", conversation_id: chatSDK.snapshot().conversation_id ?? "", app_session_id: session.app_session_id });
|
||||||
|
});
|
||||||
|
group.append(continueButton);
|
||||||
|
}
|
||||||
|
appSessions.append(group);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
chatSDK.subscribeEvents(event => {
|
chatSDK.subscribeEvents(event => {
|
||||||
// 这些是 Runtime 给 Chat 的安全事件投影,不包含原始 Agent delivery。
|
// 这些是 Runtime 给 Chat 的安全事件投影,不包含原始 Agent delivery。
|
||||||
if (event.type === "local-item") {
|
if (event.type === "local-item") {
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ export type AppInstanceState = "starting" | "foreground" | "background" | "suspe
|
|||||||
|
|
||||||
export type AppInstanceRecord = {
|
export type AppInstanceRecord = {
|
||||||
instance_id: string;
|
instance_id: string;
|
||||||
|
app_session_id?: string;
|
||||||
app_scope: AppScope;
|
app_scope: AppScope;
|
||||||
conversation_id?: string;
|
conversation_id?: string;
|
||||||
state: AppInstanceState;
|
state: AppInstanceState;
|
||||||
parent_instance_id?: string;
|
parent_instance_id?: string;
|
||||||
|
continued_from_app_session_id?: string;
|
||||||
started_at: string;
|
started_at: string;
|
||||||
stopped_at?: string;
|
stopped_at?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
@@ -47,7 +49,7 @@ export class AppInstanceManager {
|
|||||||
this.records.clear();
|
this.records.clear();
|
||||||
if (snapshot?.version !== 1 || !Array.isArray(snapshot.instances)) return;
|
if (snapshot?.version !== 1 || !Array.isArray(snapshot.instances)) return;
|
||||||
for (const raw of snapshot.instances) {
|
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;
|
if (!raw || !validID(raw.instance_id) || (raw.app_session_id !== undefined && !validID(raw.app_session_id)) || (raw.continued_from_app_session_id !== undefined && !validID(raw.continued_from_app_session_id)) || typeof raw.app_scope !== "string" || !isState(raw.state) || !validTime(raw.started_at)) continue;
|
||||||
// A host cannot safely resume an in-progress native operation. It can
|
// A host cannot safely resume an in-progress native operation. It can
|
||||||
// however present suspended apps and let the orchestrator restore focus.
|
// however present suspended apps and let the orchestrator restore focus.
|
||||||
const recovered = raw.state === "starting" || raw.state === "stopping" ? { ...raw, state: "suspended" as const } : raw;
|
const recovered = raw.state === "starting" || raw.state === "stopping" ? { ...raw, state: "suspended" as const } : raw;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export type AppWorkspaceSnapshot = { version: 1; instances: AppInstanceSnapshot;
|
|||||||
export class AppLifecycleManager {
|
export class AppLifecycleManager {
|
||||||
public readonly instances = new AppInstanceManager();
|
public readonly instances = new AppInstanceManager();
|
||||||
public readonly focus = new AppFocusManager();
|
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 start(input: { instance_id: string; app_scope: AppScope; conversation_id?: string; app_session_id?: string; parent_instance_id?: string; continued_from_app_session_id?: string }, now: string): AppInstanceRecord { return this.instances.create(input, now); }
|
||||||
public foreground(instanceID: string, now: string): AppInstanceRecord {
|
public foreground(instanceID: string, now: string): AppInstanceRecord {
|
||||||
const current = this.instances.get(instanceID); if (!current) throw new Error("Unknown App Instance.");
|
const current = this.instances.get(instanceID); if (!current) throw new Error("Unknown App Instance.");
|
||||||
const prior = this.focus.foreground();
|
const prior = this.focus.foreground();
|
||||||
|
|||||||
@@ -10,7 +10,12 @@
|
|||||||
* or a marketplace namespace. The initial registry is static, but the same
|
* 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.
|
* record shape is the boundary that the later App Registry/Market will own.
|
||||||
*/
|
*/
|
||||||
|
import { validateMiniAppManifest, type MiniAppManifestV1 } from "@/runtime/app-management/miniapp-manifest";
|
||||||
|
import { BUNDLED_REFERENCE_MANIFESTS } from "@/runtime/app-management/reference-miniapps";
|
||||||
|
import type { JsonObject } from "@/runtime/protocol/lineup-v1";
|
||||||
|
|
||||||
export type AppScope = "chat" | "app-registry" | "settings" | "runtime" | (string & {});
|
export type AppScope = "chat" | "app-registry" | "settings" | "runtime" | (string & {});
|
||||||
|
export type MiniAppKind = "system" | "bundled";
|
||||||
|
|
||||||
export type AppToolDefinition = {
|
export type AppToolDefinition = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -18,6 +23,13 @@ export type AppToolDefinition = {
|
|||||||
/** JSON-object property names accepted by this tool. */
|
/** JSON-object property names accepted by this tool. */
|
||||||
parameters: readonly string[];
|
parameters: readonly string[];
|
||||||
requires_permission?: string;
|
requires_permission?: string;
|
||||||
|
input_schema?: JsonObject;
|
||||||
|
output_schema?: JsonObject;
|
||||||
|
target?: {
|
||||||
|
app_scope: AppScope;
|
||||||
|
requires_foreground: boolean;
|
||||||
|
restore_previous_focus: boolean;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,16 +45,19 @@ export type AppManifest = {
|
|||||||
|
|
||||||
export type CoreAppRecord = {
|
export type CoreAppRecord = {
|
||||||
app_scope: AppScope;
|
app_scope: AppScope;
|
||||||
kind: "core" | "extension";
|
kind: MiniAppKind;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
default_eligible: boolean;
|
default_eligible: boolean;
|
||||||
recovery: boolean;
|
recovery: boolean;
|
||||||
manifest: AppManifest;
|
manifest: AppManifest;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Transitional input accepted while older host adapters are upgraded. */
|
||||||
|
export type CoreAppRecordInput = Omit<CoreAppRecord, "kind"> & { kind: MiniAppKind | "core" | "extension" };
|
||||||
|
|
||||||
const INITIAL_CORE_APPS: readonly CoreAppRecord[] = [
|
const INITIAL_CORE_APPS: readonly CoreAppRecord[] = [
|
||||||
{
|
{
|
||||||
app_scope: "chat", kind: "core", enabled: true, default_eligible: true, recovery: true,
|
app_scope: "chat", kind: "system", enabled: true, default_eligible: true, recovery: true,
|
||||||
manifest: {
|
manifest: {
|
||||||
app_scope: "chat", version: "1.0.0", permissions: [],
|
app_scope: "chat", version: "1.0.0", permissions: [],
|
||||||
tools: [
|
tools: [
|
||||||
@@ -61,8 +76,11 @@ const INITIAL_CORE_APPS: readonly CoreAppRecord[] = [
|
|||||||
export class CoreAppRegistry {
|
export class CoreAppRegistry {
|
||||||
private readonly apps = new Map<AppScope, CoreAppRecord>();
|
private readonly apps = new Map<AppScope, CoreAppRecord>();
|
||||||
|
|
||||||
public constructor(records: readonly CoreAppRecord[] = INITIAL_CORE_APPS) {
|
public constructor(records: readonly CoreAppRecordInput[] = INITIAL_CORE_APPS) {
|
||||||
for (const record of records) this.register(record);
|
for (const record of records) this.register(record);
|
||||||
|
if (records === INITIAL_CORE_APPS) {
|
||||||
|
for (const manifest of BUNDLED_REFERENCE_MANIFESTS) this.installMiniAppManifest(manifest);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public list(): readonly CoreAppRecord[] {
|
public list(): readonly CoreAppRecord[] {
|
||||||
@@ -83,13 +101,39 @@ export class CoreAppRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Runtime-only installation path; extension Apps never receive this object. */
|
/** Runtime-only installation path; extension Apps never receive this object. */
|
||||||
public install(record: CoreAppRecord): void { this.register(record); }
|
public install(record: CoreAppRecordInput): void { this.register(record); }
|
||||||
|
|
||||||
private register(record: CoreAppRecord): void {
|
/** Admits a validated SDK v1 Manifest without exposing the Manifest object to Apps. */
|
||||||
|
public installMiniAppManifest(manifest: MiniAppManifestV1, options: Pick<CoreAppRecord, "enabled" | "default_eligible" | "recovery"> = { enabled: true, default_eligible: false, recovery: false }): void {
|
||||||
|
const validation = validateMiniAppManifest(manifest);
|
||||||
|
if (!validation.accepted) throw new Error(`MiniApp Manifest denied: ${validation.detail}`);
|
||||||
|
this.install({
|
||||||
|
app_scope: manifest.app_scope,
|
||||||
|
kind: manifest.kind,
|
||||||
|
...options,
|
||||||
|
manifest: {
|
||||||
|
app_scope: manifest.app_scope,
|
||||||
|
version: manifest.version,
|
||||||
|
permissions: [...manifest.requested_capabilities],
|
||||||
|
tools: manifest.tools.map(tool => ({
|
||||||
|
name: tool.id,
|
||||||
|
handling: tool.handling,
|
||||||
|
parameters: [],
|
||||||
|
input_schema: tool.input_schema,
|
||||||
|
output_schema: tool.output_schema,
|
||||||
|
target: tool.target,
|
||||||
|
...(tool.permissions?.[0] ? { requires_permission: tool.permissions[0] } : {}),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private register(record: CoreAppRecordInput): void {
|
||||||
if (!isAppScope(record.app_scope) || record.manifest.app_scope !== record.app_scope) throw new Error("Core App Registry received an invalid app_scope.");
|
if (!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 (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}`);
|
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));
|
const kind: MiniAppKind = record.kind === "core" ? "system" : record.kind === "extension" ? "bundled" : record.kind;
|
||||||
|
this.apps.set(record.app_scope, copyRecord({ ...record, kind }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +143,7 @@ function copyRecord(record: CoreAppRecord): CoreAppRecord {
|
|||||||
manifest: {
|
manifest: {
|
||||||
...record.manifest,
|
...record.manifest,
|
||||||
permissions: [...record.manifest.permissions],
|
permissions: [...record.manifest.permissions],
|
||||||
tools: record.manifest.tools.map(tool => ({ ...tool, parameters: [...tool.parameters] })),
|
tools: record.manifest.tools.map(tool => ({ ...tool, parameters: [...tool.parameters], ...(tool.input_schema ? { input_schema: clone(tool.input_schema) } : {}), ...(tool.output_schema ? { output_schema: clone(tool.output_schema) } : {}) })),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -113,12 +157,17 @@ function isManifest(manifest: AppManifest): boolean {
|
|||||||
&& !toolNames.has(tool.name)
|
&& !toolNames.has(tool.name)
|
||||||
&& ["direct", "interactive", "launch", "foreground", "operation"].includes(tool.handling)
|
&& ["direct", "interactive", "launch", "foreground", "operation"].includes(tool.handling)
|
||||||
&& tool.parameters.every(parameter => /^[a-z][a-z0-9_]{0,63}$/.test(parameter))
|
&& tool.parameters.every(parameter => /^[a-z][a-z0-9_]{0,63}$/.test(parameter))
|
||||||
|
&& (!tool.input_schema || isJsonObject(tool.input_schema))
|
||||||
|
&& (!tool.output_schema || isJsonObject(tool.output_schema))
|
||||||
&& (!tool.requires_permission || manifest.permissions.includes(tool.requires_permission));
|
&& (!tool.requires_permission || manifest.permissions.includes(tool.requires_permission));
|
||||||
toolNames.add(tool.name);
|
toolNames.add(tool.name);
|
||||||
return valid;
|
return valid;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clone<T>(value: T): T { return JSON.parse(JSON.stringify(value)) as T; }
|
||||||
|
function isJsonObject(value: unknown): value is JsonObject { return Boolean(value && typeof value === "object" && !Array.isArray(value)); }
|
||||||
|
|
||||||
export function isAppScope(value: unknown): value is AppScope {
|
export function isAppScope(value: unknown): value is AppScope {
|
||||||
return typeof value === "string" && /^[a-z][a-z0-9-]{0,63}$/.test(value);
|
return typeof value === "string" && /^[a-z][a-z0-9-]{0,63}$/.test(value);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,17 @@ export type ChatViewModel = {
|
|||||||
agent_presence: readonly AgentPresence[];
|
agent_presence: readonly AgentPresence[];
|
||||||
tool_calls: readonly ToolCallRecord[];
|
tool_calls: readonly ToolCallRecord[];
|
||||||
tasks: readonly TaskRecord[];
|
tasks: readonly TaskRecord[];
|
||||||
|
/** Collapsed App sub-session summaries; business UI actions are not included. */
|
||||||
|
app_sessions: readonly Readonly<{
|
||||||
|
app_session_id: string;
|
||||||
|
app_scope: AppScope;
|
||||||
|
instance_id: string;
|
||||||
|
state: string;
|
||||||
|
interaction_count: number;
|
||||||
|
pending_interaction_count: number;
|
||||||
|
history: readonly Readonly<{ id: string; role: "agent" | "user"; text: string }>[];
|
||||||
|
continued_from_app_session_id?: string;
|
||||||
|
}>[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AgentChatMessage = ScopedConversationItem & {
|
export type AgentChatMessage = ScopedConversationItem & {
|
||||||
@@ -34,7 +45,8 @@ export type ChatAction =
|
|||||||
/** A bounded result from a user-approved capability interaction. */
|
/** A bounded result from a user-approved capability interaction. */
|
||||||
| { type: "chat.report_app_result"; conversation_id: string; result: JsonObject }
|
| { type: "chat.report_app_result"; conversation_id: string; result: JsonObject }
|
||||||
/** The current Chat Core App only exposes cancellation for a running Surface. */
|
/** The current Chat Core App only exposes cancellation for a running Surface. */
|
||||||
| { type: "chat.cancel_surface"; conversation_id: string; instance_id: string };
|
| { type: "chat.cancel_surface"; conversation_id: string; instance_id: string }
|
||||||
|
| { type: "chat.continue_app_session"; conversation_id: string; app_session_id: string };
|
||||||
|
|
||||||
export type CommandReceipt =
|
export type CommandReceipt =
|
||||||
| { disposition: "accepted"; local_id?: string }
|
| { disposition: "accepted"; local_id?: string }
|
||||||
@@ -89,7 +101,15 @@ export type RuntimeAppEvent =
|
|||||||
| { type: "sync-status"; status: "syncing" | "idle" | "retrying" }
|
| { type: "sync-status"; status: "syncing" | "idle" | "retrying" }
|
||||||
| { type: "scope-rejected"; reason: "invalid_scope" | "scope_mismatch"; raw: string }
|
| { type: "scope-rejected"; reason: "invalid_scope" | "scope_mismatch"; raw: string }
|
||||||
| { type: "state"; snapshot: KernelSnapshot }
|
| { type: "state"; snapshot: KernelSnapshot }
|
||||||
| { type: "app-lifecycle"; workspace: AppWorkspaceSnapshot; reason: "launched" | "closed" | "failed" | "restored" };
|
| { type: "app-lifecycle"; workspace: AppWorkspaceSnapshot; reason: "launched" | "closed" | "failed" | "restored" }
|
||||||
|
/** A Runtime-approved local Surface request from a bundled MiniApp. */
|
||||||
|
| {
|
||||||
|
type: "surface-request";
|
||||||
|
app_scope: AppScope;
|
||||||
|
instance_id: string;
|
||||||
|
event: "open" | "patch" | "close";
|
||||||
|
data: JsonObject;
|
||||||
|
};
|
||||||
|
|
||||||
export type AppMessageSubscription = {
|
export type AppMessageSubscription = {
|
||||||
app_scope: AppScope;
|
app_scope: AppScope;
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* MiniApp SDK v1 的声明性 Manifest 契约。
|
||||||
|
*
|
||||||
|
* 该模块只验证 Runtime 接受的声明,不加载 Bundle、不创建 DOM,也不授予任何能力。
|
||||||
|
* Runtime、Inventory 和 Tool Router 共享它,避免各自解释 Manifest。
|
||||||
|
*/
|
||||||
|
import type { JsonObject } from "@/runtime/protocol/lineup-v1";
|
||||||
|
|
||||||
|
export type MiniAppKind = "system" | "bundled";
|
||||||
|
export type MiniAppToolHandling = "direct" | "interactive" | "launch" | "foreground" | "operation";
|
||||||
|
|
||||||
|
export type MiniAppToolTarget = Readonly<{
|
||||||
|
app_scope: string;
|
||||||
|
requires_foreground: boolean;
|
||||||
|
restore_previous_focus: boolean;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type MiniAppToolDescriptor = Readonly<{
|
||||||
|
id: string;
|
||||||
|
version: 1;
|
||||||
|
handling: MiniAppToolHandling;
|
||||||
|
target?: MiniAppToolTarget;
|
||||||
|
input_schema: JsonObject;
|
||||||
|
output_schema: JsonObject;
|
||||||
|
permissions?: readonly string[];
|
||||||
|
timeout_ms?: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type MiniAppManifestV1 = Readonly<{
|
||||||
|
app_scope: string;
|
||||||
|
version: string;
|
||||||
|
kind: MiniAppKind;
|
||||||
|
tools: readonly MiniAppToolDescriptor[];
|
||||||
|
subscriptions: readonly string[];
|
||||||
|
requested_capabilities: readonly string[];
|
||||||
|
host: Readonly<{
|
||||||
|
min_version: string;
|
||||||
|
surface_required: boolean;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type ManifestValidation =
|
||||||
|
| Readonly<{ accepted: true }>
|
||||||
|
| Readonly<{ accepted: false; code: "manifest_denied"; detail: string }>;
|
||||||
|
|
||||||
|
const APP_SCOPE = /^[a-z][a-z0-9-]{0,63}$/;
|
||||||
|
const TOOL_ID = /^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$/;
|
||||||
|
const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
||||||
|
const CAPABILITY = /^[a-z][a-z0-9._-]{0,63}$/;
|
||||||
|
const HANDLINGS = new Set<MiniAppToolHandling>(["direct", "interactive", "launch", "foreground", "operation"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks only static, local rules. Policy and per-user capability approval are
|
||||||
|
* intentionally deferred to Runtime, because a Manifest never grants access.
|
||||||
|
*/
|
||||||
|
export function validateMiniAppManifest(manifest: MiniAppManifestV1): ManifestValidation {
|
||||||
|
if (!APP_SCOPE.test(manifest.app_scope)) return reject("app_scope must be a lowercase Runtime scope.");
|
||||||
|
if (!SEMVER.test(manifest.version) || !SEMVER.test(manifest.host.min_version)) return reject("version and host.min_version must be semantic versions.");
|
||||||
|
if (manifest.kind !== "system" && manifest.kind !== "bundled") return reject("kind must be system or bundled.");
|
||||||
|
if (manifest.kind === "bundled" && manifest.host.surface_required !== true) return reject("bundled MiniApps require a restricted Surface.");
|
||||||
|
if (!Array.isArray(manifest.tools) || !Array.isArray(manifest.subscriptions) || !Array.isArray(manifest.requested_capabilities)) return reject("manifest collections must be arrays.");
|
||||||
|
if (!manifest.subscriptions.every(entry => typeof entry === "string" && entry.length > 0)) return reject("subscriptions must be non-empty strings.");
|
||||||
|
if (!manifest.requested_capabilities.every(entry => CAPABILITY.test(entry))) return reject("requested capabilities contain an invalid identifier.");
|
||||||
|
|
||||||
|
const toolIDs = new Set<string>();
|
||||||
|
for (const tool of manifest.tools) {
|
||||||
|
if (!TOOL_ID.test(tool.id) || toolIDs.has(tool.id)) return reject("tool IDs must be unique dotted identifiers.");
|
||||||
|
toolIDs.add(tool.id);
|
||||||
|
if (tool.version !== 1 || !HANDLINGS.has(tool.handling)) return reject("tool version or handling is invalid.");
|
||||||
|
if (!isJsonObject(tool.input_schema) || !isJsonObject(tool.output_schema)) return reject("tool schemas must be JSON objects.");
|
||||||
|
if (tool.permissions && !tool.permissions.every((permission: string) => manifest.requested_capabilities.includes(permission))) return reject("tool permissions must be declared by the manifest.");
|
||||||
|
if (tool.timeout_ms !== undefined && (!Number.isInteger(tool.timeout_ms) || tool.timeout_ms <= 0)) return reject("tool timeout_ms must be a positive integer.");
|
||||||
|
|
||||||
|
if (tool.handling === "interactive") {
|
||||||
|
if (tool.target !== undefined) return reject("interactive Tools must not target a MiniApp.");
|
||||||
|
if (tool.timeout_ms !== undefined) return reject("interactive Tools use interaction expiry, not tool timeout_ms.");
|
||||||
|
} else if (!validTarget(tool.target)) {
|
||||||
|
return reject("non-interactive Tools require a valid target.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { accepted: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validTarget(target: MiniAppToolTarget | undefined): target is MiniAppToolTarget {
|
||||||
|
return Boolean(target
|
||||||
|
&& APP_SCOPE.test(target.app_scope)
|
||||||
|
&& typeof target.requires_foreground === "boolean"
|
||||||
|
&& typeof target.restore_previous_focus === "boolean");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isJsonObject(value: unknown): value is JsonObject {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reject(detail: string): ManifestValidation {
|
||||||
|
return { accepted: false, code: "manifest_denied", detail };
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import fixture from "@/runtime/app-management/golden/miniapp-sdk-v1.json";
|
import fixture from "@/runtime/app-management/golden/miniapp-sdk-v1.json";
|
||||||
|
import { validateMiniAppManifest, type MiniAppManifestV1 } from "@/runtime/app-management/miniapp-manifest";
|
||||||
|
import { validateStandardInteractionRequest } from "@/runtime/coordination/standard-interaction-contract";
|
||||||
|
|
||||||
type GoldenCase = Readonly<{
|
type GoldenCase = Readonly<{
|
||||||
id: string;
|
id: string;
|
||||||
@@ -35,4 +37,28 @@ describe("MiniApp SDK v1 frozen golden contract", () => {
|
|||||||
expect(Object.keys(entry.expected).length).toBeGreaterThan(0);
|
expect(Object.keys(entry.expected).length).toBeGreaterThan(0);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("enforces the frozen bundled Surface boundary", () => {
|
||||||
|
const manifest = (surfaceRequired: boolean): MiniAppManifestV1 => ({
|
||||||
|
app_scope: "task-dashboard",
|
||||||
|
version: "1.0.0",
|
||||||
|
kind: "bundled",
|
||||||
|
tools: [],
|
||||||
|
subscriptions: [],
|
||||||
|
requested_capabilities: [],
|
||||||
|
host: { min_version: "1.0.0", surface_required: surfaceRequired },
|
||||||
|
});
|
||||||
|
expect(validateMiniAppManifest(manifest(true))).toEqual({ accepted: true });
|
||||||
|
expect(validateMiniAppManifest(manifest(false))).toMatchObject({ accepted: false, code: "manifest_denied" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enforces frozen standard-interaction expiry rules", () => {
|
||||||
|
const now = new Date("2026-08-05T00:00:00.000Z");
|
||||||
|
expect(validateStandardInteractionRequest({ kind: "confirm", title: "继续", prompt: "继续吗?" }, now)).toEqual({
|
||||||
|
accepted: true,
|
||||||
|
expires_at: "2026-08-05T00:15:00.000Z",
|
||||||
|
});
|
||||||
|
expect(validateStandardInteractionRequest({ kind: "input", title: "描述", prompt: "请输入", expires_in_ms: 60_000, field: { id: "text", label: "描述", type: "textarea" } }, now)).toMatchObject({ accepted: true });
|
||||||
|
expect(validateStandardInteractionRequest({ kind: "notice", title: "提示", message: "已保存", expires_in_ms: 60_000 } as never, now)).toEqual({ accepted: false, code: "interaction_request_invalid" });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type { AppScope } from "@/runtime/app-management/app-registry";
|
||||||
|
import type { AppWorkspaceSnapshot } from "@/runtime/app-management/app-lifecycle-manager";
|
||||||
|
import type { ScopedConversationItem } from "@/runtime/coordination/runtime-envelope";
|
||||||
|
import type { MiniAppToolCallRecord } from "@/runtime/coordination/miniapp-tool-state";
|
||||||
|
import type { JsonObject } from "@/runtime/protocol/lineup-v1";
|
||||||
|
import type { CommandReceipt, Unsubscribe } from "@/runtime/app-management/app-sdk";
|
||||||
|
|
||||||
|
export type MiniAppSDKContext = Readonly<{
|
||||||
|
app_scope: AppScope;
|
||||||
|
instance_id: string;
|
||||||
|
conversation_id: string;
|
||||||
|
app_version: string;
|
||||||
|
state: "starting" | "foreground" | "background" | "suspended";
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type MiniAppToolProgress = Readonly<{ percent?: number; status?: string; detail?: string }>;
|
||||||
|
export type MiniAppToolFailure = Readonly<{ code?: string; message: string }>;
|
||||||
|
|
||||||
|
export interface LineUpMiniAppSDK {
|
||||||
|
readonly context: MiniAppSDKContext;
|
||||||
|
readonly inbox: {
|
||||||
|
list(after_message_id?: string): readonly ScopedConversationItem[];
|
||||||
|
subscribe(listener: (message: ScopedConversationItem) => void): Unsubscribe;
|
||||||
|
acknowledge(message_id: string): boolean;
|
||||||
|
};
|
||||||
|
readonly tools: {
|
||||||
|
subscribe(listener: (call: MiniAppToolCallRecord) => void): Unsubscribe;
|
||||||
|
get(call_id: string): MiniAppToolCallRecord | undefined;
|
||||||
|
reportProgress(call_id: string, progress: MiniAppToolProgress): Promise<CommandReceipt>;
|
||||||
|
complete(call_id: string, result: JsonObject): Promise<CommandReceipt>;
|
||||||
|
fail(call_id: string, failure: MiniAppToolFailure): Promise<CommandReceipt>;
|
||||||
|
cancel(call_id: string, reason?: string): Promise<CommandReceipt>;
|
||||||
|
};
|
||||||
|
readonly lifecycle: {
|
||||||
|
requestForeground(): Promise<CommandReceipt>;
|
||||||
|
requestBackground(): Promise<CommandReceipt>;
|
||||||
|
requestClose(reason?: string): Promise<CommandReceipt>;
|
||||||
|
};
|
||||||
|
readonly surfaces: {
|
||||||
|
request(event: "open" | "patch" | "close", data: JsonObject): Promise<CommandReceipt>;
|
||||||
|
};
|
||||||
|
readonly capabilities: {
|
||||||
|
request(name: string, reason: string, input?: JsonObject): Promise<CommandReceipt>;
|
||||||
|
};
|
||||||
|
/** Read-only host state projection for recovery and diagnostics. */
|
||||||
|
readonly workspace: () => AppWorkspaceSnapshot;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { validateMiniAppManifest } from "@/runtime/app-management/miniapp-manifest";
|
||||||
|
import { BUNDLED_REFERENCE_MANIFESTS } from "@/runtime/app-management/reference-miniapps";
|
||||||
|
|
||||||
|
describe("bundled MiniApp reference manifests", () => {
|
||||||
|
it("ships Task Dashboard and Whiteboard as restricted bundled apps", () => {
|
||||||
|
expect(BUNDLED_REFERENCE_MANIFESTS.map(manifest => manifest.app_scope)).toEqual(["task-dashboard", "whiteboard"]);
|
||||||
|
for (const manifest of BUNDLED_REFERENCE_MANIFESTS) {
|
||||||
|
expect(manifest.kind).toBe("bundled");
|
||||||
|
expect(validateMiniAppManifest(manifest)).toEqual({ accepted: true });
|
||||||
|
expect(manifest.host.surface_required).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import type { MiniAppManifestV1 } from "@/runtime/app-management/miniapp-manifest";
|
||||||
|
|
||||||
|
/** The two bundled SDK reference manifests shipped by the Host in SDK v1. */
|
||||||
|
export const TASK_DASHBOARD_MANIFEST: MiniAppManifestV1 = {
|
||||||
|
app_scope: "task-dashboard",
|
||||||
|
version: "1.0.0",
|
||||||
|
kind: "bundled",
|
||||||
|
subscriptions: ["lineup.v1.app.call", "lineup.v1.tool.call", "lineup.v1.text"],
|
||||||
|
requested_capabilities: [],
|
||||||
|
host: { min_version: "1.0.0", surface_required: true },
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
id: "task.create",
|
||||||
|
version: 1,
|
||||||
|
handling: "operation",
|
||||||
|
target: { app_scope: "task-dashboard", requires_foreground: true, restore_previous_focus: true },
|
||||||
|
input_schema: { type: "object", required: ["title"], properties: { title: { type: "string", minLength: 1, maxLength: 200 } }, additionalProperties: false },
|
||||||
|
output_schema: { type: "object", required: ["task_id"], properties: { task_id: { type: "string", minLength: 1 } }, additionalProperties: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "task.list",
|
||||||
|
version: 1,
|
||||||
|
handling: "direct",
|
||||||
|
target: { app_scope: "task-dashboard", requires_foreground: false, restore_previous_focus: false },
|
||||||
|
input_schema: { type: "object", additionalProperties: false },
|
||||||
|
output_schema: { type: "object" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WHITEBOARD_MANIFEST: MiniAppManifestV1 = {
|
||||||
|
app_scope: "whiteboard",
|
||||||
|
version: "1.0.0",
|
||||||
|
kind: "bundled",
|
||||||
|
subscriptions: ["lineup.v1.ui.open", "lineup.v1.ui.patch", "lineup.v1.ui.close", "lineup.v1.artifact.offer"],
|
||||||
|
requested_capabilities: [],
|
||||||
|
host: { min_version: "1.0.0", surface_required: true },
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
id: "whiteboard.open",
|
||||||
|
version: 1,
|
||||||
|
handling: "launch",
|
||||||
|
target: { app_scope: "whiteboard", requires_foreground: true, restore_previous_focus: true },
|
||||||
|
input_schema: { type: "object", additionalProperties: false },
|
||||||
|
output_schema: { type: "object" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "whiteboard.export",
|
||||||
|
version: 1,
|
||||||
|
handling: "operation",
|
||||||
|
target: { app_scope: "whiteboard", requires_foreground: true, restore_previous_focus: true },
|
||||||
|
input_schema: { type: "object", additionalProperties: false },
|
||||||
|
output_schema: { type: "object", required: ["artifact_id"], properties: { artifact_id: { type: "string", minLength: 1 } }, additionalProperties: false },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BUNDLED_REFERENCE_MANIFESTS: readonly MiniAppManifestV1[] = [TASK_DASHBOARD_MANIFEST, WHITEBOARD_MANIFEST];
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { AgentInteractionService } from "@/runtime/coordination/agent-interaction-service";
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
interaction_id: "interaction-1",
|
||||||
|
call_id: "call-1",
|
||||||
|
agent_id: "agent-1",
|
||||||
|
conversation_id: "conversation-1",
|
||||||
|
interact_instance_id: "chat-1",
|
||||||
|
created_at: "2026-08-05T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("AgentInteractionService", () => {
|
||||||
|
it("creates, presents and accepts notice exactly once", () => {
|
||||||
|
const service = new AgentInteractionService();
|
||||||
|
expect(service.create({ ...base, request: { kind: "notice", title: "已保存", message: "任务已保存" } })).toMatchObject({ disposition: "accepted" });
|
||||||
|
expect(service.present("interaction-1", "2026-08-05T00:00:01.000Z")).toMatchObject({ disposition: "accepted", record: { status: "completed", result: { outcome: "accepted" } } });
|
||||||
|
expect(service.present("interaction-1", "2026-08-05T00:00:02.000Z")).toMatchObject({ disposition: "invalid_transition", record: { status: "completed" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets only the first answer/dismiss/expiry transition win", () => {
|
||||||
|
const service = new AgentInteractionService();
|
||||||
|
service.create({ ...base, request: { kind: "confirm", title: "确认", prompt: "继续吗?", expires_in_ms: 60_000 } });
|
||||||
|
service.present("interaction-1", "2026-08-05T00:00:01.000Z");
|
||||||
|
expect(service.submit("interaction-1", { approved: true }, "2026-08-05T00:00:02.000Z")).toMatchObject({ record: { status: "completed", result: { outcome: "answered" } } });
|
||||||
|
expect(service.dismiss({ call_id: "call-1", agent_id: "agent-1", conversation_id: "conversation-1" }, "2026-08-05T00:00:03.000Z")).toMatchObject({ disposition: "invalid_transition", record: { status: "completed" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects cross-agent dismiss and restores expired records", () => {
|
||||||
|
const service = new AgentInteractionService();
|
||||||
|
service.create({ ...base, request: { kind: "input", title: "描述", prompt: "输入", expires_in_ms: 60_000, field: { id: "text", label: "描述", type: "textarea" } } });
|
||||||
|
expect(service.dismiss({ call_id: "call-1", agent_id: "other-agent", conversation_id: "conversation-1" }, "2026-08-05T00:00:01.000Z")).toEqual({ disposition: "invalid_transition" });
|
||||||
|
service.restore(service.list(), "2026-08-05T00:01:00.000Z");
|
||||||
|
expect(service.get("interaction-1")).toMatchObject({ status: "expired", result: { outcome: "expired" } });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import {
|
||||||
|
validateStandardInteractionRequest,
|
||||||
|
type StandardInteractionRequest,
|
||||||
|
} from "@/runtime/coordination/standard-interaction-contract";
|
||||||
|
|
||||||
|
export type StandardInteractionKind = StandardInteractionRequest["kind"];
|
||||||
|
|
||||||
|
export type StandardInteractionResult =
|
||||||
|
| Readonly<{ outcome: "accepted" }>
|
||||||
|
| Readonly<{ outcome: "answered"; answer: Readonly<Record<string, unknown>> }>
|
||||||
|
| Readonly<{ outcome: "cancelled" }>
|
||||||
|
| Readonly<{ outcome: "expired" }>
|
||||||
|
| Readonly<{ outcome: "failed"; error_code: string }>;
|
||||||
|
|
||||||
|
export type StandardInteractionStatus = "pending" | "presented" | "submitted" | "completed" | "cancelled" | "expired" | "failed";
|
||||||
|
|
||||||
|
export type StandardInteractionRecord = Readonly<{
|
||||||
|
interaction_id: string;
|
||||||
|
call_id: string;
|
||||||
|
agent_id: string;
|
||||||
|
conversation_id: string;
|
||||||
|
interact_instance_id: string;
|
||||||
|
app_session_id?: string;
|
||||||
|
kind: StandardInteractionKind;
|
||||||
|
request: StandardInteractionRequest;
|
||||||
|
status: StandardInteractionStatus;
|
||||||
|
result?: StandardInteractionResult;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
expires_at?: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type InteractionTransition =
|
||||||
|
| Readonly<{ disposition: "accepted"; record: StandardInteractionRecord }>
|
||||||
|
| Readonly<{ disposition: "duplicate" | "invalid_transition" | "missing" | "invalid_request"; record?: StandardInteractionRecord }>;
|
||||||
|
|
||||||
|
export type CreateInteractionInput = Readonly<{
|
||||||
|
interaction_id: string;
|
||||||
|
call_id: string;
|
||||||
|
agent_id: string;
|
||||||
|
conversation_id: string;
|
||||||
|
interact_instance_id: string;
|
||||||
|
app_session_id?: string;
|
||||||
|
request: StandardInteractionRequest;
|
||||||
|
created_at: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime-owned state machine for Agent-facing Interact records.
|
||||||
|
* It deliberately knows nothing about renderers or transport. Outbox writes
|
||||||
|
* are made by the caller only for an accepted terminal transition.
|
||||||
|
*/
|
||||||
|
export class AgentInteractionService {
|
||||||
|
private readonly records = new Map<string, StandardInteractionRecord>();
|
||||||
|
|
||||||
|
public create(input: CreateInteractionInput): InteractionTransition {
|
||||||
|
const existing = this.records.get(input.interaction_id) ?? this.byCall(input.call_id);
|
||||||
|
if (existing) return { disposition: "duplicate", record: copy(existing) };
|
||||||
|
const validation = validateStandardInteractionRequest(input.request, new Date(input.created_at));
|
||||||
|
if (!validation.accepted) return { disposition: "invalid_request" };
|
||||||
|
const record: StandardInteractionRecord = {
|
||||||
|
interaction_id: input.interaction_id,
|
||||||
|
call_id: input.call_id,
|
||||||
|
agent_id: input.agent_id,
|
||||||
|
conversation_id: input.conversation_id,
|
||||||
|
interact_instance_id: input.interact_instance_id,
|
||||||
|
...(input.app_session_id ? { app_session_id: input.app_session_id } : {}),
|
||||||
|
kind: input.request.kind,
|
||||||
|
request: cloneRequest(input.request),
|
||||||
|
status: "pending",
|
||||||
|
created_at: input.created_at,
|
||||||
|
updated_at: input.created_at,
|
||||||
|
...(validation.expires_at ? { expires_at: validation.expires_at } : {}),
|
||||||
|
};
|
||||||
|
this.records.set(record.interaction_id, record);
|
||||||
|
return { disposition: "accepted", record: copy(record) };
|
||||||
|
}
|
||||||
|
|
||||||
|
public present(interactionID: string, now: string): InteractionTransition {
|
||||||
|
const record = this.records.get(interactionID);
|
||||||
|
if (!record) return { disposition: "missing" };
|
||||||
|
if (record.status !== "pending") return { disposition: "invalid_transition", record: copy(record) };
|
||||||
|
const presented: StandardInteractionRecord = { ...record, status: "presented", updated_at: now };
|
||||||
|
this.records.set(interactionID, presented);
|
||||||
|
if (presented.kind === "notice") {
|
||||||
|
const completed: StandardInteractionRecord = { ...presented, status: "completed", result: { outcome: "accepted" }, updated_at: now };
|
||||||
|
this.records.set(interactionID, completed);
|
||||||
|
return { disposition: "accepted", record: copy(completed) };
|
||||||
|
}
|
||||||
|
return { disposition: "accepted", record: copy(presented) };
|
||||||
|
}
|
||||||
|
|
||||||
|
public submit(interactionID: string, answer: Readonly<Record<string, unknown>>, now: string): InteractionTransition {
|
||||||
|
const record = this.records.get(interactionID);
|
||||||
|
if (!record) return { disposition: "missing" };
|
||||||
|
if (record.status !== "pending" && record.status !== "presented") return { disposition: "invalid_transition", record: copy(record) };
|
||||||
|
if (expired(record, now)) return this.expire(interactionID, now);
|
||||||
|
const submitted: StandardInteractionRecord = { ...record, status: "submitted", result: { outcome: "answered", answer: { ...answer } }, updated_at: now };
|
||||||
|
const completed: StandardInteractionRecord = { ...submitted, status: "completed", updated_at: now };
|
||||||
|
this.records.set(interactionID, completed);
|
||||||
|
return { disposition: "accepted", record: copy(completed) };
|
||||||
|
}
|
||||||
|
|
||||||
|
public dismiss(input: Readonly<{ call_id: string; agent_id: string; conversation_id: string }>, now: string): InteractionTransition {
|
||||||
|
const record = this.byCall(input.call_id);
|
||||||
|
if (!record) return { disposition: "missing" };
|
||||||
|
if (record.agent_id !== input.agent_id || record.conversation_id !== input.conversation_id) return { disposition: "invalid_transition" };
|
||||||
|
if (record.status !== "pending" && record.status !== "presented") return { disposition: "invalid_transition", record: copy(record) };
|
||||||
|
const cancelled: StandardInteractionRecord = { ...record, status: "cancelled", result: { outcome: "cancelled" }, updated_at: now };
|
||||||
|
this.records.set(record.interaction_id, cancelled);
|
||||||
|
return { disposition: "accepted", record: copy(cancelled) };
|
||||||
|
}
|
||||||
|
|
||||||
|
public expire(interactionID: string, now: string): InteractionTransition {
|
||||||
|
const record = this.records.get(interactionID);
|
||||||
|
if (!record) return { disposition: "missing" };
|
||||||
|
if (record.status !== "pending" && record.status !== "presented") return { disposition: "invalid_transition", record: copy(record) };
|
||||||
|
if (!expired(record, now)) return { disposition: "invalid_transition", record: copy(record) };
|
||||||
|
const final: StandardInteractionRecord = { ...record, status: "expired", result: { outcome: "expired" }, updated_at: now };
|
||||||
|
this.records.set(interactionID, final);
|
||||||
|
return { disposition: "accepted", record: copy(final) };
|
||||||
|
}
|
||||||
|
|
||||||
|
public get(interactionID: string): StandardInteractionRecord | undefined {
|
||||||
|
const record = this.records.get(interactionID);
|
||||||
|
return record ? copy(record) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
public list(): readonly StandardInteractionRecord[] { return [...this.records.values()].map(copy); }
|
||||||
|
|
||||||
|
public restore(records: readonly StandardInteractionRecord[], now: string): void {
|
||||||
|
this.records.clear();
|
||||||
|
for (const record of records) {
|
||||||
|
if (!isRecord(record) || this.records.has(record.interaction_id)) continue;
|
||||||
|
const restored = copy(record);
|
||||||
|
if ((restored.status === "pending" || restored.status === "presented") && expired(restored, now)) {
|
||||||
|
this.records.set(restored.interaction_id, { ...restored, status: "expired", result: { outcome: "expired" }, updated_at: now });
|
||||||
|
} else {
|
||||||
|
this.records.set(restored.interaction_id, restored);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public reset(): void { this.records.clear(); }
|
||||||
|
|
||||||
|
private byCall(callID: string): StandardInteractionRecord | undefined {
|
||||||
|
return [...this.records.values()].find(record => record.call_id === callID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function expired(record: StandardInteractionRecord, now: string): boolean {
|
||||||
|
return Boolean(record.expires_at && Date.parse(record.expires_at) <= Date.parse(now));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is StandardInteractionRecord {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||||
|
const candidate = value as Partial<StandardInteractionRecord>;
|
||||||
|
return typeof candidate.interaction_id === "string"
|
||||||
|
&& typeof candidate.call_id === "string"
|
||||||
|
&& typeof candidate.agent_id === "string"
|
||||||
|
&& typeof candidate.conversation_id === "string"
|
||||||
|
&& typeof candidate.interact_instance_id === "string"
|
||||||
|
&& typeof candidate.created_at === "string"
|
||||||
|
&& !Number.isNaN(Date.parse(candidate.created_at))
|
||||||
|
&& typeof candidate.updated_at === "string"
|
||||||
|
&& !Number.isNaN(Date.parse(candidate.updated_at))
|
||||||
|
&& Boolean(candidate.request)
|
||||||
|
&& ["pending", "presented", "submitted", "completed", "cancelled", "expired", "failed"].includes(candidate.status ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneRequest(request: StandardInteractionRequest): StandardInteractionRequest {
|
||||||
|
return JSON.parse(JSON.stringify(request)) as StandardInteractionRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function copy(record: StandardInteractionRecord): StandardInteractionRecord {
|
||||||
|
return { ...record, request: cloneRequest(record.request), ...(record.result ? { result: JSON.parse(JSON.stringify(record.result)) as StandardInteractionResult } : {}) };
|
||||||
|
}
|
||||||
@@ -9,13 +9,13 @@ export type AppOrchestrationResult =
|
|||||||
/** Applies Tool Router decisions atomically to instance and focus state. */
|
/** Applies Tool Router decisions atomically to instance and focus state. */
|
||||||
export class AppOrchestrator {
|
export class AppOrchestrator {
|
||||||
public constructor(public readonly lifecycle: AppLifecycleManager, private readonly apps: CoreAppRegistry, private readonly newID: (prefix: string) => string) {}
|
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 {
|
public launch(appScope: AppScope, conversationID: string, now: string, options: { instance_id?: string; parent_instance_id?: string; continued_from_app_session_id?: string } = {}): AppOrchestrationResult {
|
||||||
const app = this.apps.get(appScope);
|
const app = this.apps.get(appScope);
|
||||||
if (!app) return { disposition: "rejected", code: "not_installed" };
|
if (!app) return { disposition: "rejected", code: "not_installed" };
|
||||||
if (!app.enabled) return { disposition: "rejected", code: "disabled" };
|
if (!app.enabled) return { disposition: "rejected", code: "disabled" };
|
||||||
const previous = this.lifecycle.focus.foregroundInstance(this.lifecycle.instances);
|
const previous = this.lifecycle.focus.foregroundInstance(this.lifecycle.instances);
|
||||||
const existing = this.lifecycle.instances.activeFor(appScope, conversationID);
|
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);
|
const instance = existing ?? this.lifecycle.start({ instance_id: options.instance_id ?? this.newID(`${appScope}:instance`), app_session_id: this.newID(`${appScope}:session`), app_scope: appScope, conversation_id: conversationID, ...(options.parent_instance_id ? { parent_instance_id: options.parent_instance_id } : {}), ...(options.continued_from_app_session_id ? { continued_from_app_session_id: options.continued_from_app_session_id } : {}) }, now);
|
||||||
return { disposition: "foreground", instance: this.lifecycle.foreground(instance.instance_id, now), ...(previous ? { previous } : {}) };
|
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 } {
|
public close(instanceID: string, now: string, error?: string): { closed?: AppInstanceRecord; restored?: AppInstanceRecord } {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class MemoryStorage implements Storage {
|
|||||||
class FakeTransport implements TransportAdapter {
|
class FakeTransport implements TransportAdapter {
|
||||||
public readonly sent: SendTextRequest[] = [];
|
public readonly sent: SendTextRequest[] = [];
|
||||||
private readonly pages: TransportMessage[][];
|
private readonly pages: TransportMessage[][];
|
||||||
|
public failSends = false;
|
||||||
|
|
||||||
public constructor(pages: TransportMessage[][] = []) {
|
public constructor(pages: TransportMessage[][] = []) {
|
||||||
this.pages = [...pages];
|
this.pages = [...pages];
|
||||||
@@ -25,6 +26,7 @@ class FakeTransport implements TransportAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async sendText(request: SendTextRequest): Promise<number | undefined> {
|
public async sendText(request: SendTextRequest): Promise<number | undefined> {
|
||||||
|
if (this.failSends) throw new Error("offline");
|
||||||
this.sent.push(request);
|
this.sent.push(request);
|
||||||
return 91 + this.sent.length;
|
return 91 + this.sent.length;
|
||||||
}
|
}
|
||||||
@@ -96,7 +98,11 @@ describe("LineUpRuntime", () => {
|
|||||||
const transport = new FakeTransport();
|
const transport = new FakeTransport();
|
||||||
const runtime = new LineUpRuntime({ storage: new MemoryStorage(), createTransport: () => transport });
|
const runtime = new LineUpRuntime({ storage: new MemoryStorage(), createTransport: () => transport });
|
||||||
|
|
||||||
expect(runtime.apps.list()).toEqual([expect.objectContaining({ app_scope: "chat", enabled: true, recovery: true })]);
|
expect(runtime.apps.list()).toContainEqual(expect.objectContaining({ app_scope: "chat", kind: "system", enabled: true, recovery: true }));
|
||||||
|
expect(runtime.apps.list()).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ app_scope: "task-dashboard", kind: "bundled" }),
|
||||||
|
expect.objectContaining({ app_scope: "whiteboard", kind: "bundled" }),
|
||||||
|
]));
|
||||||
const chat = runtime.openApp("chat");
|
const chat = runtime.openApp("chat");
|
||||||
expect(chat.app_scope).toBe("chat");
|
expect(chat.app_scope).toBe("chat");
|
||||||
expect(chat.snapshot()).toMatchObject({ app_scope: "chat", active: false });
|
expect(chat.snapshot()).toMatchObject({ app_scope: "chat", active: false });
|
||||||
@@ -124,6 +130,197 @@ describe("LineUpRuntime", () => {
|
|||||||
expect(chat.listAgentMessages()).toEqual([expect.objectContaining({ message_id: "agent_msg_7" })]);
|
expect(chat.listAgentMessages()).toEqual([expect.objectContaining({ message_id: "agent_msg_7" })]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps Agent standard interactions in Interact/IM and out of every MiniApp Inbox", async () => {
|
||||||
|
const conversationID = "user_1:2:channel_1";
|
||||||
|
const interaction = JSON.stringify({
|
||||||
|
v: 1, id: "interactive-call-1", type: "lineup.v1.tool.call", conversation_id: conversationID,
|
||||||
|
sender: { kind: "agent", id: "agent_1" },
|
||||||
|
payload: { call_id: "interactive-call-1", tool: "confirm", title: "确认", prompt: "继续吗?", data: { confirm: {} } },
|
||||||
|
});
|
||||||
|
const runtime = new LineUpRuntime({
|
||||||
|
storage: new MemoryStorage(),
|
||||||
|
createTransport: () => new FakeTransport([[{ message_seq: 12, from_uid: "agent_1", payload: interaction }], []]),
|
||||||
|
});
|
||||||
|
const chat = runtime.openChatApp();
|
||||||
|
await runtime.login(login);
|
||||||
|
await runtime.syncOnce();
|
||||||
|
|
||||||
|
expect(chat.listAgentMessages()).toEqual([expect.objectContaining({ item: expect.objectContaining({ kind: "tool-call" }) })]);
|
||||||
|
expect(runtime.snapshot()?.app_inbox).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists a pending Interact question, restores it after restart, and sends one answer", async () => {
|
||||||
|
const conversationID = "user_1:2:channel_1";
|
||||||
|
const interaction = JSON.stringify({
|
||||||
|
v: 1, id: "interactive-restart-1", type: "lineup.v1.tool.call", conversation_id: conversationID,
|
||||||
|
sender: { kind: "agent", id: "agent_1" },
|
||||||
|
payload: { call_id: "interactive-restart-1", tool: "confirm", title: "确认", prompt: "继续吗?", data: { confirm: {} } },
|
||||||
|
});
|
||||||
|
const storage = new MemoryStorage();
|
||||||
|
const firstTransport = new FakeTransport([[{ message_seq: 13, from_uid: "agent_1", payload: interaction }], []]);
|
||||||
|
const first = new LineUpRuntime({ storage, createTransport: () => firstTransport, now: () => "2026-08-04T00:00:00.000Z" });
|
||||||
|
await first.login(login);
|
||||||
|
await first.syncOnce();
|
||||||
|
expect(first.standardInteractions()).toEqual([expect.objectContaining({ call_id: "interactive-restart-1", status: "presented" })]);
|
||||||
|
|
||||||
|
const secondTransport = new FakeTransport();
|
||||||
|
const second = new LineUpRuntime({ storage, createTransport: () => secondTransport, now: () => "2026-08-04T00:01:00.000Z" });
|
||||||
|
const chat = second.openChatApp();
|
||||||
|
await second.login(login);
|
||||||
|
expect(second.standardInteractions()).toEqual([expect.objectContaining({ call_id: "interactive-restart-1", status: "presented" })]);
|
||||||
|
expect(chat.interactions.submit("interactive-restart-1", { confirmed: true })).toBe(true);
|
||||||
|
await second.flushOutbox();
|
||||||
|
expect(second.standardInteractions()).toEqual([expect.objectContaining({ call_id: "interactive-restart-1", status: "completed", result: { outcome: "answered", answer: { confirmed: true } } })]);
|
||||||
|
expect(secondTransport.sent.filter(entry => entry.payload.includes("lineup.v1.tool.result"))).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("associates an Agent question with a real App sub-session and rejects a fabricated one", async () => {
|
||||||
|
const conversationID = "user_1:2:channel_1";
|
||||||
|
const launch = JSON.stringify({
|
||||||
|
v: 1, id: "launch-whiteboard", type: "lineup.v1.app.call", conversation_id: conversationID,
|
||||||
|
sender: { kind: "agent", id: "agent_1" },
|
||||||
|
payload: { call_id: "launch-whiteboard", capability: "runtime.launch_app", reason: "打开画板", expires_at: "2026-08-05T00:00:00.000Z", arguments: { app_scope: "whiteboard", instance_id: "whiteboard:1" } },
|
||||||
|
});
|
||||||
|
const validQuestion = JSON.stringify({
|
||||||
|
v: 1, id: "whiteboard-question", type: "lineup.v1.tool.call", conversation_id: conversationID,
|
||||||
|
sender: { kind: "agent", id: "agent_1" },
|
||||||
|
payload: { call_id: "whiteboard-question", tool: "input", title: "画板名称", prompt: "请输入名称", app_session_context: { app_session_id: "whiteboard:session-id" }, data: { form: { fields: [{ id: "name", label: "名称", type: "text" }] } } },
|
||||||
|
});
|
||||||
|
const invalidQuestion = validQuestion.replace("whiteboard-question", "whiteboard-question-invalid").replace("whiteboard:session-id", "not-a-real-session");
|
||||||
|
const transport = new FakeTransport([[{ message_seq: 40, from_uid: "agent_1", payload: launch }], [{ message_seq: 41, from_uid: "agent_1", payload: validQuestion }, { message_seq: 42, from_uid: "agent_1", payload: invalidQuestion }], []]);
|
||||||
|
const runtime = new LineUpRuntime({ storage: new MemoryStorage(), createTransport: () => transport, now: () => "2026-08-04T00:00:00.000Z", newID: prefix => prefix === "whiteboard:session" ? "whiteboard:session-id" : `${prefix}-id` });
|
||||||
|
await runtime.login(login);
|
||||||
|
await runtime.syncOnce();
|
||||||
|
expect(runtime.lifecycle.instances.get("whiteboard:1")?.app_session_id).toBe("whiteboard:session-id");
|
||||||
|
await runtime.syncOnce();
|
||||||
|
expect(runtime.standardInteractions()).toEqual([expect.objectContaining({ call_id: "whiteboard-question", app_session_id: "whiteboard:session-id" })]);
|
||||||
|
expect(runtime.standardInteractions().some(record => record.call_id === "whiteboard-question-invalid")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles Agent interaction.dismiss idempotently and emits one cancellation", async () => {
|
||||||
|
const conversationID = "user_1:2:channel_1";
|
||||||
|
const question = JSON.stringify({ v: 1, id: "dismiss-question", type: "lineup.v1.tool.call", conversation_id: conversationID, sender: { kind: "agent", id: "agent_1" }, payload: { call_id: "dismiss-question", tool: "confirm", title: "确认", prompt: "继续吗?", data: { confirm: {} } } });
|
||||||
|
const dismiss = (id: string) => JSON.stringify({ v: 1, id, type: "lineup.v1.interaction.dismiss", conversation_id: conversationID, sender: { kind: "agent", id: "agent_1" }, payload: { control_id: "dismiss-control-1", call_id: "dismiss-question", reason: "已不再需要" } });
|
||||||
|
const transport = new FakeTransport([[{ message_seq: 50, from_uid: "agent_1", payload: question }], [{ message_seq: 51, from_uid: "agent_1", payload: dismiss("dismiss-1") }, { message_seq: 52, from_uid: "agent_1", payload: dismiss("dismiss-2") }], []]);
|
||||||
|
const runtime = new LineUpRuntime({ storage: new MemoryStorage(), createTransport: () => transport, now: () => "2026-08-04T00:00:00.000Z" });
|
||||||
|
await runtime.login(login);
|
||||||
|
await runtime.syncOnce();
|
||||||
|
await runtime.syncOnce();
|
||||||
|
await runtime.flushOutbox();
|
||||||
|
expect(runtime.standardInteractions()).toEqual([expect.objectContaining({ call_id: "dismiss-question", status: "cancelled" })]);
|
||||||
|
expect(transport.sent.filter(entry => entry.payload.includes("lineup.v1.tool.cancel"))).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delivers a revisioned ordinary Tool to bundled MiniApp SDK and keeps the App open after completion", async () => {
|
||||||
|
const conversationID = "user_1:2:channel_1";
|
||||||
|
const call = JSON.stringify({
|
||||||
|
v: 1, id: "task-tool-call", type: "lineup.v1.miniapp.tool.call", conversation_id: conversationID,
|
||||||
|
sender: { kind: "agent", id: "agent_1" },
|
||||||
|
payload: { call_id: "task-tool-call", tool_id: "task.create", app_scope: "task-dashboard", inventory_revision: "catalog-1", input: { title: "写验收记录" } },
|
||||||
|
});
|
||||||
|
const transport = new FakeTransport([[{ message_seq: 60, from_uid: "agent_1", payload: call }], []]);
|
||||||
|
const runtime = new LineUpRuntime({ storage: new MemoryStorage(), createTransport: () => transport, now: () => "2026-08-04T00:00:00.000Z", newID: prefix => `${prefix}-id` });
|
||||||
|
await runtime.login(login);
|
||||||
|
await runtime.syncOnce();
|
||||||
|
const instance = runtime.lifecycle.instances.activeFor("task-dashboard", conversationID);
|
||||||
|
expect(instance?.app_session_id).toBe("task-dashboard:session-id");
|
||||||
|
expect(runtime.snapshot()?.app_inbox).toEqual([expect.objectContaining({ app_scope: "task-dashboard", item: expect.objectContaining({ kind: "miniapp-tool-call" }) })]);
|
||||||
|
const sdk = runtime.openBundledMiniApp("task-dashboard", instance!.instance_id);
|
||||||
|
const received: string[] = [];
|
||||||
|
sdk.tools.subscribe(tool => received.push(tool.call_id));
|
||||||
|
expect(received).toEqual(["task-tool-call"]);
|
||||||
|
expect(runtime.miniAppToolCalls()).toEqual([expect.objectContaining({ call_id: "task-tool-call", status: "running", instance_id: instance!.instance_id })]);
|
||||||
|
await sdk.tools.complete("task-tool-call", { task_id: "task-1" });
|
||||||
|
await runtime.flushOutbox();
|
||||||
|
expect(runtime.miniAppToolCalls()).toEqual([expect.objectContaining({ call_id: "task-tool-call", status: "completed" })]);
|
||||||
|
expect(runtime.lifecycle.instances.get(instance!.instance_id)).toMatchObject({ state: "foreground" });
|
||||||
|
expect(transport.sent.filter(entry => entry.payload.includes("lineup.v1.miniapp.tool.result"))).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps bundled Surface requests local and scopes them to the current instance", async () => {
|
||||||
|
const conversationID = "user_1:2:channel_1";
|
||||||
|
const launch = JSON.stringify({
|
||||||
|
v: 1, id: "whiteboard-launch", type: "lineup.v1.miniapp.tool.call", conversation_id: conversationID,
|
||||||
|
sender: { kind: "agent", id: "agent_1" },
|
||||||
|
payload: { call_id: "whiteboard-launch", tool_id: "whiteboard.open", app_scope: "whiteboard", inventory_revision: "catalog-1", input: {} },
|
||||||
|
});
|
||||||
|
const transport = new FakeTransport([[{ message_seq: 62, from_uid: "agent_1", payload: launch }], []]);
|
||||||
|
const runtime = new LineUpRuntime({ storage: new MemoryStorage(), createTransport: () => transport });
|
||||||
|
await runtime.login(login);
|
||||||
|
await runtime.syncOnce();
|
||||||
|
const instance = runtime.lifecycle.instances.activeFor("whiteboard", conversationID);
|
||||||
|
expect(instance).toBeDefined();
|
||||||
|
const requests: unknown[] = [];
|
||||||
|
runtime.onEvent(event => { if (event.type === "surface-request") requests.push(event); });
|
||||||
|
const sdk = runtime.openBundledMiniApp("whiteboard", instance!.instance_id);
|
||||||
|
expect(await sdk.surfaces.request("open", { state: { title: "验收画板" } })).toEqual({ disposition: "accepted" });
|
||||||
|
expect(await sdk.surfaces.request("patch", { state: { title: "已更新" } })).toEqual({ disposition: "accepted" });
|
||||||
|
expect(requests).toEqual([
|
||||||
|
expect.objectContaining({ type: "surface-request", app_scope: "whiteboard", instance_id: instance!.instance_id, event: "open", data: { state: { title: "验收画板" } } }),
|
||||||
|
expect.objectContaining({ type: "surface-request", event: "patch", data: { state: { title: "已更新" } } }),
|
||||||
|
]);
|
||||||
|
expect(transport.sent.some(entry => entry.payload.includes("lineup.v1.ui.open"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converges an unclaimed bundled Tool once on App close and keeps no new delivery path", async () => {
|
||||||
|
const conversationID = "user_1:2:channel_1";
|
||||||
|
const call = JSON.stringify({ v: 1, id: "task-tool-close", type: "lineup.v1.miniapp.tool.call", conversation_id: conversationID, sender: { kind: "agent", id: "agent_1" }, payload: { call_id: "task-tool-close", tool_id: "task.create", app_scope: "task-dashboard", inventory_revision: "catalog-1", input: { title: "关闭测试" } } });
|
||||||
|
const transport = new FakeTransport([[{ message_seq: 61, from_uid: "agent_1", payload: call }], []]);
|
||||||
|
const runtime = new LineUpRuntime({ storage: new MemoryStorage(), createTransport: () => transport, now: () => "2026-08-04T00:00:00.000Z" });
|
||||||
|
await runtime.login(login);
|
||||||
|
await runtime.syncOnce();
|
||||||
|
const instance = runtime.lifecycle.instances.activeFor("task-dashboard", conversationID);
|
||||||
|
runtime.closeAppInstance(instance!.instance_id);
|
||||||
|
await runtime.flushOutbox();
|
||||||
|
expect(runtime.miniAppToolCalls()).toEqual([expect.objectContaining({ call_id: "task-tool-close", status: "cancelled", cancel_reason: "app_closed" })]);
|
||||||
|
expect(runtime.lifecycle.instances.get(instance!.instance_id)).toMatchObject({ state: "stopped" });
|
||||||
|
expect(transport.sent.filter(entry => entry.payload.includes("lineup.v1.miniapp.tool.cancel"))).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("continues a closed bundled App in a new instance and new App sub-session", async () => {
|
||||||
|
const conversationID = "user_1:2:channel_1";
|
||||||
|
const transport = new FakeTransport();
|
||||||
|
const runtime = new LineUpRuntime({ storage: new MemoryStorage(), createTransport: () => transport, newID: prefix => `${prefix}-${Math.random().toString(36).slice(2, 7)}` });
|
||||||
|
await runtime.login(login);
|
||||||
|
const first = runtime.lifecycle.instances.activeFor("whiteboard", conversationID) ?? runtime.lifecycle.start({ app_scope: "whiteboard", instance_id: "whiteboard:first", app_session_id: "whiteboard:first-session", conversation_id: conversationID }, "2026-08-04T00:00:00.000Z");
|
||||||
|
if (first.state === "starting") runtime.lifecycle.foreground(first.instance_id, "2026-08-04T00:00:00.000Z");
|
||||||
|
runtime.closeAppInstance(first.instance_id);
|
||||||
|
const receipt = await runtime.openApp("chat").dispatch({ type: "chat.continue_app_session", conversation_id: conversationID, app_session_id: first.app_session_id! });
|
||||||
|
expect(receipt).toEqual({ disposition: "accepted" });
|
||||||
|
const continued = runtime.lifecycle.instances.activeFor("whiteboard", conversationID);
|
||||||
|
expect(continued?.instance_id).not.toBe(first.instance_id);
|
||||||
|
expect(continued?.app_session_id).not.toBe(first.app_session_id);
|
||||||
|
expect(continued?.continued_from_app_session_id).toBe(first.app_session_id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replays a submitted MiniApp result from the outbox after Runtime restart", async () => {
|
||||||
|
const conversationID = "user_1:2:channel_1";
|
||||||
|
const call = JSON.stringify({
|
||||||
|
v: 1, id: "restart-tool-call", type: "lineup.v1.miniapp.tool.call", conversation_id: conversationID,
|
||||||
|
sender: { kind: "agent", id: "agent_1" },
|
||||||
|
payload: { call_id: "restart-tool-call", tool_id: "task.create", app_scope: "task-dashboard", inventory_revision: "catalog-1", input: { title: "重启恢复" } },
|
||||||
|
});
|
||||||
|
const storage = new MemoryStorage();
|
||||||
|
const firstTransport = new FakeTransport([[{ message_seq: 70, from_uid: "agent_1", payload: call }], []]);
|
||||||
|
firstTransport.failSends = true;
|
||||||
|
const first = new LineUpRuntime({ storage, createTransport: () => firstTransport });
|
||||||
|
await first.login(login);
|
||||||
|
await first.syncOnce();
|
||||||
|
const instance = first.lifecycle.instances.activeFor("task-dashboard", conversationID)!;
|
||||||
|
const sdk = first.openBundledMiniApp("task-dashboard", instance.instance_id);
|
||||||
|
sdk.tools.subscribe(() => undefined);
|
||||||
|
await sdk.tools.complete("restart-tool-call", { task_id: "restart-task" });
|
||||||
|
expect(first.miniAppToolCalls()).toEqual([expect.objectContaining({ call_id: "restart-tool-call", status: "submitted" })]);
|
||||||
|
|
||||||
|
const secondTransport = new FakeTransport();
|
||||||
|
const second = new LineUpRuntime({ storage, createTransport: () => secondTransport });
|
||||||
|
await second.login(login);
|
||||||
|
expect(second.miniAppToolCalls()).toEqual([expect.objectContaining({ call_id: "restart-tool-call", status: "submitted" })]);
|
||||||
|
await second.flushOutbox();
|
||||||
|
expect(second.miniAppToolCalls()).toEqual([expect.objectContaining({ call_id: "restart-tool-call", status: "completed" })]);
|
||||||
|
expect(secondTransport.sent.filter(entry => entry.payload.includes("restart-tool-call"))).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("projects Agent state and Chat-safe Runtime status through the SDK", async () => {
|
it("projects Agent state and Chat-safe Runtime status through the SDK", async () => {
|
||||||
const conversationID = "user_1:2:channel_1";
|
const conversationID = "user_1:2:channel_1";
|
||||||
const transport = new FakeTransport([[
|
const transport = new FakeTransport([[
|
||||||
@@ -158,7 +355,7 @@ describe("LineUpRuntime", () => {
|
|||||||
const conversationID = "user_1:2:channel_1";
|
const conversationID = "user_1:2:channel_1";
|
||||||
const transport = new FakeTransport([[
|
const transport = new FakeTransport([[
|
||||||
{ message_seq: 8, from_uid: "agent_1", payload: agentEnvelope("wrong_app_scope", conversationID, {
|
{ message_seq: 8, from_uid: "agent_1", payload: agentEnvelope("wrong_app_scope", conversationID, {
|
||||||
scope: { app_scope: "whiteboard", conversation_id: conversationID },
|
scope: { app_scope: "not-installed-app", conversation_id: conversationID },
|
||||||
}) },
|
}) },
|
||||||
{ message_seq: 9, from_uid: "agent_1", payload: agentEnvelope("wrong_conversation_scope", conversationID, {
|
{ message_seq: 9, from_uid: "agent_1", payload: agentEnvelope("wrong_conversation_scope", conversationID, {
|
||||||
scope: { app_scope: "chat", conversation_id: "other:2:conversation" },
|
scope: { app_scope: "chat", conversation_id: "other:2:conversation" },
|
||||||
@@ -242,7 +439,7 @@ describe("LineUpRuntime", () => {
|
|||||||
|
|
||||||
expect(receipt).toEqual({ disposition: "accepted", local_id: "local_test" });
|
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(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(transport.sent.filter(entry => entry.payload === "hello runtime")).toEqual([expect.objectContaining({ from_uid: "user_1", channel_id: "channel_1" })]);
|
||||||
expect(runtime.snapshot()?.outbox).toEqual([]);
|
expect(runtime.snapshot()?.outbox).toEqual([]);
|
||||||
expect(runtime.snapshot()?.items).toEqual([expect.objectContaining({ local_id: "local_test", item: expect.objectContaining({ delivery: expect.objectContaining({ status: "submitted" }) }) })]);
|
expect(runtime.snapshot()?.items).toEqual([expect.objectContaining({ local_id: "local_test", item: expect.objectContaining({ delivery: expect.objectContaining({ status: "submitted" }) }) })]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
type ConversationItem,
|
type ConversationItem,
|
||||||
type Envelope,
|
type Envelope,
|
||||||
type JsonObject,
|
type JsonObject,
|
||||||
|
type ToolCallRequest,
|
||||||
|
type MiniAppToolInvoke,
|
||||||
} from "@/runtime/protocol/lineup-v1";
|
} from "@/runtime/protocol/lineup-v1";
|
||||||
import { CoreAppRegistry, type AppScope } from "@/runtime/app-management/app-registry";
|
import { CoreAppRegistry, type AppScope } from "@/runtime/app-management/app-registry";
|
||||||
import {
|
import {
|
||||||
@@ -44,6 +46,11 @@ import type { ToolCallRecord } from "@/runtime/coordination/tool-call-state";
|
|||||||
import { AppLifecycleManager, type AppWorkspaceSnapshot } from "@/runtime/app-management/app-lifecycle-manager";
|
import { AppLifecycleManager, type AppWorkspaceSnapshot } from "@/runtime/app-management/app-lifecycle-manager";
|
||||||
import { AppOrchestrator } from "@/runtime/coordination/app-orchestrator";
|
import { AppOrchestrator } from "@/runtime/coordination/app-orchestrator";
|
||||||
import { ToolRouter } from "@/runtime/coordination/tool-router";
|
import { ToolRouter } from "@/runtime/coordination/tool-router";
|
||||||
|
import { AgentInteractionService, type StandardInteractionRecord } from "@/runtime/coordination/agent-interaction-service";
|
||||||
|
import type { StandardInteractionRequest } from "@/runtime/coordination/standard-interaction-contract";
|
||||||
|
import type { LineUpMiniAppSDK, MiniAppToolFailure, MiniAppToolProgress } from "@/runtime/app-management/miniapp-sdk";
|
||||||
|
import { MiniAppToolStateMachine, type MiniAppToolCallRecord } from "@/runtime/coordination/miniapp-tool-state";
|
||||||
|
import { validateMiniAppToolSchema } from "@/runtime/coordination/miniapp-tool-schema";
|
||||||
|
|
||||||
export type RuntimeSession = {
|
export type RuntimeSession = {
|
||||||
api: string;
|
api: string;
|
||||||
@@ -78,6 +85,8 @@ const CONTROL_ECHO_TYPES = new Set([
|
|||||||
"lineup.v1.ui.event",
|
"lineup.v1.ui.event",
|
||||||
"lineup.v1.client.inventory",
|
"lineup.v1.client.inventory",
|
||||||
"lineup.v1.app.result",
|
"lineup.v1.app.result",
|
||||||
|
"lineup.v1.app.session.opened",
|
||||||
|
"lineup.v1.app.session.closed",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -93,6 +102,8 @@ export class LineUpRuntime {
|
|||||||
|
|
||||||
private readonly store: ConversationStore;
|
private readonly store: ConversationStore;
|
||||||
private readonly kernel = new InteractionKernel();
|
private readonly kernel = new InteractionKernel();
|
||||||
|
private readonly interactionService = new AgentInteractionService();
|
||||||
|
private readonly miniappTools = new MiniAppToolStateMachine();
|
||||||
private readonly listeners = new Set<(event: RuntimeAppEvent) => void>();
|
private readonly listeners = new Set<(event: RuntimeAppEvent) => void>();
|
||||||
private readonly chatStateListeners = new Set<(view: ChatViewModel) => void>();
|
private readonly chatStateListeners = new Set<(view: ChatViewModel) => void>();
|
||||||
private readonly chatMessageListeners = new Set<(message: AgentChatMessage) => void>();
|
private readonly chatMessageListeners = new Set<(message: AgentChatMessage) => void>();
|
||||||
@@ -103,6 +114,8 @@ export class LineUpRuntime {
|
|||||||
private readonly prepareIncoming: (item: ConversationItem) => ConversationItem | undefined;
|
private readonly prepareIncoming: (item: ConversationItem) => ConversationItem | undefined;
|
||||||
private readonly orchestrator: AppOrchestrator;
|
private readonly orchestrator: AppOrchestrator;
|
||||||
private readonly toolRouter: ToolRouter;
|
private readonly toolRouter: ToolRouter;
|
||||||
|
/** SDK v1 inventory revision advertised by this Runtime instance. */
|
||||||
|
private readonly inventoryRevision = "catalog-1";
|
||||||
|
|
||||||
private transport: TransportAdapter | undefined;
|
private transport: TransportAdapter | undefined;
|
||||||
private session: RuntimeSession = {
|
private session: RuntimeSession = {
|
||||||
@@ -144,11 +157,73 @@ export class LineUpRuntime {
|
|||||||
return this.kernel.snapshot();
|
return this.kernel.snapshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public standardInteractions(): readonly StandardInteractionRecord[] {
|
||||||
|
return this.interactionService.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
public miniAppToolCalls(): readonly MiniAppToolCallRecord[] {
|
||||||
|
return this.miniappTools.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Binds an ordinary MiniApp Tool to its Runtime instance before delivery. */
|
||||||
|
public bindToolCallToInstance(callID: string, instanceID: string): boolean {
|
||||||
|
const instance = this.lifecycle.instances.get(instanceID);
|
||||||
|
const call = this.kernel.snapshot().tool_calls.find(candidate => candidate.call_id === callID);
|
||||||
|
if (!instance || !call || ["notice", "choice", "confirm", "input"].includes(call.request.tool)) return false;
|
||||||
|
const records = this.kernel.snapshot().tool_calls.map(candidate => candidate.call_id === callID ? { ...candidate, app_scope: instance.app_scope, instance_id: instanceID } : candidate);
|
||||||
|
this.store.replaceToolCalls(records);
|
||||||
|
this.kernel.restoreToolCalls(records, this.now());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
public workspaceSnapshot(): AppWorkspaceSnapshot { return this.lifecycle.snapshot(); }
|
public workspaceSnapshot(): AppWorkspaceSnapshot { return this.lifecycle.snapshot(); }
|
||||||
|
|
||||||
/** Runtime-only lifecycle entry point used when a mounted App reports completion. */
|
/** Runtime-only lifecycle entry point used when a mounted App reports completion. */
|
||||||
public closeAppInstance(instanceID: string, error?: string): void {
|
public closeAppInstance(instanceID: string, error?: string): void {
|
||||||
if (!this.session.active) return;
|
if (!this.session.active) return;
|
||||||
|
const currentInstance = this.lifecycle.instances.get(instanceID);
|
||||||
|
if (!currentInstance || currentInstance.state === "stopped" || currentInstance.state === "failed") return;
|
||||||
|
const closeReason = error ? "app_failed" : "app_closed";
|
||||||
|
for (const call of this.miniappTools.list()) {
|
||||||
|
if (call.instance_id !== instanceID || !["received", "routing", "waiting_for_app", "running"].includes(call.status)) continue;
|
||||||
|
const transition = this.miniappTools.cancel(call.call_id, instanceID, "app_closed", this.now());
|
||||||
|
if (transition.disposition !== "accepted") continue;
|
||||||
|
void this.queueProtocolAction("tool-cancel", "lineup.v1.miniapp.tool.cancel", {
|
||||||
|
call_id: call.call_id,
|
||||||
|
app_scope: call.app_scope,
|
||||||
|
reason: "app_closed",
|
||||||
|
cancel_id: `miniapp-close:${instanceID}:${call.call_id}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.store.replaceMiniAppToolCalls(this.miniappTools.list());
|
||||||
|
if (currentInstance.app_session_id) {
|
||||||
|
const pendingInteractions = this.interactionService.list()
|
||||||
|
.filter(interaction => interaction.app_session_id === currentInstance.app_session_id && (interaction.status === "pending" || interaction.status === "presented"))
|
||||||
|
.map(interaction => interaction.call_id);
|
||||||
|
void this.queueProtocolAction("app-result", "lineup.v1.app.session.closed", {
|
||||||
|
app_scope: currentInstance.app_scope,
|
||||||
|
instance_id: currentInstance.instance_id,
|
||||||
|
app_session_id: currentInstance.app_session_id,
|
||||||
|
pending_interaction_call_ids: pendingInteractions,
|
||||||
|
reason: closeReason,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const call of this.kernel.snapshot().tool_calls) {
|
||||||
|
if (call.instance_id !== instanceID || call.status !== "pending") continue;
|
||||||
|
const transition = this.kernel.cancelToolCall(call.call_id, closeReason, this.now());
|
||||||
|
if (transition.disposition !== "accepted") continue;
|
||||||
|
void this.queueProtocolAction("tool-cancel", "lineup.v1.tool.cancel", {
|
||||||
|
call_id: call.call_id,
|
||||||
|
reason: closeReason,
|
||||||
|
cancel_id: `app-close:${instanceID}:${call.call_id}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.store.replaceToolCalls(this.kernel.snapshot().tool_calls);
|
||||||
|
if (currentInstance.app_scope !== "chat") {
|
||||||
|
// Surface teardown is a local Runtime → Host event. It is deliberately
|
||||||
|
// separate from App lifecycle and does not become an Agent message.
|
||||||
|
this.emit({ type: "surface-request", app_scope: currentInstance.app_scope, instance_id: instanceID, event: "close", data: {} });
|
||||||
|
}
|
||||||
this.orchestrator.close(instanceID, this.now(), error);
|
this.orchestrator.close(instanceID, this.now(), error);
|
||||||
this.persistWorkspace();
|
this.persistWorkspace();
|
||||||
runtimeLog("app.lifecycle", { action: error ? "failed" : "closed", instance_id: instanceID });
|
runtimeLog("app.lifecycle", { action: error ? "failed" : "closed", instance_id: instanceID });
|
||||||
@@ -213,8 +288,8 @@ export class LineUpRuntime {
|
|||||||
const app = this.apps.get(appScope);
|
const app = this.apps.get(appScope);
|
||||||
const instance = this.lifecycle.instances.get(instanceID);
|
const instance = this.lifecycle.instances.get(instanceID);
|
||||||
const conversationID = this.requireConversationID();
|
const conversationID = this.requireConversationID();
|
||||||
if (!app || app.kind !== "extension" || !app.enabled || !instance || instance.app_scope !== appScope || instance.conversation_id !== conversationID) {
|
if (!app || app.kind !== "bundled" || !app.enabled || !instance || instance.app_scope !== appScope || instance.conversation_id !== conversationID) {
|
||||||
throw new Error("Extension App SDK is unavailable for this instance.");
|
throw new Error("Bundled MiniApp SDK is unavailable for this instance.");
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
app_scope: appScope, instance_id: instanceID, conversation_id: conversationID,
|
app_scope: appScope, instance_id: instanceID, conversation_id: conversationID,
|
||||||
@@ -225,6 +300,89 @@ export class LineUpRuntime {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The restricted SDK injected into a bundled MiniApp instance. */
|
||||||
|
public openBundledMiniApp(appScope: AppScope, instanceID: string): LineUpMiniAppSDK {
|
||||||
|
const app = this.apps.get(appScope);
|
||||||
|
const instance = this.lifecycle.instances.get(instanceID);
|
||||||
|
const conversationID = this.requireConversationID();
|
||||||
|
if (!app || app.kind !== "bundled" || !app.enabled || !instance || instance.app_scope !== appScope || instance.conversation_id !== conversationID) {
|
||||||
|
throw new Error("Bundled MiniApp SDK is unavailable for this instance.");
|
||||||
|
}
|
||||||
|
const context = (): LineUpMiniAppSDK["context"] => {
|
||||||
|
const current = this.lifecycle.instances.get(instanceID);
|
||||||
|
if (!current || current.state === "stopped" || current.state === "failed" || current.state === "stopping") throw new Error("MiniApp instance is no longer running.");
|
||||||
|
return {
|
||||||
|
app_scope: appScope,
|
||||||
|
instance_id: instanceID,
|
||||||
|
conversation_id: conversationID,
|
||||||
|
app_version: app.manifest.version,
|
||||||
|
state: current.state === "starting" ? "starting" : current.state,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const scopedMessages = (afterMessageID?: string): readonly ScopedConversationItem[] => this.listAppMessages(appScope, afterMessageID, instanceID);
|
||||||
|
return {
|
||||||
|
get context() { return context(); },
|
||||||
|
inbox: {
|
||||||
|
list: afterMessageID => scopedMessages(afterMessageID),
|
||||||
|
subscribe: listener => {
|
||||||
|
const unsubscribe = this.onEvent(event => {
|
||||||
|
if (event.type !== "agent-message" || event.message.scope.app_scope !== appScope) return;
|
||||||
|
listener(event.message);
|
||||||
|
});
|
||||||
|
for (const message of scopedMessages()) listener(message);
|
||||||
|
return unsubscribe;
|
||||||
|
},
|
||||||
|
acknowledge: messageID => this.store.acknowledgeAppInbox(appScope, messageID, instanceID),
|
||||||
|
},
|
||||||
|
tools: {
|
||||||
|
subscribe: listener => {
|
||||||
|
const existing = this.miniappTools.list().filter(call => call.instance_id === instanceID && call.status === "waiting_for_app");
|
||||||
|
for (const call of existing) {
|
||||||
|
this.claimMiniAppTool(call.call_id, instanceID);
|
||||||
|
const claimed = this.miniappTools.get(call.call_id);
|
||||||
|
if (claimed) listener(claimed);
|
||||||
|
}
|
||||||
|
return this.onEvent(event => {
|
||||||
|
if (event.type !== "state") return;
|
||||||
|
for (const call of this.miniappTools.list()) if (call.instance_id === instanceID && call.status === "waiting_for_app") {
|
||||||
|
this.claimMiniAppTool(call.call_id, instanceID);
|
||||||
|
const claimed = this.miniappTools.get(call.call_id);
|
||||||
|
if (claimed) listener(claimed);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
get: callID => this.miniappTools.get(callID)?.instance_id === instanceID ? this.miniappTools.get(callID) : undefined,
|
||||||
|
reportProgress: (callID, progress) => this.reportMiniAppToolProgress(instanceID, callID, progress),
|
||||||
|
complete: (callID, result) => this.completeMiniAppTool(instanceID, callID, result),
|
||||||
|
fail: (callID, failure: MiniAppToolFailure) => this.failMiniAppTool(instanceID, callID, failure),
|
||||||
|
cancel: (callID, reason = "app_cancelled") => this.cancelMiniAppTool(instanceID, callID, reason),
|
||||||
|
},
|
||||||
|
lifecycle: {
|
||||||
|
requestForeground: async () => {
|
||||||
|
if (!this.session.active) return { disposition: "rejected", code: "inactive" };
|
||||||
|
this.lifecycle.foreground(instanceID, this.now()); this.persistWorkspace(); this.emit({ type: "app-lifecycle", workspace: this.workspaceSnapshot(), reason: "launched" });
|
||||||
|
return { disposition: "accepted" };
|
||||||
|
},
|
||||||
|
requestBackground: async () => {
|
||||||
|
if (!this.session.active) return { disposition: "rejected", code: "inactive" };
|
||||||
|
this.lifecycle.background(instanceID, this.now()); this.persistWorkspace(); this.emit({ type: "app-lifecycle", workspace: this.workspaceSnapshot(), reason: "launched" });
|
||||||
|
return { disposition: "accepted" };
|
||||||
|
},
|
||||||
|
requestClose: async reason => {
|
||||||
|
if (!this.session.active) return { disposition: "rejected", code: "inactive" };
|
||||||
|
this.closeAppInstance(instanceID, reason); return { disposition: "accepted" };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
surfaces: {
|
||||||
|
request: async (event, data) => this.requestBundledSurface(appScope, instanceID, event, data),
|
||||||
|
},
|
||||||
|
capabilities: {
|
||||||
|
request: (name, reason, input = {}) => this.queueProtocolAction("app-result", "lineup.v1.app.call", { instance_id: instanceID, app_scope: appScope, capability: name, reason, arguments: input, expires_at: new Date(Date.parse(this.now()) + 15 * 60 * 1000).toISOString() }),
|
||||||
|
},
|
||||||
|
workspace: () => this.workspaceSnapshot(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** @deprecated Use openApp("chat") so App selection remains registry-driven. */
|
/** @deprecated Use openApp("chat") so App selection remains registry-driven. */
|
||||||
public openChatApp(): ChatRuntimeSDK {
|
public openChatApp(): ChatRuntimeSDK {
|
||||||
return this.openApp("chat");
|
return this.openApp("chat");
|
||||||
@@ -247,15 +405,24 @@ export class LineUpRuntime {
|
|||||||
};
|
};
|
||||||
const snapshot = this.store.open(this.requireConversationID());
|
const snapshot = this.store.open(this.requireConversationID());
|
||||||
this.lifecycle.restore(snapshot.app_workspace);
|
this.lifecycle.restore(snapshot.app_workspace);
|
||||||
|
this.miniappTools.restore(snapshot.miniapp_tool_calls, this.now());
|
||||||
|
this.store.replaceMiniAppToolCalls(this.miniappTools.list());
|
||||||
|
this.interactionService.restore(snapshot.standard_interactions.filter(record => record.agent_id === this.session.agent_uid && record.conversation_id === this.requireConversationID()), this.now());
|
||||||
|
this.store.replaceStandardInteractions(this.interactionService.list());
|
||||||
|
// Standard input drafts are UI-only and deliberately do not survive a Runtime restart.
|
||||||
|
for (const interaction of this.interactionService.list()) {
|
||||||
|
if (interaction.kind === "input" && (interaction.status === "pending" || interaction.status === "presented")) this.store.clearToolDraft(interaction.call_id);
|
||||||
|
}
|
||||||
this.kernel.restoreToolCalls(snapshot.tool_calls, this.now());
|
this.kernel.restoreToolCalls(snapshot.tool_calls, this.now());
|
||||||
this.kernel.restoreTasks(snapshot.tasks);
|
this.kernel.restoreTasks(snapshot.tasks);
|
||||||
this.store.replaceToolCalls(this.kernel.snapshot().tool_calls);
|
this.store.replaceToolCalls(this.kernel.snapshot().tool_calls);
|
||||||
this.store.replaceTasks(this.kernel.snapshot().tasks);
|
this.store.replaceTasks(this.kernel.snapshot().tasks);
|
||||||
this.session.last_seq = snapshot.cursor;
|
this.session.last_seq = snapshot.cursor;
|
||||||
if (!this.lifecycle.instances.activeFor("chat", this.requireConversationID())) {
|
if (!this.lifecycle.instances.activeFor("chat", this.requireConversationID())) {
|
||||||
this.orchestrator.launch("chat", this.requireConversationID(), this.now());
|
const launched = this.orchestrator.launch("chat", this.requireConversationID(), this.now());
|
||||||
this.persistWorkspace();
|
this.persistWorkspace();
|
||||||
runtimeLog("app.lifecycle", { action: "started", app_scope: "chat" });
|
runtimeLog("app.lifecycle", { action: "started", app_scope: "chat" });
|
||||||
|
if (launched.disposition === "foreground") this.queueAppSessionOpened(launched.instance);
|
||||||
}
|
}
|
||||||
runtimeLog("session.restored", { restored_instances: this.lifecycle.instances.list(this.requireConversationID()).length, foreground: this.lifecycle.focus.foreground() ?? "none" });
|
runtimeLog("session.restored", { restored_instances: this.lifecycle.instances.list(this.requireConversationID()).length, foreground: this.lifecycle.focus.foreground() ?? "none" });
|
||||||
this.emitState();
|
this.emitState();
|
||||||
@@ -338,6 +505,19 @@ export class LineUpRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public submitToolCall(callID: string, result: JsonObject): boolean {
|
public submitToolCall(callID: string, result: JsonObject): boolean {
|
||||||
|
const interaction = this.interactionService.list().find(record => record.call_id === callID && (record.status === "pending" || record.status === "presented"));
|
||||||
|
if (interaction) {
|
||||||
|
const kernelTransition = this.kernel.submitToolCall(callID, this.now(), result);
|
||||||
|
if (kernelTransition.disposition !== "accepted") return false;
|
||||||
|
const transition = this.interactionService.submit(interaction.interaction_id, result, this.now());
|
||||||
|
if (transition.disposition !== "accepted") return false;
|
||||||
|
this.store.replaceToolCalls(this.kernel.snapshot().tool_calls);
|
||||||
|
this.store.replaceStandardInteractions(this.interactionService.list());
|
||||||
|
this.store.clearToolDraft(callID);
|
||||||
|
void this.queueProtocolAction("tool-result", "lineup.v1.tool.result", { call_id: callID, status: "completed", result });
|
||||||
|
this.emitState();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
const transition = this.kernel.submitToolCall(callID, this.now(), result);
|
const transition = this.kernel.submitToolCall(callID, this.now(), result);
|
||||||
this.store.replaceToolCalls(this.kernel.snapshot().tool_calls);
|
this.store.replaceToolCalls(this.kernel.snapshot().tool_calls);
|
||||||
if (transition.disposition !== "accepted") return false;
|
if (transition.disposition !== "accepted") return false;
|
||||||
@@ -348,6 +528,19 @@ export class LineUpRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public cancelToolCall(callID: string, reason: string): boolean {
|
public cancelToolCall(callID: string, reason: string): boolean {
|
||||||
|
const interaction = this.interactionService.list().find(record => record.call_id === callID && (record.status === "pending" || record.status === "presented"));
|
||||||
|
if (interaction) {
|
||||||
|
const kernelTransition = this.kernel.cancelToolCall(callID, reason, this.now());
|
||||||
|
if (kernelTransition.disposition !== "accepted") return false;
|
||||||
|
const transition = this.interactionService.dismiss({ call_id: callID, agent_id: this.session.agent_uid, conversation_id: this.requireConversationID() }, this.now());
|
||||||
|
if (transition.disposition !== "accepted") return false;
|
||||||
|
this.store.replaceToolCalls(this.kernel.snapshot().tool_calls);
|
||||||
|
this.store.replaceStandardInteractions(this.interactionService.list());
|
||||||
|
this.store.clearToolDraft(callID);
|
||||||
|
void this.queueProtocolAction("tool-cancel", "lineup.v1.tool.cancel", { call_id: callID, reason });
|
||||||
|
this.emitState();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
const transition = this.kernel.cancelToolCall(callID, reason, this.now());
|
const transition = this.kernel.cancelToolCall(callID, reason, this.now());
|
||||||
this.store.replaceToolCalls(this.kernel.snapshot().tool_calls);
|
this.store.replaceToolCalls(this.kernel.snapshot().tool_calls);
|
||||||
if (transition.disposition !== "accepted") return false;
|
if (transition.disposition !== "accepted") return false;
|
||||||
@@ -385,6 +578,78 @@ export class LineUpRuntime {
|
|||||||
return this.store.acknowledgeAppInbox("chat", messageID);
|
return this.store.acknowledgeAppInbox("chat", messageID);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public claimMiniAppTool(callID: string, instanceID: string): boolean {
|
||||||
|
const transition = this.miniappTools.start(callID, instanceID, this.now());
|
||||||
|
if (transition.disposition !== "accepted") return false;
|
||||||
|
this.store.replaceMiniAppToolCalls(this.miniappTools.list());
|
||||||
|
this.emit({ type: "state", snapshot: this.kernel.snapshot() });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public reportMiniAppToolProgress(instanceID: string, callID: string, progress: MiniAppToolProgress): Promise<CommandReceipt> {
|
||||||
|
if (!validProgress(progress)) return Promise.resolve({ disposition: "rejected", code: "invalid_action" });
|
||||||
|
const transition = this.miniappTools.progress(callID, instanceID, this.now());
|
||||||
|
if (transition.disposition !== "accepted") return Promise.resolve({ disposition: "rejected", code: "invalid_action" });
|
||||||
|
this.store.replaceMiniAppToolCalls(this.miniappTools.list());
|
||||||
|
return this.queueProtocolAction("app-result", "lineup.v1.miniapp.tool.progress", {
|
||||||
|
call_id: callID,
|
||||||
|
app_scope: transition.record.app_scope,
|
||||||
|
instance_id: instanceID,
|
||||||
|
progress,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public completeMiniAppTool(instanceID: string, callID: string, result: JsonObject): Promise<CommandReceipt> {
|
||||||
|
const current = this.miniappTools.get(callID);
|
||||||
|
if (!current || current.instance_id !== instanceID) return Promise.resolve({ disposition: "rejected", code: "invalid_action" });
|
||||||
|
const app = this.apps.get(current.app_scope);
|
||||||
|
const definition = app?.manifest.tools.find(tool => tool.name === current.tool_id);
|
||||||
|
if (!app || !definition || !validateMiniAppToolSchema(result, definition.output_schema)) return Promise.resolve({ disposition: "rejected", code: "invalid_action" });
|
||||||
|
const transition = this.miniappTools.submit(callID, instanceID, result, this.now());
|
||||||
|
if (transition.disposition !== "accepted") return Promise.resolve({ disposition: "rejected", code: "invalid_action" });
|
||||||
|
this.store.replaceMiniAppToolCalls(this.miniappTools.list());
|
||||||
|
return this.queueProtocolAction("tool-result", "lineup.v1.miniapp.tool.result", {
|
||||||
|
call_id: callID,
|
||||||
|
app_scope: current.app_scope,
|
||||||
|
instance_id: instanceID,
|
||||||
|
status: "completed",
|
||||||
|
result,
|
||||||
|
result_id: `miniapp-result:${callID}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public failMiniAppTool(instanceID: string, callID: string, failure: MiniAppToolFailure): Promise<CommandReceipt> {
|
||||||
|
const current = this.miniappTools.get(callID);
|
||||||
|
if (!current || current.instance_id !== instanceID) return Promise.resolve({ disposition: "rejected", code: "invalid_action" });
|
||||||
|
const transition = this.miniappTools.fail(callID, instanceID, failure.code ?? "app_failed", this.now());
|
||||||
|
if (transition.disposition !== "accepted") return Promise.resolve({ disposition: "rejected", code: "invalid_action" });
|
||||||
|
this.store.replaceMiniAppToolCalls(this.miniappTools.list());
|
||||||
|
return this.queueProtocolAction("tool-result", "lineup.v1.miniapp.tool.result", {
|
||||||
|
call_id: callID,
|
||||||
|
app_scope: current.app_scope,
|
||||||
|
instance_id: instanceID,
|
||||||
|
status: "failed",
|
||||||
|
error: failure.message,
|
||||||
|
...(failure.code ? { error_code: failure.code } : {}),
|
||||||
|
result_id: `miniapp-result:${callID}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public cancelMiniAppTool(instanceID: string, callID: string, reason: string): Promise<CommandReceipt> {
|
||||||
|
const current = this.miniappTools.get(callID);
|
||||||
|
if (!current || current.instance_id !== instanceID) return Promise.resolve({ disposition: "rejected", code: "invalid_action" });
|
||||||
|
const transition = this.miniappTools.cancel(callID, instanceID, "runtime_cancelled", this.now());
|
||||||
|
if (transition.disposition !== "accepted") return Promise.resolve({ disposition: "rejected", code: "invalid_action" });
|
||||||
|
this.store.replaceMiniAppToolCalls(this.miniappTools.list());
|
||||||
|
return this.queueProtocolAction("tool-cancel", "lineup.v1.miniapp.tool.cancel", {
|
||||||
|
call_id: callID,
|
||||||
|
app_scope: current.app_scope,
|
||||||
|
instance_id: instanceID,
|
||||||
|
reason,
|
||||||
|
cancel_id: `miniapp-cancel:${callID}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Runtime-owned durable protocol result path for non-Chat host modules. */
|
/** Runtime-owned durable protocol result path for non-Chat host modules. */
|
||||||
public async queueProtocolAction(
|
public async queueProtocolAction(
|
||||||
kind: Exclude<OutboxEntry["kind"], "text">,
|
kind: Exclude<OutboxEntry["kind"], "text">,
|
||||||
@@ -392,7 +657,9 @@ export class LineUpRuntime {
|
|||||||
payload: JsonObject,
|
payload: JsonObject,
|
||||||
): Promise<CommandReceipt> {
|
): Promise<CommandReceipt> {
|
||||||
if (!this.session.active) return { disposition: "rejected", code: "inactive" };
|
if (!this.session.active) return { disposition: "rejected", code: "inactive" };
|
||||||
const localID = this.newID(kind);
|
const controlID = typeof payload.control_id === "string" ? payload.control_id : undefined;
|
||||||
|
const cancelID = typeof payload.cancel_id === "string" ? payload.cancel_id : undefined;
|
||||||
|
const localID = controlID ? `interaction-dismiss:${controlID}` : cancelID ? `tool-cancel:${cancelID}` : this.newID(kind);
|
||||||
const envelope: Envelope = {
|
const envelope: Envelope = {
|
||||||
v: 1,
|
v: 1,
|
||||||
id: this.newID("msg"),
|
id: this.newID("msg"),
|
||||||
@@ -463,6 +730,16 @@ export class LineUpRuntime {
|
|||||||
data: {},
|
data: {},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (action.type === "chat.continue_app_session") {
|
||||||
|
const old = this.lifecycle.instances.list(this.requireConversationID()).find(instance => instance.app_session_id === action.app_session_id);
|
||||||
|
if (!old || old.state !== "stopped" && old.state !== "failed" || old.app_scope === "chat") return { disposition: "rejected", code: "invalid_action" };
|
||||||
|
const launched = this.orchestrator.launch(old.app_scope, this.requireConversationID(), this.now(), { continued_from_app_session_id: action.app_session_id });
|
||||||
|
if (launched.disposition !== "foreground") return { disposition: "rejected", code: "invalid_action" };
|
||||||
|
this.persistWorkspace();
|
||||||
|
this.emit({ type: "app-lifecycle", workspace: this.workspaceSnapshot(), reason: "launched" });
|
||||||
|
this.queueAppSessionOpened(launched.instance);
|
||||||
|
return { disposition: "accepted" };
|
||||||
|
}
|
||||||
return { disposition: "rejected", code: "invalid_action" };
|
return { disposition: "rejected", code: "invalid_action" };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -485,12 +762,92 @@ export class LineUpRuntime {
|
|||||||
this.emit({ type: "scope-rejected", reason: scopeResolution.reason, raw: payload });
|
this.emit({ type: "scope-rejected", reason: scopeResolution.reason, raw: payload });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const route = this.toolRouter.route(payload, { app_scope: scopeResolution.scope.app_scope, conversation_id: this.requireConversationID() });
|
const route = this.toolRouter.route(payload, { app_scope: scopeResolution.scope.app_scope, conversation_id: this.requireConversationID(), inventory_revision: this.inventoryRevision });
|
||||||
if (route.disposition === "rejected") {
|
if (route.disposition === "rejected") {
|
||||||
runtimeLog("tool.rejected", { reason: route.code });
|
runtimeLog("tool.rejected", { reason: route.code });
|
||||||
this.emit({ type: "scope-rejected", reason: route.code === "scope_mismatch" ? "scope_mismatch" : "invalid_scope", raw: payload });
|
this.emit({ type: "scope-rejected", reason: route.code === "scope_mismatch" ? "scope_mismatch" : "invalid_scope", raw: payload });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (route.disposition === "interactive" && route.app_session_id) {
|
||||||
|
const appSession = this.lifecycle.instances.list(this.requireConversationID()).find(instance => instance.app_session_id === route.app_session_id);
|
||||||
|
if (!appSession) {
|
||||||
|
runtimeLog("interaction.rejected", { reason: "app_session_context_invalid", app_session_id: route.app_session_id });
|
||||||
|
this.emit({ type: "scope-rejected", reason: "scope_mismatch", raw: payload });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (route.disposition === "dismiss") {
|
||||||
|
const transition = this.interactionService.dismiss({
|
||||||
|
call_id: route.call_id,
|
||||||
|
agent_id: this.session.agent_uid,
|
||||||
|
conversation_id: this.requireConversationID(),
|
||||||
|
}, this.now());
|
||||||
|
if (transition.disposition === "accepted") {
|
||||||
|
this.store.replaceStandardInteractions(this.interactionService.list());
|
||||||
|
void this.queueProtocolAction("tool-cancel", "lineup.v1.tool.cancel", {
|
||||||
|
call_id: route.call_id,
|
||||||
|
reason: route.reason ?? "agent_dismissed",
|
||||||
|
control_id: route.control_id,
|
||||||
|
});
|
||||||
|
runtimeLog("interaction.dismissed", { call_id: route.call_id, control_id: route.control_id });
|
||||||
|
} else if (transition.disposition !== "invalid_transition") {
|
||||||
|
runtimeLog("interaction.dismiss_ignored", { call_id: route.call_id, control_id: route.control_id, disposition: transition.disposition });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let miniAppInstanceID: string | undefined;
|
||||||
|
if (route.disposition === "miniapp") {
|
||||||
|
const requested = route.instance_id ? this.lifecycle.instances.get(route.instance_id) : undefined;
|
||||||
|
if (requested && (requested.app_scope !== route.app_scope || requested.conversation_id !== this.requireConversationID() || ["stopped", "failed", "stopping"].includes(requested.state))) {
|
||||||
|
runtimeLog("tool.rejected", { reason: "app_instance_not_found" });
|
||||||
|
this.emit({ type: "scope-rejected", reason: "scope_mismatch", raw: payload });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const existing = requested ?? this.lifecycle.instances.activeFor(route.app_scope, this.requireConversationID());
|
||||||
|
if (route.handling === "launch" || route.handling === "foreground" || (!existing && route.requires_foreground)) {
|
||||||
|
const launched = this.orchestrator.launch(route.app_scope, this.requireConversationID(), this.now(), { instance_id: route.instance_id });
|
||||||
|
if (launched.disposition !== "foreground") {
|
||||||
|
runtimeLog("tool.rejected", { reason: launched.code });
|
||||||
|
this.emit({ type: "scope-rejected", reason: "invalid_scope", raw: payload });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
miniAppInstanceID = launched.instance.instance_id;
|
||||||
|
this.persistWorkspace();
|
||||||
|
this.emit({ type: "app-lifecycle", workspace: this.workspaceSnapshot(), reason: "launched" });
|
||||||
|
this.queueAppSessionOpened(launched.instance);
|
||||||
|
} else if (!existing) {
|
||||||
|
// A direct/background operation may create an instance without
|
||||||
|
// stealing focus from the current App. The orchestrator performs the
|
||||||
|
// common creation and focus bookkeeping; immediately backgrounding
|
||||||
|
// here leaves the prior foreground instance active.
|
||||||
|
const launched = this.orchestrator.launch(route.app_scope, this.requireConversationID(), this.now(), { instance_id: route.instance_id });
|
||||||
|
if (launched.disposition !== "foreground") {
|
||||||
|
runtimeLog("tool.rejected", { reason: launched.code });
|
||||||
|
this.emit({ type: "scope-rejected", reason: "invalid_scope", raw: payload });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.lifecycle.background(launched.instance.instance_id, this.now());
|
||||||
|
miniAppInstanceID = launched.instance.instance_id;
|
||||||
|
this.persistWorkspace();
|
||||||
|
this.emit({ type: "app-lifecycle", workspace: this.workspaceSnapshot(), reason: "launched" });
|
||||||
|
this.queueAppSessionOpened(launched.instance);
|
||||||
|
} else {
|
||||||
|
miniAppInstanceID = existing.instance_id;
|
||||||
|
}
|
||||||
|
const created = this.miniappTools.receive({
|
||||||
|
call_id: route.call.call_id,
|
||||||
|
tool_id: route.call.tool_id,
|
||||||
|
inventory_revision: route.call.inventory_revision,
|
||||||
|
app_scope: route.app_scope,
|
||||||
|
...(miniAppInstanceID ? { instance_id: miniAppInstanceID } : {}),
|
||||||
|
conversation_id: this.requireConversationID(),
|
||||||
|
input: route.call.input,
|
||||||
|
created_at: this.now(),
|
||||||
|
});
|
||||||
|
if (created.disposition === "invalid_transition" || created.disposition === "invalid_scope") return;
|
||||||
|
if (created.disposition === "accepted" && miniAppInstanceID) this.miniappTools.route(route.call.call_id, miniAppInstanceID, this.now());
|
||||||
|
this.store.replaceMiniAppToolCalls(this.miniappTools.list());
|
||||||
|
}
|
||||||
const result = this.kernel.ingest({ raw: payload, source: "sync", transport_id: String(message.message_seq), received_at: this.now() });
|
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.replaceToolCalls(result.snapshot.tool_calls);
|
||||||
this.store.replaceTasks(result.snapshot.tasks);
|
this.store.replaceTasks(result.snapshot.tasks);
|
||||||
@@ -507,7 +864,8 @@ export class LineUpRuntime {
|
|||||||
received_at: receivedAt,
|
received_at: receivedAt,
|
||||||
message_seq: message.message_seq,
|
message_seq: message.message_seq,
|
||||||
};
|
};
|
||||||
const targetScope = route.disposition === "launch" ? route.app_scope : scopeResolution.scope.app_scope;
|
const targetScope = route.disposition === "launch" || route.disposition === "miniapp" ? route.app_scope : scopeResolution.scope.app_scope;
|
||||||
|
if (route.disposition !== "interactive") {
|
||||||
this.store.enqueueAppInbox({
|
this.store.enqueueAppInbox({
|
||||||
message_id: messageID,
|
message_id: messageID,
|
||||||
app_scope: targetScope,
|
app_scope: targetScope,
|
||||||
@@ -515,18 +873,47 @@ export class LineUpRuntime {
|
|||||||
item,
|
item,
|
||||||
received_at: receivedAt,
|
received_at: receivedAt,
|
||||||
message_seq: message.message_seq,
|
message_seq: message.message_seq,
|
||||||
|
...(route.disposition === "miniapp" && miniAppInstanceID ? { instance_id: miniAppInstanceID } : {}),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
this.emit({ type: "agent-message", message: scoped });
|
this.emit({ type: "agent-message", message: scoped });
|
||||||
if (targetScope === "chat") this.emitChatMessage(scoped);
|
if (route.disposition === "interactive" || targetScope === "chat") this.emitChatMessage(scoped);
|
||||||
|
if (route.disposition === "interactive" && item.kind === "tool-call") {
|
||||||
|
const interactionRequest = standardRequestFromToolCall(item.call, receivedAt);
|
||||||
|
if (interactionRequest) {
|
||||||
|
const chatInstance = this.lifecycle.instances.activeFor("chat", this.requireConversationID());
|
||||||
|
const created = this.interactionService.create({
|
||||||
|
interaction_id: this.newID("interaction"),
|
||||||
|
call_id: item.call.call_id,
|
||||||
|
agent_id: this.session.agent_uid,
|
||||||
|
conversation_id: this.requireConversationID(),
|
||||||
|
interact_instance_id: chatInstance?.instance_id ?? "chat",
|
||||||
|
...(route.app_session_id ? { app_session_id: route.app_session_id } : {}),
|
||||||
|
request: interactionRequest,
|
||||||
|
created_at: receivedAt,
|
||||||
|
});
|
||||||
|
if (created.disposition === "invalid_request") runtimeLog("interaction.rejected", { reason: "invalid_request" });
|
||||||
|
if (created.disposition === "accepted") {
|
||||||
|
const presented = this.interactionService.present(created.record.interaction_id, receivedAt);
|
||||||
|
this.store.replaceStandardInteractions(this.interactionService.list());
|
||||||
|
if (presented.disposition === "accepted" && presented.record.status === "completed") {
|
||||||
|
void this.queueProtocolAction("tool-result", "lineup.v1.tool.result", { call_id: item.call.call_id, status: "completed", result: {} });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (route.disposition === "launch") {
|
if (route.disposition === "launch") {
|
||||||
|
const prior = this.lifecycle.instances.activeFor(route.app_scope, this.requireConversationID());
|
||||||
const launched = this.orchestrator.launch(route.app_scope, this.requireConversationID(), this.now(), { instance_id: route.instance_id });
|
const launched = this.orchestrator.launch(route.app_scope, this.requireConversationID(), this.now(), { instance_id: route.instance_id });
|
||||||
if (launched.disposition === "foreground") {
|
if (launched.disposition === "foreground") {
|
||||||
this.persistWorkspace();
|
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" });
|
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: "app-lifecycle", workspace: this.workspaceSnapshot(), reason: "launched" });
|
||||||
|
if (!prior) this.queueAppSessionOpened(launched.instance);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (route.disposition === "miniapp") this.emit({ type: "state", snapshot: this.kernel.snapshot() });
|
||||||
this.emit({ type: "state", snapshot: result.snapshot });
|
this.emit({ type: "state", snapshot: result.snapshot });
|
||||||
this.emitState();
|
this.emitState();
|
||||||
}
|
}
|
||||||
@@ -538,7 +925,18 @@ export class LineUpRuntime {
|
|||||||
this.emit({ type: "delivery", local_id: entry.local_id, ...(item ? { item } : {}) });
|
this.emit({ type: "delivery", local_id: entry.local_id, ...(item ? { item } : {}) });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(entry.payload) as { type?: string; payload?: { call_id?: unknown; status?: unknown } };
|
||||||
|
if (parsed.type === "lineup.v1.miniapp.tool.result" && typeof parsed.payload?.call_id === "string" && parsed.payload.status === "completed") {
|
||||||
|
this.miniappTools.complete(parsed.payload.call_id, this.now());
|
||||||
|
this.store.replaceMiniAppToolCalls(this.miniappTools.list());
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Outbox payload validation happened before enqueue; a malformed local
|
||||||
|
// payload is still acknowledged rather than retried forever.
|
||||||
|
}
|
||||||
this.store.acknowledgeOutbox(entry.local_id);
|
this.store.acknowledgeOutbox(entry.local_id);
|
||||||
|
this.emitState();
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleOutboxFailure(entry: OutboxEntry, reason: unknown): void {
|
private handleOutboxFailure(entry: OutboxEntry, reason: unknown): void {
|
||||||
@@ -564,6 +962,17 @@ export class LineUpRuntime {
|
|||||||
|
|
||||||
private chatView(): ChatViewModel {
|
private chatView(): ChatViewModel {
|
||||||
const snapshot = this.kernel.snapshot();
|
const snapshot = this.kernel.snapshot();
|
||||||
|
const interactions = this.interactionService.list();
|
||||||
|
const appSessions = this.lifecycle.instances.list(this.currentConversationID()).flatMap(instance => instance.app_session_id ? [{
|
||||||
|
app_session_id: instance.app_session_id,
|
||||||
|
app_scope: instance.app_scope,
|
||||||
|
instance_id: instance.instance_id,
|
||||||
|
state: instance.state,
|
||||||
|
interaction_count: interactions.filter(interaction => interaction.app_session_id === instance.app_session_id).length,
|
||||||
|
pending_interaction_count: interactions.filter(interaction => interaction.app_session_id === instance.app_session_id && (interaction.status === "pending" || interaction.status === "presented")).length,
|
||||||
|
history: appSessionHistory(interactions.filter(interaction => interaction.app_session_id === instance.app_session_id)),
|
||||||
|
...(instance.continued_from_app_session_id ? { continued_from_app_session_id: instance.continued_from_app_session_id } : {}),
|
||||||
|
}] : []);
|
||||||
return {
|
return {
|
||||||
app_scope: "chat",
|
app_scope: "chat",
|
||||||
conversation_id: this.currentConversationID(),
|
conversation_id: this.currentConversationID(),
|
||||||
@@ -571,15 +980,33 @@ export class LineUpRuntime {
|
|||||||
agent_presence: snapshot.agent_presence,
|
agent_presence: snapshot.agent_presence,
|
||||||
tool_calls: snapshot.tool_calls,
|
tool_calls: snapshot.tool_calls,
|
||||||
tasks: snapshot.tasks,
|
tasks: snapshot.tasks,
|
||||||
|
app_sessions: appSessions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private listChatMessages(afterMessageID?: string): readonly AgentChatMessage[] {
|
private listChatMessages(afterMessageID?: string): readonly AgentChatMessage[] {
|
||||||
return this.listAppMessages("chat", afterMessageID) as readonly AgentChatMessage[];
|
const inboxMessages = this.listAppMessages("chat", afterMessageID) as readonly AgentChatMessage[];
|
||||||
|
const knownIDs = new Set(inboxMessages.map(message => message.message_id));
|
||||||
|
const interactionMessages = this.store.snapshot().items
|
||||||
|
.filter(entry => entry.item.kind === "tool-call" && entry.item.envelope?.sender.kind === "agent")
|
||||||
|
.map(entry => {
|
||||||
|
const messageID = entry.item.id ?? entry.local_id;
|
||||||
|
return {
|
||||||
|
message_id: messageID,
|
||||||
|
scope: { app_scope: "chat" as const, conversation_id: this.requireConversationID() },
|
||||||
|
item: entry.item,
|
||||||
|
received_at: entry.created_at,
|
||||||
|
...(entry.message_seq !== undefined ? { message_seq: entry.message_seq } : {}),
|
||||||
|
} satisfies AgentChatMessage;
|
||||||
|
})
|
||||||
|
.filter(message => !knownIDs.has(message.message_id));
|
||||||
|
const merged = [...inboxMessages, ...interactionMessages];
|
||||||
|
const start = afterMessageID ? merged.findIndex(entry => entry.message_id === afterMessageID) + 1 : 0;
|
||||||
|
return merged.slice(Math.max(0, start));
|
||||||
}
|
}
|
||||||
|
|
||||||
private listAppMessages(appScope: AppScope, afterMessageID?: string): readonly ScopedConversationItem[] {
|
private listAppMessages(appScope: AppScope, afterMessageID?: string, instanceID?: string): readonly ScopedConversationItem[] {
|
||||||
const entries = this.store.listAppInbox(appScope);
|
const entries = this.store.listAppInbox(appScope, instanceID);
|
||||||
const start = afterMessageID ? entries.findIndex(entry => entry.message_id === afterMessageID) + 1 : 0;
|
const start = afterMessageID ? entries.findIndex(entry => entry.message_id === afterMessageID) + 1 : 0;
|
||||||
return entries.slice(Math.max(0, start)).map(entry => ({
|
return entries.slice(Math.max(0, start)).map(entry => ({
|
||||||
message_id: entry.message_id,
|
message_id: entry.message_id,
|
||||||
@@ -603,10 +1030,43 @@ export class LineUpRuntime {
|
|||||||
|
|
||||||
private persistWorkspace(): void { this.store.replaceAppWorkspace(this.lifecycle.snapshot()); }
|
private persistWorkspace(): void { this.store.replaceAppWorkspace(this.lifecycle.snapshot()); }
|
||||||
|
|
||||||
|
private queueAppSessionOpened(instance: { app_scope: AppScope; instance_id: string; app_session_id?: string }): void {
|
||||||
|
if (!instance.app_session_id) return;
|
||||||
|
void this.queueProtocolAction("app-result", "lineup.v1.app.session.opened", {
|
||||||
|
app_scope: instance.app_scope,
|
||||||
|
instance_id: instance.instance_id,
|
||||||
|
app_session_id: instance.app_session_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private emit(event: RuntimeAppEvent): void {
|
private emit(event: RuntimeAppEvent): void {
|
||||||
for (const listener of this.listeners) listener(event);
|
for (const listener of this.listeners) listener(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Surface requests from a bundled MiniApp stay inside the local Runtime →
|
||||||
|
* Host path. They are not Agent protocol messages: the Runtime checks the
|
||||||
|
* instance scope and normalizes the app identity before emitting a local
|
||||||
|
* event for the trusted Host adapter.
|
||||||
|
*/
|
||||||
|
private requestBundledSurface(
|
||||||
|
appScope: AppScope,
|
||||||
|
instanceID: string,
|
||||||
|
event: "open" | "patch" | "close",
|
||||||
|
data: JsonObject,
|
||||||
|
): CommandReceipt {
|
||||||
|
const app = this.apps.get(appScope);
|
||||||
|
const instance = this.lifecycle.instances.get(instanceID);
|
||||||
|
const conversationID = this.currentConversationID();
|
||||||
|
if (!this.session.active || !conversationID || !app || app.kind !== "bundled" || !app.enabled
|
||||||
|
|| !instance || instance.app_scope !== appScope || instance.conversation_id !== conversationID
|
||||||
|
|| instance.state === "stopped" || instance.state === "failed" || instance.state === "stopping") {
|
||||||
|
return { disposition: "rejected", code: "invalid_action" };
|
||||||
|
}
|
||||||
|
this.emit({ type: "surface-request", app_scope: appScope, instance_id: instanceID, event, data: { ...data } });
|
||||||
|
return { disposition: "accepted" };
|
||||||
|
}
|
||||||
|
|
||||||
private requireConversationID(): string {
|
private requireConversationID(): string {
|
||||||
const conversationID = this.currentConversationID();
|
const conversationID = this.currentConversationID();
|
||||||
if (!conversationID) throw new Error("LineUpRuntime has no active conversation.");
|
if (!conversationID) throw new Error("LineUpRuntime has no active conversation.");
|
||||||
@@ -619,6 +1079,58 @@ function boundedFailure(value: string): string {
|
|||||||
return message ? message.slice(0, 500) : "Extension App failed.";
|
return message ? message.slice(0, 500) : "Extension App failed.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function appSessionHistory(records: readonly StandardInteractionRecord[]): readonly { id: string; role: "agent" | "user"; text: string }[] {
|
||||||
|
const history: Array<{ id: string; role: "agent" | "user"; text: string }> = [];
|
||||||
|
for (const record of records) {
|
||||||
|
const request = record.request;
|
||||||
|
const prompt = "prompt" in request ? `${request.title}: ${request.prompt}` : `${request.title}: ${request.message}`;
|
||||||
|
history.push({ id: `${record.interaction_id}:agent`, role: "agent", text: prompt.slice(0, 1000) });
|
||||||
|
if (record.result?.outcome === "answered") {
|
||||||
|
const answer = record.result.answer;
|
||||||
|
const text = "text" in answer && typeof answer.text === "string" ? answer.text : "approved" in answer ? (answer.approved ? "已确认" : "已取消") : `选择:${String(answer.action_id)}`;
|
||||||
|
history.push({ id: `${record.interaction_id}:user`, role: "user", text: text.slice(0, 1000) });
|
||||||
|
} else if (record.result?.outcome === "cancelled" || record.result?.outcome === "expired") {
|
||||||
|
history.push({ id: `${record.interaction_id}:user`, role: "user", text: record.result.outcome === "cancelled" ? "已取消" : "已超时" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return history;
|
||||||
|
}
|
||||||
|
|
||||||
|
function standardRequestFromToolCall(call: ToolCallRequest, createdAt: string): StandardInteractionRequest | undefined {
|
||||||
|
const expiresIn = call.expires_at === undefined ? undefined : Date.parse(call.expires_at) - Date.parse(createdAt);
|
||||||
|
if (expiresIn !== undefined && !Number.isFinite(expiresIn)) return undefined;
|
||||||
|
if (call.tool === "notice" && call.notice) {
|
||||||
|
return { kind: "notice", title: call.title, message: call.notice.message, ...(call.notice.dismiss_label ? { dismiss_label: call.notice.dismiss_label } : {}) };
|
||||||
|
}
|
||||||
|
if (call.tool === "choice" && call.action_group?.mode === "single-choice") {
|
||||||
|
return { kind: "choice", title: call.title, prompt: call.prompt, mode: "single-choice", actions: call.action_group.actions, ...(expiresIn !== undefined ? { expires_in_ms: expiresIn } : {}) };
|
||||||
|
}
|
||||||
|
if (call.tool === "confirm" && call.confirm) {
|
||||||
|
return { kind: "confirm", title: call.title, prompt: call.prompt, approve_label: call.confirm.approve_label, cancel_label: call.confirm.cancel_label, ...(expiresIn !== undefined ? { expires_in_ms: expiresIn } : {}) };
|
||||||
|
}
|
||||||
|
if (call.tool === "input" && call.form?.fields.length === 1) {
|
||||||
|
const field = call.form.fields[0];
|
||||||
|
if (field.type !== "text" && field.type !== "textarea") return undefined;
|
||||||
|
return {
|
||||||
|
kind: "input",
|
||||||
|
title: call.title,
|
||||||
|
prompt: call.prompt,
|
||||||
|
field: { id: field.id, label: field.label, type: field.type, required: field.required, ...(field.placeholder ? { placeholder: field.placeholder } : {}), ...(field.max_length !== undefined ? { max_length: field.max_length } : {}) },
|
||||||
|
submit_label: call.form.submit_label,
|
||||||
|
cancel_label: call.form.cancel_label,
|
||||||
|
...(expiresIn !== undefined ? { expires_in_ms: expiresIn } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validProgress(progress: MiniAppToolProgress): boolean {
|
||||||
|
return progress !== null && typeof progress === "object"
|
||||||
|
&& (progress.percent === undefined || (typeof progress.percent === "number" && Number.isFinite(progress.percent) && progress.percent >= 0 && progress.percent <= 100))
|
||||||
|
&& (progress.status === undefined || (typeof progress.status === "string" && progress.status.length <= 80))
|
||||||
|
&& (progress.detail === undefined || (typeof progress.detail === "string" && progress.detail.length <= 500));
|
||||||
|
}
|
||||||
|
|
||||||
/** Deliberately excludes identities, conversation IDs, message bodies and Tool arguments. */
|
/** Deliberately excludes identities, conversation IDs, message bodies and Tool arguments. */
|
||||||
function runtimeLog(event: string, fields: Record<string, string | number>): void {
|
function runtimeLog(event: string, fields: Record<string, string | number>): void {
|
||||||
console.info("[LineUp Runtime]", event, fields);
|
console.info("[LineUp Runtime]", event, fields);
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { JsonObject, JsonValue } from "@/runtime/protocol/lineup-v1";
|
||||||
|
|
||||||
|
/** Small, deterministic JSON Schema subset frozen for MiniApp Tool v1. */
|
||||||
|
export function validateMiniAppToolSchema(value: JsonValue, schema: JsonObject | undefined): boolean {
|
||||||
|
if (!schema) return value !== undefined;
|
||||||
|
if (schema.enum !== undefined && (!Array.isArray(schema.enum) || !schema.enum.some(candidate => JSON.stringify(candidate) === JSON.stringify(value)))) return false;
|
||||||
|
const type = typeof schema.type === "string" ? schema.type : undefined;
|
||||||
|
if (type && !matchesType(value, type)) return false;
|
||||||
|
if (typeof value === "string") {
|
||||||
|
if (typeof schema.minLength === "number" && value.length < schema.minLength) return false;
|
||||||
|
if (typeof schema.maxLength === "number" && value.length > schema.maxLength) return false;
|
||||||
|
}
|
||||||
|
if (typeof value === "number") {
|
||||||
|
if (typeof schema.minimum === "number" && value < schema.minimum) return false;
|
||||||
|
if (typeof schema.maximum === "number" && value > schema.maximum) return false;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (typeof schema.maxItems === "number" && value.length > schema.maxItems) return false;
|
||||||
|
if (schema.items && typeof schema.items === "object" && !Array.isArray(schema.items) && !value.every(item => validateMiniAppToolSchema(item, schema.items as JsonObject))) return false;
|
||||||
|
}
|
||||||
|
if (isObject(value)) {
|
||||||
|
const required = Array.isArray(schema.required) ? schema.required.filter(entry => typeof entry === "string") : [];
|
||||||
|
if (!required.every(key => Object.hasOwn(value, key))) return false;
|
||||||
|
const properties = isObject(schema.properties) ? schema.properties : {};
|
||||||
|
if (schema.additionalProperties === false && Object.keys(value).some(key => !Object.hasOwn(properties, key))) return false;
|
||||||
|
for (const [key, child] of Object.entries(properties)) if (Object.hasOwn(value, key) && isObject(child) && !validateMiniAppToolSchema(value[key], child)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesType(value: JsonValue, type: string): boolean {
|
||||||
|
return type === "object" ? isObject(value)
|
||||||
|
: type === "array" ? Array.isArray(value)
|
||||||
|
: type === "string" ? typeof value === "string"
|
||||||
|
: type === "number" ? typeof value === "number" && Number.isFinite(value)
|
||||||
|
: type === "boolean" ? typeof value === "boolean"
|
||||||
|
: type === "null" ? value === null
|
||||||
|
: false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isObject(value: JsonValue | undefined): value is JsonObject {
|
||||||
|
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { MiniAppToolStateMachine } from "@/runtime/coordination/miniapp-tool-state";
|
||||||
|
|
||||||
|
describe("MiniAppToolStateMachine", () => {
|
||||||
|
it("moves a call through claim and submitted result, while rejecting duplicate terminal writes", () => {
|
||||||
|
const machine = new MiniAppToolStateMachine();
|
||||||
|
machine.receive({ call_id: "call-1", tool_id: "task.create", inventory_revision: "catalog-1", app_scope: "task-dashboard", instance_id: "task:1", conversation_id: "c", input: {}, created_at: "2026-08-04T00:00:00.000Z" });
|
||||||
|
expect(machine.route("call-1", "task:1", "2026-08-04T00:00:01.000Z")).toMatchObject({ disposition: "accepted", record: { status: "waiting_for_app" } });
|
||||||
|
expect(machine.start("call-1", "task:1", "2026-08-04T00:00:02.000Z")).toMatchObject({ disposition: "accepted", record: { status: "running" } });
|
||||||
|
expect(machine.submit("call-1", "task:1", { task_id: "t1" }, "2026-08-04T00:00:03.000Z")).toMatchObject({ disposition: "accepted", record: { status: "submitted" } });
|
||||||
|
expect(machine.submit("call-1", "task:1", { task_id: "t2" }, "2026-08-04T00:00:04.000Z")).toMatchObject({ disposition: "invalid_transition", record: { status: "submitted" } });
|
||||||
|
expect(machine.complete("call-1", "2026-08-04T00:00:05.000Z")).toMatchObject({ disposition: "accepted", record: { status: "completed" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels only the instance that owns a pending call", () => {
|
||||||
|
const machine = new MiniAppToolStateMachine();
|
||||||
|
machine.receive({ call_id: "call-2", tool_id: "task.create", inventory_revision: "catalog-1", app_scope: "task-dashboard", instance_id: "task:1", conversation_id: "c", input: {}, created_at: "2026-08-04T00:00:00.000Z" });
|
||||||
|
machine.route("call-2", "task:1", "2026-08-04T00:00:01.000Z");
|
||||||
|
expect(machine.cancel("call-2", "task:2", "app_closed", "2026-08-04T00:00:02.000Z")).toEqual(expect.objectContaining({ disposition: "invalid_scope" }));
|
||||||
|
expect(machine.cancel("call-2", "task:1", "app_closed", "2026-08-04T00:00:02.000Z")).toMatchObject({ disposition: "accepted", record: { status: "cancelled", cancel_reason: "app_closed" } });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import type { JsonObject } from "@/runtime/protocol/lineup-v1";
|
||||||
|
|
||||||
|
export type MiniAppToolStatus =
|
||||||
|
| "received"
|
||||||
|
| "routing"
|
||||||
|
| "waiting_for_app"
|
||||||
|
| "running"
|
||||||
|
| "submitted"
|
||||||
|
| "completed"
|
||||||
|
| "failed"
|
||||||
|
| "cancelled"
|
||||||
|
| "expired"
|
||||||
|
| "rejected";
|
||||||
|
|
||||||
|
export type MiniAppToolCancelReason = "app_closed" | "agent_cancelled" | "runtime_cancelled";
|
||||||
|
|
||||||
|
export type MiniAppToolCallRecord = {
|
||||||
|
call_id: string;
|
||||||
|
tool_id: string;
|
||||||
|
inventory_revision: string;
|
||||||
|
app_scope: string;
|
||||||
|
instance_id?: string;
|
||||||
|
conversation_id: string;
|
||||||
|
status: MiniAppToolStatus;
|
||||||
|
input: JsonObject;
|
||||||
|
result?: JsonObject;
|
||||||
|
error_code?: string;
|
||||||
|
cancel_reason?: MiniAppToolCancelReason;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
submitted_at?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MiniAppToolTransition =
|
||||||
|
| { disposition: "accepted"; record: MiniAppToolCallRecord }
|
||||||
|
| { disposition: "duplicate" | "missing" | "invalid_transition" | "invalid_scope"; record?: MiniAppToolCallRecord };
|
||||||
|
|
||||||
|
export class MiniAppToolStateMachine {
|
||||||
|
private readonly records = new Map<string, MiniAppToolCallRecord>();
|
||||||
|
|
||||||
|
public receive(input: Omit<MiniAppToolCallRecord, "status" | "created_at" | "updated_at"> & { created_at: string }): MiniAppToolTransition {
|
||||||
|
const existing = this.records.get(input.call_id);
|
||||||
|
if (existing) return { disposition: "duplicate", record: copy(existing) };
|
||||||
|
const record: MiniAppToolCallRecord = {
|
||||||
|
...input,
|
||||||
|
status: "received",
|
||||||
|
created_at: input.created_at,
|
||||||
|
updated_at: input.created_at,
|
||||||
|
input: clone(input.input),
|
||||||
|
};
|
||||||
|
this.records.set(record.call_id, record);
|
||||||
|
return { disposition: "accepted", record: copy(record) };
|
||||||
|
}
|
||||||
|
|
||||||
|
public route(callID: string, instanceID: string, now: string): MiniAppToolTransition {
|
||||||
|
return this.transition(callID, now, ["received", "routing"], record => {
|
||||||
|
record.status = "waiting_for_app";
|
||||||
|
record.instance_id = instanceID;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public start(callID: string, instanceID: string, now: string): MiniAppToolTransition {
|
||||||
|
return this.transition(callID, now, ["waiting_for_app", "running"], record => {
|
||||||
|
if (record.instance_id !== instanceID) throw new Error("instance mismatch");
|
||||||
|
record.status = "running";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public progress(callID: string, instanceID: string, now: string): MiniAppToolTransition {
|
||||||
|
return this.transition(callID, now, ["waiting_for_app", "running"], record => {
|
||||||
|
if (record.instance_id !== instanceID) throw new Error("instance mismatch");
|
||||||
|
record.status = "running";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public submit(callID: string, instanceID: string, result: JsonObject | undefined, now: string): MiniAppToolTransition {
|
||||||
|
return this.transition(callID, now, ["waiting_for_app", "running"], record => {
|
||||||
|
if (record.instance_id !== instanceID) throw new Error("instance mismatch");
|
||||||
|
record.status = "submitted";
|
||||||
|
record.submitted_at = now;
|
||||||
|
if (result) record.result = clone(result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public complete(callID: string, now: string): MiniAppToolTransition {
|
||||||
|
return this.transition(callID, now, ["submitted"], record => { record.status = "completed"; });
|
||||||
|
}
|
||||||
|
|
||||||
|
public fail(callID: string, instanceID: string, code: string, now: string): MiniAppToolTransition {
|
||||||
|
return this.transition(callID, now, ["waiting_for_app", "running"], record => {
|
||||||
|
if (record.instance_id !== instanceID) throw new Error("instance mismatch");
|
||||||
|
record.status = "failed";
|
||||||
|
record.error_code = code;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public cancel(callID: string, instanceID: string | undefined, reason: MiniAppToolCancelReason, now: string): MiniAppToolTransition {
|
||||||
|
return this.transition(callID, now, ["received", "routing", "waiting_for_app", "running"], record => {
|
||||||
|
if (instanceID !== undefined && record.instance_id !== instanceID) throw new Error("instance mismatch");
|
||||||
|
record.status = "cancelled";
|
||||||
|
record.cancel_reason = reason;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public get(callID: string): MiniAppToolCallRecord | undefined {
|
||||||
|
const record = this.records.get(callID);
|
||||||
|
return record ? copy(record) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
public list(): readonly MiniAppToolCallRecord[] { return [...this.records.values()].map(copy); }
|
||||||
|
|
||||||
|
public restore(records: readonly MiniAppToolCallRecord[], now: string): void {
|
||||||
|
this.records.clear();
|
||||||
|
for (const raw of records) {
|
||||||
|
if (!validRecord(raw) || this.records.has(raw.call_id)) continue;
|
||||||
|
const record = copy(raw);
|
||||||
|
// A native MiniApp cannot safely resume an in-flight callback. Keep the
|
||||||
|
// durable call waiting for the newly mounted instance to claim it.
|
||||||
|
if (["routing", "running"].includes(record.status)) record.status = "waiting_for_app";
|
||||||
|
record.updated_at = record.status === raw.status ? record.updated_at : now;
|
||||||
|
this.records.set(record.call_id, record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public replace(records: readonly MiniAppToolCallRecord[]): void {
|
||||||
|
this.records.clear();
|
||||||
|
for (const record of records) if (validRecord(record)) this.records.set(record.call_id, copy(record));
|
||||||
|
}
|
||||||
|
|
||||||
|
private transition(callID: string, now: string, allowed: readonly MiniAppToolStatus[], apply: (record: MiniAppToolCallRecord) => void): MiniAppToolTransition {
|
||||||
|
const record = this.records.get(callID);
|
||||||
|
if (!record) return { disposition: "missing" };
|
||||||
|
if (!allowed.includes(record.status)) return { disposition: "invalid_transition", record: copy(record) };
|
||||||
|
try { apply(record); } catch { return { disposition: "invalid_scope", record: copy(record) }; }
|
||||||
|
record.updated_at = now;
|
||||||
|
return { disposition: "accepted", record: copy(record) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validRecord(value: unknown): value is MiniAppToolCallRecord {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||||
|
const record = value as Partial<MiniAppToolCallRecord>;
|
||||||
|
return typeof record.call_id === "string" && record.call_id.length > 0
|
||||||
|
&& typeof record.tool_id === "string" && record.tool_id.length > 0
|
||||||
|
&& typeof record.inventory_revision === "string" && record.inventory_revision.length > 0
|
||||||
|
&& typeof record.app_scope === "string" && record.app_scope.length > 0
|
||||||
|
&& typeof record.conversation_id === "string" && record.conversation_id.length > 0
|
||||||
|
&& record.input !== undefined && typeof record.input === "object" && !Array.isArray(record.input)
|
||||||
|
&& typeof record.status === "string" && ["received", "routing", "waiting_for_app", "running", "submitted", "completed", "failed", "cancelled", "expired", "rejected"].includes(record.status)
|
||||||
|
&& typeof record.created_at === "string" && !Number.isNaN(Date.parse(record.created_at))
|
||||||
|
&& typeof record.updated_at === "string" && !Number.isNaN(Date.parse(record.updated_at));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clone<T>(value: T): T { return JSON.parse(JSON.stringify(value)) as T; }
|
||||||
|
function copy(record: MiniAppToolCallRecord): MiniAppToolCallRecord { return clone(record); }
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/** Runtime-owned validation for Agent-facing Interact standard interactions. */
|
||||||
|
|
||||||
|
export const DEFAULT_INTERACTION_EXPIRY_MS = 15 * 60 * 1000;
|
||||||
|
export const MIN_INTERACTION_EXPIRY_MS = 60 * 1000;
|
||||||
|
export const MAX_INTERACTION_EXPIRY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
type InteractionBase = Readonly<{ title: string; prompt: string; expires_in_ms?: number }>;
|
||||||
|
|
||||||
|
export type NoticeInteractionRequest = Readonly<{
|
||||||
|
kind: "notice";
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
dismiss_label?: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type ChoiceInteractionRequest = InteractionBase & Readonly<{
|
||||||
|
kind: "choice";
|
||||||
|
mode: "single-choice";
|
||||||
|
actions: readonly Readonly<{ id: string; label: string; description?: string }>[];
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type ConfirmInteractionRequest = InteractionBase & Readonly<{
|
||||||
|
kind: "confirm";
|
||||||
|
approve_label?: string;
|
||||||
|
cancel_label?: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type InputInteractionRequest = InteractionBase & Readonly<{
|
||||||
|
kind: "input";
|
||||||
|
field: Readonly<{
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
type: "text" | "textarea";
|
||||||
|
required?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
max_length?: number;
|
||||||
|
}>;
|
||||||
|
submit_label?: string;
|
||||||
|
cancel_label?: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type StandardInteractionRequest =
|
||||||
|
| NoticeInteractionRequest
|
||||||
|
| ChoiceInteractionRequest
|
||||||
|
| ConfirmInteractionRequest
|
||||||
|
| InputInteractionRequest;
|
||||||
|
|
||||||
|
export type InteractionRequestValidation =
|
||||||
|
| Readonly<{ accepted: true; expires_at?: string }>
|
||||||
|
| Readonly<{ accepted: false; code: "interaction_request_invalid" }>;
|
||||||
|
|
||||||
|
/** Validates a request before it becomes a durable StandardInteractionRecord. */
|
||||||
|
export function validateStandardInteractionRequest(request: StandardInteractionRequest, now: Date): InteractionRequestValidation {
|
||||||
|
if (!validTitle(request.title)) return invalid();
|
||||||
|
if (request.kind === "notice") {
|
||||||
|
if (!validText(request.message) || Object.hasOwn(request, "expires_in_ms")) return invalid();
|
||||||
|
return { accepted: true };
|
||||||
|
}
|
||||||
|
if (!validText(request.prompt)) return invalid();
|
||||||
|
if (request.kind === "choice" && !validChoice(request)) return invalid();
|
||||||
|
if (request.kind === "input" && !validInput(request)) return invalid();
|
||||||
|
|
||||||
|
const duration = request.expires_in_ms ?? DEFAULT_INTERACTION_EXPIRY_MS;
|
||||||
|
if (!Number.isInteger(duration) || duration < MIN_INTERACTION_EXPIRY_MS || duration > MAX_INTERACTION_EXPIRY_MS) return invalid();
|
||||||
|
return { accepted: true, expires_at: new Date(now.getTime() + duration).toISOString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validChoice(request: ChoiceInteractionRequest): boolean {
|
||||||
|
if (request.mode !== "single-choice" || request.actions.length < 2 || request.actions.length > 6) return false;
|
||||||
|
const ids = new Set<string>();
|
||||||
|
return request.actions.every(action => validIdentifier(action.id) && validText(action.label) && !ids.has(action.id) && (ids.add(action.id), true));
|
||||||
|
}
|
||||||
|
|
||||||
|
function validInput(request: InputInteractionRequest): boolean {
|
||||||
|
const { field } = request;
|
||||||
|
return validIdentifier(field.id)
|
||||||
|
&& validText(field.label)
|
||||||
|
&& (field.type === "text" || field.type === "textarea")
|
||||||
|
&& (field.max_length === undefined || (Number.isInteger(field.max_length) && field.max_length > 0 && field.max_length <= 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
function validTitle(value: string): boolean { return validText(value) && value.length <= 200; }
|
||||||
|
function validText(value: string): boolean { return typeof value === "string" && value.trim().length > 0 && value.length <= 4000; }
|
||||||
|
function validIdentifier(value: string): boolean { return typeof value === "string" && /^[A-Za-z0-9._:-]{1,128}$/.test(value); }
|
||||||
|
function invalid(): InteractionRequestValidation { return { accepted: false, code: "interaction_request_invalid" }; }
|
||||||
@@ -17,6 +17,9 @@ export type ToolCallStatus = "pending" | "submitted" | ToolCallFinalStatus;
|
|||||||
export type ToolCallRecord = {
|
export type ToolCallRecord = {
|
||||||
call_id: string;
|
call_id: string;
|
||||||
conversation_id: string;
|
conversation_id: string;
|
||||||
|
/** Runtime binding for ordinary MiniApp Tools; absent for Interact calls. */
|
||||||
|
app_scope?: string;
|
||||||
|
instance_id?: string;
|
||||||
request: ToolCallRequest;
|
request: ToolCallRequest;
|
||||||
status: ToolCallStatus;
|
status: ToolCallStatus;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
|||||||
@@ -13,6 +13,36 @@ function launch(scope = "draw-and-guess", extra: Record<string, unknown> = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("ToolRouter", () => {
|
describe("ToolRouter", () => {
|
||||||
|
it("routes standard Agent interactions to Runtime without selecting a MiniApp target", () => {
|
||||||
|
const apps = new CoreAppRegistry();
|
||||||
|
const router = new ToolRouter(apps);
|
||||||
|
const raw = JSON.stringify({ v: 1, id: "interaction-1", type: "lineup.v1.tool.call", conversation_id: "c1", sender: { kind: "agent", id: "agent" }, payload: {
|
||||||
|
call_id: "interaction-call-1", tool: "confirm", title: "确认", prompt: "继续吗?", data: { confirm: {} },
|
||||||
|
} });
|
||||||
|
expect(router.route(raw, { app_scope: "chat", conversation_id: "c1" })).toEqual({ disposition: "interactive", call_id: "interaction-call-1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes a revisioned ordinary MiniApp Tool to its declared bundled target", () => {
|
||||||
|
const apps = new CoreAppRegistry();
|
||||||
|
apps.install({
|
||||||
|
app_scope: "custom-dashboard", kind: "bundled", enabled: true, default_eligible: false, recovery: false,
|
||||||
|
manifest: { app_scope: "custom-dashboard", version: "1.0.0", permissions: [], tools: [{ name: "task.create", handling: "operation", parameters: [], input_schema: { type: "object", required: ["title"], properties: { title: { type: "string" } }, additionalProperties: false } }] },
|
||||||
|
});
|
||||||
|
const router = new ToolRouter(apps);
|
||||||
|
const raw = JSON.stringify({ v: 1, id: "miniapp-call-1", type: "lineup.v1.miniapp.tool.call", conversation_id: "c1", sender: { kind: "agent", id: "agent" }, payload: { call_id: "miniapp-call-1", tool_id: "task.create", app_scope: "custom-dashboard", inventory_revision: "catalog-1", input: { title: "测试任务" } } });
|
||||||
|
expect(router.route(raw, { app_scope: "chat", conversation_id: "c1", inventory_revision: "catalog-1" })).toEqual(expect.objectContaining({ disposition: "miniapp", handling: "operation", app_scope: "custom-dashboard" }));
|
||||||
|
expect(router.route(raw.replace("catalog-1", "catalog-2"), { app_scope: "chat", conversation_id: "c1", inventory_revision: "catalog-1" })).toEqual({ disposition: "rejected", code: "inventory_revision_mismatch" });
|
||||||
|
expect(router.route(raw.replace('"title":"测试任务"', '"unknown":true'), { app_scope: "chat", conversation_id: "c1", inventory_revision: "catalog-1" })).toEqual({ disposition: "rejected", code: "invalid_request" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retains verified App session context as metadata only", () => {
|
||||||
|
const router = new ToolRouter(new CoreAppRegistry());
|
||||||
|
const raw = JSON.stringify({ v: 1, id: "interaction-2", type: "lineup.v1.tool.call", conversation_id: "c1", sender: { kind: "agent", id: "agent" }, payload: {
|
||||||
|
call_id: "interaction-call-2", tool: "input", title: "描述", prompt: "输入", data: { form: { fields: [{ id: "text", label: "描述", type: "textarea" }] } }, app_session_context: { app_session_id: "whiteboard:1" },
|
||||||
|
} });
|
||||||
|
expect(router.route(raw, { app_scope: "chat", conversation_id: "c1" })).toEqual({ disposition: "interactive", call_id: "interaction-call-2", app_session_id: "whiteboard:1" });
|
||||||
|
});
|
||||||
|
|
||||||
it("accepts only a manifest-authorized installed extension launch", () => {
|
it("accepts only a manifest-authorized installed extension launch", () => {
|
||||||
const apps = new CoreAppRegistry(); apps.install(extension());
|
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" });
|
expect(new ToolRouter(apps).route(launch(), { app_scope: "chat", conversation_id: "c1" })).toMatchObject({ disposition: "launch", app_scope: "draw-and-guess", call_id: "call-1" });
|
||||||
|
|||||||
@@ -4,26 +4,63 @@
|
|||||||
* routed result and can never turn an arbitrary app.call into a launch.
|
* 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 type { AppManifest, AppScope, CoreAppRegistry } from "@/runtime/app-management/app-registry";
|
||||||
import { parseEnvelopeJSON, type Envelope, type JsonObject } from "@/runtime/protocol/lineup-v1";
|
import { parseEnvelopeJSON, type Envelope, type JsonObject, type MiniAppToolInvoke } from "@/runtime/protocol/lineup-v1";
|
||||||
|
import { validateMiniAppToolSchema } from "@/runtime/coordination/miniapp-tool-schema";
|
||||||
|
|
||||||
export type ToolRoute =
|
export type ToolRoute =
|
||||||
| { disposition: "pass" }
|
| { disposition: "pass" }
|
||||||
|
| { disposition: "interactive"; call_id: string; app_session_id?: string }
|
||||||
|
| { disposition: "dismiss"; call_id: string; control_id: string; reason?: string }
|
||||||
|
| { disposition: "miniapp"; call: MiniAppToolInvoke; handling: "direct" | "launch" | "foreground" | "operation"; app_scope: AppScope; instance_id?: string; requires_foreground: boolean; restore_previous_focus: boolean }
|
||||||
| { disposition: "launch"; call_id: string; app_scope: AppScope; instance_id?: string; arguments: JsonObject }
|
| { disposition: "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" };
|
| { disposition: "rejected"; code: "invalid_request" | "scope_mismatch" | "app_session_context_invalid" | "inventory_revision_mismatch" | "not_installed" | "disabled" | "manifest_denied" | "permission_denied" };
|
||||||
|
|
||||||
export class ToolRouter {
|
export class ToolRouter {
|
||||||
public constructor(private readonly apps: CoreAppRegistry) {}
|
public constructor(private readonly apps: CoreAppRegistry) {}
|
||||||
|
|
||||||
/** Returns pass for ordinary protocol messages, preserving 00.base handling. */
|
/** Returns pass for ordinary protocol messages, preserving 00.base handling. */
|
||||||
public route(raw: string, expected: { conversation_id: string; app_scope: AppScope }): ToolRoute {
|
public route(raw: string, expected: { conversation_id: string; app_scope: AppScope; inventory_revision?: string }): ToolRoute {
|
||||||
const parsed = parseEnvelopeJSON(raw);
|
const parsed = parseEnvelopeJSON(raw);
|
||||||
if (!parsed.ok) return { disposition: "pass" };
|
if (!parsed.ok) return { disposition: "pass" };
|
||||||
const envelope = parsed.envelope;
|
const envelope = parsed.envelope;
|
||||||
|
if (envelope.type === "lineup.v1.interaction.dismiss") return this.routeDismiss(envelope, expected.conversation_id);
|
||||||
|
if (envelope.type === "lineup.v1.miniapp.tool.call") return this.routeMiniApp(envelope, expected);
|
||||||
|
if (envelope.type === "lineup.v1.tool.call") return this.routeInteractive(envelope, expected.conversation_id);
|
||||||
if (envelope.type !== "lineup.v1.app.call" || envelope.payload.capability !== "runtime.launch_app") return { disposition: "pass" };
|
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" };
|
if (envelope.conversation_id !== expected.conversation_id) return { disposition: "rejected", code: "scope_mismatch" };
|
||||||
return this.routeLaunch(envelope);
|
return this.routeLaunch(envelope);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private routeMiniApp(envelope: Envelope, expected: { conversation_id: string; inventory_revision?: string }): ToolRoute {
|
||||||
|
if (envelope.conversation_id !== expected.conversation_id) return { disposition: "rejected", code: "scope_mismatch" };
|
||||||
|
const callID = text(envelope.payload.call_id);
|
||||||
|
const toolID = text(envelope.payload.tool_id);
|
||||||
|
const appScope = text(envelope.payload.app_scope);
|
||||||
|
const revision = text(envelope.payload.inventory_revision);
|
||||||
|
const input = object(envelope.payload.input);
|
||||||
|
const instanceID = envelope.payload.instance_id === undefined ? undefined : text(envelope.payload.instance_id);
|
||||||
|
if (!callID || !toolID || !appScope || !revision || !input || (envelope.payload.instance_id !== undefined && !instanceID)) return { disposition: "rejected", code: "invalid_request" };
|
||||||
|
if (expected.inventory_revision && revision !== expected.inventory_revision) return { disposition: "rejected", code: "inventory_revision_mismatch" };
|
||||||
|
const app = this.apps.get(appScope);
|
||||||
|
if (!app) return { disposition: "rejected", code: "not_installed" };
|
||||||
|
if (!app.enabled) return { disposition: "rejected", code: "disabled" };
|
||||||
|
const definition = app.manifest.tools.find(tool => tool.name === toolID);
|
||||||
|
if (!definition || definition.handling === "interactive") return { disposition: "rejected", code: "manifest_denied" };
|
||||||
|
if (!validateMiniAppToolSchema(input, definition.input_schema)) return { disposition: "rejected", code: "invalid_request" };
|
||||||
|
if (definition.requires_permission && !app.manifest.permissions.includes(definition.requires_permission)) return { disposition: "rejected", code: "permission_denied" };
|
||||||
|
return { disposition: "miniapp", call: { call_id: callID, tool_id: toolID, app_scope: appScope, inventory_revision: revision, input, ...(instanceID ? { instance_id: instanceID } : {}) }, handling: definition.handling, app_scope: appScope, ...(instanceID ? { instance_id: instanceID } : {}), requires_foreground: definition.target?.requires_foreground ?? true, restore_previous_focus: definition.target?.restore_previous_focus ?? false };
|
||||||
|
}
|
||||||
|
|
||||||
|
private routeDismiss(envelope: Envelope, expectedConversationID: string): ToolRoute {
|
||||||
|
if (envelope.conversation_id !== expectedConversationID) return { disposition: "rejected", code: "scope_mismatch" };
|
||||||
|
const controlID = text(envelope.payload.control_id);
|
||||||
|
const callID = text(envelope.payload.call_id);
|
||||||
|
if (!controlID || !callID || Object.keys(envelope.payload).some(key => key !== "control_id" && key !== "call_id" && key !== "reason")) return { disposition: "rejected", code: "invalid_request" };
|
||||||
|
const reason = envelope.payload.reason === undefined ? undefined : text(envelope.payload.reason);
|
||||||
|
if (envelope.payload.reason !== undefined && !reason) return { disposition: "rejected", code: "invalid_request" };
|
||||||
|
return { disposition: "dismiss", call_id: callID, control_id: controlID, ...(reason ? { reason } : {}) };
|
||||||
|
}
|
||||||
|
|
||||||
private routeLaunch(envelope: Envelope): ToolRoute {
|
private routeLaunch(envelope: Envelope): ToolRoute {
|
||||||
const callID = text(envelope.payload.call_id);
|
const callID = text(envelope.payload.call_id);
|
||||||
const args = object(envelope.payload.arguments);
|
const args = object(envelope.payload.arguments);
|
||||||
@@ -32,7 +69,7 @@ export class ToolRouter {
|
|||||||
const record = this.apps.get(requestedScope);
|
const record = this.apps.get(requestedScope);
|
||||||
if (!record) return { disposition: "rejected", code: "not_installed" };
|
if (!record) return { disposition: "rejected", code: "not_installed" };
|
||||||
if (!record.enabled) return { disposition: "rejected", code: "disabled" };
|
if (!record.enabled) return { disposition: "rejected", code: "disabled" };
|
||||||
const definition = record.manifest.tools.find(tool => tool.name === "app.launch" && tool.handling === "launch");
|
const definition = record.manifest.tools.find(tool => tool.handling === "launch");
|
||||||
if (!definition) return { disposition: "rejected", code: "manifest_denied" };
|
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" };
|
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);
|
const instanceID = args.instance_id === undefined ? undefined : text(args.instance_id);
|
||||||
@@ -41,6 +78,18 @@ export class ToolRouter {
|
|||||||
if (!parameters) return { disposition: "rejected", code: "invalid_request" };
|
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 };
|
return { disposition: "launch", call_id: callID, app_scope: record.app_scope, ...(instanceID ? { instance_id: instanceID } : {}), arguments: parameters };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private routeInteractive(envelope: Envelope, expectedConversationID: string): ToolRoute {
|
||||||
|
if (envelope.conversation_id !== expectedConversationID) return { disposition: "rejected", code: "scope_mismatch" };
|
||||||
|
const tool = text(envelope.payload.tool);
|
||||||
|
if (tool !== "notice" && tool !== "choice" && tool !== "confirm" && tool !== "input") return { disposition: "pass" };
|
||||||
|
const callID = text(envelope.payload.call_id);
|
||||||
|
if (!callID) return { disposition: "rejected", code: "invalid_request" };
|
||||||
|
const context = envelope.payload.app_session_context;
|
||||||
|
if (context !== undefined && (!object(context) || !text(object(context)?.app_session_id))) return { disposition: "rejected", code: "invalid_request" };
|
||||||
|
const appSessionID = object(context) ? text(object(context)?.app_session_id) : undefined;
|
||||||
|
return { disposition: "interactive", call_id: callID, ...(appSessionID ? { app_session_id: appSessionID } : {}) };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function object(value: unknown): JsonObject | undefined { return value && typeof value === "object" && !Array.isArray(value) ? value as JsonObject : undefined; }
|
function object(value: unknown): JsonObject | undefined { return value && typeof value === "object" && !Array.isArray(value) ? value as JsonObject : undefined; }
|
||||||
|
|||||||
@@ -6,11 +6,11 @@
|
|||||||
*/
|
*/
|
||||||
import type { CapabilityDefinition } from "@/runtime/capabilities/capability-registry";
|
import type { CapabilityDefinition } from "@/runtime/capabilities/capability-registry";
|
||||||
import type { SurfaceRegistrySnapshot } from "@/runtime/surfaces/surface-registry";
|
import type { SurfaceRegistrySnapshot } from "@/runtime/surfaces/surface-registry";
|
||||||
import type { CoreAppRecord } from "@/runtime/app-management/app-registry";
|
import type { CoreAppRecordInput } from "@/runtime/app-management/app-registry";
|
||||||
|
|
||||||
export type ClientInventory = Readonly<{
|
export type ClientInventory = Readonly<{
|
||||||
revision: string;
|
revision: string;
|
||||||
standard_components: readonly Readonly<{ id: "choice" | "confirm" | "input"; version: "1" }>[];
|
standard_components: readonly Readonly<{ id: "notice" | "choice" | "confirm" | "input"; version: "1" }>[];
|
||||||
applications: readonly Readonly<{
|
applications: readonly Readonly<{
|
||||||
id: string;
|
id: string;
|
||||||
version: string;
|
version: string;
|
||||||
@@ -31,7 +31,7 @@ export class ClientInventoryPublisher {
|
|||||||
private previousFingerprint = "";
|
private previousFingerprint = "";
|
||||||
private current: ClientInventory | undefined;
|
private current: ClientInventory | undefined;
|
||||||
|
|
||||||
public sync(surfaces: SurfaceRegistrySnapshot, capabilities: readonly CapabilityDefinition[], apps: readonly CoreAppRecord[] = []): { changed: boolean; inventory: ClientInventory } {
|
public sync(surfaces: SurfaceRegistrySnapshot, capabilities: readonly CapabilityDefinition[], apps: readonly CoreAppRecordInput[] = []): { changed: boolean; inventory: ClientInventory } {
|
||||||
const surfaceApplications = surfaces.enabled
|
const surfaceApplications = surfaces.enabled
|
||||||
.map(enabled => ({ id: enabled.app_id, version: enabled.version, surfaces: [{ id: "task-dashboard" as const, events: ["cancel"] as ["cancel"] }] }))
|
.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 => ({
|
const manifestApplications = apps.filter(app => app.enabled).map(app => ({
|
||||||
@@ -50,6 +50,7 @@ export class ClientInventoryPublisher {
|
|||||||
this.current = {
|
this.current = {
|
||||||
revision: `catalog-${this.revision}`,
|
revision: `catalog-${this.revision}`,
|
||||||
standard_components: [
|
standard_components: [
|
||||||
|
{ id: "notice", version: "1" },
|
||||||
{ id: "choice", version: "1" },
|
{ id: "choice", version: "1" },
|
||||||
{ id: "confirm", version: "1" },
|
{ id: "confirm", version: "1" },
|
||||||
{ id: "input", version: "1" },
|
{ id: "input", version: "1" },
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { ToolCallRecord } from "@/runtime/coordination/tool-call-state";
|
import type { ToolCallRecord } from "@/runtime/coordination/tool-call-state";
|
||||||
|
import type { MiniAppToolCallRecord } from "@/runtime/coordination/miniapp-tool-state";
|
||||||
import type { TaskRecord } from "@/runtime/coordination/task-state";
|
import type { TaskRecord } from "@/runtime/coordination/task-state";
|
||||||
import { ConversationStore } from "@/runtime/persistence/conversation-store";
|
import { ConversationStore } from "@/runtime/persistence/conversation-store";
|
||||||
|
|
||||||
@@ -106,7 +107,7 @@ describe("ConversationStore", () => {
|
|||||||
expect(snapshot.tool_drafts).toEqual({ preserved: { value: "yes" } });
|
expect(snapshot.tool_drafts).toEqual({ preserved: { value: "yes" } });
|
||||||
expect(snapshot.tasks).toEqual([expect.objectContaining({ operation_id: "task" })]);
|
expect(snapshot.tasks).toEqual([expect.objectContaining({ operation_id: "task" })]);
|
||||||
expect(snapshot.app_inbox).toEqual([]);
|
expect(snapshot.app_inbox).toEqual([]);
|
||||||
expect(storage.getItem(key)).toContain('"version":12');
|
expect(storage.getItem(key)).toContain('"version":14');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("removes legacy Artifact content bytes from memory and durable storage on open", () => {
|
it("removes legacy Artifact content bytes from memory and durable storage on open", () => {
|
||||||
@@ -238,6 +239,19 @@ describe("ConversationStore", () => {
|
|||||||
expect(JSON.stringify(snapshot.execution_summaries)).not.toContain("detail");
|
expect(JSON.stringify(snapshot.execution_summaries)).not.toContain("detail");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("persists ordinary MiniApp Tool state separately from Interact Tool calls", () => {
|
||||||
|
const storage = new MemoryStorage();
|
||||||
|
const conversationID = "user_test:2:agent_channel";
|
||||||
|
const record: MiniAppToolCallRecord = {
|
||||||
|
call_id: "miniapp_001", tool_id: "task.create", inventory_revision: "catalog-1", app_scope: "task-dashboard", instance_id: "task:instance", conversation_id: conversationID,
|
||||||
|
status: "submitted", input: { title: "任务" }, result: { task_id: "task-1" }, created_at: "2026-08-04T00:00:00.000Z", updated_at: "2026-08-04T00:00:01.000Z", submitted_at: "2026-08-04T00:00:01.000Z",
|
||||||
|
};
|
||||||
|
const first = new ConversationStore(storage);
|
||||||
|
first.open(conversationID);
|
||||||
|
first.replaceMiniAppToolCalls([record]);
|
||||||
|
expect(new ConversationStore(storage).open(conversationID).miniapp_tool_calls).toEqual([expect.objectContaining({ call_id: "miniapp_001", status: "submitted", app_scope: "task-dashboard" })]);
|
||||||
|
});
|
||||||
|
|
||||||
it("drops untrusted extra fields from persisted execution summaries", () => {
|
it("drops untrusted extra fields from persisted execution summaries", () => {
|
||||||
const storage = new MemoryStorage();
|
const storage = new MemoryStorage();
|
||||||
const store = new ConversationStore(storage);
|
const store = new ConversationStore(storage);
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import type { SurfaceInstanceSnapshot } from "@/runtime/surfaces/surface-instanc
|
|||||||
import { sanitizeCapabilityReason, type CapabilityCallRecord } from "@/runtime/capabilities/capability-call-state";
|
import { sanitizeCapabilityReason, type CapabilityCallRecord } from "@/runtime/capabilities/capability-call-state";
|
||||||
import type { AppScope } from "@/runtime/app-management/app-registry";
|
import type { AppScope } from "@/runtime/app-management/app-registry";
|
||||||
import type { AppWorkspaceSnapshot } from "@/runtime/app-management/app-lifecycle-manager";
|
import type { AppWorkspaceSnapshot } from "@/runtime/app-management/app-lifecycle-manager";
|
||||||
|
import type { StandardInteractionRecord } from "@/runtime/coordination/agent-interaction-service";
|
||||||
|
import type { MiniAppToolCallRecord } from "@/runtime/coordination/miniapp-tool-state";
|
||||||
|
|
||||||
export type StoredConversationItem = {
|
export type StoredConversationItem = {
|
||||||
local_id: string;
|
local_id: string;
|
||||||
@@ -38,6 +40,7 @@ export type AppInboxEntry = {
|
|||||||
item: ConversationItem;
|
item: ConversationItem;
|
||||||
received_at: string;
|
received_at: string;
|
||||||
message_seq?: number;
|
message_seq?: number;
|
||||||
|
instance_id?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ConversationSnapshot = {
|
export type ConversationSnapshot = {
|
||||||
@@ -53,12 +56,14 @@ export type ConversationSnapshot = {
|
|||||||
surfaces: SurfaceInstanceSnapshot;
|
surfaces: SurfaceInstanceSnapshot;
|
||||||
capability_calls: readonly CapabilityCallRecord[];
|
capability_calls: readonly CapabilityCallRecord[];
|
||||||
app_inbox: readonly AppInboxEntry[];
|
app_inbox: readonly AppInboxEntry[];
|
||||||
|
standard_interactions: readonly StandardInteractionRecord[];
|
||||||
|
miniapp_tool_calls: readonly MiniAppToolCallRecord[];
|
||||||
/** Runtime-owned instance/focus state. Never supplied by an App. */
|
/** Runtime-owned instance/focus state. Never supplied by an App. */
|
||||||
app_workspace: AppWorkspaceSnapshot;
|
app_workspace: AppWorkspaceSnapshot;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PersistedConversation = {
|
type PersistedConversation = {
|
||||||
version: 12;
|
version: 14;
|
||||||
cursor: number;
|
cursor: number;
|
||||||
items: StoredConversationItem[];
|
items: StoredConversationItem[];
|
||||||
outbox: OutboxEntry[];
|
outbox: OutboxEntry[];
|
||||||
@@ -69,6 +74,8 @@ type PersistedConversation = {
|
|||||||
surfaces: SurfaceInstanceSnapshot;
|
surfaces: SurfaceInstanceSnapshot;
|
||||||
capability_calls: CapabilityCallRecord[];
|
capability_calls: CapabilityCallRecord[];
|
||||||
app_inbox: AppInboxEntry[];
|
app_inbox: AppInboxEntry[];
|
||||||
|
standard_interactions: StandardInteractionRecord[];
|
||||||
|
miniapp_tool_calls: MiniAppToolCallRecord[];
|
||||||
app_workspace: AppWorkspaceSnapshot;
|
app_workspace: AppWorkspaceSnapshot;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -116,6 +123,8 @@ export class ConversationStore {
|
|||||||
surfaces: clone(this.state.surfaces),
|
surfaces: clone(this.state.surfaces),
|
||||||
capability_calls: clone(this.state.capability_calls),
|
capability_calls: clone(this.state.capability_calls),
|
||||||
app_inbox: this.state.app_inbox.map(entry => ({ ...entry })),
|
app_inbox: this.state.app_inbox.map(entry => ({ ...entry })),
|
||||||
|
standard_interactions: clone(this.state.standard_interactions),
|
||||||
|
miniapp_tool_calls: clone(this.state.miniapp_tool_calls),
|
||||||
app_workspace: clone(this.state.app_workspace),
|
app_workspace: clone(this.state.app_workspace),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -177,20 +186,33 @@ export class ConversationStore {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public listAppInbox(appScope: AppScope): readonly AppInboxEntry[] {
|
public listAppInbox(appScope: AppScope, instanceID?: string): readonly AppInboxEntry[] {
|
||||||
return this.state.app_inbox
|
return this.state.app_inbox
|
||||||
.filter(entry => entry.app_scope === appScope)
|
.filter(entry => entry.app_scope === appScope && (instanceID === undefined ? true : entry.instance_id === instanceID))
|
||||||
.map(entry => ({ ...entry }));
|
.map(entry => ({ ...entry }));
|
||||||
}
|
}
|
||||||
|
|
||||||
public acknowledgeAppInbox(appScope: AppScope, messageID: string): boolean {
|
public acknowledgeAppInbox(appScope: AppScope, messageID: string, instanceID?: string): boolean {
|
||||||
const before = this.state.app_inbox.length;
|
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));
|
this.state.app_inbox = this.state.app_inbox.filter(entry => !(entry.app_scope === appScope && entry.message_id === messageID
|
||||||
|
&& (instanceID === undefined ? true : entry.instance_id === instanceID)));
|
||||||
if (this.state.app_inbox.length === before) return false;
|
if (this.state.app_inbox.length === before) return false;
|
||||||
this.persist();
|
this.persist();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Replaces the Runtime-owned Agent/Interact interaction projection. */
|
||||||
|
public replaceStandardInteractions(records: readonly StandardInteractionRecord[]): void {
|
||||||
|
this.state.standard_interactions = records.map(record => clone(record));
|
||||||
|
this.persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replaces the Runtime-owned ordinary MiniApp Tool projection. */
|
||||||
|
public replaceMiniAppToolCalls(records: readonly MiniAppToolCallRecord[]): void {
|
||||||
|
this.state.miniapp_tool_calls = records.map(record => clone(record));
|
||||||
|
this.persist();
|
||||||
|
}
|
||||||
|
|
||||||
public appendLocalText(localID: string, text: string, createdAt: string): StoredConversationItem {
|
public appendLocalText(localID: string, text: string, createdAt: string): StoredConversationItem {
|
||||||
const delivery: DeliveryState = { status: "local_pending", updated_at: createdAt };
|
const delivery: DeliveryState = { status: "local_pending", updated_at: createdAt };
|
||||||
const stored: StoredConversationItem = {
|
const stored: StoredConversationItem = {
|
||||||
@@ -302,8 +324,8 @@ export class ConversationStore {
|
|||||||
try {
|
try {
|
||||||
const parsed: unknown = JSON.parse(this.storage.getItem(this.key(conversationID)) ?? "null");
|
const parsed: unknown = JSON.parse(this.storage.getItem(this.key(conversationID)) ?? "null");
|
||||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return emptyConversation();
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return emptyConversation();
|
||||||
const candidate = parsed as { version?: unknown; cursor?: unknown; items?: unknown; outbox?: unknown; tool_calls?: unknown; tool_drafts?: unknown; tasks?: unknown; execution_summaries?: unknown; surfaces?: unknown; capability_calls?: unknown; app_inbox?: unknown; app_workspace?: unknown };
|
const candidate = parsed as { version?: unknown; cursor?: unknown; items?: unknown; outbox?: unknown; tool_calls?: unknown; tool_drafts?: unknown; tasks?: unknown; execution_summaries?: unknown; surfaces?: unknown; capability_calls?: unknown; app_inbox?: unknown; standard_interactions?: unknown; miniapp_tool_calls?: unknown; app_workspace?: unknown };
|
||||||
if ((candidate.version !== 1 && candidate.version !== 2 && candidate.version !== 3 && candidate.version !== 4 && candidate.version !== 5 && candidate.version !== 6 && candidate.version !== 7 && candidate.version !== 8 && candidate.version !== 9 && candidate.version !== 10 && candidate.version !== 11 && candidate.version !== 12) || !Array.isArray(candidate.items) || !Array.isArray(candidate.outbox) || typeof candidate.cursor !== "number") {
|
if ((candidate.version !== 1 && candidate.version !== 2 && candidate.version !== 3 && candidate.version !== 4 && candidate.version !== 5 && candidate.version !== 6 && candidate.version !== 7 && candidate.version !== 8 && candidate.version !== 9 && candidate.version !== 10 && candidate.version !== 11 && candidate.version !== 12 && candidate.version !== 13 && candidate.version !== 14) || !Array.isArray(candidate.items) || !Array.isArray(candidate.outbox) || typeof candidate.cursor !== "number") {
|
||||||
return emptyConversation();
|
return emptyConversation();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,7 +334,7 @@ export class ConversationStore {
|
|||||||
// the Runtime-owned App Inbox without discarding any already durable
|
// the Runtime-owned App Inbox without discarding any already durable
|
||||||
// tool, task, Surface, capability, or execution projection.
|
// tool, task, Surface, capability, or execution projection.
|
||||||
return {
|
return {
|
||||||
version: 12,
|
version: 14,
|
||||||
cursor: Math.max(0, candidate.cursor),
|
cursor: Math.max(0, candidate.cursor),
|
||||||
items: normalizeStoredItems(candidate.items),
|
items: normalizeStoredItems(candidate.items),
|
||||||
outbox: normalizeOutbox(candidate.outbox),
|
outbox: normalizeOutbox(candidate.outbox),
|
||||||
@@ -323,18 +345,20 @@ export class ConversationStore {
|
|||||||
surfaces: normalizeSurfaces(candidate.surfaces),
|
surfaces: normalizeSurfaces(candidate.surfaces),
|
||||||
capability_calls: normalizeCapabilityCalls(candidate.capability_calls),
|
capability_calls: normalizeCapabilityCalls(candidate.capability_calls),
|
||||||
app_inbox: normalizeAppInbox(candidate.app_inbox, conversationID),
|
app_inbox: normalizeAppInbox(candidate.app_inbox, conversationID),
|
||||||
|
standard_interactions: [],
|
||||||
|
miniapp_tool_calls: [],
|
||||||
app_workspace: emptyAppWorkspace(),
|
app_workspace: emptyAppWorkspace(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (candidate.version !== 12) {
|
if (candidate.version !== 12 && candidate.version !== 13 && candidate.version !== 14) {
|
||||||
// v1 advanced its cursor from a send acknowledgement. Some browsers
|
// v1 advanced its cursor from a send acknowledgement. Some browsers
|
||||||
// had already been upgraded to v2 before that correction, retaining a
|
// had already been upgraded to v2 before that correction, retaining a
|
||||||
// high cursor with holes for their own echoes. Preserve every valid
|
// high cursor with holes for their own echoes. Preserve every valid
|
||||||
// local item but replay the server history once to repair those holes;
|
// local item but replay the server history once to repair those holes;
|
||||||
// the next persist upgrades this record to v5.
|
// the next persist upgrades this record to v5.
|
||||||
return {
|
return {
|
||||||
version: 12,
|
version: 14,
|
||||||
cursor: candidate.version === 4 || candidate.version === 5 || candidate.version === 6 || candidate.version === 7 ? Math.max(0, candidate.cursor) : 0,
|
cursor: candidate.version === 4 || candidate.version === 5 || candidate.version === 6 || candidate.version === 7 ? Math.max(0, candidate.cursor) : 0,
|
||||||
items: normalizeStoredItems(candidate.items),
|
items: normalizeStoredItems(candidate.items),
|
||||||
outbox: normalizeOutbox(candidate.outbox),
|
outbox: normalizeOutbox(candidate.outbox),
|
||||||
@@ -346,11 +370,13 @@ export class ConversationStore {
|
|||||||
surfaces: emptySurfaces(),
|
surfaces: emptySurfaces(),
|
||||||
capability_calls: [],
|
capability_calls: [],
|
||||||
app_inbox: [],
|
app_inbox: [],
|
||||||
|
standard_interactions: [],
|
||||||
|
miniapp_tool_calls: [],
|
||||||
app_workspace: emptyAppWorkspace(),
|
app_workspace: emptyAppWorkspace(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
version: 12,
|
version: 14,
|
||||||
cursor: Math.max(0, candidate.cursor),
|
cursor: Math.max(0, candidate.cursor),
|
||||||
items: normalizeStoredItems(candidate.items),
|
items: normalizeStoredItems(candidate.items),
|
||||||
outbox: normalizeOutbox(candidate.outbox),
|
outbox: normalizeOutbox(candidate.outbox),
|
||||||
@@ -361,6 +387,8 @@ export class ConversationStore {
|
|||||||
surfaces: normalizeSurfaces(candidate.surfaces),
|
surfaces: normalizeSurfaces(candidate.surfaces),
|
||||||
capability_calls: normalizeCapabilityCalls(candidate.capability_calls),
|
capability_calls: normalizeCapabilityCalls(candidate.capability_calls),
|
||||||
app_inbox: normalizeAppInbox(candidate.app_inbox, conversationID),
|
app_inbox: normalizeAppInbox(candidate.app_inbox, conversationID),
|
||||||
|
standard_interactions: normalizeStandardInteractions(candidate.standard_interactions, conversationID),
|
||||||
|
miniapp_tool_calls: normalizeMiniAppToolCalls(candidate.miniapp_tool_calls, conversationID),
|
||||||
app_workspace: normalizeAppWorkspace(candidate.app_workspace),
|
app_workspace: normalizeAppWorkspace(candidate.app_workspace),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
@@ -383,7 +411,7 @@ export class ConversationStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function emptyConversation(): PersistedConversation {
|
function emptyConversation(): PersistedConversation {
|
||||||
return { version: 12, cursor: 0, items: [], outbox: [], tool_calls: [], tool_drafts: {}, tasks: [], execution_summaries: [], surfaces: emptySurfaces(), capability_calls: [], app_inbox: [], app_workspace: emptyAppWorkspace() };
|
return { version: 14, cursor: 0, items: [], outbox: [], tool_calls: [], tool_drafts: {}, tasks: [], execution_summaries: [], surfaces: emptySurfaces(), capability_calls: [], app_inbox: [], standard_interactions: [], miniapp_tool_calls: [], app_workspace: emptyAppWorkspace() };
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptySurfaces(): SurfaceInstanceSnapshot { return { version: 1, instances: [] }; }
|
function emptySurfaces(): SurfaceInstanceSnapshot { return { version: 1, instances: [] }; }
|
||||||
@@ -424,10 +452,45 @@ function normalizeAppInbox(value: unknown, conversationID: string): AppInboxEntr
|
|||||||
item: entry.item as ConversationItem,
|
item: entry.item as ConversationItem,
|
||||||
received_at: entry.received_at,
|
received_at: entry.received_at,
|
||||||
...(typeof entry.message_seq === "number" ? { message_seq: entry.message_seq } : {}),
|
...(typeof entry.message_seq === "number" ? { message_seq: entry.message_seq } : {}),
|
||||||
|
...(typeof entry.instance_id === "string" ? { instance_id: entry.instance_id } : {}),
|
||||||
}];
|
}];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeStandardInteractions(value: unknown, conversationID: string): StandardInteractionRecord[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return value.flatMap(raw => {
|
||||||
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return [];
|
||||||
|
const record = raw as Partial<StandardInteractionRecord>;
|
||||||
|
if (typeof record.interaction_id !== "string" || !record.interaction_id || seen.has(record.interaction_id)
|
||||||
|
|| typeof record.call_id !== "string" || typeof record.agent_id !== "string"
|
||||||
|
|| record.conversation_id !== conversationID || typeof record.interact_instance_id !== "string"
|
||||||
|
|| typeof record.kind !== "string" || !record.request || typeof record.request !== "object"
|
||||||
|
|| typeof record.status !== "string" || typeof record.created_at !== "string" || Number.isNaN(Date.parse(record.created_at))
|
||||||
|
|| typeof record.updated_at !== "string" || Number.isNaN(Date.parse(record.updated_at))) return [];
|
||||||
|
seen.add(record.interaction_id);
|
||||||
|
return [clone(record as StandardInteractionRecord)];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMiniAppToolCalls(value: unknown, conversationID: string): MiniAppToolCallRecord[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return value.flatMap(raw => {
|
||||||
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return [];
|
||||||
|
const record = raw as Partial<MiniAppToolCallRecord>;
|
||||||
|
if (typeof record.call_id !== "string" || !record.call_id || seen.has(record.call_id)
|
||||||
|
|| typeof record.tool_id !== "string" || typeof record.inventory_revision !== "string"
|
||||||
|
|| typeof record.app_scope !== "string" || record.conversation_id !== conversationID
|
||||||
|
|| !record.input || typeof record.input !== "object" || Array.isArray(record.input)
|
||||||
|
|| typeof record.status !== "string" || typeof record.created_at !== "string" || Number.isNaN(Date.parse(record.created_at))
|
||||||
|
|| typeof record.updated_at !== "string" || Number.isNaN(Date.parse(record.updated_at))) return [];
|
||||||
|
seen.add(record.call_id);
|
||||||
|
return [clone(record as MiniAppToolCallRecord)];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Artifact content is delivery-only data for the host-owned volatile cache.
|
* Artifact content is delivery-only data for the host-owned volatile cache.
|
||||||
* It must never survive in the durable conversation transcript, including
|
* It must never survive in the durable conversation transcript, including
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export type DeliveryState =
|
|||||||
| { status: "failed"; updated_at: string; error: string; retryable: boolean };
|
| { status: "failed"; updated_at: string; error: string; retryable: boolean };
|
||||||
|
|
||||||
/** The only standard interaction requests admitted during Milestone 1. */
|
/** The only standard interaction requests admitted during Milestone 1. */
|
||||||
export type ToolCallKind = "choice" | "confirm" | "input";
|
export type ToolCallKind = "notice" | "choice" | "confirm" | "input";
|
||||||
|
|
||||||
export type ActionGroupMode = "single-choice" | "multi-choice" | "button";
|
export type ActionGroupMode = "single-choice" | "multi-choice" | "button";
|
||||||
|
|
||||||
@@ -63,6 +63,11 @@ export type ConfirmDefinition = {
|
|||||||
cancel_label: string;
|
cancel_label: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type NoticeDefinition = {
|
||||||
|
message: string;
|
||||||
|
dismiss_label?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type InputFieldType = "text" | "textarea" | "number";
|
export type InputFieldType = "text" | "textarea" | "number";
|
||||||
|
|
||||||
export type InputField = {
|
export type InputField = {
|
||||||
@@ -91,12 +96,24 @@ export type ToolCallRequest = {
|
|||||||
data: JsonObject;
|
data: JsonObject;
|
||||||
/** Present only for the trusted `choice` renderer introduced in M1-02. */
|
/** Present only for the trusted `choice` renderer introduced in M1-02. */
|
||||||
action_group?: ActionGroup;
|
action_group?: ActionGroup;
|
||||||
|
/** Present only for the trusted `notice` renderer introduced in SDK v1. */
|
||||||
|
notice?: NoticeDefinition;
|
||||||
/** Present only for the trusted `confirm` renderer introduced in M1-03. */
|
/** Present only for the trusted `confirm` renderer introduced in M1-03. */
|
||||||
confirm?: ConfirmDefinition;
|
confirm?: ConfirmDefinition;
|
||||||
/** Present only for the trusted `input` renderer introduced in M1-03. */
|
/** Present only for the trusted `input` renderer introduced in M1-03. */
|
||||||
form?: InputForm;
|
form?: InputForm;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** A non-interactive Tool invocation delivered to a bundled MiniApp. */
|
||||||
|
export type MiniAppToolInvoke = {
|
||||||
|
call_id: string;
|
||||||
|
tool_id: string;
|
||||||
|
app_scope: string;
|
||||||
|
inventory_revision: string;
|
||||||
|
input: JsonObject;
|
||||||
|
instance_id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ToolCallFinalStatus = "completed" | "failed" | "cancelled" | "expired";
|
export type ToolCallFinalStatus = "completed" | "failed" | "cancelled" | "expired";
|
||||||
|
|
||||||
export type ToolCallResult = {
|
export type ToolCallResult = {
|
||||||
@@ -195,6 +212,7 @@ export type ConversationItem =
|
|||||||
| (ConversationItemBase & { kind: "progress"; title: string; percent: number; status: string; task?: TaskProgress })
|
| (ConversationItemBase & { kind: "progress"; title: string; percent: number; status: string; task?: TaskProgress })
|
||||||
| (ConversationItemBase & { kind: "error"; message: string; code?: string })
|
| (ConversationItemBase & { kind: "error"; message: string; code?: string })
|
||||||
| (ConversationItemBase & { kind: "tool-call"; call: ToolCallRequest })
|
| (ConversationItemBase & { kind: "tool-call"; call: ToolCallRequest })
|
||||||
|
| (ConversationItemBase & { kind: "miniapp-tool-call"; call: MiniAppToolInvoke })
|
||||||
| (ConversationItemBase & { kind: "tool-result"; result: ToolCallResult })
|
| (ConversationItemBase & { kind: "tool-result"; result: ToolCallResult })
|
||||||
| (ConversationItemBase & { kind: "tool-cancel"; cancel: ToolCallCancel })
|
| (ConversationItemBase & { kind: "tool-cancel"; cancel: ToolCallCancel })
|
||||||
| (ConversationItemBase & { kind: "surface"; envelope: Envelope })
|
| (ConversationItemBase & { kind: "surface"; envelope: Envelope })
|
||||||
@@ -214,6 +232,7 @@ const SUPPORTED_TYPES = new Set([
|
|||||||
"lineup.v1.agent.progress",
|
"lineup.v1.agent.progress",
|
||||||
"lineup.v1.error",
|
"lineup.v1.error",
|
||||||
"lineup.v1.tool.call",
|
"lineup.v1.tool.call",
|
||||||
|
"lineup.v1.miniapp.tool.call",
|
||||||
"lineup.v1.tool.result",
|
"lineup.v1.tool.result",
|
||||||
"lineup.v1.tool.cancel",
|
"lineup.v1.tool.cancel",
|
||||||
"lineup.v1.ui.open",
|
"lineup.v1.ui.open",
|
||||||
@@ -348,7 +367,7 @@ function validAppCallPayload(payload: JsonObject): boolean {
|
|||||||
return Boolean(identifier(payload.call_id) && identifier(payload.capability) && reason && reason.length <= 500 && expiry && object(payload.arguments));
|
return Boolean(identifier(payload.call_id) && identifier(payload.capability) && reason && reason.length <= 500 && expiry && object(payload.arguments));
|
||||||
}
|
}
|
||||||
|
|
||||||
const TOOL_KINDS = new Set<ToolCallKind>(["choice", "confirm", "input"]);
|
const TOOL_KINDS = new Set<ToolCallKind>(["notice", "choice", "confirm", "input"]);
|
||||||
const TOOL_FINAL_STATUSES = new Set<ToolCallFinalStatus>(["completed", "failed", "cancelled", "expired"]);
|
const TOOL_FINAL_STATUSES = new Set<ToolCallFinalStatus>(["completed", "failed", "cancelled", "expired"]);
|
||||||
const ACTION_GROUP_MODES = new Set<ActionGroupMode>(["single-choice", "multi-choice", "button"]);
|
const ACTION_GROUP_MODES = new Set<ActionGroupMode>(["single-choice", "multi-choice", "button"]);
|
||||||
const INPUT_FIELD_TYPES = new Set<InputFieldType>(["text", "textarea", "number"]);
|
const INPUT_FIELD_TYPES = new Set<InputFieldType>(["text", "textarea", "number"]);
|
||||||
@@ -391,6 +410,14 @@ function parseConfirm(value: unknown): ConfirmDefinition | undefined {
|
|||||||
return approve && cancel ? { approve_label: approve, cancel_label: cancel } : undefined;
|
return approve && cancel ? { approve_label: approve, cancel_label: cancel } : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseNotice(value: unknown): NoticeDefinition | undefined {
|
||||||
|
const candidate = object(value);
|
||||||
|
if (!candidate) return undefined;
|
||||||
|
const message = text(candidate.message).trim();
|
||||||
|
const dismiss = candidate.dismiss_label === undefined ? undefined : boundedLabel(candidate.dismiss_label, "关闭");
|
||||||
|
return message && (candidate.dismiss_label === undefined || dismiss) ? { message, ...(dismiss ? { dismiss_label: dismiss } : {}) } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function wholeNumber(value: unknown, maximum: number): number | undefined {
|
function wholeNumber(value: unknown, maximum: number): number | undefined {
|
||||||
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= maximum ? value : undefined;
|
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= maximum ? value : undefined;
|
||||||
}
|
}
|
||||||
@@ -439,9 +466,11 @@ function parseToolCall(payload: JsonObject): ToolCallRequest | undefined {
|
|||||||
if (payload.expires_at !== undefined && !expiresAt) return undefined;
|
if (payload.expires_at !== undefined && !expiresAt) return undefined;
|
||||||
const data = payload.data === undefined ? {} : object(payload.data);
|
const data = payload.data === undefined ? {} : object(payload.data);
|
||||||
if (!data) return undefined;
|
if (!data) return undefined;
|
||||||
|
const notice = tool === "notice" ? parseNotice(data.notice ?? data) : undefined;
|
||||||
const actionGroup = tool === "choice" ? parseActionGroup(data.action_group) : undefined;
|
const actionGroup = tool === "choice" ? parseActionGroup(data.action_group) : undefined;
|
||||||
const confirm = tool === "confirm" ? parseConfirm(data.confirm) : undefined;
|
const confirm = tool === "confirm" ? parseConfirm(data.confirm) : undefined;
|
||||||
const form = tool === "input" ? parseInputForm(data.form) : undefined;
|
const form = tool === "input" ? parseInputForm(data.form) : undefined;
|
||||||
|
if (tool === "notice" && !notice) return undefined;
|
||||||
if (tool === "choice" && !actionGroup) return undefined;
|
if (tool === "choice" && !actionGroup) return undefined;
|
||||||
if (tool === "confirm" && !confirm) return undefined;
|
if (tool === "confirm" && !confirm) return undefined;
|
||||||
if (tool === "input" && !form) return undefined;
|
if (tool === "input" && !form) return undefined;
|
||||||
@@ -452,12 +481,25 @@ function parseToolCall(payload: JsonObject): ToolCallRequest | undefined {
|
|||||||
prompt: text(payload.prompt),
|
prompt: text(payload.prompt),
|
||||||
...(expiresAt ? { expires_at: expiresAt } : {}),
|
...(expiresAt ? { expires_at: expiresAt } : {}),
|
||||||
data,
|
data,
|
||||||
|
...(notice ? { notice } : {}),
|
||||||
...(actionGroup ? { action_group: actionGroup } : {}),
|
...(actionGroup ? { action_group: actionGroup } : {}),
|
||||||
...(confirm ? { confirm } : {}),
|
...(confirm ? { confirm } : {}),
|
||||||
...(form ? { form } : {}),
|
...(form ? { form } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseMiniAppToolInvoke(payload: JsonObject): MiniAppToolInvoke | undefined {
|
||||||
|
const callID = identifier(payload.call_id);
|
||||||
|
const toolID = identifier(payload.tool_id);
|
||||||
|
const appScope = text(payload.app_scope);
|
||||||
|
const inventoryRevision = identifier(payload.inventory_revision);
|
||||||
|
const input = object(payload.input);
|
||||||
|
const instanceID = payload.instance_id === undefined ? undefined : identifier(payload.instance_id);
|
||||||
|
if (!callID || !toolID || !appScope || !inventoryRevision || !input || (payload.instance_id !== undefined && !instanceID)) return undefined;
|
||||||
|
if (Object.keys(payload).some(key => !["call_id", "tool_id", "app_scope", "inventory_revision", "input", "instance_id"].includes(key))) return undefined;
|
||||||
|
return { call_id: callID, tool_id: toolID, app_scope: appScope, inventory_revision: inventoryRevision, input, ...(instanceID ? { instance_id: instanceID } : {}) };
|
||||||
|
}
|
||||||
|
|
||||||
function parseTaskProgress(payload: JsonObject, percent: number): TaskProgress | undefined {
|
function parseTaskProgress(payload: JsonObject, percent: number): TaskProgress | undefined {
|
||||||
const operationID = identifier(payload.operation_id);
|
const operationID = identifier(payload.operation_id);
|
||||||
const status = text(payload.status) as TaskStatus;
|
const status = text(payload.status) as TaskStatus;
|
||||||
@@ -566,6 +608,10 @@ export function decodeConversationItem(raw: string): ConversationItem {
|
|||||||
const call = parseToolCall(payload);
|
const call = parseToolCall(payload);
|
||||||
return call ? { kind: "tool-call", call, ...base } : fallbackItem({ code: "invalid_payload", detail: "Tool call requires call_id, supported tool, and JSON data.", envelope });
|
return call ? { kind: "tool-call", call, ...base } : fallbackItem({ code: "invalid_payload", detail: "Tool call requires call_id, supported tool, and JSON data.", envelope });
|
||||||
}
|
}
|
||||||
|
case "lineup.v1.miniapp.tool.call": {
|
||||||
|
const call = parseMiniAppToolInvoke(payload);
|
||||||
|
return call ? { kind: "miniapp-tool-call", call, ...base } : fallbackItem({ code: "invalid_payload", detail: "MiniApp Tool call requires call_id, tool_id, app_scope, inventory_revision, and JSON input.", envelope });
|
||||||
|
}
|
||||||
case "lineup.v1.tool.result": {
|
case "lineup.v1.tool.result": {
|
||||||
const result = parseToolResult(payload);
|
const result = parseToolResult(payload);
|
||||||
return result ? { kind: "tool-result", result, ...base } : fallbackItem({ code: "invalid_payload", detail: "Tool result requires call_id, final status, and JSON result.", envelope });
|
return result ? { kind: "tool-result", result, ...base } : fallbackItem({ code: "invalid_payload", detail: "Tool result requires call_id, final status, and JSON result.", envelope });
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export class IsolatedSurfaceHost {
|
|||||||
// Development uses the static dashboard. A production resolver can return
|
// Development uses the static dashboard. A production resolver can return
|
||||||
// only bytes that were already signature/hash verified by M4-02; a missing
|
// only bytes that were already signature/hash verified by M4-02; a missing
|
||||||
// exact version is a safe no-op rather than a fallback to unverified code.
|
// exact version is a safe no-op rather than a fallback to unverified code.
|
||||||
const resolvedDocument = this.documents ? this.documents.documentFor(instance) : surfaceDocument(instance.instance_id);
|
const resolvedDocument = this.documents ? this.documents.documentFor(instance) : surfaceDocument(instance.instance_id, instance.app_id);
|
||||||
if (!resolvedDocument) return;
|
if (!resolvedDocument) return;
|
||||||
const frame = document.createElement("iframe");
|
const frame = document.createElement("iframe");
|
||||||
frame.className = "isolated-surface-frame";
|
frame.className = "isolated-surface-frame";
|
||||||
@@ -146,7 +146,7 @@ function parseBridgeMessage(value: unknown): SurfaceReadyMessage | SurfaceEventM
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A static boot document for the future host-local Task Dashboard bundle. */
|
/** A static boot document for the future host-local Task Dashboard bundle. */
|
||||||
export function surfaceDocument(instanceID: string): string {
|
export function surfaceDocument(instanceID: string, _appID = "lineup.task-dashboard"): string {
|
||||||
const safeID = JSON.stringify(instanceID);
|
const safeID = JSON.stringify(instanceID);
|
||||||
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="referrer" content="no-referrer"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; connect-src 'none'; img-src data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-src 'none'; object-src 'none'; navigate-to 'none'"><style>html,body{margin:0;background:#15231c;color:#e7f0eb;font:14px system-ui,sans-serif}main{padding:12px;display:grid;gap:10px}h2,p{margin:0}progress{width:100%}ul{margin:0;padding-left:20px}#logs{max-height:64px;overflow:auto;color:#b8cfc0;font-size:12px}button{justify-self:start;border:0;border-radius:7px;padding:8px 10px;background:#a9dfc4;color:#102018;font-weight:700}</style></head><body><main><h2 id="title">任务面板</h2><progress id="progress" max="100"></progress><p id="status">等待状态</p><ul id="steps"></ul><div id="logs" aria-label="日志摘要"></div><button id="cancel" type="button">请求取消</button></main><script>"use strict";const instanceId=${safeID},q=id=>document.getElementById(id),list=(id,items)=>q(id).replaceChildren(...(Array.isArray(items)?items.slice(0,12).map(x=>{const n=document.createElement(id==="logs"?"p":"li");n.textContent=typeof x==="string"?x:"";return n}):[]));q("cancel").onclick=()=>window.parent.postMessage({v:1,type:"lineup.surface.v1.event",instance_id:instanceId,event:"cancel"},"*");window.addEventListener("message",event=>{if(event.source!==window.parent)return;const d=event.data;if(!d||d.v!==1||d.type!=="lineup.surface.v1.state"||d.instance_id!==instanceId||!d.state||typeof d.state!=="object"||Array.isArray(d.state))return;const s=d.state;q("title").textContent=typeof s.title==="string"?s.title:"任务面板";q("progress").value=typeof s.percent==="number"?Math.max(0,Math.min(100,s.percent)):0;q("status").textContent=typeof s.status==="string"?s.status:"进行中";list("steps",s.steps);list("logs",s.logs);});window.parent.postMessage({v:1,type:"lineup.surface.v1.ready",instance_id:instanceId},"*");</script></body></html>`;
|
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="referrer" content="no-referrer"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; connect-src 'none'; img-src data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-src 'none'; object-src 'none'; navigate-to 'none'"><style>html,body{margin:0;background:#15231c;color:#e7f0eb;font:14px system-ui,sans-serif}main{padding:12px;display:grid;gap:10px}h2,p{margin:0}progress{width:100%}ul{margin:0;padding-left:20px}#logs{max-height:64px;overflow:auto;color:#b8cfc0;font-size:12px}button{justify-self:start;border:0;border-radius:7px;padding:8px 10px;background:#a9dfc4;color:#102018;font-weight:700}</style></head><body><main><h2 id="title">任务面板</h2><progress id="progress" max="100"></progress><p id="status">等待状态</p><ul id="steps"></ul><div id="logs" aria-label="日志摘要"></div><button id="cancel" type="button">请求取消</button></main><script>"use strict";const instanceId=${safeID},q=id=>document.getElementById(id),list=(id,items)=>q(id).replaceChildren(...(Array.isArray(items)?items.slice(0,12).map(x=>{const n=document.createElement(id==="logs"?"p":"li");n.textContent=typeof x==="string"?x:"";return n}):[]));q("cancel").onclick=()=>window.parent.postMessage({v:1,type:"lineup.surface.v1.event",instance_id:instanceId,event:"cancel"},"*");window.addEventListener("message",event=>{if(event.source!==window.parent)return;const d=event.data;if(!d||d.v!==1||d.type!=="lineup.surface.v1.state"||d.instance_id!==instanceId||!d.state||typeof d.state!=="object"||Array.isArray(d.state))return;const s=d.state;q("title").textContent=typeof s.title==="string"?s.title:"任务面板";q("progress").value=typeof s.percent==="number"?Math.max(0,Math.min(100,s.percent)):0;q("status").textContent=typeof s.status==="string"?s.status:"进行中";list("steps",s.steps);list("logs",s.logs);});window.parent.postMessage({v:1,type:"lineup.surface.v1.ready",instance_id:instanceId},"*");</script></body></html>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,16 @@ export const DEVELOPMENT_SURFACE_MANIFESTS: readonly SurfaceManifest[] = Object.
|
|||||||
local_bundle_bytes: 0,
|
local_bundle_bytes: 0,
|
||||||
development_only: true,
|
development_only: true,
|
||||||
}),
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
app_id: "lineup.whiteboard",
|
||||||
|
version: "0.1.0",
|
||||||
|
bundle_id: "lineup.whiteboard.dev.v1",
|
||||||
|
state_schema_version: 1,
|
||||||
|
max_state_bytes: 32 * 1024,
|
||||||
|
max_bundle_bytes: 256 * 1024,
|
||||||
|
local_bundle_bytes: 0,
|
||||||
|
development_only: true,
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# LineUp App 迭代定义:MiniApp SDK v1 与内置参考 MiniApp
|
# LineUp App 迭代定义:MiniApp SDK v1 与内置参考 MiniApp
|
||||||
|
|
||||||
**迭代编号:** 03.sdk_and_coreapp
|
**迭代编号:** 03.sdk_and_coreapp
|
||||||
**状态:** 目标设计,待实现
|
**状态:** 实现进行中(核心 Runtime / SDK 闭环已完成,真实 Host 浏览器验收与最终提交尚未完成)
|
||||||
**日期:** 2026-08-05
|
**日期:** 2026-08-05
|
||||||
**前置基线:** [00.base.md](../00.base/00.base.md)、[01.kernel.md](../01.kernel/01.kernel.md)
|
**前置基线:** [00.base.md](../00.base/00.base.md)、[01.kernel.md](../01.kernel/01.kernel.md)
|
||||||
**权威架构:** [APP架构设计.md](../../APP架构设计.md)
|
**权威架构:** [APP架构设计.md](../../APP架构设计.md)
|
||||||
@@ -911,6 +911,10 @@ Whiteboard instance 启动时创建自己的 App 子会话;Agent 与用户围
|
|||||||
|
|
||||||
## 6. Runtime 与 Host 的实现模块
|
## 6. Runtime 与 Host 的实现模块
|
||||||
|
|
||||||
|
当前实现已落地以下边界:标准交互记录已接入 ConversationStore(当前版本 14),Runtime 登录时恢复并清理未提交 input 草稿;`interaction.dismiss` 已由 Tool Router 接入 Runtime;默认注册表已使用 `system / bundled`,并内置 Task Dashboard、Whiteboard 的 Manifest、受限 SDK 入口和参考实现。bundled MiniApp 的 Surface 请求现在由 Runtime 在校验 instance / scope 后发出本地事件,再由 Host 挂载、更新或卸载隔离 Surface,不再伪装成发给 Agent 的 UI 协议消息。Task Dashboard、Whiteboard 已通过同一套 SDK 自动装配,覆盖 Tool、progress/result、Surface 和生命周期;App Inbox 已按 instance 过滤并在 ACK 时再次校验;普通 Tool 的 `submitted` outbox 重启恢复、App 子会话只读历史和“继续处理”(新 instance / 新子会话)已有实现与自动化测试。
|
||||||
|
|
||||||
|
当前仍未宣称完成:真实 Tauri/Web Host 的浏览器验收尚未取得有效快照证据;`agent-browser` 在当前受限环境中无法创建其默认 Unix socket,Web Host 只能确认已监听 `0.0.0.0:1420`。此外,Chat 仍保留少量旧 SDK 交互兼容旁路,后续需在不改变已冻结协议的前提下完成清理。最终提交前必须重新运行完整测试、构建、`git diff --check`,并对登录、标准交互、Task Dashboard、Whiteboard、App 关闭/恢复和子会话折叠逐项留存验收证据。
|
||||||
|
|
||||||
本迭代预计在现有目录中演进,不重建并行 Runtime:
|
本迭代预计在现有目录中演进,不重建并行 Runtime:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
Reference in New Issue
Block a user