feat: establish typed runtime protocol model

This commit is contained in:
2026-08-03 12:58:01 +08:00
parent 73b5194cee
commit d2646f534e
5 changed files with 325 additions and 45 deletions
+273 -28
View File
@@ -1,32 +1,252 @@
/**
* 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: number;
id?: string;
v: typeof LINEUP_PROTOCOL_VERSION;
id: string;
type: string;
conversation_id?: string;
payload?: unknown;
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 };
/**
* 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 =
| { kind: "markdown"; markdown: string; envelope?: Envelope }
| { kind: "agent-status"; status: string; detail: string; envelope?: Envelope }
| { kind: "progress"; title: string; percent: number; status: string; envelope?: Envelope }
| { kind: "error"; message: string; envelope?: Envelope }
| { kind: "surface"; envelope: Envelope }
| { kind: "app-call"; envelope: Envelope }
| { kind: "fallback"; reason: string; envelope?: Envelope };
| (ConversationItemBase & { kind: "user-message"; text: string; delivery: DeliveryState })
| (ConversationItemBase & { kind: "markdown"; markdown: string })
| (ConversationItemBase & { kind: "agent-status"; status: string; detail: string })
| (ConversationItemBase & { kind: "progress"; title: string; percent: number; status: string })
| (ConversationItemBase & { kind: "error"; message: string; code?: string })
| (ConversationItemBase & { kind: "surface"; envelope: Envelope })
| (ConversationItemBase & { kind: "app-call"; envelope: Envelope })
| (ConversationItemBase & { kind: "fallback"; reason: ProtocolFallbackCode; detail: string; envelope?: Envelope });
function object(value: unknown): Record<string, unknown> | null {
export type EnvelopeParseResult =
| { ok: true; envelope: Envelope }
| { ok: false; fallback: ProtocolFallback };
const ACTOR_KINDS = new Set<ActorKind>(["human", "agent", "app", "system"]);
const SUPPORTED_TYPES = new Set([
"lineup.v1.text",
"lineup.v1.agent.status",
"lineup.v1.agent.progress",
"lineup.v1.error",
"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 Record<string, unknown>)
? (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));
}
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 {
@@ -34,27 +254,52 @@ export function decodeConversationItem(raw: string): ConversationItem {
} catch {
return { kind: "markdown", markdown: raw };
}
const envelope = object(parsed) as Envelope | null;
if (!envelope || envelope.v !== LINEUP_PROTOCOL_VERSION || !envelope.type?.startsWith("lineup.v1.")) {
return { kind: "fallback", reason: "invalid_envelope" };
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 });
}
const payload = object(envelope.payload) ?? {};
switch (envelope.type) {
case "lineup.v1.text":
return { kind: "markdown", markdown: text(payload.markdown) || text(payload.text), envelope };
case "lineup.v1.agent.status":
return { kind: "agent-status", status: text(payload.status), detail: text(payload.detail), envelope };
case "lineup.v1.agent.progress":
return { kind: "progress", title: text(payload.title), percent: Number(payload.percent) || 0, status: text(payload.status), envelope };
case "lineup.v1.error":
return { kind: "error", message: text(payload.message) || "Agent 执行失败", envelope };
case "lineup.v1.text": {
const markdown = text(payload.markdown) || text(payload.text);
return markdown ? { kind: "markdown", markdown, ...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), ...base }
: fallbackItem({ code: "invalid_payload", detail: "Agent status payload requires status.", 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 });
}
return { kind: "progress", title: text(payload.title), percent, status: text(payload.status), ...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.ui.open":
case "lineup.v1.ui.patch":
case "lineup.v1.ui.close":
return { kind: "surface", envelope };
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 { kind: "app-call", envelope };
return validAppCallPayload(payload)
? { kind: "app-call", ...base }
: fallbackItem({ code: "invalid_payload", detail: "App call payload requires call_id, capability, and arguments.", envelope });
default:
return { kind: "fallback", reason: "unsupported_type", envelope };
return fallbackItem({ code: "unsupported_type", detail: `Unsupported LineUp message type: ${envelope.type}`, envelope });
}
}