/** * 标准 Tool Call 的纯状态机。 * * `call_id` 是唯一关联键,只允许声明性 choice、confirm、input 在合法生命周期内迁移; * 本模块不渲染、不写 Store、不联网,实际结果发送由 Runtime Action/outbox 完成。 */ import type { JsonObject, ToolCallCancel, ToolCallFinalStatus, ToolCallRequest, ToolCallResult, } from "@/runtime/protocol/lineup-v1"; export type ToolCallStatus = "pending" | "submitted" | ToolCallFinalStatus; export type ToolCallRecord = { call_id: string; conversation_id: string; /** Runtime binding for ordinary MiniApp Tools; absent for Interact calls. */ app_scope?: string; instance_id?: string; request: ToolCallRequest; status: ToolCallStatus; created_at: string; updated_at: string; result?: JsonObject; submission?: JsonObject; error?: string; }; export type ToolCallTransition = | { disposition: "accepted"; record: ToolCallRecord } | { disposition: "duplicate" | "invalid_transition" | "missing"; record?: ToolCallRecord }; /** * Platform-neutral state machine for trusted standard interaction calls. * It has no UI, persistence, Transport, capability, or side-effect handler; * later M1 tasks connect those boundaries through this explicit lifecycle. */ export class ToolCallStateMachine { private readonly calls = new Map(); public receive(request: ToolCallRequest, conversationID: string, receivedAt: string): ToolCallTransition { const existing = this.calls.get(request.call_id); if (existing) return { disposition: "duplicate", record: copy(existing) }; const expired = request.expires_at !== undefined && Date.parse(request.expires_at) <= Date.parse(receivedAt); const record: ToolCallRecord = { call_id: request.call_id, conversation_id: conversationID, request: { ...request, data: { ...request.data } }, status: expired ? "expired" : "pending", created_at: receivedAt, updated_at: receivedAt, }; this.calls.set(record.call_id, record); return { disposition: "accepted", record: copy(record) }; } public submit(callID: string, updatedAt: string, submission: JsonObject = {}): ToolCallTransition { const record = this.calls.get(callID); if (record?.request.expires_at && Date.parse(record.request.expires_at) <= Date.parse(updatedAt) && (record.status === "pending" || record.status === "submitted")) { record.status = "expired"; record.updated_at = updatedAt; return { disposition: "invalid_transition", record: copy(record) }; } return this.transition(callID, updatedAt, ["pending"], record => { record.status = "submitted"; record.submission = { ...submission }; }); } public complete(result: ToolCallResult, updatedAt: string): ToolCallTransition { return this.transition(result.call_id, updatedAt, ["submitted"], record => { record.status = result.status; record.result = { ...result.result }; record.error = result.error; }); } public cancel(cancel: ToolCallCancel, updatedAt: string): ToolCallTransition { return this.transition(cancel.call_id, updatedAt, ["pending", "submitted"], record => { record.status = "cancelled"; record.error = cancel.reason || undefined; }); } public expireCall(callID: string, now: string): ToolCallTransition { const record = this.calls.get(callID); if (!record) return { disposition: "missing" }; if ((record.status !== "pending" && record.status !== "submitted") || !record.request.expires_at || Date.parse(record.request.expires_at) > Date.parse(now)) { return { disposition: "invalid_transition", record: copy(record) }; } record.status = "expired"; record.updated_at = now; return { disposition: "accepted", record: copy(record) }; } public expire(now: string): readonly ToolCallRecord[] { const changed: ToolCallRecord[] = []; const nowTime = Date.parse(now); for (const record of this.calls.values()) { if ((record.status === "pending" || record.status === "submitted") && record.request.expires_at && Date.parse(record.request.expires_at) <= nowTime) { record.status = "expired"; record.updated_at = now; changed.push(copy(record)); } } return changed; } public get(callID: string): ToolCallRecord | undefined { const record = this.calls.get(callID); return record ? copy(record) : undefined; } public snapshot(): readonly ToolCallRecord[] { return [...this.calls.values()].map(copy); } public reset(): void { this.calls.clear(); } /** Restores trusted local cache records for the current conversation only. */ public restore(records: readonly ToolCallRecord[], now: string): void { this.calls.clear(); for (const record of records) { if (!isRestoreableRecord(record) || this.calls.has(record.call_id)) continue; const restored = copy(record); if ((restored.status === "pending" || restored.status === "submitted") && restored.request.expires_at && Date.parse(restored.request.expires_at) <= Date.parse(now)) { restored.status = "expired"; restored.updated_at = now; } this.calls.set(restored.call_id, restored); } } private transition( callID: string, updatedAt: string, allowed: readonly ToolCallStatus[], apply: (record: ToolCallRecord) => void, ): ToolCallTransition { const record = this.calls.get(callID); if (!record) return { disposition: "missing" }; if (!allowed.includes(record.status)) return { disposition: "invalid_transition", record: copy(record) }; apply(record); record.updated_at = updatedAt; return { disposition: "accepted", record: copy(record) }; } } function isRestoreableRecord(value: unknown): value is ToolCallRecord { if (!value || typeof value !== "object" || Array.isArray(value)) return false; const record = value as Partial; if (typeof record.call_id !== "string" || !record.call_id || typeof record.conversation_id !== "string" || !record.conversation_id || !record.request || typeof record.request !== "object") return false; if (record.request.call_id !== record.call_id || !["choice", "confirm", "input"].includes(record.request.tool) || !record.request.data || typeof record.request.data !== "object" || Array.isArray(record.request.data)) return false; if (!["pending", "submitted", "completed", "failed", "cancelled", "expired"].includes(record.status ?? "") || typeof record.created_at !== "string" || typeof record.updated_at !== "string") return false; return !Number.isNaN(Date.parse(record.created_at)) && !Number.isNaN(Date.parse(record.updated_at)); } function copy(record: ToolCallRecord): ToolCallRecord { return { ...record, request: { ...record.request, data: { ...record.request.data } }, ...(record.result ? { result: { ...record.result } } : {}), ...(record.submission ? { submission: { ...record.submission } } : {}), }; }