/** * LineUp Runtime 的唯一协调器。 * * Runtime 独占 Transport、Conversation Store、sync loop 和 outbox;它在入站时完成旧协议 * 兼容、scope 校验、去重、持久化和 App Inbox 投递,再经 SDK 向 Chat 提供受限投影。 */ import { parseEnvelopeJSON, type Actor, type ConversationItem, type Envelope, type JsonObject, type JsonValue, type ToolCallRequest, type MiniAppToolInvoke, } from "@/runtime/protocol/lineup-v1"; import { CoreAppRegistry, type AppScope } from "@/runtime/app-management/app-registry"; import { type AgentChatMessage, type ChatAction, type ChatRuntimeSDK, type ChatViewModel, type CommandReceipt, type ExtensionRuntimeSDK, type RuntimeAppEvent, type Unsubscribe, } from "@/runtime/app-management/app-sdk"; import { ConversationStore, type ConversationSnapshot, type OutboxEntry, } from "@/runtime/persistence/conversation-store"; import { InteractionKernel, type KernelSnapshot } from "@/runtime/coordination/interaction-kernel"; import { resolveIncomingScope, type ScopedConversationItem } from "@/runtime/coordination/runtime-envelope"; import { HttpTransportAdapter, type LoginRequest, type LoginResult, type TransportAdapter, type TransportMessage, } from "@/runtime/communication/transport-adapter"; import type { SurfaceInstanceSnapshot } from "@/runtime/surfaces/surface-instance-manager"; import type { CapabilityCallRecord } from "@/runtime/capabilities/capability-call-state"; import type { ExecutionProgressSummary } from "@/runtime/coordination/execution-progress-state"; import type { TaskRecord } from "@/runtime/coordination/task-state"; import type { ToolCallRecord } from "@/runtime/coordination/tool-call-state"; import { AppLifecycleManager, type AppWorkspaceSnapshot } from "@/runtime/app-management/app-lifecycle-manager"; import { AppOrchestrator } from "@/runtime/coordination/app-orchestrator"; import { ToolRouter } from "@/runtime/coordination/tool-router"; 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"; import { CapabilityRegistry } from "@/runtime/capabilities/capability-registry"; import { CapabilityAuditLog } from "@/runtime/capabilities/capability-audit"; export type RuntimeSession = { api: string; uid: string; agent_uid: string; channel_id: string; channel_type: number; last_seq: number; active: boolean; }; export type RuntimeLoginRequest = LoginRequest & { api: string; fallback_agent_uid: string; fallback_channel_id: string; fallback_channel_type: number; }; export type RuntimeDependencies = { storage: Storage; createTransport?: (api: string) => TransportAdapter; now?: () => string; newID?: (prefix: string) => string; sleep?: (milliseconds: number) => Promise; /** Host-only sanitizer/cache hook; Runtime still owns persistence/routing. */ prepareIncoming?: (item: ConversationItem) => ConversationItem | undefined; }; const CONTROL_ECHO_TYPES = new Set([ "lineup.v1.tool.result", "lineup.v1.tool.cancel", "lineup.v1.ui.event", "lineup.v1.client.inventory", "lineup.v1.app.result", "lineup.v1.app.session.opened", "lineup.v1.app.session.closed", ]); /** * The only owner of Transport, ConversationStore, sync loop and outbox. * * It deliberately has no DOM dependency. A Core App gets a narrow SDK view; * host rendering and platform-specific artifact cache hooks are injected at * the edge instead of letting the Chat UI depend on transport/storage. */ export class LineUpRuntime { public readonly apps = new CoreAppRegistry(); public readonly lifecycle = new AppLifecycleManager(); private readonly store: ConversationStore; private readonly kernel = new InteractionKernel(); private readonly 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>(); private readonly createTransport: (api: string) => TransportAdapter; private readonly now: () => string; private readonly newID: (prefix: string) => string; private readonly sleep: (milliseconds: number) => Promise; private readonly prepareIncoming: (item: ConversationItem) => ConversationItem | undefined; private readonly orchestrator: AppOrchestrator; private readonly toolRouter: ToolRouter; private readonly capabilityRegistry = new CapabilityRegistry(); private readonly capabilityAudit = new CapabilityAuditLog(); /** SDK v1 inventory revision advertised by this Runtime instance. */ private readonly inventoryRevision = "catalog-1"; private transport: TransportAdapter | undefined; private session: RuntimeSession = { api: "", uid: "", agent_uid: "", channel_id: "", channel_type: 2, last_seq: 0, active: false, }; private syncing = false; private syncRequested = false; public constructor(dependencies: RuntimeDependencies) { this.store = new ConversationStore(dependencies.storage); this.createTransport = dependencies.createTransport ?? (api => new HttpTransportAdapter(api)); this.now = dependencies.now ?? (() => new Date().toISOString()); this.newID = dependencies.newID ?? defaultID; this.sleep = dependencies.sleep ?? (milliseconds => new Promise(resolve => globalThis.setTimeout(resolve, milliseconds))); this.prepareIncoming = dependencies.prepareIncoming ?? (item => item); this.orchestrator = new AppOrchestrator(this.lifecycle, this.apps, this.newID); this.toolRouter = new ToolRouter(this.apps); } public getSession(): RuntimeSession { return { ...this.session }; } public currentConversationID(): string | undefined { return this.session.active ? `${this.session.uid}:${this.session.channel_type}:${this.session.channel_id}` : undefined; } public snapshot(): ConversationSnapshot | undefined { return this.session.active ? this.store.snapshot() : undefined; } public kernelSnapshot(): KernelSnapshot { return this.kernel.snapshot(); } public standardInteractions(): readonly StandardInteractionRecord[] { return this.interactionService.list(); } public miniAppToolCalls(): readonly MiniAppToolCallRecord[] { return this.miniappTools.list(); } public capabilityAuditEntries() { return this.capabilityAudit.snapshot(); } /** 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 }); this.emit({ type: "app-lifecycle", workspace: this.workspaceSnapshot(), reason: error ? "failed" : "closed" }); } public onEvent(listener: (event: RuntimeAppEvent) => void): Unsubscribe { this.listeners.add(listener); return () => this.listeners.delete(listener); } /** * Opens the SDK projection for a Runtime-registered Core App. * * The first SDK implementation is still Chat-shaped, but the selection key * now comes from the App Registry rather than from a Chat-specific Runtime * entry point. New App SDK projections can be added behind this boundary. */ public openApp(appScope: AppScope): ChatRuntimeSDK { const app = this.apps.get(appScope); if (!app?.enabled) throw new Error(`The ${appScope} Core App is unavailable.`); if (appScope !== "chat") throw new Error(`The ${appScope} Core App SDK is not registered.`); return { app_scope: "chat", snapshot: () => this.chatView(), subscribe: listener => { this.chatStateListeners.add(listener); listener(this.chatView()); return () => this.chatStateListeners.delete(listener); }, subscribeEvents: listener => this.onEvent(event => { // Agent content has a separate, scope-checked SDK channel. Keeping it // out of the generic event stream prevents a Chat renderer from // accidentally consuming an unprojected delivery path. if (event.type !== "agent-message") listener(event); }), subscribeAgentMessages: listener => { this.chatMessageListeners.add(listener); return () => this.chatMessageListeners.delete(listener); }, listAgentMessages: afterMessageID => this.listChatMessages(afterMessageID), acknowledgeAgentMessage: messageID => this.acknowledgeChatMessage(messageID), dispatch: action => this.dispatchChatAction(action), interactions: { submit: (callID, result) => this.submitToolCall(callID, result), cancel: (callID, reason) => this.cancelToolCall(callID, reason), expire: callID => this.expireToolCall(callID), read: callID => this.toolCalls().find(record => record.call_id === callID), loadDraft: callID => this.getToolDraft(callID), saveDraft: (callID, values) => this.saveToolDraft(callID, values), clearDraft: callID => this.clearToolDraft(callID), }, tasks: { requestCancel: operationID => this.requestTaskCancel(operationID), read: operationID => this.tasks().find(task => task.operation_id === operationID), }, }; } /** Opens a per-instance Extension projection after Runtime has started it. */ public openExtensionApp(appScope: AppScope, instanceID: string): ExtensionRuntimeSDK { const app = this.apps.get(appScope); const instance = this.lifecycle.instances.get(instanceID); const conversationID = this.requireConversationID(); if (!app || app.kind !== "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, listMessages: afterMessageID => this.listAppMessages(appScope, afterMessageID), acknowledgeMessage: messageID => this.store.acknowledgeAppInbox(appScope, messageID), reportResult: result => this.queueProtocolAction("app-result", "lineup.v1.app.result", { instance_id: instanceID, app_scope: appScope, result }), reportFailure: message => this.closeAppInstance(instanceID, boundedFailure(message)), }; } /** 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; const inboxEntry = this.store.listAppInbox(appScope, instanceID) .find(entry => entry.message_id === event.message.message_id); if (!inboxEntry) 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.requestMiniAppCapability(instanceID, name, reason, input), }, }; } /** Host-only entry point used by the isolated bundled Surface bootstrap. */ public openBundledSurface(appScope: AppScope, instanceID: string): CommandReceipt { const state: JsonObject = appScope === "whiteboard" ? { title: "Whiteboard", status: "可编辑", elements: [] } : { title: "任务面板", status: "等待 Agent 任务", percent: 0, steps: [] }; return this.requestBundledSurface(appScope, instanceID, "open", { state }); } /** * The only parent-side endpoint exposed to an isolated bundled MiniApp. * It returns plain data and CommandReceipt values; Runtime objects never * cross the iframe boundary. */ public async handleMiniAppBridgeRequest(instanceID: string, method: string, payload: JsonObject): Promise { const instance = this.lifecycle.instances.get(instanceID); const conversationID = this.currentConversationID(); if (!this.session.active || !instance || !conversationID || instance.conversation_id !== conversationID || !["starting", "foreground", "background", "suspended"].includes(instance.state)) { throw new Error("invalid_miniapp_instance"); } const text = (key: string): string | undefined => typeof payload[key] === "string" && String(payload[key]).trim() ? String(payload[key]) : undefined; const callID = text("call_id"); if (method === "tools.list") { return { calls: this.miniappTools.list().filter(call => call.instance_id === instanceID && call.conversation_id === conversationID) }; } if (method === "inbox.list") { return { messages: JSON.parse(JSON.stringify(this.listAppMessages(instance.app_scope, undefined, instanceID))) as JsonValue[] }; } if (method === "inbox.acknowledge") { const messageID = text("message_id"); if (!messageID) throw new Error("invalid_message_id"); return { acknowledged: this.store.acknowledgeAppInbox(instance.app_scope, messageID, instanceID) }; } if (method === "tools.reportProgress") { if (!callID || !isMiniAppToolProgress(payload.progress)) throw new Error("invalid_progress"); return this.bridgeReceipt(await this.reportMiniAppToolProgress(instanceID, callID, payload.progress)); } if (method === "tools.complete") { if (!callID || !isJsonObject(payload.result)) throw new Error("invalid_tool_result"); return this.bridgeReceipt(await this.completeMiniAppTool(instanceID, callID, payload.result)); } if (method === "tools.fail") { if (!callID || typeof payload.message !== "string") throw new Error("invalid_tool_failure"); return this.bridgeReceipt(await this.failMiniAppTool(instanceID, callID, { code: text("code"), message: payload.message })); } if (method === "tools.cancel") { if (!callID) throw new Error("invalid_tool_call"); return this.bridgeReceipt(await this.cancelMiniAppTool(instanceID, callID, text("reason") ?? "app_cancelled")); } if (method === "lifecycle.foreground") { this.lifecycle.foreground(instanceID, this.now()); this.persistWorkspace(); this.emit({ type: "app-lifecycle", workspace: this.workspaceSnapshot(), reason: "launched" }); return { disposition: "accepted" }; } if (method === "lifecycle.background") { this.lifecycle.background(instanceID, this.now()); this.persistWorkspace(); this.emit({ type: "app-lifecycle", workspace: this.workspaceSnapshot(), reason: "launched" }); return { disposition: "accepted" }; } if (method === "lifecycle.close") { this.closeAppInstance(instanceID, text("reason")); return { disposition: "accepted" }; } if (method === "surface.patch" || method === "surface.close") { const event = method === "surface.patch" ? "patch" : "close"; const data = method === "surface.patch" && isJsonObject(payload.data) ? payload.data : {}; return this.bridgeReceipt(this.requestBundledSurface(instance.app_scope, instanceID, event, data)); } if (method === "capabilities.request") { const name = text("name"); const reason = text("reason"); if (!name || !reason || !isJsonObject(payload.input)) throw new Error("invalid_capability_request"); return this.bridgeReceipt(await this.requestMiniAppCapability(instanceID, name, reason, payload.input)); } throw new Error("unsupported_bridge_method"); } private bridgeReceipt(receipt: CommandReceipt): JsonObject { return receipt.disposition === "accepted" ? { disposition: "accepted", ...(receipt.local_id ? { local_id: receipt.local_id } : {}) } : { disposition: "rejected", code: receipt.code }; } /** * Capability requests are proposals, not direct Host handles. Runtime checks * the installed Manifest, local Registry/Policy, argument schema and lifecycle * before putting a request on the normal Agent confirmation path. */ private requestMiniAppCapability(instanceID: string, name: string, reason: string, input: JsonObject): Promise { const instance = this.lifecycle.instances.get(instanceID); const app = instance ? this.apps.get(instance.app_scope) : undefined; const resolution = this.capabilityRegistry.resolve(name); const callID = this.newID("capability"); if (!instance || !app || app.kind !== "bundled" || !app.manifest.permissions.includes(name)) { this.capabilityAudit.recordDecision(callID, name, "restricted", "rejected", this.now()); return Promise.resolve({ disposition: "rejected", code: "capability_not_declared" }); } if (resolution.disposition !== "available") { this.capabilityAudit.recordDecision(callID, name, "restricted", "rejected", this.now()); return Promise.resolve({ disposition: "rejected", code: "capability_unavailable" }); } if (!reason.trim() || reason.length > 500 || !this.capabilityRegistry.validateArguments(name, input)) { this.capabilityAudit.recordDecision(callID, name, resolution.definition.risk, "rejected", this.now()); return Promise.resolve({ disposition: "rejected", code: "invalid_capability_request" }); } if (resolution.definition.foreground_required && instance.state !== "foreground") { this.capabilityAudit.recordDecision(callID, name, resolution.definition.risk, "rejected", this.now()); return Promise.resolve({ disposition: "rejected", code: "capability_requires_foreground" }); } this.capabilityAudit.recordDecision(callID, name, resolution.definition.risk, "pending", this.now()); return this.queueProtocolAction("app-result", "lineup.v1.app.call", { call_id: callID, instance_id: instanceID, app_scope: instance.app_scope, capability: name, reason: reason.trim().replace(/\s+/gu, " ").slice(0, 500), arguments: input, expires_at: new Date(Date.parse(this.now()) + 15 * 60 * 1000).toISOString(), }); } /** @deprecated Use openApp("chat") so App selection remains registry-driven. */ public openChatApp(): ChatRuntimeSDK { return this.openApp("chat"); } public async login(request: RuntimeLoginRequest): Promise { const transport = this.createTransport(request.api); const result = await transport.login({ phone: request.phone, code: request.code }); this.stopSync(); this.transport = transport; this.kernel.reset(); this.session = { api: request.api, uid: result.uid, agent_uid: result.agent_uid || request.fallback_agent_uid, channel_id: result.channel_id || request.fallback_channel_id, channel_type: result.channel_type || request.fallback_channel_type, last_seq: 0, active: true, }; const snapshot = this.store.open(this.requireConversationID()); this.lifecycle.restore(snapshot.app_workspace); this.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())) { 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(); return this.store.snapshot(); } public logout(): void { this.stopSync(); this.transport = undefined; this.kernel.reset(); this.store.close(); this.session = { ...this.session, uid: "", agent_uid: "", channel_id: "", last_seq: 0, active: false }; this.emitState(); } public startSync(): void { if (!this.session.active || this.syncRequested) return; this.syncRequested = true; void this.syncLoop(); } public stopSync(): void { this.syncRequested = false; } /** A deterministic single drain for tests and for the production sync loop. */ public async syncOnce(): Promise { if (!this.session.active || !this.transport) return; this.emit({ type: "sync-status", status: "syncing" }); while (this.session.active && this.transport) { const startMessageSeq = this.session.last_seq === 0 ? 0 : this.session.last_seq + 1; const messages = await this.transport.sync({ channel_id: this.session.channel_id, channel_type: this.session.channel_type, login_uid: this.session.uid, start_message_seq: startMessageSeq, limit: 50, }); if (messages.length === 0) { this.emit({ type: "sync-status", status: "idle" }); return; } let advanced = false; for (const message of messages) { const previousSeq = this.session.last_seq; this.session.last_seq = Math.max(this.session.last_seq, message.message_seq); advanced ||= this.session.last_seq > previousSeq; this.store.advanceCursor(message.message_seq); this.processTransportMessage(message); } if (!advanced) { this.emit({ type: "sync-status", status: "idle" }); return; } } } public async flushOutbox(): Promise { if (this.syncing || !this.session.active || !this.transport) return; this.syncing = true; try { for (const entry of this.store.snapshot().outbox) { try { const sequence = await this.transport.sendText({ from_uid: this.session.uid, channel_id: this.session.channel_id, channel_type: this.session.channel_type, payload: entry.payload, }); this.handleOutboxSuccess(entry, sequence); } catch (reason) { this.handleOutboxFailure(entry, reason); return; } } } finally { this.syncing = false; this.emitState(); } } public submitToolCall(callID: string, result: JsonObject): boolean { const 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; this.store.clearToolDraft(callID); void this.queueProtocolAction("tool-result", "lineup.v1.tool.result", { call_id: callID, status: "completed", result }); this.emitState(); return true; } public cancelToolCall(callID: string, reason: string): boolean { const 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; this.store.clearToolDraft(callID); void this.queueProtocolAction("tool-cancel", "lineup.v1.tool.cancel", { call_id: callID, reason }); this.emitState(); return true; } public expireToolCall(callID: string): boolean { const transition = this.kernel.expireToolCall(callID, this.now()); this.store.replaceToolCalls(this.kernel.snapshot().tool_calls); this.emitState(); return transition.disposition === "accepted"; } public requestTaskCancel(operationID: string): boolean { const transition = this.kernel.requestTaskCancel(operationID, this.now()); this.store.replaceTasks(this.kernel.snapshot().tasks); this.emitState(); return transition.disposition === "accepted"; } public toolCalls(): readonly ToolCallRecord[] { return this.kernel.snapshot().tool_calls; } public tasks(): readonly TaskRecord[] { return this.kernel.snapshot().tasks; } public getToolDraft(callID: string): JsonObject | undefined { return this.store.getToolDraft(callID); } public saveToolDraft(callID: string, values: JsonObject): void { this.store.saveToolDraft(callID, values); } public clearToolDraft(callID: string): void { this.store.clearToolDraft(callID); } public replaceTasks(records: readonly TaskRecord[]): void { this.store.replaceTasks(records); this.emitState(); } public replaceExecutionSummaries(records: readonly ExecutionProgressSummary[]): void { this.store.replaceExecutionSummaries(records); } public replaceSurfaces(snapshot: SurfaceInstanceSnapshot): void { this.store.replaceSurfaces(snapshot); } public replaceCapabilityCalls(records: readonly CapabilityCallRecord[]): void { this.store.replaceCapabilityCalls(records); } public acknowledgeChatMessage(messageID: string): boolean { return this.store.acknowledgeAppInbox("chat", messageID); } 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, type: string, payload: JsonObject, ): Promise { if (!this.session.active) return { disposition: "rejected", code: "inactive" }; 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"), type, conversation_id: this.wireConversationID(), sender: { kind: "human", id: this.session.uid }, target: { kind: "agent", id: this.session.agent_uid }, timestamp: this.now(), payload, }; this.store.enqueueToolAction(localID, kind, JSON.stringify(envelope), this.now()); void this.flushOutbox(); return { disposition: "accepted", local_id: localID }; } /** Runtime-owned ephemeral route used only for declarative inventory updates. */ public async sendProtocolEnvelope(type: string, payload: JsonObject): Promise { if (!this.session.active || !this.transport) return; const envelope: Envelope = { v: 1, id: this.newID("msg"), type, conversation_id: this.wireConversationID(), sender: { kind: "human", id: this.session.uid }, target: { kind: "agent", id: this.session.agent_uid }, timestamp: this.now(), payload, }; await this.transport.sendText({ from_uid: this.session.uid, channel_id: this.session.channel_id, channel_type: this.session.channel_type, payload: JSON.stringify(envelope), }); } private async dispatchChatAction(action: ChatAction): Promise { if (!this.session.active) return { disposition: "rejected", code: "inactive" }; if (action.conversation_id !== this.requireConversationID()) return { disposition: "rejected", code: "scope_mismatch" }; if (action.type === "chat.send_text") { const text = action.text.trim(); if (!text) return { disposition: "rejected", code: "invalid_action" }; const localID = action.local_id ?? this.newID("local"); const stored = this.store.appendLocalText(localID, text, this.now()); this.emit({ type: "local-item", item: stored.item, local_id: localID }); this.emitState(); void this.flushOutbox(); return { disposition: "accepted", local_id: localID }; } if (action.type === "chat.submit_interaction") { return this.submitToolCall(action.call_id, action.result) ? { disposition: "accepted" } : { disposition: "rejected", code: "invalid_action" }; } if (action.type === "chat.cancel_interaction") { return this.cancelToolCall(action.call_id, action.reason) ? { disposition: "accepted" } : { disposition: "rejected", code: "invalid_action" }; } if (action.type === "chat.report_app_result") { return this.queueProtocolAction("app-result", "lineup.v1.app.result", action.result); } if (action.type === "chat.cancel_surface") { if (!action.instance_id) return { disposition: "rejected", code: "invalid_action" }; return this.queueProtocolAction("surface-event", "lineup.v1.ui.event", { instance_id: action.instance_id, event: "cancel", data: {}, }); } 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" }; } private processTransportMessage(message: TransportMessage): void { const payload = decodeTransportPayload(message.payload); // AppServer sync is channel-wide, so a channel can contain messages from // other users. They are not part of this Runtime's conversation and must // never reach scope routing or the Chat projection. if (message.from_uid !== this.session.uid && message.from_uid !== this.session.agent_uid && !message.from_uid.startsWith("agent_")) { runtimeLog("message.ignored", { stage: "sender" }); return; } // The AppServer channel is shared by several users. Agent envelopes carry // the intended human target; discard another user's message before scope // validation so it is not reported as a malformed message for this user. const parsedEnvelope = parseEnvelopeJSON(payload); if (parsedEnvelope.ok && parsedEnvelope.envelope.target?.kind === "human" && parsedEnvelope.envelope.target.id !== this.session.uid) { runtimeLog("message.ignored", { stage: "target" }); return; } if (message.from_uid === this.session.uid) { const own = parseEnvelopeJSON(payload); if (own.ok && CONTROL_ECHO_TYPES.has(own.envelope.type)) return; const restored = this.store.recordSyncedUserText(message.message_seq, payload, this.now()); if (restored) this.emit({ type: "local-item", item: restored.item, local_id: restored.local_id }); return; } const scopeResolution = resolveIncomingScope(payload, { app_scope: "chat", conversation_id: this.requireConversationID(), conversation_ids: [this.wireConversationID()], acceptsAppScope: scope => Boolean(this.apps.get(scope)?.enabled), }); if (scopeResolution.disposition === "rejected") { runtimeLog("message.rejected", { stage: "scope", reason: scopeResolution.reason }); this.emit({ type: "scope-rejected", reason: scopeResolution.reason, raw: payload }); return; } const route = this.toolRouter.route(payload, { app_scope: scopeResolution.scope.app_scope, conversation_id: this.requireConversationID(), conversation_ids: [this.wireConversationID()], 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); for (const rawItem of result.items) { const item = this.prepareIncoming(rawItem); if (!item) continue; const receivedAt = this.now(); if (!this.store.recordIncoming(item, message.message_seq, receivedAt)) continue; const messageID = item.id ?? `transport:${message.message_seq}`; const scoped: ScopedConversationItem = { message_id: messageID, scope: scopeResolution.scope, item, received_at: receivedAt, message_seq: message.message_seq, }; const targetScope = route.disposition === "launch" || route.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 (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(); } private handleOutboxSuccess(entry: OutboxEntry, sequence: number | undefined): void { if (entry.kind === "text") { if (sequence) this.store.markSubmitted(entry.local_id, sequence, this.now()); const item = this.store.snapshot().items.find(candidate => candidate.local_id === entry.local_id)?.item; this.emit({ type: "delivery", local_id: entry.local_id, ...(item ? { item } : {}) }); return; } 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 { runtimeLog("outbox.delivery_failed", { kind: entry.kind }); if (entry.kind !== "text") return; const message = reason instanceof Error ? reason.message : "发送失败"; this.store.markFailed(entry.local_id, message, this.now()); const item = this.store.snapshot().items.find(candidate => candidate.local_id === entry.local_id)?.item; this.emit({ type: "delivery", local_id: entry.local_id, ...(item ? { item } : {}) }); } private async syncLoop(): Promise { while (this.syncRequested && this.session.active) { try { await this.flushOutbox(); await this.syncOnce(); } catch { if (this.session.active) this.emit({ type: "sync-status", status: "retrying" }); } if (this.syncRequested && this.session.active) await this.sleep(1_500); } } private chatView(): ChatViewModel { const snapshot = this.kernel.snapshot(); 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(), active: this.session.active, agent_presence: snapshot.agent_presence, tool_calls: snapshot.tool_calls, tasks: snapshot.tasks, app_sessions: appSessions, }; } private listChatMessages(afterMessageID?: string): 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, 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, scope: { app_scope: appScope, conversation_id: entry.conversation_id }, item: entry.item, received_at: entry.received_at, ...(entry.message_seq !== undefined ? { message_seq: entry.message_seq } : {}), })); } private emitChatMessage(message: ScopedConversationItem): void { if (message.scope.app_scope !== "chat") return; const scoped = message as AgentChatMessage; for (const listener of this.chatMessageListeners) listener(scoped); } private emitState(): void { const view = this.chatView(); for (const listener of this.chatStateListeners) listener(view); } private persistWorkspace(): void { this.store.replaceAppWorkspace(this.lifecycle.snapshot()); } private 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."); return conversationID; } /** AppServer's network conversation key; local persistence keeps Runtime's legacy key. */ private wireConversationID(): string { if (!this.session.active) throw new Error("LineUpRuntime has no active conversation."); return `lineup:${this.session.channel_type}:${this.session.channel_id}:${this.session.uid}`; } } function boundedFailure(value: string): string { const message = value.trim(); 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)); } function isMiniAppToolProgress(value: unknown): value is MiniAppToolProgress { return isJsonObject(value) && (value.percent === undefined || (typeof value.percent === "number" && Number.isFinite(value.percent))) && (value.status === undefined || typeof value.status === "string") && (value.detail === undefined || typeof value.detail === "string"); } function isJsonObject(value: unknown): value is JsonObject { return value !== null && typeof value === "object" && !Array.isArray(value); } /** Deliberately excludes identities, conversation IDs, message bodies and Tool arguments. */ function runtimeLog(event: string, fields: Record): void { console.info("[LineUp Runtime]", event, fields); } function defaultID(prefix: string): string { const uuid = globalThis.crypto?.randomUUID?.(); return `${prefix}_${uuid ?? `${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`}`; } function decodeTransportPayload(value: string): string { try { return decodeURIComponent([...atob(value)].map(character => `%${character.charCodeAt(0).toString(16).padStart(2, "0")}`).join("")); } catch { return value; } }