diff --git a/tauri/src/core-apps/chat/chat-app-host.ts b/tauri/src/core-apps/chat/chat-app-host.ts index d5bf401..9e9add7 100644 --- a/tauri/src/core-apps/chat/chat-app-host.ts +++ b/tauri/src/core-apps/chat/chat-app-host.ts @@ -30,6 +30,7 @@ export type MountedChatApp = { messages: HTMLElement; loginView: HTMLElement; chatView: HTMLElement; + appSessions: HTMLElement; presence: HTMLElement; rendererContext: TrustedDOMRendererContext; rendererRegistry: RendererRegistry; @@ -51,6 +52,7 @@ export function mountChatApp( const messages = query(root, "#messages"); const loginView = query(root, "#login-view"); const chatView = query(root, "#chat-view"); + const appSessions = query(root, "#app-sessions"); const presence = query(root, "#presence"); const rendererContext = new TrustedDOMRendererContext( messages, @@ -68,6 +70,7 @@ export function mountChatApp( messages, loginView, chatView, + appSessions, presence, rendererContext, rendererRegistry, diff --git a/tauri/src/core-apps/chat/chat-shell.ts b/tauri/src/core-apps/chat/chat-shell.ts index a973c6d..a98cd05 100644 --- a/tauri/src/core-apps/chat/chat-shell.ts +++ b/tauri/src/core-apps/chat/chat-shell.ts @@ -19,6 +19,7 @@ export function mountChatShell(root: HTMLElement): void { diff --git a/tauri/src/core-apps/chat/styles/app.css b/tauri/src/core-apps/chat/styles/app.css index d84b590..891a0c1 100644 --- a/tauri/src/core-apps/chat/styles/app.css +++ b/tauri/src/core-apps/chat/styles/app.css @@ -1,2 +1,4 @@ /* Chat Core App 的页面基础样式:登录页、会话时间线、输入区与通用布局。 */ :root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#e9f0ec;background:#101815;font-synthesis:none}*{box-sizing:border-box}[hidden]{display:none!important}body{margin:0;min-width:320px;min-height:100vh}.shell{min-height:100vh;background:radial-gradient(circle at 5% 0,#29473d 0,transparent 32rem),#101815}.login-view{display:grid;place-items:center;gap:2.5rem;min-height:100vh;padding:2rem}.brand{display:flex;align-items:center;gap:1rem}.brand>span,.avatar{display:grid;place-items:center;border-radius:50%;background:#b8e4cf;color:#11251d;font-weight:800}.brand>span{width:3.2rem;height:3.2rem;font-size:1.4rem}.brand p{margin:0;color:#a5c9b8;font-size:.75rem;letter-spacing:.16em}.brand h1{margin:.25rem 0 0;font-size:1.5rem}.login-card{display:grid;width:min(100%,26rem);gap:1rem;padding:1.5rem;border:1px solid #365245;border-radius:1rem;background:#18251f;box-shadow:0 1rem 4rem #0006}.login-card label{display:grid;gap:.45rem;color:#b8c9c0;font-size:.85rem}.login-card input,.composer textarea{border:1px solid #456253;border-radius:.65rem;background:#0e1713;color:#eff7f2;padding:.75rem;font:inherit}.login-card button,.composer button,.action-group-card button,.tool-card button,.task-card button{border:0;border-radius:.65rem;padding:.8rem 1rem;background:#a9dfc4;color:#102018;font-weight:750;cursor:pointer}.error{min-height:1.2rem;margin:0;color:#ffaaa4;font-size:.85rem}.chat-view{display:grid;grid-template-rows:auto 1fr auto;height:100vh;max-width:62rem;margin:auto;border-inline:1px solid #2d4438;background:#142019}.chat-header{display:flex;align-items:center;justify-content:space-between;padding:1rem 1.25rem;border-bottom:1px solid #2c4437}.agent{display:flex;align-items:center;gap:.75rem}.avatar{width:2.4rem;height:2.4rem}.agent strong,.agent small{display:block}.agent small{margin-top:.12rem;color:#9bb5a7;font-size:.8rem}.quiet{border:0;background:transparent;color:#b7cabe;cursor:pointer}.messages{overflow:auto;padding:1.25rem;display:grid;align-content:start;gap:.75rem}.message-row{display:flex;gap:.6rem;max-width:min(85%,45rem)}.message-row.human{justify-self:end}.message-row.human .bubble{background:#a9dfc4;color:#122119}.message-avatar{display:grid;place-items:center;width:1.75rem;height:1.75rem;border-radius:50%;background:#315d4a;color:#d8f5e5;font-size:.8rem}.bubble{padding:.75rem .9rem;border-radius:.9rem;background:#23342b;color:#e7f0eb;line-height:1.55;overflow-wrap:anywhere}.meta{display:block;margin-top:.45rem;opacity:.6;font-size:.68rem}.markdown :first-child{margin-top:0}.markdown :last-child{margin-bottom:0}.markdown pre{overflow:auto;padding:.7rem;border-radius:.5rem;background:#0b110e}.markdown code{font-family:"SFMono-Regular",Consolas,monospace}.system{justify-self:center;margin:.35rem 0;color:#98afa2;font-size:.78rem}.thinking .bubble{color:#c7e3d5}.action-group-card,.tool-card,.task-card{display:grid;justify-self:start;gap:.7rem;width:min(100%,32rem);padding:1rem;border:1px solid #537362;border-radius:.9rem;background:#1c2c24}.action-group-card>p,.tool-card>p{margin:0;color:#bfd1c6;line-height:1.45}.action-group-actions{display:grid;gap:.5rem}.action-group-actions.multi-choice{grid-template-columns:repeat(auto-fit,minmax(8rem,1fr))}.action-group-actions.button{grid-template-columns:repeat(auto-fit,minmax(7rem,1fr))}.action-group-action{min-height:2.7rem;text-align:left}.action-group-actions.button .action-group-action{text-align:center}.action-group-action[aria-pressed="true"]{outline:2px solid #e0f8ea;background:#d0f0db}.action-group-submit{justify-self:start}.action-group-status,.tool-card-status,.task-card-status{color:#aac5b5}.action-group-card[data-status="submitted"],.tool-card[data-status="submitted"],.task-card[data-status="completed"]{border-color:#89c7a6}.tool-card[data-status="cancelled"],.tool-card[data-status="expired"],.task-card[data-status="cancelled"],.task-card[data-status="failed"]{border-color:#587064}.task-card progress{width:100%;height:.7rem;accent-color:#a9dfc4}.task-card .secondary-action{justify-self:start;background:#395447;color:#e2f1e8}.tool-card form{display:grid;gap:.75rem}.tool-card form label{display:grid;gap:.35rem;color:#c7d9ce;font-size:.85rem}.tool-card form input,.tool-card form textarea{width:100%;border:1px solid #456253;border-radius:.65rem;background:#0e1713;color:#eff7f2;padding:.7rem;font:inherit}.tool-card form textarea{min-height:5rem;resize:vertical}.tool-card-actions{display:flex;flex-wrap:wrap;gap:.5rem}.tool-card-actions .secondary-action{background:#395447;color:#e2f1e8}.tool-card-error{min-height:1.2rem;margin:0;color:#ffaaa4;font-size:.82rem}.composer{display:flex;gap:.75rem;padding:1rem;border-top:1px solid #2c4437;background:#16241d}.composer textarea{flex:1;max-height:8rem;resize:vertical}.composer button:disabled,.action-group-card button:disabled,.tool-card button:disabled,.tool-card input:disabled,.tool-card textarea:disabled,.task-card button:disabled{opacity:.5;cursor:not-allowed}@media(max-width:560px){.chat-view{border:0}.message-row{max-width:94%}.login-view{gap:1.4rem;padding:1rem}} +.notice-toast{position:fixed;right:1rem;bottom:5.5rem;z-index:20;max-width:min(24rem,calc(100vw - 2rem));padding:.75rem 1rem;border:1px solid #7ab594;border-radius:.7rem;background:#243d30;color:#effaf3;box-shadow:0 .5rem 2rem #0007} +.app-sessions{display:grid;gap:.45rem;padding:.65rem 1.25rem;border-bottom:1px solid #2c4437;background:#17261f}.app-session-group{padding:.45rem .65rem;border:1px solid #365445;border-radius:.55rem;background:#1b2d24;color:#c8ddd0}.app-session-group summary{cursor:pointer;font-size:.8rem}.app-session-group small{display:block;padding:.4rem 0 0;color:#94b2a1;font-size:.72rem} diff --git a/tauri/src/core-apps/chat/trusted-dom-renderers.ts b/tauri/src/core-apps/chat/trusted-dom-renderers.ts index 0462f01..2908e76 100644 --- a/tauri/src/core-apps/chat/trusted-dom-renderers.ts +++ b/tauri/src/core-apps/chat/trusted-dom-renderers.ts @@ -12,6 +12,7 @@ import type { ConversationItem, DeliveryState, InputForm, + NoticeDefinition, JsonObject, ToolCallRequest, } from "@/runtime/protocol/lineup-v1"; @@ -168,6 +169,10 @@ export class TrustedDOMRendererContext { public renderToolCall(item: Extract): void { const record = this.toolInteractions?.read(item.call.call_id); + if (item.call.tool === "notice" && item.call.notice) { + this.renderNoticeCard(item.call, item.call.notice); + return; + } if (item.call.tool === "choice" && item.call.action_group) { this.renderActionGroup(item.call, item.call.action_group, record); return; @@ -183,6 +188,30 @@ export class TrustedDOMRendererContext { this.renderSystemMessage("收到无法安全展示的标准交互请求。"); } + private renderNoticeCard(call: ToolCallRequest, notice: NoticeDefinition): void { + const card = document.createElement("article"); + card.className = "tool-card notice-card"; + card.dataset.callId = call.call_id; + const heading = document.createElement("strong"); + heading.textContent = call.title || "提示"; + const detail = document.createElement("p"); + detail.textContent = notice.message; + const status = document.createElement("small"); + status.className = "tool-card-status"; + status.textContent = notice.dismiss_label ?? "已收到"; + card.append(heading, detail, status); + this.toolCards.set(call.call_id, { card, status, controls: [] }); + this.messages.append(card); + this.scrollLatest(); + // Toast is only an additional short reminder; the durable IM card above remains. + const toast = document.createElement("div"); + toast.className = "notice-toast"; + toast.textContent = notice.message; + toast.setAttribute("role", "status"); + this.messages.parentElement?.append(toast); + globalThis.setTimeout(() => toast.remove(), 4_000); + } + public renderToolResult(item: Extract): void { const entry = this.toolCards.get(item.result.call_id); if (!entry) return; diff --git a/tauri/src/core-apps/task-dashboard/task-dashboard-miniapp.test.ts b/tauri/src/core-apps/task-dashboard/task-dashboard-miniapp.test.ts new file mode 100644 index 0000000..99b59cf --- /dev/null +++ b/tauri/src/core-apps/task-dashboard/task-dashboard-miniapp.test.ts @@ -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(); + }); +}); diff --git a/tauri/src/core-apps/task-dashboard/task-dashboard-miniapp.ts b/tauri/src/core-apps/task-dashboard/task-dashboard-miniapp.ts new file mode 100644 index 0000000..ba3e875 --- /dev/null +++ b/tauri/src/core-apps/task-dashboard/task-dashboard-miniapp.ts @@ -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(); + 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 { + 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 { + 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; + } +} diff --git a/tauri/src/core-apps/whiteboard/whiteboard-miniapp.test.ts b/tauri/src/core-apps/whiteboard/whiteboard-miniapp.test.ts new file mode 100644 index 0000000..1c0f1e2 --- /dev/null +++ b/tauri/src/core-apps/whiteboard/whiteboard-miniapp.test.ts @@ -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"]); + }); +}); diff --git a/tauri/src/core-apps/whiteboard/whiteboard-miniapp.ts b/tauri/src/core-apps/whiteboard/whiteboard-miniapp.ts new file mode 100644 index 0000000..332a2f0 --- /dev/null +++ b/tauri/src/core-apps/whiteboard/whiteboard-miniapp.ts @@ -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 { + 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 { + 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 { + this.state = { ...this.state, ...delta }; + await this.sdk.surfaces.request("patch", { state: this.state }); + } + + public async exportArtifact(artifact: JsonObject): Promise { + await this.sdk.surfaces.request("patch", { artifact }); + } + + public async close(): Promise { + this.unsubscribeTools?.(); + this.unsubscribeTools = undefined; + await this.sdk.surfaces.request("close", {}); + } +} diff --git a/tauri/src/main.ts b/tauri/src/main.ts index 06e7324..15a1829 100644 --- a/tauri/src/main.ts +++ b/tauri/src/main.ts @@ -27,6 +27,8 @@ import { CapabilityAuditLog } from "@/runtime/capabilities/capability-audit"; import { CapabilityExecutor, type PickedFile } from "@/runtime/capabilities/capability-executor"; import { LineUpRuntime } from "@/runtime/coordination/lineup-runtime"; import { RuntimeAppHost } from "@/runtime/app-management/runtime-app-host"; +import { TaskDashboardMiniApp } from "@/core-apps/task-dashboard/task-dashboard-miniapp"; +import { WhiteboardMiniApp } from "@/core-apps/whiteboard/whiteboard-miniapp"; // Vite 的开发标记和 M4 测试控制器需要扩展浏览器的全局类型。 declare global { @@ -123,13 +125,84 @@ const mountedApp = new RuntimeAppHost(runtime, app, { }).mountDefaultApp(); // 从通用挂载结果中取得当前应用的 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 的 ready/event 回调仍回到状态管理和 SDK Action,不直接执行 Agent 指令。 const surfaceHost = new IsolatedSurfaceHost(messages, { ready(instanceID) { surfaceInstances.ready(instanceID, new Date().toISOString()); }, 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 void }>(); +function reconcileBundledMiniApps(): void { + const active = new Set(); + 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()); +} $("#api").value = localStorage.getItem("lineup.api") || DEFAULT_API; /** @@ -220,6 +293,51 @@ chatSDK.subscribeAgentMessages(message => { 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 => { // 这些是 Runtime 给 Chat 的安全事件投影,不包含原始 Agent delivery。 if (event.type === "local-item") { diff --git a/tauri/src/runtime/app-management/app-instance-manager.ts b/tauri/src/runtime/app-management/app-instance-manager.ts index 27e327a..c7825ef 100644 --- a/tauri/src/runtime/app-management/app-instance-manager.ts +++ b/tauri/src/runtime/app-management/app-instance-manager.ts @@ -4,10 +4,12 @@ export type AppInstanceState = "starting" | "foreground" | "background" | "suspe export type AppInstanceRecord = { instance_id: string; + app_session_id?: string; app_scope: AppScope; conversation_id?: string; state: AppInstanceState; parent_instance_id?: string; + continued_from_app_session_id?: string; started_at: string; stopped_at?: string; error?: string; @@ -47,7 +49,7 @@ export class AppInstanceManager { this.records.clear(); if (snapshot?.version !== 1 || !Array.isArray(snapshot.instances)) return; for (const raw of snapshot.instances) { - if (!raw || !validID(raw.instance_id) || typeof raw.app_scope !== "string" || !isState(raw.state) || !validTime(raw.started_at)) continue; + if (!raw || !validID(raw.instance_id) || (raw.app_session_id !== undefined && !validID(raw.app_session_id)) || (raw.continued_from_app_session_id !== undefined && !validID(raw.continued_from_app_session_id)) || typeof raw.app_scope !== "string" || !isState(raw.state) || !validTime(raw.started_at)) continue; // A host cannot safely resume an in-progress native operation. It can // however present suspended apps and let the orchestrator restore focus. const recovered = raw.state === "starting" || raw.state === "stopping" ? { ...raw, state: "suspended" as const } : raw; diff --git a/tauri/src/runtime/app-management/app-lifecycle-manager.ts b/tauri/src/runtime/app-management/app-lifecycle-manager.ts index d49e32a..bee8d3e 100644 --- a/tauri/src/runtime/app-management/app-lifecycle-manager.ts +++ b/tauri/src/runtime/app-management/app-lifecycle-manager.ts @@ -8,7 +8,7 @@ export type AppWorkspaceSnapshot = { version: 1; instances: AppInstanceSnapshot; export class AppLifecycleManager { public readonly instances = new AppInstanceManager(); public readonly focus = new AppFocusManager(); - public start(input: { instance_id: string; app_scope: AppScope; conversation_id?: string; parent_instance_id?: string }, now: string): AppInstanceRecord { return this.instances.create(input, now); } + public start(input: { instance_id: string; app_scope: AppScope; conversation_id?: string; app_session_id?: string; parent_instance_id?: string; continued_from_app_session_id?: string }, now: string): AppInstanceRecord { return this.instances.create(input, now); } public foreground(instanceID: string, now: string): AppInstanceRecord { const current = this.instances.get(instanceID); if (!current) throw new Error("Unknown App Instance."); const prior = this.focus.foreground(); diff --git a/tauri/src/runtime/app-management/app-registry.ts b/tauri/src/runtime/app-management/app-registry.ts index cfe5eda..0741aa7 100644 --- a/tauri/src/runtime/app-management/app-registry.ts +++ b/tauri/src/runtime/app-management/app-registry.ts @@ -10,7 +10,12 @@ * or a marketplace namespace. The initial registry is static, but the same * record shape is the boundary that the later App Registry/Market will own. */ +import { validateMiniAppManifest, type MiniAppManifestV1 } from "@/runtime/app-management/miniapp-manifest"; +import { BUNDLED_REFERENCE_MANIFESTS } from "@/runtime/app-management/reference-miniapps"; +import type { JsonObject } from "@/runtime/protocol/lineup-v1"; + export type AppScope = "chat" | "app-registry" | "settings" | "runtime" | (string & {}); +export type MiniAppKind = "system" | "bundled"; export type AppToolDefinition = { name: string; @@ -18,6 +23,13 @@ export type AppToolDefinition = { /** JSON-object property names accepted by this tool. */ parameters: readonly 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 = { app_scope: AppScope; - kind: "core" | "extension"; + kind: MiniAppKind; enabled: boolean; default_eligible: boolean; recovery: boolean; manifest: AppManifest; }; +/** Transitional input accepted while older host adapters are upgraded. */ +export type CoreAppRecordInput = Omit & { kind: MiniAppKind | "core" | "extension" }; + 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: { app_scope: "chat", version: "1.0.0", permissions: [], tools: [ @@ -61,8 +76,11 @@ const INITIAL_CORE_APPS: readonly CoreAppRecord[] = [ export class CoreAppRegistry { private readonly apps = new Map(); - public constructor(records: readonly CoreAppRecord[] = INITIAL_CORE_APPS) { + public constructor(records: readonly CoreAppRecordInput[] = INITIAL_CORE_APPS) { for (const record of records) this.register(record); + if (records === INITIAL_CORE_APPS) { + for (const manifest of BUNDLED_REFERENCE_MANIFESTS) this.installMiniAppManifest(manifest); + } } public list(): readonly CoreAppRecord[] { @@ -83,13 +101,39 @@ export class CoreAppRegistry { } /** 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 = { 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 (this.apps.has(record.app_scope)) throw new Error(`Core App Registry has duplicate app_scope: ${record.app_scope}`); if (!isManifest(record.manifest)) throw new Error(`Core App Registry has an invalid manifest: ${record.app_scope}`); - this.apps.set(record.app_scope, copyRecord(record)); + 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: { ...record.manifest, 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) && ["direct", "interactive", "launch", "foreground", "operation"].includes(tool.handling) && tool.parameters.every(parameter => /^[a-z][a-z0-9_]{0,63}$/.test(parameter)) + && (!tool.input_schema || isJsonObject(tool.input_schema)) + && (!tool.output_schema || isJsonObject(tool.output_schema)) && (!tool.requires_permission || manifest.permissions.includes(tool.requires_permission)); toolNames.add(tool.name); return valid; }); } +function clone(value: T): T { return JSON.parse(JSON.stringify(value)) as T; } +function isJsonObject(value: unknown): value is JsonObject { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } + export function isAppScope(value: unknown): value is AppScope { return typeof value === "string" && /^[a-z][a-z0-9-]{0,63}$/.test(value); } diff --git a/tauri/src/runtime/app-management/app-sdk.ts b/tauri/src/runtime/app-management/app-sdk.ts index 4d3b563..374ac1f 100644 --- a/tauri/src/runtime/app-management/app-sdk.ts +++ b/tauri/src/runtime/app-management/app-sdk.ts @@ -21,6 +21,17 @@ export type ChatViewModel = { agent_presence: readonly AgentPresence[]; tool_calls: readonly ToolCallRecord[]; tasks: readonly TaskRecord[]; + /** Collapsed App sub-session summaries; business UI actions are not included. */ + app_sessions: readonly Readonly<{ + app_session_id: string; + app_scope: AppScope; + instance_id: string; + state: string; + interaction_count: number; + pending_interaction_count: number; + history: readonly Readonly<{ id: string; role: "agent" | "user"; text: string }>[]; + continued_from_app_session_id?: string; + }>[]; }; export type AgentChatMessage = ScopedConversationItem & { @@ -34,7 +45,8 @@ export type ChatAction = /** A bounded result from a user-approved capability interaction. */ | { type: "chat.report_app_result"; conversation_id: string; result: JsonObject } /** The current Chat Core App only exposes cancellation for a running Surface. */ - | { type: "chat.cancel_surface"; conversation_id: string; instance_id: string }; + | { type: "chat.cancel_surface"; conversation_id: string; instance_id: string } + | { type: "chat.continue_app_session"; conversation_id: string; app_session_id: string }; export type CommandReceipt = | { disposition: "accepted"; local_id?: string } @@ -89,7 +101,15 @@ export type RuntimeAppEvent = | { type: "sync-status"; status: "syncing" | "idle" | "retrying" } | { type: "scope-rejected"; reason: "invalid_scope" | "scope_mismatch"; raw: string } | { type: "state"; snapshot: KernelSnapshot } - | { type: "app-lifecycle"; workspace: AppWorkspaceSnapshot; reason: "launched" | "closed" | "failed" | "restored" }; + | { type: "app-lifecycle"; workspace: AppWorkspaceSnapshot; reason: "launched" | "closed" | "failed" | "restored" } + /** A Runtime-approved local Surface request from a bundled MiniApp. */ + | { + type: "surface-request"; + app_scope: AppScope; + instance_id: string; + event: "open" | "patch" | "close"; + data: JsonObject; + }; export type AppMessageSubscription = { app_scope: AppScope; diff --git a/tauri/src/runtime/app-management/miniapp-manifest.ts b/tauri/src/runtime/app-management/miniapp-manifest.ts new file mode 100644 index 0000000..f288a77 --- /dev/null +++ b/tauri/src/runtime/app-management/miniapp-manifest.ts @@ -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(["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(); + 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 }; +} diff --git a/tauri/src/runtime/app-management/miniapp-sdk-golden-contract.test.ts b/tauri/src/runtime/app-management/miniapp-sdk-golden-contract.test.ts index 11cc2fe..cad85ea 100644 --- a/tauri/src/runtime/app-management/miniapp-sdk-golden-contract.test.ts +++ b/tauri/src/runtime/app-management/miniapp-sdk-golden-contract.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; import fixture from "@/runtime/app-management/golden/miniapp-sdk-v1.json"; +import { validateMiniAppManifest, type MiniAppManifestV1 } from "@/runtime/app-management/miniapp-manifest"; +import { validateStandardInteractionRequest } from "@/runtime/coordination/standard-interaction-contract"; type GoldenCase = Readonly<{ id: string; @@ -35,4 +37,28 @@ describe("MiniApp SDK v1 frozen golden contract", () => { 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" }); + }); }); diff --git a/tauri/src/runtime/app-management/miniapp-sdk.ts b/tauri/src/runtime/app-management/miniapp-sdk.ts new file mode 100644 index 0000000..b7d2b8a --- /dev/null +++ b/tauri/src/runtime/app-management/miniapp-sdk.ts @@ -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; + complete(call_id: string, result: JsonObject): Promise; + fail(call_id: string, failure: MiniAppToolFailure): Promise; + cancel(call_id: string, reason?: string): Promise; + }; + readonly lifecycle: { + requestForeground(): Promise; + requestBackground(): Promise; + requestClose(reason?: string): Promise; + }; + readonly surfaces: { + request(event: "open" | "patch" | "close", data: JsonObject): Promise; + }; + readonly capabilities: { + request(name: string, reason: string, input?: JsonObject): Promise; + }; + /** Read-only host state projection for recovery and diagnostics. */ + readonly workspace: () => AppWorkspaceSnapshot; +} diff --git a/tauri/src/runtime/app-management/reference-miniapps.test.ts b/tauri/src/runtime/app-management/reference-miniapps.test.ts new file mode 100644 index 0000000..c8f51e7 --- /dev/null +++ b/tauri/src/runtime/app-management/reference-miniapps.test.ts @@ -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); + } + }); +}); diff --git a/tauri/src/runtime/app-management/reference-miniapps.ts b/tauri/src/runtime/app-management/reference-miniapps.ts new file mode 100644 index 0000000..5e4f727 --- /dev/null +++ b/tauri/src/runtime/app-management/reference-miniapps.ts @@ -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]; diff --git a/tauri/src/runtime/coordination/agent-interaction-service.test.ts b/tauri/src/runtime/coordination/agent-interaction-service.test.ts new file mode 100644 index 0000000..37da7e2 --- /dev/null +++ b/tauri/src/runtime/coordination/agent-interaction-service.test.ts @@ -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" } }); + }); +}); diff --git a/tauri/src/runtime/coordination/agent-interaction-service.ts b/tauri/src/runtime/coordination/agent-interaction-service.ts new file mode 100644 index 0000000..8384582 --- /dev/null +++ b/tauri/src/runtime/coordination/agent-interaction-service.ts @@ -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> }> + | 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(); + + 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>, 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; + 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 } : {}) }; +} diff --git a/tauri/src/runtime/coordination/app-orchestrator.ts b/tauri/src/runtime/coordination/app-orchestrator.ts index 0f3695f..6a361fd 100644 --- a/tauri/src/runtime/coordination/app-orchestrator.ts +++ b/tauri/src/runtime/coordination/app-orchestrator.ts @@ -9,13 +9,13 @@ export type AppOrchestrationResult = /** Applies Tool Router decisions atomically to instance and focus state. */ export class AppOrchestrator { public constructor(public readonly lifecycle: AppLifecycleManager, private readonly apps: CoreAppRegistry, private readonly newID: (prefix: string) => string) {} - public launch(appScope: AppScope, conversationID: string, now: string, options: { instance_id?: string; parent_instance_id?: string } = {}): AppOrchestrationResult { + public launch(appScope: AppScope, conversationID: string, now: string, options: { instance_id?: string; parent_instance_id?: string; continued_from_app_session_id?: string } = {}): AppOrchestrationResult { const app = this.apps.get(appScope); if (!app) return { disposition: "rejected", code: "not_installed" }; if (!app.enabled) return { disposition: "rejected", code: "disabled" }; const previous = this.lifecycle.focus.foregroundInstance(this.lifecycle.instances); const existing = this.lifecycle.instances.activeFor(appScope, conversationID); - const instance = existing ?? this.lifecycle.start({ instance_id: options.instance_id ?? this.newID(`${appScope}:instance`), app_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 } : {}) }; } public close(instanceID: string, now: string, error?: string): { closed?: AppInstanceRecord; restored?: AppInstanceRecord } { diff --git a/tauri/src/runtime/coordination/lineup-runtime.test.ts b/tauri/src/runtime/coordination/lineup-runtime.test.ts index be14419..58fab59 100644 --- a/tauri/src/runtime/coordination/lineup-runtime.test.ts +++ b/tauri/src/runtime/coordination/lineup-runtime.test.ts @@ -15,6 +15,7 @@ class MemoryStorage implements Storage { class FakeTransport implements TransportAdapter { public readonly sent: SendTextRequest[] = []; private readonly pages: TransportMessage[][]; + public failSends = false; public constructor(pages: TransportMessage[][] = []) { this.pages = [...pages]; @@ -25,6 +26,7 @@ class FakeTransport implements TransportAdapter { } public async sendText(request: SendTextRequest): Promise { + if (this.failSends) throw new Error("offline"); this.sent.push(request); return 91 + this.sent.length; } @@ -96,7 +98,11 @@ describe("LineUpRuntime", () => { const transport = new FakeTransport(); const runtime = new LineUpRuntime({ storage: new MemoryStorage(), createTransport: () => transport }); - expect(runtime.apps.list()).toEqual([expect.objectContaining({ app_scope: "chat", enabled: true, recovery: true })]); + expect(runtime.apps.list()).toContainEqual(expect.objectContaining({ app_scope: "chat", kind: "system", enabled: true, recovery: true })); + expect(runtime.apps.list()).toEqual(expect.arrayContaining([ + expect.objectContaining({ app_scope: "task-dashboard", kind: "bundled" }), + expect.objectContaining({ app_scope: "whiteboard", kind: "bundled" }), + ])); const chat = runtime.openApp("chat"); expect(chat.app_scope).toBe("chat"); expect(chat.snapshot()).toMatchObject({ app_scope: "chat", active: false }); @@ -124,6 +130,197 @@ describe("LineUpRuntime", () => { 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 () => { const conversationID = "user_1:2:channel_1"; const transport = new FakeTransport([[ @@ -158,7 +355,7 @@ describe("LineUpRuntime", () => { const conversationID = "user_1:2:channel_1"; const transport = new FakeTransport([[ { message_seq: 8, from_uid: "agent_1", payload: agentEnvelope("wrong_app_scope", conversationID, { - scope: { app_scope: "whiteboard", conversation_id: conversationID }, + scope: { app_scope: "not-installed-app", conversation_id: conversationID }, }) }, { message_seq: 9, from_uid: "agent_1", payload: agentEnvelope("wrong_conversation_scope", conversationID, { scope: { app_scope: "chat", conversation_id: "other:2:conversation" }, @@ -242,7 +439,7 @@ describe("LineUpRuntime", () => { expect(receipt).toEqual({ disposition: "accepted", local_id: "local_test" }); expect(local).toEqual([expect.objectContaining({ local_id: "local_test", item: expect.objectContaining({ kind: "user-message", text: "hello runtime" }) })]); - expect(transport.sent).toEqual([expect.objectContaining({ from_uid: "user_1", channel_id: "channel_1", payload: "hello runtime" })]); + expect(transport.sent.filter(entry => entry.payload === "hello runtime")).toEqual([expect.objectContaining({ from_uid: "user_1", channel_id: "channel_1" })]); expect(runtime.snapshot()?.outbox).toEqual([]); expect(runtime.snapshot()?.items).toEqual([expect.objectContaining({ local_id: "local_test", item: expect.objectContaining({ delivery: expect.objectContaining({ status: "submitted" }) }) })]); }); diff --git a/tauri/src/runtime/coordination/lineup-runtime.ts b/tauri/src/runtime/coordination/lineup-runtime.ts index ab918eb..12262e4 100644 --- a/tauri/src/runtime/coordination/lineup-runtime.ts +++ b/tauri/src/runtime/coordination/lineup-runtime.ts @@ -10,6 +10,8 @@ import { type ConversationItem, type Envelope, type JsonObject, + type ToolCallRequest, + type MiniAppToolInvoke, } from "@/runtime/protocol/lineup-v1"; import { CoreAppRegistry, type AppScope } from "@/runtime/app-management/app-registry"; 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 { AppOrchestrator } from "@/runtime/coordination/app-orchestrator"; 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 = { api: string; @@ -78,6 +85,8 @@ const CONTROL_ECHO_TYPES = new Set([ "lineup.v1.ui.event", "lineup.v1.client.inventory", "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 kernel = new InteractionKernel(); + private readonly interactionService = new AgentInteractionService(); + private readonly miniappTools = new MiniAppToolStateMachine(); private readonly listeners = new Set<(event: RuntimeAppEvent) => void>(); private readonly chatStateListeners = new Set<(view: ChatViewModel) => 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 orchestrator: AppOrchestrator; private readonly toolRouter: ToolRouter; + /** SDK v1 inventory revision advertised by this Runtime instance. */ + private readonly inventoryRevision = "catalog-1"; private transport: TransportAdapter | undefined; private session: RuntimeSession = { @@ -144,11 +157,73 @@ export class LineUpRuntime { 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(); } /** Runtime-only lifecycle entry point used when a mounted App reports completion. */ public closeAppInstance(instanceID: string, error?: string): void { 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.persistWorkspace(); runtimeLog("app.lifecycle", { action: error ? "failed" : "closed", instance_id: instanceID }); @@ -213,8 +288,8 @@ export class LineUpRuntime { const app = this.apps.get(appScope); const instance = this.lifecycle.instances.get(instanceID); const conversationID = this.requireConversationID(); - if (!app || app.kind !== "extension" || !app.enabled || !instance || instance.app_scope !== appScope || instance.conversation_id !== conversationID) { - throw new Error("Extension App SDK is unavailable for this instance."); + 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."); } return { 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. */ public openChatApp(): ChatRuntimeSDK { return this.openApp("chat"); @@ -247,15 +405,24 @@ export class LineUpRuntime { }; const snapshot = this.store.open(this.requireConversationID()); 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.restoreTasks(snapshot.tasks); this.store.replaceToolCalls(this.kernel.snapshot().tool_calls); this.store.replaceTasks(this.kernel.snapshot().tasks); this.session.last_seq = snapshot.cursor; if (!this.lifecycle.instances.activeFor("chat", this.requireConversationID())) { - this.orchestrator.launch("chat", this.requireConversationID(), this.now()); + const launched = this.orchestrator.launch("chat", this.requireConversationID(), this.now()); this.persistWorkspace(); 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" }); this.emitState(); @@ -338,6 +505,19 @@ export class LineUpRuntime { } 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); this.store.replaceToolCalls(this.kernel.snapshot().tool_calls); if (transition.disposition !== "accepted") return false; @@ -348,6 +528,19 @@ export class LineUpRuntime { } 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()); this.store.replaceToolCalls(this.kernel.snapshot().tool_calls); if (transition.disposition !== "accepted") return false; @@ -385,6 +578,78 @@ export class LineUpRuntime { 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 { + 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 { + 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 { + 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 { + 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. */ public async queueProtocolAction( kind: Exclude, @@ -392,7 +657,9 @@ export class LineUpRuntime { payload: JsonObject, ): Promise { 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 = { v: 1, id: this.newID("msg"), @@ -463,6 +730,16 @@ export class LineUpRuntime { 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" }; } @@ -485,12 +762,92 @@ export class LineUpRuntime { this.emit({ type: "scope-rejected", reason: scopeResolution.reason, raw: payload }); return; } - const route = this.toolRouter.route(payload, { app_scope: scopeResolution.scope.app_scope, conversation_id: this.requireConversationID() }); + const route = this.toolRouter.route(payload, { app_scope: scopeResolution.scope.app_scope, conversation_id: this.requireConversationID(), inventory_revision: this.inventoryRevision }); if (route.disposition === "rejected") { runtimeLog("tool.rejected", { reason: route.code }); this.emit({ type: "scope-rejected", reason: route.code === "scope_mismatch" ? "scope_mismatch" : "invalid_scope", raw: payload }); return; } + 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() }); this.store.replaceToolCalls(result.snapshot.tool_calls); this.store.replaceTasks(result.snapshot.tasks); @@ -507,26 +864,56 @@ export class LineUpRuntime { received_at: receivedAt, message_seq: message.message_seq, }; - const targetScope = route.disposition === "launch" ? route.app_scope : scopeResolution.scope.app_scope; - this.store.enqueueAppInbox({ - message_id: messageID, - app_scope: targetScope, - conversation_id: scopeResolution.scope.conversation_id, - item, - received_at: receivedAt, - message_seq: message.message_seq, - }); + const targetScope = route.disposition === "launch" || route.disposition === "miniapp" ? route.app_scope : scopeResolution.scope.app_scope; + if (route.disposition !== "interactive") { + this.store.enqueueAppInbox({ + message_id: messageID, + app_scope: targetScope, + conversation_id: scopeResolution.scope.conversation_id, + item, + received_at: receivedAt, + message_seq: message.message_seq, + ...(route.disposition === "miniapp" && miniAppInstanceID ? { instance_id: miniAppInstanceID } : {}), + }); + } 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") { + 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 }); if (launched.disposition === "foreground") { this.persistWorkspace(); runtimeLog("app.lifecycle", { action: "launched", app_scope: route.app_scope, instance_id: launched.instance.instance_id, prior_instance_id: launched.previous?.instance_id ?? "none" }); this.emit({ type: "app-lifecycle", workspace: this.workspaceSnapshot(), reason: "launched" }); + 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.emitState(); } @@ -538,7 +925,18 @@ export class LineUpRuntime { this.emit({ type: "delivery", local_id: entry.local_id, ...(item ? { item } : {}) }); 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.emitState(); } private handleOutboxFailure(entry: OutboxEntry, reason: unknown): void { @@ -564,6 +962,17 @@ export class LineUpRuntime { private chatView(): ChatViewModel { 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 { app_scope: "chat", conversation_id: this.currentConversationID(), @@ -571,15 +980,33 @@ export class LineUpRuntime { agent_presence: snapshot.agent_presence, tool_calls: snapshot.tool_calls, tasks: snapshot.tasks, + app_sessions: appSessions, }; } 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[] { - const entries = this.store.listAppInbox(appScope); + private listAppMessages(appScope: AppScope, afterMessageID?: string, instanceID?: string): readonly ScopedConversationItem[] { + const entries = this.store.listAppInbox(appScope, instanceID); const start = afterMessageID ? entries.findIndex(entry => entry.message_id === afterMessageID) + 1 : 0; return entries.slice(Math.max(0, start)).map(entry => ({ message_id: entry.message_id, @@ -603,10 +1030,43 @@ export class LineUpRuntime { 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 { 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 { const conversationID = this.currentConversationID(); 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."; } +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. */ function runtimeLog(event: string, fields: Record): void { console.info("[LineUp Runtime]", event, fields); diff --git a/tauri/src/runtime/coordination/miniapp-tool-schema.ts b/tauri/src/runtime/coordination/miniapp-tool-schema.ts new file mode 100644 index 0000000..fc23a73 --- /dev/null +++ b/tauri/src/runtime/coordination/miniapp-tool-schema.ts @@ -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)); +} diff --git a/tauri/src/runtime/coordination/miniapp-tool-state.test.ts b/tauri/src/runtime/coordination/miniapp-tool-state.test.ts new file mode 100644 index 0000000..62232a8 --- /dev/null +++ b/tauri/src/runtime/coordination/miniapp-tool-state.test.ts @@ -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" } }); + }); +}); diff --git a/tauri/src/runtime/coordination/miniapp-tool-state.ts b/tauri/src/runtime/coordination/miniapp-tool-state.ts new file mode 100644 index 0000000..e70ab9e --- /dev/null +++ b/tauri/src/runtime/coordination/miniapp-tool-state.ts @@ -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(); + + public receive(input: Omit & { 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; + 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(value: T): T { return JSON.parse(JSON.stringify(value)) as T; } +function copy(record: MiniAppToolCallRecord): MiniAppToolCallRecord { return clone(record); } diff --git a/tauri/src/runtime/coordination/standard-interaction-contract.ts b/tauri/src/runtime/coordination/standard-interaction-contract.ts new file mode 100644 index 0000000..3b627e0 --- /dev/null +++ b/tauri/src/runtime/coordination/standard-interaction-contract.ts @@ -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(); + 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" }; } diff --git a/tauri/src/runtime/coordination/tool-call-state.ts b/tauri/src/runtime/coordination/tool-call-state.ts index 35cc976..b11b6d2 100644 --- a/tauri/src/runtime/coordination/tool-call-state.ts +++ b/tauri/src/runtime/coordination/tool-call-state.ts @@ -17,6 +17,9 @@ export type ToolCallStatus = "pending" | "submitted" | ToolCallFinalStatus; export type ToolCallRecord = { call_id: string; conversation_id: string; + /** Runtime binding for ordinary MiniApp Tools; absent for Interact calls. */ + app_scope?: string; + instance_id?: string; request: ToolCallRequest; status: ToolCallStatus; created_at: string; diff --git a/tauri/src/runtime/coordination/tool-router.test.ts b/tauri/src/runtime/coordination/tool-router.test.ts index 7b8fb2f..6094053 100644 --- a/tauri/src/runtime/coordination/tool-router.test.ts +++ b/tauri/src/runtime/coordination/tool-router.test.ts @@ -13,6 +13,36 @@ function launch(scope = "draw-and-guess", extra: Record = {}) { } 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", () => { 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" }); diff --git a/tauri/src/runtime/coordination/tool-router.ts b/tauri/src/runtime/coordination/tool-router.ts index 7ceb025..8626a5b 100644 --- a/tauri/src/runtime/coordination/tool-router.ts +++ b/tauri/src/runtime/coordination/tool-router.ts @@ -4,26 +4,63 @@ * routed result and can never turn an arbitrary app.call into a launch. */ import type { AppManifest, AppScope, CoreAppRegistry } from "@/runtime/app-management/app-registry"; -import { parseEnvelopeJSON, type Envelope, type JsonObject } from "@/runtime/protocol/lineup-v1"; +import { parseEnvelopeJSON, type Envelope, type JsonObject, type MiniAppToolInvoke } from "@/runtime/protocol/lineup-v1"; +import { validateMiniAppToolSchema } from "@/runtime/coordination/miniapp-tool-schema"; export type ToolRoute = | { disposition: "pass" } + | { disposition: "interactive"; call_id: string; app_session_id?: string } + | { disposition: "dismiss"; call_id: string; control_id: string; reason?: string } + | { disposition: "miniapp"; call: MiniAppToolInvoke; handling: "direct" | "launch" | "foreground" | "operation"; app_scope: AppScope; instance_id?: string; requires_foreground: boolean; restore_previous_focus: boolean } | { disposition: "launch"; call_id: string; app_scope: AppScope; instance_id?: string; arguments: JsonObject } - | { disposition: "rejected"; code: "invalid_request" | "scope_mismatch" | "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 { public constructor(private readonly apps: CoreAppRegistry) {} /** Returns pass for ordinary protocol messages, preserving 00.base handling. */ - public route(raw: string, expected: { conversation_id: string; app_scope: AppScope }): ToolRoute { + public route(raw: string, expected: { conversation_id: string; app_scope: AppScope; inventory_revision?: string }): ToolRoute { const parsed = parseEnvelopeJSON(raw); if (!parsed.ok) return { disposition: "pass" }; const envelope = parsed.envelope; + if (envelope.type === "lineup.v1.interaction.dismiss") return this.routeDismiss(envelope, expected.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.conversation_id !== expected.conversation_id) return { disposition: "rejected", code: "scope_mismatch" }; 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 { const callID = text(envelope.payload.call_id); const args = object(envelope.payload.arguments); @@ -32,7 +69,7 @@ export class ToolRouter { const record = this.apps.get(requestedScope); if (!record) return { disposition: "rejected", code: "not_installed" }; if (!record.enabled) return { disposition: "rejected", code: "disabled" }; - const definition = record.manifest.tools.find(tool => tool.name === "app.launch" && tool.handling === "launch"); + const definition = record.manifest.tools.find(tool => tool.handling === "launch"); if (!definition) return { disposition: "rejected", code: "manifest_denied" }; if (definition.requires_permission && !record.manifest.permissions.includes(definition.requires_permission)) return { disposition: "rejected", code: "permission_denied" }; const instanceID = args.instance_id === undefined ? undefined : text(args.instance_id); @@ -41,6 +78,18 @@ export class ToolRouter { if (!parameters) return { disposition: "rejected", code: "invalid_request" }; return { disposition: "launch", call_id: callID, app_scope: record.app_scope, ...(instanceID ? { instance_id: instanceID } : {}), arguments: parameters }; } + + private routeInteractive(envelope: Envelope, 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; } diff --git a/tauri/src/runtime/inventory/client-inventory.ts b/tauri/src/runtime/inventory/client-inventory.ts index 0a5b7fe..88760e0 100644 --- a/tauri/src/runtime/inventory/client-inventory.ts +++ b/tauri/src/runtime/inventory/client-inventory.ts @@ -6,11 +6,11 @@ */ import type { CapabilityDefinition } from "@/runtime/capabilities/capability-registry"; import type { SurfaceRegistrySnapshot } from "@/runtime/surfaces/surface-registry"; -import type { CoreAppRecord } from "@/runtime/app-management/app-registry"; +import type { CoreAppRecordInput } from "@/runtime/app-management/app-registry"; export type ClientInventory = Readonly<{ 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<{ id: string; version: string; @@ -31,7 +31,7 @@ export class ClientInventoryPublisher { private previousFingerprint = ""; 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 .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 => ({ @@ -50,6 +50,7 @@ export class ClientInventoryPublisher { this.current = { revision: `catalog-${this.revision}`, standard_components: [ + { id: "notice", version: "1" }, { id: "choice", version: "1" }, { id: "confirm", version: "1" }, { id: "input", version: "1" }, diff --git a/tauri/src/runtime/persistence/conversation-store.test.ts b/tauri/src/runtime/persistence/conversation-store.test.ts index ea0980f..fefe2bb 100644 --- a/tauri/src/runtime/persistence/conversation-store.test.ts +++ b/tauri/src/runtime/persistence/conversation-store.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import type { ToolCallRecord } from "@/runtime/coordination/tool-call-state"; +import type { MiniAppToolCallRecord } from "@/runtime/coordination/miniapp-tool-state"; import type { TaskRecord } from "@/runtime/coordination/task-state"; import { ConversationStore } from "@/runtime/persistence/conversation-store"; @@ -106,7 +107,7 @@ describe("ConversationStore", () => { expect(snapshot.tool_drafts).toEqual({ preserved: { value: "yes" } }); expect(snapshot.tasks).toEqual([expect.objectContaining({ operation_id: "task" })]); expect(snapshot.app_inbox).toEqual([]); - expect(storage.getItem(key)).toContain('"version":12'); + expect(storage.getItem(key)).toContain('"version":14'); }); 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"); }); + 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", () => { const storage = new MemoryStorage(); const store = new ConversationStore(storage); diff --git a/tauri/src/runtime/persistence/conversation-store.ts b/tauri/src/runtime/persistence/conversation-store.ts index e6a4e2b..7ab3f7c 100644 --- a/tauri/src/runtime/persistence/conversation-store.ts +++ b/tauri/src/runtime/persistence/conversation-store.ts @@ -12,6 +12,8 @@ import type { SurfaceInstanceSnapshot } from "@/runtime/surfaces/surface-instanc import { sanitizeCapabilityReason, type CapabilityCallRecord } from "@/runtime/capabilities/capability-call-state"; import type { AppScope } from "@/runtime/app-management/app-registry"; import type { AppWorkspaceSnapshot } from "@/runtime/app-management/app-lifecycle-manager"; +import type { StandardInteractionRecord } from "@/runtime/coordination/agent-interaction-service"; +import type { MiniAppToolCallRecord } from "@/runtime/coordination/miniapp-tool-state"; export type StoredConversationItem = { local_id: string; @@ -38,6 +40,7 @@ export type AppInboxEntry = { item: ConversationItem; received_at: string; message_seq?: number; + instance_id?: string; }; export type ConversationSnapshot = { @@ -53,12 +56,14 @@ export type ConversationSnapshot = { surfaces: SurfaceInstanceSnapshot; capability_calls: readonly CapabilityCallRecord[]; app_inbox: readonly AppInboxEntry[]; + standard_interactions: readonly StandardInteractionRecord[]; + miniapp_tool_calls: readonly MiniAppToolCallRecord[]; /** Runtime-owned instance/focus state. Never supplied by an App. */ app_workspace: AppWorkspaceSnapshot; }; type PersistedConversation = { - version: 12; + version: 14; cursor: number; items: StoredConversationItem[]; outbox: OutboxEntry[]; @@ -69,6 +74,8 @@ type PersistedConversation = { surfaces: SurfaceInstanceSnapshot; capability_calls: CapabilityCallRecord[]; app_inbox: AppInboxEntry[]; + standard_interactions: StandardInteractionRecord[]; + miniapp_tool_calls: MiniAppToolCallRecord[]; app_workspace: AppWorkspaceSnapshot; }; @@ -116,6 +123,8 @@ export class ConversationStore { surfaces: clone(this.state.surfaces), capability_calls: clone(this.state.capability_calls), app_inbox: this.state.app_inbox.map(entry => ({ ...entry })), + standard_interactions: clone(this.state.standard_interactions), + miniapp_tool_calls: clone(this.state.miniapp_tool_calls), app_workspace: clone(this.state.app_workspace), }; } @@ -177,20 +186,33 @@ export class ConversationStore { return true; } - public listAppInbox(appScope: AppScope): readonly AppInboxEntry[] { + public listAppInbox(appScope: AppScope, instanceID?: string): readonly AppInboxEntry[] { 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 })); } - public acknowledgeAppInbox(appScope: AppScope, messageID: string): boolean { + public acknowledgeAppInbox(appScope: AppScope, messageID: string, instanceID?: string): boolean { const before = this.state.app_inbox.length; - this.state.app_inbox = this.state.app_inbox.filter(entry => !(entry.app_scope === appScope && entry.message_id === messageID)); + this.state.app_inbox = this.state.app_inbox.filter(entry => !(entry.app_scope === appScope && entry.message_id === messageID + && (instanceID === undefined ? true : entry.instance_id === instanceID))); if (this.state.app_inbox.length === before) return false; this.persist(); return true; } + /** Replaces the Runtime-owned Agent/Interact interaction projection. */ + public replaceStandardInteractions(records: readonly StandardInteractionRecord[]): void { + this.state.standard_interactions = records.map(record => clone(record)); + this.persist(); + } + + /** Replaces the Runtime-owned ordinary MiniApp Tool projection. */ + public replaceMiniAppToolCalls(records: readonly MiniAppToolCallRecord[]): void { + this.state.miniapp_tool_calls = records.map(record => clone(record)); + this.persist(); + } + public appendLocalText(localID: string, text: string, createdAt: string): StoredConversationItem { const delivery: DeliveryState = { status: "local_pending", updated_at: createdAt }; const stored: StoredConversationItem = { @@ -302,8 +324,8 @@ export class ConversationStore { try { const parsed: unknown = JSON.parse(this.storage.getItem(this.key(conversationID)) ?? "null"); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return emptyConversation(); - const candidate = parsed as { version?: unknown; cursor?: unknown; items?: unknown; outbox?: unknown; tool_calls?: unknown; tool_drafts?: unknown; tasks?: unknown; execution_summaries?: unknown; surfaces?: unknown; capability_calls?: unknown; app_inbox?: unknown; app_workspace?: unknown }; - if ((candidate.version !== 1 && candidate.version !== 2 && candidate.version !== 3 && candidate.version !== 4 && candidate.version !== 5 && candidate.version !== 6 && candidate.version !== 7 && candidate.version !== 8 && candidate.version !== 9 && candidate.version !== 10 && candidate.version !== 11 && candidate.version !== 12) || !Array.isArray(candidate.items) || !Array.isArray(candidate.outbox) || typeof candidate.cursor !== "number") { + const candidate = parsed as { version?: unknown; cursor?: unknown; items?: unknown; outbox?: unknown; tool_calls?: unknown; tool_drafts?: unknown; tasks?: unknown; execution_summaries?: unknown; surfaces?: unknown; capability_calls?: unknown; app_inbox?: unknown; standard_interactions?: unknown; miniapp_tool_calls?: unknown; app_workspace?: unknown }; + if ((candidate.version !== 1 && candidate.version !== 2 && candidate.version !== 3 && candidate.version !== 4 && candidate.version !== 5 && candidate.version !== 6 && candidate.version !== 7 && candidate.version !== 8 && candidate.version !== 9 && candidate.version !== 10 && candidate.version !== 11 && candidate.version !== 12 && candidate.version !== 13 && candidate.version !== 14) || !Array.isArray(candidate.items) || !Array.isArray(candidate.outbox) || typeof candidate.cursor !== "number") { return emptyConversation(); } @@ -312,7 +334,7 @@ export class ConversationStore { // the Runtime-owned App Inbox without discarding any already durable // tool, task, Surface, capability, or execution projection. return { - version: 12, + version: 14, cursor: Math.max(0, candidate.cursor), items: normalizeStoredItems(candidate.items), outbox: normalizeOutbox(candidate.outbox), @@ -323,18 +345,20 @@ export class ConversationStore { surfaces: normalizeSurfaces(candidate.surfaces), capability_calls: normalizeCapabilityCalls(candidate.capability_calls), app_inbox: normalizeAppInbox(candidate.app_inbox, conversationID), + standard_interactions: [], + miniapp_tool_calls: [], app_workspace: emptyAppWorkspace(), }; } - if (candidate.version !== 12) { + if (candidate.version !== 12 && candidate.version !== 13 && candidate.version !== 14) { // v1 advanced its cursor from a send acknowledgement. Some browsers // had already been upgraded to v2 before that correction, retaining a // high cursor with holes for their own echoes. Preserve every valid // local item but replay the server history once to repair those holes; // the next persist upgrades this record to v5. return { - version: 12, + version: 14, cursor: candidate.version === 4 || candidate.version === 5 || candidate.version === 6 || candidate.version === 7 ? Math.max(0, candidate.cursor) : 0, items: normalizeStoredItems(candidate.items), outbox: normalizeOutbox(candidate.outbox), @@ -346,11 +370,13 @@ export class ConversationStore { surfaces: emptySurfaces(), capability_calls: [], app_inbox: [], + standard_interactions: [], + miniapp_tool_calls: [], app_workspace: emptyAppWorkspace(), }; } return { - version: 12, + version: 14, cursor: Math.max(0, candidate.cursor), items: normalizeStoredItems(candidate.items), outbox: normalizeOutbox(candidate.outbox), @@ -361,6 +387,8 @@ export class ConversationStore { surfaces: normalizeSurfaces(candidate.surfaces), capability_calls: normalizeCapabilityCalls(candidate.capability_calls), app_inbox: normalizeAppInbox(candidate.app_inbox, conversationID), + standard_interactions: normalizeStandardInteractions(candidate.standard_interactions, conversationID), + miniapp_tool_calls: normalizeMiniAppToolCalls(candidate.miniapp_tool_calls, conversationID), app_workspace: normalizeAppWorkspace(candidate.app_workspace), }; } catch { @@ -383,7 +411,7 @@ export class ConversationStore { } 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: [] }; } @@ -424,10 +452,45 @@ function normalizeAppInbox(value: unknown, conversationID: string): AppInboxEntr item: entry.item as ConversationItem, received_at: entry.received_at, ...(typeof entry.message_seq === "number" ? { message_seq: entry.message_seq } : {}), + ...(typeof entry.instance_id === "string" ? { instance_id: entry.instance_id } : {}), }]; }); } +function normalizeStandardInteractions(value: unknown, conversationID: string): StandardInteractionRecord[] { + if (!Array.isArray(value)) return []; + const seen = new Set(); + return value.flatMap(raw => { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return []; + const record = raw as Partial; + 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(); + return value.flatMap(raw => { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return []; + const record = raw as Partial; + if (typeof record.call_id !== "string" || !record.call_id || seen.has(record.call_id) + || typeof record.tool_id !== "string" || typeof record.inventory_revision !== "string" + || typeof record.app_scope !== "string" || record.conversation_id !== conversationID + || !record.input || typeof record.input !== "object" || Array.isArray(record.input) + || typeof record.status !== "string" || typeof record.created_at !== "string" || Number.isNaN(Date.parse(record.created_at)) + || typeof record.updated_at !== "string" || Number.isNaN(Date.parse(record.updated_at))) return []; + seen.add(record.call_id); + return [clone(record as MiniAppToolCallRecord)]; + }); +} + /** * Artifact content is delivery-only data for the host-owned volatile cache. * It must never survive in the durable conversation transcript, including diff --git a/tauri/src/runtime/protocol/lineup-v1.ts b/tauri/src/runtime/protocol/lineup-v1.ts index 75fbb70..c7e365c 100644 --- a/tauri/src/runtime/protocol/lineup-v1.ts +++ b/tauri/src/runtime/protocol/lineup-v1.ts @@ -43,7 +43,7 @@ export type DeliveryState = | { status: "failed"; updated_at: string; error: string; retryable: boolean }; /** The only standard interaction requests admitted during Milestone 1. */ -export type ToolCallKind = "choice" | "confirm" | "input"; +export type ToolCallKind = "notice" | "choice" | "confirm" | "input"; export type ActionGroupMode = "single-choice" | "multi-choice" | "button"; @@ -63,6 +63,11 @@ export type ConfirmDefinition = { cancel_label: string; }; +export type NoticeDefinition = { + message: string; + dismiss_label?: string; +}; + export type InputFieldType = "text" | "textarea" | "number"; export type InputField = { @@ -91,12 +96,24 @@ export type ToolCallRequest = { data: JsonObject; /** Present only for the trusted `choice` renderer introduced in M1-02. */ action_group?: ActionGroup; + /** Present only for the trusted `notice` renderer introduced in SDK v1. */ + notice?: NoticeDefinition; /** Present only for the trusted `confirm` renderer introduced in M1-03. */ confirm?: ConfirmDefinition; /** Present only for the trusted `input` renderer introduced in M1-03. */ form?: InputForm; }; +/** A non-interactive Tool invocation delivered to a bundled MiniApp. */ +export type MiniAppToolInvoke = { + call_id: string; + tool_id: string; + app_scope: string; + inventory_revision: string; + input: JsonObject; + instance_id?: string; +}; + export type ToolCallFinalStatus = "completed" | "failed" | "cancelled" | "expired"; export type ToolCallResult = { @@ -195,6 +212,7 @@ export type ConversationItem = | (ConversationItemBase & { kind: "progress"; title: string; percent: number; status: string; task?: TaskProgress }) | (ConversationItemBase & { kind: "error"; message: string; code?: string }) | (ConversationItemBase & { kind: "tool-call"; call: ToolCallRequest }) + | (ConversationItemBase & { kind: "miniapp-tool-call"; call: MiniAppToolInvoke }) | (ConversationItemBase & { kind: "tool-result"; result: ToolCallResult }) | (ConversationItemBase & { kind: "tool-cancel"; cancel: ToolCallCancel }) | (ConversationItemBase & { kind: "surface"; envelope: Envelope }) @@ -214,6 +232,7 @@ const SUPPORTED_TYPES = new Set([ "lineup.v1.agent.progress", "lineup.v1.error", "lineup.v1.tool.call", + "lineup.v1.miniapp.tool.call", "lineup.v1.tool.result", "lineup.v1.tool.cancel", "lineup.v1.ui.open", @@ -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)); } -const TOOL_KINDS = new Set(["choice", "confirm", "input"]); +const TOOL_KINDS = new Set(["notice", "choice", "confirm", "input"]); const TOOL_FINAL_STATUSES = new Set(["completed", "failed", "cancelled", "expired"]); const ACTION_GROUP_MODES = new Set(["single-choice", "multi-choice", "button"]); const INPUT_FIELD_TYPES = new Set(["text", "textarea", "number"]); @@ -391,6 +410,14 @@ function parseConfirm(value: unknown): ConfirmDefinition | undefined { return approve && cancel ? { approve_label: approve, cancel_label: cancel } : undefined; } +function parseNotice(value: unknown): NoticeDefinition | undefined { + const candidate = object(value); + if (!candidate) return undefined; + const message = text(candidate.message).trim(); + const dismiss = candidate.dismiss_label === undefined ? undefined : boundedLabel(candidate.dismiss_label, "关闭"); + return message && (candidate.dismiss_label === undefined || dismiss) ? { message, ...(dismiss ? { dismiss_label: dismiss } : {}) } : undefined; +} + function wholeNumber(value: unknown, maximum: number): number | undefined { return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= maximum ? value : undefined; } @@ -439,9 +466,11 @@ function parseToolCall(payload: JsonObject): ToolCallRequest | undefined { if (payload.expires_at !== undefined && !expiresAt) return undefined; const data = payload.data === undefined ? {} : object(payload.data); if (!data) return undefined; + const notice = tool === "notice" ? parseNotice(data.notice ?? data) : undefined; const actionGroup = tool === "choice" ? parseActionGroup(data.action_group) : undefined; const confirm = tool === "confirm" ? parseConfirm(data.confirm) : undefined; const form = tool === "input" ? parseInputForm(data.form) : undefined; + if (tool === "notice" && !notice) return undefined; if (tool === "choice" && !actionGroup) return undefined; if (tool === "confirm" && !confirm) return undefined; if (tool === "input" && !form) return undefined; @@ -452,12 +481,25 @@ function parseToolCall(payload: JsonObject): ToolCallRequest | undefined { prompt: text(payload.prompt), ...(expiresAt ? { expires_at: expiresAt } : {}), data, + ...(notice ? { notice } : {}), ...(actionGroup ? { action_group: actionGroup } : {}), ...(confirm ? { confirm } : {}), ...(form ? { form } : {}), }; } +function parseMiniAppToolInvoke(payload: JsonObject): MiniAppToolInvoke | undefined { + const callID = identifier(payload.call_id); + const toolID = identifier(payload.tool_id); + const appScope = text(payload.app_scope); + const inventoryRevision = identifier(payload.inventory_revision); + const input = object(payload.input); + const instanceID = payload.instance_id === undefined ? undefined : identifier(payload.instance_id); + if (!callID || !toolID || !appScope || !inventoryRevision || !input || (payload.instance_id !== undefined && !instanceID)) return undefined; + if (Object.keys(payload).some(key => !["call_id", "tool_id", "app_scope", "inventory_revision", "input", "instance_id"].includes(key))) return undefined; + return { call_id: callID, tool_id: toolID, app_scope: appScope, inventory_revision: inventoryRevision, input, ...(instanceID ? { instance_id: instanceID } : {}) }; +} + function parseTaskProgress(payload: JsonObject, percent: number): TaskProgress | undefined { const operationID = identifier(payload.operation_id); const status = text(payload.status) as TaskStatus; @@ -566,6 +608,10 @@ export function decodeConversationItem(raw: string): ConversationItem { const call = parseToolCall(payload); return call ? { kind: "tool-call", call, ...base } : fallbackItem({ code: "invalid_payload", detail: "Tool call requires call_id, supported tool, and JSON data.", envelope }); } + case "lineup.v1.miniapp.tool.call": { + const call = parseMiniAppToolInvoke(payload); + return call ? { kind: "miniapp-tool-call", call, ...base } : fallbackItem({ code: "invalid_payload", detail: "MiniApp Tool call requires call_id, tool_id, app_scope, inventory_revision, and JSON input.", envelope }); + } case "lineup.v1.tool.result": { const result = parseToolResult(payload); return result ? { kind: "tool-result", result, ...base } : fallbackItem({ code: "invalid_payload", detail: "Tool result requires call_id, final status, and JSON result.", envelope }); diff --git a/tauri/src/runtime/surfaces/isolated-surface-host.ts b/tauri/src/runtime/surfaces/isolated-surface-host.ts index 6535832..108d3f6 100644 --- a/tauri/src/runtime/surfaces/isolated-surface-host.ts +++ b/tauri/src/runtime/surfaces/isolated-surface-host.ts @@ -52,7 +52,7 @@ export class IsolatedSurfaceHost { // Development uses the static dashboard. A production resolver can return // only bytes that were already signature/hash verified by M4-02; a missing // exact version is a safe no-op rather than a fallback to unverified code. - const resolvedDocument = this.documents ? this.documents.documentFor(instance) : surfaceDocument(instance.instance_id); + const resolvedDocument = this.documents ? this.documents.documentFor(instance) : surfaceDocument(instance.instance_id, instance.app_id); if (!resolvedDocument) return; const frame = document.createElement("iframe"); frame.className = "isolated-surface-frame"; @@ -146,7 +146,7 @@ function parseBridgeMessage(value: unknown): SurfaceReadyMessage | SurfaceEventM } /** 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); return `

任务面板

等待状态

    `; } diff --git a/tauri/src/runtime/surfaces/surface-registry.ts b/tauri/src/runtime/surfaces/surface-registry.ts index 4d77f39..3b88d66 100644 --- a/tauri/src/runtime/surfaces/surface-registry.ts +++ b/tauri/src/runtime/surfaces/surface-registry.ts @@ -88,6 +88,16 @@ export const DEVELOPMENT_SURFACE_MANIFESTS: readonly SurfaceManifest[] = Object. local_bundle_bytes: 0, development_only: true, }), + Object.freeze({ + app_id: "lineup.whiteboard", + version: "0.1.0", + bundle_id: "lineup.whiteboard.dev.v1", + state_schema_version: 1, + max_state_bytes: 32 * 1024, + max_bundle_bytes: 256 * 1024, + local_bundle_bytes: 0, + development_only: true, + }), ]); /** diff --git a/迭代/03.sdk_and_coreapp/03.sdk_and_coreapp.md b/迭代/03.sdk_and_coreapp/03.sdk_and_coreapp.md index df9b343..a4630f9 100644 --- a/迭代/03.sdk_and_coreapp/03.sdk_and_coreapp.md +++ b/迭代/03.sdk_and_coreapp/03.sdk_and_coreapp.md @@ -1,7 +1,7 @@ # LineUp App 迭代定义:MiniApp SDK v1 与内置参考 MiniApp **迭代编号:** 03.sdk_and_coreapp -**状态:** 目标设计,待实现 +**状态:** 实现进行中(核心 Runtime / SDK 闭环已完成,真实 Host 浏览器验收与最终提交尚未完成) **日期:** 2026-08-05 **前置基线:** [00.base.md](../00.base/00.base.md)、[01.kernel.md](../01.kernel/01.kernel.md) **权威架构:** [APP架构设计.md](../../APP架构设计.md) @@ -911,6 +911,10 @@ Whiteboard instance 启动时创建自己的 App 子会话;Agent 与用户围 ## 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: ```text