feat(iteration): implement sdk v1 miniapp runtime loop

This commit is contained in:
2026-08-05 18:30:29 +08:00
parent 486280bec0
commit d2a2fd0aff
37 changed files with 2135 additions and 60 deletions
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { AgentInteractionService } from "@/runtime/coordination/agent-interaction-service";
const base = {
interaction_id: "interaction-1",
call_id: "call-1",
agent_id: "agent-1",
conversation_id: "conversation-1",
interact_instance_id: "chat-1",
created_at: "2026-08-05T00:00:00.000Z",
};
describe("AgentInteractionService", () => {
it("creates, presents and accepts notice exactly once", () => {
const service = new AgentInteractionService();
expect(service.create({ ...base, request: { kind: "notice", title: "已保存", message: "任务已保存" } })).toMatchObject({ disposition: "accepted" });
expect(service.present("interaction-1", "2026-08-05T00:00:01.000Z")).toMatchObject({ disposition: "accepted", record: { status: "completed", result: { outcome: "accepted" } } });
expect(service.present("interaction-1", "2026-08-05T00:00:02.000Z")).toMatchObject({ disposition: "invalid_transition", record: { status: "completed" } });
});
it("lets only the first answer/dismiss/expiry transition win", () => {
const service = new AgentInteractionService();
service.create({ ...base, request: { kind: "confirm", title: "确认", prompt: "继续吗?", expires_in_ms: 60_000 } });
service.present("interaction-1", "2026-08-05T00:00:01.000Z");
expect(service.submit("interaction-1", { approved: true }, "2026-08-05T00:00:02.000Z")).toMatchObject({ record: { status: "completed", result: { outcome: "answered" } } });
expect(service.dismiss({ call_id: "call-1", agent_id: "agent-1", conversation_id: "conversation-1" }, "2026-08-05T00:00:03.000Z")).toMatchObject({ disposition: "invalid_transition", record: { status: "completed" } });
});
it("rejects cross-agent dismiss and restores expired records", () => {
const service = new AgentInteractionService();
service.create({ ...base, request: { kind: "input", title: "描述", prompt: "输入", expires_in_ms: 60_000, field: { id: "text", label: "描述", type: "textarea" } } });
expect(service.dismiss({ call_id: "call-1", agent_id: "other-agent", conversation_id: "conversation-1" }, "2026-08-05T00:00:01.000Z")).toEqual({ disposition: "invalid_transition" });
service.restore(service.list(), "2026-08-05T00:01:00.000Z");
expect(service.get("interaction-1")).toMatchObject({ status: "expired", result: { outcome: "expired" } });
});
});
@@ -0,0 +1,177 @@
import {
validateStandardInteractionRequest,
type StandardInteractionRequest,
} from "@/runtime/coordination/standard-interaction-contract";
export type StandardInteractionKind = StandardInteractionRequest["kind"];
export type StandardInteractionResult =
| Readonly<{ outcome: "accepted" }>
| Readonly<{ outcome: "answered"; answer: Readonly<Record<string, unknown>> }>
| Readonly<{ outcome: "cancelled" }>
| Readonly<{ outcome: "expired" }>
| Readonly<{ outcome: "failed"; error_code: string }>;
export type StandardInteractionStatus = "pending" | "presented" | "submitted" | "completed" | "cancelled" | "expired" | "failed";
export type StandardInteractionRecord = Readonly<{
interaction_id: string;
call_id: string;
agent_id: string;
conversation_id: string;
interact_instance_id: string;
app_session_id?: string;
kind: StandardInteractionKind;
request: StandardInteractionRequest;
status: StandardInteractionStatus;
result?: StandardInteractionResult;
created_at: string;
updated_at: string;
expires_at?: string;
}>;
export type InteractionTransition =
| Readonly<{ disposition: "accepted"; record: StandardInteractionRecord }>
| Readonly<{ disposition: "duplicate" | "invalid_transition" | "missing" | "invalid_request"; record?: StandardInteractionRecord }>;
export type CreateInteractionInput = Readonly<{
interaction_id: string;
call_id: string;
agent_id: string;
conversation_id: string;
interact_instance_id: string;
app_session_id?: string;
request: StandardInteractionRequest;
created_at: string;
}>;
/**
* Runtime-owned state machine for Agent-facing Interact records.
* It deliberately knows nothing about renderers or transport. Outbox writes
* are made by the caller only for an accepted terminal transition.
*/
export class AgentInteractionService {
private readonly records = new Map<string, StandardInteractionRecord>();
public create(input: CreateInteractionInput): InteractionTransition {
const existing = this.records.get(input.interaction_id) ?? this.byCall(input.call_id);
if (existing) return { disposition: "duplicate", record: copy(existing) };
const validation = validateStandardInteractionRequest(input.request, new Date(input.created_at));
if (!validation.accepted) return { disposition: "invalid_request" };
const record: StandardInteractionRecord = {
interaction_id: input.interaction_id,
call_id: input.call_id,
agent_id: input.agent_id,
conversation_id: input.conversation_id,
interact_instance_id: input.interact_instance_id,
...(input.app_session_id ? { app_session_id: input.app_session_id } : {}),
kind: input.request.kind,
request: cloneRequest(input.request),
status: "pending",
created_at: input.created_at,
updated_at: input.created_at,
...(validation.expires_at ? { expires_at: validation.expires_at } : {}),
};
this.records.set(record.interaction_id, record);
return { disposition: "accepted", record: copy(record) };
}
public present(interactionID: string, now: string): InteractionTransition {
const record = this.records.get(interactionID);
if (!record) return { disposition: "missing" };
if (record.status !== "pending") return { disposition: "invalid_transition", record: copy(record) };
const presented: StandardInteractionRecord = { ...record, status: "presented", updated_at: now };
this.records.set(interactionID, presented);
if (presented.kind === "notice") {
const completed: StandardInteractionRecord = { ...presented, status: "completed", result: { outcome: "accepted" }, updated_at: now };
this.records.set(interactionID, completed);
return { disposition: "accepted", record: copy(completed) };
}
return { disposition: "accepted", record: copy(presented) };
}
public submit(interactionID: string, answer: Readonly<Record<string, unknown>>, now: string): InteractionTransition {
const record = this.records.get(interactionID);
if (!record) return { disposition: "missing" };
if (record.status !== "pending" && record.status !== "presented") return { disposition: "invalid_transition", record: copy(record) };
if (expired(record, now)) return this.expire(interactionID, now);
const submitted: StandardInteractionRecord = { ...record, status: "submitted", result: { outcome: "answered", answer: { ...answer } }, updated_at: now };
const completed: StandardInteractionRecord = { ...submitted, status: "completed", updated_at: now };
this.records.set(interactionID, completed);
return { disposition: "accepted", record: copy(completed) };
}
public dismiss(input: Readonly<{ call_id: string; agent_id: string; conversation_id: string }>, now: string): InteractionTransition {
const record = this.byCall(input.call_id);
if (!record) return { disposition: "missing" };
if (record.agent_id !== input.agent_id || record.conversation_id !== input.conversation_id) return { disposition: "invalid_transition" };
if (record.status !== "pending" && record.status !== "presented") return { disposition: "invalid_transition", record: copy(record) };
const cancelled: StandardInteractionRecord = { ...record, status: "cancelled", result: { outcome: "cancelled" }, updated_at: now };
this.records.set(record.interaction_id, cancelled);
return { disposition: "accepted", record: copy(cancelled) };
}
public expire(interactionID: string, now: string): InteractionTransition {
const record = this.records.get(interactionID);
if (!record) return { disposition: "missing" };
if (record.status !== "pending" && record.status !== "presented") return { disposition: "invalid_transition", record: copy(record) };
if (!expired(record, now)) return { disposition: "invalid_transition", record: copy(record) };
const final: StandardInteractionRecord = { ...record, status: "expired", result: { outcome: "expired" }, updated_at: now };
this.records.set(interactionID, final);
return { disposition: "accepted", record: copy(final) };
}
public get(interactionID: string): StandardInteractionRecord | undefined {
const record = this.records.get(interactionID);
return record ? copy(record) : undefined;
}
public list(): readonly StandardInteractionRecord[] { return [...this.records.values()].map(copy); }
public restore(records: readonly StandardInteractionRecord[], now: string): void {
this.records.clear();
for (const record of records) {
if (!isRecord(record) || this.records.has(record.interaction_id)) continue;
const restored = copy(record);
if ((restored.status === "pending" || restored.status === "presented") && expired(restored, now)) {
this.records.set(restored.interaction_id, { ...restored, status: "expired", result: { outcome: "expired" }, updated_at: now });
} else {
this.records.set(restored.interaction_id, restored);
}
}
}
public reset(): void { this.records.clear(); }
private byCall(callID: string): StandardInteractionRecord | undefined {
return [...this.records.values()].find(record => record.call_id === callID);
}
}
function expired(record: StandardInteractionRecord, now: string): boolean {
return Boolean(record.expires_at && Date.parse(record.expires_at) <= Date.parse(now));
}
function isRecord(value: unknown): value is StandardInteractionRecord {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const candidate = value as Partial<StandardInteractionRecord>;
return typeof candidate.interaction_id === "string"
&& typeof candidate.call_id === "string"
&& typeof candidate.agent_id === "string"
&& typeof candidate.conversation_id === "string"
&& typeof candidate.interact_instance_id === "string"
&& typeof candidate.created_at === "string"
&& !Number.isNaN(Date.parse(candidate.created_at))
&& typeof candidate.updated_at === "string"
&& !Number.isNaN(Date.parse(candidate.updated_at))
&& Boolean(candidate.request)
&& ["pending", "presented", "submitted", "completed", "cancelled", "expired", "failed"].includes(candidate.status ?? "");
}
function cloneRequest(request: StandardInteractionRequest): StandardInteractionRequest {
return JSON.parse(JSON.stringify(request)) as StandardInteractionRequest;
}
function copy(record: StandardInteractionRecord): StandardInteractionRecord {
return { ...record, request: cloneRequest(record.request), ...(record.result ? { result: JSON.parse(JSON.stringify(record.result)) as StandardInteractionResult } : {}) };
}
@@ -9,13 +9,13 @@ export type AppOrchestrationResult =
/** Applies Tool Router decisions atomically to instance and focus state. */
export class AppOrchestrator {
public constructor(public readonly lifecycle: AppLifecycleManager, private readonly apps: CoreAppRegistry, private readonly newID: (prefix: string) => string) {}
public launch(appScope: AppScope, conversationID: string, now: string, options: { instance_id?: string; parent_instance_id?: string } = {}): AppOrchestrationResult {
public launch(appScope: AppScope, conversationID: string, now: string, options: { instance_id?: string; parent_instance_id?: string; continued_from_app_session_id?: string } = {}): AppOrchestrationResult {
const app = this.apps.get(appScope);
if (!app) return { disposition: "rejected", code: "not_installed" };
if (!app.enabled) return { disposition: "rejected", code: "disabled" };
const previous = this.lifecycle.focus.foregroundInstance(this.lifecycle.instances);
const existing = this.lifecycle.instances.activeFor(appScope, conversationID);
const instance = existing ?? this.lifecycle.start({ instance_id: options.instance_id ?? this.newID(`${appScope}:instance`), app_scope: appScope, conversation_id: conversationID, ...(options.parent_instance_id ? { parent_instance_id: options.parent_instance_id } : {}) }, now);
const instance = existing ?? this.lifecycle.start({ instance_id: options.instance_id ?? this.newID(`${appScope}:instance`), app_session_id: this.newID(`${appScope}:session`), app_scope: appScope, conversation_id: conversationID, ...(options.parent_instance_id ? { parent_instance_id: options.parent_instance_id } : {}), ...(options.continued_from_app_session_id ? { continued_from_app_session_id: options.continued_from_app_session_id } : {}) }, now);
return { disposition: "foreground", instance: this.lifecycle.foreground(instance.instance_id, now), ...(previous ? { previous } : {}) };
}
public close(instanceID: string, now: string, error?: string): { closed?: AppInstanceRecord; restored?: AppInstanceRecord } {
@@ -15,6 +15,7 @@ class MemoryStorage implements Storage {
class FakeTransport implements TransportAdapter {
public readonly sent: SendTextRequest[] = [];
private readonly pages: TransportMessage[][];
public failSends = false;
public constructor(pages: TransportMessage[][] = []) {
this.pages = [...pages];
@@ -25,6 +26,7 @@ class FakeTransport implements TransportAdapter {
}
public async sendText(request: SendTextRequest): Promise<number | undefined> {
if (this.failSends) throw new Error("offline");
this.sent.push(request);
return 91 + this.sent.length;
}
@@ -96,7 +98,11 @@ describe("LineUpRuntime", () => {
const transport = new FakeTransport();
const runtime = new LineUpRuntime({ storage: new MemoryStorage(), createTransport: () => transport });
expect(runtime.apps.list()).toEqual([expect.objectContaining({ app_scope: "chat", enabled: true, recovery: true })]);
expect(runtime.apps.list()).toContainEqual(expect.objectContaining({ app_scope: "chat", kind: "system", enabled: true, recovery: true }));
expect(runtime.apps.list()).toEqual(expect.arrayContaining([
expect.objectContaining({ app_scope: "task-dashboard", kind: "bundled" }),
expect.objectContaining({ app_scope: "whiteboard", kind: "bundled" }),
]));
const chat = runtime.openApp("chat");
expect(chat.app_scope).toBe("chat");
expect(chat.snapshot()).toMatchObject({ app_scope: "chat", active: false });
@@ -124,6 +130,197 @@ describe("LineUpRuntime", () => {
expect(chat.listAgentMessages()).toEqual([expect.objectContaining({ message_id: "agent_msg_7" })]);
});
it("keeps Agent standard interactions in Interact/IM and out of every MiniApp Inbox", async () => {
const conversationID = "user_1:2:channel_1";
const interaction = JSON.stringify({
v: 1, id: "interactive-call-1", type: "lineup.v1.tool.call", conversation_id: conversationID,
sender: { kind: "agent", id: "agent_1" },
payload: { call_id: "interactive-call-1", tool: "confirm", title: "确认", prompt: "继续吗?", data: { confirm: {} } },
});
const runtime = new LineUpRuntime({
storage: new MemoryStorage(),
createTransport: () => new FakeTransport([[{ message_seq: 12, from_uid: "agent_1", payload: interaction }], []]),
});
const chat = runtime.openChatApp();
await runtime.login(login);
await runtime.syncOnce();
expect(chat.listAgentMessages()).toEqual([expect.objectContaining({ item: expect.objectContaining({ kind: "tool-call" }) })]);
expect(runtime.snapshot()?.app_inbox).toEqual([]);
});
it("persists a pending Interact question, restores it after restart, and sends one answer", async () => {
const conversationID = "user_1:2:channel_1";
const interaction = JSON.stringify({
v: 1, id: "interactive-restart-1", type: "lineup.v1.tool.call", conversation_id: conversationID,
sender: { kind: "agent", id: "agent_1" },
payload: { call_id: "interactive-restart-1", tool: "confirm", title: "确认", prompt: "继续吗?", data: { confirm: {} } },
});
const storage = new MemoryStorage();
const firstTransport = new FakeTransport([[{ message_seq: 13, from_uid: "agent_1", payload: interaction }], []]);
const first = new LineUpRuntime({ storage, createTransport: () => firstTransport, now: () => "2026-08-04T00:00:00.000Z" });
await first.login(login);
await first.syncOnce();
expect(first.standardInteractions()).toEqual([expect.objectContaining({ call_id: "interactive-restart-1", status: "presented" })]);
const secondTransport = new FakeTransport();
const second = new LineUpRuntime({ storage, createTransport: () => secondTransport, now: () => "2026-08-04T00:01:00.000Z" });
const chat = second.openChatApp();
await second.login(login);
expect(second.standardInteractions()).toEqual([expect.objectContaining({ call_id: "interactive-restart-1", status: "presented" })]);
expect(chat.interactions.submit("interactive-restart-1", { confirmed: true })).toBe(true);
await second.flushOutbox();
expect(second.standardInteractions()).toEqual([expect.objectContaining({ call_id: "interactive-restart-1", status: "completed", result: { outcome: "answered", answer: { confirmed: true } } })]);
expect(secondTransport.sent.filter(entry => entry.payload.includes("lineup.v1.tool.result"))).toHaveLength(1);
});
it("associates an Agent question with a real App sub-session and rejects a fabricated one", async () => {
const conversationID = "user_1:2:channel_1";
const launch = JSON.stringify({
v: 1, id: "launch-whiteboard", type: "lineup.v1.app.call", conversation_id: conversationID,
sender: { kind: "agent", id: "agent_1" },
payload: { call_id: "launch-whiteboard", capability: "runtime.launch_app", reason: "打开画板", expires_at: "2026-08-05T00:00:00.000Z", arguments: { app_scope: "whiteboard", instance_id: "whiteboard:1" } },
});
const validQuestion = JSON.stringify({
v: 1, id: "whiteboard-question", type: "lineup.v1.tool.call", conversation_id: conversationID,
sender: { kind: "agent", id: "agent_1" },
payload: { call_id: "whiteboard-question", tool: "input", title: "画板名称", prompt: "请输入名称", app_session_context: { app_session_id: "whiteboard:session-id" }, data: { form: { fields: [{ id: "name", label: "名称", type: "text" }] } } },
});
const invalidQuestion = validQuestion.replace("whiteboard-question", "whiteboard-question-invalid").replace("whiteboard:session-id", "not-a-real-session");
const transport = new FakeTransport([[{ message_seq: 40, from_uid: "agent_1", payload: launch }], [{ message_seq: 41, from_uid: "agent_1", payload: validQuestion }, { message_seq: 42, from_uid: "agent_1", payload: invalidQuestion }], []]);
const runtime = new LineUpRuntime({ storage: new MemoryStorage(), createTransport: () => transport, now: () => "2026-08-04T00:00:00.000Z", newID: prefix => prefix === "whiteboard:session" ? "whiteboard:session-id" : `${prefix}-id` });
await runtime.login(login);
await runtime.syncOnce();
expect(runtime.lifecycle.instances.get("whiteboard:1")?.app_session_id).toBe("whiteboard:session-id");
await runtime.syncOnce();
expect(runtime.standardInteractions()).toEqual([expect.objectContaining({ call_id: "whiteboard-question", app_session_id: "whiteboard:session-id" })]);
expect(runtime.standardInteractions().some(record => record.call_id === "whiteboard-question-invalid")).toBe(false);
});
it("handles Agent interaction.dismiss idempotently and emits one cancellation", async () => {
const conversationID = "user_1:2:channel_1";
const question = JSON.stringify({ v: 1, id: "dismiss-question", type: "lineup.v1.tool.call", conversation_id: conversationID, sender: { kind: "agent", id: "agent_1" }, payload: { call_id: "dismiss-question", tool: "confirm", title: "确认", prompt: "继续吗?", data: { confirm: {} } } });
const dismiss = (id: string) => JSON.stringify({ v: 1, id, type: "lineup.v1.interaction.dismiss", conversation_id: conversationID, sender: { kind: "agent", id: "agent_1" }, payload: { control_id: "dismiss-control-1", call_id: "dismiss-question", reason: "已不再需要" } });
const transport = new FakeTransport([[{ message_seq: 50, from_uid: "agent_1", payload: question }], [{ message_seq: 51, from_uid: "agent_1", payload: dismiss("dismiss-1") }, { message_seq: 52, from_uid: "agent_1", payload: dismiss("dismiss-2") }], []]);
const runtime = new LineUpRuntime({ storage: new MemoryStorage(), createTransport: () => transport, now: () => "2026-08-04T00:00:00.000Z" });
await runtime.login(login);
await runtime.syncOnce();
await runtime.syncOnce();
await runtime.flushOutbox();
expect(runtime.standardInteractions()).toEqual([expect.objectContaining({ call_id: "dismiss-question", status: "cancelled" })]);
expect(transport.sent.filter(entry => entry.payload.includes("lineup.v1.tool.cancel"))).toHaveLength(1);
});
it("delivers a revisioned ordinary Tool to bundled MiniApp SDK and keeps the App open after completion", async () => {
const conversationID = "user_1:2:channel_1";
const call = JSON.stringify({
v: 1, id: "task-tool-call", type: "lineup.v1.miniapp.tool.call", conversation_id: conversationID,
sender: { kind: "agent", id: "agent_1" },
payload: { call_id: "task-tool-call", tool_id: "task.create", app_scope: "task-dashboard", inventory_revision: "catalog-1", input: { title: "写验收记录" } },
});
const transport = new FakeTransport([[{ message_seq: 60, from_uid: "agent_1", payload: call }], []]);
const runtime = new LineUpRuntime({ storage: new MemoryStorage(), createTransport: () => transport, now: () => "2026-08-04T00:00:00.000Z", newID: prefix => `${prefix}-id` });
await runtime.login(login);
await runtime.syncOnce();
const instance = runtime.lifecycle.instances.activeFor("task-dashboard", conversationID);
expect(instance?.app_session_id).toBe("task-dashboard:session-id");
expect(runtime.snapshot()?.app_inbox).toEqual([expect.objectContaining({ app_scope: "task-dashboard", item: expect.objectContaining({ kind: "miniapp-tool-call" }) })]);
const sdk = runtime.openBundledMiniApp("task-dashboard", instance!.instance_id);
const received: string[] = [];
sdk.tools.subscribe(tool => received.push(tool.call_id));
expect(received).toEqual(["task-tool-call"]);
expect(runtime.miniAppToolCalls()).toEqual([expect.objectContaining({ call_id: "task-tool-call", status: "running", instance_id: instance!.instance_id })]);
await sdk.tools.complete("task-tool-call", { task_id: "task-1" });
await runtime.flushOutbox();
expect(runtime.miniAppToolCalls()).toEqual([expect.objectContaining({ call_id: "task-tool-call", status: "completed" })]);
expect(runtime.lifecycle.instances.get(instance!.instance_id)).toMatchObject({ state: "foreground" });
expect(transport.sent.filter(entry => entry.payload.includes("lineup.v1.miniapp.tool.result"))).toHaveLength(1);
});
it("keeps bundled Surface requests local and scopes them to the current instance", async () => {
const conversationID = "user_1:2:channel_1";
const launch = JSON.stringify({
v: 1, id: "whiteboard-launch", type: "lineup.v1.miniapp.tool.call", conversation_id: conversationID,
sender: { kind: "agent", id: "agent_1" },
payload: { call_id: "whiteboard-launch", tool_id: "whiteboard.open", app_scope: "whiteboard", inventory_revision: "catalog-1", input: {} },
});
const transport = new FakeTransport([[{ message_seq: 62, from_uid: "agent_1", payload: launch }], []]);
const runtime = new LineUpRuntime({ storage: new MemoryStorage(), createTransport: () => transport });
await runtime.login(login);
await runtime.syncOnce();
const instance = runtime.lifecycle.instances.activeFor("whiteboard", conversationID);
expect(instance).toBeDefined();
const requests: unknown[] = [];
runtime.onEvent(event => { if (event.type === "surface-request") requests.push(event); });
const sdk = runtime.openBundledMiniApp("whiteboard", instance!.instance_id);
expect(await sdk.surfaces.request("open", { state: { title: "验收画板" } })).toEqual({ disposition: "accepted" });
expect(await sdk.surfaces.request("patch", { state: { title: "已更新" } })).toEqual({ disposition: "accepted" });
expect(requests).toEqual([
expect.objectContaining({ type: "surface-request", app_scope: "whiteboard", instance_id: instance!.instance_id, event: "open", data: { state: { title: "验收画板" } } }),
expect.objectContaining({ type: "surface-request", event: "patch", data: { state: { title: "已更新" } } }),
]);
expect(transport.sent.some(entry => entry.payload.includes("lineup.v1.ui.open"))).toBe(false);
});
it("converges an unclaimed bundled Tool once on App close and keeps no new delivery path", async () => {
const conversationID = "user_1:2:channel_1";
const call = JSON.stringify({ v: 1, id: "task-tool-close", type: "lineup.v1.miniapp.tool.call", conversation_id: conversationID, sender: { kind: "agent", id: "agent_1" }, payload: { call_id: "task-tool-close", tool_id: "task.create", app_scope: "task-dashboard", inventory_revision: "catalog-1", input: { title: "关闭测试" } } });
const transport = new FakeTransport([[{ message_seq: 61, from_uid: "agent_1", payload: call }], []]);
const runtime = new LineUpRuntime({ storage: new MemoryStorage(), createTransport: () => transport, now: () => "2026-08-04T00:00:00.000Z" });
await runtime.login(login);
await runtime.syncOnce();
const instance = runtime.lifecycle.instances.activeFor("task-dashboard", conversationID);
runtime.closeAppInstance(instance!.instance_id);
await runtime.flushOutbox();
expect(runtime.miniAppToolCalls()).toEqual([expect.objectContaining({ call_id: "task-tool-close", status: "cancelled", cancel_reason: "app_closed" })]);
expect(runtime.lifecycle.instances.get(instance!.instance_id)).toMatchObject({ state: "stopped" });
expect(transport.sent.filter(entry => entry.payload.includes("lineup.v1.miniapp.tool.cancel"))).toHaveLength(1);
});
it("continues a closed bundled App in a new instance and new App sub-session", async () => {
const conversationID = "user_1:2:channel_1";
const transport = new FakeTransport();
const runtime = new LineUpRuntime({ storage: new MemoryStorage(), createTransport: () => transport, newID: prefix => `${prefix}-${Math.random().toString(36).slice(2, 7)}` });
await runtime.login(login);
const first = runtime.lifecycle.instances.activeFor("whiteboard", conversationID) ?? runtime.lifecycle.start({ app_scope: "whiteboard", instance_id: "whiteboard:first", app_session_id: "whiteboard:first-session", conversation_id: conversationID }, "2026-08-04T00:00:00.000Z");
if (first.state === "starting") runtime.lifecycle.foreground(first.instance_id, "2026-08-04T00:00:00.000Z");
runtime.closeAppInstance(first.instance_id);
const receipt = await runtime.openApp("chat").dispatch({ type: "chat.continue_app_session", conversation_id: conversationID, app_session_id: first.app_session_id! });
expect(receipt).toEqual({ disposition: "accepted" });
const continued = runtime.lifecycle.instances.activeFor("whiteboard", conversationID);
expect(continued?.instance_id).not.toBe(first.instance_id);
expect(continued?.app_session_id).not.toBe(first.app_session_id);
expect(continued?.continued_from_app_session_id).toBe(first.app_session_id);
});
it("replays a submitted MiniApp result from the outbox after Runtime restart", async () => {
const conversationID = "user_1:2:channel_1";
const call = JSON.stringify({
v: 1, id: "restart-tool-call", type: "lineup.v1.miniapp.tool.call", conversation_id: conversationID,
sender: { kind: "agent", id: "agent_1" },
payload: { call_id: "restart-tool-call", tool_id: "task.create", app_scope: "task-dashboard", inventory_revision: "catalog-1", input: { title: "重启恢复" } },
});
const storage = new MemoryStorage();
const firstTransport = new FakeTransport([[{ message_seq: 70, from_uid: "agent_1", payload: call }], []]);
firstTransport.failSends = true;
const first = new LineUpRuntime({ storage, createTransport: () => firstTransport });
await first.login(login);
await first.syncOnce();
const instance = first.lifecycle.instances.activeFor("task-dashboard", conversationID)!;
const sdk = first.openBundledMiniApp("task-dashboard", instance.instance_id);
sdk.tools.subscribe(() => undefined);
await sdk.tools.complete("restart-tool-call", { task_id: "restart-task" });
expect(first.miniAppToolCalls()).toEqual([expect.objectContaining({ call_id: "restart-tool-call", status: "submitted" })]);
const secondTransport = new FakeTransport();
const second = new LineUpRuntime({ storage, createTransport: () => secondTransport });
await second.login(login);
expect(second.miniAppToolCalls()).toEqual([expect.objectContaining({ call_id: "restart-tool-call", status: "submitted" })]);
await second.flushOutbox();
expect(second.miniAppToolCalls()).toEqual([expect.objectContaining({ call_id: "restart-tool-call", status: "completed" })]);
expect(secondTransport.sent.filter(entry => entry.payload.includes("restart-tool-call"))).toHaveLength(1);
});
it("projects Agent state and Chat-safe Runtime status through the SDK", async () => {
const conversationID = "user_1:2:channel_1";
const transport = new FakeTransport([[
@@ -158,7 +355,7 @@ describe("LineUpRuntime", () => {
const conversationID = "user_1:2:channel_1";
const transport = new FakeTransport([[
{ message_seq: 8, from_uid: "agent_1", payload: agentEnvelope("wrong_app_scope", conversationID, {
scope: { app_scope: "whiteboard", conversation_id: conversationID },
scope: { app_scope: "not-installed-app", conversation_id: conversationID },
}) },
{ message_seq: 9, from_uid: "agent_1", payload: agentEnvelope("wrong_conversation_scope", conversationID, {
scope: { app_scope: "chat", conversation_id: "other:2:conversation" },
@@ -242,7 +439,7 @@ describe("LineUpRuntime", () => {
expect(receipt).toEqual({ disposition: "accepted", local_id: "local_test" });
expect(local).toEqual([expect.objectContaining({ local_id: "local_test", item: expect.objectContaining({ kind: "user-message", text: "hello runtime" }) })]);
expect(transport.sent).toEqual([expect.objectContaining({ from_uid: "user_1", channel_id: "channel_1", payload: "hello runtime" })]);
expect(transport.sent.filter(entry => entry.payload === "hello runtime")).toEqual([expect.objectContaining({ from_uid: "user_1", channel_id: "channel_1" })]);
expect(runtime.snapshot()?.outbox).toEqual([]);
expect(runtime.snapshot()?.items).toEqual([expect.objectContaining({ local_id: "local_test", item: expect.objectContaining({ delivery: expect.objectContaining({ status: "submitted" }) }) })]);
});
+530 -18
View File
@@ -10,6 +10,8 @@ import {
type ConversationItem,
type Envelope,
type JsonObject,
type ToolCallRequest,
type MiniAppToolInvoke,
} from "@/runtime/protocol/lineup-v1";
import { CoreAppRegistry, type AppScope } from "@/runtime/app-management/app-registry";
import {
@@ -44,6 +46,11 @@ 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";
export type RuntimeSession = {
api: string;
@@ -78,6 +85,8 @@ const CONTROL_ECHO_TYPES = new Set([
"lineup.v1.ui.event",
"lineup.v1.client.inventory",
"lineup.v1.app.result",
"lineup.v1.app.session.opened",
"lineup.v1.app.session.closed",
]);
/**
@@ -93,6 +102,8 @@ export class LineUpRuntime {
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>();
@@ -103,6 +114,8 @@ export class LineUpRuntime {
private readonly prepareIncoming: (item: ConversationItem) => ConversationItem | undefined;
private readonly orchestrator: AppOrchestrator;
private readonly toolRouter: ToolRouter;
/** SDK v1 inventory revision advertised by this Runtime instance. */
private readonly inventoryRevision = "catalog-1";
private transport: TransportAdapter | undefined;
private session: RuntimeSession = {
@@ -144,11 +157,73 @@ export class LineUpRuntime {
return this.kernel.snapshot();
}
public standardInteractions(): readonly StandardInteractionRecord[] {
return this.interactionService.list();
}
public miniAppToolCalls(): readonly MiniAppToolCallRecord[] {
return this.miniappTools.list();
}
/** 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 });
@@ -213,8 +288,8 @@ export class LineUpRuntime {
const app = this.apps.get(appScope);
const instance = this.lifecycle.instances.get(instanceID);
const conversationID = this.requireConversationID();
if (!app || app.kind !== "extension" || !app.enabled || !instance || instance.app_scope !== appScope || instance.conversation_id !== conversationID) {
throw new Error("Extension App SDK is unavailable for this instance.");
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,
@@ -225,6 +300,89 @@ export class LineUpRuntime {
};
}
/** 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;
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.queueProtocolAction("app-result", "lineup.v1.app.call", { instance_id: instanceID, app_scope: appScope, capability: name, reason, arguments: input, expires_at: new Date(Date.parse(this.now()) + 15 * 60 * 1000).toISOString() }),
},
workspace: () => this.workspaceSnapshot(),
};
}
/** @deprecated Use openApp("chat") so App selection remains registry-driven. */
public openChatApp(): ChatRuntimeSDK {
return this.openApp("chat");
@@ -247,15 +405,24 @@ export class LineUpRuntime {
};
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())) {
this.orchestrator.launch("chat", this.requireConversationID(), this.now());
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();
@@ -338,6 +505,19 @@ export class LineUpRuntime {
}
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;
@@ -348,6 +528,19 @@ export class LineUpRuntime {
}
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;
@@ -385,6 +578,78 @@ export class LineUpRuntime {
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">,
@@ -392,7 +657,9 @@ export class LineUpRuntime {
payload: JsonObject,
): Promise<CommandReceipt> {
if (!this.session.active) return { disposition: "rejected", code: "inactive" };
const localID = this.newID(kind);
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"),
@@ -463,6 +730,16 @@ export class LineUpRuntime {
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" };
}
@@ -485,12 +762,92 @@ export class LineUpRuntime {
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() });
const route = this.toolRouter.route(payload, { app_scope: scopeResolution.scope.app_scope, conversation_id: this.requireConversationID(), 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);
@@ -507,26 +864,56 @@ export class LineUpRuntime {
received_at: receivedAt,
message_seq: message.message_seq,
};
const targetScope = route.disposition === "launch" ? route.app_scope : scopeResolution.scope.app_scope;
this.store.enqueueAppInbox({
message_id: messageID,
app_scope: targetScope,
conversation_id: scopeResolution.scope.conversation_id,
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 (targetScope === "chat") this.emitChatMessage(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();
}
@@ -538,7 +925,18 @@ export class LineUpRuntime {
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 {
@@ -564,6 +962,17 @@ export class LineUpRuntime {
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(),
@@ -571,15 +980,33 @@ export class LineUpRuntime {
agent_presence: snapshot.agent_presence,
tool_calls: snapshot.tool_calls,
tasks: snapshot.tasks,
app_sessions: appSessions,
};
}
private listChatMessages(afterMessageID?: string): readonly AgentChatMessage[] {
return this.listAppMessages("chat", afterMessageID) as 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): readonly ScopedConversationItem[] {
const entries = this.store.listAppInbox(appScope);
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,
@@ -603,10 +1030,43 @@ export class LineUpRuntime {
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.");
@@ -619,6 +1079,58 @@ function boundedFailure(value: string): string {
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));
}
/** 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);
@@ -0,0 +1,43 @@
import type { JsonObject, JsonValue } from "@/runtime/protocol/lineup-v1";
/** Small, deterministic JSON Schema subset frozen for MiniApp Tool v1. */
export function validateMiniAppToolSchema(value: JsonValue, schema: JsonObject | undefined): boolean {
if (!schema) return value !== undefined;
if (schema.enum !== undefined && (!Array.isArray(schema.enum) || !schema.enum.some(candidate => JSON.stringify(candidate) === JSON.stringify(value)))) return false;
const type = typeof schema.type === "string" ? schema.type : undefined;
if (type && !matchesType(value, type)) return false;
if (typeof value === "string") {
if (typeof schema.minLength === "number" && value.length < schema.minLength) return false;
if (typeof schema.maxLength === "number" && value.length > schema.maxLength) return false;
}
if (typeof value === "number") {
if (typeof schema.minimum === "number" && value < schema.minimum) return false;
if (typeof schema.maximum === "number" && value > schema.maximum) return false;
}
if (Array.isArray(value)) {
if (typeof schema.maxItems === "number" && value.length > schema.maxItems) return false;
if (schema.items && typeof schema.items === "object" && !Array.isArray(schema.items) && !value.every(item => validateMiniAppToolSchema(item, schema.items as JsonObject))) return false;
}
if (isObject(value)) {
const required = Array.isArray(schema.required) ? schema.required.filter(entry => typeof entry === "string") : [];
if (!required.every(key => Object.hasOwn(value, key))) return false;
const properties = isObject(schema.properties) ? schema.properties : {};
if (schema.additionalProperties === false && Object.keys(value).some(key => !Object.hasOwn(properties, key))) return false;
for (const [key, child] of Object.entries(properties)) if (Object.hasOwn(value, key) && isObject(child) && !validateMiniAppToolSchema(value[key], child)) return false;
}
return true;
}
function matchesType(value: JsonValue, type: string): boolean {
return type === "object" ? isObject(value)
: type === "array" ? Array.isArray(value)
: type === "string" ? typeof value === "string"
: type === "number" ? typeof value === "number" && Number.isFinite(value)
: type === "boolean" ? typeof value === "boolean"
: type === "null" ? value === null
: false;
}
function isObject(value: JsonValue | undefined): value is JsonObject {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { MiniAppToolStateMachine } from "@/runtime/coordination/miniapp-tool-state";
describe("MiniAppToolStateMachine", () => {
it("moves a call through claim and submitted result, while rejecting duplicate terminal writes", () => {
const machine = new MiniAppToolStateMachine();
machine.receive({ call_id: "call-1", tool_id: "task.create", inventory_revision: "catalog-1", app_scope: "task-dashboard", instance_id: "task:1", conversation_id: "c", input: {}, created_at: "2026-08-04T00:00:00.000Z" });
expect(machine.route("call-1", "task:1", "2026-08-04T00:00:01.000Z")).toMatchObject({ disposition: "accepted", record: { status: "waiting_for_app" } });
expect(machine.start("call-1", "task:1", "2026-08-04T00:00:02.000Z")).toMatchObject({ disposition: "accepted", record: { status: "running" } });
expect(machine.submit("call-1", "task:1", { task_id: "t1" }, "2026-08-04T00:00:03.000Z")).toMatchObject({ disposition: "accepted", record: { status: "submitted" } });
expect(machine.submit("call-1", "task:1", { task_id: "t2" }, "2026-08-04T00:00:04.000Z")).toMatchObject({ disposition: "invalid_transition", record: { status: "submitted" } });
expect(machine.complete("call-1", "2026-08-04T00:00:05.000Z")).toMatchObject({ disposition: "accepted", record: { status: "completed" } });
});
it("cancels only the instance that owns a pending call", () => {
const machine = new MiniAppToolStateMachine();
machine.receive({ call_id: "call-2", tool_id: "task.create", inventory_revision: "catalog-1", app_scope: "task-dashboard", instance_id: "task:1", conversation_id: "c", input: {}, created_at: "2026-08-04T00:00:00.000Z" });
machine.route("call-2", "task:1", "2026-08-04T00:00:01.000Z");
expect(machine.cancel("call-2", "task:2", "app_closed", "2026-08-04T00:00:02.000Z")).toEqual(expect.objectContaining({ disposition: "invalid_scope" }));
expect(machine.cancel("call-2", "task:1", "app_closed", "2026-08-04T00:00:02.000Z")).toMatchObject({ disposition: "accepted", record: { status: "cancelled", cancel_reason: "app_closed" } });
});
});
@@ -0,0 +1,155 @@
import type { JsonObject } from "@/runtime/protocol/lineup-v1";
export type MiniAppToolStatus =
| "received"
| "routing"
| "waiting_for_app"
| "running"
| "submitted"
| "completed"
| "failed"
| "cancelled"
| "expired"
| "rejected";
export type MiniAppToolCancelReason = "app_closed" | "agent_cancelled" | "runtime_cancelled";
export type MiniAppToolCallRecord = {
call_id: string;
tool_id: string;
inventory_revision: string;
app_scope: string;
instance_id?: string;
conversation_id: string;
status: MiniAppToolStatus;
input: JsonObject;
result?: JsonObject;
error_code?: string;
cancel_reason?: MiniAppToolCancelReason;
created_at: string;
updated_at: string;
submitted_at?: string;
};
export type MiniAppToolTransition =
| { disposition: "accepted"; record: MiniAppToolCallRecord }
| { disposition: "duplicate" | "missing" | "invalid_transition" | "invalid_scope"; record?: MiniAppToolCallRecord };
export class MiniAppToolStateMachine {
private readonly records = new Map<string, MiniAppToolCallRecord>();
public receive(input: Omit<MiniAppToolCallRecord, "status" | "created_at" | "updated_at"> & { created_at: string }): MiniAppToolTransition {
const existing = this.records.get(input.call_id);
if (existing) return { disposition: "duplicate", record: copy(existing) };
const record: MiniAppToolCallRecord = {
...input,
status: "received",
created_at: input.created_at,
updated_at: input.created_at,
input: clone(input.input),
};
this.records.set(record.call_id, record);
return { disposition: "accepted", record: copy(record) };
}
public route(callID: string, instanceID: string, now: string): MiniAppToolTransition {
return this.transition(callID, now, ["received", "routing"], record => {
record.status = "waiting_for_app";
record.instance_id = instanceID;
});
}
public start(callID: string, instanceID: string, now: string): MiniAppToolTransition {
return this.transition(callID, now, ["waiting_for_app", "running"], record => {
if (record.instance_id !== instanceID) throw new Error("instance mismatch");
record.status = "running";
});
}
public progress(callID: string, instanceID: string, now: string): MiniAppToolTransition {
return this.transition(callID, now, ["waiting_for_app", "running"], record => {
if (record.instance_id !== instanceID) throw new Error("instance mismatch");
record.status = "running";
});
}
public submit(callID: string, instanceID: string, result: JsonObject | undefined, now: string): MiniAppToolTransition {
return this.transition(callID, now, ["waiting_for_app", "running"], record => {
if (record.instance_id !== instanceID) throw new Error("instance mismatch");
record.status = "submitted";
record.submitted_at = now;
if (result) record.result = clone(result);
});
}
public complete(callID: string, now: string): MiniAppToolTransition {
return this.transition(callID, now, ["submitted"], record => { record.status = "completed"; });
}
public fail(callID: string, instanceID: string, code: string, now: string): MiniAppToolTransition {
return this.transition(callID, now, ["waiting_for_app", "running"], record => {
if (record.instance_id !== instanceID) throw new Error("instance mismatch");
record.status = "failed";
record.error_code = code;
});
}
public cancel(callID: string, instanceID: string | undefined, reason: MiniAppToolCancelReason, now: string): MiniAppToolTransition {
return this.transition(callID, now, ["received", "routing", "waiting_for_app", "running"], record => {
if (instanceID !== undefined && record.instance_id !== instanceID) throw new Error("instance mismatch");
record.status = "cancelled";
record.cancel_reason = reason;
});
}
public get(callID: string): MiniAppToolCallRecord | undefined {
const record = this.records.get(callID);
return record ? copy(record) : undefined;
}
public list(): readonly MiniAppToolCallRecord[] { return [...this.records.values()].map(copy); }
public restore(records: readonly MiniAppToolCallRecord[], now: string): void {
this.records.clear();
for (const raw of records) {
if (!validRecord(raw) || this.records.has(raw.call_id)) continue;
const record = copy(raw);
// A native MiniApp cannot safely resume an in-flight callback. Keep the
// durable call waiting for the newly mounted instance to claim it.
if (["routing", "running"].includes(record.status)) record.status = "waiting_for_app";
record.updated_at = record.status === raw.status ? record.updated_at : now;
this.records.set(record.call_id, record);
}
}
public replace(records: readonly MiniAppToolCallRecord[]): void {
this.records.clear();
for (const record of records) if (validRecord(record)) this.records.set(record.call_id, copy(record));
}
private transition(callID: string, now: string, allowed: readonly MiniAppToolStatus[], apply: (record: MiniAppToolCallRecord) => void): MiniAppToolTransition {
const record = this.records.get(callID);
if (!record) return { disposition: "missing" };
if (!allowed.includes(record.status)) return { disposition: "invalid_transition", record: copy(record) };
try { apply(record); } catch { return { disposition: "invalid_scope", record: copy(record) }; }
record.updated_at = now;
return { disposition: "accepted", record: copy(record) };
}
}
function validRecord(value: unknown): value is MiniAppToolCallRecord {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const record = value as Partial<MiniAppToolCallRecord>;
return typeof record.call_id === "string" && record.call_id.length > 0
&& typeof record.tool_id === "string" && record.tool_id.length > 0
&& typeof record.inventory_revision === "string" && record.inventory_revision.length > 0
&& typeof record.app_scope === "string" && record.app_scope.length > 0
&& typeof record.conversation_id === "string" && record.conversation_id.length > 0
&& record.input !== undefined && typeof record.input === "object" && !Array.isArray(record.input)
&& typeof record.status === "string" && ["received", "routing", "waiting_for_app", "running", "submitted", "completed", "failed", "cancelled", "expired", "rejected"].includes(record.status)
&& typeof record.created_at === "string" && !Number.isNaN(Date.parse(record.created_at))
&& typeof record.updated_at === "string" && !Number.isNaN(Date.parse(record.updated_at));
}
function clone<T>(value: T): T { return JSON.parse(JSON.stringify(value)) as T; }
function copy(record: MiniAppToolCallRecord): MiniAppToolCallRecord { return clone(record); }
@@ -0,0 +1,85 @@
/** Runtime-owned validation for Agent-facing Interact standard interactions. */
export const DEFAULT_INTERACTION_EXPIRY_MS = 15 * 60 * 1000;
export const MIN_INTERACTION_EXPIRY_MS = 60 * 1000;
export const MAX_INTERACTION_EXPIRY_MS = 24 * 60 * 60 * 1000;
type InteractionBase = Readonly<{ title: string; prompt: string; expires_in_ms?: number }>;
export type NoticeInteractionRequest = Readonly<{
kind: "notice";
title: string;
message: string;
dismiss_label?: string;
}>;
export type ChoiceInteractionRequest = InteractionBase & Readonly<{
kind: "choice";
mode: "single-choice";
actions: readonly Readonly<{ id: string; label: string; description?: string }>[];
}>;
export type ConfirmInteractionRequest = InteractionBase & Readonly<{
kind: "confirm";
approve_label?: string;
cancel_label?: string;
}>;
export type InputInteractionRequest = InteractionBase & Readonly<{
kind: "input";
field: Readonly<{
id: string;
label: string;
type: "text" | "textarea";
required?: boolean;
placeholder?: string;
max_length?: number;
}>;
submit_label?: string;
cancel_label?: string;
}>;
export type StandardInteractionRequest =
| NoticeInteractionRequest
| ChoiceInteractionRequest
| ConfirmInteractionRequest
| InputInteractionRequest;
export type InteractionRequestValidation =
| Readonly<{ accepted: true; expires_at?: string }>
| Readonly<{ accepted: false; code: "interaction_request_invalid" }>;
/** Validates a request before it becomes a durable StandardInteractionRecord. */
export function validateStandardInteractionRequest(request: StandardInteractionRequest, now: Date): InteractionRequestValidation {
if (!validTitle(request.title)) return invalid();
if (request.kind === "notice") {
if (!validText(request.message) || Object.hasOwn(request, "expires_in_ms")) return invalid();
return { accepted: true };
}
if (!validText(request.prompt)) return invalid();
if (request.kind === "choice" && !validChoice(request)) return invalid();
if (request.kind === "input" && !validInput(request)) return invalid();
const duration = request.expires_in_ms ?? DEFAULT_INTERACTION_EXPIRY_MS;
if (!Number.isInteger(duration) || duration < MIN_INTERACTION_EXPIRY_MS || duration > MAX_INTERACTION_EXPIRY_MS) return invalid();
return { accepted: true, expires_at: new Date(now.getTime() + duration).toISOString() };
}
function validChoice(request: ChoiceInteractionRequest): boolean {
if (request.mode !== "single-choice" || request.actions.length < 2 || request.actions.length > 6) return false;
const ids = new Set<string>();
return request.actions.every(action => validIdentifier(action.id) && validText(action.label) && !ids.has(action.id) && (ids.add(action.id), true));
}
function validInput(request: InputInteractionRequest): boolean {
const { field } = request;
return validIdentifier(field.id)
&& validText(field.label)
&& (field.type === "text" || field.type === "textarea")
&& (field.max_length === undefined || (Number.isInteger(field.max_length) && field.max_length > 0 && field.max_length <= 1000));
}
function validTitle(value: string): boolean { return validText(value) && value.length <= 200; }
function validText(value: string): boolean { return typeof value === "string" && value.trim().length > 0 && value.length <= 4000; }
function validIdentifier(value: string): boolean { return typeof value === "string" && /^[A-Za-z0-9._:-]{1,128}$/.test(value); }
function invalid(): InteractionRequestValidation { return { accepted: false, code: "interaction_request_invalid" }; }
@@ -17,6 +17,9 @@ 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;
@@ -13,6 +13,36 @@ function launch(scope = "draw-and-guess", extra: Record<string, unknown> = {}) {
}
describe("ToolRouter", () => {
it("routes standard Agent interactions to Runtime without selecting a MiniApp target", () => {
const apps = new CoreAppRegistry();
const router = new ToolRouter(apps);
const raw = JSON.stringify({ v: 1, id: "interaction-1", type: "lineup.v1.tool.call", conversation_id: "c1", sender: { kind: "agent", id: "agent" }, payload: {
call_id: "interaction-call-1", tool: "confirm", title: "确认", prompt: "继续吗?", data: { confirm: {} },
} });
expect(router.route(raw, { app_scope: "chat", conversation_id: "c1" })).toEqual({ disposition: "interactive", call_id: "interaction-call-1" });
});
it("routes a revisioned ordinary MiniApp Tool to its declared bundled target", () => {
const apps = new CoreAppRegistry();
apps.install({
app_scope: "custom-dashboard", kind: "bundled", enabled: true, default_eligible: false, recovery: false,
manifest: { app_scope: "custom-dashboard", version: "1.0.0", permissions: [], tools: [{ name: "task.create", handling: "operation", parameters: [], input_schema: { type: "object", required: ["title"], properties: { title: { type: "string" } }, additionalProperties: false } }] },
});
const router = new ToolRouter(apps);
const raw = JSON.stringify({ v: 1, id: "miniapp-call-1", type: "lineup.v1.miniapp.tool.call", conversation_id: "c1", sender: { kind: "agent", id: "agent" }, payload: { call_id: "miniapp-call-1", tool_id: "task.create", app_scope: "custom-dashboard", inventory_revision: "catalog-1", input: { title: "测试任务" } } });
expect(router.route(raw, { app_scope: "chat", conversation_id: "c1", inventory_revision: "catalog-1" })).toEqual(expect.objectContaining({ disposition: "miniapp", handling: "operation", app_scope: "custom-dashboard" }));
expect(router.route(raw.replace("catalog-1", "catalog-2"), { app_scope: "chat", conversation_id: "c1", inventory_revision: "catalog-1" })).toEqual({ disposition: "rejected", code: "inventory_revision_mismatch" });
expect(router.route(raw.replace('"title":"测试任务"', '"unknown":true'), { app_scope: "chat", conversation_id: "c1", inventory_revision: "catalog-1" })).toEqual({ disposition: "rejected", code: "invalid_request" });
});
it("retains verified App session context as metadata only", () => {
const router = new ToolRouter(new CoreAppRegistry());
const raw = JSON.stringify({ v: 1, id: "interaction-2", type: "lineup.v1.tool.call", conversation_id: "c1", sender: { kind: "agent", id: "agent" }, payload: {
call_id: "interaction-call-2", tool: "input", title: "描述", prompt: "输入", data: { form: { fields: [{ id: "text", label: "描述", type: "textarea" }] } }, app_session_context: { app_session_id: "whiteboard:1" },
} });
expect(router.route(raw, { app_scope: "chat", conversation_id: "c1" })).toEqual({ disposition: "interactive", call_id: "interaction-call-2", app_session_id: "whiteboard:1" });
});
it("accepts only a manifest-authorized installed extension launch", () => {
const apps = new CoreAppRegistry(); apps.install(extension());
expect(new ToolRouter(apps).route(launch(), { app_scope: "chat", conversation_id: "c1" })).toMatchObject({ disposition: "launch", app_scope: "draw-and-guess", call_id: "call-1" });
+53 -4
View File
@@ -4,26 +4,63 @@
* routed result and can never turn an arbitrary app.call into a launch.
*/
import type { AppManifest, AppScope, CoreAppRegistry } from "@/runtime/app-management/app-registry";
import { parseEnvelopeJSON, type Envelope, type JsonObject } from "@/runtime/protocol/lineup-v1";
import { parseEnvelopeJSON, type Envelope, type JsonObject, type MiniAppToolInvoke } from "@/runtime/protocol/lineup-v1";
import { validateMiniAppToolSchema } from "@/runtime/coordination/miniapp-tool-schema";
export type ToolRoute =
| { disposition: "pass" }
| { disposition: "interactive"; call_id: string; app_session_id?: string }
| { disposition: "dismiss"; call_id: string; control_id: string; reason?: string }
| { disposition: "miniapp"; call: MiniAppToolInvoke; handling: "direct" | "launch" | "foreground" | "operation"; app_scope: AppScope; instance_id?: string; requires_foreground: boolean; restore_previous_focus: boolean }
| { disposition: "launch"; call_id: string; app_scope: AppScope; instance_id?: string; arguments: JsonObject }
| { disposition: "rejected"; code: "invalid_request" | "scope_mismatch" | "not_installed" | "disabled" | "manifest_denied" | "permission_denied" };
| { disposition: "rejected"; code: "invalid_request" | "scope_mismatch" | "app_session_context_invalid" | "inventory_revision_mismatch" | "not_installed" | "disabled" | "manifest_denied" | "permission_denied" };
export class ToolRouter {
public constructor(private readonly apps: CoreAppRegistry) {}
/** Returns pass for ordinary protocol messages, preserving 00.base handling. */
public route(raw: string, expected: { conversation_id: string; app_scope: AppScope }): ToolRoute {
public route(raw: string, expected: { conversation_id: string; app_scope: AppScope; inventory_revision?: string }): ToolRoute {
const parsed = parseEnvelopeJSON(raw);
if (!parsed.ok) return { disposition: "pass" };
const envelope = parsed.envelope;
if (envelope.type === "lineup.v1.interaction.dismiss") return this.routeDismiss(envelope, expected.conversation_id);
if (envelope.type === "lineup.v1.miniapp.tool.call") return this.routeMiniApp(envelope, expected);
if (envelope.type === "lineup.v1.tool.call") return this.routeInteractive(envelope, expected.conversation_id);
if (envelope.type !== "lineup.v1.app.call" || envelope.payload.capability !== "runtime.launch_app") return { disposition: "pass" };
if (envelope.conversation_id !== expected.conversation_id) return { disposition: "rejected", code: "scope_mismatch" };
return this.routeLaunch(envelope);
}
private routeMiniApp(envelope: Envelope, expected: { conversation_id: string; inventory_revision?: string }): ToolRoute {
if (envelope.conversation_id !== expected.conversation_id) return { disposition: "rejected", code: "scope_mismatch" };
const callID = text(envelope.payload.call_id);
const toolID = text(envelope.payload.tool_id);
const appScope = text(envelope.payload.app_scope);
const revision = text(envelope.payload.inventory_revision);
const input = object(envelope.payload.input);
const instanceID = envelope.payload.instance_id === undefined ? undefined : text(envelope.payload.instance_id);
if (!callID || !toolID || !appScope || !revision || !input || (envelope.payload.instance_id !== undefined && !instanceID)) return { disposition: "rejected", code: "invalid_request" };
if (expected.inventory_revision && revision !== expected.inventory_revision) return { disposition: "rejected", code: "inventory_revision_mismatch" };
const app = this.apps.get(appScope);
if (!app) return { disposition: "rejected", code: "not_installed" };
if (!app.enabled) return { disposition: "rejected", code: "disabled" };
const definition = app.manifest.tools.find(tool => tool.name === toolID);
if (!definition || definition.handling === "interactive") return { disposition: "rejected", code: "manifest_denied" };
if (!validateMiniAppToolSchema(input, definition.input_schema)) return { disposition: "rejected", code: "invalid_request" };
if (definition.requires_permission && !app.manifest.permissions.includes(definition.requires_permission)) return { disposition: "rejected", code: "permission_denied" };
return { disposition: "miniapp", call: { call_id: callID, tool_id: toolID, app_scope: appScope, inventory_revision: revision, input, ...(instanceID ? { instance_id: instanceID } : {}) }, handling: definition.handling, app_scope: appScope, ...(instanceID ? { instance_id: instanceID } : {}), requires_foreground: definition.target?.requires_foreground ?? true, restore_previous_focus: definition.target?.restore_previous_focus ?? false };
}
private routeDismiss(envelope: Envelope, expectedConversationID: string): ToolRoute {
if (envelope.conversation_id !== expectedConversationID) return { disposition: "rejected", code: "scope_mismatch" };
const controlID = text(envelope.payload.control_id);
const callID = text(envelope.payload.call_id);
if (!controlID || !callID || Object.keys(envelope.payload).some(key => key !== "control_id" && key !== "call_id" && key !== "reason")) return { disposition: "rejected", code: "invalid_request" };
const reason = envelope.payload.reason === undefined ? undefined : text(envelope.payload.reason);
if (envelope.payload.reason !== undefined && !reason) return { disposition: "rejected", code: "invalid_request" };
return { disposition: "dismiss", call_id: callID, control_id: controlID, ...(reason ? { reason } : {}) };
}
private routeLaunch(envelope: Envelope): ToolRoute {
const callID = text(envelope.payload.call_id);
const args = object(envelope.payload.arguments);
@@ -32,7 +69,7 @@ export class ToolRouter {
const record = this.apps.get(requestedScope);
if (!record) return { disposition: "rejected", code: "not_installed" };
if (!record.enabled) return { disposition: "rejected", code: "disabled" };
const definition = record.manifest.tools.find(tool => tool.name === "app.launch" && tool.handling === "launch");
const definition = record.manifest.tools.find(tool => tool.handling === "launch");
if (!definition) return { disposition: "rejected", code: "manifest_denied" };
if (definition.requires_permission && !record.manifest.permissions.includes(definition.requires_permission)) return { disposition: "rejected", code: "permission_denied" };
const instanceID = args.instance_id === undefined ? undefined : text(args.instance_id);
@@ -41,6 +78,18 @@ export class ToolRouter {
if (!parameters) return { disposition: "rejected", code: "invalid_request" };
return { disposition: "launch", call_id: callID, app_scope: record.app_scope, ...(instanceID ? { instance_id: instanceID } : {}), arguments: parameters };
}
private routeInteractive(envelope: Envelope, expectedConversationID: string): ToolRoute {
if (envelope.conversation_id !== expectedConversationID) return { disposition: "rejected", code: "scope_mismatch" };
const tool = text(envelope.payload.tool);
if (tool !== "notice" && tool !== "choice" && tool !== "confirm" && tool !== "input") return { disposition: "pass" };
const callID = text(envelope.payload.call_id);
if (!callID) return { disposition: "rejected", code: "invalid_request" };
const context = envelope.payload.app_session_context;
if (context !== undefined && (!object(context) || !text(object(context)?.app_session_id))) return { disposition: "rejected", code: "invalid_request" };
const appSessionID = object(context) ? text(object(context)?.app_session_id) : undefined;
return { disposition: "interactive", call_id: callID, ...(appSessionID ? { app_session_id: appSessionID } : {}) };
}
}
function object(value: unknown): JsonObject | undefined { return value && typeof value === "object" && !Array.isArray(value) ? value as JsonObject : undefined; }