fix(runtime): unify standard interaction terminal states
This commit is contained in:
@@ -442,7 +442,7 @@ export class TrustedDOMRendererContext {
|
||||
const buttons: HTMLButtonElement[] = [];
|
||||
const selected = new Set<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) {
|
||||
status.textContent = "该交互已结束或已提交";
|
||||
return;
|
||||
@@ -519,7 +519,7 @@ export class TrustedDOMRendererContext {
|
||||
const locked = this.applyCallStatus(card, status, call, record);
|
||||
for (const button of buttons) button.disabled = locked;
|
||||
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);
|
||||
});
|
||||
cancel.addEventListener("click", () => {
|
||||
@@ -582,7 +582,8 @@ export class TrustedDOMRendererContext {
|
||||
const values = this.readFormValues(fields);
|
||||
const problem = validateFormValues(form, values);
|
||||
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 = "";
|
||||
this.markSubmitted(card, status, controls);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import fixture from "@/runtime/app-management/golden/miniapp-sdk-v1.json";
|
||||
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<{
|
||||
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: "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 {
|
||||
validateStandardInteractionRequest,
|
||||
validateStandardInteractionAnswer,
|
||||
type StandardInteractionRequest,
|
||||
} from "@/runtime/coordination/standard-interaction-contract";
|
||||
|
||||
@@ -95,6 +96,7 @@ export class AgentInteractionService {
|
||||
if (!record) return { disposition: "missing" };
|
||||
if (record.status !== "pending" && record.status !== "presented") return { disposition: "invalid_transition", record: copy(record) };
|
||||
if (expired(record, now)) return this.expire(interactionID, now);
|
||||
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 completed: StandardInteractionRecord = { ...submitted, status: "completed", updated_at: now };
|
||||
this.records.set(interactionID, completed);
|
||||
|
||||
@@ -96,6 +96,10 @@ export class InteractionKernel {
|
||||
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 {
|
||||
this.toolCalls.restore(records, now);
|
||||
}
|
||||
|
||||
@@ -172,9 +172,9 @@ describe("LineUpRuntime", () => {
|
||||
const chat = second.openChatApp();
|
||||
await second.login(login);
|
||||
expect(second.standardInteractions()).toEqual([expect.objectContaining({ call_id: "interactive-restart-1", status: "presented" })]);
|
||||
expect(chat.interactions.submit("interactive-restart-1", { confirmed: true })).toBe(true);
|
||||
expect(chat.interactions.submit("interactive-restart-1", { approved: true })).toBe(true);
|
||||
await second.flushOutbox();
|
||||
expect(second.standardInteractions()).toEqual([expect.objectContaining({ call_id: "interactive-restart-1", status: "completed", result: { outcome: "answered", answer: { confirmed: true } } })]);
|
||||
expect(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);
|
||||
});
|
||||
|
||||
@@ -212,9 +212,60 @@ describe("LineUpRuntime", () => {
|
||||
await runtime.syncOnce();
|
||||
await runtime.flushOutbox();
|
||||
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);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const conversationID = "user_1:2:channel_1";
|
||||
const call = JSON.stringify({
|
||||
|
||||
@@ -542,9 +542,25 @@ export class LineUpRuntime {
|
||||
for (const interaction of this.interactionService.list()) {
|
||||
if (interaction.kind === "input" && (interaction.status === "pending" || interaction.status === "presented")) this.store.clearToolDraft(interaction.call_id);
|
||||
}
|
||||
this.kernel.restoreToolCalls(snapshot.tool_calls, this.now());
|
||||
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.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.session.last_seq = snapshot.cursor;
|
||||
if (!this.lifecycle.instances.activeFor("chat", this.requireConversationID())) {
|
||||
@@ -636,14 +652,22 @@ export class LineUpRuntime {
|
||||
public submitToolCall(callID: string, result: JsonObject): boolean {
|
||||
const interaction = this.interactionService.list().find(record => record.call_id === callID && (record.status === "pending" || record.status === "presented"));
|
||||
if (interaction) {
|
||||
const kernelTransition = this.kernel.submitToolCall(callID, this.now(), result);
|
||||
if (kernelTransition.disposition !== "accepted") return false;
|
||||
const before = this.interactionService.list();
|
||||
const transition = this.interactionService.submit(interaction.interaction_id, result, this.now());
|
||||
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.replaceStandardInteractions(this.interactionService.list());
|
||||
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();
|
||||
return true;
|
||||
}
|
||||
@@ -659,10 +683,14 @@ export class LineUpRuntime {
|
||||
public cancelToolCall(callID: string, reason: string): boolean {
|
||||
const interaction = this.interactionService.list().find(record => record.call_id === callID && (record.status === "pending" || record.status === "presented"));
|
||||
if (interaction) {
|
||||
const kernelTransition = this.kernel.cancelToolCall(callID, reason, this.now());
|
||||
if (kernelTransition.disposition !== "accepted") return false;
|
||||
const before = this.interactionService.list();
|
||||
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;
|
||||
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.replaceStandardInteractions(this.interactionService.list());
|
||||
this.store.clearToolDraft(callID);
|
||||
@@ -680,6 +708,24 @@ export class LineUpRuntime {
|
||||
}
|
||||
|
||||
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());
|
||||
this.store.replaceToolCalls(this.kernel.snapshot().tool_calls);
|
||||
this.emitState();
|
||||
@@ -925,12 +971,20 @@ export class LineUpRuntime {
|
||||
}
|
||||
}
|
||||
if (route.disposition === "dismiss") {
|
||||
const before = this.interactionService.list();
|
||||
const transition = this.interactionService.dismiss({
|
||||
call_id: route.call_id,
|
||||
agent_id: this.session.agent_uid,
|
||||
conversation_id: this.requireConversationID(),
|
||||
}, this.now());
|
||||
if (transition.disposition === "accepted") {
|
||||
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());
|
||||
void this.queueProtocolAction("tool-cancel", "lineup.v1.tool.cancel", {
|
||||
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 === "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);
|
||||
this.store.replaceStandardInteractions(this.interactionService.list());
|
||||
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: {} });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,24 @@ export type InteractionRequestValidation =
|
||||
| Readonly<{ accepted: true; expires_at?: string }>
|
||||
| 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. */
|
||||
export function validateStandardInteractionRequest(request: StandardInteractionRequest, now: Date): InteractionRequestValidation {
|
||||
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 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 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) };
|
||||
}
|
||||
|
||||
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[] {
|
||||
const changed: ToolCallRecord[] = [];
|
||||
const nowTime = Date.parse(now);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# LineUp App 迭代定义:MiniApp SDK v1 与内置参考 MiniApp
|
||||
|
||||
**迭代编号:** 03.sdk_and_coreapp
|
||||
**状态:** 已完成
|
||||
**状态:** 实现中,验收未通过(P2/P3 问题尚未清空)
|
||||
**日期:** 2026-08-05
|
||||
**前置基线:** [00.base.md](../00.base/00.base.md)、[01.kernel.md](../01.kernel/01.kernel.md)
|
||||
**权威架构:** [APP架构设计.md](../../APP架构设计.md)
|
||||
|
||||
@@ -21,17 +21,17 @@
|
||||
| ✅ | P1 | A3 | SDK 暴露完整 workspace,MiniApp 可看到其他 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 | 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 与 Kernel;notice 也会完成 Kernel;四种答案在 Runtime 按固定 schema 校验,默认有效期和重启过期均只写一条 outbox。 |
|
||||
| 🔴 | 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 | 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 | A11 | 旧的 `openExtensionApp` 公开入口仍提供较宽的旁路能力 | `lineup-runtime.ts:286-300` | 清理旧兼容入口,或明确它只为历史测试保留并加上 instance/call 状态约束。 |
|
||||
| 🔴 | 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` | P3 也必须在本迭代处理:删除公开旁路,或让它只返回受统一 SDK v1 约束的适配器。 |
|
||||
|
||||
## 1. 已验证通过的部分
|
||||
|
||||
- `npm run build` 通过。
|
||||
- `npm test -- --run` 通过:29 个测试文件、127 个测试。
|
||||
- `npm test -- --run` 通过:29 个测试文件、129 个测试(含默认有效期、重启过期和答案 schema 回归)。
|
||||
- `git diff --check` 通过。
|
||||
- 使用独立 `agent-browser` 会话登录本地测试账号成功。
|
||||
- 主 IM 页面、同步状态、应用子会话区域和“启用任务面板”入口可见。
|
||||
@@ -75,28 +75,30 @@ Interact 仍然可以使用可信 DOM,但它的 SDK 已明确拆成两层:`s
|
||||
投影没有 Transport、Store、Agent 原始 Envelope 或 Host 特权。旧的扁平 `ChatRuntimeSDK` 方法只保留为
|
||||
兼容别名,避免把 Interact 的 UI 适配误解成另一套协议。
|
||||
|
||||
### A6:标准交互有两套状态,可能互相打架
|
||||
### A6:标准交互有两套状态,可能互相打架(已修复)
|
||||
|
||||
- `expireToolCall()` 只修改 Kernel Tool 状态,没有调用 `AgentInteractionService.expire()`,也没有发送 expired outbox。
|
||||
- Agent dismiss 修改了 Interaction,但没有同步修改 Kernel Tool 状态。
|
||||
- submit 先改 Kernel,再改 Interaction;如果第二步失败,会留下两套状态不一致。
|
||||
- 提交结果是任意对象,没有按四种交互类型校验;例如 choice 的 UI 可以提交多选数组,而冻结契约要求的是单选结果。
|
||||
已统一处理以下路径:
|
||||
|
||||
- 本地提交、取消、过期和 Agent dismiss 都同时更新 Interaction 与 Kernel Tool;任一侧不能迁移时会恢复另一侧的旧快照。
|
||||
- notice 在呈现完成时也会同步完成 Kernel Tool,避免重启后出现 Interaction 已完成而 Tool 仍 pending。
|
||||
- `choice` 只接受 `{ action_id }`,且 action 必须来自请求;`confirm` 只接受 `{ approved: boolean }`;`input` 只接受 `{ text: string }`,并校验必填和长度。
|
||||
- 终态只创建一次对应的 Tool result/cancel outbox;重复提交、重复 dismiss 和已过期提交不会再次写出站消息。
|
||||
|
||||
这会导致 UI、持久化记录和 Agent 看到的最终结果不一致,属于当前迭代必须修复的正确性问题。
|
||||
|
||||
## 3. 验收结论
|
||||
|
||||
当前结论为(完成本轮 P1-1~P1-3 后):
|
||||
当前结论为(完成本轮 P1-1~P1-4 后):
|
||||
|
||||
```text
|
||||
功能回归:通过
|
||||
基础构建与自动化测试:通过
|
||||
真实登录和消息发送:通过
|
||||
App 架构边界:部分通过(A1/A2/A5 已实现,A2 待浏览器复验;A6 仍不通过)
|
||||
MiniApp 隔离与 SDK 规范:A1~A5 已通过,标准交互状态机仍不通过
|
||||
标准交互终态契约:不通过
|
||||
迭代整体验收:不通过,继续处理 A6;A2 需在最终浏览器复验中确认
|
||||
App 架构边界:部分通过(A1/A2/A5/A6 已实现,A2 待浏览器复验)
|
||||
MiniApp 隔离与 SDK 规范:A1~A6 已通过,仍需处理后续 P2/P3
|
||||
标准交互终态契约:通过
|
||||
迭代整体验收:不通过,继续处理 A7~A11;A2 需在最终浏览器复验中确认
|
||||
```
|
||||
|
||||
本次验收没有修改实现代码。下一步应先处理 P1,再重新运行自动化测试和真实浏览器验收;P2/P3 不能被静默
|
||||
删除,至少要在本迭代结束前有明确的修复或延期决定。
|
||||
本轮独立验收由子 agent 完成。A6 已在本轮修复并通过自动化验证;下一轮继续处理 A7~A11,并重新执行
|
||||
独立验收。P2/P3 不能被静默删除,必须在本迭代结束前解决或保留明确的延期记录。
|
||||
|
||||
Reference in New Issue
Block a user