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