From 75ce39131286fc25fc0004b60567d7db35404af6 Mon Sep 17 00:00:00 2001 From: Miayui <774713819@qq.com> Date: Wed, 16 Sep 2026 16:14:10 +0800 Subject: [PATCH] feat(n7web): refine spatial UI and NFC-gated extension routing --- apps/web-console/assistant-chat.js | 22 ++ apps/web-console/console-v2.js | 100 ++++++-- apps/web-console/self-extension.js | 9 +- apps/web-console/service-worker.js | 2 +- apps/web-console/src/styles/design-system.css | 114 +++++++++ apps/web-console/vue-ui.js | 5 + .../application/self_extension.py | 239 +++++++++++++----- src/cognitive_agent/server.py | 232 +++++++++++++++-- tests/e2e/test_seat_web_api.py | 92 ++++++- .../unit/test_assistant_frontend_contract.py | 28 +- .../test_energy_monitor_frontend_contract.py | 2 +- tests/unit/test_frontend_theme_contract.py | 18 +- tests/unit/test_self_extension.py | 78 ++++++ .../test_spatial_ui_consistency_contract.py | 16 ++ tests/unit/test_two_stage_can_parsing.py | 72 ++++++ tests/unit/test_vue_frontend_contract.py | 2 +- 16 files changed, 925 insertions(+), 106 deletions(-) diff --git a/apps/web-console/assistant-chat.js b/apps/web-console/assistant-chat.js index 163cfb8..f2faf1a 100644 --- a/apps/web-console/assistant-chat.js +++ b/apps/web-console/assistant-chat.js @@ -722,6 +722,19 @@ return "binary"; } + function focusLatestDispatchedControls(container) { + requestAnimationFrame(() => { + const transcript = $("#assistantMessages"); + const activeCard = container?.matches?.(".assistant-dispatched-controls") + ? container + : $(".assistant-dispatched-controls", container); + if (!transcript || !activeCard) return; + transcript.querySelectorAll(".assistant-dispatched-controls").forEach((card) => { + card.open = card === activeCard; + }); + }); + } + function appendDispatchedControls(message, result = {}) { if (!message?.body || !result || typeof result !== "object") return; const previous = message.dispatchedControlResult || {}; @@ -766,6 +779,7 @@ if (vueRenderer(host, viewCommands)) { host.dataset.commandSignature = signature; message.article?.classList.add("assistant-message-with-dispatched-controls"); + focusLatestDispatchedControls(host); followLatest(true); return; } @@ -777,6 +791,13 @@ card = document.createElement("details"); card.className = "assistant-dispatched-controls"; card.open = true; + card.addEventListener("toggle", () => { + if (!card.open) return; + const transcript = $("#assistantMessages"); + transcript?.querySelectorAll(".assistant-dispatched-controls[open]").forEach((otherCard) => { + if (otherCard !== card) otherCard.open = false; + }); + }); const summary = document.createElement("summary"); const headingIcon = document.createElement("span"); headingIcon.className = "assistant-dispatched-controls-heading-icon"; @@ -828,6 +849,7 @@ list.append(row); }); message.article?.classList.add("assistant-message-with-dispatched-controls"); + focusLatestDispatchedControls(card); window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } }); followLatest(true); } diff --git a/apps/web-console/console-v2.js b/apps/web-console/console-v2.js index ef69b15..38a5838 100644 --- a/apps/web-console/console-v2.js +++ b/apps/web-console/console-v2.js @@ -568,7 +568,11 @@ const verificationStatus = String(telemetry.verification_status || "").toLowerCase(); const failureMessage = String(execution?.message || "").trim(); const aborted = abortedStates.has(executionStatus); + const explicitlyBlocked = executionStatus === "blocked" + || verificationStatus === "blocked" + || Boolean(telemetry.authorization_reason); const executionFailed = executionStatus === "failed" + || explicitlyBlocked || verificationStatus === "failed_or_blocked"; const missingWindowFeedback = /0x388/i.test(failureMessage) && ( /no fresh valid/i.test(failureMessage) @@ -579,7 +583,8 @@ || /未收到.*(?:有效|新鲜).*遮阳帘.*位置/.test(failureMessage) ); const safetyBlocked = executionFailed && !submitted && ( - missingWindowFeedback + explicitlyBlocked + || missingWindowFeedback || missingShadeFeedback || /refusing (?:blind )?motion/i.test(failureMessage) || /拒绝(?:启动机构|盲动|执行)/.test(failureMessage) @@ -1100,9 +1105,9 @@ sidebarToggle.setAttribute("aria-expanded", "false"); sidebarToggle.title = "展开左侧控制台"; - const footer = make("footer", "v2-sidebar-footer", ` + const footer = make("footer", "v2-sidebar-footer v2-control-link-card", ` -
智舱控制链路正在连接服务
+
智舱控制链路正在连接服务
`); commandPanel.append(sidebarToggle, footer); @@ -1675,6 +1680,11 @@ if (!list) return; const filter = $("#v2LogFilter")?.value || "all"; const items = displaySync.logs.filter((item) => filter === "all" || item.category === filter); + const count = $("#v2SyncLogCount"); + if (count) { + count.textContent = String(items.length); + count.title = `${items.length} 条记录`; + } const icons = { task: "route", sync: "refresh-cw", extension: "usb", device: "activity" }; list.innerHTML = items.length ? items.map((item) => `
  • @@ -1685,6 +1695,21 @@ window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } }); } + function setSyncLogCollapsed(collapsed) { + const panel = $(".v2-sync-log"); + const list = $("#v2SyncLogList"); + const toggle = $("#v2SyncLogToggle"); + if (!panel || !list || !toggle) return; + const nextCollapsed = Boolean(collapsed); + const label = nextCollapsed ? "展开运行日志与同步记录" : "折叠运行日志与同步记录"; + panel.dataset.collapsed = String(nextCollapsed); + list.hidden = nextCollapsed; + toggle.setAttribute("aria-expanded", String(!nextCollapsed)); + toggle.setAttribute("aria-label", label); + toggle.title = label; + requestAnimationFrame(syncSidebarLogBaseline); + } + function panelVisible(key) { const panel = key === "devices" ? $("#v2DevicePanel") : $("#v2ExtensionPanel"); if (panel) return !panel.classList.contains("is-collapsed"); @@ -2439,9 +2464,15 @@ const sidebarFooter = $(".v2-sidebar-footer", sidebar); const syncSidebar = make("section", "v2-sync-sidebar", `
    -
    -

    运行日志 / 同步记录

    -
      +
      +
      +

      运行日志 / 同步记录

      +
      + + +
      +
      +
      `); if (sidebar && sidebarFooter) { sidebar.insertBefore(syncSidebar, sidebarFooter); @@ -2574,6 +2605,10 @@ if (event.target.matches("select[data-device-control]")) storeDeviceControlDraft(event.target); }); $("#v2LogFilter")?.addEventListener("change", renderSyncLogs); + $("#v2SyncLogToggle")?.addEventListener("click", () => { + setSyncLogCollapsed($(".v2-sync-log")?.dataset.collapsed !== "true"); + }); + setSyncLogCollapsed(true); const beginPendingControlRequest = (request = {}) => { beginCommandTiming(request); resetValidatedCommandContext(); @@ -2879,18 +2914,38 @@ const connectionText = $("#v2ConnectionText"); if (connectionText) { const status = connectionState?.status || "connecting"; - const labels = { - connecting: "正在连接服务", - online: "服务已连接", - degraded: "连接不稳定", - offline: "服务已断开", - }; - connectionText.textContent = labels[status] || labels.connecting; - connectionText.title = connectionState?.lastError || connectionText.textContent; - $(".v2-sidebar-footer")?.classList.toggle( - "offline", - status === "offline" || status === "degraded", - ); + const selectedTransport = $('.vehicle-transport-options [data-control-transport][aria-pressed="true"]') + || $('.vehicle-transport-options [data-control-transport].active'); + const transport = selectedTransport?.dataset.controlTransport || ""; + const transportOnline = selectedTransport?.dataset.connectionState === "online"; + const transportLabel = transport === "wireless_mqtt" ? "无线 CAN" : "有线 CAN"; + const footer = connectionText.closest(".v2-control-link-card"); + let presentationState = status; + let label = "正在连接服务"; + + if (status === "online" && transport) { + presentationState = transportOnline ? "online" : "offline"; + label = transport === "wireless_mqtt" + ? `${transportLabel} · MQTT ${transportOnline ? "已连接" : "未连接"}` + : `${transportLabel} · 信号链路${transportOnline ? "正常" : "断开"}`; + } else if (status === "online") { + presentationState = "online"; + label = "服务已连接"; + } else if (status === "degraded") { + label = transport ? `${transportLabel} · 主服务连接不稳定` : "主服务连接不稳定"; + } else if (status === "offline") { + label = transport ? `${transportLabel} · 主服务已断开` : "主服务已断开"; + } + + connectionText.textContent = label; + connectionText.title = connectionState?.lastError + || (!transportOnline ? selectedTransport?.title : "") + || label; + if (footer) { + footer.dataset.transport = transport || "service"; + footer.dataset.state = presentationState; + footer.classList.toggle("offline", presentationState !== "online"); + } } keepRunButtonAvailable(); } @@ -2927,6 +2982,7 @@ syncRuntimePresentation(event.detail); }); window.addEventListener("cognitive-agent:vehicle-feedback-state", () => { + syncRuntimePresentation(); renderSyncedDevices(); if (displaySync.activeDevice) { presentTwinDevice(displaySync.activeDevice, { @@ -2935,6 +2991,14 @@ }); } }); + const transportOptions = $(".vehicle-transport-options"); + if (transportOptions) { + new MutationObserver(() => syncRuntimePresentation()).observe(transportOptions, { + attributes: true, + attributeFilter: ["aria-pressed", "class", "data-connection-state", "title"], + subtree: true, + }); + } const observer = new MutationObserver(() => keepRunButtonAvailable()); [$("#hardwareMode"), $("#simulationMode")] .filter(Boolean) diff --git a/apps/web-console/self-extension.js b/apps/web-console/self-extension.js index 62bccb2..18c28d1 100644 --- a/apps/web-console/self-extension.js +++ b/apps/web-console/self-extension.js @@ -1224,7 +1224,9 @@ } showGscuResult(result); showToast( - action === "learn" ? `IR ${slot} 学习完成,槽位回读已确认。` : `${action} 指令处理完成。`, + action === "learn" + ? `IR ${slot} 学习完成并已加入自然语言解析;实际下发仍需 NFC 授权。` + : `${action} 指令处理完成。`, "success", ); } catch (error) { @@ -1275,7 +1277,10 @@ $("[data-gscu-action-names]", card).value, "tcp", ).then((value) => { - showToast(`IR ${value.slot} 的控制对象与执行动作已保存。`, "success"); + showToast( + `IR ${value.slot} 已保存并立即加入自然语言解析;实际下发仍需 NFC 授权。`, + "success", + ); }).catch((error) => { showToast(error.message, "error"); }).finally(() => { save.disabled = false; }); diff --git a/apps/web-console/service-worker.js b/apps/web-console/service-worker.js index b2cf0e2..ceb00e9 100644 --- a/apps/web-console/service-worker.js +++ b/apps/web-console/service-worker.js @@ -1,4 +1,4 @@ -const CACHE_VERSION = "cognitive-agent-pad-v315-vue-vite"; +const CACHE_VERSION = "cognitive-agent-pad-v317-nfc-auth-routing"; const SHELL_CACHE = `${CACHE_VERSION}-shell`; const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`; const APP_SHELL = [ diff --git a/apps/web-console/src/styles/design-system.css b/apps/web-console/src/styles/design-system.css index 5327730..1d2a840 100644 --- a/apps/web-console/src/styles/design-system.css +++ b/apps/web-console/src/styles/design-system.css @@ -44,6 +44,7 @@ body.console-v2 :is( .v2-sync-transport-field, .v2-sync-execution-field, .v2-can-command-panel, + .v2-control-link-card, .v2-nfc-inline, .v2-extension-gateway, .v2-extension-synced-device, @@ -132,6 +133,119 @@ body.console-v2 .v2-sync-log h3 { white-space: nowrap; } +/* Sidebar disclosure, connection status and control-command readability use + * the same semantic tokens in both day and night themes. */ +body.console-v2 .v2-sync-log[data-collapsed="true"] { + min-height: 0; + align-self: start; + grid-template-rows: auto; + flex: 0 0 auto; +} + +body.console-v2 .v2-sync-log[data-collapsed="true"] > header { + border-bottom-color: transparent; +} + +body.console-v2 .v2-sync-log[data-collapsed="true"] #v2LogFilter { + display: none; +} + +body.console-v2 .v2-sync-log #v2SyncLogList[hidden] { + display: none !important; +} + +body.console-v2 .v2-sync-log > header > div:first-child { + min-width: 0; +} + +body.console-v2 .v2-sync-log .v2-sync-log-actions { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: 6px; +} + +body.console-v2 .v2-sync-log .v2-sync-log-toggle { + min-width: 46px; + min-height: var(--n7-control-height-sm); + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + padding: 0 7px; + color: var(--muted-foreground); + background: var(--secondary); + border: 1px solid var(--input); + border-radius: var(--n7-radius-control); + cursor: pointer; +} + +body.console-v2 .v2-sync-log .v2-sync-log-toggle:is(:hover, :focus-visible) { + color: var(--accent-foreground); + background: var(--accent); + border-color: var(--ring); + outline: none; +} + +body.console-v2 .v2-sync-log .v2-sync-log-toggle svg { + width: 15px; + height: 15px; + transition: transform 160ms ease; +} + +body.console-v2 .v2-sync-log .v2-sync-log-toggle[aria-expanded="true"] svg { + transform: rotate(180deg); +} + +body.console-v2.v2-passive-display.v2-interactive-sync .v2-control-link-card { + min-height: 56px; + grid-template-columns: 10px minmax(0, 1fr); + gap: 10px; + margin: 0 !important; + padding: 10px 11px !important; + color: var(--card-foreground); + background: var(--glass-card) !important; + border: 1px solid var(--glass-line) !important; + border-radius: var(--n7-radius-card) !important; + box-shadow: var(--n7-shadow-card) !important; + -webkit-backdrop-filter: blur(16px) saturate(1.2); + backdrop-filter: blur(16px) saturate(1.2); +} + +body.console-v2 .v2-control-link-card strong { + color: var(--card-foreground) !important; +} + +body.console-v2 .v2-control-link-card small { + color: var(--muted-foreground) !important; +} + +body.console-v2 .v2-control-link-card[data-state="online"] .v2-live-dot { + background: var(--status-online); + box-shadow: 0 0 0 4px color-mix(in oklch, var(--status-online) 18%, transparent); +} + +body.console-v2 .v2-control-link-card[data-state="connecting"] .v2-live-dot { + background: var(--status-info); + box-shadow: 0 0 0 4px color-mix(in oklch, var(--status-info) 18%, transparent); +} + +body.console-v2 .v2-control-link-card[data-state="degraded"] .v2-live-dot { + background: var(--status-warning); + box-shadow: 0 0 0 4px color-mix(in oklch, var(--status-warning) 18%, transparent); +} + +body.console-v2 .v2-control-link-card[data-state="offline"] .v2-live-dot { + background: var(--status-offline); + box-shadow: 0 0 0 4px color-mix(in oklch, var(--status-offline) 18%, transparent); +} + +body.console-v2.v2-passive-display.v2-interactive-sync +.v2-can-command-list li[data-can-channel] +.v2-can-command-actual code { + color: var(--card-foreground) !important; +} + body.console-v2 :is( .mode-gallery-header h2, .mode-editor-header h2, diff --git a/apps/web-console/vue-ui.js b/apps/web-console/vue-ui.js index 03ba581..d087c76 100644 --- a/apps/web-console/vue-ui.js +++ b/apps/web-console/vue-ui.js @@ -120,6 +120,11 @@ import { Fragment, createApp, h, nextTick, reactive } from "vue"; open: state.open, onToggle(event) { state.open = event.currentTarget.open; + if (!state.open) return; + const transcript = document.querySelector("#assistantMessages"); + transcript?.querySelectorAll(".assistant-dispatched-controls[open]").forEach((card) => { + if (card !== event.currentTarget) card.open = false; + }); }, }, [ h("summary", null, [ diff --git a/src/cognitive_agent/application/self_extension.py b/src/cognitive_agent/application/self_extension.py index c30f925..994434e 100644 --- a/src/cognitive_agent/application/self_extension.py +++ b/src/cognitive_agent/application/self_extension.py @@ -380,6 +380,86 @@ class InfraredSlotCatalog: capabilities.append(sanitized) return capabilities + def list_routing_capabilities(self) -> list[dict[str, Any]]: + """Return every named IR slot, annotated with execution authorization. + + The slot catalog is the source of truth for natural-language discovery, + while ``infrared_capabilities`` remains the source of truth for physical + execution authorization. Keeping those concerns separate lets a newly + learned device become parseable immediately without allowing an + unbound NFC device to emit infrared commands. + """ + + slots = self.list_slots() + authorized = self.list_capabilities() + authorized_by_identity: dict[tuple[str, int], dict[str, Any]] = {} + for item in authorized: + try: + identity = ( + str(item.get("device_id") or "").strip(), + validate_slot(item.get("slot")), + ) + except GatewayError: + continue + if identity[0]: + authorized_by_identity.setdefault(identity, item) + + routing: list[dict[str, Any]] = [] + for slot_record in slots: + device_id = str(slot_record.get("device_id") or "").strip() + object_names = _safe_peripheral_identity_names( + slot_record.get("object_names") + ) + action_names = [ + str(value or "").strip() + for value in slot_record.get("action_names", []) + if str(value or "").strip() + ][:24] + if ( + not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,63}", device_id) + or not object_names + or not action_names + ): + continue + + slot = validate_slot(slot_record.get("slot")) + persisted = authorized_by_identity.get((device_id, slot)) + item = dict(persisted or {}) + card_uid = _normalized_card_uid(item.get("card_uid")) + enabled = item.get("enabled", True) is not False + execution_authorized = bool(card_uid and enabled) + item.update( + { + "id": str(item.get("id") or f"ir-slot:{device_id}:{slot}"), + "status": "learned", + "enabled": enabled if card_uid else False, + "parseable": True, + "execution_authorized": execution_authorized, + "authorization_state": ( + "authorized" + if execution_authorized + else "disabled" + if card_uid + else "nfc_binding_required" + ), + "device_id": device_id, + "device_name": object_names[0], + "device_aliases": object_names, + "object_names": object_names, + "device_type": str(item.get("device_type") or "other")[:48], + "card_uid": card_uid or None, + "action": action_names[0], + "aliases": action_names, + "action_names": action_names, + "risk": str(item.get("risk") or "low"), + "slot": slot, + "transport": str(item.get("transport") or "gscu_tcp")[:32], + "routing_source": "infrared_slot_catalog", + } + ) + routing.append(item) + return routing + def sync_capabilities( self, catalog: object, @@ -1244,6 +1324,28 @@ def resolve_active_peripheral_command( catalog, nfc_snapshot, require_enabled=True, + require_binding=True, + ) + + +def resolve_parseable_peripheral_command( + utterance: str, + catalog: list[dict[str, Any]], + nfc_snapshot: dict[str, Any], +) -> dict[str, Any] | None: + """Resolve a named learned IR action without granting execution rights. + + This is intentionally a parsing-only operation. Callers must inspect the + returned capability's persisted ``card_uid`` and ``enabled`` state before + allowing any physical infrared emit. + """ + + return _resolve_bound_peripheral_command( + utterance, + catalog, + nfc_snapshot, + require_enabled=False, + require_binding=False, ) @@ -1253,6 +1355,7 @@ def _resolve_bound_peripheral_command( nfc_snapshot: dict[str, Any], *, require_enabled: bool, + require_binding: bool = True, ) -> dict[str, Any] | None: """Resolve a persistently card-bound command, including disabled entries. @@ -1279,7 +1382,7 @@ def _resolve_bound_peripheral_command( ): continue bound_uid = _normalized_card_uid(raw_item.get("card_uid")) - if not bound_uid: + if require_binding and not bound_uid: continue device_score = _device_match_score(compact_utterance, raw_item) if not device_score: @@ -1310,6 +1413,17 @@ def _resolve_bound_peripheral_command( resolved = dict(matches[0][2]) resolved["slot"] = validate_slot(resolved.get("slot")) resolved["card_uid"] = _normalized_card_uid(resolved.get("card_uid")) + resolved["parseable"] = True + resolved["execution_authorized"] = bool( + resolved["card_uid"] and resolved.get("enabled", True) is not False + ) + resolved["authorization_state"] = ( + "authorized" + if resolved["execution_authorized"] + else "disabled" + if resolved["card_uid"] + else "nfc_binding_required" + ) return resolved @@ -1326,10 +1440,12 @@ def partition_active_peripheral_intent( ) -> dict[str, Any]: """Claim only explicit learned-peripheral clauses and preserve CAN text. - The function is deliberately conservative: a clause must contain both a - persistently NFC-bound device identity and one learned action. When no peripheral - clause is claimed, ``residual_utterance`` is byte-for-byte the original - trimmed input so the existing CAN parser observes exactly its old input. + The function is deliberately conservative: a clause must contain both an + explicit learned device identity and one learned action. A matching but + unbound device is returned as an authorization denial, never as residual + vehicle text. When no peripheral clause is claimed, ``residual_utterance`` + is byte-for-byte the original trimmed input so the existing CAN parser + observes exactly its old input. """ original = str(utterance or "").strip() @@ -1342,12 +1458,16 @@ def partition_active_peripheral_intent( "denied_peripheral_actions": [], } - bound_catalog = [ + routing_catalog = [ item for item in catalog[:MAX_CATALOG_ITEMS] if isinstance(item, dict) and item.get("status") == "learned" - and bool(_normalized_card_uid(item.get("card_uid"))) + ] + bound_catalog = [ + item + for item in routing_catalog + if bool(_normalized_card_uid(item.get("card_uid"))) ] active_catalog = [ item @@ -1355,8 +1475,8 @@ def partition_active_peripheral_intent( if item.get("enabled", True) is not False ] compact_original = _compact_match_text(original) - if not bound_catalog or not any( - _device_match_score(compact_original, item) for item in bound_catalog + if not routing_catalog or not any( + _device_match_score(compact_original, item) for item in routing_catalog ): return { "peripheral_actions": [], @@ -1380,36 +1500,30 @@ def partition_active_peripheral_intent( contextual_device_name = "" for index, clause in enumerate(clauses): compact_clause = _compact_match_text(clause) - explicit_bound_candidates = [ + explicit_routing_candidates = [ item - for item in bound_catalog + for item in routing_catalog if _device_match_score(compact_clause, item) ] - explicit_candidates = [ - item - for item in explicit_bound_candidates - if item.get("enabled", True) is not False - ] - if explicit_bound_candidates and _has_unshadowed_vehicle_device_phrase( + if explicit_routing_candidates and _has_unshadowed_vehicle_device_phrase( compact_clause, - explicit_bound_candidates, + explicit_routing_candidates, ): residual.append(clause) contextual_device_id = None contextual_device_name = "" continue - clause_candidates = explicit_candidates - bound_clause_candidates = explicit_bound_candidates + clause_candidates = explicit_routing_candidates resolution_text = clause - if explicit_bound_candidates: + if explicit_routing_candidates: candidate_device_ids = { str(item.get("device_id") or "") - for item in explicit_bound_candidates + for item in explicit_routing_candidates } if len(candidate_device_ids) == 1: contextual_device_id = next(iter(candidate_device_ids)) identity_names: list[str] = [] - for item in explicit_bound_candidates: + for item in explicit_routing_candidates: for field in ("object_names", "device_aliases"): values = item.get(field, []) if isinstance(values, str): @@ -1454,56 +1568,51 @@ def partition_active_peripheral_intent( ): clause_candidates = [ item - for item in active_catalog - if str(item.get("device_id") or "") == contextual_device_id - ] - bound_clause_candidates = [ - item - for item in bound_catalog + for item in routing_catalog if str(item.get("device_id") or "") == contextual_device_id ] resolution_text = f"{contextual_device_name},{clause}" - if not clause_candidates and not bound_clause_candidates: + if not clause_candidates: residual.append(clause) continue - active_resolution_catalog = ( - clause_candidates if not explicit_candidates else active_catalog + routing_resolution_catalog = ( + clause_candidates if not explicit_routing_candidates else routing_catalog ) - resolved = resolve_active_peripheral_command( - resolution_text, active_resolution_catalog, nfc_snapshot + resolved = resolve_parseable_peripheral_command( + resolution_text, routing_resolution_catalog, nfc_snapshot ) if resolved is None: - bound_resolution_catalog = ( - bound_clause_candidates - if not explicit_bound_candidates - else bound_catalog - ) - bound_match = _resolve_bound_peripheral_command( - resolution_text, - bound_resolution_catalog, - nfc_snapshot, - require_enabled=False, - ) - if ( - bound_match is not None - and bound_match.get("enabled", True) is False - ): - denied.append( - { - "index": index, - "clause": clause, - "capability": bound_match, - "reason": "nfc_command_disabled", - } - ) - continue # Explicit third-party device text must never fall through into # the vehicle CAN model as if it were a cabin actuator request. unresolved.append(clause) + authorized_device_ids = { + str(item.get("device_id") or "") for item in clause_candidates + } unresolved_candidates.update( (str(item.get("id")), item) - for item in clause_candidates + for item in active_catalog if item.get("id") + and str(item.get("device_id") or "") in authorized_device_ids + ) + continue + if not _normalized_card_uid(resolved.get("card_uid")): + denied.append( + { + "index": index, + "clause": clause, + "capability": resolved, + "reason": "nfc_binding_required", + } + ) + continue + if resolved.get("enabled", True) is False: + denied.append( + { + "index": index, + "clause": clause, + "capability": resolved, + "reason": "nfc_command_disabled", + } ) continue actions.append( @@ -2275,6 +2384,11 @@ class SelfExtensionService: return self.infrared_catalog.list_capabilities() + def list_peripheral_routing_capabilities(self) -> list[dict[str, Any]]: + """Return parseable IR actions with explicit authorization metadata.""" + + return self.infrared_catalog.list_routing_capabilities() + def sync_peripheral_catalog(self, catalog: object) -> dict[str, Any]: """Persist the complete learned capability catalog from the NFC UI.""" @@ -2441,6 +2555,13 @@ class SelfExtensionService: ) -> dict[str, Any]: """Execute a persistently NFC-bound match through the main request API.""" + if ( + capability.get("enabled", True) is False + or not _normalized_card_uid(capability.get("card_uid")) + ): + raise ExtensionIntegrationError( + "红外能力尚未完成持久 NFC 授权,已拒绝物理下发。" + ) if not _peripheral_execution_identity_authorized(utterance, capability): raise ExtensionIntegrationError( "外设名称未在原始指令中被明确提及,已拒绝红外执行。" diff --git a/src/cognitive_agent/server.py b/src/cognitive_agent/server.py index d07b793..e5418f9 100755 --- a/src/cognitive_agent/server.py +++ b/src/cognitive_agent/server.py @@ -733,7 +733,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler): ).strip() peripheral_routing = self._speech_peripheral_routing(raw_text) peripheral_transcript = bool( - peripheral_routing.get("execution_eligible") and raw_text + peripheral_routing.get("routing_eligible") and raw_text ) expected_text = raw_text if peripheral_transcript else clean_text if utterance != expected_text: @@ -933,6 +933,18 @@ class SeatDemoHandler(BaseHTTPRequestHandler): HTTPStatus.INTERNAL_SERVER_ERROR, ) + def _peripheral_routing_catalog(self) -> list[dict[str, object]]: + """Read the parse catalog while remaining compatible with test doubles.""" + + provider = getattr( + self.extension, + "list_peripheral_routing_capabilities", + None, + ) + if callable(provider): + return list(provider()) + return list(self.extension.list_peripheral_capabilities()) + def _execute_active_peripheral_request( self, utterance: str, @@ -1044,14 +1056,16 @@ class SeatDemoHandler(BaseHTTPRequestHandler): Vehicle speech keeps using its cached structured semantic payload. A learned peripheral cannot be represented by the static vehicle capability catalog, so it may instead reuse the exact server-owned ASR - transcript only after the durable NFC binding and learned action resolve. + transcript once the learned device and action resolve. Execution + authorization is checked separately so an unbound device can be shown + as parsed-and-blocked without ever reaching the infrared transport. """ text = str(utterance or "").strip() if not text: return {"execution_eligible": False, "action_count": 0} try: - catalog = self.extension.list_peripheral_capabilities() + catalog = self._peripheral_routing_catalog() nfc_snapshot = self.nfc.snapshot() partition = partition_active_peripheral_intent( text, @@ -1080,9 +1094,12 @@ class SeatDemoHandler(BaseHTTPRequestHandler): action_count = len(entries) or (1 if direct is not None else 0) if plan_catalog: action_count = max(action_count, 1) + routing_eligible = bool(action_count or unresolved or denied) return { "execution_eligible": bool(action_count and not unresolved and not denied), + "routing_eligible": routing_eligible, "action_count": action_count, + "denied_action_count": len(denied), "has_ordered_plan": bool(plan_catalog), } @@ -1386,7 +1403,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler): result: dict[str, object], entries: list[dict[str, object]], ) -> dict[str, object]: - """Expose disabled bound commands without discarding allowed work.""" + """Expose parsed-but-unauthorized IR commands without dispatching them.""" if not entries: return result @@ -1410,25 +1427,169 @@ class SeatDemoHandler(BaseHTTPRequestHandler): result["denied_peripheral_actions"] = denied_actions result["authorization_filtered"] = True + blocked_operations: list[dict[str, object]] = [] + blocked_commands: list[dict[str, object]] = [] + blocked_results: list[dict[str, object]] = [] + for position, item in enumerate(denied_actions, start=1): + reason = str(item.get("reason") or "nfc_command_disabled") + device_name = str( + item.get("device_name") or item.get("device_id") or "未命名外设" + ) + action_name = str(item.get("action") or "未命名动作") + operation_ref = ( + f"{item.get('capability_id') or 'infrared'}-authorization-{position}" + ) + message = ( + f"已识别“{device_name} · {action_name}”,但设备尚未完成 NFC " + "绑定授权,未下发。" + if reason == "nfc_binding_required" + else f"已识别“{device_name} · {action_name}”,但该 NFC 能力已被禁用,未下发。" + ) + blocked_operations.append( + { + "id": operation_ref, + "capability": "infrared.emit", + "target": { + "device": item.get("device_id"), + "name": item.get("device_name"), + }, + "parameters": { + "action": item.get("action"), + "slot": item.get("slot"), + }, + "parse_source": "local_rule", + "authorization": { + "authorized": False, + "reason": reason, + }, + } + ) + blocked_commands.append( + { + "operation_ref": operation_ref, + "bus": "GSCU_TCP", + "message": "infrared.emit", + "signals": { + "slot": item.get("slot"), + "authorization_status": "blocked", + "authorization_reason": reason, + }, + } + ) + blocked_results.append( + { + "ref": operation_ref, + "domain": "self_extension", + "status": "blocked", + "message": message, + "telemetry": { + "transport": "gscu_tcp", + "slot": item.get("slot"), + "delivery_status": "not_submitted", + "transport_submitted": False, + "gateway_acknowledged": False, + "physical_effect_confirmed": False, + "verification_status": "failed_or_blocked", + "authorization_reason": reason, + }, + } + ) + + function_configs = deepcopy(result.get("function_configs") or {}) + command_plans = deepcopy(result.get("command_plans") or {}) + config_key = "self_extension_blocked" + suffix = 2 + while config_key in function_configs or config_key in command_plans: + config_key = f"self_extension_blocked_{suffix}" + suffix += 1 + blocked_config = { + "apiVersion": "cognitive-agent.io/v1alpha1", + "kind": "FunctionConfig", + "metadata": { + "name": config_key, + "labels": { + "execution_scope": "nfc_authorization_required", + }, + }, + "spec": { + "domain": "self_extension", + "operations": blocked_operations, + }, + } + blocked_plan = { + "apiVersion": "cognitive-agent.io/v1alpha1", + "kind": "CommandPlan", + "metadata": { + "name": f"{config_key}-gscu", + "labels": {"authorization_status": "blocked"}, + }, + "spec": { + "domain": "self_extension", + "platform": "gscu1b", + "commands": blocked_commands, + }, + } + function_configs[config_key] = blocked_config + command_plans[config_key] = blocked_plan + result["function_configs"] = function_configs + result["command_plans"] = command_plans + if not isinstance(result.get("function_config"), dict): + result["function_config"] = blocked_config + if not isinstance(result.get("command_plan"), dict): + result["command_plan"] = blocked_plan + result["operation_results"] = [ + *deepcopy(list(result.get("operation_results") or [])), + *blocked_results, + ] + result["trace_events"] = [ + *deepcopy(list(result.get("trace_events") or [])), + { + "stage": "nfc_peripheral_authorization", + "status": "blocked", + "summary": "红外指令已由本地规则解析,但未通过 NFC 执行授权。", + "duration_ms": 0, + }, + ] summaries = list(result.get("decision_summary") or []) - labels = "、".join( - str(item.get("clause") or item.get("action") or "未命名动作") + binding_required = [ + item for item in denied_actions - ) - summaries.append( - f"持久绑定中有 {len(denied_actions)} 条红外能力未启用,已过滤:{labels}。" - ) + if item.get("reason") == "nfc_binding_required" + ] + disabled = [ + item + for item in denied_actions + if item.get("reason") != "nfc_binding_required" + ] + if binding_required: + labels = "、".join( + str(item.get("clause") or item.get("action") or "未命名动作") + for item in binding_required + ) + summaries.append( + f"已解析 {len(binding_required)} 条红外控制指令,但设备尚未完成 NFC " + f"绑定授权,未下发:{labels}。" + ) + if disabled: + labels = "、".join( + str(item.get("clause") or item.get("action") or "未命名动作") + for item in disabled + ) + summaries.append( + f"持久绑定中有 {len(disabled)} 条红外能力未启用,已过滤:{labels}。" + ) result["decision_summary"] = summaries prior_status = str(result.get("status") or "failed") if prior_status in {"succeeded", "partially_succeeded", "no_action"}: result["status"] = "partially_succeeded" result["success"] = False + result["outcome"] = "failed_or_blocked" execution_report = result.get("execution_report") if isinstance(execution_report, dict): spec = execution_report.get("spec") if isinstance(spec, dict): - spec["status"] = "partially_succeeded" + spec["status"] = str(result.get("status") or "blocked") return result def _execute_request_with_peripherals( @@ -1465,7 +1626,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler): **mode_arguments, _on_commands_validated=callback, ) - catalog = self.extension.list_peripheral_capabilities() + catalog = self._peripheral_routing_catalog() nfc_snapshot = self.nfc.snapshot() partition = partition_active_peripheral_intent( utterance, @@ -1479,7 +1640,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler): parsed_peripheral_clauses = list( dict.fromkeys( str(entry.get("clause") or "").strip() - for entry in entries + for entry in [*entries, *denied] if isinstance(entry, dict) and str(entry.get("clause") or "").strip() ) ) @@ -1829,14 +1990,43 @@ class SeatDemoHandler(BaseHTTPRequestHandler): "supplement_required": True, } else: - labels = "、".join( - str(item.get("clause") or "未命名动作") - for item in denied - if isinstance(item, dict) - ) - raise ExtensionIntegrationError( - f"以下持久绑定红外能力未启用,无法执行:{labels}。" + local_elapsed_ms = round( + (time.perf_counter() - local_stage_started) * 1000, + 3, ) + request_id = f"req-local-blocked-{os.urandom(8).hex()}" + result = { + "request_id": request_id, + "preview_request_id": request_id, + "utterance": utterance, + "conversation_id": conversation_id, + "status": "blocked", + "success": False, + "outcome": "failed_or_blocked", + "phase": "authorization_blocked", + "orchestration_route": "local_peripheral_authorization", + "provider_requested": provider, + "provider_used": "infrared_local_rules", + "execution_mode": execution_mode, + "control_transport": "gscu_tcp", + "decision_summary": [], + "function_config": None, + "function_configs": {}, + "command_plan": None, + "command_plans": {}, + "trace_events": [], + "validation": [], + "execution_report": None, + "operation_results": [], + "parsed_control_signatures": [], + "preview_elapsed_ms": local_elapsed_ms, + "local_control_budget_ms": LOCAL_CONTROL_BUDGET_MS, + "local_control_elapsed_ms": local_elapsed_ms, + "local_control_within_budget": ( + local_elapsed_ms < LOCAL_CONTROL_BUDGET_MS + ), + "supplement_required": False, + } result["natural_language_routing"] = deepcopy(natural_language_routing) for key in ("parsed_clauses", "resolved_clauses", "unresolved_clauses"): @@ -3528,7 +3718,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler): result = {**result, "input_mode": input_mode} raw_text = str(result.get("raw_text") or "").strip() peripheral_routing = self._speech_peripheral_routing(raw_text) - if peripheral_routing.get("execution_eligible"): + if peripheral_routing.get("routing_eligible"): result = { **result, "routing_text": raw_text, diff --git a/tests/e2e/test_seat_web_api.py b/tests/e2e/test_seat_web_api.py index 7dfc749..fe80c8e 100755 --- a/tests/e2e/test_seat_web_api.py +++ b/tests/e2e/test_seat_web_api.py @@ -1331,7 +1331,7 @@ operations: self.assertIn('canvas.addEventListener("webglcontextlost"', app_script) self.assertIn('"cognitive-agent:module-change"', app_script) self.assertIn( - 'const CACHE_VERSION = "cognitive-agent-pad-v315-vue-vite"', + 'const CACHE_VERSION = "cognitive-agent-pad-v317-nfc-auth-routing"', service_worker, ) self.assertIn('/console-v2.css?v=delivery-state-69', service_worker) @@ -2084,6 +2084,96 @@ operations: finally: SeatDemoHandler.extension = original_extension + def test_new_tcp_slot_is_immediately_parsed_but_blocked_until_nfc_binding( + self, + ) -> None: + original_extension = SeatDemoHandler.extension + original_nfc = SeatDemoHandler.nfc + with tempfile.TemporaryDirectory() as directory: + extension = SelfExtensionService( + SimulationGateway(), + catalog_db_path=Path(directory, "unbound-cactus.sqlite3"), + ) + SeatDemoHandler.extension = extension + SeatDemoHandler.nfc = SimpleNamespace( + snapshot=lambda: { + "revision": 1, + "reader": {"online": True}, + "card": None, + } + ) + save_request = urllib.request.Request( + f"{self.base_url}/api/extension/gscu/slots/11", + method="PUT", + headers={"Content-Type": "application/json"}, + data=json.dumps( + { + "device_id": "toy-cacti-002", + "object_names": ["仙人掌"], + "action_names": ["打开", "开启"], + "source": "tcp", + }, + ensure_ascii=False, + ).encode("utf-8"), + ) + control_request = urllib.request.Request( + f"{self.base_url}/api/requests", + method="POST", + headers={ + "Content-Type": "application/json", + "Accept": "application/x-ndjson", + }, + data=json.dumps( + { + "utterance": "打开仙人掌", + "provider": "local", + "conversation_id": "conv-unbound-cactus-e2e", + "execution_mode": "hardware", + "client_type": "web_console", + }, + ensure_ascii=False, + ).encode("utf-8"), + ) + try: + with self.opener.open(save_request, timeout=3) as response: + self.assertEqual(response.status, 200) + with patch.object( + extension, + "execute_gateway", + side_effect=AssertionError("unbound IR command must not be emitted"), + ): + with self.opener.open(control_request, timeout=3) as response: + events = [ + json.loads(line) + for line in response.read().decode("utf-8").splitlines() + if line.strip() + ] + finally: + SeatDemoHandler.extension = original_extension + SeatDemoHandler.nfc = original_nfc + + self.assertEqual(events[-1]["type"], "result") + result = events[-1]["payload"] + self.assertEqual(result["status"], "blocked") + self.assertLess(result["local_control_elapsed_ms"], 1000) + self.assertEqual(result["parsed_clauses"], ["打开仙人掌"]) + self.assertEqual(result["unresolved_clauses"], []) + self.assertEqual( + result["denied_peripheral_actions"][0]["reason"], + "nfc_binding_required", + ) + operation = next( + operation + for config in result["function_configs"].values() + for operation in config["spec"]["operations"] + ) + self.assertEqual(operation["target"]["device"], "toy-cacti-002") + self.assertEqual(operation["parameters"], {"action": "打开", "slot": 11}) + self.assertEqual(result["operation_results"][0]["status"], "blocked") + self.assertFalse( + result["operation_results"][0]["telemetry"]["transport_submitted"] + ) + def test_main_request_routes_durable_nfc_peripheral_after_card_removal(self) -> None: original_extension = SeatDemoHandler.extension original_nfc = SeatDemoHandler.nfc diff --git a/tests/unit/test_assistant_frontend_contract.py b/tests/unit/test_assistant_frontend_contract.py index 75041c9..a60303b 100644 --- a/tests/unit/test_assistant_frontend_contract.py +++ b/tests/unit/test_assistant_frontend_contract.py @@ -27,7 +27,7 @@ def test_console_contains_ai_assistant_surface(): def test_codex_assistant_assets_are_versioned_together_in_the_service_worker(): service_worker = (ROOT / "apps/web-console/service-worker.js").read_text(encoding="utf-8") assert ( - 'const CACHE_VERSION = "cognitive-agent-pad-v315-vue-vite"' + 'const CACHE_VERSION = "cognitive-agent-pad-v317-nfc-auth-routing"' in service_worker ) assert '"/assistant-chat.css?v=codex-assistant-29"' in service_worker @@ -120,6 +120,32 @@ def test_validated_controls_are_mirrored_into_a_compact_assistant_card(): assert ".assistant-dispatched-controls[open]" in css +def test_explicit_nfc_authorization_block_is_rendered_as_safety_blocked(): + console = (ROOT / "apps/web-console/console-v2.js").read_text(encoding="utf-8") + + assert 'executionStatus === "blocked"' in console + assert "Boolean(telemetry.authorization_reason)" in console + assert 'deliveryState = aborted' in console + assert '? "blocked"' in console + assert '? "安全阻止"' in console + + +def test_latest_dispatched_control_group_is_the_only_group_expanded(): + assistant = (ROOT / "apps/web-console/assistant-chat.js").read_text( + encoding="utf-8" + ) + vue_ui = (ROOT / "apps/web-console/vue-ui.js").read_text(encoding="utf-8") + + assert "function focusLatestDispatchedControls(container)" in assistant + assert 'transcript.querySelectorAll(".assistant-dispatched-controls")' in assistant + assert "card.open = card === activeCard" in assistant + assert "focusLatestDispatchedControls(host)" in assistant + assert "focusLatestDispatchedControls(card)" in assistant + assert 'querySelectorAll(".assistant-dispatched-controls[open]")' in assistant + assert 'querySelectorAll(".assistant-dispatched-controls[open]")' in vue_ui + assert "if (card !== event.currentTarget) card.open = false" in vue_ui + + def test_saved_mode_execution_opens_with_pending_flow_before_http_completion(): app = (ROOT / "apps/web-console/app.js").read_text(encoding="utf-8") flow = app.split( diff --git a/tests/unit/test_energy_monitor_frontend_contract.py b/tests/unit/test_energy_monitor_frontend_contract.py index 077fee9..e3673f2 100644 --- a/tests/unit/test_energy_monitor_frontend_contract.py +++ b/tests/unit/test_energy_monitor_frontend_contract.py @@ -41,7 +41,7 @@ def test_energy_monitor_assets_are_versioned_and_cached() -> None: "/energy-monitor.js?v=energy-monitor-4" ) assert ( - 'const CACHE_VERSION = "cognitive-agent-pad-v315-vue-vite"' + 'const CACHE_VERSION = "cognitive-agent-pad-v316-sidebar-accordion"' in service_worker ) diff --git a/tests/unit/test_frontend_theme_contract.py b/tests/unit/test_frontend_theme_contract.py index 41cb2a8..6f6b2b6 100644 --- a/tests/unit/test_frontend_theme_contract.py +++ b/tests/unit/test_frontend_theme_contract.py @@ -52,7 +52,7 @@ def test_theme_asset_versions_are_consistent_with_the_pwa_shell() -> None: assert source_asset in main assert f'"{cached_asset}"' in service_worker assert 'import "./styles/design-system.css"' in main - assert 'const CACHE_VERSION = "cognitive-agent-pad-v315-vue-vite"' in service_worker + assert 'const CACHE_VERSION = "cognitive-agent-pad-v316-sidebar-accordion"' in service_worker def test_reference_glass_theme_covers_the_complete_workspace() -> None: @@ -247,6 +247,22 @@ def test_command_fields_use_distinct_semantic_colors() -> None: assert mapping in console +def test_sidebar_command_and_transport_status_use_theme_semantics() -> None: + console = source("console-v2.js") + design_system = source("src/styles/design-system.css") + + assert '"v2-sidebar-footer v2-control-link-card"' in console + assert 'transport === "wireless_mqtt" ? "无线 CAN" : "有线 CAN"' in console + assert 'MQTT ${transportOnline ? "已连接" : "未连接"}' in console + assert 'footer.dataset.state = presentationState' in console + assert 'attributeFilter: ["aria-pressed", "class", "data-connection-state", "title"]' in console + assert ".v2-control-link-card" in design_system + assert 'data-state="online"' in design_system + assert 'data-state="offline"' in design_system + assert ".v2-can-command-actual code" in design_system + assert "color: var(--card-foreground) !important" in design_system + + def test_function_modes_use_an_inline_accessible_collapsible() -> None: html = source("index.html") app = source("app.js") diff --git a/tests/unit/test_self_extension.py b/tests/unit/test_self_extension.py index 4e3591b..c26bf5a 100644 --- a/tests/unit/test_self_extension.py +++ b/tests/unit/test_self_extension.py @@ -696,6 +696,84 @@ class SelfExtensionTests(unittest.TestCase): self.assertEqual(changed["device_id"], "toy-light-v2") self.assertEqual(service.list_peripheral_capabilities(), []) + def test_new_structured_slot_is_parseable_before_nfc_authorization(self) -> None: + with TemporaryDirectory() as directory: + service = SelfExtensionService( + SimulationGateway(), + catalog_db_path=Path(directory, "infrared-slots.sqlite3"), + ) + service.save_infrared_slot( + 11, + { + "device_id": "toy-cacti-002", + "object_names": ["仙人掌"], + "action_names": ["打开", "开启"], + "source": "tcp", + }, + ) + + self.assertEqual(service.list_peripheral_capabilities(), []) + routing = service.list_peripheral_routing_capabilities() + self.assertEqual(len(routing), 1) + self.assertEqual(routing[0]["id"], "ir-slot:toy-cacti-002:11") + self.assertEqual(routing[0]["authorization_state"], "nfc_binding_required") + self.assertTrue(routing[0]["parseable"]) + self.assertFalse(routing[0]["execution_authorized"]) + + partition = partition_active_peripheral_intent( + "开启仙人掌", + routing, + {"card": None}, + ) + self.assertEqual(partition["peripheral_actions"], []) + self.assertEqual(partition["residual_utterance"], "") + self.assertEqual(partition["unresolved_peripheral_clauses"], []) + self.assertEqual( + [ + ( + item["capability"]["id"], + item["capability"]["slot"], + item["reason"], + ) + for item in partition["denied_peripheral_actions"] + ], + [("ir-slot:toy-cacti-002:11", 11, "nfc_binding_required")], + ) + self.assertIsNone( + resolve_active_peripheral_command( + "开启仙人掌", + routing, + {"card": None}, + ) + ) + + service.sync_peripheral_catalog( + [ + { + **routing[0], + "enabled": True, + "card_uid": "DBA5B203", + } + ] + ) + authorized_routing = service.list_peripheral_routing_capabilities() + self.assertEqual( + authorized_routing[0]["authorization_state"], "authorized" + ) + authorized = partition_active_peripheral_intent( + "开启仙人掌", + authorized_routing, + {"card": None}, + ) + self.assertEqual( + [item["capability"]["slot"] for item in authorized["peripheral_actions"]], + [11], + ) + self.assertEqual(authorized["denied_peripheral_actions"], []) + + service.delete_infrared_slot(11) + self.assertEqual(service.list_peripheral_routing_capabilities(), []) + def test_structured_slot_rejects_invalid_or_missing_device_id(self) -> None: with TemporaryDirectory() as directory: service = SelfExtensionService( diff --git a/tests/unit/test_spatial_ui_consistency_contract.py b/tests/unit/test_spatial_ui_consistency_contract.py index 6b812bc..c572bec 100644 --- a/tests/unit/test_spatial_ui_consistency_contract.py +++ b/tests/unit/test_spatial_ui_consistency_contract.py @@ -133,6 +133,22 @@ def test_compact_sidebar_overrides_cannot_break_shared_geometry() -> None: assert "white-space: nowrap" in design_system +def test_runtime_log_starts_collapsed_with_an_accessible_disclosure() -> None: + console = source("console-v2.js") + design_system = source("src/styles/design-system.css") + + assert 'class="v2-sync-log" aria-labelledby="v2SyncLogTitle" data-collapsed="true"' in console + assert 'id="v2SyncLogList" aria-live="polite" hidden' in console + assert 'id="v2SyncLogToggle"' in console + assert 'aria-controls="v2SyncLogList"' in console + assert 'aria-expanded="false"' in console + assert "function setSyncLogCollapsed(collapsed)" in console + assert "setSyncLogCollapsed(true)" in console + assert '.v2-sync-log[data-collapsed="true"]' in design_system + assert '.v2-sync-log[data-collapsed="true"] #v2LogFilter' in design_system + assert '#v2SyncLogList[hidden]' in design_system + + def test_assistant_composer_hint_stays_inside_narrow_input() -> None: styles = source("ui-consistency.css") diff --git a/tests/unit/test_two_stage_can_parsing.py b/tests/unit/test_two_stage_can_parsing.py index 5b324ae..05f68b5 100644 --- a/tests/unit/test_two_stage_can_parsing.py +++ b/tests/unit/test_two_stage_can_parsing.py @@ -5,6 +5,10 @@ from types import SimpleNamespace from unittest.mock import patch from cognitive_agent.application.seat_service import SeatDemoService +from cognitive_agent.application.self_extension import ( + SelfExtensionService, + SimulationGateway, +) from cognitive_agent.server import SeatDemoHandler @@ -591,6 +595,74 @@ def test_pure_unresolved_peripheral_returns_cloud_ready_local_preview( ).is_file() +def test_new_unbound_infrared_device_is_parsed_but_never_dispatched( + tmp_path: Path, +) -> None: + handler = SeatDemoHandler.__new__(SeatDemoHandler) + handler.service = _service(tmp_path) + handler.extension = SelfExtensionService( + SimulationGateway(), + catalog_db_path=tmp_path / "infrared-slots.sqlite3", + ) + handler.extension.save_infrared_slot( + 11, + { + "device_id": "toy-cacti-002", + "object_names": ["仙人掌"], + "action_names": ["打开", "开启"], + "source": "tcp", + }, + ) + handler.nfc = SimpleNamespace(snapshot=lambda: {"card": None}) + validated_events: list[dict[str, object]] = [] + + with patch.object(handler.extension, "execute_gateway") as execute_gateway: + result = handler._execute_request_with_peripherals( + "/api/requests", + { + "utterance": "打开仙人掌", + "provider": "local", + "conversation_id": "conv-unbound-cactus", + "execution_mode": "hardware", + }, + on_commands_validated=validated_events.append, + ) + + execute_gateway.assert_not_called() + assert result is not None + assert result["status"] == "blocked" + assert result["phase"] == "authorization_blocked" + assert result["success"] is False + assert result["local_control_elapsed_ms"] < 1000 + assert result["natural_language_routing"] == { + "original_utterance": "打开仙人掌", + "parsed_clauses": ["打开仙人掌"], + "resolved_clauses": ["打开仙人掌"], + "unresolved_clauses": [], + } + assert result["denied_peripheral_actions"][0]["reason"] == ( + "nfc_binding_required" + ) + operation = next( + operation + for config in result["function_configs"].values() + for operation in config["spec"]["operations"] + ) + assert operation["capability"] == "infrared.emit" + assert operation["target"]["device"] == "toy-cacti-002" + assert operation["parameters"] == {"action": "打开", "slot": 11} + assert operation["authorization"] == { + "authorized": False, + "reason": "nfc_binding_required", + } + blocked = result["operation_results"][0] + assert blocked["status"] == "blocked" + assert blocked["telemetry"]["delivery_status"] == "not_submitted" + assert blocked["telemetry"]["transport_submitted"] is False + assert "尚未完成 NFC 绑定授权" in blocked["message"] + assert validated_events == [result] + + def test_mixed_request_finishes_all_local_phases_before_cloud_ir() -> None: handler = SeatDemoHandler.__new__(SeatDemoHandler) order: list[str] = [] diff --git a/tests/unit/test_vue_frontend_contract.py b/tests/unit/test_vue_frontend_contract.py index 659aedd..8932297 100644 --- a/tests/unit/test_vue_frontend_contract.py +++ b/tests/unit/test_vue_frontend_contract.py @@ -31,7 +31,7 @@ def test_vue_vite_application_is_the_primary_frontend_entry() -> None: assert package["scripts"]["build"] == "vite build" assert 'import vue from "@vitejs/plugin-vue"' in vite assert 'outDir: outputRoot' in vite - assert 'const CACHE_VERSION = "cognitive-agent-pad-v315-vue-vite"' in service_worker + assert 'const CACHE_VERSION = "cognitive-agent-pad-v317-nfc-auth-routing"' in service_worker assert "unpkg.com" not in html assert "cdn.jsdelivr.net" not in html