feat(runtime): establish miniapp kernel and sdk design
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
import type { AppInstanceRecord } from "@/runtime/app-management/app-instance-manager";
|
||||
import { AppLifecycleManager } from "@/runtime/app-management/app-lifecycle-manager";
|
||||
import type { AppScope, CoreAppRegistry } from "@/runtime/app-management/app-registry";
|
||||
|
||||
export type AppOrchestrationResult =
|
||||
| { disposition: "foreground"; instance: AppInstanceRecord; previous?: AppInstanceRecord }
|
||||
| { disposition: "rejected"; code: "not_installed" | "disabled" };
|
||||
|
||||
/** 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 {
|
||||
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);
|
||||
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 } {
|
||||
const closed = this.lifecycle.close(instanceID, now, error);
|
||||
const restoreID = this.lifecycle.focus.foreground();
|
||||
const restored = restoreID ? this.lifecycle.foreground(restoreID, now) : undefined;
|
||||
return { ...(closed ? { closed } : {}), ...(restored ? { restored } : {}) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ExecutionProgressState } from "@/runtime/coordination/execution-progress-state";
|
||||
|
||||
const T0 = "2026-08-03T00:00:00.000Z";
|
||||
const T1 = "2026-08-03T00:00:18.000Z";
|
||||
|
||||
describe("ExecutionProgressState", () => {
|
||||
it("starts a turn with no invented operation steps", () => {
|
||||
const state = new ExecutionProgressState();
|
||||
expect(state.begin("exec_001", T0)).toEqual(expect.objectContaining({ id: "exec_001", status: "running", steps: [] }));
|
||||
});
|
||||
|
||||
it("only accepts bounded, canonical execution steps for the matching turn", () => {
|
||||
const state = new ExecutionProgressState();
|
||||
state.begin("exec_001", T0);
|
||||
expect(state.applyTrace("exec_other", "completed", [], T1)).toBeUndefined();
|
||||
const summary = state.applyTrace("exec_001", "completed", [{ id: "step_01", title: "检查 qmtbridge health", status: "completed" }], T1);
|
||||
expect(summary).toEqual(expect.objectContaining({ status: "completed", steps: [{ id: "step_01", title: "检查 qmtbridge health", status: "completed" }] }));
|
||||
});
|
||||
|
||||
it("updates a real running step in place before the turn ends", () => {
|
||||
const state = new ExecutionProgressState();
|
||||
state.begin("exec_001", T0);
|
||||
expect(state.applyTrace("exec_001", "running", [{ id: "step_01", title: "查询公开资料", status: "running" }], T1))
|
||||
.toEqual(expect.objectContaining({ status: "running", finished_at: undefined, steps: [{ id: "step_01", title: "查询公开资料", status: "running" }] }));
|
||||
expect(state.applyTrace("exec_001", "completed", [{ id: "step_01", title: "查询公开资料", status: "completed" }], T1))
|
||||
.toEqual(expect.objectContaining({ status: "completed", finished_at: T1 }));
|
||||
});
|
||||
|
||||
it("finishes without a trace without fabricating a fixed lifecycle", () => {
|
||||
const state = new ExecutionProgressState();
|
||||
state.begin("exec_001", T0);
|
||||
expect(state.finishActive("completed", T1)).toEqual(expect.objectContaining({ steps: [] }));
|
||||
});
|
||||
|
||||
it("keeps overlapping execution ids independent and finishes only the matching turn", () => {
|
||||
const state = new ExecutionProgressState();
|
||||
state.begin("exec_001", T0);
|
||||
state.begin("exec_002", T1);
|
||||
state.finish("exec_002", "completed", T1);
|
||||
expect(state.snapshot()).toEqual([
|
||||
expect.objectContaining({ id: "exec_001", status: "running" }),
|
||||
expect.objectContaining({ id: "exec_002", status: "completed" }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* 单个 Agent 回合的可展示执行摘要投影。
|
||||
*
|
||||
* 只接受 Adapter 提供的受控步骤;不能从状态文本、模型推理、命令、参数、URL、路径或工具
|
||||
* 输出推断步骤,从而避免把隐私或 CoT 显示/持久化到客户端。
|
||||
*
|
||||
* Display-safe projection of one Agent turn.
|
||||
*
|
||||
* The client never turns status text, model reasoning, commands, arguments,
|
||||
* URLs, paths, or tool output into a step. Steps arrive only through the
|
||||
* separately validated, adapter-authoritative `agent.execution` envelope.
|
||||
*/
|
||||
export type ExecutionProgressStatus = "running" | "completed" | "failed";
|
||||
export type ExecutionStepStatus = "running" | "completed" | "failed";
|
||||
|
||||
export type ExecutionProgressStep = {
|
||||
id: string;
|
||||
title: string;
|
||||
status: ExecutionStepStatus;
|
||||
};
|
||||
|
||||
export type ExecutionProgressSummary = {
|
||||
id: string;
|
||||
status: ExecutionProgressStatus;
|
||||
started_at: string;
|
||||
updated_at: string;
|
||||
finished_at?: string;
|
||||
steps: readonly ExecutionProgressStep[];
|
||||
};
|
||||
|
||||
const MAX_SUMMARIES = 20;
|
||||
const MAX_STEPS = 12;
|
||||
const ID = /^[A-Za-z][A-Za-z0-9_.:-]{0,127}$/;
|
||||
const VALID_STATUSES = new Set<ExecutionProgressStatus>(["running", "completed", "failed"]);
|
||||
const VALID_STEP_STATUSES = new Set<ExecutionStepStatus>(["running", "completed", "failed"]);
|
||||
|
||||
export class ExecutionProgressState {
|
||||
private summaries: ExecutionProgressSummary[] = [];
|
||||
|
||||
/** A status boundary starts its exact turn, but deliberately creates no fake step. */
|
||||
public begin(id: string, now: string): ExecutionProgressSummary {
|
||||
const safeID = validID(id) ? id : `execution_${Date.parse(now) || Date.now()}`;
|
||||
const existing = this.summaries.find(summary => summary.id === safeID);
|
||||
if (existing) {
|
||||
existing.updated_at = now;
|
||||
return copy(existing);
|
||||
}
|
||||
const summary: ExecutionProgressSummary = { id: safeID, status: "running", started_at: now, updated_at: now, steps: [] };
|
||||
this.summaries.push(summary);
|
||||
this.trim();
|
||||
return copy(summary);
|
||||
}
|
||||
|
||||
/** Applies a protocol-validated trace for its exact execution id. */
|
||||
public applyTrace(id: string, status: ExecutionProgressStatus, steps: readonly ExecutionProgressStep[], now: string): ExecutionProgressSummary | undefined {
|
||||
const summary = this.summaries.find(entry => entry.id === id);
|
||||
if (!summary) return undefined;
|
||||
summary.status = status;
|
||||
summary.updated_at = now;
|
||||
summary.finished_at = status === "running" ? undefined : now;
|
||||
summary.steps = sanitizeSteps(steps);
|
||||
return copy(summary);
|
||||
}
|
||||
|
||||
/** Ends the one active turn without inventing an operation description. */
|
||||
public finishActive(status: "completed" | "failed", now: string): ExecutionProgressSummary | undefined {
|
||||
const active = this.summaries.find(summary => summary.status === "running");
|
||||
if (!active) return undefined;
|
||||
active.status = status;
|
||||
active.updated_at = now;
|
||||
active.finished_at = now;
|
||||
return copy(active);
|
||||
}
|
||||
|
||||
/** Ends only the matching running execution; never steals another turn's card. */
|
||||
public finish(id: string | undefined, status: "completed" | "failed", now: string): ExecutionProgressSummary | undefined {
|
||||
if (!id) return this.finishActive(status, now);
|
||||
const summary = this.summaries.find(entry => entry.id === id && entry.status === "running");
|
||||
if (!summary) return undefined;
|
||||
summary.status = status;
|
||||
summary.updated_at = now;
|
||||
summary.finished_at = now;
|
||||
return copy(summary);
|
||||
}
|
||||
|
||||
public restore(summaries: readonly ExecutionProgressSummary[]): void {
|
||||
this.summaries = summaries.flatMap(summary => valid(summary) ? [copy(summary)] : []).slice(-MAX_SUMMARIES);
|
||||
}
|
||||
|
||||
public snapshot(): readonly ExecutionProgressSummary[] { return this.summaries.map(copy); }
|
||||
|
||||
private trim(): void { if (this.summaries.length > MAX_SUMMARIES) this.summaries.splice(0, this.summaries.length - MAX_SUMMARIES); }
|
||||
}
|
||||
|
||||
function valid(value: ExecutionProgressSummary): boolean {
|
||||
return validID(value.id) && VALID_STATUSES.has(value.status)
|
||||
&& validTime(value.started_at) && validTime(value.updated_at)
|
||||
&& (value.finished_at === undefined || validTime(value.finished_at))
|
||||
&& Array.isArray(value.steps) && value.steps.length <= MAX_STEPS
|
||||
&& value.steps.every(step => validStep(step));
|
||||
}
|
||||
|
||||
function validID(value: unknown): value is string { return typeof value === "string" && ID.test(value); }
|
||||
function validTime(value: unknown): value is string { return typeof value === "string" && !Number.isNaN(Date.parse(value)); }
|
||||
function validStep(value: unknown): value is ExecutionProgressStep {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value)
|
||||
&& validID((value as ExecutionProgressStep).id)
|
||||
&& typeof (value as ExecutionProgressStep).title === "string"
|
||||
&& (value as ExecutionProgressStep).title.trim().length > 0
|
||||
&& (value as ExecutionProgressStep).title.length <= 160
|
||||
&& VALID_STEP_STATUSES.has((value as ExecutionProgressStep).status));
|
||||
}
|
||||
function sanitizeSteps(steps: readonly ExecutionProgressStep[]): ExecutionProgressStep[] {
|
||||
const seen = new Set<string>();
|
||||
return steps.flatMap(step => validStep(step) && !seen.has(step.id) ? (seen.add(step.id), [{ id: step.id, title: step.title.trim(), status: step.status }]) : []).slice(0, MAX_STEPS);
|
||||
}
|
||||
function copy(summary: ExecutionProgressSummary): ExecutionProgressSummary {
|
||||
return { ...summary, steps: summary.steps.map(step => ({ ...step })) };
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { decodeConversationItem, parseEnvelopeJSON } from "@/runtime/protocol/lineup-v1";
|
||||
import { InteractionKernel } from "@/runtime/coordination/interaction-kernel";
|
||||
|
||||
function envelope(overrides: Record<string, unknown> = {}): string {
|
||||
return JSON.stringify({
|
||||
v: 1,
|
||||
id: "evt_kernel_001",
|
||||
type: "lineup.v1.text",
|
||||
conversation_id: "conversation_kernel_test",
|
||||
sender: { kind: "agent", id: "agent_test" },
|
||||
payload: { markdown: "# Hello\n\n**trusted**" },
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe("InteractionKernel", () => {
|
||||
it("accepts a valid typed envelope and projects its trusted markdown item", () => {
|
||||
const kernel = new InteractionKernel();
|
||||
const result = kernel.ingest({ raw: envelope(), source: "sync", transport_id: "41" });
|
||||
|
||||
expect(result.disposition).toBe("accepted");
|
||||
expect(result.items).toEqual([expect.objectContaining({
|
||||
kind: "markdown",
|
||||
id: "evt_kernel_001",
|
||||
markdown: "# Hello\n\n**trusted**",
|
||||
})]);
|
||||
expect(result.snapshot.seen_envelope_ids).toBe(1);
|
||||
});
|
||||
|
||||
it("rejects invalid protocol JSON while preserving legacy plain text as safe markdown", () => {
|
||||
const kernel = new InteractionKernel();
|
||||
const invalidProtocol = parseEnvelopeJSON("{");
|
||||
const legacyText = kernel.ingest({ raw: "{", source: "sync" });
|
||||
const unknown = kernel.ingest({
|
||||
raw: envelope({ id: "evt_kernel_unknown", type: "lineup.v1.unknown" }),
|
||||
source: "live",
|
||||
});
|
||||
|
||||
expect(invalidProtocol).toMatchObject({ ok: false, fallback: { code: "invalid_json" } });
|
||||
expect(legacyText.items).toEqual([expect.objectContaining({ kind: "markdown", markdown: "{" })]);
|
||||
expect(unknown.items).toEqual([expect.objectContaining({ kind: "fallback", reason: "unsupported_type", id: "evt_kernel_unknown" })]);
|
||||
});
|
||||
|
||||
it("deduplicates one envelope id across sync and live transport sources", () => {
|
||||
const kernel = new InteractionKernel();
|
||||
const first = kernel.ingest({ raw: envelope(), source: "sync" });
|
||||
const duplicate = kernel.ingest({ raw: envelope(), source: "live" });
|
||||
|
||||
expect(first.disposition).toBe("accepted");
|
||||
expect(duplicate).toMatchObject({ disposition: "duplicate", items: [] });
|
||||
expect(duplicate.snapshot.seen_envelope_ids).toBe(1);
|
||||
});
|
||||
|
||||
it("projects the latest agent status by actor and retains a bounded dedupe set", () => {
|
||||
const kernel = new InteractionKernel(2);
|
||||
const thinking = kernel.ingest({
|
||||
raw: envelope({ id: "evt_status_thinking", type: "lineup.v1.agent.status", payload: { status: "thinking", detail: "planning" } }),
|
||||
source: "sync",
|
||||
received_at: "2026-08-03T00:00:00.000Z",
|
||||
});
|
||||
kernel.ingest({ raw: envelope({ id: "evt_other", payload: { markdown: "one" } }), source: "sync" });
|
||||
kernel.ingest({ raw: envelope({ id: "evt_newer", payload: { markdown: "two" } }), source: "sync" });
|
||||
|
||||
expect(thinking.snapshot.agent_presence).toEqual([expect.objectContaining({
|
||||
status: "thinking",
|
||||
detail: "planning",
|
||||
envelope_id: "evt_status_thinking",
|
||||
updated_at: "2026-08-03T00:00:00.000Z",
|
||||
})]);
|
||||
// The first id has expired from the bounded set and is accepted again.
|
||||
expect(kernel.ingest({ raw: envelope({ id: "evt_status_thinking" }), source: "live" }).disposition).toBe("accepted");
|
||||
});
|
||||
|
||||
it("projects tool calls by call_id and applies a result only after submission", () => {
|
||||
const kernel = new InteractionKernel();
|
||||
const call = kernel.ingest({
|
||||
raw: envelope({
|
||||
id: "evt_tool_call",
|
||||
type: "lineup.v1.tool.call",
|
||||
payload: { call_id: "call_kernel_001", tool: "choice", title: "Choose", prompt: "One", data: { action_group: { mode: "single-choice", actions: [{ id: "alpha", label: "Alpha" }] } } },
|
||||
}),
|
||||
source: "sync",
|
||||
received_at: "2026-08-03T08:00:00.000Z",
|
||||
});
|
||||
const duplicateCallID = kernel.ingest({
|
||||
raw: envelope({
|
||||
id: "evt_tool_call_duplicate_id",
|
||||
type: "lineup.v1.tool.call",
|
||||
payload: { call_id: "call_kernel_001", tool: "choice", title: "Ignored", prompt: "Ignored", data: { action_group: { mode: "button", actions: [{ id: "beta", label: "Beta" }] } } },
|
||||
}),
|
||||
source: "live",
|
||||
});
|
||||
const earlyResult = kernel.ingest({
|
||||
raw: envelope({ id: "evt_tool_result_early", type: "lineup.v1.tool.result", payload: { call_id: "call_kernel_001", status: "completed", result: {} } }),
|
||||
source: "live",
|
||||
});
|
||||
|
||||
expect(call.snapshot.tool_calls).toEqual([expect.objectContaining({ call_id: "call_kernel_001", status: "pending" })]);
|
||||
expect(duplicateCallID.snapshot.tool_calls).toEqual([
|
||||
expect.objectContaining({ request: expect.objectContaining({ title: "Choose" }) }),
|
||||
]);
|
||||
expect(earlyResult.snapshot.tool_calls).toEqual([expect.objectContaining({ status: "pending" })]);
|
||||
expect(kernel.submitToolCall("call_kernel_001", "2026-08-03T08:01:00.000Z")).toMatchObject({ disposition: "accepted" });
|
||||
|
||||
const completed = kernel.ingest({
|
||||
raw: envelope({ id: "evt_tool_result_completed", type: "lineup.v1.tool.result", payload: { call_id: "call_kernel_001", status: "completed", result: { selected: "alpha" } } }),
|
||||
source: "live",
|
||||
});
|
||||
expect(completed.snapshot.tool_calls).toEqual([
|
||||
expect.objectContaining({ call_id: "call_kernel_001", status: "completed", result: { selected: "alpha" } }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps malformed tool payloads on the safe fallback path", () => {
|
||||
const kernel = new InteractionKernel();
|
||||
const invalidCall = kernel.ingest({
|
||||
raw: envelope({ id: "evt_invalid_tool_call", type: "lineup.v1.tool.call", payload: { call_id: "call_bad", tool: "shell", data: {} } }),
|
||||
source: "sync",
|
||||
});
|
||||
const invalidResult = kernel.ingest({
|
||||
raw: envelope({ id: "evt_invalid_tool_result", type: "lineup.v1.tool.result", payload: { call_id: "call_bad", status: "submitted", result: {} } }),
|
||||
source: "sync",
|
||||
});
|
||||
|
||||
expect(invalidCall.items).toEqual([expect.objectContaining({ kind: "fallback", reason: "invalid_payload" })]);
|
||||
expect(invalidResult.items).toEqual([expect.objectContaining({ kind: "fallback", reason: "invalid_payload" })]);
|
||||
});
|
||||
|
||||
it("accepts only a bounded, uniquely identified ActionGroup for choice calls", () => {
|
||||
const kernel = new InteractionKernel();
|
||||
const accepted = kernel.ingest({
|
||||
raw: envelope({
|
||||
id: "evt_choice_group",
|
||||
type: "lineup.v1.tool.call",
|
||||
payload: {
|
||||
call_id: "call_choice_group",
|
||||
tool: "choice",
|
||||
title: "Pick deployment targets",
|
||||
prompt: "You may select multiple targets.",
|
||||
data: { action_group: { mode: "multi-choice", actions: [{ id: "staging", label: "Staging" }, { id: "production", label: "Production", description: "Requires approval" }] } },
|
||||
},
|
||||
}),
|
||||
source: "sync",
|
||||
});
|
||||
const duplicateActionID = kernel.ingest({
|
||||
raw: envelope({
|
||||
id: "evt_choice_invalid_group",
|
||||
type: "lineup.v1.tool.call",
|
||||
payload: { call_id: "call_bad_group", tool: "choice", title: "Bad", prompt: "Bad", data: { action_group: { mode: "multi-choice", actions: [{ id: "same", label: "One" }, { id: "same", label: "Two" }] } } },
|
||||
}),
|
||||
source: "sync",
|
||||
});
|
||||
|
||||
expect(accepted.items).toEqual([expect.objectContaining({
|
||||
kind: "tool-call",
|
||||
call: expect.objectContaining({ action_group: { mode: "multi-choice", actions: [{ id: "staging", label: "Staging" }, { id: "production", label: "Production", description: "Requires approval" }] } }),
|
||||
})]);
|
||||
expect(duplicateActionID.items).toEqual([expect.objectContaining({ kind: "fallback", reason: "invalid_payload" })]);
|
||||
});
|
||||
|
||||
it("accepts trusted confirm and input schemas while rejecting unsafe form fields", () => {
|
||||
const kernel = new InteractionKernel();
|
||||
const confirm = kernel.ingest({
|
||||
raw: envelope({ id: "evt_confirm", type: "lineup.v1.tool.call", payload: { call_id: "call_confirm", tool: "confirm", title: "Continue?", prompt: "This can be cancelled.", data: { confirm: { approve_label: "Continue", cancel_label: "Stop" } } } }),
|
||||
source: "sync",
|
||||
});
|
||||
const form = kernel.ingest({
|
||||
raw: envelope({ id: "evt_input", type: "lineup.v1.tool.call", payload: { call_id: "call_input", tool: "input", title: "Release", prompt: "Version and notes", data: { form: { fields: [{ id: "version", label: "Version", type: "text", required: true, pattern: "^v\\d+\\.\\d+\\.\\d+$" }, { id: "notes", label: "Notes", type: "textarea", max_length: 200 }], submit_label: "Create", cancel_label: "Cancel" } } } }),
|
||||
source: "sync",
|
||||
});
|
||||
const invalid = kernel.ingest({
|
||||
raw: envelope({ id: "evt_input_invalid", type: "lineup.v1.tool.call", payload: { call_id: "call_input_invalid", tool: "input", title: "Bad", prompt: "Bad", data: { form: { fields: [{ id: "bad", label: "Bad", type: "file" }] } } } }),
|
||||
source: "sync",
|
||||
});
|
||||
|
||||
expect(confirm.items).toEqual([expect.objectContaining({ kind: "tool-call", call: expect.objectContaining({ confirm: { approve_label: "Continue", cancel_label: "Stop" } }) })]);
|
||||
expect(form.items).toEqual([expect.objectContaining({ kind: "tool-call", call: expect.objectContaining({ form: expect.objectContaining({ fields: expect.arrayContaining([expect.objectContaining({ id: "version", required: true })]) }) }) })]);
|
||||
expect(invalid.items).toEqual([expect.objectContaining({ kind: "fallback", reason: "invalid_payload" })]);
|
||||
});
|
||||
|
||||
it("aggregates structured task progress by operation_id and exposes a local cancellation request", () => {
|
||||
const kernel = new InteractionKernel();
|
||||
const first = kernel.ingest({
|
||||
raw: envelope({ id: "evt_task_first", type: "lineup.v1.agent.progress", payload: { operation_id: "task_kernel_001", title: "Build report", percent: 15, status: "running", cancellable: true } }),
|
||||
source: "sync",
|
||||
});
|
||||
const update = kernel.ingest({
|
||||
raw: envelope({ id: "evt_task_update", type: "lineup.v1.agent.progress", payload: { operation_id: "task_kernel_001", title: "Build report", percent: 65, status: "running", cancellable: true } }),
|
||||
source: "live",
|
||||
});
|
||||
|
||||
expect(first.snapshot.tasks).toEqual([expect.objectContaining({ operation_id: "task_kernel_001", percent: 15 })]);
|
||||
expect(update.snapshot.tasks).toEqual([expect.objectContaining({ operation_id: "task_kernel_001", percent: 65 })]);
|
||||
expect(kernel.requestTaskCancel("task_kernel_001", "2026-08-03T09:05:00.000Z")).toMatchObject({ disposition: "accepted" });
|
||||
expect(kernel.snapshot().tasks).toEqual([expect.objectContaining({ cancel_requested_at: "2026-08-03T09:05:00.000Z" })]);
|
||||
});
|
||||
|
||||
it("admits only app identity and state for a Surface open, never an Agent-provided bundle", () => {
|
||||
const safe = decodeConversationItem(envelope({
|
||||
id: "evt_surface_safe",
|
||||
type: "lineup.v1.ui.open",
|
||||
payload: { instance_id: "task:001", app: { app_id: "lineup.task-dashboard", version: "0.1.0" }, state: { title: "Build" } },
|
||||
}));
|
||||
const injectedBundle = decodeConversationItem(envelope({
|
||||
id: "evt_surface_bundle",
|
||||
type: "lineup.v1.ui.open",
|
||||
payload: { instance_id: "task:002", app: { app_id: "lineup.task-dashboard", version: "0.1.0" }, bundle_url: "https://example.invalid/app.js" },
|
||||
}));
|
||||
const extraAppField = decodeConversationItem(envelope({
|
||||
id: "evt_surface_extra_app",
|
||||
type: "lineup.v1.ui.open",
|
||||
payload: { instance_id: "task:003", app: { app_id: "lineup.task-dashboard", version: "0.1.0", source: "injected" } },
|
||||
}));
|
||||
|
||||
expect(safe).toEqual(expect.objectContaining({ kind: "surface", id: "evt_surface_safe" }));
|
||||
expect(injectedBundle).toEqual(expect.objectContaining({ kind: "fallback", reason: "invalid_payload" }));
|
||||
expect(extraAppField).toEqual(expect.objectContaining({ kind: "fallback", reason: "invalid_payload" }));
|
||||
});
|
||||
|
||||
it("requires a bounded reason, expiry and exact payload shape for app calls", () => {
|
||||
const valid = decodeConversationItem(envelope({
|
||||
id: "evt_capability_valid", type: "lineup.v1.app.call",
|
||||
payload: { call_id: "cap_001", capability: "app.open_url", reason: "打开文档", expires_at: "2026-08-04T00:00:00.000Z", arguments: { url: "https://example.com" } },
|
||||
}));
|
||||
const missingExpiry = decodeConversationItem(envelope({
|
||||
id: "evt_capability_invalid", type: "lineup.v1.app.call",
|
||||
payload: { call_id: "cap_002", capability: "app.open_url", reason: "打开文档", arguments: { url: "https://example.com" } },
|
||||
}));
|
||||
expect(valid).toEqual(expect.objectContaining({ kind: "app-call" }));
|
||||
expect(missingExpiry).toEqual(expect.objectContaining({ kind: "fallback", reason: "invalid_payload" }));
|
||||
});
|
||||
|
||||
it("admits artifact offers as data but rejects undeclared control fields", () => {
|
||||
const safe = decodeConversationItem(envelope({
|
||||
id: "evt_artifact_safe", type: "lineup.v1.artifact.offer",
|
||||
payload: { artifact_id: "report_001", name: "report.pdf", mime_type: "application/pdf", size_bytes: 42, integrity: "verified", created_at: "2026-08-03T00:00:00.000Z" },
|
||||
}));
|
||||
const injected = decodeConversationItem(envelope({
|
||||
id: "evt_artifact_injected", type: "lineup.v1.artifact.offer",
|
||||
payload: { artifact_id: "report_002", name: "report.pdf", mime_type: "application/pdf", size_bytes: 42, integrity: "verified", created_at: "2026-08-03T00:00:00.000Z", url: "https://bad.example" },
|
||||
}));
|
||||
expect(safe).toEqual(expect.objectContaining({ kind: "artifact-offer" }));
|
||||
const content = decodeConversationItem(envelope({
|
||||
id: "evt_artifact_content", type: "lineup.v1.artifact.offer",
|
||||
payload: { artifact_id: "report_003", name: "report.txt", mime_type: "text/plain", size_bytes: 5, integrity: "verified", created_at: "2026-08-03T00:00:00.000Z", local_ref: "artifact-cache-003", content_base64: "aGVsbG8=" },
|
||||
}));
|
||||
expect(content).toEqual(expect.objectContaining({ kind: "artifact-offer" }));
|
||||
expect(injected).toEqual(expect.objectContaining({ kind: "fallback", reason: "invalid_payload" }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Runtime 的协议接纳与交互状态协调内核。
|
||||
*
|
||||
* 它把原始传输文本解码为受信任的 ConversationItem、抑制重复 envelope、维护 Agent 状态和
|
||||
* Tool/Task 投影;不操作 DOM、网络、持久化或系统能力。
|
||||
*/
|
||||
import {
|
||||
decodeConversationItem,
|
||||
type Actor,
|
||||
type ConversationItem,
|
||||
type JsonObject,
|
||||
type ToolCallFinalStatus,
|
||||
} from "@/runtime/protocol/lineup-v1";
|
||||
import { ToolCallStateMachine, type ToolCallRecord } from "@/runtime/coordination/tool-call-state";
|
||||
import { TaskStateMachine, type TaskRecord } from "@/runtime/coordination/task-state";
|
||||
|
||||
/** Transport metadata is intentionally opaque to the protocol and renderer. */
|
||||
export type IncomingMessage = {
|
||||
raw: string;
|
||||
source: "sync" | "live";
|
||||
transport_id?: string;
|
||||
received_at?: string;
|
||||
};
|
||||
|
||||
export type AgentPresence = {
|
||||
actor: Actor;
|
||||
status: string;
|
||||
detail: string;
|
||||
envelope_id: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type KernelSnapshot = {
|
||||
seen_envelope_ids: number;
|
||||
agent_presence: readonly AgentPresence[];
|
||||
tool_calls: readonly ToolCallRecord[];
|
||||
tasks: readonly TaskRecord[];
|
||||
};
|
||||
|
||||
export type KernelResult =
|
||||
| { disposition: "accepted"; items: readonly ConversationItem[]; snapshot: KernelSnapshot }
|
||||
| { disposition: "duplicate"; items: readonly []; snapshot: KernelSnapshot };
|
||||
|
||||
/**
|
||||
* The Interaction Kernel is the sole path from untrusted incoming payloads to
|
||||
* trusted conversation items. It has no DOM, fetch, storage, or platform
|
||||
* dependency; M0-04 will persist its in-memory state through Conversation
|
||||
* Store and M0-05 will feed it from multiple Transport Adapter sources.
|
||||
*/
|
||||
export class InteractionKernel {
|
||||
private readonly seenIDs = new Set<string>();
|
||||
private readonly seenOrder: string[] = [];
|
||||
private readonly presenceByActor = new Map<string, AgentPresence>();
|
||||
private readonly toolCalls = new ToolCallStateMachine();
|
||||
private readonly tasks = new TaskStateMachine();
|
||||
|
||||
public constructor(private readonly maxRememberedEnvelopeIDs = 2_000) {
|
||||
if (!Number.isSafeInteger(maxRememberedEnvelopeIDs) || maxRememberedEnvelopeIDs < 1) {
|
||||
throw new Error("maxRememberedEnvelopeIDs must be a positive integer.");
|
||||
}
|
||||
}
|
||||
|
||||
public ingest(message: IncomingMessage): KernelResult {
|
||||
const item = decodeConversationItem(message.raw);
|
||||
if (item.id && this.seenIDs.has(item.id)) {
|
||||
return { disposition: "duplicate", items: [], snapshot: this.snapshot() };
|
||||
}
|
||||
|
||||
if (item.id) this.remember(item.id);
|
||||
this.project(item, message.received_at ?? new Date().toISOString());
|
||||
return { disposition: "accepted", items: [item], snapshot: this.snapshot() };
|
||||
}
|
||||
|
||||
public snapshot(): KernelSnapshot {
|
||||
return {
|
||||
seen_envelope_ids: this.seenIDs.size,
|
||||
agent_presence: [...this.presenceByActor.values()].map(presence => ({ ...presence })),
|
||||
tool_calls: this.toolCalls.snapshot(),
|
||||
tasks: this.tasks.snapshot(),
|
||||
};
|
||||
}
|
||||
|
||||
public submitToolCall(callID: string, updatedAt: string, submission: JsonObject = {}) {
|
||||
return this.toolCalls.submit(callID, updatedAt, submission);
|
||||
}
|
||||
|
||||
public completeToolCall(callID: string, status: ToolCallFinalStatus, result: JsonObject, updatedAt: string, error?: string) {
|
||||
return this.toolCalls.complete({ call_id: callID, status, result, ...(error ? { error } : {}) }, updatedAt);
|
||||
}
|
||||
|
||||
public cancelToolCall(callID: string, reason: string, updatedAt: string) {
|
||||
return this.toolCalls.cancel({ call_id: callID, reason }, updatedAt);
|
||||
}
|
||||
|
||||
public expireToolCall(callID: string, updatedAt: string) {
|
||||
return this.toolCalls.expireCall(callID, updatedAt);
|
||||
}
|
||||
|
||||
public restoreToolCalls(records: readonly ToolCallRecord[], now: string): void {
|
||||
this.toolCalls.restore(records, now);
|
||||
}
|
||||
|
||||
public requestTaskCancel(operationID: string, updatedAt: string) {
|
||||
return this.tasks.requestCancel(operationID, updatedAt);
|
||||
}
|
||||
|
||||
public restoreTasks(records: readonly TaskRecord[]): void {
|
||||
this.tasks.restore(records);
|
||||
}
|
||||
|
||||
/** Clear only the in-memory projection when the active conversation changes. */
|
||||
public reset(): void {
|
||||
this.seenIDs.clear();
|
||||
this.seenOrder.length = 0;
|
||||
this.presenceByActor.clear();
|
||||
this.toolCalls.reset();
|
||||
this.tasks.reset();
|
||||
}
|
||||
|
||||
private remember(id: string): void {
|
||||
this.seenIDs.add(id);
|
||||
this.seenOrder.push(id);
|
||||
while (this.seenOrder.length > this.maxRememberedEnvelopeIDs) {
|
||||
const expired = this.seenOrder.shift();
|
||||
if (expired) this.seenIDs.delete(expired);
|
||||
}
|
||||
}
|
||||
|
||||
private project(item: ConversationItem, updatedAt: string): void {
|
||||
if (item.kind === "tool-call" && item.envelope) {
|
||||
this.toolCalls.receive(item.call, item.envelope.conversation_id, updatedAt);
|
||||
return;
|
||||
}
|
||||
if (item.kind === "tool-cancel") {
|
||||
this.toolCalls.cancel(item.cancel, updatedAt);
|
||||
return;
|
||||
}
|
||||
if (item.kind === "tool-result") {
|
||||
this.toolCalls.complete(item.result, updatedAt);
|
||||
return;
|
||||
}
|
||||
if (item.kind === "progress" && item.task && item.envelope) {
|
||||
this.tasks.receive(item.task, item.envelope.conversation_id, updatedAt);
|
||||
return;
|
||||
}
|
||||
if (item.kind !== "agent-status" || !item.envelope) return;
|
||||
const actor = item.envelope.sender;
|
||||
const actorKey = `${actor.kind}:${actor.id}`;
|
||||
this.presenceByActor.set(actorKey, {
|
||||
actor,
|
||||
status: item.status,
|
||||
detail: item.detail,
|
||||
envelope_id: item.envelope.id,
|
||||
updated_at: updatedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { LoginRequest, LoginResult, SendTextRequest, SyncRequest, TransportAdapter, TransportMessage } from "@/runtime/communication/transport-adapter";
|
||||
import { LineUpRuntime } from "@/runtime/coordination/lineup-runtime";
|
||||
|
||||
class MemoryStorage implements Storage {
|
||||
private readonly values = new Map<string, string>();
|
||||
public get length(): number { return this.values.size; }
|
||||
public clear(): void { this.values.clear(); }
|
||||
public getItem(key: string): string | null { return this.values.get(key) ?? null; }
|
||||
public key(index: number): string | null { return [...this.values.keys()][index] ?? null; }
|
||||
public removeItem(key: string): void { this.values.delete(key); }
|
||||
public setItem(key: string, value: string): void { this.values.set(key, value); }
|
||||
}
|
||||
|
||||
class FakeTransport implements TransportAdapter {
|
||||
public readonly sent: SendTextRequest[] = [];
|
||||
private readonly pages: TransportMessage[][];
|
||||
|
||||
public constructor(pages: TransportMessage[][] = []) {
|
||||
this.pages = [...pages];
|
||||
}
|
||||
|
||||
public async login(_request: LoginRequest): Promise<LoginResult> {
|
||||
return { uid: "user_1", agent_uid: "agent_1", channel_id: "channel_1", channel_type: 2 };
|
||||
}
|
||||
|
||||
public async sendText(request: SendTextRequest): Promise<number | undefined> {
|
||||
this.sent.push(request);
|
||||
return 91 + this.sent.length;
|
||||
}
|
||||
|
||||
public async sync(_request: SyncRequest): Promise<readonly TransportMessage[]> {
|
||||
return this.pages.shift() ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
const login = {
|
||||
api: "http://lineup.test",
|
||||
phone: "13800000000",
|
||||
code: "123456",
|
||||
fallback_agent_uid: "agent_fallback",
|
||||
fallback_channel_id: "channel_fallback",
|
||||
fallback_channel_type: 2,
|
||||
};
|
||||
|
||||
function agentEnvelope(id: string, conversationID: string, extra: Record<string, unknown> = {}): string {
|
||||
return JSON.stringify({
|
||||
v: 1,
|
||||
id,
|
||||
type: "lineup.v1.text",
|
||||
conversation_id: conversationID,
|
||||
sender: { kind: "agent", id: "agent_1" },
|
||||
payload: { markdown: "**hello**" },
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
describe("LineUpRuntime", () => {
|
||||
it("launches an installed extension, backgrounds Interaction IM, restores focus after completion, and recovers workspace", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const conversationID = "user_1:2:channel_1";
|
||||
const launch = JSON.stringify({
|
||||
v: 1, id: "launch-game", type: "lineup.v1.app.call", conversation_id: conversationID,
|
||||
sender: { kind: "agent", id: "agent_1" },
|
||||
payload: { call_id: "game-call", capability: "runtime.launch_app", reason: "Start game", expires_at: "2026-08-05T00:00:00.000Z", arguments: { app_scope: "draw-and-guess", instance_id: "draw-and-guess:game-001" } },
|
||||
});
|
||||
const transport = new FakeTransport([[{ message_seq: 30, from_uid: "agent_1", payload: launch }], []]);
|
||||
const runtime = new LineUpRuntime({ storage, createTransport: () => transport, now: () => "2026-08-04T00:00:00.000Z" });
|
||||
runtime.apps.install({
|
||||
app_scope: "draw-and-guess", kind: "extension", enabled: true, default_eligible: false, recovery: false,
|
||||
manifest: { app_scope: "draw-and-guess", version: "1.0.0", permissions: [], tools: [{ name: "app.launch", handling: "launch", parameters: ["app_scope", "instance_id"] }] },
|
||||
});
|
||||
await runtime.login(login);
|
||||
await runtime.syncOnce();
|
||||
|
||||
expect(runtime.lifecycle.instances.get("draw-and-guess:game-001")).toMatchObject({ state: "foreground", conversation_id: conversationID });
|
||||
const interaction = runtime.lifecycle.instances.activeFor("chat", conversationID);
|
||||
expect(interaction).toMatchObject({ state: "background" });
|
||||
expect(runtime.snapshot()?.app_inbox).toEqual([expect.objectContaining({ app_scope: "draw-and-guess", message_id: "launch-game" })]);
|
||||
const game = runtime.openExtensionApp("draw-and-guess", "draw-and-guess:game-001");
|
||||
expect(game.listMessages()).toEqual([expect.objectContaining({ scope: { app_scope: "draw-and-guess", conversation_id: conversationID } })]);
|
||||
expect(await game.reportResult({ winner: "user_1" })).toMatchObject({ disposition: "accepted" });
|
||||
expect(transport.sent.at(-1)?.payload).toContain("lineup.v1.app.result");
|
||||
|
||||
runtime.closeAppInstance("draw-and-guess:game-001");
|
||||
expect(runtime.lifecycle.focus.foreground()).toBe(interaction?.instance_id);
|
||||
expect(runtime.lifecycle.instances.get(interaction!.instance_id)).toMatchObject({ state: "foreground" });
|
||||
|
||||
const recovered = new LineUpRuntime({ storage, createTransport: () => new FakeTransport(), now: () => "2026-08-04T00:00:00.000Z" });
|
||||
await recovered.login(login);
|
||||
expect(recovered.workspaceSnapshot().focus.stack).toEqual([interaction!.instance_id]);
|
||||
expect(recovered.lifecycle.instances.get("draw-and-guess:game-001")).toMatchObject({ state: "stopped" });
|
||||
});
|
||||
|
||||
it("loads chat from the Core App Registry and exposes only a scoped Chat SDK", async () => {
|
||||
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 })]);
|
||||
const chat = runtime.openApp("chat");
|
||||
expect(chat.app_scope).toBe("chat");
|
||||
expect(chat.snapshot()).toMatchObject({ app_scope: "chat", active: false });
|
||||
|
||||
await runtime.login(login);
|
||||
expect(chat.snapshot()).toMatchObject({ app_scope: "chat", active: true, conversation_id: "user_1:2:channel_1" });
|
||||
});
|
||||
|
||||
it("adapts legacy LineUp v1 messages into chat scope before SDK delivery", async () => {
|
||||
const conversationID = "user_1:2:channel_1";
|
||||
const transport = new FakeTransport([[{ message_seq: 7, from_uid: "agent_1", payload: agentEnvelope("agent_msg_7", conversationID) }], []]);
|
||||
const runtime = new LineUpRuntime({ storage: new MemoryStorage(), createTransport: () => transport, now: () => "2026-08-04T00:00:00.000Z" });
|
||||
const chat = runtime.openChatApp();
|
||||
const received: unknown[] = [];
|
||||
chat.subscribeAgentMessages(message => received.push(message));
|
||||
|
||||
await runtime.login(login);
|
||||
await runtime.syncOnce();
|
||||
|
||||
expect(received).toEqual([expect.objectContaining({
|
||||
message_id: "agent_msg_7",
|
||||
scope: { app_scope: "chat", conversation_id: conversationID },
|
||||
item: expect.objectContaining({ kind: "markdown", markdown: "**hello**" }),
|
||||
})]);
|
||||
expect(chat.listAgentMessages()).toEqual([expect.objectContaining({ message_id: "agent_msg_7" })]);
|
||||
});
|
||||
|
||||
it("projects Agent state and Chat-safe Runtime status through the SDK", async () => {
|
||||
const conversationID = "user_1:2:channel_1";
|
||||
const transport = new FakeTransport([[
|
||||
{
|
||||
message_seq: 8,
|
||||
from_uid: "agent_1",
|
||||
payload: agentEnvelope("agent_thinking_8", conversationID, {
|
||||
type: "lineup.v1.agent.status",
|
||||
payload: { status: "thinking" },
|
||||
}),
|
||||
},
|
||||
], []]);
|
||||
const runtime = new LineUpRuntime({ storage: new MemoryStorage(), createTransport: () => transport });
|
||||
const chat = runtime.openChatApp();
|
||||
const views: Array<{ active: boolean; agent_presence: readonly { status: string }[] }> = [];
|
||||
const statuses: string[] = [];
|
||||
chat.subscribe(view => views.push(view));
|
||||
chat.subscribeEvents(event => { if (event.type === "sync-status") statuses.push(event.status); });
|
||||
|
||||
await runtime.login(login);
|
||||
await runtime.syncOnce();
|
||||
|
||||
expect(views[0]).toMatchObject({ active: false, agent_presence: [] });
|
||||
expect(views).toContainEqual(expect.objectContaining({
|
||||
active: true,
|
||||
agent_presence: [expect.objectContaining({ status: "thinking" })],
|
||||
}));
|
||||
expect(statuses).toEqual(["syncing", "idle"]);
|
||||
});
|
||||
|
||||
it("rejects an explicit App or conversation scope mismatch before Chat can observe it", async () => {
|
||||
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 },
|
||||
}) },
|
||||
{ message_seq: 9, from_uid: "agent_1", payload: agentEnvelope("wrong_conversation_scope", conversationID, {
|
||||
scope: { app_scope: "chat", conversation_id: "other:2:conversation" },
|
||||
}) },
|
||||
], []]);
|
||||
const runtime = new LineUpRuntime({ storage: new MemoryStorage(), createTransport: () => transport });
|
||||
const chat = runtime.openChatApp();
|
||||
const received: unknown[] = [];
|
||||
const rejected: unknown[] = [];
|
||||
chat.subscribeAgentMessages(message => received.push(message));
|
||||
runtime.onEvent(event => { if (event.type === "scope-rejected") rejected.push(event); });
|
||||
|
||||
await runtime.login(login);
|
||||
await runtime.syncOnce();
|
||||
|
||||
expect(received).toEqual([]);
|
||||
expect(chat.listAgentMessages()).toEqual([]);
|
||||
expect(rejected).toEqual([
|
||||
expect.objectContaining({ reason: "scope_mismatch" }),
|
||||
expect.objectContaining({ reason: "scope_mismatch" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects malformed scope and never delivers the same message id twice", async () => {
|
||||
const conversationID = "user_1:2:channel_1";
|
||||
const duplicate = agentEnvelope("dedupe_me", conversationID);
|
||||
const malformed = agentEnvelope("bad_scope", conversationID, {
|
||||
scope: { app_scope: 42, conversation_id: conversationID },
|
||||
});
|
||||
const transport = new FakeTransport([[
|
||||
{ message_seq: 20, from_uid: "agent_1", payload: duplicate },
|
||||
{ message_seq: 21, from_uid: "agent_1", payload: duplicate },
|
||||
{ message_seq: 22, from_uid: "agent_1", payload: malformed },
|
||||
], []]);
|
||||
const runtime = new LineUpRuntime({ storage: new MemoryStorage(), createTransport: () => transport });
|
||||
const chat = runtime.openChatApp();
|
||||
const delivered: string[] = [];
|
||||
const rejected: string[] = [];
|
||||
chat.subscribeAgentMessages(message => delivered.push(message.message_id));
|
||||
runtime.onEvent(event => { if (event.type === "scope-rejected") rejected.push(event.reason); });
|
||||
|
||||
await runtime.login(login);
|
||||
await runtime.syncOnce();
|
||||
|
||||
expect(delivered).toEqual(["dedupe_me"]);
|
||||
expect(chat.listAgentMessages()).toHaveLength(1);
|
||||
expect(rejected).toEqual(["invalid_scope"]);
|
||||
});
|
||||
|
||||
it("persists an unacknowledged App Inbox message and restores it after Runtime recreation", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const conversationID = "user_1:2:channel_1";
|
||||
const firstTransport = new FakeTransport([[{ message_seq: 11, from_uid: "agent_1", payload: agentEnvelope("agent_msg_11", conversationID) }], []]);
|
||||
const first = new LineUpRuntime({ storage, createTransport: () => firstTransport });
|
||||
first.openChatApp();
|
||||
await first.login(login);
|
||||
await first.syncOnce();
|
||||
|
||||
const restored = new LineUpRuntime({ storage, createTransport: () => new FakeTransport() });
|
||||
const chat = restored.openChatApp();
|
||||
await restored.login(login);
|
||||
|
||||
expect(chat.listAgentMessages()).toEqual([expect.objectContaining({
|
||||
message_id: "agent_msg_11",
|
||||
scope: { app_scope: "chat", conversation_id: conversationID },
|
||||
})]);
|
||||
expect(chat.acknowledgeAgentMessage("agent_msg_11")).toBe(true);
|
||||
expect(chat.listAgentMessages()).toEqual([]);
|
||||
});
|
||||
|
||||
it("sends user text through Runtime Action, local echo and Runtime-owned outbox", async () => {
|
||||
const transport = new FakeTransport();
|
||||
const runtime = new LineUpRuntime({ storage: new MemoryStorage(), createTransport: () => transport });
|
||||
const chat = runtime.openChatApp();
|
||||
const local: unknown[] = [];
|
||||
chat.subscribeEvents(event => { if (event.type === "local-item") local.push(event); });
|
||||
await runtime.login(login);
|
||||
|
||||
const receipt = await chat.dispatch({ type: "chat.send_text", conversation_id: "user_1:2:channel_1", text: "hello runtime", local_id: "local_test" });
|
||||
await runtime.flushOutbox();
|
||||
|
||||
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(runtime.snapshot()?.outbox).toEqual([]);
|
||||
expect(runtime.snapshot()?.items).toEqual([expect.objectContaining({ local_id: "local_test", item: expect.objectContaining({ delivery: expect.objectContaining({ status: "submitted" }) }) })]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,638 @@
|
||||
/**
|
||||
* LineUp Runtime 的唯一协调器。
|
||||
*
|
||||
* Runtime 独占 Transport、Conversation Store、sync loop 和 outbox;它在入站时完成旧协议
|
||||
* 兼容、scope 校验、去重、持久化和 App Inbox 投递,再经 SDK 向 Chat 提供受限投影。
|
||||
*/
|
||||
import {
|
||||
parseEnvelopeJSON,
|
||||
type Actor,
|
||||
type ConversationItem,
|
||||
type Envelope,
|
||||
type JsonObject,
|
||||
} from "@/runtime/protocol/lineup-v1";
|
||||
import { CoreAppRegistry, type AppScope } from "@/runtime/app-management/app-registry";
|
||||
import {
|
||||
type AgentChatMessage,
|
||||
type ChatAction,
|
||||
type ChatRuntimeSDK,
|
||||
type ChatViewModel,
|
||||
type CommandReceipt,
|
||||
type ExtensionRuntimeSDK,
|
||||
type RuntimeAppEvent,
|
||||
type Unsubscribe,
|
||||
} from "@/runtime/app-management/app-sdk";
|
||||
import {
|
||||
ConversationStore,
|
||||
type ConversationSnapshot,
|
||||
type OutboxEntry,
|
||||
} from "@/runtime/persistence/conversation-store";
|
||||
import { InteractionKernel, type KernelSnapshot } from "@/runtime/coordination/interaction-kernel";
|
||||
import { resolveIncomingScope, type ScopedConversationItem } from "@/runtime/coordination/runtime-envelope";
|
||||
import {
|
||||
HttpTransportAdapter,
|
||||
type LoginRequest,
|
||||
type LoginResult,
|
||||
type TransportAdapter,
|
||||
type TransportMessage,
|
||||
} from "@/runtime/communication/transport-adapter";
|
||||
import type { SurfaceInstanceSnapshot } from "@/runtime/surfaces/surface-instance-manager";
|
||||
import type { CapabilityCallRecord } from "@/runtime/capabilities/capability-call-state";
|
||||
import type { ExecutionProgressSummary } from "@/runtime/coordination/execution-progress-state";
|
||||
import type { TaskRecord } from "@/runtime/coordination/task-state";
|
||||
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";
|
||||
|
||||
export type RuntimeSession = {
|
||||
api: string;
|
||||
uid: string;
|
||||
agent_uid: string;
|
||||
channel_id: string;
|
||||
channel_type: number;
|
||||
last_seq: number;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
export type RuntimeLoginRequest = LoginRequest & {
|
||||
api: string;
|
||||
fallback_agent_uid: string;
|
||||
fallback_channel_id: string;
|
||||
fallback_channel_type: number;
|
||||
};
|
||||
|
||||
export type RuntimeDependencies = {
|
||||
storage: Storage;
|
||||
createTransport?: (api: string) => TransportAdapter;
|
||||
now?: () => string;
|
||||
newID?: (prefix: string) => string;
|
||||
sleep?: (milliseconds: number) => Promise<void>;
|
||||
/** Host-only sanitizer/cache hook; Runtime still owns persistence/routing. */
|
||||
prepareIncoming?: (item: ConversationItem) => ConversationItem | undefined;
|
||||
};
|
||||
|
||||
const CONTROL_ECHO_TYPES = new Set([
|
||||
"lineup.v1.tool.result",
|
||||
"lineup.v1.tool.cancel",
|
||||
"lineup.v1.ui.event",
|
||||
"lineup.v1.client.inventory",
|
||||
"lineup.v1.app.result",
|
||||
]);
|
||||
|
||||
/**
|
||||
* The only owner of Transport, ConversationStore, sync loop and outbox.
|
||||
*
|
||||
* It deliberately has no DOM dependency. A Core App gets a narrow SDK view;
|
||||
* host rendering and platform-specific artifact cache hooks are injected at
|
||||
* the edge instead of letting the Chat UI depend on transport/storage.
|
||||
*/
|
||||
export class LineUpRuntime {
|
||||
public readonly apps = new CoreAppRegistry();
|
||||
public readonly lifecycle = new AppLifecycleManager();
|
||||
|
||||
private readonly store: ConversationStore;
|
||||
private readonly kernel = new InteractionKernel();
|
||||
private readonly listeners = new Set<(event: RuntimeAppEvent) => void>();
|
||||
private readonly chatStateListeners = new Set<(view: ChatViewModel) => void>();
|
||||
private readonly chatMessageListeners = new Set<(message: AgentChatMessage) => void>();
|
||||
private readonly createTransport: (api: string) => TransportAdapter;
|
||||
private readonly now: () => string;
|
||||
private readonly newID: (prefix: string) => string;
|
||||
private readonly sleep: (milliseconds: number) => Promise<void>;
|
||||
private readonly prepareIncoming: (item: ConversationItem) => ConversationItem | undefined;
|
||||
private readonly orchestrator: AppOrchestrator;
|
||||
private readonly toolRouter: ToolRouter;
|
||||
|
||||
private transport: TransportAdapter | undefined;
|
||||
private session: RuntimeSession = {
|
||||
api: "",
|
||||
uid: "",
|
||||
agent_uid: "",
|
||||
channel_id: "",
|
||||
channel_type: 2,
|
||||
last_seq: 0,
|
||||
active: false,
|
||||
};
|
||||
private syncing = false;
|
||||
private syncRequested = false;
|
||||
|
||||
public constructor(dependencies: RuntimeDependencies) {
|
||||
this.store = new ConversationStore(dependencies.storage);
|
||||
this.createTransport = dependencies.createTransport ?? (api => new HttpTransportAdapter(api));
|
||||
this.now = dependencies.now ?? (() => new Date().toISOString());
|
||||
this.newID = dependencies.newID ?? defaultID;
|
||||
this.sleep = dependencies.sleep ?? (milliseconds => new Promise(resolve => globalThis.setTimeout(resolve, milliseconds)));
|
||||
this.prepareIncoming = dependencies.prepareIncoming ?? (item => item);
|
||||
this.orchestrator = new AppOrchestrator(this.lifecycle, this.apps, this.newID);
|
||||
this.toolRouter = new ToolRouter(this.apps);
|
||||
}
|
||||
|
||||
public getSession(): RuntimeSession {
|
||||
return { ...this.session };
|
||||
}
|
||||
|
||||
public currentConversationID(): string | undefined {
|
||||
return this.session.active ? `${this.session.uid}:${this.session.channel_type}:${this.session.channel_id}` : undefined;
|
||||
}
|
||||
|
||||
public snapshot(): ConversationSnapshot | undefined {
|
||||
return this.session.active ? this.store.snapshot() : undefined;
|
||||
}
|
||||
|
||||
public kernelSnapshot(): KernelSnapshot {
|
||||
return this.kernel.snapshot();
|
||||
}
|
||||
|
||||
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;
|
||||
this.orchestrator.close(instanceID, this.now(), error);
|
||||
this.persistWorkspace();
|
||||
runtimeLog("app.lifecycle", { action: error ? "failed" : "closed", instance_id: instanceID });
|
||||
this.emit({ type: "app-lifecycle", workspace: this.workspaceSnapshot(), reason: error ? "failed" : "closed" });
|
||||
}
|
||||
|
||||
public onEvent(listener: (event: RuntimeAppEvent) => void): Unsubscribe {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the SDK projection for a Runtime-registered Core App.
|
||||
*
|
||||
* The first SDK implementation is still Chat-shaped, but the selection key
|
||||
* now comes from the App Registry rather than from a Chat-specific Runtime
|
||||
* entry point. New App SDK projections can be added behind this boundary.
|
||||
*/
|
||||
public openApp(appScope: AppScope): ChatRuntimeSDK {
|
||||
const app = this.apps.get(appScope);
|
||||
if (!app?.enabled) throw new Error(`The ${appScope} Core App is unavailable.`);
|
||||
if (appScope !== "chat") throw new Error(`The ${appScope} Core App SDK is not registered.`);
|
||||
return {
|
||||
app_scope: "chat",
|
||||
snapshot: () => this.chatView(),
|
||||
subscribe: listener => {
|
||||
this.chatStateListeners.add(listener);
|
||||
listener(this.chatView());
|
||||
return () => this.chatStateListeners.delete(listener);
|
||||
},
|
||||
subscribeEvents: listener => this.onEvent(event => {
|
||||
// Agent content has a separate, scope-checked SDK channel. Keeping it
|
||||
// out of the generic event stream prevents a Chat renderer from
|
||||
// accidentally consuming an unprojected delivery path.
|
||||
if (event.type !== "agent-message") listener(event);
|
||||
}),
|
||||
subscribeAgentMessages: listener => {
|
||||
this.chatMessageListeners.add(listener);
|
||||
return () => this.chatMessageListeners.delete(listener);
|
||||
},
|
||||
listAgentMessages: afterMessageID => this.listChatMessages(afterMessageID),
|
||||
acknowledgeAgentMessage: messageID => this.acknowledgeChatMessage(messageID),
|
||||
dispatch: action => this.dispatchChatAction(action),
|
||||
interactions: {
|
||||
submit: (callID, result) => this.submitToolCall(callID, result),
|
||||
cancel: (callID, reason) => this.cancelToolCall(callID, reason),
|
||||
expire: callID => this.expireToolCall(callID),
|
||||
read: callID => this.toolCalls().find(record => record.call_id === callID),
|
||||
loadDraft: callID => this.getToolDraft(callID),
|
||||
saveDraft: (callID, values) => this.saveToolDraft(callID, values),
|
||||
clearDraft: callID => this.clearToolDraft(callID),
|
||||
},
|
||||
tasks: {
|
||||
requestCancel: operationID => this.requestTaskCancel(operationID),
|
||||
read: operationID => this.tasks().find(task => task.operation_id === operationID),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Opens a per-instance Extension projection after Runtime has started it. */
|
||||
public openExtensionApp(appScope: AppScope, instanceID: string): ExtensionRuntimeSDK {
|
||||
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.");
|
||||
}
|
||||
return {
|
||||
app_scope: appScope, instance_id: instanceID, conversation_id: conversationID,
|
||||
listMessages: afterMessageID => this.listAppMessages(appScope, afterMessageID),
|
||||
acknowledgeMessage: messageID => this.store.acknowledgeAppInbox(appScope, messageID),
|
||||
reportResult: result => this.queueProtocolAction("app-result", "lineup.v1.app.result", { instance_id: instanceID, app_scope: appScope, result }),
|
||||
reportFailure: message => this.closeAppInstance(instanceID, boundedFailure(message)),
|
||||
};
|
||||
}
|
||||
|
||||
/** @deprecated Use openApp("chat") so App selection remains registry-driven. */
|
||||
public openChatApp(): ChatRuntimeSDK {
|
||||
return this.openApp("chat");
|
||||
}
|
||||
|
||||
public async login(request: RuntimeLoginRequest): Promise<ConversationSnapshot> {
|
||||
const transport = this.createTransport(request.api);
|
||||
const result = await transport.login({ phone: request.phone, code: request.code });
|
||||
this.stopSync();
|
||||
this.transport = transport;
|
||||
this.kernel.reset();
|
||||
this.session = {
|
||||
api: request.api,
|
||||
uid: result.uid,
|
||||
agent_uid: result.agent_uid || request.fallback_agent_uid,
|
||||
channel_id: result.channel_id || request.fallback_channel_id,
|
||||
channel_type: result.channel_type || request.fallback_channel_type,
|
||||
last_seq: 0,
|
||||
active: true,
|
||||
};
|
||||
const snapshot = this.store.open(this.requireConversationID());
|
||||
this.lifecycle.restore(snapshot.app_workspace);
|
||||
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());
|
||||
this.persistWorkspace();
|
||||
runtimeLog("app.lifecycle", { action: "started", app_scope: "chat" });
|
||||
}
|
||||
runtimeLog("session.restored", { restored_instances: this.lifecycle.instances.list(this.requireConversationID()).length, foreground: this.lifecycle.focus.foreground() ?? "none" });
|
||||
this.emitState();
|
||||
return this.store.snapshot();
|
||||
}
|
||||
|
||||
public logout(): void {
|
||||
this.stopSync();
|
||||
this.transport = undefined;
|
||||
this.kernel.reset();
|
||||
this.store.close();
|
||||
this.session = { ...this.session, uid: "", agent_uid: "", channel_id: "", last_seq: 0, active: false };
|
||||
this.emitState();
|
||||
}
|
||||
|
||||
public startSync(): void {
|
||||
if (!this.session.active || this.syncRequested) return;
|
||||
this.syncRequested = true;
|
||||
void this.syncLoop();
|
||||
}
|
||||
|
||||
public stopSync(): void {
|
||||
this.syncRequested = false;
|
||||
}
|
||||
|
||||
/** A deterministic single drain for tests and for the production sync loop. */
|
||||
public async syncOnce(): Promise<void> {
|
||||
if (!this.session.active || !this.transport) return;
|
||||
this.emit({ type: "sync-status", status: "syncing" });
|
||||
while (this.session.active && this.transport) {
|
||||
const startMessageSeq = this.session.last_seq === 0 ? 0 : this.session.last_seq + 1;
|
||||
const messages = await this.transport.sync({
|
||||
channel_id: this.session.channel_id,
|
||||
channel_type: this.session.channel_type,
|
||||
login_uid: this.session.uid,
|
||||
start_message_seq: startMessageSeq,
|
||||
limit: 50,
|
||||
});
|
||||
if (messages.length === 0) {
|
||||
this.emit({ type: "sync-status", status: "idle" });
|
||||
return;
|
||||
}
|
||||
let advanced = false;
|
||||
for (const message of messages) {
|
||||
const previousSeq = this.session.last_seq;
|
||||
this.session.last_seq = Math.max(this.session.last_seq, message.message_seq);
|
||||
advanced ||= this.session.last_seq > previousSeq;
|
||||
this.store.advanceCursor(message.message_seq);
|
||||
this.processTransportMessage(message);
|
||||
}
|
||||
if (!advanced) {
|
||||
this.emit({ type: "sync-status", status: "idle" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async flushOutbox(): Promise<void> {
|
||||
if (this.syncing || !this.session.active || !this.transport) return;
|
||||
this.syncing = true;
|
||||
try {
|
||||
for (const entry of this.store.snapshot().outbox) {
|
||||
try {
|
||||
const sequence = await this.transport.sendText({
|
||||
from_uid: this.session.uid,
|
||||
channel_id: this.session.channel_id,
|
||||
channel_type: this.session.channel_type,
|
||||
payload: entry.payload,
|
||||
});
|
||||
this.handleOutboxSuccess(entry, sequence);
|
||||
} catch (reason) {
|
||||
this.handleOutboxFailure(entry, reason);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.syncing = false;
|
||||
this.emitState();
|
||||
}
|
||||
}
|
||||
|
||||
public submitToolCall(callID: string, result: JsonObject): boolean {
|
||||
const transition = this.kernel.submitToolCall(callID, this.now(), result);
|
||||
this.store.replaceToolCalls(this.kernel.snapshot().tool_calls);
|
||||
if (transition.disposition !== "accepted") return false;
|
||||
this.store.clearToolDraft(callID);
|
||||
void this.queueProtocolAction("tool-result", "lineup.v1.tool.result", { call_id: callID, status: "completed", result });
|
||||
this.emitState();
|
||||
return true;
|
||||
}
|
||||
|
||||
public cancelToolCall(callID: string, reason: string): boolean {
|
||||
const transition = this.kernel.cancelToolCall(callID, reason, this.now());
|
||||
this.store.replaceToolCalls(this.kernel.snapshot().tool_calls);
|
||||
if (transition.disposition !== "accepted") return false;
|
||||
this.store.clearToolDraft(callID);
|
||||
void this.queueProtocolAction("tool-cancel", "lineup.v1.tool.cancel", { call_id: callID, reason });
|
||||
this.emitState();
|
||||
return true;
|
||||
}
|
||||
|
||||
public expireToolCall(callID: string): boolean {
|
||||
const transition = this.kernel.expireToolCall(callID, this.now());
|
||||
this.store.replaceToolCalls(this.kernel.snapshot().tool_calls);
|
||||
this.emitState();
|
||||
return transition.disposition === "accepted";
|
||||
}
|
||||
|
||||
public requestTaskCancel(operationID: string): boolean {
|
||||
const transition = this.kernel.requestTaskCancel(operationID, this.now());
|
||||
this.store.replaceTasks(this.kernel.snapshot().tasks);
|
||||
this.emitState();
|
||||
return transition.disposition === "accepted";
|
||||
}
|
||||
|
||||
public toolCalls(): readonly ToolCallRecord[] { return this.kernel.snapshot().tool_calls; }
|
||||
public tasks(): readonly TaskRecord[] { return this.kernel.snapshot().tasks; }
|
||||
public getToolDraft(callID: string): JsonObject | undefined { return this.store.getToolDraft(callID); }
|
||||
public saveToolDraft(callID: string, values: JsonObject): void { this.store.saveToolDraft(callID, values); }
|
||||
public clearToolDraft(callID: string): void { this.store.clearToolDraft(callID); }
|
||||
public replaceTasks(records: readonly TaskRecord[]): void { this.store.replaceTasks(records); this.emitState(); }
|
||||
public replaceExecutionSummaries(records: readonly ExecutionProgressSummary[]): void { this.store.replaceExecutionSummaries(records); }
|
||||
public replaceSurfaces(snapshot: SurfaceInstanceSnapshot): void { this.store.replaceSurfaces(snapshot); }
|
||||
public replaceCapabilityCalls(records: readonly CapabilityCallRecord[]): void { this.store.replaceCapabilityCalls(records); }
|
||||
|
||||
public acknowledgeChatMessage(messageID: string): boolean {
|
||||
return this.store.acknowledgeAppInbox("chat", messageID);
|
||||
}
|
||||
|
||||
/** Runtime-owned durable protocol result path for non-Chat host modules. */
|
||||
public async queueProtocolAction(
|
||||
kind: Exclude<OutboxEntry["kind"], "text">,
|
||||
type: string,
|
||||
payload: JsonObject,
|
||||
): Promise<CommandReceipt> {
|
||||
if (!this.session.active) return { disposition: "rejected", code: "inactive" };
|
||||
const localID = this.newID(kind);
|
||||
const envelope: Envelope = {
|
||||
v: 1,
|
||||
id: this.newID("msg"),
|
||||
type,
|
||||
conversation_id: this.requireConversationID(),
|
||||
sender: { kind: "human", id: this.session.uid },
|
||||
target: { kind: "agent", id: this.session.agent_uid },
|
||||
timestamp: this.now(),
|
||||
payload,
|
||||
};
|
||||
this.store.enqueueToolAction(localID, kind, JSON.stringify(envelope), this.now());
|
||||
void this.flushOutbox();
|
||||
return { disposition: "accepted", local_id: localID };
|
||||
}
|
||||
|
||||
/** Runtime-owned ephemeral route used only for declarative inventory updates. */
|
||||
public async sendProtocolEnvelope(type: string, payload: JsonObject): Promise<void> {
|
||||
if (!this.session.active || !this.transport) return;
|
||||
const envelope: Envelope = {
|
||||
v: 1,
|
||||
id: this.newID("msg"),
|
||||
type,
|
||||
conversation_id: this.requireConversationID(),
|
||||
sender: { kind: "human", id: this.session.uid },
|
||||
target: { kind: "agent", id: this.session.agent_uid },
|
||||
timestamp: this.now(),
|
||||
payload,
|
||||
};
|
||||
await this.transport.sendText({
|
||||
from_uid: this.session.uid,
|
||||
channel_id: this.session.channel_id,
|
||||
channel_type: this.session.channel_type,
|
||||
payload: JSON.stringify(envelope),
|
||||
});
|
||||
}
|
||||
|
||||
private async dispatchChatAction(action: ChatAction): Promise<CommandReceipt> {
|
||||
if (!this.session.active) return { disposition: "rejected", code: "inactive" };
|
||||
if (action.conversation_id !== this.requireConversationID()) return { disposition: "rejected", code: "scope_mismatch" };
|
||||
if (action.type === "chat.send_text") {
|
||||
const text = action.text.trim();
|
||||
if (!text) return { disposition: "rejected", code: "invalid_action" };
|
||||
const localID = action.local_id ?? this.newID("local");
|
||||
const stored = this.store.appendLocalText(localID, text, this.now());
|
||||
this.emit({ type: "local-item", item: stored.item, local_id: localID });
|
||||
this.emitState();
|
||||
void this.flushOutbox();
|
||||
return { disposition: "accepted", local_id: localID };
|
||||
}
|
||||
if (action.type === "chat.submit_interaction") {
|
||||
return this.submitToolCall(action.call_id, action.result)
|
||||
? { disposition: "accepted" }
|
||||
: { disposition: "rejected", code: "invalid_action" };
|
||||
}
|
||||
if (action.type === "chat.cancel_interaction") {
|
||||
return this.cancelToolCall(action.call_id, action.reason)
|
||||
? { disposition: "accepted" }
|
||||
: { disposition: "rejected", code: "invalid_action" };
|
||||
}
|
||||
if (action.type === "chat.report_app_result") {
|
||||
return this.queueProtocolAction("app-result", "lineup.v1.app.result", action.result);
|
||||
}
|
||||
if (action.type === "chat.cancel_surface") {
|
||||
if (!action.instance_id) return { disposition: "rejected", code: "invalid_action" };
|
||||
return this.queueProtocolAction("surface-event", "lineup.v1.ui.event", {
|
||||
instance_id: action.instance_id,
|
||||
event: "cancel",
|
||||
data: {},
|
||||
});
|
||||
}
|
||||
return { disposition: "rejected", code: "invalid_action" };
|
||||
}
|
||||
|
||||
private processTransportMessage(message: TransportMessage): void {
|
||||
const payload = decodeTransportPayload(message.payload);
|
||||
if (message.from_uid === this.session.uid) {
|
||||
const own = parseEnvelopeJSON(payload);
|
||||
if (own.ok && CONTROL_ECHO_TYPES.has(own.envelope.type)) return;
|
||||
const restored = this.store.recordSyncedUserText(message.message_seq, payload, this.now());
|
||||
if (restored) this.emit({ type: "local-item", item: restored.item, local_id: restored.local_id });
|
||||
return;
|
||||
}
|
||||
|
||||
const scopeResolution = resolveIncomingScope(payload, {
|
||||
app_scope: "chat", conversation_id: this.requireConversationID(),
|
||||
acceptsAppScope: scope => Boolean(this.apps.get(scope)?.enabled),
|
||||
});
|
||||
if (scopeResolution.disposition === "rejected") {
|
||||
runtimeLog("message.rejected", { stage: "scope", reason: scopeResolution.reason });
|
||||
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() });
|
||||
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;
|
||||
}
|
||||
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);
|
||||
for (const rawItem of result.items) {
|
||||
const item = this.prepareIncoming(rawItem);
|
||||
if (!item) continue;
|
||||
const receivedAt = this.now();
|
||||
if (!this.store.recordIncoming(item, message.message_seq, receivedAt)) continue;
|
||||
const messageID = item.id ?? `transport:${message.message_seq}`;
|
||||
const scoped: ScopedConversationItem = {
|
||||
message_id: messageID,
|
||||
scope: scopeResolution.scope,
|
||||
item,
|
||||
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,
|
||||
});
|
||||
this.emit({ type: "agent-message", message: scoped });
|
||||
if (targetScope === "chat") this.emitChatMessage(scoped);
|
||||
}
|
||||
if (route.disposition === "launch") {
|
||||
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" });
|
||||
}
|
||||
}
|
||||
this.emit({ type: "state", snapshot: result.snapshot });
|
||||
this.emitState();
|
||||
}
|
||||
|
||||
private handleOutboxSuccess(entry: OutboxEntry, sequence: number | undefined): void {
|
||||
if (entry.kind === "text") {
|
||||
if (sequence) this.store.markSubmitted(entry.local_id, sequence, this.now());
|
||||
const item = this.store.snapshot().items.find(candidate => candidate.local_id === entry.local_id)?.item;
|
||||
this.emit({ type: "delivery", local_id: entry.local_id, ...(item ? { item } : {}) });
|
||||
return;
|
||||
}
|
||||
this.store.acknowledgeOutbox(entry.local_id);
|
||||
}
|
||||
|
||||
private handleOutboxFailure(entry: OutboxEntry, reason: unknown): void {
|
||||
runtimeLog("outbox.delivery_failed", { kind: entry.kind });
|
||||
if (entry.kind !== "text") return;
|
||||
const message = reason instanceof Error ? reason.message : "发送失败";
|
||||
this.store.markFailed(entry.local_id, message, this.now());
|
||||
const item = this.store.snapshot().items.find(candidate => candidate.local_id === entry.local_id)?.item;
|
||||
this.emit({ type: "delivery", local_id: entry.local_id, ...(item ? { item } : {}) });
|
||||
}
|
||||
|
||||
private async syncLoop(): Promise<void> {
|
||||
while (this.syncRequested && this.session.active) {
|
||||
try {
|
||||
await this.flushOutbox();
|
||||
await this.syncOnce();
|
||||
} catch {
|
||||
if (this.session.active) this.emit({ type: "sync-status", status: "retrying" });
|
||||
}
|
||||
if (this.syncRequested && this.session.active) await this.sleep(1_500);
|
||||
}
|
||||
}
|
||||
|
||||
private chatView(): ChatViewModel {
|
||||
const snapshot = this.kernel.snapshot();
|
||||
return {
|
||||
app_scope: "chat",
|
||||
conversation_id: this.currentConversationID(),
|
||||
active: this.session.active,
|
||||
agent_presence: snapshot.agent_presence,
|
||||
tool_calls: snapshot.tool_calls,
|
||||
tasks: snapshot.tasks,
|
||||
};
|
||||
}
|
||||
|
||||
private listChatMessages(afterMessageID?: string): readonly AgentChatMessage[] {
|
||||
return this.listAppMessages("chat", afterMessageID) as readonly AgentChatMessage[];
|
||||
}
|
||||
|
||||
private listAppMessages(appScope: AppScope, afterMessageID?: string): readonly ScopedConversationItem[] {
|
||||
const entries = this.store.listAppInbox(appScope);
|
||||
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,
|
||||
scope: { app_scope: appScope, conversation_id: entry.conversation_id },
|
||||
item: entry.item,
|
||||
received_at: entry.received_at,
|
||||
...(entry.message_seq !== undefined ? { message_seq: entry.message_seq } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
private emitChatMessage(message: ScopedConversationItem): void {
|
||||
if (message.scope.app_scope !== "chat") return;
|
||||
const scoped = message as AgentChatMessage;
|
||||
for (const listener of this.chatMessageListeners) listener(scoped);
|
||||
}
|
||||
|
||||
private emitState(): void {
|
||||
const view = this.chatView();
|
||||
for (const listener of this.chatStateListeners) listener(view);
|
||||
}
|
||||
|
||||
private persistWorkspace(): void { this.store.replaceAppWorkspace(this.lifecycle.snapshot()); }
|
||||
|
||||
private emit(event: RuntimeAppEvent): void {
|
||||
for (const listener of this.listeners) listener(event);
|
||||
}
|
||||
|
||||
private requireConversationID(): string {
|
||||
const conversationID = this.currentConversationID();
|
||||
if (!conversationID) throw new Error("LineUpRuntime has no active conversation.");
|
||||
return conversationID;
|
||||
}
|
||||
}
|
||||
|
||||
function boundedFailure(value: string): string {
|
||||
const message = value.trim();
|
||||
return message ? message.slice(0, 500) : "Extension App failed.";
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
|
||||
function defaultID(prefix: string): string {
|
||||
const uuid = globalThis.crypto?.randomUUID?.();
|
||||
return `${prefix}_${uuid ?? `${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`}`;
|
||||
}
|
||||
|
||||
function decodeTransportPayload(value: string): string {
|
||||
try {
|
||||
return decodeURIComponent([...atob(value)].map(character => `%${character.charCodeAt(0).toString(16).padStart(2, "0")}`).join(""));
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Runtime 内部可路由消息的作用域模型与兼容适配器。
|
||||
*
|
||||
* 所有投递到 App 的消息必须有合法 `app_scope + conversation_id`。旧 `lineup.v1` 未携带
|
||||
* scope 时只在 Runtime 内映射到当前 Chat 会话;显式非法或不匹配 scope 一律拒绝。
|
||||
*/
|
||||
import type { ConversationItem } from "@/runtime/protocol/lineup-v1";
|
||||
import { isAppScope, type AppScope } from "@/runtime/app-management/app-registry";
|
||||
|
||||
export type RuntimeScope = {
|
||||
app_scope: AppScope;
|
||||
conversation_id: string;
|
||||
instance_id?: string;
|
||||
operation_id?: string;
|
||||
};
|
||||
|
||||
export type RuntimeEnvelope = {
|
||||
v: 1;
|
||||
id: string;
|
||||
type: string;
|
||||
timestamp: string;
|
||||
scope: RuntimeScope;
|
||||
payload: unknown;
|
||||
};
|
||||
|
||||
export type ScopedConversationItem = {
|
||||
message_id: string;
|
||||
scope: RuntimeScope;
|
||||
item: ConversationItem;
|
||||
received_at: string;
|
||||
message_seq?: number;
|
||||
};
|
||||
|
||||
export type ScopeResolution =
|
||||
| { disposition: "accepted"; scope: RuntimeScope }
|
||||
| { disposition: "rejected"; reason: "invalid_scope" | "scope_mismatch" };
|
||||
|
||||
/**
|
||||
* Compatibility adapter for the existing LineUp v1 wire protocol.
|
||||
*
|
||||
* Old envelopes have only `conversation_id`; until all producers have been
|
||||
* upgraded, Runtime attaches the receiving Core App's local scope. New
|
||||
* producers may include `scope`, but it is never trusted without exact
|
||||
* validation against the active conversation and target app.
|
||||
*/
|
||||
export function resolveIncomingScope(
|
||||
raw: string,
|
||||
expected: { app_scope: AppScope; conversation_id: string; acceptsAppScope?: (scope: AppScope) => boolean },
|
||||
): ScopeResolution {
|
||||
const legacyScope: RuntimeScope = { app_scope: expected.app_scope, conversation_id: expected.conversation_id };
|
||||
let parsed: unknown;
|
||||
try { parsed = JSON.parse(raw); } catch {
|
||||
// Legacy plaintext is a Chat message in the active conversation.
|
||||
return { disposition: "accepted", scope: legacyScope };
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return { disposition: "accepted", scope: legacyScope };
|
||||
}
|
||||
const candidate = parsed as { scope?: unknown; conversation_id?: unknown };
|
||||
if (candidate.scope === undefined) {
|
||||
// Existing v1 envelope: conversation_id remains authoritative.
|
||||
if (typeof candidate.conversation_id === "string" && candidate.conversation_id !== expected.conversation_id) {
|
||||
return { disposition: "rejected", reason: "scope_mismatch" };
|
||||
}
|
||||
return { disposition: "accepted", scope: legacyScope };
|
||||
}
|
||||
if (!candidate.scope || typeof candidate.scope !== "object" || Array.isArray(candidate.scope)) {
|
||||
return { disposition: "rejected", reason: "invalid_scope" };
|
||||
}
|
||||
const scope = candidate.scope as { app_scope?: unknown; conversation_id?: unknown; instance_id?: unknown; operation_id?: unknown };
|
||||
if (!isAppScope(scope.app_scope) || typeof scope.conversation_id !== "string" || !scope.conversation_id) {
|
||||
return { disposition: "rejected", reason: "invalid_scope" };
|
||||
}
|
||||
if (scope.conversation_id !== expected.conversation_id || (expected.acceptsAppScope ? !expected.acceptsAppScope(scope.app_scope) : scope.app_scope !== expected.app_scope)) {
|
||||
return { disposition: "rejected", reason: "scope_mismatch" };
|
||||
}
|
||||
if ((scope.instance_id !== undefined && typeof scope.instance_id !== "string") || (scope.operation_id !== undefined && typeof scope.operation_id !== "string")) {
|
||||
return { disposition: "rejected", reason: "invalid_scope" };
|
||||
}
|
||||
return {
|
||||
disposition: "accepted",
|
||||
scope: {
|
||||
app_scope: scope.app_scope,
|
||||
conversation_id: scope.conversation_id,
|
||||
...(typeof scope.instance_id === "string" ? { instance_id: scope.instance_id } : {}),
|
||||
...(typeof scope.operation_id === "string" ? { operation_id: scope.operation_id } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TaskStateMachine } from "@/runtime/coordination/task-state";
|
||||
|
||||
const TIME = "2026-08-03T09:00:00.000Z";
|
||||
|
||||
describe("TaskStateMachine", () => {
|
||||
it("projects repeated progress updates into one task record", () => {
|
||||
const tasks = new TaskStateMachine();
|
||||
tasks.receive({ operation_id: "task_001", title: "Generate report", percent: 20, status: "running", cancellable: true }, "conversation_001", TIME);
|
||||
tasks.receive({ operation_id: "task_001", title: "Generate report", percent: 80, status: "running", cancellable: true }, "conversation_001", "2026-08-03T09:01:00.000Z");
|
||||
|
||||
expect(tasks.snapshot()).toEqual([expect.objectContaining({ operation_id: "task_001", percent: 80, status: "running" })]);
|
||||
});
|
||||
|
||||
it("records one local cancellation request and lets the Agent close it as cancelled", () => {
|
||||
const tasks = new TaskStateMachine();
|
||||
tasks.receive({ operation_id: "task_002", title: "Export", percent: 40, status: "running", cancellable: true }, "conversation_001", TIME);
|
||||
|
||||
expect(tasks.requestCancel("task_002", "2026-08-03T09:01:00.000Z")).toMatchObject({ disposition: "accepted", record: { cancel_requested_at: "2026-08-03T09:01:00.000Z" } });
|
||||
expect(tasks.requestCancel("task_002", "2026-08-03T09:02:00.000Z").disposition).toBe("invalid_transition");
|
||||
tasks.receive({ operation_id: "task_002", title: "Export", percent: 40, status: "cancelled", cancellable: false }, "conversation_001", "2026-08-03T09:03:00.000Z");
|
||||
|
||||
expect(tasks.get("task_002")).toMatchObject({ status: "cancelled" });
|
||||
expect(tasks.get("task_002")).not.toHaveProperty("cancel_requested_at");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 按 `operation_id` 聚合 Agent 任务进度的纯状态机。
|
||||
*
|
||||
* 同一任务的更新只替换最新投影,避免聊天时间线因进度变化无限新增卡片;取消请求也必须
|
||||
* 经过受限状态跃迁。
|
||||
*/
|
||||
import type { TaskProgress, TaskStatus } from "@/runtime/protocol/lineup-v1";
|
||||
|
||||
export type TaskRecord = TaskProgress & {
|
||||
conversation_id: string;
|
||||
updated_at: string;
|
||||
cancel_requested_at?: string;
|
||||
};
|
||||
|
||||
export type TaskTransition =
|
||||
| { disposition: "accepted"; record: TaskRecord }
|
||||
| { disposition: "missing" | "invalid_transition"; record?: TaskRecord };
|
||||
|
||||
/**
|
||||
* Platform-neutral task projection. It has no DOM, storage, transport, or
|
||||
* side-effect handler. M1-04 records a user cancellation request locally;
|
||||
* M1-06 later owns durable delivery to the Agent.
|
||||
*/
|
||||
export class TaskStateMachine {
|
||||
private readonly tasks = new Map<string, TaskRecord>();
|
||||
|
||||
public receive(update: TaskProgress, conversationID: string, updatedAt: string): TaskRecord {
|
||||
const current = this.tasks.get(update.operation_id);
|
||||
const next: TaskRecord = {
|
||||
...update,
|
||||
conversation_id: conversationID,
|
||||
updated_at: updatedAt,
|
||||
...(current?.cancel_requested_at && update.status !== "cancelled" ? { cancel_requested_at: current.cancel_requested_at } : {}),
|
||||
};
|
||||
this.tasks.set(next.operation_id, next);
|
||||
return copy(next);
|
||||
}
|
||||
|
||||
public requestCancel(operationID: string, updatedAt: string): TaskTransition {
|
||||
const record = this.tasks.get(operationID);
|
||||
if (!record) return { disposition: "missing" };
|
||||
if (!record.cancellable || isTerminal(record.status) || record.cancel_requested_at) return { disposition: "invalid_transition", record: copy(record) };
|
||||
record.cancel_requested_at = updatedAt;
|
||||
record.updated_at = updatedAt;
|
||||
return { disposition: "accepted", record: copy(record) };
|
||||
}
|
||||
|
||||
public get(operationID: string): TaskRecord | undefined {
|
||||
const record = this.tasks.get(operationID);
|
||||
return record ? copy(record) : undefined;
|
||||
}
|
||||
|
||||
public snapshot(): readonly TaskRecord[] { return [...this.tasks.values()].map(copy); }
|
||||
public reset(): void { this.tasks.clear(); }
|
||||
public restore(records: readonly TaskRecord[]): void {
|
||||
this.tasks.clear();
|
||||
for (const record of records) {
|
||||
if (!isRestoreable(record) || this.tasks.has(record.operation_id)) continue;
|
||||
this.tasks.set(record.operation_id, copy(record));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isTerminal(status: TaskStatus): boolean { return status === "completed" || status === "failed" || status === "cancelled"; }
|
||||
|
||||
function isRestoreable(value: unknown): value is TaskRecord {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const record = value as Partial<TaskRecord>;
|
||||
return typeof record.operation_id === "string" && Boolean(record.operation_id)
|
||||
&& typeof record.conversation_id === "string" && Boolean(record.conversation_id)
|
||||
&& typeof record.title === "string" && typeof record.percent === "number" && Number.isFinite(record.percent)
|
||||
&& TASK_STATUS_VALUES.has(record.status as TaskStatus) && typeof record.cancellable === "boolean"
|
||||
&& typeof record.updated_at === "string" && !Number.isNaN(Date.parse(record.updated_at));
|
||||
}
|
||||
|
||||
const TASK_STATUS_VALUES = new Set<TaskStatus>(["running", "waiting", "completed", "failed", "cancelled"]);
|
||||
function copy(record: TaskRecord): TaskRecord { return { ...record }; }
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ToolCallRequest } from "@/runtime/protocol/lineup-v1";
|
||||
import { ToolCallStateMachine } from "@/runtime/coordination/tool-call-state";
|
||||
|
||||
const RECEIVED_AT = "2026-08-03T08:00:00.000Z";
|
||||
|
||||
function request(overrides: Partial<ToolCallRequest> = {}): ToolCallRequest {
|
||||
return {
|
||||
call_id: "call_state_001",
|
||||
tool: "choice",
|
||||
title: "Choose a path",
|
||||
prompt: "Select one option.",
|
||||
data: { source: "test" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ToolCallStateMachine", () => {
|
||||
it("creates one pending record per call_id and preserves the first request", () => {
|
||||
const state = new ToolCallStateMachine();
|
||||
const first = state.receive(request(), "conversation_001", RECEIVED_AT);
|
||||
const duplicate = state.receive(request({ title: "Replacement must be ignored" }), "conversation_002", "2026-08-03T08:01:00.000Z");
|
||||
|
||||
expect(first).toMatchObject({ disposition: "accepted", record: { status: "pending", conversation_id: "conversation_001" } });
|
||||
expect(duplicate).toMatchObject({ disposition: "duplicate", record: { request: { title: "Choose a path" } } });
|
||||
expect(state.snapshot()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("permits only pending to submitted to a final result", () => {
|
||||
const state = new ToolCallStateMachine();
|
||||
state.receive(request(), "conversation_001", RECEIVED_AT);
|
||||
|
||||
expect(state.complete({ call_id: "call_state_001", status: "completed", result: { selected: "alpha" } }, "2026-08-03T08:01:00.000Z"))
|
||||
.toMatchObject({ disposition: "invalid_transition", record: { status: "pending" } });
|
||||
expect(state.submit("call_state_001", "2026-08-03T08:02:00.000Z", { action_ids: ["alpha"] }))
|
||||
.toMatchObject({ disposition: "accepted", record: { status: "submitted", submission: { action_ids: ["alpha"] } } });
|
||||
expect(state.complete({ call_id: "call_state_001", status: "completed", result: { selected: "alpha" } }, "2026-08-03T08:03:00.000Z"))
|
||||
.toMatchObject({ disposition: "accepted", record: { status: "completed", result: { selected: "alpha" } } });
|
||||
expect(state.submit("call_state_001", "2026-08-03T08:04:00.000Z").disposition).toBe("invalid_transition");
|
||||
expect(state.submit("missing_call", "2026-08-03T08:04:00.000Z").disposition).toBe("missing");
|
||||
|
||||
state.receive(request({ call_id: "call_state_failed" }), "conversation_001", RECEIVED_AT);
|
||||
state.submit("call_state_failed", "2026-08-03T08:05:00.000Z");
|
||||
expect(state.complete({ call_id: "call_state_failed", status: "failed", result: {}, error: "Validation rejected" }, "2026-08-03T08:06:00.000Z"))
|
||||
.toMatchObject({ disposition: "accepted", record: { status: "failed", error: "Validation rejected" } });
|
||||
});
|
||||
|
||||
it("cancels an active request but never changes a terminal record", () => {
|
||||
const state = new ToolCallStateMachine();
|
||||
state.receive(request(), "conversation_001", RECEIVED_AT);
|
||||
|
||||
expect(state.cancel({ call_id: "call_state_001", reason: "User left the flow" }, "2026-08-03T08:01:00.000Z"))
|
||||
.toMatchObject({ disposition: "accepted", record: { status: "cancelled", error: "User left the flow" } });
|
||||
expect(state.cancel({ call_id: "call_state_001", reason: "Again" }, "2026-08-03T08:02:00.000Z").disposition).toBe("invalid_transition");
|
||||
expect(state.get("missing_call")?.status).toBeUndefined();
|
||||
});
|
||||
|
||||
it("expires only active requests, including one that is already expired on receipt", () => {
|
||||
const state = new ToolCallStateMachine();
|
||||
state.receive(request({ call_id: "expired_on_receipt", expires_at: "2026-08-03T07:59:59.000Z" }), "conversation_001", RECEIVED_AT);
|
||||
state.receive(request({ call_id: "expires_later", expires_at: "2026-08-03T08:05:00.000Z" }), "conversation_001", RECEIVED_AT);
|
||||
state.receive(request({ call_id: "no_expiry" }), "conversation_001", RECEIVED_AT);
|
||||
|
||||
expect(state.get("expired_on_receipt")?.status).toBe("expired");
|
||||
expect(state.expire("2026-08-03T08:05:00.000Z")).toEqual([
|
||||
expect.objectContaining({ call_id: "expires_later", status: "expired" }),
|
||||
]);
|
||||
expect(state.get("no_expiry")?.status).toBe("pending");
|
||||
});
|
||||
|
||||
it("restores an active record but expires it if its deadline passed while away", () => {
|
||||
const state = new ToolCallStateMachine();
|
||||
state.restore([{
|
||||
call_id: "call_restore_001",
|
||||
conversation_id: "conversation_001",
|
||||
request: request({ call_id: "call_restore_001", expires_at: "2026-08-03T08:05:00.000Z" }),
|
||||
status: "submitted",
|
||||
submission: { values: { version: "1.2.3" } },
|
||||
created_at: RECEIVED_AT,
|
||||
updated_at: "2026-08-03T08:01:00.000Z",
|
||||
}], "2026-08-03T08:06:00.000Z");
|
||||
|
||||
expect(state.get("call_restore_001")).toMatchObject({ status: "expired", submission: { values: { version: "1.2.3" } } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* 标准 Tool Call 的纯状态机。
|
||||
*
|
||||
* `call_id` 是唯一关联键,只允许声明性 choice、confirm、input 在合法生命周期内迁移;
|
||||
* 本模块不渲染、不写 Store、不联网,实际结果发送由 Runtime Action/outbox 完成。
|
||||
*/
|
||||
import type {
|
||||
JsonObject,
|
||||
ToolCallCancel,
|
||||
ToolCallFinalStatus,
|
||||
ToolCallRequest,
|
||||
ToolCallResult,
|
||||
} from "@/runtime/protocol/lineup-v1";
|
||||
|
||||
export type ToolCallStatus = "pending" | "submitted" | ToolCallFinalStatus;
|
||||
|
||||
export type ToolCallRecord = {
|
||||
call_id: string;
|
||||
conversation_id: string;
|
||||
request: ToolCallRequest;
|
||||
status: ToolCallStatus;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
result?: JsonObject;
|
||||
submission?: JsonObject;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type ToolCallTransition =
|
||||
| { disposition: "accepted"; record: ToolCallRecord }
|
||||
| { disposition: "duplicate" | "invalid_transition" | "missing"; record?: ToolCallRecord };
|
||||
|
||||
/**
|
||||
* Platform-neutral state machine for trusted standard interaction calls.
|
||||
* It has no UI, persistence, Transport, capability, or side-effect handler;
|
||||
* later M1 tasks connect those boundaries through this explicit lifecycle.
|
||||
*/
|
||||
export class ToolCallStateMachine {
|
||||
private readonly calls = new Map<string, ToolCallRecord>();
|
||||
|
||||
public receive(request: ToolCallRequest, conversationID: string, receivedAt: string): ToolCallTransition {
|
||||
const existing = this.calls.get(request.call_id);
|
||||
if (existing) return { disposition: "duplicate", record: copy(existing) };
|
||||
const expired = request.expires_at !== undefined && Date.parse(request.expires_at) <= Date.parse(receivedAt);
|
||||
const record: ToolCallRecord = {
|
||||
call_id: request.call_id,
|
||||
conversation_id: conversationID,
|
||||
request: { ...request, data: { ...request.data } },
|
||||
status: expired ? "expired" : "pending",
|
||||
created_at: receivedAt,
|
||||
updated_at: receivedAt,
|
||||
};
|
||||
this.calls.set(record.call_id, record);
|
||||
return { disposition: "accepted", record: copy(record) };
|
||||
}
|
||||
|
||||
public submit(callID: string, updatedAt: string, submission: JsonObject = {}): ToolCallTransition {
|
||||
const record = this.calls.get(callID);
|
||||
if (record?.request.expires_at && Date.parse(record.request.expires_at) <= Date.parse(updatedAt) && (record.status === "pending" || record.status === "submitted")) {
|
||||
record.status = "expired";
|
||||
record.updated_at = updatedAt;
|
||||
return { disposition: "invalid_transition", record: copy(record) };
|
||||
}
|
||||
return this.transition(callID, updatedAt, ["pending"], record => {
|
||||
record.status = "submitted";
|
||||
record.submission = { ...submission };
|
||||
});
|
||||
}
|
||||
|
||||
public complete(result: ToolCallResult, updatedAt: string): ToolCallTransition {
|
||||
return this.transition(result.call_id, updatedAt, ["submitted"], record => {
|
||||
record.status = result.status;
|
||||
record.result = { ...result.result };
|
||||
record.error = result.error;
|
||||
});
|
||||
}
|
||||
|
||||
public cancel(cancel: ToolCallCancel, updatedAt: string): ToolCallTransition {
|
||||
return this.transition(cancel.call_id, updatedAt, ["pending", "submitted"], record => {
|
||||
record.status = "cancelled";
|
||||
record.error = cancel.reason || undefined;
|
||||
});
|
||||
}
|
||||
|
||||
public expireCall(callID: string, now: string): ToolCallTransition {
|
||||
const record = this.calls.get(callID);
|
||||
if (!record) return { disposition: "missing" };
|
||||
if ((record.status !== "pending" && record.status !== "submitted") || !record.request.expires_at || Date.parse(record.request.expires_at) > Date.parse(now)) {
|
||||
return { disposition: "invalid_transition", record: copy(record) };
|
||||
}
|
||||
record.status = "expired";
|
||||
record.updated_at = now;
|
||||
return { disposition: "accepted", record: copy(record) };
|
||||
}
|
||||
|
||||
public expire(now: string): readonly ToolCallRecord[] {
|
||||
const changed: ToolCallRecord[] = [];
|
||||
const nowTime = Date.parse(now);
|
||||
for (const record of this.calls.values()) {
|
||||
if ((record.status === "pending" || record.status === "submitted") && record.request.expires_at && Date.parse(record.request.expires_at) <= nowTime) {
|
||||
record.status = "expired";
|
||||
record.updated_at = now;
|
||||
changed.push(copy(record));
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
public get(callID: string): ToolCallRecord | undefined {
|
||||
const record = this.calls.get(callID);
|
||||
return record ? copy(record) : undefined;
|
||||
}
|
||||
|
||||
public snapshot(): readonly ToolCallRecord[] {
|
||||
return [...this.calls.values()].map(copy);
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.calls.clear();
|
||||
}
|
||||
|
||||
/** Restores trusted local cache records for the current conversation only. */
|
||||
public restore(records: readonly ToolCallRecord[], now: string): void {
|
||||
this.calls.clear();
|
||||
for (const record of records) {
|
||||
if (!isRestoreableRecord(record) || this.calls.has(record.call_id)) continue;
|
||||
const restored = copy(record);
|
||||
if ((restored.status === "pending" || restored.status === "submitted") && restored.request.expires_at && Date.parse(restored.request.expires_at) <= Date.parse(now)) {
|
||||
restored.status = "expired";
|
||||
restored.updated_at = now;
|
||||
}
|
||||
this.calls.set(restored.call_id, restored);
|
||||
}
|
||||
}
|
||||
|
||||
private transition(
|
||||
callID: string,
|
||||
updatedAt: string,
|
||||
allowed: readonly ToolCallStatus[],
|
||||
apply: (record: ToolCallRecord) => void,
|
||||
): ToolCallTransition {
|
||||
const record = this.calls.get(callID);
|
||||
if (!record) return { disposition: "missing" };
|
||||
if (!allowed.includes(record.status)) return { disposition: "invalid_transition", record: copy(record) };
|
||||
apply(record);
|
||||
record.updated_at = updatedAt;
|
||||
return { disposition: "accepted", record: copy(record) };
|
||||
}
|
||||
}
|
||||
|
||||
function isRestoreableRecord(value: unknown): value is ToolCallRecord {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const record = value as Partial<ToolCallRecord>;
|
||||
if (typeof record.call_id !== "string" || !record.call_id || typeof record.conversation_id !== "string" || !record.conversation_id || !record.request || typeof record.request !== "object") return false;
|
||||
if (record.request.call_id !== record.call_id || !["choice", "confirm", "input"].includes(record.request.tool) || !record.request.data || typeof record.request.data !== "object" || Array.isArray(record.request.data)) return false;
|
||||
if (!["pending", "submitted", "completed", "failed", "cancelled", "expired"].includes(record.status ?? "") || typeof record.created_at !== "string" || typeof record.updated_at !== "string") return false;
|
||||
return !Number.isNaN(Date.parse(record.created_at)) && !Number.isNaN(Date.parse(record.updated_at));
|
||||
}
|
||||
|
||||
function copy(record: ToolCallRecord): ToolCallRecord {
|
||||
return {
|
||||
...record,
|
||||
request: { ...record.request, data: { ...record.request.data } },
|
||||
...(record.result ? { result: { ...record.result } } : {}),
|
||||
...(record.submission ? { submission: { ...record.submission } } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CoreAppRegistry } from "@/runtime/app-management/app-registry";
|
||||
import { ToolRouter } from "@/runtime/coordination/tool-router";
|
||||
|
||||
function extension(enabled = true) {
|
||||
return {
|
||||
app_scope: "draw-and-guess", kind: "extension" as const, enabled, default_eligible: false, recovery: false,
|
||||
manifest: { app_scope: "draw-and-guess", version: "1.0.0", permissions: [], tools: [{ name: "app.launch", handling: "launch" as const, parameters: ["app_scope"] }] },
|
||||
};
|
||||
}
|
||||
function launch(scope = "draw-and-guess", extra: Record<string, unknown> = {}) {
|
||||
return JSON.stringify({ v: 1, id: "launch-1", type: "lineup.v1.app.call", conversation_id: "c1", sender: { kind: "agent", id: "agent" }, payload: { call_id: "call-1", capability: "runtime.launch_app", reason: "Start a game", expires_at: "2026-08-05T00:00:00.000Z", arguments: { app_scope: scope, ...extra } } });
|
||||
}
|
||||
|
||||
describe("ToolRouter", () => {
|
||||
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" });
|
||||
});
|
||||
it("rejects unknown and malformed launch requests before an App can see them", () => {
|
||||
const apps = new CoreAppRegistry(); apps.install(extension()); const router = new ToolRouter(apps);
|
||||
expect(router.route(launch("not-installed"), { app_scope: "chat", conversation_id: "c1" })).toEqual({ disposition: "rejected", code: "not_installed" });
|
||||
expect(router.route(launch("draw-and-guess", { bundle_url: "https://evil.invalid" }), { app_scope: "chat", conversation_id: "c1" })).toEqual({ disposition: "rejected", code: "invalid_request" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Runtime Tool Router. It is the only interpreter for Agent requests that
|
||||
* alter the application workspace. A renderer merely receives the already
|
||||
* 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";
|
||||
|
||||
export type ToolRoute =
|
||||
| { disposition: "pass" }
|
||||
| { 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" };
|
||||
|
||||
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 {
|
||||
const parsed = parseEnvelopeJSON(raw);
|
||||
if (!parsed.ok) return { disposition: "pass" };
|
||||
const envelope = parsed.envelope;
|
||||
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 routeLaunch(envelope: Envelope): ToolRoute {
|
||||
const callID = text(envelope.payload.call_id);
|
||||
const args = object(envelope.payload.arguments);
|
||||
const requestedScope = args && text(args.app_scope);
|
||||
if (!callID || !args || !requestedScope || Object.keys(args).some(key => key !== "app_scope" && key !== "instance_id" && key !== "parameters")) return { disposition: "rejected", code: "invalid_request" };
|
||||
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");
|
||||
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);
|
||||
if (args.instance_id !== undefined && (!instanceID || !/^[A-Za-z0-9._:-]{1,128}$/.test(instanceID))) return { disposition: "rejected", code: "invalid_request" };
|
||||
const parameters = args.parameters === undefined ? {} : object(args.parameters);
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
function object(value: unknown): JsonObject | undefined { return value && typeof value === "object" && !Array.isArray(value) ? value as JsonObject : undefined; }
|
||||
function text(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value : undefined; }
|
||||
Reference in New Issue
Block a user