1313 lines
66 KiB
TypeScript
1313 lines
66 KiB
TypeScript
/**
|
|
* 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<void>;
|
|
/** Host-only sanitizer/cache hook; Runtime still owns persistence/routing. */
|
|
prepareIncoming?: (item: ConversationItem) => ConversationItem | undefined;
|
|
};
|
|
|
|
const CONTROL_ECHO_TYPES = new Set([
|
|
"lineup.v1.tool.result",
|
|
"lineup.v1.tool.cancel",
|
|
"lineup.v1.ui.event",
|
|
"lineup.v1.client.inventory",
|
|
"lineup.v1.app.result",
|
|
"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<void>;
|
|
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<JsonObject> {
|
|
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<CommandReceipt> {
|
|
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<ConversationSnapshot> {
|
|
const transport = this.createTransport(request.api);
|
|
const result = await transport.login({ phone: request.phone, code: request.code });
|
|
this.stopSync();
|
|
this.transport = transport;
|
|
this.kernel.reset();
|
|
this.session = {
|
|
api: request.api,
|
|
uid: result.uid,
|
|
agent_uid: result.agent_uid || request.fallback_agent_uid,
|
|
channel_id: result.channel_id || request.fallback_channel_id,
|
|
channel_type: result.channel_type || request.fallback_channel_type,
|
|
last_seq: 0,
|
|
active: true,
|
|
};
|
|
const snapshot = this.store.open(this.requireConversationID());
|
|
this.lifecycle.restore(snapshot.app_workspace);
|
|
this.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<void> {
|
|
if (!this.session.active || !this.transport) return;
|
|
this.emit({ type: "sync-status", status: "syncing" });
|
|
while (this.session.active && this.transport) {
|
|
const startMessageSeq = this.session.last_seq === 0 ? 0 : this.session.last_seq + 1;
|
|
const messages = await this.transport.sync({
|
|
channel_id: this.session.channel_id,
|
|
channel_type: this.session.channel_type,
|
|
login_uid: this.session.uid,
|
|
start_message_seq: startMessageSeq,
|
|
limit: 50,
|
|
});
|
|
if (messages.length === 0) {
|
|
this.emit({ type: "sync-status", status: "idle" });
|
|
return;
|
|
}
|
|
let advanced = false;
|
|
for (const message of messages) {
|
|
const previousSeq = this.session.last_seq;
|
|
this.session.last_seq = Math.max(this.session.last_seq, message.message_seq);
|
|
advanced ||= this.session.last_seq > previousSeq;
|
|
this.store.advanceCursor(message.message_seq);
|
|
this.processTransportMessage(message);
|
|
}
|
|
if (!advanced) {
|
|
this.emit({ type: "sync-status", status: "idle" });
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
public async flushOutbox(): Promise<void> {
|
|
if (this.syncing || !this.session.active || !this.transport) return;
|
|
this.syncing = true;
|
|
try {
|
|
for (const entry of this.store.snapshot().outbox) {
|
|
try {
|
|
const sequence = await this.transport.sendText({
|
|
from_uid: this.session.uid,
|
|
channel_id: this.session.channel_id,
|
|
channel_type: this.session.channel_type,
|
|
payload: entry.payload,
|
|
});
|
|
this.handleOutboxSuccess(entry, sequence);
|
|
} catch (reason) {
|
|
this.handleOutboxFailure(entry, reason);
|
|
return;
|
|
}
|
|
}
|
|
} finally {
|
|
this.syncing = false;
|
|
this.emitState();
|
|
}
|
|
}
|
|
|
|
public submitToolCall(callID: string, result: JsonObject): boolean {
|
|
const 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<CommandReceipt> {
|
|
if (!validProgress(progress)) return Promise.resolve({ disposition: "rejected", code: "invalid_action" });
|
|
const transition = this.miniappTools.progress(callID, instanceID, this.now());
|
|
if (transition.disposition !== "accepted") return Promise.resolve({ disposition: "rejected", code: "invalid_action" });
|
|
this.store.replaceMiniAppToolCalls(this.miniappTools.list());
|
|
return this.queueProtocolAction("app-result", "lineup.v1.miniapp.tool.progress", {
|
|
call_id: callID,
|
|
app_scope: transition.record.app_scope,
|
|
instance_id: instanceID,
|
|
progress,
|
|
});
|
|
}
|
|
|
|
public completeMiniAppTool(instanceID: string, callID: string, result: JsonObject): Promise<CommandReceipt> {
|
|
const current = this.miniappTools.get(callID);
|
|
if (!current || current.instance_id !== instanceID) return Promise.resolve({ disposition: "rejected", code: "invalid_action" });
|
|
const app = this.apps.get(current.app_scope);
|
|
const definition = app?.manifest.tools.find(tool => tool.name === current.tool_id);
|
|
if (!app || !definition || !validateMiniAppToolSchema(result, definition.output_schema)) return Promise.resolve({ disposition: "rejected", code: "invalid_action" });
|
|
const transition = this.miniappTools.submit(callID, instanceID, result, this.now());
|
|
if (transition.disposition !== "accepted") return Promise.resolve({ disposition: "rejected", code: "invalid_action" });
|
|
this.store.replaceMiniAppToolCalls(this.miniappTools.list());
|
|
return this.queueProtocolAction("tool-result", "lineup.v1.miniapp.tool.result", {
|
|
call_id: callID,
|
|
app_scope: current.app_scope,
|
|
instance_id: instanceID,
|
|
status: "completed",
|
|
result,
|
|
result_id: `miniapp-result:${callID}`,
|
|
});
|
|
}
|
|
|
|
public failMiniAppTool(instanceID: string, callID: string, failure: MiniAppToolFailure): Promise<CommandReceipt> {
|
|
const current = this.miniappTools.get(callID);
|
|
if (!current || current.instance_id !== instanceID) return Promise.resolve({ disposition: "rejected", code: "invalid_action" });
|
|
const transition = this.miniappTools.fail(callID, instanceID, failure.code ?? "app_failed", this.now());
|
|
if (transition.disposition !== "accepted") return Promise.resolve({ disposition: "rejected", code: "invalid_action" });
|
|
this.store.replaceMiniAppToolCalls(this.miniappTools.list());
|
|
return this.queueProtocolAction("tool-result", "lineup.v1.miniapp.tool.result", {
|
|
call_id: callID,
|
|
app_scope: current.app_scope,
|
|
instance_id: instanceID,
|
|
status: "failed",
|
|
error: failure.message,
|
|
...(failure.code ? { error_code: failure.code } : {}),
|
|
result_id: `miniapp-result:${callID}`,
|
|
});
|
|
}
|
|
|
|
public cancelMiniAppTool(instanceID: string, callID: string, reason: string): Promise<CommandReceipt> {
|
|
const current = this.miniappTools.get(callID);
|
|
if (!current || current.instance_id !== instanceID) return Promise.resolve({ disposition: "rejected", code: "invalid_action" });
|
|
const transition = this.miniappTools.cancel(callID, instanceID, "runtime_cancelled", this.now());
|
|
if (transition.disposition !== "accepted") return Promise.resolve({ disposition: "rejected", code: "invalid_action" });
|
|
this.store.replaceMiniAppToolCalls(this.miniappTools.list());
|
|
return this.queueProtocolAction("tool-cancel", "lineup.v1.miniapp.tool.cancel", {
|
|
call_id: callID,
|
|
app_scope: current.app_scope,
|
|
instance_id: instanceID,
|
|
reason,
|
|
cancel_id: `miniapp-cancel:${callID}`,
|
|
});
|
|
}
|
|
|
|
/** Runtime-owned durable protocol result path for non-Chat host modules. */
|
|
public async queueProtocolAction(
|
|
kind: Exclude<OutboxEntry["kind"], "text">,
|
|
type: string,
|
|
payload: JsonObject,
|
|
): Promise<CommandReceipt> {
|
|
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<void> {
|
|
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<CommandReceipt> {
|
|
if (!this.session.active) return { disposition: "rejected", code: "inactive" };
|
|
if (action.conversation_id !== this.requireConversationID()) return { disposition: "rejected", code: "scope_mismatch" };
|
|
if (action.type === "chat.send_text") {
|
|
const text = action.text.trim();
|
|
if (!text) return { disposition: "rejected", code: "invalid_action" };
|
|
const localID = action.local_id ?? this.newID("local");
|
|
const stored = this.store.appendLocalText(localID, text, this.now());
|
|
this.emit({ type: "local-item", item: stored.item, local_id: localID });
|
|
this.emitState();
|
|
void this.flushOutbox();
|
|
return { disposition: "accepted", local_id: localID };
|
|
}
|
|
if (action.type === "chat.submit_interaction") {
|
|
return this.submitToolCall(action.call_id, action.result)
|
|
? { disposition: "accepted" }
|
|
: { disposition: "rejected", code: "invalid_action" };
|
|
}
|
|
if (action.type === "chat.cancel_interaction") {
|
|
return this.cancelToolCall(action.call_id, action.reason)
|
|
? { disposition: "accepted" }
|
|
: { disposition: "rejected", code: "invalid_action" };
|
|
}
|
|
if (action.type === "chat.report_app_result") {
|
|
return this.queueProtocolAction("app-result", "lineup.v1.app.result", action.result);
|
|
}
|
|
if (action.type === "chat.cancel_surface") {
|
|
if (!action.instance_id) return { disposition: "rejected", code: "invalid_action" };
|
|
return this.queueProtocolAction("surface-event", "lineup.v1.ui.event", {
|
|
instance_id: action.instance_id,
|
|
event: "cancel",
|
|
data: {},
|
|
});
|
|
}
|
|
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<void> {
|
|
while (this.syncRequested && this.session.active) {
|
|
try {
|
|
await this.flushOutbox();
|
|
await this.syncOnce();
|
|
} catch {
|
|
if (this.session.active) this.emit({ type: "sync-status", status: "retrying" });
|
|
}
|
|
if (this.syncRequested && this.session.active) await this.sleep(1_500);
|
|
}
|
|
}
|
|
|
|
private chatView(): ChatViewModel {
|
|
const snapshot = this.kernel.snapshot();
|
|
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<string, string | number>): void {
|
|
console.info("[LineUp Runtime]", event, fields);
|
|
}
|
|
|
|
function defaultID(prefix: string): string {
|
|
const uuid = globalThis.crypto?.randomUUID?.();
|
|
return `${prefix}_${uuid ?? `${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`}`;
|
|
}
|
|
|
|
function decodeTransportPayload(value: string): string {
|
|
try {
|
|
return decodeURIComponent([...atob(value)].map(character => `%${character.charCodeAt(0).toString(16).padStart(2, "0")}`).join(""));
|
|
} catch {
|
|
return value;
|
|
}
|
|
}
|