567 lines
23 KiB
TypeScript
567 lines
23 KiB
TypeScript
/**
|
|
* 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<ActorKind>(["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<ToolCallKind>(["choice", "confirm", "input"]);
|
|
const TOOL_FINAL_STATUSES = new Set<ToolCallFinalStatus>(["completed", "failed", "cancelled", "expired"]);
|
|
const ACTION_GROUP_MODES = new Set<ActionGroupMode>(["single-choice", "multi-choice", "button"]);
|
|
const INPUT_FIELD_TYPES = new Set<InputFieldType>(["text", "textarea", "number"]);
|
|
const TASK_STATUSES = new Set<TaskStatus>(["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<string>();
|
|
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<string>();
|
|
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<string>();
|
|
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 });
|
|
}
|
|
}
|