diff --git a/tauri/src/core-apps/chat/trusted-dom-renderers.ts b/tauri/src/core-apps/chat/trusted-dom-renderers.ts index 2908e76..9508215 100644 --- a/tauri/src/core-apps/chat/trusted-dom-renderers.ts +++ b/tauri/src/core-apps/chat/trusted-dom-renderers.ts @@ -442,7 +442,7 @@ export class TrustedDOMRendererContext { const buttons: HTMLButtonElement[] = []; const selected = new Set(); 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); }); diff --git a/tauri/src/runtime/app-management/miniapp-sdk-golden-contract.test.ts b/tauri/src/runtime/app-management/miniapp-sdk-golden-contract.test.ts index cad85ea..b293222 100644 --- a/tauri/src/runtime/app-management/miniapp-sdk-golden-contract.test.ts +++ b/tauri/src/runtime/app-management/miniapp-sdk-golden-contract.test.ts @@ -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); + }); }); diff --git a/tauri/src/runtime/coordination/agent-interaction-service.ts b/tauri/src/runtime/coordination/agent-interaction-service.ts index 8384582..e365e68 100644 --- a/tauri/src/runtime/coordination/agent-interaction-service.ts +++ b/tauri/src/runtime/coordination/agent-interaction-service.ts @@ -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); diff --git a/tauri/src/runtime/coordination/interaction-kernel.ts b/tauri/src/runtime/coordination/interaction-kernel.ts index f029c96..61ec037 100644 --- a/tauri/src/runtime/coordination/interaction-kernel.ts +++ b/tauri/src/runtime/coordination/interaction-kernel.ts @@ -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); } diff --git a/tauri/src/runtime/coordination/lineup-runtime.test.ts b/tauri/src/runtime/coordination/lineup-runtime.test.ts index e9257e7..6e2086c 100644 --- a/tauri/src/runtime/coordination/lineup-runtime.test.ts +++ b/tauri/src/runtime/coordination/lineup-runtime.test.ts @@ -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({ diff --git a/tauri/src/runtime/coordination/lineup-runtime.ts b/tauri/src/runtime/coordination/lineup-runtime.ts index 543b2ad..4388351 100644 --- a/tauri/src/runtime/coordination/lineup-runtime.ts +++ b/tauri/src/runtime/coordination/lineup-runtime.ts @@ -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: {} }); } } diff --git a/tauri/src/runtime/coordination/standard-interaction-contract.ts b/tauri/src/runtime/coordination/standard-interaction-contract.ts index 3b627e0..bd56317 100644 --- a/tauri/src/runtime/coordination/standard-interaction-contract.ts +++ b/tauri/src/runtime/coordination/standard-interaction-contract.ts @@ -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> { + 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 { + if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} diff --git a/tauri/src/runtime/coordination/tool-call-state.ts b/tauri/src/runtime/coordination/tool-call-state.ts index b11b6d2..e27cd96 100644 --- a/tauri/src/runtime/coordination/tool-call-state.ts +++ b/tauri/src/runtime/coordination/tool-call-state.ts @@ -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); diff --git a/迭代/03.sdk_and_coreapp/03.sdk_and_coreapp.md b/迭代/03.sdk_and_coreapp/03.sdk_and_coreapp.md index b597305..6c08160 100644 --- a/迭代/03.sdk_and_coreapp/03.sdk_and_coreapp.md +++ b/迭代/03.sdk_and_coreapp/03.sdk_and_coreapp.md @@ -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) diff --git a/迭代/03.sdk_and_coreapp/04.acceptance_review.md b/迭代/03.sdk_and_coreapp/04.acceptance_review.md index 53a2979..45406b3 100644 --- a/迭代/03.sdk_and_coreapp/04.acceptance_review.md +++ b/迭代/03.sdk_and_coreapp/04.acceptance_review.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 不能被静默删除,必须在本迭代结束前解决或保留明确的延期记录。