fix(runtime): unify standard interaction terminal states

This commit is contained in:
2026-08-06 00:44:01 +08:00
parent 823e71a162
commit 309dd6161d
10 changed files with 196 additions and 29 deletions
@@ -442,7 +442,7 @@ export class TrustedDOMRendererContext {
const buttons: HTMLButtonElement[] = []; const buttons: HTMLButtonElement[] = [];
const selected = new Set<string>(); const selected = new Set<string>();
const submit = (actionIDs: readonly string[]) => { const submit = (actionIDs: readonly string[]) => {
const accepted = this.toolInteractions?.submit(call.call_id, { action_ids: [...actionIDs] }) ?? false; const accepted = this.toolInteractions?.submit(call.call_id, { action_id: actionIDs[0] }) ?? false;
if (!accepted) { if (!accepted) {
status.textContent = "该交互已结束或已提交"; status.textContent = "该交互已结束或已提交";
return; return;
@@ -519,7 +519,7 @@ export class TrustedDOMRendererContext {
const locked = this.applyCallStatus(card, status, call, record); const locked = this.applyCallStatus(card, status, call, record);
for (const button of buttons) button.disabled = locked; for (const button of buttons) button.disabled = locked;
approve.addEventListener("click", () => { approve.addEventListener("click", () => {
if (!this.toolInteractions?.submit(call.call_id, { confirmed: true })) return this.markInteractionUnavailable(status); if (!this.toolInteractions?.submit(call.call_id, { approved: true })) return this.markInteractionUnavailable(status);
this.markSubmitted(card, status, buttons); this.markSubmitted(card, status, buttons);
}); });
cancel.addEventListener("click", () => { cancel.addEventListener("click", () => {
@@ -582,7 +582,8 @@ export class TrustedDOMRendererContext {
const values = this.readFormValues(fields); const values = this.readFormValues(fields);
const problem = validateFormValues(form, values); const problem = validateFormValues(form, values);
if (problem) { errors.textContent = problem; return; } if (problem) { errors.textContent = problem; return; }
if (!this.toolInteractions?.submit(call.call_id, { values })) return this.markInteractionUnavailable(status); const text = values[form.fields[0]?.id ?? ""];
if (!this.toolInteractions?.submit(call.call_id, { text })) return this.markInteractionUnavailable(status);
errors.textContent = ""; errors.textContent = "";
this.markSubmitted(card, status, controls); this.markSubmitted(card, status, controls);
}); });
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import fixture from "@/runtime/app-management/golden/miniapp-sdk-v1.json"; import fixture from "@/runtime/app-management/golden/miniapp-sdk-v1.json";
import { validateMiniAppManifest, type MiniAppManifestV1 } from "@/runtime/app-management/miniapp-manifest"; import { validateMiniAppManifest, type MiniAppManifestV1 } from "@/runtime/app-management/miniapp-manifest";
import { validateStandardInteractionRequest } from "@/runtime/coordination/standard-interaction-contract"; import { validateStandardInteractionAnswer, validateStandardInteractionRequest } from "@/runtime/coordination/standard-interaction-contract";
type GoldenCase = Readonly<{ type GoldenCase = Readonly<{
id: string; id: string;
@@ -61,4 +61,16 @@ describe("MiniApp SDK v1 frozen golden contract", () => {
expect(validateStandardInteractionRequest({ kind: "input", title: "描述", prompt: "请输入", expires_in_ms: 60_000, field: { id: "text", label: "描述", type: "textarea" } }, now)).toMatchObject({ accepted: true }); expect(validateStandardInteractionRequest({ kind: "input", title: "描述", prompt: "请输入", expires_in_ms: 60_000, field: { id: "text", label: "描述", type: "textarea" } }, now)).toMatchObject({ accepted: true });
expect(validateStandardInteractionRequest({ kind: "notice", title: "提示", message: "已保存", expires_in_ms: 60_000 } as never, now)).toEqual({ accepted: false, code: "interaction_request_invalid" }); expect(validateStandardInteractionRequest({ kind: "notice", title: "提示", message: "已保存", expires_in_ms: 60_000 } as never, now)).toEqual({ accepted: false, code: "interaction_request_invalid" });
}); });
it("accepts only the frozen answer shape for each standard interaction", () => {
const choice = { kind: "choice", title: "选择", prompt: "选一个", mode: "single-choice", actions: [{ id: "a", label: "A" }, { id: "b", label: "B" }] } as const;
const confirm = { kind: "confirm", title: "确认", prompt: "继续吗?" } as const;
const input = { kind: "input", title: "描述", prompt: "请输入", field: { id: "text", label: "描述", type: "textarea" } } as const;
expect(validateStandardInteractionAnswer(choice, { action_id: "a" })).toBe(true);
expect(validateStandardInteractionAnswer(choice, { action_ids: ["a"] })).toBe(false);
expect(validateStandardInteractionAnswer(confirm, { approved: true })).toBe(true);
expect(validateStandardInteractionAnswer(confirm, { confirmed: true })).toBe(false);
expect(validateStandardInteractionAnswer(input, { text: "hello" })).toBe(true);
expect(validateStandardInteractionAnswer(input, { values: { text: "hello" } })).toBe(false);
});
}); });
@@ -1,5 +1,6 @@
import { import {
validateStandardInteractionRequest, validateStandardInteractionRequest,
validateStandardInteractionAnswer,
type StandardInteractionRequest, type StandardInteractionRequest,
} from "@/runtime/coordination/standard-interaction-contract"; } from "@/runtime/coordination/standard-interaction-contract";
@@ -95,6 +96,7 @@ export class AgentInteractionService {
if (!record) return { disposition: "missing" }; if (!record) return { disposition: "missing" };
if (record.status !== "pending" && record.status !== "presented") return { disposition: "invalid_transition", record: copy(record) }; if (record.status !== "pending" && record.status !== "presented") return { disposition: "invalid_transition", record: copy(record) };
if (expired(record, now)) return this.expire(interactionID, now); if (expired(record, now)) return this.expire(interactionID, now);
if (!validateStandardInteractionAnswer(record.request, answer)) return { disposition: "invalid_request", record: copy(record) };
const submitted: StandardInteractionRecord = { ...record, status: "submitted", result: { outcome: "answered", answer: { ...answer } }, updated_at: now }; const submitted: StandardInteractionRecord = { ...record, status: "submitted", result: { outcome: "answered", answer: { ...answer } }, updated_at: now };
const completed: StandardInteractionRecord = { ...submitted, status: "completed", updated_at: now }; const completed: StandardInteractionRecord = { ...submitted, status: "completed", updated_at: now };
this.records.set(interactionID, completed); this.records.set(interactionID, completed);
@@ -96,6 +96,10 @@ export class InteractionKernel {
return this.toolCalls.expireCall(callID, updatedAt); return this.toolCalls.expireCall(callID, updatedAt);
} }
public setToolCallExpiry(callID: string, expiresAt: string, updatedAt: string) {
return this.toolCalls.setExpiry(callID, expiresAt, updatedAt);
}
public restoreToolCalls(records: readonly ToolCallRecord[], now: string): void { public restoreToolCalls(records: readonly ToolCallRecord[], now: string): void {
this.toolCalls.restore(records, now); this.toolCalls.restore(records, now);
} }
@@ -172,9 +172,9 @@ describe("LineUpRuntime", () => {
const chat = second.openChatApp(); const chat = second.openChatApp();
await second.login(login); await second.login(login);
expect(second.standardInteractions()).toEqual([expect.objectContaining({ call_id: "interactive-restart-1", status: "presented" })]); expect(second.standardInteractions()).toEqual([expect.objectContaining({ call_id: "interactive-restart-1", status: "presented" })]);
expect(chat.interactions.submit("interactive-restart-1", { confirmed: true })).toBe(true); expect(chat.interactions.submit("interactive-restart-1", { approved: true })).toBe(true);
await second.flushOutbox(); await second.flushOutbox();
expect(second.standardInteractions()).toEqual([expect.objectContaining({ call_id: "interactive-restart-1", status: "completed", result: { outcome: "answered", answer: { confirmed: true } } })]); expect(second.standardInteractions()).toEqual([expect.objectContaining({ call_id: "interactive-restart-1", status: "completed", result: { outcome: "answered", answer: { approved: true } } })]);
expect(secondTransport.sent.filter(entry => entry.payload.includes("lineup.v1.tool.result"))).toHaveLength(1); expect(secondTransport.sent.filter(entry => entry.payload.includes("lineup.v1.tool.result"))).toHaveLength(1);
}); });
@@ -212,9 +212,60 @@ describe("LineUpRuntime", () => {
await runtime.syncOnce(); await runtime.syncOnce();
await runtime.flushOutbox(); await runtime.flushOutbox();
expect(runtime.standardInteractions()).toEqual([expect.objectContaining({ call_id: "dismiss-question", status: "cancelled" })]); expect(runtime.standardInteractions()).toEqual([expect.objectContaining({ call_id: "dismiss-question", status: "cancelled" })]);
expect(runtime.toolCalls()).toEqual([expect.objectContaining({ call_id: "dismiss-question", status: "cancelled" })]);
expect(transport.sent.filter(entry => entry.payload.includes("lineup.v1.tool.cancel"))).toHaveLength(1); expect(transport.sent.filter(entry => entry.payload.includes("lineup.v1.tool.cancel"))).toHaveLength(1);
}); });
it("rejects an invalid standard answer before either interaction state or outbox changes", async () => {
const conversationID = "user_1:2:channel_1";
const question = JSON.stringify({ v: 1, id: "answer-shape-question", type: "lineup.v1.tool.call", conversation_id: conversationID, sender: { kind: "agent", id: "agent_1" }, payload: { call_id: "answer-shape-question", tool: "choice", title: "选择", prompt: "选一个", data: { action_group: { mode: "single-choice", actions: [{ id: "a", label: "A" }, { id: "b", label: "B" }] } } } });
const transport = new FakeTransport([[{ message_seq: 53, from_uid: "agent_1", payload: question }], []]);
const runtime = new LineUpRuntime({ storage: new MemoryStorage(), createTransport: () => transport });
const chat = runtime.openChatApp();
await runtime.login(login);
await runtime.syncOnce();
expect(chat.interactions.submit("answer-shape-question", { action_ids: ["a"] })).toBe(false);
expect(runtime.standardInteractions()).toEqual([expect.objectContaining({ call_id: "answer-shape-question", status: "presented" })]);
expect(runtime.toolCalls()).toEqual([expect.objectContaining({ call_id: "answer-shape-question", status: "pending" })]);
expect(chat.interactions.submit("answer-shape-question", { action_id: "a" })).toBe(true);
await runtime.flushOutbox();
expect(transport.sent.filter(entry => entry.payload.includes("lineup.v1.tool.result"))).toHaveLength(1);
});
it("uses one default expiry for Interaction and Kernel and emits one expired result", async () => {
let now = "2026-08-04T00:00:00.000Z";
const conversationID = "user_1:2:channel_1";
const question = JSON.stringify({ v: 1, id: "default-expiry-question", type: "lineup.v1.tool.call", conversation_id: conversationID, sender: { kind: "agent", id: "agent_1" }, payload: { call_id: "default-expiry-question", tool: "confirm", title: "确认", prompt: "继续吗?", data: { confirm: {} } } });
const transport = new FakeTransport([[{ message_seq: 54, from_uid: "agent_1", payload: question }], []]);
const runtime = new LineUpRuntime({ storage: new MemoryStorage(), createTransport: () => transport, now: () => now });
await runtime.login(login);
await runtime.syncOnce();
expect(runtime.standardInteractions()[0]).toMatchObject({ expires_at: "2026-08-04T00:15:00.000Z", status: "presented" });
expect(runtime.toolCalls()[0]).toMatchObject({ request: { expires_at: "2026-08-04T00:15:00.000Z" }, status: "pending" });
now = "2026-08-04T00:16:00.000Z";
expect(runtime.expireToolCall("default-expiry-question")).toBe(true);
await runtime.flushOutbox();
expect(runtime.standardInteractions()[0]).toMatchObject({ status: "expired" });
expect(runtime.toolCalls()[0]).toMatchObject({ status: "expired" });
expect(transport.sent.filter(entry => entry.payload.includes("default-expiry-question") && entry.payload.includes("\"status\":\"expired\"")).length).toBe(1);
});
it("replays one expired result when Runtime restarts after an unanswered interaction expires", async () => {
const storage = new MemoryStorage();
const conversationID = "user_1:2:channel_1";
const question = JSON.stringify({ v: 1, id: "restart-expiry-question", type: "lineup.v1.tool.call", conversation_id: conversationID, sender: { kind: "agent", id: "agent_1" }, payload: { call_id: "restart-expiry-question", tool: "confirm", title: "确认", prompt: "继续吗?", data: { confirm: {} } } });
const first = new LineUpRuntime({ storage, createTransport: () => new FakeTransport([[{ message_seq: 55, from_uid: "agent_1", payload: question }], []]), now: () => "2026-08-04T00:00:00.000Z" });
await first.login(login);
await first.syncOnce();
const transport = new FakeTransport();
const second = new LineUpRuntime({ storage, createTransport: () => transport, now: () => "2026-08-04T00:16:00.000Z" });
await second.login(login);
expect(second.standardInteractions()).toEqual([expect.objectContaining({ call_id: "restart-expiry-question", status: "expired" })]);
expect(second.toolCalls()).toEqual([expect.objectContaining({ call_id: "restart-expiry-question", status: "expired" })]);
await second.flushOutbox();
expect(transport.sent.filter(entry => entry.payload.includes("restart-expiry-question") && entry.payload.includes("\"status\":\"expired\"")).length).toBe(1);
});
it("delivers a revisioned ordinary Tool to bundled MiniApp SDK and keeps the App open after completion", async () => { it("delivers a revisioned ordinary Tool to bundled MiniApp SDK and keeps the App open after completion", async () => {
const conversationID = "user_1:2:channel_1"; const conversationID = "user_1:2:channel_1";
const call = JSON.stringify({ const call = JSON.stringify({
@@ -542,9 +542,25 @@ export class LineUpRuntime {
for (const interaction of this.interactionService.list()) { for (const interaction of this.interactionService.list()) {
if (interaction.kind === "input" && (interaction.status === "pending" || interaction.status === "presented")) this.store.clearToolDraft(interaction.call_id); if (interaction.kind === "input" && (interaction.status === "pending" || interaction.status === "presented")) this.store.clearToolDraft(interaction.call_id);
} }
this.kernel.restoreToolCalls(snapshot.tool_calls, this.now()); const restoredInteractions = this.interactionService.list();
const normalizedToolCalls = snapshot.tool_calls.map(call => {
const interaction = restoredInteractions.find(record => record.call_id === call.call_id);
return interaction?.expires_at && !call.request.expires_at
? { ...call, request: { ...call.request, expires_at: interaction.expires_at } }
: call;
});
this.kernel.restoreToolCalls(normalizedToolCalls, this.now());
this.kernel.restoreTasks(snapshot.tasks); this.kernel.restoreTasks(snapshot.tasks);
this.store.replaceToolCalls(this.kernel.snapshot().tool_calls); this.store.replaceToolCalls(this.kernel.snapshot().tool_calls);
const previouslyOpen = new Set(snapshot.standard_interactions
.filter(record => record.agent_id === this.session.agent_uid && record.conversation_id === this.requireConversationID() && (record.status === "pending" || record.status === "presented"))
.map(record => record.call_id));
this.store.replaceStandardInteractions(restoredInteractions);
for (const interaction of restoredInteractions) {
if (interaction.status !== "expired" || !previouslyOpen.has(interaction.call_id)) continue;
const alreadyQueued = this.store.snapshot().outbox.some(entry => entry.kind === "tool-result" && entry.payload.includes(`\"call_id\":\"${interaction.call_id}\"`) && entry.payload.includes("\"status\":\"expired\""));
if (!alreadyQueued) void this.queueProtocolAction("tool-result", "lineup.v1.tool.result", { call_id: interaction.call_id, status: "expired", result: {} });
}
this.store.replaceTasks(this.kernel.snapshot().tasks); this.store.replaceTasks(this.kernel.snapshot().tasks);
this.session.last_seq = snapshot.cursor; this.session.last_seq = snapshot.cursor;
if (!this.lifecycle.instances.activeFor("chat", this.requireConversationID())) { if (!this.lifecycle.instances.activeFor("chat", this.requireConversationID())) {
@@ -636,14 +652,22 @@ export class LineUpRuntime {
public submitToolCall(callID: string, result: JsonObject): boolean { public submitToolCall(callID: string, result: JsonObject): boolean {
const interaction = this.interactionService.list().find(record => record.call_id === callID && (record.status === "pending" || record.status === "presented")); const interaction = this.interactionService.list().find(record => record.call_id === callID && (record.status === "pending" || record.status === "presented"));
if (interaction) { if (interaction) {
const kernelTransition = this.kernel.submitToolCall(callID, this.now(), result); const before = this.interactionService.list();
if (kernelTransition.disposition !== "accepted") return false;
const transition = this.interactionService.submit(interaction.interaction_id, result, this.now()); const transition = this.interactionService.submit(interaction.interaction_id, result, this.now());
if (transition.disposition !== "accepted") return false; if (transition.disposition !== "accepted") return false;
const now = this.now();
const kernelTransition = transition.record.status === "expired"
? this.kernel.expireToolCall(callID, now)
: this.kernel.submitToolCall(callID, now, result);
if (kernelTransition.disposition !== "accepted") {
this.interactionService.restore(before, now);
return false;
}
this.store.replaceToolCalls(this.kernel.snapshot().tool_calls); this.store.replaceToolCalls(this.kernel.snapshot().tool_calls);
this.store.replaceStandardInteractions(this.interactionService.list()); this.store.replaceStandardInteractions(this.interactionService.list());
this.store.clearToolDraft(callID); this.store.clearToolDraft(callID);
void this.queueProtocolAction("tool-result", "lineup.v1.tool.result", { call_id: callID, status: "completed", result }); const status = transition.record.status === "expired" ? "expired" : "completed";
void this.queueProtocolAction("tool-result", "lineup.v1.tool.result", { call_id: callID, status, result: status === "expired" ? {} : result });
this.emitState(); this.emitState();
return true; return true;
} }
@@ -659,10 +683,14 @@ export class LineUpRuntime {
public cancelToolCall(callID: string, reason: string): boolean { public cancelToolCall(callID: string, reason: string): boolean {
const interaction = this.interactionService.list().find(record => record.call_id === callID && (record.status === "pending" || record.status === "presented")); const interaction = this.interactionService.list().find(record => record.call_id === callID && (record.status === "pending" || record.status === "presented"));
if (interaction) { if (interaction) {
const kernelTransition = this.kernel.cancelToolCall(callID, reason, this.now()); const before = this.interactionService.list();
if (kernelTransition.disposition !== "accepted") return false;
const transition = this.interactionService.dismiss({ call_id: callID, agent_id: this.session.agent_uid, conversation_id: this.requireConversationID() }, this.now()); const transition = this.interactionService.dismiss({ call_id: callID, agent_id: this.session.agent_uid, conversation_id: this.requireConversationID() }, this.now());
if (transition.disposition !== "accepted") return false; if (transition.disposition !== "accepted") return false;
const kernelTransition = this.kernel.cancelToolCall(callID, reason, this.now());
if (kernelTransition.disposition !== "accepted") {
this.interactionService.restore(before, this.now());
return false;
}
this.store.replaceToolCalls(this.kernel.snapshot().tool_calls); this.store.replaceToolCalls(this.kernel.snapshot().tool_calls);
this.store.replaceStandardInteractions(this.interactionService.list()); this.store.replaceStandardInteractions(this.interactionService.list());
this.store.clearToolDraft(callID); this.store.clearToolDraft(callID);
@@ -680,6 +708,24 @@ export class LineUpRuntime {
} }
public expireToolCall(callID: string): boolean { public expireToolCall(callID: string): boolean {
const interaction = this.interactionService.list().find(record => record.call_id === callID && (record.status === "pending" || record.status === "presented"));
if (interaction) {
const before = this.interactionService.list();
const now = this.now();
const interactionTransition = this.interactionService.expire(interaction.interaction_id, now);
if (interactionTransition.disposition !== "accepted") return false;
const kernelTransition = this.kernel.expireToolCall(callID, now);
if (kernelTransition.disposition !== "accepted") {
this.interactionService.restore(before, now);
return false;
}
this.store.replaceToolCalls(this.kernel.snapshot().tool_calls);
this.store.replaceStandardInteractions(this.interactionService.list());
this.store.clearToolDraft(callID);
void this.queueProtocolAction("tool-result", "lineup.v1.tool.result", { call_id: callID, status: "expired", result: {} });
this.emitState();
return true;
}
const transition = this.kernel.expireToolCall(callID, this.now()); const transition = this.kernel.expireToolCall(callID, this.now());
this.store.replaceToolCalls(this.kernel.snapshot().tool_calls); this.store.replaceToolCalls(this.kernel.snapshot().tool_calls);
this.emitState(); this.emitState();
@@ -925,12 +971,20 @@ export class LineUpRuntime {
} }
} }
if (route.disposition === "dismiss") { if (route.disposition === "dismiss") {
const before = this.interactionService.list();
const transition = this.interactionService.dismiss({ const transition = this.interactionService.dismiss({
call_id: route.call_id, call_id: route.call_id,
agent_id: this.session.agent_uid, agent_id: this.session.agent_uid,
conversation_id: this.requireConversationID(), conversation_id: this.requireConversationID(),
}, this.now()); }, this.now());
if (transition.disposition === "accepted") { if (transition.disposition === "accepted") {
const kernelTransition = this.kernel.cancelToolCall(route.call_id, route.reason ?? "agent_dismissed", this.now());
if (kernelTransition.disposition !== "accepted") {
this.interactionService.restore(before, this.now());
runtimeLog("interaction.dismiss_ignored", { call_id: route.call_id, control_id: route.control_id, disposition: kernelTransition.disposition });
return;
}
this.store.replaceToolCalls(this.kernel.snapshot().tool_calls);
this.store.replaceStandardInteractions(this.interactionService.list()); this.store.replaceStandardInteractions(this.interactionService.list());
void this.queueProtocolAction("tool-cancel", "lineup.v1.tool.cancel", { void this.queueProtocolAction("tool-cancel", "lineup.v1.tool.cancel", {
call_id: route.call_id, call_id: route.call_id,
@@ -1042,9 +1096,18 @@ export class LineUpRuntime {
}); });
if (created.disposition === "invalid_request") runtimeLog("interaction.rejected", { reason: "invalid_request" }); if (created.disposition === "invalid_request") runtimeLog("interaction.rejected", { reason: "invalid_request" });
if (created.disposition === "accepted") { if (created.disposition === "accepted") {
if (created.record.expires_at) {
const expiry = this.kernel.setToolCallExpiry(item.call.call_id, created.record.expires_at, receivedAt);
if (expiry.disposition === "accepted") this.store.replaceToolCalls(this.kernel.snapshot().tool_calls);
}
const presented = this.interactionService.present(created.record.interaction_id, receivedAt); const presented = this.interactionService.present(created.record.interaction_id, receivedAt);
this.store.replaceStandardInteractions(this.interactionService.list()); this.store.replaceStandardInteractions(this.interactionService.list());
if (presented.disposition === "accepted" && presented.record.status === "completed") { if (presented.disposition === "accepted" && presented.record.status === "completed") {
const submitted = this.kernel.submitToolCall(item.call.call_id, receivedAt, {});
const completed = submitted.disposition === "accepted"
? this.kernel.completeToolCall(item.call.call_id, "completed", {}, receivedAt)
: submitted;
if (completed.disposition === "accepted") this.store.replaceToolCalls(this.kernel.snapshot().tool_calls);
void this.queueProtocolAction("tool-result", "lineup.v1.tool.result", { call_id: item.call.call_id, status: "completed", result: {} }); void this.queueProtocolAction("tool-result", "lineup.v1.tool.result", { call_id: item.call.call_id, status: "completed", result: {} });
} }
} }
@@ -49,6 +49,24 @@ export type InteractionRequestValidation =
| Readonly<{ accepted: true; expires_at?: string }> | Readonly<{ accepted: true; expires_at?: string }>
| Readonly<{ accepted: false; code: "interaction_request_invalid" }>; | Readonly<{ accepted: false; code: "interaction_request_invalid" }>;
/** Validates the only answer shape that Runtime accepts for a standard interaction. */
export function validateStandardInteractionAnswer(request: StandardInteractionRequest, answer: unknown): answer is Readonly<Record<string, unknown>> {
if (!isObject(answer)) return false;
const keys = Object.keys(answer);
if (request.kind === "choice") {
if (keys.length !== 1 || keys[0] !== "action_id" || typeof answer.action_id !== "string") return false;
return request.actions.some(action => action.id === answer.action_id);
}
if (request.kind === "confirm") return keys.length === 1 && keys[0] === "approved" && typeof answer.approved === "boolean";
if (request.kind === "input") {
if (keys.length !== 1 || keys[0] !== "text" || typeof answer.text !== "string") return false;
const text = answer.text;
if (request.field.required && text.trim().length === 0) return false;
return text.length <= (request.field.max_length ?? 1000);
}
return false;
}
/** Validates a request before it becomes a durable StandardInteractionRecord. */ /** Validates a request before it becomes a durable StandardInteractionRecord. */
export function validateStandardInteractionRequest(request: StandardInteractionRequest, now: Date): InteractionRequestValidation { export function validateStandardInteractionRequest(request: StandardInteractionRequest, now: Date): InteractionRequestValidation {
if (!validTitle(request.title)) return invalid(); if (!validTitle(request.title)) return invalid();
@@ -83,3 +101,8 @@ function validTitle(value: string): boolean { return validText(value) && value.l
function validText(value: string): boolean { return typeof value === "string" && value.trim().length > 0 && value.length <= 4000; } function validText(value: string): boolean { return typeof value === "string" && value.trim().length > 0 && value.length <= 4000; }
function validIdentifier(value: string): boolean { return typeof value === "string" && /^[A-Za-z0-9._:-]{1,128}$/.test(value); } function validIdentifier(value: string): boolean { return typeof value === "string" && /^[A-Za-z0-9._:-]{1,128}$/.test(value); }
function invalid(): InteractionRequestValidation { return { accepted: false, code: "interaction_request_invalid" }; } function invalid(): InteractionRequestValidation { return { accepted: false, code: "interaction_request_invalid" }; }
function isObject(value: unknown): value is Record<string, unknown> {
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
@@ -96,6 +96,15 @@ export class ToolCallStateMachine {
return { disposition: "accepted", record: copy(record) }; return { disposition: "accepted", record: copy(record) };
} }
public setExpiry(callID: string, expiresAt: string, updatedAt: string): ToolCallTransition {
const record = this.calls.get(callID);
if (!record || record.status !== "pending" || !Number.isFinite(Date.parse(expiresAt))) return { disposition: record ? "invalid_transition" : "missing", ...(record ? { record: copy(record) } : {}) };
if (record.request.expires_at && record.request.expires_at !== expiresAt) return { disposition: "invalid_transition", record: copy(record) };
record.request = { ...record.request, expires_at: expiresAt };
record.updated_at = updatedAt;
return { disposition: "accepted", record: copy(record) };
}
public expire(now: string): readonly ToolCallRecord[] { public expire(now: string): readonly ToolCallRecord[] {
const changed: ToolCallRecord[] = []; const changed: ToolCallRecord[] = [];
const nowTime = Date.parse(now); const nowTime = Date.parse(now);
@@ -1,7 +1,7 @@
# LineUp App 迭代定义:MiniApp SDK v1 与内置参考 MiniApp # LineUp App 迭代定义:MiniApp SDK v1 与内置参考 MiniApp
**迭代编号:** 03.sdk_and_coreapp **迭代编号:** 03.sdk_and_coreapp
**状态:** 已完成 **状态:** 实现中,验收未通过(P2/P3 问题尚未清空)
**日期:** 2026-08-05 **日期:** 2026-08-05
**前置基线:** [00.base.md](../00.base/00.base.md)、[01.kernel.md](../01.kernel/01.kernel.md) **前置基线:** [00.base.md](../00.base/00.base.md)、[01.kernel.md](../01.kernel/01.kernel.md)
**权威架构:** [APP架构设计.md](../../APP架构设计.md) **权威架构:** [APP架构设计.md](../../APP架构设计.md)
@@ -21,17 +21,17 @@
| ✅ | P1 | A3 | SDK 暴露完整 workspaceMiniApp 可看到其他 App 实例和全局焦点栈 | `lineup-runtime.ts``miniapp-sdk.ts` | 已从 `LineUpMiniAppSDK` 删除 `workspace()`,不再把全局实例和焦点栈交给 bundled MiniApp。 | | ✅ | P1 | A3 | SDK 暴露完整 workspaceMiniApp 可看到其他 App 实例和全局焦点栈 | `lineup-runtime.ts``miniapp-sdk.ts` | 已从 `LineUpMiniAppSDK` 删除 `workspace()`,不再把全局实例和焦点栈交给 bundled MiniApp。 |
| ✅ | P1 | A4 | 同一个 App 的多个 instance 之间 Inbox 订阅没有隔离 | `lineup-runtime.ts` | 已按 `app_scope + conversation_id + instance_id` 过滤实时订阅,并补充双实例回归测试。 | | ✅ | P1 | A4 | 同一个 App 的多个 instance 之间 Inbox 订阅没有隔离 | `lineup-runtime.ts` | 已按 `app_scope + conversation_id + instance_id` 过滤实时订阅,并补充双实例回归测试。 |
| ✅ | P1 | A5 | Interact 使用专用 `ChatRuntimeSDK` 旁路,没有和其他 MiniApp 共用 SDK v1 语义 | `tauri/src/runtime/app-management/app-sdk.ts``lineup-runtime.ts``main.ts` | 已明确为“通用 Runtime 操作 + 可信 UI 投影”:Interact 保留可信 DOM 和 IM 展示,但行为统一走 `sdk.runtime.*`,展示走 `sdk.ui.*`;旧扁平方法仅作兼容别名。 | | ✅ | P1 | A5 | Interact 使用专用 `ChatRuntimeSDK` 旁路,没有和其他 MiniApp 共用 SDK v1 语义 | `tauri/src/runtime/app-management/app-sdk.ts``lineup-runtime.ts``main.ts` | 已明确为“通用 Runtime 操作 + 可信 UI 投影”:Interact 保留可信 DOM 和 IM 展示,但行为统一走 `sdk.runtime.*`,展示走 `sdk.ui.*`;旧扁平方法仅作兼容别名。 |
| 🔴 | P1 | A6 | 标准交互的超时、dismiss、提交和答案 schema 没有由 Runtime 统一裁决 | `lineup-runtime.ts:507-558, 798-815``agent-interaction-service.ts:93-101` | 必须让 Interaction 与 Kernel Tool 共用一个终态迁移;超时/dismiss 要写唯一 Agent outbox;提交前按 `notice/choice/confirm/input` 校验答案,拒绝重复和竞态。 | | | P1 | A6 | 标准交互的超时、dismiss、提交和答案 schema 没有由 Runtime 统一裁决 | `lineup-runtime.ts``agent-interaction-service.ts``standard-interaction-contract.ts` | 已让本地提交、取消、过期和 Agent dismiss 同步推进 Interaction 与 Kernelnotice 也会完成 Kernel;四种答案在 Runtime 按固定 schema 校验,默认有效期和重启过期均只写一条 outbox。 |
| 🔴 | P2 | A7 | 迭代文档中的 Tool 名称和代码 Manifest 不一致 | 主文档 §5.2/§5.3;`reference-miniapps.ts` | 统一契约、Manifest、fixture、Agent Inventory 和验收脚本中的名称。 | | 🔴 | P2 | A7 | 迭代文档中的 Tool 名称和代码 Manifest 不一致 | 主文档 §5.2/§5.3;`reference-miniapps.ts` | 统一契约、Manifest、fixture、Agent Inventory 和验收脚本中的名称。 |
| 🔴 | P2 | A8 | 当前 Task Dashboard/Whiteboard Surface 主要是 Host 硬编码的静态页面,无法证明真实业务 UI 在各自受限 Surface 内运行 | `main.ts:180-198``isolated-surface-host.ts` | 至少提供能代表业务边界的受限 Surface fixture,并验证 MiniApp 业务逻辑不能取得 Host DOM 或 Runtime 内部。 | | 🔴 | P2 | A8 | 当前 Task Dashboard/Whiteboard Surface 主要是 Host 硬编码的静态页面,无法证明真实业务 UI 在各自受限 Surface 内运行 | `main.ts:180-198``isolated-surface-host.ts` | 至少提供能代表业务边界的受限 Surface fixture,并验证 MiniApp 业务逻辑不能取得 Host DOM 或 Runtime 内部。 |
| 🔴 | P2 | A9 | MiniApp progress 没有进入可恢复的 Tool 状态记录 | `miniapp-tool-state.ts` | 如果契约要求进度可恢复,需持久化最后进度并覆盖 Runtime 重启;否则修改文档,明确 progress 只是一种可丢失事件。 | | 🔴 | P2 | A9 | MiniApp progress 没有进入可恢复的 Tool 状态记录 | `miniapp-tool-state.ts` | 如果契约要求进度可恢复,需持久化最后进度并覆盖 Runtime 重启;否则修改文档,明确 progress 只是一种可丢失事件。 |
| | P3 | A10 | App session 记录和 opened/closed 事件没有显式保存 `agent_id` / `conversation_id` 字段 | `app-instance-manager.ts``lineup-runtime.ts:203-209,1052-1058` | Envelope 有部分上下文,但应与文档最低字段要求统一,避免未来多 Agent 或跨会话时产生歧义。 | | 🔴 | P3 | A10 | App session 记录和 opened/closed 事件没有显式保存 `agent_id` / `conversation_id` 字段 | `app-instance-manager.ts``lineup-runtime.ts:203-209,1052-1058` | P3 也必须在本迭代处理:App session opened/closed 事件和持久化记录补齐两个上下文字段。 |
| | P3 | A11 | 旧的 `openExtensionApp` 公开入口仍提供较宽的旁路能力 | `lineup-runtime.ts:286-300` | 清理旧兼容入口,或明确它只为历史测试保留并加上 instance/call 状态约束。 | | 🔴 | P3 | A11 | 旧的 `openExtensionApp` 公开入口仍提供较宽的旁路能力 | `lineup-runtime.ts:286-300` | P3 也必须在本迭代处理:删除公开旁路,或让它只返回受统一 SDK v1 约束的适配器。 |
## 1. 已验证通过的部分 ## 1. 已验证通过的部分
- `npm run build` 通过。 - `npm run build` 通过。
- `npm test -- --run` 通过:29 个测试文件、127 个测试。 - `npm test -- --run` 通过:29 个测试文件、129 个测试(含默认有效期、重启过期和答案 schema 回归)
- `git diff --check` 通过。 - `git diff --check` 通过。
- 使用独立 `agent-browser` 会话登录本地测试账号成功。 - 使用独立 `agent-browser` 会话登录本地测试账号成功。
- 主 IM 页面、同步状态、应用子会话区域和“启用任务面板”入口可见。 - 主 IM 页面、同步状态、应用子会话区域和“启用任务面板”入口可见。
@@ -75,28 +75,30 @@ Interact 仍然可以使用可信 DOM,但它的 SDK 已明确拆成两层:`s
投影没有 Transport、Store、Agent 原始 Envelope 或 Host 特权。旧的扁平 `ChatRuntimeSDK` 方法只保留为 投影没有 Transport、Store、Agent 原始 Envelope 或 Host 特权。旧的扁平 `ChatRuntimeSDK` 方法只保留为
兼容别名,避免把 Interact 的 UI 适配误解成另一套协议。 兼容别名,避免把 Interact 的 UI 适配误解成另一套协议。
### A6:标准交互有两套状态,可能互相打架 ### A6:标准交互有两套状态,可能互相打架(已修复)
- `expireToolCall()` 只修改 Kernel Tool 状态,没有调用 `AgentInteractionService.expire()`,也没有发送 expired outbox。 已统一处理以下路径:
- Agent dismiss 修改了 Interaction,但没有同步修改 Kernel Tool 状态。
- submit 先改 Kernel,再改 Interaction;如果第二步失败,会留下两套状态不一致 - 本地提交、取消、过期和 Agent dismiss 都同时更新 Interaction 与 Kernel Tool;任一侧不能迁移时会恢复另一侧的旧快照
- 提交结果是任意对象,没有按四种交互类型校验;例如 choice 的 UI 可以提交多选数组,而冻结契约要求的是单选结果 - notice 在呈现完成时也会同步完成 Kernel Tool,避免重启后出现 Interaction 已完成而 Tool 仍 pending
- `choice` 只接受 `{ action_id }`,且 action 必须来自请求;`confirm` 只接受 `{ approved: boolean }``input` 只接受 `{ text: string }`,并校验必填和长度。
- 终态只创建一次对应的 Tool result/cancel outbox;重复提交、重复 dismiss 和已过期提交不会再次写出站消息。
这会导致 UI、持久化记录和 Agent 看到的最终结果不一致,属于当前迭代必须修复的正确性问题。 这会导致 UI、持久化记录和 Agent 看到的最终结果不一致,属于当前迭代必须修复的正确性问题。
## 3. 验收结论 ## 3. 验收结论
当前结论为(完成本轮 P1-1P1-3 后): 当前结论为(完成本轮 P1-1P1-4 后):
```text ```text
功能回归:通过 功能回归:通过
基础构建与自动化测试:通过 基础构建与自动化测试:通过
真实登录和消息发送:通过 真实登录和消息发送:通过
App 架构边界:部分通过(A1/A2/A5 已实现,A2 待浏览器复验A6 仍不通过 App 架构边界:部分通过(A1/A2/A5/A6 已实现,A2 待浏览器复验)
MiniApp 隔离与 SDK 规范:A1A5 已通过,标准交互状态机仍不通过 MiniApp 隔离与 SDK 规范:A1A6 已通过,仍需处理后续 P2/P3
标准交互终态契约:通过 标准交互终态契约:通过
迭代整体验收:不通过,继续处理 A6A2 需在最终浏览器复验中确认 迭代整体验收:不通过,继续处理 A7A11A2 需在最终浏览器复验中确认
``` ```
次验收没有修改实现代码。下一步应先处理 P1重新运行自动化测试和真实浏览器验收;P2/P3 不能被静默 轮独立验收由子 agent 完成。A6 已在本轮修复并通过自动化验证;下一轮继续处理 A7~A11重新执行
删除,至少要在本迭代结束前有明确的修复或延期决定 独立验收。P2/P3 不能被静默删除,必须在本迭代结束前解决或保留明确的延期记录