/** * Platform-neutral LineUp v1 protocol model. * * This module deliberately contains no DOM, network, Tauri, or storage code. * It is the boundary between untrusted transport payloads and the trusted * Interaction Kernel introduced in M0-03. */ export const LINEUP_PROTOCOL_VERSION = 1; export type JsonPrimitive = string | number | boolean | null; export type JsonValue = JsonPrimitive | JsonObject | JsonValue[]; export type JsonObject = { [key: string]: JsonValue }; export type ActorKind = "human" | "agent" | "app" | "system"; export type Actor = { kind: ActorKind; id: string; }; /** A validated LineUp v1 wire envelope. */ export type Envelope = { v: typeof LINEUP_PROTOCOL_VERSION; id: string; type: string; conversation_id: string; sender: Actor; target?: Actor; /** ISO-8601 timestamp supplied by the producing adapter or client. */ timestamp?: string; payload: JsonObject; }; export type DeliveryState = | { status: "local_pending"; updated_at: string } | { status: "submitted"; updated_at: string; transport_id?: string } | { status: "delivered"; updated_at: string; transport_id?: string } | { status: "failed"; updated_at: string; error: string; retryable: boolean }; /** The only standard interaction requests admitted during Milestone 1. */ export type ToolCallKind = "choice" | "confirm" | "input"; export type ActionGroupMode = "single-choice" | "multi-choice" | "button"; export type ActionGroupAction = { id: string; label: string; description?: string; }; export type ActionGroup = { mode: ActionGroupMode; actions: readonly ActionGroupAction[]; }; export type ConfirmDefinition = { approve_label: string; cancel_label: string; }; export type InputFieldType = "text" | "textarea" | "number"; export type InputField = { id: string; label: string; type: InputFieldType; required: boolean; placeholder?: string; min_length?: number; max_length?: number; pattern?: string; }; export type InputForm = { fields: readonly InputField[]; submit_label: string; cancel_label: string; }; export type ToolCallRequest = { call_id: string; tool: ToolCallKind; title: string; prompt: string; expires_at?: string; data: JsonObject; /** Present only for the trusted `choice` renderer introduced in M1-02. */ action_group?: ActionGroup; /** Present only for the trusted `confirm` renderer introduced in M1-03. */ confirm?: ConfirmDefinition; /** Present only for the trusted `input` renderer introduced in M1-03. */ form?: InputForm; }; export type ToolCallFinalStatus = "completed" | "failed" | "cancelled" | "expired"; export type ToolCallResult = { call_id: string; status: ToolCallFinalStatus; result: JsonObject; error?: string; }; export type ToolCallCancel = { call_id: string; reason: string; }; export type TaskStatus = "running" | "waiting" | "completed" | "failed" | "cancelled"; export type TaskProgress = { operation_id: string; title: string; percent: number; status: TaskStatus; cancellable: boolean; }; /** Display-only, adapter-authoritative execution trace. It grants no ability. */ export type AgentExecutionStep = { id: string; title: string; status: "running" | "completed" | "failed" }; export type AgentExecutionTrace = { execution_id: string; status: "running" | "completed" | "failed"; steps: readonly AgentExecutionStep[] }; /** * A user-originated action awaiting delivery through the Transport Adapter. * It is data only: the Store owns retries and the Kernel owns state changes. */ export type UserAction = | { kind: "send-text"; id: string; conversation_id: string; text: string; created_at: string; delivery: DeliveryState; } | { kind: "tool-result" | "tool-cancel"; id: string; conversation_id: string; call_id: string; payload: JsonObject; created_at: string; delivery: DeliveryState; } | { kind: "surface-event"; id: string; conversation_id: string; instance_id: string; event: string; data: JsonObject; created_at: string; delivery: DeliveryState; } | { kind: "app-result"; id: string; conversation_id: string; call_id: string; payload: JsonObject; created_at: string; delivery: DeliveryState; }; export type ProtocolFallbackCode = | "invalid_json" | "invalid_envelope" | "unsupported_version" | "unsupported_type" | "invalid_payload"; /** Safe, displayable representation of content that must never be executed. */ export type ProtocolFallback = { code: ProtocolFallbackCode; /** A stable, non-sensitive diagnostic intended for logs and the fallback renderer. */ detail: string; envelope?: Envelope; }; type ConversationItemBase = { id?: string; envelope?: Envelope; }; export type ConversationItem = | (ConversationItemBase & { kind: "user-message"; text: string; delivery: DeliveryState }) | (ConversationItemBase & { kind: "markdown"; markdown: string; execution_id?: string }) | (ConversationItemBase & { kind: "agent-status"; status: string; detail: string; execution_id?: string }) | (ConversationItemBase & { kind: "execution-summary"; trace: AgentExecutionTrace }) | (ConversationItemBase & { kind: "progress"; title: string; percent: number; status: string; task?: TaskProgress }) | (ConversationItemBase & { kind: "error"; message: string; code?: string }) | (ConversationItemBase & { kind: "tool-call"; call: ToolCallRequest }) | (ConversationItemBase & { kind: "tool-result"; result: ToolCallResult }) | (ConversationItemBase & { kind: "tool-cancel"; cancel: ToolCallCancel }) | (ConversationItemBase & { kind: "surface"; envelope: Envelope }) | (ConversationItemBase & { kind: "app-call"; envelope: Envelope }) | (ConversationItemBase & { kind: "fallback"; reason: ProtocolFallbackCode; detail: string; envelope?: Envelope }); export type EnvelopeParseResult = | { ok: true; envelope: Envelope } | { ok: false; fallback: ProtocolFallback }; const ACTOR_KINDS = new Set(["human", "agent", "app", "system"]); const SUPPORTED_TYPES = new Set([ "lineup.v1.text", "lineup.v1.agent.status", "lineup.v1.agent.execution", "lineup.v1.agent.progress", "lineup.v1.error", "lineup.v1.tool.call", "lineup.v1.tool.result", "lineup.v1.tool.cancel", "lineup.v1.ui.open", "lineup.v1.ui.patch", "lineup.v1.ui.close", "lineup.v1.app.call", ]); const ID_MAX_LENGTH = 256; const INSTANCE_ID = /^[A-Za-z0-9._:-]{1,128}$/; const MESSAGE_TYPE = /^lineup\.v1\.[a-z][a-z0-9.]*[a-z0-9]$/; function object(value: unknown): JsonObject | null { return value !== null && typeof value === "object" && !Array.isArray(value) ? (value as JsonObject) : null; } function isJsonValue(value: unknown, depth = 0): value is JsonValue { if (depth > 32 || value === null || typeof value === "string" || typeof value === "boolean") return depth <= 32; if (typeof value === "number") return Number.isFinite(value); if (Array.isArray(value)) return value.every(entry => isJsonValue(entry, depth + 1)); if (value === null || typeof value !== "object") return false; const prototype = Object.getPrototypeOf(value); return (prototype === Object.prototype || prototype === null) && Object.values(value).every(entry => isJsonValue(entry, depth + 1)); } function text(value: unknown): string { return typeof value === "string" ? value : ""; } function identifier(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 && value.length <= ID_MAX_LENGTH ? value : null; } function parseActor(value: unknown): Actor | null { const candidate = object(value); const id = candidate && identifier(candidate.id); const kind = candidate?.kind; return id && typeof kind === "string" && ACTOR_KINDS.has(kind as ActorKind) ? { kind: kind as ActorKind, id } : null; } function fallback(code: ProtocolFallbackCode, detail: string, envelope?: Envelope): EnvelopeParseResult { return { ok: false, fallback: { code, detail, envelope } }; } /** * Validates the shared envelope shape. It intentionally does not dispatch a * message to a renderer or execute any request; that belongs to the Kernel. */ export function parseEnvelope(value: unknown): EnvelopeParseResult { const candidate = object(value); if (!candidate) return fallback("invalid_envelope", "Envelope must be a JSON object."); if (candidate.v !== LINEUP_PROTOCOL_VERSION) return fallback("unsupported_version", "Unsupported LineUp protocol version."); const id = identifier(candidate.id); const type = text(candidate.type); const conversationID = identifier(candidate.conversation_id); const sender = parseActor(candidate.sender); const payload = object(candidate.payload); if (!id || !MESSAGE_TYPE.test(type) || !conversationID || !sender || !payload || !isJsonValue(payload)) { return fallback("invalid_envelope", "Missing or invalid required LineUp envelope fields."); } let target: Actor | undefined; if (candidate.target !== undefined) { target = parseActor(candidate.target) ?? undefined; if (!target) return fallback("invalid_envelope", "Envelope target must be a valid actor."); } if (candidate.timestamp !== undefined && (typeof candidate.timestamp !== "string" || Number.isNaN(Date.parse(candidate.timestamp)))) { return fallback("invalid_envelope", "Envelope timestamp must be an ISO-8601 string."); } return { ok: true, envelope: { v: LINEUP_PROTOCOL_VERSION, id, type, conversation_id: conversationID, sender, ...(target ? { target } : {}), ...(candidate.timestamp !== undefined ? { timestamp: candidate.timestamp as string } : {}), payload, }, }; } /** Parses a JSON wire payload when callers do not support legacy plain text. */ export function parseEnvelopeJSON(raw: string): EnvelopeParseResult { try { return parseEnvelope(JSON.parse(raw)); } catch { return fallback("invalid_json", "Transport payload is not valid JSON."); } } function validSurfacePayload(type: string, payload: JsonObject): boolean { const instanceID = text(payload.instance_id); if (!INSTANCE_ID.test(instanceID)) return false; if (type === "lineup.v1.ui.open") { if (!object(payload.app)) return false; return payload.state === undefined || object(payload.state) !== null; } if (type === "lineup.v1.ui.patch") return object(payload.state) !== null; return type === "lineup.v1.ui.close"; } function validAppCallPayload(payload: JsonObject): boolean { return Boolean(identifier(payload.call_id) && identifier(payload.capability) && object(payload.arguments)); } const TOOL_KINDS = new Set(["choice", "confirm", "input"]); const TOOL_FINAL_STATUSES = new Set(["completed", "failed", "cancelled", "expired"]); const ACTION_GROUP_MODES = new Set(["single-choice", "multi-choice", "button"]); const INPUT_FIELD_TYPES = new Set(["text", "textarea", "number"]); const TASK_STATUSES = new Set(["running", "waiting", "completed", "failed", "cancelled"]); const EXECUTION_STATUSES = new Set(["running", "completed", "failed"]); function optionalTimestamp(value: unknown): string | undefined { return typeof value === "string" && !Number.isNaN(Date.parse(value)) ? value : undefined; } function parseActionGroup(value: unknown): ActionGroup | undefined { const candidate = object(value); const mode = text(candidate?.mode) as ActionGroupMode; if (!candidate || !ACTION_GROUP_MODES.has(mode) || !Array.isArray(candidate.actions) || candidate.actions.length < 1 || candidate.actions.length > 12) return undefined; const seen = new Set(); const actions: ActionGroupAction[] = []; for (const value of candidate.actions) { const action = object(value); const id = action && identifier(action.id); const label = action && text(action.label).trim(); const description = action ? text(action.description).trim() : ""; if (!id || !label || label.length > 200 || seen.has(id)) return undefined; if (description.length > 500) return undefined; seen.add(id); actions.push({ id, label, ...(description ? { description } : {}) }); } return { mode, actions }; } function boundedLabel(value: unknown, fallback: string): string | undefined { const label = value === undefined ? fallback : text(value).trim(); return label && label.length <= 80 ? label : undefined; } function parseConfirm(value: unknown): ConfirmDefinition | undefined { const candidate = object(value); if (!candidate) return undefined; const approve = boundedLabel(candidate.approve_label, "确认"); const cancel = boundedLabel(candidate.cancel_label, "取消"); return approve && cancel ? { approve_label: approve, cancel_label: cancel } : undefined; } function wholeNumber(value: unknown, maximum: number): number | undefined { return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= maximum ? value : undefined; } function parseInputForm(value: unknown): InputForm | undefined { const candidate = object(value); if (!candidate || !Array.isArray(candidate.fields) || candidate.fields.length < 1 || candidate.fields.length > 8) return undefined; const seen = new Set(); const fields: InputField[] = []; for (const value of candidate.fields) { const field = object(value); const id = field && identifier(field.id); const label = field && text(field.label).trim(); const type = field ? text(field.type) : ""; const placeholder = field ? text(field.placeholder).trim() : ""; const minLength = field?.min_length === undefined ? undefined : wholeNumber(field.min_length, 10_000); const maxLength = field?.max_length === undefined ? undefined : wholeNumber(field.max_length, 10_000); const pattern = field ? text(field.pattern) : ""; if (!id || !label || label.length > 120 || !INPUT_FIELD_TYPES.has(type as InputFieldType) || seen.has(id) || placeholder.length > 200 || pattern.length > 256) return undefined; if ((field?.min_length !== undefined && minLength === undefined) || (field?.max_length !== undefined && maxLength === undefined) || (minLength !== undefined && maxLength !== undefined && minLength > maxLength)) return undefined; if (pattern) { try { new RegExp(pattern, "u"); } catch { return undefined; } } seen.add(id); fields.push({ id, label, type: type as InputFieldType, required: field?.required === true, ...(placeholder ? { placeholder } : {}), ...(minLength !== undefined ? { min_length: minLength } : {}), ...(maxLength !== undefined ? { max_length: maxLength } : {}), ...(pattern ? { pattern } : {}), }); } const submit = boundedLabel(candidate.submit_label, "提交"); const cancel = boundedLabel(candidate.cancel_label, "取消"); return submit && cancel ? { fields, submit_label: submit, cancel_label: cancel } : undefined; } function parseToolCall(payload: JsonObject): ToolCallRequest | undefined { const callID = identifier(payload.call_id); const tool = text(payload.tool) as ToolCallKind; if (!callID || !TOOL_KINDS.has(tool)) return undefined; const expiresAt = optionalTimestamp(payload.expires_at); if (payload.expires_at !== undefined && !expiresAt) return undefined; const data = payload.data === undefined ? {} : object(payload.data); if (!data) return undefined; const actionGroup = tool === "choice" ? parseActionGroup(data.action_group) : undefined; const confirm = tool === "confirm" ? parseConfirm(data.confirm) : undefined; const form = tool === "input" ? parseInputForm(data.form) : undefined; if (tool === "choice" && !actionGroup) return undefined; if (tool === "confirm" && !confirm) return undefined; if (tool === "input" && !form) return undefined; return { call_id: callID, tool, title: text(payload.title), prompt: text(payload.prompt), ...(expiresAt ? { expires_at: expiresAt } : {}), data, ...(actionGroup ? { action_group: actionGroup } : {}), ...(confirm ? { confirm } : {}), ...(form ? { form } : {}), }; } function parseTaskProgress(payload: JsonObject, percent: number): TaskProgress | undefined { const operationID = identifier(payload.operation_id); const status = text(payload.status) as TaskStatus; if (!operationID || !TASK_STATUSES.has(status)) return undefined; const title = text(payload.title).trim(); if (!title || title.length > 200) return undefined; return { operation_id: operationID, title, percent, status, cancellable: payload.cancellable === true }; } function parseExecutionTrace(payload: JsonObject): AgentExecutionTrace | undefined { const executionID = identifier(payload.execution_id); const status = text(payload.status); if (!executionID || !EXECUTION_STATUSES.has(status) || !Array.isArray(payload.steps) || payload.steps.length > 12) return undefined; const steps: AgentExecutionStep[] = []; const seen = new Set(); for (const raw of payload.steps) { const step = object(raw); const id = step && identifier(step.id); const title = step && text(step.title).trim(); const stepStatus = step && text(step.status); if (!id || !title || title.length > 160 || !stepStatus || !EXECUTION_STATUSES.has(stepStatus) || seen.has(id)) return undefined; seen.add(id); steps.push({ id, title, status: stepStatus as AgentExecutionStep["status"] }); } return { execution_id: executionID, status: status as AgentExecutionTrace["status"], steps }; } function parseToolResult(payload: JsonObject): ToolCallResult | undefined { const callID = identifier(payload.call_id); const status = text(payload.status) as ToolCallFinalStatus; const result = payload.result === undefined ? {} : object(payload.result); if (!callID || !TOOL_FINAL_STATUSES.has(status) || !result) return undefined; return { call_id: callID, status, result, ...(text(payload.error) ? { error: text(payload.error) } : {}) }; } function parseToolCancel(payload: JsonObject): ToolCallCancel | undefined { const callID = identifier(payload.call_id); return callID ? { call_id: callID, reason: text(payload.reason) } : undefined; } function fallbackItem(value: ProtocolFallback): ConversationItem { return { kind: "fallback", reason: value.code, detail: value.detail, ...(value.envelope ? { id: value.envelope.id, envelope: value.envelope } : {}), }; } /** * Converts an untrusted transport payload into a trusted renderer model. * Plain legacy text remains a Markdown item for backwards compatibility; * malformed JSON and invalid LineUp envelopes are always safe fallbacks. */ export function decodeConversationItem(raw: string): ConversationItem { let parsed: unknown; try { parsed = JSON.parse(raw); } catch { return { kind: "markdown", markdown: raw }; } const parsedEnvelope = parseEnvelope(parsed); if (!parsedEnvelope.ok) return fallbackItem(parsedEnvelope.fallback); const envelope = parsedEnvelope.envelope; const payload = envelope.payload; const base = { id: envelope.id, envelope }; if (!SUPPORTED_TYPES.has(envelope.type)) { return fallbackItem({ code: "unsupported_type", detail: `Unsupported LineUp message type: ${envelope.type}`, envelope }); } switch (envelope.type) { case "lineup.v1.text": { const markdown = text(payload.markdown) || text(payload.text); return markdown ? { kind: "markdown", markdown, ...(identifier(payload.execution_id) ? { execution_id: identifier(payload.execution_id)! } : {}), ...base } : fallbackItem({ code: "invalid_payload", detail: "Text payload requires text or markdown.", envelope }); } case "lineup.v1.agent.status": { const status = text(payload.status); return status ? { kind: "agent-status", status, detail: text(payload.detail), ...(identifier(payload.execution_id) ? { execution_id: identifier(payload.execution_id)! } : {}), ...base } : fallbackItem({ code: "invalid_payload", detail: "Agent status payload requires status.", envelope }); } case "lineup.v1.agent.execution": { const trace = parseExecutionTrace(payload); return trace ? { kind: "execution-summary", trace, ...base } : fallbackItem({ code: "invalid_payload", detail: "Agent execution requires bounded public steps.", envelope }); } case "lineup.v1.agent.progress": { const percent = payload.percent; if (typeof percent !== "number" || !Number.isFinite(percent) || percent < 0 || percent > 100) { return fallbackItem({ code: "invalid_payload", detail: "Agent progress payload requires percent between 0 and 100.", envelope }); } const task = payload.operation_id === undefined ? undefined : parseTaskProgress(payload, percent); if (payload.operation_id !== undefined && !task) return fallbackItem({ code: "invalid_payload", detail: "Task progress requires operation_id, title, supported status, and valid cancellation flag.", envelope }); return { kind: "progress", title: text(payload.title), percent, status: text(payload.status), ...(task ? { task } : {}), ...base }; } case "lineup.v1.error": { const message = text(payload.message); return message ? { kind: "error", message, ...(text(payload.code) ? { code: text(payload.code) } : {}), ...base } : fallbackItem({ code: "invalid_payload", detail: "Error payload requires message.", envelope }); } case "lineup.v1.tool.call": { const call = parseToolCall(payload); return call ? { kind: "tool-call", call, ...base } : fallbackItem({ code: "invalid_payload", detail: "Tool call requires call_id, supported tool, and JSON data.", envelope }); } case "lineup.v1.tool.result": { const result = parseToolResult(payload); return result ? { kind: "tool-result", result, ...base } : fallbackItem({ code: "invalid_payload", detail: "Tool result requires call_id, final status, and JSON result.", envelope }); } case "lineup.v1.tool.cancel": { const cancel = parseToolCancel(payload); return cancel ? { kind: "tool-cancel", cancel, ...base } : fallbackItem({ code: "invalid_payload", detail: "Tool cancel requires call_id.", envelope }); } case "lineup.v1.ui.open": case "lineup.v1.ui.patch": case "lineup.v1.ui.close": return validSurfacePayload(envelope.type, payload) ? { kind: "surface", ...base } : fallbackItem({ code: "invalid_payload", detail: "Surface payload does not match its lifecycle contract.", envelope }); case "lineup.v1.app.call": return validAppCallPayload(payload) ? { kind: "app-call", ...base } : fallbackItem({ code: "invalid_payload", detail: "App call payload requires call_id, capability, and arguments.", envelope }); default: return fallbackItem({ code: "unsupported_type", detail: `Unsupported LineUp message type: ${envelope.type}`, envelope }); } }